From cdcdd2221edd2b5e55b18a62070e4f776068d4c8 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Wed, 29 Jul 2026 12:52:59 +0800 Subject: [PATCH 001/113] feat(host): show-hidden toggle in the directory browser footer --- .../src/client/DirectoryBrowser.module.css | 26 +++++++++++++++++++ .../src/client/DirectoryBrowser.tsx | 22 +++++++++++++--- .../src/client/index.ts | 4 +++ .../tests/client-flow.spec.tsx | 2 ++ .../tests/directory-browser.spec.tsx | 17 ++++++++++++ 5 files changed, 67 insertions(+), 4 deletions(-) diff --git a/packages/host/directory-picker-browse/src/client/DirectoryBrowser.module.css b/packages/host/directory-picker-browse/src/client/DirectoryBrowser.module.css index 800af854a1..bdb57ca848 100644 --- a/packages/host/directory-picker-browse/src/client/DirectoryBrowser.module.css +++ b/packages/host/directory-picker-browse/src/client/DirectoryBrowser.module.css @@ -242,6 +242,32 @@ border-top: 1px solid var(--dsw-alias-border-l3); } +/* Show-hidden toggle: a subtle text button in the footer, left of the gap. */ +.showHiddenToggle { + border: none; + background: transparent; + padding: 0; + font-size: 13px; + line-height: 20px; + font-weight: 500; + color: var(--dsw-alias-label-secondary); + cursor: pointer; + white-space: nowrap; +} + +.showHiddenToggle:hover { + color: var(--dsw-alias-label-primary); +} + +.showHiddenToggle:disabled { + color: var(--dsw-alias-label-caption); + cursor: default; +} + +.showHiddenToggleActive { + color: var(--dsw-alias-label-primary); +} + .footerGap { flex: 1 1 0; } diff --git a/packages/host/directory-picker-browse/src/client/DirectoryBrowser.tsx b/packages/host/directory-picker-browse/src/client/DirectoryBrowser.tsx index f348f1dd8d..20fef7a827 100644 --- a/packages/host/directory-picker-browse/src/client/DirectoryBrowser.tsx +++ b/packages/host/directory-picker-browse/src/client/DirectoryBrowser.tsx @@ -10,8 +10,8 @@ * selects the created folder. Open adopts the selected folder, falling back * to the listed level. Pure consumer of the injected browse calls — the * owning flow decides what "Open" means and owns the workspace-creation - * error surface. Hidden entries are host-flagged and filtered here (a - * show-hidden toggle is deferred work, client-side only). + * error surface. Hidden entries are host-flagged and hidden by default; + * a "Show hidden files" toggle in the footer reveals them (client-side only). */ import { useCallback, useEffect, useRef, useState } from 'react' import clsx from 'clsx' @@ -60,16 +60,17 @@ function displayCrumbs(listing: DirectoryListing, homeLabel: string): DirectoryE } /** One column of folder rows (the Miller view renders one or two of these). */ -function LevelColumn({ entries, selectedPath, busy, onPick, wide }: { +function LevelColumn({ entries, selectedPath, busy, onPick, wide, showHidden }: { entries: readonly DirectoryEntry[] selectedPath: string | null busy: boolean onPick: (entry: DirectoryEntry) => void wide: boolean + showHidden: boolean }) { return (
- {entries.filter(entry => !entry.hidden).map((entry) => { + {entries.filter(entry => showHidden || !entry.hidden).map((entry) => { const selected = entry.path === selectedPath return ( // The wrapper carries the list semantics; the row keeps its NATIVE @@ -110,6 +111,8 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen, const [error, setError] = useState(null) // Path-edit state: null = breadcrumb mode; a string = the draft being typed. const [pathDraft, setPathDraft] = useState(null) + // Show-hidden toggle state (pure client-side filter, reset on close). + const [showHidden, setShowHidden] = useState(false) // Create-folder state: null = closed; a string = the nested dialog's draft. const [folderDraft, setFolderDraft] = useState(null) const [creatingFolder, setCreatingFolder] = useState(false) @@ -213,6 +216,7 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen, setSelected(null) setChild(null) setCreatingFolder(false) + setShowHidden(false) navigate() return } @@ -409,6 +413,7 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen, busy={parentInert} onPick={select} wide={!twoPane} + showHidden={showHidden} /> )} {twoPane && } @@ -419,6 +424,7 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen, busy={parentInert} onPick={advance} wide={false} + showHidden={showHidden} /> )}
@@ -442,6 +448,14 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen, > {t('browser.newFolder')} + diff --git a/packages/host/directory-picker-browse/src/client/index.ts b/packages/host/directory-picker-browse/src/client/index.ts index e47613cdaf..a458ca94c7 100644 --- a/packages/host/directory-picker-browse/src/client/index.ts +++ b/packages/host/directory-picker-browse/src/client/index.ts @@ -47,7 +47,6 @@ export function apply(ctx: ClientContext): void { 'browser.loading': '加载中…', 'browser.truncated': '文件夹过多,仅显示开头部分。', 'browser.showHidden': '显示隐藏文件', - 'browser.hideHidden': '隐藏隐藏文件', }], ['en', { 'browser.title': 'Select Workspace Directory', @@ -63,7 +62,6 @@ export function apply(ctx: ClientContext): void { 'browser.loading': 'Loading…', 'browser.truncated': 'Too many folders to list; only the beginning is shown.', 'browser.showHidden': 'Show hidden files', - 'browser.hideHidden': 'Hide hidden files', }], ] try { diff --git a/packages/host/directory-picker-browse/tests/client-flow.spec.tsx b/packages/host/directory-picker-browse/tests/client-flow.spec.tsx index c29afc935c..31ec5a4927 100644 --- a/packages/host/directory-picker-browse/tests/client-flow.spec.tsx +++ b/packages/host/directory-picker-browse/tests/client-flow.spec.tsx @@ -163,7 +163,6 @@ describe('directory-picker-browse client half', () => { expect(injected.t('browser.title')).toBe('选择工作区目录') expect(injected.t('browser.newFolder')).toBe('新建文件夹') expect(injected.t('browser.showHidden')).toBe('显示隐藏文件') - expect(injected.t('browser.hideHidden')).toBe('隐藏隐藏文件') }) it('drives the injected browse calls through the hole entry', async () => { diff --git a/packages/host/directory-picker-browse/tests/directory-browser.spec.tsx b/packages/host/directory-picker-browse/tests/directory-browser.spec.tsx index 624a2c8705..bc2ad81f76 100644 --- a/packages/host/directory-picker-browse/tests/directory-browser.spec.tsx +++ b/packages/host/directory-picker-browse/tests/directory-browser.spec.tsx @@ -107,11 +107,14 @@ describe('DirectoryBrowser', () => { const b = mount() await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() }) expect(screen.queryByText('.config')).toBeNull() - // Toggle hidden files on. - fireEvent.click(screen.getByRole('button', { name: 'browser.showHidden' })) + // The fixed-label toggle reports its state through aria-pressed. + const toggle = screen.getByRole('button', { name: 'browser.showHidden' }) + expect(toggle.getAttribute('aria-pressed')).toBe('false') + fireEvent.click(toggle) + expect(toggle.getAttribute('aria-pressed')).toBe('true') expect(screen.getByText('.config')).toBeTruthy() - // Toggle hidden files off. - fireEvent.click(screen.getByRole('button', { name: 'browser.hideHidden' })) + fireEvent.click(toggle) + expect(toggle.getAttribute('aria-pressed')).toBe('false') expect(screen.queryByText('.config')).toBeNull() // Close resets the toggle. b.view.rerender() From 60383efef93e083763f7b86239afc7e4798c181e Mon Sep 17 00:00:00 2001 From: creatixchu Date: Wed, 29 Jul 2026 13:42:11 +0800 Subject: [PATCH 003/113] feat(host): blur cancels path editing; scrollbar clearance in the miller columns --- .../src/client/DirectoryBrowser.module.css | 11 ++++- .../src/client/DirectoryBrowser.tsx | 42 +++++++++++-------- .../tests/directory-browser.spec.tsx | 14 +++++++ 3 files changed, 48 insertions(+), 19 deletions(-) diff --git a/packages/host/directory-picker-browse/src/client/DirectoryBrowser.module.css b/packages/host/directory-picker-browse/src/client/DirectoryBrowser.module.css index a2c712c811..444ff19cbf 100644 --- a/packages/host/directory-picker-browse/src/client/DirectoryBrowser.module.css +++ b/packages/host/directory-picker-browse/src/client/DirectoryBrowser.module.css @@ -55,7 +55,9 @@ align-items: stretch; flex: 1 1 0; min-height: 0; - gap: 20px; + /* Columns already end in an 8px scrollbar clearance, so the divider only + * needs a slim gap of its own on each side. */ + gap: 12px; overflow-x: auto; scrollbar-width: none; } @@ -136,7 +138,9 @@ flex-direction: column; flex: 1 1 0; min-height: 0; - padding: 16px 24px; + /* Right inset is slimmer than the left: the trailing column's own 8px + * scrollbar clearance makes up the optical difference. */ + padding: 16px 16px 16px 24px; } /* Two-pane columns split the row evenly around the divider; 256px is the @@ -149,6 +153,9 @@ flex: 1 1 0; min-width: 256px; overflow-y: auto; + /* The overlay scrollbar paints at the column's edge; keep the row pills + * clear of the thumb. */ + padding-right: 8px; } .columnWide { diff --git a/packages/host/directory-picker-browse/src/client/DirectoryBrowser.tsx b/packages/host/directory-picker-browse/src/client/DirectoryBrowser.tsx index 149e04d87e..5c3391454e 100644 --- a/packages/host/directory-picker-browse/src/client/DirectoryBrowser.tsx +++ b/packages/host/directory-picker-browse/src/client/DirectoryBrowser.tsx @@ -200,6 +200,25 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen, }) }, [launchListing]) + /** Abandon path editing (Escape or clicking away) and restore the crumb view. */ + const cancelPathEdit = useCallback(() => { + // Cancel also withdraws a navigation the editor already launched: its + // late success must not jump to the cancelled path, so the pending + // request is superseded and the view leaves the loading state. + supersede() + setLoading(false) + setPathDraft(null) + setError(null) + // Editing may have superseded the selection's preview request; a + // selection with no preview would render a half-empty two-pane view, so + // cancel falls back to the single-pane level. + if (child === null) setSelected(null) + // With no level listed yet (the editor superseded the initial home + // listing), plain cancellation would leave a permanently blank picker: + // restart the home listing. + if (parent === null) navigate() + }, [supersede, child, parent, navigate]) + /** A right-column pick advances the view one level: child becomes the level. */ const advance = useCallback((entry: DirectoryEntry) => { /* v8 ignore next -- narrowing guard: the right column only renders with a child listing. */ @@ -382,25 +401,14 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen, } if (event.key === 'Escape') { event.stopPropagation() - // Cancel also withdraws a navigation the editor already - // launched: its late success must not jump to the - // cancelled path, so the pending request is superseded - // and the view leaves the loading state. - supersede() - setLoading(false) - setPathDraft(null) - setError(null) - // Editing may have superseded the selection's preview - // request; a selection with no preview would render a - // half-empty two-pane view, so cancel falls back to the - // single-pane level. - if (child === null) setSelected(null) - // With no level listed yet (the editor superseded the - // initial home listing), plain cancellation would leave a - // permanently blank picker: restart the home listing. - if (parent === null) navigate() + cancelPathEdit() } }} + // Clicking anywhere outside the editor reads as leaving it: + // focus loss cancels the edit like Escape. Enter keeps focus + // in the input while its navigation is in flight, so a + // submitted path is never withdrawn by this handler. + onBlur={cancelPathEdit} /> )} diff --git a/packages/host/directory-picker-browse/tests/directory-browser.spec.tsx b/packages/host/directory-picker-browse/tests/directory-browser.spec.tsx index bc2ad81f76..d4fc9e2b89 100644 --- a/packages/host/directory-picker-browse/tests/directory-browser.spec.tsx +++ b/packages/host/directory-picker-browse/tests/directory-browser.spec.tsx @@ -220,6 +220,20 @@ describe('DirectoryBrowser', () => { expect(screen.queryByLabelText('browser.editPath', { selector: 'input' })).toBeNull() }) + it('clicking away from the path editor cancels it back to the crumb view', async () => { + mount() + await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() }) + fireEvent.click(screen.getByRole('button', { name: 'browser.editPath' })) + const input = screen.getByLabelText('browser.editPath') + fireEvent.change(input, { target: { value: '/somewhere/else' } }) + // Focus moving anywhere outside the editor abandons the draft like Escape. + fireEvent.blur(input) + expect(screen.queryByLabelText('browser.editPath', { selector: 'input' })).toBeNull() + // The crumb view is back and the abandoned draft was never navigated to. + expect(screen.getByRole('button', { name: 'browser.editPath' })).toBeTruthy() + expect(screen.getByRole('listitem').textContent).toBe('Documents') + }) + it('restarts the home listing when Escape cancels an edit opened before any level listed', async () => { // The initial home listing hangs; Edit Path supersedes it while parent // is still null, and Escape must not strand a blank picker. From e561c28232a273f834e66e557e8375a3bb2cf7d0 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Wed, 29 Jul 2026 13:47:56 +0800 Subject: [PATCH 004/113] feat(host): seed path editor with a trailing separator; prefix-filter levels from the draft tail --- .../src/client/DirectoryBrowser.tsx | 44 +++++++++++++++-- .../tests/directory-browser.spec.tsx | 47 ++++++++++++++++++- 2 files changed, 86 insertions(+), 5 deletions(-) diff --git a/packages/host/directory-picker-browse/src/client/DirectoryBrowser.tsx b/packages/host/directory-picker-browse/src/client/DirectoryBrowser.tsx index 5c3391454e..01e69f314c 100644 --- a/packages/host/directory-picker-browse/src/client/DirectoryBrowser.tsx +++ b/packages/host/directory-picker-browse/src/client/DirectoryBrowser.tsx @@ -12,7 +12,10 @@ * owning flow decides what "Open" means and owns the workspace-creation * error surface. Hidden entries are host-flagged and hidden by default; the * footer's fixed-label "Show hidden files" toggle (aria-pressed, check when - * on) reveals them (client-side only). + * on) reveals them (client-side only). The path editor opens seeded with a + * trailing separator, and while the draft's directory part names a listed + * level, its final segment prefix-filters that level's rows (a dot-led + * prefix also reveals the hidden entries it names). */ import { useCallback, useEffect, useRef, useState } from 'react' import clsx from 'clsx' @@ -60,18 +63,45 @@ function displayCrumbs(listing: DirectoryListing, homeLabel: string): DirectoryE return [{ name: homeLabel, path: listing.home, hidden: false }, ...tail] } +/** The separator a Host path's own platform uses (Windows listings carry backslashes). */ +function separatorOf(path: string): string { + return path.includes('\\') ? '\\' : '/' +} + +/** + * The path draft's final segment, when its directory part is exactly the + * level `listing` lists — the segment the level prefix-filters on while the + * user types. Any other draft (no separator yet, or naming some other + * directory) leaves the level unfiltered. + */ +function draftPrefixFor(listing: DirectoryListing, draft: string | null): string | null { + if (draft === null) return null + const sep = separatorOf(draft) + const cut = draft.lastIndexOf(sep) + if (cut === -1) return null + const level = listing.path.endsWith(sep) ? listing.path : `${listing.path}${sep}` + return draft.slice(0, cut + 1) === level ? draft.slice(cut + 1) : null +} + /** One column of folder rows (the Miller view renders one or two of these). */ -function LevelColumn({ entries, selectedPath, busy, onPick, wide, showHidden }: { +function LevelColumn({ entries, selectedPath, busy, onPick, wide, showHidden, filterPrefix }: { entries: readonly DirectoryEntry[] selectedPath: string | null busy: boolean onPick: (entry: DirectoryEntry) => void wide: boolean showHidden: boolean + filterPrefix: string | null }) { + const visible = entries.filter((entry) => { + if (filterPrefix !== null && !entry.name.toLowerCase().startsWith(filterPrefix.toLowerCase())) return false + // A dot-led prefix names hidden entries explicitly, so matching ones + // surface even while the toggle keeps the rest hidden. + return showHidden || !entry.hidden || filterPrefix?.startsWith('.') === true + }) return (
- {entries.filter(entry => showHidden || !entry.hidden).map((entry) => { + {visible.map((entry) => { const selected = entry.path === selectedPath return ( // The wrapper carries the list semantics; the row keeps its NATIVE @@ -370,7 +400,11 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen, // otherwise close the editor via navigate's draft reset. supersede() setLoading(false) - setPathDraft(selected?.path ?? parent?.path ?? '') + // Seed with a trailing separator so typing immediately + // continues into child names (and prefix-filters below). + const base = selected?.path ?? parent?.path ?? '' + const sep = separatorOf(base) + setPathDraft(base === '' || base.endsWith(sep) ? base : `${base}${sep}`) }} /> @@ -423,6 +457,7 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen, onPick={select} wide={!twoPane} showHidden={showHidden} + filterPrefix={draftPrefixFor(parent, pathDraft)} /> )} {twoPane && } @@ -434,6 +469,7 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen, onPick={advance} wide={false} showHidden={showHidden} + filterPrefix={draftPrefixFor(child, pathDraft)} /> )}
diff --git a/packages/host/directory-picker-browse/tests/directory-browser.spec.tsx b/packages/host/directory-picker-browse/tests/directory-browser.spec.tsx index d4fc9e2b89..98f3bd6e4f 100644 --- a/packages/host/directory-picker-browse/tests/directory-browser.spec.tsx +++ b/packages/host/directory-picker-browse/tests/directory-browser.spec.tsx @@ -206,7 +206,9 @@ describe('DirectoryBrowser', () => { await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() }) fireEvent.click(screen.getByRole('button', { name: 'browser.editPath' })) const input = screen.getByLabelText('browser.editPath') - expect(input.value).toBe(HOME) + // The editor seeds with a trailing separator so typing continues into + // child names. + expect(input.value).toBe(`${HOME}/`) fireEvent.change(input, { target: { value: DOCS } }) fireEvent.keyDown(input, { key: 'Enter' }) await waitFor(() => { expect(screen.getByRole('listitem').textContent).toBe('harness') }) @@ -220,6 +222,49 @@ describe('DirectoryBrowser', () => { expect(screen.queryByLabelText('browser.editPath', { selector: 'input' })).toBeNull() }) + it('prefix-filters the listed level from the draft tail, dot revealing hidden matches', async () => { + mount() + await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() }) + fireEvent.click(screen.getByRole('button', { name: 'browser.editPath' })) + const input = screen.getByLabelText('browser.editPath') + // The seeded empty segment leaves the level as-is: hidden stays hidden. + expect(screen.getByRole('listitem').textContent).toBe('Documents') + // Case-insensitive prefix narrows the rows. + fireEvent.change(input, { target: { value: `${HOME}/do` } }) + expect(screen.getByRole('listitem').textContent).toBe('Documents') + // A dot-led prefix names hidden entries, so it reveals the match. + fireEvent.change(input, { target: { value: `${HOME}/.co` } }) + expect(screen.getByRole('listitem').textContent).toBe('.config') + // A prefix matching nothing empties the level (no stale rows linger). + fireEvent.change(input, { target: { value: `${HOME}/zzz` } }) + expect(screen.queryByRole('listitem')).toBeNull() + // A draft naming some other directory (or none) leaves the level whole. + fireEvent.change(input, { target: { value: 'no-separator' } }) + expect(screen.getByRole('listitem').textContent).toBe('Documents') + }) + + it('seeds and filters with backslashes on a Windows-rooted listing', async () => { + const ROOT = 'C:\\' + const windowsListing: DirectoryListing = { + path: ROOT, + home: ROOT, + crumbs: [{ name: 'C:\\', path: ROOT, hidden: false }], + entries: [ + { name: 'Program Files', path: `${ROOT}Program Files`, hidden: false }, + { name: 'Users', path: `${ROOT}Users`, hidden: false }, + ], + truncated: false, + } + mount({ listDirectory: vi.fn(async () => windowsListing) }) + await waitFor(() => { expect(screen.getAllByRole('listitem')).toHaveLength(2) }) + fireEvent.click(screen.getByRole('button', { name: 'browser.editPath' })) + const input = screen.getByLabelText('browser.editPath') + // The root already ends in its separator: no doubled backslash. + expect(input.value).toBe(ROOT) + fireEvent.change(input, { target: { value: `${ROOT}u` } }) + expect(screen.getByRole('listitem').textContent).toBe('Users') + }) + it('clicking away from the path editor cancels it back to the crumb view', async () => { mount() await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() }) From 7714c9fa8b4c890ebc495e766a9d2f778e141953 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Wed, 29 Jul 2026 13:52:44 +0800 Subject: [PATCH 005/113] doc(host): document the show-hidden toggle and path-draft prefix filter; snapshot the flow --- ...directory-picker-capability-seam.i18n.yaml | 4 +-- ...-07-28-directory-picker-capability-seam.md | 2 +- ...-28-directory-picker-capability-seam.zh.md | 2 +- apps/web/tests/workspace-flow.snapshot.ts | 33 +++++++++++++++++++ .../directory-picker-browse/README.i18n.yaml | 4 +-- .../host/directory-picker-browse/README.md | 2 +- .../host/directory-picker-browse/README.zh.md | 2 +- 7 files changed, 41 insertions(+), 8 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.i18n.yaml index bb9425fa64..6f21a60e83 100644 --- a/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md -2026-07-28-directory-picker-capability-seam.md: 7c8f8cb67690cb4c5858cefb52b8cd79e649ec38 -2026-07-28-directory-picker-capability-seam.zh.md: 05545fc3cd758523814b31afa705249972d86464 +2026-07-28-directory-picker-capability-seam.md: 3f5e1436f3af14ce06ffab00ceca90167e16afd0 +2026-07-28-directory-picker-capability-seam.zh.md: 09a3e20f7c12e3c22d63875091593f9a6ca8a1ad diff --git a/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md b/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md index 7c8f8cb676..3f5e1436f3 100644 --- a/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md +++ b/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md @@ -18,7 +18,7 @@ Placement and policy rulings folded into this decision: - **Not the `ctx.fs` seam.** `packages/fs/` is the model/session-facing storage stack (policy events, sandbox-swappable backends). Riding it would couple GUI browsing to the model's confinement backend — swapping `fs-sandbox` for the model must never change GUI behavior — and OS facts (home anchoring, hidden conventions) are not storage primitives. The picker seam stays presentation-free and model-free; `packages/host/` is its consumer-domain home. - **Dependency survey (hand-roll vs adopt).** Node's stdlib *is* the maintained cross-platform OS layer (`readdir(withFileTypes)`, `homedir`, path semantics); surveyed alternatives fail the dependency bar — file-manager packages (`node-file-manager`, `files-and-folders`, Syncfusion's provider) are whole HTTP apps (fit), drive-letter helpers (`drivelist` native addon, `windows-drive-letters` ~7y stale) fail health/proportionality. The browse backend is a thin adapter over stdlib. -- **Hidden entries: return-and-flag.** The host stamps `hidden` (POSIX dot convention) and returns everything; the client filters. Display policy stays client-side, and the planned show-hidden toggle becomes a client-only change. Windows' `FILE_ATTRIBUTE_HIDDEN` is not exposed by dirents — documented limitation until a native probe pays for itself. +- **Hidden entries: return-and-flag.** The host stamps `hidden` (POSIX dot convention) and returns everything; the client filters. Display policy stays client-side, and the show-hidden toggle shipped as exactly that client-only change (the browse client's footer toggle). Windows' `FILE_ATTRIBUTE_HIDDEN` is not exposed by dirents — documented limitation until a native probe pays for itself. - **Symlinks: follow for enterability.** `stat` probes symlinks (broken/cyclic → skipped); crumbs keep the logical path the operator navigated, and `workspace.create` already canonicalizes via realpath at adoption. - **Listing levels are bounded, and streamed.** One `list` call returns at most `maxEntries` rows (config, default 1000 — GitHub's web-UI directory-listing bound). The level streams via `opendir` into a name-sorted window of `maxEntries + 1` candidates, so memory stays O(maxEntries) and enterability probing touches only windowed candidates; the wire `DirectoryListing` carries a required `truncated` flag so the client states incompleteness instead of silently missing tail entries. A windowed broken symlink is not backfilled from beyond the window — the eviction already marks the level truncated. Window insertion is binary with an O(1) full-window tail rejection (an oversized level must not pay a window scan per dirent), and `list(path, signal)` threads the carrier's request signal so a scan of a stalled network directory cannot outlive a disconnected caller — every await in the scan (open, each read, each symlink probe) races the signal, an aborted exit abandons rather than awaits the close (Node queues close behind in-flight reads), and abandoned settlements are swallowed so cleanup can never surface as an unhandled rejection. An unbounded level is a memory/responsiveness hole for large or adversarial directories. - **Whole-filesystem scope, no roots config.** `workspace.create` accepts arbitrary paths and the API serves bash-driving methods, so a browse root would be UX scoping, not a boundary; configurability without a consumer fails the evidence bar. Deferred until a deployment needs it. diff --git a/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.zh.md b/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.zh.md index 05545fc3cd..09a3e20f7c 100644 --- a/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.zh.md @@ -18,7 +18,7 @@ web GUI 的"打开本地文件夹"流程被焊死在一种交互上:`host.pick - **不用 `ctx.fs` seam。** `packages/fs/` 是面向模型/会话的存储栈(policy 事件、sandbox 可换后端)。骑上去会把 GUI 浏览耦合进模型的限制后端——为模型换 `fs-sandbox` 绝不能改变 GUI 行为——而 OS 事实(home 锚定、隐藏约定)也不是存储原语。picker seam 保持无展示、无模型;`packages/host/` 是它消费方域的家。 - **依赖调研(手写 vs 引入)。** Node 标准库本身就是维护中的跨平台 OS 层(`readdir(withFileTypes)`、`homedir`、路径语义);调研过的替代品都过不了依赖门槛——文件管理器包(`node-file-manager`、`files-and-folders`、Syncfusion 的 provider)是整套 HTTP 应用(契合度不过),盘符工具(原生插件 `drivelist`、约七年未更的 `windows-drive-letters`)健康度/比例失当。browse 后端是标准库上的薄适配。 -- **隐藏条目:返回并打标。** 宿主标注 `hidden`(POSIX 点前缀约定)并返回全部条目;客户端过滤。展示策略留在客户端,计划中的"显示隐藏"开关变成纯客户端改动。Windows 的 `FILE_ATTRIBUTE_HIDDEN` 不被 dirent 暴露——记为限制,直到原生探测值回其成本。 +- **隐藏条目:返回并打标。** 宿主标注 `hidden`(POSIX 点前缀约定)并返回全部条目;客户端过滤。展示策略留在客户端,"显示隐藏"开关正是作为这一纯客户端改动落地(browse 客户端的 footer 开关)。Windows 的 `FILE_ATTRIBUTE_HIDDEN` 不被 dirent 暴露——记为限制,直到原生探测值回其成本。 - **符号链接:为可进入性而跟随。** 用 `stat` 探测符号链接(断链/循环→跳过);面包屑保留操作者导航的逻辑路径,`workspace.create` 在接纳时本就做 realpath 规范化。 - **列举层级有上限,且流式处理。** 单次 `list` 至多返回 `maxEntries` 行(配置项,默认 1000——GitHub 网页端目录列举的同一上限)。层级经 `opendir` 流入一个按名排序、容量 `maxEntries + 1` 的候选窗口,内存保持 O(maxEntries),可进入性探测只触及窗口内候选;线上 `DirectoryListing` 携带必填的 `truncated` 标志,让客户端明示不完整而不是静默缺尾。窗口内的断链符号链接不从窗口外回填——发生过驱逐本身已把层级标记为截断。窗口插入为二分查找、满窗尾部单次比较即拒绝(超大层级不能为每个 dirent 付出一次全窗扫描),且 `list(path, signal)` 透传载体的请求信号,滞塞网络目录的扫描不会在调用方断连后继续存活——扫描中的每个 await(打开、每次读取、每次符号链接探测)都与信号赛跑,中止路径放弃而非等待 close(Node 会把 close 排在在飞读取之后),被放弃的 settlement 全部吞掉,清理不会以未处理拒绝的形式冒出。无上限的层级对超大或恶意构造的目录就是内存/响应性漏洞。 - **全盘可浏览,不做 roots 配置。** `workspace.create` 接受任意路径且 API 本就提供驱动 bash 的方法,浏览根只会是 UX 范围而非边界;没有消费方的可配置性过不了证据门槛。等到有部署需要再做。 diff --git a/apps/web/tests/workspace-flow.snapshot.ts b/apps/web/tests/workspace-flow.snapshot.ts index 86a20e73fe..32d44b6fff 100644 --- a/apps/web/tests/workspace-flow.snapshot.ts +++ b/apps/web/tests/workspace-flow.snapshot.ts @@ -214,6 +214,39 @@ it('adopts a directory through the composed in-app browse flow and lands in its }) }) +it('reveals hidden fixture entries via the footer toggle and prefix-filters from the path draft', async () => { + boot('?fixture=empty') + + await findLockedComposer() + fireEvent.click(workspaceChip()) + fireEvent.click(await screen.findByRole('menuitem', { name: 'Open local folder…' })) + const dialog = await screen.findByRole('dialog', { name: '选择工作区目录' }, { timeout: 10_000 }) + await within(dialog).findByText('Documents', {}, { timeout: 10_000 }) + // The host flags .config hidden; the level filters it until the + // fixed-label footer toggle presses on (state lives in aria-pressed). + expect(within(dialog).queryByText('.config')).toBeNull() + const toggle = within(dialog).getByRole('button', { name: '显示隐藏文件' }) + expect(toggle.getAttribute('aria-pressed')).toBe('false') + fireEvent.click(toggle) + expect(toggle.getAttribute('aria-pressed')).toBe('true') + await within(dialog).findByText('.config', {}, { timeout: 10_000 }) + fireEvent.click(toggle) + expect(within(dialog).queryByText('.config')).toBeNull() + // The path editor seeds the level's path with a trailing separator and the + // draft's final segment prefix-filters the listed rows while typing. + fireEvent.click(within(dialog).getByRole('button', { name: '编辑路径' })) + const input = within(dialog).getByLabelText('编辑路径') + expect(input.value).toBe('/home/fixture/') + fireEvent.change(input, { target: { value: '/home/fixture/do' } }) + expect(within(dialog).getByText('Documents')).toBeDefined() + expect(within(dialog).getByText('Downloads')).toBeDefined() + expect(within(dialog).queryByText('.config')).toBeNull() + // A dot-led prefix names hidden entries, so its matches surface. + fireEvent.change(input, { target: { value: '/home/fixture/.c' } }) + await within(dialog).findByText('.config', {}, { timeout: 10_000 }) + expect(within(dialog).queryByText('Documents')).toBeNull() +}) + it('selects the recent Workspace and opens its blank Session on first load', async () => { boot('?fixture') diff --git a/packages/host/directory-picker-browse/README.i18n.yaml b/packages/host/directory-picker-browse/README.i18n.yaml index 4454afde3a..f40742fc0d 100644 --- a/packages/host/directory-picker-browse/README.i18n.yaml +++ b/packages/host/directory-picker-browse/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/host/directory-picker-browse/README.md -README.md: 318380405214d5f25ad77e348c4e134a8981ffb3 -README.zh.md: 2f88f64cc2974b8535e34eb9798f512ea109b754 +README.md: 9772baa2a6e632a5f0f18cc18b9b55b45cd845ca +README.zh.md: 682495fe10bdeed41f709a438dcd222d612129e3 diff --git a/packages/host/directory-picker-browse/README.md b/packages/host/directory-picker-browse/README.md index 3183804052..9772baa2a6 100644 --- a/packages/host/directory-picker-browse/README.md +++ b/packages/host/directory-picker-browse/README.md @@ -6,7 +6,7 @@ The **in-app browsing backend** of the [directory-picker seam](../directory-pick Behavior facts: listings return **directories only**, name-sorted, with symlinks-to-directories followed (broken/cyclic links skipped — the probe `stat` failing means "not enterable") and a host-owned `hidden` flag (POSIX dot convention) left for the client to act on; `crumbs` is the root-to-target ancestor chain, the root crumb labeled by its full path (`/`, `C:\`); an absent `list` path means the host account's home directory. `createDirectory` is non-recursive (a missing parent is a real failure, not a level to invent) and validates the name as a single non-blank segment even when called directly, mirroring the wire schema's fence. Both primitives reject an explicit path that is not fully qualified — relative forms, and on Windows the rooted drive-less forms (`\foo`, `/foo`) and incomplete UNC prefixes (`\\`, `\\server`) that `isAbsolute` accepts — with `directory-unreadable`/`directory-create-failed`, instead of letting `resolve` rebase it under the host process cwd or current drive. One `list` call returns at most `maxEntries` rows (config, default 1000 — the bound GitHub's web UI applies to directory listings), and the level streams through a bounded window so memory stays O(maxEntries) no matter how many children the directory holds: a cut level keeps the name-sorted head, counts hidden rows against the bound, probes only windowed candidates, and reports `truncated: true` so the client can say the level is incomplete (a windowed broken symlink is not backfilled from beyond the window — the eviction already marks the level truncated); window insertion is binary with an O(1) full-window tail rejection, and `list` threads the caller's `AbortSignal` so a disconnect or timeout stops the scan instead of letting it outlive the caller. Failures throw the seam's typed `DirectoryPickerError`. Policy rationale: [the directory-picker capability seam Agent Note](../../../.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md). -**Dual-face package**: the browser half (`./client`) fills [ui-workspace's](../../client/ui-workspace/README.md) two directory-flow holes with the in-app **Select Workspace Directory** dialog (figma `Harness` 813-23126 family — Miller two-column view, breadcrumb with a click-to-edit path zone, nested New-folder dialog), driving `host.listDirectory`/`host.createDirectory` and registering its own locale namespace (`directory-browser`, zh default / en). One cordis.yml row therefore composes both sides of the browse interaction; the client carries no capability-kind branching, and mounting a second flow package fails at load (the holes are `single` kind). +**Dual-face package**: the browser half (`./client`) fills [ui-workspace's](../../client/ui-workspace/README.md) two directory-flow holes with the in-app **Select Workspace Directory** dialog (figma `Harness` 813-23126 family — Miller two-column view; breadcrumb with a click-to-edit path zone whose editor seeds a trailing separator, prefix-filters the listed level from the draft's final segment while typing, and cancels on Escape or focus loss; a fixed-label show-hidden footer toggle over the host's `hidden` flags, with a dot-led typed prefix revealing its matches; nested New-folder dialog), driving `host.listDirectory`/`host.createDirectory` and registering its own locale namespace (`directory-browser`, zh default / en). One cordis.yml row therefore composes both sides of the browse interaction; the client carries no capability-kind branching, and mounting a second flow package fails at load (the holes are `single` kind). ## Model Experience diff --git a/packages/host/directory-picker-browse/README.zh.md b/packages/host/directory-picker-browse/README.zh.md index 2f88f64cc2..682495fe10 100644 --- a/packages/host/directory-picker-browse/README.zh.md +++ b/packages/host/directory-picker-browse/README.zh.md @@ -6,7 +6,7 @@ 行为事实:列举**只返回目录**、按名称排序,指向目录的符号链接会被跟随(断链/循环链接被跳过——探测 `stat` 失败即"不可进入"),并携带宿主判定的 `hidden` 标志(POSIX 点前缀约定),展示决策留给客户端;`crumbs` 是从根到目标的祖先链,根 crumb 以完整路径标注(`/`、`C:\`);`list` 不带路径即列举宿主账户的家目录。`createDirectory` 不递归(父目录缺失是真实失败,不是要补造的层级),且即便被直接调用也把名称校验为单个非空段,与协议 schema 的栅栏一致。两个原语都拒绝非完全限定的显式路径——相对形态,以及 Windows 上 `isAbsolute` 会放行的无盘符有根形态(`\foo`、`/foo`)与不完整的 UNC 前缀(`\\`、`\\server`)——报 `directory-unreadable`/`directory-create-failed`,而不是任由 `resolve` 把它重定位到宿主进程 cwd 或当前盘符之下。单次 `list` 至多返回 `maxEntries` 行(配置项,默认 1000——GitHub 网页端对目录列举采用的同一上限),且层级以流式方式经过一个有界窗口,无论目录有多少子项内存都保持 O(maxEntries):被截断的层级保留按名排序的头部、隐藏行计入上限、只探测窗口内候选,并报告 `truncated: true`,供客户端提示层级不完整(窗口内的断链符号链接不会从窗口外回填——发生过驱逐本身已把层级标记为截断);窗口插入为二分查找、满窗尾部单次比较即拒绝,且 `list` 透传调用方的 `AbortSignal`,断连或超时会停止扫描而不是让它在调用方离开后继续。失败抛出 seam 的类型化 `DirectoryPickerError`。策略依据:[目录选择能力 seam Agent Note](../../../.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md)。 -**双面包**:browser half(`./client`)以应用内 **选择工作区目录** 对话框(figma `Harness` 813-23126 家族——Miller 双列视图、带点击即编辑路径区的面包屑、嵌套新建文件夹对话框)填入 [ui-workspace](../../client/ui-workspace/README.md) 的两个目录流洞,驱动 `host.listDirectory`/`host.createDirectory`,并注册自己的 locale 命名空间(`directory-browser`,zh 默认/en)。因此一行 cordis.yml 同时组合浏览交互的两侧;client 侧不含任何能力 kind 分支,挂载第二个流程包会在加载期失败(洞为 `single` kind)。 +**双面包**:browser half(`./client`)以应用内 **选择工作区目录** 对话框(figma `Harness` 813-23126 家族——Miller 双列视图;带点击即编辑路径区的面包屑,其编辑器预填尾随分隔符、输入时以草稿末段对所列层级做前缀过滤、按 Escape 或失焦即取消;基于宿主 `hidden` 标志、标签固定的"显示隐藏"footer 开关,键入以点开头的前缀会显出其匹配项;嵌套新建文件夹对话框)填入 [ui-workspace](../../client/ui-workspace/README.md) 的两个目录流洞,驱动 `host.listDirectory`/`host.createDirectory`,并注册自己的 locale 命名空间(`directory-browser`,zh 默认/en)。因此一行 cordis.yml 同时组合浏览交互的两侧;client 侧不含任何能力 kind 分支,挂载第二个流程包会在加载期失败(洞为 `single` kind)。 ## 模型体验 From 998a5f33819c0ec3246763588b4f513cd526b8a3 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Wed, 29 Jul 2026 14:27:01 +0800 Subject: [PATCH 006/113] =?UTF-8?q?fix(host):=20review=20round=20=E2=80=94?= =?UTF-8?q?=20host-derived=20separator,=20click-safe=20blur=20cancel,=20de?= =?UTF-8?q?ad=20wide=20prop?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../directory-picker-browse/README.i18n.yaml | 4 +- .../host/directory-picker-browse/README.md | 2 +- .../host/directory-picker-browse/README.zh.md | 2 +- .../src/client/DirectoryBrowser.module.css | 15 ++--- .../src/client/DirectoryBrowser.tsx | 66 +++++++++++++------ .../tests/directory-browser.spec.tsx | 56 +++++++++++++++- 6 files changed, 112 insertions(+), 33 deletions(-) diff --git a/packages/host/directory-picker-browse/README.i18n.yaml b/packages/host/directory-picker-browse/README.i18n.yaml index f40742fc0d..eea9966b83 100644 --- a/packages/host/directory-picker-browse/README.i18n.yaml +++ b/packages/host/directory-picker-browse/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/host/directory-picker-browse/README.md -README.md: 9772baa2a6e632a5f0f18cc18b9b55b45cd845ca -README.zh.md: 682495fe10bdeed41f709a438dcd222d612129e3 +README.md: 95d2d66406210f4ba687ef5a45a38b142abd6f26 +README.zh.md: 9da374bec80f80085ff36871c227c496bb1fde7b diff --git a/packages/host/directory-picker-browse/README.md b/packages/host/directory-picker-browse/README.md index 9772baa2a6..95d2d66406 100644 --- a/packages/host/directory-picker-browse/README.md +++ b/packages/host/directory-picker-browse/README.md @@ -6,7 +6,7 @@ The **in-app browsing backend** of the [directory-picker seam](../directory-pick Behavior facts: listings return **directories only**, name-sorted, with symlinks-to-directories followed (broken/cyclic links skipped — the probe `stat` failing means "not enterable") and a host-owned `hidden` flag (POSIX dot convention) left for the client to act on; `crumbs` is the root-to-target ancestor chain, the root crumb labeled by its full path (`/`, `C:\`); an absent `list` path means the host account's home directory. `createDirectory` is non-recursive (a missing parent is a real failure, not a level to invent) and validates the name as a single non-blank segment even when called directly, mirroring the wire schema's fence. Both primitives reject an explicit path that is not fully qualified — relative forms, and on Windows the rooted drive-less forms (`\foo`, `/foo`) and incomplete UNC prefixes (`\\`, `\\server`) that `isAbsolute` accepts — with `directory-unreadable`/`directory-create-failed`, instead of letting `resolve` rebase it under the host process cwd or current drive. One `list` call returns at most `maxEntries` rows (config, default 1000 — the bound GitHub's web UI applies to directory listings), and the level streams through a bounded window so memory stays O(maxEntries) no matter how many children the directory holds: a cut level keeps the name-sorted head, counts hidden rows against the bound, probes only windowed candidates, and reports `truncated: true` so the client can say the level is incomplete (a windowed broken symlink is not backfilled from beyond the window — the eviction already marks the level truncated); window insertion is binary with an O(1) full-window tail rejection, and `list` threads the caller's `AbortSignal` so a disconnect or timeout stops the scan instead of letting it outlive the caller. Failures throw the seam's typed `DirectoryPickerError`. Policy rationale: [the directory-picker capability seam Agent Note](../../../.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md). -**Dual-face package**: the browser half (`./client`) fills [ui-workspace's](../../client/ui-workspace/README.md) two directory-flow holes with the in-app **Select Workspace Directory** dialog (figma `Harness` 813-23126 family — Miller two-column view; breadcrumb with a click-to-edit path zone whose editor seeds a trailing separator, prefix-filters the listed level from the draft's final segment while typing, and cancels on Escape or focus loss; a fixed-label show-hidden footer toggle over the host's `hidden` flags, with a dot-led typed prefix revealing its matches; nested New-folder dialog), driving `host.listDirectory`/`host.createDirectory` and registering its own locale namespace (`directory-browser`, zh default / en). One cordis.yml row therefore composes both sides of the browse interaction; the client carries no capability-kind branching, and mounting a second flow package fails at load (the holes are `single` kind). +**Dual-face package**: the browser half (`./client`) fills [ui-workspace's](../../client/ui-workspace/README.md) two directory-flow holes with the in-app **Select Workspace Directory** dialog (figma `Harness` 813-23126 family — Miller two-column view; breadcrumb with a click-to-edit path zone whose editor seeds a trailing separator, prefix-filters the listed level from the draft's final segment while typing (case-insensitively, over the listed — possibly truncated — rows only; Enter still navigates by the exact text), and cancels on Escape or focus loss; a fixed-label show-hidden footer toggle over the host's `hidden` flags, with a dot-led typed prefix revealing its matches; nested New-folder dialog), driving `host.listDirectory`/`host.createDirectory` and registering its own locale namespace (`directory-browser`, zh default / en). One cordis.yml row therefore composes both sides of the browse interaction; the client carries no capability-kind branching, and mounting a second flow package fails at load (the holes are `single` kind). ## Model Experience diff --git a/packages/host/directory-picker-browse/README.zh.md b/packages/host/directory-picker-browse/README.zh.md index 682495fe10..9da374bec8 100644 --- a/packages/host/directory-picker-browse/README.zh.md +++ b/packages/host/directory-picker-browse/README.zh.md @@ -6,7 +6,7 @@ 行为事实:列举**只返回目录**、按名称排序,指向目录的符号链接会被跟随(断链/循环链接被跳过——探测 `stat` 失败即"不可进入"),并携带宿主判定的 `hidden` 标志(POSIX 点前缀约定),展示决策留给客户端;`crumbs` 是从根到目标的祖先链,根 crumb 以完整路径标注(`/`、`C:\`);`list` 不带路径即列举宿主账户的家目录。`createDirectory` 不递归(父目录缺失是真实失败,不是要补造的层级),且即便被直接调用也把名称校验为单个非空段,与协议 schema 的栅栏一致。两个原语都拒绝非完全限定的显式路径——相对形态,以及 Windows 上 `isAbsolute` 会放行的无盘符有根形态(`\foo`、`/foo`)与不完整的 UNC 前缀(`\\`、`\\server`)——报 `directory-unreadable`/`directory-create-failed`,而不是任由 `resolve` 把它重定位到宿主进程 cwd 或当前盘符之下。单次 `list` 至多返回 `maxEntries` 行(配置项,默认 1000——GitHub 网页端对目录列举采用的同一上限),且层级以流式方式经过一个有界窗口,无论目录有多少子项内存都保持 O(maxEntries):被截断的层级保留按名排序的头部、隐藏行计入上限、只探测窗口内候选,并报告 `truncated: true`,供客户端提示层级不完整(窗口内的断链符号链接不会从窗口外回填——发生过驱逐本身已把层级标记为截断);窗口插入为二分查找、满窗尾部单次比较即拒绝,且 `list` 透传调用方的 `AbortSignal`,断连或超时会停止扫描而不是让它在调用方离开后继续。失败抛出 seam 的类型化 `DirectoryPickerError`。策略依据:[目录选择能力 seam Agent Note](../../../.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md)。 -**双面包**:browser half(`./client`)以应用内 **选择工作区目录** 对话框(figma `Harness` 813-23126 家族——Miller 双列视图;带点击即编辑路径区的面包屑,其编辑器预填尾随分隔符、输入时以草稿末段对所列层级做前缀过滤、按 Escape 或失焦即取消;基于宿主 `hidden` 标志、标签固定的"显示隐藏"footer 开关,键入以点开头的前缀会显出其匹配项;嵌套新建文件夹对话框)填入 [ui-workspace](../../client/ui-workspace/README.md) 的两个目录流洞,驱动 `host.listDirectory`/`host.createDirectory`,并注册自己的 locale 命名空间(`directory-browser`,zh 默认/en)。因此一行 cordis.yml 同时组合浏览交互的两侧;client 侧不含任何能力 kind 分支,挂载第二个流程包会在加载期失败(洞为 `single` kind)。 +**双面包**:browser half(`./client`)以应用内 **选择工作区目录** 对话框(figma `Harness` 813-23126 家族——Miller 双列视图;带点击即编辑路径区的面包屑,其编辑器预填尾随分隔符、输入时以草稿末段对所列层级做前缀过滤(不区分大小写,且仅作用于已列出、可能被截断的行;Enter 仍按确切文本导航)、按 Escape 或失焦即取消;基于宿主 `hidden` 标志、标签固定的"显示隐藏"footer 开关,键入以点开头的前缀会显出其匹配项;嵌套新建文件夹对话框)填入 [ui-workspace](../../client/ui-workspace/README.md) 的两个目录流洞,驱动 `host.listDirectory`/`host.createDirectory`,并注册自己的 locale 命名空间(`directory-browser`,zh 默认/en)。因此一行 cordis.yml 同时组合浏览交互的两侧;client 侧不含任何能力 kind 分支,挂载第二个流程包会在加载期失败(洞为 `single` kind)。 ## 模型体验 diff --git a/packages/host/directory-picker-browse/src/client/DirectoryBrowser.module.css b/packages/host/directory-picker-browse/src/client/DirectoryBrowser.module.css index 444ff19cbf..3b681de32a 100644 --- a/packages/host/directory-picker-browse/src/client/DirectoryBrowser.module.css +++ b/packages/host/directory-picker-browse/src/client/DirectoryBrowser.module.css @@ -131,8 +131,8 @@ } /* Miller content: symmetric 16px vertical padding so the divider clears the - * header and footer rules evenly; columns are 256 wide (or full width solo) - * with the hairline divider centered between them; each column scrolls alone. */ + * header and footer rules evenly; each column scrolls alone (column widths + * live at .column). */ .content { display: flex; flex-direction: column; @@ -143,9 +143,10 @@ padding: 16px 16px 16px 24px; } -/* Two-pane columns split the row evenly around the divider; 256px is the - * floor below which the row scrolls (scrollbar hidden, the effect pins the - * child pane into view) instead of squeezing the panes. */ +/* Columns split the row evenly around the divider (a solo column takes the + * whole row); 256px is the floor below which the row scrolls (scrollbar + * hidden, the effect pins the child pane into view) instead of squeezing + * the panes. */ .column { display: flex; flex-direction: column; @@ -158,10 +159,6 @@ padding-right: 8px; } -.columnWide { - width: 100%; -} - .divider { flex: none; width: 1px; diff --git a/packages/host/directory-picker-browse/src/client/DirectoryBrowser.tsx b/packages/host/directory-picker-browse/src/client/DirectoryBrowser.tsx index 01e69f314c..b924c072bb 100644 --- a/packages/host/directory-picker-browse/src/client/DirectoryBrowser.tsx +++ b/packages/host/directory-picker-browse/src/client/DirectoryBrowser.tsx @@ -1,10 +1,11 @@ /** * The in-app workspace-directory browser (figma Harness 813-23126 family): a - * 600×420 dialog (clamped to short/narrow viewports — the Miller row scrolls + * 680×500 dialog (clamped to short/narrow viewports — the Miller row scrolls * sideways, the columns scroll down) whose header carries the title, the selection-path * breadcrumb, and a click-to-edit path zone; below it a Miller view — one - * full-width level until a row is selected, then two 256px columns (level | - * selected folder's children) around a hairline divider. Selecting in the + * full-width level until a row is selected, then two columns splitting the + * row evenly (256px floor; level | selected folder's children) around a + * hairline divider. Selecting in the * right column shifts the view one level deeper. "New folder" opens a nested * create dialog targeting the selected folder (or the level itself) and * selects the created folder. Open adopts the selected folder, falling back @@ -63,20 +64,26 @@ function displayCrumbs(listing: DirectoryListing, homeLabel: string): DirectoryE return [{ name: homeLabel, path: listing.home, hidden: false }, ...tail] } -/** The separator a Host path's own platform uses (Windows listings carry backslashes). */ -function separatorOf(path: string): string { - return path.includes('\\') ? '\\' : '/' +/** + * The listing's platform separator, read from the host-stamped home path — + * never sniffed from typed text or entry paths, where a backslash is a legal + * POSIX name character rather than a platform fact. + */ +function separatorOf(listing: DirectoryListing): '\\' | '/' { + return listing.home.includes('\\') ? '\\' : '/' } /** * The path draft's final segment, when its directory part is exactly the * level `listing` lists — the segment the level prefix-filters on while the * user types. Any other draft (no separator yet, or naming some other - * directory) leaves the level unfiltered. + * directory) leaves the level unfiltered. The directory part compares + * exactly (it is the host's own path text, reached by seeding or erasing); + * only the name filter downstream is case-insensitive. */ function draftPrefixFor(listing: DirectoryListing, draft: string | null): string | null { if (draft === null) return null - const sep = separatorOf(draft) + const sep = separatorOf(listing) const cut = draft.lastIndexOf(sep) if (cut === -1) return null const level = listing.path.endsWith(sep) ? listing.path : `${listing.path}${sep}` @@ -84,12 +91,11 @@ function draftPrefixFor(listing: DirectoryListing, draft: string | null): string } /** One column of folder rows (the Miller view renders one or two of these). */ -function LevelColumn({ entries, selectedPath, busy, onPick, wide, showHidden, filterPrefix }: { +function LevelColumn({ entries, selectedPath, busy, onPick, showHidden, filterPrefix }: { entries: readonly DirectoryEntry[] selectedPath: string | null busy: boolean onPick: (entry: DirectoryEntry) => void - wide: boolean showHidden: boolean filterPrefix: string | null }) { @@ -100,7 +106,7 @@ function LevelColumn({ entries, selectedPath, busy, onPick, wide, showHidden, fi return showHidden || !entry.hidden || filterPrefix?.startsWith('.') === true }) return ( -
+
{visible.map((entry) => { const selected = entry.path === selectedPath return ( @@ -112,6 +118,10 @@ function LevelColumn({ entries, selectedPath, busy, onPick, wide, showHidden, fi aria-current={selected || undefined} className={clsx(css.row, selected && css.rowSelected)} disabled={busy} + // Keep focus where it is (the path editor, notably): a focus + // steal on mousedown would blur-cancel the editor, unmount the + // filtered rows mid-gesture, and drop this very click. + onMouseDown={(event) => { event.preventDefault() }} onClick={() => { onPick(entry) }} > {selected @@ -142,7 +152,7 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen, const [error, setError] = useState(null) // Path-edit state: null = breadcrumb mode; a string = the draft being typed. const [pathDraft, setPathDraft] = useState(null) - // Show-hidden toggle state (pure client-side filter, reset on close). + // Show-hidden toggle state (pure client-side filter, reset on each open). const [showHidden, setShowHidden] = useState(false) // Create-folder state: null = closed; a string = the nested dialog's draft. const [folderDraft, setFolderDraft] = useState(null) @@ -212,6 +222,9 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen, /** Select a row of the listed level and preview its children on the right. */ const select = useCallback((entry: DirectoryEntry) => { const { seq, scan } = launchListing(entry.path) + // A pick while the path editor is open adopts the (filtered) row and + // closes the editor — the draft served its purpose. + setPathDraft(null) setSelected(entry) setChild(null) setLoading(true) @@ -402,9 +415,15 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen, setLoading(false) // Seed with a trailing separator so typing immediately // continues into child names (and prefix-filters below). - const base = selected?.path ?? parent?.path ?? '' - const sep = separatorOf(base) - setPathDraft(base === '' || base.endsWith(sep) ? base : `${base}${sep}`) + // No listed level means nothing to seed from (the editor + // is the recovery path for a failed home listing). + if (parent === null) { + setPathDraft('') + return + } + const base = selected?.path ?? parent.path + const sep = separatorOf(parent) + setPathDraft(base.endsWith(sep) ? base : `${base}${sep}`) }} /> @@ -441,8 +460,15 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen, // Clicking anywhere outside the editor reads as leaving it: // focus loss cancels the edit like Escape. Enter keeps focus // in the input while its navigation is in flight, so a - // submitted path is never withdrawn by this handler. - onBlur={cancelPathEdit} + // submitted path is never withdrawn by this handler; rows and + // the show-hidden toggle suppress focus steal on mousedown so + // a click on them lands before any cancel. Window/tab focus + // loss also fires blur in some engines — only a focus move + // within a focused document reads as leaving the editor. + onBlur={() => { + if (!document.hasFocus()) return + cancelPathEdit() + }} /> )}
@@ -455,7 +481,6 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen, selectedPath={selected?.path ?? null} busy={parentInert} onPick={select} - wide={!twoPane} showHidden={showHidden} filterPrefix={draftPrefixFor(parent, pathDraft)} /> @@ -467,7 +492,6 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen, selectedPath={null} busy={parentInert} onPick={advance} - wide={false} showHidden={showHidden} filterPrefix={draftPrefixFor(child, pathDraft)} /> @@ -498,6 +522,10 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen, className={clsx(css.showHiddenToggle, showHidden && css.showHiddenToggleActive)} aria-pressed={showHidden} disabled={parentInert} + // The toggle composes with the path editor (dot-led prefixes and + // this filter interleave): don't steal focus, so toggling never + // blur-cancels a draft mid-thought. + onMouseDown={(event) => { event.preventDefault() }} onClick={() => { setShowHidden(prev => !prev) }} > {showHidden && } diff --git a/packages/host/directory-picker-browse/tests/directory-browser.spec.tsx b/packages/host/directory-picker-browse/tests/directory-browser.spec.tsx index 98f3bd6e4f..af3aff5385 100644 --- a/packages/host/directory-picker-browse/tests/directory-browser.spec.tsx +++ b/packages/host/directory-picker-browse/tests/directory-browser.spec.tsx @@ -107,9 +107,11 @@ describe('DirectoryBrowser', () => { const b = mount() await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() }) expect(screen.queryByText('.config')).toBeNull() - // The fixed-label toggle reports its state through aria-pressed. + // The fixed-label toggle reports its state through aria-pressed. Its + // mousedown never steals focus (so it composes with the path editor). const toggle = screen.getByRole('button', { name: 'browser.showHidden' }) expect(toggle.getAttribute('aria-pressed')).toBe('false') + fireEvent.mouseDown(toggle) fireEvent.click(toggle) expect(toggle.getAttribute('aria-pressed')).toBe('true') expect(screen.getByText('.config')).toBeTruthy() @@ -243,6 +245,58 @@ describe('DirectoryBrowser', () => { expect(screen.getByRole('listitem').textContent).toBe('Documents') }) + it('filters the child pane in two-pane mode and follows the draft back up a level', async () => { + mount() + await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() }) + fireEvent.click(rowButton(screen.getByRole('listitem'))) + await waitFor(() => { expect(columns()).toHaveLength(2) }) + fireEvent.click(screen.getByRole('button', { name: 'browser.editPath' })) + const input = screen.getByLabelText('browser.editPath') + // The seed comes from the selection, so the draft tail addresses the + // RIGHT pane (the selection's children). + expect(input.value).toBe(`${DOCS}/`) + fireEvent.change(input, { target: { value: `${DOCS}/h` } }) + expect(within(columns()[1]!).getByText('harness')).toBeTruthy() + fireEvent.change(input, { target: { value: `${DOCS}/zzz` } }) + expect(within(columns()[1]!).queryAllByRole('listitem')).toHaveLength(0) + expect(within(columns()[0]!).getByText('Documents')).toBeTruthy() + // Erasing back into the parent's own path moves the filter to the LEFT + // pane and releases the right one. + fireEvent.change(input, { target: { value: `${HOME}/zz` } }) + expect(within(columns()[0]!).queryAllByRole('listitem')).toHaveLength(0) + expect(within(columns()[1]!).getByText('harness')).toBeTruthy() + }) + + it('keeps the path editor open when blur comes from window focus loss', async () => { + mount() + await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() }) + fireEvent.click(screen.getByRole('button', { name: 'browser.editPath' })) + const input = screen.getByLabelText('browser.editPath') + // A blur while the document itself lost focus (window switch, dev-tools + // focus) must not discard the draft. + const hasFocus = vi.spyOn(document, 'hasFocus').mockReturnValue(false) + fireEvent.blur(input) + expect(screen.getByLabelText('browser.editPath', { selector: 'input' })).toBeTruthy() + hasFocus.mockRestore() + }) + + it('picking a filtered row adopts it and closes the path editor', async () => { + mount() + await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() }) + fireEvent.click(screen.getByRole('button', { name: 'browser.editPath' })) + const input = screen.getByLabelText('browser.editPath') + fireEvent.change(input, { target: { value: `${HOME}/do` } }) + // The row suppresses focus steal on mousedown (no blur-cancel unmounts + // the filtered rows mid-gesture), then the click both selects the row + // and closes the editor. + const row = rowButton(screen.getByRole('listitem')) + fireEvent.mouseDown(row) + fireEvent.click(row) + expect(screen.queryByLabelText('browser.editPath', { selector: 'input' })).toBeNull() + await waitFor(() => { expect(columns()).toHaveLength(2) }) + expect(screen.getByRole('button', { name: 'browser.home' })).toBeTruthy() + }) + it('seeds and filters with backslashes on a Windows-rooted listing', async () => { const ROOT = 'C:\\' const windowsListing: DirectoryListing = { From 27053efffceacf9330388c062a0dfdc29c516a85 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Wed, 29 Jul 2026 14:47:40 +0800 Subject: [PATCH 007/113] =?UTF-8?q?fix(host):=20review=20round=202=20?= =?UTF-8?q?=E2=80=94=20dialog-scoped=20blur=20cancel,=20editing-only=20foc?= =?UTF-8?q?us=20hold,=20selection=20exempt=20from=20filters?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/client/DirectoryBrowser.module.css | 5 +- .../src/client/DirectoryBrowser.tsx | 53 ++++++++++------- .../tests/directory-browser.spec.tsx | 57 +++++++++++++++++-- 3 files changed, 89 insertions(+), 26 deletions(-) diff --git a/packages/host/directory-picker-browse/src/client/DirectoryBrowser.module.css b/packages/host/directory-picker-browse/src/client/DirectoryBrowser.module.css index 3b681de32a..d4fa986371 100644 --- a/packages/host/directory-picker-browse/src/client/DirectoryBrowser.module.css +++ b/packages/host/directory-picker-browse/src/client/DirectoryBrowser.module.css @@ -55,8 +55,9 @@ align-items: stretch; flex: 1 1 0; min-height: 0; - /* Columns already end in an 8px scrollbar clearance, so the divider only - * needs a slim gap of its own on each side. */ + /* 12px of row gap on each side of the divider; the left side reads wider + * by the column's trailing 8px scrollbar clearance, which is deliberate — + * the thumb needs that room, the right pane's rows do not. */ gap: 12px; overflow-x: auto; scrollbar-width: none; diff --git a/packages/host/directory-picker-browse/src/client/DirectoryBrowser.tsx b/packages/host/directory-picker-browse/src/client/DirectoryBrowser.tsx index b924c072bb..9c3dc66fbb 100644 --- a/packages/host/directory-picker-browse/src/client/DirectoryBrowser.tsx +++ b/packages/host/directory-picker-browse/src/client/DirectoryBrowser.tsx @@ -65,9 +65,12 @@ function displayCrumbs(listing: DirectoryListing, homeLabel: string): DirectoryE } /** - * The listing's platform separator, read from the host-stamped home path — - * never sniffed from typed text or entry paths, where a backslash is a legal - * POSIX name character rather than a platform fact. + * The listing's platform separator, inferred from the home path the host + * stamped — never from typed text or entry paths, where a backslash is a + * legal POSIX name character. Still a heuristic at the last step: a POSIX + * home directory whose own name contains a backslash would misread. + * TODO: replace with a host-stamped `separator` field on the wire + * DirectoryListing so the platform fact travels verbatim. */ function separatorOf(listing: DirectoryListing): '\\' | '/' { return listing.home.includes('\\') ? '\\' : '/' @@ -91,15 +94,20 @@ function draftPrefixFor(listing: DirectoryListing, draft: string | null): string } /** One column of folder rows (the Miller view renders one or two of these). */ -function LevelColumn({ entries, selectedPath, busy, onPick, showHidden, filterPrefix }: { +function LevelColumn({ entries, selectedPath, busy, onPick, showHidden, filterPrefix, pathEditing }: { entries: readonly DirectoryEntry[] selectedPath: string | null busy: boolean onPick: (entry: DirectoryEntry) => void showHidden: boolean filterPrefix: string | null + pathEditing: boolean }) { const visible = entries.filter((entry) => { + // The selection is exempt from both filters: it anchors the two-pane + // view (crumbs and the child pane point at it), so neither the hidden + // filter after a dot-reveal pick nor a prefix miss may orphan it. + if (entry.path === selectedPath) return true if (filterPrefix !== null && !entry.name.toLowerCase().startsWith(filterPrefix.toLowerCase())) return false // A dot-led prefix names hidden entries explicitly, so matching ones // surface even while the toggle keeps the rest hidden. @@ -118,10 +126,11 @@ function LevelColumn({ entries, selectedPath, busy, onPick, showHidden, filterPr aria-current={selected || undefined} className={clsx(css.row, selected && css.rowSelected)} disabled={busy} - // Keep focus where it is (the path editor, notably): a focus - // steal on mousedown would blur-cancel the editor, unmount the - // filtered rows mid-gesture, and drop this very click. - onMouseDown={(event) => { event.preventDefault() }} + // While the path editor is open, keep focus in it: a focus + // steal on mousedown would blur the editor and (in engines + // where the blur lands before our guards) drop this click. + // Outside editing, rows keep native focus behavior. + onMouseDown={pathEditing ? (event) => { event.preventDefault() } : undefined} onClick={() => { onPick(entry) }} > {selected @@ -457,16 +466,19 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen, cancelPathEdit() } }} - // Clicking anywhere outside the editor reads as leaving it: - // focus loss cancels the edit like Escape. Enter keeps focus + // Focus leaving the DIALOG reads as leaving the editor and + // cancels like Escape. Three guarded non-cancel paths: window + // or tab focus loss (document no longer focused); a focus + // move that stays inside the dialog card (keyboard Tab onto + // the filtered rows or the footer toggle); and pointer paths, + // where rows and the toggle suppress focus steal on mousedown + // while editing so their click lands first. Enter keeps focus // in the input while its navigation is in flight, so a - // submitted path is never withdrawn by this handler; rows and - // the show-hidden toggle suppress focus steal on mousedown so - // a click on them lands before any cancel. Window/tab focus - // loss also fires blur in some engines — only a focus move - // within a focused document reads as leaving the editor. - onBlur={() => { + // submitted path is never withdrawn here. + onBlur={(event) => { if (!document.hasFocus()) return + if (event.relatedTarget instanceof HTMLElement + && event.relatedTarget.closest('[role="dialog"]') !== null) return cancelPathEdit() }} /> @@ -483,6 +495,7 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen, onPick={select} showHidden={showHidden} filterPrefix={draftPrefixFor(parent, pathDraft)} + pathEditing={draftPending} /> )} {twoPane && } @@ -494,6 +507,7 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen, onPick={advance} showHidden={showHidden} filterPrefix={draftPrefixFor(child, pathDraft)} + pathEditing={draftPending} /> )}
@@ -523,9 +537,10 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen, aria-pressed={showHidden} disabled={parentInert} // The toggle composes with the path editor (dot-led prefixes and - // this filter interleave): don't steal focus, so toggling never - // blur-cancels a draft mid-thought. - onMouseDown={(event) => { event.preventDefault() }} + // this filter interleave): while editing, don't steal focus, so + // toggling never blur-cancels a draft mid-thought. Outside editing + // it keeps native focus behavior. + onMouseDown={draftPending ? (event) => { event.preventDefault() } : undefined} onClick={() => { setShowHidden(prev => !prev) }} > {showHidden && } diff --git a/packages/host/directory-picker-browse/tests/directory-browser.spec.tsx b/packages/host/directory-picker-browse/tests/directory-browser.spec.tsx index af3aff5385..419572734c 100644 --- a/packages/host/directory-picker-browse/tests/directory-browser.spec.tsx +++ b/packages/host/directory-picker-browse/tests/directory-browser.spec.tsx @@ -29,6 +29,18 @@ function listingFor(path?: string): DirectoryListing { ], truncated: false, }, + [`${HOME}/.config`]: { + path: `${HOME}/.config`, + home: HOME, + crumbs: [ + { name: '/', path: '/', hidden: false }, + { name: 'home', path: '/home', hidden: false }, + { name: 'u', path: HOME, hidden: false }, + { name: '.config', path: `${HOME}/.config`, hidden: true }, + ], + entries: [], + truncated: false, + }, [DOCS]: { path: DOCS, home: HOME, @@ -261,23 +273,58 @@ describe('DirectoryBrowser', () => { expect(within(columns()[1]!).queryAllByRole('listitem')).toHaveLength(0) expect(within(columns()[0]!).getByText('Documents')).toBeTruthy() // Erasing back into the parent's own path moves the filter to the LEFT - // pane and releases the right one. + // pane and releases the right one. The selected row is exempt (it + // anchors the two-pane view), so it alone survives the miss. fireEvent.change(input, { target: { value: `${HOME}/zz` } }) - expect(within(columns()[0]!).queryAllByRole('listitem')).toHaveLength(0) + expect(within(columns()[0]!).getAllByRole('listitem').map(item => item.textContent)).toEqual(['Documents']) expect(within(columns()[1]!).getByText('harness')).toBeTruthy() }) - it('keeps the path editor open when blur comes from window focus loss', async () => { + it('keeps the draft and filter through window focus loss and in-dialog focus moves', async () => { mount() await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() }) fireEvent.click(screen.getByRole('button', { name: 'browser.editPath' })) const input = screen.getByLabelText('browser.editPath') + fireEvent.change(input, { target: { value: `${HOME}/do` } }) // A blur while the document itself lost focus (window switch, dev-tools - // focus) must not discard the draft. + // focus) must not discard the draft: value and filter both survive. const hasFocus = vi.spyOn(document, 'hasFocus').mockReturnValue(false) fireEvent.blur(input) - expect(screen.getByLabelText('browser.editPath', { selector: 'input' })).toBeTruthy() hasFocus.mockRestore() + expect(screen.getByLabelText('browser.editPath', { selector: 'input' }).value).toBe(`${HOME}/do`) + expect(screen.getByRole('listitem').textContent).toBe('Documents') + // A keyboard focus move that stays inside the dialog (Tab onto the + // filtered row) keeps the draft too — the results stay reachable. + fireEvent.blur(input, { relatedTarget: rowButton(screen.getByRole('listitem')) }) + expect(screen.getByLabelText('browser.editPath', { selector: 'input' }).value).toBe(`${HOME}/do`) + // Toggling show-hidden mid-edit suppresses focus steal: the draft and + // its filter survive the toggle in both directions. + const toggle = screen.getByRole('button', { name: 'browser.showHidden' }) + fireEvent.mouseDown(toggle) + fireEvent.click(toggle) + expect(toggle.getAttribute('aria-pressed')).toBe('true') + expect(screen.getByLabelText('browser.editPath', { selector: 'input' }).value).toBe(`${HOME}/do`) + expect(screen.getByRole('listitem').textContent).toBe('Documents') + // Focus landing outside the dialog cancels like Escape. + fireEvent.blur(input, { relatedTarget: document.body }) + expect(screen.queryByLabelText('browser.editPath', { selector: 'input' })).toBeNull() + }) + + it('a picked dot-revealed hidden row stays visible as the selection', async () => { + mount() + await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() }) + fireEvent.click(screen.getByRole('button', { name: 'browser.editPath' })) + const input = screen.getByLabelText('browser.editPath') + fireEvent.change(input, { target: { value: `${HOME}/.co` } }) + const row = rowButton(screen.getByRole('listitem')) + expect(row.textContent).toBe('.config') + fireEvent.mouseDown(row) + fireEvent.click(row) + // The pick cleared the draft (and with it the dot-reveal), but the + // selection is exempt from the hidden filter: the anchor row survives. + expect(screen.queryByLabelText('browser.editPath', { selector: 'input' })).toBeNull() + await waitFor(() => { expect(columns()).toHaveLength(2) }) + expect(within(columns()[0]!).getByText('.config')).toBeTruthy() }) it('picking a filtered row adopts it and closes the path editor', async () => { From 355d505b89a763d65c9593387feeebb59d35a225 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Wed, 29 Jul 2026 15:10:35 +0800 Subject: [PATCH 008/113] =?UTF-8?q?fix(host):=20review=20round=203=20?= =?UTF-8?q?=E2=80=94=20card-scope=20escape/focus-leave=20cancel,=20pick=20?= =?UTF-8?q?refocus;=20record=20display-policy=20trade-offs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...directory-picker-capability-seam.i18n.yaml | 4 +- ...-07-28-directory-picker-capability-seam.md | 6 +- ...-28-directory-picker-capability-seam.zh.md | 6 +- .../directory-picker-browse/README.i18n.yaml | 4 +- .../host/directory-picker-browse/README.md | 2 +- .../host/directory-picker-browse/README.zh.md | 2 +- .../src/client/DirectoryBrowser.module.css | 10 +- .../src/client/DirectoryBrowser.tsx | 351 ++++++++++-------- .../tests/directory-browser.spec.tsx | 35 +- 9 files changed, 244 insertions(+), 176 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.i18n.yaml index 6f21a60e83..dc22b8f807 100644 --- a/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md -2026-07-28-directory-picker-capability-seam.md: 3f5e1436f3af14ce06ffab00ceca90167e16afd0 -2026-07-28-directory-picker-capability-seam.zh.md: 09a3e20f7c12e3c22d63875091593f9a6ca8a1ad +2026-07-28-directory-picker-capability-seam.md: f633cd60b32c10e63814bf06a06773d2dba396f2 +2026-07-28-directory-picker-capability-seam.zh.md: b708e6fffa46cf51fd35154bfc2b947d955e5e9c diff --git a/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md b/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md index 3f5e1436f3..f633cd60b3 100644 --- a/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md +++ b/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md @@ -18,7 +18,8 @@ Placement and policy rulings folded into this decision: - **Not the `ctx.fs` seam.** `packages/fs/` is the model/session-facing storage stack (policy events, sandbox-swappable backends). Riding it would couple GUI browsing to the model's confinement backend — swapping `fs-sandbox` for the model must never change GUI behavior — and OS facts (home anchoring, hidden conventions) are not storage primitives. The picker seam stays presentation-free and model-free; `packages/host/` is its consumer-domain home. - **Dependency survey (hand-roll vs adopt).** Node's stdlib *is* the maintained cross-platform OS layer (`readdir(withFileTypes)`, `homedir`, path semantics); surveyed alternatives fail the dependency bar — file-manager packages (`node-file-manager`, `files-and-folders`, Syncfusion's provider) are whole HTTP apps (fit), drive-letter helpers (`drivelist` native addon, `windows-drive-letters` ~7y stale) fail health/proportionality. The browse backend is a thin adapter over stdlib. -- **Hidden entries: return-and-flag.** The host stamps `hidden` (POSIX dot convention) and returns everything; the client filters. Display policy stays client-side, and the show-hidden toggle shipped as exactly that client-only change (the browse client's footer toggle). Windows' `FILE_ATTRIBUTE_HIDDEN` is not exposed by dirents — documented limitation until a native probe pays for itself. +- **Hidden entries: return-and-flag.** The host stamps `hidden` (POSIX dot convention) and returns everything; the client filters. Display policy stays client-side, and the show-hidden toggle shipped as exactly that client-only change: a fixed-label footer toggle whose state lives in the pressed presentation (`aria-pressed` + check glyph), a dot-led path-draft prefix reveals the hidden entries it names, and the current selection is exempt from both the hidden and the prefix filter (it anchors the two-pane view). Windows' `FILE_ATTRIBUTE_HIDDEN` is not exposed by dirents — documented limitation until a native probe pays for itself. +- **Path-editor cancel scope: the dialog card.** The browse client's path editor cancels on Escape and on focus leaving the card, both observed at a card-scope wrapper rather than the input — after Tab parks focus on a filtered row the input is off the event path, yet Escape must collapse the editor (not the dialog) and a later focus departure must still cancel. Non-cancel exemptions: window/tab focus loss, in-card focus moves, and pointer paths (rows and the toggle suppress focus steal on mousedown while editing). Separators for seeding and draft-tail filtering are inferred from `listing.home`; the wire-field alternative below records the deferred authoritative form. - **Symlinks: follow for enterability.** `stat` probes symlinks (broken/cyclic → skipped); crumbs keep the logical path the operator navigated, and `workspace.create` already canonicalizes via realpath at adoption. - **Listing levels are bounded, and streamed.** One `list` call returns at most `maxEntries` rows (config, default 1000 — GitHub's web-UI directory-listing bound). The level streams via `opendir` into a name-sorted window of `maxEntries + 1` candidates, so memory stays O(maxEntries) and enterability probing touches only windowed candidates; the wire `DirectoryListing` carries a required `truncated` flag so the client states incompleteness instead of silently missing tail entries. A windowed broken symlink is not backfilled from beyond the window — the eviction already marks the level truncated. Window insertion is binary with an O(1) full-window tail rejection (an oversized level must not pay a window scan per dirent), and `list(path, signal)` threads the carrier's request signal so a scan of a stalled network directory cannot outlive a disconnected caller — every await in the scan (open, each read, each symlink probe) races the signal, an aborted exit abandons rather than awaits the close (Node queues close behind in-flight reads), and abandoned settlements are swallowed so cleanup can never surface as an unhandled rejection. An unbounded level is a memory/responsiveness hole for large or adversarial directories. - **Whole-filesystem scope, no roots config.** `workspace.create` accepts arbitrary paths and the API serves bash-driving methods, so a browse root would be UX scoping, not a boundary; configurability without a consumer fails the evidence bar. Deferred until a deployment needs it. @@ -30,6 +31,9 @@ Placement and policy rulings folded into this decision: - **One uniform seam method set (`pick(): path`).** Rejected: an in-app browser cannot be served behind a single host-side call — the browsing loop lives in the client and needs primitives on the wire; the native chooser cannot implement primitives. The interaction difference is irreducible, hence the discriminant. - **Direct stdlib calls inside apiproxy (no seam).** Rejected: keeps the gateway the only swap point (source edits), loses fixture/test backends, and contradicts the plugin doctrine that motivated the work. - **Adopting a file-manager/drive-enumeration dependency.** Rejected per the survey above; recorded here as the dependency policy requires. +- **A flip-label show-hidden toggle ("Hide hidden files").** Rejected: a flipping action label is ambiguous between state and action and doubles the negative; the fixed label with a pressed presentation states both at once. +- **Pure relatedTarget blur cancellation (no mousedown suppression).** Rejected: Safari does not focus buttons on pointer down, so a click's focusout carries a null `relatedTarget` and would cancel the editor before the click lands; editing-scoped mousedown suppression plus the card-anchored relatedTarget guard covers pointer and keyboard paths together. +- **A wire `separator` field on `DirectoryListing` (host stamps `path.sep`).** Deferred, not rejected: it is the authoritative form — a POSIX home directory containing a backslash defeats the `listing.home` heuristic — but it touches the seam type and every backend; the browse client's `separatorOf` carries a TODO pointing at this alternative until a wire change is next scheduled. ## Consequences diff --git a/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.zh.md b/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.zh.md index 09a3e20f7c..b708e6fffa 100644 --- a/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.zh.md @@ -18,7 +18,8 @@ web GUI 的"打开本地文件夹"流程被焊死在一种交互上:`host.pick - **不用 `ctx.fs` seam。** `packages/fs/` 是面向模型/会话的存储栈(policy 事件、sandbox 可换后端)。骑上去会把 GUI 浏览耦合进模型的限制后端——为模型换 `fs-sandbox` 绝不能改变 GUI 行为——而 OS 事实(home 锚定、隐藏约定)也不是存储原语。picker seam 保持无展示、无模型;`packages/host/` 是它消费方域的家。 - **依赖调研(手写 vs 引入)。** Node 标准库本身就是维护中的跨平台 OS 层(`readdir(withFileTypes)`、`homedir`、路径语义);调研过的替代品都过不了依赖门槛——文件管理器包(`node-file-manager`、`files-and-folders`、Syncfusion 的 provider)是整套 HTTP 应用(契合度不过),盘符工具(原生插件 `drivelist`、约七年未更的 `windows-drive-letters`)健康度/比例失当。browse 后端是标准库上的薄适配。 -- **隐藏条目:返回并打标。** 宿主标注 `hidden`(POSIX 点前缀约定)并返回全部条目;客户端过滤。展示策略留在客户端,"显示隐藏"开关正是作为这一纯客户端改动落地(browse 客户端的 footer 开关)。Windows 的 `FILE_ATTRIBUTE_HIDDEN` 不被 dirent 暴露——记为限制,直到原生探测值回其成本。 +- **隐藏条目:返回并打标。** 宿主标注 `hidden`(POSIX 点前缀约定)并返回全部条目;客户端过滤。展示策略留在客户端,"显示隐藏"开关正是作为这一纯客户端改动落地:标签固定的 footer 开关,其状态由按下态呈现承载(`aria-pressed` + 勾选符号);以点开头的路径草稿前缀会显出它所指名的隐藏条目;当前选中项则不受隐藏与前缀两种过滤影响(它锚定着双栏视图)。Windows 的 `FILE_ATTRIBUTE_HIDDEN` 不被 dirent 暴露——记为限制,直到原生探测值回其成本。 +- **路径编辑器的取消范围:对话框卡片。** browse 客户端的路径编辑器在按 Escape 与焦点离开卡片时取消,两者都在卡片范围的包装层而非输入框上监听——Tab 把焦点停到某个过滤命中的行之后,输入框已不在事件路径上,但 Escape 仍须收起编辑器(而非对话框),其后的焦点离开也仍须取消。不取消的豁免:窗口/标签页失焦、卡片内焦点移动,以及指针路径(编辑期间行与开关在 mousedown 时抑制焦点夺取)。预填与草稿末段过滤所用的分隔符从 `listing.home` 推断;下文的线上字段替代方案记录了被延期的权威形态。 - **符号链接:为可进入性而跟随。** 用 `stat` 探测符号链接(断链/循环→跳过);面包屑保留操作者导航的逻辑路径,`workspace.create` 在接纳时本就做 realpath 规范化。 - **列举层级有上限,且流式处理。** 单次 `list` 至多返回 `maxEntries` 行(配置项,默认 1000——GitHub 网页端目录列举的同一上限)。层级经 `opendir` 流入一个按名排序、容量 `maxEntries + 1` 的候选窗口,内存保持 O(maxEntries),可进入性探测只触及窗口内候选;线上 `DirectoryListing` 携带必填的 `truncated` 标志,让客户端明示不完整而不是静默缺尾。窗口内的断链符号链接不从窗口外回填——发生过驱逐本身已把层级标记为截断。窗口插入为二分查找、满窗尾部单次比较即拒绝(超大层级不能为每个 dirent 付出一次全窗扫描),且 `list(path, signal)` 透传载体的请求信号,滞塞网络目录的扫描不会在调用方断连后继续存活——扫描中的每个 await(打开、每次读取、每次符号链接探测)都与信号赛跑,中止路径放弃而非等待 close(Node 会把 close 排在在飞读取之后),被放弃的 settlement 全部吞掉,清理不会以未处理拒绝的形式冒出。无上限的层级对超大或恶意构造的目录就是内存/响应性漏洞。 - **全盘可浏览,不做 roots 配置。** `workspace.create` 接受任意路径且 API 本就提供驱动 bash 的方法,浏览根只会是 UX 范围而非边界;没有消费方的可配置性过不了证据门槛。等到有部署需要再做。 @@ -30,6 +31,9 @@ web GUI 的"打开本地文件夹"流程被焊死在一种交互上:`host.pick - **统一方法集的 seam(`pick(): path`)。** 否决:应用内浏览器无法藏在一次宿主侧调用后面——浏览循环在客户端,需要协议上的原语;而对话框实现不了原语。交互差异不可约,故用判别标签。 - **apiproxy 里直接调标准库(不建 seam)。** 否决:换装点仍是改网关源码,失去 fixture/测试后端,与促成这项工作的插件教义相悖。 - **引入文件管理器/盘符枚举依赖。** 按上文调研否决;依赖政策要求记录于此。 +- **动作标签随状态翻转的"显示隐藏"开关("隐藏隐藏文件")。** 否决:会翻转的动作标签在状态与动作之间有歧义,还把否定叠了两层;固定标签加按下态呈现一次说清两者。 +- **纯 relatedTarget 失焦取消(不做 mousedown 抑制)。** 否决:Safari 在指针按下时不给按钮聚焦,点击触发的 focusout 因而携带空 `relatedTarget`,会在点击落地前就取消编辑器;编辑期作用的 mousedown 抑制加上锚定卡片的 relatedTarget 守卫才能同时覆盖指针与键盘路径。 +- **在 `DirectoryListing` 上增设线上 `separator` 字段(宿主标注 `path.sep`)。** 延期而非否决:它才是权威形态——含反斜杠的 POSIX 家目录会击穿 `listing.home` 启发式——但它触及 seam 类型与每个后端;browse 客户端的 `separatorOf` 挂着指向本方案的 TODO,直到下次安排线上变更。 ## 后果 diff --git a/packages/host/directory-picker-browse/README.i18n.yaml b/packages/host/directory-picker-browse/README.i18n.yaml index eea9966b83..e59cd22b1e 100644 --- a/packages/host/directory-picker-browse/README.i18n.yaml +++ b/packages/host/directory-picker-browse/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/host/directory-picker-browse/README.md -README.md: 95d2d66406210f4ba687ef5a45a38b142abd6f26 -README.zh.md: 9da374bec80f80085ff36871c227c496bb1fde7b +README.md: 7813e0e9c589d1f37471b08ebafcf08878cbf768 +README.zh.md: 46538ed6f1cf23357cc4f8e289a8117d0e5e017d diff --git a/packages/host/directory-picker-browse/README.md b/packages/host/directory-picker-browse/README.md index 95d2d66406..7813e0e9c5 100644 --- a/packages/host/directory-picker-browse/README.md +++ b/packages/host/directory-picker-browse/README.md @@ -6,7 +6,7 @@ The **in-app browsing backend** of the [directory-picker seam](../directory-pick Behavior facts: listings return **directories only**, name-sorted, with symlinks-to-directories followed (broken/cyclic links skipped — the probe `stat` failing means "not enterable") and a host-owned `hidden` flag (POSIX dot convention) left for the client to act on; `crumbs` is the root-to-target ancestor chain, the root crumb labeled by its full path (`/`, `C:\`); an absent `list` path means the host account's home directory. `createDirectory` is non-recursive (a missing parent is a real failure, not a level to invent) and validates the name as a single non-blank segment even when called directly, mirroring the wire schema's fence. Both primitives reject an explicit path that is not fully qualified — relative forms, and on Windows the rooted drive-less forms (`\foo`, `/foo`) and incomplete UNC prefixes (`\\`, `\\server`) that `isAbsolute` accepts — with `directory-unreadable`/`directory-create-failed`, instead of letting `resolve` rebase it under the host process cwd or current drive. One `list` call returns at most `maxEntries` rows (config, default 1000 — the bound GitHub's web UI applies to directory listings), and the level streams through a bounded window so memory stays O(maxEntries) no matter how many children the directory holds: a cut level keeps the name-sorted head, counts hidden rows against the bound, probes only windowed candidates, and reports `truncated: true` so the client can say the level is incomplete (a windowed broken symlink is not backfilled from beyond the window — the eviction already marks the level truncated); window insertion is binary with an O(1) full-window tail rejection, and `list` threads the caller's `AbortSignal` so a disconnect or timeout stops the scan instead of letting it outlive the caller. Failures throw the seam's typed `DirectoryPickerError`. Policy rationale: [the directory-picker capability seam Agent Note](../../../.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md). -**Dual-face package**: the browser half (`./client`) fills [ui-workspace's](../../client/ui-workspace/README.md) two directory-flow holes with the in-app **Select Workspace Directory** dialog (figma `Harness` 813-23126 family — Miller two-column view; breadcrumb with a click-to-edit path zone whose editor seeds a trailing separator, prefix-filters the listed level from the draft's final segment while typing (case-insensitively, over the listed — possibly truncated — rows only; Enter still navigates by the exact text), and cancels on Escape or focus loss; a fixed-label show-hidden footer toggle over the host's `hidden` flags, with a dot-led typed prefix revealing its matches; nested New-folder dialog), driving `host.listDirectory`/`host.createDirectory` and registering its own locale namespace (`directory-browser`, zh default / en). One cordis.yml row therefore composes both sides of the browse interaction; the client carries no capability-kind branching, and mounting a second flow package fails at load (the holes are `single` kind). +**Dual-face package**: the browser half (`./client`) fills [ui-workspace's](../../client/ui-workspace/README.md) two directory-flow holes with the in-app **Select Workspace Directory** dialog (figma `Harness` 813-23126 family — Miller two-column view; breadcrumb with a click-to-edit path zone whose editor seeds a trailing separator, prefix-filters the listed level from the draft's final segment while typing (case-insensitively, over the listed — possibly truncated — rows only; Enter still navigates by the exact text), and cancels on Escape or when focus leaves the dialog card (window/tab switches and in-card focus moves keep the draft); a fixed-label show-hidden footer toggle over the host's `hidden` flags, with a dot-led typed prefix revealing its matches and the current selection exempt from both filters; nested New-folder dialog), driving `host.listDirectory`/`host.createDirectory` and registering its own locale namespace (`directory-browser`, zh default / en). One cordis.yml row therefore composes both sides of the browse interaction; the client carries no capability-kind branching, and mounting a second flow package fails at load (the holes are `single` kind). ## Model Experience diff --git a/packages/host/directory-picker-browse/README.zh.md b/packages/host/directory-picker-browse/README.zh.md index 9da374bec8..46538ed6f1 100644 --- a/packages/host/directory-picker-browse/README.zh.md +++ b/packages/host/directory-picker-browse/README.zh.md @@ -6,7 +6,7 @@ 行为事实:列举**只返回目录**、按名称排序,指向目录的符号链接会被跟随(断链/循环链接被跳过——探测 `stat` 失败即"不可进入"),并携带宿主判定的 `hidden` 标志(POSIX 点前缀约定),展示决策留给客户端;`crumbs` 是从根到目标的祖先链,根 crumb 以完整路径标注(`/`、`C:\`);`list` 不带路径即列举宿主账户的家目录。`createDirectory` 不递归(父目录缺失是真实失败,不是要补造的层级),且即便被直接调用也把名称校验为单个非空段,与协议 schema 的栅栏一致。两个原语都拒绝非完全限定的显式路径——相对形态,以及 Windows 上 `isAbsolute` 会放行的无盘符有根形态(`\foo`、`/foo`)与不完整的 UNC 前缀(`\\`、`\\server`)——报 `directory-unreadable`/`directory-create-failed`,而不是任由 `resolve` 把它重定位到宿主进程 cwd 或当前盘符之下。单次 `list` 至多返回 `maxEntries` 行(配置项,默认 1000——GitHub 网页端对目录列举采用的同一上限),且层级以流式方式经过一个有界窗口,无论目录有多少子项内存都保持 O(maxEntries):被截断的层级保留按名排序的头部、隐藏行计入上限、只探测窗口内候选,并报告 `truncated: true`,供客户端提示层级不完整(窗口内的断链符号链接不会从窗口外回填——发生过驱逐本身已把层级标记为截断);窗口插入为二分查找、满窗尾部单次比较即拒绝,且 `list` 透传调用方的 `AbortSignal`,断连或超时会停止扫描而不是让它在调用方离开后继续。失败抛出 seam 的类型化 `DirectoryPickerError`。策略依据:[目录选择能力 seam Agent Note](../../../.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md)。 -**双面包**:browser half(`./client`)以应用内 **选择工作区目录** 对话框(figma `Harness` 813-23126 家族——Miller 双列视图;带点击即编辑路径区的面包屑,其编辑器预填尾随分隔符、输入时以草稿末段对所列层级做前缀过滤(不区分大小写,且仅作用于已列出、可能被截断的行;Enter 仍按确切文本导航)、按 Escape 或失焦即取消;基于宿主 `hidden` 标志、标签固定的"显示隐藏"footer 开关,键入以点开头的前缀会显出其匹配项;嵌套新建文件夹对话框)填入 [ui-workspace](../../client/ui-workspace/README.md) 的两个目录流洞,驱动 `host.listDirectory`/`host.createDirectory`,并注册自己的 locale 命名空间(`directory-browser`,zh 默认/en)。因此一行 cordis.yml 同时组合浏览交互的两侧;client 侧不含任何能力 kind 分支,挂载第二个流程包会在加载期失败(洞为 `single` kind)。 +**双面包**:browser half(`./client`)以应用内 **选择工作区目录** 对话框(figma `Harness` 813-23126 家族——Miller 双列视图;带点击即编辑路径区的面包屑,其编辑器预填尾随分隔符、输入时以草稿末段对所列层级做前缀过滤(不区分大小写,且仅作用于已列出、可能被截断的行;Enter 仍按确切文本导航)、按 Escape 或焦点离开对话框卡片即取消(窗口/标签页切换与卡片内焦点移动保留草稿);基于宿主 `hidden` 标志、标签固定的"显示隐藏"footer 开关,键入以点开头的前缀会显出其匹配项,且当前选中项不受这两种过滤影响;嵌套新建文件夹对话框)填入 [ui-workspace](../../client/ui-workspace/README.md) 的两个目录流洞,驱动 `host.listDirectory`/`host.createDirectory`,并注册自己的 locale 命名空间(`directory-browser`,zh 默认/en)。因此一行 cordis.yml 同时组合浏览交互的两侧;client 侧不含任何能力 kind 分支,挂载第二个流程包会在加载期失败(洞为 `single` kind)。 ## 模型体验 diff --git a/packages/host/directory-picker-browse/src/client/DirectoryBrowser.module.css b/packages/host/directory-picker-browse/src/client/DirectoryBrowser.module.css index d4fa986371..e6f1daba98 100644 --- a/packages/host/directory-picker-browse/src/client/DirectoryBrowser.module.css +++ b/packages/host/directory-picker-browse/src/client/DirectoryBrowser.module.css @@ -155,7 +155,8 @@ flex: 1 1 0; min-width: 256px; overflow-y: auto; - /* The overlay scrollbar paints at the column's edge; keep the row pills + /* The themed scrollbar occupies the column's edge (styled scrollbars are + * classic, gutter-taking ones); the extra clearance keeps the row pills * clear of the thumb. */ padding-right: 8px; } @@ -283,6 +284,13 @@ color: var(--dsw-alias-label-primary); } +/* Card-scope wrapper hosting the path editor's Escape and focus-leave + * observers; display:contents keeps header/content/footer as direct flex + * children of the Modal card. */ +.editorScope { + display: contents; +} + .footerGap { flex: 1 1 0; } diff --git a/packages/host/directory-picker-browse/src/client/DirectoryBrowser.tsx b/packages/host/directory-picker-browse/src/client/DirectoryBrowser.tsx index 9c3dc66fbb..37c3d33982 100644 --- a/packages/host/directory-picker-browse/src/client/DirectoryBrowser.tsx +++ b/packages/host/directory-picker-browse/src/client/DirectoryBrowser.tsx @@ -70,7 +70,8 @@ function displayCrumbs(listing: DirectoryListing, homeLabel: string): DirectoryE * legal POSIX name character. Still a heuristic at the last step: a POSIX * home directory whose own name contains a backslash would misread. * TODO: replace with a host-stamped `separator` field on the wire - * DirectoryListing so the platform fact travels verbatim. + * DirectoryListing so the platform fact travels verbatim (the trade-off is + * recorded in the directory-picker capability seam Agent Note). */ function separatorOf(listing: DirectoryListing): '\\' | '/' { return listing.home.includes('\\') ? '\\' : '/' @@ -131,7 +132,13 @@ function LevelColumn({ entries, selectedPath, busy, onPick, showHidden, filterPr // where the blur lands before our guards) drop this click. // Outside editing, rows keep native focus behavior. onMouseDown={pathEditing ? (event) => { event.preventDefault() } : undefined} - onClick={() => { onPick(entry) }} + onClick={(event) => { + // A pick during editing is about to unmount the focused + // input; park focus on the picked row so keyboard traversal + // stays inside the dialog (the Modal has no focus trap). + if (pathEditing) event.currentTarget.focus() + onPick(entry) + }} > {selected ? @@ -386,177 +393,197 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen, className={clsx(css.dialog)} headless > -
-

{t('browser.title')}

-
- {pathDraft === null - ? ( - <> - - {crumbs.map((crumb, index) => ( - - {index > 0 && } - - - ))} - - {/* The empty zone right of the crumbs is the path-edit affordance. */} - + + ))} + + {/* The empty zone right of the crumbs is the path-edit affordance. */} +
+
+
+
+ {parent !== null && ( + )} -
-
-
-
- {parent !== null && ( - - )} - {twoPane && } - {twoPane && child !== null && ( - - )} -
- {loading &&
{t('browser.loading')}
} - {/* The backend bounds a level at its complete-result limit; say so + {twoPane && } + {twoPane && child !== null && ( + + )} +
+ {loading &&
{t('browser.loading')}
} + {/* The backend bounds a level at its complete-result limit; say so * whenever a visible pane was cut instead of letting the tail of a * huge directory go silently missing. */} - {(parent?.truncated === true || child?.truncated === true) && !loading + {(parent?.truncated === true || child?.truncated === true) && !loading &&
{t('browser.truncated')}
} - {error !== null &&
{error}
} - -
- - - - - + {error !== null &&
{error}
} +
+
+ + + + + +
{/* Nested create dialog (figma 813:23278): names one folder inside the target. */} { // A blur while the document itself lost focus (window switch, dev-tools // focus) must not discard the draft: value and filter both survive. const hasFocus = vi.spyOn(document, 'hasFocus').mockReturnValue(false) - fireEvent.blur(input) + fireEvent.focusOut(input) hasFocus.mockRestore() expect(screen.getByLabelText('browser.editPath', { selector: 'input' }).value).toBe(`${HOME}/do`) expect(screen.getByRole('listitem').textContent).toBe('Documents') // A keyboard focus move that stays inside the dialog (Tab onto the // filtered row) keeps the draft too — the results stay reachable. - fireEvent.blur(input, { relatedTarget: rowButton(screen.getByRole('listitem')) }) + fireEvent.focusOut(input, { relatedTarget: rowButton(screen.getByRole('listitem')) }) expect(screen.getByLabelText('browser.editPath', { selector: 'input' }).value).toBe(`${HOME}/do`) // Toggling show-hidden mid-edit suppresses focus steal: the draft and // its filter survive the toggle in both directions. @@ -305,9 +305,31 @@ describe('DirectoryBrowser', () => { expect(toggle.getAttribute('aria-pressed')).toBe('true') expect(screen.getByLabelText('browser.editPath', { selector: 'input' }).value).toBe(`${HOME}/do`) expect(screen.getByRole('listitem').textContent).toBe('Documents') - // Focus landing outside the dialog cancels like Escape. - fireEvent.blur(input, { relatedTarget: document.body }) + // Focus landing outside the dialog cancels like Escape — even when the + // departure happens from a row the user had Tabbed onto, not the input + // (the observer lives on the card scope, not the input). + fireEvent.focusOut(rowButton(screen.getByRole('listitem')), { relatedTarget: document.body }) expect(screen.queryByLabelText('browser.editPath', { selector: 'input' })).toBeNull() + // Outside editing the card-scope observer is inert. + fireEvent.focusOut(screen.getByRole('button', { name: 'browser.showHidden' })) + expect(screen.getByRole('button', { name: 'browser.editPath' })).toBeTruthy() + }) + + it('Escape with focus on a filtered row collapses the editor, not the dialog', async () => { + const b = mount() + await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() }) + fireEvent.click(screen.getByRole('button', { name: 'browser.editPath' })) + const input = screen.getByLabelText('browser.editPath') + fireEvent.change(input, { target: { value: `${HOME}/do` } }) + // Tab parked focus on the result row; Escape must still mean "leave + // path editing", not "close the whole dialog". + const row = rowButton(screen.getByRole('listitem')) + fireEvent.keyDown(row, { key: 'Escape' }) + expect(screen.queryByLabelText('browser.editPath', { selector: 'input' })).toBeNull() + expect(b.onClose).not.toHaveBeenCalled() + // With no draft left, Escape falls through to the Modal and closes. + fireEvent.keyDown(row, { key: 'Escape' }) + expect(b.onClose).toHaveBeenCalledTimes(1) }) it('a picked dot-revealed hidden row stays visible as the selection', async () => { @@ -340,6 +362,9 @@ describe('DirectoryBrowser', () => { fireEvent.mouseDown(row) fireEvent.click(row) expect(screen.queryByLabelText('browser.editPath', { selector: 'input' })).toBeNull() + // Focus parks on the picked row (the editor's input just unmounted and + // the Modal has no focus trap to catch a fall to body). + expect(document.activeElement).toBe(row) await waitFor(() => { expect(columns()).toHaveLength(2) }) expect(screen.getByRole('button', { name: 'browser.home' })).toBeTruthy() }) @@ -373,7 +398,7 @@ describe('DirectoryBrowser', () => { const input = screen.getByLabelText('browser.editPath') fireEvent.change(input, { target: { value: '/somewhere/else' } }) // Focus moving anywhere outside the editor abandons the draft like Escape. - fireEvent.blur(input) + fireEvent.focusOut(input) expect(screen.queryByLabelText('browser.editPath', { selector: 'input' })).toBeNull() // The crumb view is back and the abandoned draft was never navigated to. expect(screen.getByRole('button', { name: 'browser.editPath' })).toBeTruthy() From 9c9c32ed59de8ee0762dcd2b04334a8e366538f6 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Wed, 29 Jul 2026 15:26:00 +0800 Subject: [PATCH 009/113] =?UTF-8?q?fix(host):=20review=20round=204=20?= =?UTF-8?q?=E2=80=94=20post-commit=20refocus=20covers=20right-pane=20picks?= =?UTF-8?q?;=20combobox=20semantics=20recorded=20as=20deferred?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...directory-picker-capability-seam.i18n.yaml | 4 +-- ...-07-28-directory-picker-capability-seam.md | 2 +- ...-28-directory-picker-capability-seam.zh.md | 2 +- .../src/client/DirectoryBrowser.module.css | 14 ++++---- .../src/client/DirectoryBrowser.tsx | 35 ++++++++++++++----- .../tests/directory-browser.spec.tsx | 19 ++++++++++ 6 files changed, 56 insertions(+), 20 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.i18n.yaml index dc22b8f807..e70ac895a0 100644 --- a/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md -2026-07-28-directory-picker-capability-seam.md: f633cd60b32c10e63814bf06a06773d2dba396f2 -2026-07-28-directory-picker-capability-seam.zh.md: b708e6fffa46cf51fd35154bfc2b947d955e5e9c +2026-07-28-directory-picker-capability-seam.md: 550d51cf2e4e5b70b0844401d42bfea2342612e7 +2026-07-28-directory-picker-capability-seam.zh.md: afaf277e866349b20d370fe1fd638b0c71e1a3e7 diff --git a/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md b/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md index f633cd60b3..550d51cf2e 100644 --- a/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md +++ b/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md @@ -19,7 +19,7 @@ Placement and policy rulings folded into this decision: - **Not the `ctx.fs` seam.** `packages/fs/` is the model/session-facing storage stack (policy events, sandbox-swappable backends). Riding it would couple GUI browsing to the model's confinement backend — swapping `fs-sandbox` for the model must never change GUI behavior — and OS facts (home anchoring, hidden conventions) are not storage primitives. The picker seam stays presentation-free and model-free; `packages/host/` is its consumer-domain home. - **Dependency survey (hand-roll vs adopt).** Node's stdlib *is* the maintained cross-platform OS layer (`readdir(withFileTypes)`, `homedir`, path semantics); surveyed alternatives fail the dependency bar — file-manager packages (`node-file-manager`, `files-and-folders`, Syncfusion's provider) are whole HTTP apps (fit), drive-letter helpers (`drivelist` native addon, `windows-drive-letters` ~7y stale) fail health/proportionality. The browse backend is a thin adapter over stdlib. - **Hidden entries: return-and-flag.** The host stamps `hidden` (POSIX dot convention) and returns everything; the client filters. Display policy stays client-side, and the show-hidden toggle shipped as exactly that client-only change: a fixed-label footer toggle whose state lives in the pressed presentation (`aria-pressed` + check glyph), a dot-led path-draft prefix reveals the hidden entries it names, and the current selection is exempt from both the hidden and the prefix filter (it anchors the two-pane view). Windows' `FILE_ATTRIBUTE_HIDDEN` is not exposed by dirents — documented limitation until a native probe pays for itself. -- **Path-editor cancel scope: the dialog card.** The browse client's path editor cancels on Escape and on focus leaving the card, both observed at a card-scope wrapper rather than the input — after Tab parks focus on a filtered row the input is off the event path, yet Escape must collapse the editor (not the dialog) and a later focus departure must still cancel. Non-cancel exemptions: window/tab focus loss, in-card focus moves, and pointer paths (rows and the toggle suppress focus steal on mousedown while editing). Separators for seeding and draft-tail filtering are inferred from `listing.home`; the wire-field alternative below records the deferred authoritative form. +- **Path-editor cancel scope: the dialog card.** The browse client's path editor cancels on Escape and on focus leaving the card, both observed at a card-scope wrapper rather than the input — after Tab parks focus on a filtered row the input is off the event path, yet Escape must collapse the editor (not the dialog) and a later focus departure must still cancel. Non-cancel exemptions: window/tab focus loss, in-card focus moves, and pointer paths (rows and the toggle suppress focus steal on mousedown while editing). Separators for seeding and draft-tail filtering are inferred from `listing.home`; the wire-field alternative below records the deferred authoritative form. Combobox semantics between the editor and the list it filters (`aria-expanded`/`aria-controls`/active-descendant, result announcements) are likewise deferred — today they read to assistive tech as separate widgets. - **Symlinks: follow for enterability.** `stat` probes symlinks (broken/cyclic → skipped); crumbs keep the logical path the operator navigated, and `workspace.create` already canonicalizes via realpath at adoption. - **Listing levels are bounded, and streamed.** One `list` call returns at most `maxEntries` rows (config, default 1000 — GitHub's web-UI directory-listing bound). The level streams via `opendir` into a name-sorted window of `maxEntries + 1` candidates, so memory stays O(maxEntries) and enterability probing touches only windowed candidates; the wire `DirectoryListing` carries a required `truncated` flag so the client states incompleteness instead of silently missing tail entries. A windowed broken symlink is not backfilled from beyond the window — the eviction already marks the level truncated. Window insertion is binary with an O(1) full-window tail rejection (an oversized level must not pay a window scan per dirent), and `list(path, signal)` threads the carrier's request signal so a scan of a stalled network directory cannot outlive a disconnected caller — every await in the scan (open, each read, each symlink probe) races the signal, an aborted exit abandons rather than awaits the close (Node queues close behind in-flight reads), and abandoned settlements are swallowed so cleanup can never surface as an unhandled rejection. An unbounded level is a memory/responsiveness hole for large or adversarial directories. - **Whole-filesystem scope, no roots config.** `workspace.create` accepts arbitrary paths and the API serves bash-driving methods, so a browse root would be UX scoping, not a boundary; configurability without a consumer fails the evidence bar. Deferred until a deployment needs it. diff --git a/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.zh.md b/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.zh.md index b708e6fffa..afaf277e86 100644 --- a/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.zh.md @@ -19,7 +19,7 @@ web GUI 的"打开本地文件夹"流程被焊死在一种交互上:`host.pick - **不用 `ctx.fs` seam。** `packages/fs/` 是面向模型/会话的存储栈(policy 事件、sandbox 可换后端)。骑上去会把 GUI 浏览耦合进模型的限制后端——为模型换 `fs-sandbox` 绝不能改变 GUI 行为——而 OS 事实(home 锚定、隐藏约定)也不是存储原语。picker seam 保持无展示、无模型;`packages/host/` 是它消费方域的家。 - **依赖调研(手写 vs 引入)。** Node 标准库本身就是维护中的跨平台 OS 层(`readdir(withFileTypes)`、`homedir`、路径语义);调研过的替代品都过不了依赖门槛——文件管理器包(`node-file-manager`、`files-and-folders`、Syncfusion 的 provider)是整套 HTTP 应用(契合度不过),盘符工具(原生插件 `drivelist`、约七年未更的 `windows-drive-letters`)健康度/比例失当。browse 后端是标准库上的薄适配。 - **隐藏条目:返回并打标。** 宿主标注 `hidden`(POSIX 点前缀约定)并返回全部条目;客户端过滤。展示策略留在客户端,"显示隐藏"开关正是作为这一纯客户端改动落地:标签固定的 footer 开关,其状态由按下态呈现承载(`aria-pressed` + 勾选符号);以点开头的路径草稿前缀会显出它所指名的隐藏条目;当前选中项则不受隐藏与前缀两种过滤影响(它锚定着双栏视图)。Windows 的 `FILE_ATTRIBUTE_HIDDEN` 不被 dirent 暴露——记为限制,直到原生探测值回其成本。 -- **路径编辑器的取消范围:对话框卡片。** browse 客户端的路径编辑器在按 Escape 与焦点离开卡片时取消,两者都在卡片范围的包装层而非输入框上监听——Tab 把焦点停到某个过滤命中的行之后,输入框已不在事件路径上,但 Escape 仍须收起编辑器(而非对话框),其后的焦点离开也仍须取消。不取消的豁免:窗口/标签页失焦、卡片内焦点移动,以及指针路径(编辑期间行与开关在 mousedown 时抑制焦点夺取)。预填与草稿末段过滤所用的分隔符从 `listing.home` 推断;下文的线上字段替代方案记录了被延期的权威形态。 +- **路径编辑器的取消范围:对话框卡片。** browse 客户端的路径编辑器在按 Escape 与焦点离开卡片时取消,两者都在卡片范围的包装层而非输入框上监听——Tab 把焦点停到某个过滤命中的行之后,输入框已不在事件路径上,但 Escape 仍须收起编辑器(而非对话框),其后的焦点离开也仍须取消。不取消的豁免:窗口/标签页失焦、卡片内焦点移动,以及指针路径(编辑期间行与开关在 mousedown 时抑制焦点夺取)。预填与草稿末段过滤所用的分隔符从 `listing.home` 推断;下文的线上字段替代方案记录了被延期的权威形态。编辑器与其过滤的列表之间的 combobox 语义(`aria-expanded`/`aria-controls`/active-descendant、结果播报)同样被延期——目前二者在辅助技术看来是彼此独立的控件。 - **符号链接:为可进入性而跟随。** 用 `stat` 探测符号链接(断链/循环→跳过);面包屑保留操作者导航的逻辑路径,`workspace.create` 在接纳时本就做 realpath 规范化。 - **列举层级有上限,且流式处理。** 单次 `list` 至多返回 `maxEntries` 行(配置项,默认 1000——GitHub 网页端目录列举的同一上限)。层级经 `opendir` 流入一个按名排序、容量 `maxEntries + 1` 的候选窗口,内存保持 O(maxEntries),可进入性探测只触及窗口内候选;线上 `DirectoryListing` 携带必填的 `truncated` 标志,让客户端明示不完整而不是静默缺尾。窗口内的断链符号链接不从窗口外回填——发生过驱逐本身已把层级标记为截断。窗口插入为二分查找、满窗尾部单次比较即拒绝(超大层级不能为每个 dirent 付出一次全窗扫描),且 `list(path, signal)` 透传载体的请求信号,滞塞网络目录的扫描不会在调用方断连后继续存活——扫描中的每个 await(打开、每次读取、每次符号链接探测)都与信号赛跑,中止路径放弃而非等待 close(Node 会把 close 排在在飞读取之后),被放弃的 settlement 全部吞掉,清理不会以未处理拒绝的形式冒出。无上限的层级对超大或恶意构造的目录就是内存/响应性漏洞。 - **全盘可浏览,不做 roots 配置。** `workspace.create` 接受任意路径且 API 本就提供驱动 bash 的方法,浏览根只会是 UX 范围而非边界;没有消费方的可配置性过不了证据门槛。等到有部署需要再做。 diff --git a/packages/host/directory-picker-browse/src/client/DirectoryBrowser.module.css b/packages/host/directory-picker-browse/src/client/DirectoryBrowser.module.css index e6f1daba98..2c09dd2940 100644 --- a/packages/host/directory-picker-browse/src/client/DirectoryBrowser.module.css +++ b/packages/host/directory-picker-browse/src/client/DirectoryBrowser.module.css @@ -15,6 +15,13 @@ gap: 0; } +/* Card-scope wrapper hosting the path editor's Escape and focus-leave + * observers; display:contents keeps header/content/footer as direct flex + * children of the Modal card. */ +.editorScope { + display: contents; +} + /* Header block: pl24 pr14 pt16 pb8, 8px between title row and crumb row. */ .header { display: flex; @@ -284,13 +291,6 @@ color: var(--dsw-alias-label-primary); } -/* Card-scope wrapper hosting the path editor's Escape and focus-leave - * observers; display:contents keeps header/content/footer as direct flex - * children of the Modal card. */ -.editorScope { - display: contents; -} - .footerGap { flex: 1 1 0; } diff --git a/packages/host/directory-picker-browse/src/client/DirectoryBrowser.tsx b/packages/host/directory-picker-browse/src/client/DirectoryBrowser.tsx index 37c3d33982..bb0c4c5d7c 100644 --- a/packages/host/directory-picker-browse/src/client/DirectoryBrowser.tsx +++ b/packages/host/directory-picker-browse/src/client/DirectoryBrowser.tsx @@ -132,13 +132,11 @@ function LevelColumn({ entries, selectedPath, busy, onPick, showHidden, filterPr // where the blur lands before our guards) drop this click. // Outside editing, rows keep native focus behavior. onMouseDown={pathEditing ? (event) => { event.preventDefault() } : undefined} - onClick={(event) => { - // A pick during editing is about to unmount the focused - // input; park focus on the picked row so keyboard traversal - // stays inside the dialog (the Modal has no focus trap). - if (pathEditing) event.currentTarget.focus() - onPick(entry) - }} + // Editing-time focus parking happens after commit (the + // DirectoryBrowser refocus effect): a right-pane pick replaces + // this very column, so focusing the clicked node here would + // still fall to body. + onClick={() => { onPick(entry) }} > {selected ? @@ -235,11 +233,18 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen, }) }, [launchListing]) + // An editing-time pick parks focus on the selection after commit; the + // flag is set by select() and consumed by the refocus effect below the + // miller-row ref. + const refocusPick = useRef(false) + /** Select a row of the listed level and preview its children on the right. */ const select = useCallback((entry: DirectoryEntry) => { const { seq, scan } = launchListing(entry.path) // A pick while the path editor is open adopts the (filtered) row and - // closes the editor — the draft served its purpose. + // closes the editor — the draft served its purpose. Focus re-parks on + // the selection after commit (see the refocus effect below). + if (pathDraft !== null) refocusPick.current = true setPathDraft(null) setSelected(entry) setChild(null) @@ -257,7 +262,7 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen, // breadcrumb still names the level: fall back to the single pane. setSelected(null) }) - }, [launchListing]) + }, [launchListing, pathDraft]) /** Abandon path editing (Escape or clicking away) and restore the crumb view. */ const cancelPathEdit = useCallback(() => { @@ -368,6 +373,18 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen, const row = millerRowRef.current if (row !== null && childPath !== undefined) row.scrollLeft = row.scrollWidth }, [childPath]) + // An editing-time pick unmounts the focused input, and a right-pane pick + // additionally replaces the picked button's whole column (advance swaps + // both panes): park focus on the selection's row — aria-current in the + // freshly rendered left pane — after commit, so keyboard traversal stays + // inside the dialog (the Modal has no focus trap). + useEffect(() => { + if (!refocusPick.current) return + refocusPick.current = false + /* v8 ignore next 2 -- narrowing guard: the pick that set the flag just rendered its aria-current row inside the miller row. */ + const row = millerRowRef.current?.querySelector('button[aria-current="true"]') + row?.focus() + }) if (!open) return null const twoPane = selected !== null diff --git a/packages/host/directory-picker-browse/tests/directory-browser.spec.tsx b/packages/host/directory-picker-browse/tests/directory-browser.spec.tsx index 550146df41..217ffa481e 100644 --- a/packages/host/directory-picker-browse/tests/directory-browser.spec.tsx +++ b/packages/host/directory-picker-browse/tests/directory-browser.spec.tsx @@ -369,6 +369,25 @@ describe('DirectoryBrowser', () => { expect(screen.getByRole('button', { name: 'browser.home' })).toBeTruthy() }) + it('a right-pane pick while editing parks focus on the advanced selection', async () => { + mount() + await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() }) + fireEvent.click(rowButton(screen.getByRole('listitem'))) + await waitFor(() => { expect(columns()).toHaveLength(2) }) + fireEvent.click(screen.getByRole('button', { name: 'browser.editPath' })) + const input = screen.getByLabelText('browser.editPath') + fireEvent.change(input, { target: { value: `${DOCS}/h` } }) + // The advance replaces BOTH panes (the picked button's own column + // unmounts), so focus is re-parked on the selection's aria-current row + // in the freshly rendered left pane rather than the clicked node. + const row = rowButton(within(columns()[1]!).getByRole('listitem')) + fireEvent.mouseDown(row) + fireEvent.click(row) + expect(screen.queryByLabelText('browser.editPath', { selector: 'input' })).toBeNull() + await waitFor(() => { expect(document.activeElement?.textContent).toBe('harness') }) + expect(document.activeElement?.getAttribute('aria-current')).toBe('true') + }) + it('seeds and filters with backslashes on a Windows-rooted listing', async () => { const ROOT = 'C:\\' const windowsListing: DirectoryListing = { From 23b74c5d7daa1245fe1aae28a2ee7b2f0a5da362 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Wed, 29 Jul 2026 15:38:52 +0800 Subject: [PATCH 010/113] =?UTF-8?q?fix(host):=20review=20round=205=20nits?= =?UTF-8?q?=20=E2=80=94=20refocus=20covers=20Enter/Escape=20exits;=20trail?= =?UTF-8?q?ing=20pressed=20check;=20narrowed=20v8=20ignores?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/client/DirectoryBrowser.tsx | 69 +++++++++++++++---- .../tests/directory-browser.spec.tsx | 8 +++ 2 files changed, 62 insertions(+), 15 deletions(-) diff --git a/packages/host/directory-picker-browse/src/client/DirectoryBrowser.tsx b/packages/host/directory-picker-browse/src/client/DirectoryBrowser.tsx index bb0c4c5d7c..52211bed4a 100644 --- a/packages/host/directory-picker-browse/src/client/DirectoryBrowser.tsx +++ b/packages/host/directory-picker-browse/src/client/DirectoryBrowser.tsx @@ -233,10 +233,15 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen, }) }, [launchListing]) - // An editing-time pick parks focus on the selection after commit; the - // flag is set by select() and consumed by the refocus effect below the - // miller-row ref. + // Editor-close focus parking (consumed by the refocus effect below the + // miller-row ref): a pick parks on the selection's row, Enter and an + // input-focused Escape park on the crumb edit zone that replaces the + // input. Pointer-out cancels never set (or clear) these — yanking focus + // back from wherever the user clicked would be worse than the fall. const refocusPick = useRef(false) + const refocusEditZone = useRef(false) + const pathInputRef = useRef(null) + const editZoneRef = useRef(null) /** Select a row of the listed level and preview its children on the right. */ const select = useCallback((entry: DirectoryEntry) => { @@ -373,17 +378,33 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen, const row = millerRowRef.current if (row !== null && childPath !== undefined) row.scrollLeft = row.scrollWidth }, [childPath]) - // An editing-time pick unmounts the focused input, and a right-pane pick - // additionally replaces the picked button's whole column (advance swaps - // both panes): park focus on the selection's row — aria-current in the - // freshly rendered left pane — after commit, so keyboard traversal stays - // inside the dialog (the Modal has no focus trap). + // Every editor exit that would drop focus to body re-parks it after + // commit, so keyboard traversal stays inside the dialog (the Modal has no + // focus trap): a pick lands on the selection's row — aria-current in the + // freshly rendered left pane, which survives even a right-pane advance + // replacing the picked button's column — while Enter and an input-focused + // Escape land on the crumb edit zone that replaces the input. useEffect(() => { - if (!refocusPick.current) return - refocusPick.current = false - /* v8 ignore next 2 -- narrowing guard: the pick that set the flag just rendered its aria-current row inside the miller row. */ - const row = millerRowRef.current?.querySelector('button[aria-current="true"]') - row?.focus() + if (pathDraft !== null) return + if (refocusPick.current) { + refocusPick.current = false + refocusEditZone.current = false + const rowHost = millerRowRef.current + /* v8 ignore next -- narrowing guard: the miller row is mounted whenever a pick just committed. */ + if (rowHost === null) return + const row = rowHost.querySelector('button[aria-current="true"]') + /* v8 ignore next -- narrowing guard: the pick that set the flag just rendered its aria-current row. */ + if (row === null) return + row.focus() + return + } + if (refocusEditZone.current) { + refocusEditZone.current = false + const zone = editZoneRef.current + /* v8 ignore next -- narrowing guard: crumb mode renders the edit zone whenever the editor just closed. */ + if (zone === null) return + zone.focus() + } }) if (!open) return null @@ -424,6 +445,10 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen, // document listener — the same containment the input previously // provided for itself. event.stopPropagation() + // Escape while the input holds focus is about to unmount it; with + // focus already parked on a row, that row survives the cancel and + // keeps focus naturally. + if (document.activeElement === pathInputRef.current) refocusEditZone.current = true cancelPathEdit() }} // Focus leaving THIS dialog card while editing cancels like Escape. @@ -442,6 +467,10 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen, /* v8 ignore next -- narrowing guard: this scope always renders inside the Modal card. */ if (card === null) return if (event.relatedTarget instanceof Node && card.contains(event.relatedTarget)) return + // The user moved focus out of the card themselves: cancel without + // re-parking (a lingering Enter-failure flag must not yank focus + // back either). + refocusEditZone.current = false cancelPathEdit() }} > @@ -475,6 +504,7 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen, // listing itself fails, typing an absolute path is the one // remaining way forward. disabled={parentInert} + ref={editZoneRef} onClick={() => { // Opening the editor supersedes any pending listing: a // settlement landing before the first keystroke would @@ -502,6 +532,7 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen, value={pathDraft} aria-label={t('browser.editPath')} autoFocus + ref={pathInputRef} disabled={parentInert} onChange={(event) => { // Editing the draft supersedes any in-flight navigation: @@ -521,7 +552,13 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen, // Trim only detects a blank draft; the Host gets the // original text — a real directory name may end in // whitespace, and trimming would list its sibling. - if (pathDraft.trim() !== '') navigate(pathDraft) + if (pathDraft.trim() !== '') { + // Success will unmount the still-focused input; park + // focus on the returning crumb edit zone (a failure + // keeps the editor, so the flag waits until close). + refocusEditZone.current = true + navigate(pathDraft) + } } }} /> @@ -586,8 +623,10 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen, onMouseDown={draftPending ? (event) => { event.preventDefault() } : undefined} onClick={() => { setShowHidden(prev => !prev) }} > - {showHidden && } {t('browser.showHidden')} + {/* Trailing check (Menu's selected vocabulary): the label never + * shifts when the pressed state toggles. */} + {showHidden && } diff --git a/packages/host/directory-picker-browse/tests/directory-browser.spec.tsx b/packages/host/directory-picker-browse/tests/directory-browser.spec.tsx index 217ffa481e..06f8b78a3f 100644 --- a/packages/host/directory-picker-browse/tests/directory-browser.spec.tsx +++ b/packages/host/directory-picker-browse/tests/directory-browser.spec.tsx @@ -227,6 +227,9 @@ describe('DirectoryBrowser', () => { fireEvent.keyDown(input, { key: 'Enter' }) await waitFor(() => { expect(screen.getByRole('listitem').textContent).toBe('harness') }) expect(columns()).toHaveLength(1) + // The submitted navigation unmounted the focused input; focus parks on + // the crumb edit zone that replaced it. + expect(document.activeElement).toBe(screen.getByRole('button', { name: 'browser.editPath' })) fireEvent.click(screen.getByRole('button', { name: 'browser.editPath' })) const again = screen.getByLabelText('browser.editPath') fireEvent.change(again, { target: { value: ' ' } }) @@ -234,6 +237,8 @@ describe('DirectoryBrowser', () => { expect(b.listDirectory).toHaveBeenCalledTimes(2) fireEvent.keyDown(again, { key: 'Escape' }) expect(screen.queryByLabelText('browser.editPath', { selector: 'input' })).toBeNull() + // Escape with focus in the input parks focus on the returning edit zone. + expect(document.activeElement).toBe(screen.getByRole('button', { name: 'browser.editPath' })) }) it('prefix-filters the listed level from the draft tail, dot revealing hidden matches', async () => { @@ -324,9 +329,12 @@ describe('DirectoryBrowser', () => { // Tab parked focus on the result row; Escape must still mean "leave // path editing", not "close the whole dialog". const row = rowButton(screen.getByRole('listitem')) + row.focus() fireEvent.keyDown(row, { key: 'Escape' }) expect(screen.queryByLabelText('browser.editPath', { selector: 'input' })).toBeNull() expect(b.onClose).not.toHaveBeenCalled() + // Focus was already on a surviving row, so nothing re-parks it. + expect(document.activeElement).toBe(row) // With no draft left, Escape falls through to the Modal and closes. fireEvent.keyDown(row, { key: 'Escape' }) expect(b.onClose).toHaveBeenCalledTimes(1) From a1d752f79958d63e4d1ce3e28af37e04c2b790cb Mon Sep 17 00:00:00 2001 From: creatixchu Date: Wed, 29 Jul 2026 21:12:04 +0800 Subject: [PATCH 011/113] =?UTF-8?q?feat(host):=20navigations=20land=20sele?= =?UTF-8?q?ction-anchored=20=E2=80=94=20crumb=20jumps=20step=20back=20a=20?= =?UTF-8?q?pane=20instead=20of=20collapsing?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...directory-picker-capability-seam.i18n.yaml | 4 +- ...-07-28-directory-picker-capability-seam.md | 1 + ...-28-directory-picker-capability-seam.zh.md | 1 + apps/web/tests/workspace-flow.snapshot.ts | 6 + .../directory-picker-browse/README.i18n.yaml | 4 +- .../host/directory-picker-browse/README.md | 2 +- .../host/directory-picker-browse/README.zh.md | 2 +- .../src/client/DirectoryBrowser.tsx | 52 +++++++-- .../tests/directory-browser.spec.tsx | 108 +++++++++++++++++- 9 files changed, 160 insertions(+), 20 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.i18n.yaml index e70ac895a0..e270a79a11 100644 --- a/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md -2026-07-28-directory-picker-capability-seam.md: 550d51cf2e4e5b70b0844401d42bfea2342612e7 -2026-07-28-directory-picker-capability-seam.zh.md: afaf277e866349b20d370fe1fd638b0c71e1a3e7 +2026-07-28-directory-picker-capability-seam.md: 51d71d15cb5144d555a5b156d9b108d7a2ad41b8 +2026-07-28-directory-picker-capability-seam.zh.md: 60bc14c13d1e4655faefbd5eaa63469119bf0ca3 diff --git a/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md b/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md index 550d51cf2e..51d71d15cb 100644 --- a/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md +++ b/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md @@ -20,6 +20,7 @@ Placement and policy rulings folded into this decision: - **Dependency survey (hand-roll vs adopt).** Node's stdlib *is* the maintained cross-platform OS layer (`readdir(withFileTypes)`, `homedir`, path semantics); surveyed alternatives fail the dependency bar — file-manager packages (`node-file-manager`, `files-and-folders`, Syncfusion's provider) are whole HTTP apps (fit), drive-letter helpers (`drivelist` native addon, `windows-drive-letters` ~7y stale) fail health/proportionality. The browse backend is a thin adapter over stdlib. - **Hidden entries: return-and-flag.** The host stamps `hidden` (POSIX dot convention) and returns everything; the client filters. Display policy stays client-side, and the show-hidden toggle shipped as exactly that client-only change: a fixed-label footer toggle whose state lives in the pressed presentation (`aria-pressed` + check glyph), a dot-led path-draft prefix reveals the hidden entries it names, and the current selection is exempt from both the hidden and the prefix filter (it anchors the two-pane view). Windows' `FILE_ATTRIBUTE_HIDDEN` is not exposed by dirents — documented limitation until a native probe pays for itself. - **Path-editor cancel scope: the dialog card.** The browse client's path editor cancels on Escape and on focus leaving the card, both observed at a card-scope wrapper rather than the input — after Tab parks focus on a filtered row the input is off the event path, yet Escape must collapse the editor (not the dialog) and a later focus departure must still cancel. Non-cancel exemptions: window/tab focus loss, in-card focus moves, and pointer paths (rows and the toggle suppress focus steal on mousedown while editing). Separators for seeding and draft-tail filtering are inferred from `listing.home`; the wire-field alternative below records the deferred authoritative form. Combobox semantics between the editor and the list it filters (`aria-expanded`/`aria-controls`/active-descendant, result announcements) are likewise deferred — today they read to assistive tech as separate widgets. +- **Navigation lands selection-anchored.** Away from the display root, the browse client's navigate (a crumb jump or a submitted path) lists the target's parent level with the target selected and its children on the right — two panes throughout, so a crumb jump reads as stepping back one pane rather than collapsing to a single column. The display root (home, or a rootward chain with no parent crumb) keeps the single wide level; the parent leg runs under the same supersession scope as the landing, and its failure falls back to the single-pane landing quietly — the target listed fine, and nobody asked to see the parent. - **Symlinks: follow for enterability.** `stat` probes symlinks (broken/cyclic → skipped); crumbs keep the logical path the operator navigated, and `workspace.create` already canonicalizes via realpath at adoption. - **Listing levels are bounded, and streamed.** One `list` call returns at most `maxEntries` rows (config, default 1000 — GitHub's web-UI directory-listing bound). The level streams via `opendir` into a name-sorted window of `maxEntries + 1` candidates, so memory stays O(maxEntries) and enterability probing touches only windowed candidates; the wire `DirectoryListing` carries a required `truncated` flag so the client states incompleteness instead of silently missing tail entries. A windowed broken symlink is not backfilled from beyond the window — the eviction already marks the level truncated. Window insertion is binary with an O(1) full-window tail rejection (an oversized level must not pay a window scan per dirent), and `list(path, signal)` threads the carrier's request signal so a scan of a stalled network directory cannot outlive a disconnected caller — every await in the scan (open, each read, each symlink probe) races the signal, an aborted exit abandons rather than awaits the close (Node queues close behind in-flight reads), and abandoned settlements are swallowed so cleanup can never surface as an unhandled rejection. An unbounded level is a memory/responsiveness hole for large or adversarial directories. - **Whole-filesystem scope, no roots config.** `workspace.create` accepts arbitrary paths and the API serves bash-driving methods, so a browse root would be UX scoping, not a boundary; configurability without a consumer fails the evidence bar. Deferred until a deployment needs it. diff --git a/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.zh.md b/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.zh.md index afaf277e86..60bc14c13d 100644 --- a/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.zh.md @@ -20,6 +20,7 @@ web GUI 的"打开本地文件夹"流程被焊死在一种交互上:`host.pick - **依赖调研(手写 vs 引入)。** Node 标准库本身就是维护中的跨平台 OS 层(`readdir(withFileTypes)`、`homedir`、路径语义);调研过的替代品都过不了依赖门槛——文件管理器包(`node-file-manager`、`files-and-folders`、Syncfusion 的 provider)是整套 HTTP 应用(契合度不过),盘符工具(原生插件 `drivelist`、约七年未更的 `windows-drive-letters`)健康度/比例失当。browse 后端是标准库上的薄适配。 - **隐藏条目:返回并打标。** 宿主标注 `hidden`(POSIX 点前缀约定)并返回全部条目;客户端过滤。展示策略留在客户端,"显示隐藏"开关正是作为这一纯客户端改动落地:标签固定的 footer 开关,其状态由按下态呈现承载(`aria-pressed` + 勾选符号);以点开头的路径草稿前缀会显出它所指名的隐藏条目;当前选中项则不受隐藏与前缀两种过滤影响(它锚定着双栏视图)。Windows 的 `FILE_ATTRIBUTE_HIDDEN` 不被 dirent 暴露——记为限制,直到原生探测值回其成本。 - **路径编辑器的取消范围:对话框卡片。** browse 客户端的路径编辑器在按 Escape 与焦点离开卡片时取消,两者都在卡片范围的包装层而非输入框上监听——Tab 把焦点停到某个过滤命中的行之后,输入框已不在事件路径上,但 Escape 仍须收起编辑器(而非对话框),其后的焦点离开也仍须取消。不取消的豁免:窗口/标签页失焦、卡片内焦点移动,以及指针路径(编辑期间行与开关在 mousedown 时抑制焦点夺取)。预填与草稿末段过滤所用的分隔符从 `listing.home` 推断;下文的线上字段替代方案记录了被延期的权威形态。编辑器与其过滤的列表之间的 combobox 语义(`aria-expanded`/`aria-controls`/active-descendant、结果播报)同样被延期——目前二者在辅助技术看来是彼此独立的控件。 +- **导航以选中项为锚落地。** 在展示根之外,browse 客户端的导航(crumb 跳转或提交的路径)列出目标的父层级,选中目标并在右侧展示其子项——全程双栏,因此 crumb 跳转读作后退一栏,而不是塌缩成单列。展示根(home,或没有父 crumb 的抵根链)保持单个宽层级;父层级这一程与落地共用同一 supersession 范围,其失败会静默回退到单栏落地——目标本身列举无误,本也没有人要求查看父层级。 - **符号链接:为可进入性而跟随。** 用 `stat` 探测符号链接(断链/循环→跳过);面包屑保留操作者导航的逻辑路径,`workspace.create` 在接纳时本就做 realpath 规范化。 - **列举层级有上限,且流式处理。** 单次 `list` 至多返回 `maxEntries` 行(配置项,默认 1000——GitHub 网页端目录列举的同一上限)。层级经 `opendir` 流入一个按名排序、容量 `maxEntries + 1` 的候选窗口,内存保持 O(maxEntries),可进入性探测只触及窗口内候选;线上 `DirectoryListing` 携带必填的 `truncated` 标志,让客户端明示不完整而不是静默缺尾。窗口内的断链符号链接不从窗口外回填——发生过驱逐本身已把层级标记为截断。窗口插入为二分查找、满窗尾部单次比较即拒绝(超大层级不能为每个 dirent 付出一次全窗扫描),且 `list(path, signal)` 透传载体的请求信号,滞塞网络目录的扫描不会在调用方断连后继续存活——扫描中的每个 await(打开、每次读取、每次符号链接探测)都与信号赛跑,中止路径放弃而非等待 close(Node 会把 close 排在在飞读取之后),被放弃的 settlement 全部吞掉,清理不会以未处理拒绝的形式冒出。无上限的层级对超大或恶意构造的目录就是内存/响应性漏洞。 - **全盘可浏览,不做 roots 配置。** `workspace.create` 接受任意路径且 API 本就提供驱动 bash 的方法,浏览根只会是 UX 范围而非边界;没有消费方的可配置性过不了证据门槛。等到有部署需要再做。 diff --git a/apps/web/tests/workspace-flow.snapshot.ts b/apps/web/tests/workspace-flow.snapshot.ts index 32d44b6fff..2ed00f7cbf 100644 --- a/apps/web/tests/workspace-flow.snapshot.ts +++ b/apps/web/tests/workspace-flow.snapshot.ts @@ -202,6 +202,12 @@ it('adopts a directory through the composed in-app browse flow and lands in its // row's name span is stable (clicks bubble to the row button). fireEvent.click(await within(dialog).findByText('Documents', {}, { timeout: 10_000 })) fireEvent.click(await within(dialog).findByText('project', {}, { timeout: 10_000 })) + // A crumb jump away from the root steps BACK one pane instead of + // collapsing: Documents lands selected in the home level (Downloads is + // the home-level marker) with its children still on the right. + fireEvent.click(within(dialog).getByRole('button', { name: 'Documents' })) + await within(dialog).findByText('Downloads', {}, { timeout: 10_000 }) + fireEvent.click(await within(dialog).findByText('project', {}, { timeout: 10_000 })) // Open disables while the selection's child listing is in flight; wait for // the enabled state or the click lands on a dead button on slow runners. await waitFor(() => { diff --git a/packages/host/directory-picker-browse/README.i18n.yaml b/packages/host/directory-picker-browse/README.i18n.yaml index e59cd22b1e..842e91481c 100644 --- a/packages/host/directory-picker-browse/README.i18n.yaml +++ b/packages/host/directory-picker-browse/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/host/directory-picker-browse/README.md -README.md: 7813e0e9c589d1f37471b08ebafcf08878cbf768 -README.zh.md: 46538ed6f1cf23357cc4f8e289a8117d0e5e017d +README.md: 51e27115b5179796810c19b618b08a3523de8d10 +README.zh.md: 4caeff1c2ebfea2c12e6bb598dae2c34f2e34d84 diff --git a/packages/host/directory-picker-browse/README.md b/packages/host/directory-picker-browse/README.md index 7813e0e9c5..51e27115b5 100644 --- a/packages/host/directory-picker-browse/README.md +++ b/packages/host/directory-picker-browse/README.md @@ -6,7 +6,7 @@ The **in-app browsing backend** of the [directory-picker seam](../directory-pick Behavior facts: listings return **directories only**, name-sorted, with symlinks-to-directories followed (broken/cyclic links skipped — the probe `stat` failing means "not enterable") and a host-owned `hidden` flag (POSIX dot convention) left for the client to act on; `crumbs` is the root-to-target ancestor chain, the root crumb labeled by its full path (`/`, `C:\`); an absent `list` path means the host account's home directory. `createDirectory` is non-recursive (a missing parent is a real failure, not a level to invent) and validates the name as a single non-blank segment even when called directly, mirroring the wire schema's fence. Both primitives reject an explicit path that is not fully qualified — relative forms, and on Windows the rooted drive-less forms (`\foo`, `/foo`) and incomplete UNC prefixes (`\\`, `\\server`) that `isAbsolute` accepts — with `directory-unreadable`/`directory-create-failed`, instead of letting `resolve` rebase it under the host process cwd or current drive. One `list` call returns at most `maxEntries` rows (config, default 1000 — the bound GitHub's web UI applies to directory listings), and the level streams through a bounded window so memory stays O(maxEntries) no matter how many children the directory holds: a cut level keeps the name-sorted head, counts hidden rows against the bound, probes only windowed candidates, and reports `truncated: true` so the client can say the level is incomplete (a windowed broken symlink is not backfilled from beyond the window — the eviction already marks the level truncated); window insertion is binary with an O(1) full-window tail rejection, and `list` threads the caller's `AbortSignal` so a disconnect or timeout stops the scan instead of letting it outlive the caller. Failures throw the seam's typed `DirectoryPickerError`. Policy rationale: [the directory-picker capability seam Agent Note](../../../.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md). -**Dual-face package**: the browser half (`./client`) fills [ui-workspace's](../../client/ui-workspace/README.md) two directory-flow holes with the in-app **Select Workspace Directory** dialog (figma `Harness` 813-23126 family — Miller two-column view; breadcrumb with a click-to-edit path zone whose editor seeds a trailing separator, prefix-filters the listed level from the draft's final segment while typing (case-insensitively, over the listed — possibly truncated — rows only; Enter still navigates by the exact text), and cancels on Escape or when focus leaves the dialog card (window/tab switches and in-card focus moves keep the draft); a fixed-label show-hidden footer toggle over the host's `hidden` flags, with a dot-led typed prefix revealing its matches and the current selection exempt from both filters; nested New-folder dialog), driving `host.listDirectory`/`host.createDirectory` and registering its own locale namespace (`directory-browser`, zh default / en). One cordis.yml row therefore composes both sides of the browse interaction; the client carries no capability-kind branching, and mounting a second flow package fails at load (the holes are `single` kind). +**Dual-face package**: the browser half (`./client`) fills [ui-workspace's](../../client/ui-workspace/README.md) two directory-flow holes with the in-app **Select Workspace Directory** dialog (figma `Harness` 813-23126 family — Miller two-column view whose navigations land selection-anchored: a crumb jump or a submitted path lists the target's parent level with the target selected, so stepping back keeps two panes while the display root keeps the single wide level; breadcrumb with a click-to-edit path zone whose editor seeds a trailing separator, prefix-filters the listed level from the draft's final segment while typing (case-insensitively, over the listed — possibly truncated — rows only; Enter still navigates by the exact text), and cancels on Escape or when focus leaves the dialog card (window/tab switches and in-card focus moves keep the draft); a fixed-label show-hidden footer toggle over the host's `hidden` flags, with a dot-led typed prefix revealing its matches and the current selection exempt from both filters; nested New-folder dialog), driving `host.listDirectory`/`host.createDirectory` and registering its own locale namespace (`directory-browser`, zh default / en). One cordis.yml row therefore composes both sides of the browse interaction; the client carries no capability-kind branching, and mounting a second flow package fails at load (the holes are `single` kind). ## Model Experience diff --git a/packages/host/directory-picker-browse/README.zh.md b/packages/host/directory-picker-browse/README.zh.md index 46538ed6f1..4caeff1c2e 100644 --- a/packages/host/directory-picker-browse/README.zh.md +++ b/packages/host/directory-picker-browse/README.zh.md @@ -6,7 +6,7 @@ 行为事实:列举**只返回目录**、按名称排序,指向目录的符号链接会被跟随(断链/循环链接被跳过——探测 `stat` 失败即"不可进入"),并携带宿主判定的 `hidden` 标志(POSIX 点前缀约定),展示决策留给客户端;`crumbs` 是从根到目标的祖先链,根 crumb 以完整路径标注(`/`、`C:\`);`list` 不带路径即列举宿主账户的家目录。`createDirectory` 不递归(父目录缺失是真实失败,不是要补造的层级),且即便被直接调用也把名称校验为单个非空段,与协议 schema 的栅栏一致。两个原语都拒绝非完全限定的显式路径——相对形态,以及 Windows 上 `isAbsolute` 会放行的无盘符有根形态(`\foo`、`/foo`)与不完整的 UNC 前缀(`\\`、`\\server`)——报 `directory-unreadable`/`directory-create-failed`,而不是任由 `resolve` 把它重定位到宿主进程 cwd 或当前盘符之下。单次 `list` 至多返回 `maxEntries` 行(配置项,默认 1000——GitHub 网页端对目录列举采用的同一上限),且层级以流式方式经过一个有界窗口,无论目录有多少子项内存都保持 O(maxEntries):被截断的层级保留按名排序的头部、隐藏行计入上限、只探测窗口内候选,并报告 `truncated: true`,供客户端提示层级不完整(窗口内的断链符号链接不会从窗口外回填——发生过驱逐本身已把层级标记为截断);窗口插入为二分查找、满窗尾部单次比较即拒绝,且 `list` 透传调用方的 `AbortSignal`,断连或超时会停止扫描而不是让它在调用方离开后继续。失败抛出 seam 的类型化 `DirectoryPickerError`。策略依据:[目录选择能力 seam Agent Note](../../../.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md)。 -**双面包**:browser half(`./client`)以应用内 **选择工作区目录** 对话框(figma `Harness` 813-23126 家族——Miller 双列视图;带点击即编辑路径区的面包屑,其编辑器预填尾随分隔符、输入时以草稿末段对所列层级做前缀过滤(不区分大小写,且仅作用于已列出、可能被截断的行;Enter 仍按确切文本导航)、按 Escape 或焦点离开对话框卡片即取消(窗口/标签页切换与卡片内焦点移动保留草稿);基于宿主 `hidden` 标志、标签固定的"显示隐藏"footer 开关,键入以点开头的前缀会显出其匹配项,且当前选中项不受这两种过滤影响;嵌套新建文件夹对话框)填入 [ui-workspace](../../client/ui-workspace/README.md) 的两个目录流洞,驱动 `host.listDirectory`/`host.createDirectory`,并注册自己的 locale 命名空间(`directory-browser`,zh 默认/en)。因此一行 cordis.yml 同时组合浏览交互的两侧;client 侧不含任何能力 kind 分支,挂载第二个流程包会在加载期失败(洞为 `single` kind)。 +**双面包**:browser half(`./client`)以应用内 **选择工作区目录** 对话框(figma `Harness` 813-23126 家族——Miller 双列视图,其导航以选中项为锚落地:crumb 跳转或提交的路径会列出目标的父层级并选中目标,因此后退仍保持双栏,而展示根保持单个宽层级;带点击即编辑路径区的面包屑,其编辑器预填尾随分隔符、输入时以草稿末段对所列层级做前缀过滤(不区分大小写,且仅作用于已列出、可能被截断的行;Enter 仍按确切文本导航)、按 Escape 或焦点离开对话框卡片即取消(窗口/标签页切换与卡片内焦点移动保留草稿);基于宿主 `hidden` 标志、标签固定的"显示隐藏"footer 开关,键入以点开头的前缀会显出其匹配项,且当前选中项不受这两种过滤影响;嵌套新建文件夹对话框)填入 [ui-workspace](../../client/ui-workspace/README.md) 的两个目录流洞,驱动 `host.listDirectory`/`host.createDirectory`,并注册自己的 locale 命名空间(`directory-browser`,zh 默认/en)。因此一行 cordis.yml 同时组合浏览交互的两侧;client 侧不含任何能力 kind 分支,挂载第二个流程包会在加载期失败(洞为 `single` kind)。 ## 模型体验 diff --git a/packages/host/directory-picker-browse/src/client/DirectoryBrowser.tsx b/packages/host/directory-picker-browse/src/client/DirectoryBrowser.tsx index 52211bed4a..ba0502fc65 100644 --- a/packages/host/directory-picker-browse/src/client/DirectoryBrowser.tsx +++ b/packages/host/directory-picker-browse/src/client/DirectoryBrowser.tsx @@ -214,24 +214,60 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen, return { seq, scan: listDirectory(path, controller.signal) } }, [supersede, listDirectory]) - /** Replace the whole view with one freshly listed level (no selection). */ + /** + * Replace the whole view with a freshly navigated level. Away from the + * display root the landing keeps the navigated directory SELECTED inside + * its parent level (left pane = parent, right pane = its children), so a + * crumb jump or a submitted path reads as stepping back one pane instead + * of collapsing to a single column; the display root (the home level, or + * a chain with no parent) keeps the single wide level. + */ const navigate = useCallback((path?: string) => { const { seq, scan } = launchListing(path) setLoading(true) setError(null) - scan.then((next) => { + scan.then((target) => { if (seq !== requestSeq.current) return - setParent(next) - setSelected(null) - setChild(null) - setLoading(false) - setPathDraft(null) + const parentCrumb = target.crumbs.at(-2) + const anchor = target.crumbs.at(-1) + if (target.path === target.home || parentCrumb === undefined + /* v8 ignore next -- narrowing: the anchor crumb exists whenever a parent crumb does (root-to-target inclusive chain). */ + || anchor === undefined) { + setParent(target) + setSelected(null) + setChild(null) + setLoading(false) + setPathDraft(null) + return + } + // Two-pane landing: the parent leg runs under the same supersession + // scope (a newer intent aborts it like the first leg). + const controller = new AbortController() + scanController.current = controller + listDirectory(parentCrumb.path, controller.signal).then((parentLevel) => { + if (seq !== requestSeq.current) return + setParent(parentLevel) + setSelected(anchor) + setChild(target) + setLoading(false) + setPathDraft(null) + }, () => { + if (seq !== requestSeq.current) return + // The target listed fine and is what the user asked for; a parent + // leg failure quietly falls back to the single-pane landing rather + // than surfacing an error for a level nobody requested. + setParent(target) + setSelected(null) + setChild(null) + setLoading(false) + setPathDraft(null) + }) }, (reason: unknown) => { if (seq !== requestSeq.current) return setLoading(false) setError(failureText(reason)) }) - }, [launchListing]) + }, [launchListing, listDirectory]) // Editor-close focus parking (consumed by the refocus effect below the // miller-row ref): a pick parks on the selection's row, Enter and an diff --git a/packages/host/directory-picker-browse/tests/directory-browser.spec.tsx b/packages/host/directory-picker-browse/tests/directory-browser.spec.tsx index 06f8b78a3f..ab60efcc5d 100644 --- a/packages/host/directory-picker-browse/tests/directory-browser.spec.tsx +++ b/packages/host/directory-picker-browse/tests/directory-browser.spec.tsx @@ -29,6 +29,13 @@ function listingFor(path?: string): DirectoryListing { ], truncated: false, }, + '/': { + path: '/', + home: HOME, + crumbs: [{ name: '/', path: '/', hidden: false }], + entries: [{ name: 'home', path: '/home', hidden: false }], + truncated: false, + }, [`${HOME}/.config`]: { path: `${HOME}/.config`, home: HOME, @@ -198,6 +205,88 @@ describe('DirectoryBrowser', () => { expect(rowButton(screen.getByRole('listitem')).getAttribute('aria-current')).toBeNull() }) + it('a crumb jump away from the root lands two-pane with the target selected', async () => { + mount() + await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() }) + fireEvent.click(rowButton(screen.getByRole('listitem'))) + await waitFor(() => { expect(columns()).toHaveLength(2) }) + fireEvent.click(rowButton(within(columns()[1]!).getByRole('listitem'))) + await waitFor(() => { expect(screen.getByRole('button', { name: 'harness' })).toBeTruthy() }) + // Jumping to the Documents crumb is a step BACK one pane, not a + // collapse: Documents stays selected in the home level, its children + // stay on the right. + fireEvent.click(screen.getByRole('button', { name: 'Documents' })) + await waitFor(() => { + expect(rowButton(within(columns()[0]!).getByRole('listitem')).getAttribute('aria-current')).toBe('true') + }) + expect(columns()).toHaveLength(2) + expect(within(columns()[0]!).getByText('Documents')).toBeTruthy() + expect(within(columns()[1]!).getByText('harness')).toBeTruthy() + }) + + it('a navigation to the filesystem root keeps the single wide level', async () => { + mount() + await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() }) + fireEvent.click(screen.getByRole('button', { name: 'browser.editPath' })) + fireEvent.change(screen.getByLabelText('browser.editPath'), { target: { value: '/' } }) + fireEvent.keyDown(screen.getByLabelText('browser.editPath'), { key: 'Enter' }) + // A one-crumb chain has no parent level to show on the left. + await waitFor(() => { expect(screen.getByRole('listitem').textContent).toBe('home') }) + expect(columns()).toHaveLength(1) + }) + + it('drops a parent leg that settles after a newer intent, resolving or rejecting', async () => { + const settlers: { resolve: (value: DirectoryListing) => void; reject: (reason: unknown) => void }[] = [] + const listDirectory = vi.fn(async (path?: string) => { + if (path === HOME) { + return new Promise((resolve, reject) => { settlers.push({ resolve, reject }) }) + } + return listingFor(path) + }) + mount({ listDirectory }) + await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() }) + // Enter lands the target leg; the parent leg hangs. Escape supersedes + // the landing, and the late parent RESOLUTION must change nothing. + fireEvent.click(screen.getByRole('button', { name: 'browser.editPath' })) + fireEvent.change(screen.getByLabelText('browser.editPath'), { target: { value: DOCS } }) + fireEvent.keyDown(screen.getByLabelText('browser.editPath'), { key: 'Enter' }) + await waitFor(() => { expect(settlers).toHaveLength(1) }) + fireEvent.keyDown(screen.getByLabelText('browser.editPath'), { key: 'Escape' }) + await act(async () => { settlers[0]!.resolve(listingFor(HOME)) }) + expect(columns()).toHaveLength(1) + expect(screen.getByRole('listitem').textContent).toBe('Documents') + // Same shape, late parent REJECTION: equally silent. + fireEvent.click(screen.getByRole('button', { name: 'browser.editPath' })) + fireEvent.change(screen.getByLabelText('browser.editPath'), { target: { value: DOCS } }) + fireEvent.keyDown(screen.getByLabelText('browser.editPath'), { key: 'Enter' }) + await waitFor(() => { expect(settlers).toHaveLength(2) }) + fireEvent.keyDown(screen.getByLabelText('browser.editPath'), { key: 'Escape' }) + await act(async () => { settlers[1]!.reject(new Error('late')) }) + expect(columns()).toHaveLength(1) + expect(screen.queryByRole('alert')).toBeNull() + }) + + it('falls back to the single-pane landing when the parent leg of a navigation fails', async () => { + const listDirectory = vi.fn(async (path?: string) => { + // The initial open lists home through the absent-path form; only the + // parent leg names HOME explicitly. + if (path === HOME) { + throw new DirectoryBrowseError({ code: 'directory-unreadable', message: 'parent gone', details: { path } }) + } + return listingFor(path) + }) + mount({ listDirectory }) + await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() }) + fireEvent.click(screen.getByRole('button', { name: 'browser.editPath' })) + fireEvent.change(screen.getByLabelText('browser.editPath'), { target: { value: DOCS } }) + fireEvent.keyDown(screen.getByLabelText('browser.editPath'), { key: 'Enter' }) + // The target listed fine; the failed parent leg neither blocks the + // landing nor surfaces an error for a level nobody asked to see. + await waitFor(() => { expect(screen.getByRole('listitem').textContent).toBe('harness') }) + expect(columns()).toHaveLength(1) + expect(screen.queryByRole('alert')).toBeNull() + }) + it('opens the selection, else the listed level; Cancel closes; busy freezes Open', async () => { const b = mount() await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() }) @@ -225,8 +314,11 @@ describe('DirectoryBrowser', () => { expect(input.value).toBe(`${HOME}/`) fireEvent.change(input, { target: { value: DOCS } }) fireEvent.keyDown(input, { key: 'Enter' }) - await waitFor(() => { expect(screen.getByRole('listitem').textContent).toBe('harness') }) - expect(columns()).toHaveLength(1) + // Away from the root a navigation lands two-pane: the target selected + // in its parent level, its own children on the right. + await waitFor(() => { expect(columns()).toHaveLength(2) }) + expect(rowButton(within(columns()[0]!).getByRole('listitem')).getAttribute('aria-current')).toBe('true') + expect(within(columns()[1]!).getByText('harness')).toBeTruthy() // The submitted navigation unmounted the focused input; focus parks on // the crumb edit zone that replaced it. expect(document.activeElement).toBe(screen.getByRole('button', { name: 'browser.editPath' })) @@ -234,7 +326,9 @@ describe('DirectoryBrowser', () => { const again = screen.getByLabelText('browser.editPath') fireEvent.change(again, { target: { value: ' ' } }) fireEvent.keyDown(again, { key: 'Enter' }) - expect(b.listDirectory).toHaveBeenCalledTimes(2) + // Initial home + the DOCS target leg + its parent leg; the blank draft + // added none. + expect(b.listDirectory).toHaveBeenCalledTimes(3) fireEvent.keyDown(again, { key: 'Escape' }) expect(screen.queryByLabelText('browser.editPath', { selector: 'input' })).toBeNull() // Escape with focus in the input parks focus on the returning edit zone. @@ -945,11 +1039,13 @@ describe('DirectoryBrowser', () => { b.listDirectory.mockReturnValueOnce(slow) fireEvent.click(screen.getByRole('button', { name: 'browser.home' })) fireEvent.click(within(screen.getByRole('navigation')).getByRole('button', { name: 'Documents' })) - await waitFor(() => { expect(screen.getByRole('listitem').textContent).toBe('harness') }) + // The newer jump lands two-pane: Documents selected at home, children right. + await waitFor(() => { expect(within(columns()[1]!).getByText('harness')).toBeTruthy() }) resolveSlow(listingFor(undefined)) await new Promise(settle => setTimeout(settle, 0)) - // The stale home listing did not replace the newer Documents level. - expect(screen.getByRole('listitem').textContent).toBe('harness') + // The stale home listing did not replace the newer Documents landing. + expect(columns()).toHaveLength(2) + expect(within(columns()[1]!).getByText('harness')).toBeTruthy() }) it('names the create target by its path when the level reports no crumbs', async () => { From 42e3cceb6489258c142f51bb3ad54d4e6fc5c2d7 Mon Sep 17 00:00:00 2001 From: Turtle Date: Tue, 28 Jul 2026 22:19:13 +0800 Subject: [PATCH 012/113] feat(paths): add resolveSessionsRoot as the one shared session-store root Every surface that persists or lists sessions resolves one directory under the Harness home, so history is shared across working directories instead of scattered per project. --- packages/util/paths/README.i18n.yaml | 4 ++-- packages/util/paths/README.md | 4 ++++ packages/util/paths/README.zh.md | 8 ++++++-- packages/util/paths/src/index.ts | 21 +++++++++++++++++++++ packages/util/paths/tests/paths.spec.ts | 11 +++++++++++ 5 files changed, 44 insertions(+), 4 deletions(-) diff --git a/packages/util/paths/README.i18n.yaml b/packages/util/paths/README.i18n.yaml index a57d18b601..f21ee7f88b 100644 --- a/packages/util/paths/README.i18n.yaml +++ b/packages/util/paths/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/util/paths/README.md -README.md: b28e684f3183d739c8e229a9b341801dbf345d86 -README.zh.md: ab4e8123d19fd56749e3e7a0d59e8cd6bea0c3d0 +README.md: 837ee0cdf7687fac58058e04ff5971002124038a +README.zh.md: d34eeb023a506d5c07f32638ac74698b3a913efa diff --git a/packages/util/paths/README.md b/packages/util/paths/README.md index b28e684f31..837ee0cdf7 100644 --- a/packages/util/paths/README.md +++ b/packages/util/paths/README.md @@ -16,6 +16,10 @@ Shared filesystem path helpers for DeepSeek Harness user data. `expandHomePath()` expands `~`, `~/...`, and Windows-style `~\...` prefixes against the operating-system home directory. It leaves non-tilde paths and `~user/...` untouched. +## Session store + +`resolveSessionsRoot()` resolves the shared session-store root under the Harness home, by the same precedence as `resolveDshHome()`. `SESSIONS_DIR_NAME` owns its directory name: `sessions`. Every surface that persists sessions resolves this one directory, so history is shared across working directories instead of scattered per project; a persistence backend may still partition inside it. Two surfaces resolving different roots would silently split one user's history into disjoint stores, which is why the location is owned here rather than joined per caller. + This package is intentionally small and harness-dep-free so product packages can share user-data path conventions without depending on one another. ## Known Limitations and Deferred Work diff --git a/packages/util/paths/README.zh.md b/packages/util/paths/README.zh.md index ab4e8123d1..d34eeb023a 100644 --- a/packages/util/paths/README.zh.md +++ b/packages/util/paths/README.zh.md @@ -16,9 +16,13 @@ DeepSeek Harness 用户数据的共享文件系统路径辅助工具。 `expandHomePath()` 使用操作系统主目录展开 `~`、`~/...` 和 Windows 风格的 `~\...` 前缀。它会保留非波浪号路径和 `~user/...` 原样不变。 -该包(package)刻意保持规模小且不依赖 harness,以便产品包共享用户数据路径约定,而不必彼此依赖。 +## 会话存储 -## 已知限制与暂缓事项 +`resolveSessionsRoot()` 按与 `resolveDshHome()` 相同的优先级,解析 Harness 主目录下的共享会话存储根目录。`SESSIONS_DIR_NAME` 定义其目录名:`sessions`。每个持久化会话的界面都解析这同一个目录,因此历史记录在各工作目录之间共享,而不是按项目分散;持久化后端仍可在其内部分区。若两个界面解析出不同的根目录,会静默地把同一用户的历史记录拆成互不相交的存储,这正是该位置由此处拥有、而非由各调用方自行拼接的原因。 + +该包刻意保持规模小且不依赖 harness,以便产品包共享用户数据路径约定,而不必彼此依赖。 + +## 已知限制与待完成工作 - **展开范围刻意保持狭窄**:只有单独的 `~`、`~/...` 和 `~\...` 使用当前操作系统主目录;`~alice/...` 等指定用户的形式、环境变量和 shell 表达式保持不变。 - **辅助工具不会操作文件系统**:调用方仍负责目录创建、存在性检查、权限,以及对结果路径应用信任策略。 diff --git a/packages/util/paths/src/index.ts b/packages/util/paths/src/index.ts index c54a5e0a5f..85a6c881cd 100644 --- a/packages/util/paths/src/index.ts +++ b/packages/util/paths/src/index.ts @@ -52,6 +52,27 @@ export function resolveDshHome(configured?: string, env: Record = process.env, +): string { + return join(resolveDshHome(configuredHome, env), SESSIONS_DIR_NAME) +} + /** * Describe a resolved harness home symbolically for user-facing display. * diff --git a/packages/util/paths/tests/paths.spec.ts b/packages/util/paths/tests/paths.spec.ts index 6e1b94b1e9..5d41b52c44 100644 --- a/packages/util/paths/tests/paths.spec.ts +++ b/packages/util/paths/tests/paths.spec.ts @@ -4,10 +4,12 @@ import { describe, expect, it } from 'vitest' import { DEFAULT_DSH_HOME_DISPLAY, DSH_HOME_DIR_NAME, + SESSIONS_DIR_NAME, defaultDshHome, dshHomeDisplay, expandHomePath, resolveDshHome, + resolveSessionsRoot, } from '@deepseek-ai/dsh-paths' describe('dsh path helpers', () => { @@ -38,6 +40,15 @@ describe('dsh path helpers', () => { expect(resolveDshHome(undefined, { DSH_HOME: ' ' })).toBe(defaultDshHome()) }) + it('resolves the session store under the home it was given, by the same precedence', () => { + expect(SESSIONS_DIR_NAME).toBe('sessions') + expect(resolveSessionsRoot('/tmp/explicit-dsh', { DSH_HOME: '~/env-dsh' })) + .toBe(join(resolve('/tmp/explicit-dsh'), 'sessions')) + expect(resolveSessionsRoot(undefined, { DSH_HOME: '~/env-dsh' })) + .toBe(join(homedir(), 'env-dsh', 'sessions')) + expect(resolveSessionsRoot(undefined, {})).toBe(join(defaultDshHome(), 'sessions')) + }) + it('labels a resolved home by whether it is the default root', () => { expect(dshHomeDisplay(resolve(defaultDshHome()))).toBe('~/.dsh') expect(dshHomeDisplay('/some/other/root')).toBe('$DSH_HOME') From e7c0a5b7947232fd8eaedab013e9f2d5d3de846d Mon Sep 17 00:00:00 2001 From: Turtle Date: Wed, 29 Jul 2026 14:29:32 +0800 Subject: [PATCH 013/113] Merge origin/master: web permission sandbox, default pi-ai providers --- ...8-launcher-owned-resume-identity.i18n.yaml | 6 + ...26-07-28-launcher-owned-resume-identity.md | 62 ++ ...07-28-launcher-owned-resume-identity.zh.md | 62 ++ ...cated-full-screen-tui-front-door.i18n.yaml | 4 +- ...17-dedicated-full-screen-tui-front-door.md | 2 +- ...dedicated-full-screen-tui-front-door.zh.md | 2 +- .../2026-07-21-tui-resume-command.i18n.yaml | 6 +- .../feature/2026-07-21-tui-resume-command.md | 12 +- .../2026-07-21-tui-resume-command.zh.md | 12 +- ...24-configurable-tui-prompt-theme.i18n.yaml | 6 +- ...026-07-24-configurable-tui-prompt-theme.md | 2 +- ...-07-24-configurable-tui-prompt-theme.zh.md | 2 +- ...2026-07-27-tmux-location-context.i18n.yaml | 6 + .../2026-07-27-tmux-location-context.md | 61 ++ .../2026-07-27-tmux-location-context.zh.md | 61 ++ .../2026-07-27-tui-tool-card-header.i18n.yaml | 4 +- .../2026-07-27-tui-tool-card-header.md | 2 +- .../2026-07-27-tui-tool-card-header.zh.md | 2 +- ...026-07-28-cross-workspace-resume.i18n.yaml | 6 + .../2026-07-28-cross-workspace-resume.md | 52 ++ .../2026-07-28-cross-workspace-resume.zh.md | 52 ++ ...sh-guided-skill-session-commands.i18n.yaml | 6 + ...07-28-dsh-guided-skill-session-commands.md | 43 ++ ...28-dsh-guided-skill-session-commands.zh.md | 43 ++ ...-07-28-dsh-meta-source-workspace.i18n.yaml | 6 + .../2026-07-28-dsh-meta-source-workspace.md | 49 ++ ...2026-07-28-dsh-meta-source-workspace.zh.md | 49 ++ ...live-session-registry-and-dsh-ls.i18n.yaml | 6 + ...-07-28-live-session-registry-and-dsh-ls.md | 65 ++ ...-28-live-session-registry-and-dsh-ls.zh.md | 65 ++ ...8-source-guard-staging-edit-gate.i18n.yaml | 6 + ...26-07-28-source-guard-staging-edit-gate.md | 76 +++ ...07-28-source-guard-staging-edit-gate.zh.md | 76 +++ apps/cli/README.md | 14 +- apps/cli/README.zh.md | 14 +- apps/cli/package.json | 3 + apps/cli/src/app-cli-entry.ts | 11 +- apps/cli/src/args.ts | 149 ++++- apps/cli/src/bin.ts | 16 + apps/cli/src/headless.ts | 2 + apps/cli/src/list-sessions.ts | 101 +++ apps/cli/src/register-session.ts | 44 ++ apps/cli/src/tui.ts | 144 ++++- apps/cli/src/web.ts | 2 + apps/cli/tests/args.spec.ts | 39 +- apps/cli/tests/built-bin.e2e.ts | 55 +- apps/cli/tests/list-sessions.spec.ts | 74 +++ apps/cli/tests/sessions-root.spec.ts | 20 + apps/cli/tsconfig.json | 9 + docs/capability-seams.md | 8 + docs/config-catalog.md | 86 ++- docs/cordis-catalog/services.md | 38 ++ docs/i18n/terminology.md | 1 + docs/module-graph.md | 33 + examples/cordis-agent/cordis.yml | 1 - .../fixtures/guard/source-guard/cordis.yml | 38 ++ .../fixtures/guard/source-guard/mock-llm.ts | 43 ++ .../guard/source-guard/mount-guard.ts | 14 + .../tests/fixtures/tmux-context-driver.ts | 16 + .../tests/fixtures/tmux-context-mock-bash.ts | 46 ++ .../tests/fixtures/tmux-context-mock-llm.ts | 22 + .../tests/fixtures/tmux-context.cordis.yml | 21 + examples/package.json | 3 + examples/tui-agent/README.md | 2 +- examples/tui-agent/code-mode.cordis.yml | 2 - examples/tui-agent/composition.md | 3 + examples/tui-agent/cordis.yml | 23 +- .../tests/fixtures/tui-scripted-llm.ts | 27 +- .../tests/fixtures/tui-scripted.cordis.yml | 2 - .../tui-agent/tests/tui-keyless-smoke.e2e.ts | 74 ++- knip.json | 35 ++ packages/context/README.md | 5 +- .../context/tmux-context/README.i18n.yaml | 6 + packages/context/tmux-context/README.md | 68 ++ packages/context/tmux-context/README.zh.md | 68 ++ packages/context/tmux-context/package.json | 49 ++ packages/context/tmux-context/src/index.ts | 227 +++++++ .../context/tmux-context/src/invariant.ts | 30 + .../tmux-context/tests/tmux-context.e2e.ts | 77 +++ .../tmux-context/tests/tmux-context.spec.ts | 367 +++++++++++ packages/context/tmux-context/tsconfig.json | 40 ++ .../cordis/tool-cordis/src/api-catalog.ts | 30 + packages/examples/tui-demo/README.md | 8 +- packages/examples/tui-demo/package.json | 16 +- packages/examples/tui-demo/src/index.ts | 47 +- .../examples/tui-demo/tests/tui-agent.spec.ts | 67 +- packages/examples/tui-demo/tsconfig.json | 3 + packages/guard/README.md | 5 +- packages/guard/source-guard/README.i18n.yaml | 6 + packages/guard/source-guard/README.md | 88 +++ packages/guard/source-guard/README.zh.md | 88 +++ packages/guard/source-guard/package.json | 56 ++ packages/guard/source-guard/src/index.ts | 319 ++++++++++ packages/guard/source-guard/src/invariant.ts | 85 +++ .../source-guard/tests/invariant.spec.ts | 134 ++++ .../tests/loader-composition.e2e.ts | 93 +++ .../source-guard/tests/source-guard.spec.ts | 581 ++++++++++++++++++ packages/guard/source-guard/tsconfig.json | 42 ++ packages/session-registry/README.i18n.yaml | 6 + packages/session-registry/README.md | 15 + packages/session-registry/README.zh.md | 15 + .../session-registry-file/README.i18n.yaml | 6 + .../session-registry-file/README.md | 42 ++ .../session-registry-file/README.zh.md | 42 ++ .../session-registry-file/package.json | 47 ++ .../session-registry-file/src/file.ts | 97 +++ .../session-registry-file/src/index.ts | 233 +++++++ .../session-registry-file/src/invariant.ts | 32 + .../session-registry-file/src/liveness.ts | 30 + .../tests/fixtures/register-once.ts | 27 + .../tests/session-registry-file.spec.ts | 382 ++++++++++++ .../session-registry-file/tsconfig.json | 21 + .../session-registry-live/README.i18n.yaml | 6 + .../session-registry-live/README.md | 32 + .../session-registry-live/README.zh.md | 32 + .../session-registry-live/package.json | 44 ++ .../session-registry-live/src/index.ts | 84 +++ .../session-registry-live/src/invariant.ts | 31 + .../tests/session-registry-live.spec.ts | 227 +++++++ .../session-registry-live/tsconfig.json | 24 + .../session-registry/README.i18n.yaml | 6 + .../session-registry/README.md | 30 + .../session-registry/README.zh.md | 30 + .../session-registry/package.json | 41 ++ .../session-registry/src/index.ts | 82 +++ .../session-registry/src/invariant.ts | 58 ++ .../session-registry/src/types.ts | 55 ++ .../session-registry/tests/invariant.spec.ts | 98 +++ .../session-registry/tsconfig.json | 21 + .../support/acp-snapshot/README.i18n.yaml | 4 +- packages/support/acp-snapshot/README.md | 2 +- packages/support/acp-snapshot/README.zh.md | 2 +- packages/support/acp-snapshot/src/harness.ts | 8 + packages/support/acp-snapshot/src/suite.ts | 7 + .../suite/plain-turn/stdout.expected.jsonl | 2 +- .../support/acp-snapshot/tests/suite.spec.ts | 3 +- packages/ui/app-boot/README.i18n.yaml | 4 +- packages/ui/app-boot/README.md | 3 +- packages/ui/app-boot/README.zh.md | 3 +- packages/ui/app-boot/src/index.ts | 11 - pnpm-lock.yaml | 188 ++++++ scripts/gen-cordis-catalog.ts | 2 + scripts/gen-doc-graphs.ts | 9 + .../verify-package-readme-model-experience.ts | 3 + skills/dsh-migrate/SKILL.md | 57 ++ tsconfig.base.json | 2 + tsconfig.host.json | 5 + 147 files changed, 6770 insertions(+), 195 deletions(-) create mode 100644 .agents/notes/implemented/architecture/2026-07-28-launcher-owned-resume-identity.i18n.yaml create mode 100644 .agents/notes/implemented/architecture/2026-07-28-launcher-owned-resume-identity.md create mode 100644 .agents/notes/implemented/architecture/2026-07-28-launcher-owned-resume-identity.zh.md create mode 100644 .agents/notes/implemented/feature/2026-07-27-tmux-location-context.i18n.yaml create mode 100644 .agents/notes/implemented/feature/2026-07-27-tmux-location-context.md create mode 100644 .agents/notes/implemented/feature/2026-07-27-tmux-location-context.zh.md create mode 100644 .agents/notes/implemented/feature/2026-07-28-cross-workspace-resume.i18n.yaml create mode 100644 .agents/notes/implemented/feature/2026-07-28-cross-workspace-resume.md create mode 100644 .agents/notes/implemented/feature/2026-07-28-cross-workspace-resume.zh.md create mode 100644 .agents/notes/implemented/feature/2026-07-28-dsh-guided-skill-session-commands.i18n.yaml create mode 100644 .agents/notes/implemented/feature/2026-07-28-dsh-guided-skill-session-commands.md create mode 100644 .agents/notes/implemented/feature/2026-07-28-dsh-guided-skill-session-commands.zh.md create mode 100644 .agents/notes/implemented/feature/2026-07-28-dsh-meta-source-workspace.i18n.yaml create mode 100644 .agents/notes/implemented/feature/2026-07-28-dsh-meta-source-workspace.md create mode 100644 .agents/notes/implemented/feature/2026-07-28-dsh-meta-source-workspace.zh.md create mode 100644 .agents/notes/implemented/feature/2026-07-28-live-session-registry-and-dsh-ls.i18n.yaml create mode 100644 .agents/notes/implemented/feature/2026-07-28-live-session-registry-and-dsh-ls.md create mode 100644 .agents/notes/implemented/feature/2026-07-28-live-session-registry-and-dsh-ls.zh.md create mode 100644 .agents/notes/implemented/feature/2026-07-28-source-guard-staging-edit-gate.i18n.yaml create mode 100644 .agents/notes/implemented/feature/2026-07-28-source-guard-staging-edit-gate.md create mode 100644 .agents/notes/implemented/feature/2026-07-28-source-guard-staging-edit-gate.zh.md create mode 100644 apps/cli/src/list-sessions.ts create mode 100644 apps/cli/src/register-session.ts create mode 100644 apps/cli/tests/list-sessions.spec.ts create mode 100644 apps/cli/tests/sessions-root.spec.ts create mode 100644 examples/headless-agent/tests/fixtures/guard/source-guard/cordis.yml create mode 100644 examples/headless-agent/tests/fixtures/guard/source-guard/mock-llm.ts create mode 100644 examples/headless-agent/tests/fixtures/guard/source-guard/mount-guard.ts create mode 100644 examples/headless-agent/tests/fixtures/tmux-context-driver.ts create mode 100644 examples/headless-agent/tests/fixtures/tmux-context-mock-bash.ts create mode 100644 examples/headless-agent/tests/fixtures/tmux-context-mock-llm.ts create mode 100644 examples/headless-agent/tests/fixtures/tmux-context.cordis.yml create mode 100644 packages/context/tmux-context/README.i18n.yaml create mode 100644 packages/context/tmux-context/README.md create mode 100644 packages/context/tmux-context/README.zh.md create mode 100644 packages/context/tmux-context/package.json create mode 100644 packages/context/tmux-context/src/index.ts create mode 100644 packages/context/tmux-context/src/invariant.ts create mode 100644 packages/context/tmux-context/tests/tmux-context.e2e.ts create mode 100644 packages/context/tmux-context/tests/tmux-context.spec.ts create mode 100644 packages/context/tmux-context/tsconfig.json create mode 100644 packages/guard/source-guard/README.i18n.yaml create mode 100644 packages/guard/source-guard/README.md create mode 100644 packages/guard/source-guard/README.zh.md create mode 100644 packages/guard/source-guard/package.json create mode 100644 packages/guard/source-guard/src/index.ts create mode 100644 packages/guard/source-guard/src/invariant.ts create mode 100644 packages/guard/source-guard/tests/invariant.spec.ts create mode 100644 packages/guard/source-guard/tests/loader-composition.e2e.ts create mode 100644 packages/guard/source-guard/tests/source-guard.spec.ts create mode 100644 packages/guard/source-guard/tsconfig.json create mode 100644 packages/session-registry/README.i18n.yaml create mode 100644 packages/session-registry/README.md create mode 100644 packages/session-registry/README.zh.md create mode 100644 packages/session-registry/session-registry-file/README.i18n.yaml create mode 100644 packages/session-registry/session-registry-file/README.md create mode 100644 packages/session-registry/session-registry-file/README.zh.md create mode 100644 packages/session-registry/session-registry-file/package.json create mode 100644 packages/session-registry/session-registry-file/src/file.ts create mode 100644 packages/session-registry/session-registry-file/src/index.ts create mode 100644 packages/session-registry/session-registry-file/src/invariant.ts create mode 100644 packages/session-registry/session-registry-file/src/liveness.ts create mode 100644 packages/session-registry/session-registry-file/tests/fixtures/register-once.ts create mode 100644 packages/session-registry/session-registry-file/tests/session-registry-file.spec.ts create mode 100644 packages/session-registry/session-registry-file/tsconfig.json create mode 100644 packages/session-registry/session-registry-live/README.i18n.yaml create mode 100644 packages/session-registry/session-registry-live/README.md create mode 100644 packages/session-registry/session-registry-live/README.zh.md create mode 100644 packages/session-registry/session-registry-live/package.json create mode 100644 packages/session-registry/session-registry-live/src/index.ts create mode 100644 packages/session-registry/session-registry-live/src/invariant.ts create mode 100644 packages/session-registry/session-registry-live/tests/session-registry-live.spec.ts create mode 100644 packages/session-registry/session-registry-live/tsconfig.json create mode 100644 packages/session-registry/session-registry/README.i18n.yaml create mode 100644 packages/session-registry/session-registry/README.md create mode 100644 packages/session-registry/session-registry/README.zh.md create mode 100644 packages/session-registry/session-registry/package.json create mode 100644 packages/session-registry/session-registry/src/index.ts create mode 100644 packages/session-registry/session-registry/src/invariant.ts create mode 100644 packages/session-registry/session-registry/src/types.ts create mode 100644 packages/session-registry/session-registry/tests/invariant.spec.ts create mode 100644 packages/session-registry/session-registry/tsconfig.json create mode 100644 skills/dsh-migrate/SKILL.md diff --git a/.agents/notes/implemented/architecture/2026-07-28-launcher-owned-resume-identity.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-28-launcher-owned-resume-identity.i18n.yaml new file mode 100644 index 0000000000..b63d7d3093 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-07-28-launcher-owned-resume-identity.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-28-launcher-owned-resume-identity.md +2026-07-28-launcher-owned-resume-identity.md: 8c48194892f67c1a0f3f87094cd174ca1a71a383 +2026-07-28-launcher-owned-resume-identity.zh.md: 88113017986ac8aaf473d246f701c977804353bf diff --git a/.agents/notes/implemented/architecture/2026-07-28-launcher-owned-resume-identity.md b/.agents/notes/implemented/architecture/2026-07-28-launcher-owned-resume-identity.md new file mode 100644 index 0000000000..8c48194892 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-07-28-launcher-owned-resume-identity.md @@ -0,0 +1,62 @@ +# Agent Note: Launcher-owned session identity and exit line + +Status: implemented + +English | [中文](2026-07-28-launcher-owned-resume-identity.zh.md) + +## Problem + +Two facts a launcher owns were shipped as deployment config keys on `dsh-tui-demo`: `resumeSessionId` (which session `main` binds to) and `resumeCommand` (the exit hint template, with `{session}` interpolated). Neither varies by deployment — both are properties of how the process was invoked, which only the launcher knows. + +Routing them through YAML made them silently droppable. `@cordisjs/plugin-include` applies a targeted patch by replacing whole top-level keys (`target[key] = value`), so a personal `~/.dsh/config.yaml` patching the `tui-agent` entry's `config` replaces the shipped block entirely. A user overlay written to change provider and model therefore deleted every resume key it did not restate, and nothing reported it: absent `resumeCommand` legitimately means "no fallback configured". + +Both failures were live in one real overlay. The exit hint stopped printing, because the overlay omitted `resumeCommand`. Worse, the overlay carried `resumeSessionId: !!js process.env.RESUME_SESSION_ID` — a stale line from before [the env-var bridge was removed](../../archived/architecture/2026-07-24-dsh-commander-argument-adapter.md) — which overwrote the shipped `!!js "typeof resumeSessionId === 'string' ? …"` intake with a read of a variable nothing sets. `dsh --resume ` then started a *fresh* session and said nothing, reproduced directly: the banner showed a newly minted id, not the requested one. The [`dsh meta`](../feature/2026-07-28-dsh-meta-source-workspace.md) note had recorded this silent resume as an unexplained pre-existing defect; the overlay's shallow replacement is the cause. + +A config key cannot express these facts safely, because the deployment is not the authority on them. + +## Decision + +Session identity and the exit line are launcher-owned context slots, provided before any Loader entry mounts. Neither appears in any `cordis.yml` or in `dsh-tui`'s or `dsh-tui-demo`'s `Config`. + +`dsh-tui` declares both slots beside the existing `tuiResumeHost` host capability, which set the precedent — a resume host has always been a provided capability rather than config: + +- `MAIN_SESSION_ID_KEY` carries a `MainSessionIdentity` (`{ id: SessionId, resume: boolean }`). `dsh-tui-demo` binds both the TUI and the configured agent to `id`, and takes the history-loading `resumeSessionId` path only when `resume` is set, because that path requires an existing log and fails loud without one. An absent slot means no launcher chose a session, so the app mints `main-session-` and creates it fresh. +- `TUI_GOODBYE_MESSAGE_KEY` carries the complete line printed once the terminal is released on exit. Absent prints nothing. + +`apps/cli` mints or selects the id and builds the line from the invocation it is reproducing, sharing one `resumeArgs` helper with the `/resume` execve handoff so the printed command and the in-place handoff cannot diverge. The line now names `--config` when one was passed, and reproduces `dsh meta --resume ` in meta mode — closing the mode-aware hint deferred by the `dsh meta` note, where a copied hint previously only worked from the checkout. + +**`ctx.provide` is the only channel from launcher argv into a Loader-mounted plugin.** Config `!!js` expressions evaluate as `with (entry.ctx) { eval(expr) }` (`vendor/loader/src/config/utils.ts`), so a bare identifier resolves against the entry's context and nothing else reaches it. The slot therefore cannot be removed while the app bundle is mounted from YAML; what changes is that it is now internal launcher↔app plumbing instead of a documented key a config author must wire correctly. + +The message is a plain string, not a callback. That forces the launcher to know the id before boot, which is why minting moved out of the app bundle — and it keeps exit free of awaited work after the terminal is released. + +The TUI owns rendering, not wording: it applies `displayText` before its own `palette.muted`, so a hostile `--config` path cannot inject terminal escapes into the exit line. Sanitizing means the launcher cannot embed its own ANSI. + +## Alternatives considered + +**Keep the keys and add built-in defaults in `dsh-tui-demo`.** Rejected: a default in code survives an overlay, but two ways to state one fact remain, and a config author can still set the key wrong — which is exactly how the stale `process.env.RESUME_SESSION_ID` line disabled resume. + +**Merge `dsh-tui-demo` into `apps/cli` and delete the slot entirely.** Rejected after investigation, though it is the only way to remove the slot. `examples/tui-agent/code-mode.cordis.yml` patches the `tui-agent` entry through a nested `plugin-include` to switch `tools.mode` and the persona, and `examples/cordis-agent/cordis.yml` reuses the bundle as a different product; both extension points exist only because `tui-agent` is a declared config entry. Merging also moves a 162-line, 18-dependency composition into the CLI's `v8 ignore` process-wiring block, out of the per-file coverage gate. + +**Put the goodbye message on `TuiResumeHost`.** Rejected: an exit line is not a handoff capability, and a host that cannot replace its process may still want to print one. They are independent slots. + +**Have the host supply only the command text and let the TUI keep the `To resume this session:` prefix.** Rejected: the TUI would retain resume vocabulary for a string it no longer understands, and meta mode proves the launcher is the only component that knows what the command should say. + +**Let the TUI keep suppressing the line until the session is durably persisted.** Rejected: that check is why the exit path queried persistence and swallowed listing failures. A plain string cannot consult persistence, and misuse now fails loud through `agent-loop/config-start-failed` rather than silently resuming nothing. + +**A callback (`goodbyeMessage(agent)`) so the host could decide at exit time.** Rejected: it restores async work after `ui.stop()`, reintroducing a hang risk during teardown for a string that is already knowable at boot. + +## Consequences + +- Removing two published `Config` keys is a breaking config change: a stale config naming either now fails schema validation at boot instead of degrading silently. Intended, and acceptable pre-release. +- `TuiResumeHost` is unchanged, but `TuiRuntime` gains `goodbyeMessage`; `apps/cli` is the only provider. +- The exit line prints even for a session with no log (launch, quit immediately). Using it then fails loud rather than starting a surprise session. This is the deliberate cost of dropping the persistence check. +- `dsh-tui` no longer reads `sessionPersistence` at all: `currentResumeCommand`, `listWorkspaceSessions`, and its swallowed-error path are deleted, and the `/resume` selector's `sessionQuery` reads are now the only session discovery in the TUI. +- The launcher mints session ids for its own app, so a non-CLI host that provides no slot keeps the bundle's own minting. + +## Testing + +`packages/ui/tui/tests/tui.spec.ts` pins the printed line, the absent-slot silence, and escape sanitization of a hostile message; the former two exit-suppression tests are replaced, since suppression is the behavior this change removes. `packages/examples/tui-demo/tests/tui-agent.spec.ts` drives the identity slot for the resume, launcher-minted, and no-slot cases through a fake `ctx.get`. + +The load-bearing coverage is `examples/tui-agent/tests/tui-keyless-smoke.e2e.ts`, which launches the real `apps/cli/src/bin.ts` in a PTY: one test asserts the exit line carries `--config`, and a regression test seeds a personal `config.yaml` that replaces the entire `tui-agent` config block and asserts the line still prints — encoding "an overlay cannot drop resume" as an executed contract rather than a comment. + +Verified live in tmux against the real personal overlay: the defect reproduced on unmodified staging (requested id ignored, fresh id in the banner), and on this branch the same overlay yields a printed exit line, a `--resume` that restores the prior turn, and a `/resume` selector marking the session `current · live · persisted`. A wrong id now fails loud. diff --git a/.agents/notes/implemented/architecture/2026-07-28-launcher-owned-resume-identity.zh.md b/.agents/notes/implemented/architecture/2026-07-28-launcher-owned-resume-identity.zh.md new file mode 100644 index 0000000000..8811301798 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-07-28-launcher-owned-resume-identity.zh.md @@ -0,0 +1,62 @@ +# Agent Note:由启动器持有的会话身份与退出行 + +Status: implemented + +[English](2026-07-28-launcher-owned-resume-identity.md) | 中文 + +## Problem + +有两项本应由启动器持有的事实,却被作为 `dsh-tui-demo` 上的部署配置键交付:`resumeSessionId`(`main` 绑定到哪个会话)与 `resumeCommand`(退出提示的模板,其中 `{session}` 会被插值)。二者都不随部署而变——它们都是进程被如何调用的属性,而这一点只有启动器知道。 + +把它们经由 YAML 传递,使其可被静默丢弃。`@cordisjs/plugin-include` 施加定向补丁的方式是替换整个顶层键(`target[key] = value`),因此一份对 `tui-agent` 条目的 `config` 打补丁的个人 `~/.dsh/config.yaml`,会把交付时的整块内容整体替换掉。于是,一份为改动 provider 和 model 而写的用户 overlay,会删掉它未重述的每一个 resume 键,且没有任何东西报告这一点:缺失 `resumeCommand` 合法地意味着「未配置回退」。 + +两处失效在同一份真实的 overlay 中同时存在。退出提示不再打印,因为该 overlay 省略了 `resumeCommand`。更糟的是,该 overlay 带着 `resumeSessionId: !!js process.env.RESUME_SESSION_ID`——一行来自 [env 变量桥被移除](../../archived/architecture/2026-07-24-dsh-commander-argument-adapter.md)之前的陈旧代码——它用一次对某个无人设置的变量的读取,覆盖掉了交付时的 `!!js "typeof resumeSessionId === 'string' ? …"` 入口。此后 `dsh --resume ` 会开启一个*全新*会话且什么都不说,并被直接复现:banner 显示的是一个新铸造的 id,而非所请求的那个。[`dsh meta`](../feature/2026-07-28-dsh-meta-source-workspace.md) note 曾把这次静默的 resume 记为一处无法解释的既有缺陷;而 overlay 的浅层替换正是其成因。 + +一个配置键无法安全地表达这些事实,因为部署方并非它们的权威。 + +## Decision + +会话身份与退出行是由启动器持有的上下文槽位,在任何 Loader 条目挂载之前提供。二者都不出现在任何 `cordis.yml` 中,也不出现在 `dsh-tui` 或 `dsh-tui-demo` 的 `Config` 中。 + +`dsh-tui` 在既有的 `tuiResumeHost` 宿主能力旁声明这两个槽位,后者确立了先例——resume 宿主一直是一项被提供的能力,而非配置: + +- `MAIN_SESSION_ID_KEY` 承载一个 `MainSessionIdentity`(`{ id: SessionId, resume: boolean }`)。`dsh-tui-demo` 把 TUI 与所配置的 agent 都绑定到 `id`,并且仅当 `resume` 被置位时才走加载历史的 `resumeSessionId` 路径,因为该路径要求存在一份日志、否则会明确报错。槽位缺失意味着没有启动器选定会话,于是应用铸造 `main-session-` 并新建它。 +- `TUI_GOODBYE_MESSAGE_KEY` 承载退出时终端释放后打印一次的完整行。缺失则什么都不打印。 + +`apps/cli` 铸造或选定 id,并依据它所复现的那次调用构建该行,与 `/resume` 的 execve 移交共用同一个 `resumeArgs` 助手,从而使打印出的命令与原地移交不会分歧。该行现在会在传入了 `--config` 时命名它,并在 meta 模式下复现 `dsh meta --resume `——从而收口了 `dsh meta` note 所推迟的随 mode 变化的提示,在那里被复制的提示此前只有在检出目录中才有效。 + +**`ctx.provide` 是从启动器 argv 进入被 Loader 挂载的插件的唯一通道。** 配置的 `!!js` 表达式会以 `with (entry.ctx) { eval(expr) }`(`vendor/loader/src/config/utils.ts`)求值,因此一个裸标识符会针对该条目的上下文解析,别无它物可达。于是只要应用 bundle 仍从 YAML 挂载,这个槽位就无法被移除;变化之处在于它现在是启动器↔应用之间的内部管线,而不再是一个配置作者必须正确接线的、有文档记载的键。 + +该消息是一个纯字符串,而非回调。这迫使启动器在启动前就知道 id,也正是铸造从应用 bundle 中移出的原因——并且它让退出在终端释放之后免于任何被 await 的工作。 + +TUI 持有渲染,而非措辞:它在自己的 `palette.muted` 之前先应用 `displayText`,因此一个恶意的 `--config` 路径无法把终端转义序列注入退出行。做净化意味着启动器无法嵌入自己的 ANSI。 + +## Alternatives considered + +**保留这些键,并在 `dsh-tui-demo` 中加入内建默认值。** 拒绝:代码中的默认值能在 overlay 下存活,但表达同一事实的两种途径依然并存,而配置作者仍可把键设错——这正是那行陈旧的 `process.env.RESUME_SESSION_ID` 使 resume 失效的方式。 + +**把 `dsh-tui-demo` 合并进 `apps/cli` 并彻底删除该槽位。** 经调查后拒绝,尽管这是移除该槽位的唯一途径。`examples/tui-agent/code-mode.cordis.yml` 通过一个嵌套的 `plugin-include` 给 `tui-agent` 条目打补丁,以切换 `tools.mode` 与人设,而 `examples/cordis-agent/cordis.yml` 把该 bundle 作为另一款产品复用;这两个扩展点都仅因 `tui-agent` 是一个声明式配置条目才存在。合并还会把一段 162 行、18 个依赖的组合逻辑挪进 CLI 的 `v8 ignore` 进程接线块中,脱离逐文件覆盖率门禁。 + +**把 goodbye 消息放到 `TuiResumeHost` 上。** 拒绝:退出行不是一项移交能力,而一个无法替换自身进程的宿主仍可能想要打印一行。它们是相互独立的槽位。 + +**让宿主只提供命令文本,而由 TUI 保留 `To resume this session:` 前缀。** 拒绝:TUI 将为一个它已不再理解的字符串保留 resume 词汇,而 meta 模式证明启动器才是唯一知道该命令应当说什么的组件。 + +**让 TUI 继续在会话被持久化之前抑制该行。** 拒绝:这项检查正是退出路径要查询持久化并吞掉列举失败的原因。一个纯字符串无法查询持久化,而误用现在会经由 `agent-loop/config-start-failed` 明确报错,而不是静默地恢复了个空。 + +**用一个回调(`goodbyeMessage(agent)`)让宿主能在退出时决定。** 拒绝:它会在 `ui.stop()` 之后恢复异步工作,为一个在启动时就已可知的字符串,重新引入拆解期间的挂起风险。 + +## Consequences + +- 移除两个已发布的 `Config` 键是一次破坏性配置变更:一份命名了任一键的陈旧配置,现在会在启动时的 schema 校验中明确报错,而不再静默降级。这是有意为之,且在预发布阶段可以接受。 +- `TuiResumeHost` 保持不变,但 `TuiRuntime` 新增 `goodbyeMessage`;`apps/cli` 是唯一的提供方。 +- 即便某会话没有日志(启动后立即退出),退出行也会打印。此时使用它会明确报错,而不是开启一个意外的会话。这是丢弃持久化检查的有意代价。 +- `dsh-tui` 完全不再读取 `sessionPersistence`:`currentResumeCommand`、`listWorkspaceSessions` 及其吞错路径都被删除,`/resume` 选择器的 `sessionQuery` 读取如今是 TUI 中唯一的会话发现途径。 +- 启动器为其自身的应用铸造会话 id,因此一个不提供任何槽位的非 CLI 宿主,仍保留 bundle 自带的铸造逻辑。 + +## Testing + +`packages/ui/tui/tests/tui.spec.ts` 钉住打印出的行、槽位缺失时的静默,以及对恶意消息的转义净化;此前那两个退出抑制测试被替换,因为抑制正是本次改动移除的行为。`packages/examples/tui-demo/tests/tui-agent.spec.ts` 通过一个伪造的 `ctx.get`,为 resume、启动器铸造与无槽位三种情形驱动身份槽位。 + +承重的覆盖是 `examples/tui-agent/tests/tui-keyless-smoke.e2e.ts`,它在一个 PTY 中拉起真实的 `apps/cli/src/bin.ts`:一个测试断言退出行携带 `--config`,一个回归测试植入一份个人 `config.yaml` 来替换整块 `tui-agent` 配置块并断言该行仍会打印——把「overlay 不能丢掉 resume」编码为一条被执行的契约,而非一句注释。 + +在 tmux 中针对真实的个人 overlay 做过实测:该缺陷在未修改的 staging 上复现(所请求的 id 被忽略,banner 里是新的 id),而在本分支上同一份 overlay 会产出一行打印的退出行、一个能恢复上一轮次的 `--resume`,以及一个把该会话标记为 `current · live · persisted` 的 `/resume` 选择器。错误的 id 现在会明确报错。 diff --git a/.agents/notes/implemented/feature/2026-07-17-dedicated-full-screen-tui-front-door.i18n.yaml b/.agents/notes/implemented/feature/2026-07-17-dedicated-full-screen-tui-front-door.i18n.yaml index 3df1059e1f..bba253325c 100644 --- a/.agents/notes/implemented/feature/2026-07-17-dedicated-full-screen-tui-front-door.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-17-dedicated-full-screen-tui-front-door.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-17-dedicated-full-screen-tui-front-door.md -2026-07-17-dedicated-full-screen-tui-front-door.md: a3f3d6b51e85ad20218ce5aebf526bd96946be55 -2026-07-17-dedicated-full-screen-tui-front-door.zh.md: 6a0c2f12815e9418a2b5bcb237d3db3616ccd133 +2026-07-17-dedicated-full-screen-tui-front-door.md: bc6241e925d5bf094deded761fb96fd6b6c48a1f +2026-07-17-dedicated-full-screen-tui-front-door.zh.md: 0f5306ff547cb975b29d1fbcb7e610b3a540ce18 diff --git a/.agents/notes/implemented/feature/2026-07-17-dedicated-full-screen-tui-front-door.md b/.agents/notes/implemented/feature/2026-07-17-dedicated-full-screen-tui-front-door.md index a3f3d6b51e..bc6241e925 100644 --- a/.agents/notes/implemented/feature/2026-07-17-dedicated-full-screen-tui-front-door.md +++ b/.agents/notes/implemented/feature/2026-07-17-dedicated-full-screen-tui-front-door.md @@ -24,7 +24,7 @@ The TUI rebuilds the transcript from the active `session.surface` and reprojects Editor input calls `agent.send()` while idle and `agent.steer()` while a turn is running. Cancellation, reasoning visibility, tool-card expansion, redraw, transcript clearing, and exit are terminal-only controls. `/exit` and `/quit` share the same exit path: they cancel an active turn, wait for idle, and then restore and close the terminal. The idle footer derives context occupancy from `tokenMeter` and shows the selected model and explicit reasoning effort; during a run, elapsed activity and the Escape interrupt hint replace that summary. `/status` remains available in either state and appends a detailed terminal-only snapshot: session identity and timestamps, selected model, reasoning effort/default state and reasoning visibility, lifecycle counts folded from the event log, the same deduplicated usage buckets and KV-cache rate as the footer, and context use from `tokenMeter` plus the selected model's advertised capacity. The plugin registers the shared `userInteraction` provider and presents queued questions in a wide bottom-left keyboard panel with batch progress, numbered options, and aligned descriptions; the panel's controls hint lists only actions meaningful for the current option count, omitting navigation when exactly one option is shown; agent behavior and answer logging remain owned by their existing services. -The `/model` command presents the advisory `ctx.llm` catalog as a keyboard selector and changes only this TUI session's target; argument forms remain available for direct selection. Each model row owns the adapter-advertised reasoning-effort order and default: Shift+Tab cycles that row's efforts, includes provider-default behavior when the adapter advertises no default, and leaves models without selectable metadata unchanged. Agent-scoped prompt-assembly and request waterfalls snapshot one provider/model/reasoning-effort target per step, so `{{provider}}` / `{{model}}` interpolation and request routing cannot split when a command arrives during assembly. The latest logged request header restores a used target; a selection that never reaches a request remains process-local. +The `/model` command presents the advisory `ctx.llm` catalog as a keyboard selector and changes only this TUI session's target; argument forms remain available for direct selection. The selector carries a filter box above the list: typing narrows the rows to a case-insensitive substring over each row's `provider/model` label, model name, and description, keeping the selection on the previously highlighted row when it survives the filter; Escape clears a non-empty filter before a second Escape cancels the selector. Each model row owns the adapter-advertised reasoning-effort order and default: Shift+Tab cycles that row's efforts, includes provider-default behavior when the adapter advertises no default, and leaves models without selectable metadata unchanged. Agent-scoped prompt-assembly and request waterfalls snapshot one provider/model/reasoning-effort target per step, so `{{provider}}` / `{{model}}` interpolation and request routing cannot split when a command arrives during assembly. The latest logged request header restores a used target; a selection that never reaches a request remains process-local. ### Terminal ownership diff --git a/.agents/notes/implemented/feature/2026-07-17-dedicated-full-screen-tui-front-door.zh.md b/.agents/notes/implemented/feature/2026-07-17-dedicated-full-screen-tui-front-door.zh.md index 6a0c2f1281..0f5306ff54 100644 --- a/.agents/notes/implemented/feature/2026-07-17-dedicated-full-screen-tui-front-door.zh.md +++ b/.agents/notes/implemented/feature/2026-07-17-dedicated-full-screen-tui-front-door.zh.md @@ -24,7 +24,7 @@ TUI 从活跃的 `session.surface` 重建 transcript(文本记录),并在 agent 空闲时,编辑器输入调用 `agent.send()`;轮次运行中则调用 `agent.steer()`。取消、推理显隐、工具卡片展开、重绘、清空 transcript 和退出都只是终端控制。`/exit` 和 `/quit` 共用同一条退出路径:先取消进行中的轮次,等待 agent 空闲,然后恢复并关闭终端。空闲态页脚根据 `tokenMeter` 得出上下文占用率,并显示所选模型和显式选定的推理强度;agent 运行期间,该摘要会替换为带已用时长的活动指示和 Escape 中断提示。`/status` 在这两种状态下均可用,并会追加一份仅在终端显示的详细快照,其中包括会话标识与时间戳、所选模型、推理强度(或默认状态)及推理显隐状态、从事件日志归并得出的生命周期计数、与页脚一致的去重用量分项和 KV 缓存命中率,以及 `tokenMeter` 给出的上下文用量和所选模型公布的容量。插件注册共享的 `userInteraction` 提供方,在左下角宽幅键盘操作面板中呈现排队的问题,面板显示批次进度、带编号的选项和对齐的描述;面板的操作提示只列出在当前选项数量下有意义的操作,仅有一个选项时不显示导航项;agent 行为和答案日志仍由既有服务负责。 -`/model` 命令将建议性的 `ctx.llm` 目录呈现为键盘选择器,并且只更改当前 TUI 会话的目标;带参数的形式仍可直接选择目标。每个模型行都持有适配器公布的推理强度顺序和默认值:按 Shift+Tab 可循环切换该行的推理强度;如果适配器没有公布默认值,循环中还会包含提供方默认行为;没有可选元数据的模型则保持不变。agent 作用域内的 prompt 组装和请求两条 waterfall(瀑布式事件)会为每个步骤快照一次同一个提供方/模型/推理强度目标,因此即使命令在组装期间到达,`{{provider}}` / `{{model}}` 插值与请求路由也不会分裂。系统通过日志中最新的请求头恢复已经使用过的目标;未被请求使用的选择只保留在当前进程中。 +`/model` 命令将建议性的 `ctx.llm` 目录呈现为键盘选择器,并且只更改当前 TUI 会话的目标;带参数的形式仍可直接选择目标。选择器在列表上方设有一个过滤框:输入内容会按对每行 `provider/model` 标签、模型名称和描述的大小写不敏感子串匹配来缩小行集,并在原先高亮行仍通过过滤时保持其选中状态;Escape 会先清空非空的过滤内容,再次按 Escape 才取消选择器。每个模型行都持有适配器公布的推理强度顺序和默认值:按 Shift+Tab 可循环切换该行的推理强度;如果适配器没有公布默认值,循环中还会包含提供方默认行为;没有可选元数据的模型则保持不变。agent 作用域内的 prompt 组装和请求两条 waterfall(瀑布式事件)会为每个步骤快照一次同一个提供方/模型/推理强度目标,因此即使命令在组装期间到达,`{{provider}}` / `{{model}}` 插值与请求路由也不会分裂。系统通过日志中最新的请求头恢复已经使用过的目标;未被请求使用的选择只保留在当前进程中。 ### 终端所有权 diff --git a/.agents/notes/implemented/feature/2026-07-21-tui-resume-command.i18n.yaml b/.agents/notes/implemented/feature/2026-07-21-tui-resume-command.i18n.yaml index d470b61414..ea5ef87849 100644 --- a/.agents/notes/implemented/feature/2026-07-21-tui-resume-command.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-21-tui-resume-command.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write -2026-07-21-tui-resume-command.md: 86f62e16f5e2ee83e2ed36f0ed675ca2a1422c4b -2026-07-21-tui-resume-command.zh.md: 06e58f81445aaaf5299282714148194c1d2aacf4 +# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-21-tui-resume-command.md +2026-07-21-tui-resume-command.md: c8cb855378d793a168e1f87d6b41f9a48db7dc14 +2026-07-21-tui-resume-command.zh.md: 2a7e74d1106499cb7d7232dc13954ae126246550 diff --git a/.agents/notes/implemented/feature/2026-07-21-tui-resume-command.md b/.agents/notes/implemented/feature/2026-07-21-tui-resume-command.md index 86f62e16f5..c8cb855378 100644 --- a/.agents/notes/implemented/feature/2026-07-21-tui-resume-command.md +++ b/.agents/notes/implemented/feature/2026-07-21-tui-resume-command.md @@ -10,17 +10,17 @@ The original `/resume` printed shell commands. It did not let a keyboard user in ## Decision -`/resume` uses the TUI's existing interactive overlay seam as a full-viewport picker rather than a centered dialog. The flat page keeps the search field, workspace, candidates, and shortcut footer in stable screen regions; only the active row uses the accent role. Its search editor starts immediately after the search glyph and emits pi-tui's cursor marker, so terminal IME composition remains anchored in the field. Escape clears a non-empty query before a second Escape closes the picker. It lists the current workspace by last logged activity and searches log-backed title or id. Each candidate displays current/live/persisted state, last turn outcome, recent provider/model, durable goal phase when present, and the id as secondary text. The current session and sessions already live in this runtime remain visible but disabled. +`/resume` uses the TUI's existing interactive overlay seam as a full-viewport picker rather than a centered dialog. The flat page keeps the search field, workspace scope line, candidates, and shortcut footer in stable screen regions; only the active row uses the accent role. Its search editor starts immediately after the search glyph and emits pi-tui's cursor marker, so terminal IME composition remains anchored in the field. Escape clears a non-empty query before a second Escape closes the picker. It orders candidates by last logged activity and searches log-backed title or id. Each candidate displays current/live/persisted state, last turn outcome, recent provider/model, durable goal phase when present, and the id as secondary text. The current session and sessions already live in this runtime remain visible but disabled. The picker opens on the current workspace and reaches every other one through the scope toggle the [cross-workspace resume](2026-07-28-cross-workspace-resume.md) note owns. -`session-query.readSession()` supplies a detached complete log validated by the same core replay boundary used by resume. The TUI folds title and goal state from that log. A candidate load failure is local to that row; selecting a candidate revalidates the log, `cwd`, route, current agent's idle status, and the exclusions for the current session and sessions already live in this runtime, so a stale listing cannot bypass preflight. A missing adapter reports an intact session with an unavailable route. This preflight does not lock the target or exclude another process. +`session-query.readSession()` supplies a detached complete log validated by the same core replay boundary used by resume. The TUI folds title and goal state from that log. A candidate load failure is local to that row; selecting a candidate revalidates the log, workspace, route, current agent's idle status, and the exclusions for the current session and sessions already live in this runtime, so a stale listing cannot bypass preflight. A missing adapter reports an intact session with an unavailable route. This preflight does not lock the target or exclude another process. -After preflight, the TUI flushes the current session, confirms that its agent remains idle, then stops the terminal before calling `TuiRuntime.handoffResume`. The shipped `dsh` host disposes the root app and uses `process.execve` with a normalized `--resume` argument, atomically replacing the process rather than starting a child. The resumed app publishes the same `SessionId`; ordinary replay restores transcript, title, todos, and durable goal state. Goal activation is intentionally disarmed, and the TUI asks for human confirmation or `/goal resume`. +After preflight, the TUI flushes the current session, confirms that its agent remains idle, then stops the terminal before calling `TuiRuntime.handoffResume` with the validated id and the target workspace. The shipped `dsh` host disposes the root app and uses `process.execve` with a normalized `--resume` argument, atomically replacing the process rather than starting a child. The resumed app publishes the same `SessionId`; ordinary replay restores transcript, title, todos, and durable goal state. Goal activation is intentionally disarmed, and the TUI asks for human confirmation or `/goal resume`. -`resumeCommand` remains an exit and no-host fallback. The TUI substitutes `{session}` only for display and never executes arbitrary shell text. The exit hint still appears only after the current session is durable. +The exit line is a launcher-owned context slot rather than a config template, and a host without in-place handoff reports that the session stays resumable instead of naming a command it cannot construct; the [launcher-owned resume identity](../architecture/2026-07-28-launcher-owned-resume-identity.md) note owns that ownership move and supersedes the `resumeCommand` config key this note originally shipped. The TUI still never executes shell text. ## Alternatives considered -**Have the TUI spawn `resumeCommand`.** Rejected: the template is deployment text, not trusted argv, and the TUI does not own app teardown or process lifetime. The constrained host seam receives only a validated `SessionId`. +**Have the TUI spawn the resume command.** Rejected: the text is display copy, not trusted argv, and the TUI does not own app teardown or process lifetime. The constrained host seam receives only a validated `SessionId`. **Construct the resumed agent inside the existing TUI.** Rejected: replacing one config-created agent would cross Loader ownership, scoped plugin setup, persistence retirement, and terminal lifecycle in the presentation layer. Root disposal plus process replacement reuses the supported startup path. @@ -36,4 +36,4 @@ After preflight, the TUI flushes the current session, confirms that its agent re ## Testing -TUI tests cover keyboard navigation, title/id search, search-clear/cancel behavior, running-agent refusal, refusal of the current session and sessions already live in this runtime, route absence, corrupt rows, preflight revalidation, fallback commands, and stop-before-handoff ordering. Session-query tests pin detached full-log validation. Agent-loop resume tests pin exact identity and history; title, todo, and goal replay suites pin restored projections and disarmed goal activation. The keyless TUI snapshot owns the full-viewport selector and its IME cursor anchor, and a real PTY smoke covers search plus handoff. +TUI tests cover keyboard navigation, title/id search, search-clear/cancel behavior, running-agent refusal, refusal of the current session and sessions already live in this runtime, route absence, corrupt rows, preflight revalidation, the no-host warning, and stop-before-handoff ordering. Session-query tests pin detached full-log validation. Agent-loop resume tests pin exact identity and history; title, todo, and goal replay suites pin restored projections and disarmed goal activation. The keyless TUI snapshot owns the full-viewport selector and its IME cursor anchor, and a real PTY smoke covers search plus handoff. diff --git a/.agents/notes/implemented/feature/2026-07-21-tui-resume-command.zh.md b/.agents/notes/implemented/feature/2026-07-21-tui-resume-command.zh.md index 06e58f8144..2a7e74d110 100644 --- a/.agents/notes/implemented/feature/2026-07-21-tui-resume-command.zh.md +++ b/.agents/notes/implemented/feature/2026-07-21-tui-resume-command.zh.md @@ -10,17 +10,17 @@ Status: implemented ## Decision -`/resume` 使用 TUI 现有的交互式浮层接口,但以占满 viewport 的选择页呈现,而不是居中弹窗。这个扁平页面把搜索框、workspace、候选项和快捷键页脚放在稳定的屏幕区域,只有当前行使用强调色。搜索编辑器紧跟搜索图标起始,并输出 pi-tui 的光标标记,因此终端输入法的组合文本会锚定在输入框中。查询非空时,第一次按 Escape 会清空查询,第二次才关闭选择页。页面按日志记录的最后活动时间列出当前 workspace 的会话,并支持按日志内标题或 id 搜索。每个候选项都会显示是否为当前会话、是否活跃、是否已持久化,最近一个轮次的结果,最近使用的提供方/模型,以及可用时的持久化目标阶段;id 作为次要信息显示。当前会话和已在本运行时中处于活跃状态的会话仍会显示,但不可选择。 +`/resume` 使用 TUI 现有的交互式浮层接口,但以占满 viewport 的选择页呈现,而不是居中弹窗。这个扁平页面把搜索框、workspace 作用域行、候选项和快捷键页脚放在稳定的屏幕区域,只有当前行使用强调色。搜索编辑器紧跟搜索图标起始,并输出 pi-tui 的光标标记,因此终端输入法的组合文本会锚定在输入框中。查询非空时,第一次按 Escape 会清空查询,第二次才关闭选择页。页面按日志记录的最后活动时间排列候选项,并支持按日志内标题或 id 搜索。每个候选项都会显示是否为当前会话、是否活跃、是否已持久化,最近一个轮次的结果,最近使用的提供方/模型,以及可用时的持久化目标阶段;id 作为次要信息显示。当前会话和已在本运行时中处于活跃状态的会话仍会显示,但不可选择。选择页打开时位于当前 workspace,并通过[跨 workspace 恢复](2026-07-28-cross-workspace-resume.md)记录所拥有的作用域切换到达其他每一个 workspace。 -`session-query.readSession()` 提供一份脱离运行时的完整日志,并通过恢复流程所用的同一核心回放边界完成验证。TUI 从该日志中折叠出标题和目标状态。候选项加载失败时只影响该行;选择候选项后会复查日志、`cwd`、路由、当前 agent 的空闲状态,以及针对当前会话和已在本运行时中处于活跃状态的会话的排除规则,避免陈旧列表绕过预检。适配器缺失时会报告会话完整但路由不可用。该预检不会锁定目标,也不会排除其他进程。 +`session-query.readSession()` 提供一份脱离运行时的完整日志,并通过恢复流程所用的同一核心回放边界完成验证。TUI 从该日志中折叠出标题和目标状态。候选项加载失败时只影响该行;选择候选项后会复查日志、workspace、路由、当前 agent 的空闲状态,以及针对当前会话和已在本运行时中处于活跃状态的会话的排除规则,避免陈旧列表绕过预检。适配器缺失时会报告会话完整但路由不可用。该预检不会锁定目标,也不会排除其他进程。 -预检通过后,TUI 会刷写当前会话,再次确认其 agent 仍处于空闲状态,然后停止终端并调用 `TuiRuntime.handoffResume`。已交付的 `dsh` 宿主会释放根应用,并使用带有规范化 `--resume` 参数的 `process.execve` 原子替换当前进程,而不是启动子进程。恢复后的应用发布相同的 `SessionId`;常规回放会还原 transcript(文本记录)、标题、待办事项和持久化目标状态。系统会有意解除目标的激活状态,TUI 则要求用户确认继续或执行 `/goal resume`。 +预检通过后,TUI 会刷写当前会话,再次确认其 agent 仍处于空闲状态,然后停止终端并以经过验证的 id 和目标 workspace 调用 `TuiRuntime.handoffResume`。已交付的 `dsh` 宿主会释放根应用,并使用带有规范化 `--resume` 参数的 `process.execve` 原子替换当前进程,而不是启动子进程。恢复后的应用发布相同的 `SessionId`;常规回放会还原 transcript(文本记录)、标题、待办事项和持久化目标状态。系统会有意解除目标的激活状态,TUI 则要求用户确认继续或执行 `/goal resume`。 -`resumeCommand` 保留为退出及无宿主时的回退方案。TUI 仅为显示目的替换 `{session}`,绝不执行任意 shell 文本。只有当前会话已经持久化时,退出提示才会出现。 +退出时打印的行是启动器拥有的上下文插槽,而非配置模板;不支持原地交接的宿主会说明会话仍可恢复,而不再给出它无法构造的命令。[由启动器持有的会话身份与退出行](../architecture/2026-07-28-launcher-owned-resume-identity.md)记录了这次所有权迁移,并取代本记录最初交付的 `resumeCommand` 配置键。TUI 仍然绝不执行 shell 文本。 ## Alternatives considered -**让 TUI 创建 `resumeCommand` 进程。** 否决:该模板是部署文本,不是可信的参数列表,且 TUI 不拥有应用拆卸或进程生命周期。受约束的宿主接口只接收经过验证的 `SessionId`。 +**让 TUI 创建恢复命令进程。** 否决:该文本是展示用文案,不是可信的参数列表,且 TUI 不拥有应用拆卸或进程生命周期。受约束的宿主接口只接收经过验证的 `SessionId`。 **在现有 TUI 内构造恢复后的 agent。** 否决:在表现层替换由配置创建的 agent,会跨越 Loader 所有权、作用域插件初始化、持久化资源释放和终端生命周期。释放根应用并替换进程可以复用受支持的启动路径。 @@ -36,4 +36,4 @@ Status: implemented ## Testing -TUI 测试覆盖键盘导航、标题/id 搜索、清空搜索后再取消、agent 运行期间拒绝恢复、拒绝恢复当前会话和已在本运行时中处于活跃状态的会话、路由缺失、损坏的候选行、预检复查、回退命令,以及停止终端先于宿主交接的顺序。session-query 测试固定脱离运行时的完整日志验证。agent-loop 恢复测试固定会话身份和历史完全一致;标题、待办事项和目标回放测试套件固定这些投影均可恢复,且目标激活状态已经解除。无密钥 TUI 快照固定全屏选择页和输入法光标锚点,真实 PTY smoke 则覆盖搜索与交接。 +TUI 测试覆盖键盘导航、标题/id 搜索、清空搜索后再取消、agent 运行期间拒绝恢复、拒绝恢复当前会话和已在本运行时中处于活跃状态的会话、路由缺失、损坏的候选行、预检复查、无宿主时的告警,以及停止终端先于宿主交接的顺序。session-query 测试固定脱离运行时的完整日志验证。agent-loop 恢复测试固定会话身份和历史完全一致;标题、待办事项和目标回放测试套件固定这些投影均可恢复,且目标激活状态已经解除。无密钥 TUI 快照固定全屏选择页和输入法光标锚点,真实 PTY smoke 则覆盖搜索与交接。 diff --git a/.agents/notes/implemented/feature/2026-07-24-configurable-tui-prompt-theme.i18n.yaml b/.agents/notes/implemented/feature/2026-07-24-configurable-tui-prompt-theme.i18n.yaml index 4532ab2148..d8ac4d0dca 100644 --- a/.agents/notes/implemented/feature/2026-07-24-configurable-tui-prompt-theme.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-24-configurable-tui-prompt-theme.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write -2026-07-24-configurable-tui-prompt-theme.md: 4008f23a3f545e9b4484f0fa3f8490ad9b2c5541 -2026-07-24-configurable-tui-prompt-theme.zh.md: daf15b54d7e04d3860eacad47b754b963cde36fa +# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-24-configurable-tui-prompt-theme.md +2026-07-24-configurable-tui-prompt-theme.md: f8815c6c1904c47ebb899c3da7ac62a50f7f88f0 +2026-07-24-configurable-tui-prompt-theme.zh.md: 831471860a9dcd8bb10408c492e1fc6299af6262 diff --git a/.agents/notes/implemented/feature/2026-07-24-configurable-tui-prompt-theme.md b/.agents/notes/implemented/feature/2026-07-24-configurable-tui-prompt-theme.md index 4008f23a3f..f8815c6c19 100644 --- a/.agents/notes/implemented/feature/2026-07-24-configurable-tui-prompt-theme.md +++ b/.agents/notes/implemented/feature/2026-07-24-configurable-tui-prompt-theme.md @@ -16,7 +16,7 @@ The TUI theme groups `color`, `truecolor`, `leftPrompt`, `rightPrompt`, `inputPr Registered fragments are trusted ANSI-capable presentation output. Template literals and ordinary external content remain sanitized, but a prompt-value plugin may emit terminal controls. Composite values own coordinated background transitions and separators, so one `${powerline}` value can render a complete Powerline segment without coupling adjacent atomic providers. -The built-in `cwd`, `git/worktree`, `token_meter/cache_hit_rate`, `model`, `context`, `timing`, styled `symbol` label, and `indicator` caret values use the same registry. Session and agent events update their handles, while the running timer updates `timing` and the animated `indicator` each tick. The shipped input template is `${symbol} ${indicator}`, preserving the existing `dsh > ` prefix. +The built-in `cwd`, `git/worktree`, `token_meter/cache_hit_rate`, `model`, `context`, `queued`, styled `symbol` label, and `indicator` caret values use the same registry. Session and agent events update their handles, while the running timer updates `queued` — the steering-queue badge, unavailable unless a running turn has queued messages — and the animated `indicator` each tick. The shipped input template is `${symbol} ${indicator}`, preserving the existing `dsh > ` prefix. ## Alternatives considered diff --git a/.agents/notes/implemented/feature/2026-07-24-configurable-tui-prompt-theme.zh.md b/.agents/notes/implemented/feature/2026-07-24-configurable-tui-prompt-theme.zh.md index daf15b54d7..831471860a 100644 --- a/.agents/notes/implemented/feature/2026-07-24-configurable-tui-prompt-theme.zh.md +++ b/.agents/notes/implemented/feature/2026-07-24-configurable-tui-prompt-theme.zh.md @@ -16,7 +16,7 @@ TUI 主题把 `color`、`truecolor`、`leftPrompt`、`rightPrompt`、`inputPromp 注册的片段被视为可信的、允许携带 ANSI 的呈现输出。模板中的字面文本与普通外部内容仍会被清洗,但提供提示符值的插件可以输出终端控制序列。复合值自行负责协调背景色过渡与分隔符,因此一个 `${powerline}` 值就能渲染完整的 Powerline 段,而无需与相邻的原子提供方耦合。 -内置的 `cwd`、`git/worktree`、`token_meter/cache_hit_rate`、`model`、`context`、`timing`、带样式的 `symbol` 标签与 `indicator` 光标符值使用同一个注册表。会话与 agent(智能体)事件更新各自的句柄,运行计时器每一拍更新 `timing` 与带动画的 `indicator`。随附的输入模板为 `${symbol} ${indicator}`,保留了原有的 `dsh > ` 前缀。 +内置的 `cwd`、`git/worktree`、`token_meter/cache_hit_rate`、`model`、`context`、`queued`、带样式的 `symbol` 标签与 `indicator` 光标符值使用同一个注册表。会话与 agent(智能体)事件更新各自的句柄,运行计时器每一拍更新 `queued`——转向队列徽标,仅在运行中的一轮有排队消息时才可用——与带动画的 `indicator`。随附的输入模板为 `${symbol} ${indicator}`,保留了原有的 `dsh > ` 前缀。 ## 曾考虑的替代方案 diff --git a/.agents/notes/implemented/feature/2026-07-27-tmux-location-context.i18n.yaml b/.agents/notes/implemented/feature/2026-07-27-tmux-location-context.i18n.yaml new file mode 100644 index 0000000000..cbac9d1251 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-27-tmux-location-context.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-27-tmux-location-context.md +2026-07-27-tmux-location-context.md: b6f0b0cc85fa4808bd761f30bdbab8ad65e61717 +2026-07-27-tmux-location-context.zh.md: e79214e03296a87c622df91437385950c00eded6 diff --git a/.agents/notes/implemented/feature/2026-07-27-tmux-location-context.md b/.agents/notes/implemented/feature/2026-07-27-tmux-location-context.md new file mode 100644 index 0000000000..b6f0b0cc85 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-27-tmux-location-context.md @@ -0,0 +1,61 @@ +# Agent Note: tmux-location context + +Status: implemented + +English | [中文](2026-07-27-tmux-location-context.zh.md) + +## Problem + +An agent running inside tmux has no way to tell the model where it is: which session, window, and pane the process occupies, and how the window is laid out. A user directing several panes wants the model to orient itself to its own location so instructions like "the pane below" or "this window" resolve. The location must reach the model as durable, reconstructable context, not a system-prompt value rewritten in place, and must cost nothing when the location has not changed. + +tmux exposes this without a daemon: `$TMUX_PANE` names the process's pane, and `tmux display-message -t "$TMUX_PANE" -p ''` prints any pane/window/session field. The open question was how to observe it — pull on each preparation, or push from a tmux hook — and how to avoid a per-step token cost and hidden process-local state. + +## Decision + +`@deepseek-ai/dsh-tmux-context` is an opt-in function plugin in `packages/context/tmux-context/`, alongside the other bounded request-context enrichments that define neither a tool nor a service. Shipped examples do not mount it because tmux-location disclosure and its token cost are deployment policy. + +**Pull on the first step of each turn, not a tmux push.** The plugin prepends an `agent/step` listener and acts only when `step === 1`. A pull model needs no background process, no hook installation in the user's tmux, and no teardown; it re-reads current state each turn so a moved, renamed, or re-laid-out pane is picked up naturally. Gating on the first step makes the reading per-turn: a location is stable within a turn, and re-querying every step would add cost without new information. A pane moved mid-turn is reflected on the next turn, which is the accepted tradeoff for the simpler design. + +**Read through the `ctx.bash` seam, never raw `child_process`.** The listener runs the tmux/`ps` read commands through `ctx.bash`, so the deployment's sandbox and policy apply and the plugin owns no subprocess code. Absent `ctx.bash`, absent tmux env, a wrong field count, or an empty pane id each make the attempt a no-op, matching how `workspace-context` no-ops without an `fs` provider. + +**Detect a real pane by tty, not by `$TMUX_PANE` alone.** `$TMUX_PANE` is inherited: a terminal launched from a tmux shell (a VS Code integrated terminal, a desktop launcher) carries `$TMUX`/`$TMUX_PANE` from that ancestor even though the process does not live in that pane, which otherwise injects a stale, wrong location. The command resolves this process's controlling terminal with `ps -o tty= -p ` (the agent's own pid, passed in-process) and compares it to the pane's `#{pane_tty}`; fields are emitted only on a match. A genuine pane owns this process's tty; an inherited environment names some other pane's tty and reads as "not in tmux". Checking `$TMUX` instead does not help — it is inherited identically. This is the definitive discriminator and needs no allowlist of terminal emulators. + +**Own location and layout only.** The queried fields are session name, window index/name, pane index/id, window/pane active flags, and `window_layout`. Pane and window pixel sizes are excluded (layout tree conveys structure; sizes are noisy and change on every terminal resize). Sibling-pane contents are never captured (`capture-pane`), keeping the reading small and avoiding scraping unrelated, possibly sensitive, output. + +**Inject only on change, with optional interval floor.** When due, the plugin calls `agent.inject()` for one `user/message` with source `{ kind: 'plugin', plugin: 'tmux-context' }`. Change suppression compares the rendered state block (everything after the turn preamble line) against the latest injection of this source, found by scanning raw durable session events — so the schedule survives compaction and process resume without a process-local cache. The optional `refreshIntervalMs` (manually validated as a non-negative safe integer at plugin load) additionally suppresses injections within that window of the latest one. + +### Text + +```text +tmux location (turn ): +session , window "", pane +window active=<0|1>, pane active=<0|1>, layout +``` + +The turn preamble is the volatile first line; the two-line state block below it is the unit compared for change suppression, so re-injection is driven by tmux state, not loop position. + +### Durability and request reconstruction + +Each reading is a normal surface node until compaction shadows it; the plugin contributes nothing to system-prompt assembly and `request/header` carries no tmux-context text. The reading records a preparation attempt, not a committed step: because the prepended listener runs first, its append may remain when a later `agent/step` listener cancels or fails the attempt, and the append-only log performs no rollback. + +The published `./invariant` companion registers no runtime check: a reading is a per-turn snapshot of external tmux state, so the session holds no cross-event relation to validate, and scheduling and format stay pinned by the package's pipeline tests. + +## Consequences + +An agent booted inside tmux now receives its own session/window/pane location and window layout as durable, source-attributed context, updated per turn when the location changes. Deployments opt in through cordis.yml; the default spine and shipped examples stay silent. Outside a real tmux pane — including a terminal that merely inherited `$TMUX`/`$TMUX_PANE` — or without a `ctx.bash` executor, the plugin is inert with no error, so composing it is safe everywhere. Because the reading is one durable `user/message`, it survives compaction as ordinary history, contributes nothing to system-prompt assembly or request headers, and costs at most one two-line message per changed turn. The pull model adds one bash execution (through the sandboxed bash seam) on the first step of each turn that is due — internally a `ps` tty probe, a `tmux display-message` tty query, and the field query. Only the optional interval floor suppresses the query itself; an unchanged location is known only after querying, so it suppresses the injection alone. + +## Testing + +Unit tests pin: first-step injection and source/surface metadata; the `$TMUX_PANE`-keyed command including its `#{pane_tty}`-vs-`ps -o tty=` guard; step-gating; change suppression across turns and re-injection on a moved pane; positive-interval suppression and threshold; every no-op path (no bash, nonzero exit, wrong field count, empty pane id, aborted signal); prepended ordering before ordinary `agent/step` listeners; resilience to a corrupt prior reading (non-text block, single-line text); and config rejection of negative and non-integer intervals. Per-file coverage is 100%. + +## Alternatives considered + +- **Push from a tmux hook / background watcher** — rejected: requires installing hooks in the user's tmux and a background process with teardown, to gain mid-step freshness that per-turn context does not need. +- **Run every step** — rejected: location is stable within a turn; re-querying adds token cost without new information. Gating on `step === 1` yields per-turn readings. +- **Raw `child_process`** — rejected: bypasses the sandbox/policy seam and hand-rolls subprocess code the `ctx.bash` executor already owns. +- **Include pane/window pixel sizes** — rejected: sizes churn on every resize and add noise; the layout tree already conveys structure. +- **Scrape sibling panes with `capture-pane`** — rejected: large, noisy, and privacy-sensitive; out of scope for "own location". +- **Dynamic system-prompt section** — rejected: replacing a value erases the earlier readings behind prior reasoning and is not reconstructable; one durable attributed message records each location where it became visible. +- **Trust `$TMUX_PANE` (or `$TMUX`) presence** — rejected: both are inherited by terminals launched from a tmux shell (VS Code integrated terminal), so a non-pane process injects a stale location. The pane `#{pane_tty}` vs. this process's controlling tty is the definitive check. +- **Denylist known terminal emulators (e.g. `TERM_PROGRAM=vscode`)** — rejected: a partial, ever-growing list that still misses other launchers; the tty match is exact and launcher-agnostic. +- **A runtime invariant validating each reading's turn, position, and format** — shipped initially, then removed: it re-derived the producer's own scheduling from the log and asserted a regex over text the same package had just rendered, so it restated `apply()` rather than checking an independent relation. Every failure it could report required an edit to this package, which its pipeline tests already catch. Reintroduce a companion check only for a relation the plugin does not itself compute — for example if readings gain cross-turn ordering or enclosure obligations that another package can violate. diff --git a/.agents/notes/implemented/feature/2026-07-27-tmux-location-context.zh.md b/.agents/notes/implemented/feature/2026-07-27-tmux-location-context.zh.md new file mode 100644 index 0000000000..e79214e032 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-27-tmux-location-context.zh.md @@ -0,0 +1,61 @@ +# Agent Note:tmux 位置上下文 + +Status: implemented + +[English](2026-07-27-tmux-location-context.md) | 中文 + +## 问题 + +运行在 tmux 内的 agent 无法告诉模型自己身在何处:进程占据哪个 session、window、pane,以及 window 如何布局。当用户操作多个 pane 时,希望模型能对自身位置有所定位,从而让"下方的 pane""这个 window"之类的指令得以解析。位置必须以持久、可重建的上下文形式送达模型,而非在原地被改写的系统提示值,并且当位置未变化时不产生任何成本。 + +tmux 无需守护进程即可暴露这些信息:`$TMUX_PANE` 标识进程所在 pane,`tmux display-message -t "$TMUX_PANE" -p ''` 可打印任意 pane/window/session 字段。待决问题在于如何观测——在每次准备时拉取,还是由 tmux hook 推送——以及如何避免逐步骤 token 成本与隐藏的进程内状态。 + +## 决策 + +`@deepseek-ai/dsh-tmux-context` 是位于 `packages/context/tmux-context/` 的可选启用型函数插件,与其他既不定义工具也不定义服务的有界请求上下文增强并列。随附示例不挂载它,因为 tmux 位置披露及其 token 成本属于部署策略。 + +**在每轮的第一个 step 拉取,而非 tmux 推送。** 插件前置注册一个 `agent/step` 监听器,仅在 `step === 1` 时动作。拉取模型无需后台进程、无需在用户的 tmux 中安装 hook、也无需清理;它每轮重新读取当前状态,因此被移动、改名或重新布局的 pane 都会被自然感知。以第一个 step 为门槛使读数按轮次生成:位置在一轮内是稳定的,逐步骤重复查询只会增加成本而不带来新信息。轮次中途移动的 pane 会在下一轮反映,这是换取更简单设计所接受的取舍。 + +**通过 `ctx.bash` seam 读取,绝不用裸 `child_process`。** 监听器通过 `ctx.bash` 运行 tmux/`ps` 只读命令,从而应用部署方的沙箱与策略,插件不拥有任何子进程代码。`ctx.bash` 缺失、tmux 环境缺失、字段数不符或 pane id 为空,都会使本次尝试成为空操作,与 `workspace-context` 在无 `fs` provider 时的空操作一致。 + +**以 tty 判定真实 pane,而非仅凭 `$TMUX_PANE`。** `$TMUX_PANE` 会被继承:从 tmux shell 启动的终端(VS Code 集成终端、桌面启动器)会从该祖先进程带上 `$TMUX`/`$TMUX_PANE`,即使进程并不位于那个 pane 中,否则就会注入一个陈旧且错误的位置。命令用 `ps -o tty= -p `(在进程内传入 agent 自身的 pid)解析本进程的控制终端,并与 pane 的 `#{pane_tty}` 比较;只有匹配时才输出字段。真正的 pane 拥有本进程的 tty;继承而来的环境指向的是另一个 pane 的 tty,因而被读作"不在 tmux 中"。改为检查 `$TMUX` 也无济于事——它同样会被继承。这是决定性的判别依据,且无需维护终端模拟器名单。 + +**仅自身位置与布局。** 查询字段为 session name、window index/name、pane index/id、window/pane 活动标志以及 `window_layout`。省略 pane 与 window 像素尺寸(布局树已传达结构;尺寸嘈杂且每次终端缩放都会变化)。从不采集相邻 pane 内容(`capture-pane`),使读数保持小巧,并避免抓取无关、可能敏感的输出。 + +**仅在变化时注入,并可选间隔下限。** 需要时,插件调用 `agent.inject()` 注入一条来源为 `{ kind: 'plugin', plugin: 'tmux-context' }` 的 `user/message`。变化抑制将渲染出的状态块(轮次前缀行之后的全部内容)与该来源的最近一次注入比较,后者通过扫描原始持久会话事件获得——因此调度可跨压缩与进程恢复存续,无需进程内缓存。可选的 `refreshIntervalMs`(在插件加载时手动校验为非负安全整数)会额外抑制距最近一次注入不足该窗口的注入。 + +### 文本 + +```text +tmux location (turn ): +session , window "", pane +window active=<0|1>, pane active=<0|1>, layout +``` + +轮次前缀是易变的首行;其下的两行状态块才是变化抑制所比较的单元,因此重新注入由 tmux 状态驱动,而非循环位置。 + +### 持久性与请求重建 + +每条读数在被压缩遮蔽前都是普通表层节点;插件对系统提示装配毫无贡献,`request/header` 也不携带任何 tmux-context 文本。读数记录的是一次准备尝试,而非已提交的 step:由于前置监听器最先运行,当后续 `agent/step` 监听器取消或失败时其追加可能仍会保留,只追加的日志不做回滚。 + +发布的 `./invariant` 伴生插件不注册任何运行时检查:读数是外部 tmux 状态的按轮快照,会话中不存在需要校验的跨事件关系,调度与格式由本包的管线测试固定。 + +## 后果 + +启动于 tmux 内的 agent 现在会以持久、带来源标记的上下文收到自身的 session/window/pane 位置及 window 布局,并在位置变化时按轮次更新。部署方通过 cordis.yml 选择启用;默认 spine 与随附示例保持沉默。在真实 tmux pane 之外——包括仅继承了 `$TMUX`/`$TMUX_PANE` 的终端——或没有 `ctx.bash` 执行器时,插件保持惰性且不报错,因此在任何地方组合它都安全。由于读数是一条持久的 `user/message`,它作为普通历史经受压缩,对系统提示装配与请求头毫无贡献,且每个发生变化的轮次至多花费一条两行消息。拉取模型在每个到期轮次的第一个 step 增加一次 bash 执行(经沙箱化的 bash seam)——内部包含一次 `ps` tty 探测、一次 `tmux display-message` tty 查询和字段查询。只有可选的间隔下限会抑制查询本身;位置是否变化只有在查询之后才知道,因此它只抑制注入。 + +## 测试 + +单元测试固定了:首个 step 的注入及来源/表层元数据;以 `$TMUX_PANE` 为键的命令(含其 `#{pane_tty}` 与 `ps -o tty=` 的比对守卫);step 门槛;跨轮次的变化抑制与 pane 移动时的重新注入;正间隔抑制与阈值;每条空操作路径(无 bash、非零退出、字段数不符、pane id 为空、信号已取消);前置排序先于普通 `agent/step` 监听器;对损坏的历史读数(非文本块、单行文本)的容错;以及配置对负值与非整数间隔的拒绝。逐文件覆盖率为 100%。 + +## 考虑过的替代方案 + +- **由 tmux hook / 后台监视器推送**——否决:需要在用户的 tmux 中安装 hook,并引入带清理的后台进程,只为换取按轮次上下文并不需要的步内新鲜度。 +- **每个 step 都运行**——否决:位置在一轮内稳定;重复查询只增加 token 成本而无新信息。以 `step === 1` 为门槛得到按轮次读数。 +- **裸 `child_process`**——否决:绕过沙箱/策略 seam,并手写 `ctx.bash` 执行器已拥有的子进程代码。 +- **包含 pane/window 像素尺寸**——否决:尺寸每次缩放都变动、徒增噪声;布局树已传达结构。 +- **用 `capture-pane` 抓取相邻 pane**——否决:庞大、嘈杂且涉及隐私;超出"自身位置"范围。 +- **动态系统提示区块**——否决:替换某个值会抹去支撑先前推理的历史读数且不可重建;单条持久且带来源的消息在每个位置变得可见时予以记录。 +- **信任 `$TMUX_PANE`(或 `$TMUX`)存在即可**——否决:两者都会被从 tmux shell 启动的终端(VS Code 集成终端)继承,于是非 pane 进程会注入陈旧位置。pane 的 `#{pane_tty}` 与本进程控制终端的比对才是决定性检查。 +- **对已知终端模拟器设黑名单(如 `TERM_PROGRAM=vscode`)**——否决:名单不完整且会不断增长,仍会漏掉其他启动器;tty 比对精确且与启动器无关。 +- **用运行时 invariant 校验每条读数的轮次、位置与格式**——最初随包发布,随后移除:它从日志中重新推导生产者自身的调度,并对同一个包刚刚渲染出的文本断言正则,因此只是重述 `apply()`,而非检查一条独立关系。它能报出的每种失败都必须先修改本包,而这些本包的管线测试已经覆盖。仅当出现插件自身并不计算的关系时才重新引入伴生检查——例如读数将来具备可被其他包破坏的跨轮次顺序或包裹义务。 diff --git a/.agents/notes/implemented/feature/2026-07-27-tui-tool-card-header.i18n.yaml b/.agents/notes/implemented/feature/2026-07-27-tui-tool-card-header.i18n.yaml index 820645dc95..ed6406245e 100644 --- a/.agents/notes/implemented/feature/2026-07-27-tui-tool-card-header.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-27-tui-tool-card-header.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-27-tui-tool-card-header.md -2026-07-27-tui-tool-card-header.md: 0868d8dcaf5ab1641a122ecda05578aef46f5f94 -2026-07-27-tui-tool-card-header.zh.md: 71db5fc6fc853039ea9be1cb1c51686f941c4cb7 +2026-07-27-tui-tool-card-header.md: 13f5e7fec1a82d02d5bf8cae4784505c02ab6f38 +2026-07-27-tui-tool-card-header.zh.md: 85f9f1244a7da568f5dcc892729021ac0fa270c9 diff --git a/.agents/notes/implemented/feature/2026-07-27-tui-tool-card-header.md b/.agents/notes/implemented/feature/2026-07-27-tui-tool-card-header.md index 0868d8dcaf..13f5e7fec1 100644 --- a/.agents/notes/implemented/feature/2026-07-27-tui-tool-card-header.md +++ b/.agents/notes/implemented/feature/2026-07-27-tui-tool-card-header.md @@ -24,7 +24,7 @@ The redesign is TUI-only. It touches `ToolCardComponent` in `packages/ui/tui/src **Keep the presenter title in the header** (e.g. `Tool / read / Read src/index.ts`). Rejected: the verb duplicates the tool name, and non-bash tools have no genuinely distinct one-line description — the target belongs in the body, so only bash contributes a header desc. -**A summary footer for every card type** (line counts, exit pills, diff counts as a uniform `└ …` line). Deferred: only the diff footer shipped. Terminal exit keeps its existing dim `[exit N]` line, long output keeps its existing head+tail middle-elision, an empty result stays header-only, and an error body stays plain (only the header color carries the error) — the current treatments were kept deliberately, not by omission. +**A summary footer for every card type** (line counts, exit pills, diff counts as a uniform `└ …` line). Deferred: only the diff footer shipped. Terminal exit keeps its existing dim `[exit N]` line, long output keeps its existing head+tail middle-elision, an empty result stays header-only, and an error body stays plain (only the header color carries the error) — the current treatments were kept deliberately, not by omission. The body's flat default-foreground styling was later revisited: the [consolidated TUI presentation](../architecture/2026-07-28-consolidated-tui-presentation.md) recesses the whole body into one dim tone under this note's colored status header. ## Consequences diff --git a/.agents/notes/implemented/feature/2026-07-27-tui-tool-card-header.zh.md b/.agents/notes/implemented/feature/2026-07-27-tui-tool-card-header.zh.md index 71db5fc6fc..85f9f1244a 100644 --- a/.agents/notes/implemented/feature/2026-07-27-tui-tool-card-header.zh.md +++ b/.agents/notes/implemented/feature/2026-07-27-tui-tool-card-header.zh.md @@ -24,7 +24,7 @@ TUI 曾把每次工具调用渲染为 `{glyph} {title}`,其中 `title` 是 pre **把 presenter 标题保留在表头**(例如 `Tool / read / Read src/index.ts`)。已否决:动词与工具名重复,而非 bash 工具并没有真正独立的单行描述——操作对象属于正文,因此只有 bash 向表头贡献描述段。 -**为每一种卡片都加一条汇总页脚**(行数、退出码徽章、diff 计数统一为一条 `└ …` 行)。已推迟:仅 diff 页脚落地。终端退出保留其既有的变暗 `[exit N]` 行,长输出保留其既有的首尾中段省略,空结果保持仅表头,错误正文保持朴素(仅表头颜色承载错误)——这些既有处理是有意保留的,而非遗漏。 +**为每一种卡片都加一条汇总页脚**(行数、退出码徽章、diff 计数统一为一条 `└ …` 行)。已推迟:仅 diff 页脚落地。终端退出保留其既有的变暗 `[exit N]` 行,长输出保留其既有的首尾中段省略,空结果保持仅表头,错误正文保持朴素(仅表头颜色承载错误)——这些既有处理是有意保留的,而非遗漏。正文原本以默认前景色平铺,这种样式后来也经过调整:[整合后的 TUI 呈现](../architecture/2026-07-28-consolidated-tui-presentation.md)把整个正文收进本文所述彩色状态标题之下的同一种暗色调。 ## Consequences diff --git a/.agents/notes/implemented/feature/2026-07-28-cross-workspace-resume.i18n.yaml b/.agents/notes/implemented/feature/2026-07-28-cross-workspace-resume.i18n.yaml new file mode 100644 index 0000000000..60285a26b0 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-28-cross-workspace-resume.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-28-cross-workspace-resume.md +2026-07-28-cross-workspace-resume.md: 09b638398ea9379d39df94fcb42da3395cdd70df +2026-07-28-cross-workspace-resume.zh.md: 5a2e7d2535c07b4ace0416b234dc28b32cbcd2fc diff --git a/.agents/notes/implemented/feature/2026-07-28-cross-workspace-resume.md b/.agents/notes/implemented/feature/2026-07-28-cross-workspace-resume.md new file mode 100644 index 0000000000..09b638398e --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-28-cross-workspace-resume.md @@ -0,0 +1,52 @@ +# Agent Note: Cross-workspace session resume + +Status: implemented + +English | [中文](2026-07-28-cross-workspace-resume.zh.md) + +## Problem + +`/resume` could only reach sessions started in the launch directory, so returning to yesterday's work in another project meant remembering its path, leaving the TUI, and relaunching there. Two independent causes produced that limit, and fixing either alone changes nothing. + +Storage was the binding one. The shipped `tui-demo` bundle defaulted `persistenceRoot` to a relative `./.sessions`, so each launch directory owned a disjoint JSONL root and a disjoint derived `session-query.db`. Sessions from another project were not filtered out of the listing — they were absent from the store the listing reads. The JSONL backend already partitions per-cwd *inside* one root, so the partitioning was doubled: once by root, once within it. + +The picker then filtered again. It dropped records whose `cwd` differed from the current session before display, and `summarizeResumeCandidate` independently marked a differing `cwd` as `disabledReason: 'different workspace'`, so a foreign session that did reach the store was both hidden and refused. + +Finally, resume never changed directory. The host re-execs `dsh --resume=` through `process.execve`, which inherits the cwd. Session *header* cwd is restored from the log, but process cwd is what `dsh-fs-local`, the bash executor, and glob/grep resolve against, so resuming a foreign session would have replayed its transcript while acting on the wrong project. + +## Decision + +The dsh launcher supplies one session root under its Harness home through a boot slot, the picker gains a workspace scope, and the handoff carries the target directory. + +**Storage.** `dsh-paths` owns the location as `resolveSessionsRoot()` (`sessions` under the Harness home, by `resolveDshHome`'s precedence), but only the launcher assumes it: shared-store policy is the dsh CLI's, never a plugin's. The TUI surface provides the root through the `SESSIONS_ROOT_KEY` boot slot (`ctx.provide` before Loader entries mount) and `dsh web` patches the same root in `apps/cli/src/app-cli-entry.ts`. Two CLI surfaces computing that path independently is exactly the failure this change fixes — disjoint stores — so the fact gets one home rather than a `join` per caller, alongside the existing `registryRoot()` precedent for `run`. + +`tui-demo` itself keeps a project-local `./.sessions` default and reads the launcher slot between explicit config and that default (`config.persistenceRoot ?? ctx.get(SESSIONS_ROOT_KEY) ?? './.sessions'`). The precedence lives in `composeTuiApp`, not as a schemastery `.default()`, because a schema default would materialize before the compose function runs and shadow the slot for every Loader mount. `examples/tui-agent/cordis.yml` omits `persistenceRoot` so the launcher slot (or, for a bare example boot, the project-local default) applies. Configuring an explicit root always wins, which remains the correct choice for a hermetic deployment. + +**Scope, not exclusion.** A workspace other than the current one is a display scope rather than a disabled reason. `showResume()` summarizes every record and the `ResumePicker` owns a `scope` of `'workspace' | 'all'`, defaulting to the current workspace so the common case is unchanged. Tab toggles; the scope line names the active scope and the count the other holds; each row in the all-workspaces scope reports its own workspace, and that label joins the searchable text only in the scope that shows it. A toggle clears the query and selection so the highlighted row always belongs to the visible list, and the per-row workspace line makes a row one terminal row taller in that scope, which the visible-count budget accounts for. + +`summarizeResumeCandidate` therefore drops `'different workspace'` and gains `'session has no recorded workspace'`. That is a real new refusal rather than a rename: a header without `cwd` names no directory for the host to enter, so it cannot be handed off even though its log is intact. + +**Handoff.** `TuiResumeHost.handoff` takes the target `cwd` beside the `SessionId`. `preflightResume` resolves both together and returns them, so the caller cannot re-derive a stale directory from the row it displayed — a record whose `cwd` moved between listing and preflight is resumed in the *re-read* directory, which is why the former "reject a moved cwd" behavior is now a handoff with the new path. The shipped host chdirs before disposing the app: an unreachable directory must reject while the caller can still restore the terminal, because after teardown no owner remains to report to. `resumeArgs` keeps the `meta` subcommand form only when the target is this checkout, since `dsh meta` chdirs to the harness source itself and would override any other workspace. + +## Alternatives considered + +**Patch `persistenceRoot` from the `dsh` launcher instead of changing the bundle default.** Rejected after finding that a loader patch assigns `config` wholesale. The personal `~/.dsh/config.yaml` overlay already patches the `tui-agent` row with a partial config, which is exactly why `persistenceRoot` was falling back to the bundle default in the first place; a launcher patch would either be erased by that overlay or have to win over it and make the overlay unable to set the field. Owning the default in the bundle survives any partial patch and keeps one home for the fact. + +**Keep `./.sessions` and additionally scan the Harness-home root.** Rejected: two roots means two SQLite indexes and a merged listing whose rows have different liveness and revision authorities, to preserve visibility of logs that the no-migration decision already gives up. + +**Migrate existing project-local logs into the shared root.** Rejected by the requester. Sessions under a project's `./.sessions` stay on disk and stay resumable by explicit `dsh --resume ` from that directory, but no longer appear in `/resume`. + +**One flat list of every workspace.** Rejected: it loses the "this project" default that the overwhelmingly common case wants, and in a busy home directory the current project's sessions would compete with unrelated ones. + +**Let the host infer the directory from the restored session header.** Rejected: the header is model- and prompt-facing state restored *after* boot, while the directory must be entered *before* `execve`. Passing it explicitly keeps the ordering visible at the seam. + +## Consequences + +- Sessions already stored under a project-local `./.sessions` disappear from `/resume`. This is the accepted cost of no migration. +- One shared root makes the pre-existing absence of a cross-process session lock reachable in one step: colliding used to require two terminals in the same directory, and is now one Tab away. `record.live` comes from the in-process `SessionQueryService`, so preflight rejects only sessions live in *this* runtime, while the JSONL backend takes no lock and two processes appending one log with independent `seq` counters would interleave. Closing this is no longer speculative hardening: `SessionRegistry.list()` already publishes live sessions cross-process under the same Harness home for `dsh list-sessions`, so consulting it in `summarizeResumeCandidate` is a small follow-up. It stays out of this change as pre-existing scope. +- A resumed session can change the process's working directory, so a foreign resume is not a pure transcript restoration — every path-resolving tool moves with it. +- The Harness home now holds session logs for every project on the machine. Its growth is no longer bounded by one checkout, and no retention policy is introduced here. + +## Testing + +TUI tests cover the default scope hiding other workspaces while reporting their count, Tab revealing them with per-row workspace labels, Tab back clearing the query and selection, searching by workspace label, a cwd-less record staying visible but disabled, and the handoff receiving both the id and the workspace re-read at preflight. The former "reject a moved cwd" case now asserts the handoff carries the new directory. `dsh-paths` tests pin `resolveSessionsRoot`'s precedence against `resolveDshHome`'s. `tui-demo` composition tests pin the project-local default and the derived `session-query.db` path. The keyless TUI snapshot pins both scopes of the selector, including the scope line, the per-row workspace lines, and the Tab hint in the footer. A manual cross-workspace resume verified at the process level that the replacement's working directory became the target workspace. diff --git a/.agents/notes/implemented/feature/2026-07-28-cross-workspace-resume.zh.md b/.agents/notes/implemented/feature/2026-07-28-cross-workspace-resume.zh.md new file mode 100644 index 0000000000..5a2e7d2535 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-28-cross-workspace-resume.zh.md @@ -0,0 +1,52 @@ +# Agent Note: 跨 workspace 会话恢复 + +Status: implemented + +[English](2026-07-28-cross-workspace-resume.md) | 中文 + +## Problem + +`/resume` 只能触达在启动目录中创建的会话,因此要回到昨天在另一个项目里的工作,就得记住它的路径、退出 TUI、再到那里重新启动。造成这一限制的原因有两个,彼此独立,只修其中一个都不会有任何变化。 + +存储是那个决定性的原因。已交付的 `tui-demo` 组合包把 `persistenceRoot` 默认成相对路径 `./.sessions`,于是每个启动目录都独占一份互不相交的 JSONL 根目录,以及一份互不相交的派生 `session-query.db`。来自另一个项目的会话并不是在列表中被过滤掉的——它们根本不存在于列表读取的存储中。JSONL 后端本来就会在*同一个*根目录*内部*按 cwd 分区,所以分区被叠加了两层:一层按根目录,一层在根目录内部。 + +接着选择器又过滤了一次。它在展示前丢弃 `cwd` 与当前会话不同的记录,而 `summarizeResumeCandidate` 又独立地把不同的 `cwd` 标记为 `disabledReason: 'different workspace'`,于是一个确实进入了存储的外部会话既被隐藏,也会被拒绝。 + +最后,恢复流程从不切换目录。宿主通过 `process.execve` 重新执行 `dsh --resume=`,而它会继承 cwd。会话*头部*的 cwd 会从日志中还原,但 `dsh-fs-local`、bash 执行器以及 glob/grep 解析路径时依据的是进程 cwd,所以恢复一个外部会话会在回放它的 transcript(文本记录)的同时,作用到错误的项目上。 + +## Decision + +dsh 启动器通过启动槽位提供其 Harness home 下的同一个会话根目录,选择器获得 workspace 范围,交接过程携带目标目录。 + +**存储。** `dsh-paths` 以 `resolveSessionsRoot()` 拥有该位置(按 `resolveDshHome` 的优先级,取 Harness home 下的 `sessions`),但只有启动器假定它:共享存储策略属于 dsh CLI,绝不属于插件。TUI 界面通过 `SESSIONS_ROOT_KEY` 启动槽位(在 Loader 条目挂载前 `ctx.provide`)提供该根目录,`dsh web` 则在 `apps/cli/src/app-cli-entry.ts` 中为同一根目录打补丁。CLI 的两处界面各自独立计算该路径,正是本次改动所修复的那种失败——互不相交的存储——因此这项事实只有一个归属,而不是每个调用方各做一次 `join`,这与 `run` 已有的 `registryRoot()` 先例一致。 + +`tui-demo` 自身保持项目本地的 `./.sessions` 默认值,并在显式配置与该默认值之间读取启动器槽位(`config.persistenceRoot ?? ctx.get(SESSIONS_ROOT_KEY) ?? './.sessions'`)。这一优先级放在 `composeTuiApp` 内,而不是写成 schemastery 的 `.default()`,因为 schema 默认值会在 compose 函数运行前物化,使每次 Loader 挂载都遮蔽该槽位。`examples/tui-agent/cordis.yml` 不写 `persistenceRoot`,因此启动器槽位(裸示例启动时则为项目本地默认值)生效。显式配置的根目录总是获胜,对于封闭部署来说这仍然是正确的选择。 + +**是范围,不是排除。** 当前 workspace 之外的 workspace 是一种展示范围,而不是禁用理由。`showResume()` 汇总每一条记录,`ResumePicker` 持有一个 `'workspace' | 'all'` 的 `scope`,默认为当前 workspace,因此常见场景毫无变化。Tab 切换范围;范围行会说明当前生效的范围,以及另一个范围下的数量;在全 workspace 范围中每一行都报告自己的 workspace,而该标签只在展示它的范围里才加入可搜索文本。切换范围会清空查询和选中项,使高亮行始终属于可见列表;而逐行的 workspace 行会让该范围下的每一行在终端里多占一行,可见条数预算已经把这一点计入。 + +因此 `summarizeResumeCandidate` 去掉了 `'different workspace'`,并新增 `'session has no recorded workspace'`。这是一条真正新增的拒绝理由,而不是改名:没有 `cwd` 的头部没有指明任何目录供宿主进入,所以即便它的日志完好也无法完成交接。 + +**交接。** `TuiResumeHost.handoff` 在 `SessionId` 之外还接收目标 `cwd`。`preflightResume` 把两者一起解析并一起返回,因此调用方无法从它展示过的那一行里重新推导出一个陈旧目录——在列表展示与预检之间 `cwd` 发生了移动的记录,会在*重新读取到的*目录中恢复,这也是原先「拒绝已移动的 cwd」的行为如今变成携带新路径完成交接的原因。已交付的宿主在释放应用之前切换目录:不可达的目录必须在调用方还能恢复终端时就拒绝,因为拆卸之后已经没有任何所有者可供汇报。`resumeArgs` 只在目标就是本 checkout 时才保留 `meta` 子命令形式,因为 `dsh meta` 会切换到 harness 源码本身,从而覆盖任何其他 workspace。 + +## Alternatives considered + +**从 `dsh` 启动器给 `persistenceRoot` 打补丁,而不是改动组合包默认值。** 在发现 loader 补丁会整体赋值 `config` 之后否决。个人的 `~/.dsh/config.yaml` 覆盖层已经用一份局部配置给 `tui-agent` 那一项打了补丁,这恰恰就是 `persistenceRoot` 一开始会退回到组合包默认值的原因;启动器补丁要么会被该覆盖层擦除,要么必须压过它,从而让覆盖层再也无法设置这个字段。把默认值放在组合包里能经受任何局部补丁,并让这项事实只有一个归属。 + +**保留 `./.sessions`,并额外扫描 Harness home 根目录。** 否决:两个根目录意味着两份 SQLite 索引,以及一份合并列表——其中各行的活跃状态与版本权威来源并不相同,而这一切只是为了保住不做迁移的决策本就已经放弃的那部分日志可见性。 + +**把现有的项目本地日志迁移到共享根目录。** 被需求方否决。项目 `./.sessions` 下的会话仍留在磁盘上,从该目录显式执行 `dsh --resume ` 仍可恢复,只是不再出现在 `/resume` 中。 + +**把所有 workspace 铺成一个扁平列表。** 否决:这会丢掉绝大多数场景想要的「本项目」默认值,而在一个繁忙的 home 目录里,当前项目的会话会和无关会话争夺注意力。 + +**让宿主从还原后的会话头部推断目录。** 否决:会话头部是面向模型与提示词的状态,在启动*之后*才还原,而目录必须在 `execve` *之前*进入。显式传递它能让这个顺序在边界处保持可见。 + +## Consequences + +- 已经存放在项目本地 `./.sessions` 下的会话会从 `/resume` 中消失。这是不做迁移所接受的代价。 +- 同一个共享根目录让原本就缺失的跨进程会话锁一步之内即可触达:过去要造成冲突需要在同一个目录里开两个终端,如今只差一次 Tab。`record.live` 来自进程内的 `SessionQueryService`,因此预检只会拒绝在*本*运行时中处于活跃状态的会话,而 JSONL 后端不加任何锁,两个进程用各自独立的 `seq` 计数器追加同一份日志会互相交错。解决这一点已不再是投机性加固:`SessionRegistry.list()` 已经为 `dsh list-sessions` 在同一个 Harness home 下跨进程发布活跃会话,因此在 `summarizeResumeCandidate` 中查询它是一项小的后续工作。它作为既有范围之外的问题不纳入本次改动。 +- 恢复一个会话可以改变进程的工作目录,因此恢复外部会话不是单纯的 transcript 还原——每个解析路径的工具都会随之移动。 +- Harness home 现在保存着这台机器上每个项目的会话日志。它的增长不再受单个 checkout 约束,而本记录也没有引入任何保留策略。 + +## Testing + +TUI 测试覆盖默认范围隐藏其他 workspace 但报告其数量、Tab 显示它们并带上逐行 workspace 标签、再按 Tab 返回时清空查询与选中项、按 workspace 标签搜索、无 cwd 的记录仍可见但不可选,以及交接同时收到 id 和在预检时重新读取到的 workspace。原先「拒绝已移动的 cwd」的用例现在断言交接携带新目录。`dsh-paths` 测试固定 `resolveSessionsRoot` 的优先级与 `resolveDshHome` 的一致。`tui-demo` 组合测试固定项目本地默认值以及派生出的 `session-query.db` 路径。无密钥 TUI 快照固定选择器的两个范围,包括范围行、逐行 workspace 行,以及页脚中的 Tab 提示。手动执行的一次跨 workspace 恢复在进程层面验证了替换后进程的工作目录变为目标 workspace。 diff --git a/.agents/notes/implemented/feature/2026-07-28-dsh-guided-skill-session-commands.i18n.yaml b/.agents/notes/implemented/feature/2026-07-28-dsh-guided-skill-session-commands.i18n.yaml new file mode 100644 index 0000000000..7e8ca82fae --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-28-dsh-guided-skill-session-commands.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-28-dsh-guided-skill-session-commands.md +2026-07-28-dsh-guided-skill-session-commands.md: 338629f5a1adb9c1973daf87f2f52479bd70ba47 +2026-07-28-dsh-guided-skill-session-commands.zh.md: a9a8a70212dd92b9c850a529789f4e4879838090 diff --git a/.agents/notes/implemented/feature/2026-07-28-dsh-guided-skill-session-commands.md b/.agents/notes/implemented/feature/2026-07-28-dsh-guided-skill-session-commands.md new file mode 100644 index 0000000000..338629f5a1 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-28-dsh-guided-skill-session-commands.md @@ -0,0 +1,43 @@ +# Agent Note: `dsh migrate`/`dsh upgrade` seed the first turn with a skill + +Status: implemented + +English | [中文](2026-07-28-dsh-guided-skill-session-commands.zh.md) + +## Problem + +Two recurring flows begin with the user manually invoking one skill and answering its questions: migrating from another coding agent, and upgrading this checkout. Both require the user to know the skill exists and to type `/skill:dsh-migrate` or `/skill:dsh-upgrade` as the session's first turn. A dedicated entry command that drops the user straight into that guided session removes the discovery step. + +## Decision + +`dsh migrate` and `dsh upgrade` boot the ordinary TUI as a fresh session whose first turn auto-invokes a bundled skill (`dsh-migrate`, `dsh-upgrade`), exactly as if the user typed `/skill:` and pressed Enter. + +The seed reuses the existing TUI skill path, not a new one. `createTuiChat` already has `invokeSkill(name, instructions)` — the code a typed `/skill:` runs, including the "Unknown skill" notice. The launcher passes the skill name to the app through a new boot-context slot `INITIAL_SKILL_KEY` (`tuiInitialSkill`), mirroring `MAIN_SESSION_ID_KEY`/`TUI_GOODBYE_MESSAGE_KEY`: `ctx.provide` is the only channel from launcher argv into a Loader-mounted plugin. The TUI's `apply()` reads the slot and folds it into `config.initialSkill`; after `ui.start()` succeeds, `createTuiChat` fires `invokeSkill(config.initialSkill, '')` once when set. + +**Freshness is gated in the launcher, not the TUI.** `runSkillSession` always mints a fresh session and provides the slot only when `resumeSessionId === undefined`, so a later `dsh --resume ` of that session is an ordinary TUI session with no re-injection. The TUI stays generic: it invokes whatever skill it is handed, once, at startup. + +**`migrate`/`upgrade` take no options.** Unlike `meta`, they carry no `--resume`, `--config`, or `-p`; a guided fresh-session entry has nothing to resume or reconfigure. Any leaked default-surface option fails loud, matching the `web`/`meta` rejection pattern in the Commander adapter. The two modes share one `SkillSessionInvocation` discriminant (`mode: 'migrate' | 'upgrade'`); `bin.ts` maps the mode to `dsh-${mode}`. + +The `dsh-migrate` skill is bundled under `skills/` (shipped through `DSH_BUNDLED_SKILL_DIR`, like `dsh-upgrade`). It asks which source agent (opencode/pi/Claude Code/Codex) if unstated, then maps each capability — workspace instructions, personal overlay, skills, hooks, MCP, API/env — to its DSH equivalent, grounded in the actual repo surfaces (the `hooks-claude`/`hooks-codex` bridges, `~/.dsh/{config.yaml,.env,AGENTS.md,skills/}`, `AGENTS.md`/`CLAUDE.md`, `mcporter`), and states plainly when a capability has no equivalent. + +## Testing + +`apps/cli/tests/args.spec.ts` gains routing for `migrate`/`upgrade` (bare discriminant) and exit-1 for every leaked option on either side of each subcommand. + +`packages/ui/tui/tests/tui.spec.ts` gains two fake-terminal cases in the existing skill describe block: `config.initialSkill` set delivers the rendered skill body as the first turn with no user input, and an unknown initial skill reports a notice without sending. `runSkillSession` itself is composition inside the module's `v8 ignore` block, like `runTui`/`runMeta`. + +No keyless PTY snapshot: per the maintainer's scope call for this change, unit coverage plus interactive verification suffices, and the seed rides the already-snapshotted `/skill:` render path. Both commands were verified interactively in tmux from a scratch cwd: `dsh migrate` loaded `dsh-migrate` and asked which source agent; `dsh upgrade` loaded `dsh-upgrade`, which pulled in `dsh-customize` and began checkout discovery. + +## Alternatives considered + +**Prefill the input and let the user press Enter.** Rejected: needs a new editor-prefill seam and still requires a keystroke. Auto-submit reuses `invokeSkill` and delivers the intended one-command entry. + +**Seed a natural-language instruction ("use the dsh-migrate skill…") instead of `/skill:`.** Rejected here: the literal skill-invocation path renders the skill body into the first turn deterministically, identical to the manual command, rather than depending on the model choosing to load the skill. + +**Support `--resume` on `migrate`/`upgrade`.** Rejected: these are one-shot guided entries. A resumed session is an ordinary TUI session reachable through the default surface's `dsh --resume `; re-injecting the skill on resume would duplicate the first turn. + +**Read `INITIAL_SKILL_KEY` in the app bundle (like `MAIN_SESSION_ID_KEY`) rather than in the TUI's `apply()`.** Not needed: `initialSkill` is a TUI `Config` field consumed in `createTuiChat`, so folding the slot into config at the TUI entry keeps it beside the other launcher-owned runtime reads (`tuiResumeHost`, `tuiGoodbyeMessage`) and leaves the app bundle unchanged. + +## Consequences + +Migrating or upgrading is one command from anywhere, with the guiding skill already invoked. The launcher→TUI initial-skill slot is reusable by any future guided-session command; the TUI's contract is "invoke this named skill once at startup," and freshness/resume policy stays with the launcher that owns session identity. The [TUI skill slash command](2026-07-21-tui-skill-slash-command.md) remains the mechanism; this note adds a launcher-driven auto-invocation of it and does not supersede it. diff --git a/.agents/notes/implemented/feature/2026-07-28-dsh-guided-skill-session-commands.zh.md b/.agents/notes/implemented/feature/2026-07-28-dsh-guided-skill-session-commands.zh.md new file mode 100644 index 0000000000..a9a8a70212 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-28-dsh-guided-skill-session-commands.zh.md @@ -0,0 +1,43 @@ +# Agent Note:`dsh migrate`/`dsh upgrade` 以 skill 播种首轮 + +Status: implemented + +[English](2026-07-28-dsh-guided-skill-session-commands.md) | 中文 + +## 问题 + +有两个反复出现的流程都以用户手动调用某个 skill 并回答其问题开始:从其他编码 agent 迁移,以及升级本 checkout。二者都要求用户知道该 skill 存在,并把 `/skill:dsh-migrate` 或 `/skill:dsh-upgrade` 作为会话首轮键入。一个专用入口命令若能让用户直接进入该引导式会话,便可省去这一发现步骤。 + +## 决策 + +`dsh migrate` 与 `dsh upgrade` 以全新会话启动普通 TUI,其首轮自动调用一个内置 skill(`dsh-migrate`、`dsh-upgrade`),效果等同于用户键入 `/skill:` 并回车。 + +播种复用现有的 TUI skill 路径,而非新增一条。`createTuiChat` 已有 `invokeSkill(name, instructions)`——即键入 `/skill:` 所走的代码,包含“未知 skill”通知。启动器通过一个新的启动上下文槽 `INITIAL_SKILL_KEY`(`tuiInitialSkill`)把 skill 名称传给应用,与 `MAIN_SESSION_ID_KEY`/`TUI_GOODBYE_MESSAGE_KEY` 一致:`ctx.provide` 是从启动器 argv 进入 Loader 挂载插件的唯一通道。TUI 的 `apply()` 读取该槽并折叠进 `config.initialSkill`;`ui.start()` 成功后,`createTuiChat` 在其被设置时调用一次 `invokeSkill(config.initialSkill, '')`。 + +**新鲜性在启动器而非 TUI 中把关。** `runSkillSession` 总是创建全新会话,且仅在 `resumeSessionId === undefined` 时提供该槽,因此之后 `dsh --resume ` 恢复该会话时是普通 TUI 会话,不会重复注入。TUI 保持通用:它只是把接到的 skill 在启动时调用一次。 + +**`migrate`/`upgrade` 不接受任何选项。** 与 `meta` 不同,它们不带 `--resume`、`--config` 或 `-p`;引导式全新会话入口没有可恢复或可重配置的内容。任何泄漏的默认界面选项都会明确报错,与 Commander 适配器中 `web`/`meta` 的拒绝模式一致。两个 mode 共用一个 `SkillSessionInvocation` 判别式(`mode: 'migrate' | 'upgrade'`);`bin.ts` 将 mode 映射为 `dsh-${mode}`。 + +`dsh-migrate` skill 内置于 `skills/`(经 `DSH_BUNDLED_SKILL_DIR` 交付,与 `dsh-upgrade` 相同)。若未说明源 agent,它会先询问是哪个(opencode/pi/Claude Code/Codex),再把每项能力——workspace 指令、个人覆盖、skills、hooks、MCP、API/env——映射到对应的 DSH 等价物,并基于仓库实际的表面(`hooks-claude`/`hooks-codex` 桥、`~/.dsh/{config.yaml,.env,AGENTS.md,skills/}`、`AGENTS.md`/`CLAUDE.md`、`mcporter`)落地;当某能力无等价物时明确说明。 + +## 测试 + +`apps/cli/tests/args.spec.ts` 新增 `migrate`/`upgrade` 的路由(裸判别式),以及每个子命令两侧任一泄漏选项的退出码 1。 + +`packages/ui/tui/tests/tui.spec.ts` 在既有 skill describe 块中新增两个伪终端用例:设置 `config.initialSkill` 时无需用户输入即把渲染后的 skill 正文作为首轮投递;未知的初始 skill 以通知形式报告且不发送。`runSkillSession` 本身是模块 `v8 ignore` 块内的组装,与 `runTui`/`runMeta` 相同。 + +无 keyless PTY 快照:依据维护者对本次改动的范围裁定,单元覆盖加交互式验证已足够,且播种走的是已有快照的 `/skill:` 渲染路径。两个命令均已在 tmux 中从临时 cwd 交互式验证:`dsh migrate` 加载 `dsh-migrate` 并询问源 agent;`dsh upgrade` 加载 `dsh-upgrade`,后者引入 `dsh-customize` 并开始 checkout 发现。 + +## 考虑过的替代方案 + +**预填输入框并让用户按回车。** 已否决:需要新增编辑器预填 seam,且仍需一次按键。自动提交复用 `invokeSkill`,实现预期的一命令入口。 + +**播种自然语言指令(“使用 dsh-migrate skill……”)而非 `/skill:`。** 在此否决:字面 skill 调用路径会确定性地把 skill 正文渲染进首轮,与手动命令完全一致,而不依赖模型自行选择加载该 skill。 + +**在 `migrate`/`upgrade` 上支持 `--resume`。** 已否决:它们是一次性引导入口。恢复的会话是可经默认界面 `dsh --resume ` 到达的普通 TUI 会话;恢复时重新注入 skill 会重复首轮。 + +**在应用 bundle 中读取 `INITIAL_SKILL_KEY`(像 `MAIN_SESSION_ID_KEY` 那样)而非在 TUI 的 `apply()` 中。** 无此必要:`initialSkill` 是在 `createTuiChat` 中消费的 TUI `Config` 字段,因此在 TUI 入口处把该槽折叠进 config,可与其他启动器拥有的运行时读取(`tuiResumeHost`、`tuiGoodbyeMessage`)并列,且无需改动应用 bundle。 + +## 后果 + +迁移或升级从任何位置都只需一条命令,且引导 skill 已被调用。启动器→TUI 的初始 skill 槽可被未来任何引导式会话命令复用;TUI 的契约是“在启动时调用一次这个具名 skill”,而新鲜性/恢复策略留在拥有会话身份的启动器一侧。[TUI skill 斜杠命令](2026-07-21-tui-skill-slash-command.md)仍是该机制;本 note 在其之上新增了一个由启动器驱动的自动调用,并未取代它。 diff --git a/.agents/notes/implemented/feature/2026-07-28-dsh-meta-source-workspace.i18n.yaml b/.agents/notes/implemented/feature/2026-07-28-dsh-meta-source-workspace.i18n.yaml new file mode 100644 index 0000000000..1a7c941216 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-28-dsh-meta-source-workspace.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-28-dsh-meta-source-workspace.md +2026-07-28-dsh-meta-source-workspace.md: d65e0e6ff092b63931dd58c52fa76fb76a071dff +2026-07-28-dsh-meta-source-workspace.zh.md: 72a0c65e6eeda7d63dccc2306a3d7625cdb54362 diff --git a/.agents/notes/implemented/feature/2026-07-28-dsh-meta-source-workspace.md b/.agents/notes/implemented/feature/2026-07-28-dsh-meta-source-workspace.md new file mode 100644 index 0000000000..d65e0e6ff0 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-28-dsh-meta-source-workspace.md @@ -0,0 +1,49 @@ +# Agent Note: `dsh meta` boots the TUI over the harness checkout + +Status: implemented + +English | [中文](2026-07-28-dsh-meta-source-workspace.zh.md) + +## Problem + +`dsh` treats the invoking directory as the workspace, which is what makes it useful on arbitrary projects. Working on dsh itself therefore means `cd`-ing to the checkout first — and the checkout is not a memorable path: the source install keeps it under a container directory as a timestamped staging worktree (`~/.dsh/source/staging-`) behind a `current` symlink, so the target moves on every upgrade. The agent is already *told* where its source lives by the `harness:source` prompt section, and the `cordis` toolset can modify that runtime, but the human still had to locate the directory by hand to start a session there. + +## Decision + +`dsh meta` boots the ordinary TUI with the harness checkout as the workspace, from any directory. + +The target is `SOURCE_ROOT` in `apps/cli/src/tui.ts` — `fileURLToPath(new URL('../../..', import.meta.url))`, three hops up from `apps/cli/{src,lib}` — the same constant the `harness:source` prompt section already names, so the workspace and the path advertised to the model cannot drift. It follows the launcher's real path, so a PATH symlink through `current` resolves to whichever staging worktree is active. + +The mechanism is one `process.chdir(workspace)` inside `runTui`, guarded by a new optional third parameter that only `runMeta` passes. The cwd *is* the workspace seam in the shipped tree: `examples/tui-agent/cordis.yml` derives the session cwd (`!!js process.cwd()`), the `./.sessions` persistence root, and the HMR watch root (`root: ['.']`) from it, so one chdir moves all three together and meta sessions land in the checkout's gitignored `.sessions/`. It runs after both `.env` layers are loaded — the bin's invoking-directory load and the personal one — so the ambient > project > personal precedence is untouched. `DEFAULT_CONFIG` and `SOURCE_ROOT` are absolute and TUI mode passes no snapshot mode, so config resolution is chdir-independent. + +`meta` accepts only `--resume `. `--config` would boot a foreign tree against the harness workspace, which is the `--config` case rather than this one; `-p` is not interactive. Both fail loud, as does an empty `--resume=` — matching the default surface, where a swallowed empty id would silently start a fresh session. + +**`meta` does not redeclare `--resume`.** Commander parses an option a subcommand shares with its parent into `program.opts()` and leaves the subcommand's own options object empty, so redeclaring it silently dropped the id (found by probing the adapter, not by review). The action reads `program.opts()`, which also accepts the flag on either side of the subcommand; `--help` still lists it among the parent's options. + +## Testing + +`apps/cli/tests/args.spec.ts` extends its two existing cases rather than adding a file: routing for `meta`, `meta --resume `, and `--resume meta` (pinning the shared-option behavior above), and exit-1 for `meta --resume=`, `meta --config`, and `meta -p`. `runMeta` itself is composition inside the module's existing `v8 ignore` block, like `runTui`. + +There is no keyless PTY smoke for this mode. The smoke harness gives each run a temp cwd, but `dsh meta` deliberately chdirs to the real checkout, so a smoke would write `.sessions/` into the live tree mid-test. Covering it properly needs an injectable target directory — a test-only seam this note declines to add for a one-line chdir. + +The mode was verified interactively instead. Launched from `$HOME`, a `pwd` tool call reports the checkout, git resolves to its branch, the session log lands under the checkout's `.sessions/` (leaving `~/.sessions` untouched and the tree free of unignored residue), and plain `dsh` from another directory still uses the invoking one. + +`dsh meta --resume ` once started a *fresh* session instead of resuming — a pre-existing defect on the default surface, not one this mode introduced. [Launcher-owned resume identity](../architecture/2026-07-28-launcher-owned-resume-identity.md) found the cause and fixed it: a personal overlay had replaced the whole `tui-agent` config block, overwriting the shipped `resumeSessionId` intake with a read of an unset environment variable, so a valid id was silently ignored. Session identity is now a launcher-owned context slot that no config key can displace, and `meta` routes through it. + +## Alternatives considered + +**Thread an explicit workspace through `boot` and the config tree.** Avoids mutating process-wide state, but the shipped config reads the cwd in three places (`!!js process.cwd()`, `persistenceRoot`, HMR `root`), so each would need its own new plumbing and config key to stay consistent. `chdir` before boot expresses "this is the workspace" once, at the seam that already means it. + +**A `--meta` flag on the default surface.** Rejected: the default surface is option-only so that subcommands do not collide with a positional, and a flag that silently relocates the workspace reads as a modifier of the current directory rather than a different target. `meta` alongside `web` matches the existing shape. + +**Resolve `~/.dsh/source/current` instead of the launcher's own path.** Rejected: it would diverge from the `harness:source` prompt path whenever a non-installed checkout's `bin/dsh` is invoked directly, telling the model one source root while working in another. + +**Make the printed resume hint mode-aware.** Deferred here as a known cost, then delivered by [launcher-owned resume identity](../architecture/2026-07-28-launcher-owned-resume-identity.md): the exit line became a launcher-provided context slot, so meta mode prints `dsh meta --resume ` and a copied hint works from any directory. It previously came from static config as `dsh --resume {session}` and only worked when re-run from the checkout. + +## Consequences + +Starting a session on dsh's own source is `dsh meta` from anywhere, and the workspace is guaranteed to be the same checkout the model is told about. Meta sessions are isolated in the checkout's `.sessions/`, so `dsh meta --resume` sees only other meta sessions — intended, since a session's logged cwd belongs to its workspace. + +The resume hint was this mode's original cost and is now resolved. [Launcher-owned resume identity](../architecture/2026-07-28-launcher-owned-resume-identity.md) made both the printed line and the in-place `/resume` handoff reproduce the mode as `dsh meta --resume ` from one shared argv helper, so a copied hint works from any directory and the handoff no longer depends implicitly on `execve` preserving the process cwd. + +`runTui` gains an optional third parameter, so the workspace override is visible at the one function that owns TUI composition rather than hidden in a second copy of it. diff --git a/.agents/notes/implemented/feature/2026-07-28-dsh-meta-source-workspace.zh.md b/.agents/notes/implemented/feature/2026-07-28-dsh-meta-source-workspace.zh.md new file mode 100644 index 0000000000..72a0c65e6e --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-28-dsh-meta-source-workspace.zh.md @@ -0,0 +1,49 @@ +# Agent Note:`dsh meta` 以 harness 检出为 workspace 启动 TUI + +Status: implemented + +[English](2026-07-28-dsh-meta-source-workspace.md) | 中文 + +## Problem + +`dsh` 把调用目录视为 workspace,这正是它能作用于任意项目的原因。但因此,开发 dsh 自身就得先 `cd` 到检出目录——而该目录并不是一个好记的路径:源码安装会把它放在一个容器目录下、作为带时间戳的 staging 工作树(`~/.dsh/source/staging-`),并由 `current` 符号链接指向,因此每次升级后目标都会变化。`harness:source` 提示词段已经*告知* agent 其源码位置,`cordis` 工具集也能修改该运行时,但人类仍需手工定位该目录才能在其中开始会话。 + +## Decision + +`dsh meta` 在任意目录下都以 harness 检出为 workspace 启动普通 TUI。 + +目标是 `apps/cli/src/tui.ts` 中的 `SOURCE_ROOT`——`fileURLToPath(new URL('../../..', import.meta.url))`,从 `apps/cli/{src,lib}` 向上三级——与 `harness:source` 提示词段所用的常量完全相同,因此 workspace 与告知模型的路径不可能发生偏离。它跟随启动器的真实路径,所以经由 `current` 的 PATH 符号链接会解析到当前生效的那个 staging 工作树。 + +机制是 `runTui` 内的一次 `process.chdir(workspace)`,由一个新的可选第三参数把守,只有 `runMeta` 会传入。在已交付的配置树中,cwd *就是* workspace 的接缝:`examples/tui-agent/cordis.yml` 由它派生出会话 cwd(`!!js process.cwd()`)、`./.sessions` 持久化根目录以及 HMR 监视根目录(`root: ['.']`),因此一次 chdir 会让三者一并移动,meta 会话则落在检出目录中被 gitignore 的 `.sessions/` 内。它在两层 `.env` 都加载之后执行——bin 对调用目录的加载与个人层加载——因此“环境中已有的值 > 项目 > 个人”的优先级不受影响。`DEFAULT_CONFIG` 与 `SOURCE_ROOT` 都是绝对路径,且 TUI 模式不传 snapshot mode,所以配置解析与 chdir 无关。 + +`meta` 只接受 `--resume `。`--config` 会以 harness workspace 启动其他配置树,那属于 `--config` 的场景而非本场景;`-p` 并非交互式。两者都会明确报错,空的 `--resume=` 亦然——与默认界面一致,在那里被吞掉的空 id 会静默开启一个新会话。 + +**`meta` 不重新声明 `--resume`。** 对于子命令与父命令共享的选项,Commander 会将其解析进 `program.opts()`,而把子命令自身的 options 对象留空;因此重新声明会静默丢弃该 id(这是通过实测适配器发现的,而非评审发现)。action 读取 `program.opts()`,这同时也允许该标志出现在子命令的任意一侧;`--help` 仍会在父命令的选项中列出它。 + +## Testing + +`apps/cli/tests/args.spec.ts` 扩展其已有的两个用例而非新增文件:`meta`、`meta --resume ` 与 `--resume meta` 的路由(钉住上述共享选项行为),以及 `meta --resume=`、`meta --config`、`meta -p` 的退出码 1。`runMeta` 自身与 `runTui` 一样,属于该模块既有 `v8 ignore` 块内的组合代码。 + +该 mode 没有 keyless PTY 冒烟测试。冒烟框架会为每次运行提供临时 cwd,但 `dsh meta` 刻意 chdir 到真实检出目录,因此冒烟测试会在测试中途把 `.sessions/` 写入实际工作树。要正确覆盖它需要一个可注入的目标目录——为了一行 chdir 而引入的测试专用 seam,本 note 不予采纳。 + +取而代之的是交互式验证。从 `$HOME` 启动后,`pwd` 工具调用报告的是该检出目录,git 解析到其分支,会话日志落在该检出的 `.sessions/` 下(`~/.sessions` 未被触及,工作树也没有未被忽略的残留),并且从其他目录运行的普通 `dsh` 仍使用调用目录。 + +`dsh meta --resume <有效 id>` 曾经开启一个*新*会话而非恢复——这是默认界面上既已存在的缺陷,并非本 mode 引入。[由启动器持有的会话身份与退出行](../architecture/2026-07-28-launcher-owned-resume-identity.md) 查明了原因并将其修复:一个个人 overlay 替换了整个 `tui-agent` 配置块,用对一个未设置的环境变量的读取覆盖了已交付的 `resumeSessionId` 入口,因此有效的 id 会被静默忽略。会话标识如今是一个启动器拥有的上下文槽位,没有任何配置键能取代它,而 `meta` 经由它进行路由。 + +## Alternatives considered + +**通过 `boot` 与配置树显式传递 workspace。** 这可避免修改进程级状态,但已交付的配置在三处读取 cwd(`!!js process.cwd()`、`persistenceRoot`、HMR `root`),每一处都需要各自新增管线与配置键才能保持一致。启动前 chdir 只在本就表达该含义的接缝上表达一次“这就是 workspace”。 + +**在默认界面上加一个 `--meta` 标志。** 拒绝:默认界面是纯选项形式,以免子命令与位置参数冲突;而一个会静默改变 workspace 的标志读起来像是对当前目录的修饰,而非另一个目标。`meta` 与 `web` 并列符合既有形态。 + +**解析 `~/.dsh/source/current` 而非启动器自身路径。** 拒绝:当直接调用某个非安装检出的 `bin/dsh` 时,它会与 `harness:source` 提示词路径产生偏离——告知模型一个源码根目录,却在另一个目录中工作。 + +**让打印的恢复提示随 mode 变化。** 在此作为已知代价推迟,随后由 [由启动器持有的会话身份与退出行](../architecture/2026-07-28-launcher-owned-resume-identity.md) 交付:退出行变成了一个启动器提供的上下文槽位,因此 meta 模式打印 `dsh meta --resume `,被复制的提示在任意目录下都有效。它此前来自静态配置,固定为 `dsh --resume {session}`,且只有在检出目录中重新运行才有效。 + +## Consequences + +在 dsh 自身源码上开启会话变成了在任意位置执行 `dsh meta`,且该 workspace 必然就是告知模型的那个检出目录。meta 会话被隔离在检出目录的 `.sessions/` 内,因此 `dsh meta --resume` 只能看到其他 meta 会话——这是预期行为,因为会话记录的 cwd 属于它的 workspace。 + +恢复提示曾是本 mode 的原初代价,如今已解决。[由启动器持有的会话身份与退出行](../architecture/2026-07-28-launcher-owned-resume-identity.md) 让打印的行与原地 `/resume` 移交都从同一个共享的 argv 辅助函数将该 mode 复现为 `dsh meta --resume `,因此被复制的提示在任意目录下都有效,且移交不再隐式依赖于 `execve` 保留进程 cwd。 + +`runTui` 新增一个可选第三参数,因此 workspace 覆盖是在拥有 TUI 组合逻辑的那唯一一个函数上可见的,而不是隐藏在它的第二份副本中。 diff --git a/.agents/notes/implemented/feature/2026-07-28-live-session-registry-and-dsh-ls.i18n.yaml b/.agents/notes/implemented/feature/2026-07-28-live-session-registry-and-dsh-ls.i18n.yaml new file mode 100644 index 0000000000..eb4893717e --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-28-live-session-registry-and-dsh-ls.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-28-live-session-registry-and-dsh-ls.md +2026-07-28-live-session-registry-and-dsh-ls.md: 02343c83ccee7b67e3b3e4c72de842415d4a9f6e +2026-07-28-live-session-registry-and-dsh-ls.zh.md: 722ac45f2eb07256f196d2828b8969ae9f4965b8 diff --git a/.agents/notes/implemented/feature/2026-07-28-live-session-registry-and-dsh-ls.md b/.agents/notes/implemented/feature/2026-07-28-live-session-registry-and-dsh-ls.md new file mode 100644 index 0000000000..02343c83cc --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-28-live-session-registry-and-dsh-ls.md @@ -0,0 +1,65 @@ +# Agent Note: live-session registry and `dsh list-sessions` + +Status: implemented + +English | [中文](2026-07-28-live-session-registry-and-dsh-ls.zh.md) + +## Problem + +Nothing could answer "which dsh sessions am I running right now". A user with sessions across several projects had no way to enumerate them, and no way to recover the id needed for `--resume` except the exit line of the terminal that printed it. Session persistence records every session that ever existed, so it cannot answer the question: it has no notion of liveness, and no `process.pid` appeared anywhere in the session, persistence, or storage packages. + +## Decision + +`dsh list-sessions` (alias `dsh ps`) lists the sessions running right now — session id, pid, uptime, workspace, title — newest first, across every workspace, with `--json` for machines. Three packages back it, as a capability seam. + +[`dsh-session-registry`](../../../../packages/session-registry/session-registry/README.md) (`ctx.sessionRegistry`) is the seam: the abstract service contract and record vocabulary, so the medium can later move to a database without touching consumers. [`dsh-session-registry-file`](../../../../packages/session-registry/session-registry-file/README.md) implements it over one lock-guarded JSON file under the Harness home. [`dsh-session-registry-live`](../../../../packages/session-registry/session-registry-live/README.md) follows `session/created`, `session/disposed`, and `session/title` and keeps the registry in step. `apps/cli` mounts both on every launcher surface — the TUI, `dsh meta`, headless, and web — and `dsh list-sessions` mounts only the service, booting no agent tree. No surface label is recorded: a launcher's mode is not a property of the session, and the workspace column already distinguishes a `dsh meta` session from a project one. + +### Liveness is derived, never stored + +`list()` probes each record's pid with `kill(pid, 0)` and drops the dead ones, writing the pruned result back. A process killed without running its disposer leaves a record that the next read removes, so there is no daemon, no heartbeat, and no permanent phantom. A per-process `bootId` distinguishes a recycled pid, so deregistration cannot delete a namesake record from a different incarnation. `EPERM` counts as alive: a live session owned by another user must not be dropped. + +### Two independent concurrency layers + +The file is written by every dsh process and by several sessions inside one process, and the two cases need different mechanisms. + +Across processes, each read-modify-write holds a [`proper-lockfile`](https://github.com/moxystudio/node-proper-lockfile) advisory lock. Within one process, calls queue on an internal chain, because the advisory lock is tracked per process: overlapping same-process callers contend for its bounded retry budget rather than queueing, and past roughly a dozen concurrent calls that budget runs out and a registration rejects. Since publication is fire-and-forget, such a rejection silently drops a live session from the listing — the exact "listing that lies" failure this feature exists to avoid. Both layers are load-bearing and each is pinned by a test that fails without it. + +### Records carry their own title + +The title is the one mutable field, replaced through `retitle` as `session/title` events arrive. It lives in the record rather than being read from the session log because the log's location, format, and compression are per-deployment backend choices: the TUI writes project-local zstd-compressed JSONL, the web and headless surfaces write to a global root, a user profile overrides either, and SQLite has no per-session file at all. An independent reader cannot portably parse that, so `dsh list-sessions` opens no log and assumes no backend. + +### Subagents are invisible by construction + +Only top-level launcher surfaces mount the publisher. In-process subagents (`spawn`, `fork`) have no process of their own, and the out-of-process backends spawn `dsh-jsonrpc-agent` rather than this CLI. No filter flag is needed, and no subagent package changed. + +## Alternatives considered + +**One file per session under `~/.dsh/run/`.** No lock at all, since each process only writes and deletes its own file. Rejected in favour of the single file the user chose, which then made a real advisory lock mandatory rather than optional. + +**A domain over the `storage-json` backend.** The obvious reuse, and wrong: that backend documents "no cross-process write locking … last write wins" and names single-host-process as its assumption, and the [domain KV storage note](../../proposed/architecture/2026-07-24-domain-kv-storage-and-workspace.md) puts multi-process explicitly out of scope. A registry written concurrently by every launcher is precisely that excluded case. Widening the backend's contract would have changed a shipped guarantee for one consumer; a separate package owns the multi-process medium instead. + +**Hand-rolled `O_EXCL` lock directory.** Rejected under the [dependencies-over-hand-rolling policy](../process/2026-07-26-dependencies-over-hand-rolling.md): stale-lock detection, retry backoff, and compromise handling are exactly the surface a maintained dependency should own. + +**Accept last-write-wins on the single file.** Cheapest to build, and it silently omits real running sessions when two start close together. A listing tool that lies is worse than no listing tool. + +**Read the title from the session log in `dsh list-sessions`.** Implemented first, then verified live: the shipped TUI writes `session.jsonl.zstd`, whose frame helpers are internal to the jsonl backend. Exporting them would have hard-coded one backend's file format into the CLI and still shown nothing for SQLite. + +**Register the web server itself with a placeholder session id.** `dsh web` owns no session — its sessions are created later by browser clients — so a server row would have put a fake id in a session table. Following session lifecycle instead makes browser sessions appear and disappear as they are opened, which also subsumed the TUI's launcher-side registration and deleted that separate path. + +**A `--here`/`--workspace` filter.** Dropped on request: the listing is always global, and narrowing is the user's `grep`. + +## Consequences + +The registry is an observability aid, so every write is best-effort: a registry fault warns and never fails a working agent session. The cost is that a listing can lag reality by one failed write, healed by the next. + +Title mirroring costs one locked read-modify-write per revision, so an aggressive retitling cadence pays that write each time. + +`bootId` bounds pid reuse only for records this process wrote. A foreign record whose pid the operating system has reassigned to an unrelated live process is reported alive until its owner removes it — accepted because the portable alternative, reading real process start times, is `/proc`-only. + +Liveness is pid existence, not health: a hung process still lists as running. The registry deliberately makes no progress judgement. + +## Testing + +Unit coverage pins durable-format validation (torn text, foreign version, per-row damage that must not hide siblings), pid pruning against a genuinely reaped pid, `EPERM`-is-alive, incarnation-scoped deregistration, and `retitle` scoping. Both concurrency layers have a regression test verified to fail when its mechanism is removed: 8 real processes for the cross-process lock, 24 overlapping in-process calls for the chain. The publisher is tested over the real `SessionStore` rather than a hand-built emitter, because publication depends on the store's actual lifecycle dispatch. + +Verified live in tmux against the assembled application: two concurrent TUI sessions in different workspaces both listed, a title appeared after the first turn, clean exit deregistered, and `SIGKILL` left a stale record that the next `dsh list-sessions` pruned and durably rewrote. diff --git a/.agents/notes/implemented/feature/2026-07-28-live-session-registry-and-dsh-ls.zh.md b/.agents/notes/implemented/feature/2026-07-28-live-session-registry-and-dsh-ls.zh.md new file mode 100644 index 0000000000..722ac45f2e --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-28-live-session-registry-and-dsh-ls.zh.md @@ -0,0 +1,65 @@ +# Agent Note: 活跃会话注册表与 `dsh list-sessions` + +Status: implemented + +[English](2026-07-28-live-session-registry-and-dsh-ls.md) | 中文 + +## 问题 + +没有任何东西能回答「我此刻正在运行哪些 dsh 会话」。会话散落在多个项目中的用户既无法枚举它们,也无法找回 `--resume` 所需的 id,唯一的来源是打印过它的那个终端的退出行。会话持久化记录了曾经存在过的每个会话,因此它答不了这个问题:它没有存活状态的概念,而且 session、persistence、storage 这几个包里任何位置都没有出现过 `process.pid`。 + +## 决策 + +`dsh list-sessions`(别名 `dsh ps`)列出此刻正在运行的会话(会话 id、pid、运行时长、工作区、标题),最新的排在最前,覆盖所有工作区,并提供面向机器的 `--json`。背后由三个包(package)以能力 seam 的形式支撑。 + +[`dsh-session-registry`](../../../../packages/session-registry/session-registry/README.md)(`ctx.sessionRegistry`)是 seam:抽象服务契约与记录词汇,使介质将来可以换成数据库而不触及消费方。[`dsh-session-registry-file`](../../../../packages/session-registry/session-registry-file/README.md) 在 Harness home 下的一个加锁保护的 JSON 文件上实现它。[`dsh-session-registry-live`](../../../../packages/session-registry/session-registry-live/README.md) 跟随 `session/created`、`session/disposed` 和 `session/title`,让注册表保持同步。`apps/cli` 在每个启动方接口(TUI、`dsh meta`、headless、web)上都挂载这两个包,而 `dsh list-sessions` 只挂载该服务,不启动任何 agent(智能体)树。不记录任何接口标签:启动方的模式并不是会话的属性,而工作区那一列已经能把 `dsh meta` 会话和项目会话区分开。 + +### 存活状态是推导出来的,绝不存储 + +`list()` 用 `kill(pid, 0)` 探测每条记录的 pid,剪除已消亡的记录,并把剪除后的结果写回。未运行 disposer(资源释放)就被杀掉的进程留下的记录,会被下一次读取移除,因此不需要 daemon,不需要心跳,也不会有永久残留的幽灵记录。每个进程独有的 `bootId` 用于区分被复用的 pid,因此注销不会删除属于另一个 incarnation 的同名记录。`EPERM` 算作存活:归属于另一个用户的存活会话绝不能被丢掉。 + +### 两层相互独立的并发机制 + +该文件既被每个 dsh 进程写入,也被同一进程内的多个会话写入,这两种情形需要不同的机制。 + +跨进程时,每次读-改-写都持有一个 [`proper-lockfile`](https://github.com/moxystudio/node-proper-lockfile) 咨询锁。进程内则由各次调用在内部链上排队,因为咨询锁是按进程跟踪的:同一进程中重叠的调用方会争抢它有界的重试预算,而不是排队等待;大约超过十几次并发调用后,该预算耗尽,某次注册就会被拒绝。由于发布采用 fire-and-forget 方式,这样一次拒绝会静默地把一个存活会话从列表中丢掉——而这正是本功能要避免的「列表说谎」故障。两层机制都是必需的,且各有一个测试固定它:移除该机制,对应测试就会失败。 + +### 记录自带标题 + +标题是唯一的可变字段,随 `session/title` 事件到达,通过 `retitle` 替换。它存放在记录里,而不是从会话日志读取,因为日志的位置、格式和压缩都是逐部署的后端选择:TUI 写入项目本地的 zstd 压缩 JSONL,web 与 headless 界面写入全局根目录,用户配置文件可以覆盖二者,而 SQLite 根本没有逐会话的文件。独立读取方无法以可移植的方式解析这些内容,因此 `dsh list-sessions` 不打开任何日志,也不假定任何后端。 + +### subagent 在设计上就不可见 + +只有顶层启动方接口才挂载发布方。进程内 subagent(`spawn`、`fork`)没有自己的进程,而进程外后端 spawn 的是 `dsh-jsonrpc-agent` 而不是本 CLI(命令行界面)。不需要任何过滤开关,也没有改动任何 subagent 包。 + +## 考虑过的替代方案 + +**在 `~/.dsh/run/` 下每个会话一个文件。** 完全不需要锁,因为每个进程只写入和删除自己的文件。不予采纳,改用用户选定的单文件方案,而这也使真正的咨询锁从可选变为必需。 + +**在 `storage-json` 后端之上做一个 domain。** 这是最显而易见的复用,但它是错的:该后端明确记载「无跨进程写锁……最后写入者胜出」,并把单一宿主进程列为自身前提,而[domain KV 存储 note](../../proposed/architecture/2026-07-24-domain-kv-storage-and-workspace.md)明确把多进程排除在范围之外。被每个启动方并发写入的注册表恰恰就是这个被排除的场景。放宽该后端的契约,等于为一个消费方改动一项已上线的保证;改由一个独立的包拥有这套多进程介质。 + +**手写 `O_EXCL` 锁目录。** 依据[优先使用依赖而非手写政策](../process/2026-07-26-dependencies-over-hand-rolling.md)不予采纳:陈旧锁检测、重试退避和受损处理,恰恰是应当由一个有人维护的依赖拥有的那部分工作。 + +**在单文件上接受最后写入者胜出。** 这是最省事的实现,但当两个会话相近时间启动时,它会静默漏掉真实运行中的会话。一个会说谎的列表工具比没有列表工具更糟。 + +**在 `dsh list-sessions` 中从会话日志读取标题。** 该方案先落地实现,随后经实机验证否决:上线的 TUI 写入 `session.jsonl.zstd`,其帧处理辅助函数是 jsonl 后端的内部实现。把它们导出,等于把某一个后端的文件格式硬编码进 CLI,而且对 SQLite 仍然什么都显示不出来。 + +**用占位会话 id 注册 web 服务器本身。** `dsh web` 不拥有任何会话(它的会话由浏览器客户端稍后创建),因此一行服务器记录会把一个假 id 放进会话表。改为跟随会话生命周期后,浏览器会话会随打开与关闭而出现和消失,这同时也涵盖了 TUI 启动方一侧的注册,并删除了那条独立路径。 + +**加一个 `--here`/`--workspace` 过滤开关。** 按要求放弃:列表始终是全局的,收窄范围交给用户自己的 `grep`。 + +## 后果 + +注册表是一项可观测性辅助设施,因此每次写入都是尽力而为:注册表故障只发出警告,绝不让正常工作的 agent 会话失败。代价是列表可能因一次失败的写入而落后于现实一步,并由下一次写入修复。 + +标题镜像每次修订都要付出一次加锁的读-改-写,因此改名节奏激进时,每次改名都要付出这一次写入。 + +`bootId` 只对本进程写入的记录约束 pid 复用。如果一条外来记录的 pid 已被操作系统重新分配给一个无关的存活进程,那么在其所有者移除它之前,该记录会被报告为存活——之所以接受,是因为可移植的替代方案(读取进程真实启动时间)仅在 `/proc` 上可用。 + +存活状态只表示 pid 存在,不表示健康:挂死的进程仍会被列为正在运行。注册表刻意不对进展作出判断。 + +## 测试 + +单元覆盖固定了持久格式校验(截断文本、外来版本、不得遮蔽同级记录的单条损坏)、针对真正已回收 pid 的剪除、`EPERM` 算存活、按 incarnation 限定范围的注销,以及 `retitle` 的作用范围。两层并发机制各有一个回归测试,且都已验证在移除对应机制后会失败:跨进程锁用 8 个真实进程,进程内链用 24 次重叠调用。发布方在真实的 `SessionStore` 上测试,而非手搭的事件发射器,因为发布依赖该 store 实际的生命周期派发。 + +已在 tmux 中针对组装后的应用实机验证:位于不同工作区的两个并发 TUI 会话都被列出,第一轮之后出现标题,正常退出完成注销,而 `SIGKILL` 留下的陈旧记录被下一次 `dsh list-sessions` 剪除并持久重写。 diff --git a/.agents/notes/implemented/feature/2026-07-28-source-guard-staging-edit-gate.i18n.yaml b/.agents/notes/implemented/feature/2026-07-28-source-guard-staging-edit-gate.i18n.yaml new file mode 100644 index 0000000000..91227d80e1 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-28-source-guard-staging-edit-gate.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-28-source-guard-staging-edit-gate.md +2026-07-28-source-guard-staging-edit-gate.md: 8452006190166c783efafc398566ef7f4da10323 +2026-07-28-source-guard-staging-edit-gate.zh.md: 83589ce833e4aa74968b40247848685a1e030f6b diff --git a/.agents/notes/implemented/feature/2026-07-28-source-guard-staging-edit-gate.md b/.agents/notes/implemented/feature/2026-07-28-source-guard-staging-edit-gate.md new file mode 100644 index 0000000000..8452006190 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-28-source-guard-staging-edit-gate.md @@ -0,0 +1,76 @@ +# Agent Note: source-guard denies direct staging-checkout edits + +Status: implemented + +English | [中文](2026-07-28-source-guard-staging-edit-gate.zh.md) + +## Problem + +The [`dsh-customize`](../../../../skills/dsh-customize/SKILL.md) skill governs every personal change to a dsh source checkout: implement in a task worktree branched from the staging tip, then integrate under `.agents/merge.lock`. Its central rule is negative — do not edit the personal staging checkout directly — and a negative rule delivered only as prompt text fails in exactly the case that matters. An agent that never loads the skill never sees the rule, and one that loads it early can still forget it thirty tool calls later. The failure is silent and expensive: commits land on the staging branch that the launcher runs from, outside any task branch, with no lock held and no rollback worktree. + +Prompt guidance cannot fix this, because the guidance is what went unread. The rule needs an enforcement point. + +## Decision + +`@deepseek-ai/dsh-source-guard` (`packages/guard/source-guard/`) is a `tools/pre-execute` listener that returns `{kind: 'deny', reason}` for a `write` or `edit` whose target resolves inside a protected staging worktree, unless the calling session's durable log already records a successful `skill` call naming `dsh-customize`. It registers no service and contributes no prompt text or tool schema; an allowed call is indistinguishable from one made without the plugin. It is not in any shipped default composition. + +### Git identity from files, not a path prefix and not `git` + +Whether a path is protected is decided by reading `.git`, its `gitdir:` pointer, and `HEAD`. Three shapes resolve: a plain clone (`.git` is a directory that is its own common dir), a linked worktree (`.git` is a file pointing at `/worktrees/`, whose common dir is two levels up), and a detached HEAD (`HEAD` holds a raw object id and names no branch). A `gitdir:` pointer resolves whether absolute — what `git worktree add` writes — or relative, which git resolves against the worktree directory holding it. + +Denial requires the target's worktree to match the launcher's on both identities: the same shared git directory and the same branch. Both come from resolving `protectedCheckout`, so nothing about the protected branch is configured. An earlier revision matched a `dsh-staging/*` name pattern instead; the exact-branch rule replaced it because a pattern is wrong in both directions. It denied every sibling staging worktree an old install had left behind, none of which runs a launcher, and it silently protected nothing for a maintainer whose staging branch follows no naming convention — a fatal property for a shipped default that must hold for checkouts [`scripts/install.sh`](../../../../scripts/install.sh) did not create. + +A path-prefix rule would have been wrong, not merely imprecise. The task worktrees the skill prescribes live *inside* the protected tree at `/.worktrees/...`, so a prefix rule would deny every edit the workflow requires. Resolution walks outward from the target and stops at the first enclosing worktree, so it reports the innermost one: a nested task worktree answers with its own task branch and is allowed, while the launcher's own tree answers with the launcher's branch and is denied. + +Two path details decide whether the gate holds at all, and both are enforcement, not polish. Repository identity is compared on symlink-resolved paths (`canonicalPath` from `dsh-sandbox`), because a session cwd under `/var/...` and a configured path under `/private/var/...` are the same macOS directory and a lexical comparison would fail open on every write. And a relative `file_path` is resolved against the calling session's workspace, exactly as `dsh-tool-fs` resolves it; judging only absolute paths would have left a relative path as an unguarded route to a protected file. + +`protectedCheckout` names a path inside the guarded checkout, defaulting to this module's own file. That resolves the checkout the running harness was launched from — the live deployment, whatever its branch is named. A harness running from an installed copy resolves a different repository, or none, and guards nothing; the rule is meaningless outside a source checkout. + +The shipped TUI composition loads the plugin with these defaults, so every source install is protected without configuration. It is inert for an ordinary project: a workspace in another repository, or none, never matches the launcher's identities. + +### Satisfaction replayed from the durable log + +The gate lifts on a `tool/call` naming the `skill` tool whose arguments parse to `{name: }`, paired by call id with a non-error `tool/result`. Both fields are already durable (`packages/core/session/src/types.ts`), so this needs no new session event and no coupling to skill-provider internals. + +The log is the only state. In-memory satisfaction (the `WeakMap` shape [`repeat-tool-guard`](../../archived/feature/2026-07-08-repeat-tool-guard.md) uses for its chains) would be smaller, but it loses satisfaction on resume: a resumed session that already read the skill would be told to read it again, and the denial would look like a bug rather than a rule. Replay costs a scan bounded by the first hit and buys resume correctness. + +### Fail open, deliberately + +A path outside any worktree, a detached HEAD, a foreign repository, a malformed `gitdir:` pointer, and unreadable metadata all leave the call to the rest of the chain. The alternative — denying whenever git identity is unavailable — converts any `.git` permission problem into a harness that cannot write files at all. The guard exists to prevent one specific, recoverable mistake; it must not become a larger outage than the mistake. + +### Narrow scope + +`read` is never gated: inspecting staging violates nothing, and the skill explicitly permits read-only questions. `bash` is not gated either. Reliably classifying mutating shell commands is a matcher problem with no honest completion condition, so a determined model can still change staging through a shell. This is a boundary against forgetting, not a sandbox against intent. + +## Alternatives considered + +- **Advisory reminder instead of denial** (`additionalContexts` on `tools/post-execute`, the `repeat-tool-guard` shape). Rejected: the write has already happened when the reminder arrives, so the violation is committed and the guidance is again just text. +- **`{kind: 'ask'}` routed to approval.** Rejected: it prompts on every legitimate task-worktree edit in the common case, and degrades to denial in a composition without approval support, making behavior depend on unrelated plugins. +- **Running `git rev-parse` through `ctx.subprocess`.** Rejected after measuring the alternative: two file reads answer the same question with no process spawn per gated write, no `git` on `PATH` requirement, and no subprocess dependency. Reading `.git` and `HEAD` is a stable on-disk format, not an implementation detail. +- **Explicit `protectedRoots` config with no detection.** Rejected: it makes the common case require configuration to be correct, and a stale absolute path silently disables protection. +- **A configurable staging-branch name pattern** (`stagingBranchPatterns`, default `dsh-staging/*`). Shipped first, then removed: it protects the wrong set in both directions — every stale sibling worktree that runs no launcher, and nothing at all for a maintainer whose branch is named otherwise. Deriving the branch from the launcher needs no configuration and cannot be misconfigured. +- **Auto-detecting the checkout with no override.** Rejected: the detection is a default, not a law; a deployment guarding a different checkout, or running from an installed copy, needs the explicit value. +- **Denying everything under the checkout root, `.worktrees/` included.** Rejected: it blocks the workflow the skill prescribes, so the guard would fire on every legitimate task edit. +- **Gating `bash` with a mutating-command matcher.** Deferred, not rejected: worth revisiting if bypasses are observed in practice. A matcher that is wrong in either direction is worse than an honestly narrow gate. + +## Consequences + +The rule now holds without depending on the model having read it, and the denial names the path, the branch, and the skill, so the model's next action is determined rather than guessed. Enforcement sits at the operation boundary that owns the decision, so it cannot be bypassed by prompt filtering or listener order. + +Shipping it in the TUI default means every source install is protected without configuration, and the protection follows the launcher across upgrades because the branch is derived rather than named. The cost of that reach is that the plugin loads for every user, including those whose workspace it can never match. + +What it cost otherwise: the guard is only as complete as its tool list, and `bash` remains open. Worktree identity is cached per directory for the plugin's lifetime, so a mid-session branch switch is not observed on either side. Only the launcher's own checkout is protected, so a stale sibling stays editable. Loading the skill lifts the gate for the whole session without verifying the workflow was actually followed — the gate proves the instructions were read, not obeyed. Satisfaction is per session, so a subagent with its own session must load the skill itself. + +## Testing + +Unit suites drive a real agent loop against a mock adapter over real git-metadata fixtures — a staging worktree, a task worktree nested inside it, a plain clone, a foreign repository on a staging-named branch, a detached HEAD, absolute and relative `gitdir:` pointers, a symlinked route to one repository, a malformed pointer, and unreadable metadata — covering both source files to per-file 100%. A companion `invariant.ts` validates the durable denial's shape, since the refusal text is the package's only model-visible output and is actionable only when it names the path, branch, and skill. + +The real-composition smoke boots `examples/headless-agent/tests/fixtures/guard/source-guard/cordis.yml` through the Loader and the headless app, and asserts three things about the assembled run: the tool result is an error, its text is the exact denial, and the targeted file still holds its original bytes — enforcement before dispatch, not advice after it. + +An ACP snapshot scenario (`source-guard-staging-deny`) originally owned the assembled transcript, seeding a staging worktree in the harness's generated cwd through a new `Scenario.prepareCwd` hook — git never tracks an entry named `.git` and `.gitignore` excludes every `worktrees/` directory, so the fixture committed the two `HEAD` bodies and the hook assembled the real layout. Authoring it paid for itself immediately: it exposed both path defects above (the transcript showed `fs-policy` answering first wherever the guard had quietly declined to judge) and then caught its own first fixture, whose ignored `worktrees/` path passed locally from an untracked file. The scenario was later removed with the assembled-run evidence consolidated into the Loader-composition smoke; the `prepareCwd` hook it introduced remains part of the snapshot harness for repository-shaped fixtures. + +## Related + +- [The personal-staging maintenance skills Agent Note](../process/2026-07-23-personal-staging-maintenance-skills.md) — the workflow this gate enforces one rule of. That note owns the skills' content and discovery; this one owns the enforcement point and holds no authority over the workflow itself. +- [The interception-seams Agent Note](2026-06-30-interception-seams.md) — the `tools/pre-execute` `allow`/`deny`/`ask` vocabulary this gate's denial uses. +- [The repeat-tool-guard Agent Note](../../archived/feature/2026-07-08-repeat-tool-guard.md) — the sibling guard whose advisory shape this one deliberately does not take. diff --git a/.agents/notes/implemented/feature/2026-07-28-source-guard-staging-edit-gate.zh.md b/.agents/notes/implemented/feature/2026-07-28-source-guard-staging-edit-gate.zh.md new file mode 100644 index 0000000000..83589ce833 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-28-source-guard-staging-edit-gate.zh.md @@ -0,0 +1,76 @@ +# Agent Note: source-guard 拒绝直接编辑 staging 检出目录 + +Status: implemented + +[English](2026-07-28-source-guard-staging-edit-gate.md) | 中文 + +## Problem + +[`dsh-customize`](../../../../skills/dsh-customize/SKILL.md) skill(技能)规范对 dsh 源码检出的每项个人变更:先在从 staging 分支顶端分出的任务 worktree 中实现,再于 `.agents/merge.lock` 保护下完成集成。它的核心规则是一条禁令:不得直接编辑个人 staging 检出目录;而若只通过提示词文本传达禁令,它恰好会在最要紧的场景中失效。从未加载该 skill 的 agent(智能体)根本看不到规则;即便尽早加载,也仍可能在三十次工具调用后将其忘掉。这种失败既静默又代价高昂:提交会落在启动器实际运行的 staging 分支上,不属于任何任务分支,既未持有锁,也没有用于回滚的 worktree。 + +提示词指导无法解决这个问题,因为未被阅读的正是这些指导。该规则需要一个强制执行点。 + +## Decision + +`@deepseek-ai/dsh-source-guard`(`packages/guard/source-guard/`)是一个 `tools/pre-execute` 监听器;它会返回 `{kind: 'deny', reason}`,拒绝目标解析到受保护 staging worktree 内的 `write` 或 `edit`,除非调用会话的持久日志已经记录过一次成功的 `skill` 调用,且名称为 `dsh-customize`。它不注册服务,也不贡献提示词文本或工具 schema;获准的调用与未加载该插件时的调用没有区别。任何已交付的默认组合都不包含它。 + +### 从文件而非路径前缀或 `git` 判定 Git 身份 + +系统通过读取 `.git`、其中的 `gitdir:` 指针以及 `HEAD` 来判断路径是否受保护。它可以解析三种形态:普通克隆(`.git` 是目录,且自身就是共享目录)、链接 worktree(`.git` 是文件,指向 `/worktrees/`,其共享目录位于上两级)以及 HEAD 分离状态(`HEAD` 保存原始对象 id,不指向任何分支)。`gitdir:` 指针无论是绝对路径(`git worktree add` 写入的形式)还是相对路径都可以解析;Git 会以包含该指针的 worktree 目录为基准解析相对路径。 + +只有目标的 worktree 在两项身份上都与启动器的 worktree 匹配时才会拒绝:共用同一个共享 Git 目录,且分支相同。这两项身份均通过解析 `protectedCheckout` 得出,因此无需配置受保护分支的任何信息。较早版本则匹配 `dsh-staging/*` 名称模式;现已改用确切分支规则,因为模式会在两个方向上出错。它会拒绝旧安装留下的每一个同级 staging worktree,尽管其中没有任何一个运行着启动器;对于 staging 分支不遵循任何命名约定的维护者,它又会静默地完全不提供保护——而已交付的默认配置必须在 [`scripts/install.sh`](../../../../scripts/install.sh) 未创建的检出目录上也能生效,这一属性是致命的。 + +路径前缀规则不仅不精确,而且本身就是错误的。该 skill 规定的任务 worktree 位于受保护树*内部*的 `/.worktrees/...`,因此前缀规则会拒绝工作流要求的每一次编辑。解析过程从目标向外逐层查找,遇到第一个所属 worktree 时停止,因此返回最内层的 worktree:嵌套的任务 worktree 会返回自身的任务分支并获准,而启动器自身所在的树会返回启动器的分支并被拒绝。 + +有两个路径细节决定门禁究竟能否生效,二者都是强制执行要求,而非细节润色。仓库身份会按解析符号链接后的路径进行比较(使用 `dsh-sandbox` 的 `canonicalPath`),因为位于 `/var/...` 下的会话 cwd 和位于 `/private/var/...` 下的配置路径在 macOS 上是同一个目录,若按路径字符串比较,每次写入都会故障放行(fail-open)。此外,相对 `file_path` 会完全按照 `dsh-tool-fs` 的方式,相对于调用会话的工作区解析;若只判断绝对路径,相对路径就会成为绕过门禁访问受保护文件的路径。 + +`protectedCheckout` 指定受保护检出目录内的一条路径,默认值为本模块自身的文件。由此解析出运行中 harness 的启动来源检出目录——当前运行的部署,无论其分支采用什么名称。若 harness 从已安装副本运行,解析出的会是另一个仓库或没有仓库,因此不会保护任何内容;该规则在源码检出之外没有意义。 + +已交付的 TUI 组合会以这些默认值加载插件,因此每个源码安装无需配置即可受到保护。对于普通项目,它不会生效:若工作区位于其他仓库中,或不存在工作区,就绝不会匹配启动器的身份。 + +### 从持久日志回放满足状态 + +如果日志中存在一条 `tool/call`,它调用名为 `skill` 的工具,参数可解析为 `{name: }`,且按调用 id 能配对到非错误的 `tool/result`,门禁即解除。二者都已持久化(`packages/core/session/src/types.ts`),因此无需新增会话事件,也不与 skill 提供方内部实现耦合。 + +日志是唯一状态。在内存中记录满足状态(`WeakMap` 结构,[`repeat-tool-guard`](../../archived/feature/2026-07-08-repeat-tool-guard.md) 将其用于调用链)所需实现会更小,但恢复后满足状态会丢失:一个已经读取过该 skill 的恢复会话会被要求再次读取,而这次拒绝看起来会像缺陷而不是规则。回放的代价是扫描日志,但首次命中即停止,并换来恢复行为正确。 + +### 刻意采用故障放行 + +目标路径不在任何 worktree 内、HEAD 分离、属于其他仓库、`gitdir:` 指针格式错误或元数据不可读时,调用都会交给调用链的其余部分处理。反过来,只要无法判定 Git 身份就拒绝,会让任何 `.git` 权限问题都导致 harness 完全无法写文件。该 guard 旨在防止一种特定且可恢复的错误,不得造成比该错误更严重的故障。 + +### 范围收窄 + +`read` 从不受门禁限制:检查 staging 不会违反任何规则,而且该 skill 明确允许只读提问。`bash` 同样不受门禁限制。要可靠判定哪些 shell 命令会修改状态,需要构造一个无法给出可信完备标准的匹配器,因此执意修改的模型仍可通过 shell 修改 staging。这是一道防止遗忘的边界,不是阻止刻意操作的沙箱。 + +## Alternatives considered + +- **用建议性提醒代替拒绝**(使用 `additionalContexts`,挂载在 `tools/post-execute` 上,采用 `repeat-tool-guard` 的形态)。不予采纳:提醒到达时写入已经发生,违规已成事实,而指导又一次沦为纯文本。 +- **将 `{kind: 'ask'}` 交给审批。** 不予采纳:在常见场景中,它会对任务 worktree 内每次合法编辑都发起询问;在没有审批支持的组合中还会退化为拒绝,使行为取决于无关插件。 +- **运行 `git rev-parse`,并通过 `ctx.subprocess` 执行。** 对替代方案进行实测后不予采纳:读取两个文件即可回答同一问题,每次受门禁限制的写入都无需 spawn 进程,不要求 `git` 存在于 `PATH` 中,也不依赖子进程。读取 `.git` 与 `HEAD` 所依据的是稳定的磁盘格式,而非实现细节。 +- **显式配置 `protectedRoots`,不做检测。** 不予采纳:这会让常见场景的保护效果依赖配置正确性,而陈旧的绝对路径会静默禁用保护。 +- **可配置的 staging 分支名称模式**(`stagingBranchPatterns`,默认 `dsh-staging/*`)。最初随产品交付,随后删除:它从两个方向划错了保护范围——既纳入每个不运行启动器的陈旧同级 worktree,又完全不保护分支另有名称的维护者。由启动器派生分支无需配置,也不可能配置错误。 +- **自动检测检出目录,不提供覆盖项。** 不予采纳:检测只是默认行为,而非不可更改的规定;若部署要保护另一个检出目录,或自身从已安装副本运行,就需要显式值。 +- **拒绝检出根目录下的一切操作,包括 `.worktrees/`。** 不予采纳:这会阻断该 skill 规定的工作流,让 guard 在每次合法任务编辑时触发。 +- **用修改类命令匹配器把守 `bash`。** 推迟而非否决:如果实际观察到绕过行为,值得重新考虑。任一方向判断错误的匹配器,都不如如实限定范围的门禁。 + +## Consequences + +如今,该规则无需依赖模型已经读过它也能生效;拒绝理由会列出路径、分支与 skill,让模型的下一步操作明确,无需猜测。强制执行位于拥有该决策的操作边界,因此提示词过滤或监听器顺序都无法绕过它。 + +将其纳入 TUI 默认组合意味着每个源码安装无需配置即可受到保护;由于分支是派生而非按名称指定,保护会在升级时跟随启动器。这种覆盖范围的代价是插件会为每位用户加载,包括工作区永远不可能匹配启动器身份的用户。 + +除此之外的代价是:guard 的完整程度受限于其工具列表,`bash` 仍保持开放。worktree 身份在插件生命周期内按目录缓存,因此无法观察到任一侧在会话中途切换分支。只保护启动器自身的检出目录,因此陈旧的同级检出目录仍可编辑。加载该 skill 会为整个会话解除门禁,却不会验证工作流是否确实得到遵循——门禁只能证明指令已被阅读,不能证明已被执行。满足状态按会话隔离,因此拥有独立会话的 subagent 必须自行加载该 skill。 + +## Testing + +单元测试套件基于真实 Git 元数据 fixture(测试前置数据),使用 mock 适配器驱动真实 agent loop(智能体循环):覆盖一个 staging worktree、嵌套其中的任务 worktree、普通克隆、位于 staging 命名分支上的其他仓库、HEAD 分离状态、绝对和相对 `gitdir:` 指针、指向同一仓库的符号链接路径、格式错误的指针以及不可读元数据,使两个源码文件都达到逐文件 100% 覆盖率。配套的 `invariant.ts` 会验证持久拒绝的结构,因为拒绝文本是该包唯一面向模型的输出,且只有其中列出路径、分支和 skill 时才具有可操作性。 + +真实组合冒烟测试通过 Loader 与 headless 应用启动 `examples/headless-agent/tests/fixtures/guard/source-guard/cordis.yml`,并对组装后的运行断言三项事实:工具结果是错误、文本与拒绝理由逐字一致、目标文件仍保留原始字节。这证明系统在分发前强制执行规则,而不是事后给出建议。 + +一个 ACP(Agent Client Protocol)快照场景(`source-guard-staging-deny`)最初负责组装后的 transcript(文本记录),通过新的 `Scenario.prepareCwd` 钩子在 harness 生成的 cwd 中植入 staging worktree——Git 永远不会跟踪名为 `.git` 的条目,且 `.gitignore` 会排除所有 `worktrees/` 目录,因此 fixture 提交两个 `HEAD` 的内容,由钩子组装真实布局。编写它立刻证明了投入的价值:它暴露了上述两个路径缺陷(transcript 显示每当 guard 悄然不作判断时 `fs-policy` 都会率先响应),随后又发现了自身首版 fixture 的问题——被忽略的 `worktrees/` 路径因未跟踪文件而在本地通过。该场景后来被移除,组装运行证据合并进 Loader 组合冒烟测试;它引入的 `prepareCwd` 钩子仍留在快照 harness 中,服务于仓库形态的 fixture。 + +## Related + +- [个人 staging 维护 skill 的 Agent Note](../process/2026-07-23-personal-staging-maintenance-skills.md):本门禁负责执行该工作流的一条规则。对方 Agent Note 负责这些 skill 的内容与发现机制;本文只负责强制执行点,对工作流本身不具有定义权。 +- [拦截 seam Agent Note](2026-06-30-interception-seams.md):本门禁拒绝时使用的 `tools/pre-execute` `allow`/`deny`/`ask` 词汇。 +- [repeat-tool-guard Agent Note](../../archived/feature/2026-07-08-repeat-tool-guard.md):同类 guard;本文刻意不采用其建议性形态。 diff --git a/apps/cli/README.md b/apps/cli/README.md index 93c36d18ab..5241a29b4c 100644 --- a/apps/cli/README.md +++ b/apps/cli/README.md @@ -2,18 +2,24 @@ English | [中文](README.zh.md) -The `dsh` command-line entry follows the `apps/` assembly tier: `apps/*` are product assemblies over `packages/*` libraries. Plain `dsh` boots the interactive TUI coding agent, `dsh -p "task"` runs one headless turn, and `dsh web` serves the browser UI. +The `dsh` command-line entry follows the `apps/` assembly tier: `apps/*` are product assemblies over `packages/*` libraries. Plain `dsh` boots the interactive TUI coding agent, `dsh -p "task"` runs one headless turn, `dsh meta` boots that same TUI over this harness checkout, `dsh migrate` and `dsh upgrade` boot a fresh guided TUI session whose first turn invokes a bundled skill, `dsh list-sessions` lists the sessions running right now, and `dsh web` serves the browser UI. -Argv is parsed once through a [Commander](https://github.com/tj/commander.js) adapter ([`src/args.ts`](src/args.ts)): one program whose default (no subcommand) is the TUI/headless surface (`--config`, `-p`/`--prompt`, `--resume`) and whose `web` subcommand is the browser UI. `src/bin.ts` switches on the resolved mode and dynamic-imports only that mode's module. `dsh --help` lists every mode and `dsh web --help` renders the web usage, `dsh --version` prints this app's version, and an unknown option or a mistyped `--resume` fails loud (stderr, exit 1) instead of misrouting. `dsh web`'s `--host`/`--port` are unvalidated pass-through overrides: the `dsh-host-webserver` schema is the single source of both the default (the shipped `cordis.yml` value when a flag is absent) and validity, and rejects a bad value at boot. `--trusted-host` appends named authorities for the /api browser-trust fence; an all-interfaces bind additionally derives the machine's LAN IP literals itself ([`src/app-cli-entry.ts`](src/app-cli-entry.ts)), so the printed LAN URL works without flags. +Argv is parsed once through a [Commander](https://github.com/tj/commander.js) adapter ([`src/args.ts`](src/args.ts)): one program whose default (no subcommand) is the TUI/headless surface (`--config`, `-p`/`--prompt`, `--resume`), whose `meta` subcommand is the same TUI over this checkout, whose `migrate`/`upgrade` subcommands are option-less guided-session entries, whose `list-sessions` subcommand (alias `ps`) lists live sessions, and whose `web` subcommand is the browser UI. `src/bin.ts` switches on the resolved mode and dynamic-imports only that mode's module. `dsh --help` lists every mode and `dsh web --help` renders the web usage, `dsh --version` prints this app's version, and an unknown option or a mistyped `--resume` fails loud (stderr, exit 1) instead of misrouting. Every subcommand that shares no option with the default surface — `migrate`, `upgrade`, `list-sessions`, `web` — rejects a leaked `--config`/`-p`/`--resume` rather than running and dropping it. `dsh web`'s `--host`/`--port` are unvalidated pass-through overrides: the `dsh-host-webserver` schema is the single source of both the default (the shipped `cordis.yml` value when a flag is absent) and validity, and rejects a bad value at boot. `--trusted-host` appends named authorities for the /api browser-trust fence; an all-interfaces bind additionally derives the machine's LAN IP literals itself ([`src/app-cli-entry.ts`](src/app-cli-entry.ts)), so the printed LAN URL works without flags. The TUI surface: - boots the shipped default config (`examples/tui-agent/cordis.yml`), or the tree named by `--config ` (the demo/test escape for booting an alternate example tree), through [`dsh-app-boot`](../../packages/ui/app-boot/README.md); -- resumes a persisted session with `dsh --resume ` and, when the Node host exposes `process.execve`, supplies the TUI's in-place handoff host: after selector preflight and current-session flush, the host disposes the app and replaces the process with a normalized `dsh --resume `; runtimes without process replacement keep the displayed command fallback. The flag provides the id on the boot context under `RESUME_SESSION_ID_KEY` (no environment variable), which the shipped config reads through `!!js`, and a missing or unreadable id fails loud instead of creating a fresh session; -- treats the **invoking directory** as the workspace — sessions, relative paths, and workspace instructions resolve from the cwd; +- resumes a persisted session with `dsh --resume ` and, when the Node host exposes `process.execve`, supplies the TUI's in-place handoff host: after selector preflight and current-session flush, the host disposes the app and replaces the process with a normalized resume invocation; runtimes without process replacement leave the session running and say so. This CLI owns session identity and the exit line rather than the config: it mints or selects the `main` session id and provides it, plus the exact command that reproduces this invocation, on the boot context ([`MAIN_SESSION_ID_KEY`](../../packages/ui/tui/README.md) and `TUI_GOODBYE_MESSAGE_KEY`). No `cordis.yml` key can drop resume, and a missing or unreadable id fails loud instead of creating a fresh session; +- treats the **invoking directory** as the workspace — sessions, relative paths, and workspace instructions resolve from the cwd (`dsh meta` is the sole exception, below); - tells the agent where its own source lives: after boot it adds a prompt section naming this harness checkout, resolved from the launcher's real path so it holds under a PATH symlink and an arbitrary cwd, so the self-referential `cordis` toolset can read and modify it; - applies the personal overlay from `~/.dsh` (see [app-boot's Personal config](../../packages/ui/app-boot/README.md#personal-config)): `.env` fills environment gaps (ambient > project `.env` > personal `.env`), `config.yaml` patches the booted tree. +`dsh meta` is that same TUI with this harness checkout as the workspace, so working on dsh itself needs no `cd`. It chdirs to the checkout root — resolved from the launcher's real path, the same root the source-path prompt section names — after both `.env` layers are loaded, so environment precedence is unchanged while the session cwd, the `./.sessions` persistence root, and the HMR watch root all move together. It accepts only `--resume `; `--config` (which would boot a foreign tree) and `-p` (which is not interactive) fail loud. Because meta sessions live under the checkout, `--resume` here sees only other meta sessions, and both the in-place handoff and the printed exit line reproduce the mode as `dsh meta --resume `, so a copied command resumes the right session from any directory. + +`dsh migrate` and `dsh upgrade` are guided fresh-session entries over the default TUI surface: each mints a fresh session in the invoking directory and seeds its first turn with a bundled skill (`dsh-migrate` for migrating from another coding agent — opencode, pi, Claude Code, Codex; `dsh-upgrade` for upgrading this checkout), exactly as if the user typed `/skill:`. The launcher passes the skill name on the boot context ([`INITIAL_SKILL_KEY`](../../packages/ui/tui/README.md)), which the TUI auto-invokes once the chat is live. Both take no options — `--config`, `-p`, and `--resume` fail loud — and seed only on this first launch, so a later `dsh --resume ` of the session is an ordinary TUI session with no re-injection. + +`dsh list-sessions` lists the sessions running right now: session id, pid, uptime, workspace, and title, newest first. It is read-only and boots no agent tree — it mounts the [session registry](../../packages/session-registry/session-registry/README.md) alone, so listing is fast and cannot start model work as a side effect. Every surface publishes its sessions into that registry through [`dsh-session-registry-live`](../../packages/session-registry/session-registry-live/README.md), and records whose process is gone are pruned on read, so a crashed session disappears without cleanup. `--json` emits the same records as a machine-readable array; an empty listing prints one line and exits 0. There is no workspace filter: the listing is always every live session, whatever directory it runs in. Only top-level surfaces appear — subagents share or spawn other processes and are deliberately invisible. + The Web and headless surfaces boot one shared composition (`cordis.yml`): both treat the invoking directory as the default project and Workspace root, create named Workspaces beneath that root unless `--workspace-root ` overrides it, load applicable `AGENTS.md`/`CLAUDE.md` instructions into each agent-loop request prefix with a 65,536-byte render budget, and opt into first-message model titles. Headless differs only in listening on an OS-assigned port (parallel `dsh -p` runs never collide; the stderr-printed URL opens the live session in a browser). Both need the frontend dist and client bundles built (`pnpm run build && pnpm run build:web`). The shipped TUI and Web compositions register the native DeepSeek adapter plus pi-ai OpenAI and Anthropic profiles. Credentials and endpoint overrides come from the provider-standard `DEEPSEEK_API_KEY` / `DEEPSEEK_BASE_URL`, `OPENAI_API_KEY` / `OPENAI_BASE_URL`, and `ANTHROPIC_API_KEY` / `ANTHROPIC_BASE_URL` pairs in the boot's layered environment. diff --git a/apps/cli/README.zh.md b/apps/cli/README.zh.md index 85f4624a59..69324735cb 100644 --- a/apps/cli/README.zh.md +++ b/apps/cli/README.zh.md @@ -2,18 +2,24 @@ [English](README.md) | 中文 -`dsh` 命令行入口遵循 `apps/` 组装层:`apps/*` 是位于 `packages/*` 库之上的产品组装。直接运行 `dsh` 会启动交互式 TUI 编码 agent(智能体),`dsh -p "task"` 运行一个无头轮次,`dsh web` 则提供浏览器 UI。 +`dsh` 命令行入口遵循 `apps/` 组装层:`apps/*` 是位于 `packages/*` 库之上的产品组装。直接运行 `dsh` 会启动交互式 TUI 编码 agent(智能体),`dsh -p "task"` 运行一个无头轮次,`dsh meta` 以本 harness checkout 为 workspace 启动同一个 TUI,`dsh migrate` 和 `dsh upgrade` 启动一个全新的引导式 TUI 会话并在首轮调用内置 skill,`dsh list-sessions` 列出此刻正在运行的会话,`dsh web` 则提供浏览器 UI。 -Argv 只会通过 [Commander](https://github.com/tj/commander.js) 适配器([`src/args.ts`](src/args.ts))解析一次:同一个程序的默认形式(无子命令)是 TUI/无头界面(`--config`、`-p`/`--prompt`、`--resume`),`web` 子命令则是浏览器 UI。`src/bin.ts` 按解析后的 mode 分支,仅动态导入该 mode 的模块。`dsh --help` 列出所有 mode,`dsh web --help` 渲染 Web 用法,`dsh --version` 打印此应用的版本;未知选项或拼错的 `--resume` 会明确报错(stderr,退出码 1),而不会被错路由。`dsh web` 的 `--host`/`--port` 是未验证的直通覆盖:`dsh-host-webserver` schema 是默认值(标志缺失时使用已交付的 `cordis.yml` 值)和有效性的唯一真源,并在启动时拒绝错误值。`--trusted-host` 为 /api 浏览器信任栅栏追加具名权威;全接口绑定还会自行推导本机的 LAN IP 字面量([`src/app-cli-entry.ts`](src/app-cli-entry.ts)),因此打印出的 LAN URL 无需任何标志即可使用。 +Argv 只会通过 [Commander](https://github.com/tj/commander.js) 适配器([`src/args.ts`](src/args.ts))解析一次:同一个程序的默认形式(无子命令)是 TUI/无头界面(`--config`、`-p`/`--prompt`、`--resume`),`meta` 子命令是以本 checkout 为 workspace 的同一个 TUI,`migrate`/`upgrade` 子命令是无选项的引导会话入口,`list-sessions` 子命令(别名 `ps`)列出存活会话,`web` 子命令则是浏览器 UI。`src/bin.ts` 按解析后的 mode 分支,仅动态导入该 mode 的模块。`dsh --help` 列出所有 mode,`dsh web --help` 渲染 Web 用法,`dsh --version` 打印此应用的版本;未知选项或拼错的 `--resume` 会明确报错(stderr,退出码 1),而不会被错路由。凡与默认界面不共享任何选项的子命令(`migrate`、`upgrade`、`list-sessions`、`web`)都会拒绝泄漏进来的 `--config`/`-p`/`--resume`,而不会照常运行并丢弃它。`dsh web` 的 `--host`/`--port` 是未验证的直通覆盖:`dsh-host-webserver` schema 是默认值(标志缺失时使用已交付的 `cordis.yml` 值)和有效性的唯一真源,并在启动时拒绝错误值。`--trusted-host` 为 /api 浏览器信任栅栏追加具名权威;全接口绑定还会自行推导本机的 LAN IP 字面量([`src/app-cli-entry.ts`](src/app-cli-entry.ts)),因此打印出的 LAN URL 无需任何标志即可使用。 TUI 界面: - 启动已交付的默认配置(`examples/tui-agent/cordis.yml`),或由 `--config ` 指定的树(演示/测试用于启动其他示例树的逃生口),并通过 [`dsh-app-boot`](../../packages/ui/app-boot/README.md) 完成启动; -- 使用 `dsh --resume ` 恢复已持久化会话。当 Node 宿主公开 `process.execve` 时,还会提供 TUI 的原地移交宿主:选择器预检并刷新当前会话后,宿主会释放应用,并以规范化的 `dsh --resume ` 替换进程;不支持进程替换的运行时保留屏幕上显示的命令回退。该标志通过 `RESUME_SESSION_ID_KEY` 在启动上下文中提供 id(不使用环境变量),已交付的配置通过 `!!js` 读取它;缺失或无法读取的 id 会明确报错,而不会创建新会话; -- 将 **调用目录** 视为 workspace:会话、相对路径和 workspace 指令都从 cwd 解析; +- 使用 `dsh --resume ` 恢复已持久化会话。当 Node 宿主公开 `process.execve` 时,还会提供 TUI 的原地移交宿主:选择器预检并刷新当前会话后,宿主会释放应用,并以规范化的恢复调用替换进程;不支持进程替换的运行时会让会话继续运行并给出提示。会话身份与退出行由本 CLI 拥有,而非由配置指定:它创建或选定 `main` 会话 id,并把该 id 以及可复现本次调用的确切命令一起提供到启动上下文([`MAIN_SESSION_ID_KEY`](../../packages/ui/tui/README.md) 与 `TUI_GOODBYE_MESSAGE_KEY`)。任何 `cordis.yml` 键都无法移除恢复能力;缺失或无法读取的 id 会明确报错,而不会创建新会话; +- 将 **调用目录** 视为 workspace:会话、相对路径和 workspace 指令都从 cwd 解析(`dsh meta` 是唯一例外,见下文); - 告知 agent 自身源码所在位置:启动后添加一个命名此 harness checkout 的提示词段。该路径从启动器的真实路径解析,因此在 PATH 符号链接和任意 cwd 下仍然有效,使自指的 `cordis` 工具集可以读取并修改它; - 应用 `~/.dsh` 中的个人覆盖(参见 [app-boot 的个人配置](../../packages/ui/app-boot/README.md#personal-config)):`.env` 填补环境缺口(环境中已有的值 > 项目 `.env` > 个人 `.env`),`config.yaml` 则修补已启动的树。 +`dsh meta` 是以本 harness checkout 为 workspace 的同一个 TUI,因此开发 dsh 自身无需 `cd`。它在两层 `.env` 都加载之后才 chdir 到 checkout 根目录(从启动器的真实路径解析,与源码路径提示词段所指的根目录相同),因此环境优先级不变,而会话 cwd、`./.sessions` 持久化根目录与 HMR 监视根目录会一并移动。它只接受 `--resume `;`--config`(会启动其他配置树)和 `-p`(非交互)都会明确报错。由于 meta 会话位于该 checkout 之下,此处的 `--resume` 只能看到其他 meta 会话;原地移交与打印的退出行都会以 `dsh meta --resume ` 复现该 mode,因此复制的命令在任何目录下都能恢复到正确的会话。 + +`dsh migrate` 与 `dsh upgrade` 是默认 TUI 界面之上的引导式全新会话入口:各自在调用目录中创建一个全新会话,并以一个内置 skill 播种其首轮(`dsh-migrate` 用于从其他编码 agent 迁移——opencode、pi、Claude Code、Codex;`dsh-upgrade` 用于升级本 checkout),效果等同于用户手动键入 `/skill:`。启动器将 skill 名称提供到启动上下文([`INITIAL_SKILL_KEY`](../../packages/ui/tui/README.md)),TUI 在聊天就绪后自动调用它。两者都不接受任何选项——`--config`、`-p`、`--resume` 都会明确报错——且仅在首次启动时播种,因此之后 `dsh --resume ` 恢复该会话时是普通 TUI 会话,不会重复注入。 + +`dsh list-sessions` 列出此刻正在运行的会话:会话 id、pid、运行时长、工作区和标题,最新的在前。它是只读的,不启动任何 agent 树——它只挂载[会话注册表](../../packages/session-registry/session-registry/README.md),因此列表既快,也不会作为副作用启动模型工作。每个界面都通过 [`dsh-session-registry-live`](../../packages/session-registry/session-registry-live/README.md) 把自己的会话发布到该注册表,进程已不存在的记录会在读取时被剪除,因此崩溃的会话无需清理便会消失。`--json` 以机器可读的数组形式输出同样的记录;空列表打印一行并以 0 退出。没有工作区过滤:列表始终是全部存活会话,无论它们运行在哪个目录下。只有顶层界面会出现——subagent 共用别的进程,或 spawn 出别的进程,因此被刻意排除在列表之外。 + Web 和无头界面启动同一个共享组合(`cordis.yml`):两者都将调用目录视为默认项目和 Workspace 根目录,除非通过 `--workspace-root ` 覆盖,否则会在该根目录下创建具名 Workspace;它们会把适用的 `AGENTS.md`/`CLAUDE.md` 指令加载到每个 agent-loop 请求前缀中,渲染预算为 65,536 字节,并选用首条消息模型标题。无头界面唯一的差异是监听操作系统分配的端口(并行 `dsh -p` 运行绝不冲突;stderr 打印的 URL 会在浏览器中打开实时会话)。两者都需要先构建前端 dist 和客户端 bundle(`pnpm run build && pnpm run build:web`)。 已交付的 TUI 和 Web 组合会注册原生 DeepSeek 适配器,以及 pi-ai 的 OpenAI 和 Anthropic 提供方配置。凭据和端点覆盖来自启动分层环境中的提供方标准变量对:`DEEPSEEK_API_KEY` / `DEEPSEEK_BASE_URL`、`OPENAI_API_KEY` / `OPENAI_BASE_URL` 和 `ANTHROPIC_API_KEY` / `ANTHROPIC_BASE_URL`。 diff --git a/apps/cli/package.json b/apps/cli/package.json index 0914aaf5aa..783fff6487 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -70,6 +70,9 @@ "@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^", "@deepseek-ai/dsh-session-projection": "workspace:^", "@deepseek-ai/dsh-session-projection-cache": "workspace:^", + "@deepseek-ai/dsh-session-registry": "workspace:^", + "@deepseek-ai/dsh-session-registry-file": "workspace:^", + "@deepseek-ai/dsh-session-registry-live": "workspace:^", "@deepseek-ai/dsh-session-title": "workspace:^", "@deepseek-ai/dsh-session-title-first-message-llm": "workspace:^", "@deepseek-ai/dsh-skill": "workspace:^", diff --git a/apps/cli/src/app-cli-entry.ts b/apps/cli/src/app-cli-entry.ts index 668d8f7f02..02bf596403 100644 --- a/apps/cli/src/app-cli-entry.ts +++ b/apps/cli/src/app-cli-entry.ts @@ -18,7 +18,7 @@ import Loader from '@cordisjs/plugin-loader' import Include, { type PatchOptions } from '@cordisjs/plugin-include' import yaml from 'js-yaml' import { assertEntriesLoaded, installFailLoud, loadEnv } from '@deepseek-ai/dsh-app-boot' -import { resolveDshHome } from '@deepseek-ai/dsh-paths' +import { resolveDshHome, resolveSessionsRoot } from '@deepseek-ai/dsh-paths' // Empty type import carries the httpServer Context merge for the port read below. import type {} from '@deepseek-ai/dsh-host-webserver' @@ -178,11 +178,11 @@ export class AppCLIEntry { overrides.set(entryId, bag) } - // Source 0: computed engineering defaults. The session store defaults to - // a global dir under the Harness home ($DSH_HOME, else ~/.dsh) so history - // is shared across every cwd, not a project-local ./.sessions. The profile + // Source 0: computed engineering defaults. The session store is the one + // shared root every dsh surface resolves, so history follows the user across + // working directories instead of splitting per project. The profile // (Source 1) overwrites this same field via last-write-wins in put(). - put('session-persistence-jsonl', 'root', join(resolveDshHome(), 'sessions')) + put('session-persistence-jsonl', 'root', resolveSessionsRoot()) // Source 1: profile json (missing file = empty; unmapped key = loud). for (const [key, value] of Object.entries(this.readProfile())) { @@ -209,6 +209,7 @@ export class AppCLIEntry { // user config. Workspace knowledge stays here. put('webserver', 'distIndex', this.resolveDistIndex()) + this.patches = [...overrides.entries()].map(([id, bag]) => { const yml = rows.get(id) if (yml === undefined) throw new Error(`dsh: patch target row "${id}" not found in ${this.options.configPath}`) diff --git a/apps/cli/src/args.ts b/apps/cli/src/args.ts index b929dc73f2..d143430ed4 100644 --- a/apps/cli/src/args.ts +++ b/apps/cli/src/args.ts @@ -2,9 +2,9 @@ * Commander adapter for the `dsh` command-line entry: the one place argv is * parsed and routed to a mode. `bin.ts` switches on the returned discriminant * and dynamic-imports that mode's module. One program: the default (no - * subcommand) is the TUI/headless surface with option-only flags; `web` is a - * real subcommand. Commander owns `--help`/`--version` and parse errors — it - * prints and exits at the point of failure (a domain failure routes through + * subcommand) is the TUI/headless surface with option-only flags; `meta` and + * `web` are real subcommands. Commander owns `--help`/`--version` and parse + * errors — it prints and exits at the point of failure (a domain failure routes through * `command.error`), so this returns only a resolved mode. * @module @deepseek-ai/dsh/args */ @@ -24,6 +24,39 @@ interface HeadlessInvocation { prompt: string } +/** + * Interactive TUI over this harness checkout: `dsh meta`. Identical to + * {@link TuiInvocation} except the workspace is the launcher's own source tree + * rather than the invoking directory. No `--config`: booting a foreign tree + * against the harness workspace is the `--config` case, not this one. + */ +interface MetaInvocation { + mode: 'meta' + resume?: string +} + +/** + * Guided fresh-session entries: `dsh migrate` seeds the first turn with the + * `dsh-migrate` skill, `dsh upgrade` with `dsh-upgrade`. Each always mints a + * fresh session in the invoking directory and takes no options — `--resume`, + * `--config`, and `-p` are rejected as mistyped, so there is nothing to carry. + */ +interface SkillSessionInvocation { + mode: 'migrate' | 'upgrade' +} + +/** + * List live sessions: `dsh list-sessions` (alias `dsh ps`). A read-only surface + * that boots no agent tree — it reads the cross-process session registry and + * exits. `json` selects the machine-readable form over the human table. There + * is no workspace filter: the listing is always every live session, whatever + * directory it runs in. + */ +interface ListSessionsInvocation { + mode: 'list-sessions' + json: boolean +} + /** * Browser UI: `dsh web`. `host`/`port` are present only when the flag was * passed — pass-through overrides with no CLI default and no CLI validation: @@ -45,7 +78,13 @@ interface WebInvocation { } /** The resolved `dsh` invocation: exactly one mode. `--help`/`--version`/errors exit inside {@link parseDshArgs}. */ -export type DshInvocation = TuiInvocation | HeadlessInvocation | WebInvocation +export type DshInvocation = + | TuiInvocation + | HeadlessInvocation + | MetaInvocation + | SkillSessionInvocation + | ListSessionsInvocation + | WebInvocation /** Raw web-subcommand options straight from Commander. */ interface WebOptions { @@ -86,13 +125,22 @@ export function parseDshArgs(argv: readonly string[], version: string): DshInvoc const program = new Command() .name('dsh') .version(version, '-V, --version', 'output the version number') - .description('dsh: interactive TUI (default), headless task, and browser UI') + .description('dsh: DeepSeek Harness — an interactive coding agent for your terminal.\nRun `dsh` with no arguments to start a session in the current directory.') + // The default surface takes no positional task, so `dsh "task"` fails + // commander's arity check with no hint; these examples are where a first + // reader learns the entry points and that a one-shot task rides `-p`. + .addHelpText('after', ` +Examples: + dsh start an interactive session in this directory + dsh -p "run the tests" answer one task, print the result, and exit + dsh --resume continue a past session (list ids with \`dsh ps\`) +`) .exitOverride() // Default surface: option-only (no positional), so `web` can be a real // subcommand without a positional collision. - .option('--config ', 'boot an alternate cordis.yml instead of the shipped tree (TUI mode)') - .option('-p, --prompt ', 'run one headless turn for this task, print the result, and exit') - .option('--resume ', 'resume the persisted session with this id (TUI mode)') + .option('-p, --prompt ', 'answer this task without the interactive UI, then exit') + .option('--resume ', 'continue a past session by id (list ids with `dsh ps`)') + .option('--config ', 'start with an alternate plugin configuration file') .action((options: { config?: string; prompt?: string; resume?: string }) => { if (options.prompt !== undefined) { // A headless prompt owns the invocation; an empty task has nothing to @@ -115,25 +163,84 @@ export function parseDshArgs(argv: readonly string[], version: string): DshInvoc } }) - const web = program.command('web').description('serve the browser UI (host/port default to the shipped config)') + // Commander parses the parent (default-surface) options on either side of a + // subcommand into `program.opts()`. For a subcommand that shares none of them, + // a leaked `--config`/`-p`/`--resume` is a mistyped invocation that must fail + // loud rather than silently run and drop the input. + const rejectParentOptions = (command: string): void => { + const parent = program.opts<{ config?: string; prompt?: string; resume?: string }>() + if (parent.config !== undefined || parent.prompt !== undefined || parent.resume !== undefined) { + program.error(`error: ${command} takes none of --config, -p/--prompt, or --resume`) + } + } + + // Registration order is the rendered help order, so daily use comes first + // and the harness-development surfaces (`web --dev`, `meta`) come last. + // `migrate` and `upgrade` are guided fresh-session entries: they take no + // options and always mint a fresh session, so nothing is left to carry. Each + // description names the outcome, not the skill the first turn invokes. + const guided = { + migrate: 'import settings from another coding agent (Claude Code, Codex, opencode)', + upgrade: 'update this dsh installation to the latest version', + } as const + for (const mode of ['migrate', 'upgrade'] as const) { + program + .command(mode) + .description(guided[mode]) + .action(() => { + rejectParentOptions(mode) + resolved = { mode } + }) + } + + program + .command('list-sessions') + .alias('ps') + .description('list sessions running right now') + .option('--json', 'print the records as a JSON array instead of a table') + .action((options: { json?: boolean }) => { + rejectParentOptions('list-sessions') + resolved = { mode: 'list-sessions', json: options.json === true } + }) + + // Host and port name no default: the CLI passes neither through when the flag + // is absent, so the shipped `cordis.yml` value stands and restating it here + // would duplicate a fact this file does not own. + const web = program.command('web').description('serve the browser UI on the configured host and port') web - .option('--host ', 'override the config bind host (127.0.0.1 or 0.0.0.0)') - .option('--port ', 'override the config listen port (0 requests an OS-assigned port)') - .option('--dev', 'mount the client HMR driver and watch plugin bundles for rebuilds') - .option('--workspace-root ', 'parent directory for name-created workspaces') + .option('--host ', 'bind host; pass 0.0.0.0 to reach it from another machine') + .option('--port ', 'listen port; pass 0 to let the OS pick a free one') + .option('--dev', 'developer mode: hot-reload the browser client') + .option('--workspace-root ', 'parent directory for workspaces created from the browser UI') .option('--trusted-host ', 'extra authority the /api browser-trust fence accepts (host or host:port; repeatable)') .action((options: WebOptions) => { - // Commander parses the parent (default-surface) options on either side of - // the subcommand into `program.opts()`. `web` shares none of them, so a - // leaked `--config`/`-p`/`--resume` is a mistyped invocation that must - // fail loud rather than silently start the web server and drop it. - const parent = program.opts<{ config?: string; prompt?: string; resume?: string }>() - if (parent.config !== undefined || parent.prompt !== undefined || parent.resume !== undefined) { - program.error('error: web takes none of --config, -p/--prompt, or --resume') - } + rejectParentOptions('web') resolved = resolveWeb(options) }) + // `--resume` is NOT redeclared here: an option a subcommand shares with its + // parent parses into `program.opts()` and leaves the subcommand's own options + // empty, so redeclaring it would silently drop the id. Commander therefore + // omits it from this subcommand's option list, hence the trailing help text. + program + .command('meta') + .description('work on the dsh source that runs this command, from any directory') + .addHelpText('after', '\nAccepts --resume to resume a persisted session from this checkout.\n') + .action(() => { + // Commander parses the parent (default-surface) options on either side of + // the subcommand into `program.opts()`. `meta` accepts only `--resume`, so + // a leaked `--config`/`-p` is a mistyped invocation that must fail loud + // rather than silently be dropped. + const parent = program.opts<{ config?: string; prompt?: string; resume?: string }>() + if (parent.config !== undefined || parent.prompt !== undefined) { + program.error('error: meta takes neither --config nor -p/--prompt') + } + // Same reason as the default surface: an empty id would start a fresh + // session downstream instead of failing the mistyped resume. + if (parent.resume === '') program.error('error: --resume needs a session id') + resolved = { mode: 'meta', ...parent.resume !== undefined && { resume: parent.resume } } + }) + try { program.parse(argv, { from: 'user' }) } catch (error) { diff --git a/apps/cli/src/bin.ts b/apps/cli/src/bin.ts index 88dbece55a..8079db1cd4 100644 --- a/apps/cli/src/bin.ts +++ b/apps/cli/src/bin.ts @@ -43,6 +43,22 @@ switch (invocation.mode) { await runTui(invocation.config, invocation.resume) break } + case 'meta': { + const { runMeta } = await import('./tui.ts') + await runMeta(invocation.resume) + break + } + case 'list-sessions': { + const { runListSessions } = await import('./list-sessions.ts') + await runListSessions(invocation.json) + break + } + case 'migrate': + case 'upgrade': { + const { runSkillSession } = await import('./tui.ts') + await runSkillSession(`dsh-${invocation.mode}`) + break + } default: invocation satisfies never throw new Error(`dsh: unhandled invocation mode ${JSON.stringify(invocation)}`) diff --git a/apps/cli/src/headless.ts b/apps/cli/src/headless.ts index d7588ccae4..7ee18c1ea1 100644 --- a/apps/cli/src/headless.ts +++ b/apps/cli/src/headless.ts @@ -14,6 +14,7 @@ import type { MuxFrame } from '@deepseek-ai/dsh-host-apiproxy/api' import type { RpcRequest, RpcResponse } from '@deepseek-ai/dsh-host-apiproxy/api/rpc' import type { SessionId } from '@deepseek-ai/dsh-session' import { AppCLIEntry } from './app-cli-entry.ts' +import { registerLiveSessions } from './register-session.ts' /** Outcome of one headless turn: aggregated final text plus the turn-end reason kind. */ interface TurnOutcome { @@ -80,6 +81,7 @@ export async function runHeadless(task: string): Promise { port: 0, }) const { ctx, port } = await entry.run() + await registerLiveSessions(ctx) const dispose = async (): Promise => { await ctx.fiber.dispose() } // The headless session is web-observable while it runs (same composition). process.stderr.write(`dsh: observing at http://127.0.0.1:${String(port)}\n`) diff --git a/apps/cli/src/list-sessions.ts b/apps/cli/src/list-sessions.ts new file mode 100644 index 0000000000..08a972f402 --- /dev/null +++ b/apps/cli/src/list-sessions.ts @@ -0,0 +1,101 @@ +/** + * `dsh list-sessions` (alias `dsh ps`) — list the sessions running right now. + * + * A read-only surface: it mounts the session registry alone and never boots an + * agent tree, so listing stays fast and cannot start model work as a side + * effect. Liveness comes from the registry, which prunes records whose process + * is gone, and every displayed field including the title comes from the record, + * so no session log is opened and no backend format is assumed. + * @module @deepseek-ai/dsh/list-sessions + */ + +import { Context } from 'cordis' +import { type SessionRegistryRecord } from '@deepseek-ai/dsh-session-registry' +import SessionRegistryFile from '@deepseek-ai/dsh-session-registry-file' +import { registryRoot } from './register-session.ts' + +/** Column header text, also the minimum width of each column. */ +const HEADERS = ['SESSION', 'PID', 'UPTIME', 'WORKSPACE', 'TITLE'] as const + +/** Shown when a session has no title yet. */ +const NO_TITLE = '—' + +/** + * Render milliseconds of uptime as a compact human duration. + * @param ms - elapsed milliseconds since the session registered. + * @returns a short duration such as `12s`, `4m`, or `2h14m`. + */ +export function formatUptime(ms: number): string { + const seconds = Math.max(0, Math.floor(ms / 1000)) + if (seconds < 60) return `${String(seconds)}s` + const minutes = Math.floor(seconds / 60) + if (minutes < 60) return `${String(minutes)}m` + const hours = Math.floor(minutes / 60) + const remainder = minutes % 60 + if (hours < 24) return remainder === 0 ? `${String(hours)}h` : `${String(hours)}h${String(remainder)}m` + const days = Math.floor(hours / 24) + const leftoverHours = hours % 24 + return leftoverHours === 0 ? `${String(days)}d` : `${String(days)}d${String(leftoverHours)}h` +} + +/** One fully-resolved listing row, in column order. */ +type Row = readonly [string, string, string, string, string] + +/** + * Build the display rows for a listing, newest session first. + * @param records - the live records to render. + * @param now - the current epoch milliseconds uptime is measured against. + * @returns one row per record, each already stringified per column. + */ +export function buildRows(records: readonly SessionRegistryRecord[], now: number): Row[] { + return [...records] + .sort((left, right) => right.startedAt - left.startedAt) + .map(record => [ + record.sessionId, + String(record.pid), + formatUptime(now - record.startedAt), + record.cwd, + record.title ?? NO_TITLE, + ] as const) +} + +/** + * Render rows as a left-aligned table with a header line. + * + * The last column is never padded, so a long title cannot add trailing + * whitespace to every line. + * @param rows - the rows to render, already stringified. + * @returns the complete table text, newline-terminated. + */ +export function renderTable(rows: readonly Row[]): string { + const widths = HEADERS.map((header, column) => + Math.max(header.length, ...rows.map(row => row[column]?.length ?? 0))) + const line = (cells: readonly string[]): string => + cells.map((cell, column) => column === cells.length - 1 ? cell : cell.padEnd(widths[column] ?? 0)).join(' ').trimEnd() + return [line(HEADERS), ...rows.map(row => line(row))].join('\n') + '\n' +} + +/** + * List live sessions and exit. Prints a table by default, or a JSON array with + * `--json`; an empty listing is a success, not an error. + * @param json - emit the machine-readable JSON array instead of the table. + */ +export async function runListSessions(json: boolean): Promise { + const ctx = new Context() + await ctx.plugin(SessionRegistryFile, { root: registryRoot() }) + const records = await ctx.sessionRegistry.list() + await ctx.fiber.dispose() + + if (json) { + const rows = [...records] + .sort((left, right) => right.startedAt - left.startedAt) + .map(record => ({ ...record, uptimeMs: Date.now() - record.startedAt, title: record.title ?? null })) + process.stdout.write(`${JSON.stringify(rows, undefined, 2)}\n`) + return + } + if (records.length === 0) { + process.stdout.write('no dsh sessions running\n') + return + } + process.stdout.write(renderTable(buildRows(records, Date.now()))) +} diff --git a/apps/cli/src/register-session.ts b/apps/cli/src/register-session.ts new file mode 100644 index 0000000000..ff78456e8a --- /dev/null +++ b/apps/cli/src/register-session.ts @@ -0,0 +1,44 @@ +/** + * Mounts the cross-process live-session registry that `dsh list-sessions` reads, plus the + * publisher that keeps it in step with this process's sessions. + * + * Both plugins mount on the booted app's own context, so records share that + * fiber's lifetime: an ordinary exit disposes the fiber and deregisters, while a + * killed process leaves records the next reader prunes by pid. Only top-level + * surfaces a user launches mount this — in-process subagents have no process of + * their own, and out-of-process subagent backends spawn `dsh-jsonrpc-agent` + * rather than this CLI, so neither reaches this path. + * @module @deepseek-ai/dsh/register-session + */ + +import { join } from 'node:path' +import type { Context } from 'cordis' +import { resolveDshHome } from '@deepseek-ai/dsh-paths' +import SessionRegistryFile from '@deepseek-ai/dsh-session-registry-file' +import * as sessionRegistryLive from '@deepseek-ai/dsh-session-registry-live' + +/** Registry root under the Harness home, shared by every surface and by `dsh list-sessions`. */ +export const registryRoot = (): string => join(resolveDshHome(), 'run') + +/** + * Publish this process's sessions for the lifetime of `ctx`. + * + * Publication follows session lifecycle rather than a launcher-known id, so one + * path serves every surface identically — the TUI's single session and a + * server's on-demand ones alike — and titles reach the listing as they are + * logged. + * + * Mounting is best-effort: a registry failure must not take down a working agent + * session, because the registry is an observability aid rather than part of the + * agent's contract. Failures warn through the context logger. + * @param ctx - the booted app context whose lifetime the records share. + */ +export async function registerLiveSessions(ctx: Context): Promise { + try { + const scope = ctx.isolate('sessionRegistry') + await scope.plugin(SessionRegistryFile, { root: registryRoot() }) + await scope.plugin(sessionRegistryLive) + } catch (error) { + ctx.logger('dsh').warn('session registry unavailable; `dsh list-sessions` will not list these sessions: %s', String(error)) + } +} diff --git a/apps/cli/src/tui.ts b/apps/cli/src/tui.ts index 0d402c1c51..fb1c354d46 100644 --- a/apps/cli/src/tui.ts +++ b/apps/cli/src/tui.ts @@ -4,13 +4,20 @@ * from the Harness home (`~/.dsh`): its `.env` fills environment gaps (precedence: * ambient environment, then the invoking directory's `.env`, then the personal one) * and its `config.yaml` patches the booted tree. The workspace is the invoking - * directory: sessions, relative paths, and workspace instructions resolve from - * the cwd, so `dsh` acts on whatever project it is launched in. After boot, the - * agent's system prompt is told the path to this harness checkout so it can find - * its own source. + * directory: the session cwd, relative paths, and workspace instructions resolve + * from it, so `dsh` acts on whatever project it is launched in. Session storage + * is the exception — it lives under the Harness home so `/resume` reaches every + * workspace, and an in-place resume enters the selected session's own directory. + * `dsh meta` + * ({@link runMeta}) is the one exception — it makes this harness checkout the + * workspace. `dsh migrate`/`dsh upgrade` ({@link runSkillSession}) are fresh + * sessions whose first turn auto-invokes a bundled skill. After boot, the + * agent's system prompt is told the path to this harness checkout so it can + * find its own source. * @module @deepseek-ai/dsh/tui */ +import { randomUUID } from 'node:crypto' import { join } from 'node:path' import { fileURLToPath } from 'node:url' import { @@ -19,13 +26,18 @@ import { installFailLoud, loadEnv, loadPersonalPatches, - RESUME_SESSION_ID_KEY, resolveConfigPath, } from '@deepseek-ai/dsh-app-boot' -import { resolveDshHome } from '@deepseek-ai/dsh-paths' +import { resolveDshHome, resolveSessionsRoot } from '@deepseek-ai/dsh-paths' +import { SessionId } from '@deepseek-ai/dsh-session' import type { Context } from 'cordis' +import { registerLiveSessions } from './register-session.ts' import { + INITIAL_SKILL_KEY, + MAIN_SESSION_ID_KEY, + SESSIONS_ROOT_KEY, TUI_GOODBYE_MESSAGE_KEY, + type MainSessionIdentity, type TuiResumeHost, } from '@deepseek-ai/dsh-tui' @@ -41,18 +53,65 @@ const DEFAULT_CONFIG = fileURLToPath(new URL('../../../examples/tui-agent/cordis // symlink, an arbitrary cwd). The agent is told where its own source lives. const SOURCE_ROOT = fileURLToPath(new URL('../../..', import.meta.url)) +/** + * The value `dsh` provides on the {@link SESSIONS_ROOT_KEY} boot slot: its + * shared session-store root, `sessions` under the Harness home. Shared-store + * policy is the launcher's alone — the app bundle treats the slot as opaque and + * keeps a project-local fallback, so only `dsh` decides that sessions are + * shared across working directories (making `/resume` and `list-sessions` span + * every workspace). + * @returns the absolute session-store root this launcher shares. + */ +export function launcherSessionsRoot(): string { + return resolveSessionsRoot() +} + /* v8 ignore start -- composition over the unit-tested dsh-app-boot helpers; the tui-agent PTY smoke drives this path end to end, personal overlay included */ +/** + * Run the interactive TUI with this harness checkout as the workspace + * (`dsh meta`), whatever directory it was launched from. + * @param resumeSessionId - a persisted session id to resume, or `undefined`; + * see {@link runTui}. Meta-mode sessions live under the checkout, so an id from + * an ordinary `dsh` run in another directory is not found here. + */ +export async function runMeta(resumeSessionId: string | undefined): Promise { + return runTui(undefined, resumeSessionId, SOURCE_ROOT) +} + +/** + * Run the interactive TUI as a guided fresh session whose first turn invokes a + * bundled skill (`dsh migrate` → `dsh-migrate`, `dsh upgrade` → `dsh-upgrade`). + * Always mints a fresh session in the invoking directory; the skill is seeded + * only on this first launch, so a later `--resume` of the session is an ordinary + * TUI session with no re-injection. + * @param skill - the bundled skill name to auto-invoke as the first turn. + */ +export async function runSkillSession(skill: string): Promise { + return runTui(undefined, undefined, undefined, skill) +} + /** * Run the interactive TUI from the invoking directory. * @param config - a config path to boot instead of the shipped default, or * `undefined` for the default; already parsed from `--config`. - * @param resumeSessionId - a persisted session id to resume, or `undefined`; - * already parsed and non-empty-validated from `--resume`. It is provided on the - * boot context under {@link RESUME_SESSION_ID_KEY}, which the shipped config - * reads through `!!js` to rehydrate that session. + * @param resumeSessionId - a persisted session id to resume, or `undefined` to + * mint a fresh one; already parsed and non-empty-validated from `--resume`. + * Either way the resulting identity reaches the booted app through + * {@link MAIN_SESSION_ID_KEY}, so no config key selects the session. + * @param workspace - a directory to make the workspace instead of the invoking + * one, or `undefined` to keep the cwd. Only `dsh meta` passes it. + * @param initialSkill - a bundled skill to auto-invoke as a fresh session's + * first turn, or `undefined`. Set only by {@link runSkillSession} and ignored + * on a resume, so it never re-fires; reaches the app through + * {@link INITIAL_SKILL_KEY}. */ -export async function runTui(config: string | undefined, resumeSessionId: string | undefined): Promise { +export async function runTui( + config: string | undefined, + resumeSessionId: string | undefined, + workspace?: string, + initialSkill?: string, +): Promise { // 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. @@ -66,28 +125,52 @@ export async function runTui(config: string | undefined, resumeSessionId: string // The bin already loaded the invoking directory's .env; the personal .env // only fills what is still unset (process.loadEnvFile never overrides). loadEnv(NAME, resolveDshHome()) + // Both .env layers are loaded, so switching the workspace here cannot alter + // environment precedence. The cwd IS the workspace seam: the shipped config + // resolves the session cwd and the HMR watch root from it, so one chdir moves + // both together. Sessions themselves live under the Harness home so `/resume` + // spans every workspace, and are unaffected by this chdir. + if (workspace !== undefined) process.chdir(workspace) process.env.DSH_BUNDLED_SKILL_DIR = join(SOURCE_ROOT, 'skills') // The in-place `/resume` handoff re-execs `dsh` with a normalized `--resume` - // flag, so the resumed process rehydrates through this same intake. The host - // is offered only when Node exposes `process.execve` and knows its own entry. + // flag, so the resumed process rehydrates through this same intake. The + // selected session may belong to another workspace, so the handoff also enters + // that directory. The host is offered only when Node exposes `process.execve` + // and knows its own entry. const entry = process.argv[1] const execve = process.execve?.bind(process) const app: { current?: Context } = {} - const resumeCommand = (sessionId: string): string => - `${NAME} --resume=${sessionId}${config === undefined ? '' : ` --config ${config}`}` + // Resuming reproduces THIS invocation with a different id. Meta mode is a + // subcommand that rejects `--config`, while the default surface carries it, so + // both the in-place handoff and the printed command derive from one shape. + // `meta` is only reproducible for a target inside this checkout: it chdirs to + // SOURCE_ROOT itself, which would override any other workspace, so a + // cross-workspace resume takes the default surface and the caller supplies the + // directory instead. + const resumeArgs = (sessionId: string, targetCwd?: string): string[] => + workspace !== undefined && (targetCwd === undefined || targetCwd === workspace) + ? ['meta', `--resume=${sessionId}`] + : [`--resume=${sessionId}`, ...config !== undefined ? ['--config', config] : []] + // Mint the fresh id here rather than in the app bundle: the exit line names + // the session to resume, so the launcher must know it before the tree boots. + const identity: MainSessionIdentity = resumeSessionId === undefined + ? { id: SessionId(`main-session-${randomUUID()}`), resume: false } + : { id: SessionId(resumeSessionId), resume: true } + const goodbye = `To resume this session: ${NAME} ${resumeArgs(identity.id).join(' ')}` const resumeHost: TuiResumeHost | undefined = entry === undefined || execve === undefined ? undefined : { async handoff(sessionId, cwd): Promise { const current = app.current if (current === undefined) throw new Error(`${NAME}: app boot has not completed`) - // Rebuild argv from the parsed config plus the selected id: TUI mode's - // only arguments are `--config ` and `--resume `. const nextArgv = [ process.execPath, ...process.execArgv, entry, - `--resume=${sessionId}`, - ...config !== undefined ? ['--config', config] : [], + ...resumeArgs(sessionId, cwd), ] + // `execve` inherits the cwd, and the target session may belong to another + // workspace. Enter it BEFORE teardown commits: an unreachable directory + // (deleted, unreadable) must reject while the caller can still restore the + // terminal, and a chdir after disposal would have no owner to report to. try { process.chdir(cwd) } catch (error) { @@ -108,16 +191,27 @@ export async function runTui(config: string | undefined, resumeSessionId: string resolveConfigPath(config ?? DEFAULT_CONFIG, undefined), loadPersonalPatches(NAME), (hostCtx) => { - // Inject the resume id (or undefined) so the shipped config's `!!js` - // reads it as a bare identifier; then offer the in-place handoff host. - hostCtx.provide(RESUME_SESSION_ID_KEY, resumeSessionId) - if (resumeSessionId !== undefined) { - hostCtx.provide(TUI_GOODBYE_MESSAGE_KEY, `To resume this session: ${resumeCommand(resumeSessionId)}`) - } + // The launcher owns session identity and the exit line: a config-mounted + // app bundle reads both from these slots, so no cordis.yml key can drop + // resume. + hostCtx.provide(MAIN_SESSION_ID_KEY, identity) + hostCtx.provide(TUI_GOODBYE_MESSAGE_KEY, goodbye) + // Shared-store policy is the launcher's: sessions live in one root under + // the Harness home across every cwd, so /resume and list-sessions see + // every workspace. The bundle treats the slot as opaque. + hostCtx.provide(SESSIONS_ROOT_KEY, launcherSessionsRoot()) if (resumeHost !== undefined) hostCtx.provide('tuiResumeHost', resumeHost) + // Seed the first turn only for a fresh session, so resuming never + // re-invokes the skill. + if (initialSkill !== undefined && resumeSessionId === undefined) { + hostCtx.provide(INITIAL_SKILL_KEY, initialSkill) + } }, ) app.current = ctx addHarnessSourceSection(ctx, SOURCE_ROOT) + // Publication follows the store; meta mode already chdir'd, so each session + // reports its own cwd. + await registerLiveSessions(ctx) } /* v8 ignore stop */ diff --git a/apps/cli/src/web.ts b/apps/cli/src/web.ts index 69e79ab5d9..f4ad016005 100644 --- a/apps/cli/src/web.ts +++ b/apps/cli/src/web.ts @@ -8,6 +8,7 @@ import { fileURLToPath } from 'node:url' import { AppCLIEntry } from './app-cli-entry.ts' +import { registerLiveSessions } from './register-session.ts' const CONFIG_PATH = fileURLToPath(new URL('../cordis.yml', import.meta.url)) @@ -40,6 +41,7 @@ export async function runWeb( ...trustedHosts !== undefined && { trustedHosts }, }) const { ctx, port: boundPort } = await entry.run() + await registerLiveSessions(ctx) let exiting = false const shutdown = (code: number): void => { diff --git a/apps/cli/tests/args.spec.ts b/apps/cli/tests/args.spec.ts index 45830eee30..99569c7cf0 100644 --- a/apps/cli/tests/args.spec.ts +++ b/apps/cli/tests/args.spec.ts @@ -24,17 +24,33 @@ function exitCode(argv: string[]): number { afterEach(() => { vi.restoreAllMocks() }) describe('parseDshArgs', () => { - it('routes each mode by its shape: default TUI, -p headless, web subcommand', () => { + it('routes each mode by its shape: default TUI, -p headless, meta and web subcommands', () => { expect(parse([])).toEqual({ mode: 'tui' }) expect(parse(['--config', 'custom.yml'])).toEqual({ mode: 'tui', config: 'custom.yml' }) expect(parse(['--resume', 'sess', '--config', 'app.yml'])).toEqual({ mode: 'tui', config: 'app.yml', resume: 'sess' }) expect(parse(['-p', 'do the thing'])).toEqual({ mode: 'headless', prompt: 'do the thing' }) + // `meta` accepts `--resume` but does not redeclare it: a shared option parses + // into program.opts() on either side of the subcommand, and redeclaring it + // would leave the subcommand's own options empty and drop the id. + expect(parse(['meta'])).toEqual({ mode: 'meta' }) + expect(parse(['meta', '--resume', 'sess'])).toEqual({ mode: 'meta', resume: 'sess' }) + expect(parse(['--resume', 'sess', 'meta'])).toEqual({ mode: 'meta', resume: 'sess' }) + // Credential setup is option-free: it writes the Harness-home .env, so + // there is nothing for a flag to select. // Bare `web` carries no host/port: the shipped cordis.yml owns the default. expect(parse(['web'])).toEqual({ mode: 'web', dev: false }) // Host/port are unvalidated pass-throughs (the webserver schema gates them // at boot); the adapter only coerces the port string to a number. expect(parse(['web', '--host', '0.0.0.0', '--port', '8080', '--dev', '--workspace-root', '/w'])) .toEqual({ mode: 'web', host: '0.0.0.0', port: 8080, dev: true, workspaceRoot: '/w' }) + // Guided fresh-session entries carry nothing: bare mode discriminant only. + expect(parse(['migrate'])).toEqual({ mode: 'migrate' }) + expect(parse(['upgrade'])).toEqual({ mode: 'upgrade' }) + // `list-sessions` has one option and no workspace filter: the listing is always + // global. `ps` is its alias and resolves to the same mode. + expect(parse(['list-sessions'])).toEqual({ mode: 'list-sessions', json: false }) + expect(parse(['list-sessions', '--json'])).toEqual({ mode: 'list-sessions', json: true }) + expect(parse(['ps', '--json'])).toEqual({ mode: 'list-sessions', json: true }) // --trusted-host is variadic and repeatable; authorities pass through unvalidated. expect(parse(['web', '--trusted-host', 'harness.internal:3080', 'lab.internal', '--trusted-host', '10.0.0.9'])) .toEqual({ mode: 'web', dev: false, trustedHosts: ['harness.internal:3080', 'lab.internal', '10.0.0.9'] }) @@ -55,6 +71,27 @@ describe('parseDshArgs', () => { expect(exitCode(['web', '-p', 'task'])).toBe(1) expect(exitCode(['web', '--resume', 's'])).toBe(1) expect(exitCode(['--config', 'c.yml', 'web'])).toBe(1) + // Same rule for credential setup: it shares no option with the default + // surface, so a leaked flag is a typo, not something to ignore. + // `meta` fixes its own config tree and is interactive, so --config/-p are + // rejected; an empty id is swallowed downstream exactly as above. + expect(exitCode(['meta', '--resume='])).toBe(1) + expect(exitCode(['meta', '--config', 'c.yml'])).toBe(1) + expect(exitCode(['meta', '-p', 'task'])).toBe(1) + // `migrate`/`upgrade` take no options: any leaked default-surface flag is a + // mistyped invocation, not a silently-dropped input. + expect(exitCode(['migrate', '--resume', 's'])).toBe(1) + expect(exitCode(['migrate', '--config', 'c.yml'])).toBe(1) + expect(exitCode(['migrate', '-p', 'task'])).toBe(1) + expect(exitCode(['upgrade', '--resume', 's'])).toBe(1) + expect(exitCode(['upgrade', '--config', 'c.yml'])).toBe(1) + expect(exitCode(['-p', 'task', 'upgrade'])).toBe(1) + // `list-sessions`/`ps` is read-only and shares no default-surface option: a leaked flag is a + // mistyped invocation, not a listing with a silently dropped input. + expect(exitCode(['ps', '--resume', 's'])).toBe(1) + expect(exitCode(['list-sessions', '--config', 'c.yml'])).toBe(1) + expect(exitCode(['list-sessions', '-p', 'task'])).toBe(1) + expect(exitCode(['--resume', 's', 'ps'])).toBe(1) }) it('exits 0 for --help (disclosing web) and --version', () => { diff --git a/apps/cli/tests/built-bin.e2e.ts b/apps/cli/tests/built-bin.e2e.ts index 1ea7d9f0db..71c8257b08 100644 --- a/apps/cli/tests/built-bin.e2e.ts +++ b/apps/cli/tests/built-bin.e2e.ts @@ -1,8 +1,9 @@ -import { existsSync } from 'node:fs' +import { existsSync, mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' import { join } from 'node:path' import { fileURLToPath } from 'node:url' import { execa } from 'execa' -import { describe, expect, it } from 'vitest' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' /** * Published-entry smoke for the `dsh` bin: run the built `lib/bin.js` under @@ -16,19 +17,28 @@ import { describe, expect, it } from 'vitest' * node_modules, so no external consumer is assembled; missing-config fail-loud * and full-boot coverage for the shared dsh-app-boot glue live in cli-demo's * built-bin suite, and interactive TTY behavior is PTY-covered by - * examples/tui-agent. Skips before the bin is built. + * examples/tui-agent. `dsh list-sessions` is covered here too: it is the one surface that + * boots no agent tree, so the built bin is the whole product path. + * Skips before the bin is built. */ const repoRoot = fileURLToPath(new URL('../../../', import.meta.url)) const dshBin = join(repoRoot, 'apps/cli/lib/bin.js') -/** Run the built bin with PIPED stdio (stdin closed at EOF); resolve with output + exit code. */ -async function runBuiltBin(): Promise<{ stdout: string; code: number; stderr: string }> { - const result = await execa(process.execPath, [dshBin], { +/** + * Run the built bin with PIPED stdio (stdin closed at EOF); resolve with output + * + exit code. `env` isolates the Harness home for surfaces that read it. + */ +async function runBuiltBin( + args: readonly string[] = [], + env: Record = {}, +): Promise<{ stdout: string; code: number; stderr: string }> { + const result = await execa(process.execPath, [dshBin, ...args], { input: '', timeout: 25_000, killSignal: 'SIGKILL', reject: false, + env, }) if (result.timedOut) { throw new Error(`dsh built bin did not exit within 25s. stdout:\n${result.stdout}\nstderr:\n${result.stderr}`) @@ -45,4 +55,37 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)', // The refusal happens before any plugin mounts: stdout stays silent. expect(stdout).toBe('') }, 30_000) + + describe('dsh list-sessions', () => { + let home: string + beforeEach(() => { home = mkdtempSync(join(tmpdir(), 'dsh-ls-bin-')) }) + afterEach(() => { rmSync(home, { recursive: true, force: true }) }) + + it('reports an empty listing as success, not an error', async () => { + const { stdout, code, stderr } = await runBuiltBin(['list-sessions'], { DSH_HOME: home }) + expect(code).toBe(0) + expect(stdout.trim()).toBe('no dsh sessions running') + expect(stderr).toBe('') + }, 30_000) + + it('emits an empty JSON array for machines', async () => { + const { stdout, code } = await runBuiltBin(['ps', '--json'], { DSH_HOME: home }) + expect(code).toBe(0) + expect(JSON.parse(stdout)).toEqual([]) + }, 30_000) + + it('runs without a TTY, unlike the TUI surface', async () => { + // The listing is read-only and boots no agent tree, so piped stdio — the + // launch the TUI refuses — is a supported way to run it. + const { code, stderr } = await runBuiltBin(['ps'], { DSH_HOME: home }) + expect(code).toBe(0) + expect(stderr).not.toContain('interactive TTYs') + }, 30_000) + + it('rejects a leaked default-surface flag instead of listing', async () => { + const { code, stderr } = await runBuiltBin(['list-sessions', '--resume', 'sess'], { DSH_HOME: home }) + expect(code).not.toBe(0) + expect(stderr).toContain('list-sessions takes none of') + }, 30_000) + }) }) diff --git a/apps/cli/tests/list-sessions.spec.ts b/apps/cli/tests/list-sessions.spec.ts new file mode 100644 index 0000000000..a73faf1b20 --- /dev/null +++ b/apps/cli/tests/list-sessions.spec.ts @@ -0,0 +1,74 @@ +/** + * Tests for the `dsh list-sessions` presentation layer: uptime formatting, row building + * (newest first, absent-title placeholder) and table alignment without trailing + * padding. Every displayed field comes from the record, so there is no log + * reading to cover here. + */ + +import { describe, expect, it } from 'vitest' +import { SessionId } from '@deepseek-ai/dsh-session' +import { BootId, type SessionRegistryRecord } from '@deepseek-ai/dsh-session-registry' +import { buildRows, formatUptime, renderTable } from '../src/list-sessions.ts' + +function record(overrides: Partial = {}): SessionRegistryRecord { + return { + sessionId: SessionId('sess-1'), + pid: 4242, + cwd: '/work/project', + startedAt: 1_000, + bootId: BootId('boot-1'), + ...overrides, + } +} + +describe('formatUptime', () => { + it.each([ + [0, '0s'], + [999, '0s'], + [12_000, '12s'], + [59_999, '59s'], + [60_000, '1m'], + [3_540_000, '59m'], + [3_600_000, '1h'], + [8_040_000, '2h14m'], + [86_400_000, '1d'], + [90_000_000, '1d1h'], + ])('renders %ims as %s', (ms, expected) => { + expect(formatUptime(ms)).toBe(expected) + }) + + it('never renders a negative duration for a clock that moved backwards', () => { + expect(formatUptime(-5_000)).toBe('0s') + }) +}) + +describe('buildRows', () => { + it('orders newest first and marks a missing title', () => { + const rows = buildRows([ + record({ sessionId: SessionId('older'), startedAt: 1_000 }), + record({ sessionId: SessionId('newer'), startedAt: 5_000 }), + ], 65_000) + expect(rows.map(row => row[0])).toEqual(['newer', 'older']) + expect(rows[0]).toEqual(['newer', '4242', '1m', '/work/project', '—']) + }) +}) + +describe('renderTable', () => { + it('aligns columns and leaves no trailing whitespace', () => { + const table = renderTable(buildRows([ + record({ sessionId: SessionId('short'), startedAt: 0, title: 'a title' }), + record({ sessionId: SessionId('a-much-longer-session-id'), startedAt: 1, title: 'a title' }), + ], 1_000)) + const lines = table.split('\n') + expect(lines[0]).toMatch(/^SESSION {18}\s+PID/) + for (const line of lines) expect(line).toBe(line.trimEnd()) + // The header and every row align on the same column starts. + const pidColumn = (line: string): number => line.includes('4242') ? line.indexOf('4242') : line.indexOf('PID') + expect(pidColumn(lines[1] ?? '')).toBe(pidColumn(lines[0] ?? '')) + expect(pidColumn(lines[2] ?? '')).toBe(pidColumn(lines[0] ?? '')) + }) + + it('renders a header even with no rows, so the columns stay discoverable', () => { + expect(renderTable([])).toBe('SESSION PID UPTIME WORKSPACE TITLE\n') + }) +}) diff --git a/apps/cli/tests/sessions-root.spec.ts b/apps/cli/tests/sessions-root.spec.ts new file mode 100644 index 0000000000..8f12455f1b --- /dev/null +++ b/apps/cli/tests/sessions-root.spec.ts @@ -0,0 +1,20 @@ +/** + * Pins the launcher side of the shared-session-store contract: `dsh` defaults + * its opaque `SESSIONS_ROOT_KEY` boot-slot value to `DSH_HOME/sessions`. The + * plugin side — the slot treated as opaque, explicit config winning, and a + * project-local fallback with no globality assumption — is pinned by + * `packages/examples/tui-demo/tests/tui-agent.spec.ts`. + */ + +import { join, resolve } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { launcherSessionsRoot } from '../src/tui.ts' + +afterEach(() => vi.unstubAllEnvs()) + +describe('launcherSessionsRoot', () => { + it('defaults the boot slot to sessions under DSH_HOME', () => { + vi.stubEnv('DSH_HOME', '/tmp/dsh-slot-home') + expect(launcherSessionsRoot()).toBe(resolve(join('/tmp/dsh-slot-home', 'sessions'))) + }) +}) diff --git a/apps/cli/tsconfig.json b/apps/cli/tsconfig.json index ce43c0f31c..ef0a6705ad 100644 --- a/apps/cli/tsconfig.json +++ b/apps/cli/tsconfig.json @@ -29,6 +29,15 @@ { "path": "../../packages/util/paths" }, + { + "path": "../../packages/session-registry/session-registry" + }, + { + "path": "../../packages/session-registry/session-registry-file" + }, + { + "path": "../../packages/session-registry/session-registry-live" + }, { "path": "../../packages/client/connection" }, diff --git a/docs/capability-seams.md b/docs/capability-seams.md index 43f5b20716..6de0f7ff6e 100644 --- a/docs/capability-seams.md +++ b/docs/capability-seams.md @@ -47,6 +47,10 @@ flowchart LR pkg_workspace["workspace"] svc_workspace["ctx.workspace
Workspace entity registry"] pkg_apiproxy["apiproxy"] + pkg_session_registry["session-registry"] + svc_sessionRegistry["ctx.sessionRegistry
Live-session registry"] + pkg_session_registry_file["session-registry-file"] + pkg_session_registry_live["session-registry-live"] svc_sessionQuery["ctx.sessionQuery
Session reads, traces, filters, and search"] pkg_session_reference["session-reference"] pkg_tool_session_query["tool-session-query"] @@ -197,6 +201,8 @@ flowchart LR pkg_session_query --> svc_sessionQuery pkg_session_query_sqlite --> svc_sessionQuery pkg_session_reference --> svc_sessionReferences + pkg_session_registry --> svc_sessionRegistry + pkg_session_registry_file --> svc_sessionRegistry pkg_session_telemetry --> svc_telemetry pkg_session_telemetry_otel --> svc_telemetry pkg_session_title --> svc_sessionTitle @@ -279,6 +285,7 @@ flowchart LR svc_sessionQuery --> pkg_session_reference svc_sessionQuery --> pkg_tool_session_query svc_sessionReferences --> pkg_tui + svc_sessionRegistry --> pkg_session_registry_live svc_sessions --> pkg_agent svc_sessions --> pkg_agent_loop svc_sessions --> pkg_cli_demo @@ -339,6 +346,7 @@ flowchart LR | `ctx.storage` | `seam` | [`storage`](../packages/storage/storage) | [`storage-json`](../packages/storage/storage-json), [`storage-sqlite`](../packages/storage/storage-sqlite) | [`storage-domain`](../packages/storage/storage-domain) | - | Backends register side by side under names; data forms (domain first) mount on the hub and translate typed operations into opaque KV-unit primitives. | | `ctx.storageDomain` | `core` | [`storage-domain`](../packages/storage/storage-domain) | - | [`workspace`](../packages/workspace/workspace) | - | Waits for every configured backend, then publishes the domain form as one lifecycle-bound service for typed durable state. | | `ctx.workspace` | `core` | [`workspace`](../packages/workspace/workspace) | - | `apiproxy` | - | Owns WorkspaceId-branded records over the domain facility; stable sessionIds accounts drive Host RPC and GUI projections. | +| `ctx.sessionRegistry` | `seam` | [`session-registry`](../packages/session-registry/session-registry) | [`session-registry-file`](../packages/session-registry/session-registry-file) | [`session-registry-live`](../packages/session-registry/session-registry-live) | - | Seam contract for live-session records; the file backend owns the lock-guarded medium, liveness is derived from the recorded pid at read time, and the publisher mirrors lifecycle and title events for `dsh list-sessions`. | | `ctx.sessionQuery` | `seam` | [`session-query`](../packages/session-query/session-query) | [`session-query-sqlite`](../packages/session-query/session-query-sqlite) | [`session-reference`](../packages/context/session-reference), [`tool-session-query`](../packages/session-query/tool-session-query) | - | The interface supplies exact reads, filters, and traces; its concrete backend adds full-text reconciliation, ranking, snippets, and cursor generations, while the model consumer owns workspace authority and cursor-free rendering. | | `ctx.sessionReferences` | `core` | [`session-reference`](../packages/context/session-reference) | - | [`tui`](../packages/ui/tui) | - | Projects bounded current-surface conversation snapshots into durable untrusted message context; host adapters own mention syntax. | | `ctx.sessionTitle` | `seam` | [`session-title`](../packages/session-title/session-title) | [`session-title-first-message-llm`](../packages/session-title/session-title-first-message-llm), [`session-title-all-messages-llm`](../packages/session-title/session-title-all-messages-llm) | - | - | Owns the deterministic fallback, latest-title fold, and sole optional asynchronous provider registration. | diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 3c0543e4ca..8bd6e966b9 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -1153,6 +1153,26 @@ export interface Config { Source: [`packages/context/session-reference/src/config.ts:11`](../packages/context/session-reference/src/config.ts) +## `@deepseek-ai/dsh-session-registry-file` + +```ts config-catalog +/** + * Plugin config as callers write it: `root` is required — a cwd fallback would + * scatter registries — while the lock tunables are optional because + * `static Config` supplies their defaults. + */ +export interface Config { + /** Directory holding the registry file; created `0o700` on demand. */ + root: string + /** Milliseconds after which a held lock is considered abandoned and reclaimed. */ + lockStaleMs?: number + /** Retries before a contended acquisition fails loud. */ + lockRetries?: number +} +``` + +Source: [`packages/session-registry/session-registry-file/src/index.ts:43`](../packages/session-registry/session-registry-file/src/index.ts) + ## `@deepseek-ai/dsh-session-telemetry-otel` Requires: `sessions` @@ -1263,6 +1283,38 @@ export interface Config { Source: [`packages/skill/skill-local/src/index.ts:41`](../packages/skill/skill-local/src/index.ts) +## `@deepseek-ai/dsh-source-guard` + +Requires: `fs` + +```ts config-catalog +/** + * Plugin config, validated by the same-named schemastery schema plus the + * load-time checks in `apply` (misconfiguration fails loud: an empty `tools` + * list, a blank `requiredSkill`, or a relative `protectedCheckout` throws at + * plugin load, never a silent fall-back). + */ +export interface Config { + /** Skill whose loaded presence in the session lifts the denial (default `dsh-customize`). */ + requiredSkill?: string + /** Tool names to gate (default `['write', 'edit']`). */ + tools?: string[] + /** + * Absolute path inside the checkout this guard protects. Its worktree + * supplies BOTH protected identities: the repository (targets in any other + * repository are ignored) and the exact branch (only that branch's worktree + * is protected). Defaults to this module's own location, which resolves the + * checkout the running harness was launched from — the live deployment, + * whatever its branch is named. Set it explicitly to guard a different + * checkout, or when the harness runs from an installed copy whose own + * location is not a checkout at all. + */ + protectedCheckout?: string +} +``` + +Source: [`packages/guard/source-guard/src/index.ts:30`](../packages/guard/source-guard/src/index.ts) + ## `@deepseek-ai/dsh-spill-local` ```ts config-catalog @@ -1541,6 +1593,20 @@ export interface Config { Source: [`packages/context/time-context/src/index.ts:20`](../packages/context/time-context/src/index.ts) +## `@deepseek-ai/dsh-tmux-context` + +Requires: `agents` + +```ts config-catalog +/** Per-turn tmux-location scheduling. Invalid values fail plugin load. */ +export interface Config { + /** Minimum milliseconds between durable injections in one session. Omit or set to 0 to inject on every eligible change. */ + refreshIntervalMs?: number +} +``` + +Source: [`packages/context/tmux-context/src/index.ts:33`](../packages/context/tmux-context/src/index.ts) + ## `@deepseek-ai/dsh-token-meter` ```ts config-catalog @@ -1960,7 +2026,12 @@ export interface Config { dshHome?: string /** Fallback session-title limits forwarded through agent-spine-demo. */ sessionTitle?: NonNullable - /** Directory for JSONL sessions and the derived query index. Defaults to `./.sessions`. */ + /** + * Directory for JSONL sessions and the derived query index. Precedence: + * this explicit config, then the launcher's opaque `SESSIONS_ROOT_KEY` boot + * slot (the dsh CLI resolves it to `DSH_HOME/sessions`), then a project-local + * `./.sessions` fallback — the bundle itself never assumes a global store. + */ persistenceRoot?: string /** JSONL artifact encoding; defaults to checksummed Zstandard frames. */ persistenceCompression?: JsonlCompression @@ -1968,13 +2039,6 @@ export interface Config { sessionReferences?: SessionReferenceConfig /** 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 the session, e.g. - * `dsh --resume {session}`. - */ - resumeCommand?: string /** Full-screen TUI presentation settings. */ ui?: uiTui.TuiConfig /** Skill registry, local-provider, and model-facing consumer config. */ @@ -1985,8 +2049,6 @@ export interface Config { toolTasks?: NonNullable /** Persisted same-session goals; owner defaults enable them, or false disables the stack and command. */ goals?: agentCore.GoalConfig | false - /** Persisted session id to resume instead of creating a fresh session. */ - resumeSessionId?: string /** Controls automatic AGENTS.md/CLAUDE.md loading; configure a byte budget or set `false`. */ workspaceContext: agentCore.Config['workspaceContext'] } @@ -1994,7 +2056,7 @@ export interface Config { Depends on: [`agentCore`](../packages/examples/agent-spine-demo/src/index.ts) · [`JsonlCompression`](../packages/session-persistence/session-persistence-jsonl/src/index.ts) · [`SessionReferenceConfig`](#deepseek-aidsh-session-reference) · [`ToolsConfig`](#deepseek-aidsh-tools) · [`uiTui`](../packages/ui/tui/src/index.ts) -Source: [`packages/examples/tui-demo/src/index.ts:39`](../packages/examples/tui-demo/src/index.ts) +Source: [`packages/examples/tui-demo/src/index.ts:44`](../packages/examples/tui-demo/src/index.ts) ## `@deepseek-ai/dsh-user-approval` @@ -2231,6 +2293,7 @@ These load from a `cordis.yml` entry with no `config:` block; they declare no co - `@deepseek-ai/dsh-session` ([`packages/core/session/src/index.ts`](../packages/core/session/src/index.ts)) - `@deepseek-ai/dsh-session-checkpoint-policy` — requires `llm` · `sessionPersistence` · `sessions` · `tools` ([`packages/session-persistence/session-checkpoint-policy/src/index.ts`](../packages/session-persistence/session-checkpoint-policy/src/index.ts)) - `@deepseek-ai/dsh-session-projection` ([`packages/session-projection/session-projection/src/index.ts`](../packages/session-projection/session-projection/src/index.ts)) +- `@deepseek-ai/dsh-session-registry-live` — requires `sessions` · `sessionRegistry` ([`packages/session-registry/session-registry-live/src/index.ts`](../packages/session-registry/session-registry-live/src/index.ts)) - `@deepseek-ai/dsh-storage` ([`packages/storage/storage/src/index.ts`](../packages/storage/storage/src/index.ts)) - `@deepseek-ai/dsh-subagent` ([`packages/subagent/subagent/src/index.ts`](../packages/subagent/subagent/src/index.ts)) - `@deepseek-ai/dsh-subprocess-local` ([`packages/subprocess/subprocess-local/src/index.ts`](../packages/subprocess/subprocess-local/src/index.ts)) @@ -2253,6 +2316,7 @@ Abstract service classes — a deployment loads a concrete implementation packag - `@deepseek-ai/dsh-sandbox` — abstract `SandboxProvider` ([`packages/sandbox/sandbox/src/index.ts`](../packages/sandbox/sandbox/src/index.ts)) - `@deepseek-ai/dsh-session-persistence` — abstract `SessionPersistence` ([`packages/session-persistence/session-persistence/src/index.ts`](../packages/session-persistence/session-persistence/src/index.ts)) - `@deepseek-ai/dsh-session-query` — abstract `SessionQueryService` ([`packages/session-query/session-query/src/index.ts`](../packages/session-query/session-query/src/index.ts)) +- `@deepseek-ai/dsh-session-registry` — abstract `SessionRegistry` ([`packages/session-registry/session-registry/src/index.ts`](../packages/session-registry/session-registry/src/index.ts)) - `@deepseek-ai/dsh-spill` — abstract `SpillStore` ([`packages/spill/spill/src/index.ts`](../packages/spill/spill/src/index.ts)) - `@deepseek-ai/dsh-subprocess` — abstract `SubprocessService` ([`packages/subprocess/subprocess/src/index.ts`](../packages/subprocess/subprocess/src/index.ts)) - `@deepseek-ai/dsh-tasks` — abstract `TaskService` ([`packages/tasks/tasks/src/index.ts`](../packages/tasks/tasks/src/index.ts)) diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index db31daaf06..7010113be7 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -1464,6 +1464,44 @@ Types: [Agent](../core-data-structures/core.md) · [ContentBlock](../core-data-s Source: [`packages/context/session-reference/src/index.ts:70`](../../packages/context/session-reference/src/index.ts) +## `ctx.sessionRegistry` — `SessionRegistry` (abstract seam) + +Cross-process live-session registry. Reads prune dead records, so every returned record's process existed at observation time. Backends serialize mutations against concurrent registrars — other processes and overlapping calls in this one — so records are never lost to a torn read-modify-write. + +```ts cordis-catalog +/** + * Publish this process's record, replacing any stale record for the same + * session id, and prune records whose process is gone. + * @param registration - the session, surface, and workspace to publish. + * @returns the effect disposer that removes this record again; awaiting it + * waits for the removal to reach durability. + */ +abstract register(registration: SessionRegistration): Promise<() => Promise> + +/** + * Replace the recorded title of a session this process registered. + * + * Titles arrive after registration and can be revised, so this is the one + * mutable field. Only a record matching this process and incarnation is + * touched, leaving a same-id record owned by another process alone. An unknown + * session id is a no-op rather than an error: a title can resolve after the + * session's record has already been removed. + * @param sessionId - the session whose recorded title changes. + * @param title - the new title text. + */ +abstract retitle(sessionId: SessionId, title: string): Promise + +/** + * List live sessions, pruning records whose process no longer exists. + * @returns one record per live registered session, newest registration last. + */ +abstract list(): Promise +``` + +Types: [SessionId](../core-data-structures/core.md) + +Source: [`packages/session-registry/session-registry/src/index.ts:44`](../../packages/session-registry/session-registry/src/index.ts) + ## `ctx.sessions` — `SessionStore` In-memory session store (`ctx.sessions`). diff --git a/docs/i18n/terminology.md b/docs/i18n/terminology.md index 5072c2684d..74392c18ef 100644 --- a/docs/i18n/terminology.md +++ b/docs/i18n/terminology.md @@ -116,6 +116,7 @@ | extension | 扩展 | | | | | extension point | 扩展点 | | | 注意与 `seam` 区分 | | fail-fast | 快速失败 | | | | +| fail-open | 故障放行 | 故障放行(fail-open) | 故障开放 | 与 `fail-fast` 对称;指无法判定时放行而非阻断 | | fenced code block | 围栏代码块 | | | 沿用 MDN 中文翻译 | | fingerprint | 指纹 | | | 通用内容指纹;双语配对机制使用 sidecar record 记录两侧 blob hash | | finish reason | 结束原因 | | | | diff --git a/docs/module-graph.md b/docs/module-graph.md index d3e60978e7..777457020d 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -173,6 +173,7 @@ flowchart TD subgraph group_context["packages/context"] pkg_session_reference["session-reference"] pkg_time_context["time-context"] + pkg_tmux_context["tmux-context"] pkg_workspace_context["workspace-context"] end subgraph group_examples["packages/examples"] @@ -184,6 +185,7 @@ flowchart TD end subgraph group_guard["packages/guard"] pkg_repeat_tool_guard["repeat-tool-guard"] + pkg_source_guard["source-guard"] end subgraph group_host["packages/host"] pkg_host_apiproxy["host-apiproxy"] @@ -221,6 +223,11 @@ flowchart TD pkg_session_projection["session-projection"] pkg_session_projection_cache["session-projection-cache"] end + subgraph group_session_registry["packages/session-registry"] + pkg_session_registry["session-registry"] + pkg_session_registry_file["session-registry-file"] + pkg_session_registry_live["session-registry-live"] + end subgraph group_storage["packages/storage"] pkg_storage["storage"] pkg_storage_domain["storage-domain"] @@ -450,6 +457,9 @@ flowchart TD pkg_sandbox_policy --> pkg_session pkg_session_projection --> pkg_invariants pkg_session_projection --> pkg_session + pkg_session_registry --> pkg_brand + pkg_session_registry --> pkg_invariants + pkg_session_registry --> pkg_session pkg_llm_retry --> pkg_agent pkg_llm_retry --> pkg_invariants pkg_llm_retry --> pkg_llm @@ -523,6 +533,10 @@ flowchart TD pkg_time_context --> pkg_agent pkg_time_context --> pkg_invariants pkg_time_context --> pkg_session + pkg_tmux_context --> pkg_agent + pkg_tmux_context --> pkg_bash + pkg_tmux_context --> pkg_invariants + pkg_tmux_context --> pkg_session pkg_pty --> pkg_agent pkg_pty --> pkg_brand pkg_pty --> pkg_invariants @@ -533,6 +547,9 @@ flowchart TD pkg_session_projection_cache --> pkg_session_persistence pkg_session_projection_cache --> pkg_session_projection pkg_session_projection_cache --> pkg_storage_domain + pkg_session_registry_file --> pkg_invariants + pkg_session_registry_file --> pkg_session + pkg_session_registry_file --> pkg_session_registry pkg_tasks --> pkg_agent pkg_tasks --> pkg_brand pkg_tasks --> pkg_invariants @@ -614,6 +631,10 @@ flowchart TD pkg_pty_local --> pkg_sandbox_policy pkg_pty_local --> pkg_session pkg_pty_local --> pkg_subprocess + pkg_session_registry_live --> pkg_invariants + pkg_session_registry_live --> pkg_session + pkg_session_registry_live --> pkg_session_registry + pkg_session_registry_live --> pkg_session_title pkg_tasks_local --> pkg_agent pkg_tasks_local --> pkg_invariants pkg_tasks_local --> pkg_tasks @@ -776,6 +797,13 @@ flowchart TD pkg_repeat_tool_guard --> pkg_agent pkg_repeat_tool_guard --> pkg_invariants pkg_repeat_tool_guard --> pkg_tools + pkg_source_guard --> pkg_agent + pkg_source_guard --> pkg_fs + pkg_source_guard --> pkg_invariants + pkg_source_guard --> pkg_llm + pkg_source_guard --> pkg_sandbox + pkg_source_guard --> pkg_session + pkg_source_guard --> pkg_tools pkg_tool_lsp --> pkg_invariants pkg_tool_lsp --> pkg_llm pkg_tool_lsp --> pkg_lsp @@ -1045,6 +1073,7 @@ flowchart TD | [`sandbox-local`](../packages/sandbox/sandbox-local) | `sandbox` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox) | | [`sandbox-policy`](../packages/sandbox/sandbox-policy) | `sandbox` | [`invariants`](../packages/support/invariants), [`sandbox`](../packages/sandbox/sandbox), [`session`](../packages/core/session) | | [`session-projection`](../packages/session-projection/session-projection) | `session-projection` | [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) | +| [`session-registry`](../packages/session-registry/session-registry) | `session-registry` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) | | [`llm-retry`](../packages/llm/llm-retry) | `llm` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`timeout`](../packages/util/timeout) | | [`goal`](../packages/goal/goal) | `goal` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-projection`](../packages/session-projection/session-projection) | | [`bash-local`](../packages/bash/bash-local) | `bash` | [`bash`](../packages/bash/bash), [`invariants`](../packages/support/invariants), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | @@ -1062,9 +1091,11 @@ flowchart TD | [`user-interaction`](../packages/ui/user-interaction) | `ui` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm) | | [`client-ui-model`](../packages/client/ui-model) | `client` | [`client-connection`](../packages/client/connection), [`client-runtime`](../packages/client/runtime), [`client-ui-command`](../packages/client/ui-command), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | | [`time-context`](../packages/context/time-context) | `context` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) | +| [`tmux-context`](../packages/context/tmux-context) | `context` | [`agent`](../packages/core/agent), [`bash`](../packages/bash/bash), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) | | [`pty`](../packages/pty/pty) | `pty` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants) | | [`scripts`](../packages/sdk/scripts) | `sdk` | [`app-boot`](../packages/ui/app-boot), [`invariants`](../packages/support/invariants) | | [`session-projection-cache`](../packages/session-projection/session-projection-cache) | `session-projection` | [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-projection`](../packages/session-projection/session-projection), [`storage-domain`](../packages/storage/storage-domain) | +| [`session-registry-file`](../packages/session-registry/session-registry-file) | `session-registry` | [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`session-registry`](../packages/session-registry/session-registry) | | [`tasks`](../packages/tasks/tasks) | `tasks` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) | | [`session-telemetry`](../packages/telemetry/session-telemetry) | `telemetry` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) | | [`workflow`](../packages/workflow/workflow) | `workflow` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | @@ -1080,6 +1111,7 @@ flowchart TD | [`permission`](../packages/ui/permission) | `ui` | [`bash`](../packages/bash/bash), [`commands`](../packages/ui/commands), [`invariants`](../packages/support/invariants), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`session-projection`](../packages/session-projection/session-projection), [`user-approval`](../packages/ui/user-approval) | | [`client-ui-goal`](../packages/client/ui-goal) | `client` | [`client-connection`](../packages/client/connection), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`goal`](../packages/goal/goal), [`invariants`](../packages/support/invariants) | | [`pty-local`](../packages/pty/pty-local) | `pty` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`pty`](../packages/pty/pty), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`subprocess`](../packages/subprocess/subprocess) | +| [`session-registry-live`](../packages/session-registry/session-registry-live) | `session-registry` | [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`session-registry`](../packages/session-registry/session-registry), [`session-title`](../packages/session-title/session-title) | | [`tasks-local`](../packages/tasks/tasks-local) | `tasks` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`tasks`](../packages/tasks/tasks), [`timeout`](../packages/util/timeout) | | [`session-telemetry-otel`](../packages/telemetry/session-telemetry-otel) | `telemetry` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-telemetry`](../packages/telemetry/session-telemetry) | | [`agent-loop`](../packages/core/agent-loop) | `core` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | @@ -1107,6 +1139,7 @@ flowchart TD | [`session-reference`](../packages/context/session-reference) | `context` | [`agent`](../packages/core/agent), [`compact`](../packages/compact/compact), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`retention`](../packages/util/retention), [`session`](../packages/core/session), [`session-query`](../packages/session-query/session-query) | | [`workspace-context`](../packages/context/workspace-context) | `context` | [`agent`](../packages/core/agent), [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`paths`](../packages/util/paths), [`session`](../packages/core/session), [`tools`](../packages/core/tools) | | [`repeat-tool-guard`](../packages/guard/repeat-tool-guard) | `guard` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`tools`](../packages/core/tools) | +| [`source-guard`](../packages/guard/source-guard) | `guard` | [`agent`](../packages/core/agent), [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`session`](../packages/core/session), [`tools`](../packages/core/tools) | | [`tool-lsp`](../packages/lsp/tool-lsp) | `lsp` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`lsp`](../packages/lsp/lsp), [`system-prompt`](../packages/core/system-prompt), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) | | [`mcp-client`](../packages/mcp/mcp-client) | `mcp` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`subprocess`](../packages/subprocess/subprocess), [`tools`](../packages/core/tools) | | [`tool-pty`](../packages/pty/tool-pty) | `pty` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`pty`](../packages/pty/pty), [`retention`](../packages/util/retention), [`system-prompt`](../packages/core/system-prompt), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools) | diff --git a/examples/cordis-agent/cordis.yml b/examples/cordis-agent/cordis.yml index 83144b2153..601ac94419 100644 --- a/examples/cordis-agent/cordis.yml +++ b/examples/cordis-agent/cordis.yml @@ -60,7 +60,6 @@ config: provider: deepseek model: deepseek-v4-pro - resumeSessionId: !!js "typeof resumeSessionId === 'string' ? resumeSessionId : undefined" persistenceRoot: './.sessions' workspaceContext: maxBytes: 65536 diff --git a/examples/headless-agent/tests/fixtures/guard/source-guard/cordis.yml b/examples/headless-agent/tests/fixtures/guard/source-guard/cordis.yml new file mode 100644 index 0000000000..a8a83302bb --- /dev/null +++ b/examples/headless-agent/tests/fixtures/guard/source-guard/cordis.yml @@ -0,0 +1,38 @@ +# Test-only composition: the model attempts one `write` into a staging-shaped +# git fixture, so the guard's denial is observed through the real Loader and app. +- id: source-guard-mock-llm + name: './mock-llm.ts' + +# Managed child-process groups for the bash executor (spawn/kill/output plumbing). +- id: subprocess + name: '@deepseek-ai/dsh-subprocess-local' + +- id: bash + name: '@deepseek-ai/dsh-bash-local' + +- id: fs + name: '@deepseek-ai/dsh-fs-local' + +# Read-before-edit policy: without it the write would resolve `createIfAbsent` +# and the transcript would not show the guard as the sole reason for refusal. +- id: fs-policy + name: '@deepseek-ai/dsh-fs-policy' + +- id: tool-fs + name: '@deepseek-ai/dsh-tool-fs' + +# Mounts the guard with `protectedCheckout` resolved against the process cwd, so +# it arms for the staging fixture the smoke builds there rather than for the +# checkout running the test (the config default is this module's own location). +- id: source-guard-fixture + name: './mount-guard.ts' + +- id: cli-agent + name: '@deepseek-ai/dsh-cli-demo' + config: + provider: source-guard-mock + model: source-guard-mock + persona: 'Test the source guard.' + persistenceRoot: './.sessions' + persistenceCompression: none + workspaceContext: false diff --git a/examples/headless-agent/tests/fixtures/guard/source-guard/mock-llm.ts b/examples/headless-agent/tests/fixtures/guard/source-guard/mock-llm.ts new file mode 100644 index 0000000000..44baccd617 --- /dev/null +++ b/examples/headless-agent/tests/fixtures/guard/source-guard/mock-llm.ts @@ -0,0 +1,43 @@ +import { resolve } from 'node:path' +import type { Context } from 'cordis' +import { CallId, LlmAdapter, type GenerateOptions, type StreamChunk } from '@deepseek-ai/dsh-llm' + +/** The staged file the smoke builds in the process cwd; the guard must refuse to write it. */ +const TARGET = resolve('staging/guarded.ts') + +/** + * Two-step adapter for the source-guard Loader fixture: the first step calls + * `write` on the staged file, the second closes the turn once a tool result has + * come back, so the transcript records what the model received. + */ +class SourceGuardMockAdapter extends LlmAdapter { + async * stream(options: GenerateOptions): AsyncIterable { + const alreadyCalled = options.messages.some(message => message.content.some( + block => block.type === 'tool-result', + )) + if (alreadyCalled) { + const text = 'denied as expected' + yield { type: 'block-start', index: 0, blockType: 'text' } + yield { type: 'text-delta', index: 0, text } + yield { type: 'block-end', index: 0, block: { type: 'text', text } } + yield { type: 'usage', usage: { inputTokens: 1, outputTokens: 1 } } + yield { type: 'finish', reason: { kind: 'stop' } } + return + } + const callId = CallId('source-guard-write') + const args = JSON.stringify({ file_path: TARGET, content: 'edited\n' }) + yield { type: 'block-start', index: 0, blockType: 'tool-call' } + yield { type: 'tool-call-delta', index: 0, id: callId, name: 'write', argumentsDelta: args } + yield { type: 'block-end', index: 0, block: { type: 'tool-call', id: callId, name: 'write', arguments: args } } + yield { type: 'usage', usage: { inputTokens: 1, outputTokens: 1 } } + yield { type: 'finish', reason: { kind: 'tool-calls' } } + } +} + +export const name = 'source-guard-mock-llm' +export const inject = ['llm'] + +/** Register the test-only `source-guard-mock` adapter. */ +export function apply(ctx: Context): void { + ctx.llm.registerAdapter(['source-guard-mock'], new SourceGuardMockAdapter()) +} diff --git a/examples/headless-agent/tests/fixtures/guard/source-guard/mount-guard.ts b/examples/headless-agent/tests/fixtures/guard/source-guard/mount-guard.ts new file mode 100644 index 0000000000..90a89a2897 --- /dev/null +++ b/examples/headless-agent/tests/fixtures/guard/source-guard/mount-guard.ts @@ -0,0 +1,14 @@ +import { resolve } from 'node:path' +import type { Context } from 'cordis' +import * as SourceGuard from '@deepseek-ai/dsh-source-guard' + +export const name = 'source-guard-fixture' + +/** + * Mount the real guard against the staging fixture in the process cwd. The + * checkout under protection is a runtime fact of the isolated smoke directory, + * which no static config value can name. + */ +export async function apply(ctx: Context): Promise { + await ctx.plugin(SourceGuard, { protectedCheckout: resolve('staging/guard-anchor.ts') }) +} diff --git a/examples/headless-agent/tests/fixtures/tmux-context-driver.ts b/examples/headless-agent/tests/fixtures/tmux-context-driver.ts new file mode 100644 index 0000000000..2fd6d0f5ec --- /dev/null +++ b/examples/headless-agent/tests/fixtures/tmux-context-driver.ts @@ -0,0 +1,16 @@ +#!/usr/bin/env node +/** Test driver that sends two turns through one Headless Loader composition. */ + +import { boot, resolveConfigPath } from '@deepseek-ai/dsh-app-boot' +import { runOneShot } from '@deepseek-ai/dsh-cli-demo/src/cli.ts' + +const configPath = process.argv[2] +if (configPath === undefined) throw new Error('tmux-context driver requires a config path') + +const ctx = await boot('tmux-context-e2e', resolveConfigPath(configPath, undefined)) +try { + await runOneShot(ctx, { task: 'first' }) + await runOneShot(ctx, { task: 'second' }) +} finally { + await ctx.fiber.dispose() +} diff --git a/examples/headless-agent/tests/fixtures/tmux-context-mock-bash.ts b/examples/headless-agent/tests/fixtures/tmux-context-mock-bash.ts new file mode 100644 index 0000000000..3e9540abff --- /dev/null +++ b/examples/headless-agent/tests/fixtures/tmux-context-mock-bash.ts @@ -0,0 +1,46 @@ +import type { Context } from 'cordis' +import { BashExecutor } from '@deepseek-ai/dsh-bash' +import type { BashExecRequest, BashExecSpec, BashProcess, BashRunResult } from '@deepseek-ai/dsh-bash' + +/** + * Deterministic `ctx.bash` for the tmux-context Loader fixture: any command + * (the plugin's `tmux display-message`) returns a fixed tab-delimited reading, + * so the injected tmux location is stable without a real tmux server. `start()` + * throws — tmux-context must never spawn a background process. + */ +class TmuxMockBash extends BashExecutor { + override resolve(request: BashExecRequest): BashExecSpec { + return { + command: request.command, + workdir: request.workdir ?? process.cwd(), + timeoutMs: request.timeoutMs ?? 60_000, + stdoutMaxBytes: request.stdoutMaxBytes ?? 64_000, + signal: request.signal, + sandboxPolicy: request.sandboxPolicy, + } + } + + override run(_spec: BashExecSpec): Promise { + const line = ['work', '0', 'editor', '1', '%3', '1', '1', 'a1b2,80x24,0,0,4'].join('\\t') + return Promise.resolve({ + exitCode: 0, + signal: null, + timedOut: false, + aborted: false, + timeoutMs: 60_000, + stdout: { text: `${line}\n`, truncated: false }, + stderr: { text: '', truncated: false }, + }) + } + + override start(): BashProcess { + throw new Error('tmux-context must never start a background task') + } +} + +export const name = 'tmux-context-mock-bash' + +/** Register the deterministic `ctx.bash` executor for the fixture. */ +export function apply(ctx: Context): void { + ctx.plugin(TmuxMockBash) +} diff --git a/examples/headless-agent/tests/fixtures/tmux-context-mock-llm.ts b/examples/headless-agent/tests/fixtures/tmux-context-mock-llm.ts new file mode 100644 index 0000000000..2f6c7a6408 --- /dev/null +++ b/examples/headless-agent/tests/fixtures/tmux-context-mock-llm.ts @@ -0,0 +1,22 @@ +import type { Context } from 'cordis' +import { LlmAdapter, type StreamChunk } from '@deepseek-ai/dsh-llm' + +/** Deterministic one-step adapter for the tmux-context Loader fixture. */ +class TmuxContextMockAdapter extends LlmAdapter { + async * stream(): AsyncIterable { + const text = 'tmux context sampled' + yield { type: 'block-start', index: 0, blockType: 'text' } + yield { type: 'text-delta', index: 0, text } + yield { type: 'block-end', index: 0, block: { type: 'text', text } } + yield { type: 'usage', usage: { inputTokens: 1, outputTokens: 1 } } + yield { type: 'finish', reason: { kind: 'stop' } } + } +} + +export const name = 'tmux-context-mock-llm' +export const inject = ['llm'] + +/** Register the test-only `tmux-context-mock` adapter. */ +export function apply(ctx: Context): void { + ctx.llm.registerAdapter(['tmux-context-mock'], new TmuxContextMockAdapter()) +} diff --git a/examples/headless-agent/tests/fixtures/tmux-context.cordis.yml b/examples/headless-agent/tests/fixtures/tmux-context.cordis.yml new file mode 100644 index 0000000000..419922a0e3 --- /dev/null +++ b/examples/headless-agent/tests/fixtures/tmux-context.cordis.yml @@ -0,0 +1,21 @@ +# Test-only composition: keep tmux-context opt-in while exercising its real Loader/app path. +# A deterministic mock ctx.bash returns a fixed tmux reading, so the injected location +# is stable without a real tmux server on the test host. +- id: tmux-context-mock-llm + name: './tmux-context-mock-llm.ts' + +- id: bash + name: './tmux-context-mock-bash.ts' + +- id: tmux-context + name: '@deepseek-ai/dsh-tmux-context' + +- id: cli-agent + name: '@deepseek-ai/dsh-cli-demo' + config: + provider: tmux-context-mock + model: tmux-context-mock + persona: 'Test the tmux-context plugin.' + persistenceRoot: './.sessions' + persistenceCompression: 'none' + workspaceContext: false diff --git a/examples/package.json b/examples/package.json index 51fc48b8fa..b6f06057e3 100644 --- a/examples/package.json +++ b/examples/package.json @@ -10,6 +10,7 @@ "@deepseek-ai/dsh-acp-demo": "workspace:*", "@deepseek-ai/dsh-agent-spine-demo": "workspace:*", "@deepseek-ai/dsh-app-boot": "workspace:*", + "@deepseek-ai/dsh-bash": "workspace:*", "@deepseek-ai/dsh-bash-local": "workspace:*", "@deepseek-ai/dsh-bash-sandbox": "workspace:*", "@deepseek-ai/dsh-cli-demo": "workspace:*", @@ -44,6 +45,7 @@ "@deepseek-ai/dsh-session-query-sqlite": "workspace:*", "@deepseek-ai/dsh-session-telemetry-otel": "workspace:*", "@deepseek-ai/dsh-session-title-first-message-llm": "workspace:*", + "@deepseek-ai/dsh-source-guard": "workspace:*", "@deepseek-ai/dsh-spill-local": "workspace:*", "@deepseek-ai/dsh-spill-policy": "workspace:*", "@deepseek-ai/dsh-subagent": "workspace:*", @@ -54,6 +56,7 @@ "@deepseek-ai/dsh-tasks-local": "workspace:*", "@deepseek-ai/dsh-time-context": "workspace:*", "@deepseek-ai/dsh-timeout-policy": "workspace:*", + "@deepseek-ai/dsh-tmux-context": "workspace:*", "@deepseek-ai/dsh-token-meter": "workspace:*", "@deepseek-ai/dsh-tool-ask-user": "workspace:*", "@deepseek-ai/dsh-tool-cordis": "workspace:*", diff --git a/examples/tui-agent/README.md b/examples/tui-agent/README.md index ea8695d37e..eae5994fd8 100644 --- a/examples/tui-agent/README.md +++ b/examples/tui-agent/README.md @@ -29,7 +29,7 @@ Each run starts a fresh session by default (its event log lands under `./.sessio dsh --resume ``` -`/resume` opens a searchable keyboard selector with titles, activity, last-turn results, model route, durable goal phase, and live/persisted state. The installed `dsh` host flushes and disposes the current app, then replaces the process with `dsh --resume `. The TUI still prints that command on exit and shows it when a custom host cannot hand off. `dsh --resume ` provides the id on the boot context, which `cordis.yml` reads (`resumeSessionId: !!js "typeof resumeSessionId === 'string' ? resumeSessionId : undefined"`); with no flag the agent starts a new session. A missing or unreadable id starts no agent and emits `agent-loop/config-start-failed`: the TUI prints the failure and exits nonzero. The selector has no cross-process session lock, so deployments with concurrent hosts must coordinate session ownership separately. +`/resume` opens a searchable keyboard selector with titles, activity, last-turn results, model route, durable goal phase, and live/persisted state. The installed `dsh` host flushes and disposes the current app, then replaces the process with `dsh --resume `; a host that cannot hand off in place says so and leaves the session running. Resume needs no key in this file: `dsh` provides the session identity and the exit line on the boot context, so `--resume ` and the printed resume command survive any personal-overlay patch of the `tui-agent` entry. With no flag the agent starts a new session. A missing or unreadable id starts no agent and emits `agent-loop/config-start-failed`: the TUI prints the failure and exits nonzero. The selector has no cross-process session lock, so deployments with concurrent hosts must coordinate session ownership separately. ## Code Mode diff --git a/examples/tui-agent/code-mode.cordis.yml b/examples/tui-agent/code-mode.cordis.yml index 46d982794c..eec54fe6ad 100644 --- a/examples/tui-agent/code-mode.cordis.yml +++ b/examples/tui-agent/code-mode.cordis.yml @@ -10,9 +10,7 @@ config: provider: deepseek model: deepseek-v4-pro - resumeSessionId: !!js "typeof resumeSessionId === 'string' ? resumeSessionId : undefined" persistenceRoot: './.sessions' - resumeCommand: 'dsh --resume {session}' workspaceContext: maxBytes: 65536 tools: diff --git a/examples/tui-agent/composition.md b/examples/tui-agent/composition.md index 86129d3fa4..1bd1b7298c 100644 --- a/examples/tui-agent/composition.md +++ b/examples/tui-agent/composition.md @@ -59,6 +59,8 @@ flowchart LR cfg --> plugin_tui_fs_policy plugin_tui_tool_fs["tool-fs
@deepseek-ai/dsh-tool-fs"] cfg --> plugin_tui_tool_fs + plugin_tui_source_guard["source-guard
@deepseek-ai/dsh-source-guard"] + cfg --> plugin_tui_source_guard plugin_tui_tool_fs_search["tool-fs-search
@deepseek-ai/dsh-tool-fs-search"] cfg --> plugin_tui_tool_fs_search plugin_tui_timeout_policy["timeout-policy
@deepseek-ai/dsh-timeout-policy"] @@ -93,6 +95,7 @@ flowchart LR | `fs-local` | `@deepseek-ai/dsh-fs-local` | | `fs-policy` | `@deepseek-ai/dsh-fs-policy` | | `tool-fs` | `@deepseek-ai/dsh-tool-fs` | +| `source-guard` | `@deepseek-ai/dsh-source-guard` | | `tool-fs-search` | `@deepseek-ai/dsh-tool-fs-search` | | `timeout-policy` | `@deepseek-ai/dsh-timeout-policy` | | `spill-local` | `@deepseek-ai/dsh-spill-local` | diff --git a/examples/tui-agent/cordis.yml b/examples/tui-agent/cordis.yml index 2b14271c96..b3f31ee928 100644 --- a/examples/tui-agent/cordis.yml +++ b/examples/tui-agent/cordis.yml @@ -47,15 +47,11 @@ config: provider: deepseek model: deepseek-v4-pro - # `dsh --resume ` provides the session id on the boot context (the ids - # live under ./.sessions); with no flag the identifier is undefined and a - # fresh session starts each run. The typeof guard tolerates a launcher that - # never provides the slot, reading undefined rather than throwing. - resumeSessionId: !!js "typeof resumeSessionId === 'string' ? resumeSessionId : undefined" - persistenceRoot: './.sessions' - # Printed on exit and listed by `/resume`; `{session}` fills the live id. - # `dsh --resume ` resumes that session, so run it from this cwd. - resumeCommand: 'dsh --resume {session}' + # Session identity and the resume command printed on exit are launcher-owned: + # `dsh` provides both on the boot context, so `--resume ` and the exit + # hint need no key here. `persistenceRoot` is omitted: the dsh launcher + # supplies its shared Harness-home store through a boot slot, and a bare + # example boot falls back to the bundle's project-local `./.sessions`. workspaceContext: maxBytes: 65536 ui: @@ -169,6 +165,15 @@ - id: tool-fs name: '@deepseek-ai/dsh-tool-fs' +# Refuses write/edit inside the dsh checkout this launcher runs from, on that +# checkout's own branch, until the session loads dsh-customize — the skill whose +# workflow (task worktree, then integrate under the staging lock) the refusal +# points at. Inert everywhere else: another repository, a task worktree nested +# under the protected one, a sibling checkout on a different branch, and any +# workspace outside a dsh source install all pass through untouched. +- id: source-guard + name: '@deepseek-ai/dsh-source-guard' + # Bash-backed discovery tools (glob/grep): fixed ripgrep commands through the # local bash executor above — not ctx.fs. Capped results save the complete # formatted list through the spill backend below (ctx.spillStore, optional). diff --git a/examples/tui-agent/tests/fixtures/tui-scripted-llm.ts b/examples/tui-agent/tests/fixtures/tui-scripted-llm.ts index 517fa6adab..79c4f0504a 100644 --- a/examples/tui-agent/tests/fixtures/tui-scripted-llm.ts +++ b/examples/tui-agent/tests/fixtures/tui-scripted-llm.ts @@ -20,6 +20,13 @@ const SKILL_BLOCK_OPEN = '' const SKILL_BODY_MARKER = 'SCRIPTED SKILL BODY MARKER' const SKILL_RECEIVED_TEXT = 'Scripted skill body received.' const TITLE_TEXT = 'scripted session title' +// The failing-bash scenario proves the terminal card reports a non-zero exit +// exactly once: the model-facing result carries the `[exit code: N]` marker, and +// the card turns it into its own `[exit N]` pill instead of showing both. +const BASH_FAILURE_PROBE = 'Run the failing scripted command.' +const BASH_FAILURE_COMMAND = 'printf "SCRIPTED_BASH_FAILED\\n"; exit 3' +const BASH_FAILURE_TEXT = 'Scripted bash failure observed.' +const BASH_FAILURE_CALL_ID = CallId('call-bash-failure') function textChunks(text: string): StreamChunk[] { return [ @@ -108,9 +115,23 @@ class ScriptedTuiAdapter extends LlmAdapter { return } - const hasToolResult = lastMessage?.content.some(block => block.type === 'tool-result') ?? false - if (hasToolResult) { - for (const chunk of textChunks(FINAL_TEXT)) yield chunk + const blocks = lastMessage?.content ?? [] + if (blocks.some(block => block.type === 'tool-result')) { + const answered = blocks.some(block => block.type === 'tool-result' && block.toolCallId === BASH_FAILURE_CALL_ID) + for (const chunk of textChunks(answered ? BASH_FAILURE_TEXT : FINAL_TEXT)) yield chunk + return + } + if (lastText.includes(BASH_FAILURE_PROBE)) { + const bashArgs = JSON.stringify({ command: BASH_FAILURE_COMMAND, description: 'Run the failing scripted command' }) + yield { type: 'block-start', index: 0, blockType: 'tool-call' } + yield { type: 'tool-call-delta', index: 0, id: BASH_FAILURE_CALL_ID, name: 'bash', argumentsDelta: bashArgs } + yield { + type: 'block-end', + index: 0, + block: { type: 'tool-call', id: BASH_FAILURE_CALL_ID, name: 'bash', arguments: bashArgs }, + } + yield { type: 'usage', usage: { inputTokens: 20, outputTokens: 10 } } + yield { type: 'finish', reason: { kind: 'tool-calls' } } return } diff --git a/examples/tui-agent/tests/fixtures/tui-scripted.cordis.yml b/examples/tui-agent/tests/fixtures/tui-scripted.cordis.yml index a32bdf987b..e615a7ec09 100644 --- a/examples/tui-agent/tests/fixtures/tui-scripted.cordis.yml +++ b/examples/tui-agent/tests/fixtures/tui-scripted.cordis.yml @@ -33,8 +33,6 @@ # The smoke's log inspection reads plain `.jsonl`; keep the scripted # fixture uncompressed like the other snapshot-facing configs. persistenceCompression: none - resumeSessionId: !!js "typeof resumeSessionId === 'string' ? resumeSessionId : undefined" - resumeCommand: 'dsh --resume {session}' workspaceContext: maxBytes: 65536 welcome: 'scripted TUI ready.' diff --git a/examples/tui-agent/tests/tui-keyless-smoke.e2e.ts b/examples/tui-agent/tests/tui-keyless-smoke.e2e.ts index 7ae98167de..5b1ccdff75 100644 --- a/examples/tui-agent/tests/tui-keyless-smoke.e2e.ts +++ b/examples/tui-agent/tests/tui-keyless-smoke.e2e.ts @@ -365,11 +365,11 @@ describe('dsh CLI keyless smoke (apps/cli through the same PTY)', () => { expect(output).toContain('must be a top-level YAML array of loader patch entries') }, LOADER_SMOKE_TEST_TIMEOUT_MS) - it('routes the --resume flag into the config resume intake, failing loud on a missing id', async () => { - // The flag path end to end: apps/cli parses `--resume missing-session` and - // provides the id on the boot context, the shipped config's `!!js` reads it - // as a bare identifier, and the resume fails loud — proving the printed - // `dsh --resume ` hint reaches the config resume intake with no env var. + it('routes the --resume flag into the launcher session-identity slot, failing loud on a missing id', async () => { + // The flag path end to end: apps/cli parses `--resume missing-session`, + // provides it as the launcher-owned identity on the boot context, and the + // resume fails loud — proving the printed hint reaches the app's resume + // intake with no config key and no environment variable. const output = await smoke({ label: 'dsh resume flag failure', tempDirPrefix: 'dsh-resume-flag-', @@ -380,6 +380,70 @@ describe('dsh CLI keyless smoke (apps/cli through the same PTY)', () => { expect(output).toContain('ui-tui: session "missing-session" failed to start:') }, LOADER_SMOKE_TEST_TIMEOUT_MS) + it('prints the launcher-owned resume command on exit, naming the booted config', async () => { + // The exit line is built by apps/cli from this invocation, so it must carry + // `--config`: a hint that omitted it would resume into the default tree. + const output = await smoke({ + label: 'dsh goodbye message', + tempDirPrefix: 'dsh-goodbye-', + binScript: dshBinScript, + configPath: scriptedConfigPath, + actions: [{ waitFor: 'scripted TUI ready.', send: '/exit\r' }], + }) + expect(output).toMatch(/To resume this session: dsh --resume=main-session-[0-9a-f-]{36} --config/) + }, LOADER_SMOKE_TEST_TIMEOUT_MS) + + it('keeps resume working when the personal overlay replaces the whole tui-agent config', async () => { + // Loader patches replace a targeted `config` key wholesale, so a personal + // overlay that omits a resume key used to silently disable the exit hint. + // Launcher-owned identity and exit line make that unreachable. + const output = await smoke({ + label: 'dsh overlay keeps resume', + tempDirPrefix: 'dsh-overlay-resume-', + binScript: dshBinScript, + configArgs: [], + prepare: seedWorkspace({ + personal: { + 'config.yaml': [ + '- id: tui-agent', + " name: '@deepseek-ai/dsh-tui-demo'", + ' config:', + ' provider: deepseek', + ' model: deepseek-v4-flash', + ' workspaceContext: false', + ' welcome: OVERLAY REPLACED THE CONFIG.', + '', + ].join('\n'), + }, + }), + actions: [{ waitFor: 'OVERLAY REPLACED THE CONFIG.', send: '/exit\r' }], + }) + expect(output).toMatch(/To resume this session: dsh --resume=main-session-[0-9a-f-]{36}/) + }, LOADER_SMOKE_TEST_TIMEOUT_MS) + + it('reports a failing bash command exactly once, as the terminal card exit pill', async () => { + // The model-facing result ends in `[exit code: 3]`, which the terminal card + // consumes into its own `[exit 3]` pill. Rendering both would report the same + // exit twice, so the marker must not survive into the card body. + const output = await smoke({ + label: 'tui-agent bash exit pill', + tempDirPrefix: 'dsh-bash-exit-pill-', + configPath: scriptedConfigPath, + actions: [ + ...SELECT_PRO_MODEL, + { + waitFor: 'Model selected: tui-scripted/tui-scripted-model-pro.', + send: 'Run the failing scripted command.\r', + }, + { waitFor: 'Scripted bash failure observed.', send: '/exit\r' }, + ], + }) + // The command really ran: its stdout is in the card body. + expect(output).toContain('SCRIPTED_BASH_FAILED') + expect(output).toContain('[exit 3]') + expect(output).not.toContain('[exit code: 3]') + }, LOADER_SMOKE_TEST_TIMEOUT_MS) + it('tells the model its source path and offers the bundled maintenance skills', async () => { // The launcher resolves the checkout root three hops up from apps/cli/{src,lib}; // this test file sits an equal depth under the same root, so the same hop applies. diff --git a/knip.json b/knip.json index 38eb32c223..0d616c8388 100644 --- a/knip.json +++ b/knip.json @@ -33,10 +33,15 @@ "headless-agent/tests/fixtures/semantic-checkpoint-agent.ts", "headless-agent/tests/fixtures/subagent-inheritance-agent.ts", "headless-agent/tests/fixtures/goal-domain/seed-goal.ts", + "headless-agent/tests/fixtures/guard/source-guard/mock-llm.ts", + "headless-agent/tests/fixtures/guard/source-guard/mount-guard.ts", "headless-agent/tests/fixtures/time-context-driver.ts", "headless-agent/tests/fixtures/time-context-mock-llm.ts", "headless-agent/tests/fixtures/telemetry-otel-driver.ts", "headless-agent/tests/fixtures/telemetry-redact-rule.ts", + "headless-agent/tests/fixtures/tmux-context-driver.ts", + "headless-agent/tests/fixtures/tmux-context-mock-llm.ts", + "headless-agent/tests/fixtures/tmux-context-mock-bash.ts", "acp-agent/tests/snapshots/lsp-definition/workspace/subject.ts", "tui-agent/tests/fixtures/tui-scripted-llm.ts", "acp-agent/tests/fixtures/subagent/subagent-acp/mock-delegating-llm.ts", @@ -174,6 +179,16 @@ "tests/**/*.ts" ] }, + "packages/context/tmux-context": { + "entry": [ + "tests/**/*.spec.ts", + "tests/**/*.e2e.ts" + ], + "project": [ + "src/**/*.ts", + "tests/**/*.ts" + ] + }, "packages/lsp/lsp-local": { "entry": [ "tests/**/*.spec.ts", @@ -289,6 +304,26 @@ "tests/**/*.ts" ] }, + "packages/guard/source-guard": { + "entry": [ + "tests/**/*.spec.ts", + "tests/**/*.e2e.ts" + ], + "project": [ + "src/**/*.ts", + "tests/**/*.ts" + ] + }, + "packages/session-registry/session-registry-file": { + "entry": [ + "tests/**/*.spec.ts", + "tests/fixtures/register-once.ts" + ], + "project": [ + "src/**/*.ts", + "tests/**/*.ts" + ] + }, "packages/session-query/session-query-sqlite": { "entry": [ "tests/**/*.spec.ts", diff --git a/packages/context/README.md b/packages/context/README.md index a5244dfe99..ffe9aaa9e2 100644 --- a/packages/context/README.md +++ b/packages/context/README.md @@ -2,12 +2,13 @@ English | [中文](README.zh.md) -Product plugins that add model-visible request context without defining a tool. `workspace-context` is included by the default `dsh-agent-spine-demo` bundle and can be disabled through bundle config; `time-context` is opt-in, while the standard TUI bundle composes `session-reference` explicitly. +Product plugins that add model-visible request context without defining a tool. `workspace-context` is included by the default `dsh-agent-spine-demo` bundle and can be disabled through bundle config; `time-context` and `tmux-context` are opt-in, while the standard TUI bundle composes `session-reference` explicitly. | Package | Role | ctx key | |---|---|---| | `session-reference/` | Bounded current-surface snapshots of other sessions | `ctx.sessionReferences` | | `time-context/` | Durable per-step current time and elapsed-time context | (none) | -| `workspace-context/` | `AGENTS.md`/`CLAUDE.md` workspace context loader | (listens on `agent/step` + `tools/post-execute`) | +| `tmux-context/` | Durable per-turn context with this agent's tmux pane/window location | (listens on `agent/pre-step`, reads `ctx.bash`) | +| `workspace-context/` | `AGENTS.md`/`CLAUDE.md` workspace context loader | (listens on `agent/session-prefix` + `tools/post-execute`) | The [`workspace-context` decision record](../../.agents/notes/implemented/feature/2026-06-24-workspace-context.md) explains its per-agent/session isolation and lifecycle split. diff --git a/packages/context/tmux-context/README.i18n.yaml b/packages/context/tmux-context/README.i18n.yaml new file mode 100644 index 0000000000..ec4562eea4 --- /dev/null +++ b/packages/context/tmux-context/README.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write packages/context/tmux-context/README.md +README.md: 5ea36948d6d83135c5aa97650c0d77e942adbbaa +README.zh.md: 914d8d7c99c37de2c64541bcf4968996d819077d diff --git a/packages/context/tmux-context/README.md b/packages/context/tmux-context/README.md new file mode 100644 index 0000000000..5ea36948d6 --- /dev/null +++ b/packages/context/tmux-context/README.md @@ -0,0 +1,68 @@ +# @deepseek-ai/dsh-tmux-context + +English | [中文](README.zh.md) + +Opt-in durable context naming the tmux session, window, and pane this agent process runs in, plus the window's pane-tree layout. Sampled once per turn during model-request preparation. `dsh-agent-spine-demo` and shipped examples do not mount it. Decision record: [the tmux-context Agent Note](../../../.agents/notes/implemented/feature/2026-07-27-tmux-location-context.md). + +## Config + +```yaml +- id: tmux-context + name: '@deepseek-ai/dsh-tmux-context' + config: + refreshIntervalMs: 60000 # optional; omit or set to 0 to inject on every changed turn +``` + +`refreshIntervalMs` must be a non-negative safe integer. Omission or `0` injects whenever the tmux state changed since the last injection. A positive value additionally suppresses injections that fall within that many milliseconds of the latest one. + +## How it reads tmux + +The plugin prepends an `agent/step` listener that runs only on the first step of each turn. When due, it runs one read-only command through the `ctx.bash` executor seam: + +```sh +[ -n "$TMUX_PANE" ] || exit 1 +self_tty=$(ps -o tty= -p | tr -d ' ') +pane_tty=$(tmux display-message -t "$TMUX_PANE" -p '#{pane_tty}') || exit 1 +[ "$pane_tty" = "/dev/$self_tty" ] || exit 1 +exec tmux display-message -t "$TMUX_PANE" -p '' +``` + +`$TMUX_PANE` alone is insufficient: a terminal launched from a tmux shell (a VS Code integrated terminal, a desktop launcher) **inherits** `$TMUX` and `$TMUX_PANE` from that ancestor, so the variables are present even though the process does not live in that pane. The command therefore also compares the pane's `#{pane_tty}` against this process's own controlling terminal (`ps -o tty=` for its pid): a genuine pane owns this process's tty, while an inherited environment names some other pane's tty. Running through `ctx.bash` applies the deployment's sandbox and policy; the plugin owns no subprocess code. When `ctx.bash` is absent, the process is not in a real tmux pane (`$TMUX_PANE` unset, or the tty does not match ⇒ nonzero exit), or the reading is malformed, the attempt is a no-op, never an error. + +State is pulled on every eligible turn — a moved, renamed, or re-laid-out pane is picked up without any tmux hook or background process. The plugin re-injects only when the rendered tmux state differs from its last injection, so an unchanged location adds nothing. + +## Timing semantics + +When an injection is due, the plugin appends one injected `user/message` through `agent.inject()` before `step/start`, with source `{ kind: 'plugin', plugin: 'tmux-context' }`. Change suppression and interval scheduling scan the raw durable session events for the latest injection of this source, so the schedule survives compaction and resumed processes without process-local cache state; sessions schedule independently. The reading records a request-preparation attempt, not a committed step; because the listener runs first, its append may remain when a later pre-step listener cancels or fails the attempt (the log is append-only and the plugin performs no rollback). + +## Model Experience + +### Preparation-time tmux location + +#### What the model sees + +On each turn whose tmux state changed, one source-tagged context message with the three lines below. `` is tmux's compact pane-tree description; pane and window pixel sizes are intentionally excluded, and the contents of sibling panes are never captured. + +##### Changed-turn reading + +```markdown +tmux location (turn ): +session , window "", pane +window active=<0|1>, pane active=<0|1>, layout +``` + +#### Token effect + +Each two-line reading accumulates until compaction shadows it. Unchanged locations and interval suppression add nothing. + +#### KV Cache effect + +Append-only; newly visible content follows the reusable request prefix and does not invalidate existing KV-cache entries. + +## Known Limitations and Deferred Work + +- **First step only** — a pane moved or resized mid-turn is reflected on the next turn, not between steps. +- **Own location only** — the plugin never captures the visible text of sibling panes. +- **Layout, not size** — pane/window pixel dimensions are omitted; only the layout tree and active flags are reported. +- **Tab-delimited fields** — a tmux window name containing the literal two-character sequence `\t` would mis-split the reading and be skipped as malformed; ordinary names are unaffected. +- **tty-based pane detection** — the process is considered "in tmux" only when its controlling terminal matches `$TMUX_PANE`'s `#{pane_tty}`. This deliberately excludes terminals that inherited `$TMUX`/`$TMUX_PANE` from a tmux ancestor (e.g. a VS Code integrated terminal). `ps -o tty=` is POSIX; the check is a no-op wherever it or `#{pane_tty}` is unavailable. diff --git a/packages/context/tmux-context/README.zh.md b/packages/context/tmux-context/README.zh.md new file mode 100644 index 0000000000..914d8d7c99 --- /dev/null +++ b/packages/context/tmux-context/README.zh.md @@ -0,0 +1,68 @@ +# @deepseek-ai/dsh-tmux-context + +[English](README.md) | 中文 + +可选启用的持久上下文,记录本 agent 进程所在的 tmux session、window、pane,以及该 window 的 pane 树布局。在准备模型请求时每轮采样一次。`dsh-agent-spine-demo` 与随附示例均不挂载它。决策记录见:[tmux-context Agent Note](../../../.agents/notes/implemented/feature/2026-07-27-tmux-location-context.md)。 + +## 配置 + +```yaml +- id: tmux-context + name: '@deepseek-ai/dsh-tmux-context' + config: + refreshIntervalMs: 60000 # optional; omit or set to 0 to inject on every changed turn +``` + +`refreshIntervalMs` 必须是非负安全整数。省略或 `0` 表示只要 tmux 状态自上次注入以来发生变化就注入。正值会额外抑制距最近一次注入不足该毫秒数的注入。 + +## 如何读取 tmux + +插件前置注册一个 `agent/step` 监听器,仅在每轮的第一个 step 运行。当需要注入时,它通过 `ctx.bash` 执行器 seam 运行一条只读命令: + +```sh +[ -n "$TMUX_PANE" ] || exit 1 +self_tty=$(ps -o tty= -p | tr -d ' ') +pane_tty=$(tmux display-message -t "$TMUX_PANE" -p '#{pane_tty}') || exit 1 +[ "$pane_tty" = "/dev/$self_tty" ] || exit 1 +exec tmux display-message -t "$TMUX_PANE" -p '' +``` + +仅凭 `$TMUX_PANE` 并不足够:从 tmux shell 启动的终端(VS Code 集成终端、桌面启动器)会从该祖先进程**继承** `$TMUX` 与 `$TMUX_PANE`,因此即使进程并不位于那个 pane 中,这些变量依然存在。为此该命令还会把 pane 的 `#{pane_tty}` 与本进程自己的控制终端(对其 pid 执行 `ps -o tty=`)作比较:真正的 pane 拥有本进程的 tty,而继承而来的环境指向的是另一个 pane 的 tty。通过 `ctx.bash` 运行会应用部署方的沙箱与策略;插件不拥有任何子进程代码。当 `ctx.bash` 缺失、进程不在真实的 tmux pane 内(`$TMUX_PANE` 未设置,或 tty 不匹配 ⇒ 非零退出)或读取结果格式非法时,本次尝试为空操作,绝不报错。 + +状态在每个符合条件的轮次拉取——pane 被移动、改名或重新布局都会被感知,无需任何 tmux hook 或后台进程。插件仅在渲染出的 tmux 状态与上次注入不同时才重新注入,因此位置不变时不会新增任何内容。 + +## 时序语义 + +当需要注入时,插件在 `step/start` 之前通过 `agent.inject()` 追加一条注入的 `user/message`,来源为 `{ kind: 'plugin', plugin: 'tmux-context' }`。变化抑制与间隔调度会扫描原始持久会话事件中该来源的最近一次注入,因此调度可跨压缩与恢复的进程存续,无需进程内缓存状态;各会话独立调度。该读数记录的是一次请求准备尝试,而非已提交的 step;由于监听器最先运行,当后续 pre-step 监听器取消或失败时,它的追加可能仍会保留(日志只追加,插件不做回滚)。 + +## 模型体验 + +### 准备期 tmux 位置 + +#### 模型看到的内容 + +在 tmux 状态发生变化的每一轮,注入一条带来源标记、含以下三行的上下文消息。`` 是 tmux 紧凑的 pane 树描述;pane 与 window 的像素尺寸有意省略,相邻 pane 的内容从不采集。 + +##### 变化轮次读数 + +```markdown +tmux location (turn ): +session , window "", pane +window active=<0|1>, pane active=<0|1>, layout +``` + +#### Token 影响 + +每条两行读数会累积,直到压缩将其遮蔽。位置未变化以及间隔抑制不会新增内容。 + +#### KV 缓存影响 + +只追加;新增可见内容位于可复用的请求前缀之后,不会使已有 KV 缓存条目失效。 + +## 已知限制与后续工作 + +- **仅第一个 step**——轮次中途移动或缩放的 pane 会在下一轮反映,而非在 step 之间。 +- **仅自身位置**——插件从不采集相邻 pane 的可见文本。 +- **只有布局,没有尺寸**——省略 pane/window 像素尺寸;仅报告布局树与活动标志。 +- **制表符分隔字段**——若 tmux window 名称包含字面两字符序列 `\t`,会使读数分割错误并作为非法读数跳过;常规名称不受影响。 +- **基于 tty 的 pane 判定**——只有当进程的控制终端与 `$TMUX_PANE` 的 `#{pane_tty}` 一致时,才视为“位于 tmux 中”。这会有意排除从 tmux 祖先进程继承 `$TMUX`/`$TMUX_PANE` 的终端(如 VS Code 集成终端)。`ps -o tty=` 属于 POSIX;在其或 `#{pane_tty}` 不可用的环境中,该检查即为空操作。 diff --git a/packages/context/tmux-context/package.json b/packages/context/tmux-context/package.json new file mode 100644 index 0000000000..a874ebb08c --- /dev/null +++ b/packages/context/tmux-context/package.json @@ -0,0 +1,49 @@ +{ + "name": "@deepseek-ai/dsh-tmux-context", + "description": "Opt-in durable per-step context with this agent's tmux pane and window location", + "version": "0.0.1", + "private": true, + "type": "module", + "main": "lib/index.js", + "types": "lib/types/index.d.ts", + "exports": { + ".": { + "types": "./lib/types/index.d.ts", + "default": "./lib/index.js" + }, + "./invariant": { + "types": "./lib/types/invariant.d.ts", + "default": "./lib/invariant.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "files": [ + "lib/index.js", + "lib/invariant.js", + "lib/types/**/*.d.ts", + "lib/types/**/*.d.ts.map", + "src" + ], + "license": "BSD-3-Clause", + "dependencies": { + "schemastery": "^3.18.0" + }, + "peerDependencies": { + "@deepseek-ai/dsh-agent": "^0.0.1", + "@deepseek-ai/dsh-bash": "^0.0.1", + "@deepseek-ai/dsh-invariants": "^0.0.1", + "@deepseek-ai/dsh-session": "^0.0.1", + "cordis": "^4.0.0-rc.7" + }, + "devDependencies": { + "@deepseek-ai/dsh-agent": "workspace:^", + "@deepseek-ai/dsh-bash": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-llm": "workspace:^", + "@deepseek-ai/dsh-loader-smoke": "workspace:^", + "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/dsh-system-prompt": "workspace:^", + "cordis": "^4.0.0-rc.7" + } +} diff --git a/packages/context/tmux-context/src/index.ts b/packages/context/tmux-context/src/index.ts new file mode 100644 index 0000000000..495ab886a0 --- /dev/null +++ b/packages/context/tmux-context/src/index.ts @@ -0,0 +1,227 @@ +/** + * Opt-in request-preparation tmux-location context. Eligible step attempts + * append durable, source-attributed context naming the tmux session, window, + * and pane this agent process runs in, plus the window's pane-tree layout. + * + * The plugin pulls state once per turn, on the first step (`step === 1`), by + * running one `tmux display-message` through the `ctx.bash` executor seam. It + * confirms this process genuinely runs inside the pane `$TMUX_PANE` names by + * matching the pane's `#{pane_tty}` against this process's controlling terminal, + * so a terminal that merely inherited `$TMUX`/`$TMUX_PANE` from a tmux ancestor + * (e.g. a VS Code integrated terminal) reads as "not in tmux". It re-injects + * only when the rendered tmux state changes since the last injection (a moved, + * renamed, or re-laid-out pane), with an optional `refreshIntervalMs` floor + * between injections. Absent tmux environment, an inherited-only environment, + * absent `ctx.bash`, or a failed query is a no-op, never an error. + * + * @module @deepseek-ai/dsh-tmux-context + */ + +import type { Context } from 'cordis' +import z from 'schemastery' +import type { Agent } from '@deepseek-ai/dsh-agent' +import type { BashExecutor } from '@deepseek-ai/dsh-bash' +import { createUserMessage } from '@deepseek-ai/dsh-llm' + +/** Cordis plugin name used by loader diagnostics. */ +export const name = 'tmux-context' + +/** The agent registry that owns the `agent/step` lifecycle seam. */ +export const inject = ['agents'] + +/** Per-turn tmux-location scheduling. Invalid values fail plugin load. */ +export interface Config { + /** Minimum milliseconds between durable injections in one session. Omit or set to 0 to inject on every eligible change. */ + refreshIntervalMs?: number +} + +/** Schemastery validation for {@link Config}. */ +export const Config: z = z.object({ + refreshIntervalMs: z.number(), +}) + +/** + * Tab-separated tmux format fields, in query order. Layout (`window_layout`) + * is the pane-tree description; pane/window pixel sizes are intentionally + * excluded (own location and layout only, per the package scope). + */ +const TMUX_FIELDS = [ + '#{session_name}', + '#{window_index}', + '#{window_name}', + '#{pane_index}', + '#{pane_id}', + '#{window_active}', + '#{pane_active}', + '#{window_layout}', +] as const + +/** Structured tmux location parsed from one `display-message` reading. */ +interface TmuxLocation { + sessionName: string + windowIndex: string + windowName: string + paneIndex: string + paneId: string + windowActive: string + paneActive: string + windowLayout: string +} + +/** Prefix marking the volatile turn/step preamble line of a rendered reading. */ +const READING_PREFIX = 'tmux location (turn ' + +/** + * Field separator between tmux format fields. tmux does not interpret C escapes + * in a format, so the literal two-character sequence `\t` is emitted verbatim + * and split back out here; this avoids embedding raw whitespace in the command. + */ +const FIELD_SEP = '\\t' + +/** + * Read this process's tmux location through the bash seam, or `undefined` when + * this process is not genuinely running inside a tmux pane or the query fails. + * + * `$TMUX_PANE` alone is insufficient: a terminal launched from a tmux shell + * (e.g. VS Code's integrated terminal, a desktop launcher) inherits `$TMUX` and + * `$TMUX_PANE` from that ancestor, so the variables are present even though this + * process does not live in that pane. The command therefore also compares the + * pane's `#{pane_tty}` against this process's own controlling terminal + * (`ps -o tty=` for {@link processId}); a genuine pane owns this process's tty, + * an inherited environment names some other pane's tty. Fields are emitted only + * on a match, so an inherited environment reads as "not in tmux" and injects + * nothing. + * + * @param bash - the executor seam used to run the read-only tmux/ps commands. + * @param processId - this agent process's pid, whose controlling tty must match the pane. + * @param signal - abort signal forwarded to the executor. + * @returns the parsed location, or `undefined` when not in a real pane or on any failure. + */ +async function queryTmuxLocation( + bash: BashExecutor, + processId: number, + signal: AbortSignal, +): Promise { + const format = TMUX_FIELDS.join(FIELD_SEP) + const command = [ + '[ -n "$TMUX_PANE" ] || exit 1', + `self_tty=$(ps -o tty= -p ${processId} | tr -d ' ')`, + '[ -n "$self_tty" ] || exit 1', + 'pane_tty=$(tmux display-message -t "$TMUX_PANE" -p \'#{pane_tty}\') || exit 1', + '[ "$pane_tty" = "/dev/$self_tty" ] || exit 1', + `exec tmux display-message -t "$TMUX_PANE" -p '${format}'`, + ].join('\n') + const spec = bash.resolve({ command, signal }) + const result = await bash.run(spec) + if (result.exitCode !== 0) return undefined + const line = result.stdout.text.split('\n', 1)[0] as string + const parts = line.split(FIELD_SEP) + if (parts.length !== TMUX_FIELDS.length) return undefined + const [ + sessionName, + windowIndex, + windowName, + paneIndex, + paneId, + windowActive, + paneActive, + windowLayout, + ] = parts as [string, string, string, string, string, string, string, string] + if (paneId.length === 0) return undefined + return { + sessionName, + windowIndex, + windowName, + paneIndex, + paneId, + windowActive, + paneActive, + windowLayout, + } +} + +/** + * Render the stable tmux state block: the part of a reading compared for + * change suppression. It excludes the turn preamble so re-injection is driven + * only by tmux state, not by loop position. + */ +function renderState(location: TmuxLocation): string { + return `session ${location.sessionName}, ` + + `window ${location.windowIndex} ${JSON.stringify(location.windowName)}, ` + + `pane ${location.paneIndex} ${location.paneId}\n` + + `window active=${location.windowActive}, pane active=${location.paneActive}, ` + + `layout ${location.windowLayout}` +} + +/** Render the full durable reading, including the volatile turn preamble. */ +function renderReading(location: TmuxLocation, turn: number): string { + return `${READING_PREFIX}${turn}):\n${renderState(location)}` +} + +/** + * The stable state block of this plugin's latest durable injection, or + * `undefined` when the session has none. Scans raw durable events so the + * schedule survives compaction and resumed processes without process-local + * cache state. + */ +function latestInjectedState(agent: Agent): { state: string; time: number } | undefined { + for (const event of [...agent.session.events].reverse()) { + if (event.type === 'user/message' + && event.data.source.kind === 'plugin' + && event.data.source.plugin === name) { + const [block] = event.data.content + if (block?.type !== 'text') return undefined + const newline = block.text.indexOf('\n') + const state = newline === -1 ? '' : block.text.slice(newline + 1) + return { state, time: event.time } + } + } + return undefined +} + +/** Reject refresh intervals that cannot represent an exact elapsed-millisecond threshold. */ +function validateRefreshInterval(refreshIntervalMs: number | undefined): void { + if (refreshIntervalMs !== undefined && ( + !Number.isSafeInteger(refreshIntervalMs) + || refreshIntervalMs < 0 + )) { + throw new TypeError( + `tmux-context: refreshIntervalMs must be a non-negative safe integer, got ${String(refreshIntervalMs)}`, + ) + } +} + +/** + * Register a prepended `agent/step` listener for the lifetime of `ctx`. + * @param ctx - plugin context; the listener is disposed with it. + * @param config - durable refresh scheduling configuration. + * @throws when the refresh interval is invalid. + */ +export function apply(ctx: Context, config: Config): void { + const refreshIntervalMs = config.refreshIntervalMs + validateRefreshInterval(refreshIntervalMs) + + ctx.on('agent/step', async ( + agent: Agent, + turn: number, + step: number, + signal: AbortSignal, + ): Promise => { + if (signal.aborted || step !== 1) return + const bash = ctx.get('bash') + if (bash === undefined) return + const previous = latestInjectedState(agent) + if (refreshIntervalMs !== undefined && refreshIntervalMs > 0 && previous !== undefined) { + const now = Date.now() + if (now >= previous.time && now - previous.time < refreshIntervalMs) return + } + const location = await queryTmuxLocation(bash, process.pid, signal) + if (location === undefined) return + const state = renderState(location) + if (previous !== undefined && previous.state === state) return + agent.inject(createUserMessage({ + content: [{ type: 'text', text: renderReading(location, turn) }], + source: { kind: 'plugin', plugin: name }, + })) + }, { prepend: true }) +} diff --git a/packages/context/tmux-context/src/invariant.ts b/packages/context/tmux-context/src/invariant.ts new file mode 100644 index 0000000000..181f1a2289 --- /dev/null +++ b/packages/context/tmux-context/src/invariant.ts @@ -0,0 +1,30 @@ +/** + * Package-owned invariant companion for `@deepseek-ai/dsh-tmux-context`. + * @module @deepseek-ai/dsh-tmux-context/invariant + */ + +/* jscpd:ignore-start */ +import type { Context } from 'cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-tmux-context' + +/** Cordis companion plugin name. */ +export const name = 'tmux-context-invariant' +/** Service required before the companion can reserve package ownership. */ +export const inject = ['invariants'] + +/** + * No runtime invariant: a reading is a per-turn snapshot of external tmux state, so the session + * holds no cross-event relation to check; scheduling and format are owned by pipeline tests. + */ +const install: InvariantInstaller = () => {} + +/** + * Register this package's invariant companion. + * @param ctx - Cordis context carrying the invariant service. + * @returns the installed registration's disposer after setup succeeds. + */ +export const apply = (ctx: Context): Promise<() => void> => + Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) +/* jscpd:ignore-end */ diff --git a/packages/context/tmux-context/tests/tmux-context.e2e.ts b/packages/context/tmux-context/tests/tmux-context.e2e.ts new file mode 100644 index 0000000000..06e3c1f3f5 --- /dev/null +++ b/packages/context/tmux-context/tests/tmux-context.e2e.ts @@ -0,0 +1,77 @@ +import { readFile, readdir } from 'node:fs/promises' +import { join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { describe, expect, it } from 'vitest' +import { type SessionEvent } from '@deepseek-ai/dsh-session' +import { LOADER_SMOKE_TEST_TIMEOUT_MS, runLoaderSmoke } from '@deepseek-ai/dsh-loader-smoke' + +// Keep the Loader config under examples so both modes exercise the same deployable +// topology: local fixture source plus bare plugins owned by the examples workspace. +const driver = fileURLToPath(new URL( + '../../../../examples/headless-agent/tests/fixtures/tmux-context-driver.ts', + import.meta.url, +)) +const configPath = fileURLToPath(new URL( + '../../../../examples/headless-agent/tests/fixtures/tmux-context.cordis.yml', + import.meta.url, +)) +const repoTsconfig = fileURLToPath(new URL('../../../../tsconfig.json', import.meta.url)) + +async function jsonlFiles(dir: string): Promise { + const entries = await readdir(dir, { withFileTypes: true }) + const paths = await Promise.all(entries.map(async (entry) => { + const path = join(dir, entry.name) + if (entry.isDirectory()) return jsonlFiles(path) + return entry.isFile() && entry.name.endsWith('.jsonl') ? [path] : [] + })) + return paths.flat() +} + +describe('tmux-context through a real headless cordis.yml', () => { + it('injects one ordered tmux-location event on the first turn and suppresses the unchanged second', async () => { + let events: SessionEvent[] = [] + const { stderr } = await runLoaderSmoke({ + label: 'tmux-context headless smoke', + tempDirPrefix: 'tmux-context-e2e-', + binScript: driver, + libBinScript: driver, + configPath, + tsconfigPath: repoTsconfig, + inspect: async (cwd) => { + const logs = await jsonlFiles(join(cwd, '.sessions')) + expect(logs).toHaveLength(1) + const lines = (await readFile(logs[0] as string, 'utf8')).trimEnd().split('\n') + events = lines.slice(1).map(line => JSON.parse(line) as SessionEvent) + }, + }) + expect(stderr).not.toContain('UNHANDLED') + expect(events.filter(event => event.type === 'turn/end')).toHaveLength(2) + + const contexts = events.filter( + (event): event is SessionEvent<'user/message'> => + event.type === 'user/message' + && event.data.source.kind === 'plugin' + && event.data.source.plugin === 'tmux-context') + // Two identical-state turns: the location injects once and is suppressed after. + expect(contexts).toHaveLength(1) + + const [reading] = contexts + if (reading === undefined) throw new Error('missing tmux-context reading') + const starts = events.filter(event => event.type === 'step/start') + expect(reading.seq).toBeLessThan(starts[0]!.seq) + expect(reading.surfaceOp).toBe('append') + + const text = reading.data.content + .filter(block => block.type === 'text') + .map(block => block.text) + .join('\n') + expect(text).toBe( + 'tmux location (turn 1):\n' + + 'session work, window 0 "editor", pane 1 %3\n' + + 'window active=1, pane active=1, layout a1b2,80x24,0,0,4', + ) + + const headers = events.filter(event => event.type === 'request/header') + expect(JSON.stringify(headers)).not.toContain('tmux location (turn') + }, LOADER_SMOKE_TEST_TIMEOUT_MS) +}) diff --git a/packages/context/tmux-context/tests/tmux-context.spec.ts b/packages/context/tmux-context/tests/tmux-context.spec.ts new file mode 100644 index 0000000000..fedd672f57 --- /dev/null +++ b/packages/context/tmux-context/tests/tmux-context.spec.ts @@ -0,0 +1,367 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { Context } from 'cordis' +import { Session, SessionId } from '@deepseek-ai/dsh-session' +import AgentRegistry, { agentEvents, type Agent } from '@deepseek-ai/dsh-agent' +import { createUserMessage } from '@deepseek-ai/dsh-llm' +import { BashExecutor } from '@deepseek-ai/dsh-bash' +import type { BashExecRequest, BashExecSpec, BashProcess, BashRunResult } from '@deepseek-ai/dsh-bash' +import * as tmuxContext from '@deepseek-ai/dsh-tmux-context' +import type { Config } from '@deepseek-ai/dsh-tmux-context' + +const SIGNAL = new AbortController().signal + +/** One `#{...}`-joined tmux reading line for the eight queried fields. */ +function tmuxLine(fields: { + sessionName?: string + windowIndex?: string + windowName?: string + paneIndex?: string + paneId?: string + windowActive?: string + paneActive?: string + windowLayout?: string +} = {}): string { + return [ + fields.sessionName ?? '0', + fields.windowIndex ?? '1', + fields.windowName ?? 'node', + fields.paneIndex ?? '2', + fields.paneId ?? '%90', + fields.windowActive ?? '1', + fields.paneActive ?? '0', + fields.windowLayout ?? 'd517,270x71,0,0{135x71,0,0,87,134x71,136,0[134x35,136,0,90,134x35,136,36,93]}', + ].join('\\t') +} + +function runResult(stdout: string, overrides: Partial = {}): BashRunResult { + return { + exitCode: 0, + signal: null, + timedOut: false, + aborted: false, + timeoutMs: 60_000, + stdout: { text: stdout, truncated: false }, + stderr: { text: '', truncated: false }, + ...overrides, + } +} + +/** A scriptable fake `ctx.bash` recording the command it was asked to run. */ +class FakeBash extends BashExecutor { + commands: string[] = [] + result: BashRunResult = runResult(`${tmuxLine()}\n`) + runError?: Error + + override resolve(request: BashExecRequest): BashExecSpec { + return { + command: request.command, + workdir: request.workdir ?? '/work', + timeoutMs: request.timeoutMs ?? 60_000, + stdoutMaxBytes: request.stdoutMaxBytes ?? 64_000, + signal: request.signal, + sandboxPolicy: request.sandboxPolicy, + } + } + override async run(spec: BashExecSpec): Promise { + this.commands.push(spec.command) + if (this.runError) throw this.runError + return this.result + } + override start(): BashProcess { + throw new Error('tmux-context must never start a background task') + } +} + +async function mount(config: Config, withBash: true): Promise<{ ctx: Context; bash: FakeBash }> +async function mount(config?: Config, withBash?: boolean): Promise<{ ctx: Context; bash: FakeBash | undefined }> +async function mount( + config: Config = {}, + withBash = false, +): Promise<{ ctx: Context; bash: FakeBash | undefined }> { + const ctx = new Context() + await ctx.plugin(AgentRegistry) + let bash: FakeBash | undefined + if (withBash) { + await ctx.plugin(FakeBash) + bash = ctx.bash as FakeBash + } + await ctx.plugin(tmuxContext, config) + return { ctx, bash } +} + +function sessionAgent(session: Session, id = 'agent'): Agent { + return { + id: SessionId(id), + options: {}, + session, + status: 'running', + acceptsNextStep: true, + ctx: new Context(), + followup: () => {}, + steer: () => {}, + inject(input) { + session.append('user/message', input, { surfaceOp: 'append' }) + }, + send: () => {}, + cancel() {}, + whenIdle: () => Promise.resolve(), + } +} + +function openMessageTurn(session: Session, turn: number): void { + session.append('turn/start', { turn, trigger: { kind: 'message', source: { kind: 'user' } } }) + session.append('user/message', createUserMessage({ + content: [{ type: 'text', text: `turn ${turn}` }], + source: { kind: 'user' }, + }), { surfaceOp: 'append' }) +} + +function contextTexts(session: Session): string[] { + const texts: string[] = [] + for (const event of session.events) { + if (event.type === 'user/message' + && event.data.source.kind === 'plugin' + && event.data.source.plugin === 'tmux-context') { + texts.push(event.data.content.find(block => block.type === 'text')?.text ?? '') + } + } + return texts +} + +async function fire( + ctx: Context, + agent: Agent, + turn: number, + step: number, + signal: AbortSignal = SIGNAL, +): Promise { + await agentEvents(ctx, agent).serial('agent/step', turn, step, signal) +} + +afterEach(() => { + vi.restoreAllMocks() + vi.useRealTimers() +}) + +describe('tmux-context injection', () => { + it('injects the tmux location on the first step of a turn', async () => { + const { ctx } = await mount({}, true) + const session = new Session(SessionId('first')) + openMessageTurn(session, 1) + + await fire(ctx, sessionAgent(session), 1, 1) + + expect(contextTexts(session)).toEqual([ + 'tmux location (turn 1):\n' + + 'session 0, window 1 "node", pane 2 %90\n' + + 'window active=1, pane active=0, ' + + 'layout d517,270x71,0,0{135x71,0,0,87,134x71,136,0[134x35,136,0,90,134x35,136,36,93]}', + ]) + const event = session.events.at(-1) + if (event?.type !== 'user/message') throw new Error('missing tmux context') + expect(event.data.source).toEqual({ kind: 'plugin', plugin: 'tmux-context' }) + expect(event.surfaceOp).toBe('append') + }) + + it('queries the pane this process runs in and matches its controlling tty', async () => { + const { ctx, bash } = await mount({}, true) + const session = new Session(SessionId('command')) + openMessageTurn(session, 1) + + await fire(ctx, sessionAgent(session), 1, 1) + + expect(bash.commands).toHaveLength(1) + const command = bash.commands[0]! + expect(command).toContain('[ -n "$TMUX_PANE" ]') + expect(command).toContain('tmux display-message -t "$TMUX_PANE" -p') + // Guards against an inherited $TMUX_PANE: the pane's tty must equal this + // process's controlling tty (resolved for this exact pid). + expect(command).toContain(`ps -o tty= -p ${process.pid}`) + // The exact fragment matters: unquoted, `#` starts a shell comment and the + // substitution silently breaks while a substring check still passes. + expect(command).toContain('pane_tty=$(tmux display-message -t "$TMUX_PANE" -p \'#{pane_tty}\') || exit 1') + expect(command).toContain('[ "$pane_tty" = "/dev/$self_tty" ]') + }) + + it('does not run on later steps of a turn', async () => { + const { ctx, bash } = await mount({}, true) + const session = new Session(SessionId('later-step')) + openMessageTurn(session, 1) + + await fire(ctx, sessionAgent(session), 1, 2) + + expect(bash.commands).toHaveLength(0) + expect(contextTexts(session)).toHaveLength(0) + }) + + it('re-injects a new turn only when tmux state changed', async () => { + const { ctx, bash } = await mount({}, true) + const session = new Session(SessionId('change')) + const agent = sessionAgent(session) + + openMessageTurn(session, 1) + await fire(ctx, agent, 1, 1) + session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) + + // Same state on turn 2: suppressed. + openMessageTurn(session, 2) + await fire(ctx, agent, 2, 1) + session.append('turn/end', { turn: 2, reason: { kind: 'completed' } }) + expect(contextTexts(session)).toHaveLength(1) + + // Moved pane on turn 3: re-injected. + bash.result = runResult(`${tmuxLine({ windowName: 'shell', paneId: '%12' })}\n`) + openMessageTurn(session, 3) + await fire(ctx, agent, 3, 1) + + const texts = contextTexts(session) + expect(texts).toHaveLength(2) + expect(texts[1]).toContain('tmux location (turn 3):') + expect(texts[1]).toContain('window 1 "shell", pane 2 %12') + }) + + it('honors a positive refresh interval between injections', async () => { + vi.useFakeTimers() + vi.setSystemTime(1_000) + const { ctx, bash } = await mount({ refreshIntervalMs: 10_000 }, true) + const session = new Session(SessionId('interval')) + const agent = sessionAgent(session) + + openMessageTurn(session, 1) + await fire(ctx, agent, 1, 1) + session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) + + // Changed state but inside the interval: suppressed, and never queried. + bash.result = runResult(`${tmuxLine({ paneId: '%99' })}\n`) + vi.setSystemTime(5_000) + openMessageTurn(session, 2) + await fire(ctx, agent, 2, 1) + expect(contextTexts(session)).toHaveLength(1) + expect(bash.commands).toHaveLength(1) + + // Past the interval: queried and re-injected. + vi.setSystemTime(12_000) + openMessageTurn(session, 3) + await fire(ctx, agent, 3, 1) + expect(contextTexts(session)).toHaveLength(2) + expect(bash.commands).toHaveLength(2) + }) +}) + +describe('tmux-context prior-reading resilience', () => { + it('treats a prior non-text plugin reading as absent and injects afresh', async () => { + const { ctx, bash } = await mount({}, true) + const session = new Session(SessionId('prior-non-text')) + const agent = sessionAgent(session) + openMessageTurn(session, 1) + session.append('user/message', createUserMessage({ + content: [{ type: 'reasoning', text: 'not a location' }], + source: { kind: 'plugin', plugin: 'tmux-context' }, + }), { surfaceOp: 'append' }) + + await fire(ctx, agent, 1, 1) + + expect(bash.commands).toHaveLength(1) + expect(contextTexts(session).at(-1)).toContain('tmux location (turn 1):') + }) + + it('treats a prior single-line plugin reading (no newline) as empty state', async () => { + const { ctx, bash } = await mount({}, true) + const session = new Session(SessionId('prior-single-line')) + const agent = sessionAgent(session) + openMessageTurn(session, 1) + session.append('user/message', createUserMessage({ + content: [{ type: 'text', text: 'single line, no newline' }], + source: { kind: 'plugin', plugin: 'tmux-context' }, + }), { surfaceOp: 'append' }) + + await fire(ctx, agent, 1, 1) + + // Empty prior state never equals the multi-line reading, so it re-injects. + expect(bash.commands).toHaveLength(1) + expect(contextTexts(session).at(-1)).toContain('tmux location (turn 1):') + }) +}) + +describe('tmux-context no-op paths', () => { + it('is a no-op when no bash executor is mounted', async () => { + const { ctx } = await mount() + const session = new Session(SessionId('no-bash')) + openMessageTurn(session, 1) + + await fire(ctx, sessionAgent(session), 1, 1) + + expect(contextTexts(session)).toHaveLength(0) + }) + + it('is a no-op when the tmux query exits nonzero (outside tmux, or an inherited env whose tty does not match the pane)', async () => { + const { ctx, bash } = await mount({}, true) + bash.result = runResult('', { exitCode: 1 }) + const session = new Session(SessionId('outside-tmux')) + openMessageTurn(session, 1) + + await fire(ctx, sessionAgent(session), 1, 1) + + expect(contextTexts(session)).toHaveLength(0) + }) + + it('is a no-op when the reading has the wrong field count', async () => { + const { ctx, bash } = await mount({}, true) + bash.result = runResult('0\\t1\\tnode\n') + const session = new Session(SessionId('malformed')) + openMessageTurn(session, 1) + + await fire(ctx, sessionAgent(session), 1, 1) + + expect(contextTexts(session)).toHaveLength(0) + }) + + it('is a no-op when the pane id is empty', async () => { + const { ctx, bash } = await mount({}, true) + bash.result = runResult(`${tmuxLine({ paneId: '' })}\n`) + const session = new Session(SessionId('empty-pane')) + openMessageTurn(session, 1) + + await fire(ctx, sessionAgent(session), 1, 1) + + expect(contextTexts(session)).toHaveLength(0) + }) + + it('skips an already-aborted step and runs before ordinary agent/step listeners', async () => { + const { ctx } = await mount({}, true) + const session = new Session(SessionId('ordering')) + const agent = sessionAgent(session) + openMessageTurn(session, 1) + let ordinarySawContext = false + ctx.on('agent/step', (subject) => { + ordinarySawContext = subject.session.events.some( + event => event.type === 'user/message' + && event.data.source.kind === 'plugin' + && event.data.source.plugin === 'tmux-context', + ) + }) + + const abort = new AbortController() + abort.abort() + await fire(ctx, agent, 1, 1, abort.signal) + expect(contextTexts(session)).toHaveLength(0) + + await fire(ctx, agent, 1, 1) + expect(ordinarySawContext).toBe(true) + expect(contextTexts(session)).toHaveLength(1) + }) +}) + +describe('tmux-context configuration', () => { + it('rejects a negative refresh interval at plugin load', async () => { + await expect(mount({ refreshIntervalMs: -1 })).rejects.toThrow( + /refreshIntervalMs must be a non-negative safe integer/, + ) + }) + + it('rejects a non-integer refresh interval at plugin load', async () => { + await expect(mount({ refreshIntervalMs: 1.5 })).rejects.toThrow( + /refreshIntervalMs must be a non-negative safe integer/, + ) + }) +}) diff --git a/packages/context/tmux-context/tsconfig.json b/packages/context/tmux-context/tsconfig.json new file mode 100644 index 0000000000..fe893f9402 --- /dev/null +++ b/packages/context/tmux-context/tsconfig.json @@ -0,0 +1,40 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": ["src"], + "references": [ + { + "path": "../../../vendor/cosmokit" + }, + { + "path": "../../../vendor/cordis" + }, + { + "path": "../../../vendor/schemastery" + }, + { + "path": "../../llm/llm" + }, + { + "path": "../../bash/bash" + }, + { + "path": "../../core/agent" + }, + { + "path": "../../core/system-prompt" + }, + { + "path": "../../support/loader-smoke" + }, + { + "path": "../../support/invariants" + }, + { + "path": "../../core/session" + } + ] +} diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index 7d9b087b8f..a27c126b75 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -688,6 +688,24 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ }, ], }, + { + key: 'sessionRegistry', + summary: 'Cross-process live-session registry.', + methods: [ + { + signature: 'abstract register(registration: SessionRegistration): Promise<() => Promise>', + jsDoc: '/**\n * Publish this process\'s record, replacing any stale record for the same\n * session id, and prune records whose process is gone.\n * @param registration - the session, surface, and workspace to publish.\n * @returns the effect disposer that removes this record again; awaiting it\n * waits for the removal to reach durability.\n */', + }, + { + signature: 'abstract retitle(sessionId: SessionId, title: string): Promise', + jsDoc: '/**\n * Replace the recorded title of a session this process registered.\n *\n * Titles arrive after registration and can be revised, so this is the one\n * mutable field. Only a record matching this process and incarnation is\n * touched, leaving a same-id record owned by another process alone. An unknown\n * session id is a no-op rather than an error: a title can resolve after the\n * session\'s record has already been removed.\n * @param sessionId - the session whose recorded title changes.\n * @param title - the new title text.\n */', + }, + { + signature: 'abstract list(): Promise', + jsDoc: '/**\n * List live sessions, pruning records whose process no longer exists.\n * @returns one record per live registered session, newest registration last.\n */', + }, + ], + }, { key: 'sessions', summary: 'In-memory session store (`ctx.sessions`).', @@ -1543,6 +1561,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'BashSandboxInfo', declaration: 'export interface BashSandboxInfo {\n mode: SandboxMode;\n denied: boolean;\n enforcement?: SandboxEnforcement;\n runnerFailed?: boolean;\n}', }, + { + name: 'BootId', + declaration: 'export type BootId = Branded<\'BootId\'>;', + }, { name: 'Branded', declaration: 'export type Branded = string & {\n readonly [BRAND]: B;\n};', @@ -2247,6 +2269,14 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'SessionReferenceInput', declaration: 'export interface SessionReferenceInput {\n sessionId: SessionId;\n label?: string;\n}', }, + { + name: 'SessionRegistration', + declaration: 'export interface SessionRegistration {\n sessionId: SessionId;\n cwd: string;\n title?: string;\n}', + }, + { + name: 'SessionRegistryRecord', + declaration: 'export interface SessionRegistryRecord {\n readonly sessionId: SessionId;\n readonly pid: number;\n readonly cwd: string;\n readonly startedAt: number;\n readonly bootId: BootId;\n readonly title?: string;\n}', + }, { name: 'SessionResultFilter', declaration: 'export type SessionResultFilter = {\n kind: \'id\';\n values: readonly SessionId[];\n} | {\n kind: \'cwd\';\n values: readonly (string | null)[];\n} | ({\n kind: \'created-at\';\n} & SessionResultRange) | {\n kind: \'parent\';\n values: readonly (SessionId | null)[];\n} | {\n kind: \'availability\';\n values: readonly SessionAvailability[];\n};', diff --git a/packages/examples/tui-demo/README.md b/packages/examples/tui-demo/README.md index 058ebe87af..cbe8c79780 100644 --- a/packages/examples/tui-demo/README.md +++ b/packages/examples/tui-demo/README.md @@ -39,15 +39,15 @@ Swappable LLM, bash, filesystem, and other capability providers remain in the le | `toolTasks` | owner defaults | Background-task control-tool config, or `false` | | `goals` | owner defaults | Persisted goal-domain and model-tool config; `false` removes the goal stack and `/goal` producer | | `workspaceContext` | required | Workspace-instruction config, or `false` | -| `persistenceRoot` | `./.sessions` | JSONL persistence root and parent of the derived `session-query.db` index | +| `persistenceRoot` | `./.sessions` (launcher boot slot overrides) | JSONL persistence root and parent of the derived `session-query.db` index | | `persistenceCompression` | `'zstd'` | JSONL artifact encoding (`'zstd'` or raw `'none'`) | | `sessionReferences` | service defaults | Cross-session candidate and snapshot limits routed to `dsh-session-reference` | | `welcome` | `ready.` | TUI subtitle | -| `resumeCommand` | — | Exit and no-host fallback command template; the selector itself uses session query and host handoff | | `ui` | owner defaults | TUI presentation settings such as reasoning, color, and card height | -| `resumeSessionId` | — | Exact persisted session to resume | -Fresh runs mint a `main-session-` session id and pass it to both the TUI and configured agent. Resumed runs bind both components to `resumeSessionId`. The TUI mounts before the spine so it can render a matching config-start failure instead of leaving a blank terminal. The app composes persistence and session query for `/resume`; an embedding host may additionally provide `tuiResumeHost` for in-place process handoff. +Session identity is launcher-owned rather than configurable: a launcher provides `MAIN_SESSION_ID_KEY` on the boot context, and this app binds both the TUI and the configured agent to that id, loading persisted history only when the launcher also set `resume`. With no such slot the app mints a `main-session-` and creates it fresh. The TUI mounts before the spine so it can render a matching config-start failure instead of leaving a blank terminal. The app composes persistence and session query for `/resume`; a launcher may additionally provide `tuiResumeHost` for in-place process handoff and `TUI_GOODBYE_MESSAGE_KEY` for the line printed on exit. + +`persistenceRoot` defaults to project-local `./.sessions`: an app bundle must not assume the user's shared session store. A launcher that wants one store across every cwd states that policy through the `SESSIONS_ROOT_KEY` boot slot (`ctx.provide` before any Loader entry mounts) — the dsh CLI provides its Harness-home root there, so its `/resume` lists sessions from every workspace. Precedence is explicit config, then the launcher slot, then the project-local default. ## Front door diff --git a/packages/examples/tui-demo/package.json b/packages/examples/tui-demo/package.json index 50145e6c29..fc59453f0e 100644 --- a/packages/examples/tui-demo/package.json +++ b/packages/examples/tui-demo/package.json @@ -30,20 +30,20 @@ "@cordisjs/plugin-loader": "^1.0.0-rc.5", "@deepseek-ai/dsh-agent": "^0.0.1", "@deepseek-ai/dsh-agent-loop": "^0.0.1", - "@deepseek-ai/dsh-commands": "^0.0.1", - "@deepseek-ai/dsh-command-goal": "^0.0.1", "@deepseek-ai/dsh-agent-spine-demo": "^0.0.1", + "@deepseek-ai/dsh-command-goal": "^0.0.1", + "@deepseek-ai/dsh-commands": "^0.0.1", "@deepseek-ai/dsh-invariants": "^0.0.1", "@deepseek-ai/dsh-llm": "^0.0.1", "@deepseek-ai/dsh-session": "^0.0.1", "@deepseek-ai/dsh-session-checkpoint-policy": "^0.0.1", + "@deepseek-ai/dsh-session-persistence-jsonl": "^0.0.1", "@deepseek-ai/dsh-session-query": "^0.0.1", "@deepseek-ai/dsh-session-query-sqlite": "^0.0.1", "@deepseek-ai/dsh-session-reference": "^0.0.1", - "@deepseek-ai/dsh-session-persistence-jsonl": "^0.0.1", - "@deepseek-ai/dsh-tui": "^0.0.1", "@deepseek-ai/dsh-tool-ask-user": "^0.0.1", "@deepseek-ai/dsh-tools": "^0.0.1", + "@deepseek-ai/dsh-tui": "^0.0.1", "@deepseek-ai/dsh-user-interaction": "^0.0.1", "@deepseek-ai/dsh-workspace-context": "^0.0.1", "cordis": "^4.0.0-rc.7", @@ -53,21 +53,21 @@ "@cordisjs/plugin-loader": "workspace:^", "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-agent-loop": "workspace:^", - "@deepseek-ai/dsh-commands": "workspace:^", - "@deepseek-ai/dsh-command-goal": "workspace:^", "@deepseek-ai/dsh-agent-spine-demo": "workspace:^", + "@deepseek-ai/dsh-command-goal": "workspace:^", + "@deepseek-ai/dsh-commands": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-session-checkpoint-policy": "workspace:^", + "@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^", "@deepseek-ai/dsh-session-query": "workspace:^", "@deepseek-ai/dsh-session-query-sqlite": "workspace:^", "@deepseek-ai/dsh-session-reference": "workspace:^", - "@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^", "@deepseek-ai/dsh-system-prompt": "workspace:^", - "@deepseek-ai/dsh-tui": "workspace:^", "@deepseek-ai/dsh-tool-ask-user": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", + "@deepseek-ai/dsh-tui": "workspace:^", "@deepseek-ai/dsh-user-interaction": "workspace:^", "@deepseek-ai/dsh-workspace-context": "workspace:^", "cordis": "^4.0.0-rc.7", diff --git a/packages/examples/tui-demo/src/index.ts b/packages/examples/tui-demo/src/index.ts index c60ba94b3c..df644090c8 100644 --- a/packages/examples/tui-demo/src/index.ts +++ b/packages/examples/tui-demo/src/index.ts @@ -30,6 +30,11 @@ import * as toolAskUser from '@deepseek-ai/dsh-tool-ask-user' import * as uiTui from '@deepseek-ai/dsh-tui' export const name = 'tui-demo' + +// The bundle's own fallback stays project-local: a plugin must never assume +// the user's shared session store. The dsh launcher's SESSIONS_ROOT_KEY slot +// (opaque here — the CLI resolves it to DSH_HOME/sessions) carries any +// shared-store policy, and explicit config wins over both. const DEFAULT_PERSISTENCE_ROOT = './.sessions' // Each front door keeps a complete Loader contract so its deployment config is @@ -53,7 +58,12 @@ export interface Config { dshHome?: string /** Fallback session-title limits forwarded through agent-spine-demo. */ sessionTitle?: NonNullable - /** Directory for JSONL sessions and the derived query index. Defaults to `./.sessions`. */ + /** + * Directory for JSONL sessions and the derived query index. Precedence: + * this explicit config, then the launcher's opaque `SESSIONS_ROOT_KEY` boot + * slot (the dsh CLI resolves it to `DSH_HOME/sessions`), then a project-local + * `./.sessions` fallback — the bundle itself never assumes a global store. + */ persistenceRoot?: string /** JSONL artifact encoding; defaults to checksummed Zstandard frames. */ persistenceCompression?: JsonlCompression @@ -61,13 +71,6 @@ export interface Config { sessionReferences?: SessionReferenceConfig /** 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 the session, e.g. - * `dsh --resume {session}`. - */ - resumeCommand?: string /** Full-screen TUI presentation settings. */ ui?: uiTui.TuiConfig /** Skill registry, local-provider, and model-facing consumer config. */ @@ -78,8 +81,6 @@ export interface Config { toolTasks?: NonNullable /** Persisted same-session goals; owner defaults enable them, or false disables the stack and command. */ goals?: agentCore.GoalConfig | false - /** Persisted session id to resume instead of creating a fresh session. */ - resumeSessionId?: string /** Controls automatic AGENTS.md/CLAUDE.md loading; configure a byte budget or set `false`. */ workspaceContext: agentCore.Config['workspaceContext'] } @@ -94,33 +95,38 @@ export const Config: z = z.object({ tools: ToolRegistry.Config, dshHome: z.string(), sessionTitle: agentCore.SessionTitleConfigSchema, - persistenceRoot: z.string().default(DEFAULT_PERSISTENCE_ROOT), + // No schema default: schemastery would materialize it before composeTuiApp + // runs, shadowing the launcher's SESSIONS_ROOT_KEY slot for a Loader mount. + persistenceRoot: z.string(), persistenceCompression: JsonlCompressionSchema, sessionReferences: SessionReferenceService.Config, welcome: z.string(), - resumeCommand: z.string(), ui: uiTui.TuiConfigSchema, skills: agentCore.SkillConfigSchema, toolBash: agentCore.ToolBashConfigSchema, toolTasks: z.union([z.const(false), agentCore.ToolTasksConfigSchema]), goals: z.union([z.const(false), agentCore.GoalConfigSchema]), - resumeSessionId: z.string(), workspaceContext: z.union([z.const(false), workspaceContext.Config]).required(), }) /* jscpd:ignore-end */ /** * Compose the spine, TUI, JSONL persistence, and user-question tool around one - * exact fresh or resumed session identity. The TUI subscribes to startup - * failures before the spine creates the agent. + * exact fresh or resumed session identity, taken from the launcher's + * {@link uiTui.MAIN_SESSION_ID_KEY} slot. The TUI subscribes to startup failures + * before the spine creates the agent. * @param ctx - context receiving the app's child plugins. * @param config - validated app configuration. */ export function composeTuiApp(ctx: Context, config: Config): void { - const resumeSessionId = config.resumeSessionId === '' ? undefined : config.resumeSessionId - const sessionId = SessionId(resumeSessionId ?? `main-session-${randomUUID()}`) + // The launcher, not the deployment config, owns `main`'s session identity: it + // reaches a Loader-mounted bundle only through this context slot. A launcher + // that supplies an id knows whether that session already exists, so it also + // states whether to load persisted history. No launcher means mint one here. + const identity = ctx.get(uiTui.MAIN_SESSION_ID_KEY) + const sessionId = SessionId(identity?.id ?? `main-session-${randomUUID()}`) const goals = config.goals ?? {} - const persistenceRoot = config.persistenceRoot ?? DEFAULT_PERSISTENCE_ROOT + const persistenceRoot = config.persistenceRoot ?? ctx.get(uiTui.SESSIONS_ROOT_KEY) ?? DEFAULT_PERSISTENCE_ROOT ctx.plugin(CommandService) if (goals !== false) ctx.plugin(commandGoal) ctx.plugin(SessionPersistenceJsonl, { @@ -135,7 +141,6 @@ export function composeTuiApp(ctx: Context, config: Config): void { ctx.plugin(uiTui, { ...config.ui, ...config.welcome === undefined ? {} : { welcome: config.welcome }, - ...config.resumeCommand === undefined ? {} : { resumeCommand: config.resumeCommand }, sessionId, }) ctx.plugin(agentCore, { @@ -146,7 +151,9 @@ export function composeTuiApp(ctx: Context, config: Config): void { provider: config.provider, model: config.model, cwd: process.cwd(), - ...resumeSessionId === undefined ? { sessionId } : { resumeSessionId: sessionId }, + // `resumeSessionId` requires existing persisted history and rejects a + // missing log, so only a launcher that asked to resume takes that path. + ...identity?.resume === true ? { resumeSessionId: sessionId } : { sessionId }, }], }) ctx.plugin(toolAskUser) diff --git a/packages/examples/tui-demo/tests/tui-agent.spec.ts b/packages/examples/tui-demo/tests/tui-agent.spec.ts index f647b3d9c6..88519e33a1 100644 --- a/packages/examples/tui-demo/tests/tui-agent.spec.ts +++ b/packages/examples/tui-demo/tests/tui-agent.spec.ts @@ -3,6 +3,8 @@ import { join } from 'node:path' import { Context } from 'cordis' import Loader from '@cordisjs/plugin-loader' import { TOOL_ORDER_REST } from '@deepseek-ai/dsh-system-prompt' +import { SessionId } from '@deepseek-ai/dsh-session' +import { MAIN_SESSION_ID_KEY, SESSIONS_ROOT_KEY, type MainSessionIdentity } from '@deepseek-ai/dsh-tui' import * as tuiAgent from '../src/index.ts' interface PluginCall { @@ -10,12 +12,21 @@ interface PluginCall { readonly config: unknown } -function recordingContext(): { readonly ctx: Context; readonly calls: PluginCall[] } { +/** + * Record the composed plugin tree. `identity` stands in for the launcher-owned + * {@link MAIN_SESSION_ID_KEY} slot; omitting it means no launcher chose a session. + */ +function recordingContext( + identity?: MainSessionIdentity, + sessionsRoot?: string, +): { readonly ctx: Context; readonly calls: PluginCall[] } { const calls: PluginCall[] = [] const ctx = { plugin(plugin: { name?: string }, config?: unknown) { calls.push({ name: plugin.name ?? '', config }) }, + get: (key: string) => key === MAIN_SESSION_ID_KEY ? identity + : key === SESSIONS_ROOT_KEY ? sessionsRoot : undefined, } as unknown as Context return { ctx, calls } } @@ -39,7 +50,6 @@ describe('dsh-tui-demo app', () => { maxReferenceBytes: 1234, }, welcome: 'TUI ready', - resumeCommand: 'dsh --resume {session}', ui: { theme: { color: false }, maxToolOutputLines: 3 }, skills: { tool: { catalogDescriptionMaxLength: 8 } }, toolBash: { enableRunInBackground: false }, @@ -71,7 +81,6 @@ describe('dsh-tui-demo app', () => { const tuiConfig = calls[8]?.config as { sessionId: string } expect(tuiConfig).toMatchObject({ welcome: 'TUI ready', - resumeCommand: 'dsh --resume {session}', theme: { color: false }, maxToolOutputLines: 3, }) @@ -100,16 +109,46 @@ describe('dsh-tui-demo app', () => { }) }) - it('resumes the configured session and applies runtime defaults', () => { - const { ctx, calls } = recordingContext() + it('uses the launcher sessions-root slot through schema-normalized config', () => { + // The Loader normalizes config through the schemastery Config BEFORE apply + // runs. A schema .default() on persistenceRoot would materialize here and + // permanently shadow the launcher slot — the regression this test pins. + const normalized = tuiAgent.Config({ + provider: 'mock', + model: 'mock-model', + workspaceContext: false, + } as never) + expect(normalized.persistenceRoot).toBeUndefined() + + const { ctx, calls } = recordingContext(undefined, '/launcher/sessions') + tuiAgent.composeTuiApp(ctx, normalized) + expect(calls[2]?.config).toMatchObject({ root: '/launcher/sessions' }) + expect(calls[4]?.config).toEqual({ path: join('/launcher/sessions', 'session-query.db') }) + }) + + it('lets an explicit persistenceRoot win over the launcher slot', () => { + const { ctx, calls } = recordingContext(undefined, '/launcher/sessions') + tuiAgent.composeTuiApp(ctx, { + provider: 'mock', + model: 'mock-model', + persistenceRoot: '/explicit/root', + workspaceContext: false, + }) + expect(calls[2]?.config).toEqual({ root: '/explicit/root' }) + }) + + it('loads persisted history for a launcher-selected resume identity', () => { + // The bundle default stays project-local: shared-store policy is the + // launcher's, which patches `persistenceRoot` itself (the dsh CLI does). + const { ctx, calls } = recordingContext({ id: SessionId('persisted-session'), resume: true }) tuiAgent.composeTuiApp(ctx, { provider: 'mock', model: 'mock-model', - resumeSessionId: 'persisted-session', workspaceContext: false, }) expect(calls[2]?.config).toEqual({ root: './.sessions' }) + expect(calls[4]?.config).toEqual({ path: join('./.sessions', 'session-query.db') }) expect(calls[5]?.config).toEqual({}) // No configured welcome forwards none: the TUI banner sweeps in without a subtitle. expect(calls[8]?.config).toEqual({ sessionId: 'persisted-session' }) @@ -119,12 +158,24 @@ describe('dsh-tui-demo app', () => { }) }) - it('normalizes an empty resume id and routes apply through the same composition', () => { + it('creates a launcher-minted identity fresh rather than loading history', () => { + const { ctx, calls } = recordingContext({ id: SessionId('minted-session'), resume: false }) + tuiAgent.composeTuiApp(ctx, { + provider: 'mock', + model: 'mock-model', + workspaceContext: false, + }) + + expect(calls[8]?.config).toEqual({ sessionId: 'minted-session' }) + expect((calls[9]?.config as { agents: Array> }).agents[0]) + .toMatchObject({ id: 'main', sessionId: 'minted-session' }) + }) + + it('mints a fresh session with no launcher slot and routes apply through the same composition', () => { const { ctx, calls } = recordingContext() tuiAgent.apply(ctx, { provider: 'mock', model: 'mock-model', - resumeSessionId: '', goals: false, workspaceContext: false, }) diff --git a/packages/examples/tui-demo/tsconfig.json b/packages/examples/tui-demo/tsconfig.json index d26d5b7da6..0ddf3b411c 100644 --- a/packages/examples/tui-demo/tsconfig.json +++ b/packages/examples/tui-demo/tsconfig.json @@ -20,6 +20,9 @@ { "path": "../../core/session" }, + { + "path": "../../util/paths" + }, { "path": "../../session-query/session-query" }, diff --git a/packages/guard/README.md b/packages/guard/README.md index b7375fd2bb..a164bdcddb 100644 --- a/packages/guard/README.md +++ b/packages/guard/README.md @@ -2,10 +2,11 @@ English | [中文](README.zh.md) -Behavioral guard plugins that watch the agent loop for unproductive patterns and nudge the model back on course. A single **product** package — there is no interface/implementation seam here, because a guard is a self-contained consumer of existing core seams (`tools/post-execute`, `agent/prompt-submit`, `agent/status`), not a swappable capability. +Behavioral guard plugins that watch the agent loop and correct it — some by nudging the model back on course, some by refusing an operation outright. All are **product** packages: there is no interface/implementation seam here, because a guard is a self-contained consumer of existing core seams (`tools/pre-execute`, `tools/post-execute`, `agent/prompt-submit`, `agent/status`), not a swappable capability. | Package | Role | ctx key | |---|---|---| | `repeat-tool-guard/` | Advisory reminders when an agent loops on identical tool calls | (listens on `ctx.tools`' waterfalls) | +| `source-guard/` | Denies file edits inside a dsh staging worktree until the required skill is loaded | (listens on `ctx.tools`' waterfalls) | -Reminders travel as `additionalContexts` on the `tools/post-execute` decision; the agent loop appends them as logged plugin-sourced `user/message` events after the step's tool results (see [the tools package](../core/tools)), so everything a guard says to the model is reconstructable from the session log. +An advisory guard's reminders travel as `additionalContexts` on the `tools/post-execute` decision; the agent loop appends them as logged plugin-sourced `user/message` events after the step's tool results (see [the tools package](../core/tools)), so everything such a guard says to the model is reconstructable from the session log. An enforcing guard instead decides on `tools/pre-execute`, where a `deny` becomes the call's error result and the operation never dispatches. diff --git a/packages/guard/source-guard/README.i18n.yaml b/packages/guard/source-guard/README.i18n.yaml new file mode 100644 index 0000000000..801d0979b6 --- /dev/null +++ b/packages/guard/source-guard/README.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write packages/guard/source-guard/README.md +README.md: f083ef0af53c4d4f7c4a0875837ac3a3c851c54c +README.zh.md: 7c9efa7b8d3aec1a95b62416ef624a887fd48aa0 diff --git a/packages/guard/source-guard/README.md b/packages/guard/source-guard/README.md new file mode 100644 index 0000000000..f083ef0af5 --- /dev/null +++ b/packages/guard/source-guard/README.md @@ -0,0 +1,88 @@ +# @deepseek-ai/dsh-source-guard + +English | [中文](README.zh.md) + +An enforcement gate, not a model-facing tool: it never appears in the tool list and adds exactly one behavior — it denies a `write` or `edit` whose target sits inside the dsh checkout the running harness was launched from, on that checkout's own branch, until the calling session's durable log shows a successful load of the `dsh-customize` skill. That skill requires personal changes to be implemented in a task worktree and integrated under the staging lock; this plugin turns its central rule ("do not edit the personal staging checkout directly") from prompt guidance into a boundary the model cannot cross by forgetting. + +## Config + +```yaml +- id: source-guard + name: '@deepseek-ai/dsh-source-guard' + config: + requiredSkill: dsh-customize # default; the skill whose load lifts the denial + tools: [write, edit] # default; the gated tool names + protectedCheckout: /path/to/checkout # defaults to this module's own location +``` + +Every field fails loud at plugin load: an empty `tools` list, a blank `requiredSkill`, or a relative `protectedCheckout` throws, never a silent fall-back. + +`protectedCheckout` names a path inside the checkout to guard, and its worktree supplies BOTH protected identities: the repository and the exact branch. Its default is this module's own file, which resolves the checkout the running harness was launched from — the live deployment, whatever its branch is named. Nothing about the branch is configured or pattern-matched, so a maintainer whose staging branch follows no naming convention is protected identically. A harness running from an installed copy resolves a different repository, or none, and therefore guards nothing; the rule is meaningless outside a source checkout. + +The shipped TUI composition (`examples/tui-agent/cordis.yml`) loads this plugin with defaults. It is inert for anyone whose workspace is not the launcher's own checkout, so an ordinary project sees no change. + +## Which paths are protected + +Protection is decided by git identity read from files — `.git`, its `gitdir:` pointer, and `HEAD` — never by path prefix and never by running `git`. Prefix matching would be wrong here: the task worktrees the skill prescribes live *inside* the staging tree, at `/.worktrees/...`, and are exactly where edits belong. + +Resolution walks OUTWARD from the target and stops at the first enclosing worktree, so it reports the INNERMOST one. Denial needs that worktree to match the launcher's on BOTH identities: the same shared git directory and the same branch. A task worktree nested under the protected tree answers with its own task branch and passes; the launcher's own tree answers with the launcher's branch and is denied. Repository identity is compared on symlink-resolved paths, so two routes to one repository — a session cwd under `/var/...` and a configured path under `/private/var/...` on macOS — match rather than falling open. + +Requiring the exact branch, not a name pattern, keeps the gate on the live deployment only. A stale sibling checkout left by an earlier install shares the repository but runs no launcher, so the workflow rule does not apply to it and it stays editable. + +A `gitdir:` pointer may be absolute (what `git worktree add` writes) or relative, which git resolves against the worktree directory holding it; both resolve here. A relative `file_path` resolves against the calling session's workspace, exactly as the filesystem tools resolve it, so it is not an unguarded route to a protected file. + +The gate is deliberately narrow: + +- **`read` is never gated.** Inspecting the staging checkout violates nothing, so only mutating tools are candidates. +- **`bash` is not gated.** Reliably classifying mutating shell commands is out of scope, so a determined model can still change staging through a shell. +- **Calls without an agent are allowed.** A direct `ctx.tools.execute()` caller has no session to replay and no model to correct. +- **Unresolvable git state fails OPEN.** A path outside any worktree, a detached HEAD on either side, a different repository or branch, a malformed `.git` pointer, or unreadable metadata all leave the call to the rest of the chain. A gate that blocked every write whenever git identity was unavailable would cause more harm than the violation it prevents. +- **An unresolvable target is not judged.** An empty `file_path`, a non-string one, or a relative one in a session that names no workspace leaves the call to the tool's own validation. + +Worktree identity is cached per target directory for the plugin's lifetime, so repeated writes in one directory read git metadata once; a mid-session branch switch is therefore not observed. + +## How the denial lifts + +Satisfaction is replayed from the session's durable log: a `tool/call` naming the `skill` tool whose arguments parse to `{name: }`, paired by call id with a non-error `tool/result`. Because the log is the only state, satisfaction survives a session resume — a resumed session that already loaded the skill is not asked again. A failed load, a differently-named skill, and malformed argument JSON all leave the denial in place. + +Satisfaction is per session, so a subagent with its own session must load the skill itself. + +## Enforcement point + +The gate is a `tools/pre-execute` listener returning `{kind: 'deny', reason}`, so the call never dispatches and the file is never touched. It delegates via `next()` in every non-violating case. Denial — not an advisory reminder — is the point: an advisory nudge leaves the violation committed, and `ask` degrades to denial in a composition without approval support. + +## Testing + +Unit suites drive a real agent loop against a mock adapter over real git-metadata fixtures — a staging worktree, a task worktree nested inside it, a plain clone, a foreign repository on a staging-named branch, a detached HEAD, absolute and relative `gitdir:` pointers, a symlinked route to the same repository, and unreadable metadata — to per-file 100%. The assembled-run evidence is the Loader-composition smoke (`tests/loader-composition.e2e.ts`): it boots a real headless app over `examples/headless-agent/tests/fixtures/guard/source-guard/cordis.yml`, seeds a staging worktree in a temporary cwd, and asserts the tool result is an error carrying the exact denial while the targeted file keeps its original bytes. + +## Model Experience + +### Denied filesystem call + +#### What the model sees + +A gated call into a protected worktree without the required skill loaded returns an error result carrying exactly the text below. No prompt section, tool schema, or successful-call text is added, and an allowed call is indistinguishable from one made without this plugin. + +##### Denial result + +```markdown +Error: Editing "" directly is not allowed: it is inside the dsh checkout this session is running from, on branch . Load the skill first and follow it — implement in a task worktree, then integrate under the staging lock. +``` + +#### Token effect + +Zero tokens while no denial occurs. A denial adds its small retained error result and avoids the success payload the call would have produced. + +#### KV Cache effect + +Append-only; newly visible content follows the reusable request prefix and does not invalidate existing KV-cache entries. + +## Known Limitations and Deferred Work + +- **`bash` is ungated** — the guard is a boundary for the filesystem tools only; a shell command can still mutate a protected worktree. +- **Worktree identity is cached per directory for the plugin's lifetime** — switching a protected worktree's branch mid-session does not change decisions until the next load, on either the target or the launcher side. +- **Only the launcher's own checkout is protected** — a stale sibling checkout of the same repository stays editable, deliberately; run `dsh` from it to protect it. +- **Disarmed outside a source checkout** — a harness running from an installed copy protects nothing unless `protectedCheckout` names a real checkout explicitly. +- **Satisfaction is per session** — a subagent's session must load the skill itself; a parent's load does not carry over. +- **Fail-open on unresolvable git state** — a broken or unreadable `.git` means no protection, chosen deliberately over blocking every edit. +- **One skill lifts the whole gate for the session** — loading it does not verify the workflow was actually followed, only that the instructions were read. diff --git a/packages/guard/source-guard/README.zh.md b/packages/guard/source-guard/README.zh.md new file mode 100644 index 0000000000..7c9efa7b8d --- /dev/null +++ b/packages/guard/source-guard/README.zh.md @@ -0,0 +1,88 @@ +# @deepseek-ai/dsh-source-guard + +[English](README.md) | 中文 + +这是一道强制执行门禁,而非面向模型的工具:它不会出现在工具列表中,只增加一种行为。若 `write` 或 `edit` 的目标位于运行中 harness 启动来源的 dsh 检出目录内,并处于该检出目录自身的分支上,它会拒绝调用,直到调用方会话的持久日志表明已成功加载 `dsh-customize` skill(技能)。该 skill 要求在任务 worktree 中实现个人变更,并在 staging 锁保护下完成集成;本插件把其核心规则(「不要直接编辑个人 staging 检出目录」)从提示词指导变成一道模型无法因遗忘而越过的边界。 + +## 配置 + +```yaml +- id: source-guard + name: '@deepseek-ai/dsh-source-guard' + config: + requiredSkill: dsh-customize # default; the skill whose load lifts the denial + tools: [write, edit] # default; the gated tool names + protectedCheckout: /path/to/checkout # defaults to this module's own location +``` + +插件加载时,每个字段都会对错误配置快速失败:`tools` 为空列表、`requiredSkill` 为空白字符串,或 `protectedCheckout` 使用相对路径时,都会抛出错误,绝不静默回退。 + +`protectedCheckout` 指定位于待保护检出目录内的一条路径;其 worktree 会提供两项受保护身份:仓库和确切分支。其默认值是本模块自己的文件,由此解析出运行中 harness 启动来源的检出目录——当前运行的部署,无论其分支采用什么名称。分支既无需配置,也不会通过模式匹配,因此 staging 分支不遵循任何命名约定的维护者同样会受到保护。若 harness 从已安装副本运行,则会解析到另一个仓库,或根本解析不到仓库,因此不会保护任何内容;这条规则在源码检出目录之外没有意义。 + +已交付的 TUI 组合(`examples/tui-agent/cordis.yml`)会以默认配置加载本插件。若用户的工作区并非启动器自身所在的检出目录,本插件不会生效,因此普通项目不会发生任何变化。 + +## 受保护的路径 + +保护范围根据从文件读取的 Git 身份确定,即 `.git`、其中的 `gitdir:` 指针和 `HEAD`;既不按路径前缀判断,也不运行 `git`。此处若匹配路径前缀就会出错:skill 要求使用的任务 worktree 位于 staging 树*内部*的 `/.worktrees/...`,而这正是应该进行编辑的位置。 + +解析过程从目标路径开始向外逐层查找,遇到第一个所属 worktree 就停止,因此返回最内层的 worktree。只有该 worktree 在两项身份上都与启动器的 worktree 匹配,才会拒绝:共用同一个共享 Git 目录,且分支相同。嵌套在受保护树下的任务 worktree 会返回自己的任务分支并获准;启动器自身所在的树会返回启动器的分支并被拒绝。仓库身份会按解析符号链接后的路径进行比较,因此指向同一仓库的两条路径——macOS 上位于 `/var/...` 下的会话 cwd 和位于 `/private/var/...` 下的配置路径——会相互匹配,而不会触发故障放行(fail-open)。 + +要求匹配确切分支而非名称模式,可确保门禁仅作用于当前运行的部署。先前安装留下的陈旧同级检出目录虽然共享仓库,却没有运行启动器,因此该工作流规则不适用于它,它仍可编辑。 + +`gitdir:` 指针既可以是绝对路径(`git worktree add` 写入的形式),也可以是相对路径;Git 会以包含该指针的 worktree 目录为基准解析相对路径,本插件对两者都能解析。相对 `file_path` 会像文件系统工具一样,相对于调用会话的工作区解析,因此不会成为绕过门禁访问受保护文件的路径。 + +门禁刻意保持较窄的范围: + +- **`read` 从不受门禁限制。** 检查 staging 检出不构成违规,因此只有修改类工具是候选项。 +- **`bash` 不受门禁限制。** 可靠识别会修改内容的 shell 命令不在范围内,因此执意修改的模型仍可通过 shell 修改 staging。 +- **没有 agent(智能体)的调用会被放行。** 直接调用 `ctx.tools.execute()` 的调用方没有可供回放的会话,也没有需要纠正的模型。 +- **无法解析 Git 状态时故障放行。** 不属于任何 worktree 的路径、任一侧的 HEAD 分离状态、其他仓库或分支、格式错误的 `.git` 指针或不可读的元数据,都会把调用交给链中后续环节处理。若每逢 Git 身份不可用就阻止所有写入,这道门禁造成的危害将大于它所防止的违规。 +- **无法解析的目标不会被判断。** `file_path` 为空、不是字符串,或它是相对路径而会话未指定工作区时,调用会交给工具自身校验。 + +插件会在其整个生命周期内按目标目录缓存 worktree 身份,因此同一目录中的重复写入只读取一次 Git 元数据;由此,系统不会观察到会话中途的分支切换。 + +## 如何解除拒绝 + +是否满足解锁条件由会话的持久日志回放得出:日志中存在一条 `tool/call`,它调用名为 `skill` 的工具,参数可解析为 `{name: }`,并且有一条调用 id 相同的非错误 `tool/result` 与之配对。由于日志是唯一状态源,恢复会话时仍能保留这一结果:若恢复的会话已经加载该 skill,系统不会再次要求加载。加载失败、skill 名称不同或参数 JSON 格式错误,都会让拒绝继续生效。 + +解锁条件按会话独立满足,因此拥有独立会话的 subagent 必须自行加载该 skill。 + +## 强制执行点 + +门禁是一个 `tools/pre-execute` 监听器,返回 `{kind: 'deny', reason}`,因此调用绝不会分派执行,文件也绝不会被修改。在所有不违规的情况下,它都会通过 `next()` 委派。这里刻意采用拒绝而非建议性提醒:建议性提醒仍会让违规落地,而在不支持批准的组合中,`ask` 会退化为拒绝。 + +## 测试 + +单元测试套件基于真实 Git 元数据 fixture(测试前置数据),使用 mock 适配器驱动真实 agent loop(智能体循环):覆盖 staging worktree、嵌套其中的任务 worktree、普通克隆、位于 staging 命名分支上的其他仓库、HEAD 分离状态、绝对和相对 `gitdir:` 指针、指向同一仓库的符号链接路径以及不可读元数据,达到逐文件 100% 覆盖率。组装运行层面的证据来自 Loader 组合冒烟测试(`tests/loader-composition.e2e.ts`):它通过 `examples/headless-agent/tests/fixtures/guard/source-guard/cordis.yml` 启动一个真实的 headless 应用,在临时 cwd 中植入 staging worktree,并断言工具结果是携带精确拒绝文本的错误,同时目标文件保持原始字节不变。 + +## 模型体验 + +### 被拒绝的文件系统调用 + +#### 模型看到的内容 + +如果未加载必需 skill 就对受保护 worktree 发起受门禁限制的调用,系统会返回错误结果,其中的文本与下文完全一致。系统不会添加提示词段、工具 schema 或成功调用文本;允许的调用与未启用此插件时的调用完全无法区分。 + +##### 拒绝结果 + +```markdown +Error: Editing "" directly is not allowed: it is inside the dsh checkout this session is running from, on branch . Load the skill first and follow it — implement in a task worktree, then integrate under the staging lock. +``` + +#### Token 影响 + +未发生拒绝时为零 token。一次拒绝会添加一条会保留在历史中的短小错误结果,同时避免生成该调用原本会产生的成功载荷。 + +#### KV Cache 影响 + +仅追加;新出现的内容位于可复用请求前缀之后,不会使现有 KV-cache 条目失效。 + +## 已知限制与暂缓工作 + +- **`bash` 不受门禁限制**:此插件只为文件系统工具提供边界;shell 命令仍可修改受保护的 worktree。 +- **插件生命周期内按目录缓存 worktree 身份**:在会话中途切换受保护 worktree 的分支,不会改变判断结果,直至下次加载插件;目标侧和启动器侧都是如此。 +- **仅保护启动器自身的检出目录**:同一仓库中的陈旧同级检出目录会被刻意保留为可编辑状态;若要保护它,请从中运行 `dsh`。 +- **源码检出之外不启用**:从已安装副本运行的 harness 不保护任何内容,除非 `protectedCheckout` 明确指定真实检出目录。 +- **解锁条件按会话独立满足**:subagent 的会话必须自行加载该 skill;父会话的加载状态不会继承。 +- **无法解析 Git 状态时故障放行**:损坏或不可读的 `.git` 会使保护失效;这是刻意选择的结果,因为另一方案是阻止所有编辑。 +- **仅加载一个 skill 即可为会话解除整道门禁**:加载该 skill 并不能验证是否实际遵循工作流,只能证明已阅读这些指令。 diff --git a/packages/guard/source-guard/package.json b/packages/guard/source-guard/package.json new file mode 100644 index 0000000000..4e893e0a69 --- /dev/null +++ b/packages/guard/source-guard/package.json @@ -0,0 +1,56 @@ +{ + "name": "@deepseek-ai/dsh-source-guard", + "description": "Source-guard plugin: denies direct file edits inside a dsh staging worktree until the required customization skill is loaded", + "version": "0.0.1", + "private": true, + "type": "module", + "main": "lib/index.js", + "types": "lib/types/index.d.ts", + "exports": { + ".": { + "types": "./lib/types/index.d.ts", + "default": "./lib/index.js" + }, + "./invariant": { + "types": "./lib/types/invariant.d.ts", + "default": "./lib/invariant.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "files": [ + "lib/index.js", + "lib/invariant.js", + "lib/types/**/*.d.ts", + "lib/types/**/*.d.ts.map", + "src" + ], + "license": "BSD-3-Clause", + "dependencies": { + "schemastery": "^3.18.0" + }, + "peerDependencies": { + "@deepseek-ai/dsh-agent": "^0.0.1", + "@deepseek-ai/dsh-fs": "^0.0.1", + "@deepseek-ai/dsh-invariants": "^0.0.1", + "@deepseek-ai/dsh-llm": "^0.0.1", + "@deepseek-ai/dsh-sandbox": "^0.0.1", + "@deepseek-ai/dsh-session": "^0.0.1", + "@deepseek-ai/dsh-tools": "^0.0.1", + "cordis": "^4.0.0-rc.7" + }, + "devDependencies": { + "@deepseek-ai/dsh-agent": "workspace:^", + "@deepseek-ai/dsh-agent-loop": "workspace:^", + "@deepseek-ai/dsh-agent-loop-testkit": "workspace:^", + "@deepseek-ai/dsh-fs": "workspace:^", + "@deepseek-ai/dsh-fs-local": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-llm": "workspace:^", + "@deepseek-ai/dsh-loader-smoke": "workspace:^", + "@deepseek-ai/dsh-sandbox": "workspace:^", + "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/dsh-tools": "workspace:^", + "cordis": "^4.0.0-rc.7" + } +} diff --git a/packages/guard/source-guard/src/index.ts b/packages/guard/source-guard/src/index.ts new file mode 100644 index 0000000000..4e242efbd0 --- /dev/null +++ b/packages/guard/source-guard/src/index.ts @@ -0,0 +1,319 @@ +/** + * Denies model-driven file mutation inside a dsh staging worktree until the + * calling session has loaded the required customization skill. Config, git + * resolution, and satisfaction semantics live in the package README; rationale + * lives in the source-guard Agent Note. + * @module @deepseek-ai/dsh-source-guard + */ + +import { dirname, isAbsolute, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' +import type { Context } from 'cordis' +import z from 'schemastery' +import { canonicalPath } from '@deepseek-ai/dsh-sandbox' +import type {} from '@deepseek-ai/dsh-fs' +import type { CallId } from '@deepseek-ai/dsh-llm' +import type { Session } from '@deepseek-ai/dsh-session' +import type { PreToolDecision, ToolExecution } from '@deepseek-ai/dsh-tools' + +export const name = 'source-guard' + +/** The `ctx.fs` provider supplies the git-metadata reads this guard resolves paths with. */ +export const inject = ['fs'] + +/** + * Plugin config, validated by the same-named schemastery schema plus the + * load-time checks in `apply` (misconfiguration fails loud: an empty `tools` + * list, a blank `requiredSkill`, or a relative `protectedCheckout` throws at + * plugin load, never a silent fall-back). + */ +export interface Config { + /** Skill whose loaded presence in the session lifts the denial (default `dsh-customize`). */ + requiredSkill?: string + /** Tool names to gate (default `['write', 'edit']`). */ + tools?: string[] + /** + * Absolute path inside the checkout this guard protects. Its worktree + * supplies BOTH protected identities: the repository (targets in any other + * repository are ignored) and the exact branch (only that branch's worktree + * is protected). Defaults to this module's own location, which resolves the + * checkout the running harness was launched from — the live deployment, + * whatever its branch is named. Set it explicitly to guard a different + * checkout, or when the harness runs from an installed copy whose own + * location is not a checkout at all. + */ + protectedCheckout?: string +} + +export const Config: z = z.object({ + requiredSkill: z.string().default('dsh-customize'), + tools: z.array(z.string()).default(['write', 'edit']), + protectedCheckout: z.string().default(fileURLToPath(import.meta.url)), +}) + +/** + * The tool whose successful call satisfies the guard. Fixed, not configurable: + * this is the harness's own skill-loading tool name, so a deployment that + * renamed it has no skill to load and nothing for this guard to observe. + */ +const SKILL_TOOL = 'skill' + +/** + * The argument key every gated tool names its target with. `write` and `edit` + * share it (`dsh-tool-fs`), and gating a tool that does not is a + * misconfiguration the guard reports rather than silently allowing. + */ +const PATH_ARGUMENT = 'file_path' + +/** + * The absolute `file_path` a gated call targets, or `undefined` when the + * arguments carry no usable one. Arguments arrive as the loop's parsed model + * JSON, so this is a model-input boundary: any shape is possible. + * + * A relative path resolves against the calling session's workspace, exactly as + * the filesystem tools resolve it (`dsh-tool-fs`'s `sessionCwd`). Judging only + * absolute paths would leave `write` with a relative `file_path` as an + * unguarded path to the same file. + */ +function targetPath(argumentsValue: unknown, sessionCwd: string | undefined): string | undefined { + if (typeof argumentsValue !== 'object' || argumentsValue === null) return undefined + const value = (argumentsValue as Record)[PATH_ARGUMENT] + if (typeof value !== 'string' || value.length === 0) return undefined + if (isAbsolute(value)) return resolve(value) + // Without a session cwd the tools fall back to a provider-owned default this + // guard cannot observe, so the target is genuinely unresolvable here. + return sessionCwd === undefined ? undefined : resolve(sessionCwd, value) +} + +/** One resolved worktree's identity: the branch its HEAD names, and the repository it belongs to. */ +interface Worktree { + /** Branch name from `HEAD`, or `undefined` for a detached HEAD. */ + branch: string | undefined + /** + * Symlink-resolved absolute path of the shared git directory, identifying the + * repository across worktrees. Canonical because two paths reaching one + * repository by different symlink routes must compare equal — on macOS a + * session cwd under `/var/...` and a configured path under `/private/var/...` + * name the same directory, and a lexical comparison would fail open. + */ + commonDir: string +} + +/** + * What one git-metadata path holds: a file's text, the fact that it is a + * directory, or nothing resolvable. Every caller treats the unresolvable case + * as "not a worktree" and lets the call proceed, so distinguishing absence + * from a permission error would change no decision. + */ +type GitEntry = + | { kind: 'file'; text: string } + | { kind: 'directory' } + | { kind: 'absent' } + +/** Probe one git-metadata path, reading its text when it is a regular file. */ +async function readGitEntry(ctx: Context, path: string): Promise { + try { + const target = await ctx.fs.resolve(path) + const info = await ctx.fs.stat(target) + if (info?.type === 'directory') return { kind: 'directory' } + if (info?.type !== 'file') return { kind: 'absent' } + return { kind: 'file', text: await ctx.fs.readText(target) } + } catch { + // Any resolve/stat/read failure (absent, denied, unreadable encoding) + // yields no git identity. Nothing else can reach here: the guard performs + // no other IO. + return { kind: 'absent' } + } +} + +/** + * Branch name from a `HEAD` file's contents. A symbolic ref names a branch; a + * detached HEAD holds a raw object id and has no branch, which no staging + * pattern can match. + */ +function branchFromHead(head: string): string | undefined { + const trimmed = head.trim() + const ref = 'ref: refs/heads/' + return trimmed.startsWith(ref) ? trimmed.slice(ref.length) : undefined +} + +/** + * Resolve the git directory a worktree root's `.git` entry designates, plus + * the shared common directory. A plain clone's `.git` is a directory that is + * its own common dir; a linked worktree's `.git` is a file pointing into the + * main repository's `worktrees/`, whose common dir is two levels up. + * A `gitdir:` pointer may be relative, which git resolves against the worktree + * directory holding it. + */ +async function resolveGitDir(ctx: Context, root: string): Promise<{ gitDir: string; commonDir: string } | undefined> { + const dotGit = resolve(root, '.git') + const entry = await readGitEntry(ctx, dotGit) + // A plain clone keeps a `.git` DIRECTORY, which is both the git dir and the + // common dir; a linked worktree keeps a `.git` FILE pointing elsewhere. + if (entry.kind === 'directory') return { gitDir: dotGit, commonDir: canonicalPath(dotGit) } + if (entry.kind === 'absent') return undefined + const prefix = 'gitdir:' + const trimmed = entry.text.trim() + if (!trimmed.startsWith(prefix)) return undefined + const pointer = trimmed.slice(prefix.length).trim() + if (pointer.length === 0) return undefined + const gitDir = resolve(root, pointer) + // `/worktrees/` — the shared repository is two levels up. + return { gitDir, commonDir: canonicalPath(dirname(dirname(gitDir))) } +} + +/** + * Walk from a path toward the filesystem root and resolve the first enclosing + * worktree, or `undefined` when the path is inside none. + */ +async function findWorktree(ctx: Context, from: string): Promise { + let current = from + for (;;) { + const dirs = await resolveGitDir(ctx, current) + if (dirs !== undefined) { + const head = await readGitEntry(ctx, resolve(dirs.gitDir, 'HEAD')) + return { + branch: head.kind === 'file' ? branchFromHead(head.text) : undefined, + commonDir: dirs.commonDir, + } + } + const parent = dirname(current) + if (parent === current) return undefined + current = parent + } +} + +/** + * The skill name a `skill` call's raw argument JSON requested, or `undefined` + * when the JSON is malformed or carries no string `name`. The log stores the + * model's unparsed argument string, so this is a model-JSON boundary. + */ +function skillNameOf(rawArguments: string): string | undefined { + let parsed: unknown + try { + parsed = JSON.parse(rawArguments) + } catch { + // The model produced argument text that is not JSON; the call cannot have + // named a skill. Nothing else in this try can throw. + return undefined + } + if (typeof parsed !== 'object' || parsed === null) return undefined + const value = (parsed as Record).name + return typeof value === 'string' ? value : undefined +} + +/** + * Whether the session's durable log records a successful load of + * `requiredSkill`. Replayed from `tool/call` + `tool/result` pairs, so + * satisfaction survives a session resume: the log is the only state. + */ +function skillLoaded(session: Session, requiredSkill: string): boolean { + const requested = new Map() + for (const event of session.events) { + if (event.type === 'tool/call') { + if (event.data.name === SKILL_TOOL) requested.set(event.data.callId, event.data.arguments) + continue + } + const block = event.type === 'tool/result' ? event.data.message.content[0] : undefined + if (block === undefined || block.isError === true) continue + const rawArguments = requested.get(block.toolCallId) + if (rawArguments !== undefined && skillNameOf(rawArguments) === requiredSkill) return true + } + return false +} + +/** The denial text a blocked call reports to the model. */ +function denialReason(path: string, branch: string, requiredSkill: string): string { + return `Editing "${path}" directly is not allowed: it is inside the dsh checkout this session is running from, on branch ${branch}. ` + + `Load the ${requiredSkill} skill first and follow it — implement in a task worktree, then integrate under the staging lock.` +} + +/** + * Install the guard's listener. + * @param ctx - plugin context; the listener is scoped to it and disposed with it. + * @param config - validated {@link Config}; re-checked fail-loud here. + */ +export function apply(ctx: Context, config: Config): void { + // schemastery's .default() guarantees the fields are set after validation. + const requiredSkill = config.requiredSkill as string + const tools = config.tools as string[] + if (tools.length === 0) { + throw new Error('source-guard: `tools` must not be empty') + } + if (requiredSkill.trim().length === 0) { + throw new Error('source-guard: `requiredSkill` must not be blank') + } + const gated = new Set(tools) + + const protectedCheckout = config.protectedCheckout as string + if (!isAbsolute(protectedCheckout)) { + throw new Error(`source-guard: \`protectedCheckout\` must be an absolute path, got "${protectedCheckout}"`) + } + // Resolved once per plugin lifetime: the worktree this guard arms for, which + // supplies both the protected repository and the protected branch. A harness + // running from an installed copy resolves a different repository (or none) + // and therefore guards nothing, which is correct — the rule is meaningless + // outside a source checkout. + let protectedRepository: Promise | undefined + + /** The repository containing {@link Config.protectedCheckout}. */ + function repository(): Promise { + protectedRepository ??= findWorktree(ctx, dirname(protectedCheckout)) + return protectedRepository + } + + // Worktree identity per directory, cached for the plugin's lifetime: a + // directory's repository and branch are stable in practice, and re-reading + // git metadata on every write would repeat identical IO. A mid-session + // branch switch is therefore not observed (see the README). + const worktrees = new Map>() + + /** Resolve (and memoize) the worktree enclosing a target path's directory. */ + function worktreeOf(path: string): Promise { + const directory = dirname(path) + let pending = worktrees.get(directory) + if (pending === undefined) { + pending = findWorktree(ctx, directory) + worktrees.set(directory, pending) + } + return pending + } + + /** + * The target path and the staging branch protecting it, or `undefined` when + * the call may proceed. Fails open on every unresolvable case: a path outside + * any worktree, a detached HEAD, a different repository, or unreadable git + * metadata leaves the call to the rest of the chain, because a guard that + * blocked writes whenever git identity was unavailable would be worse than + * the violation it prevents. + */ + async function protectedTarget(exec: ToolExecution, session: Session): Promise<{ path: string; branch: string } | undefined> { + if (!gated.has(exec.name)) return undefined + const path = targetPath(exec.arguments, session.header.cwd) + if (path === undefined) return undefined + const launcher = await repository() + // A detached launcher checkout names no branch to protect, so nothing is. + if (launcher?.branch === undefined) return undefined + // Resolution walks OUTWARD from the target, so it reports the INNERMOST + // enclosing worktree: a task worktree nested under the protected tree + // answers with its own task branch, which is not the launcher's. That is + // what keeps the prescribed workflow unblocked. + const worktree = await worktreeOf(path) + if (worktree === undefined || worktree.commonDir !== launcher.commonDir) return undefined + // Only the branch the launcher itself runs from is protected: a stale + // sibling checkout of the same repository is not the live deployment. + if (worktree.branch !== launcher.branch) return undefined + return { path, branch: launcher.branch } + } + + ctx.on('tools/pre-execute', async (exec, next): Promise => { + // A direct `ctx.tools.execute()` caller has no session to replay and no + // model to correct; only agent-loop calls are gated. + if (exec.agent === undefined) return next() + const { session } = exec.agent + const target = await protectedTarget(exec, session) + if (target === undefined) return next() + if (skillLoaded(session, requiredSkill)) return next() + return { kind: 'deny', reason: denialReason(target.path, target.branch, requiredSkill) } + }) +} diff --git a/packages/guard/source-guard/src/invariant.ts b/packages/guard/source-guard/src/invariant.ts new file mode 100644 index 0000000000..f5b68cf24d --- /dev/null +++ b/packages/guard/source-guard/src/invariant.ts @@ -0,0 +1,85 @@ +/** + * Package-owned invariant companion for `@deepseek-ai/dsh-source-guard`. + * @module @deepseek-ai/dsh-source-guard/invariant + */ + +import type { Context } from 'cordis' +import type { InvariantFailure, InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import type { Session, SessionEvent } from '@deepseek-ai/dsh-session' + +const PACKAGE_NAME = '@deepseek-ai/dsh-source-guard' + +/** Cordis companion plugin name. */ +export const name = 'source-guard-invariant' +/** Service required before the companion can reserve package ownership. */ +export const inject = ['invariants'] + +/** + * The durable shape of this guard's refusal. The denial is the package's only + * model-visible output, and it is actionable only when it names all three of + * the offending path, the branch that protects it, and the skill that lifts + * the denial — a refusal missing any of them tells the model to stop without + * telling it how to proceed. + */ +const DENIAL = new RegExp( + '^Error: Editing "(?.+)" directly is not allowed: ' + + 'it is inside the dsh checkout this session is running from, on branch (?\\S+)\\. ' + + 'Load the (?\\S+) skill first and follow it ' + + '— implement in a task worktree, then integrate under the staging lock\\.$', +) + +/** The denial prefix identifying a result this package produced, before its full shape is validated. */ +const DENIAL_PREFIX = 'Error: Editing "' + +/** Validate one guard-produced denial result's model-facing text. */ +function validateDenial(text: string, fail: InvariantFailure): void { + const match = DENIAL.exec(text) + if (match === null) { + fail('source-guard denial must name the path, the protecting branch, and the skill that lifts it') + } + // The pattern's `\S+` groups already establish a non-empty branch and skill; + // only path absoluteness remains to check. + const { path } = match.groups as { path: string } + if (!path.startsWith('/') && !/^[A-Za-z]:[\\/]/.test(path)) { + fail(`source-guard denial must name an absolute path, got ${JSON.stringify(path)}`) + } +} + +/** Validate every guard denial carried by one session's durable log. */ +function validateSession(session: Session, fail: InvariantFailure): void { + for (const event of session.events) { + if (event.type !== 'tool/result') continue + validateEvent(event, fail) + } +} + +/** Validate one durable tool result, when it carries this package's denial. */ +function validateEvent(event: SessionEvent<'tool/result'>, fail: InvariantFailure): void { + const result = event.data.message.content[0] + if (result.isError !== true) return + for (const block of result.content) { + if (block.type !== 'text' || !block.text.startsWith(DENIAL_PREFIX)) continue + validateDenial(block.text, fail) + } +} + +/* jscpd:ignore-start -- package companions share replay and dispatch plumbing */ +/** Install validation for loaded and newly appended denial results. */ +const install: InvariantInstaller = Object.assign((ctx: Context, fail: InvariantFailure) => { + for (const session of ctx.sessions.list()) validateSession(session, fail) + ctx.on('internal/dispatch', (_mode, eventName, args) => { + if (eventName !== 'session/event') return + const [, event] = args as [Session, SessionEvent] + if (event.type !== 'tool/result') return + validateEvent(event, fail) + }, { global: true }) +}, { inject: ['sessions'] }) +/* jscpd:ignore-end */ + +/** + * Register this package's invariant companion. + * @param ctx - Cordis context carrying the invariant service. + * @returns the installed registration's disposer after setup succeeds. + */ +export const apply = (ctx: Context): Promise<() => void> => + Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) diff --git a/packages/guard/source-guard/tests/invariant.spec.ts b/packages/guard/source-guard/tests/invariant.spec.ts new file mode 100644 index 0000000000..cd51c1a9a1 --- /dev/null +++ b/packages/guard/source-guard/tests/invariant.spec.ts @@ -0,0 +1,134 @@ +import { describe, expect, it } from 'vitest' +import { Context } from 'cordis' +import { CallId, createToolResultMessage, createUserMessage, type ContentBlock } from '@deepseek-ai/dsh-llm' +import SessionStore, { SessionId, type SessionEvent } from '@deepseek-ai/dsh-session' +import InvariantService from '@deepseek-ai/dsh-invariants' +import * as SourceGuardInvariant from '@deepseek-ai/dsh-source-guard/invariant' + +/** + * The companion validates the durable shape of this package's only + * model-visible output: its refusal must name the offending path, the branch + * that protects it, and the skill that lifts it, so the model can act on the + * denial instead of merely stopping. + */ + +const PATH = '/repo/staging/file.ts' + +/** A well-formed denial for `path`, as the guard materializes it into a tool result. */ +function denial(path = PATH, branch = 'dsh-staging/20260101T000000Z', skill = 'dsh-customize'): string { + return `Error: Editing "${path}" directly is not allowed: it is inside the dsh checkout this session is running from, ` + + `on branch ${branch}. Load the ${skill} skill first and follow it ` + + '— implement in a task worktree, then integrate under the staging lock.' +} + +async function setup(): Promise { + const ctx = new Context() + await ctx.plugin(SessionStore) + await ctx.plugin(InvariantService, { enabled: true }) + await ctx.plugin(SourceGuardInvariant) + return ctx +} + +/** One durable tool result carrying `content`, error-flagged unless told otherwise. */ +function result(content: unknown[], isError = true): SessionEvent { + return { + type: 'tool/result', + seq: 0, + time: 1, + surfaceOp: 'append', + sourceEventSeqs: [0], + data: { + turn: 1, + step: 1, + message: createToolResultMessage({ + callId: CallId('c0'), + content: content as ContentBlock[], + isError, + }), + }, + } +} + +describe('source-guard invariants', () => { + it('accepts a denial naming the path, branch, and skill', async () => { + const ctx = await setup() + const session = ctx.sessions.create(SessionId('accept')) + expect(() => { ctx.emit('session/event', session, result([{ type: 'text', text: denial() }])) }).not.toThrow() + }) + + it('accepts a Windows-style absolute path', async () => { + const ctx = await setup() + const session = ctx.sessions.create(SessionId('accept-windows')) + const event = result([{ type: 'text', text: denial(String.raw`C:\repo\staging\file.ts`) }]) + expect(() => { ctx.emit('session/event', session, event) }).not.toThrow() + }) + + it.each([ + ['a successful result that merely quotes the prefix', false], + ])('ignores %s', async (_label, isError) => { + const ctx = await setup() + const session = ctx.sessions.create(SessionId('ignore-success')) + const event = result([{ type: 'text', text: 'Error: Editing "x" was fine' }], isError) + expect(() => { ctx.emit('session/event', session, event) }).not.toThrow() + }) + + it.each([ + ['a non-text block', [{ type: 'image', data: 'x', mimeType: 'image/png' }]], + ['text that is not this package\'s denial', [{ type: 'text', text: 'Error: something else' }]], + ])('ignores %s', async (_label, content) => { + const ctx = await setup() + const session = ctx.sessions.create(SessionId('ignore-other')) + expect(() => { ctx.emit('session/event', session, result(content)) }).not.toThrow() + }) + + it('ignores an event that is not a tool result', async () => { + const ctx = await setup() + const session = ctx.sessions.create(SessionId('ignore-kind')) + const event: SessionEvent = { + type: 'user/message', + seq: 0, + time: 1, + surfaceOp: 'append', + data: createUserMessage({ content: [{ type: 'text', text: denial() }], source: { kind: 'user' } }), + } + expect(() => { ctx.emit('session/event', session, event) }).not.toThrow() + }) + + it.each([ + [ + 'omits the skill that lifts it', + `Error: Editing "${PATH}" directly is not allowed: it is inside the dsh checkout this session is running from, on branch main.`, + ], + [ + 'names a relative path', + denial('relative/file.ts'), + ], + ])('rejects a denial that %s', async (_label, text) => { + const ctx = await setup() + const session = ctx.sessions.create(SessionId('reject')) + expect(() => { ctx.emit('session/event', session, result([{ type: 'text', text }])) }).toThrow(/source-guard denial/) + }) + + it('rejects an invalid denial already present on late registration', async () => { + const ctx = new Context() + await ctx.plugin(SessionStore) + const session = ctx.sessions.create(SessionId('late')) + session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + session.append('step/start', { turn: 1, step: 1 }) + const call = session.append('tool/call', { + turn: 1, step: 1, callId: CallId('c0'), name: 'write', arguments: '{}', + }) + session.append('tool/result', { + turn: 1, + step: 1, + message: createToolResultMessage({ + callId: CallId('c0'), + content: [{ type: 'text', text: denial('relative/file.ts') }], + isError: true, + }), + }, { surfaceOp: 'append', sourceEventSeqs: [call.seq] }) + + await ctx.plugin(InvariantService, { enabled: true }) + await expect(ctx.plugin(SourceGuardInvariant).then(() => undefined)).rejects.toThrow(/source-guard denial/) + }) +}) diff --git a/packages/guard/source-guard/tests/loader-composition.e2e.ts b/packages/guard/source-guard/tests/loader-composition.e2e.ts new file mode 100644 index 0000000000..2595dc8d2a --- /dev/null +++ b/packages/guard/source-guard/tests/loader-composition.e2e.ts @@ -0,0 +1,93 @@ +import { mkdir, readdir, readFile, realpath, writeFile } from 'node:fs/promises' +import { join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { describe, expect, it } from 'vitest' +import { type SessionEvent } from '@deepseek-ai/dsh-session' +import { LOADER_SMOKE_TEST_TIMEOUT_MS, runLoaderSmoke } from '@deepseek-ai/dsh-loader-smoke' + +// The Loader config lives under examples so both launch modes exercise the same +// deployable topology: a local fixture adapter plus bare workspace plugins. +const configPath = fileURLToPath(new URL( + '../../../../examples/headless-agent/tests/fixtures/guard/source-guard/cordis.yml', + import.meta.url, +)) +const binScript = fileURLToPath(new URL('../../../examples/cli-demo/src/bin.ts', import.meta.url)) +const repoTsconfig = fileURLToPath(new URL('../../../../tsconfig.json', import.meta.url)) + +/** Every `.jsonl` session log under `dir`. */ +async function jsonlFiles(dir: string): Promise { + const entries = await readdir(dir, { withFileTypes: true }) + const paths = await Promise.all(entries.map(async (entry) => { + const path = join(dir, entry.name) + if (entry.isDirectory()) return jsonlFiles(path) + return entry.isFile() && entry.name.endsWith('.jsonl') ? [path] : [] + })) + return paths.flat() +} + +/** + * Write git metadata mirroring the installer layout — a master clone owning the + * shared git directory and one linked worktree on a staging branch — and return + * the worktree file the model will try to write. + */ +async function stagingFixture(cwd: string): Promise<{ checkout: string; target: string }> { + const gitDir = join(cwd, 'master', '.git') + const worktreeGitDir = join(gitDir, 'worktrees', 'staging') + await mkdir(worktreeGitDir, { recursive: true }) + await writeFile(join(gitDir, 'HEAD'), 'ref: refs/heads/master\n') + await writeFile(join(worktreeGitDir, 'HEAD'), 'ref: refs/heads/dsh-staging/20260101T000000Z\n') + const checkout = join(cwd, 'staging') + await mkdir(checkout, { recursive: true }) + await writeFile(join(checkout, '.git'), `gitdir: ${worktreeGitDir}\n`) + const target = join(checkout, 'guarded.ts') + await writeFile(target, 'original\n') + return { checkout, target } +} + +describe('source-guard through a real headless cordis.yml', () => { + it('denies the model-requested write and leaves the staged file untouched', async () => { + let events: SessionEvent[] = [] + let contents = '' + let target = '' + const { stderr } = await runLoaderSmoke({ + label: 'source-guard headless smoke', + tempDirPrefix: 'source-guard-e2e-', + binScript, + configPath, + tsconfigPath: repoTsconfig, + binArgs: ['--config', configPath, 'edit the guarded file'], + // The isolated cwd is not known when these options are built, so the + // config and adapter resolve their fixture paths against the child's own + // cwd, which is that directory. + prepare: async (cwd) => { + // macOS puts the temp directory behind the /var -> /private/var + // symlink; the child resolves its cwd, so compare against the same + // real path rather than the symlinked one this process was handed. + target = (await stagingFixture(await realpath(cwd))).target + }, + inspect: async (cwd) => { + const logs = await jsonlFiles(join(cwd, '.sessions')) + expect(logs).toHaveLength(1) + const lines = (await readFile(logs[0] as string, 'utf8')).trimEnd().split('\n') + events = lines.slice(1).map(line => JSON.parse(line) as SessionEvent) + contents = await readFile(target, 'utf8') + }, + }) + expect(stderr).not.toContain('UNHANDLED') + + const results = events.filter( + (event): event is SessionEvent<'tool/result'> => event.type === 'tool/result') + expect(results).toHaveLength(1) + const result = results[0]?.data.message.content[0] + expect(result?.isError).toBe(true) + const text = result?.content.map(block => block.type === 'text' ? block.text : '').join('') + expect(text).toBe( + `Error: Editing "${target}" directly is not allowed: it is inside the dsh checkout this session is running from, ` + + 'on branch dsh-staging/20260101T000000Z. Load the dsh-customize skill first and follow it ' + + '— implement in a task worktree, then integrate under the staging lock.', + ) + // Enforcement, not advice: the guard denies before dispatch, so the file + // the model targeted still holds its original bytes. + expect(contents).toBe('original\n') + }, LOADER_SMOKE_TEST_TIMEOUT_MS) +}) diff --git a/packages/guard/source-guard/tests/source-guard.spec.ts b/packages/guard/source-guard/tests/source-guard.spec.ts new file mode 100644 index 0000000000..ccbc7d7c2b --- /dev/null +++ b/packages/guard/source-guard/tests/source-guard.spec.ts @@ -0,0 +1,581 @@ +import { chmod, mkdir, mkdtemp, rm, symlink, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { Context } from 'cordis' +import AgentLoop from '@deepseek-ai/dsh-agent-loop' +import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit' +import LocalFileSystem from '@deepseek-ai/dsh-fs-local' +import { CallId, createToolResultMessage, createUserMessage } from '@deepseek-ai/dsh-llm' +import { SessionId, type SessionEvent } from '@deepseek-ai/dsh-session' +import { defineContentToolFixture } from '@deepseek-ai/dsh-tools' +import type { Agent } from '@deepseek-ai/dsh-agent' +import * as SourceGuard from '@deepseek-ai/dsh-source-guard' +import type { Config } from '@deepseek-ai/dsh-source-guard' +import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts' + +/** + * Behavior suite for the staging-source guard: worktree resolution over REAL + * git metadata fixtures (a staging worktree, a nested task worktree, a plain + * clone, an unrelated repository, a detached HEAD), skill satisfaction replayed + * from the durable session log, and fail-loud config validation — all driven + * through a real agent loop against a scripted mock adapter (no network). + */ + +const roots: string[] = [] + +afterEach(async () => { + await Promise.all(roots.splice(0).map(root => rm(root, { recursive: true, force: true }))) +}) + +/** + * Build a git-metadata fixture tree that mirrors the real installer layout: a + * `master` clone holding the shared git directory, linked worktrees registered + * under `master/.git/worktrees/`, and one file per worktree to target. + */ +async function fixture(): Promise<{ + /** Absolute path of the fixture container. */ + root: string + /** A file inside the staging worktree — the protected target. */ + stagingFile: string + /** A file inside a task worktree NESTED under the staging tree. */ + taskFile: string + /** A file inside a SIBLING staging worktree of the same repository, on another branch. */ + siblingFile: string + /** A file inside the plain master clone. */ + masterFile: string + /** A file inside a worktree whose HEAD is detached. */ + detachedFile: string + /** A file inside an unrelated repository sharing no git directory. */ + outsideFile: string + /** A file under no repository at all. */ + looseFile: string +}> { + const root = await mkdtemp(join(tmpdir(), 'source-guard-')) + roots.push(root) + const master = join(root, 'master') + const gitDir = join(master, '.git') + await mkdir(join(gitDir, 'worktrees'), { recursive: true }) + await writeFile(join(gitDir, 'HEAD'), 'ref: refs/heads/master\n') + await writeFile(join(master, 'file.ts'), 'master\n') + + /** Register one linked worktree at `path` whose HEAD file holds `head`. */ + async function linked(path: string, name: string, head: string): Promise { + const worktreeGitDir = join(gitDir, 'worktrees', name) + await mkdir(worktreeGitDir, { recursive: true }) + await writeFile(join(worktreeGitDir, 'HEAD'), head) + await mkdir(path, { recursive: true }) + await writeFile(join(path, '.git'), `gitdir: ${worktreeGitDir}\n`) + const file = join(path, 'file.ts') + await writeFile(file, 'content\n') + return file + } + + const staging = join(root, 'staging-20260728T022827Z') + const stagingFile = await linked(staging, 'staging-20260728T022827Z', 'ref: refs/heads/dsh-staging/20260728T022827Z\n') + // The prescribed workflow's task worktree lives INSIDE the staging tree. + const taskFile = await linked(join(staging, '.worktrees', 'task', 'x'), 'task-x', 'ref: refs/heads/task/x\n') + // A stale staging worktree from an earlier install: same repository, different branch. + const siblingFile = await linked( + join(root, 'staging-20260727T045831Z'), + 'staging-20260727T045831Z', + 'ref: refs/heads/dsh-staging/20260727T045831Z\n', + ) + const detachedFile = await linked(join(root, 'detached'), 'detached', '0123456789abcdef0123456789abcdef01234567\n') + + const outside = join(root, 'outside') + await mkdir(join(outside, '.git'), { recursive: true }) + await writeFile(join(outside, '.git', 'HEAD'), 'ref: refs/heads/dsh-staging/20260728T022827Z\n') + const outsideFile = join(outside, 'file.ts') + await writeFile(outsideFile, 'outside\n') + + const loose = join(root, 'loose') + await mkdir(loose, { recursive: true }) + const looseFile = join(loose, 'file.ts') + await writeFile(looseFile, 'loose\n') + + return { + root, stagingFile, taskFile, siblingFile, masterFile: join(master, 'file.ts'), detachedFile, outsideFile, looseFile, + } +} + +/** + * Boot the core spine, a real local filesystem, and the guard, pointing + * `protectedCheckout` at a fixture path so the guard arms for the fixture + * repository instead of the checkout these tests actually run in. + */ +async function harness(protectedCheckout: string, config: Partial = {}): Promise { + const ctx = new Context() + await mountAgentLoopTestDependencies(ctx) + await ctx.plugin(LocalFileSystem, {}) + await ctx.plugin(AgentLoop, { agents: [] }) + await ctx.plugin(SourceGuard, { ...config, protectedCheckout }) + for (const name of ['write', 'edit', 'read', 'skill']) { + ctx.tools.register(defineContentToolFixture({ + name, + description: name, + parameters: { file_path: { type: 'string' }, name: { type: 'string' } }, + async execute() { return [{ type: 'text', text: 'ok' }] }, + })) + } + return ctx +} + +function waitForIdle(ctx: Context, agent: Agent): Promise { + return new Promise((resolve) => { + const dispose = ctx.on('agent/status', (subject, status) => { + if (subject === agent && status === 'idle') { dispose(); resolve() } + }) + }) +} + +/** Every tool result in the agent's log as `{ isError, text }`, in log order. */ +function results(agent: Agent): { isError: boolean; text: string }[] { + return [...agent.session.events] + .filter((event): event is SessionEvent<'tool/result'> => event.type === 'tool/result') + .map(event => event.data.message.content[0]) + .map(result => ({ + isError: result.isError === true, + text: result.content.map(block => block.type === 'text' ? block.text : '').join(''), + })) +} + +/** + * Durable events recording completed `skill` calls, as a RESUMED session's seed: + * the guard's satisfaction check then has nothing but the log to read, with no + * in-memory state from an original run to fall back on. + */ +function priorSkillCalls(calls: { arguments: string; isError?: boolean }[]): SessionEvent[] { + const events: SessionEvent[] = [ + { type: 'turn/start', seq: 0, time: 1, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } }, + { type: 'step/start', seq: 1, time: 2, data: { turn: 1, step: 1 } }, + ] + for (const [index, call] of calls.entries()) { + const callId = CallId(`prior${index}`) + const seq = events.length + events.push({ + type: 'tool/call', + seq, + time: seq + 1, + data: { turn: 1, step: 1, callId, name: 'skill', arguments: call.arguments }, + }) + events.push({ + type: 'tool/result', + seq: seq + 1, + time: seq + 2, + surfaceOp: 'append', + sourceEventSeqs: [seq], + data: { + turn: 1, + step: 1, + message: createToolResultMessage({ + callId, + content: [{ type: 'text', text: 'loaded' }], + isError: call.isError ?? false, + }), + }, + }) + } + const tail = events.length + events.push({ type: 'step/end', seq: tail, time: tail + 1, data: { turn: 1, step: 1 } }) + events.push({ type: 'turn/end', seq: tail + 1, time: tail + 2, data: { turn: 1, reason: { kind: 'completed' } } }) + return events +} + +/** Resume a session from durable seed events and let the model attempt one write at `path`. */ +async function resume(ctx: Context, id: string, seed: SessionEvent[], path: string): Promise { + const adapter = new MockAdapter([ + toolCallResponse(CallId('c0'), 'write', { file_path: path }), + textResponse('done'), + ]) + ctx.llm.registerAdapter(['mock'], adapter) + const { agent } = await ctx.agentLoop.createAgent(ctx, { + sessionId: SessionId(id), + seed, + agentOptions: { provider: 'mock', model: 'mock' }, + }) + agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })) + await waitForIdle(ctx, agent) + return agent +} + +/** Drive one turn whose scripted model output is the given tool calls, then a closing text. */ +async function run( + ctx: Context, + calls: { name: string; args: Record }[], + cwd?: string, +): Promise { + const adapter = new MockAdapter([ + ...calls.map((call, index) => toolCallResponse(CallId(`c${index}`), call.name, call.args)), + textResponse('done'), + ]) + ctx.llm.registerAdapter(['mock'], adapter) + const agent = ctx.agentLoop.create( + SessionId('s1'), + { provider: 'mock', model: 'mock' }, + cwd === undefined ? {} : { cwd }, + ) + agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })) + await waitForIdle(ctx, agent) + return agent +} + +describe('staging protection', () => { + it('denies a write inside the staging worktree and names the path, branch, and skill', async () => { + const paths = await fixture() + const ctx = await harness(paths.stagingFile) + const agent = await run(ctx, [{ name: 'write', args: { file_path: paths.stagingFile } }]) + const [result] = results(agent) + expect(result?.isError).toBe(true) + expect(result?.text).toBe( + `Error: Editing "${paths.stagingFile}" directly is not allowed: it is inside the dsh checkout this session is running from, ` + + 'on branch dsh-staging/20260728T022827Z. Load the dsh-customize skill first and follow it ' + + '— implement in a task worktree, then integrate under the staging lock.', + ) + }) + + it('denies an edit inside the staging worktree', async () => { + const paths = await fixture() + const ctx = await harness(paths.stagingFile) + const agent = await run(ctx, [{ name: 'edit', args: { file_path: paths.stagingFile } }]) + expect(results(agent)[0]?.isError).toBe(true) + }) + + it('allows a read inside the staging worktree, since inspection never violates the skill', async () => { + const paths = await fixture() + const ctx = await harness(paths.stagingFile) + const agent = await run(ctx, [{ name: 'read', args: { file_path: paths.stagingFile } }]) + expect(results(agent)).toEqual([{ isError: false, text: 'ok' }]) + }) + + it('allows a write inside a task worktree nested under the staging tree', async () => { + const paths = await fixture() + const ctx = await harness(paths.stagingFile) + const agent = await run(ctx, [{ name: 'write', args: { file_path: paths.taskFile } }]) + expect(results(agent)).toEqual([{ isError: false, text: 'ok' }]) + }) + + it('allows a write in the plain clone that owns the shared git directory', async () => { + const paths = await fixture() + const ctx = await harness(paths.stagingFile) + const agent = await run(ctx, [{ name: 'write', args: { file_path: paths.masterFile } }]) + expect(results(agent)).toEqual([{ isError: false, text: 'ok' }]) + }) + + it('allows a write on a staging-named branch in an unrelated repository', async () => { + const paths = await fixture() + const ctx = await harness(paths.stagingFile) + const agent = await run(ctx, [{ name: 'write', args: { file_path: paths.outsideFile } }]) + expect(results(agent)).toEqual([{ isError: false, text: 'ok' }]) + }) + + it('allows a write under a detached HEAD, which names no branch to match', async () => { + const paths = await fixture() + const ctx = await harness(paths.stagingFile) + const agent = await run(ctx, [{ name: 'write', args: { file_path: paths.detachedFile } }]) + expect(results(agent)).toEqual([{ isError: false, text: 'ok' }]) + }) + + it('allows a write when the git metadata exists but cannot be read', async () => { + const paths = await fixture() + // A `.git` pointer that stats as a file yet fails to read leaves the guard + // with no branch to judge; failing open beats blocking every edit. + const unreadable = join(paths.root, 'unreadable') + await mkdir(unreadable, { recursive: true }) + await writeFile(join(unreadable, '.git'), `gitdir: ${join(paths.root, 'master', '.git')}\n`) + await chmod(join(unreadable, '.git'), 0o000) + const file = join(unreadable, 'file.ts') + await writeFile(file, 'content\n') + const ctx = await harness(paths.stagingFile) + const agent = await run(ctx, [{ name: 'write', args: { file_path: file } }]) + expect(results(agent)).toEqual([{ isError: false, text: 'ok' }]) + }) + + it('allows a write under no repository at all', async () => { + const paths = await fixture() + const ctx = await harness(paths.stagingFile) + const agent = await run(ctx, [{ name: 'write', args: { file_path: paths.looseFile } }]) + expect(results(agent)).toEqual([{ isError: false, text: 'ok' }]) + }) + + it('arms for nothing when its own location is inside no repository', async () => { + const paths = await fixture() + const ctx = await harness(paths.looseFile) + const agent = await run(ctx, [{ name: 'write', args: { file_path: paths.stagingFile } }]) + expect(results(agent)).toEqual([{ isError: false, text: 'ok' }]) + }) + + it('arms for nothing when the launcher checkout has a detached HEAD', async () => { + const paths = await fixture() + // A detached launcher names no branch, so there is no branch to protect. + const ctx = await harness(paths.detachedFile) + const agent = await run(ctx, [{ name: 'write', args: { file_path: paths.stagingFile } }]) + expect(results(agent)).toEqual([{ isError: false, text: 'ok' }]) + }) + + it('denies when the target and the protected checkout reach one repository through different symlinks', async () => { + const paths = await fixture() + // macOS reaches the temp directory through both `/var/...` and + // `/private/var/...`; a lexical repository comparison would treat the two + // routes as different repositories and fail open on every write. + const link = join(paths.root, 'link') + await symlink(dirname(paths.stagingFile), link, 'dir') + const ctx = await harness(paths.stagingFile) + const agent = await run(ctx, [{ name: 'write', args: { file_path: join(link, 'file.ts') } }]) + expect(results(agent)[0]?.text).toContain('on branch dsh-staging/20260728T022827Z') + }) + + it('denies a RELATIVE target path resolved against the session workspace', async () => { + const paths = await fixture() + const ctx = await harness(paths.stagingFile) + // The filesystem tools resolve a relative `file_path` against the session + // cwd, so judging only absolute paths would leave this as an unguarded + // route to the same file. + const agent = await run(ctx, [{ name: 'write', args: { file_path: 'file.ts' } }], dirname(paths.stagingFile)) + expect(results(agent)[0]?.text).toContain('directly is not allowed') + }) + + it('ignores a relative target path when the session names no workspace', async () => { + const paths = await fixture() + const ctx = await harness(paths.stagingFile) + const agent = await run(ctx, [{ name: 'write', args: { file_path: 'file.ts' } }]) + expect(results(agent)).toEqual([{ isError: false, text: 'ok' }]) + }) + + it('ignores a call whose target path is an empty string', async () => { + const paths = await fixture() + const ctx = await harness(paths.stagingFile) + const agent = await run(ctx, [{ name: 'write', args: { file_path: '' } }]) + expect(results(agent)).toEqual([{ isError: false, text: 'ok' }]) + }) + + it.each([ + ['a non-string file_path', { file_path: 7 }], + ['no file_path at all', { other: 'x' }], + ])('ignores a gated call carrying %s', async (_label, args) => { + const paths = await fixture() + const ctx = await harness(paths.stagingFile) + const agent = await run(ctx, [{ name: 'write', args }]) + expect(results(agent)[0]?.text).not.toContain('directly is not allowed') + }) + + it('ignores a gated call whose arguments are not JSON, which the loop keeps as raw text', async () => { + const paths = await fixture() + const ctx = await harness(paths.stagingFile) + const callId = CallId('raw') + const adapter = new MockAdapter([ + [ + { type: 'block-start', index: 0, blockType: 'tool-call' }, + { type: 'tool-call-delta', index: 0, id: callId, name: 'write', argumentsDelta: 'not json' }, + { type: 'block-end', index: 0, block: { type: 'tool-call', id: callId, name: 'write', arguments: 'not json' } }, + { type: 'finish', reason: { kind: 'tool-calls' } }, + ], + textResponse('done'), + ]) + ctx.llm.registerAdapter(['mock'], adapter) + const agent = ctx.agentLoop.create(SessionId('raw'), { provider: 'mock', model: 'mock' }) + agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })) + await waitForIdle(ctx, agent) + expect(results(agent)[0]?.text).not.toContain('directly is not allowed') + }) + + it.each([ + ['a `.git` pointer that names no git directory', 'not a gitdir pointer\n'], + ['an empty `.git` pointer', 'gitdir:\n'], + ['a `.git` pointer into a nonexistent git directory', 'gitdir: /nonexistent/worktrees/x\n'], + ])('allows a write behind %s', async (_label, pointer) => { + const paths = await fixture() + const broken = join(paths.root, 'broken') + await mkdir(broken, { recursive: true }) + await writeFile(join(broken, '.git'), pointer) + const file = join(broken, 'file.ts') + await writeFile(file, 'content\n') + const ctx = await harness(paths.stagingFile) + const agent = await run(ctx, [{ name: 'write', args: { file_path: file } }]) + expect(results(agent)).toEqual([{ isError: false, text: 'ok' }]) + }) + + it('denies behind a RELATIVE `.git` pointer, which git resolves against the worktree', async () => { + const paths = await fixture() + // `git worktree add` writes an absolute pointer, but a relocated or + // hand-written one may be relative; git accepts both, so the guard must + // resolve both or it would fail open on a real repository layout. + const relative = join(paths.root, 'relative-pointer') + await mkdir(relative, { recursive: true }) + await writeFile(join(relative, '.git'), 'gitdir: ../master/.git/worktrees/staging-20260728T022827Z\n') + const file = join(relative, 'file.ts') + await writeFile(file, 'content\n') + const ctx = await harness(paths.stagingFile) + const agent = await run(ctx, [{ name: 'write', args: { file_path: file } }]) + expect(results(agent)[0]?.text).toContain('on branch dsh-staging/20260728T022827Z') + }) + + it('allows a write when the worktree resolves but its HEAD is missing', async () => { + const paths = await fixture() + const gitDir = join(paths.root, 'master', '.git', 'worktrees', 'headless') + await mkdir(gitDir, { recursive: true }) + const headless = join(paths.root, 'headless') + await mkdir(headless, { recursive: true }) + await writeFile(join(headless, '.git'), `gitdir: ${gitDir}\n`) + const file = join(headless, 'file.ts') + await writeFile(file, 'content\n') + const ctx = await harness(paths.stagingFile) + const agent = await run(ctx, [{ name: 'write', args: { file_path: file } }]) + expect(results(agent)).toEqual([{ isError: false, text: 'ok' }]) + }) + + it('reuses one resolution for sibling targets in the same directory', async () => { + const paths = await fixture() + const sibling = join(dirname(paths.stagingFile), 'other.ts') + await writeFile(sibling, 'content\n') + const ctx = await harness(paths.stagingFile) + const agent = await run(ctx, [ + { name: 'write', args: { file_path: paths.stagingFile } }, + { name: 'write', args: { file_path: sibling } }, + ]) + expect(results(agent).map(result => result.isError)).toEqual([true, true]) + }) + + it('protects whichever branch the launcher checkout is on, whatever its name', async () => { + const paths = await fixture() + // The protected branch is read from `protectedCheckout`'s own worktree, so + // a checkout on an unconventional branch name is still protected — a + // hardcoded name pattern would have silently guarded nothing. + const ctx = await harness(paths.taskFile) + const agent = await run(ctx, [{ name: 'write', args: { file_path: paths.taskFile } }]) + expect(results(agent)[0]?.text).toContain('on branch task/x') + }) + + it('allows a write in a SIBLING checkout of the same repository on another branch', async () => { + const paths = await fixture() + // A stale staging worktree left by an earlier install shares the + // repository but is not the live deployment, so the workflow rule the + // guard enforces does not apply to it. + const ctx = await harness(paths.siblingFile) + const agent = await run(ctx, [{ name: 'write', args: { file_path: paths.stagingFile } }]) + expect(results(agent)).toEqual([{ isError: false, text: 'ok' }]) + }) + + it('gates only the configured tools', async () => { + const paths = await fixture() + const ctx = await harness(paths.stagingFile, { tools: ['edit'] }) + const agent = await run(ctx, [ + { name: 'write', args: { file_path: paths.stagingFile } }, + { name: 'edit', args: { file_path: paths.stagingFile } }, + ]) + expect(results(agent).map(result => result.isError)).toEqual([false, true]) + }) +}) + +describe('skill satisfaction', () => { + it('allows the write after a successful load of the required skill in the same turn', async () => { + const paths = await fixture() + const ctx = await harness(paths.stagingFile) + const agent = await run(ctx, [ + { name: 'skill', args: { name: 'dsh-customize' } }, + { name: 'write', args: { file_path: paths.stagingFile } }, + ]) + expect(results(agent)).toEqual([ + { isError: false, text: 'ok' }, + { isError: false, text: 'ok' }, + ]) + }) + + it('allows the write when the skill load is only in the REPLAYED log, so resume keeps satisfaction', async () => { + const paths = await fixture() + const ctx = await harness(paths.stagingFile) + const seed = priorSkillCalls([{ arguments: JSON.stringify({ name: 'dsh-customize' }) }]) + const agent = await resume(ctx, 'resumed', seed, paths.stagingFile) + expect(results(agent).at(-1)).toEqual({ isError: false, text: 'ok' }) + }) + + it('does not accept a failed skill load', async () => { + const paths = await fixture() + const ctx = await harness(paths.stagingFile) + const seed = priorSkillCalls([{ arguments: JSON.stringify({ name: 'dsh-customize' }), isError: true }]) + const agent = await resume(ctx, 'failed', seed, paths.stagingFile) + expect(results(agent).at(-1)?.isError).toBe(true) + }) + + it('does not accept a different skill', async () => { + const paths = await fixture() + const ctx = await harness(paths.stagingFile) + const agent = await run(ctx, [ + { name: 'skill', args: { name: 'dsh-upgrade' } }, + { name: 'write', args: { file_path: paths.stagingFile } }, + ]) + expect(results(agent).map(result => result.isError)).toEqual([false, true]) + }) + + it('does not accept a skill call whose arguments are not a JSON object naming a string', async () => { + const paths = await fixture() + const ctx = await harness(paths.stagingFile) + const seed = priorSkillCalls([ + { arguments: 'not json' }, + { arguments: '[]' }, + { arguments: '{"name":7}' }, + { arguments: 'null' }, + ]) + const agent = await resume(ctx, 'malformed', seed, paths.stagingFile) + expect(results(agent).at(-1)?.isError).toBe(true) + }) + + it('honours a configured skill name other than the default', async () => { + const paths = await fixture() + const ctx = await harness(paths.stagingFile, { requiredSkill: 'other-skill' }) + const agent = await run(ctx, [ + { name: 'skill', args: { name: 'other-skill' } }, + { name: 'write', args: { file_path: paths.stagingFile } }, + ]) + expect(results(agent).map(result => result.isError)).toEqual([false, false]) + }) +}) + +describe('non-agent callers', () => { + it('leaves a direct registry call ungated, having no session to replay', async () => { + const paths = await fixture() + const ctx = await harness(paths.stagingFile) + const result = await ctx.tools.execute({ + callId: CallId('direct'), + name: 'write', + arguments: { file_path: paths.stagingFile }, + signal: new AbortController().signal, + }) + expect(result.isError).toBe(false) + }) +}) + +describe('config validation', () => { + it.each([ + ['tools', { tools: [] }, '`tools` must not be empty'], + ['requiredSkill', { requiredSkill: ' ' }, '`requiredSkill` must not be blank'], + ['protectedCheckout', { protectedCheckout: 'relative/path' }, '`protectedCheckout` must be an absolute path'], + ])('rejects an invalid %s at load', async (_field, config, message) => { + const ctx = new Context() + await mountAgentLoopTestDependencies(ctx) + await ctx.plugin(LocalFileSystem, {}) + await expect(ctx.plugin(SourceGuard, config as Config)).rejects.toThrow(message) + }) +}) + +describe('disposal', () => { + it('stops gating once the plugin fiber is disposed', async () => { + const paths = await fixture() + const ctx = new Context() + await mountAgentLoopTestDependencies(ctx) + await ctx.plugin(LocalFileSystem, {}) + await ctx.plugin(AgentLoop, { agents: [] }) + const fiber = await ctx.plugin(SourceGuard, { protectedCheckout: paths.stagingFile }) + for (const name of ['write', 'skill']) { + ctx.tools.register(defineContentToolFixture({ + name, + description: name, + parameters: { file_path: { type: 'string' }, name: { type: 'string' } }, + async execute() { return [{ type: 'text', text: 'ok' }] }, + })) + } + await fiber.dispose() + const agent = await run(ctx, [{ name: 'write', args: { file_path: paths.stagingFile } }]) + expect(results(agent)).toEqual([{ isError: false, text: 'ok' }]) + }) +}) diff --git a/packages/guard/source-guard/tsconfig.json b/packages/guard/source-guard/tsconfig.json new file mode 100644 index 0000000000..133b7fefdf --- /dev/null +++ b/packages/guard/source-guard/tsconfig.json @@ -0,0 +1,42 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../../../vendor/cosmokit" + }, + { + "path": "../../../vendor/cordis" + }, + { + "path": "../../../vendor/schemastery" + }, + { + "path": "../../core/tools" + }, + { + "path": "../../core/agent" + }, + { + "path": "../../core/session" + }, + { + "path": "../../fs/fs" + }, + { + "path": "../../llm/llm" + }, + { + "path": "../../sandbox/sandbox" + }, + { + "path": "../../support/invariants" + } + ] +} diff --git a/packages/session-registry/README.i18n.yaml b/packages/session-registry/README.i18n.yaml new file mode 100644 index 0000000000..6eb22cf334 --- /dev/null +++ b/packages/session-registry/README.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write packages/session-registry/README.md +README.md: c79caccd05d6cbdda0663dd897490fb6004b8250 +README.zh.md: c3fff3bc6b0e0417dd291d129d7fb1001b50258f diff --git a/packages/session-registry/README.md b/packages/session-registry/README.md new file mode 100644 index 0000000000..c79caccd05 --- /dev/null +++ b/packages/session-registry/README.md @@ -0,0 +1,15 @@ +# session-registry/ — live-session registry family + +English | [中文](README.zh.md) + +Which sessions are running right now, readable from a different process. `dsh list-sessions` is the consumer. + +| Package | Role | ctx key | +|---|---|---| +| [`session-registry/`](session-registry/README.md) | The seam: abstract registry service contract and record vocabulary | `ctx.sessionRegistry` | +| [`session-registry-file/`](session-registry-file/README.md) | Backend: one lock-guarded JSON file, pid-derived liveness | — | +| [`session-registry-live/`](session-registry-live/README.md) | Publisher: follows session lifecycle and title events, keeping the registry in step | — | + +The split follows the three-package capability-seam convention: the seam answers "what is live" for a short-lived reader that mounts nothing else, the file backend owns today's medium and can be replaced by a database without touching consumers, and the publisher needs the session store and runs inside a full agent composition. Liveness is derived from the recorded pid at read time rather than stored, so a killed process leaves nothing to clean up. Records carry their own title because log location, format, and compression are per-deployment backend choices an independent reader cannot portably parse. + +This family is independent of session persistence: it records which processes hold which sessions, never conversation content, and a session that is never persisted still lists. diff --git a/packages/session-registry/README.zh.md b/packages/session-registry/README.zh.md new file mode 100644 index 0000000000..c3fff3bc6b --- /dev/null +++ b/packages/session-registry/README.zh.md @@ -0,0 +1,15 @@ +# session-registry/:活跃会话注册表家族 + +[English](README.md) | 中文 + +当前正在运行哪些会话,可以从另一个进程读取。消费方是 `dsh list-sessions`。 + +| 包 | 职责 | ctx 键 | +|---|---|---| +| [`session-registry/`](session-registry/README.md) | seam:抽象注册表服务契约与记录词汇 | `ctx.sessionRegistry` | +| [`session-registry-file/`](session-registry-file/README.md) | 后端:单个加锁保护的 JSON 文件、由 pid 推导的存活状态 | — | +| [`session-registry-live/`](session-registry-live/README.md) | 发布方:跟随会话生命周期与标题事件,让注册表保持同步 | — | + +这样拆分遵循由三个包构成的能力 seam 惯例:seam 要回答「哪些会话是活跃的」,供一个不挂载其他任何东西的短生命周期读取方使用;文件后端拥有今天的介质,将来可以换成数据库而不触及消费方;发布方需要会话存储,运行在完整的 agent(智能体)组合体内。存活状态在读取时由记录的 pid 推导,而不是存下来,因此进程被杀掉后不留下任何需要清理的东西。记录自带标题,因为日志位置、格式和压缩都是各部署自行选择的后端方案,独立的读取方无法以可移植的方式解析。 + +这个家族与会话持久化相互独立:它只记录哪些进程持有哪些会话,绝不记录对话内容;从未被持久化的会话同样能被列出。 diff --git a/packages/session-registry/session-registry-file/README.i18n.yaml b/packages/session-registry/session-registry-file/README.i18n.yaml new file mode 100644 index 0000000000..d08b25d86f --- /dev/null +++ b/packages/session-registry/session-registry-file/README.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write packages/session-registry/session-registry-file/README.md +README.md: b6f29a459e5f7f6484aed9f4968d54f969ca6e73 +README.zh.md: 35861466ed5fb826ee118c506943c3a08024a827 diff --git a/packages/session-registry/session-registry-file/README.md b/packages/session-registry/session-registry-file/README.md new file mode 100644 index 0000000000..b6f29a459e --- /dev/null +++ b/packages/session-registry/session-registry-file/README.md @@ -0,0 +1,42 @@ +# @deepseek-ai/dsh-session-registry-file + +English | [中文](README.zh.md) + +File-backed implementation of the [live-session registry seam](../session-registry/README.md): one lock-guarded JSON file under the Harness home is the whole medium. Mounting it publishes `ctx.sessionRegistry`; `file` exposes the absolute registry path (`/sessions.json`). + +## Liveness and crash safety + +Liveness is derived at read time from the recorded pid via `kill(pid, 0)`: `ESRCH` is dead, `EPERM` is alive under another user, and any other errno propagates rather than being read as an answer. A process killed without running its disposer therefore leaves a record that the next `list()` prunes and rewrites — no daemon, no heartbeat, and no permanent phantom. `bootId` distinguishes a recycled pid, so deregistration cannot delete a namesake record belonging to a different incarnation. + +## Concurrency + +Both layers are required and neither substitutes for the other. + +- **Across processes**, each read-modify-write cycle holds a [`proper-lockfile`](https://github.com/moxystudio/node-proper-lockfile) advisory lock. Unlocked whole-file republication loses records under concurrent launchers, which is why the storage-hub JSON backend — documented last-write-wins, single-host-process — cannot serve this medium. +- **Within one process**, calls queue on an internal chain. The advisory lock is tracked per process, so overlapping same-process callers contend for its bounded retry budget instead of queueing; past roughly a dozen concurrent calls that budget runs out and a registration rejects. Callers publish fire-and-forget, so such a rejection would silently drop a live session from the listing. + +Writes are temp-file plus atomic `rename` (no fsync: a listing lost to a crash is rebuilt by the next process's read, so crash durability buys nothing here), under a `0o700` root with a `0o600` file. + +## Durable format + +`sessions.json` carries a `version` stamp pinned at `0` under the pre-release stance: a differing version is rejected rather than migrated. Reads validate every field because the medium is shared and user-visible. An individually unusable row is dropped while its siblings survive, and unparsable text or a foreign version reads as empty — one malformed record written by another harness version must not hide every other live session. Any of these marks the medium damaged, so the next write republishes and heals it. + +## Config + +| Key | Type | Default | Meaning | +| --- | --- | --- | --- | +| `root` | string | required — no default (a cwd fallback would scatter registries) | Directory holding `sessions.json`; created `0o700` on demand | +| `lockStaleMs` | natural | `10000` | Milliseconds after which a held lock is treated as abandoned and reclaimed | +| `lockRetries` | natural | `10` | Retries before a contended acquisition fails loud | + +## Model Experience + +None, as this package registers no tools, injects no prompts, and appends no session events; it stores host-side process records for the CLI listing surface only. + +#### KV Cache effect + +Independent of live requests: the registry never touches a request prefix, so nothing here can invalidate provider cache reuse. + +## Known Limitations and Deferred Work + +- **A reused pid within the stale window is trusted** — `bootId` distinguishes incarnations of records this process wrote, but a foreign record whose pid the operating system has since reassigned to an unrelated live process is reported alive until its owner removes it. diff --git a/packages/session-registry/session-registry-file/README.zh.md b/packages/session-registry/session-registry-file/README.zh.md new file mode 100644 index 0000000000..35861466ed --- /dev/null +++ b/packages/session-registry/session-registry-file/README.zh.md @@ -0,0 +1,42 @@ +# @deepseek-ai/dsh-session-registry-file + +[English](README.md) | 中文 + +[存活会话注册表 seam](../session-registry/README.md) 的文件后端实现:整套介质就是 Harness home 下的一个加锁保护的 JSON 文件。挂载它即发布 `ctx.sessionRegistry`;`file` 暴露注册表文件的绝对路径(`/sessions.json`)。 + +## 存活状态与崩溃安全 + +存活状态在读取时由记录的 pid 经 `kill(pid, 0)` 推导:`ESRCH` 表示已消亡,`EPERM` 表示存活于另一个用户之下,其他任何 errno 都向外抛出,而不会被当成一个答案来解读。因此,未运行 disposer 就被杀掉的进程留下的记录,会被下一次 `list()` 剪除并重写——不需要 daemon,不需要心跳,也不会有永久残留的幽灵记录。`bootId` 用于区分被复用的 pid,因此注销不会删除属于另一个 incarnation 的同名记录。 + +## 并发 + +两层机制都是必需的,任何一层都无法替代另一层。 + +- **跨进程**:每个读改写周期都持有 [`proper-lockfile`](https://github.com/moxystudio/node-proper-lockfile) 咨询锁。无锁的全文件重发布会在并发启动器下丢失记录,这正是 storage-hub JSON 后端(文档声明 last-write-wins、单宿主进程)无法承担该介质的原因。 +- **进程内**:调用在内部链上排队。咨询锁按进程跟踪,因此同进程的重叠调用者会争用其有限的重试预算而非排队;并发调用超过十来个时预算耗尽,注册会被拒绝。调用方以 fire-and-forget 方式发布,这样的拒绝会静默地把一个存活会话从列表中丢掉。 + +写入采用临时文件加原子 `rename`(不做 fsync:崩溃丢失的列表会被下一个进程的读取重建,崩溃持久性在这里没有收益),根目录 `0o700`,文件 `0o600`。 + +## 持久化格式 + +`sessions.json` 携带一个 `version` 戳,在预发布立场下固定为 `0`:版本不同将被拒绝而非迁移。由于介质是共享且用户可见的,读取会校验每个字段。单条不可用的行会被丢弃而其同伴保留;无法解析的文本或异版本文件读作空——另一个 harness 版本写入的一条损坏记录,不得隐藏所有其他存活会话。上述任一情况都会把介质标记为受损,下一次写入将重新发布并修复它。 + +## 配置 + +| 键 | 类型 | 默认值 | 含义 | +| --- | --- | --- | --- | +| `root` | string | 必填——无默认值(回退到 cwd 会使注册表散落各处) | 存放 `sessions.json` 的目录;按需以 `0o700` 创建 | +| `lockStaleMs` | natural | `10000` | 持有的锁超过该毫秒数即视为被遗弃并被回收 | +| `lockRetries` | natural | `10` | 锁争用时在明确失败前的重试次数 | + +## 模型体验 + +无。本包不注册工具、不注入提示词、不追加会话事件;它只为 CLI 列表界面存储宿主侧进程记录。 + +#### KV 缓存影响 + +与在途请求无关:注册表从不触碰请求前缀,因此这里不会使提供方缓存复用失效。 + +## 已知限制与后续工作 + +- **陈旧窗口内被复用的 pid 会被信任**——`bootId` 能区分本进程所写记录的 incarnation,但外来记录的 pid 若已被操作系统重新分配给无关的存活进程,在其属主移除之前会一直被报告为存活。 diff --git a/packages/session-registry/session-registry-file/package.json b/packages/session-registry/session-registry-file/package.json new file mode 100644 index 0000000000..321a59902d --- /dev/null +++ b/packages/session-registry/session-registry-file/package.json @@ -0,0 +1,47 @@ +{ + "name": "@deepseek-ai/dsh-session-registry-file", + "description": "Lock-guarded JSON-file backend for the dsh live-session registry seam", + "version": "0.0.1", + "private": true, + "type": "module", + "main": "lib/index.js", + "types": "lib/types/index.d.ts", + "exports": { + ".": { + "types": "./lib/types/index.d.ts", + "default": "./lib/index.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json", + "./invariant": { + "types": "./lib/types/invariant.d.ts", + "default": "./lib/invariant.js" + } + }, + "files": [ + "lib/index.js", + "lib/invariant.js", + "lib/types/**/*.d.ts", + "lib/types/**/*.d.ts.map", + "src" + ], + "license": "BSD-3-Clause", + "dependencies": { + "proper-lockfile": "^4.1.2", + "schemastery": "^3.15.0" + }, + "peerDependencies": { + "@deepseek-ai/dsh-invariants": "^0.0.1", + "@deepseek-ai/dsh-session": "^0.0.1", + "@deepseek-ai/dsh-session-registry": "^0.0.1", + "cordis": "^4.0.0-rc.6" + }, + "devDependencies": { + "@deepseek-ai/dsh-brand": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/dsh-session-registry": "workspace:^", + "@types/proper-lockfile": "^4.1.4", + "cordis": "^4.0.0-rc.6" + } +} diff --git a/packages/session-registry/session-registry-file/src/file.ts b/packages/session-registry/session-registry-file/src/file.ts new file mode 100644 index 0000000000..0c9a5775fb --- /dev/null +++ b/packages/session-registry/session-registry-file/src/file.ts @@ -0,0 +1,97 @@ +/** + * Registry file format: the durable boundary between independent `dsh` + * processes. Every field is validated on read because the medium is shared, + * user-visible, and writable by other harness versions — a foreign or truncated + * file must not crash `dsh list-sessions` into an empty listing that hides live sessions. + * @module @deepseek-ai/dsh-session-registry-file/file + */ + +import { SessionId } from '@deepseek-ai/dsh-session' +import { BootId, type SessionRegistryRecord } from '@deepseek-ai/dsh-session-registry' + +/** + * On-disk format version. Pinned at `0` under the pre-release stance: a + * differing version is rejected rather than migrated, matching every other + * harness backend. + */ +export const SESSION_REGISTRY_FORMAT_VERSION = 0 + +/** The complete registry file: a version stamp plus the live records. */ +export interface RegistryFileContents { + /** Format stamp, always {@link SESSION_REGISTRY_FORMAT_VERSION} when written. */ + readonly version: number + /** One record per registered process, in no significant order. */ + readonly records: readonly SessionRegistryRecord[] +} + +/** An empty registry: the value a missing file reads as. */ +export const EMPTY_REGISTRY: RegistryFileContents = { version: SESSION_REGISTRY_FORMAT_VERSION, records: [] } + +/** Narrow an unknown JSON value to a record shape, or reject it as unusable. */ +function parseRecord(value: unknown): SessionRegistryRecord | undefined { + if (typeof value !== 'object' || value === null) return undefined + const row = value as Record + const { sessionId, pid, cwd, startedAt, bootId } = row + if (typeof sessionId !== 'string' || sessionId === '') return undefined + // A non-integer or non-positive pid cannot be probed for liveness. + if (typeof pid !== 'number' || !Number.isSafeInteger(pid) || pid <= 0) return undefined + if (typeof cwd !== 'string' || cwd === '') return undefined + if (typeof startedAt !== 'number' || !Number.isSafeInteger(startedAt) || startedAt < 0) return undefined + if (typeof bootId !== 'string' || bootId === '') return undefined + // An absent title is legal (a fresh session has none); a present but + // non-string one is a damaged row rather than a missing optional field. + const { title } = row + if (title !== undefined && typeof title !== 'string') return undefined + return { + sessionId: SessionId(sessionId), + pid, + cwd, + startedAt, + bootId: BootId(bootId), + ...title !== undefined && { title }, + } +} + +/** + * Parse registry file text into records, dropping individually unusable rows. + * + * A row that cannot be interpreted is dropped rather than rejected wholesale: + * one malformed record written by a different harness version must not hide + * every other live session. Unparsable text and a version mismatch yield an + * empty registry for the same reason — the caller republishes the whole file, so + * the next write heals the medium. + * @param text - the raw file contents. + * @returns the records that parsed, and whether the text was fully understood. + */ +export function parseRegistry(text: string): { records: SessionRegistryRecord[]; intact: boolean } { + let parsed: unknown + try { + parsed = JSON.parse(text) + } catch { + // Swallows only SyntaxError from this one JSON.parse: a torn or foreign + // file heals on the next write, and nothing else can reach this catch. + return { records: [], intact: false } + } + if (typeof parsed !== 'object' || parsed === null) return { records: [], intact: false } + const file = parsed as Record + if (file.version !== SESSION_REGISTRY_FORMAT_VERSION) return { records: [], intact: false } + if (!Array.isArray(file.records)) return { records: [], intact: false } + const records: SessionRegistryRecord[] = [] + let intact = true + for (const row of file.records) { + const record = parseRecord(row) + if (record === undefined) intact = false + else records.push(record) + } + return { records, intact } +} + +/** + * Serialize records as registry file text. + * @param records - the live records to publish. + * @returns pretty-printed JSON with a trailing newline, for a legible medium. + */ +export function serializeRegistry(records: readonly SessionRegistryRecord[]): string { + const file: RegistryFileContents = { version: SESSION_REGISTRY_FORMAT_VERSION, records } + return `${JSON.stringify(file, undefined, 2)}\n` +} diff --git a/packages/session-registry/session-registry-file/src/index.ts b/packages/session-registry/session-registry-file/src/index.ts new file mode 100644 index 0000000000..41e080b79a --- /dev/null +++ b/packages/session-registry/session-registry-file/src/index.ts @@ -0,0 +1,233 @@ +/** + * File-backed live-session registry: one lock-guarded JSON file under the + * Harness home implements the `@deepseek-ai/dsh-session-registry` seam. Every + * operation is a read-modify-write under an advisory lock, because concurrent + * launchers write the same file — the storage-hub JSON backend documents + * last-write-wins for exactly this case and cannot be reused. Liveness is + * derived at read time from the recorded pid. + * @module @deepseek-ai/dsh-session-registry-file + */ + +import { randomUUID } from 'node:crypto' +import { mkdir, readFile, rename, writeFile, open } from 'node:fs/promises' +import { dirname, join } from 'node:path' +import type { Context } from 'cordis' +import lockfile from 'proper-lockfile' +import z from 'schemastery' +import type { SessionId } from '@deepseek-ai/dsh-session' +import { + SessionRegistry, BootId, + type SessionRegistration, type SessionRegistryRecord, +} from '@deepseek-ai/dsh-session-registry' +import { EMPTY_REGISTRY, parseRegistry, serializeRegistry } from './file.ts' +import { isPidAlive } from './liveness.ts' + +export { SESSION_REGISTRY_FORMAT_VERSION, parseRegistry, serializeRegistry } from './file.ts' +export type { RegistryFileContents } from './file.ts' +export { isPidAlive } from './liveness.ts' + +/** The file name holding the registry, relative to {@link Config.root}. */ +export const REGISTRY_FILE_NAME = 'sessions.json' + +/** Default lock staleness threshold; a held lock older than this is reclaimed. */ +const DEFAULT_LOCK_STALE_MS = 10_000 + +/** Default retry budget for a contended lock acquisition. */ +const DEFAULT_LOCK_RETRIES = 10 + +/** + * Plugin config as callers write it: `root` is required — a cwd fallback would + * scatter registries — while the lock tunables are optional because + * `static Config` supplies their defaults. + */ +export interface Config { + /** Directory holding the registry file; created `0o700` on demand. */ + root: string + /** Milliseconds after which a held lock is considered abandoned and reclaimed. */ + lockStaleMs?: number + /** Retries before a contended acquisition fails loud. */ + lockRetries?: number +} + +/** The file-backed {@link SessionRegistry} implementation. */ +export class SessionRegistryFile extends SessionRegistry { + static Config: z = z.object({ + root: z.string().required(), + lockStaleMs: z.natural().default(DEFAULT_LOCK_STALE_MS), + lockRetries: z.natural().default(DEFAULT_LOCK_RETRIES), + }) + + /** Absolute path of the registry file this service reads and writes. */ + readonly file: string + + /** Directory holding {@link file}, created `0o700` on demand. */ + private readonly root: string + + /** Tail of the in-process serialization chain; see {@link mutate}. */ + private chain: Promise = Promise.resolve() + + /** Resolved lock staleness threshold in milliseconds, fixed at construction. */ + private readonly stale: number + + /** Resolved contended-acquisition retry budget, fixed at construction. */ + private readonly retries: number + + constructor(ctx: Context, config: Config) { + super(ctx, BootId(randomUUID())) + this.root = config.root + this.file = join(this.root, REGISTRY_FILE_NAME) + // Resolve the optional tunables here, once: `static Config` supplies these + // same defaults for a Loader mount, and a direct programmatic mount that + // omits them gets them too rather than an undefined lock option. + this.stale = config.lockStaleMs ?? DEFAULT_LOCK_STALE_MS + this.retries = config.lockRetries ?? DEFAULT_LOCK_RETRIES + } + + /** @inheritdoc */ + async register(registration: SessionRegistration): Promise<() => Promise> { + const record: SessionRegistryRecord = { + sessionId: registration.sessionId, + pid: process.pid, + cwd: registration.cwd, + startedAt: Date.now(), + bootId: this.bootId, + ...registration.title !== undefined && { title: registration.title }, + } + await this.mutate(records => [ + ...records.filter(other => other.sessionId !== record.sessionId), + record, + ]) + // The disposer is awaited by Cordis teardown, so the record is durably gone + // before disposal completes rather than racing process exit. A failure here + // is reported, not thrown: the record is already pid-prunable, and an + // unwinding teardown must not be turned into a rejection. + return this.ctx.effect(() => async () => { + try { + await this.mutate(records => records.filter(other => !this.isSelf(other, record))) + } catch (error) { + this.ctx.logger.warn('failed to deregister %s: %s', record.sessionId, String(error)) + } + }) + } + + /** @inheritdoc */ + async retitle(sessionId: SessionId, title: string): Promise { + await this.mutate(records => records.map(record => + record.sessionId === sessionId && record.pid === process.pid && record.bootId === this.bootId + ? { ...record, title } + : record)) + } + + /** @inheritdoc */ + async list(): Promise { + // Pruning is a write, so the read path takes the same lock: a listing that + // observed a half-written file could omit a live session. + return this.mutate(records => [...records]) + } + + /** True when a stored record is this exact registration (pid AND incarnation). */ + private isSelf(candidate: SessionRegistryRecord, self: SessionRegistryRecord): boolean { + return candidate.sessionId === self.sessionId + && candidate.pid === self.pid + && candidate.bootId === self.bootId + } + + /** + * Serialize one read-modify-write cycle against every other cycle in THIS + * process, then run it under the cross-process lock. + * + * Both layers are required and neither substitutes for the other. The advisory + * lock excludes other processes but is tracked per process, so it rejects a + * same-process concurrent acquisition outright (`ELOCKED`) instead of queueing + * — and a composition that creates several sessions at once really does + * overlap these calls. This chain gives those callers a queue; the lock gives + * independent processes exclusion. + */ + private mutate( + change: (records: readonly SessionRegistryRecord[]) => SessionRegistryRecord[], + ): Promise { + // Failures must not poison the chain for later callers, so the tail only + // tracks settlement, never the rejection itself. + const result = this.chain.then(() => this.mutateExclusively(change)) + this.chain = result.then(() => undefined, () => undefined) + return result + } + + /** + * Run one locked read-modify-write cycle: read, prune dead records, apply + * `change`, and republish when the result differs from what was stored. + */ + private async mutateExclusively( + change: (records: readonly SessionRegistryRecord[]) => SessionRegistryRecord[], + ): Promise { + await mkdir(this.root, { recursive: true, mode: 0o700 }) + // proper-lockfile needs the target to exist before it can guard it; an + // exclusive create loses harmlessly to a concurrent launcher doing the same. + await this.ensureFile() + const release = await lockfile.lock(this.file, { + stale: this.stale, + retries: { retries: this.retries, minTimeout: 20, maxTimeout: 500 }, + }) + try { + const before = await this.read() + const live = before.records.filter(record => isPidAlive(record.pid)) + const next = change(live) + // Republish when a record changed or the medium itself was damaged, so a + // foreign or torn file heals instead of being re-parsed on every read. + if (!before.intact || !sameRecords(before.records, next)) await this.write(next) + return next + } finally { + await release() + } + } + + /** Create the registry file if absent, without disturbing existing content. */ + private async ensureFile(): Promise { + try { + const handle = await open(this.file, 'wx', 0o600) + try { + await handle.writeFile(serializeRegistry(EMPTY_REGISTRY.records)) + } finally { + await handle.close() + } + } catch (error) { + // Swallows only EEXIST: another launcher created the file first, which is + // the intended outcome. Every other errno propagates. + /* v8 ignore next -- a non-EEXIST create failure needs a permission or IO fault on a root this cycle just created 0o700. */ + if ((error as NodeJS.ErrnoException).code !== 'EEXIST') throw error + } + } + + /** Read and parse the registry file; a missing file reads as empty. */ + private async read(): Promise<{ records: SessionRegistryRecord[]; intact: boolean }> { + // The caller holds the lock, and acquiring it requires the file to exist, so + // a read failure here is real corruption rather than an absent registry and + // propagates: a swallowed error would report "no live sessions" for a medium + // that could not be read. + return parseRegistry(await readFile(this.file, 'utf8')) + } + + /** Publish the complete record set via temp-write plus atomic rename. */ + private async write(records: readonly SessionRegistryRecord[]): Promise { + const temp = join(dirname(this.file), `.${REGISTRY_FILE_NAME}.${process.pid}.${randomUUID()}.tmp`) + await writeFile(temp, serializeRegistry(records), { mode: 0o600 }) + await rename(temp, this.file) + } +} + +/** Compare record lists by identity fields, to decide whether a write is needed. */ +function sameRecords(left: readonly SessionRegistryRecord[], right: readonly SessionRegistryRecord[]): boolean { + if (left.length !== right.length) return false + return left.every((record, index) => { + const other = right[index] + return other !== undefined + && record.sessionId === other.sessionId + && record.pid === other.pid + && record.bootId === other.bootId + && record.cwd === other.cwd + && record.startedAt === other.startedAt + && record.title === other.title + }) +} + +export default SessionRegistryFile diff --git a/packages/session-registry/session-registry-file/src/invariant.ts b/packages/session-registry/session-registry-file/src/invariant.ts new file mode 100644 index 0000000000..9fd175abc4 --- /dev/null +++ b/packages/session-registry/session-registry-file/src/invariant.ts @@ -0,0 +1,32 @@ +/** + * Package-owned invariant companion for `@deepseek-ai/dsh-session-registry-file`. + * @module @deepseek-ai/dsh-session-registry-file/invariant + */ + +import type { Context } from 'cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-session-registry-file' + +/** Cordis companion plugin name. */ +export const name = 'session-registry-file-invariant' +/** Service required before the companion can reserve package ownership. */ +export const inject = ['invariants'] + +/** + * No runtime invariant: the relations a reader must trust (unique live session + * ids, attributable pids) are contract-level and validated by the seam's + * companion around the authoritative `list()`, whatever backend serves it. The + * file medium's own correctness — locking, atomic republication, and + * foreign-row rejection — requires cross-process round-trip tests, not a + * continuously observable in-process relation. + */ +const install: InvariantInstaller = () => {} + +/** + * Register this package's invariant companion. + * @param ctx - Cordis context carrying the invariant service. + * @returns the installed registration's disposer after setup succeeds. + */ +export const apply = (ctx: Context): Promise<() => void> => + Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) diff --git a/packages/session-registry/session-registry-file/src/liveness.ts b/packages/session-registry/session-registry-file/src/liveness.ts new file mode 100644 index 0000000000..ff7421edd9 --- /dev/null +++ b/packages/session-registry/session-registry-file/src/liveness.ts @@ -0,0 +1,30 @@ +/** + * Process-liveness probe for stored registry records. + * @module @deepseek-ai/dsh-session-registry-file/liveness + */ + +/** + * Signal-0 probe: report whether a pid currently exists. + * + * `kill(pid, 0)` sends no signal and only tests existence. `ESRCH` means no such + * process. `EPERM` means the process exists but is owned by another user, which + * is still alive — reporting it dead would drop a live record. Any other errno + * is unexpected and propagates rather than being read as a liveness answer. + * @param pid - the operating-system process id to probe. + * @param kill - signal sender, defaulting to `process.kill`; injected by tests. + * @returns whether a process with this pid exists. + */ +export function isPidAlive( + pid: number, + kill: (pid: number, signal: number) => void = process.kill.bind(process), +): boolean { + try { + kill(pid, 0) + return true + } catch (error) { + const code = (error as NodeJS.ErrnoException).code + if (code === 'ESRCH') return false + if (code === 'EPERM') return true + throw error + } +} diff --git a/packages/session-registry/session-registry-file/tests/fixtures/register-once.ts b/packages/session-registry/session-registry-file/tests/fixtures/register-once.ts new file mode 100644 index 0000000000..56e05d11fd --- /dev/null +++ b/packages/session-registry/session-registry-file/tests/fixtures/register-once.ts @@ -0,0 +1,27 @@ +/** + * Concurrency-test driver: register one session in a real separate process, + * report readiness on stdout, then stay alive until the parent closes stdin. + * + * Staying alive is load-bearing. The registry prunes records whose process is + * gone, so a driver that exited after writing would be pruned by the next + * writer — the test would then measure pruning instead of the concurrent + * read-modify-write it exists to cover. Argv: ` `. + */ + +import { Context } from 'cordis' +import { SessionId } from '@deepseek-ai/dsh-session' +import SessionRegistryFile from '@deepseek-ai/dsh-session-registry-file' + +const [root, sessionId] = process.argv.slice(2) +if (root === undefined || sessionId === undefined) throw new Error('usage: register-once ') + +const ctx = new Context() +await ctx.plugin(SessionRegistryFile, { root, lockStaleMs: 10_000, lockRetries: 60 }) +await ctx.sessionRegistry.register({ sessionId: SessionId(sessionId), cwd: process.cwd() }) +process.stdout.write('registered\n') + +// Hold the process open so its record stays live; the parent ends the run by +// closing stdin, and never disposes the fiber, so no deregistration races the +// parent's read. +process.stdin.resume() +process.stdin.on('end', () => { process.exit(0) }) diff --git a/packages/session-registry/session-registry-file/tests/session-registry-file.spec.ts b/packages/session-registry/session-registry-file/tests/session-registry-file.spec.ts new file mode 100644 index 0000000000..e48bd46385 --- /dev/null +++ b/packages/session-registry/session-registry-file/tests/session-registry-file.spec.ts @@ -0,0 +1,382 @@ +/** + * Tests for the cross-process live-session registry: records survive a + * round-trip, dead pids are pruned, a recycled pid cannot resurrect a foreign + * record, the file format rejects foreign and torn media without hiding live + * sessions, disposal deregisters, and concurrent registrations from independent + * processes all survive (the failure the advisory lock exists to prevent). + */ + +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { Context } from 'cordis' +import { mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs' +import { execFile, spawn } from 'node:child_process' +import { tmpdir } from 'node:os' +import { fileURLToPath } from 'node:url' +import { join } from 'node:path' +import { promisify } from 'node:util' +import { SessionId } from '@deepseek-ai/dsh-session' +import { BootId } from '@deepseek-ai/dsh-session-registry' +import SessionRegistryFile, { + REGISTRY_FILE_NAME, + SESSION_REGISTRY_FORMAT_VERSION, + isPidAlive, + parseRegistry, + serializeRegistry, +} from '@deepseek-ai/dsh-session-registry-file' + +const run = promisify(execFile) + +let root: string + +beforeEach(() => { + root = mkdtempSync(join(tmpdir(), 'dsh-session-registry-test-')) +}) +afterEach(() => { + rmSync(root, { recursive: true, force: true }) +}) + +/** Mount the service on a fresh Cordis fiber, returning it with its context. */ +async function service(): Promise<{ ctx: Context; registry: SessionRegistryFile }> { + const ctx = new Context() + await ctx.plugin(SessionRegistryFile, { root }) + return { ctx, registry: ctx.sessionRegistry as SessionRegistryFile } +} + +const file = (): string => join(root, REGISTRY_FILE_NAME) + +describe('config resolution', () => { + it('applies the shipped lock defaults when a caller omits them', async () => { + // `ctx.plugin` runs the schema, which fills these in, so the constructor's + // own resolution is reachable only by constructing the service directly — + // the path a programmatic embedder takes. + const ctx = new Context() + const service = new SessionRegistryFile(ctx, { root }) + await service.register({ sessionId: SessionId('defaulted'), cwd: '/w' }) + expect((await service.list()).map(record => record.sessionId)).toEqual(['defaulted']) + await ctx.fiber.dispose() + }) + + it('honors explicitly configured lock tunables', async () => { + const ctx = new Context() + await ctx.plugin(SessionRegistryFile, { root, lockStaleMs: 5_000, lockRetries: 3 }) + await ctx.sessionRegistry.register({ sessionId: SessionId('tuned'), cwd: '/w' }) + expect((await ctx.sessionRegistry.list()).map(record => record.sessionId)).toEqual(['tuned']) + await ctx.fiber.dispose() + }) +}) + +describe('register and list', () => { + it('publishes a record readable by an independent service instance', async () => { + const first = await service() + await first.registry.register({ sessionId: SessionId('sess-1'), cwd: '/tmp/project' }) + + // A second instance stands in for another process reading the same file. + const reader = await service() + const listed = await reader.registry.list() + expect(listed).toHaveLength(1) + expect(listed[0]).toMatchObject({ + sessionId: 'sess-1', + cwd: '/tmp/project', + pid: process.pid, + }) + await first.ctx.fiber.dispose() + await reader.ctx.fiber.dispose() + }) + + it('replaces an earlier record for the same session id', async () => { + const { ctx, registry } = await service() + await registry.register({ sessionId: SessionId('sess-1'), cwd: '/a' }) + await registry.register({ sessionId: SessionId('sess-1'), cwd: '/b' }) + const listed = await registry.list() + expect(listed).toHaveLength(1) + // The later registration wins: `cwd` distinguishes the two calls. + expect(listed[0]?.cwd).toBe('/b') + await ctx.fiber.dispose() + }) + + it('creates the registry root private and the file owner-only', async () => { + const { ctx, registry } = await service() + await registry.register({ sessionId: SessionId('sess-1'), cwd: '/a' }) + expect(statSync(root).mode & 0o777).toBe(0o700) + expect(statSync(file()).mode & 0o777).toBe(0o600) + await ctx.fiber.dispose() + }) +}) + +describe('liveness pruning', () => { + it('drops a record whose process is gone', async () => { + const { ctx, registry } = await service() + await registry.register({ sessionId: SessionId('live'), cwd: '/a' }) + + // A real exited pid: spawn a process, wait for it, then claim its id. The + // kernel has reaped it, so signal 0 reports ESRCH. + const dead = await run(process.execPath, ['-e', 'process.stdout.write(String(process.pid))']) + const deadPid = Number(dead.stdout) + expect(isPidAlive(deadPid)).toBe(false) + const stored = parseRegistry(readFileSync(file(), 'utf8')).records + writeFileSync(file(), serializeRegistry([ + ...stored, + { sessionId: SessionId('ghost'), pid: deadPid, cwd: '/b', startedAt: 1, bootId: BootId('boot-x') }, + ])) + + const listed = await registry.list() + expect(listed.map(record => record.sessionId)).toEqual(['live']) + // The prune is durable, not just filtered in memory. + expect(parseRegistry(readFileSync(file(), 'utf8')).records.map(r => r.sessionId)).toEqual(['live']) + await ctx.fiber.dispose() + }) + + it('keeps a live record owned by another user (EPERM means alive)', () => { + const eperm = (): never => { + const error = new Error('operation not permitted') as NodeJS.ErrnoException + error.code = 'EPERM' + throw error + } + expect(isPidAlive(1, eperm)).toBe(true) + }) + + it('propagates an unexpected errno instead of guessing liveness', () => { + const einval = (): never => { + const error = new Error('invalid') as NodeJS.ErrnoException + error.code = 'EINVAL' + throw error + } + expect(() => isPidAlive(1, einval)).toThrow('invalid') + }) +}) + +describe('pid recycling', () => { + it('deregistration removes only this incarnation, not a namesake pid', async () => { + const { ctx, registry } = await service() + const disposer = await registry.register({ sessionId: SessionId('mine'), cwd: '/a' }) + + // A foreign record reusing THIS live pid under a different session and boot + // id: deregistering must not delete it. + const stored = parseRegistry(readFileSync(file(), 'utf8')).records + writeFileSync(file(), serializeRegistry([ + ...stored, + { sessionId: SessionId('other'), pid: process.pid, cwd: '/b', startedAt: 2, bootId: BootId('boot-other') }, + ])) + + // Awaiting the disposer is the contract: the record is durably gone when it + // settles, so the assertion needs no timing slack. + await disposer() + const listed = await registry.list() + expect(listed.map(record => record.sessionId)).toEqual(['other']) + await ctx.fiber.dispose() + }) +}) + +describe('file format', () => { + it('round-trips records', () => { + const records = [{ + sessionId: SessionId('s'), pid: 5 as const, cwd: '/c', startedAt: 7, bootId: BootId('b'), + }] + expect(parseRegistry(serializeRegistry(records))).toEqual({ records, intact: true }) + }) + + it('stamps the format version', () => { + const stamped = JSON.parse(serializeRegistry([])) as { version: number } + expect(stamped.version).toBe(SESSION_REGISTRY_FORMAT_VERSION) + }) + + it.each([ + ['torn json', '{"version":0,"records":[{'], + ['a foreign version', '{"version":99,"records":[]}'], + ['a non-object root', '[]'], + ['a null root', 'null'], + ['a non-array records field', '{"version":0,"records":{}}'], + ])('reads %s as an empty, non-intact registry', (_label, text) => { + expect(parseRegistry(text)).toEqual({ records: [], intact: false }) + }) + + it.each([ + ['a missing session id', { pid: 1, cwd: '/a', startedAt: 0, bootId: 'b' }], + ['a non-integer pid', { sessionId: 's', pid: 1.5, cwd: '/a', startedAt: 0, bootId: 'b' }], + ['a non-positive pid', { sessionId: 's', pid: 0, cwd: '/a', startedAt: 0, bootId: 'b' }], + ['an empty cwd', { sessionId: 's', pid: 1, cwd: '', startedAt: 0, bootId: 'b' }], + ['a negative startedAt', { sessionId: 's', pid: 1, cwd: '/a', startedAt: -1, bootId: 'b' }], + ['a missing boot id', { sessionId: 's', pid: 1, cwd: '/a', startedAt: 0 }], + ['a non-string title', { sessionId: 's', pid: 1, cwd: '/a', startedAt: 0, bootId: 'b', title: 7 }], + ['a non-object row', 'nonsense'], + ])('drops a row with %s but keeps its intact siblings', (_label, row) => { + const good = { sessionId: 'keep', pid: 1, cwd: '/a', startedAt: 0, bootId: 'b' } + const text = JSON.stringify({ version: SESSION_REGISTRY_FORMAT_VERSION, records: [row, good] }) + const parsed = parseRegistry(text) + expect(parsed.records.map(record => record.sessionId)).toEqual(['keep']) + expect(parsed.intact).toBe(false) + }) + + it('heals a damaged medium on the next locked write', async () => { + writeFileSync(file(), 'not json at all') + const { ctx, registry } = await service() + await registry.list() + expect(parseRegistry(readFileSync(file(), 'utf8')).intact).toBe(true) + await ctx.fiber.dispose() + }) + + it('reads a missing file as no live sessions', async () => { + const { ctx, registry } = await service() + rmSync(file(), { force: true }) + expect(await registry.list()).toEqual([]) + await ctx.fiber.dispose() + }) + +}) + +describe('failure reporting', () => { + it('tolerates a registry file another process created first', async () => { + // Two services racing `ensureFile`: the loser sees EEXIST, which is the + // intended outcome rather than an error, and both still publish. + const first = await service() + const second = await service() + await Promise.all([ + first.registry.register({ sessionId: SessionId('a'), cwd: '/a' }), + second.registry.register({ sessionId: SessionId('b'), cwd: '/b' }), + ]) + expect((await first.registry.list()).map(record => record.sessionId).sort()).toEqual(['a', 'b']) + await first.ctx.fiber.dispose() + await second.ctx.fiber.dispose() + }) + + it('warns instead of throwing when deregistration fails during teardown', async () => { + const { ctx, registry } = await service() + await registry.register({ sessionId: SessionId('doomed'), cwd: '/w' }) + // Make the registry path unusable, so the disposer's own write fails while the + // fiber is already unwinding. Teardown must still complete. + rmSync(root, { recursive: true, force: true }) + mkdirSync(join(root, REGISTRY_FILE_NAME), { recursive: true }) + await expect(ctx.fiber.dispose()).resolves.not.toThrow() + }) + + it('propagates a read failure that is not a missing file', async () => { + const { ctx, registry } = await service() + await registry.register({ sessionId: SessionId('sess-1'), cwd: '/w' }) + // A directory where the file belongs makes the read fail with EISDIR, which + // is corruption rather than "no live sessions" and must not read as empty. + rmSync(file(), { force: true }) + mkdirSync(file(), { recursive: true }) + await expect(registry.list()).rejects.toThrow() + rmSync(file(), { recursive: true, force: true }) + await ctx.fiber.dispose() + }) +}) + +describe('retitle', () => { + it('replaces the recorded title of a session this process owns', async () => { + const { ctx, registry } = await service() + await registry.register({ sessionId: SessionId('sess-1'), cwd: '/a' }) + expect((await registry.list())[0]?.title).toBeUndefined() + + await registry.retitle(SessionId('sess-1'), 'first') + expect((await registry.list())[0]?.title).toBe('first') + await registry.retitle(SessionId('sess-1'), 'second') + expect((await registry.list())[0]?.title).toBe('second') + await ctx.fiber.dispose() + }) + + it('accepts a registration that already carries a title', async () => { + const { ctx, registry } = await service() + await registry.register({ sessionId: SessionId('sess-1'), cwd: '/a', title: 'preset' }) + expect((await registry.list())[0]?.title).toBe('preset') + await ctx.fiber.dispose() + }) + + it('leaves a same-id record owned by another incarnation untouched', async () => { + const { ctx, registry } = await service() + // Same live pid, different boot id: another incarnation's record must not be + // retitled by this one. + writeFileSync(file(), serializeRegistry([ + { sessionId: SessionId('foreign'), pid: process.pid, cwd: '/b', startedAt: 2, bootId: BootId('boot-other') }, + ])) + await registry.retitle(SessionId('foreign'), 'not mine') + expect((await registry.list())[0]?.title).toBeUndefined() + await ctx.fiber.dispose() + }) + + it('ignores an unknown session id, since a title can resolve after removal', async () => { + const { ctx, registry } = await service() + await expect(registry.retitle(SessionId('never-registered'), 'ghost')).resolves.toBeUndefined() + expect(await registry.list()).toEqual([]) + await ctx.fiber.dispose() + }) +}) + +describe('same-process concurrency', () => { + it('keeps every record when one process registers several sessions at once', async () => { + // The advisory lock is tracked per process, so same-process callers contend + // for it through its bounded retry budget instead of queueing. Past a dozen + // or so overlapping calls that budget runs out and a registration rejects — + // and callers publish fire-and-forget, so the rejection is swallowed and the + // session silently vanishes from the listing. The service therefore + // serializes its own callers; the lock only excludes other processes. + const { ctx, registry } = await service() + // Register once first so the file and directory already exist: without that, + // the concurrent calls serialize behind their own mkdir/create awaits and the + // overlap under test never happens. + await registry.register({ sessionId: SessionId('warm'), cwd: '/w' }) + const settled = await Promise.allSettled(Array.from({ length: 24 }, (_unused, index) => + registry.register({ sessionId: SessionId(`bulk-${String(index)}`), cwd: `/w/${String(index)}` }))) + + // Every call must SUCCEED, not merely leave the file consistent. Callers + // publish fire-and-forget, so a rejection is swallowed and the session + // silently vanishes from the listing rather than failing loudly. + expect(settled.filter(outcome => outcome.status === 'rejected')).toEqual([]) + const expected = [...Array.from({ length: 24 }, (_unused, index) => `bulk-${String(index)}`), 'warm'].sort() + expect((await registry.list()).map(record => record.sessionId).sort()).toEqual(expected) + await ctx.fiber.dispose() + }) + + it('keeps serving later callers after one cycle fails', async () => { + const { ctx, registry } = await service() + // A directory sitting where the registry file must be makes one cycle fail + // without breaking the shared chain for the calls queued behind it. + rmSync(root, { recursive: true, force: true }) + mkdirSync(join(root, REGISTRY_FILE_NAME), { recursive: true }) + await expect(registry.register({ sessionId: SessionId('doomed'), cwd: '/w' })).rejects.toThrow() + + rmSync(root, { recursive: true, force: true }) + await registry.register({ sessionId: SessionId('after'), cwd: '/w' }) + expect((await registry.list()).map(record => record.sessionId)).toEqual(['after']) + await ctx.fiber.dispose() + }) +}) + +describe('cross-process concurrency', () => { + it('keeps every record when independent processes register at once', async () => { + // The regression that motivates the advisory lock: unlocked whole-file + // republication loses records under concurrent writers. Real processes are + // required — same-process promises would serialize on the event loop. + const driver = fileURLToPath(new URL('./fixtures/register-once.ts', import.meta.url)) + const count = 8 + const repoRoot = fileURLToPath(new URL('../../../../', import.meta.url)) + const tsx = join(repoRoot, 'node_modules/tsx/dist/loader.mjs') + // Source plane: tsx resolves the workspace import through the root + // tsconfig `paths` to `src`, so this runs without a build step. + const env = { ...process.env, TSX_TSCONFIG_PATH: join(repoRoot, 'tsconfig.json') } + + const children = Array.from({ length: count }, (_unused, index) => + spawn(process.execPath, ['--import', tsx, driver, root, `sess-${String(index)}`], { + env, + stdio: ['pipe', 'pipe', 'inherit'], + })) + try { + // Every child must have committed its record AND still be alive when the + // file is read, so the assertion sees concurrent writes rather than prunes. + await Promise.all(children.map(child => new Promise((resolve, reject) => { + child.stdout.once('data', () => { resolve() }) + child.once('error', reject) + child.once('exit', (code) => { reject(new Error(`driver exited early with ${String(code)}`)) }) + }))) + + const stored = parseRegistry(readFileSync(file(), 'utf8')) + expect(stored.intact).toBe(true) + expect(stored.records.map(record => record.sessionId).sort()).toEqual( + Array.from({ length: count }, (_unused, index) => `sess-${String(index)}`).sort(), + ) + } finally { + for (const child of children) child.stdin.end() + await Promise.all(children.map(child => new Promise((resolve) => { child.once('exit', () => { resolve() }) }))) + } + }, 60_000) +}) diff --git a/packages/session-registry/session-registry-file/tsconfig.json b/packages/session-registry/session-registry-file/tsconfig.json new file mode 100644 index 0000000000..0ab9c6a3a4 --- /dev/null +++ b/packages/session-registry/session-registry-file/tsconfig.json @@ -0,0 +1,21 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../session-registry" + }, + { + "path": "../../support/invariants" + }, + { + "path": "../../core/session" + } + ] +} diff --git a/packages/session-registry/session-registry-live/README.i18n.yaml b/packages/session-registry/session-registry-live/README.i18n.yaml new file mode 100644 index 0000000000..2d4554f70c --- /dev/null +++ b/packages/session-registry/session-registry-live/README.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write packages/session-registry/session-registry-live/README.md +README.md: 0404915a97c03999210b0c4e0356cd2cecb2b040 +README.zh.md: 5bfb9a90db2578bfe6517dd9cd3d11ebd043f515 diff --git a/packages/session-registry/session-registry-live/README.md b/packages/session-registry/session-registry-live/README.md new file mode 100644 index 0000000000..0404915a97 --- /dev/null +++ b/packages/session-registry/session-registry-live/README.md @@ -0,0 +1,32 @@ +# @deepseek-ai/dsh-session-registry-live + +English | [中文](README.zh.md) + +Publishes every live session in this process into the [session registry](../session-registry/README.md), so `dsh list-sessions` lists the sessions a server creates on demand rather than only the one a launcher minted up front. + +## Behavior + +Registration follows session lifecycle rather than a launcher-known identity: the plugin publishes every session present at mount and every later `session/created`, and removes a record when its session is disposed. One path therefore serves both the TUI's single session and the browser UI's one-per-conversation sessions. + +A session whose header carries no `cwd` is skipped — the listing's workspace column would have nothing truthful to show. + +`session/title` events are mirrored onto the record through `retitle`, so the latest logged title reaches the listing. Carrying the title in the record is what keeps the reader backend-agnostic: the log's location, file format, and compression are per-deployment choices (the shipped TUI writes zstd-compressed JSONL), so an independent process cannot portably parse one. + +Publication is fire-and-forget with a warning on failure: the registry is an observability aid, so a registry fault must not fail a working agent session. A session that ends while its registration is still in flight leaves a tombstone the completing registration observes, so its record cannot outlive the session until a pid-based prune. + +## Config + +None. Every published record is derived from the session itself, so no deployment-varying choice is left to configure. + +## Model Experience + +None, as this package registers no tools, injects no prompts, and appends no session events; it only mirrors existing lifecycle and title events into a host-side process record. + +#### KV Cache effect + +Independent of live requests: the plugin reads session events and writes a separate registry file without touching any request prefix, so it cannot invalidate provider cache reuse. + +## Known Limitations and Deferred Work + +- **A skipped session is invisible, not deferred** — a session created without a `cwd` is never published, even if a workspace becomes known later; there is no re-check. +- **Title mirroring costs one registry write per revision** — each `session/title` event triggers a locked read-modify-write, so a deployment with an aggressive retitling cadence pays that write per revision. diff --git a/packages/session-registry/session-registry-live/README.zh.md b/packages/session-registry/session-registry-live/README.zh.md new file mode 100644 index 0000000000..5bfb9a90db --- /dev/null +++ b/packages/session-registry/session-registry-live/README.zh.md @@ -0,0 +1,32 @@ +# @deepseek-ai/dsh-session-registry-live + +[English](README.md) | 中文 + +把本进程内每个活跃会话发布到[会话注册表](../session-registry/README.md),因此 `dsh list-sessions` 能列出服务端按需创建的所有会话,而不是只列出启动器一开始铸出的那一个。 + +## 行为 + +注册跟随会话生命周期,而不依赖启动器已知的身份:插件会发布挂载时已存在的每个会话,以及此后每个 `session/created`,并在会话被 dispose(资源释放)时移除对应记录。因此同一条路径既服务 TUI 的单个会话,也服务浏览器 UI 的每对话一个的多个会话。 + +会话头不带 `cwd` 时会被跳过:列表的工作区列拿不到任何真实内容可展示。 + +`session/title` 事件通过 `retitle` 镜像到记录上,因此最新记录的标题能到达列表。把标题带在记录里,正是让读取方与后端无关的原因:日志的位置、文件格式和压缩都是逐部署的选择(随附的 TUI 写入 Zstandard 压缩的 JSONL),因此独立进程无法以可移植的方式解析它。 + +发布是 fire-and-forget,失败只发出警告:注册表是一项可观测性辅助设施,因此注册表故障绝不能让正常工作的 agent(智能体)会话失败。会话在其注册仍在途中时结束,会留下一个 tombstone,让即将完成的注册观测到,因此它的记录不会一直存活到某次基于 pid 的清理才消失。 + +## 配置 + +无。每条发布的记录都从会话本身派生而来,因此没有留下任何逐部署的选择需要配置。 + +## 模型体验 + +无。该包(package)不注册工具、不注入提示词,也不追加会话事件;它只把既有的生命周期事件和标题事件镜像进宿主侧的进程记录。 + +#### KV 缓存影响 + +与实时请求相互独立:该插件读取会话事件,并写入一个独立的注册表文件,不触碰任何请求前缀,因此它无法使提供方 cache 复用失效。 + +## 已知限制与延期工作 + +- **被跳过的会话是不可见,而非延后处理**——创建时不带 `cwd` 的会话永不发布,即使之后工作区变为已知也不会;没有重新检查机制。 +- **标题镜像每次修订都要付出一次注册表写入**——每个 `session/title` 事件都会触发一次加锁的读取、修改和写入,因此改名节奏激进的部署要按修订次数付出这些写入。 diff --git a/packages/session-registry/session-registry-live/package.json b/packages/session-registry/session-registry-live/package.json new file mode 100644 index 0000000000..f0c5aeddc6 --- /dev/null +++ b/packages/session-registry/session-registry-live/package.json @@ -0,0 +1,44 @@ +{ + "name": "@deepseek-ai/dsh-session-registry-live", + "description": "Publishes every live session into the cross-process session registry that `dsh list-sessions` reads", + "version": "0.0.1", + "private": true, + "type": "module", + "main": "lib/index.js", + "types": "lib/types/index.d.ts", + "exports": { + ".": { + "types": "./lib/types/index.d.ts", + "default": "./lib/index.js" + }, + "./invariant": { + "types": "./lib/types/invariant.d.ts", + "default": "./lib/invariant.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "files": [ + "lib/index.js", + "lib/invariant.js", + "lib/types/**/*.d.ts", + "lib/types/**/*.d.ts.map", + "src" + ], + "license": "BSD-3-Clause", + "peerDependencies": { + "@deepseek-ai/dsh-invariants": "^0.0.1", + "@deepseek-ai/dsh-session": "^0.0.1", + "@deepseek-ai/dsh-session-registry": "^0.0.1", + "@deepseek-ai/dsh-session-title": "^0.0.1", + "cordis": "^4.0.0-rc.6" + }, + "devDependencies": { + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/dsh-session-registry": "workspace:^", + "@deepseek-ai/dsh-session-registry-file": "workspace:^", + "@deepseek-ai/dsh-session-title": "workspace:^", + "cordis": "^4.0.0-rc.6" + } +} diff --git a/packages/session-registry/session-registry-live/src/index.ts b/packages/session-registry/session-registry-live/src/index.ts new file mode 100644 index 0000000000..8e34487e65 --- /dev/null +++ b/packages/session-registry/session-registry-live/src/index.ts @@ -0,0 +1,84 @@ +/** + * Publishes every live session in this process into the cross-process session + * registry, so `dsh list-sessions` lists sessions a server creates on demand rather than + * only the one a launcher minted up front. + * + * Mounted in a composition whose sessions come and go — the browser UI creates + * one per conversation — this plugin follows `session/created` and + * `session/disposed` instead of registering a single launcher-known identity. + * A session with no `cwd` in its header is skipped: the registry's workspace + * column would have nothing truthful to show, and a subagent child is exactly + * that case. Titles are mirrored into the record as `session/title` events + * arrive, so a reader never has to parse a backend's log format. + * @module @deepseek-ai/dsh-session-registry-live + */ + +import type { Context } from 'cordis' +import type { Session } from '@deepseek-ai/dsh-session' +// Empty type imports carry the Context merges this plugin relies on: the +// `sessionRegistry` service and the `session/title` session event. +import type {} from '@deepseek-ai/dsh-session-registry' +import type {} from '@deepseek-ai/dsh-session-title' + +/** Cordis plugin name. */ +export const name = 'session-registry-live' + +/** Services required before sessions can be followed and records published. */ +export const inject = ['sessions', 'sessionRegistry'] + +/** + * Follow session lifecycle and keep the registry in step. + * @param ctx - context carrying the session store and the registry service. + */ +export function apply(ctx: Context): void { + /** + * Per-session registration state. `'disposing'` is a tombstone written when a + * session ends while its registration is still in flight: without it the + * late-arriving disposer would be stored for a session that no longer exists + * and its record would outlive the session until a pid-based prune. + */ + const registered = new Map Promise) | 'disposing'>() + + const publish = (session: Session): void => { + const cwd = session.header.cwd + // A session without a workspace has no listable location; skipping keeps the + // registry free of rows `dsh list-sessions` could not render truthfully. + if (cwd === undefined) return + void ctx.sessionRegistry.register({ sessionId: session.id, cwd }) + .then((dispose) => { + if (registered.get(session) === 'disposing') { + registered.delete(session) + void dispose() + return + } + registered.set(session, dispose) + }) + .catch((error: unknown) => { + registered.delete(session) + ctx.logger.warn('failed to publish session %s: %s', session.id, String(error)) + }) + } + + for (const session of ctx.sessions.list()) publish(session) + ctx.on('session/created', (session) => { publish(session) }, { global: true }) + ctx.on('session/disposed', (session) => { + const entry = registered.get(session) + if (typeof entry === 'function') { + registered.delete(session) + void entry() + return + } + // Registration is still in flight; leave a tombstone for it to observe. + registered.set(session, 'disposing') + }, { global: true }) + + // Mirror title revisions onto the record. A title arrives after registration + // and may be replaced, so the listing tracks the latest logged value. + ctx.on('session/event', (session, event) => { + if (event.type !== 'session/title') return + const { title } = event.data + void ctx.sessionRegistry.retitle(session.id, title).catch((error: unknown) => { + ctx.logger.warn('failed to retitle %s: %s', session.id, String(error)) + }) + }, { global: true }) +} diff --git a/packages/session-registry/session-registry-live/src/invariant.ts b/packages/session-registry/session-registry-live/src/invariant.ts new file mode 100644 index 0000000000..19d2a9f483 --- /dev/null +++ b/packages/session-registry/session-registry-live/src/invariant.ts @@ -0,0 +1,31 @@ +/** + * Package-owned invariant companion for `@deepseek-ai/dsh-session-registry-live`. + * @module @deepseek-ai/dsh-session-registry-live/invariant + */ + +/* jscpd:ignore-start */ +import type { Context } from 'cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-session-registry-live' + +/** Cordis companion plugin name. */ +export const name = 'session-registry-live-invariant' +/** Service required before the companion can reserve package ownership. */ +export const inject = ['invariants'] + +/** + * No runtime invariant: this plugin owns no durable state of its own — the + * uniqueness and liveness relations over published records are checked by the + * companion in `@deepseek-ai/dsh-session-registry`, which owns that file. + */ +const install: InvariantInstaller = () => {} + +/** + * Register this package's invariant companion. + * @param ctx - Cordis context carrying the invariant service. + * @returns the installed registration's disposer after setup succeeds. + */ +export const apply = (ctx: Context): Promise<() => void> => + Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) +/* jscpd:ignore-end */ diff --git a/packages/session-registry/session-registry-live/tests/session-registry-live.spec.ts b/packages/session-registry/session-registry-live/tests/session-registry-live.spec.ts new file mode 100644 index 0000000000..ec0973259e --- /dev/null +++ b/packages/session-registry/session-registry-live/tests/session-registry-live.spec.ts @@ -0,0 +1,227 @@ +/** + * Tests for the live-session publisher over the REAL session store, so + * publication follows the store's actual lifecycle dispatch rather than a + * hand-built event emitter: sessions created after mount are published, + * disposal removes their records, a session without a workspace is skipped, and + * logged title revisions are mirrored onto the record so a reader never parses a + * backend's log format. + */ + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { Context } from 'cordis' +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' +import { type SessionRegistryRecord } from '@deepseek-ai/dsh-session-registry' +import SessionRegistryFile from '@deepseek-ai/dsh-session-registry-file' +import * as live from '@deepseek-ai/dsh-session-registry-live' +// Empty type import carries the `session/title` event into the session-event map. +import type {} from '@deepseek-ai/dsh-session-title' + +let root: string + +beforeEach(() => { root = mkdtempSync(join(tmpdir(), 'dsh-registry-live-test-')) }) +afterEach(() => { + rmSync(root, { recursive: true, force: true }) + vi.restoreAllMocks() +}) + +/** Mount the real store plus the publisher. */ +async function mount(): Promise { + const ctx = new Context() + await ctx.plugin(SessionStore) + await ctx.plugin(SessionRegistryFile, { root, lockStaleMs: 10_000, lockRetries: 20 }) + await ctx.plugin(live) + return ctx +} + +/** Let the publisher's fire-and-forget registration reach durability. */ +const settle = (): Promise => new Promise((resolve) => { setTimeout(resolve, 200) }) + +/** Read the registry through an independent service, as `dsh list-sessions` would. */ +async function listExternally(): Promise { + const reader = new Context() + await reader.plugin(SessionRegistryFile, { root, lockStaleMs: 10_000, lockRetries: 20 }) + const records = await reader.sessionRegistry.list() + await reader.fiber.dispose() + return records +} + +describe('publishing', () => { + it('publishes sessions that already exist when the plugin mounts', async () => { + // A composition may mount the publisher after sessions exist (a resumed + // session, or plugin order), so mount-time adoption is its own path. + const ctx = new Context() + await ctx.plugin(SessionStore) + ctx.sessions.create(SessionId('preexisting'), { meta: { cwd: '/work/a' } }) + await ctx.plugin(SessionRegistryFile, { root, lockStaleMs: 10_000, lockRetries: 20 }) + await ctx.plugin(live) + await settle() + + expect((await ctx.sessionRegistry.list()).map(record => record.sessionId)).toEqual(['preexisting']) + await ctx.fiber.dispose() + }) + + it('publishes a session created after mount', async () => { + const ctx = await mount() + ctx.sessions.create(SessionId('later'), { meta: { cwd: '/work/b' } }) + await settle() + + const listed = await ctx.sessionRegistry.list() + expect(listed).toHaveLength(1) + expect(listed[0]).toMatchObject({ sessionId: 'later', cwd: '/work/b' }) + await ctx.fiber.dispose() + }) + + it('skips a session with no workspace, having nothing truthful to list', async () => { + const ctx = await mount() + ctx.sessions.create(SessionId('no-cwd')) + await settle() + expect(await ctx.sessionRegistry.list()).toEqual([]) + await ctx.fiber.dispose() + }) + + it('has no title until one is logged', async () => { + const ctx = await mount() + ctx.sessions.create(SessionId('fresh'), { meta: { cwd: '/work/c' } }) + await settle() + expect((await ctx.sessionRegistry.list())[0]?.title).toBeUndefined() + await ctx.fiber.dispose() + }) + + it('mirrors the latest logged title onto the record', async () => { + const ctx = await mount() + const session = ctx.sessions.create(SessionId('titled'), { meta: { cwd: '/work/d' } }) + await settle() + + session.append('session/title', { title: 'first guess', messageSeqs: [0], source: { kind: 'fallback' } }) + await settle() + expect((await ctx.sessionRegistry.list())[0]?.title).toBe('first guess') + + // A revision replaces the previous value rather than accumulating. + session.append('session/title', { title: 'better title', messageSeqs: [0], source: { kind: 'fallback' } }) + await settle() + expect((await ctx.sessionRegistry.list())[0]?.title).toBe('better title') + await ctx.fiber.dispose() + }) + + it('ignores session events other than a title revision', async () => { + const ctx = await mount() + const session = ctx.sessions.create(SessionId('busy'), { meta: { cwd: '/work/z' } }) + await settle() + const retitle = vi.spyOn(ctx.sessionRegistry, 'retitle') + + session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) + await settle() + expect(retitle).not.toHaveBeenCalled() + await ctx.fiber.dispose() + }) + + it('retitles only the session that logged the event', async () => { + const ctx = await mount() + const first = ctx.sessions.create(SessionId('one'), { meta: { cwd: '/work/e' } }) + ctx.sessions.create(SessionId('two'), { meta: { cwd: '/work/f' } }) + await settle() + + first.append('session/title', { title: 'only mine', messageSeqs: [0], source: { kind: 'fallback' } }) + await settle() + const byId = new Map((await ctx.sessionRegistry.list()).map(record => [record.sessionId, record.title])) + expect(byId.get(SessionId('one'))).toBe('only mine') + expect(byId.get(SessionId('two'))).toBeUndefined() + await ctx.fiber.dispose() + }) + + it('publishes every concurrently created session', async () => { + const ctx = await mount() + for (let index = 0; index < 5; index += 1) { + ctx.sessions.create(SessionId(`bulk-${String(index)}`), { meta: { cwd: `/work/bulk-${String(index)}` } }) + } + await settle() + expect((await ctx.sessionRegistry.list()).map(record => record.sessionId).sort()) + .toEqual(['bulk-0', 'bulk-1', 'bulk-2', 'bulk-3', 'bulk-4']) + await ctx.fiber.dispose() + }) +}) + +describe('failure and race handling', () => { + it('removes the record when a session is disposed mid-registration', async () => { + // The tombstone path: the session ends before its registration resolves, so + // the late disposer must be applied instead of stored for a dead session. + const ctx = await mount() + let owner: Context | undefined + await ctx.plugin({ + inject: ['sessions'], + apply: (child: Context) => { + owner = child + child.sessions.create(SessionId('raced'), { meta: { cwd: '/work/race' } }) + }, + }) + // No settle: dispose while `register` is still in flight. + await owner?.fiber.dispose() + await settle() + expect(await ctx.sessionRegistry.list()).toEqual([]) + await ctx.fiber.dispose() + }) + + it('warns and drops the record when publication fails', async () => { + const ctx = await mount() + ctx.sessionRegistry.register = () => Promise.reject(new Error('registry offline')) + const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => undefined) + + ctx.sessions.create(SessionId('unpublishable'), { meta: { cwd: '/work/x' } }) + await settle() + expect(warn.mock.calls.flat().join(' ')).toMatch(/failed to publish session/) + await ctx.fiber.dispose() + }) + + it('warns when a title revision cannot be recorded', async () => { + const ctx = await mount() + const session = ctx.sessions.create(SessionId('titled'), { meta: { cwd: '/work/y' } }) + await settle() + + ctx.sessionRegistry.retitle = () => Promise.reject(new Error('registry offline')) + const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => undefined) + + session.append('session/title', { title: 'doomed', messageSeqs: [0], source: { kind: 'fallback' } }) + await settle() + expect(warn.mock.calls.flat().join(' ')).toMatch(/failed to retitle/) + await ctx.fiber.dispose() + }) +}) + +describe('disposal', () => { + it('removes a record when its own session is disposed, keeping the others', async () => { + const ctx = await mount() + // A session belongs to the fiber that created it, so a child plugin fiber + // gives one session an independent lifetime without disposing the services. + let owner: Context | undefined + await ctx.plugin({ + inject: ['sessions'], + apply: (child: Context) => { + owner = child + child.sessions.create(SessionId('ephemeral'), { meta: { cwd: '/work/e' } }) + }, + }) + ctx.sessions.create(SessionId('durable'), { meta: { cwd: '/work/f' } }) + await settle() + expect(await ctx.sessionRegistry.list()).toHaveLength(2) + + // Disposing only that fiber ends its session, which the publisher follows. + await owner?.fiber.dispose() + await settle() + expect((await ctx.sessionRegistry.list()).map(record => record.sessionId)).toEqual(['durable']) + await ctx.fiber.dispose() + }) + + it('leaves no record behind after the whole tree unloads', async () => { + const ctx = await mount() + ctx.sessions.create(SessionId('a'), { meta: { cwd: '/work/g' } }) + ctx.sessions.create(SessionId('b'), { meta: { cwd: '/work/h' } }) + await settle() + expect(await ctx.sessionRegistry.list()).toHaveLength(2) + + await ctx.fiber.dispose() + expect(await listExternally()).toEqual([]) + }) +}) diff --git a/packages/session-registry/session-registry-live/tsconfig.json b/packages/session-registry/session-registry-live/tsconfig.json new file mode 100644 index 0000000000..456adfc51b --- /dev/null +++ b/packages/session-registry/session-registry-live/tsconfig.json @@ -0,0 +1,24 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../../support/invariants" + }, + { + "path": "../../core/session" + }, + { + "path": "../session-registry" + }, + { + "path": "../../session-title/session-title" + } + ] +} diff --git a/packages/session-registry/session-registry/README.i18n.yaml b/packages/session-registry/session-registry/README.i18n.yaml new file mode 100644 index 0000000000..211c8222e8 --- /dev/null +++ b/packages/session-registry/session-registry/README.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write packages/session-registry/session-registry/README.md +README.md: 8ab8e232e6cd041e5476d4e6cadcb8b64a85f586 +README.zh.md: 62774e8a2a892c97ba6343f12dd35f827ad63e33 diff --git a/packages/session-registry/session-registry/README.md b/packages/session-registry/session-registry/README.md new file mode 100644 index 0000000000..8ab8e232e6 --- /dev/null +++ b/packages/session-registry/session-registry/README.md @@ -0,0 +1,30 @@ +# @deepseek-ai/dsh-session-registry + +English | [中文](README.zh.md) + +Live-session registry seam (`ctx.sessionRegistry`): the contract and record vocabulary for a cross-process registry of the sessions running right now, so a separate short-lived process such as `dsh list-sessions` can answer "what am I running". This package owns no medium — a backend (the lock-guarded JSON file in [`session-registry-file`](../session-registry-file/README.md) today, a database later) implements the abstract service. + +## Shape + +- `register(registration)` — publish `{ sessionId, cwd, title? }` stamped with this process's pid, a per-incarnation `bootId`, and `startedAt`. Replaces any existing record for the same session id. Returns the `ctx.effect` disposer; awaiting it waits for the removal to reach durability. +- `retitle(sessionId, title)` — replace the recorded title of a session **this** process registered. Titles arrive after registration and can be revised, so it is the one mutable field. A record owned by another pid or incarnation is left alone, and an unknown id is a no-op because a title can resolve after the record is gone. +- `list()` — every live record, newest registration last. Liveness is part of the contract, not the backend's discretion: every returned record's process existed at observation time, so a process killed without running its disposer leaves no permanent phantom. + +Backends serialize mutations against concurrent registrars — other processes and overlapping calls in this one — so records are never lost to a torn read-modify-write. + +## Record vocabulary + +`SessionRegistryRecord` carries `sessionId` (unique across live records), `pid`, `cwd`, `startedAt`, a `bootId` distinguishing a recycled pid from the original incarnation, and an optional `title`. The title travels in the record rather than being read from the session log because log location, format, and compression are per-deployment backend choices an independent reader cannot portably parse. + +## Model Experience + +None, as this package registers no tools, injects no prompts, and appends no session events; it defines the host-side listing contract only. + +#### KV Cache effect + +Independent of live requests: the registry never touches a request prefix, so nothing here can invalidate provider cache reuse. + +## Known Limitations and Deferred Work + +- **Records are process-scoped, not agent-scoped** — only top-level launcher surfaces publish. In-process subagents have no process of their own, and out-of-process subagent backends spawn `dsh-jsonrpc-agent` rather than the CLI, so neither appears in a listing. +- **Liveness is pid existence, not health** — a hung or stopped process still lists as running; the contract deliberately makes no judgement about whether a session is making progress. diff --git a/packages/session-registry/session-registry/README.zh.md b/packages/session-registry/session-registry/README.zh.md new file mode 100644 index 0000000000..62774e8a2a --- /dev/null +++ b/packages/session-registry/session-registry/README.zh.md @@ -0,0 +1,30 @@ +# @deepseek-ai/dsh-session-registry + +[English](README.md) | 中文 + +存活会话注册表 seam(`ctx.sessionRegistry`):定义跨进程「当前正在运行哪些会话」注册表的契约与记录词汇,使 `dsh list-sessions` 这类独立的短生命周期进程能够回答「我正在运行什么」。本包不拥有任何介质——由后端实现该抽象服务(今天是 [`session-registry-file`](../session-registry-file/README.md) 中加锁保护的 JSON 文件,将来可以是数据库)。 + +## 形状 + +- `register(registration)`:发布 `{ sessionId, cwd, title? }`,并盖上本进程的 pid、每个 incarnation 独有的 `bootId` 和 `startedAt`。同一会话 id 的既有记录会被替换。返回 `ctx.effect` disposer;await 它即等待移除达到持久性。 +- `retitle(sessionId, title)`:替换**本**进程注册的某个会话的已记录标题。标题在注册之后才到达,并且可以修订,因此它是唯一的可变字段。归属于其他 pid 或其他 incarnation 的记录不受影响;未知 id 为空操作,因为标题可能在记录消失之后才解析出来。 +- `list()`:返回全部存活记录,按注册时间从旧到新排列。存活性属于契约本身,而非后端的自由裁量:每条返回记录的进程在观察时刻都存在,因此未运行 disposer 就被杀掉的进程不会留下永久的幽灵记录。 + +后端必须将变更与并发注册方(其他进程,以及本进程内相互重叠的调用)串行化,使记录不会因撕裂的读改写而丢失。 + +## 记录词汇 + +`SessionRegistryRecord` 携带 `sessionId`(在存活记录中唯一)、`pid`、`cwd`、`startedAt`、用于区分被复用 pid 与原 incarnation 的 `bootId`,以及可选的 `title`。标题随记录传递而非从会话日志读取,因为日志的位置、格式与压缩是各部署后端的选择,独立读取方无法可移植地解析。 + +## 模型体验 + +无。本包不注册工具、不注入提示词、不追加会话事件;它只定义宿主侧的列表契约。 + +#### KV 缓存影响 + +与在途请求无关:注册表从不触碰请求前缀,因此这里不会使提供方缓存复用失效。 + +## 已知限制与后续工作 + +- **记录以进程为粒度,而非以 agent 为粒度**——只有用户直接启动的顶层界面会发布。进程内 subagent 没有自己的进程,进程外 subagent 后端启动的是 `dsh-jsonrpc-agent` 而非本 CLI,两者都不会出现在列表中。 +- **存活性只表示 pid 存在,不表示健康**——挂起或停止的进程仍会被列为运行中;契约刻意不判断会话是否在推进。 diff --git a/packages/session-registry/session-registry/package.json b/packages/session-registry/session-registry/package.json new file mode 100644 index 0000000000..75308999f5 --- /dev/null +++ b/packages/session-registry/session-registry/package.json @@ -0,0 +1,41 @@ +{ + "name": "@deepseek-ai/dsh-session-registry", + "description": "Live-session registry seam for the DeepSeek Harness: contract and record vocabulary", + "version": "0.0.1", + "private": true, + "type": "module", + "main": "lib/index.js", + "types": "lib/types/index.d.ts", + "exports": { + ".": { + "types": "./lib/types/index.d.ts", + "default": "./lib/index.js" + }, + "./invariant": { + "types": "./lib/types/invariant.d.ts", + "default": "./lib/invariant.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "files": [ + "lib/index.js", + "lib/invariant.js", + "lib/types/**/*.d.ts", + "lib/types/**/*.d.ts.map", + "src" + ], + "license": "BSD-3-Clause", + "peerDependencies": { + "@deepseek-ai/dsh-brand": "^0.0.1", + "@deepseek-ai/dsh-invariants": "^0.0.1", + "@deepseek-ai/dsh-session": "^0.0.1", + "cordis": "^4.0.0-rc.6" + }, + "devDependencies": { + "@deepseek-ai/dsh-brand": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-session": "workspace:^", + "cordis": "^4.0.0-rc.6" + } +} diff --git a/packages/session-registry/session-registry/src/index.ts b/packages/session-registry/session-registry/src/index.ts new file mode 100644 index 0000000000..413317c31c --- /dev/null +++ b/packages/session-registry/session-registry/src/index.ts @@ -0,0 +1,82 @@ +/** + * Live-session registry seam (`ctx.sessionRegistry`): a cross-process registry + * of live `dsh` sessions, so a separate short-lived process such as + * `dsh list-sessions` can answer "what am I running right now". + * + * This package owns only the service contract and the record vocabulary; a + * backend (the lock-guarded JSON file in + * `@deepseek-ai/dsh-session-registry-file` today, a database later) owns the + * medium. Whatever the medium, liveness is part of the contract: {@link list} + * returns only records whose process existed at observation time, so a process + * killed without running its disposer leaves no permanent phantom. + * @module @deepseek-ai/dsh-session-registry + */ + +import { Context, Service } from 'cordis' +import type { SessionId } from '@deepseek-ai/dsh-session' +import { BootId, type SessionRegistryRecord } from './types.ts' + +export { BootId } from './types.ts' +export type { SessionRegistryRecord } from './types.ts' + +declare module 'cordis' { + interface Context { + sessionRegistry: SessionRegistry + } +} + +/** What one process publishes about itself; the service supplies pid and timing. */ +export interface SessionRegistration { + /** The session this process runs. */ + sessionId: SessionId + /** Absolute workspace directory the session acts on. */ + cwd: string + /** Human-readable session title, when one already exists. */ + title?: string +} + +/** + * Cross-process live-session registry. Reads prune dead records, so every + * returned record's process existed at observation time. Backends serialize + * mutations against concurrent registrars — other processes and overlapping + * calls in this one — so records are never lost to a torn read-modify-write. + */ +export abstract class SessionRegistry extends Service { + /** This process incarnation's id, stamped into every record it publishes. */ + protected readonly bootId: BootId + + constructor(ctx: Context, bootId: BootId) { + super(ctx, 'sessionRegistry') + this.bootId = bootId + } + + /** + * Publish this process's record, replacing any stale record for the same + * session id, and prune records whose process is gone. + * @param registration - the session, surface, and workspace to publish. + * @returns the effect disposer that removes this record again; awaiting it + * waits for the removal to reach durability. + */ + abstract register(registration: SessionRegistration): Promise<() => Promise> + + /** + * Replace the recorded title of a session this process registered. + * + * Titles arrive after registration and can be revised, so this is the one + * mutable field. Only a record matching this process and incarnation is + * touched, leaving a same-id record owned by another process alone. An unknown + * session id is a no-op rather than an error: a title can resolve after the + * session's record has already been removed. + * @param sessionId - the session whose recorded title changes. + * @param title - the new title text. + */ + abstract retitle(sessionId: SessionId, title: string): Promise + + /** + * List live sessions, pruning records whose process no longer exists. + * @returns one record per live registered session, newest registration last. + */ + abstract list(): Promise +} + +export default SessionRegistry diff --git a/packages/session-registry/session-registry/src/invariant.ts b/packages/session-registry/session-registry/src/invariant.ts new file mode 100644 index 0000000000..f41207fbed --- /dev/null +++ b/packages/session-registry/session-registry/src/invariant.ts @@ -0,0 +1,58 @@ +/** + * Package-owned invariant companion for `@deepseek-ai/dsh-session-registry`. + * @module @deepseek-ai/dsh-session-registry/invariant + */ + +import type { Context } from 'cordis' +import type { InvariantFailure, InvariantInstaller } from '@deepseek-ai/dsh-invariants' +import type { SessionRegistryRecord } from './types.ts' + +const PACKAGE_NAME = '@deepseek-ai/dsh-session-registry' + +/** Cordis companion plugin name. */ +export const name = 'session-registry-invariant' +/** Service required before the companion can reserve package ownership. */ +export const inject = ['invariants'] + +/** + * Cross-check every published listing against the relations the seam contract + * owns: a session id identifies at most one live record, and each listed record + * carries the identity fields a reader must be able to trust. Only a backend's + * mutation path can break either, so the check wraps the authoritative read + * rather than inspecting any medium. + * + * Liveness itself is deliberately not re-probed here. A backend derives it at + * read time, so a second probe would race the first and report a process that + * exited in between as a violation of a contract the seam never made. + */ +const install: InvariantInstaller = Object.assign((ctx: Context, fail: InvariantFailure) => { + const service = ctx.sessionRegistry + const listed = service.list.bind(service) + ctx.effect(() => { + service.list = async (): Promise => { + const records = await listed() + const seen = new Set() + for (const record of records) { + if (seen.has(record.sessionId)) { + fail(`session ${record.sessionId} appears in more than one live registry record`) + } + seen.add(record.sessionId) + // A record a reader cannot attribute to a process is unusable: `dsh list-sessions` + // renders the pid and derives liveness from it. + if (!Number.isSafeInteger(record.pid) || record.pid <= 0) { + fail(`listed session ${record.sessionId} carries unusable pid ${String(record.pid)}`) + } + } + return records + } + return () => { service.list = listed } + }) +}, { inject: ['sessionRegistry'] }) + +/** + * Register this package's invariant companion. + * @param ctx - Cordis context carrying the invariant service. + * @returns the installed registration's disposer after setup succeeds. + */ +export const apply = (ctx: Context): Promise<() => void> => + Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) diff --git a/packages/session-registry/session-registry/src/types.ts b/packages/session-registry/session-registry/src/types.ts new file mode 100644 index 0000000000..6fff91648e --- /dev/null +++ b/packages/session-registry/session-registry/src/types.ts @@ -0,0 +1,55 @@ +/** + * Registry record vocabulary: the durable shape one live `dsh` process + * publishes about itself and `dsh list-sessions` reads back. + * @module @deepseek-ai/dsh-session-registry/types + */ + +import type { Branded } from '@deepseek-ai/dsh-brand' +import type { SessionId } from '@deepseek-ai/dsh-session' + +/** + * Identifies one process incarnation. Minted per registering process, so a + * record whose `pid` was recycled by the operating system cannot be mistaken + * for the original: the boot id differs even when the pid matches. + */ +export type BootId = Branded<'BootId'> + +/** + * Brand a string as a {@link BootId}. + * @param id - the raw boot id string. + * @returns the same string, branded (a compile-time cast — no runtime cost). + */ +export function BootId(id: string): BootId { + return id as BootId +} + +/** + * One live session's self-published registration. Every field is immutable for + * the lifetime of the registration: a process publishes once at startup and + * removes the record on exit, never mutating it in place. + * + * Only top-level surfaces a user starts directly register: in-process subagents + * have no process of their own, and out-of-process subagent backends spawn + * `dsh-jsonrpc-agent` rather than this CLI, so neither can reach the registry. + */ +export interface SessionRegistryRecord { + /** The session this process is running. Unique across live records. */ + readonly sessionId: SessionId + /** Operating-system process id, used with `bootId` to decide liveness. */ + readonly pid: number + /** Absolute workspace directory the session acts on. */ + readonly cwd: string + /** Non-negative safe-integer Unix epoch milliseconds when the process registered. */ + readonly startedAt: number + /** This process incarnation's id, distinguishing a recycled `pid`. */ + readonly bootId: BootId + /** + * Human-readable session title, as the registering process last knew it. + * + * Carried in the record rather than read from the session log: the log's + * location, file format, and compression are per-deployment backend choices, + * so an independent reader cannot portably parse one. Absent until a title + * exists — a fresh session has none. + */ + readonly title?: string +} diff --git a/packages/session-registry/session-registry/tests/invariant.spec.ts b/packages/session-registry/session-registry/tests/invariant.spec.ts new file mode 100644 index 0000000000..6d97b3d129 --- /dev/null +++ b/packages/session-registry/session-registry/tests/invariant.spec.ts @@ -0,0 +1,98 @@ +/** + * Tests for the registry's invariant companion: each acceptance path is proven + * to REJECT an invalid case, since a check that cannot fail is not a check. + * The backend is a minimal in-memory stub — the companion owns contract-level + * relations over `list()` results, whatever medium serves them. + */ + +import { describe, expect, it } from 'vitest' +import { Context } from 'cordis' +import InvariantService from '@deepseek-ai/dsh-invariants' +import { SessionId } from '@deepseek-ai/dsh-session' +import { BootId, SessionRegistry, type SessionRegistration, type SessionRegistryRecord } from '@deepseek-ai/dsh-session-registry' +import * as invariant from '@deepseek-ai/dsh-session-registry/src/invariant.ts' + +/** Minimal in-memory backend whose listings the test scripts directly. */ +class StubRegistry extends SessionRegistry { + records: SessionRegistryRecord[] = [] + + constructor(ctx: Context) { + super(ctx, BootId('stub-boot')) + } + + register(registration: SessionRegistration): Promise<() => Promise> { + this.records.push({ + sessionId: registration.sessionId, + pid: process.pid, + cwd: registration.cwd, + startedAt: Date.now(), + bootId: this.bootId, + }) + return Promise.resolve(() => Promise.resolve()) + } + + retitle(): Promise { + return Promise.resolve() + } + + list(): Promise { + return Promise.resolve([...this.records]) + } +} + +/** One record with the given identity fields, live by construction. */ +function record(sessionId: string, boot: string, pid = process.pid): SessionRegistryRecord { + return { sessionId: SessionId(sessionId), pid, cwd: '/w', startedAt: 1, bootId: BootId(boot) } +} + +/** Mount the stub backend, optionally seeding records before the companion wraps `list`. */ +async function mount(records?: SessionRegistryRecord[]): Promise<{ ctx: Context; stub: StubRegistry }> { + const ctx = new Context() + await ctx.plugin(InvariantService, { enabled: true }) + await ctx.plugin(StubRegistry) + const stub = ctx.sessionRegistry as StubRegistry + if (records !== undefined) stub.records = records + await ctx.plugin(invariant) + return { ctx, stub } +} + +describe('listing invariants', () => { + it('accepts a well-formed listing', async () => { + const { ctx } = await mount() + await ctx.sessionRegistry.register({ sessionId: SessionId('ok'), cwd: '/w' }) + await expect(ctx.sessionRegistry.list()).resolves.toHaveLength(1) + await ctx.fiber.dispose() + }) + + it('rejects a listing where one session id appears twice', async () => { + // Two live records for one session: only a broken mutation path (or an + // out-of-band writer) can produce this, and it would make + // `dsh list-sessions` show one session twice. + const { ctx } = await mount([record('dup', 'boot-a'), record('dup', 'boot-b')]) + await expect(ctx.sessionRegistry.list()).rejects.toThrow(/appears in more than one live registry record/) + await ctx.fiber.dispose() + }) + + it('rejects a listing whose record carries an unusable pid', async () => { + // A record no reader could attribute to a process: `dsh list-sessions` + // renders the pid and derives liveness from it. + const { ctx } = await mount([record('ghost', 'boot-x', 0)]) + await expect(ctx.sessionRegistry.list()).rejects.toThrow(/carries unusable pid/) + await ctx.fiber.dispose() + }) + + it('stops checking, and keeps working, when the companion unloads', async () => { + // A duplicate-id listing the mounted companion rejects, so the post-disposal + // read proves the wrapper is gone rather than merely bypassed. + const ctx = new Context() + await ctx.plugin(InvariantService, { enabled: true }) + await ctx.plugin(StubRegistry) + ;(ctx.sessionRegistry as StubRegistry).records = [record('dup', 'boot-a'), record('dup', 'boot-b')] + const companion = await ctx.plugin(invariant) + await expect(ctx.sessionRegistry.list()).rejects.toThrow(/appears in more than one/) + + await companion.dispose() + await expect(ctx.sessionRegistry.list()).resolves.toHaveLength(2) + await ctx.fiber.dispose() + }) +}) diff --git a/packages/session-registry/session-registry/tsconfig.json b/packages/session-registry/session-registry/tsconfig.json new file mode 100644 index 0000000000..cca8f9282b --- /dev/null +++ b/packages/session-registry/session-registry/tsconfig.json @@ -0,0 +1,21 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../../support/invariants" + }, + { + "path": "../../util/brand" + }, + { + "path": "../../core/session" + } + ] +} diff --git a/packages/support/acp-snapshot/README.i18n.yaml b/packages/support/acp-snapshot/README.i18n.yaml index 3c59b2ba93..be6b79d5c5 100644 --- a/packages/support/acp-snapshot/README.i18n.yaml +++ b/packages/support/acp-snapshot/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/support/acp-snapshot/README.md -README.md: 43666d2d117170f9d7f73737fb1704efc2a55f27 -README.zh.md: 608b2490f5de7dc7ec4ecd863f41df7f609451ac +README.md: a119656ce09863f3025f6f2ec7ffbedfb3bca4ca +README.zh.md: 736f34fec83d48b3d783b63e7bab0dec44fe1676 diff --git a/packages/support/acp-snapshot/README.md b/packages/support/acp-snapshot/README.md index 43666d2d11..a119656ce0 100644 --- a/packages/support/acp-snapshot/README.md +++ b/packages/support/acp-snapshot/README.md @@ -51,7 +51,7 @@ defineAcpSnapshotSuite({ }) ``` -A scenario booting a differently-composed tree sets its own `configPath` (an overlay whose basename still ends in `cordis.yml`, so the bin's replay swap finds the sibling `*cordis.snapshot.yml`) and, when that composition changes the request header, its own `headerClass` with its own pinning scenario — the acp-agent example's Code Mode and filesystem scenarios are templates. Default generated workspaces are stored in session fixtures as `{{cwd}}` so platform temp roots and random basenames do not affect recordings; `workspaceParent` moves the generated cwd outside the platform temp area when temporary-directory grants are themselves under test, keeps that explicit path in the fixture, and remains parent-owned while the harness removes only the generated child. A pin owns its generated `system-prompt.expected.md` or `tool-schemas.expected.json` by default; `systemPromptSource` and `toolSchemasSource` name another pin when the complete corresponding sequence is identical, so each distinct version is committed once. The pin's `session.jsonl` stores `"system":"{{system}}","tools":"{{tools}}"` while retaining config, reason, and any model-visible prefix. A pin with legitimate mid-run header changes declares `expectedHeaderChanges`; a shared source must declare the same count, and record/refresh rejects claimants that generate different bytes. +A scenario booting a differently-composed tree sets its own `configPath` (an overlay whose basename still ends in `cordis.yml`, so the bin's replay swap finds the sibling `*cordis.snapshot.yml`) and, when that composition changes the request header, its own `headerClass` with its own pinning scenario — the acp-agent example's Code Mode and filesystem scenarios are templates. Default generated workspaces are stored in session fixtures as `{{cwd}}` so platform temp roots and random basenames do not affect recordings; `workspaceParent` moves the generated cwd outside the platform temp area when temporary-directory grants are themselves under test, keeps that explicit path in the fixture, and remains parent-owned while the harness removes only the generated child. `prepareCwd` runs after the `workspace/` fixture is copied and before the child boots, for world state a committed fixture cannot carry: git never tracks an entry named `.git`, and `.gitignore` excludes every `worktrees/` directory, so a repository-shaped scenario commits the file bodies under representable names and the hook assembles the real layout from them. A pin owns its generated `system-prompt.expected.md` or `tool-schemas.expected.json` by default; `systemPromptSource` and `toolSchemasSource` name another pin when the complete corresponding sequence is identical, so each distinct version is committed once. The pin's `session.jsonl` stores `"system":"{{system}}","tools":"{{tools}}"` while retaining config, reason, and any model-visible prefix. A pin with legitimate mid-run header changes declares `expectedHeaderChanges`; a shared source must declare the same count, and record/refresh rejects claimants that generate different bytes. Every scenario compares `stdout.expected.jsonl` with cwd-rooted separators canonicalized to `/`. On Windows, `pinsNativeWindowsStdout` additionally compares the complete `stdout.expected.windows.jsonl` after the shared expected output and requires that sidecar exactly when enabled. A scenario whose driven behavior needs POSIX process semantics (e.g. cancelling a live bash call kills a detached process group) declares `posixOnly`, which skips its run test on Windows while the fixture guards keep covering its committed files everywhere. diff --git a/packages/support/acp-snapshot/README.zh.md b/packages/support/acp-snapshot/README.zh.md index 608b2490f5..736f34fec8 100644 --- a/packages/support/acp-snapshot/README.zh.md +++ b/packages/support/acp-snapshot/README.zh.md @@ -51,7 +51,7 @@ defineAcpSnapshotSuite({ }) ``` -启动不同组合树的场景会设置自己的 `configPath`(一个 basename 仍以 `cordis.yml` 结尾的 overlay,使 bin 的回放交换可找到同级 `*cordis.snapshot.yml`);当该组合改变请求 header 时,还会设置自己的 `headerClass` 和 pin 场景,acp-agent 示例的 Code Mode 与文件系统场景是模板。默认生成的 workspace 在会话 fixture 中存储为 `{{cwd}}`,使平台临时根目录和随机 basename 不影响录制结果;当临时目录授权自身待测时,`workspaceParent` 将生成 cwd 移出平台临时区域,在 fixture 中保留该显式路径,并仍归父级所有,而 harness 只移除生成的子级。每个 pin 默认拥有其生成的 `system-prompt.expected.md` 或 `tool-schemas.expected.json`;当完整的对应序列相同时,`systemPromptSource` 和 `toolSchemasSource` 指定另一个 pin 作为来源,因此每个不同版本只提交一次。该 pin 的 `session.jsonl` 存储 `"system":"{{system}}","tools":"{{tools}}"`,同时保留配置、原因和任何模型可见前缀。具有合法运行中 header 变更的 pin 声明 `expectedHeaderChanges`;共享来源必须声明相同的 header 变更数量,录制/刷新会拒绝生成不同字节的共享引用方。 +启动不同组合树的场景会设置自己的 `configPath`(一个 basename 仍以 `cordis.yml` 结尾的 overlay,使 bin 的回放交换可找到同级 `*cordis.snapshot.yml`);当该组合改变请求 header 时,还会设置自己的 `headerClass` 和 pin 场景,acp-agent 示例的 Code Mode 与文件系统场景是模板。默认生成的 workspace 在会话 fixture 中存储为 `{{cwd}}`,使平台临时根目录和随机 basename 不影响录制结果;当临时目录授权自身待测时,`workspaceParent` 将生成 cwd 移出平台临时区域,在 fixture 中保留该显式路径,并仍归父级所有,而 harness 只移除生成的子级。`prepareCwd` 在复制 `workspace/` fixture 后、子级启动前运行,用于提供已提交 fixture 无法承载的环境状态:Git 永远不会跟踪名为 `.git` 的条目,且 `.gitignore` 会排除所有 `worktrees/` 目录,因此仓库形态的场景会以可表示的名称提交文件内容,并由该钩子据此组装真实布局。每个 pin 默认拥有其生成的 `system-prompt.expected.md` 或 `tool-schemas.expected.json`;当完整的对应序列相同时,`systemPromptSource` 和 `toolSchemasSource` 指定另一个 pin 作为来源,因此每个不同版本只提交一次。该 pin 的 `session.jsonl` 存储 `"system":"{{system}}","tools":"{{tools}}"`,同时保留配置、原因和任何模型可见前缀。具有合法运行中 header 变更的 pin 声明 `expectedHeaderChanges`;共享来源必须声明相同的 header 变更数量,录制/刷新会拒绝生成不同字节的共享引用方。 每个场景都比较 `stdout.expected.jsonl`,其中以 cwd 为根的分隔符规范化为 `/`。在 Windows 上,`pinsNativeWindowsStdout` 还会在共享预期输出之后比较完整 `stdout.expected.windows.jsonl`,并在启用时精确要求该 sidecar。驱动行为需要 POSIX 进程语义的场景(例如取消实时 bash 调用会终止脱离进程组)声明 `posixOnly`,在 Windows 上跳过运行测试,但 fixture 保护仍在所有平台覆盖其已提交文件。 diff --git a/packages/support/acp-snapshot/src/harness.ts b/packages/support/acp-snapshot/src/harness.ts index d11a4f1b9c..56df34e80f 100644 --- a/packages/support/acp-snapshot/src/harness.ts +++ b/packages/support/acp-snapshot/src/harness.ts @@ -155,6 +155,13 @@ export interface RunOptions { * start from an empty workspace. */ workspaceDir?: string + /** + * Optional setup run in the generated cwd after {@link workspaceDir} is + * copied and before the child boots — for world state a committed fixture + * cannot express, such as a `.git` entry (git never tracks that name, so a + * repository-shaped fixture has to be materialized at run time). + */ + prepareCwd?: (cwd: string) => Promise /** * Parent directory for the generated session cwd. Defaults to * `os.tmpdir()`. A scenario that must distinguish its workspace from the @@ -221,6 +228,7 @@ export async function runScenario(input: InputScript, opts: RunOptions): Promise if (opts.workspaceDir !== undefined && existsSync(opts.workspaceDir)) { await cp(opts.workspaceDir, cwd, { recursive: true }) } + await opts.prepareCwd?.(cwd) const env: NodeJS.ProcessEnv = { ...opts.env, DSH_SNAPSHOT: opts.mode, diff --git a/packages/support/acp-snapshot/src/suite.ts b/packages/support/acp-snapshot/src/suite.ts index d04a481993..efc1165c45 100644 --- a/packages/support/acp-snapshot/src/suite.ts +++ b/packages/support/acp-snapshot/src/suite.ts @@ -130,6 +130,12 @@ export interface Scenario { * test and the scenario needs an independent project location. */ workspaceParent?: string + /** + * Setup run in the generated cwd after the `workspace/` fixture is copied and + * before the child boots, for world state a committed fixture cannot express + * — a `.git` entry, which git never tracks under that name. + */ + prepareCwd?: (cwd: string) => Promise /** * Whether Windows additionally compares stdout with native separators against * `stdout.expected.windows.jsonl`. The shared canonical stdout expected output is still @@ -981,6 +987,7 @@ export function defineAcpSnapshotSuite(options: SnapshotSuiteOptions): void { ...!RECORDING && childFixtureFiles.length > 0 ? { childFiles: childFixtureFiles.map(file => join(dir, file)) } : {}, ...existsSync(workspaceDir) ? { workspaceDir } : {}, ...scenario.workspaceParent !== undefined ? { workspaceParent: scenario.workspaceParent } : {}, + ...scenario.prepareCwd !== undefined ? { prepareCwd: scenario.prepareCwd } : {}, // A scenario booting an overlay tree passes its own live config; the // bin's replay swap derives the sibling `*cordis.snapshot.yml` from it. ...scenario.configPath !== undefined ? { configPath: scenario.configPath } : {}, diff --git a/packages/support/acp-snapshot/tests/fixtures/suite/plain-turn/stdout.expected.jsonl b/packages/support/acp-snapshot/tests/fixtures/suite/plain-turn/stdout.expected.jsonl index d0242ae39f..bed4c47530 100644 --- a/packages/support/acp-snapshot/tests/fixtures/suite/plain-turn/stdout.expected.jsonl +++ b/packages/support/acp-snapshot/tests/fixtures/suite/plain-turn/stdout.expected.jsonl @@ -1,5 +1,5 @@ {"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentCapabilities":{"loadSession":false}}} {"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}"}} {"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"thinking about it"}}}} -{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"workspace:seed.txt"}}}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"workspace:prepared.marker,seed.txt"}}}} {"jsonrpc":"2.0","id":3,"result":{"stopReason":"end_turn"}} diff --git a/packages/support/acp-snapshot/tests/suite.spec.ts b/packages/support/acp-snapshot/tests/suite.spec.ts index 420dcef2cf..a8dec942ae 100644 --- a/packages/support/acp-snapshot/tests/suite.spec.ts +++ b/packages/support/acp-snapshot/tests/suite.spec.ts @@ -1,5 +1,5 @@ import { cpSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' -import { rm } from 'node:fs/promises' +import { rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { fileURLToPath } from 'node:url' @@ -78,6 +78,7 @@ const REPLAY_SCENARIOS: Scenario[] = [ env: { DSH_PERMISSION_MODE: 'never' }, configPath: AGENT.configPath, workspaceParent: tmpdir(), + prepareCwd: async (cwd) => { await writeFile(join(cwd, 'prepared.marker'), 'prepared\n') }, }, { name: 'no-model', hasModelTurn: false, recorded: false, headerClass: 'main' }, { name: 'blocked-log', hasModelTurn: false, comparesLog: true, recorded: false, headerClass: 'main' }, diff --git a/packages/ui/app-boot/README.i18n.yaml b/packages/ui/app-boot/README.i18n.yaml index 08a274fd25..d58dfc1575 100644 --- a/packages/ui/app-boot/README.i18n.yaml +++ b/packages/ui/app-boot/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/ui/app-boot/README.md -README.md: 0282d3e9559d55c3fe5b07df133747750c06ebad -README.zh.md: b7121bbd288cd6e3f9ef2301de6018ceb380eb06 +README.md: 2ccf03a2b30a416334e3b8dbe806875019213a31 +README.zh.md: 15c13adad5064df5e882f55dbef367c146376c6d diff --git a/packages/ui/app-boot/README.md b/packages/ui/app-boot/README.md index 0282d3e955..2ccf03a2b3 100644 --- a/packages/ui/app-boot/README.md +++ b/packages/ui/app-boot/README.md @@ -11,8 +11,7 @@ Shared boot glue for the app bins ([`dsh`](../../../apps/cli/README.md), [`dsh-c | `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, reporting every unresolved plugin name as a Cordis startup failure | | `loadPersonalPatches(binName, dir?)` | Parse the optional `config.yaml` in the Harness home (default [`resolveDshHome()`](../../util/paths/README.md): `$DSH_HOME`, else `~/.dsh`) — a top-level YAML array of include `PatchOptions` (id-targeted config overrides, `insert` lists, `!!js` allowed); absent file → `undefined`, an unreadable/unparsable/non-array file throws | -| `boot(binName, absoluteConfigPath, patches?, prepare?)` | Create the root context, run optional host preparation before plugins mount (e.g. `ctx.provide(RESUME_SESSION_ID_KEY, id)`), then mount the Loader/include tree, await it, assert entries loaded, and return the root context | -| `RESUME_SESSION_ID_KEY` | Context key a bin sets through `boot`'s `prepare` hook to hand a resume session id to the booted config; the config reads it as the bare identifier `resumeSessionId` in a `!!js` expression, so resuming needs no environment variable | +| `boot(binName, absoluteConfigPath, patches?, prepare?)` | Create the root context, run optional host preparation before plugins mount (`prepare` is where a bin provides launcher-owned context slots a mounted app reads, such as [`MAIN_SESSION_ID_KEY`](../tui/README.md)), then mount the Loader/include tree, await it, assert entries loaded, and 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 | diff --git a/packages/ui/app-boot/README.zh.md b/packages/ui/app-boot/README.zh.md index b7121bbd28..15c13adad5 100644 --- a/packages/ui/app-boot/README.zh.md +++ b/packages/ui/app-boot/README.zh.md @@ -11,8 +11,7 @@ | `installFailLoud(binName, proc?)` | 将 `boot()` 之后未处理的 Loader rejection 转换为一行带标签的 stderr 消息并执行 `exit(1)`;返回卸载函数(供测试使用) | | `assertEntriesLoaded(ctx, binName)` | 树结算后,如果其中存在已启用但没有 fiber 的条目,则抛出异常,并以 Cordis 启动故障的形式报告每个未解析插件的名称 | | `loadPersonalPatches(binName, dir?)` | 解析 Harness home 中可选的 `config.yaml`(默认使用 [`resolveDshHome()`](../../util/paths/README.md):先取 `$DSH_HOME`,否则取 `~/.dsh`):其顶层是一个 YAML 数组,内容为 include 的 `PatchOptions`(按 id 定位的配置覆盖、`insert` 列表,允许 `!!js`);文件不存在时返回 `undefined`,文件不可读、不可解析或内容不是数组时抛出异常 | -| `boot(binName, absoluteConfigPath, patches?, prepare?)` | 创建根上下文,在插件挂载前执行可选的宿主准备操作(例如 `ctx.provide(RESUME_SESSION_ID_KEY, id)`),再挂载 Loader/include 树并等待其结算,断言所有条目均已加载,最后返回根上下文 | -| `RESUME_SESSION_ID_KEY` | bin 通过 `boot` 的 `prepare` 钩子设置的上下文键,用于把要恢复的会话 id 交给已启动配置;配置以裸标识符 `resumeSessionId` 在 `!!js` 表达式中读取它,因此恢复操作无需环境变量 | +| `boot(binName, absoluteConfigPath, patches?, prepare?)` | 创建根上下文,在插件挂载前执行可选的宿主准备操作(`prepare` 正是 bin 提供由启动器拥有、供已挂载应用读取的上下文插槽之处,例如 [`MAIN_SESSION_ID_KEY`](../tui/README.md)),再挂载 Loader/include 树并等待其结算,断言所有条目均已加载,最后返回根上下文 | | `addHarnessSourceSection(ctx, sourceRoot)` | 添加全局 `harness:source` 提示词段落(顺序紧随 harness 身份、位于 persona 之前),告知 agent(智能体)自身源代码 checkout 的磁盘路径;如果已启动树没有此项服务,则不执行操作并返回 `undefined`。这里的服务是 `systemPrompt`;该段落注册到它的 fiber,因此开发环境 HMR(热模块替换)重新加载系统提示词后,它会消失直至下次启动 | | `HARNESS_SOURCE_SECTION` | `'harness:source'` 段落名称,供 `addHarnessSourceSection` 注册使用 | diff --git a/packages/ui/app-boot/src/index.ts b/packages/ui/app-boot/src/index.ts index 4644912304..9882d22603 100644 --- a/packages/ui/app-boot/src/index.ts +++ b/packages/ui/app-boot/src/index.ts @@ -156,17 +156,6 @@ export function assertEntriesLoaded(ctx: Context, binName: string): void { } } -/** - * Context key a bin sets through {@link boot}'s `prepare` hook to hand a resume - * session id to the booted config: `ctx.provide(RESUME_SESSION_ID_KEY, id)` - * makes `id` readable as the bare identifier `resumeSessionId` in a config - * `!!js` expression. The value is the bin's already-parsed id (or `undefined`), - * so resuming a session needs no environment variable. A bin that never - * provides it leaves the identifier undeclared, so configs read it defensively - * (`typeof resumeSessionId === 'string' ? resumeSessionId : undefined`). - */ -export const RESUME_SESSION_ID_KEY = 'resumeSessionId' - /** * Boot the Loader against `absoluteConfigPath` and return only after the whole * tree settles. Entry names load through the Loader's internal module loader diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 70de732701..e3be18db21 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -278,6 +278,15 @@ importers: '@deepseek-ai/dsh-session-projection-cache': specifier: workspace:^ version: link:../../packages/session-projection/session-projection-cache + '@deepseek-ai/dsh-session-registry': + specifier: workspace:^ + version: link:../../packages/session-registry/session-registry + '@deepseek-ai/dsh-session-registry-file': + specifier: workspace:^ + version: link:../../packages/session-registry/session-registry-file + '@deepseek-ai/dsh-session-registry-live': + specifier: workspace:^ + version: link:../../packages/session-registry/session-registry-live '@deepseek-ai/dsh-session-title': specifier: workspace:^ version: link:../../packages/session-title/session-title @@ -454,6 +463,9 @@ importers: '@deepseek-ai/dsh-app-boot': specifier: workspace:* version: link:../packages/ui/app-boot + '@deepseek-ai/dsh-bash': + specifier: workspace:* + version: link:../packages/bash/bash '@deepseek-ai/dsh-bash-local': specifier: workspace:* version: link:../packages/bash/bash-local @@ -553,6 +565,9 @@ importers: '@deepseek-ai/dsh-session-title-first-message-llm': specifier: workspace:* version: link:../packages/session-title/session-title-first-message-llm + '@deepseek-ai/dsh-source-guard': + specifier: workspace:* + version: link:../packages/guard/source-guard '@deepseek-ai/dsh-spill-local': specifier: workspace:* version: link:../packages/spill/spill-local @@ -586,6 +601,9 @@ importers: '@deepseek-ai/dsh-timeout-policy': specifier: workspace:* version: link:../packages/timeout/timeout-policy + '@deepseek-ai/dsh-tmux-context': + specifier: workspace:* + version: link:../packages/context/tmux-context '@deepseek-ai/dsh-token-meter': specifier: workspace:* version: link:../packages/llm/token-meter @@ -1883,6 +1901,37 @@ importers: specifier: ^4.0.0-rc.7 version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + packages/context/tmux-context: + dependencies: + schemastery: + specifier: ^3.18.0 + version: 3.18.0 + devDependencies: + '@deepseek-ai/dsh-agent': + specifier: workspace:^ + version: link:../../core/agent + '@deepseek-ai/dsh-bash': + specifier: workspace:^ + version: link:../../bash/bash + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants + '@deepseek-ai/dsh-llm': + specifier: workspace:^ + version: link:../../llm/llm + '@deepseek-ai/dsh-loader-smoke': + specifier: workspace:^ + version: link:../../support/loader-smoke + '@deepseek-ai/dsh-session': + specifier: workspace:^ + version: link:../../core/session + '@deepseek-ai/dsh-system-prompt': + specifier: workspace:^ + version: link:../../core/system-prompt + cordis: + specifier: ^4.0.0-rc.7 + version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + packages/context/workspace-context: dependencies: schemastery: @@ -2742,6 +2791,49 @@ importers: specifier: ^4.0.0-rc.7 version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + packages/guard/source-guard: + dependencies: + schemastery: + specifier: ^3.18.0 + version: 3.18.0 + devDependencies: + '@deepseek-ai/dsh-agent': + specifier: workspace:^ + version: link:../../core/agent + '@deepseek-ai/dsh-agent-loop': + specifier: workspace:^ + version: link:../../core/agent-loop + '@deepseek-ai/dsh-agent-loop-testkit': + specifier: workspace:^ + version: link:../../support/agent-loop-testkit + '@deepseek-ai/dsh-fs': + specifier: workspace:^ + version: link:../../fs/fs + '@deepseek-ai/dsh-fs-local': + specifier: workspace:^ + version: link:../../fs/fs-local + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants + '@deepseek-ai/dsh-llm': + specifier: workspace:^ + version: link:../../llm/llm + '@deepseek-ai/dsh-loader-smoke': + specifier: workspace:^ + version: link:../../support/loader-smoke + '@deepseek-ai/dsh-sandbox': + specifier: workspace:^ + version: link:../../sandbox/sandbox + '@deepseek-ai/dsh-session': + specifier: workspace:^ + version: link:../../core/session + '@deepseek-ai/dsh-tools': + specifier: workspace:^ + version: link:../../core/tools + cordis: + specifier: ^4.0.0-rc.7 + version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + packages/hooks/hook-protocol: devDependencies: '@deepseek-ai/dsh-bash': @@ -3890,6 +3982,70 @@ importers: specifier: ^4.0.0-rc.7 version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + packages/session-registry/session-registry: + devDependencies: + '@deepseek-ai/dsh-brand': + specifier: workspace:^ + version: link:../../util/brand + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants + '@deepseek-ai/dsh-session': + specifier: workspace:^ + version: link:../../core/session + cordis: + specifier: ^4.0.0-rc.6 + version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + + packages/session-registry/session-registry-file: + dependencies: + proper-lockfile: + specifier: ^4.1.2 + version: 4.1.2 + schemastery: + specifier: ^3.15.0 + version: 3.18.0 + devDependencies: + '@deepseek-ai/dsh-brand': + specifier: workspace:^ + version: link:../../util/brand + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants + '@deepseek-ai/dsh-session': + specifier: workspace:^ + version: link:../../core/session + '@deepseek-ai/dsh-session-registry': + specifier: workspace:^ + version: link:../session-registry + '@types/proper-lockfile': + specifier: ^4.1.4 + version: 4.1.4 + cordis: + specifier: ^4.0.0-rc.6 + version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + + packages/session-registry/session-registry-live: + devDependencies: + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants + '@deepseek-ai/dsh-session': + specifier: workspace:^ + version: link:../../core/session + '@deepseek-ai/dsh-session-registry': + specifier: workspace:^ + version: link:../session-registry + '@deepseek-ai/dsh-session-registry-file': + specifier: workspace:^ + version: link:../session-registry-file + '@deepseek-ai/dsh-session-title': + specifier: workspace:^ + version: link:../../session-title/session-title + cordis: + specifier: ^4.0.0-rc.6 + version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + packages/session-title/session-title: dependencies: schemastery: @@ -8000,6 +8156,9 @@ packages: '@types/prop-types@15.7.15': resolution: {integrity: sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==} + '@types/proper-lockfile@4.1.4': + resolution: {integrity: sha512-uo2ABllncSqg9F1D4nugVl9v93RmjxF6LJzQLMLDdPaXCUIDPeOJ21Gbqi43xNKzBi/WQ0Q0dICqufzQbMjipQ==} + '@types/react-dom@18.3.7': resolution: {integrity: sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==} peerDependencies: @@ -9103,6 +9262,9 @@ packages: resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} engines: {node: '>= 0.4'} + graceful-fs@4.2.11: + resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + hachure-fill@0.5.2: resolution: {integrity: sha512-3GKBOn+m2LX9iq+JC1064cSFprJY4jL1jCXTcpnfER5HYE2l/4EfWSGzkPa/ZDBmYI0ZOEj5VHV/eKnPGkHuOg==} @@ -10090,6 +10252,9 @@ packages: process-nextick-args@2.0.1: resolution: {integrity: sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==} + proper-lockfile@4.1.2: + resolution: {integrity: sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA==} + property-information@7.2.0: resolution: {integrity: sha512-IAtzIB6sUiWaJYrX9smp3V46pBGbBeLFRGdh25kg1334VcBlD8HzhPeNIWQH9zhGmo2itIe25EHt9dQP7G5hmg==} @@ -10193,6 +10358,10 @@ packages: resolve-pkg-maps@1.0.0: resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} + retry@0.12.0: + resolution: {integrity: sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==} + engines: {node: '>= 4'} + retry@0.13.1: resolution: {integrity: sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==} engines: {node: '>= 4'} @@ -10334,6 +10503,9 @@ packages: siginfo@2.0.0: resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + signal-exit@3.0.7: + resolution: {integrity: sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==} + signal-exit@4.1.0: resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} engines: {node: '>=14'} @@ -12903,6 +13075,10 @@ snapshots: '@types/prop-types@15.7.15': {} + '@types/proper-lockfile@4.1.4': + dependencies: + '@types/retry': 0.12.0 + '@types/react-dom@18.3.7(@types/react@18.3.31)': dependencies: '@types/react': 18.3.31 @@ -14204,6 +14380,8 @@ snapshots: gopd@1.2.0: {} + graceful-fs@4.2.11: {} + hachure-fill@0.5.2: {} handlebars@4.7.9: @@ -15384,6 +15562,12 @@ snapshots: process-nextick-args@2.0.1: {} + proper-lockfile@4.1.2: + dependencies: + graceful-fs: 4.2.11 + retry: 0.12.0 + signal-exit: 3.0.7 + property-information@7.2.0: {} protobufjs@7.6.4: @@ -15533,6 +15717,8 @@ snapshots: resolve-pkg-maps@1.0.0: {} + retry@0.12.0: {} + retry@0.13.1: {} rfdc@1.4.1: {} @@ -15769,6 +15955,8 @@ snapshots: siginfo@2.0.0: {} + signal-exit@3.0.7: {} + signal-exit@4.1.0: {} sisteransi@1.0.5: {} diff --git a/scripts/gen-cordis-catalog.ts b/scripts/gen-cordis-catalog.ts index 0f3270f8ee..90a5bef153 100644 --- a/scripts/gen-cordis-catalog.ts +++ b/scripts/gen-cordis-catalog.ts @@ -229,6 +229,8 @@ const TYPE_LINK_EXEMPTIONS: Readonly> = { DirectoryPickerCapability: 'picker interaction contract is owned by packages/host/directory-picker/README.md', CreateAgentOptions: 'agent creation contract is owned by packages/core/agent/README.md', Domain: 'domain interface is owned by packages/storage/storage-domain/README.md', + SessionRegistration: 'registry publication input is owned by packages/session-registry/session-registry/README.md', + SessionRegistryRecord: 'live-session record vocabulary is owned by packages/session-registry/session-registry/README.md', DomainChanged: 'event-local snapshot is owned by packages/storage/storage-domain/src/events.ts', DomainFacility: 'domain form facility is owned by packages/storage/storage-domain/README.md', DomainImpl: 'domain implementation contract is owned by packages/storage/storage-domain/README.md', diff --git a/scripts/gen-doc-graphs.ts b/scripts/gen-doc-graphs.ts index 8846cca3eb..6df0c9acee 100644 --- a/scripts/gen-doc-graphs.ts +++ b/scripts/gen-doc-graphs.ts @@ -171,6 +171,15 @@ const SERVICE_ROLES: ServiceRole[] = [ consumers: ['apiproxy'], note: 'Owns WorkspaceId-branded records over the domain facility; stable sessionIds accounts drive Host RPC and GUI projections.', }, + { + key: 'sessionRegistry', + pkg: 'session-registry', + title: 'Live-session registry', + mode: 'seam', + implementations: ['session-registry-file'], + consumers: ['session-registry-live'], + note: 'Seam contract for live-session records; the file backend owns the lock-guarded medium, liveness is derived from the recorded pid at read time, and the publisher mirrors lifecycle and title events for `dsh list-sessions`.', + }, { key: 'sessionQuery', pkg: 'session-query', diff --git a/scripts/verify-package-readme-model-experience.ts b/scripts/verify-package-readme-model-experience.ts index fd74c6d13e..58ac34a3cf 100644 --- a/scripts/verify-package-readme-model-experience.ts +++ b/scripts/verify-package-readme-model-experience.ts @@ -98,6 +98,9 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly> = { 'packages/session-projection/session-projection': { kind: 'none', reason: 'The projection registry serves client-facing read models of already-logged session state and registers no model surface.' }, 'packages/session-projection/session-projection-cache': { kind: 'none', reason: 'The persisted cache accelerates host-side cold reads of projection state and registers no model surface.' }, 'packages/session-query/session-query': { kind: 'none', reason: 'The trusted query service exposes cloned records only to callers and registers no model surface.' }, + 'packages/session-registry/session-registry': { kind: 'none', reason: 'The seam defines the host-side listing contract and registers no model surface.' }, + 'packages/session-registry/session-registry-file': { kind: 'none', reason: 'The file backend stores host-side process records for the CLI listing surface and registers no model surface.' }, + 'packages/session-registry/session-registry-live': { kind: 'none', reason: 'The publisher mirrors lifecycle and title events into host-side process records and registers no model surface.' }, 'packages/session-query/session-query-sqlite': { kind: 'none', reason: 'The search backend returns hits only to callers and registers no model surface.' }, 'packages/telemetry/session-telemetry': { kind: 'none', reason: 'The seam observes the session stream and hands redacted copies outward; it registers no model surface.' }, 'packages/telemetry/session-telemetry-otel': { kind: 'none', reason: 'The backend forwards seam records into the OTel SDK pipeline and registers no model surface.' }, diff --git a/skills/dsh-migrate/SKILL.md b/skills/dsh-migrate/SKILL.md new file mode 100644 index 0000000000..fbea31f04f --- /dev/null +++ b/skills/dsh-migrate/SKILL.md @@ -0,0 +1,57 @@ +--- +name: dsh-migrate +description: Migrate a user's setup from another coding agent (opencode, pi, Claude Code, Codex) to DSH — porting instruction files, custom commands and skills, hooks, MCP servers, and API/env configuration into their DSH equivalents. Use when the user asks to migrate, switch, or move from another coding agent to DSH. +--- + +# DSH Migrate + +Move a user's existing coding-agent setup onto DSH: instruction files, custom commands, skills, hooks, MCP servers, and API/environment configuration. Port only what has a real DSH equivalent; tell the user plainly when something has none. + +## First: identify the source + +Ask which agent the user is migrating from if they have not said: **opencode**, **pi**, **Claude Code**, or **Codex**. The mapping differs per source. Then locate that agent's config (ask the user, or inspect the obvious locations: `~/.claude/` and `.claude/` for Claude Code, `~/.codex/` and `.codex/` for Codex, the opencode/pi config dir the user names). Read what exists before proposing changes; never invent files the user does not have. + +## DSH targets + +Every migration lands in one of these DSH surfaces. Verify the exact path against the running install rather than assuming. + +- **Workspace instructions**: DSH reads `AGENTS.md` and `CLAUDE.md` (and `AGENTS.local.md` / `CLAUDE.local.md`) from the project, walking up to the project root, plus a user-global `~/.dsh/AGENTS.md`. `CLAUDE.md` is read as-is, so a Claude Code project needs no rename. +- **Personal overlay** (user-global, applies to every DSH session): the Harness home `~/.dsh/` holds `config.yaml` (a top-level YAML array of Loader patch entries that patch the booted plugin tree), `.env` (fills environment gaps only — ambient env and the invoking directory's `.env` win), `AGENTS.md`, and `skills/`. +- **Skills**: directory-bundle or flat-Markdown skills load from `.dsh/skills/` and `.agents/skills/` in the project, and `~/.dsh/skills/` and `~/.agents/skills/` for the user. Personal skills go in `~/.dsh/skills//SKILL.md`. Use the `skill-creator` skill to author them. +- **Hooks**: DSH runs a mapped subset of an existing Claude Code or Codex hook config through compatibility bridges — no rewrite needed for the supported subset. See the per-source sections. +- **MCP servers**: DSH has no native MCP client. Reach MCP servers through the `mcporter` skill / CLI, which can call servers already configured for other tools. +- **API / model config**: DSH uses `DEEPSEEK_API_KEY` (and optional `DEEPSEEK_BASE_URL`) from `.env` (root, invoking directory, or `~/.dsh/.env`). Model and provider are chosen in the booted `cordis.yml` / personal overlay, not per-provider config files. + +## Per-source mapping + +### Claude Code + +- `CLAUDE.md` → read as-is by DSH workspace instructions; keep it, or consolidate into `AGENTS.md`. User-global rules → `~/.dsh/AGENTS.md`. +- `.claude/hooks.json` (or a settings file's `hooks` key) → the `@deepseek-ai/dsh-hooks-claude` bridge runs the mapped command-hook subset on DSH's interception seams, with `${CLAUDE_PLUGIN_ROOT}` / `${CLAUDE_PROJECT_DIR}` substitution. Add it to the booted `cordis.yml` (or personal overlay) pointing `configPath` at the existing file. Anything outside the mapped subset should become a native DSH plugin, not a shimmed hook. +- Slash commands → DSH commands are plugin-provided; there is no drop-in import. Reimplement genuinely needed ones as skills (`~/.dsh/skills/`) or plugins. +- MCP servers in Claude config → use `mcporter` to reach them; DSH has no native MCP. +- `ANTHROPIC_API_KEY` etc. do not transfer; DSH is DeepSeek-backed via `DEEPSEEK_API_KEY`. + +### Codex + +- Codex `AGENTS.md` → DSH already reads `AGENTS.md`; keep it. User-global → `~/.dsh/AGENTS.md`. +- Codex hook config → the `@deepseek-ai/dsh-hooks-codex` bridge runs a deliberate subset (`PreToolUse`, `PostToolUse`, `SessionStart`, `UserPromptSubmit`, `Stop`; regex-only matchers; no plugin env injection; no pre-tool approval/rewrite). Add the bridge to the booted config with `configPath` at the existing Codex hooks file. State the unsupported points to the user rather than implying full parity. +- MCP servers → `mcporter`. +- API/env → `DEEPSEEK_API_KEY` in `.env`. + +### opencode / pi + +- These have no compatibility bridge. Port by concept, not by file: + - Agent/system instructions → `AGENTS.md` (project) and `~/.dsh/AGENTS.md` (user-global). + - Provider/model and any plugin-style tuning → the booted `cordis.yml` or `~/.dsh/config.yaml` overlay patches; API keys → `.env`. + - Reusable prompts/commands → skills under `~/.dsh/skills/`. + - MCP servers → `mcporter`. +- pi has no native MCP by design; the `mcporter` route is the same as for DSH. + +## Do the migration + +1. Confirm the source agent and read its actual config. +2. For each capability (instructions, hooks, commands/skills, MCP, API/env), map it to the DSH target above, or tell the user it has no equivalent. +3. Write the ported files (`AGENTS.md`, `~/.dsh/AGENTS.md`, `~/.dsh/config.yaml`, `~/.dsh/.env`, skills). For hook bridges, add the plugin entry to the booted config. +4. Verify: hooks need the bridge plugin present in the running tree; MCP needs `mcporter` reachable; API needs `DEEPSEEK_API_KEY` set. Test in a real DSH session, not just on paper. +5. Summarize what was ported, what was reimplemented, and what has no DSH equivalent. diff --git a/tsconfig.base.json b/tsconfig.base.json index ca4fc68b58..62033becd0 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -87,6 +87,7 @@ "./packages/session-persistence/*/src/invariant.ts", "./packages/session-projection/*/src/invariant.ts", "./packages/session-query/*/src/invariant.ts", + "./packages/session-registry/*/src/invariant.ts", "./packages/telemetry/*/src/invariant.ts", "./packages/acp/*/src/invariant.ts", "./packages/storage/*/src/invariant.ts", @@ -178,6 +179,7 @@ "./packages/session-persistence/*/src", "./packages/session-projection/*/src", "./packages/session-query/*/src", + "./packages/session-registry/*/src", "./packages/session-title/*/src", "./packages/telemetry/*/src", "./packages/acp/*/src", diff --git a/tsconfig.host.json b/tsconfig.host.json index 2287d21a9a..71157bca2a 100644 --- a/tsconfig.host.json +++ b/tsconfig.host.json @@ -86,6 +86,7 @@ { "path": "./packages/goal/goal-session" }, { "path": "./packages/goal/command-goal" }, { "path": "./packages/context/time-context" }, + { "path": "./packages/context/tmux-context" }, { "path": "./packages/context/session-reference" }, { "path": "./packages/ui/user-interaction" }, { "path": "./packages/ui/user-approval" }, @@ -165,6 +166,7 @@ { "path": "./packages/todo/tool-todo" }, { "path": "./packages/plan/plan-mode" }, { "path": "./packages/guard/repeat-tool-guard" }, + { "path": "./packages/guard/source-guard" }, { "path": "./packages/cordis/tool-cordis" }, { "path": "./packages/hooks/hook-protocol" }, { "path": "./packages/hooks/hooks-claude" }, @@ -182,6 +184,9 @@ { "path": "./packages/lsp/lsp" }, { "path": "./packages/lsp/lsp-local" }, { "path": "./packages/lsp/tool-lsp" }, + { "path": "./packages/session-registry/session-registry" }, + { "path": "./packages/session-registry/session-registry-file" }, + { "path": "./packages/session-registry/session-registry-live" }, { "path": "./apps/cli" } ] } From f290a8b8513f82757f0348ac58a655e770ff94ff Mon Sep 17 00:00:00 2001 From: Turtle Date: Wed, 29 Jul 2026 13:58:21 +0800 Subject: [PATCH 014/113] refactor(cli)!: one shared base config with per-surface overlays MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `dsh` shipped two config trees that were 43 rows the same: apps/cli/cordis.yml composed web as 74 flat rows, while the TUI booted examples/tui-agent/cordis.yml whose single `@deepseek-ai/dsh-tui-demo` row mounted twelve plugins behind a twenty-key pass-through Config. Neither file was what its location claimed — apps/cli hardcoded the "example" as the product default and the "demo" bundle was the application — and every capability change had to be made twice. - apps/cli/base.cordis.yml holds the 43 shared rows; tui.cordis.yml and web.cordis.yml are patch lists stating only what differs per surface - overlays apply as SIBLING patch lists at one include level, because include patches never cross an include boundary. Precedence: base < surface < (--config | personal ~/.dsh/config.yaml) < launcher flag/profile patches - `--config` now applies an overlay INSTEAD OF the personal one, so a demo or test tree never inherits the user's route; new `--config-replace` boots a file as the entire tree (the old `--config` behaviour). Both survive /resume - vendor/include: index each `insert`ed row as it is added so a later patch can configure or disable it. Upstream built the id index once before the patch loop, leaving every surface-only row — the whole TUI front door — silently unpatchable from user config. Logged as local modification 8 - session identity moves to dsh-agent-loop's CONFIGURED_AGENT_IDENTITIES_KEY; dsh-tui's MAIN_SESSION_ID_KEY is deleted (only the bundle read it) - delete examples/tui-agent, examples/cordis-agent, packages/examples/tui-demo; TUI tests → apps/cli/tests, cordis e2e → packages/cordis/tool-cordis/tests, examples/code-mode survives as an overlay leaf - `dsh web` gains --config, threaded into AppCLIEntry as an extra overlay Three latent defects surfaced and are fixed here: the TUI captured the optional sessionQuery service once at construction and could permanently disable /resume when it won the mount race; the session-store root silently reverted to a project-local ./.sessions; --config-replace was dropped by the resume handoff. Verified by booting each tree through the real Loader (TUI 55 entries, web 75, zero unsettled) rather than reading YAML. All eight terminal snapshots replay byte-identically; 14/14 PTY smoke, 112/112 snapshots, 25/25 doc-sync, hygiene and lint clean. --- ...7-23-client-plugin-loading-model.i18n.yaml | 6 +- .../2026-07-23-client-plugin-loading-model.md | 2 +- ...26-07-23-client-plugin-loading-model.zh.md | 2 +- ...tree-boot-and-transport-layering.i18n.yaml | 6 +- ...config-tree-boot-and-transport-layering.md | 2 +- ...fig-tree-boot-and-transport-layering.zh.md | 2 +- ...-native-typescript-source-launch.i18n.yaml | 4 +- ...-28-dsh-native-typescript-source-launch.md | 2 +- ...-dsh-native-typescript-source-launch.zh.md | 2 +- ...8-launcher-owned-resume-identity.i18n.yaml | 4 +- ...26-07-28-launcher-owned-resume-identity.md | 20 +- ...07-28-launcher-owned-resume-identity.zh.md | 20 +- ...-self-referential-cordis-toolset.i18n.yaml | 4 +- ...6-07-08-self-referential-cordis-toolset.md | 2 +- ...7-08-self-referential-cordis-toolset.zh.md | 2 +- ...cated-full-screen-tui-front-door.i18n.yaml | 4 +- ...17-dedicated-full-screen-tui-front-door.md | 2 +- ...dedicated-full-screen-tui-front-door.zh.md | 2 +- ...07-24-web-session-model-selector.i18n.yaml | 4 +- .../2026-07-24-web-session-model-selector.md | 2 +- ...026-07-24-web-session-model-selector.zh.md | 2 +- ...026-07-28-cross-workspace-resume.i18n.yaml | 4 +- .../2026-07-28-cross-workspace-resume.md | 6 +- .../2026-07-28-cross-workspace-resume.zh.md | 6 +- ...sh-guided-skill-session-commands.i18n.yaml | 4 +- ...07-28-dsh-guided-skill-session-commands.md | 4 +- ...28-dsh-guided-skill-session-commands.zh.md | 4 +- ...-20-remove-stdio-and-echo-agents.i18n.yaml | 6 +- ...2026-07-20-remove-stdio-and-echo-agents.md | 2 +- ...6-07-20-remove-stdio-and-echo-agents.zh.md | 2 +- ...7-29-shared-base-config-overlays.i18n.yaml | 6 + .../2026-07-29-shared-base-config-overlays.md | 53 +++ ...26-07-29-shared-base-config-overlays.zh.md | 53 +++ ...-18-tui-terminal-state-snapshots.i18n.yaml | 4 +- ...2026-07-18-tui-terminal-state-snapshots.md | 2 +- ...6-07-18-tui-terminal-state-snapshots.zh.md | 2 +- ...6-07-24-web-gui-browser-e2e-lane.i18n.yaml | 4 +- .../2026-07-24-web-gui-browser-e2e-lane.md | 4 +- .../2026-07-24-web-gui-browser-e2e-lane.zh.md | 4 +- apps/cli/README.i18n.yaml | 4 +- apps/cli/README.md | 2 +- apps/cli/README.zh.md | 2 +- apps/cli/base.cordis.yml | 210 +++++++++ .../tui-agent => apps/cli}/composition.md | 145 +++--- apps/cli/cordis.yml | 420 ------------------ apps/cli/package.json | 3 +- apps/cli/src/app-cli-entry.ts | 62 ++- apps/cli/src/args.ts | 32 +- apps/cli/src/bin.ts | 4 +- apps/cli/src/headless.ts | 3 +- apps/cli/src/tui.ts | 62 ++- apps/cli/src/web.ts | 12 +- .../cli}/tests/fixtures/tui-scripted-llm.ts | 0 .../tests/fixtures/tui-scripted.cordis.yml | 71 +++ .../cli}/tests/pty-harness.ts | 0 .../bash-terminal-card/session.jsonl | 0 .../bash-terminal-card/terminal.expected.txt | 0 .../code-mode-dispatch-spill/session.jsonl | 0 .../terminal.expected.txt | 0 .../tests/snapshots/code-mode/session.jsonl | 0 .../snapshots/code-mode/terminal.expected.txt | 0 .../cordis-dynamic-toolchain/session.1.jsonl | 0 .../cordis-dynamic-toolchain/session.2.jsonl | 0 .../cordis-dynamic-toolchain/session.jsonl | 0 .../terminal.expected.txt | 0 .../dynamic-workflow/session.1.jsonl | 0 .../snapshots/dynamic-workflow/session.jsonl | 0 .../dynamic-workflow/terminal.expected.txt | 0 .../multi-turn-conversation/session.jsonl | 0 .../terminal.expected.txt | 0 .../parallel-file-reads/session.jsonl | 0 .../parallel-file-reads/terminal.expected.txt | 0 .../parallel-file-reads/workspace/a.txt | 0 .../parallel-file-reads/workspace/b.txt | 0 .../tests/snapshots/todo-plan/session.jsonl | 0 .../snapshots/todo-plan/terminal.expected.txt | 0 .../cli}/tests/tui-keyless-smoke.e2e.ts | 81 ++-- .../cli}/tests/tui.snapshot.ts | 0 apps/cli/tui.cordis.yml | 131 ++++++ apps/cli/web.cordis.yml | 167 +++++++ docs/capability-seams.md | 4 +- docs/config-catalog.md | 55 +-- docs/cookbook/extension-cookbook.i18n.yaml | 4 +- docs/cookbook/extension-cookbook.md | 2 +- docs/cookbook/extension-cookbook.zh.md | 2 +- docs/cordis-catalog/events.md | 2 +- docs/cordis-catalog/services.md | 2 +- .../cordis-tutorial/01-first-plugin.i18n.yaml | 6 +- docs/cordis-tutorial/01-first-plugin.md | 2 +- docs/cordis-tutorial/01-first-plugin.zh.md | 2 +- docs/development.i18n.yaml | 4 +- docs/development.md | 2 +- docs/development.zh.md | 2 +- docs/event-producer-consumer.md | 2 +- docs/graph-atlas.md | 3 +- docs/module-graph.md | 20 - docs/testing.i18n.yaml | 4 +- docs/testing.md | 2 +- docs/testing.zh.md | 2 +- docs/tool-catalog.md | 8 +- .../develop/practice/llm-adapter.i18n.yaml | 6 +- docs/user/develop/practice/llm-adapter.md | 8 +- docs/user/develop/practice/llm-adapter.zh.md | 8 +- docs/user/guide/config.i18n.yaml | 6 +- docs/user/guide/config.md | 13 +- docs/user/guide/config.zh.md | 13 +- docs/user/guide/index.i18n.yaml | 6 +- docs/user/guide/index.md | 15 +- docs/user/guide/index.zh.md | 15 +- docs/user/guide/quickstart.i18n.yaml | 6 +- docs/user/guide/quickstart.md | 2 +- docs/user/guide/quickstart.zh.md | 2 +- examples/README.i18n.yaml | 5 + examples/README.md | 12 +- examples/README.zh.md | 16 +- .../{tui-agent => code-mode}/README.i18n.yaml | 6 + examples/code-mode/README.md | 35 ++ examples/code-mode/README.zh.md | 35 ++ examples/code-mode/cordis.yml | 36 ++ examples/code-mode/package.json | 7 + examples/cordis-agent/README.md | 37 -- examples/cordis-agent/composition.md | 55 --- examples/cordis-agent/cordis.yml | 87 ---- examples/cordis-agent/package.json | 7 - .../cordis-agent/tests/cordis-tools.e2e.ts | 163 ------- examples/cordis-agent/tests/harness.ts | 41 -- .../cordis-agent/tests/keyless-smoke.e2e.ts | 23 - examples/package.json | 31 +- examples/tui-agent/README.md | 80 ---- examples/tui-agent/code-mode.cordis.yml | 30 -- examples/tui-agent/cordis.yml | 201 --------- examples/tui-agent/package.json | 7 - .../tests/fixtures/tui-scripted.cordis.yml | 53 --- examples/web-cordis/cordis.yml | 29 +- knip.json | 19 +- packages/client/AGENTS.md | 2 +- packages/core/agent-loop/src/index.ts | 57 +++ packages/examples/README.i18n.yaml | 5 + packages/examples/README.md | 3 +- packages/examples/README.zh.md | 8 + .../agent-spine-demo/README.i18n.yaml | 5 + packages/examples/agent-spine-demo/README.md | 2 +- .../examples/agent-spine-demo/README.zh.md | 4 + packages/examples/tui-demo/README.md | 112 ----- packages/examples/tui-demo/package.json | 76 ---- packages/examples/tui-demo/src/index.ts | 169 ------- packages/examples/tui-demo/src/invariant.ts | 30 -- .../examples/tui-demo/tests/tui-agent.spec.ts | 203 --------- packages/examples/tui-demo/tsconfig.json | 66 --- packages/examples/tui-demo/tsdown.config.ts | 19 - packages/guard/source-guard/README.i18n.yaml | 4 +- packages/guard/source-guard/README.md | 2 +- packages/guard/source-guard/README.zh.md | 2 +- packages/host/apiproxy/README.i18n.yaml | 4 +- packages/host/apiproxy/README.md | 2 +- packages/host/apiproxy/README.zh.md | 2 +- packages/todo/README.i18n.yaml | 5 + packages/todo/README.md | 2 +- packages/todo/README.zh.md | 4 + packages/todo/tool-todo/README.i18n.yaml | 5 + packages/todo/tool-todo/README.md | 2 +- packages/todo/tool-todo/README.zh.md | 4 + packages/ui/README.i18n.yaml | 5 + packages/ui/README.md | 2 +- packages/ui/README.zh.md | 4 + packages/ui/app-boot/src/index.ts | 46 +- .../ui/app-boot/tests/config-reload.spec.ts | 49 ++ packages/ui/tui/src/chat/resume.ts | 23 +- packages/ui/tui/src/index.ts | 5 +- pnpm-lock.yaml | 134 +++--- scripts/demo-code-mode.mjs | 2 +- scripts/demo-cordis.mjs | 14 +- scripts/gen-doc-graphs.ts | 30 +- scripts/gen-tool-catalog.ts | 4 +- scripts/run-gates.ts | 2 +- .../request-response.expected.json | 4 +- scripts/verify-cordis-config.ts | 2 +- tsconfig.base.json | 1 + tsconfig.host.json | 1 - vendor/README.md | 2 +- vendor/include/src/index.ts | 6 + vitest.snapshot.config.ts | 2 + 182 files changed, 1659 insertions(+), 2400 deletions(-) create mode 100644 .agents/notes/implemented/simplification/2026-07-29-shared-base-config-overlays.i18n.yaml create mode 100644 .agents/notes/implemented/simplification/2026-07-29-shared-base-config-overlays.md create mode 100644 .agents/notes/implemented/simplification/2026-07-29-shared-base-config-overlays.zh.md create mode 100644 apps/cli/base.cordis.yml rename {examples/tui-agent => apps/cli}/composition.md (51%) delete mode 100644 apps/cli/cordis.yml rename {examples/tui-agent => apps/cli}/tests/fixtures/tui-scripted-llm.ts (100%) create mode 100644 apps/cli/tests/fixtures/tui-scripted.cordis.yml rename {examples/tui-agent => apps/cli}/tests/pty-harness.ts (100%) rename {examples/tui-agent => apps/cli}/tests/snapshots/bash-terminal-card/session.jsonl (100%) rename {examples/tui-agent => apps/cli}/tests/snapshots/bash-terminal-card/terminal.expected.txt (100%) rename {examples/tui-agent => apps/cli}/tests/snapshots/code-mode-dispatch-spill/session.jsonl (100%) rename {examples/tui-agent => apps/cli}/tests/snapshots/code-mode-dispatch-spill/terminal.expected.txt (100%) rename {examples/tui-agent => apps/cli}/tests/snapshots/code-mode/session.jsonl (100%) rename {examples/tui-agent => apps/cli}/tests/snapshots/code-mode/terminal.expected.txt (100%) rename {examples/tui-agent => apps/cli}/tests/snapshots/cordis-dynamic-toolchain/session.1.jsonl (100%) rename {examples/tui-agent => apps/cli}/tests/snapshots/cordis-dynamic-toolchain/session.2.jsonl (100%) rename {examples/tui-agent => apps/cli}/tests/snapshots/cordis-dynamic-toolchain/session.jsonl (100%) rename {examples/tui-agent => apps/cli}/tests/snapshots/cordis-dynamic-toolchain/terminal.expected.txt (100%) rename {examples/tui-agent => apps/cli}/tests/snapshots/dynamic-workflow/session.1.jsonl (100%) rename {examples/tui-agent => apps/cli}/tests/snapshots/dynamic-workflow/session.jsonl (100%) rename {examples/tui-agent => apps/cli}/tests/snapshots/dynamic-workflow/terminal.expected.txt (100%) rename {examples/tui-agent => apps/cli}/tests/snapshots/multi-turn-conversation/session.jsonl (100%) rename {examples/tui-agent => apps/cli}/tests/snapshots/multi-turn-conversation/terminal.expected.txt (100%) rename {examples/tui-agent => apps/cli}/tests/snapshots/parallel-file-reads/session.jsonl (100%) rename {examples/tui-agent => apps/cli}/tests/snapshots/parallel-file-reads/terminal.expected.txt (100%) rename {examples/tui-agent => apps/cli}/tests/snapshots/parallel-file-reads/workspace/a.txt (100%) rename {examples/tui-agent => apps/cli}/tests/snapshots/parallel-file-reads/workspace/b.txt (100%) rename {examples/tui-agent => apps/cli}/tests/snapshots/todo-plan/session.jsonl (100%) rename {examples/tui-agent => apps/cli}/tests/snapshots/todo-plan/terminal.expected.txt (100%) rename {examples/tui-agent => apps/cli}/tests/tui-keyless-smoke.e2e.ts (89%) rename {examples/tui-agent => apps/cli}/tests/tui.snapshot.ts (100%) create mode 100644 apps/cli/tui.cordis.yml create mode 100644 apps/cli/web.cordis.yml rename examples/{tui-agent => code-mode}/README.i18n.yaml (54%) create mode 100644 examples/code-mode/README.md create mode 100644 examples/code-mode/README.zh.md create mode 100644 examples/code-mode/cordis.yml create mode 100644 examples/code-mode/package.json delete mode 100644 examples/cordis-agent/README.md delete mode 100644 examples/cordis-agent/composition.md delete mode 100644 examples/cordis-agent/cordis.yml delete mode 100644 examples/cordis-agent/package.json delete mode 100644 examples/cordis-agent/tests/cordis-tools.e2e.ts delete mode 100644 examples/cordis-agent/tests/harness.ts delete mode 100644 examples/cordis-agent/tests/keyless-smoke.e2e.ts delete mode 100644 examples/tui-agent/README.md delete mode 100644 examples/tui-agent/code-mode.cordis.yml delete mode 100644 examples/tui-agent/cordis.yml delete mode 100644 examples/tui-agent/package.json delete mode 100644 examples/tui-agent/tests/fixtures/tui-scripted.cordis.yml delete mode 100644 packages/examples/tui-demo/README.md delete mode 100644 packages/examples/tui-demo/package.json delete mode 100644 packages/examples/tui-demo/src/index.ts delete mode 100644 packages/examples/tui-demo/src/invariant.ts delete mode 100644 packages/examples/tui-demo/tests/tui-agent.spec.ts delete mode 100644 packages/examples/tui-demo/tsconfig.json delete mode 100644 packages/examples/tui-demo/tsdown.config.ts diff --git a/.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.i18n.yaml index 6c1dd7368d..bab12b6785 100644 --- a/.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write -2026-07-23-client-plugin-loading-model.md: 4028f50bf5cf8a05063df3bf5e2b4b7f45a3c02e -2026-07-23-client-plugin-loading-model.zh.md: 7dc96b47c950af07adb71ba7fe131d53e5c0e51d +# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.md +2026-07-23-client-plugin-loading-model.md: ef9f14f2a150b0845ff89939bce5f8863dc15707 +2026-07-23-client-plugin-loading-model.zh.md: 0fbcbb0717be9d5aac27c33d9482c7e13bf7f4ea diff --git a/.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.md b/.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.md index 4028f50bf5..ef9f14f2a1 100644 --- a/.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.md +++ b/.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.md @@ -116,7 +116,7 @@ One governance implementation runs on both sides of the wire; the browser-specif Costs accepted: the vendored Loader carries idle machinery in the browser (EntryTree persistence is a no-op, groups/isolation unused); every plugin edit in dev pays a bundle rebuild plus fiber remount; graph `inject` rows are informational — activation truth is service-level — so a mismatch surfaces at the settled sweep, not at graph validation; and the three not-yet-promoted libraries keep their static-import export surface until their DI conversions land. -Roster endgame (landed 2026-07-25 with the config-tree boot move): the roster lives in `apps/cli/cordis.yml`, `mountWebPlugins` and the `CLIENT_PACKAGES` constant are gone, and recomposing a deployment means swapping the yml/overlay. The graph composer moved from a webserver-side registry into the `dsh-client-modules` node half (the package upgraded to dual-face per this note's promotion rule — its consumer now reaches it through cordis DI), and the transport split landed alongside: the webserver became a plain route-registration plugin, `/api/*` binding moved to the connection node half over the upgraded `api-gateway` plugin (`dsh-host-apiproxy` providing `ctx.apiProxy`), and the dev bundle watch + SSE channel moved to the hmr node half. +Roster endgame (landed 2026-07-25 with the config-tree boot move): the roster lives in `apps/cli/web.cordis.yml`, `mountWebPlugins` and the `CLIENT_PACKAGES` constant are gone, and recomposing a deployment means swapping the yml/overlay. The graph composer moved from a webserver-side registry into the `dsh-client-modules` node half (the package upgraded to dual-face per this note's promotion rule — its consumer now reaches it through cordis DI), and the transport split landed alongside: the webserver became a plain route-registration plugin, `/api/*` binding moved to the connection node half over the upgraded `api-gateway` plugin (`dsh-host-apiproxy` providing `ctx.apiProxy`), and the dev bundle watch + SSE channel moved to the hmr node half. ## Alternatives considered diff --git a/.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.zh.md b/.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.zh.md index 7dc96b47c9..0fbcbb0717 100644 --- a/.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.zh.md @@ -116,7 +116,7 @@ wire 两侧跑着同一份治理实现;浏览器特有的表面只是一套模 接受的代价:vendored Loader 在浏览器里背着闲置机件(EntryTree 持久化是 no-op,分组/隔离未用);开发期每次修改插件都要付一次 bundle 重建加 fiber 重挂;图中 `inject` 行仅是信息性说明——激活的真相在服务层——因此不匹配会在 settled 扫描时浮出,而不是在图校验时被拦下;三个尚未升格的库在各自的 DI 转换落地之前保持静态 import 的导出面。 -名册的终局(2026-07-25 随配置树 boot 迁移落地):名册住 `apps/cli/cordis.yml`,`mountWebPlugins` 与 `CLIENT_PACKAGES` 常量已消失,重组一次部署等于换 yml/overlay。图的组合器从 webserver 侧的注册表迁进 `dsh-client-modules` 的 node 半(该包按本 note 的升级法则升格为双面——其消费方现经 cordis DI 到达),传输拆分同轮落地:webserver 变为朴素路由注册插件,`/api/*` 绑定迁到 connection 的 node 半、走升格后的 `api-gateway` 插件(`dsh-host-apiproxy` 提供 `ctx.apiProxy`),dev 的 bundle 监视与 SSE 通道迁到 hmr 的 node 半。 +名册的终局(2026-07-25 随配置树 boot 迁移落地):名册住 `apps/cli/base.cordis.yml` plus its surface overlay,`mountWebPlugins` 与 `CLIENT_PACKAGES` 常量已消失,重组一次部署等于换 yml/overlay。图的组合器从 webserver 侧的注册表迁进 `dsh-client-modules` 的 node 半(该包按本 note 的升级法则升格为双面——其消费方现经 cordis DI 到达),传输拆分同轮落地:webserver 变为朴素路由注册插件,`/api/*` 绑定迁到 connection 的 node 半、走升格后的 `api-gateway` 插件(`dsh-host-apiproxy` 提供 `ctx.apiProxy`),dev 的 bundle 监视与 SSE 通道迁到 hmr 的 node 半。 ## Alternatives considered diff --git a/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.i18n.yaml index b8d0f27e13..1d723d4d94 100644 --- a/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write -2026-07-24-web-config-tree-boot-and-transport-layering.md: 99e5d0d95f320464a6f12857e9d6e90e3587c777 -2026-07-24-web-config-tree-boot-and-transport-layering.zh.md: 89c1a578f0ad0841eea7512538e3de8095b82c4d +# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.md +2026-07-24-web-config-tree-boot-and-transport-layering.md: bf1dd829af73e61f2545c1f9034bc7ffda7042ed +2026-07-24-web-config-tree-boot-and-transport-layering.zh.md: 03c1b64f3a9a17cb0f607764ea2c1a52b77551a3 diff --git a/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.md b/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.md index 99e5d0d95f..bf1dd829af 100644 --- a/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.md +++ b/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.md @@ -12,7 +12,7 @@ English | [中文](2026-07-24-web-config-tree-boot-and-transport-layering.zh.md) ## Decision -**Composition is one flat config tree.** `apps/cli/cordis.yml` holds every row — the host runtime (32 rows), the `api-gateway` row, the `webserver` row, and the `dshClient` rows (the browser roster; the modules row is simultaneously a host row). No spine bundle: every plugin is one row and every config field is yml-editable. `--dev` appends the `dsh-client-hmr` row in code before the settle sweep — prod and dev differ by exactly that row. Row order carries no load semantics; activation is service-availability driven, and the boot compensates with a fail-loud triple: `assertEntriesLoaded` (import failures), `installFailLoud` (late apply rejections), and an all-ACTIVE sweep (PENDING fibers — cordis inject waiting has no timeout). +**Composition is one flat config tree.** `apps/cli/base.cordis.yml` plus its surface overlay holds every row — the host runtime (32 rows), the `api-gateway` row, the `webserver` row, and the `dshClient` rows (the browser roster; the modules row is simultaneously a host row). No spine bundle: every plugin is one row and every config field is yml-editable. That stance later became repository-wide, with the rows both surfaces share factored into `apps/cli/base.cordis.yml` and each surface reduced to an overlay ([shared-base overlays](../simplification/2026-07-29-shared-base-config-overlays.md)). `--dev` appends the `dsh-client-hmr` row in code before the settle sweep — prod and dev differ by exactly that row. Row order carries no load semantics; activation is service-availability driven, and the boot compensates with a fail-loud triple: `assertEntriesLoaded` (import failures), `installFailLoud` (late apply rejections), and an all-ACTIVE sweep (PENDING fibers — cordis inject waiting has no timeout). **Boot glue is a class pair.** `AppCLIEntry` (apps/cli) and `AppWebEntry` (the shell kernel) hold only what must exist independently of cordis: argv facts, the composed patch set, the parsed boot manifest, the module system instance, loading-page handles — everything else lives in plugins. `AppCLIEntry.run()` is three stages: layered env (ambient > cwd `.env` > `$DSH_HOME/.env`, closing the defect above) → patch composition → Loader include boot plus the triple. `AppWebEntry.run()` mirrors it browser-side: parse `window.__DSH_BOOT__` into a `BootManifest` (two views: npm-package rows for the module table, cordis-plugin rows for entry composition; malformed wire throws), build the module system, render the loading page, prefetch the `immediately` tier in parallel with Context/Loader setup, **await the prefetch before creating entries** (materialization is `tree.import`'s synchronous require, unprotected by fiber inject waiting; cross-package require edges such as i18n → runtime/client need every immediately-tier factory registered first — an empirically found 10–25% boot race otherwise), adopt the modules entry, create the graph rows, settle, sweep. diff --git a/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.zh.md b/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.zh.md index 89c1a578f0..03c1b64f3a 100644 --- a/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.zh.md @@ -12,7 +12,7 @@ Status: implemented ## 决策 -**组合是一棵平铺 config tree。** `apps/cli/cordis.yml` 持有全部行——host runtime(32 行)、`api-gateway` 行、`webserver` 行、`dshClient` 行(浏览器 roster;modules 行同时是 host 行)。不做 spine bundle:每插件一行、每个 config 字段 yml 可改。`--dev` 在 settle sweep 之前由代码追加 `dsh-client-hmr` 行——prod 与 dev 的全部差异就是这一行。行序无装载语义;激活由服务可用性驱动,boot 以 fail-loud 三件套补偿:`assertEntriesLoaded`(import 失败)、`installFailLoud`(迟到的 apply 拒绝)、all-ACTIVE sweep(PENDING fiber——cordis inject 等待没有超时)。 +**组合是一棵平铺 config tree。** `apps/cli/base.cordis.yml` plus its surface overlay 持有全部行——host runtime(32 行)、`api-gateway` 行、`webserver` 行、`dshClient` 行(浏览器 roster;modules 行同时是 host 行)。不做 spine bundle:每插件一行、每个 config 字段 yml 可改。这一立场后来推广到全仓:两个 surface 共享的配置项被抽取进 `apps/cli/base.cordis.yml`,各 surface 则收敛为一份 overlay([共享 base overlay](../simplification/2026-07-29-shared-base-config-overlays.md))。`--dev` 在 settle sweep 之前由代码追加 `dsh-client-hmr` 行——prod 与 dev 的全部差异就是这一行。行序无装载语义;激活由服务可用性驱动,boot 以 fail-loud 三件套补偿:`assertEntriesLoaded`(import 失败)、`installFailLoud`(迟到的 apply 拒绝)、all-ACTIVE sweep(PENDING fiber——cordis inject 等待没有超时)。 **boot 胶水是一对 class。** `AppCLIEntry`(apps/cli)与 `AppWebEntry`(壳内核)只持有独立于 cordis 必须提前存在的东西:argv 事实、合成的 patch 集、解析出的 boot manifest、模块系统实例、loading 页句柄——其余一律进插件。`AppCLIEntry.run()` 三段:分层 env(ambient > cwd `.env` > `$DSH_HOME/.env`,顺手关掉上述缺陷)→ patch 合成 → Loader include boot 加三件套。`AppWebEntry.run()` 在浏览器侧镜像它:把 `window.__DSH_BOOT__` 解析成 `BootManifest`(双视角:npm 包行给模块表、cordis 插件行给 entry 组合;畸形 wire 大声抛)、建模块系统、渲染 loading 页、immediately 层预取与 Context/Loader 准备并行、**create entry 之前等预取齐**(物化是 `tree.import` 的同步 require,不受 fiber inject 等待保护;i18n → runtime/client 这类跨包 require 边要求 immediately 层工厂全部注册完——否则有实测 10–25% 的 boot 竞态)、收编 modules entry、逐图行 create、settle、sweep。 diff --git a/.agents/notes/implemented/architecture/2026-07-28-dsh-native-typescript-source-launch.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-28-dsh-native-typescript-source-launch.i18n.yaml index f3a3226c11..fae9bf86b2 100644 --- a/.agents/notes/implemented/architecture/2026-07-28-dsh-native-typescript-source-launch.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-28-dsh-native-typescript-source-launch.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-28-dsh-native-typescript-source-launch.md -2026-07-28-dsh-native-typescript-source-launch.md: 1ba1dd2663038ad7c49af71f8b428245f7fa3e2b -2026-07-28-dsh-native-typescript-source-launch.zh.md: 02f84f34820469ad9e810ae17d79d3fe12b0cd4c +2026-07-28-dsh-native-typescript-source-launch.md: 3fa6ee3711ab5a44391e5c261cff1e7c05fa495a +2026-07-28-dsh-native-typescript-source-launch.zh.md: b749687b3a8c0f0fb92826dabbcedffa21e433f8 diff --git a/.agents/notes/implemented/architecture/2026-07-28-dsh-native-typescript-source-launch.md b/.agents/notes/implemented/architecture/2026-07-28-dsh-native-typescript-source-launch.md index 1ba1dd2663..e128ae1a39 100644 --- a/.agents/notes/implemented/architecture/2026-07-28-dsh-native-typescript-source-launch.md +++ b/.agents/notes/implemented/architecture/2026-07-28-dsh-native-typescript-source-launch.md @@ -20,7 +20,7 @@ The `dsh` TUI, Web, and headless source launches use `node --experimental-transf `scripts/tspath-loader.ts` registers only a module resolve hook. It uses `TSX_TSCONFIG_PATH` when set (resolving relative values from the invoking cwd) and otherwise reads the root `tsconfig.json`; `TsconfigPathsResolver` follows that config's `extends` chain through the repository's existing TypeScript development tool, selects exact or wildcard `paths` entries according to tsconfig rules, and maps matching workspace bare specifiers to `.ts`/`.mts`/`.cts` source files or directory index files. Node remains solely responsible for code transformation. The source-only loader is not part of the built CLI and `apps/cli` does not declare `typescript` as a runtime dependency. -Source imports are redirected only when the target package is either the nearest package manifest's own name or one of that manifest's declared runtime dependencies. The Cordis Loader uses the configuration directory URL as the import parent; the resolver then searches upward for the workspace manifest that declares the plugin, so dependency ownership for `examples/tui-agent/cordis.yml` lies with `examples/package.json`, and dependency ownership for `apps/cli/cordis.yml` lies with `apps/cli/package.json`. Specifiers that do not match tsconfig paths, refer to undeclared dependencies, or are not bare all fall back to Node's default resolution. +Source imports are redirected only when the target package is either the nearest package manifest's own name or one of that manifest's declared runtime dependencies. The Cordis Loader uses the configuration directory URL as the import parent; the resolver then searches upward for the workspace manifest that declares the plugin, so dependency ownership for `examples/tui-agent/cordis.yml` lies with `examples/package.json`, and dependency ownership for `apps/cli/base.cordis.yml` plus its surface overlay lies with `apps/cli/package.json`. Specifiers that do not match tsconfig paths, refer to undeclared dependencies, or are not bare all fall back to Node's default resolution. `verify-cordis-config` performs a one-way completeness check on both resolver manifests: every bare plugin package in a configuration must appear in the corresponding manifest's `dependencies`, while the manifest may contain extra dependencies not referenced by that configuration. The root `AGENTS.md` makes updating the configuration and dependencies together a standing rule. diff --git a/.agents/notes/implemented/architecture/2026-07-28-dsh-native-typescript-source-launch.zh.md b/.agents/notes/implemented/architecture/2026-07-28-dsh-native-typescript-source-launch.zh.md index 02f84f3482..5ff1d975f8 100644 --- a/.agents/notes/implemented/architecture/2026-07-28-dsh-native-typescript-source-launch.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-28-dsh-native-typescript-source-launch.zh.md @@ -20,7 +20,7 @@ Cordis 配置还引入了另一条解析边界。`cordis.yml` 中的 bare plugin `scripts/tspath-loader.ts` 只注册一个模块 resolve hook。设置 `TSX_TSCONFIG_PATH` 时,它会使用该路径(相对路径从调用方的 cwd 解析),否则读取根 `tsconfig.json`;`TsconfigPathsResolver` 使用仓库已有的 TypeScript 开发工具沿该配置的 `extends` 链解析,按 tsconfig 规则选择精确或 wildcard `paths` 条目,并将命中的 workspace bare specifier 映射到 `.ts`/`.mts`/`.cts` 源文件或目录 index 文件。代码转换始终只由 Node 负责。该源码专用 loader 不属于构建后的 CLI,`apps/cli` 也不会把 `typescript` 声明为运行时依赖。 -只有当目标包是最近 package manifest 的自身名称或其已声明的运行时依赖时,源码 import 才会重定向。Cordis Loader 使用配置目录 URL 作为 import parent;此时 resolver 会向上查找声明该插件的 workspace manifest。因此,`examples/tui-agent/cordis.yml` 的依赖由 `examples/package.json` 持有,`apps/cli/cordis.yml` 的依赖由 `apps/cli/package.json` 持有。未命中 tsconfig paths、引用未声明依赖或不是 bare specifier 的说明符全部交回 Node 默认解析。 +只有当目标包是最近 package manifest 的自身名称或其已声明的运行时依赖时,源码 import 才会重定向。Cordis Loader 使用配置目录 URL 作为 import parent;此时 resolver 会向上查找声明该插件的 workspace manifest。因此,`examples/tui-agent/cordis.yml` 的依赖由 `examples/package.json` 持有,`apps/cli/base.cordis.yml` plus its surface overlay 的依赖由 `apps/cli/package.json` 持有。未命中 tsconfig paths、引用未声明依赖或不是 bare specifier 的说明符全部交回 Node 默认解析。 `verify-cordis-config` 对这两个解析方 manifest 执行单向完整性检查:配置中的每个 bare plugin package 都必须出现在对应 manifest 的 `dependencies` 中,manifest 可以包含该配置未引用的额外依赖。根 `AGENTS.md` 将同步更新配置和依赖定为常驻规则。 diff --git a/.agents/notes/implemented/architecture/2026-07-28-launcher-owned-resume-identity.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-28-launcher-owned-resume-identity.i18n.yaml index b63d7d3093..294898f6dd 100644 --- a/.agents/notes/implemented/architecture/2026-07-28-launcher-owned-resume-identity.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-28-launcher-owned-resume-identity.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-28-launcher-owned-resume-identity.md -2026-07-28-launcher-owned-resume-identity.md: 8c48194892f67c1a0f3f87094cd174ca1a71a383 -2026-07-28-launcher-owned-resume-identity.zh.md: 88113017986ac8aaf473d246f701c977804353bf +2026-07-28-launcher-owned-resume-identity.md: 775f57c019a0dafbb2cfb724a8fb140610234032 +2026-07-28-launcher-owned-resume-identity.zh.md: 94e1de1402586186f6b184baeb8873b1e2868551 diff --git a/.agents/notes/implemented/architecture/2026-07-28-launcher-owned-resume-identity.md b/.agents/notes/implemented/architecture/2026-07-28-launcher-owned-resume-identity.md index 8c48194892..775f57c019 100644 --- a/.agents/notes/implemented/architecture/2026-07-28-launcher-owned-resume-identity.md +++ b/.agents/notes/implemented/architecture/2026-07-28-launcher-owned-resume-identity.md @@ -6,7 +6,7 @@ English | [中文](2026-07-28-launcher-owned-resume-identity.zh.md) ## Problem -Two facts a launcher owns were shipped as deployment config keys on `dsh-tui-demo`: `resumeSessionId` (which session `main` binds to) and `resumeCommand` (the exit hint template, with `{session}` interpolated). Neither varies by deployment — both are properties of how the process was invoked, which only the launcher knows. +Two facts a launcher owns were shipped as deployment config keys on the TUI app bundle: `resumeSessionId` (which session `main` binds to) and `resumeCommand` (the exit hint template, with `{session}` interpolated). Neither varies by deployment — both are properties of how the process was invoked, which only the launcher knows. Routing them through YAML made them silently droppable. `@cordisjs/plugin-include` applies a targeted patch by replacing whole top-level keys (`target[key] = value`), so a personal `~/.dsh/config.yaml` patching the `tui-agent` entry's `config` replaces the shipped block entirely. A user overlay written to change provider and model therefore deleted every resume key it did not restate, and nothing reported it: absent `resumeCommand` legitimately means "no fallback configured". @@ -16,12 +16,14 @@ A config key cannot express these facts safely, because the deployment is not th ## Decision -Session identity and the exit line are launcher-owned context slots, provided before any Loader entry mounts. Neither appears in any `cordis.yml` or in `dsh-tui`'s or `dsh-tui-demo`'s `Config`. +Session identity and the exit line are launcher-owned context slots, provided before any Loader entry mounts. Neither appears in any `cordis.yml` nor in any plugin's `Config`. -`dsh-tui` declares both slots beside the existing `tuiResumeHost` host capability, which set the precedent — a resume host has always been a provided capability rather than config: +Both sit beside the existing `tuiResumeHost` host capability, which set the precedent — a resume host has always been a provided capability rather than config. Each slot is declared by the package that consumes it: -- `MAIN_SESSION_ID_KEY` carries a `MainSessionIdentity` (`{ id: SessionId, resume: boolean }`). `dsh-tui-demo` binds both the TUI and the configured agent to `id`, and takes the history-loading `resumeSessionId` path only when `resume` is set, because that path requires an existing log and fails loud without one. An absent slot means no launcher chose a session, so the app mints `main-session-` and creates it fresh. -- `TUI_GOODBYE_MESSAGE_KEY` carries the complete line printed once the terminal is released on exit. Absent prints nothing. +- `CONFIGURED_AGENT_IDENTITIES_KEY` (`dsh-agent-loop`) carries launcher identities keyed by configured-agent `id`, each a `LauncherAgentIdentity` (`{ id: SessionId, resume: boolean }`). `agent-loop` applies the matching identity over its configured agent, replacing both identity keys, and takes the history-loading `resumeSessionId` path only when `resume` is set, because that path requires an existing log and fails loud without one. An absent slot leaves the configured identity untouched. The `tui` row resolves the same id through its own `sessionId` key, so the front door renders exactly the agent that was bound. +- `TUI_GOODBYE_MESSAGE_KEY` (`dsh-tui`) carries the complete line printed once the terminal is released on exit. Absent prints nothing. + +Identity belongs to `agent-loop` because that is the plugin which creates configured agents, and because a patch replaces a row's whole `config`: an overlay repointing the agent row's model route would erase a launcher-set identity key. See [the shared-base overlay note](../simplification/2026-07-29-shared-base-config-overlays.md). `apps/cli` mints or selects the id and builds the line from the invocation it is reproducing, sharing one `resumeArgs` helper with the `/resume` execve handoff so the printed command and the in-place handoff cannot diverge. The line now names `--config` when one was passed, and reproduces `dsh meta --resume ` in meta mode — closing the mode-aware hint deferred by the `dsh meta` note, where a copied hint previously only worked from the checkout. @@ -33,9 +35,9 @@ The TUI owns rendering, not wording: it applies `displayText` before its own `pa ## Alternatives considered -**Keep the keys and add built-in defaults in `dsh-tui-demo`.** Rejected: a default in code survives an overlay, but two ways to state one fact remain, and a config author can still set the key wrong — which is exactly how the stale `process.env.RESUME_SESSION_ID` line disabled resume. +**Keep the keys and add built-in defaults in the app bundle.** Rejected: a default in code survives an overlay, but two ways to state one fact remain, and a config author can still set the key wrong — which is exactly how the stale `process.env.RESUME_SESSION_ID` line disabled resume. -**Merge `dsh-tui-demo` into `apps/cli` and delete the slot entirely.** Rejected after investigation, though it is the only way to remove the slot. `examples/tui-agent/code-mode.cordis.yml` patches the `tui-agent` entry through a nested `plugin-include` to switch `tools.mode` and the persona, and `examples/cordis-agent/cordis.yml` reuses the bundle as a different product; both extension points exist only because `tui-agent` is a declared config entry. Merging also moves a 162-line, 18-dependency composition into the CLI's `v8 ignore` process-wiring block, out of the per-file coverage gate. +**Merge the app bundle into `apps/cli` and delete the slot entirely.** Rejected here, then [adopted later](../simplification/2026-07-29-shared-base-config-overlays.md) in a form this note did not consider: the composition moved into flat config files (`apps/cli/base.cordis.yml` plus a per-surface overlay) rather than into CLI code, so it never entered the `v8 ignore` process-wiring block, and the overlay extension points survive as ordinary row patches. The slot itself was not deleted — it moved to `dsh-agent-loop`, because a launcher fact still cannot travel through a replaceable config key. **Put the goodbye message on `TuiResumeHost`.** Rejected: an exit line is not a handoff capability, and a host that cannot replace its process may still want to print one. They are independent slots. @@ -55,8 +57,8 @@ The TUI owns rendering, not wording: it applies `displayText` before its own `pa ## Testing -`packages/ui/tui/tests/tui.spec.ts` pins the printed line, the absent-slot silence, and escape sanitization of a hostile message; the former two exit-suppression tests are replaced, since suppression is the behavior this change removes. `packages/examples/tui-demo/tests/tui-agent.spec.ts` drives the identity slot for the resume, launcher-minted, and no-slot cases through a fake `ctx.get`. +`packages/ui/tui/tests/tui.spec.ts` pins the printed line, the absent-slot silence, and escape sanitization of a hostile message; the former two exit-suppression tests are replaced, since suppression is the behavior this change removes. `packages/core/agent-loop/tests/` drives the identity slot for the resume, launcher-minted, and no-slot cases. -The load-bearing coverage is `examples/tui-agent/tests/tui-keyless-smoke.e2e.ts`, which launches the real `apps/cli/src/bin.ts` in a PTY: one test asserts the exit line carries `--config`, and a regression test seeds a personal `config.yaml` that replaces the entire `tui-agent` config block and asserts the line still prints — encoding "an overlay cannot drop resume" as an executed contract rather than a comment. +The load-bearing coverage is `apps/cli/tests/tui-keyless-smoke.e2e.ts`, which launches the real `apps/cli/src/bin.ts` in a PTY: one test asserts the exit line carries `--config`, and a regression test seeds a personal `config.yaml` that replaces the entire `agent-loop` config block and asserts the line still prints — encoding "an overlay cannot drop resume" as an executed contract rather than a comment. Verified live in tmux against the real personal overlay: the defect reproduced on unmodified staging (requested id ignored, fresh id in the banner), and on this branch the same overlay yields a printed exit line, a `--resume` that restores the prior turn, and a `/resume` selector marking the session `current · live · persisted`. A wrong id now fails loud. diff --git a/.agents/notes/implemented/architecture/2026-07-28-launcher-owned-resume-identity.zh.md b/.agents/notes/implemented/architecture/2026-07-28-launcher-owned-resume-identity.zh.md index 8811301798..94e1de1402 100644 --- a/.agents/notes/implemented/architecture/2026-07-28-launcher-owned-resume-identity.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-28-launcher-owned-resume-identity.zh.md @@ -6,7 +6,7 @@ Status: implemented ## Problem -有两项本应由启动器持有的事实,却被作为 `dsh-tui-demo` 上的部署配置键交付:`resumeSessionId`(`main` 绑定到哪个会话)与 `resumeCommand`(退出提示的模板,其中 `{session}` 会被插值)。二者都不随部署而变——它们都是进程被如何调用的属性,而这一点只有启动器知道。 +有两项本应由启动器持有的事实,却被作为 TUI 应用组合包上的部署配置键交付:`resumeSessionId`(`main` 绑定到哪个会话)与 `resumeCommand`(退出提示的模板,其中 `{session}` 会被插值)。二者都不随部署而变——它们都是进程被如何调用的属性,而这一点只有启动器知道。 把它们经由 YAML 传递,使其可被静默丢弃。`@cordisjs/plugin-include` 施加定向补丁的方式是替换整个顶层键(`target[key] = value`),因此一份对 `tui-agent` 条目的 `config` 打补丁的个人 `~/.dsh/config.yaml`,会把交付时的整块内容整体替换掉。于是,一份为改动 provider 和 model 而写的用户 overlay,会删掉它未重述的每一个 resume 键,且没有任何东西报告这一点:缺失 `resumeCommand` 合法地意味着「未配置回退」。 @@ -16,12 +16,14 @@ Status: implemented ## Decision -会话身份与退出行是由启动器持有的上下文槽位,在任何 Loader 条目挂载之前提供。二者都不出现在任何 `cordis.yml` 中,也不出现在 `dsh-tui` 或 `dsh-tui-demo` 的 `Config` 中。 +会话身份与退出行是由启动器持有的上下文槽位,在任何 Loader 条目挂载之前提供。二者都不出现在任何 `cordis.yml` 中,也不出现在任何插件的 `Config` 中。 -`dsh-tui` 在既有的 `tuiResumeHost` 宿主能力旁声明这两个槽位,后者确立了先例——resume 宿主一直是一项被提供的能力,而非配置: +这两个槽位与既有的 `tuiResumeHost` 宿主能力并列,后者确立了先例——resume 宿主一直是一项被提供的能力,而非配置。每个槽位都由消费它的包声明: -- `MAIN_SESSION_ID_KEY` 承载一个 `MainSessionIdentity`(`{ id: SessionId, resume: boolean }`)。`dsh-tui-demo` 把 TUI 与所配置的 agent 都绑定到 `id`,并且仅当 `resume` 被置位时才走加载历史的 `resumeSessionId` 路径,因为该路径要求存在一份日志、否则会明确报错。槽位缺失意味着没有启动器选定会话,于是应用铸造 `main-session-` 并新建它。 -- `TUI_GOODBYE_MESSAGE_KEY` 承载退出时终端释放后打印一次的完整行。缺失则什么都不打印。 +- `CONFIGURED_AGENT_IDENTITIES_KEY`(`dsh-agent-loop`)按所配置 agent 的 `id` 承载启动器身份,每项为一个 `LauncherAgentIdentity`(`{ id: SessionId, resume: boolean }`)。`agent-loop` 将匹配的身份覆盖到其所配置的 agent 上,替换两个身份键;并且仅当 `resume` 被置位时才走加载历史的 `resumeSessionId` 路径,因为该路径要求存在一份日志、否则会明确报错。槽位缺失则保留配置中的身份不变。`tui` 配置项通过自身的 `sessionId` 键解析同一个 id,因此前端入口渲染的正是被绑定的那个 agent。 +- `TUI_GOODBYE_MESSAGE_KEY`(`dsh-tui`)承载退出时终端释放后打印一次的完整行。缺失则什么都不打印。 + +身份归属于 `agent-loop`,因为它才是创建所配置 agent 的插件;也因为 patch 会整体替换配置项的 `config`:重新指向 agent 配置项模型路由的 overlay 会抹掉启动器设置的身份键。参见[共享 base overlay note](../simplification/2026-07-29-shared-base-config-overlays.md)。 `apps/cli` 铸造或选定 id,并依据它所复现的那次调用构建该行,与 `/resume` 的 execve 移交共用同一个 `resumeArgs` 助手,从而使打印出的命令与原地移交不会分歧。该行现在会在传入了 `--config` 时命名它,并在 meta 模式下复现 `dsh meta --resume `——从而收口了 `dsh meta` note 所推迟的随 mode 变化的提示,在那里被复制的提示此前只有在检出目录中才有效。 @@ -33,9 +35,9 @@ TUI 持有渲染,而非措辞:它在自己的 `palette.muted` 之前先应 ## Alternatives considered -**保留这些键,并在 `dsh-tui-demo` 中加入内建默认值。** 拒绝:代码中的默认值能在 overlay 下存活,但表达同一事实的两种途径依然并存,而配置作者仍可把键设错——这正是那行陈旧的 `process.env.RESUME_SESSION_ID` 使 resume 失效的方式。 +**保留这些键,并在应用组合包中加入内建默认值。** 拒绝:代码中的默认值能在 overlay 下存活,但表达同一事实的两种途径依然并存,而配置作者仍可把键设错——这正是那行陈旧的 `process.env.RESUME_SESSION_ID` 使 resume 失效的方式。 -**把 `dsh-tui-demo` 合并进 `apps/cli` 并彻底删除该槽位。** 经调查后拒绝,尽管这是移除该槽位的唯一途径。`examples/tui-agent/code-mode.cordis.yml` 通过一个嵌套的 `plugin-include` 给 `tui-agent` 条目打补丁,以切换 `tools.mode` 与人设,而 `examples/cordis-agent/cordis.yml` 把该 bundle 作为另一款产品复用;这两个扩展点都仅因 `tui-agent` 是一个声明式配置条目才存在。合并还会把一段 162 行、18 个依赖的组合逻辑挪进 CLI 的 `v8 ignore` 进程接线块中,脱离逐文件覆盖率门禁。 +**把应用组合包合并进 `apps/cli` 并彻底删除该槽位。** 此处拒绝,但后来以本 note 未曾设想的形式[被采纳](../simplification/2026-07-29-shared-base-config-overlays.md):组合被搬进平铺的配置文件(`apps/cli/base.cordis.yml` 加各 surface 一份 overlay),而非搬进 CLI 代码,因此从未进入 `v8 ignore` 进程接线块,overlay 的扩展点也作为普通配置项 patch 保留了下来。槽位本身并未被删除——它迁移到了 `dsh-agent-loop`,因为启动器的事实依然不能经由一个可被整体替换的配置键传递。 **把 goodbye 消息放到 `TuiResumeHost` 上。** 拒绝:退出行不是一项移交能力,而一个无法替换自身进程的宿主仍可能想要打印一行。它们是相互独立的槽位。 @@ -55,8 +57,8 @@ TUI 持有渲染,而非措辞:它在自己的 `palette.muted` 之前先应 ## Testing -`packages/ui/tui/tests/tui.spec.ts` 钉住打印出的行、槽位缺失时的静默,以及对恶意消息的转义净化;此前那两个退出抑制测试被替换,因为抑制正是本次改动移除的行为。`packages/examples/tui-demo/tests/tui-agent.spec.ts` 通过一个伪造的 `ctx.get`,为 resume、启动器铸造与无槽位三种情形驱动身份槽位。 +`packages/ui/tui/tests/tui.spec.ts` 钉住打印出的行、槽位缺失时的静默,以及对恶意消息的转义净化;此前那两个退出抑制测试被替换,因为抑制正是本次改动移除的行为。`packages/core/agent-loop/tests/` 为 resume、启动器铸造与无槽位三种情形驱动身份槽位。 -承重的覆盖是 `examples/tui-agent/tests/tui-keyless-smoke.e2e.ts`,它在一个 PTY 中拉起真实的 `apps/cli/src/bin.ts`:一个测试断言退出行携带 `--config`,一个回归测试植入一份个人 `config.yaml` 来替换整块 `tui-agent` 配置块并断言该行仍会打印——把「overlay 不能丢掉 resume」编码为一条被执行的契约,而非一句注释。 +承重的覆盖是 `apps/cli/tests/tui-keyless-smoke.e2e.ts`,它在一个 PTY 中拉起真实的 `apps/cli/src/bin.ts`:一个测试断言退出行携带 `--config`,一个回归测试植入一份个人 `config.yaml` 来替换整块 `agent-loop` 配置块并断言该行仍会打印——把「overlay 不能丢掉 resume」编码为一条被执行的契约,而非一句注释。 在 tmux 中针对真实的个人 overlay 做过实测:该缺陷在未修改的 staging 上复现(所请求的 id 被忽略,banner 里是新的 id),而在本分支上同一份 overlay 会产出一行打印的退出行、一个能恢复上一轮次的 `--resume`,以及一个把该会话标记为 `current · live · persisted` 的 `/resume` 选择器。错误的 id 现在会明确报错。 diff --git a/.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.i18n.yaml b/.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.i18n.yaml index 421933e57a..56c5d930b4 100644 --- a/.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md -2026-07-08-self-referential-cordis-toolset.md: 40934fe0e2975c4e068df6ef8f31ed7921df3230 -2026-07-08-self-referential-cordis-toolset.zh.md: 13662b9359aa85895ce85391ebd5a5902cc451cc +2026-07-08-self-referential-cordis-toolset.md: 335d5e808016ebc37c8457c5dcf4d7da9d8b8c93 +2026-07-08-self-referential-cordis-toolset.zh.md: 8d2a105a2189aa23915f718c6440069d1a2aa9ed diff --git a/.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md b/.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md index 40934fe0e2..335d5e8080 100644 --- a/.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md +++ b/.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md @@ -12,7 +12,7 @@ First, model-written registration must be validated where it happens: a malforme ## Decision -The toolset ships as [`@deepseek-ai/dsh-tool-cordis`](../../../../packages/cordis/tool-cordis/README.md) and is demoed by [`examples/cordis-agent`](../../../../examples/cordis-agent/README.md). It gives the model three tools over the live Cordis runtime in the current DSH process: inspect it, mount an in-memory temporary Plugin, and unmount that Plugin to quiescence. +The toolset ships as [`@deepseek-ai/dsh-tool-cordis`](../../../../packages/cordis/tool-cordis/README.md) and is demoed by `examples/web-cordis`. It gives the model three tools over the live Cordis runtime in the current DSH process: inspect it, mount an in-memory temporary Plugin, and unmount that Plugin to quiescence. The vm isolates accidental global pollution, and the context façade hides framework internals. Neither restricts the authority of exposed services: a temporary Plugin can call `ctx.bash` with the host executor's privileges and reach the real filesystem and web services. It runs in the shared DSH runtime and may affect other sessions in that process. This is an opt-in development tool with bash-equivalent trust, not a security boundary or product default. diff --git a/.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.zh.md b/.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.zh.md index 13662b9359..8d2a105a21 100644 --- a/.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.zh.md +++ b/.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.zh.md @@ -12,7 +12,7 @@ Status: implemented ## 决策 -该工具集以 [`@deepseek-ai/dsh-tool-cordis`](../../../../packages/cordis/tool-cordis/README.md) 发布,并由 [`examples/cordis-agent`](../../../../examples/cordis-agent/README.md) 演示。它为模型提供三个工具,操作当前 DSH 进程中的活跃 Cordis 运行时:审视它、挂载一个仅存于内存的临时 Plugin,再将该 Plugin 卸载至完全停稳。 +该工具集以 [`@deepseek-ai/dsh-tool-cordis`](../../../../packages/cordis/tool-cordis/README.md) 发布,并由 `examples/web-cordis` 演示。它为模型提供三个工具,操作当前 DSH 进程中的活跃 Cordis 运行时:审视它、挂载一个仅存于内存的临时 Plugin,再将该 Plugin 卸载至完全停稳。 vm 隔离了意外的全局污染,上下文门面隐藏了框架内部细节。但二者都不限制已暴露服务的权限:临时 Plugin 可以调用 `ctx.bash` 以宿主执行器的权限运行命令,也能访问真实的文件系统和网络服务。它运行在共享 DSH runtime 中,可能影响同一进程的其他 session。这是一个需要显式启用的开发工具,信任等级与 bash 相当,不是安全边界,也不是产品默认配置。 diff --git a/.agents/notes/implemented/feature/2026-07-17-dedicated-full-screen-tui-front-door.i18n.yaml b/.agents/notes/implemented/feature/2026-07-17-dedicated-full-screen-tui-front-door.i18n.yaml index bba253325c..330dacea22 100644 --- a/.agents/notes/implemented/feature/2026-07-17-dedicated-full-screen-tui-front-door.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-17-dedicated-full-screen-tui-front-door.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-17-dedicated-full-screen-tui-front-door.md -2026-07-17-dedicated-full-screen-tui-front-door.md: bc6241e925d5bf094deded761fb96fd6b6c48a1f -2026-07-17-dedicated-full-screen-tui-front-door.zh.md: 0f5306ff547cb975b29d1fbcb7e610b3a540ce18 +2026-07-17-dedicated-full-screen-tui-front-door.md: 0f9492509a4fedcf1fcd16deed3b9cd570e3ba18 +2026-07-17-dedicated-full-screen-tui-front-door.zh.md: 0d5a508d200ee39d751070854ce9b93f78ad4121 diff --git a/.agents/notes/implemented/feature/2026-07-17-dedicated-full-screen-tui-front-door.md b/.agents/notes/implemented/feature/2026-07-17-dedicated-full-screen-tui-front-door.md index bc6241e925..0f9492509a 100644 --- a/.agents/notes/implemented/feature/2026-07-17-dedicated-full-screen-tui-front-door.md +++ b/.agents/notes/implemented/feature/2026-07-17-dedicated-full-screen-tui-front-door.md @@ -14,7 +14,7 @@ The interactive channel must remain a Cordis plugin over the same agent, session DeepSeek Harness ships [`@deepseek-ai/dsh-tui`](../../../../packages/ui/tui/README.md) as a dedicated Cordis plugin. It owns terminal input and presentation only; agent lifecycle, session persistence, tool execution, and the model-facing question tool remain separate composition entries. The plugin requires both stdin and stdout to be TTYs and fails instead of silently changing to line-oriented behavior. -The app layer has one terminal front door. `@deepseek-ai/dsh-tui-demo` mounts the TUI before the configured agent, and `examples/tui-agent` owns the interactive coding composition and Code Mode overlay directly. Non-interactive tasks use `@deepseek-ai/dsh-cli-demo`; ACP remains a separate automation protocol. +There is one terminal front door. `@deepseek-ai/dsh-tui` mounts before the configured agent, and `apps/cli/tui.cordis.yml` — an overlay over the shared `base.cordis.yml` — owns the interactive coding composition, with the Code Mode overlay in `examples/code-mode`. Non-interactive tasks use `@deepseek-ai/dsh-cli-demo`; ACP remains a separate automation protocol. The selected front door receives the exact generated or resumed `SessionId` used by the pre-created agent. It mounts before the agent composition, waits for the matching root agent, and enters full-screen mode only after that agent exists. A matching `agent-loop/config-start-failed` event is therefore reported before screen takeover and exits with status 1. diff --git a/.agents/notes/implemented/feature/2026-07-17-dedicated-full-screen-tui-front-door.zh.md b/.agents/notes/implemented/feature/2026-07-17-dedicated-full-screen-tui-front-door.zh.md index 0f5306ff54..0d5a508d20 100644 --- a/.agents/notes/implemented/feature/2026-07-17-dedicated-full-screen-tui-front-door.zh.md +++ b/.agents/notes/implemented/feature/2026-07-17-dedicated-full-screen-tui-front-door.zh.md @@ -14,7 +14,7 @@ Status: implemented DeepSeek Harness 将 [`@deepseek-ai/dsh-tui`](../../../../packages/ui/tui/README.md) 作为独立的 Cordis 插件交付。该插件只负责终端输入与呈现;agent 生命周期、会话持久化、工具执行以及模型可见的提问工具仍由不同组合项负责。插件要求 stdin 和 stdout 均为 TTY;条件不满足时会失败,不会静默切换为逐行输出。 -应用组合层只有一个终端入口。`@deepseek-ai/dsh-tui-demo` 在已配置 agent 之前挂载 TUI,`examples/tui-agent` 直接拥有交互式 coding 组装及其 Code Mode overlay。非交互任务使用 `@deepseek-ai/dsh-cli-demo`;ACP 仍是独立的自动化协议。 +只有一个终端入口。`@deepseek-ai/dsh-tui` 在已配置 agent 之前挂载,而 `apps/cli/tui.cordis.yml`——叠加在共享 `base.cordis.yml` 之上的 overlay——拥有交互式 coding 组装,Code Mode overlay 则位于 `examples/code-mode`。非交互任务使用 `@deepseek-ai/dsh-cli-demo`;ACP 仍是独立的自动化协议。 所选入口接收预创建 agent 使用的同一个新建或恢复 `SessionId`。入口先于 agent 组合挂载,等待相符的根 agent 出现,然后才进入全屏模式。因此,相符的 `agent-loop/config-start-failed` 事件会在接管屏幕前报告,并以状态码 1 退出。 diff --git a/.agents/notes/implemented/feature/2026-07-24-web-session-model-selector.i18n.yaml b/.agents/notes/implemented/feature/2026-07-24-web-session-model-selector.i18n.yaml index b65218274c..55b9d222b4 100644 --- a/.agents/notes/implemented/feature/2026-07-24-web-session-model-selector.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-24-web-session-model-selector.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-24-web-session-model-selector.md -2026-07-24-web-session-model-selector.md: 4bd52bba8d3ba16c01fc59d2b561516f6fd0bb87 -2026-07-24-web-session-model-selector.zh.md: 35f3525b80e7d27fb573e854aefc317ed354df53 +2026-07-24-web-session-model-selector.md: bf2f1f3c677ef576b1a00fb8bd0133154462d705 +2026-07-24-web-session-model-selector.zh.md: b2e17bdf487c717dc85e9b16b8f8e526557cdfb9 diff --git a/.agents/notes/implemented/feature/2026-07-24-web-session-model-selector.md b/.agents/notes/implemented/feature/2026-07-24-web-session-model-selector.md index 4bd52bba8d..bf2f1f3c67 100644 --- a/.agents/notes/implemented/feature/2026-07-24-web-session-model-selector.md +++ b/.agents/notes/implemented/feature/2026-07-24-web-session-model-selector.md @@ -18,7 +18,7 @@ The browser `ModelService` owns one `ModelDirectory` per live session. Its snaps `@deepseek-ai/dsh-client-ui-conversation` declares the session-scoped single slot `conversation.input.model` as a child of its composer-bar entry. InputBar renders the seat in its trailing controls immediately before the pending indicator and primary button; the seat receives the bar's `locked` owner prop and session scope. `@deepseek-ai/dsh-client-ui-model` occupies that seat and also contributes `/model` over the same directory. Its compact trigger displays the catalog model name and effective reasoning label, falling back to ids when metadata is absent. The upward menu first offers Model and, when the current exact model supports it, Effort; Model drills into provider groups, while Effort drills into the adapter-ordered levels. The provider-default row appears only when the adapter does not configure a model default. -The production browser roster is the flat config tree in `apps/cli/cordis.yml`; the model feature is one `dshClient` row rather than a package hardcoded in Web boot code. Its package manifest orders it after the runtime and command feature, while Cordis service injection waits for the conversation slot before registering the composer occupant. +The production browser roster is the flat config tree in `apps/cli/web.cordis.yml`; the model feature is one `dshClient` row rather than a package hardcoded in Web boot code. Its package manifest orders it after the runtime and command feature, while Cordis service injection waits for the conversation slot before registering the composer occupant. ## Alternatives considered diff --git a/.agents/notes/implemented/feature/2026-07-24-web-session-model-selector.zh.md b/.agents/notes/implemented/feature/2026-07-24-web-session-model-selector.zh.md index 35f3525b80..b2e17bdf48 100644 --- a/.agents/notes/implemented/feature/2026-07-24-web-session-model-selector.zh.md +++ b/.agents/notes/implemented/feature/2026-07-24-web-session-model-selector.zh.md @@ -18,7 +18,7 @@ Web Host 为每个新建或恢复的 agent(智能体)复用 `installAgentLlm `@deepseek-ai/dsh-client-ui-conversation` 将会话作用域的单实例 slot `conversation.input.model` 声明为其输入栏 entry 的子 slot。InputBar 在尾部控件区将该 seat 渲染于 pending 指示器与主按钮之前;该 seat 接收输入栏的 `locked` owner prop 与会话作用域。`@deepseek-ai/dsh-client-ui-model` 占用该 seat,并在同一目录上提供 `/model`。其紧凑型触发器显示目录中的模型名称与生效的推理强度标签;元数据缺失时则回退到相应 ID。向上展开的菜单首先提供 Model,并在当前精确模型支持时提供 Effort;Model 可深入提供方分组,Effort 可深入适配器排序的级别。仅当适配器没有配置模型默认值时,才显示提供方默认值行。 -生产环境的浏览器名册是 `apps/cli/cordis.yml` 中的平铺 config tree;模型功能对应其中一行 `dshClient` 配置项,而不是 Web boot 代码中硬编码的包。其包 manifest(元数据清单)将加载顺序置于运行时与命令功能之后;Cordis 服务注入则等待 conversation slot 可用,再注册 composer 占用方。 +生产环境的浏览器名册是 `apps/cli/base.cordis.yml` plus its surface overlay 中的平铺 config tree;模型功能对应其中一行 `dshClient` 配置项,而不是 Web boot 代码中硬编码的包。其包 manifest(元数据清单)将加载顺序置于运行时与命令功能之后;Cordis 服务注入则等待 conversation slot 可用,再注册 composer 占用方。 ## 考虑过的替代方案 diff --git a/.agents/notes/implemented/feature/2026-07-28-cross-workspace-resume.i18n.yaml b/.agents/notes/implemented/feature/2026-07-28-cross-workspace-resume.i18n.yaml index 60285a26b0..848b39e600 100644 --- a/.agents/notes/implemented/feature/2026-07-28-cross-workspace-resume.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-28-cross-workspace-resume.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-28-cross-workspace-resume.md -2026-07-28-cross-workspace-resume.md: 09b638398ea9379d39df94fcb42da3395cdd70df -2026-07-28-cross-workspace-resume.zh.md: 5a2e7d2535c07b4ace0416b234dc28b32cbcd2fc +2026-07-28-cross-workspace-resume.md: d559b73a5ba0f8136d20ef6dcf7c62989d1527e9 +2026-07-28-cross-workspace-resume.zh.md: 404b81cbc07a455e5553a9c227d663e491456c9e diff --git a/.agents/notes/implemented/feature/2026-07-28-cross-workspace-resume.md b/.agents/notes/implemented/feature/2026-07-28-cross-workspace-resume.md index 09b638398e..d559b73a5b 100644 --- a/.agents/notes/implemented/feature/2026-07-28-cross-workspace-resume.md +++ b/.agents/notes/implemented/feature/2026-07-28-cross-workspace-resume.md @@ -8,7 +8,7 @@ English | [中文](2026-07-28-cross-workspace-resume.zh.md) `/resume` could only reach sessions started in the launch directory, so returning to yesterday's work in another project meant remembering its path, leaving the TUI, and relaunching there. Two independent causes produced that limit, and fixing either alone changes nothing. -Storage was the binding one. The shipped `tui-demo` bundle defaulted `persistenceRoot` to a relative `./.sessions`, so each launch directory owned a disjoint JSONL root and a disjoint derived `session-query.db`. Sessions from another project were not filtered out of the listing — they were absent from the store the listing reads. The JSONL backend already partitions per-cwd *inside* one root, so the partitioning was doubled: once by root, once within it. +Storage was the binding one. The shipped TUI composition defaulted its persistence root to a relative `./.sessions`, so each launch directory owned a disjoint JSONL root and a disjoint derived `session-query.db`. Sessions from another project were not filtered out of the listing — they were absent from the store the listing reads. The JSONL backend already partitions per-cwd *inside* one root, so the partitioning was doubled: once by root, once within it. The picker then filtered again. It dropped records whose `cwd` differed from the current session before display, and `summarizeResumeCandidate` independently marked a differing `cwd` as `disabledReason: 'different workspace'`, so a foreign session that did reach the store was both hidden and refused. @@ -20,7 +20,7 @@ The dsh launcher supplies one session root under its Harness home through a boot **Storage.** `dsh-paths` owns the location as `resolveSessionsRoot()` (`sessions` under the Harness home, by `resolveDshHome`'s precedence), but only the launcher assumes it: shared-store policy is the dsh CLI's, never a plugin's. The TUI surface provides the root through the `SESSIONS_ROOT_KEY` boot slot (`ctx.provide` before Loader entries mount) and `dsh web` patches the same root in `apps/cli/src/app-cli-entry.ts`. Two CLI surfaces computing that path independently is exactly the failure this change fixes — disjoint stores — so the fact gets one home rather than a `join` per caller, alongside the existing `registryRoot()` precedent for `run`. -`tui-demo` itself keeps a project-local `./.sessions` default and reads the launcher slot between explicit config and that default (`config.persistenceRoot ?? ctx.get(SESSIONS_ROOT_KEY) ?? './.sessions'`). The precedence lives in `composeTuiApp`, not as a schemastery `.default()`, because a schema default would materialize before the compose function runs and shadow the slot for every Loader mount. `examples/tui-agent/cordis.yml` omits `persistenceRoot` so the launcher slot (or, for a bare example boot, the project-local default) applies. Configuring an explicit root always wins, which remains the correct choice for a hermetic deployment. +The shared base states that precedence in the row itself: `apps/cli/base.cordis.yml`'s `session-persistence-jsonl` row reads `root: !!js launcherSessionsRoot ?? './.sessions'`, so the launcher slot wins and a bare boot without one keeps the project-local default. Expressing it as the row's own `!!js` value rather than a schemastery `.default()` matters for the same reason it did in the bundle: a schema default would materialize before the slot could be read. An overlay or personal patch that states an explicit root always wins, which remains the correct choice for a hermetic deployment. **Scope, not exclusion.** A workspace other than the current one is a display scope rather than a disabled reason. `showResume()` summarizes every record and the `ResumePicker` owns a `scope` of `'workspace' | 'all'`, defaulting to the current workspace so the common case is unchanged. Tab toggles; the scope line names the active scope and the count the other holds; each row in the all-workspaces scope reports its own workspace, and that label joins the searchable text only in the scope that shows it. A toggle clears the query and selection so the highlighted row always belongs to the visible list, and the per-row workspace line makes a row one terminal row taller in that scope, which the visible-count budget accounts for. @@ -49,4 +49,4 @@ The dsh launcher supplies one session root under its Harness home through a boot ## Testing -TUI tests cover the default scope hiding other workspaces while reporting their count, Tab revealing them with per-row workspace labels, Tab back clearing the query and selection, searching by workspace label, a cwd-less record staying visible but disabled, and the handoff receiving both the id and the workspace re-read at preflight. The former "reject a moved cwd" case now asserts the handoff carries the new directory. `dsh-paths` tests pin `resolveSessionsRoot`'s precedence against `resolveDshHome`'s. `tui-demo` composition tests pin the project-local default and the derived `session-query.db` path. The keyless TUI snapshot pins both scopes of the selector, including the scope line, the per-row workspace lines, and the Tab hint in the footer. A manual cross-workspace resume verified at the process level that the replacement's working directory became the target workspace. +TUI tests cover the default scope hiding other workspaces while reporting their count, Tab revealing them with per-row workspace labels, Tab back clearing the query and selection, searching by workspace label, a cwd-less record staying visible but disabled, and the handoff receiving both the id and the workspace re-read at preflight. The former "reject a moved cwd" case now asserts the handoff carries the new directory. `dsh-paths` tests pin `resolveSessionsRoot`'s precedence against `resolveDshHome`'s. `apps/cli` tests pin the project-local default and the derived `session-query.db` path. The keyless TUI snapshot pins both scopes of the selector, including the scope line, the per-row workspace lines, and the Tab hint in the footer. A manual cross-workspace resume verified at the process level that the replacement's working directory became the target workspace. diff --git a/.agents/notes/implemented/feature/2026-07-28-cross-workspace-resume.zh.md b/.agents/notes/implemented/feature/2026-07-28-cross-workspace-resume.zh.md index 5a2e7d2535..404b81cbc0 100644 --- a/.agents/notes/implemented/feature/2026-07-28-cross-workspace-resume.zh.md +++ b/.agents/notes/implemented/feature/2026-07-28-cross-workspace-resume.zh.md @@ -8,7 +8,7 @@ Status: implemented `/resume` 只能触达在启动目录中创建的会话,因此要回到昨天在另一个项目里的工作,就得记住它的路径、退出 TUI、再到那里重新启动。造成这一限制的原因有两个,彼此独立,只修其中一个都不会有任何变化。 -存储是那个决定性的原因。已交付的 `tui-demo` 组合包把 `persistenceRoot` 默认成相对路径 `./.sessions`,于是每个启动目录都独占一份互不相交的 JSONL 根目录,以及一份互不相交的派生 `session-query.db`。来自另一个项目的会话并不是在列表中被过滤掉的——它们根本不存在于列表读取的存储中。JSONL 后端本来就会在*同一个*根目录*内部*按 cwd 分区,所以分区被叠加了两层:一层按根目录,一层在根目录内部。 +存储是那个决定性的原因。已交付的 TUI 组合把持久化根默认成相对路径 `./.sessions`,于是每个启动目录都独占一份互不相交的 JSONL 根目录,以及一份互不相交的派生 `session-query.db`。来自另一个项目的会话并不是在列表中被过滤掉的——它们根本不存在于列表读取的存储中。JSONL 后端本来就会在*同一个*根目录*内部*按 cwd 分区,所以分区被叠加了两层:一层按根目录,一层在根目录内部。 接着选择器又过滤了一次。它在展示前丢弃 `cwd` 与当前会话不同的记录,而 `summarizeResumeCandidate` 又独立地把不同的 `cwd` 标记为 `disabledReason: 'different workspace'`,于是一个确实进入了存储的外部会话既被隐藏,也会被拒绝。 @@ -20,7 +20,7 @@ dsh 启动器通过启动槽位提供其 Harness home 下的同一个会话根 **存储。** `dsh-paths` 以 `resolveSessionsRoot()` 拥有该位置(按 `resolveDshHome` 的优先级,取 Harness home 下的 `sessions`),但只有启动器假定它:共享存储策略属于 dsh CLI,绝不属于插件。TUI 界面通过 `SESSIONS_ROOT_KEY` 启动槽位(在 Loader 条目挂载前 `ctx.provide`)提供该根目录,`dsh web` 则在 `apps/cli/src/app-cli-entry.ts` 中为同一根目录打补丁。CLI 的两处界面各自独立计算该路径,正是本次改动所修复的那种失败——互不相交的存储——因此这项事实只有一个归属,而不是每个调用方各做一次 `join`,这与 `run` 已有的 `registryRoot()` 先例一致。 -`tui-demo` 自身保持项目本地的 `./.sessions` 默认值,并在显式配置与该默认值之间读取启动器槽位(`config.persistenceRoot ?? ctx.get(SESSIONS_ROOT_KEY) ?? './.sessions'`)。这一优先级放在 `composeTuiApp` 内,而不是写成 schemastery 的 `.default()`,因为 schema 默认值会在 compose 函数运行前物化,使每次 Loader 挂载都遮蔽该槽位。`examples/tui-agent/cordis.yml` 不写 `persistenceRoot`,因此启动器槽位(裸示例启动时则为项目本地默认值)生效。显式配置的根目录总是获胜,对于封闭部署来说这仍然是正确的选择。 +共享 base 直接在配置项自身表达这一优先级:`apps/cli/base.cordis.yml` 的 `session-persistence-jsonl` 配置项写作 `root: !!js launcherSessionsRoot ?? './.sessions'`,因此启动器槽位优先,而没有槽位的裸启动则保留项目本地默认值。把它写成配置项自身的 `!!js` 取值、而不是 schemastery 的 `.default()`,其原因与在组合包中相同:schema 默认值会在能够读取槽位之前就物化。若 overlay 或个人 patch 显式声明了根目录,则始终以其为准——对于要求自洽封闭的部署,这仍是正确选择。 **是范围,不是排除。** 当前 workspace 之外的 workspace 是一种展示范围,而不是禁用理由。`showResume()` 汇总每一条记录,`ResumePicker` 持有一个 `'workspace' | 'all'` 的 `scope`,默认为当前 workspace,因此常见场景毫无变化。Tab 切换范围;范围行会说明当前生效的范围,以及另一个范围下的数量;在全 workspace 范围中每一行都报告自己的 workspace,而该标签只在展示它的范围里才加入可搜索文本。切换范围会清空查询和选中项,使高亮行始终属于可见列表;而逐行的 workspace 行会让该范围下的每一行在终端里多占一行,可见条数预算已经把这一点计入。 @@ -49,4 +49,4 @@ dsh 启动器通过启动槽位提供其 Harness home 下的同一个会话根 ## Testing -TUI 测试覆盖默认范围隐藏其他 workspace 但报告其数量、Tab 显示它们并带上逐行 workspace 标签、再按 Tab 返回时清空查询与选中项、按 workspace 标签搜索、无 cwd 的记录仍可见但不可选,以及交接同时收到 id 和在预检时重新读取到的 workspace。原先「拒绝已移动的 cwd」的用例现在断言交接携带新目录。`dsh-paths` 测试固定 `resolveSessionsRoot` 的优先级与 `resolveDshHome` 的一致。`tui-demo` 组合测试固定项目本地默认值以及派生出的 `session-query.db` 路径。无密钥 TUI 快照固定选择器的两个范围,包括范围行、逐行 workspace 行,以及页脚中的 Tab 提示。手动执行的一次跨 workspace 恢复在进程层面验证了替换后进程的工作目录变为目标 workspace。 +TUI 测试覆盖默认范围隐藏其他 workspace 但报告其数量、Tab 显示它们并带上逐行 workspace 标签、再按 Tab 返回时清空查询与选中项、按 workspace 标签搜索、无 cwd 的记录仍可见但不可选,以及交接同时收到 id 和在预检时重新读取到的 workspace。原先「拒绝已移动的 cwd」的用例现在断言交接携带新目录。`dsh-paths` 测试固定 `resolveSessionsRoot` 的优先级与 `resolveDshHome` 的一致。`apps/cli` 测试钉住项目本地默认值与派生的 `session-query.db` 路径。无密钥 TUI 快照固定选择器的两个范围,包括范围行、逐行 workspace 行,以及页脚中的 Tab 提示。手动执行的一次跨 workspace 恢复在进程层面验证了替换后进程的工作目录变为目标 workspace。 diff --git a/.agents/notes/implemented/feature/2026-07-28-dsh-guided-skill-session-commands.i18n.yaml b/.agents/notes/implemented/feature/2026-07-28-dsh-guided-skill-session-commands.i18n.yaml index 7e8ca82fae..c60a7f0127 100644 --- a/.agents/notes/implemented/feature/2026-07-28-dsh-guided-skill-session-commands.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-28-dsh-guided-skill-session-commands.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-28-dsh-guided-skill-session-commands.md -2026-07-28-dsh-guided-skill-session-commands.md: 338629f5a1adb9c1973daf87f2f52479bd70ba47 -2026-07-28-dsh-guided-skill-session-commands.zh.md: a9a8a70212dd92b9c850a529789f4e4879838090 +2026-07-28-dsh-guided-skill-session-commands.md: a11807dca9ad1640857cd95a8b528df691b8a27e +2026-07-28-dsh-guided-skill-session-commands.zh.md: e48420ed77496d8c336031473364f9008fccced7 diff --git a/.agents/notes/implemented/feature/2026-07-28-dsh-guided-skill-session-commands.md b/.agents/notes/implemented/feature/2026-07-28-dsh-guided-skill-session-commands.md index 338629f5a1..a11807dca9 100644 --- a/.agents/notes/implemented/feature/2026-07-28-dsh-guided-skill-session-commands.md +++ b/.agents/notes/implemented/feature/2026-07-28-dsh-guided-skill-session-commands.md @@ -12,7 +12,7 @@ Two recurring flows begin with the user manually invoking one skill and answerin `dsh migrate` and `dsh upgrade` boot the ordinary TUI as a fresh session whose first turn auto-invokes a bundled skill (`dsh-migrate`, `dsh-upgrade`), exactly as if the user typed `/skill:` and pressed Enter. -The seed reuses the existing TUI skill path, not a new one. `createTuiChat` already has `invokeSkill(name, instructions)` — the code a typed `/skill:` runs, including the "Unknown skill" notice. The launcher passes the skill name to the app through a new boot-context slot `INITIAL_SKILL_KEY` (`tuiInitialSkill`), mirroring `MAIN_SESSION_ID_KEY`/`TUI_GOODBYE_MESSAGE_KEY`: `ctx.provide` is the only channel from launcher argv into a Loader-mounted plugin. The TUI's `apply()` reads the slot and folds it into `config.initialSkill`; after `ui.start()` succeeds, `createTuiChat` fires `invokeSkill(config.initialSkill, '')` once when set. +The seed reuses the existing TUI skill path, not a new one. `createTuiChat` already has `invokeSkill(name, instructions)` — the code a typed `/skill:` runs, including the "Unknown skill" notice. The launcher passes the skill name to the TUI through a new boot-context slot `INITIAL_SKILL_KEY` (`tuiInitialSkill`), mirroring `CONFIGURED_AGENT_IDENTITIES_KEY`/`TUI_GOODBYE_MESSAGE_KEY`: `ctx.provide` is the only channel from launcher argv into a Loader-mounted plugin. The TUI's `apply()` reads the slot and folds it into `config.initialSkill`; after `ui.start()` succeeds, `createTuiChat` fires `invokeSkill(config.initialSkill, '')` once when set. **Freshness is gated in the launcher, not the TUI.** `runSkillSession` always mints a fresh session and provides the slot only when `resumeSessionId === undefined`, so a later `dsh --resume ` of that session is an ordinary TUI session with no re-injection. The TUI stays generic: it invokes whatever skill it is handed, once, at startup. @@ -36,7 +36,7 @@ No keyless PTY snapshot: per the maintainer's scope call for this change, unit c **Support `--resume` on `migrate`/`upgrade`.** Rejected: these are one-shot guided entries. A resumed session is an ordinary TUI session reachable through the default surface's `dsh --resume `; re-injecting the skill on resume would duplicate the first turn. -**Read `INITIAL_SKILL_KEY` in the app bundle (like `MAIN_SESSION_ID_KEY`) rather than in the TUI's `apply()`.** Not needed: `initialSkill` is a TUI `Config` field consumed in `createTuiChat`, so folding the slot into config at the TUI entry keeps it beside the other launcher-owned runtime reads (`tuiResumeHost`, `tuiGoodbyeMessage`) and leaves the app bundle unchanged. +**Read `INITIAL_SKILL_KEY` outside the TUI (as `CONFIGURED_AGENT_IDENTITIES_KEY` is read by `agent-loop`) rather than in the TUI's `apply()`.** Not needed: `initialSkill` is a TUI `Config` field consumed in `createTuiChat`, so folding the slot into config at the TUI entry keeps it beside the other launcher-owned runtime reads (`tuiResumeHost`, `tuiGoodbyeMessage`) and touches no other plugin. ## Consequences diff --git a/.agents/notes/implemented/feature/2026-07-28-dsh-guided-skill-session-commands.zh.md b/.agents/notes/implemented/feature/2026-07-28-dsh-guided-skill-session-commands.zh.md index a9a8a70212..e48420ed77 100644 --- a/.agents/notes/implemented/feature/2026-07-28-dsh-guided-skill-session-commands.zh.md +++ b/.agents/notes/implemented/feature/2026-07-28-dsh-guided-skill-session-commands.zh.md @@ -12,7 +12,7 @@ Status: implemented `dsh migrate` 与 `dsh upgrade` 以全新会话启动普通 TUI,其首轮自动调用一个内置 skill(`dsh-migrate`、`dsh-upgrade`),效果等同于用户键入 `/skill:` 并回车。 -播种复用现有的 TUI skill 路径,而非新增一条。`createTuiChat` 已有 `invokeSkill(name, instructions)`——即键入 `/skill:` 所走的代码,包含“未知 skill”通知。启动器通过一个新的启动上下文槽 `INITIAL_SKILL_KEY`(`tuiInitialSkill`)把 skill 名称传给应用,与 `MAIN_SESSION_ID_KEY`/`TUI_GOODBYE_MESSAGE_KEY` 一致:`ctx.provide` 是从启动器 argv 进入 Loader 挂载插件的唯一通道。TUI 的 `apply()` 读取该槽并折叠进 `config.initialSkill`;`ui.start()` 成功后,`createTuiChat` 在其被设置时调用一次 `invokeSkill(config.initialSkill, '')`。 +播种复用现有的 TUI skill 路径,而非新增一条。`createTuiChat` 已有 `invokeSkill(name, instructions)`——即键入 `/skill:` 所走的代码,包含“未知 skill”通知。启动器通过一个新的启动上下文槽 `INITIAL_SKILL_KEY`(`tuiInitialSkill`)把 skill 名称传给 TUI,与 `CONFIGURED_AGENT_IDENTITIES_KEY`/`TUI_GOODBYE_MESSAGE_KEY` 一致:`ctx.provide` 是从启动器 argv 进入 Loader 挂载插件的唯一通道。TUI 的 `apply()` 读取该槽并折叠进 `config.initialSkill`;`ui.start()` 成功后,`createTuiChat` 在其被设置时调用一次 `invokeSkill(config.initialSkill, '')`。 **新鲜性在启动器而非 TUI 中把关。** `runSkillSession` 总是创建全新会话,且仅在 `resumeSessionId === undefined` 时提供该槽,因此之后 `dsh --resume ` 恢复该会话时是普通 TUI 会话,不会重复注入。TUI 保持通用:它只是把接到的 skill 在启动时调用一次。 @@ -36,7 +36,7 @@ Status: implemented **在 `migrate`/`upgrade` 上支持 `--resume`。** 已否决:它们是一次性引导入口。恢复的会话是可经默认界面 `dsh --resume ` 到达的普通 TUI 会话;恢复时重新注入 skill 会重复首轮。 -**在应用 bundle 中读取 `INITIAL_SKILL_KEY`(像 `MAIN_SESSION_ID_KEY` 那样)而非在 TUI 的 `apply()` 中。** 无此必要:`initialSkill` 是在 `createTuiChat` 中消费的 TUI `Config` 字段,因此在 TUI 入口处把该槽折叠进 config,可与其他启动器拥有的运行时读取(`tuiResumeHost`、`tuiGoodbyeMessage`)并列,且无需改动应用 bundle。 +**在 TUI 之外读取 `INITIAL_SKILL_KEY`(如同 `agent-loop` 读取 `CONFIGURED_AGENT_IDENTITIES_KEY` 那样),而非在 TUI 的 `apply()` 中。** 无此必要:`initialSkill` 是在 `createTuiChat` 中消费的 TUI `Config` 字段,因此在 TUI 入口处把该槽位折叠进配置,可以让它与其他由启动器持有的运行时读取(`tuiResumeHost`、`tuiGoodbyeMessage`)并列,且不触及任何其他插件。 ## 后果 diff --git a/.agents/notes/implemented/simplification/2026-07-20-remove-stdio-and-echo-agents.i18n.yaml b/.agents/notes/implemented/simplification/2026-07-20-remove-stdio-and-echo-agents.i18n.yaml index 89b7165650..68cca7bc54 100644 --- a/.agents/notes/implemented/simplification/2026-07-20-remove-stdio-and-echo-agents.i18n.yaml +++ b/.agents/notes/implemented/simplification/2026-07-20-remove-stdio-and-echo-agents.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write -2026-07-20-remove-stdio-and-echo-agents.md: d804f8e3c3de886fc48c87d6378c9f06d0e3c15a -2026-07-20-remove-stdio-and-echo-agents.zh.md: d42e6d527231e1703153c02c66d9286fa1e73c95 +# pnpm run verify-translation-pairing --write .agents/notes/implemented/simplification/2026-07-20-remove-stdio-and-echo-agents.md +2026-07-20-remove-stdio-and-echo-agents.md: 4b6a41489b3a1031aad6134052273035ea208e30 +2026-07-20-remove-stdio-and-echo-agents.zh.md: 95af2e74ccce4738fed713c3c58904c48517a479 diff --git a/.agents/notes/implemented/simplification/2026-07-20-remove-stdio-and-echo-agents.md b/.agents/notes/implemented/simplification/2026-07-20-remove-stdio-and-echo-agents.md index d804f8e3c3..4b6a41489b 100644 --- a/.agents/notes/implemented/simplification/2026-07-20-remove-stdio-and-echo-agents.md +++ b/.agents/notes/implemented/simplification/2026-07-20-remove-stdio-and-echo-agents.md @@ -18,7 +18,7 @@ The stdio and Echo agents are removed without compatibility packages, modes, com The remaining application roles are explicit: -- [`@deepseek-ai/dsh-tui-demo`](../../../../packages/examples/tui-demo/README.md) owns terminal-interactive execution. It rejects non-TTY streams before Loader boot; `examples/tui-agent` owns the complete coding composition, Code Mode overlay, PTY coverage, and terminal snapshots. +- [`@deepseek-ai/dsh-tui`](../../../../packages/ui/tui/README.md) owns terminal-interactive execution. It rejects non-TTY streams before Loader boot; `apps/cli/base.cordis.yml` plus the `tui.cordis.yml` overlay own the complete coding composition, with the Code Mode overlay in `examples/code-mode` and PTY plus terminal-snapshot coverage in `apps/cli/tests/`. - [`@deepseek-ai/dsh-cli-demo`](../../../../packages/examples/cli-demo/README.md) owns non-interactive execution, including pipes. `examples/headless-agent` owns the real-model one-shot composition, replay snapshots, generic real-agent suites, and test-only keyless Loader fixtures. - [`@deepseek-ai/dsh-acp-demo`](../../../../packages/examples/acp-demo/README.md) and `@deepseek-ai/dsh-jsonrpc` own their framed protocol integrations. diff --git a/.agents/notes/implemented/simplification/2026-07-20-remove-stdio-and-echo-agents.zh.md b/.agents/notes/implemented/simplification/2026-07-20-remove-stdio-and-echo-agents.zh.md index d42e6d5272..95af2e74cc 100644 --- a/.agents/notes/implemented/simplification/2026-07-20-remove-stdio-and-echo-agents.zh.md +++ b/.agents/notes/implemented/simplification/2026-07-20-remove-stdio-and-echo-agents.zh.md @@ -18,7 +18,7 @@ DeepSeek Harness 在 TUI 和 Headless coding agent 之外,还提供了两个 保留的应用角色均有明确归属: -- [`@deepseek-ai/dsh-tui-demo`](../../../../packages/examples/tui-demo/README.md) 负责终端交互式执行。它会在 Loader 启动前拒绝非 TTY 流;`examples/tui-agent` 拥有完整 coding 组装、Code Mode 覆盖层、PTY 覆盖和终端快照。 +- [`@deepseek-ai/dsh-tui`](../../../../packages/ui/tui/README.md) 负责终端交互式执行。它会在 Loader 启动前拒绝非 TTY 流;`examples/tui-agent` 拥有完整 coding 组装、Code Mode 覆盖层、PTY 覆盖和终端快照。 - [`@deepseek-ai/dsh-cli-demo`](../../../../packages/examples/cli-demo/README.md) 负责非交互式执行,包括管道方式。`examples/headless-agent` 拥有真实模型的单次任务组装、回放快照、通用真实 agent 测试套件,以及仅供测试使用的无密钥 Loader fixture。 - [`@deepseek-ai/dsh-acp-demo`](../../../../packages/examples/acp-demo/README.md) 和 `@deepseek-ai/dsh-jsonrpc` 负责各自的分帧协议集成。 diff --git a/.agents/notes/implemented/simplification/2026-07-29-shared-base-config-overlays.i18n.yaml b/.agents/notes/implemented/simplification/2026-07-29-shared-base-config-overlays.i18n.yaml new file mode 100644 index 0000000000..ac8a924568 --- /dev/null +++ b/.agents/notes/implemented/simplification/2026-07-29-shared-base-config-overlays.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/simplification/2026-07-29-shared-base-config-overlays.md +2026-07-29-shared-base-config-overlays.md: c3c1bd962dd61201958053d1f1c04e05646f78bd +2026-07-29-shared-base-config-overlays.zh.md: dcf7eaafa7f46b8c2fc1a45e627d31770ba3b234 diff --git a/.agents/notes/implemented/simplification/2026-07-29-shared-base-config-overlays.md b/.agents/notes/implemented/simplification/2026-07-29-shared-base-config-overlays.md new file mode 100644 index 0000000000..c3c1bd962d --- /dev/null +++ b/.agents/notes/implemented/simplification/2026-07-29-shared-base-config-overlays.md @@ -0,0 +1,53 @@ +# Agent Note: One shared base config with per-surface overlays + +Status: implemented + +English | [中文](2026-07-29-shared-base-config-overlays.zh.md) + +## Problem + +`dsh` shipped two full config trees that were 43 rows the same. `apps/cli/cordis.yml` composed the web surface as 74 flat rows, while the TUI booted `examples/tui-agent/cordis.yml`, whose single `@deepseek-ai/dsh-tui-demo` row mounted twelve plugins and re-declared their configuration as its own twenty-key pass-through `Config`. + +Neither file was what its location claimed. `examples/tui-agent` was not an example: `apps/cli/src/tui.ts` hardcoded it as the product's default config, and it owned the TUI PTY smoke, the eight terminal snapshot scenarios, and the PTY harness the `cordis-agent` leaf imported. `dsh-tui-demo` was not a demo either — it was the application, mounted by the shipped binary from `packages/examples/`. + +The duplication was the load-bearing problem. Of the 43 shared rows, 38 were byte-identical and 5 differed for a defensible per-surface reason, so every capability change had to be made twice and could silently drift. The bundle also inverted a default: `composeTuiApp` read `config.goals ?? {}`, so the shipped TUI mounted goals, `tool-goal`, `goal-session`, and `/goal` although no config key requested them. + +## Decision + +One shared base, one overlay per surface, composed as sibling patch lists. + +`apps/cli/base.cordis.yml` holds the 43 rows both surfaces mount. `apps/cli/tui.cordis.yml` and `apps/cli/web.cordis.yml` are **patch lists**, not trees: each states the handful of rows whose value is surface-specific and inserts its own rows. The launcher includes the base once and applies every overlay as a sibling patch list at **one** include level, because include patches never cross an include boundary — stacking overlays as nested includes would silently stop reaching base rows. + +Precedence is list order, last write winning per row: base, then the surface overlay, then either a `--config` overlay or the personal `~/.dsh/config.yaml`, then the launcher's own flag and profile patches. + +`--config ` now applies an overlay **instead of** the personal overlay, so a demo or test tree never inherits the user's provider and model. `--config-replace ` boots a file as the entire tree, bypassing base, surface overlay, and personal overlay alike; that is what the old `--config` did, so trees like `examples/web-cordis` moved to the new flag. Both flags survive the `/resume` execve handoff, or resuming would silently change the agent. + +A patch replaces its target row's whole `config` rather than merging, which shapes the split: a row whose value differs per surface lives in the overlays, never in the base, so no row is patched by three layers at once. Session identity therefore cannot ride a config key at all — it moved to `dsh-agent-loop`'s `CONFIGURED_AGENT_IDENTITIES_KEY`, as [the launcher-owned identity note](../architecture/2026-07-28-launcher-owned-resume-identity.md) now records. + +`examples/tui-agent`, `examples/cordis-agent`, and `packages/examples/tui-demo` are deleted. The TUI tests move to `apps/cli/tests/`, the cordis-toolset e2e to `packages/cordis/tool-cordis/tests/`, and `examples/code-mode` survives as a genuine example: an overlay that patches `tools.mode` and inserts the code runtime. + +## Alternatives considered + +**Leave both trees flat and duplicated.** Rejected: 43 rows maintained twice is the defect, and a gate asserting they stay identical would freeze the duplication rather than remove it. + +**Nest the overlays as includes (`code-mode` → `tui` → `base`).** Rejected after testing the Loader: patches do not cross an include boundary, so the outer file's patches are dropped with only a warning. A three-level chain left `tools` unpatchable, and a base behind one include made every personal patch a silent no-op. + +**Put the union of all rows in the base and have each overlay disable what it does not want.** Rejected: the base stops meaning "shared", and each surface carries rows it exists only to switch off. + +**Keep the per-surface rows in the base and let overlays patch them.** Adopted only for the five rows that must exist in both trees, because a patch cannot create a row. Their base entries carry the plugin name and the config both surfaces share; each overlay states the rest. + +## Consequences + +An overlay or `--config` tree that named `@deepseek-ai/dsh-tui-demo`, or patched the `tui-agent` row, no longer resolves. Overlays now patch the row that owns each key: the model route on `agent-loop`, the persona on `system-prompt`, presentation on `tui`. + +A patch whose `id` matches no row stays a Loader warning rather than an error. That is deliberate: one personal overlay is shared across surfaces, and `insert` rows match nothing by design, so a row that exists only under `web` must not fail the TUI's boot. + +`dsh web` gains `--config`, threaded into `AppCLIEntry` as an extra overlay. `AppCLIEntry` reads both the base and its surface overlay when recovering row defaults for its own patch merge, since a flag override must preserve the overlay's other fields on the same row. + +## Verification + +Composition is checked by booting each tree through the real Loader and inspecting settled entries, not by reading YAML: the TUI settles 55 entries and web 75, both with zero unloaded or unsettled rows, and web's `httpServer` up. The three-layer case (`base` + `tui` + `code-mode`) confirms `tools.mode` reaching `code` over the TUI overlay's `native`. + +All eight terminal snapshot scenarios replay byte-identically after moving, and the 14-case PTY smoke passes, including two cases that assert a personal overlay reaches an **inserted** row — the behavior the vendored `plugin-include` fix enables ([`vendor/README.md`](../../../../vendor/README.md) local modification 8, covered by `packages/ui/app-boot/tests/config-reload.spec.ts`). + +Flattening surfaced three latent defects, each fixed here: the TUI captured the optional `sessionQuery` service once at construction and so could permanently disable `/resume` when it won the mount race; the shipped session-store root silently reverted to a project-local `./.sessions`; and `--config-replace` was dropped by the resume handoff. diff --git a/.agents/notes/implemented/simplification/2026-07-29-shared-base-config-overlays.zh.md b/.agents/notes/implemented/simplification/2026-07-29-shared-base-config-overlays.zh.md new file mode 100644 index 0000000000..dcf7eaafa7 --- /dev/null +++ b/.agents/notes/implemented/simplification/2026-07-29-shared-base-config-overlays.zh.md @@ -0,0 +1,53 @@ +# Agent Note: 一份共享 base 配置加各 surface 的 overlay + +Status: implemented + +[English](2026-07-29-shared-base-config-overlays.md) | 中文 + +## 问题 + +`dsh` 交付了两棵完整的配置树,其中 43 个配置项完全相同。`apps/cli/cordis.yml` 以 74 个平铺配置项组合 web surface,而 TUI 启动的是 `examples/tui-agent/cordis.yml`——其中单独一行 `@deepseek-ai/dsh-tui-demo` 挂载了十二个插件,并把它们的配置重新声明为自己那份二十个键、仅作透传的 `Config`。 + +这两份文件都名不副实。`examples/tui-agent` 并不是示例:`apps/cli/src/tui.ts` 把它硬编码为产品的默认配置;它还拥有 TUI 的 PTY 冒烟测试、八个终端快照场景,以及被 `cordis-agent` 叶节点 import 的 PTY harness。`dsh-tui-demo` 也不是 demo——它就是应用本身,由交付的二进制从 `packages/examples/` 中挂载。 + +真正决定性的问题是重复。43 个共享配置项中,38 个逐字节相同,5 个因各 surface 的正当理由而不同;因此每次能力改动都必须改两处,而且可能无声漂移。该组合包还反转了一个默认值:`composeTuiApp` 读取 `config.goals ?? {}`,于是交付的 TUI 挂载了 goals、`tool-goal`、`goal-session` 和 `/goal`——尽管没有任何配置键要求它们。 + +## 决策 + +一份共享 base,每个 surface 一份 overlay,以平级 patch 列表的形式组合。 + +`apps/cli/base.cordis.yml` 持有两个 surface 都会挂载的 43 个配置项。`apps/cli/tui.cordis.yml` 与 `apps/cli/web.cordis.yml` 是 **patch 列表**,不是配置树:各自声明少数取值因 surface 而异的配置项,并 insert 自己的配置项。启动器只 include base 一次,并把每个 overlay 作为**同一** include 层级上的平级 patch 列表应用——因为 include patch 不会跨越 include 边界,把 overlay 堆叠成嵌套 include 会使其静默地无法触达 base 配置项。 + +优先级即列表顺序,逐配置项后写者胜:base,然后是 surface overlay,接着是 `--config` overlay 或个人 `~/.dsh/config.yaml`,最后是启动器自身的 flag 与 profile patch。 + +`--config ` 现在应用一个 overlay 来**取代**个人 overlay,因此 demo 或测试用的树绝不会继承用户的 provider 与 model。`--config-replace ` 则把某个文件作为整棵树启动,同时绕过 base、surface overlay 与个人 overlay;这正是旧 `--config` 的行为,所以像 `examples/web-cordis` 这样的树改用了新 flag。两个 flag 都会在 `/resume` 的 execve 交接中保留,否则 resume 会静默更换 agent。 + +patch 会整体替换目标配置项的 `config` 而不合并,这决定了拆分方式:取值因 surface 而异的配置项住在 overlay 中,绝不住在 base 里,从而没有任何配置项会被三层同时 patch。因此会话身份根本不能经由配置键传递——它迁移到了 `dsh-agent-loop` 的 `CONFIGURED_AGENT_IDENTITIES_KEY`,如[启动器持有身份的 note](../architecture/2026-07-28-launcher-owned-resume-identity.md) 现在所记录。 + +`examples/tui-agent`、`examples/cordis-agent` 与 `packages/examples/tui-demo` 均被删除。TUI 测试迁往 `apps/cli/tests/`,cordis 工具集的 e2e 迁入 `packages/cordis/tool-cordis/tests/`,而 `examples/code-mode` 作为一个名副其实的示例保留下来:一个 patch `tools.mode` 并 insert code runtime 的 overlay。 + +## 备选方案 + +**保留两棵平铺且重复的树。** 拒绝:43 个配置项维护两份正是缺陷本身,而用一个门禁断言二者保持一致只会固化重复,而非消除它。 + +**把 overlay 嵌套成 include(`code-mode` → `tui` → `base`)。** 在对 Loader 实测后拒绝:patch 不会跨越 include 边界,因此外层文件的 patch 只会伴随一条告警被丢弃。三层链条使 `tools` 无法被 patch,而位于一层 include 之后的 base,会让每个个人 patch 都变成静默的空操作。 + +**把所有配置项的并集放进 base,由各 overlay 禁用自己不需要的部分。** 拒绝:base 将不再意味着「共享」,而每个 surface 都要携带仅为将其关闭而存在的配置项。 + +**把因 surface 而异的配置项留在 base 中,由 overlay 去 patch。** 仅对必须同时存在于两棵树中的那五个配置项采用,因为 patch 无法创建配置项。它们在 base 中的条目携带插件名与两个 surface 共享的配置,其余部分由各 overlay 声明。 + +## 影响 + +指名 `@deepseek-ai/dsh-tui-demo`、或 patch `tui-agent` 配置项的 overlay 与 `--config` 树将不再可解析。overlay 现在要 patch 拥有对应键的那一行:模型路由在 `agent-loop`,人设在 `system-prompt`,呈现设置在 `tui`。 + +若某个 patch 的 `id` 不匹配任何配置项,Loader 仍只告警而不报错。这是有意为之:同一份个人 overlay 会跨 surface 共用,而 `insert` 配置项按设计本就不匹配任何目标,因此仅在 `web` 下存在的配置项不能让 TUI 启动失败。 + +`dsh web` 新增 `--config`,作为一份额外 overlay 传入 `AppCLIEntry`。`AppCLIEntry` 在为自身 patch 合并恢复配置项默认值时会同时读取 base 与其 surface overlay,因为 flag 覆盖必须保留同一配置项上 overlay 的其他字段。 + +## 验证 + +组合的正确性通过用真实 Loader 启动每棵树并检查已就绪的条目来核对,而不是靠阅读 YAML:TUI 就绪 55 个条目、web 就绪 75 个,两者都没有未加载或未就绪的配置项,且 web 的 `httpServer` 已启动。三层叠加的情形(`base` + `tui` + `code-mode`)确认 `tools.mode` 越过 TUI overlay 的 `native` 达到了 `code`。 + +全部八个终端快照场景在迁移后逐字节重放一致,14 个用例的 PTY 冒烟测试全部通过,其中两个用例断言个人 overlay 能触达一个 **insert 进来的**配置项——这正是 vendored `plugin-include` 修复所启用的行为([`vendor/README.md`](../../../../vendor/README.md) 本地修改第 8 条,由 `packages/ui/app-boot/tests/config-reload.spec.ts` 覆盖)。 + +平铺过程暴露出三处潜伏缺陷,均在此一并修复:TUI 曾在构造时一次性捕获可选的 `sessionQuery` 服务,因此在挂载竞争中胜出时会永久禁用 `/resume`;交付的会话存储根目录曾静默退回项目本地的 `./.sessions`;`--config-replace` 曾在 resume 交接中被丢弃。 diff --git a/.agents/notes/implemented/testing/2026-07-18-tui-terminal-state-snapshots.i18n.yaml b/.agents/notes/implemented/testing/2026-07-18-tui-terminal-state-snapshots.i18n.yaml index d491394db7..9345449920 100644 --- a/.agents/notes/implemented/testing/2026-07-18-tui-terminal-state-snapshots.i18n.yaml +++ b/.agents/notes/implemented/testing/2026-07-18-tui-terminal-state-snapshots.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/testing/2026-07-18-tui-terminal-state-snapshots.md -2026-07-18-tui-terminal-state-snapshots.md: 18c79bc2d0dabf4d78887354f30a2cdc083899e1 -2026-07-18-tui-terminal-state-snapshots.zh.md: d1d4a6ca859e94a153e0bf645a17f03c0dac234b +2026-07-18-tui-terminal-state-snapshots.md: b8e6f77d96fbd4d077d55fefa3652813c737a37b +2026-07-18-tui-terminal-state-snapshots.zh.md: 1b2f6b58e53d9ccd5f0935dab9e9812d2bfe3eb4 diff --git a/.agents/notes/implemented/testing/2026-07-18-tui-terminal-state-snapshots.md b/.agents/notes/implemented/testing/2026-07-18-tui-terminal-state-snapshots.md index 18c79bc2d0..b8e6f77d96 100644 --- a/.agents/notes/implemented/testing/2026-07-18-tui-terminal-state-snapshots.md +++ b/.agents/notes/implemented/testing/2026-07-18-tui-terminal-state-snapshots.md @@ -21,7 +21,7 @@ TUI coverage has four complementary layers: 3. `examples/tui-agent/tests/tui.snapshot.ts` replays committed JSONL session logs through the production agent loop and real tools, then compares the resulting semantic terminal state. 4. `examples/tui-agent/tests/tui-keyless-smoke.e2e.ts` boots the real Loader composition in a PTY, drives a scripted conversation through streaming and `ask_user_question`, and verifies startup, input, exit, failure reporting, and terminal restoration. -The runnable TUI has its own `examples/tui-agent` leaf beside the Headless and ACP leaves. It owns the interactive coding backends and tools directly and loads `@deepseek-ai/dsh-tui-demo`; TUI snapshots and PTY tests live with that leaf. The [redundant-agent removal](../simplification/2026-07-20-remove-stdio-and-echo-agents.md) owns this consolidation. +The runnable TUI is the shipped `apps/cli` composition: the shared `base.cordis.yml` plus the `tui.cordis.yml` overlay, which owns the interactive coding backends, tools, and front door. TUI snapshots and PTY tests live in `apps/cli/tests/`. The [redundant-agent removal](../simplification/2026-07-20-remove-stdio-and-echo-agents.md) owns this consolidation. ### Recorded-session replay diff --git a/.agents/notes/implemented/testing/2026-07-18-tui-terminal-state-snapshots.zh.md b/.agents/notes/implemented/testing/2026-07-18-tui-terminal-state-snapshots.zh.md index d1d4a6ca85..1b2f6b58e5 100644 --- a/.agents/notes/implemented/testing/2026-07-18-tui-terminal-state-snapshots.zh.md +++ b/.agents/notes/implemented/testing/2026-07-18-tui-terminal-state-snapshots.zh.md @@ -21,7 +21,7 @@ TUI 覆盖分为四个互补层次: 3. `examples/tui-agent/tests/tui.snapshot.ts` 通过生产 agent loop 和真实工具回放已提交的 JSONL 会话日志,再比较生成的语义终端状态。 4. `examples/tui-agent/tests/tui-keyless-smoke.e2e.ts` 在 PTY 中启动真实 Loader 组合,驱动一段经过流式输出和 `ask_user_question` 的脚本化会话,并验证启动、输入、退出、失败报告和终端恢复。 -可运行 TUI 在 `examples/tui-agent` 中拥有独立叶节点,与 Headless 和 ACP 叶节点并列。它直接拥有交互式 coding 后端与工具,并加载 `@deepseek-ai/dsh-tui-demo`;TUI 快照和 PTY 测试也归属这个叶节点。[移除重复 agent 的决策](../simplification/2026-07-20-remove-stdio-and-echo-agents.md)负责此次整合。 +可运行 TUI 就是交付的 `apps/cli` 组合:共享的 `base.cordis.yml` 加 `tui.cordis.yml` overlay,后者拥有交互式 coding 后端、工具与前端入口。TUI 快照和 PTY 测试位于 `apps/cli/tests/`。[移除重复 agent 的决策](../simplification/2026-07-20-remove-stdio-and-echo-agents.md)负责此次整合。 ### 已录制会话回放 diff --git a/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.i18n.yaml b/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.i18n.yaml index 0283559c9c..0863874748 100644 --- a/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.i18n.yaml +++ b/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.md -2026-07-24-web-gui-browser-e2e-lane.md: ce59dcce270d548c91e3719eee8e9c83aea0c154 -2026-07-24-web-gui-browser-e2e-lane.zh.md: bad3dd15ed7b98cc17340666a6c1094d0de057b1 +2026-07-24-web-gui-browser-e2e-lane.md: 37e2bccddb3725073ebb38377c2cd7464b418012 +2026-07-24-web-gui-browser-e2e-lane.zh.md: f2bbdbb028a3e35831298fea4d5a0f3dec410b82 diff --git a/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.md b/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.md index ce59dcce27..04240456d1 100644 --- a/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.md +++ b/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.md @@ -16,7 +16,7 @@ The web GUI ships as a real assembled chain — chromium page → client plugin A plain shared-fixture module (the [testing-policy sanctioned shape](../../../../docs/testing.md)), not a package: the gate-worthy logic — replay derivation, session parsing, log scrubbing, persistence — lives in the gated packages `dsh-llm-replay`, `dsh-acp-snapshot`, and `dsh-session-persistence-jsonl`; what remains is boot wiring and browser glue, and chromium-driving code cannot hold per-file 100% coverage on the browserless coverage runners. -`launchWebScaffold()` boots the real web composition from the shipped `apps/cli/cordis.yml` through the vendored Loader's include mechanism — the same tree and mechanism `AppCLIEntry` drives for `dsh web`. Divergences ride include patches over that tree, the ACP `cordis.snapshot.yml` pattern expressed in-process: temp `persistenceRoot`, `workspace-context` disabled (recorded fixtures must not embed this repo's AGENTS.md), `session-title-llm` disabled (its fire-and-forget title call would race the loop for the session's replay cursor), the webserver row pinned to port 0 with the built dist, and in keyless modes `llm-deepseek` disabled. A patch id that stops matching a row fails the boot sweep loudly instead of drifting. The boot runs `chdir`'d to the temp workspace so the api-gateway's `process.cwd()` session default, tool cwds, and fixtures agree; the `dsh web` bin's own glue (argv, profile json, AppCLIEntry) stays held by the keyless CLI smokes in `smoke-real.e2e.ts`. Setup rollback and ordinary close both dispose the Cordis tree before removing the two owned temp roots, attempt every cleanup independently, and report cleanup failures without masking the setup failure. +`launchWebScaffold()` boots the real web composition from the shipped `apps/cli/base.cordis.yml` plus its surface overlay through the vendored Loader's include mechanism — the same tree and mechanism `AppCLIEntry` drives for `dsh web`. Divergences ride include patches over that tree, the ACP `cordis.snapshot.yml` pattern expressed in-process: temp `persistenceRoot`, `workspace-context` disabled (recorded fixtures must not embed this repo's AGENTS.md), `session-title-llm` disabled (its fire-and-forget title call would race the loop for the session's replay cursor), the webserver row pinned to port 0 with the built dist, and in keyless modes `llm-deepseek` disabled. A patch id that stops matching a row fails the boot sweep loudly instead of drifting. The boot runs `chdir`'d to the temp workspace so the api-gateway's `process.cwd()` session default, tool cwds, and fixtures agree; the `dsh web` bin's own glue (argv, profile json, AppCLIEntry) stays held by the keyless CLI smokes in `smoke-real.e2e.ts`. Setup rollback and ordinary close both dispose the Cordis tree before removing the two owned temp roots, attempt every cleanup independently, and report cleanup failures without masking the setup failure. Keyless model displacement is the disabled adapter row plus `installLlmReplay` filling the open seam on the settled root ctx in providers-catalog mode — never catch-all: with the adapter row disabled no adapter exists, so catch-all would leave `resolveModelInfo` unroutable and `compact-basic`'s post-step pressure check would warn every step instead of being provably inert (the published 128k `contextWindow` keeps it inert for small fixtures). The direct install rather than an inserted replay plugin row is deliberate: it returns the `ReplayHandle` the teardown consumption check needs. A scenario with no fixture leaves the seam empty, so a stray stream fails loud with NO_ADAPTER. @@ -66,7 +66,7 @@ Surveyed AI-chat/agent web UIs and mocking layers (LibreChat, vercel/ai-chatbot **A committed normalized-session-log golden as a second expected surface.** Rejected: the log surface is pinned by the ACP/headless/TUI suites through the same loop and persistence; here it would double refresh cost and re-test lower tiers. Inline world-state assertions on root-context events keep the world-verification duty. -**Spawning the `dsh web` bin with a `DSH_SNAPSHOT` replay branch.** Rejected: it needs a test-only replay branch plus environment plumbing in the shipped CLI. The in-process scaffold already loads the same `apps/cli/cordis.yml`; only argv, profile JSON, and `AppCLIEntry` glue remain outside it, and the keyless CLI smokes cover those paths. +**Spawning the `dsh web` bin with a `DSH_SNAPSHOT` replay branch.** Rejected: it needs a test-only replay branch plus environment plumbing in the shipped CLI. The in-process scaffold already loads the same `apps/cli/base.cordis.yml` plus its surface overlay; only argv, profile JSON, and `AppCLIEntry` glue remain outside it, and the keyless CLI smokes cover those paths. **Changing the wire protocol for testability.** Rejected: the contract already has a first-class keyless isomorphic seam (`InProcessApiClient(toFetchHandler(api))`), the per-event unbatched SSE is exactly what makes replay observable in a browser, and testing a wire we no longer ship would invert the tier's purpose. diff --git a/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.zh.md b/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.zh.md index bad3dd15ed..6be7056b3d 100644 --- a/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.zh.md +++ b/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.zh.md @@ -16,7 +16,7 @@ Web GUI 以一条真实组装链交付——chromium 页面 → client 插件 bu 一个普通的共享 fixture 模块([测试政策认可的形态](../../../../docs/testing.md)),不是包:值得门禁把守的逻辑——回放推导、会话解析、日志脱敏、持久化——都在已受门禁的包 `dsh-llm-replay`、`dsh-acp-snapshot`、`dsh-session-persistence-jsonl` 中;剩下的只是启动接线和浏览器胶水,而驱动 chromium 的源码在无浏览器的覆盖率 runner 上无法诚实保持逐文件 100% 覆盖率。 -`launchWebScaffold()` 通过 vendored Loader 的 include 机制,从交付的 `apps/cli/cordis.yml` 启动真实 web 组合——与 `AppCLIEntry` 为 `dsh web` 驱动的是同一棵树、同一套机制。差异全部经 include patch 覆盖在这棵树上,即 ACP `cordis.snapshot.yml` 模式的进程内表达:临时 `persistenceRoot`;禁用 `workspace-context`(录制的 fixture 不得嵌入本仓库的 AGENTS.md);禁用 `session-title-llm`(其发后不管的标题调用会与循环争抢会话的回放游标);webserver 行钉到端口 0 加已构建 dist;无密钥模式下禁用 `llm-deepseek`。patch 的 id 一旦不再匹配任何行,boot 扫描会大声失败而不是漂移。boot 在临时工作区 `chdir` 下运行,使 api-gateway 的 `process.cwd()` 会话默认值、工具 cwd 与 fixture 一致;`dsh web` bin 自身的胶水(argv、profile json、AppCLIEntry)仍由 `smoke-real.e2e.ts` 中的无密钥 CLI 冒烟把守。初始化回滚和正常关闭都会先对 Cordis 树执行 dispose(资源释放),再删除 scaffold 持有的两个临时根目录;每项清理都会独立尝试,并会报告清理失败而不掩盖初始化失败。 +`launchWebScaffold()` 通过 vendored Loader 的 include 机制,从交付的 `apps/cli/base.cordis.yml` plus its surface overlay 启动真实 web 组合——与 `AppCLIEntry` 为 `dsh web` 驱动的是同一棵树、同一套机制。差异全部经 include patch 覆盖在这棵树上,即 ACP `cordis.snapshot.yml` 模式的进程内表达:临时 `persistenceRoot`;禁用 `workspace-context`(录制的 fixture 不得嵌入本仓库的 AGENTS.md);禁用 `session-title-llm`(其发后不管的标题调用会与循环争抢会话的回放游标);webserver 行钉到端口 0 加已构建 dist;无密钥模式下禁用 `llm-deepseek`。patch 的 id 一旦不再匹配任何行,boot 扫描会大声失败而不是漂移。boot 在临时工作区 `chdir` 下运行,使 api-gateway 的 `process.cwd()` 会话默认值、工具 cwd 与 fixture 一致;`dsh web` bin 自身的胶水(argv、profile json、AppCLIEntry)仍由 `smoke-real.e2e.ts` 中的无密钥 CLI 冒烟把守。初始化回滚和正常关闭都会先对 Cordis 树执行 dispose(资源释放),再删除 scaffold 持有的两个临时根目录;每项清理都会独立尝试,并会报告清理失败而不掩盖初始化失败。 无密钥的模型替换 = 禁用适配器行的 patch 加 `installLlmReplay` 在停稳的根 ctx 上以提供方目录(providers-catalog)模式填充开放的 seam——绝不用 catch-all:适配器行被禁用后不存在任何适配器,catch-all 会让 `resolveModelInfo` 无路由可走,`compact-basic` 的步后压力检查将步步告警,而不是被可证明地闲置(发布的 128k `contextWindow` 使该路径对小 fixture 保持闲置)。选择直接安装而非插入回放插件行是刻意的:直接安装返回收尾消费检查所需的 `ReplayHandle`。没有 fixture 的场景让 seam 保持空置,任何离群的流式调用都会以 NO_ADAPTER 大声失败。 @@ -66,7 +66,7 @@ Web GUI 以一条真实组装链交付——chromium 页面 → client 插件 bu **第二份提交的规范化会话日志预期输出。** 已否决:日志表面已由 ACP/headless/TUI 套件经同一循环与持久化钉住;在此只会翻倍刷新成本并重复测试下层。内联在根上下文事件上的世界状态断言保住了验证世界的义务。 -**以 `DSH_SNAPSHOT` 回放分支拉起 `dsh web` bin。** 已否决:它需要在交付的 CLI 中增加测试专用回放分支和环境变量管道。进程内 scaffold 已加载同一份 `apps/cli/cordis.yml`;只剩 argv、profile JSON 和 `AppCLIEntry` 胶水不在其覆盖范围内,而这些路径已由无密钥 CLI 冒烟覆盖。 +**以 `DSH_SNAPSHOT` 回放分支拉起 `dsh web` bin。** 已否决:它需要在交付的 CLI 中增加测试专用回放分支和环境变量管道。进程内 scaffold 已加载同一份 `apps/cli/base.cordis.yml` plus its surface overlay;只剩 argv、profile JSON 和 `AppCLIEntry` 胶水不在其覆盖范围内,而这些路径已由无密钥 CLI 冒烟覆盖。 **为可测试性改 wire 协议。** 已否决:契约已有第一等的无密钥同构 seam(`InProcessApiClient(toFetchHandler(api))`),逐事件不合批的 SSE 恰是回放在浏览器中可观测的原因,测试一条不再交付的 wire 会颠倒该层的存在意义。 diff --git a/apps/cli/README.i18n.yaml b/apps/cli/README.i18n.yaml index bb5f370009..ba60b83086 100644 --- a/apps/cli/README.i18n.yaml +++ b/apps/cli/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write apps/cli/README.md -README.md: 93c36d18abd06bbd7a80c918f520b92489180395 -README.zh.md: 85f4624a592eaf2ae44dc31fb4e18fb5657e62fd +README.md: 8b52c8bd5c5b7b75855e2bfbe28d061c5f3742b0 +README.zh.md: 0df55c52f723e901b090673b8fea663f38cbeaaf diff --git a/apps/cli/README.md b/apps/cli/README.md index 5241a29b4c..72b168ed98 100644 --- a/apps/cli/README.md +++ b/apps/cli/README.md @@ -8,7 +8,7 @@ Argv is parsed once through a [Commander](https://github.com/tj/commander.js) ad The TUI surface: -- boots the shipped default config (`examples/tui-agent/cordis.yml`), or the tree named by `--config ` (the demo/test escape for booting an alternate example tree), through [`dsh-app-boot`](../../packages/ui/app-boot/README.md); +- boots the shipped default config (`apps/cli/base.cordis.yml`), or the tree named by `--config ` (the demo/test escape for booting an alternate example tree), through [`dsh-app-boot`](../../packages/ui/app-boot/README.md); - resumes a persisted session with `dsh --resume ` and, when the Node host exposes `process.execve`, supplies the TUI's in-place handoff host: after selector preflight and current-session flush, the host disposes the app and replaces the process with a normalized resume invocation; runtimes without process replacement leave the session running and say so. This CLI owns session identity and the exit line rather than the config: it mints or selects the `main` session id and provides it, plus the exact command that reproduces this invocation, on the boot context ([`MAIN_SESSION_ID_KEY`](../../packages/ui/tui/README.md) and `TUI_GOODBYE_MESSAGE_KEY`). No `cordis.yml` key can drop resume, and a missing or unreadable id fails loud instead of creating a fresh session; - treats the **invoking directory** as the workspace — sessions, relative paths, and workspace instructions resolve from the cwd (`dsh meta` is the sole exception, below); - tells the agent where its own source lives: after boot it adds a prompt section naming this harness checkout, resolved from the launcher's real path so it holds under a PATH symlink and an arbitrary cwd, so the self-referential `cordis` toolset can read and modify it; diff --git a/apps/cli/README.zh.md b/apps/cli/README.zh.md index 69324735cb..dc0f73aaba 100644 --- a/apps/cli/README.zh.md +++ b/apps/cli/README.zh.md @@ -8,7 +8,7 @@ Argv 只会通过 [Commander](https://github.com/tj/commander.js) 适配器([` TUI 界面: -- 启动已交付的默认配置(`examples/tui-agent/cordis.yml`),或由 `--config ` 指定的树(演示/测试用于启动其他示例树的逃生口),并通过 [`dsh-app-boot`](../../packages/ui/app-boot/README.md) 完成启动; +- 启动已交付的默认配置(`apps/cli/base.cordis.yml`),或由 `--config ` 指定的树(演示/测试用于启动其他示例树的逃生口),并通过 [`dsh-app-boot`](../../packages/ui/app-boot/README.md) 完成启动; - 使用 `dsh --resume ` 恢复已持久化会话。当 Node 宿主公开 `process.execve` 时,还会提供 TUI 的原地移交宿主:选择器预检并刷新当前会话后,宿主会释放应用,并以规范化的恢复调用替换进程;不支持进程替换的运行时会让会话继续运行并给出提示。会话身份与退出行由本 CLI 拥有,而非由配置指定:它创建或选定 `main` 会话 id,并把该 id 以及可复现本次调用的确切命令一起提供到启动上下文([`MAIN_SESSION_ID_KEY`](../../packages/ui/tui/README.md) 与 `TUI_GOODBYE_MESSAGE_KEY`)。任何 `cordis.yml` 键都无法移除恢复能力;缺失或无法读取的 id 会明确报错,而不会创建新会话; - 将 **调用目录** 视为 workspace:会话、相对路径和 workspace 指令都从 cwd 解析(`dsh meta` 是唯一例外,见下文); - 告知 agent 自身源码所在位置:启动后添加一个命名此 harness checkout 的提示词段。该路径从启动器的真实路径解析,因此在 PATH 符号链接和任意 cwd 下仍然有效,使自指的 `cordis` 工具集可以读取并修改它; diff --git a/apps/cli/base.cordis.yml b/apps/cli/base.cordis.yml new file mode 100644 index 0000000000..8ece88a40d --- /dev/null +++ b/apps/cli/base.cordis.yml @@ -0,0 +1,210 @@ +# The shared `dsh` core: every row both the TUI (`tui.cordis.yml`) and the web +# surface (`web.cordis.yml`) mount identically. Neither surface includes the +# other — each is a patch list applied over THIS file at one include level, so a +# surface overlay, a `--config` overlay, and the personal `~/.dsh/config.yaml` +# all address these rows by id. Patch lists stack in that order, last write +# winning per row. +# +# A patch replaces the targeted row's whole `config` rather than merging into +# it, so a row whose value differs per surface does NOT live here: it belongs to +# each overlay, keeping any single row down to one overlay layer plus the user's. +# That is why `agent-loop`, `system-prompt`, `tools`, `fs-local`, and +# `llm-deepseek` thinking defaults are absent below. +# +# Row order carries no load semantics (activation is service-availability +# driven); the grouping is for readers. + +- id: timer + name: '@cordisjs/plugin-timer' + +- id: llm + name: '@deepseek-ai/dsh-llm' + +- id: session + name: '@deepseek-ai/dsh-session' + +- id: session-title + name: '@deepseek-ai/dsh-session-title' + config: + fallbackMaxWords: 5 + fallbackMaxBytes: 40 + maxTitleBytes: 80 + +- id: session-title-llm + name: '@deepseek-ai/dsh-session-title-first-message-llm' + config: + targetWords: 5 + targetCjkCharacters: 10 + maxInputBytes: 4096 + maxOutputTokens: 64 + timeoutMs: 60000 + +- id: user-interaction + name: '@deepseek-ai/dsh-user-interaction' + +- id: agent + name: '@deepseek-ai/dsh-agent' + +- id: tasks + name: '@deepseek-ai/dsh-tasks-local' + +- id: llm-retry + name: '@deepseek-ai/dsh-llm-retry' + +# The session store root is the launcher's policy, not a plugin's: `dsh` shares +# one store under the Harness home across every cwd, so `/resume` and +# `dsh ps` span workspaces. Without a launcher the project-local fallback keeps +# an embedder's sessions beside its project. +- id: session-persistence-jsonl + name: '@deepseek-ai/dsh-session-persistence-jsonl' + config: + root: !!js launcherSessionsRoot ?? './.sessions' + +- id: subprocess + name: '@deepseek-ai/dsh-subprocess-local' + +- id: bash-local + name: '@deepseek-ai/dsh-bash-local' + config: + timeoutMs: 60000 + +- id: tool-bash + name: '@deepseek-ai/dsh-tool-bash' + +- id: tool-tasks + name: '@deepseek-ai/dsh-tool-tasks' + +- id: fs-policy + name: '@deepseek-ai/dsh-fs-policy' + +- id: tool-fs + name: '@deepseek-ai/dsh-tool-fs' + +- id: tool-fs-search + name: '@deepseek-ai/dsh-tool-fs-search' + +- id: workspace-context + name: '@deepseek-ai/dsh-workspace-context' + config: + maxBytes: 65536 + +- id: skill + name: '@deepseek-ai/dsh-skill' + +- id: skill-local + name: '@deepseek-ai/dsh-skill-local' + +- id: tool-skill + name: '@deepseek-ai/dsh-tool-skill' + +- id: commands + name: '@deepseek-ai/dsh-commands' + +- id: goal + name: '@deepseek-ai/dsh-goal' + +- id: goal-session + name: '@deepseek-ai/dsh-goal-session' + +- id: command-goal + name: '@deepseek-ai/dsh-command-goal' + +- id: plan-mode + name: '@deepseek-ai/dsh-plan-mode' + config: + section: | + You are in plan mode. Stay in plan mode until exit_plan_mode succeeds or the user switches the session mode. Imperative language to implement changes means plan the implementation, not execute it. A user's conversational agreement — including an answer confirming something you asked — approves nothing and does not end plan mode; fold the confirmed decision into the plan and submit it through exit_plan_mode. + + Explore first. Use non-mutating reads, searches, static analysis, and checks to ground the plan in the actual repository. Do not edit or write files, change configuration, run formatters or code generation that rewrites tracked files, commit, or otherwise carry out the plan. Prefer existing functions and patterns over new machinery. + + The tool catalog stays the same across modes for request-cache stability. These plan-mode rules override any later tool description or guidance that suggests using mutation tools; those tools remain listed only to keep the request shape stable. Do not use todo_write to track this planning phase: it tracks implementation after an approved plan, while the plan itself belongs in exit_plan_mode. + + Resolve discoverable facts by inspection. Use ask_user_question only for user-owned choices or material ambiguity that inspection cannot answer. Do not ask the user where code lives or how current behavior works when you can find out. + + Make the plan decision-complete: state the goal and success criteria; group implementation changes by subsystem; identify public API, schema, and data-flow changes; cover edge cases, failure modes, tests, acceptance criteria, and explicit assumptions. Keep it concise enough to review but detailed enough that another engineer can implement it without making design decisions. + + When ready, call exit_plan_mode with the complete plan markdown, starting with a # title. Make exit_plan_mode the only and final tool call in that assistant response: it presents the plan for approval, and implementation begins only in a later step after approval. Do not paste the final plan as a plain reply or ask "should I proceed?" through prose or ask_user_question. If review rejects it, incorporate the feedback and present again. If the review channel is unavailable or aborted, stay in plan mode and ask the user to switch modes manually; do not proceed with implementation. + +- id: token-meter + name: '@deepseek-ai/dsh-token-meter' + +- id: compact-basic + name: '@deepseek-ai/dsh-compact-basic' + +- id: subagent + name: '@deepseek-ai/dsh-subagent' + +- id: subagent-spawn + name: '@deepseek-ai/dsh-subagent-spawn' + config: + providerName: spawn + +- id: subagent-fork + name: '@deepseek-ai/dsh-subagent-fork' + config: + providerName: fork + +- id: tool-subagent + name: '@deepseek-ai/dsh-tool-subagent' + config: + provider: spawn + toolName: subagent + +- id: tool-subagent-fork + name: '@deepseek-ai/dsh-tool-subagent' + config: + provider: fork + toolName: subagent_fork + +- id: workflow-workerthread + name: '@deepseek-ai/dsh-workflow-workerthread' + config: + provider: spawn + +- id: tool-workflow + name: '@deepseek-ai/dsh-tool-workflow' + +- id: timeout-policy + name: '@deepseek-ai/dsh-timeout-policy' + +- id: spill-local + name: '@deepseek-ai/dsh-spill-local' + +- id: spill-policy + name: '@deepseek-ai/dsh-spill-policy' + config: + maxInlineBytes: 50000 + +# ── rows every surface mounts, whose values each overlay states ────────────── + +# The tool registry. Presentation mode is a surface choice, so each overlay +# states it; omitting it here keeps the schema default (native). +- id: tools + name: '@deepseek-ai/dsh-tools' + +# The deployment persona is a surface choice; plan-mode and tool plugins own +# their own prompt sections. +- id: system-prompt + name: '@deepseek-ai/dsh-system-prompt' + config: + persona: '' + +# Agents created at startup. The TUI pre-creates `main`; the web surface creates +# sessions on client request, so its overlay keeps this empty. +- id: agent-loop + name: '@deepseek-ai/dsh-agent-loop' + config: + agents: [] + +# The filesystem provider. `cwd` defaults to the package's `process.cwd()`; the +# TUI states it explicitly because that value is also the session workspace. +- id: fs-local + name: '@deepseek-ai/dsh-fs-local' + +# The native DeepSeek adapter; reads the key/base-url the boot's layered .env +# loading left in the environment. Thinking defaults are a surface choice. +- id: llm-deepseek + name: '@deepseek-ai/dsh-llm-deepseek' + config: + apiKey: !!js process.env.DEEPSEEK_API_KEY + baseURL: !!js process.env.DEEPSEEK_BASE_URL diff --git a/examples/tui-agent/composition.md b/apps/cli/composition.md similarity index 51% rename from examples/tui-agent/composition.md rename to apps/cli/composition.md index 1bd1b7298c..47d14ce88d 100644 --- a/examples/tui-agent/composition.md +++ b/apps/cli/composition.md @@ -1,38 +1,67 @@ -# TUI Agent App Composition +# dsh TUI Composition The TUI agent combines the real DeepSeek adapter, coding tools, compaction, subagents, and workflows with the full-screen terminal app package. ```mermaid flowchart LR - cfg["examples/tui-agent
cordis.yml"] - plugin_tui_hmr["hmr
@cordisjs/plugin-hmr"] - cfg --> plugin_tui_hmr - plugin_tui_llm_deepseek["llm-deepseek
@deepseek-ai/dsh-llm-deepseek"] - cfg --> plugin_tui_llm_deepseek - plugin_tui_llm_pi_ai["llm-pi-ai
@deepseek-ai/dsh-llm-pi-ai"] - cfg --> plugin_tui_llm_pi_ai - plugin_tui_subprocess["subprocess
@deepseek-ai/dsh-subprocess-local"] - cfg --> plugin_tui_subprocess - plugin_tui_bash["bash
@deepseek-ai/dsh-bash-local"] - cfg --> plugin_tui_bash - plugin_tui_tui_agent["tui-agent
@deepseek-ai/dsh-tui-demo"] - cfg --> plugin_tui_tui_agent - plugin_tui_tui_agent --> bundle_agent_core["@deepseek-ai/dsh-agent-spine-demo"] - plugin_tui_tui_agent --> bundle_jsonl["@deepseek-ai/dsh-session-persistence-jsonl"] - plugin_tui_tui_agent --> frontdoor_tui["@deepseek-ai/dsh-tui
pre-created main agent"] - bundle_agent_core --> spine_llm["ctx.llm"] - bundle_agent_core --> spine_sessions["ctx.sessions"] - bundle_agent_core --> spine_tools["ctx.tools + tool-bash"] - bundle_agent_core --> spine_loop["ctx.agents + ctx.agentLoop"] + cfg["apps/cli (dsh)
cordis.yml"] + plugin_tui_timer["timer
@cordisjs/plugin-timer"] + cfg --> plugin_tui_timer + plugin_tui_llm["llm
@deepseek-ai/dsh-llm"] + cfg --> plugin_tui_llm + plugin_tui_session["session
@deepseek-ai/dsh-session"] + cfg --> plugin_tui_session + plugin_tui_session_title["session-title
@deepseek-ai/dsh-session-title"] + cfg --> plugin_tui_session_title plugin_tui_session_title_llm["session-title-llm
@deepseek-ai/dsh-session-title-first-message-llm"] cfg --> plugin_tui_session_title_llm + plugin_tui_user_interaction["user-interaction
@deepseek-ai/dsh-user-interaction"] + cfg --> plugin_tui_user_interaction + plugin_tui_agent["agent
@deepseek-ai/dsh-agent"] + cfg --> plugin_tui_agent + plugin_tui_tasks["tasks
@deepseek-ai/dsh-tasks-local"] + cfg --> plugin_tui_tasks + plugin_tui_llm_retry["llm-retry
@deepseek-ai/dsh-llm-retry"] + cfg --> plugin_tui_llm_retry + plugin_tui_session_persistence_jsonl["session-persistence-jsonl
@deepseek-ai/dsh-session-persistence-jsonl"] + cfg --> plugin_tui_session_persistence_jsonl + plugin_tui_subprocess["subprocess
@deepseek-ai/dsh-subprocess-local"] + cfg --> plugin_tui_subprocess + plugin_tui_bash_local["bash-local
@deepseek-ai/dsh-bash-local"] + cfg --> plugin_tui_bash_local + plugin_tui_tool_bash["tool-bash
@deepseek-ai/dsh-tool-bash"] + cfg --> plugin_tui_tool_bash + plugin_tui_tool_tasks["tool-tasks
@deepseek-ai/dsh-tool-tasks"] + cfg --> plugin_tui_tool_tasks + plugin_tui_fs_policy["fs-policy
@deepseek-ai/dsh-fs-policy"] + cfg --> plugin_tui_fs_policy + plugin_tui_tool_fs["tool-fs
@deepseek-ai/dsh-tool-fs"] + cfg --> plugin_tui_tool_fs + plugin_tui_tool_fs_search["tool-fs-search
@deepseek-ai/dsh-tool-fs-search"] + cfg --> plugin_tui_tool_fs_search + plugin_tui_workspace_context["workspace-context
@deepseek-ai/dsh-workspace-context"] + cfg --> plugin_tui_workspace_context + plugin_tui_skill["skill
@deepseek-ai/dsh-skill"] + cfg --> plugin_tui_skill + plugin_tui_skill_local["skill-local
@deepseek-ai/dsh-skill-local"] + cfg --> plugin_tui_skill_local + plugin_tui_tool_skill["tool-skill
@deepseek-ai/dsh-tool-skill"] + cfg --> plugin_tui_tool_skill + plugin_tui_commands["commands
@deepseek-ai/dsh-commands"] + cfg --> plugin_tui_commands + plugin_tui_goal["goal
@deepseek-ai/dsh-goal"] + cfg --> plugin_tui_goal + plugin_tui_goal_session["goal-session
@deepseek-ai/dsh-goal-session"] + cfg --> plugin_tui_goal_session + plugin_tui_command_goal["command-goal
@deepseek-ai/dsh-command-goal"] + cfg --> plugin_tui_command_goal + plugin_tui_plan_mode["plan-mode
@deepseek-ai/dsh-plan-mode"] + cfg --> plugin_tui_plan_mode plugin_tui_token_meter["token-meter
@deepseek-ai/dsh-token-meter"] cfg --> plugin_tui_token_meter - plugin_tui_tool_result_prune["tool-result-prune
@deepseek-ai/dsh-compact-tool-result-prune"] - cfg --> plugin_tui_tool_result_prune plugin_tui_compact_basic["compact-basic
@deepseek-ai/dsh-compact-basic"] cfg --> plugin_tui_compact_basic plugin_tui_subagent["subagent
@deepseek-ai/dsh-subagent"] @@ -49,39 +78,53 @@ flowchart LR cfg --> plugin_tui_workflow_workerthread plugin_tui_tool_workflow["tool-workflow
@deepseek-ai/dsh-tool-workflow"] cfg --> plugin_tui_tool_workflow - plugin_tui_tool_ralph["tool-ralph
@deepseek-ai/dsh-tool-ralph"] - cfg --> plugin_tui_tool_ralph - plugin_tui_plan_mode["plan-mode
@deepseek-ai/dsh-plan-mode"] - cfg --> plugin_tui_plan_mode - plugin_tui_fs_local["fs-local
@deepseek-ai/dsh-fs-local"] - cfg --> plugin_tui_fs_local - plugin_tui_fs_policy["fs-policy
@deepseek-ai/dsh-fs-policy"] - cfg --> plugin_tui_fs_policy - plugin_tui_tool_fs["tool-fs
@deepseek-ai/dsh-tool-fs"] - cfg --> plugin_tui_tool_fs - plugin_tui_source_guard["source-guard
@deepseek-ai/dsh-source-guard"] - cfg --> plugin_tui_source_guard - plugin_tui_tool_fs_search["tool-fs-search
@deepseek-ai/dsh-tool-fs-search"] - cfg --> plugin_tui_tool_fs_search plugin_tui_timeout_policy["timeout-policy
@deepseek-ai/dsh-timeout-policy"] cfg --> plugin_tui_timeout_policy plugin_tui_spill_local["spill-local
@deepseek-ai/dsh-spill-local"] cfg --> plugin_tui_spill_local plugin_tui_spill_policy["spill-policy
@deepseek-ai/dsh-spill-policy"] cfg --> plugin_tui_spill_policy + plugin_tui_tools["tools
@deepseek-ai/dsh-tools"] + cfg --> plugin_tui_tools + plugin_tui_system_prompt["system-prompt
@deepseek-ai/dsh-system-prompt"] + cfg --> plugin_tui_system_prompt + plugin_tui_agent_loop["agent-loop
@deepseek-ai/dsh-agent-loop"] + cfg --> plugin_tui_agent_loop + plugin_tui_fs_local["fs-local
@deepseek-ai/dsh-fs-local"] + cfg --> plugin_tui_fs_local + plugin_tui_llm_deepseek["llm-deepseek
@deepseek-ai/dsh-llm-deepseek"] + cfg --> plugin_tui_llm_deepseek ``` | Plugin id | Package / module | | --- | --- | -| `hmr` | `@cordisjs/plugin-hmr` | -| `llm-deepseek` | `@deepseek-ai/dsh-llm-deepseek` | -| `llm-pi-ai` | `@deepseek-ai/dsh-llm-pi-ai` | -| `subprocess` | `@deepseek-ai/dsh-subprocess-local` | -| `bash` | `@deepseek-ai/dsh-bash-local` | -| `tui-agent` | `@deepseek-ai/dsh-tui-demo` | +| `timer` | `@cordisjs/plugin-timer` | +| `llm` | `@deepseek-ai/dsh-llm` | +| `session` | `@deepseek-ai/dsh-session` | +| `session-title` | `@deepseek-ai/dsh-session-title` | | `session-title-llm` | `@deepseek-ai/dsh-session-title-first-message-llm` | +| `user-interaction` | `@deepseek-ai/dsh-user-interaction` | +| `agent` | `@deepseek-ai/dsh-agent` | +| `tasks` | `@deepseek-ai/dsh-tasks-local` | +| `llm-retry` | `@deepseek-ai/dsh-llm-retry` | +| `session-persistence-jsonl` | `@deepseek-ai/dsh-session-persistence-jsonl` | +| `subprocess` | `@deepseek-ai/dsh-subprocess-local` | +| `bash-local` | `@deepseek-ai/dsh-bash-local` | +| `tool-bash` | `@deepseek-ai/dsh-tool-bash` | +| `tool-tasks` | `@deepseek-ai/dsh-tool-tasks` | +| `fs-policy` | `@deepseek-ai/dsh-fs-policy` | +| `tool-fs` | `@deepseek-ai/dsh-tool-fs` | +| `tool-fs-search` | `@deepseek-ai/dsh-tool-fs-search` | +| `workspace-context` | `@deepseek-ai/dsh-workspace-context` | +| `skill` | `@deepseek-ai/dsh-skill` | +| `skill-local` | `@deepseek-ai/dsh-skill-local` | +| `tool-skill` | `@deepseek-ai/dsh-tool-skill` | +| `commands` | `@deepseek-ai/dsh-commands` | +| `goal` | `@deepseek-ai/dsh-goal` | +| `goal-session` | `@deepseek-ai/dsh-goal-session` | +| `command-goal` | `@deepseek-ai/dsh-command-goal` | +| `plan-mode` | `@deepseek-ai/dsh-plan-mode` | | `token-meter` | `@deepseek-ai/dsh-token-meter` | -| `tool-result-prune` | `@deepseek-ai/dsh-compact-tool-result-prune` | | `compact-basic` | `@deepseek-ai/dsh-compact-basic` | | `subagent` | `@deepseek-ai/dsh-subagent` | | `subagent-spawn` | `@deepseek-ai/dsh-subagent-spawn` | @@ -90,17 +133,15 @@ flowchart LR | `tool-subagent-fork` | `@deepseek-ai/dsh-tool-subagent` | | `workflow-workerthread` | `@deepseek-ai/dsh-workflow-workerthread` | | `tool-workflow` | `@deepseek-ai/dsh-tool-workflow` | -| `tool-ralph` | `@deepseek-ai/dsh-tool-ralph` | -| `plan-mode` | `@deepseek-ai/dsh-plan-mode` | -| `fs-local` | `@deepseek-ai/dsh-fs-local` | -| `fs-policy` | `@deepseek-ai/dsh-fs-policy` | -| `tool-fs` | `@deepseek-ai/dsh-tool-fs` | -| `source-guard` | `@deepseek-ai/dsh-source-guard` | -| `tool-fs-search` | `@deepseek-ai/dsh-tool-fs-search` | | `timeout-policy` | `@deepseek-ai/dsh-timeout-policy` | | `spill-local` | `@deepseek-ai/dsh-spill-local` | | `spill-policy` | `@deepseek-ai/dsh-spill-policy` | +| `tools` | `@deepseek-ai/dsh-tools` | +| `system-prompt` | `@deepseek-ai/dsh-system-prompt` | +| `agent-loop` | `@deepseek-ai/dsh-agent-loop` | +| `fs-local` | `@deepseek-ai/dsh-fs-local` | +| `llm-deepseek` | `@deepseek-ai/dsh-llm-deepseek` | -Source config: [`examples/tui-agent/cordis.yml`](cordis.yml). +Source config: [`apps/cli/base.cordis.yml`](base.cordis.yml). Maintenance mode: hybrid: the leaf plugin list is parsed from its `cordis.yml`; app package expansion is curated from package source. diff --git a/apps/cli/cordis.yml b/apps/cli/cordis.yml deleted file mode 100644 index ed44f75c7f..0000000000 --- a/apps/cli/cordis.yml +++ /dev/null @@ -1,420 +0,0 @@ -# dsh web — the full web-shape composition: host runtime (layer 1), the -# transport/service layer (layer 2), and the browser plugin roster (dshClient -# rows the modules node half scans into window.__DSH_BOOT__). Row order -# carries no load semantics (activation is service-availability driven); the -# grouping below is for readers. `--dev` appends the dsh-client-hmr row in -# code (AppCLIEntry) — prod and dev differ by exactly that one row. -# AppCLIEntry patches this tree before boot: profile json + CLI flags + -# distIndex land as config patches over the rows below (yaml = engineering -# defaults, json = user config, user wins per field). - -# ── layer 1: runtime ──────────────────────────────────────────────────────── - -- id: timer - name: '@cordisjs/plugin-timer' - -- id: llm - name: '@deepseek-ai/dsh-llm' - -- id: session - name: '@deepseek-ai/dsh-session' - -# Projection registry: drives every registered domain unit over committed -# session events and serves finished values (history-tail projections block + -# session/projection frames). Without this row every domain's optional unit -# injection stays silent — no block, no frames, no titles/todos on the web. -- id: session-projection - name: '@deepseek-ai/dsh-session-projection' - -- id: session-title - name: '@deepseek-ai/dsh-session-title' - config: - fallbackMaxWords: 5 - fallbackMaxBytes: 40 - maxTitleBytes: 80 - -# Model-made titles on the first-message cadence (the web sidebar renders -# session/title). Same values as the TUI composition. -- id: session-title-llm - name: '@deepseek-ai/dsh-session-title-first-message-llm' - config: - targetWords: 5 - targetCjkCharacters: 10 - maxInputBytes: 4096 - maxOutputTokens: 64 - timeoutMs: 60000 - -- id: system-prompt - name: '@deepseek-ai/dsh-system-prompt' - config: - persona: '' - -- id: tools - name: '@deepseek-ai/dsh-tools' - config: - # TEMPORARY workaround: DSH_TOOLS_MODE (native|code|both) opts a whole dsh - # process into Code Mode while per-session tool-mode selection is being - # designed; unset keeps the schema default (native). Remove the env seam - # once the web UI owns the choice per session. - mode: !!js process.env.DSH_TOOLS_MODE - -# Code Mode substrate for the row above. Mounted unconditionally because -# Loader metadata is static (no conditional rows): a native-mode boot only -# registers the service — a worker thread spawns per run_code execution. -- id: code-runtime - name: '@deepseek-ai/dsh-code-runtime-worker' - -- id: user-interaction - name: '@deepseek-ai/dsh-user-interaction' - -- id: agent - name: '@deepseek-ai/dsh-agent' - -- id: tasks - name: '@deepseek-ai/dsh-tasks-local' - -- id: agent-loop - name: '@deepseek-ai/dsh-agent-loop' - config: - agents: [] - -# The native DeepSeek adapter; reads the key/base-url the boot's layered -# .env loading (cwd then $DSH_HOME) left in the environment. -- id: llm-deepseek - name: '@deepseek-ai/dsh-llm-deepseek' - config: - apiKey: !!js process.env.DEEPSEEK_API_KEY - baseURL: !!js process.env.DEEPSEEK_BASE_URL - -# Common pi-ai provider routes read credentials and endpoint overrides from the -# boot's layered environment. -- id: llm-pi-ai - name: '@deepseek-ai/dsh-llm-pi-ai' - config: - providers: - - provider: openai - apiKey: !!js process.env.OPENAI_API_KEY - baseURL: !!js process.env.OPENAI_BASE_URL - - provider: anthropic - apiKey: !!js process.env.ANTHROPIC_API_KEY - baseURL: !!js process.env.ANTHROPIC_BASE_URL - -# Transient-failure recovery around the loop's model calls (same policy as -# the TUI's agent-spine composition; defaults: 2 retries, 500ms→10s backoff). -- id: llm-retry - name: '@deepseek-ai/dsh-llm-retry' - -# Session store root. AppCLIEntry resolves the engineering default to a -# global dir under the Harness home ($DSH_HOME, else ~/.dsh): sessions live -# in one place across every cwd, not a project-local ./.sessions. The -# persistenceRoot profile key (user config) still overrides this per field. -- id: session-persistence-jsonl - name: '@deepseek-ai/dsh-session-persistence-jsonl' - config: - root: './.sessions' - -- id: storage - name: '@deepseek-ai/dsh-storage' - -- id: storage-json - name: '@deepseek-ai/dsh-storage-json' - config: - root: './.storages' - -- id: storage-domain - name: '@deepseek-ai/dsh-storage-domain' - config: - backend: json - -- id: workspace - name: '@deepseek-ai/dsh-workspace' - -# Persisted projection cache: durable per-session checkpoints of every -# registered projection unit (json backend → ./.storages/session_projcache.json, -# beside workspace.json), throttled between the two mandatory points -# (turn/end + detach), serving cold listings without full-log loads. -- id: session-projection-cache - name: '@deepseek-ai/dsh-session-projection-cache' - config: - writeEveryEvents: 200 - writeIntervalMs: 5000 - -# Managed child-process groups for the bash executor (spawn/kill/output plumbing). -- id: subprocess - name: '@deepseek-ai/dsh-subprocess-local' - -# The sandboxed product path (the acp-agent composition): per-platform -# runner provider, the shared policy home, the confined bash executor, and -# the approval seam its escalation asks through. The web deployment default -# is danger-full-access + never (same behavior as the former bash-local -# rows); DSH_PERMISSION_MODE opts a process into a confined default, and -# per-session switches ride the /permission command's knob events. -- id: sandbox - name: '@deepseek-ai/dsh-sandbox-local' - -- id: sandbox-policy - name: '@deepseek-ai/dsh-sandbox-policy' - config: - mode: !!js process.env.DSH_PERMISSION_MODE ?? 'danger-full-access' - workspaceRoot: !!js process.cwd() - -- id: bash-sandbox - name: '@deepseek-ai/dsh-bash-sandbox' - -- id: approval - name: '@deepseek-ai/dsh-user-approval' - config: - policy: !!js "(process.env.DSH_PERMISSION_MODE ?? 'danger-full-access') === 'danger-full-access' ? 'never' : 'ask'" - -# Presets over the two knobs (requires the confining executor + approval): -# the web permission chip's table, served through the permissions projection -# and switched through /permission. -- id: permission - name: '@deepseek-ai/dsh-permission' - config: - presets: - read-only: - sandbox: read-only - approval: ask - workspace-write: - sandbox: workspace-write - approval: ask - danger-full-access: - sandbox: danger-full-access - approval: never - -- id: tool-bash - name: '@deepseek-ai/dsh-tool-bash' - -- id: tool-todo - name: '@deepseek-ai/dsh-tool-todo' - -- id: tool-tasks - name: '@deepseek-ai/dsh-tool-tasks' - -# fs cwd stays the package default (process.cwd()) — the same value the -# gateway injects into session.cwd, so paths and sessions agree. The -# sandboxed backend rides the SAME policy as bash: write/edit fence by the -# effective mode, so read/write/edit stay available under every mode. -- id: fs-sandbox - name: '@deepseek-ai/dsh-fs-sandbox' - -- id: fs-policy - name: '@deepseek-ai/dsh-fs-policy' - -- id: tool-fs - name: '@deepseek-ai/dsh-tool-fs' - -- id: tool-fs-search - name: '@deepseek-ai/dsh-tool-fs-search' - -- id: workspace-context - name: '@deepseek-ai/dsh-workspace-context' - config: - maxBytes: 65536 - -- id: skill - name: '@deepseek-ai/dsh-skill' - -- id: skill-local - name: '@deepseek-ai/dsh-skill-local' - -- id: tool-skill - name: '@deepseek-ai/dsh-tool-skill' - -# Host command registry: the single source of truth behind command.list / -# command.execute; the web '/' menu is a pure projection of this registry. -- id: commands - name: '@deepseek-ai/dsh-commands' - -# Goal service + automatic same-session continuation + the /goal command. -# The GoalService registers the 'goal' session projection unit; the web -# GoalBar reads it through useProjection. -- id: goal - name: '@deepseek-ai/dsh-goal' - -- id: goal-session - name: '@deepseek-ai/dsh-goal-session' - -- id: command-goal - name: '@deepseek-ai/dsh-command-goal' - -# Plan mode registers /plan (the first real command on the web surface). -# Section text mirrors examples/tui-agent/cordis.yml (the reference -# deployment); plan-mode throws at load on an empty section. -- id: plan-mode - name: '@deepseek-ai/dsh-plan-mode' - config: - section: | - You are in plan mode. Stay in plan mode until exit_plan_mode succeeds or the user switches the session mode. Imperative language to implement changes means plan the implementation, not execute it. A user's conversational agreement — including an answer confirming something you asked — approves nothing and does not end plan mode; fold the confirmed decision into the plan and submit it through exit_plan_mode. - - Explore first. Use non-mutating reads, searches, static analysis, and checks to ground the plan in the actual repository. Do not edit or write files, change configuration, run formatters or code generation that rewrites tracked files, commit, or otherwise carry out the plan. Prefer existing functions and patterns over new machinery. - - The tool catalog stays the same across modes for request-cache stability. These plan-mode rules override any later tool description or guidance that suggests using mutation tools; those tools remain listed only to keep the request shape stable. Do not use todo_write to track this planning phase: it tracks implementation after an approved plan, while the plan itself belongs in exit_plan_mode. - - Resolve discoverable facts by inspection. Use ask_user_question only for user-owned choices or material ambiguity that inspection cannot answer. Do not ask the user where code lives or how current behavior works when you can find out. - - Make the plan decision-complete: state the goal and success criteria; group implementation changes by subsystem; identify public API, schema, and data-flow changes; cover edge cases, failure modes, tests, acceptance criteria, and explicit assumptions. Keep it concise enough to review but detailed enough that another engineer can implement it without making design decisions. - - When ready, call exit_plan_mode with the complete plan markdown, starting with a # title. Make exit_plan_mode the only and final tool call in that assistant response: it presents the plan for approval, and implementation begins only in a later step after approval. Do not paste the final plan as a plain reply or ask "should I proceed?" through prose or ask_user_question. If review rejects it, incorporate the feedback and present again. If the review channel is unavailable or aborted, stay in plan mode and ask the user to switch modes manually; do not proceed with implementation. - -# token-meter rejects unknown config keys — keep this row bare. -- id: token-meter - name: '@deepseek-ai/dsh-token-meter' - -- id: compact-basic - name: '@deepseek-ai/dsh-compact-basic' - -- id: subagent - name: '@deepseek-ai/dsh-subagent' - -- id: subagent-spawn - name: '@deepseek-ai/dsh-subagent-spawn' - config: - providerName: spawn - -- id: subagent-fork - name: '@deepseek-ai/dsh-subagent-fork' - config: - providerName: fork - -- id: tool-subagent - name: '@deepseek-ai/dsh-tool-subagent' - config: - provider: spawn - toolName: subagent - -- id: tool-subagent-fork - name: '@deepseek-ai/dsh-tool-subagent' - config: - provider: fork - toolName: subagent_fork - -- id: workflow-workerthread - name: '@deepseek-ai/dsh-workflow-workerthread' - config: - provider: spawn - -- id: tool-workflow - name: '@deepseek-ai/dsh-tool-workflow' - -- id: timeout-policy - name: '@deepseek-ai/dsh-timeout-policy' - -- id: spill-local - name: '@deepseek-ai/dsh-spill-local' - -# Omitting maxInlineBytes makes the whole policy a silent no-op — always -# state it explicitly. -- id: spill-policy - name: '@deepseek-ai/dsh-spill-policy' - config: - maxInlineBytes: 50000 - -# The API gateway: the transport-agnostic dispatch face every client shape -# shares. provider/model are the host default routing — the profile json's -# mapping target (user config overrides these engineering defaults). -# Directory-picking package, dual-face: the node half serves the gateway's -# host.* picker RPCs, the browser half fills ui-workspace's directory-flow -# slots — one row composes the whole interaction. Swap point: mount -# '-native' instead for the host-display OS chooser. -- id: directory-picker - name: '@deepseek-ai/dsh-host-directory-picker-browse' - -- id: api-gateway - name: '@deepseek-ai/dsh-host-apiproxy' - config: - provider: deepseek - model: deepseek-v4-flash - -# ── layer 2: transport/service ────────────────────────────────────────────── - -# Plain route-registration carrier. distIndex is an assembly fact, not user -# config — AppCLIEntry resolves the frontend dist and patches it in; host and -# port arrive as CLI-flag patches over these defaults. -- id: webserver - name: '@deepseek-ai/dsh-host-webserver' - config: - host: 127.0.0.1 - port: 3080 - -# ── browser plugin roster (dshClient rows; node halves are layer-2 hosts) ── - -# Dual-face: node half scans this very tree for dshClient rows, composes -# window.__DSH_BOOT__, serves /plugins//client.js; browser half is the -# module table the shell kernel constructs before cordis exists (§4.7 — -# adopted as a plugin entry by the kernel, never fetched). -- id: modules - name: '@deepseek-ai/dsh-client-modules' - -# Owns both ends of the web transport: node half binds the gateway to the -# webserver under /api; browser half is the fetch/SSE client. -- id: connection - name: '@deepseek-ai/dsh-client-connection' - -- id: client-runtime - name: '@deepseek-ai/dsh-client-runtime' - -- id: ui-theme - name: '@deepseek-ai/dsh-client-ui-theme' - -- id: locale - name: '@deepseek-ai/dsh-client-locale' - -- id: ui-layout - name: '@deepseek-ai/dsh-client-ui-layout' - -- id: ui-sidebar - name: '@deepseek-ai/dsh-client-ui-sidebar' - -- id: ui-settings - name: '@deepseek-ai/dsh-client-ui-settings' - -- id: ui-settings-general - name: '@deepseek-ai/dsh-client-ui-settings-general' - -- id: ui-models - name: '@deepseek-ai/dsh-client-ui-models' - -- id: ui-conversation - name: '@deepseek-ai/dsh-client-ui-conversation' - - -- id: ui-workspace - name: '@deepseek-ai/dsh-client-ui-workspace' - -# Input triggers: the '/' | '@' pipeline (ui-slash), the command surface over -# it (ui-command), and the two reference sources (ui-skill / ui-subagent). -- id: ui-slash - name: '@deepseek-ai/dsh-client-ui-slash' - -- id: ui-command - name: '@deepseek-ai/dsh-client-ui-command' - -- id: ui-skill - name: '@deepseek-ai/dsh-client-ui-skill' - -- id: ui-subagent - name: '@deepseek-ai/dsh-client-ui-subagent' - -# Goal surface: GoalBar in the input dock over the goal session projection. -- id: ui-goal - name: '@deepseek-ai/dsh-client-ui-goal' - -# Model selection: the /model popupSelect + composer seat over session.models. -- id: ui-model - name: '@deepseek-ai/dsh-client-ui-model' - -# The /permission popup picker (hostBacked over the host /permission command). -- id: ui-permission - name: '@deepseek-ai/dsh-client-ui-permission' - -# Plan control: the composer plan seat over the plan projection + /plan channel. -- id: ui-plan - name: '@deepseek-ai/dsh-client-ui-plan' - -- id: ui-question - name: '@deepseek-ai/dsh-client-ui-question' - -- id: ui-trajectory - name: '@deepseek-ai/dsh-client-ui-trajectory' diff --git a/apps/cli/package.json b/apps/cli/package.json index 783fff6487..c815cf037a 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -110,6 +110,7 @@ "js-yaml": "^4.2.0" }, "devDependencies": { - "@types/js-yaml": "^4.0.9" + "@types/js-yaml": "^4.0.9", + "node-pty": "1.1.0" } } diff --git a/apps/cli/src/app-cli-entry.ts b/apps/cli/src/app-cli-entry.ts index 02bf596403..c0e67bae00 100644 --- a/apps/cli/src/app-cli-entry.ts +++ b/apps/cli/src/app-cli-entry.ts @@ -17,7 +17,7 @@ import type { FiberState } from 'cordis' import Loader from '@cordisjs/plugin-loader' import Include, { type PatchOptions } from '@cordisjs/plugin-include' import yaml from 'js-yaml' -import { assertEntriesLoaded, installFailLoud, loadEnv } from '@deepseek-ai/dsh-app-boot' +import { assertEntriesLoaded, installFailLoud, loadEnv, loadOverlayPatches } from '@deepseek-ai/dsh-app-boot' import { resolveDshHome, resolveSessionsRoot } from '@deepseek-ai/dsh-paths' // Empty type import carries the httpServer Context merge for the port read below. import type {} from '@deepseek-ai/dsh-host-webserver' @@ -100,8 +100,21 @@ const FIBER_PENDING = 0 as FiberState.PENDING /** Constructor facts for one dsh invocation over the shared composition (argv already parsed by the surface bin). */ export interface AppCLIEntryOptions { - /** Absolute path of the shipped cordis.yml. */ + /** Absolute path of the shared base config the Loader includes. */ configPath: string + /** + * Absolute path of this surface's overlay: a patch list applied over + * {@link configPath} before this entry's own profile/flag patches. Its rows + * are also merge inputs, so a flag override preserves the overlay's other + * fields on the same row. + */ + overlayPath: string + /** + * Optional extra overlay applied after {@link overlayPath} and before this + * entry's own profile/flag patches — the `--config` escape for demos and + * tests that need a row this surface does not ship. + */ + extraOverlayPath?: string /** Whether to append the HMR row (the whole prod/dev difference; web surface only). */ dev: boolean /** --host when explicitly passed; undefined keeps the yml engineering default. */ @@ -223,11 +236,22 @@ export class AppCLIEntry { ctx.baseUrl = pathToFileURL(join(resolve(this.options.configPath), '..')).href + '/' await ctx.plugin(Loader) ctx.loader.builtins.include = Include + // One include of the shared base with every overlay as a sibling patch + // list: patches never cross an include boundary, so nesting them would + // silently stop reaching base rows. The surface overlay applies first, then + // this entry's profile-json and CLI-flag patches, which therefore win. + const patches = [ + ...loadOverlayPatches('dsh', this.options.overlayPath), + ...this.options.extraOverlayPath === undefined + ? [] + : loadOverlayPatches('dsh', this.options.extraOverlayPath), + ...this.patches, + ] await ctx.loader.create({ name: 'cordis:include', config: { path: pathToFileURL(resolve(this.options.configPath)).href, - ...this.patches.length > 0 ? { patches: this.patches } : {}, + ...patches.length > 0 ? { patches } : {}, }, }) if (this.options.dev) { @@ -262,17 +286,39 @@ export class AppCLIEntry { } } - /** Bypass parse of the shipped yml (id → row) for patch-merge inputs; Loader still reads the file itself. */ + /** + * Bypass parse of the base and this surface's overlay (id → row) for + * patch-merge inputs; the Loader still reads both files itself. The overlay + * wins per row, matching the order its patches are applied in, and its + * `insert` rows are indexed too because a flag may target one of them. + */ private parseYmlRows(): Map { - const doc = yaml.load(readFileSync(this.options.configPath, 'utf8'), { schema: includeYamlSchema }) - if (!Array.isArray(doc)) throw new Error(`dsh: ${this.options.configPath} is not a top-level entry list`) const rows = new Map() - for (const row of doc as { id?: string; config?: unknown }[]) { - if (typeof row.id === 'string') rows.set(row.id, row) + const files = [this.options.configPath, this.options.overlayPath] + if (this.options.extraOverlayPath !== undefined) files.push(this.options.extraOverlayPath) + for (const file of files) { + for (const row of this.parseRowList(file)) { + if (typeof row.id === 'string') rows.set(row.id, row) + for (const inserted of row.insert ?? []) { + if (typeof inserted.id === 'string') rows.set(inserted.id, inserted) + } + } } return rows } + /** + * Parse one entry or patch list, rejecting anything that is not a top-level + * array so a malformed file fails here rather than at row lookup. + * @param file - absolute path of the config or overlay file. + * @returns the parsed top-level entries. + */ + private parseRowList(file: string): { id?: string; config?: unknown; insert?: { id?: string; config?: unknown }[] }[] { + const doc = yaml.load(readFileSync(file, 'utf8'), { schema: includeYamlSchema }) + if (!Array.isArray(doc)) throw new Error(`dsh: ${file} is not a top-level entry list`) + return doc as { id?: string; config?: unknown; insert?: { id?: string; config?: unknown }[] }[] + } + /** Profile json under cwd; read-only — never created here, absent = no user config. */ private readProfile(): Record { let raw: string diff --git a/apps/cli/src/args.ts b/apps/cli/src/args.ts index d143430ed4..16cf22eda0 100644 --- a/apps/cli/src/args.ts +++ b/apps/cli/src/args.ts @@ -11,10 +11,15 @@ import { Command, CommanderError } from 'commander' -/** Interactive TUI: the default mode. `--config` swaps the tree; `--resume ` rehydrates a session. */ +/** + * Interactive TUI: the default mode. `--config` applies an overlay over the + * shipped composition in place of the personal one, `--config-replace` boots a + * file as the whole tree instead, and `--resume ` rehydrates a session. + */ interface TuiInvocation { mode: 'tui' config?: string + configReplace?: string resume?: string } @@ -69,6 +74,8 @@ interface ListSessionsInvocation { */ interface WebInvocation { mode: 'web' + /** Overlay of loader patches applied over the shipped web composition. */ + config?: string host?: string port?: number dev: boolean @@ -88,6 +95,7 @@ export type DshInvocation = /** Raw web-subcommand options straight from Commander. */ interface WebOptions { + config?: string host?: string port?: string dev?: boolean @@ -104,6 +112,7 @@ interface WebOptions { function resolveWeb(options: WebOptions): WebInvocation { return { mode: 'web', + ...options.config !== undefined && { config: options.config }, ...options.host !== undefined && { host: options.host }, ...options.port !== undefined && { port: Number(options.port) }, dev: options.dev === true, @@ -140,15 +149,16 @@ Examples: // subcommand without a positional collision. .option('-p, --prompt ', 'answer this task without the interactive UI, then exit') .option('--resume ', 'continue a past session by id (list ids with `dsh ps`)') - .option('--config ', 'start with an alternate plugin configuration file') - .action((options: { config?: string; prompt?: string; resume?: string }) => { + .option('--config ', 'apply this overlay of loader patches instead of the personal one') + .option('--config-replace ', 'boot this file as the entire tree, ignoring the shipped and personal configuration') + .action((options: { config?: string; configReplace?: string; prompt?: string; resume?: string }) => { if (options.prompt !== undefined) { // A headless prompt owns the invocation; an empty task has nothing to // run, and --config/--resume are TUI inputs that must not silently // vanish from a headless run. if (options.prompt === '') program.error('error: --prompt needs a task') - if (options.config !== undefined || options.resume !== undefined) { - program.error('error: --prompt takes no --config or --resume') + if (options.config !== undefined || options.configReplace !== undefined || options.resume !== undefined) { + program.error('error: --prompt takes no --config, --config-replace, or --resume') } resolved = { mode: 'headless', prompt: options.prompt } return @@ -156,9 +166,15 @@ Examples: // An empty --resume= id would silently start a fresh session downstream // (agent-loop treats '' as no-resume), so a mistyped resume must fail loud. if (options.resume === '') program.error('error: --resume needs a session id') + // The two config flags are mutually exclusive: one layers over the shipped + // tree, the other discards it, so accepting both would silently drop one. + if (options.config !== undefined && options.configReplace !== undefined) { + program.error('error: --config and --config-replace are mutually exclusive') + } resolved = { mode: 'tui', ...options.config !== undefined && { config: options.config }, + ...options.configReplace !== undefined && { configReplace: options.configReplace }, ...options.resume !== undefined && { resume: options.resume }, } }) @@ -168,8 +184,9 @@ Examples: // a leaked `--config`/`-p`/`--resume` is a mistyped invocation that must fail // loud rather than silently run and drop the input. const rejectParentOptions = (command: string): void => { - const parent = program.opts<{ config?: string; prompt?: string; resume?: string }>() - if (parent.config !== undefined || parent.prompt !== undefined || parent.resume !== undefined) { + const parent = program.opts<{ config?: string; configReplace?: string; prompt?: string; resume?: string }>() + if (parent.config !== undefined || parent.configReplace !== undefined + || parent.prompt !== undefined || parent.resume !== undefined) { program.error(`error: ${command} takes none of --config, -p/--prompt, or --resume`) } } @@ -208,6 +225,7 @@ Examples: // would duplicate a fact this file does not own. const web = program.command('web').description('serve the browser UI on the configured host and port') web + .option('--config ', 'apply this overlay of loader patches over the shipped configuration') .option('--host ', 'bind host; pass 0.0.0.0 to reach it from another machine') .option('--port ', 'listen port; pass 0 to let the OS pick a free one') .option('--dev', 'developer mode: hot-reload the browser client') diff --git a/apps/cli/src/bin.ts b/apps/cli/src/bin.ts index 8079db1cd4..caff90d7f2 100644 --- a/apps/cli/src/bin.ts +++ b/apps/cli/src/bin.ts @@ -30,7 +30,7 @@ const invocation = parseDshArgs(process.argv.slice(2), readVersion()) switch (invocation.mode) { case 'web': { const { runWeb } = await import('./web.ts') - await runWeb(invocation.host, invocation.port, invocation.dev, invocation.workspaceRoot, invocation.trustedHosts) + await runWeb(invocation.host, invocation.port, invocation.dev, invocation.workspaceRoot, invocation.trustedHosts, invocation.config) break } case 'headless': { @@ -40,7 +40,7 @@ switch (invocation.mode) { } case 'tui': { const { runTui } = await import('./tui.ts') - await runTui(invocation.config, invocation.resume) + await runTui(invocation.config, invocation.resume, undefined, undefined, invocation.configReplace) break } case 'meta': { diff --git a/apps/cli/src/headless.ts b/apps/cli/src/headless.ts index 7ee18c1ea1..7c73623907 100644 --- a/apps/cli/src/headless.ts +++ b/apps/cli/src/headless.ts @@ -76,7 +76,8 @@ async function consumeUntilTurnEnd(frames: AsyncIterable>, export async function runHeadless(task: string): Promise { // A missing DEEPSEEK_API_KEY throws here (plugin load is fail-loud, uncaught by design). const entry = new AppCLIEntry({ - configPath: fileURLToPath(new URL('../cordis.yml', import.meta.url)), + configPath: fileURLToPath(new URL('../base.cordis.yml', import.meta.url)), + overlayPath: fileURLToPath(new URL('../web.cordis.yml', import.meta.url)), dev: false, port: 0, }) diff --git a/apps/cli/src/tui.ts b/apps/cli/src/tui.ts index fb1c354d46..c65f01fbc3 100644 --- a/apps/cli/src/tui.ts +++ b/apps/cli/src/tui.ts @@ -25,11 +25,13 @@ import { boot, installFailLoud, loadEnv, + loadOverlayPatches, loadPersonalPatches, resolveConfigPath, } from '@deepseek-ai/dsh-app-boot' import { resolveDshHome, resolveSessionsRoot } from '@deepseek-ai/dsh-paths' import { SessionId } from '@deepseek-ai/dsh-session' +import { CONFIGURED_AGENT_IDENTITIES_KEY } from '@deepseek-ai/dsh-agent-loop' import type { Context } from 'cordis' import { registerLiveSessions } from './register-session.ts' import { @@ -46,7 +48,18 @@ const NAME = 'dsh' // Both the source tree (apps/cli/src) and the bundled bin (apps/cli/lib) sit // one directory under apps/cli, so the shipped default config resolves with // the same relative hop from either artifact. -const DEFAULT_CONFIG = fileURLToPath(new URL('../../../examples/tui-agent/cordis.yml', import.meta.url)) +// The shared core every `dsh` surface mounts, and the TUI's own overlay over +// it. Both the source tree (apps/cli/src) and the bundled bin (apps/cli/lib) +// sit one directory under apps/cli, so each resolves with the same hop. +const BASE_CONFIG = fileURLToPath(new URL('../base.cordis.yml', import.meta.url)) +const TUI_OVERLAY = fileURLToPath(new URL('../tui.cordis.yml', import.meta.url)) + +// The `agents` entry in tui.cordis.yml the TUI drives; the launcher binds its +// session identity by this config id. +const MAIN_AGENT_ID = 'main' + +/** Filename of the derived `/resume` index, kept beside the session logs. */ +const SESSION_QUERY_DB = 'session-query.db' // The harness checkout root: three hops up from apps/cli/{src,lib}, resolved // from this bin's location so it holds however `dsh` is launched (a PATH @@ -93,24 +106,31 @@ export async function runSkillSession(skill: string): Promise { /** * Run the interactive TUI from the invoking directory. - * @param config - a config path to boot instead of the shipped default, or - * `undefined` for the default; already parsed from `--config`. + * @param config - an overlay patch list applied over the shared base and the + * TUI overlay, REPLACING the personal `~/.dsh/config.yaml` so a named tree never + * inherits the user's route, or `undefined` to use the personal overlay; + * already parsed from `--config`. * @param resumeSessionId - a persisted session id to resume, or `undefined` to * mint a fresh one; already parsed and non-empty-validated from `--resume`. * Either way the resulting identity reaches the booted app through - * {@link MAIN_SESSION_ID_KEY}, so no config key selects the session. + * {@link CONFIGURED_AGENT_IDENTITIES_KEY}, so no config key selects the session + * and an overlay replacing the agent row cannot drop it. * @param workspace - a directory to make the workspace instead of the invoking * one, or `undefined` to keep the cwd. Only `dsh meta` passes it. * @param initialSkill - a bundled skill to auto-invoke as a fresh session's * first turn, or `undefined`. Set only by {@link runSkillSession} and ignored * on a resume, so it never re-fires; reaches the app through * {@link INITIAL_SKILL_KEY}. + * @param configReplace - a config path to boot as the ENTIRE tree, bypassing the + * shared base, the TUI overlay, and the personal overlay alike, or `undefined` + * to compose them; already parsed from `--config-replace`. */ export async function runTui( config: string | undefined, resumeSessionId: string | undefined, workspace?: string, initialSkill?: string, + configReplace?: string, ): Promise { // Refuse pipes BEFORE booting: a compose-time throw inside the Loader tree // is logged per-entry rather than rethrown, so a piped launch would @@ -150,7 +170,13 @@ export async function runTui( const resumeArgs = (sessionId: string, targetCwd?: string): string[] => workspace !== undefined && (targetCwd === undefined || targetCwd === workspace) ? ['meta', `--resume=${sessionId}`] - : [`--resume=${sessionId}`, ...config !== undefined ? ['--config', config] : []] + : [ + `--resume=${sessionId}`, + // Both config flags must survive the handoff: resuming into a different + // tree than the session was created in would silently change the agent. + ...config !== undefined ? ['--config', config] : [], + ...configReplace !== undefined ? ['--config-replace', configReplace] : [], + ] // Mint the fresh id here rather than in the app bundle: the exit line names // the session to resume, so the launcher must know it before the tree boots. const identity: MainSessionIdentity = resumeSessionId === undefined @@ -186,10 +212,25 @@ export async function runTui( } }, } + // One include of the shared base, with every overlay applied as a sibling + // patch list: patches never cross an include boundary, so stacking these as + // nested includes would silently stop reaching base rows. Later lists win. + // + // `--config` REPLACES the personal overlay rather than layering under it: an + // explicitly named tree must not inherit `~/.dsh/config.yaml`'s route, or a + // demo or test config would silently run on the user's provider and model. + // `--config-replace` additionally discards the base and the surface overlay. + const replaceTree = configReplace !== undefined + const patches = replaceTree ? [] : [ + ...loadOverlayPatches(NAME, TUI_OVERLAY), + ...config === undefined + ? loadPersonalPatches(NAME) ?? [] + : loadOverlayPatches(NAME, resolveConfigPath(config, undefined)), + ] const ctx = await boot( NAME, - resolveConfigPath(config ?? DEFAULT_CONFIG, undefined), - loadPersonalPatches(NAME), + replaceTree ? resolveConfigPath(configReplace, undefined) : BASE_CONFIG, + patches, (hostCtx) => { // The launcher owns session identity and the exit line: a config-mounted // app bundle reads both from these slots, so no cordis.yml key can drop @@ -200,6 +241,13 @@ export async function runTui( // the Harness home across every cwd, so /resume and list-sessions see // every workspace. The bundle treats the slot as opaque. hostCtx.provide(SESSIONS_ROOT_KEY, launcherSessionsRoot()) + // The agent-loop row reads this to bind `main`, and the tui row reads the + // same id, so a personal overlay repointing the model route cannot drop + // the session identity or desynchronise the two. + hostCtx.provide(CONFIGURED_AGENT_IDENTITIES_KEY, { [MAIN_AGENT_ID]: identity }) + // The launcher owns the session store location, so it also owns the + // derived index path that must sit beside those logs. + hostCtx.provide('launcherSessionQueryPath', join(launcherSessionsRoot(), SESSION_QUERY_DB)) if (resumeHost !== undefined) hostCtx.provide('tuiResumeHost', resumeHost) // Seed the first turn only for a fresh session, so resuming never // re-invokes the skill. diff --git a/apps/cli/src/web.ts b/apps/cli/src/web.ts index f4ad016005..24d21a6eb4 100644 --- a/apps/cli/src/web.ts +++ b/apps/cli/src/web.ts @@ -7,10 +7,13 @@ */ import { fileURLToPath } from 'node:url' +import { resolveConfigPath } from '@deepseek-ai/dsh-app-boot' import { AppCLIEntry } from './app-cli-entry.ts' import { registerLiveSessions } from './register-session.ts' -const CONFIG_PATH = fileURLToPath(new URL('../cordis.yml', import.meta.url)) +// The shared core every `dsh` surface mounts, plus this surface's overlay over it. +const BASE_CONFIG = fileURLToPath(new URL('../base.cordis.yml', import.meta.url)) +const WEB_OVERLAY = fileURLToPath(new URL('../web.cordis.yml', import.meta.url)) // Display-only mirror of the webserver schema's loopback host: the address the // local URL always prints. Not a source of truth — the schema is. @@ -24,6 +27,8 @@ const LOOPBACK_HOST = '127.0.0.1' * @param dev - mount the client HMR driver and watch plugin bundles for rebuilds. * @param workspaceRoot - parent directory for name-created workspaces, or `undefined` for the gateway's cwd fallback. * @param trustedHosts - extra authorities for the /api browser-trust fence, or `undefined` for the derived LAN literals alone. + * @param config - an overlay of loader patches applied over the shipped web + * composition, or `undefined` for none; already parsed from `--config`. */ export async function runWeb( host: string | undefined, @@ -31,9 +36,12 @@ export async function runWeb( dev: boolean, workspaceRoot: string | undefined, trustedHosts: string[] | undefined, + config?: string, ): Promise { const entry = new AppCLIEntry({ - configPath: CONFIG_PATH, + configPath: BASE_CONFIG, + overlayPath: WEB_OVERLAY, + ...config !== undefined && { extraOverlayPath: resolveConfigPath(config, undefined) }, dev, ...host !== undefined && { host }, ...port !== undefined && { port }, diff --git a/examples/tui-agent/tests/fixtures/tui-scripted-llm.ts b/apps/cli/tests/fixtures/tui-scripted-llm.ts similarity index 100% rename from examples/tui-agent/tests/fixtures/tui-scripted-llm.ts rename to apps/cli/tests/fixtures/tui-scripted-llm.ts diff --git a/apps/cli/tests/fixtures/tui-scripted.cordis.yml b/apps/cli/tests/fixtures/tui-scripted.cordis.yml new file mode 100644 index 0000000000..bde14e4627 --- /dev/null +++ b/apps/cli/tests/fixtures/tui-scripted.cordis.yml @@ -0,0 +1,71 @@ +# Overlay for the keyless conversational PTY test: the shipped composition with +# only the model replaced, so the terminal interaction is deterministic and +# network-free while the agent/TUI/user-question stack stays the production one. +# +# Passed as `--config`, so the launcher includes `base.cordis.yml`, applies +# `tui.cordis.yml`, then this file — all sibling patch lists at one include +# level. A patch replaces the targeted row's whole `config`, so each row below +# restates every key it owns. + +# The scripted adapter replaces the DeepSeek one: no key, no network. A patch's +# `name` is an assertion rather than a replacement, so the base row is disabled +# and the adapter inserted. Relative specifiers resolve against the INCLUDED +# file's directory (apps/cli), because the include moves baseUrl to that tree. +- id: llm-deepseek + disabled: true + +- insert: + - id: scripted-llm + name: './tests/fixtures/tui-scripted-llm.ts' + +- id: agent-loop + config: + agents: + - id: main + provider: tui-scripted + model: tui-scripted-model + # `cwd` scopes the session to this workspace, which is what `/resume` + # filters on; dropping it would hide the seeded session. + cwd: !!js process.cwd() + +- id: system-prompt + config: + persona: 'Scripted model {{model}}.' + +# The smoke's log inspection reads plain `.jsonl` under the workspace, so this +# fixture pins a project-local root instead of the launcher's shared store, and +# keeps the artifacts uncompressed like the other snapshot-facing configs. +- id: session-persistence-jsonl + config: + root: './.sessions' + compression: none + +# The derived index must sit under the same root as the logs it indexes; this +# fixture pins both to the workspace instead of the launcher's shared store. +- id: session-query-sqlite + config: + path: './.sessions/session-query.db' + +- id: plan-mode + config: + section: 'Stay in plan mode for this scripted TUI test.' + +# The scripted adapter answers the tool-less title request with a fixed string, +# so the PTY test can assert the logged title reaches the terminal window title. +- id: session-title-llm + config: + targetWords: 5 + targetCjkCharacters: 10 + maxInputBytes: 4096 + maxOutputTokens: 64 + timeoutMs: 10000 + +- id: tui + config: + sessionId: !!js configuredAgentIdentities?.main?.id ?? 'main' + welcome: 'scripted TUI ready.' + showReasoning: true + +# HMR watches the repository; a PTY subprocess test must not start a watcher. +- id: hmr + disabled: true diff --git a/examples/tui-agent/tests/pty-harness.ts b/apps/cli/tests/pty-harness.ts similarity index 100% rename from examples/tui-agent/tests/pty-harness.ts rename to apps/cli/tests/pty-harness.ts diff --git a/examples/tui-agent/tests/snapshots/bash-terminal-card/session.jsonl b/apps/cli/tests/snapshots/bash-terminal-card/session.jsonl similarity index 100% rename from examples/tui-agent/tests/snapshots/bash-terminal-card/session.jsonl rename to apps/cli/tests/snapshots/bash-terminal-card/session.jsonl diff --git a/examples/tui-agent/tests/snapshots/bash-terminal-card/terminal.expected.txt b/apps/cli/tests/snapshots/bash-terminal-card/terminal.expected.txt similarity index 100% rename from examples/tui-agent/tests/snapshots/bash-terminal-card/terminal.expected.txt rename to apps/cli/tests/snapshots/bash-terminal-card/terminal.expected.txt diff --git a/examples/tui-agent/tests/snapshots/code-mode-dispatch-spill/session.jsonl b/apps/cli/tests/snapshots/code-mode-dispatch-spill/session.jsonl similarity index 100% rename from examples/tui-agent/tests/snapshots/code-mode-dispatch-spill/session.jsonl rename to apps/cli/tests/snapshots/code-mode-dispatch-spill/session.jsonl diff --git a/examples/tui-agent/tests/snapshots/code-mode-dispatch-spill/terminal.expected.txt b/apps/cli/tests/snapshots/code-mode-dispatch-spill/terminal.expected.txt similarity index 100% rename from examples/tui-agent/tests/snapshots/code-mode-dispatch-spill/terminal.expected.txt rename to apps/cli/tests/snapshots/code-mode-dispatch-spill/terminal.expected.txt diff --git a/examples/tui-agent/tests/snapshots/code-mode/session.jsonl b/apps/cli/tests/snapshots/code-mode/session.jsonl similarity index 100% rename from examples/tui-agent/tests/snapshots/code-mode/session.jsonl rename to apps/cli/tests/snapshots/code-mode/session.jsonl diff --git a/examples/tui-agent/tests/snapshots/code-mode/terminal.expected.txt b/apps/cli/tests/snapshots/code-mode/terminal.expected.txt similarity index 100% rename from examples/tui-agent/tests/snapshots/code-mode/terminal.expected.txt rename to apps/cli/tests/snapshots/code-mode/terminal.expected.txt diff --git a/examples/tui-agent/tests/snapshots/cordis-dynamic-toolchain/session.1.jsonl b/apps/cli/tests/snapshots/cordis-dynamic-toolchain/session.1.jsonl similarity index 100% rename from examples/tui-agent/tests/snapshots/cordis-dynamic-toolchain/session.1.jsonl rename to apps/cli/tests/snapshots/cordis-dynamic-toolchain/session.1.jsonl diff --git a/examples/tui-agent/tests/snapshots/cordis-dynamic-toolchain/session.2.jsonl b/apps/cli/tests/snapshots/cordis-dynamic-toolchain/session.2.jsonl similarity index 100% rename from examples/tui-agent/tests/snapshots/cordis-dynamic-toolchain/session.2.jsonl rename to apps/cli/tests/snapshots/cordis-dynamic-toolchain/session.2.jsonl diff --git a/examples/tui-agent/tests/snapshots/cordis-dynamic-toolchain/session.jsonl b/apps/cli/tests/snapshots/cordis-dynamic-toolchain/session.jsonl similarity index 100% rename from examples/tui-agent/tests/snapshots/cordis-dynamic-toolchain/session.jsonl rename to apps/cli/tests/snapshots/cordis-dynamic-toolchain/session.jsonl diff --git a/examples/tui-agent/tests/snapshots/cordis-dynamic-toolchain/terminal.expected.txt b/apps/cli/tests/snapshots/cordis-dynamic-toolchain/terminal.expected.txt similarity index 100% rename from examples/tui-agent/tests/snapshots/cordis-dynamic-toolchain/terminal.expected.txt rename to apps/cli/tests/snapshots/cordis-dynamic-toolchain/terminal.expected.txt diff --git a/examples/tui-agent/tests/snapshots/dynamic-workflow/session.1.jsonl b/apps/cli/tests/snapshots/dynamic-workflow/session.1.jsonl similarity index 100% rename from examples/tui-agent/tests/snapshots/dynamic-workflow/session.1.jsonl rename to apps/cli/tests/snapshots/dynamic-workflow/session.1.jsonl diff --git a/examples/tui-agent/tests/snapshots/dynamic-workflow/session.jsonl b/apps/cli/tests/snapshots/dynamic-workflow/session.jsonl similarity index 100% rename from examples/tui-agent/tests/snapshots/dynamic-workflow/session.jsonl rename to apps/cli/tests/snapshots/dynamic-workflow/session.jsonl diff --git a/examples/tui-agent/tests/snapshots/dynamic-workflow/terminal.expected.txt b/apps/cli/tests/snapshots/dynamic-workflow/terminal.expected.txt similarity index 100% rename from examples/tui-agent/tests/snapshots/dynamic-workflow/terminal.expected.txt rename to apps/cli/tests/snapshots/dynamic-workflow/terminal.expected.txt diff --git a/examples/tui-agent/tests/snapshots/multi-turn-conversation/session.jsonl b/apps/cli/tests/snapshots/multi-turn-conversation/session.jsonl similarity index 100% rename from examples/tui-agent/tests/snapshots/multi-turn-conversation/session.jsonl rename to apps/cli/tests/snapshots/multi-turn-conversation/session.jsonl diff --git a/examples/tui-agent/tests/snapshots/multi-turn-conversation/terminal.expected.txt b/apps/cli/tests/snapshots/multi-turn-conversation/terminal.expected.txt similarity index 100% rename from examples/tui-agent/tests/snapshots/multi-turn-conversation/terminal.expected.txt rename to apps/cli/tests/snapshots/multi-turn-conversation/terminal.expected.txt diff --git a/examples/tui-agent/tests/snapshots/parallel-file-reads/session.jsonl b/apps/cli/tests/snapshots/parallel-file-reads/session.jsonl similarity index 100% rename from examples/tui-agent/tests/snapshots/parallel-file-reads/session.jsonl rename to apps/cli/tests/snapshots/parallel-file-reads/session.jsonl diff --git a/examples/tui-agent/tests/snapshots/parallel-file-reads/terminal.expected.txt b/apps/cli/tests/snapshots/parallel-file-reads/terminal.expected.txt similarity index 100% rename from examples/tui-agent/tests/snapshots/parallel-file-reads/terminal.expected.txt rename to apps/cli/tests/snapshots/parallel-file-reads/terminal.expected.txt diff --git a/examples/tui-agent/tests/snapshots/parallel-file-reads/workspace/a.txt b/apps/cli/tests/snapshots/parallel-file-reads/workspace/a.txt similarity index 100% rename from examples/tui-agent/tests/snapshots/parallel-file-reads/workspace/a.txt rename to apps/cli/tests/snapshots/parallel-file-reads/workspace/a.txt diff --git a/examples/tui-agent/tests/snapshots/parallel-file-reads/workspace/b.txt b/apps/cli/tests/snapshots/parallel-file-reads/workspace/b.txt similarity index 100% rename from examples/tui-agent/tests/snapshots/parallel-file-reads/workspace/b.txt rename to apps/cli/tests/snapshots/parallel-file-reads/workspace/b.txt diff --git a/examples/tui-agent/tests/snapshots/todo-plan/session.jsonl b/apps/cli/tests/snapshots/todo-plan/session.jsonl similarity index 100% rename from examples/tui-agent/tests/snapshots/todo-plan/session.jsonl rename to apps/cli/tests/snapshots/todo-plan/session.jsonl diff --git a/examples/tui-agent/tests/snapshots/todo-plan/terminal.expected.txt b/apps/cli/tests/snapshots/todo-plan/terminal.expected.txt similarity index 100% rename from examples/tui-agent/tests/snapshots/todo-plan/terminal.expected.txt rename to apps/cli/tests/snapshots/todo-plan/terminal.expected.txt diff --git a/examples/tui-agent/tests/tui-keyless-smoke.e2e.ts b/apps/cli/tests/tui-keyless-smoke.e2e.ts similarity index 89% rename from examples/tui-agent/tests/tui-keyless-smoke.e2e.ts rename to apps/cli/tests/tui-keyless-smoke.e2e.ts index 5b1ccdff75..289f716f6a 100644 --- a/examples/tui-agent/tests/tui-keyless-smoke.e2e.ts +++ b/apps/cli/tests/tui-keyless-smoke.e2e.ts @@ -9,9 +9,10 @@ import { packChunkRuns, SessionId, type SessionEvent, type SessionHeader } from import { logPath, toHeaderLine } from '../../../packages/session-persistence/session-persistence-jsonl/src/format.ts' import { runTuiPtySmoke, type TuiPtySmokeOptions } from './pty-harness.ts' -const dshBinScript = fileURLToPath(new URL('../../../apps/cli/src/bin.ts', import.meta.url)) -const configPath = fileURLToPath(new URL('../cordis.yml', import.meta.url)) -const codeModeConfigPath = fileURLToPath(new URL('../code-mode.cordis.yml', import.meta.url)) +const dshBinScript = fileURLToPath(new URL('../src/bin.ts', import.meta.url)) +// `--config` layers an overlay over the shared base, so the default surface +// needs no config argument at all; these are the overlays under test. +const codeModeConfigPath = fileURLToPath(new URL('../../../examples/code-mode/cordis.yml', import.meta.url)) const scriptedConfigPath = fileURLToPath(new URL('./fixtures/tui-scripted.cordis.yml', import.meta.url)) const tsconfigPath = fileURLToPath(new URL('../../../tsconfig.json', import.meta.url)) @@ -119,12 +120,16 @@ async function readLoggedRequestContext(cwd: string): Promise & { label: string }): Promise { return runTuiPtySmoke({ - tempDirPrefix: 'tui-agent-smoke-', + tempDirPrefix: 'dsh-tui-smoke-', binScript: dshBinScript, - configPath, tsconfigPath, env: { DEEPSEEK_API_KEY: 'keyless-tui-no-call' }, ...overrides, @@ -139,13 +144,14 @@ const SELECT_PRO_MODEL = [ { waitFor: 'Select model', send: '\x1b[B\x1b[Z\r' }, ] as const -describe('tui-agent keyless smoke (real Loader tree in a PTY)', () => { +describe('dsh TUI keyless smoke (real Loader tree in a PTY)', () => { it('boots pi-tui, sweeps the borderless banner in, enters plan mode, and restores the terminal', async () => { // With no configured welcome the borderless banner sweeps in left-to-right; // the detail line's session id (`main-session-`) renders only once // the sweep reaches it, so it marks a settled banner. const output = await smoke({ - label: 'tui-agent boot', + label: 'dsh boot', + configArgs: [], actions: [ { waitFor: 'main-session-', send: '/plan' }, { waitFor: '[off|message] — Enter or leave plan mode', send: '\r' }, @@ -165,8 +171,8 @@ describe('tui-agent keyless smoke (real Loader tree in a PTY)', () => { it('switches models, streams a response, answers a user-question dialog, and exits cleanly', async () => { const output = await smoke({ - label: 'tui-agent conversation', - tempDirPrefix: 'tui-agent-conversation-', + label: 'dsh conversation', + tempDirPrefix: 'dsh-tui-conversation-', configPath: scriptedConfigPath, actions: [ ...SELECT_PRO_MODEL, @@ -223,8 +229,8 @@ describe('tui-agent keyless smoke (real Loader tree in a PTY)', () => { // rendered `` block reaches the model — proven by the // scripted adapter echoing the fixture's body marker only when it arrives. const output = await smoke({ - label: 'tui-agent skill', - tempDirPrefix: 'tui-agent-skill-', + label: 'dsh skill', + tempDirPrefix: 'dsh-tui-skill-', configPath: scriptedConfigPath, prepare: seedWorkspace({ skills: { @@ -252,8 +258,11 @@ describe('tui-agent keyless smoke (real Loader tree in a PTY)', () => { it('fuzzy-completes an @file path without reading or submitting the file', async () => { const output = await smoke({ - label: 'tui-agent file autocomplete', - tempDirPrefix: 'tui-agent-file-autocomplete-', + label: 'dsh file autocomplete', + tempDirPrefix: 'dsh-tui-file-autocomplete-', + // The shipped composition: no welcome, so the banner's session-id detail + // line marks a settled boot. Completion never calls the model. + configArgs: [], prepare: seedWorkspace({ workspace: { 'src/terminal-special-case.ts': 'export const marker = true\n', @@ -275,8 +284,8 @@ describe('tui-agent keyless smoke (real Loader tree in a PTY)', () => { // The overlay's only keyless composition proof: the include+patch tree, // worker code runtime, and one-tool registry all mount before the banner. const output = await smoke({ - label: 'tui-agent code mode', - tempDirPrefix: 'tui-agent-code-mode-', + label: 'dsh code mode', + tempDirPrefix: 'dsh-tui-code-mode-', configPath: codeModeConfigPath, actions: [{ waitFor: 'TUI Code Mode ready.', send: '/exit\r' }], }) @@ -325,8 +334,10 @@ describe('dsh CLI keyless smoke (apps/cli through the same PTY)', () => { it('applies the personal overlay: config.yaml patches the tree and .env feeds its !!js', async () => { // The whole personal-config chain in one boot: the personal .env supplies - // the variable, config.yaml patches the tui-agent entry with a `!!js` - // reference to it, and the banner renders the patched welcome verbatim. + // the variable, config.yaml patches the `tui` row — a row the SURFACE + // OVERLAY inserted, not one the base declares — with a `!!js` reference to + // it, and the banner renders the patched welcome verbatim. That proves a + // later patch list reaches a row an earlier one inserted. const output = await smoke({ label: 'dsh personal overlay', tempDirPrefix: 'dsh-personal-overlay-', @@ -336,12 +347,11 @@ describe('dsh CLI keyless smoke (apps/cli through the same PTY)', () => { personal: { '.env': 'DSH_PERSONAL_WELCOME=PERSONAL OVERLAY READY.\n', 'config.yaml': [ - '- id: tui-agent', - " name: '@deepseek-ai/dsh-tui-demo'", + '- id: workspace-context', + ' disabled: true', + '- id: tui', ' config:', - ' provider: deepseek', - ' model: deepseek-v4-flash', - ' workspaceContext: false', + " sessionId: !!js configuredAgentIdentities?.main?.id ?? 'main'", ' welcome: !!js process.env.DSH_PERSONAL_WELCOME', '', ].join('\n'), @@ -393,10 +403,11 @@ describe('dsh CLI keyless smoke (apps/cli through the same PTY)', () => { expect(output).toMatch(/To resume this session: dsh --resume=main-session-[0-9a-f-]{36} --config/) }, LOADER_SMOKE_TEST_TIMEOUT_MS) - it('keeps resume working when the personal overlay replaces the whole tui-agent config', async () => { + it('keeps resume working when the personal overlay replaces the whole agent-loop config', async () => { // Loader patches replace a targeted `config` key wholesale, so a personal - // overlay that omits a resume key used to silently disable the exit hint. - // Launcher-owned identity and exit line make that unreachable. + // overlay repointing the model route drops every identity key the shipped + // row declared. Launcher-owned identity makes that unreachable: agent-loop + // applies the launcher's id over whatever route survives. const output = await smoke({ label: 'dsh overlay keeps resume', tempDirPrefix: 'dsh-overlay-resume-', @@ -405,12 +416,18 @@ describe('dsh CLI keyless smoke (apps/cli through the same PTY)', () => { prepare: seedWorkspace({ personal: { 'config.yaml': [ - '- id: tui-agent', - " name: '@deepseek-ai/dsh-tui-demo'", + '- id: workspace-context', + ' disabled: true', + '- id: agent-loop', ' config:', - ' provider: deepseek', - ' model: deepseek-v4-flash', - ' workspaceContext: false', + ' agents:', + ' - id: main', + ' provider: deepseek', + ' model: deepseek-v4-flash', + ' cwd: !!js process.cwd()', + '- id: tui', + ' config:', + " sessionId: !!js configuredAgentIdentities?.main?.id ?? 'main'", ' welcome: OVERLAY REPLACED THE CONFIG.', '', ].join('\n'), @@ -426,7 +443,7 @@ describe('dsh CLI keyless smoke (apps/cli through the same PTY)', () => { // consumes into its own `[exit 3]` pill. Rendering both would report the same // exit twice, so the marker must not survive into the card body. const output = await smoke({ - label: 'tui-agent bash exit pill', + label: 'dsh bash exit pill', tempDirPrefix: 'dsh-bash-exit-pill-', configPath: scriptedConfigPath, actions: [ diff --git a/examples/tui-agent/tests/tui.snapshot.ts b/apps/cli/tests/tui.snapshot.ts similarity index 100% rename from examples/tui-agent/tests/tui.snapshot.ts rename to apps/cli/tests/tui.snapshot.ts diff --git a/apps/cli/tui.cordis.yml b/apps/cli/tui.cordis.yml new file mode 100644 index 0000000000..0a6f2213c1 --- /dev/null +++ b/apps/cli/tui.cordis.yml @@ -0,0 +1,131 @@ +# `dsh` (the default surface) — the full-screen TUI, as a patch list over +# `base.cordis.yml`. The launcher includes the base and applies this file, then +# any `--config` overlay, then the personal `~/.dsh/config.yaml`, as sibling +# patch lists at ONE include level: patches never cross an include boundary, so +# stacking overlays as nested includes would silently stop reaching base rows. +# +# A patch replaces the targeted row's whole `config`, so each row below restates +# every key it owns. A patch whose `id` matches no row is skipped with a Loader +# warning, which is deliberate: one personal overlay is shared across surfaces, +# so a row that exists only under `web` must not fail the TUI's boot. +# +# The launcher owns session identity and the exit line, and provides both on the +# boot context rather than through config, so no key here — and no overlay +# replacing one — can drop `--resume`. + +# ── surface-specific values the base deliberately omits ───────────────────── + +# `main` is the agent the TUI drives. `provider`/`model` are the route `dsh +# login` rewrites and a personal overlay repoints; `cwd` anchors the session to +# the invoking directory, which is also what scopes `/resume` to this workspace. +- id: agent-loop + config: + agents: + - id: main + provider: deepseek + model: deepseek-v4-pro + cwd: !!js process.cwd() + +# Keep the persona to identity and behavior; tool plugins own tool guidance. +# The loop resolves {{model}} from this agent's configuration. +- id: system-prompt + config: + persona: | + You are a coding agent powered by the {{model}} model. + + Verify your work by running the code or tests. Keep answers brief and + factual. + +# Shipped default: full thinking at max effort on every request (wire-only +# defaults; they never enter the request header). +- id: llm-deepseek + config: + apiKey: !!js process.env.DEEPSEEK_API_KEY + baseURL: !!js process.env.DEEPSEEK_BASE_URL + thinking: enabled + reasoningEffort: max + +# This single-session app resolves relative paths from the process cwd. +- id: fs-local + config: + cwd: !!js process.cwd() + +# The shipped TUI presents the native tool registry. `examples/code-mode` is the +# overlay that switches this row to the `run_code` transport. +- id: tools + config: + mode: native + +# ── TUI-only rows ─────────────────────────────────────────────────────────── + +- insert: + # Development-only hot reload; it depends on Loader internals, so it stays a + # surface row rather than joining the shared base. + - id: hmr + name: '@cordisjs/plugin-hmr' + config: + root: ['.'] + + # Relational runtime checks over the authoritative event streams; each + # companion registers the assertions its own package owns. + - id: invariants + name: '@deepseek-ai/dsh-invariants' + - id: session-invariant + name: '@deepseek-ai/dsh-session/invariant' + - id: agent-invariant + name: '@deepseek-ai/dsh-agent/invariant' + - id: scope-invariant + name: '@deepseek-ai/dsh-scope/invariant' + - id: agent-loop-invariant + name: '@deepseek-ai/dsh-agent-loop/invariant' + + - id: session-checkpoint-policy + name: '@deepseek-ai/dsh-session-checkpoint-policy' + + # The derived query index behind `/resume`. The launcher owns the session + # store location, so it provides the resolved index path on the boot context + # (`launcherSessionQueryPath`); the index and the logs it indexes therefore + # cannot diverge. The project-local fallback applies when no launcher sets it. + - id: session-query-sqlite + name: '@deepseek-ai/dsh-session-query-sqlite' + config: + path: !!js launcherSessionQueryPath ?? './.sessions/session-query.db' + + - id: session-reference + name: '@deepseek-ai/dsh-session-reference' + + # Refuses write/edit inside the dsh checkout this launcher runs from, on that + # checkout's own branch, until the session loads dsh-customize. Inert + # everywhere else, so an ordinary project sees no change. + - id: source-guard + name: '@deepseek-ai/dsh-source-guard' + + - id: tool-result-prune + name: '@deepseek-ai/dsh-compact-tool-result-prune' + + # Persisted same-session goals reach the model and the slash menu here; the + # domain, driver, and `/goal` command are in the base. + - id: tool-goal + name: '@deepseek-ai/dsh-tool-goal' + + # A separate fixed consumer demonstrates fresh-agent Ralph iteration without + # changing the workflow tool or same-session goal behavior. + - id: tool-ralph + name: '@deepseek-ai/dsh-tool-ralph' + + # The keyboard-backed provider behind ask_user_question and the plan-mode + # review, and the front door it renders inside. + - id: tui-prompt + name: '@deepseek-ai/dsh-tui/prompt' + + # The TUI renders exactly the agent the agent-loop row bound, so it reads the + # same launcher-owned identity rather than restating one. + - id: tui + name: '@deepseek-ai/dsh-tui' + config: + sessionId: !!js configuredAgentIdentities?.main?.id ?? 'main' + showReasoning: true + maxToolOutputLines: 6 + + - id: tool-ask-user + name: '@deepseek-ai/dsh-tool-ask-user' diff --git a/apps/cli/web.cordis.yml b/apps/cli/web.cordis.yml new file mode 100644 index 0000000000..9cbd73a6f6 --- /dev/null +++ b/apps/cli/web.cordis.yml @@ -0,0 +1,167 @@ +# `dsh web` — the browser surface, as a patch list over `base.cordis.yml`. +# The launcher includes the base and applies this file, then any `--config` +# overlay, then AppCLIEntry's profile-json and CLI-flag patches, as sibling patch +# lists at ONE include level: patches never cross an include boundary, so +# stacking overlays as nested includes would silently stop reaching base rows. +# +# A patch replaces the targeted row's whole `config`, so each row below restates +# every key it owns. `--dev` appends the dsh-client-hmr row in code +# (AppCLIEntry) — prod and dev differ by exactly that one row. + +# ── surface-specific values the base deliberately omits ───────────────────── + +- id: system-prompt + config: + persona: '' + +- id: tools + config: + # TEMPORARY workaround: DSH_TOOLS_MODE (native|code|both) opts a whole dsh + # process into Code Mode while per-session tool-mode selection is being + # designed; unset keeps the schema default (native). Remove the env seam + # once the web UI owns the choice per session. + mode: !!js process.env.DSH_TOOLS_MODE + +- id: agent-loop + config: + agents: [] + +- id: llm-deepseek + config: + apiKey: !!js process.env.DEEPSEEK_API_KEY + baseURL: !!js process.env.DEEPSEEK_BASE_URL + +- id: fs-local + +# ── web-only host rows, the transport layer, and the browser roster ───────── + +# `dshClient` rows are the browser roster the modules node half scans into +# window.__DSH_BOOT__; the modules row is simultaneously a host row. +- insert: + - id: session-projection + name: '@deepseek-ai/dsh-session-projection' + + - id: code-runtime + name: '@deepseek-ai/dsh-code-runtime-worker' + + - id: storage + name: '@deepseek-ai/dsh-storage' + + - id: storage-json + name: '@deepseek-ai/dsh-storage-json' + config: + root: './.storages' + + - id: storage-domain + name: '@deepseek-ai/dsh-storage-domain' + config: + backend: json + + - id: workspace + name: '@deepseek-ai/dsh-workspace' + + - id: session-projection-cache + name: '@deepseek-ai/dsh-session-projection-cache' + config: + writeEveryEvents: 200 + writeIntervalMs: 5000 + + - id: tool-todo + name: '@deepseek-ai/dsh-tool-todo' + + # The API gateway: the transport-agnostic dispatch face every client shape + # shares. provider/model are the host default routing — the profile json's + # mapping target (user config overrides these engineering defaults). + - id: api-gateway + name: '@deepseek-ai/dsh-host-apiproxy' + config: + provider: deepseek + model: deepseek-v4-flash + + # ── layer 2: transport/service ────────────────────────────────────────────── + + # Plain route-registration carrier. distIndex is an assembly fact, not user + # config — AppCLIEntry resolves the frontend dist and patches it in; host and + # port arrive as CLI-flag patches over these defaults. + - id: webserver + name: '@deepseek-ai/dsh-host-webserver' + config: + host: 127.0.0.1 + port: 3080 + + # ── browser plugin roster (dshClient rows; node halves are layer-2 hosts) ── + + # Dual-face: node half scans this very tree for dshClient rows, composes + # window.__DSH_BOOT__, serves /plugins//client.js; browser half is the + # module table the shell kernel constructs before cordis exists (§4.7 — + # adopted as a plugin entry by the kernel, never fetched). + - id: modules + name: '@deepseek-ai/dsh-client-modules' + + # Owns both ends of the web transport: node half binds the gateway to the + # webserver under /api; browser half is the fetch/SSE client. + - id: connection + name: '@deepseek-ai/dsh-client-connection' + + - id: client-runtime + name: '@deepseek-ai/dsh-client-runtime' + + - id: ui-theme + name: '@deepseek-ai/dsh-client-ui-theme' + + - id: locale + name: '@deepseek-ai/dsh-client-locale' + + - id: ui-layout + name: '@deepseek-ai/dsh-client-ui-layout' + + - id: ui-sidebar + name: '@deepseek-ai/dsh-client-ui-sidebar' + + - id: ui-settings + name: '@deepseek-ai/dsh-client-ui-settings' + + - id: ui-settings-general + name: '@deepseek-ai/dsh-client-ui-settings-general' + + - id: ui-models + name: '@deepseek-ai/dsh-client-ui-models' + + - id: ui-conversation + name: '@deepseek-ai/dsh-client-ui-conversation' + + + - id: ui-workspace + name: '@deepseek-ai/dsh-client-ui-workspace' + + # Input triggers: the '/' | '@' pipeline (ui-slash), the command surface over + # it (ui-command), and the two reference sources (ui-skill / ui-subagent). + - id: ui-slash + name: '@deepseek-ai/dsh-client-ui-slash' + + - id: ui-command + name: '@deepseek-ai/dsh-client-ui-command' + + - id: ui-skill + name: '@deepseek-ai/dsh-client-ui-skill' + + - id: ui-subagent + name: '@deepseek-ai/dsh-client-ui-subagent' + + # Goal surface: GoalBar in the input dock over the goal session projection. + - id: ui-goal + name: '@deepseek-ai/dsh-client-ui-goal' + + # Model selection: the /model popupSelect + composer seat over session.models. + - id: ui-model + name: '@deepseek-ai/dsh-client-ui-model' + + # Plan control: the composer plan seat over the plan projection + /plan channel. + - id: ui-plan + name: '@deepseek-ai/dsh-client-ui-plan' + + - id: ui-question + name: '@deepseek-ai/dsh-client-ui-question' + + - id: ui-trajectory + name: '@deepseek-ai/dsh-client-ui-trajectory' diff --git a/docs/capability-seams.md b/docs/capability-seams.md index 6de0f7ff6e..f4607ce0f2 100644 --- a/docs/capability-seams.md +++ b/docs/capability-seams.md @@ -89,7 +89,6 @@ flowchart LR pkg_skill_local["skill-local"] svc_agents["ctx.agents
Agent service"] pkg_acp["acp"] - pkg_tui_demo["tui-demo"] svc_agentLoop["ctx.agentLoop
Concrete loop driver"] pkg_agent_spine_demo["agent-spine-demo"] pkg_goal["goal"] @@ -245,7 +244,6 @@ flowchart LR svc_agents --> pkg_agent_loop svc_agents --> pkg_cli_demo svc_agents --> pkg_subagent_inprocess - svc_agents --> pkg_tui_demo svc_approval --> pkg_tool_bash svc_approval --> pkg_tools svc_bash --> pkg_hooks_claude @@ -359,7 +357,7 @@ flowchart LR | `ctx.sessionProjectionCache` | `core` | [`session-projection-cache`](../packages/session-projection/session-projection-cache) | - | [`host-apiproxy`](../packages/host/apiproxy) | - | Durably checkpoints projection unit states per session (throttled + turn/end/detach mandatory points) and serves the cold-read ladder: cache row + persistence tail replay, so listings never load full logs. | | `ctx.tui` | `bundle` | [`tui`](../packages/ui/tui) | - | - | - | One TUI front door provides a FIFO overlay host; injected plugins receive caller-fiber ownership without access to pi-tui or terminal lifecycle state. | | `ctx.skills` | `seam` | [`skill`](../packages/skill/skill) | [`skill-local`](../packages/skill/skill-local) | [`tool-skill`](../packages/skill/tool-skill) | - | Merges provider skill catalogs; tool-skill renders the session-prefix catalog and loads complete skill bodies. | -| `ctx.agents` | `core` | [`agent`](../packages/core/agent) | - | [`agent-loop`](../packages/core/agent-loop), [`acp`](../packages/acp/acp), [`cli-demo`](../packages/examples/cli-demo), [`subagent-inprocess`](../packages/subagent/subagent-inprocess), [`tui-demo`](../packages/examples/tui-demo) | - | Owns live Agent handles, the create/resume factory seam, and process-local initiator propagation. | +| `ctx.agents` | `core` | [`agent`](../packages/core/agent) | - | [`agent-loop`](../packages/core/agent-loop), [`acp`](../packages/acp/acp), [`cli-demo`](../packages/examples/cli-demo), [`subagent-inprocess`](../packages/subagent/subagent-inprocess) | - | Owns live Agent handles, the create/resume factory seam, and process-local initiator propagation. | | `ctx.agentLoop` | `bundle` | [`agent-loop`](../packages/core/agent-loop) | - | [`agent-spine-demo`](../packages/examples/agent-spine-demo) | - | The one concrete loop plugin; extension packages depend on dsh-agent events and services, not on this package. | | `ctx.goals` | `core` | [`goal`](../packages/goal/goal) | - | - | - | Folds revisioned objective state from the session log and keeps live continuation activation process-local. | | `ctx.subprocess` | `seam` | [`subprocess`](../packages/subprocess/subprocess) | [`subprocess-local`](../packages/subprocess/subprocess-local) | [`bash-local`](../packages/bash/bash-local), [`bash-sandbox`](../packages/bash/bash-sandbox), [`lsp-local`](../packages/lsp/lsp-local), [`subagent-acp`](../packages/subagent/subagent-acp) | - | The bash executors, the LSP host, and the ACP subagent backend spawn their children through ctx.subprocess; the service owns tree lifetime, stdio dispositions (pipes, inherit, bounded spill-backed collection), and kill escalation. | diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 8bd6e966b9..35280954e7 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -108,7 +108,7 @@ export interface Config { Depends on: [`AgentOptions`](core-data-structures/core.md) · [`SessionId`](core-data-structures/core.md) -Source: [`packages/core/agent-loop/src/index.ts:155`](../packages/core/agent-loop/src/index.ts) +Source: [`packages/core/agent-loop/src/index.ts:211`](../packages/core/agent-loop/src/index.ts) ## `@deepseek-ai/dsh-agent-spine-demo` @@ -2005,59 +2005,6 @@ export interface TuiThemeConfig { Source: [`packages/ui/tui/src/config.ts:117`](../packages/ui/tui/src/config.ts) -## `@deepseek-ai/dsh-tui-demo` - -```ts config-catalog -/** App config routed to the spine, TUI, configured agent, and JSONL backend. */ -export interface Config { - /** Provider route for the `main` agent. */ - provider: string - /** Model name for the `main` agent; a matching adapter must be registered. */ - model: string - /** Bundled agent-loop concurrency cap; `1` is serial and omission uses its default. */ - maxParallelToolCalls?: number - /** Deployment persona forwarded to the system-prompt plugin. */ - persona?: string - /** Explicit model-facing tool order forwarded to the system-prompt plugin. */ - toolOrder?: string[] - /** Tool-registry presentation config forwarded through agent-spine-demo. */ - tools?: ToolsConfig - /** DeepSeek Harness home directory exposed to bash and used for local skill discovery. */ - dshHome?: string - /** Fallback session-title limits forwarded through agent-spine-demo. */ - sessionTitle?: NonNullable - /** - * Directory for JSONL sessions and the derived query index. Precedence: - * this explicit config, then the launcher's opaque `SESSIONS_ROOT_KEY` boot - * slot (the dsh CLI resolves it to `DSH_HOME/sessions`), then a project-local - * `./.sessions` fallback — the bundle itself never assumes a global store. - */ - persistenceRoot?: string - /** JSONL artifact encoding; defaults to checksummed Zstandard frames. */ - persistenceCompression?: JsonlCompression - /** Cross-session reference discovery and snapshot byte budgets. */ - sessionReferences?: SessionReferenceConfig - /** TUI transcript's optional first line; absent renders nothing on start. */ - welcome?: string - /** Full-screen TUI presentation settings. */ - ui?: uiTui.TuiConfig - /** Skill registry, local-provider, and model-facing consumer config. */ - skills?: agentCore.SkillConfig - /** Model-facing bash tool config forwarded through agent-spine-demo. */ - toolBash?: NonNullable - /** Generic background-task controls forwarded through agent-spine-demo; set false to omit them. */ - toolTasks?: NonNullable - /** Persisted same-session goals; owner defaults enable them, or false disables the stack and command. */ - goals?: agentCore.GoalConfig | false - /** Controls automatic AGENTS.md/CLAUDE.md loading; configure a byte budget or set `false`. */ - workspaceContext: agentCore.Config['workspaceContext'] -} -``` - -Depends on: [`agentCore`](../packages/examples/agent-spine-demo/src/index.ts) · [`JsonlCompression`](../packages/session-persistence/session-persistence-jsonl/src/index.ts) · [`SessionReferenceConfig`](#deepseek-aidsh-session-reference) · [`ToolsConfig`](#deepseek-aidsh-tools) · [`uiTui`](../packages/ui/tui/src/index.ts) - -Source: [`packages/examples/tui-demo/src/index.ts:44`](../packages/examples/tui-demo/src/index.ts) - ## `@deepseek-ai/dsh-user-approval` ```ts config-catalog diff --git a/docs/cookbook/extension-cookbook.i18n.yaml b/docs/cookbook/extension-cookbook.i18n.yaml index 9ff53f33c3..b971e0ce51 100644 --- a/docs/cookbook/extension-cookbook.i18n.yaml +++ b/docs/cookbook/extension-cookbook.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/cookbook/extension-cookbook.md -extension-cookbook.md: 36ab56dcdce1166ef69cec7834f6c17be72d89c3 -extension-cookbook.zh.md: 8c8f9486ec592fcc80f1053f54adce2e33798d4b +extension-cookbook.md: 3fed79372e284df8d2cf87a3b1ca6fac6e2131ae +extension-cookbook.zh.md: 1d422712592c7427fde785f1419633a13a55cca3 diff --git a/docs/cookbook/extension-cookbook.md b/docs/cookbook/extension-cookbook.md index 36ab56dcdc..3fed79372e 100644 --- a/docs/cookbook/extension-cookbook.md +++ b/docs/cookbook/extension-cookbook.md @@ -91,7 +91,7 @@ export function apply(ctx: Context) { ## Runnable wirings -Runnable leaves load their plugin trees from `examples/*/cordis.yml`; the root `demo:*` scripts and those leaf directories are the authoritative inventory. Interactive leaves use [`@deepseek-ai/dsh-tui-demo`](../../packages/examples/tui-demo), non-interactive leaves use [`@deepseek-ai/dsh-cli-demo`](../../packages/examples/cli-demo), ACP leaves use [`@deepseek-ai/dsh-acp-demo`](../../packages/examples/acp-demo), and the app packages share [`@deepseek-ai/dsh-agent-spine-demo`](../../packages/examples/agent-spine-demo). +Runnable leaves load their plugin trees from `examples/*/cordis.yml`; the root `demo:*` scripts and those leaf directories are the authoritative inventory. Interactive leaves use [`@deepseek-ai/dsh-tui`](../../packages/ui/tui), non-interactive leaves use [`@deepseek-ai/dsh-cli-demo`](../../packages/examples/cli-demo), ACP leaves use [`@deepseek-ai/dsh-acp-demo`](../../packages/examples/acp-demo), and the app packages share [`@deepseek-ai/dsh-agent-spine-demo`](../../packages/examples/agent-spine-demo). ## The feature → mechanism map diff --git a/docs/cookbook/extension-cookbook.zh.md b/docs/cookbook/extension-cookbook.zh.md index 8c8f9486ec..1d42271259 100644 --- a/docs/cookbook/extension-cookbook.zh.md +++ b/docs/cookbook/extension-cookbook.zh.md @@ -91,7 +91,7 @@ export function apply(ctx: Context) { ## 可运行的组装示例 -可运行叶子从 `examples/*/cordis.yml` 加载各自的插件树;根目录的 `demo:*` 脚本和这些叶子目录是权威清单。交互式叶子使用 [`@deepseek-ai/dsh-tui-demo`](../../packages/examples/tui-demo),非交互式叶子使用 [`@deepseek-ai/dsh-cli-demo`](../../packages/examples/cli-demo),ACP 叶子使用 [`@deepseek-ai/dsh-acp-demo`](../../packages/examples/acp-demo),应用包共享 [`@deepseek-ai/dsh-agent-spine-demo`](../../packages/examples/agent-spine-demo)。 +可运行叶子从 `examples/*/cordis.yml` 加载各自的插件树;根目录的 `demo:*` 脚本和这些叶子目录是权威清单。交互式叶子使用 [`@deepseek-ai/dsh-tui`](../../packages/ui/tui),非交互式叶子使用 [`@deepseek-ai/dsh-cli-demo`](../../packages/examples/cli-demo),ACP 叶子使用 [`@deepseek-ai/dsh-acp-demo`](../../packages/examples/acp-demo),应用包共享 [`@deepseek-ai/dsh-agent-spine-demo`](../../packages/examples/agent-spine-demo)。 ## 功能→机制映射 diff --git a/docs/cordis-catalog/events.md b/docs/cordis-catalog/events.md index 967241fbf6..93b87fe9d9 100644 --- a/docs/cordis-catalog/events.md +++ b/docs/cordis-catalog/events.md @@ -381,7 +381,7 @@ A declarative agent entry failed before it could publish a live agent. Consumers Types: [SessionId](../core-data-structures/core.md) -Source: [`packages/core/agent-loop/src/index.ts:148`](../../packages/core/agent-loop/src/index.ts) +Source: [`packages/core/agent-loop/src/index.ts:157`](../../packages/core/agent-loop/src/index.ts) ## `approval/*` diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 7010113be7..f576af1243 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -44,7 +44,7 @@ async resume(ownerCtx: Context, options: ResumeAgentOptions): Promise pkg_session_persistence_jsonl pkg_cli_demo --> pkg_tools pkg_cli_demo --> pkg_workspace_context - pkg_tui_demo --> pkg_agent - pkg_tui_demo --> pkg_agent_loop - pkg_tui_demo --> pkg_agent_spine_demo - pkg_tui_demo --> pkg_command_goal - pkg_tui_demo --> pkg_commands - pkg_tui_demo --> pkg_invariants - pkg_tui_demo --> pkg_llm - pkg_tui_demo --> pkg_session - pkg_tui_demo --> pkg_session_checkpoint_policy - pkg_tui_demo --> pkg_session_persistence_jsonl - pkg_tui_demo --> pkg_session_query - pkg_tui_demo --> pkg_session_query_sqlite - pkg_tui_demo --> pkg_session_reference - pkg_tui_demo --> pkg_tool_ask_user - pkg_tui_demo --> pkg_tools - pkg_tui_demo --> pkg_tui - pkg_tui_demo --> pkg_user_interaction - pkg_tui_demo --> pkg_workspace_context pkg_sdk_client --> pkg_invariants pkg_sdk_client --> pkg_llm pkg_sdk_client --> pkg_sdk_protocol @@ -1160,6 +1141,5 @@ flowchart TD | [`jsonrpc`](../packages/ui/jsonrpc) | `ui` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`llm-deepseek`](../packages/llm/llm-deepseek), [`scope`](../packages/core/scope), [`sdk-protocol`](../packages/sdk/sdk-protocol), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) | | [`acp-demo`](../packages/examples/acp-demo) | `examples` | [`acp`](../packages/acp/acp), [`agent-spine-demo`](../packages/examples/agent-spine-demo), [`app-boot`](../packages/ui/app-boot), [`invariants`](../packages/support/invariants), [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy), [`session-persistence-jsonl`](../packages/session-persistence/session-persistence-jsonl), [`session-query`](../packages/session-query/session-query), [`session-query-sqlite`](../packages/session-query/session-query-sqlite), [`tools`](../packages/core/tools), [`workspace-context`](../packages/context/workspace-context) | | [`cli-demo`](../packages/examples/cli-demo) | `examples` | [`agent`](../packages/core/agent), [`agent-spine-demo`](../packages/examples/agent-spine-demo), [`app-boot`](../packages/ui/app-boot), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy), [`session-persistence-jsonl`](../packages/session-persistence/session-persistence-jsonl), [`tools`](../packages/core/tools), [`workspace-context`](../packages/context/workspace-context) | -| [`tui-demo`](../packages/examples/tui-demo) | `examples` | [`agent`](../packages/core/agent), [`agent-loop`](../packages/core/agent-loop), [`agent-spine-demo`](../packages/examples/agent-spine-demo), [`command-goal`](../packages/goal/command-goal), [`commands`](../packages/ui/commands), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy), [`session-persistence-jsonl`](../packages/session-persistence/session-persistence-jsonl), [`session-query`](../packages/session-query/session-query), [`session-query-sqlite`](../packages/session-query/session-query-sqlite), [`session-reference`](../packages/context/session-reference), [`tool-ask-user`](../packages/ui/tool-ask-user), [`tools`](../packages/core/tools), [`tui`](../packages/ui/tui), [`user-interaction`](../packages/ui/user-interaction), [`workspace-context`](../packages/context/workspace-context) | | [`sdk-client`](../packages/sdk/sdk-client) | `sdk` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sdk-protocol`](../packages/sdk/sdk-protocol), [`session`](../packages/core/session) | | [`subagent-dsh-sdk`](../packages/subagent/subagent-dsh-sdk) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sdk-client`](../packages/sdk/sdk-client), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subprocess`](../packages/subprocess/subprocess) | diff --git a/docs/testing.i18n.yaml b/docs/testing.i18n.yaml index 976f52c779..b8397d1b8e 100644 --- a/docs/testing.i18n.yaml +++ b/docs/testing.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/testing.md -testing.md: 04bd7782fa4328b6b693f13f60f4e33b463f8a18 -testing.zh.md: 5712fd8ce7b0cd46ebeb237bdd12c6c572ebe3de +testing.md: 53f6d3c8a9c0eca61d7c5272ab78da9492ddc2a2 +testing.zh.md: 527ab0b1a0fa28fc9fd210e7955aa2280cd1850a diff --git a/docs/testing.md b/docs/testing.md index 04bd7782fa..53f6d3c8a9 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -46,4 +46,4 @@ An e2e assertion re-runs the command or re-reads the file externally; a keyword ## When a snapshot test is required -Every non-trivial model-, protocol-, or human-visible change adds or updates a keyless scenario in the same PR through a runnable example's owning snapshot suite. Package tests, e2e assertions, mock/test-only compositions, and PR rationale do not replace the assembled transcript; extend the harness when needed. ACP automation scenarios use `examples//tests/snapshots/`, a scenario table over the [`dsh-acp-snapshot`](../packages/support/acp-snapshot/README.md) suite factory (`examples/acp-agent` is primary); `examples/headless-agent` owns the `stream-json` snapshot and replay fixtures. Completed interactive-terminal journeys use JSONL-driven scenarios under `examples/tui-agent/tests/snapshots/`; transient presentation uses the package-local semantic matrix, with a PTY case when input, Loader selection, or terminal teardown changes. Browser-rendered web GUI journeys use `apps/web/tests/snapshots/`. New capability seams, lifecycle shapes, or transcript surfaces name every coverage tier at plan time and verify the harness can express it before implementation. +Every non-trivial model-, protocol-, or human-visible change adds or updates a keyless scenario in the same PR through a runnable example's owning snapshot suite. Package tests, e2e assertions, mock/test-only compositions, and PR rationale do not replace the assembled transcript; extend the harness when needed. ACP automation scenarios use `examples//tests/snapshots/`, a scenario table over the [`dsh-acp-snapshot`](../packages/support/acp-snapshot/README.md) suite factory (`examples/acp-agent` is primary); `examples/headless-agent` owns the `stream-json` snapshot and replay fixtures. Completed interactive-terminal journeys use JSONL-driven scenarios under `apps/cli/tests/snapshots/`; transient presentation uses the package-local semantic matrix, with a PTY case when input, Loader selection, or terminal teardown changes. Browser-rendered web GUI journeys use `apps/web/tests/snapshots/`. New capability seams, lifecycle shapes, or transcript surfaces name every coverage tier at plan time and verify the harness can express it before implementation. diff --git a/docs/testing.zh.md b/docs/testing.zh.md index 5712fd8ce7..527ab0b1a0 100644 --- a/docs/testing.zh.md +++ b/docs/testing.zh.md @@ -46,4 +46,4 @@ e2e 断言应重新运行命令或从外部重新读取文件;对 agent 自身 ## 何时需要快照测试 -每项非平凡的模型可见、协议可见或人类可见变更,都必须在同一 PR 中,通过可运行示例所属的快照套件添加或更新无密钥场景。包测试、e2e 断言、mock 与仅测试组合、PR 理由都不能取代组装后的 transcript;必要时应扩展 harness。ACP 自动化场景使用 `examples//tests/snapshots/`,即基于 [`dsh-acp-snapshot`](../packages/support/acp-snapshot/README.md) 套件工厂的场景表(`examples/acp-agent` 为主套件);`examples/headless-agent` 拥有 `stream-json` 快照与回放 fixture。已完成的交互式终端旅程使用 `examples/tui-agent/tests/snapshots/` 下由 JSONL 驱动的场景;瞬态呈现使用包内语义矩阵,输入、Loader 选择或终端清理发生变化时还要添加 PTY 用例。新的能力 seam、生命周期形态或 transcript 呈现接口在计划阶段就要列出每个覆盖层级,并在实现前验证 harness 能够表达它们。 +每项非平凡的模型可见、协议可见或人类可见变更,都必须在同一 PR 中,通过可运行示例所属的快照套件添加或更新无密钥场景。包测试、e2e 断言、mock 与仅测试组合、PR 理由都不能取代组装后的 transcript;必要时应扩展 harness。ACP 自动化场景使用 `examples//tests/snapshots/`,即基于 [`dsh-acp-snapshot`](../packages/support/acp-snapshot/README.md) 套件工厂的场景表(`examples/acp-agent` 为主套件);`examples/headless-agent` 拥有 `stream-json` 快照与回放 fixture。已完成的交互式终端旅程使用 `apps/cli/tests/snapshots/` 下由 JSONL 驱动的场景;瞬态呈现使用包内语义矩阵,输入、Loader 选择或终端清理发生变化时还要添加 PTY 用例。新的能力 seam、生命周期形态或 transcript 呈现接口在计划阶段就要列出每个覆盖层级,并在实现前验证 harness 能够表达它们。 diff --git a/docs/tool-catalog.md b/docs/tool-catalog.md index 995a8c669d..399d0aff27 100644 --- a/docs/tool-catalog.md +++ b/docs/tool-catalog.md @@ -19,7 +19,7 @@ This table connects model-visible tool names to the plugin package and service s | `@deepseek-ai/dsh-tools` | `run_code` | `ctx.tools`, `ctx.codeRuntime (execution time)`, `ctx.systemPrompt` | `tool/call`, `one tool/code-dispatch-start + tool/code-dispatch pair per bridged sub-call`, `tool/result` | - | Owned by the tool registry as a reserved transport outside filterable capability layers under `mode: code` / `mode: both` (see the Code Mode Agent Note). Under `code` it is the registry's only wire contribution; the other visible capabilities are declared in a generated TypeScript SDK section, and a program calls them through bindings scheduled under the native concurrency contract (submission-ordered starts and policy; concurrency-safe bodies overlap up to `maxParallelSubCalls`) that re-enter the complete guarded tool pipeline and link each nested execution to this outer result. | | `@deepseek-ai/dsh-plan-mode` | `exit_plan_mode` | `ctx.tools`, `ctx.systemPrompt`, `ctx.userInteraction (execution time, opportunistic)` | `tool/call`, `plan/mode inactive on an approved review`, `tool/result` | - | exit_plan_mode stays in the model-facing schema while planning is inactive so transitions add no tool-catalog churn on top of the plan-policy change. Its execute path rejects calls outside plan mode; in plan mode it presents the plan over the user-interaction seam (approve / keep planning with feedback), and approval logs plan mode inactive at the step boundary. | | `@deepseek-ai/dsh-tool-bash` | `bash` | `ctx.tools`, `ctx.bash`, `ctx.tasks at call time for run_in_background` | `tool/call`, `tool/result` | - | The bash tool is the model-facing consumer of the bash executor seam. A `run_in_background` run registers with the generic `ctx.tasks` runtime and is collected/stopped through the `task_*` tools from `@deepseek-ai/dsh-tool-tasks`; the `enableRunInBackground` config (default true) removes the parameter entirely when disabled. | -| `@deepseek-ai/dsh-tool-cordis` | `cordis_inspect`, `cordis_mount`, `cordis_unmount` | `ctx.tools` | `tool/call`, `tool/result`, `process-local temporary Plugin lifecycle` | - | Ships in examples/cordis-agent only (a deliberate opt-in — temporary Plugin code reaches the real runtime, see .agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md). Plugins created by cordis_mount may register ADDITIONAL model-visible tools until unmounted or DSH restarts; a full changed request header logs those tool-set changes. | +| `@deepseek-ai/dsh-tool-cordis` | `cordis_inspect`, `cordis_mount`, `cordis_unmount` | `ctx.tools` | `tool/call`, `tool/result`, `process-local temporary Plugin lifecycle` | - | Not in any shipped tree (a deliberate opt-in — temporary Plugin code reaches the real runtime, see .agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md). Plugins created by cordis_mount may register ADDITIONAL model-visible tools until unmounted or DSH restarts; a full changed request header logs those tool-set changes. | | `@deepseek-ai/dsh-tool-fs` | `edit`, `read`, `write` | `ctx.tools`, `ctx.fs`, `ctx.systemPrompt` | `tool/call`, `fs/write-intent or fs/edit-intent for mutations`, `fs/observed after successful file operations`, `tool/result` | - | The read-before-write/edit policy is added by `@deepseek-ai/dsh-fs-policy` (an `fs/*` event-gate plugin, no schema change); a deployment that loads these tools is expected to also load it. The tool schemas above are identical with or without the policy plugin. | | `@deepseek-ai/dsh-tool-fs-search` | `glob`, `grep` | `ctx.tools`, `ctx.bash`, `ctx.systemPrompt` | `tool/call`, `tool/result` | - | glob and grep are conditional bash-backed discovery tools: they register only when ctx.bash can find `rg`, then run fixed ripgrep commands through ctx.bash as ordinary foreground calls (never background tasks). Capped results save the complete formatted list through the optional ctx.spillStore backend; returned locators are follow-up-readable/searchable when the backend exposes local paths in co-located deployments. | | `@deepseek-ai/dsh-tool-pty` | `terminal_close`, `terminal_list`, `terminal_open`, `terminal_read`, `terminal_send`, `terminal_signal` | `ctx.tools`, `ctx.pty`, `ctx.systemPrompt`, `ctx.tasks at call time for run_in_background` | `tool/call`, `tool/result` | - | The six terminal tools are opt-in and complement one-shot bash/filesystem tools. `terminal_send(run_in_background: true)` registers with `ctx.tasks`; TUI, named key sequences, BEL, resize, auto-start, and cross-agent sharing are absent from the schema. | @@ -28,7 +28,7 @@ This table connects model-visible tool names to the plugin package and service s | `@deepseek-ai/dsh-tool-ralph` | `ralph` | `ctx.tools`, `ctx.workflows`, `ctx.subagents`, `ctx.systemPrompt`, `a calling Agent (exec.agent parents every fresh round)` | `tool/call`, `tool/result`, `workflow and child session events during execution` | - | A fixed foreground workflow starts one fresh structured child per round; the model selects only the immutable objective and an optional round cap. | | `@deepseek-ai/dsh-tool-skill` | `skill` | `ctx.tools`, `ctx.skills` | `tool/call`, `tool/result` | - | - | | `@deepseek-ai/dsh-tool-session-query` | `session_event_read`, `session_event_search`, `session_event_trace`, `session_search`, `session_trace` | `ctx.tools`, `ctx.systemPrompt`, `ctx.sessionQuery`, `a calling Agent for workspace authority` | `tool/call`, `tool/result` | - | The five read-only tools hide provider cursors and authorize every result from the immutable calling agent session. The package is opt-in; compositions that need enforced deadlines or bounded inline output also mount the generic timeout or spill policies. | -| `@deepseek-ai/dsh-tool-subagent` | `subagent` | `ctx.tools`, `ctx.subagents` | `tool/call`, `tool/result`, `child session events through the chosen provider` | `subagent`, `subagent_fork` | The registered tool name is the load-time `toolName` config (default `subagent`); the schema above is that default. The shipped example agents load this package once per subagent backend, so the model additionally sees `subagent_fork` (bound to the fork backend) with an identical schema — see `examples/tui-agent/cordis.yml` and `examples/acp-agent/cordis.yml`. | +| `@deepseek-ai/dsh-tool-subagent` | `subagent` | `ctx.tools`, `ctx.subagents` | `tool/call`, `tool/result`, `child session events through the chosen provider` | `subagent`, `subagent_fork` | The registered tool name is the load-time `toolName` config (default `subagent`); the schema above is that default. The shipped example agents load this package once per subagent backend, so the model additionally sees `subagent_fork` (bound to the fork backend) with an identical schema — see `apps/cli/base.cordis.yml` and `examples/acp-agent/cordis.yml`. | | `@deepseek-ai/dsh-tool-tasks` | `task_kill`, `task_list`, `task_output` | `ctx.tools`, `ctx.tasks`, `ctx.systemPrompt` | `tool/call`, `tool/result`, `user/message via agent.inject() for background completion notices` | - | The kind-agnostic background-task control surface: background bash commands, PTY sends, and subagents are read, listed, and killed through the same three tools. Loading the plugin attaches the control surface that arms producers' `ctx.tasks.start()`. | | `@deepseek-ai/dsh-tool-todo` | `todo_write` | `ctx.tools`, `owning Agent session` | `tool/call`, `todo/write`, `tool/result` | - | todo_write is session-owned state; UIs render the latest todo/write event as a checklist. | | `@deepseek-ai/dsh-tool-workflow` | `workflow` | `ctx.tools`, `ctx.workflows`, `ctx.systemPrompt`, `a calling Agent (exec.agent parents the script children)` | `tool/call`, `tool/result` | - | - | @@ -277,7 +277,7 @@ Unmount a current-process temporary Plugin created by cordis_mount. Waits for it Source: [`packages/cordis/tool-cordis/src/index.ts`](../packages/cordis/tool-cordis/src/index.ts) -Ships in examples/cordis-agent only (a deliberate opt-in — temporary Plugin code reaches the real runtime, see .agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md). Plugins created by cordis_mount may register ADDITIONAL model-visible tools until unmounted or DSH restarts; a full changed request header logs those tool-set changes. +Not in any shipped tree (a deliberate opt-in — temporary Plugin code reaches the real runtime, see .agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md). Plugins created by cordis_mount may register ADDITIONAL model-visible tools until unmounted or DSH restarts; a full changed request header logs those tool-set changes. ## `@deepseek-ai/dsh-tool-fs` @@ -1049,7 +1049,7 @@ Delegate a self-contained task to a subagent (a separate agent that works in its Source: [`packages/subagent/tool-subagent/src/index.ts`](../packages/subagent/tool-subagent/src/index.ts) -The registered tool name is the load-time `toolName` config (default `subagent`); the schema above is that default. The shipped example agents load this package once per subagent backend, so the model additionally sees `subagent_fork` (bound to the fork backend) with an identical schema — see `examples/tui-agent/cordis.yml` and `examples/acp-agent/cordis.yml`. +The registered tool name is the load-time `toolName` config (default `subagent`); the schema above is that default. The shipped example agents load this package once per subagent backend, so the model additionally sees `subagent_fork` (bound to the fork backend) with an identical schema — see `apps/cli/base.cordis.yml` and `examples/acp-agent/cordis.yml`. ## `@deepseek-ai/dsh-tool-tasks` diff --git a/docs/user/develop/practice/llm-adapter.i18n.yaml b/docs/user/develop/practice/llm-adapter.i18n.yaml index 402e3537a7..0044ac720e 100644 --- a/docs/user/develop/practice/llm-adapter.i18n.yaml +++ b/docs/user/develop/practice/llm-adapter.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write -llm-adapter.md: 3bf005647d3e7908f1d1553374266c1d6c12c2e6 -llm-adapter.zh.md: b967d112ed6879b11486ab7aedf64653089d93de +# pnpm run verify-translation-pairing --write docs/user/develop/practice/llm-adapter.md +llm-adapter.md: 7445688530c1ba61e5c065f9f5e49db6498da5b1 +llm-adapter.zh.md: c30200314a01f7a61d48e3f47288013c31e5aef4 diff --git a/docs/user/develop/practice/llm-adapter.md b/docs/user/develop/practice/llm-adapter.md index 3bf005647d..7445688530 100644 --- a/docs/user/develop/practice/llm-adapter.md +++ b/docs/user/develop/practice/llm-adapter.md @@ -133,10 +133,12 @@ The first argument lists the model names handled by the adapter. If `cordis.yml` - my-model-v1 - my-model-v2 -- id: tui-agent - name: '@deepseek-ai/dsh-tui-demo' +- id: agent-loop + name: '@deepseek-ai/dsh-agent-loop' config: - provider: my-llm + agents: + - id: main + provider: my-llm model: my-model-v1 # References the model registered above. workspaceContext: false ``` diff --git a/docs/user/develop/practice/llm-adapter.zh.md b/docs/user/develop/practice/llm-adapter.zh.md index b967d112ed..c30200314a 100644 --- a/docs/user/develop/practice/llm-adapter.zh.md +++ b/docs/user/develop/practice/llm-adapter.zh.md @@ -133,10 +133,12 @@ ctx.llm.registerAdapter(['model-name-1', 'model-name-2'], adapter) - my-model-v1 - my-model-v2 -- id: tui-agent - name: '@deepseek-ai/dsh-tui-demo' +- id: agent-loop + name: '@deepseek-ai/dsh-agent-loop' config: - provider: my-llm + agents: + - id: main + provider: my-llm model: my-model-v1 # References the model registered above. workspaceContext: false ``` diff --git a/docs/user/guide/config.i18n.yaml b/docs/user/guide/config.i18n.yaml index 695584a6e6..a45fbb2186 100644 --- a/docs/user/guide/config.i18n.yaml +++ b/docs/user/guide/config.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write -config.md: a884cb9c2ec31bd4b12a31cce6290df1d134cf9a -config.zh.md: 3fb9ce69e5f7cb6b92f055ef18595e5ff07d9bbf +# pnpm run verify-translation-pairing --write docs/user/guide/config.md +config.md: c9c760cbdb9650e608809fee4498b7f884b339dc +config.zh.md: b80e31c6f77f51d013aaebe5c4e9e43f5819e33f diff --git a/docs/user/guide/config.md b/docs/user/guide/config.md index a884cb9c2e..c9c760cbdb 100644 --- a/docs/user/guide/config.md +++ b/docs/user/guide/config.md @@ -8,7 +8,7 @@ Harness uses `cordis.yml` to describe which plugins an agent loads and the confi The repository examples are runnable configurations and the most reliable starting points for a new project: -- [tui-agent](../../../examples/tui-agent/cordis.yml) combines the DeepSeek model, Bash, filesystem, compaction, subagents, workflows, and the interactive TUI. +- [the shared `dsh` base](../../../apps/cli/base.cordis.yml) plus the [`tui.cordis.yml`](../../../apps/cli/tui.cordis.yml) overlay combines the DeepSeek model, Bash, filesystem, compaction, subagents, workflows, and the interactive TUI. - [headless-agent](../../../examples/headless-agent/cordis.yml) exposes the coding composition as a one-shot task. - [acp-agent](../../../examples/acp-agent/cordis.yml) exposes fresh sessions to programmatic ACP clients. @@ -25,12 +25,13 @@ A minimal configuration is a list of plugin entries: - id: bash name: '@deepseek-ai/dsh-bash-local' -- id: tui-agent - name: '@deepseek-ai/dsh-tui-demo' +- id: agent-loop + name: '@deepseek-ai/dsh-agent-loop' config: - provider: deepseek - model: deepseek-v4-flash - workspaceContext: false + agents: + - id: main + provider: deepseek + model: deepseek-v4-flash ``` ## Plugin entries diff --git a/docs/user/guide/config.zh.md b/docs/user/guide/config.zh.md index 3fb9ce69e5..b80e31c6f7 100644 --- a/docs/user/guide/config.zh.md +++ b/docs/user/guide/config.zh.md @@ -8,7 +8,7 @@ Harness 使用 `cordis.yml` 描述 Agent 加载哪些插件以及每个插件的 仓库中的示例就是可以运行的配置,也是新项目最可靠的起点: -- [tui-agent](../../../examples/tui-agent/cordis.yml) 组合 DeepSeek 模型、Bash、文件系统、压缩、子代理、工作流和交互式 TUI。 +- [共享的 `dsh` base](../../../apps/cli/base.cordis.yml) 叠加 [`tui.cordis.yml`](../../../apps/cli/tui.cordis.yml) overlay,组合 DeepSeek 模型、Bash、文件系统、压缩、子代理、工作流和交互式 TUI。 - [headless-agent](../../../examples/headless-agent/cordis.yml) 以单次任务形式暴露 coding 组装。 - [acp-agent](../../../examples/acp-agent/cordis.yml) 向程序化 ACP(Agent Client Protocol)客户端提供全新会话。 @@ -25,12 +25,13 @@ Harness 使用 `cordis.yml` 描述 Agent 加载哪些插件以及每个插件的 - id: bash name: '@deepseek-ai/dsh-bash-local' -- id: tui-agent - name: '@deepseek-ai/dsh-tui-demo' +- id: agent-loop + name: '@deepseek-ai/dsh-agent-loop' config: - provider: deepseek - model: deepseek-v4-flash - workspaceContext: false + agents: + - id: main + provider: deepseek + model: deepseek-v4-flash ``` ## 插件条目 diff --git a/docs/user/guide/index.i18n.yaml b/docs/user/guide/index.i18n.yaml index e2b307201e..17056741b7 100644 --- a/docs/user/guide/index.i18n.yaml +++ b/docs/user/guide/index.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write -index.md: b698b8aeee6cebff374e20ca0f76ddc9e75213c0 -index.zh.md: 337d246baa12ccf6d7a9656d1ea3b06002554c13 +# pnpm run verify-translation-pairing --write docs/user/guide/index.md +index.md: 72d822f6377089f56abda4383f393eed30a6709a +index.zh.md: 442cda48072c3f6bf9579e880c0ab381b799966f diff --git a/docs/user/guide/index.md b/docs/user/guide/index.md index b698b8aeee..72d822f637 100644 --- a/docs/user/guide/index.md +++ b/docs/user/guide/index.md @@ -14,12 +14,17 @@ Harness implements every capability an AI agent needs—including LLM calls, too config: apiKey: !!js process.env.DEEPSEEK_API_KEY -# Select the interactive application -- name: '@deepseek-ai/dsh-tui-demo' +# Select the agent the interactive front door drives +- id: agent-loop + name: '@deepseek-ai/dsh-agent-loop' config: - provider: deepseek - model: deepseek-v4-flash - workspaceContext: false + agents: + - id: main + provider: deepseek + model: deepseek-v4-flash + +# Select the interactive front door +- name: '@deepseek-ai/dsh-tui' ``` ## Who it is for diff --git a/docs/user/guide/index.zh.md b/docs/user/guide/index.zh.md index 337d246baa..442cda4807 100644 --- a/docs/user/guide/index.zh.md +++ b/docs/user/guide/index.zh.md @@ -14,12 +14,17 @@ Harness 将一个 AI Agent(智能体) 所需要的所有能力——LLM 调 config: apiKey: !!js process.env.DEEPSEEK_API_KEY -# Select the interactive application -- name: '@deepseek-ai/dsh-tui-demo' +# Select the agent the interactive front door drives +- id: agent-loop + name: '@deepseek-ai/dsh-agent-loop' config: - provider: deepseek - model: deepseek-v4-flash - workspaceContext: false + agents: + - id: main + provider: deepseek + model: deepseek-v4-flash + +# Select the interactive front door +- name: '@deepseek-ai/dsh-tui' ``` ## 适合谁 diff --git a/docs/user/guide/quickstart.i18n.yaml b/docs/user/guide/quickstart.i18n.yaml index b3de74949b..71065d0781 100644 --- a/docs/user/guide/quickstart.i18n.yaml +++ b/docs/user/guide/quickstart.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write -quickstart.md: 25ce51ee3d010d2eb800071b9697fc62857dace1 -quickstart.zh.md: e2e023670a999566e273d8893c42103dc273e7b1 +# pnpm run verify-translation-pairing --write docs/user/guide/quickstart.md +quickstart.md: 75c0d7096f09092106aca46a5a078673708040e7 +quickstart.zh.md: 0beadb65f32f21d18bb4550eda967ed2b275b10f diff --git a/docs/user/guide/quickstart.md b/docs/user/guide/quickstart.md index 25ce51ee3d..be41d00d0f 100644 --- a/docs/user/guide/quickstart.md +++ b/docs/user/guide/quickstart.md @@ -52,7 +52,7 @@ The full-screen agent can read and write files, run commands, delegate subtasks, ## What happened -headless-agent uses the `@deepseek-ai/dsh-cli-demo` app; tui-agent uses the interactive `@deepseek-ai/dsh-tui-demo` app. Both load the same providerless agent spine, while their `cordis.yml` files select the DeepSeek model and capability plugins appropriate to each surface. +headless-agent uses the `@deepseek-ai/dsh-cli-demo` app; the interactive `dsh` surface instead composes [`apps/cli/base.cordis.yml`](../../../apps/cli/base.cordis.yml) with the `tui.cordis.yml` overlay and no app bundle. Both load the same providerless agent spine, while their `cordis.yml` files select the DeepSeek model and capability plugins appropriate to each surface. ## Next steps diff --git a/docs/user/guide/quickstart.zh.md b/docs/user/guide/quickstart.zh.md index e2e023670a..f592fdb3fb 100644 --- a/docs/user/guide/quickstart.zh.md +++ b/docs/user/guide/quickstart.zh.md @@ -52,7 +52,7 @@ pnpm run demo:tui ## 回头看 -headless-agent 使用 `@deepseek-ai/dsh-cli-demo` app,tui-agent 使用交互式 `@deepseek-ai/dsh-tui-demo` app。二者加载同一个 providerless agent spine,并通过各自的 `cordis.yml` 为对应 surface 选择 DeepSeek 模型和能力插件。 +headless-agent 使用 `@deepseek-ai/dsh-cli-demo` app;交互式 `dsh` surface 则以 [`apps/cli/base.cordis.yml`](../../../apps/cli/base.cordis.yml) 叠加 `tui.cordis.yml` overlay 组合而成,不使用 app 组合包。二者加载同一个 providerless agent spine,并通过各自的 `cordis.yml` 为对应 surface 选择 DeepSeek 模型和能力插件。 ## 下一步 diff --git a/examples/README.i18n.yaml b/examples/README.i18n.yaml index 647d829b4f..a05f495338 100644 --- a/examples/README.i18n.yaml +++ b/examples/README.i18n.yaml @@ -2,5 +2,10 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write examples/README.md +<<<<<<< HEAD README.md: 7f12178d1b67f1ebfac6f4f0e31403c54106e98f README.zh.md: c7c1bf76593661616464558e554d57340d7c03b1 +======= +README.md: eb425ad152579ee10bbd54e99c667606fc8a649a +README.zh.md: 5f00b4471b516bb78679f09b41dcb1bdbc3ebdbc +>>>>>>> a1c6a2c3f (refactor(cli)!: one shared base config with per-surface overlays) diff --git a/examples/README.md b/examples/README.md index 7f12178d1b..eb425ad152 100644 --- a/examples/README.md +++ b/examples/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -Runnable demos (not workspaces) that showcase how the harness is wired. Each example is a **thin leaf**: a `cordis.yml` that picks swappable backends, loads one app package, and may add optional product tools. The composition and boot glue live in [`@deepseek-ai/dsh-tui-demo`](../packages/examples/tui-demo), [`@deepseek-ai/dsh-cli-demo`](../packages/examples/cli-demo), [`@deepseek-ai/dsh-acp-demo`](../packages/examples/acp-demo), and their shared [`@deepseek-ai/dsh-agent-spine-demo`](../packages/examples/agent-spine-demo) bundle. There is no `start.ts`; the terminal `demo:*` scripts boot through the [`dsh`](../apps/cli/README.md) CLI (which mounts the `tui-demo` bundle), and the headless/ACP scripts invoke the `cli-demo`/`acp-demo` bins. +Runnable demos (not workspaces) that showcase how the harness is wired. Each example is a **thin leaf**: either a `cordis.yml` tree that picks swappable backends and loads one app package, or an **overlay** — a patch list `dsh --config` applies over the shipped composition ([`apps/cli/base.cordis.yml`](../apps/cli/base.cordis.yml) plus a surface overlay). Bundled compositions live in [`@deepseek-ai/dsh-cli-demo`](../packages/examples/cli-demo), [`@deepseek-ai/dsh-acp-demo`](../packages/examples/acp-demo), and their shared [`@deepseek-ai/dsh-agent-spine-demo`](../packages/examples/agent-spine-demo) bundle; the `dsh` surfaces use flat config trees instead. There is no `start.ts`; the terminal `demo:*` scripts boot through the [`dsh`](../apps/cli/README.md) CLI, and the headless/ACP scripts invoke the `cli-demo`/`acp-demo` bins. ## headless-agent @@ -10,21 +10,21 @@ A non-interactive agent demo that accepts one positional task, runs one complete Run with: `pnpm run demo:headless "task"` (needs `DEEPSEEK_API_KEY`). See [headless-agent/README.md](headless-agent/README.md) for the output contract, safety boundaries, and snapshot suite. -## tui-agent +## code-mode -The interactive coding agent: DeepSeek V4, filesystem and bash tools, subagents, workflows, `todo_write`, compaction, and the full-screen TUI. It is also the home of TUI PTY and snapshot scenarios. +An **overlay** over the shipped TUI that reduces the model-facing registry to the `run_code` transport, so the model batches tool work into TypeScript programs instead of one call per turn. -Run with: `pnpm run demo:tui` (needs `DEEPSEEK_API_KEY`). Run its Code Mode overlay with `pnpm run demo:code-mode`. See [tui-agent/README.md](tui-agent/README.md) for controls and composition. +Run with: `pnpm run demo:code-mode` (needs `DEEPSEEK_API_KEY`). See [code-mode/README.md](code-mode/README.md). The interactive agent itself is not an example: `pnpm run demo:tui` boots [`apps/cli/tui.cordis.yml`](../apps/cli/tui.cordis.yml) over the shared base, and its PTY and snapshot scenarios live in `apps/cli/tests/`. ## jsonrpc-agent An unattended coding agent driven through the Python SDK: JSON-RPC stdio, foreground-only `bash`, `read` / `write` / `edit`, one foreground `subagent`, `todo_write`, JSONL persistence, and compaction. It excludes terminal UI, stdout logging, approvals, skills, and background task controls. See [jsonrpc-agent/README.md](jsonrpc-agent/README.md). -## cordis-agent +## web-cordis The **self-referential** demo: the coding spine plus [`@deepseek-ai/dsh-tool-cordis`](../packages/cordis/tool-cordis), whose three tools (`cordis_inspect` / `cordis_mount` / `cordis_unmount`) let the agent inspect the current DSH process, mount model-written temporary Plugins (an event listener, a brand-new tool, or a service another temporary Plugin injects), and unmount them again. These Plugins exist only in memory and share one internal `cordis-dynamic` fiber subtree; `ctx.fs`/`ctx.web` ride along provider-only as capabilities they can use. -Run the TUI with `pnpm run demo:cordis`, the browser UI at `http://127.0.0.1:3081` with `pnpm run demo:cordis web`, or the ACP server with `pnpm run demo:cordis acp` (all need `DEEPSEEK_API_KEY`). See [cordis-agent/README.md](cordis-agent/README.md) for the staged demo script and [the toolset Agent Note](../.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md) for the design and sandbox caveats. +Run the browser UI at `http://127.0.0.1:3081` with `pnpm run demo:cordis`, or the ACP server with `pnpm run demo:cordis acp` (both need `DEEPSEEK_API_KEY`). See [the toolset Agent Note](../.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md) for the design and sandbox caveats. ## acp-agent diff --git a/examples/README.zh.md b/examples/README.zh.md index c7c1bf7659..de91e30217 100644 --- a/examples/README.zh.md +++ b/examples/README.zh.md @@ -2,7 +2,11 @@ [English](README.md) | 中文 +<<<<<<< HEAD 展示 harness 如何组装的可运行演示(不是 workspace)。每个示例都是一个 **轻量叶节点**:一份选择可替换后端、加载一个应用包(package)并可添加可选产品工具的 `cordis.yml`。组合和启动粘合代码位于 [`@deepseek-ai/dsh-tui-demo`](../packages/examples/tui-demo)、[`@deepseek-ai/dsh-cli-demo`](../packages/examples/cli-demo)、[`@deepseek-ai/dsh-acp-demo`](../packages/examples/acp-demo) 及它们共享的 [`@deepseek-ai/dsh-agent-spine-demo`](../packages/examples/agent-spine-demo) 组合包中。没有 `start.ts`;终端 `demo:*` 脚本通过 [`dsh`](../apps/cli/README.md) CLI(命令行界面)启动(该 CLI 挂载 `tui-demo` 组合包),无头/ACP(Agent Client Protocol)脚本则调用 `cli-demo`/`acp-demo` bin。 +======= +展示 harness 如何接线的可运行演示(不是 workspace)。每个示例都是一个 **轻量叶节点**:要么是一份选择可替换后端、加载一个应用包(package)的 `cordis.yml` 配置树,要么是一个 **overlay**——由 `dsh --config` 叠加到交付组合([`apps/cli/base.cordis.yml`](../apps/cli/base.cordis.yml) 加一份 surface overlay)之上的 patch 列表。成组的组合位于 [`@deepseek-ai/dsh-cli-demo`](../packages/examples/cli-demo)、[`@deepseek-ai/dsh-acp-demo`](../packages/examples/acp-demo) 及它们共享的 [`@deepseek-ai/dsh-agent-spine-demo`](../packages/examples/agent-spine-demo) 组合包中;`dsh` 的各 surface 则改用平铺 config tree。没有 `start.ts`;终端 `demo:*` 脚本通过 [`dsh`](../apps/cli/README.md) CLI(命令行界面)启动,无头/ACP(Agent Client Protocol)脚本则调用 `cli-demo`/`acp-demo` bin。 +>>>>>>> a1c6a2c3f (refactor(cli)!: one shared base config with per-surface overlays) ## headless-agent @@ -10,21 +14,25 @@ 运行:`pnpm run demo:headless "task"`(需要 `DEEPSEEK_API_KEY`)。输出契约、安全边界和快照套件详见 [headless-agent/README.md](headless-agent/README.md)。 -## tui-agent +## code-mode -交互式编码 agent:DeepSeek V4、文件系统与 bash 工具、subagent、工作流、`todo_write`、压缩(compaction)和全屏 TUI。这里也是 TUI PTY 与快照场景的归属地。 +叠加在交付 TUI 之上的 **overlay**:把面向模型的注册表收敛为 `run_code` 这一个传输,使模型把工具工作批量写进 TypeScript 程序,而不是每轮一次调用。 -运行:`pnpm run demo:tui`(需要 `DEEPSEEK_API_KEY`)。使用 `pnpm run demo:code-mode` 运行其 Code Mode 覆盖。控制与组合详见 [tui-agent/README.md](tui-agent/README.md)。 +运行:`pnpm run demo:code-mode`(需要 `DEEPSEEK_API_KEY`)。详见 [code-mode/README.md](code-mode/README.md)。交互式 agent 本身不再是示例:`pnpm run demo:tui` 在共享 base 之上启动 [`apps/cli/tui.cordis.yml`](../apps/cli/tui.cordis.yml),其 PTY 与快照场景位于 `apps/cli/tests/`。 ## jsonrpc-agent 通过 Python SDK 驱动的无人值守编码 agent:JSON-RPC stdio、仅前台 `bash`、`read`/`write`/`edit`、一个前台 `subagent`、`todo_write`、JSONL 持久化和压缩。它不包含终端 UI、stdout 日志、批准、skill(技能)和后台任务控制。详见 [jsonrpc-agent/README.md](jsonrpc-agent/README.md)。 -## cordis-agent +## web-cordis **自指** 演示:编码主干加 [`@deepseek-ai/dsh-tool-cordis`](../packages/cordis/tool-cordis),其三个工具(`cordis_inspect`/`cordis_mount`/`cordis_unmount`)使 agent 可以检查当前 DSH 进程、挂载模型编写的临时插件(事件监听器、一个全新工具,或一个供另一个临时插件注入的服务),并再次卸载它们。这些插件只存在于内存中,共享一个内部 `cordis-dynamic` fiber 子树;`ctx.fs`/`ctx.web` 仅作为它们可用的能力提供方。 +<<<<<<< HEAD 使用 `pnpm run demo:cordis` 运行 TUI,使用 `pnpm run demo:cordis web` 在 `http://127.0.0.1:3081` 启动浏览器 UI,或使用 `pnpm run demo:cordis acp` 启动 ACP 服务器(三者均需 `DEEPSEEK_API_KEY`)。分阶段演示脚本详见 [cordis-agent/README.md](cordis-agent/README.md),设计与沙箱注意事项详见[工具集 Agent Note(agent 决策记录)](../.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md)。 +======= +使用 `pnpm run demo:cordis` 在 `http://127.0.0.1:3081` 启动浏览器 UI,或使用 `pnpm run demo:cordis acp` 启动 ACP 服务器(两者均需 `DEEPSEEK_API_KEY`)。设计与沙箱注意事项详见[工具集 Agent Note](../.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md)。 +>>>>>>> a1c6a2c3f (refactor(cli)!: one shared base config with per-surface overlays) ## acp-agent diff --git a/examples/tui-agent/README.i18n.yaml b/examples/code-mode/README.i18n.yaml similarity index 54% rename from examples/tui-agent/README.i18n.yaml rename to examples/code-mode/README.i18n.yaml index 863092ffb8..2b1171f393 100644 --- a/examples/tui-agent/README.i18n.yaml +++ b/examples/code-mode/README.i18n.yaml @@ -1,6 +1,12 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: +<<<<<<< HEAD:examples/tui-agent/README.i18n.yaml # pnpm run verify-translation-pairing --write examples/tui-agent/README.md README.md: ea8695d37ea247a38644392a4572c1ea9855fd44 README.zh.md: c6acd39d8713816d870c00fa8597754d0d09880a +======= +# pnpm run verify-translation-pairing --write examples/code-mode/README.md +README.md: 1557483ee98fab60aa63f8c5ec40f7e592c275ba +README.zh.md: c20a9a7dde80eb0e03f17b62b5dc21189ef65f06 +>>>>>>> a1c6a2c3f (refactor(cli)!: one shared base config with per-surface overlays):examples/code-mode/README.i18n.yaml diff --git a/examples/code-mode/README.md b/examples/code-mode/README.md new file mode 100644 index 0000000000..1557483ee9 --- /dev/null +++ b/examples/code-mode/README.md @@ -0,0 +1,35 @@ +# code-mode + +English | [中文](README.zh.md) + +Code Mode over the shipped TUI: the model stops calling one tool per turn and instead writes TypeScript programs for a single `run_code` tool, executed in a worker thread. + +This leaf is an **overlay**, not a tree. `dsh --config` includes the shared [`apps/cli/base.cordis.yml`](../../apps/cli/base.cordis.yml), applies the [`tui.cordis.yml`](../../apps/cli/tui.cordis.yml) surface overlay, then applies this file — all as sibling patch lists at one include level. + +## Run it + +```sh +# repo root .env (gitignored) or exported env: +# DEEPSEEK_API_KEY=sk-… +# DEEPSEEK_BASE_URL=https://… # optional; defaults to the public API +pnpm run demo:code-mode +``` + +`pnpm run demo:code-mode acp` boots the same idea over the ACP transport from [`examples/acp-agent/code-mode.cordis.yml`](../acp-agent/code-mode.cordis.yml). + +## What the overlay changes + +| Row | Change | +| --- | --- | +| `tools` | `mode: code` — the wire registry collapses to `run_code` plus its generated TypeScript SDK prompt section | +| `system-prompt` | a persona telling the model to batch tool work into one program | +| `tui` | a Code Mode welcome line | +| `code-runtime` | inserted: `@deepseek-ai/dsh-code-runtime-worker`, the worker thread `run_code` executes in | + +Everything else — the model backend, executors, filesystem tools, persistence, delegation, and the front door — comes from the base and the TUI overlay. + +A patch replaces a row's whole `config` rather than merging into it, so each row above restates every key it owns. A patch whose `id` matches no row is skipped with a Loader warning, so renaming a row in the base or the surface overlay requires updating this file. + +## Model Experience + +The model sees one tool instead of the full native registry. Its request carries the `run_code` schema and a generated TypeScript SDK section describing the callable surface, which costs more prompt tokens up front but replaces many single-call turns with one program — fewer round trips and fewer intermediate tool results in the transcript. Because the tool catalog is part of the cached request prefix, switching modes invalidates the KV cache for the session. diff --git a/examples/code-mode/README.zh.md b/examples/code-mode/README.zh.md new file mode 100644 index 0000000000..c20a9a7dde --- /dev/null +++ b/examples/code-mode/README.zh.md @@ -0,0 +1,35 @@ +# code-mode + +[English](README.md) | 中文 + +在交付的 TUI 之上启用 Code Mode:模型不再每轮调用一个工具,而是为单一的 `run_code` 工具编写 TypeScript 程序,并在 worker 线程中执行。 + +本叶节点是一个 **overlay**,而不是配置树。`dsh --config` 会 include 共享的 [`apps/cli/base.cordis.yml`](../../apps/cli/base.cordis.yml),应用 [`tui.cordis.yml`](../../apps/cli/tui.cordis.yml) surface overlay,再应用本文件——三者都是同一 include 层级上的平级 patch 列表。 + +## 运行 + +```sh +# repo root .env (gitignored) or exported env: +# DEEPSEEK_API_KEY=sk-… +# DEEPSEEK_BASE_URL=https://… # optional; defaults to the public API +pnpm run demo:code-mode +``` + +`pnpm run demo:code-mode acp` 通过 ACP 传输启动同样的思路,配置位于 [`examples/acp-agent/code-mode.cordis.yml`](../acp-agent/code-mode.cordis.yml)。 + +## 该 overlay 改动了什么 + +| 配置项 | 改动 | +| --- | --- | +| `tools` | `mode: code`——线上注册表收敛为 `run_code`,外加其生成的 TypeScript SDK 提示词片段 | +| `system-prompt` | 指示模型把相关工具调用批量写进一个程序的 persona | +| `tui` | Code Mode 的欢迎行 | +| `code-runtime` | 新插入:`@deepseek-ai/dsh-code-runtime-worker`,即 `run_code` 的执行 worker 线程 | + +其余部分——模型后端、执行器、文件系统工具、持久化、委派与前端入口——全部来自 base 与 TUI overlay。 + +配置 patch 会整体替换该配置项的 `config`,而非与其合并,因此上表每一行都必须重述自己拥有的全部键。若某个 patch 的 `id` 不再匹配任何配置项,Loader 只会告警并跳过它,所以在 base 或 surface overlay 中重命名配置项时必须同步更新本文件。 + +## Model Experience(模型体验) + +模型看到的是一个工具,而不是完整的原生注册表。其请求携带 `run_code` 的 schema 以及一段生成的 TypeScript SDK 说明,用于描述可调用面。这会在前置阶段消耗更多提示词 token,但把多个单次调用的轮次压缩为一个程序——往返更少,transcript 中的中间工具结果也更少。由于工具目录属于被缓存的请求前缀,切换模式会使该会话的 KV 缓存失效。 diff --git a/examples/code-mode/cordis.yml b/examples/code-mode/cordis.yml new file mode 100644 index 0000000000..551b318647 --- /dev/null +++ b/examples/code-mode/cordis.yml @@ -0,0 +1,36 @@ +# Code Mode over the shipped TUI: the model stops calling one tool per turn and +# instead writes TypeScript programs for a single `run_code` tool, executed in a +# worker thread. +# +# This file is a `--config` OVERLAY, not a tree: `dsh --config +# examples/code-mode/cordis.yml` includes the shared base, applies the TUI +# overlay, then applies these patches, all at one include level. Patches never +# cross an include boundary, so an overlay must stay a patch list rather than a +# file that includes another config. +# +# A patch replaces the targeted row's whole `config`, so each row restates every +# key it owns. +- id: tools + config: + mode: code + +- id: system-prompt + config: + persona: | + You are a coding agent powered by the {{model}} model. + + You work by writing TypeScript programs for run_code: batch related + tool work into one program, loop and branch where it helps, and print + or return ONLY the findings that matter. + +- id: tui + config: + sessionId: !!js configuredAgentIdentities?.main?.id ?? 'main' + welcome: 'TUI Code Mode ready. Give it a multi-tool task.' + showReasoning: true + maxToolOutputLines: 6 + +# The worker thread `run_code` executes in; the base mounts no code runtime. +- insert: + - id: code-runtime + name: '@deepseek-ai/dsh-code-runtime-worker' diff --git a/examples/code-mode/package.json b/examples/code-mode/package.json new file mode 100644 index 0000000000..9a5ec92480 --- /dev/null +++ b/examples/code-mode/package.json @@ -0,0 +1,7 @@ +{ + "name": "code-mode-example", + "private": true, + "version": "0.0.1", + "type": "module", + "description": "Runnable overlay: the shipped TUI composition with the model-facing registry reduced to the run_code transport" +} diff --git a/examples/cordis-agent/README.md b/examples/cordis-agent/README.md deleted file mode 100644 index 55970e932b..0000000000 --- a/examples/cordis-agent/README.md +++ /dev/null @@ -1,37 +0,0 @@ -# cordis-agent - -English | [中文](README.zh.md) - -The self-referential harness demo: the DeepSeek V4 coding spine on the full-screen TUI plus [`@deepseek-ai/dsh-tool-cordis`](../../packages/cordis/tool-cordis/README.md), which lets the model inspect the current DSH process, mount in-memory temporary Plugins, and unmount them. Temporary Plugins remain active across turns but disappear on unmount, toolset unload, or DSH restart; they create no files or configuration and may affect other sessions in the process. The `ctx.fs` and `ctx.web` services are provider-only capabilities available to those Plugins. The design lives in [the toolset Agent Note](../../.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md). - -## Run it - -```sh -# repo root .env (gitignored) or exported env: -# DEEPSEEK_API_KEY=sk-… -# DEEPSEEK_BASE_URL=https://… # optional; defaults to the public API -pnpm run demo:cordis # TUI (default) -pnpm run demo:cordis web # browser UI at http://127.0.0.1:3081 -pnpm run demo:cordis acp # ACP server -``` - -The intended demo is staged — verify the listener link first, then let the agent extend itself: - -``` -> Mount a temporary Plugin that listens to the 'agent/status' event and logs every status change, then run `echo hi` with bash. - [tool call] cordis_mount({"code": "return { name: 'status-logger', apply(ctx) { ctx.on('agent/status', (agent, status) => console.log('status →', status)) } }"}) - [tool result] Temporary Plugin dyn-1 is running (plugin "status-logger"; available until unmounted or DSH restarts). - [tool call] bash({"command": "echo hi"}) -[cordis:dyn-1] status → … ← the temporary listener firing, live -> Now give yourself a reverse_text tool and use it on "harness". - [tool call] cordis_mount({"code": "return { name: 'reverse-text', inject: ['tools'], apply(ctx) { ctx.tools.register(harness.defineTool({ name: 'reverse_text', … })) } }"}) - [tool call] reverse_text({"text": "harness"}) ← a tool the agent built for itself, one step earlier -> Unmount both temporary Plugins. - [tool call] cordis_unmount({"id": "dyn-1"}) -``` - -Ask for `cordis_inspect` with `what: "api"` or `what: "events"` to see the generated service/event reference used to write Plugin code, and mount two cooperating temporary Plugins (`ctx.provide` in one, `inject` in the other) to watch Cordis park and revive the consumer. - -## End-to-end tests - -`tests/keyless-smoke.e2e.ts` boots the real `cordis.yml` through the Loader with a dummy key and asserts the banner, package-name resolution, and clean EOF exit. `tests/cordis-tools.e2e.ts` is the with-key smoke: a real model mounts a temporary status listener and the test verifies its tagged console line, creates and uses a `reverse_text` tool, and composes two temporary Plugins through provide/inject. [`packages/cordis/tool-cordis`](../../packages/cordis/tool-cordis) carries the unit coverage under the per-file 100% gate. diff --git a/examples/cordis-agent/composition.md b/examples/cordis-agent/composition.md deleted file mode 100644 index 02d68215f5..0000000000 --- a/examples/cordis-agent/composition.md +++ /dev/null @@ -1,55 +0,0 @@ - - -# Cordis Agent App Composition - -The self-referential demo puts @deepseek-ai/dsh-tool-cordis on the coding spine, letting the agent inspect its current-process runtime and mount or unmount in-memory temporary Plugins. - -```mermaid -flowchart LR - cfg["examples/cordis-agent
cordis.yml"] - plugin_cordis_hmr["hmr
@cordisjs/plugin-hmr"] - cfg --> plugin_cordis_hmr - plugin_cordis_llm_deepseek["llm-deepseek
@deepseek-ai/dsh-llm-deepseek"] - cfg --> plugin_cordis_llm_deepseek - plugin_cordis_subprocess["subprocess
@deepseek-ai/dsh-subprocess-local"] - cfg --> plugin_cordis_subprocess - plugin_cordis_bash["bash
@deepseek-ai/dsh-bash-local"] - cfg --> plugin_cordis_bash - plugin_cordis_fs_local["fs-local
@deepseek-ai/dsh-fs-local"] - cfg --> plugin_cordis_fs_local - plugin_cordis_web["web
@deepseek-ai/dsh-web"] - cfg --> plugin_cordis_web - plugin_cordis_web_fetch_local["web-fetch-local
@deepseek-ai/dsh-web-fetch-local"] - cfg --> plugin_cordis_web_fetch_local - plugin_cordis_token_meter["token-meter
@deepseek-ai/dsh-token-meter"] - cfg --> plugin_cordis_token_meter - plugin_cordis_tui_agent["tui-agent
@deepseek-ai/dsh-tui-demo"] - cfg --> plugin_cordis_tui_agent - plugin_cordis_tui_agent --> bundle_agent_core["@deepseek-ai/dsh-agent-spine-demo"] - plugin_cordis_tui_agent --> bundle_jsonl["@deepseek-ai/dsh-session-persistence-jsonl"] - plugin_cordis_tui_agent --> frontdoor_tui["@deepseek-ai/dsh-tui
pre-created main agent"] - bundle_agent_core --> spine_llm["ctx.llm"] - bundle_agent_core --> spine_sessions["ctx.sessions"] - bundle_agent_core --> spine_tools["ctx.tools + tool-bash"] - bundle_agent_core --> spine_loop["ctx.agents + ctx.agentLoop"] - plugin_cordis_tool_cordis["tool-cordis
@deepseek-ai/dsh-tool-cordis"] - cfg --> plugin_cordis_tool_cordis -``` - -| Plugin id | Package / module | -| --- | --- | -| `hmr` | `@cordisjs/plugin-hmr` | -| `llm-deepseek` | `@deepseek-ai/dsh-llm-deepseek` | -| `subprocess` | `@deepseek-ai/dsh-subprocess-local` | -| `bash` | `@deepseek-ai/dsh-bash-local` | -| `fs-local` | `@deepseek-ai/dsh-fs-local` | -| `web` | `@deepseek-ai/dsh-web` | -| `web-fetch-local` | `@deepseek-ai/dsh-web-fetch-local` | -| `token-meter` | `@deepseek-ai/dsh-token-meter` | -| `tui-agent` | `@deepseek-ai/dsh-tui-demo` | -| `tool-cordis` | `@deepseek-ai/dsh-tool-cordis` | - -Source config: [`examples/cordis-agent/cordis.yml`](cordis.yml). - -Maintenance mode: hybrid: the leaf plugin list is parsed from its `cordis.yml`; app package expansion is curated from package source. diff --git a/examples/cordis-agent/cordis.yml b/examples/cordis-agent/cordis.yml deleted file mode 100644 index 601ac94419..0000000000 --- a/examples/cordis-agent/cordis.yml +++ /dev/null @@ -1,87 +0,0 @@ -# Self-referential TUI demo: the coding spine plus tools to inspect the live -# service/plugin/tool/temporary/API/event state, mount a model-written temporary -# Plugin, and quiescently unmount it. The app bin loads the gitignored -# root `.env` before reading the required DeepSeek key and optional base URL. -# Trust stance: the vm and context façade limit accidental global/framework -# access but are not a security boundary; temporary Plugin code reaches live capabilities -# such as `ctx.bash`. Grant this toolset like bash access. See -# ../../.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md. - -# Development-only hot reload; production assemblies omit it. -- id: hmr - name: '@cordisjs/plugin-hmr' - config: - root: ['.'] - -# The DeepSeek adapter. Shipped default: full thinking at max effort on every -# request (wire-only defaults; they never enter the request header). -- id: llm-deepseek - name: '@deepseek-ai/dsh-llm-deepseek' - config: - apiKey: !!js process.env.DEEPSEEK_API_KEY - baseURL: !!js process.env.DEEPSEEK_BASE_URL - thinking: enabled - reasoningEffort: max - -# Local bash executor for agent-spine-demo's tool-bash schema — gives the agent an -# ordinary tool whose calls make the mounted listeners observably fire. -# Managed child-process groups for the bash executor (spawn/kill/output plumbing). -- id: subprocess - name: '@deepseek-ai/dsh-subprocess-local' - -- id: bash - name: '@deepseek-ai/dsh-bash-local' - config: - timeoutMs: 60000 - -# Filesystem service for mounted plugins (ctx.fs) — the local provider only. -# The model-facing read/write/edit tools stay unmounted on purpose: this demo -# is about the agent building its own tools over the services. -- id: fs-local - name: '@deepseek-ai/dsh-fs-local' - config: - cwd: !!js process.cwd() - -# Web service for mounted plugins (ctx.web): the seam plus the anonymous local -# fetch provider (keyless). No search provider is loaded — ctx.web search -# calls fail loud until a deployment adds one. -- id: web - name: '@deepseek-ai/dsh-web' - -- id: web-fetch-local - name: '@deepseek-ai/dsh-web-fetch-local' - -- id: token-meter - name: '@deepseek-ai/dsh-token-meter' - -# The app bundle pre-creates the self-referential demo's `main` agent. -- id: tui-agent - name: '@deepseek-ai/dsh-tui-demo' - config: - provider: deepseek - model: deepseek-v4-pro - persistenceRoot: './.sessions' - workspaceContext: - maxBytes: 65536 - welcome: 'cordis-agent ready. Ask it to inspect its runtime, mount a temporary listener, or invent a temporary tool for itself.' - persona: | - You are cordis-agent, a self-referential harness demo powered by the - {{model}} model. - - You run INSIDE a cordis plugin runtime, and your cordis_* tools operate - on that live runtime: cordis_inspect to look around (its `api` and - `events` sections document the service methods, type shapes, and events - your Plugin code can use), cordis_mount to mount an in-memory temporary - Plugin (an event listener, a brand-new tool for yourself, or a service - another temporary Plugin injects), cordis_unmount to clean one up. These - Plugins remain across turns but disappear on unmount, toolset unload, or - DSH restart and may affect other sessions in this process. In Plugin code, NEVER use Node - built-ins (require/setTimeout/fetch) — use the runtime's cordis services - via inject: fs, web, bash, and timer (ctx.setTimeout). Prefer small - single-purpose plugins, prefer plain notification events over waterfall - events unless you intend to intercept, and unmount what you no longer - need. Report results briefly. - -# The self-referential cordis toolset (loaded after the app so ctx.tools exists). -- id: tool-cordis - name: '@deepseek-ai/dsh-tool-cordis' diff --git a/examples/cordis-agent/package.json b/examples/cordis-agent/package.json deleted file mode 100644 index 8d5a693555..0000000000 --- a/examples/cordis-agent/package.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "name": "cordis-agent-example", - "private": true, - "version": "0.0.1", - "type": "module", - "description": "Runnable demo: the self-referential harness — an agent that inspects and modifies its own cordis runtime" -} diff --git a/examples/cordis-agent/tests/cordis-tools.e2e.ts b/examples/cordis-agent/tests/cordis-tools.e2e.ts deleted file mode 100644 index 8589ab77ec..0000000000 --- a/examples/cordis-agent/tests/cordis-tools.e2e.ts +++ /dev/null @@ -1,163 +0,0 @@ -import { afterEach, describe, expect, it, vi } from 'vitest' -import type { Context } from 'cordis' -import { createUserMessage, CallId } from '@deepseek-ai/dsh-llm' -import { cordisHarness, waitForIdle } from './harness.ts' -import { SessionId } from '@deepseek-ai/dsh-session' - -const testToolSignal = new AbortController().signal - -/** - * With-key smoke for the self-referential cordis tools: a REAL model drives - * cordis_mount/cordis_unmount against the live context the test observes. - * World-verified, not self-reported: the mounted listener must actually WRITE - * its tagged console line, the self-made tool must actually EXIST in the - * registry and appear as a real `tool/call`, the cross-mount service must - * actually LAND in the reflect store. Key-gated (see vitest.e2e.config.ts). - */ - -let ctx: Context | undefined - -afterEach(async () => { - vi.restoreAllMocks() - // Always dispose the harness, even on failure/retry/timeout: agent-loop - // teardown stops the loop, and disposing the tree unwinds every dynamic - // mount the model left behind. - await ctx?.fiber.dispose() - ctx = undefined -}) - -/** The tagged write-through lines (`[cordis:dyn-n] …`) captured by a console spy. */ -function taggedCalls(log: { mock: { calls: unknown[][] } }): unknown[][] { - return log.mock.calls.filter(call => typeof call[0] === 'string' && /^\[cordis:dyn-\d+\]$/.test(call[0])) -} - -/** Model-facing text of one tool result, concatenated. */ -function resultText(result: { content: { type: string; text?: string }[] }): string { - return result.content.filter(block => block.type === 'text').map(block => block.text).join('') -} - -describe.skipIf(!process.env.DEEPSEEK_API_KEY)('cordis tools: a real model modifies its own runtime', () => { - it('mounts a temporary status listener whose tagged output actually fires, then unmounts it', async () => { - ctx = await cordisHarness() - const log = vi.spyOn(console, 'log').mockImplementation(() => {}) - const agent = ctx.agentLoop.create(SessionId('cordis-e2e-listener'), { provider: 'deepseek', model: 'deepseek-v4-flash' }) - - agent.followup(createUserMessage({ - content: [{ - type: 'text', - text: 'Use cordis_mount to create a temporary Plugin that listens to the \'agent/status\' ' - + 'Cordis event and logs every change with console.log. Reply "running" once done.', - }], source: { kind: 'user' } })) - await waitForIdle(ctx, agent) - - // The WORLD check: the turn's own running→idle transition must have driven - // the mounted listener through the tagged sandbox console. - expect(taggedCalls(log).length).toBeGreaterThan(0) - const mid = await ctx.tools.execute({ - signal: testToolSignal, - callId: CallId('verify-mounted'), name: 'cordis_inspect', arguments: { what: 'temporary' }, - }) - expect(resultText(mid)).toContain('dyn-') - - agent.followup(createUserMessage({ content: [{ type: 'text', text: 'Now unmount the temporary Plugin you just mounted.' }], source: { kind: 'user' } })) - await waitForIdle(ctx, agent) - - const after = await ctx.tools.execute({ - signal: testToolSignal, - callId: CallId('verify-unmounted'), name: 'cordis_inspect', arguments: { what: 'temporary' }, - }) - expect(resultText(after)).toContain('No temporary Plugins are running.') - }, 120_000) - - it('builds itself a reverse_text tool and actually calls it', async () => { - ctx = await cordisHarness() - const agent = ctx.agentLoop.create(SessionId('cordis-e2e-selftool'), { provider: 'deepseek', model: 'deepseek-v4-flash' }) - - agent.followup(createUserMessage({ - content: [{ - type: 'text', - text: 'Give yourself a new tool: use cordis_mount to create a temporary Plugin with ' - + 'inject ["tools"] that calls harness.registerTool(ctx, harness.defineTool({...})) ' - + 'to register a tool named reverse_text with one required string parameter ' - + '"text", returning the text reversed. Then CALL reverse_text with the ' - + 'exact text "harness" and report its exact output.', - }], source: { kind: 'user' } })) - await waitForIdle(ctx, agent) - - // World checks: the tool exists in the registry, was invoked as a real tool call, and its - // RESULT (the self-made execute actually running) is the reversed string. Model prose is only - // self-report and is deliberately not asserted. - expect(ctx.tools.get('reverse_text')).toBeDefined() - const events = [...agent.session.events] - const calls = events.filter(event => event.type === 'tool/call') - expect(calls.some(event => event.data.name === 'cordis_mount')).toBe(true) - const reverseCalls = calls.filter(event => event.data.name === 'reverse_text') - expect(reverseCalls.length).toBeGreaterThan(0) - const reverseResults = events - .filter(event => event.type === 'tool/result') - .filter(event => reverseCalls.some(call => call.data.callId === event.data.message.source.callId)) - .flatMap(event => event.data.message.content[0].content.filter(block => block.type === 'text').map(block => block.text)) - // On failure, surface what the model actually mounted and what the tool - // returned — an e2e failing at a distance is undebuggable without it. - const mountCode = calls - .filter(event => event.data.name === 'cordis_mount') - .map(event => event.data.arguments) - .join('\n---\n') - const trace = events.map((event) => { - switch (event.type) { - case 'tool/call': return `tool/call:${event.data.name}` - case 'tool/result': return `tool/result:${event.data.message.content[0].isError ? 'ERR:' + JSON.stringify(event.data.message.content[0].content).slice(0, 200) : 'ok'}` - case 'turn/end': return `turn/end:${JSON.stringify(event.data.reason)}` - default: return event.type - } - }).join('\n') - expect( - reverseResults.some(text => text.includes('ssenrah')), - `no reversed output in reverse_text results.\nresults: ${JSON.stringify(reverseResults)}\nmount code: ${mountCode}\ntrace:\n${trace}`, - ).toBe(true) - }, 120_000) - - it('composes two temporary Plugins through provide/inject, and unmounting the provider parks the consumer', async () => { - ctx = await cordisHarness() - const agent = ctx.agentLoop.create(SessionId('cordis-e2e-compose'), { provider: 'deepseek', model: 'deepseek-v4-flash' }) - - agent.followup(createUserMessage({ - content: [{ - type: 'text', - text: 'Mount TWO separate temporary Plugins with cordis_mount. First a provider: apply calls ' - + 'ctx.provide(\'shouter\', { shout: (s) => s.toUpperCase() }). Second a consumer with ' - + 'inject ["shouter", "tools"] that registers (via harness.registerTool + harness.defineTool) ' - + 'a tool named shout_text with one required string parameter "text" whose execute returns ' - + 'ctx.shouter.shout(args.text) as a text content block. Then CALL shout_text with "quiet" ' - + 'and report the exact output.', - }], source: { kind: 'user' } })) - await waitForIdle(ctx, agent) - - // World checks: the service is really in the store, the tool really ran. - expect(ctx.get('shouter')).toBeDefined() - expect(ctx.tools.get('shout_text')).toBeDefined() - const events = [...agent.session.events] - const shoutCalls = events - .filter(event => event.type === 'tool/call') - .filter(event => event.data.name === 'shout_text') - expect(shoutCalls.length).toBeGreaterThan(0) - const shoutResults = events - .filter(event => event.type === 'tool/result') - .filter(event => shoutCalls.some(call => call.data.callId === event.data.message.source.callId)) - .flatMap(event => event.data.message.content[0].content.filter(block => block.type === 'text').map(block => block.text)) - expect(shoutResults.some(text => text.includes('QUIET'))).toBe(true) - - agent.followup(createUserMessage({ content: [{ type: 'text', text: 'Now unmount ONLY the provider temporary Plugin (the one that provided shouter).' }], source: { kind: 'user' } })) - await waitForIdle(ctx, agent) - - // The consumer must have been parked by cordis itself: service gone, - // dependent tool unregistered, temporary section naming the missing service. - expect(ctx.get('shouter')).toBeUndefined() - expect(ctx.tools.get('shout_text')).toBeUndefined() - const after = await ctx.tools.execute({ - signal: testToolSignal, - callId: CallId('verify-parked'), name: 'cordis_inspect', arguments: { what: 'temporary' }, - }) - expect(resultText(after)).toContain('waiting for: shouter') - }, 120_000) -}) diff --git a/examples/cordis-agent/tests/harness.ts b/examples/cordis-agent/tests/harness.ts deleted file mode 100644 index 2e12cd76c3..0000000000 --- a/examples/cordis-agent/tests/harness.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { Context } from 'cordis' -import type { Agent } from '@deepseek-ai/dsh-agent' -import AgentLoop from '@deepseek-ai/dsh-agent-loop' -import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit' -import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek' -import * as ToolCordis from '@deepseek-ai/dsh-tool-cordis' - -/** - * Shared harness for the cordis-agent e2e suite: the agent spine with the real - * DeepSeek adapter and the real `@deepseek-ai/dsh-tool-cordis` plugin, so a - * live model can mount plugins into the very context the test observes. Lives - * outside the *.e2e.ts pattern so importing it never re-registers another - * file's tests. - */ - -const PERSONA = 'You are cordis-agent, a self-referential harness demo. ' - + 'Your cordis_* tools operate on the live cordis runtime you run inside: ' - + 'cordis_inspect to look around, cordis_mount to mount a temporary Plugin, cordis_unmount ' - + 'to unmount one. Follow the tool descriptions exactly and report results briefly.' - -export async function cordisHarness(): Promise { - const ctx = new Context() - await mountAgentLoopTestDependencies(ctx, { - systemPrompt: { persona: PERSONA }, - }) - await ctx.plugin(AgentLoop, { agents: [] }) - await ctx.plugin(LlmDeepSeek) - await ctx.plugin(ToolCordis) - return ctx -} - -export function waitForIdle(ctx: Context, agent: Agent): Promise { - return new Promise((resolve) => { - const dispose = ctx.on('agent/status', (subject, status) => { - if (subject === agent && status === 'idle') { - dispose() - resolve() - } - }) - }) -} diff --git a/examples/cordis-agent/tests/keyless-smoke.e2e.ts b/examples/cordis-agent/tests/keyless-smoke.e2e.ts deleted file mode 100644 index c340eea036..0000000000 --- a/examples/cordis-agent/tests/keyless-smoke.e2e.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { fileURLToPath } from 'node:url' -import { describe, expect, it } from 'vitest' -import { LOADER_SMOKE_TEST_TIMEOUT_MS } from '@deepseek-ai/dsh-loader-smoke' -import { runTuiPtySmoke } from '../../tui-agent/tests/pty-harness.ts' - -const binScript = fileURLToPath(new URL('../../../apps/cli/src/bin.ts', import.meta.url)) -const configPath = fileURLToPath(new URL('../cordis.yml', import.meta.url)) -const tsconfigPath = fileURLToPath(new URL('../../../tsconfig.json', import.meta.url)) - -describe('cordis-agent keyless smoke (real Loader tree in a PTY)', () => { - it('boots the full tool-cordis tree and exits cleanly through the TUI', async () => { - const output = await runTuiPtySmoke({ - label: 'cordis-agent', - tempDirPrefix: 'cordis-agent-smoke-', - binScript, - configPath, - tsconfigPath, - env: { DEEPSEEK_API_KEY: 'keyless-smoke-no-call' }, - actions: [{ waitFor: 'cordis-agent ready.', send: '/exit\r' }], - }) - expect(output).toContain('cordis-agent ready.') - }, LOADER_SMOKE_TEST_TIMEOUT_MS) -}) diff --git a/examples/package.json b/examples/package.json index b6f06057e3..88e1e8f57a 100644 --- a/examples/package.json +++ b/examples/package.json @@ -3,11 +3,14 @@ "private": true, "version": "0.0.1", "type": "module", - "description": "Workspace umbrella for runnable demos and example-owned test compositions: declares their cordis.yml packages so plain Node resolves real exports→lib. Not a build target.", + "description": "Workspace umbrella for runnable demos and example-owned test compositions: declares their cordis.yml packages so plain Node resolves real exports\u2192lib. Not a build target.", "dependencies": { "@cordisjs/plugin-hmr": "workspace:*", "@cordisjs/plugin-include": "workspace:*", + "@cordisjs/plugin-timer": "workspace:*", "@deepseek-ai/dsh-acp-demo": "workspace:*", + "@deepseek-ai/dsh-agent": "workspace:*", + "@deepseek-ai/dsh-agent-loop": "workspace:*", "@deepseek-ai/dsh-agent-spine-demo": "workspace:*", "@deepseek-ai/dsh-app-boot": "workspace:*", "@deepseek-ai/dsh-bash": "workspace:*", @@ -15,6 +18,8 @@ "@deepseek-ai/dsh-bash-sandbox": "workspace:*", "@deepseek-ai/dsh-cli-demo": "workspace:*", "@deepseek-ai/dsh-code-runtime-worker": "workspace:*", + "@deepseek-ai/dsh-command-goal": "workspace:*", + "@deepseek-ai/dsh-commands": "workspace:*", "@deepseek-ai/dsh-compact-basic": "workspace:*", "@deepseek-ai/dsh-compact-tool-result-prune": "workspace:*", "@deepseek-ai/dsh-fs-local": "workspace:*", @@ -24,6 +29,7 @@ "@deepseek-ai/dsh-goal-session": "workspace:*", "@deepseek-ai/dsh-hooks-claude": "workspace:*", "@deepseek-ai/dsh-hooks-codex": "workspace:*", + "@deepseek-ai/dsh-invariants": "workspace:*", "@deepseek-ai/dsh-jsonrpc": "workspace:*", "@deepseek-ai/dsh-llm": "workspace:*", "@deepseek-ai/dsh-llm-deepseek": "workspace:*", @@ -33,32 +39,40 @@ "@deepseek-ai/dsh-lsp-local": "workspace:*", "@deepseek-ai/dsh-permission": "workspace:*", "@deepseek-ai/dsh-plan-mode": "workspace:*", - "@deepseek-ai/dsh-subprocess-local": "workspace:*", "@deepseek-ai/dsh-pty": "workspace:*", "@deepseek-ai/dsh-pty-local": "workspace:*", "@deepseek-ai/dsh-repeat-tool-guard": "workspace:*", "@deepseek-ai/dsh-sandbox-local": "workspace:*", "@deepseek-ai/dsh-sandbox-policy": "workspace:^", + "@deepseek-ai/dsh-scope": "workspace:*", + "@deepseek-ai/dsh-session": "workspace:*", "@deepseek-ai/dsh-session-checkpoint-policy": "workspace:*", "@deepseek-ai/dsh-session-persistence-jsonl": "workspace:*", "@deepseek-ai/dsh-session-query": "workspace:*", "@deepseek-ai/dsh-session-query-sqlite": "workspace:*", + "@deepseek-ai/dsh-session-reference": "workspace:*", "@deepseek-ai/dsh-session-telemetry-otel": "workspace:*", + "@deepseek-ai/dsh-session-title": "workspace:*", "@deepseek-ai/dsh-session-title-first-message-llm": "workspace:*", + "@deepseek-ai/dsh-skill": "workspace:*", + "@deepseek-ai/dsh-skill-local": "workspace:*", "@deepseek-ai/dsh-source-guard": "workspace:*", "@deepseek-ai/dsh-spill-local": "workspace:*", "@deepseek-ai/dsh-spill-policy": "workspace:*", "@deepseek-ai/dsh-subagent": "workspace:*", "@deepseek-ai/dsh-subagent-acp": "workspace:*", - "@deepseek-ai/dsh-subagent-fork": "workspace:*", "@deepseek-ai/dsh-subagent-dsh-sdk": "workspace:*", + "@deepseek-ai/dsh-subagent-fork": "workspace:*", "@deepseek-ai/dsh-subagent-spawn": "workspace:*", + "@deepseek-ai/dsh-subprocess-local": "workspace:*", + "@deepseek-ai/dsh-system-prompt": "workspace:*", "@deepseek-ai/dsh-tasks-local": "workspace:*", "@deepseek-ai/dsh-time-context": "workspace:*", "@deepseek-ai/dsh-timeout-policy": "workspace:*", "@deepseek-ai/dsh-tmux-context": "workspace:*", "@deepseek-ai/dsh-token-meter": "workspace:*", "@deepseek-ai/dsh-tool-ask-user": "workspace:*", + "@deepseek-ai/dsh-tool-bash": "workspace:*", "@deepseek-ai/dsh-tool-cordis": "workspace:*", "@deepseek-ai/dsh-tool-fs": "workspace:*", "@deepseek-ai/dsh-tool-fs-search": "workspace:*", @@ -67,18 +81,19 @@ "@deepseek-ai/dsh-tool-pty": "workspace:*", "@deepseek-ai/dsh-tool-ralph": "workspace:*", "@deepseek-ai/dsh-tool-session-query": "workspace:*", + "@deepseek-ai/dsh-tool-skill": "workspace:*", "@deepseek-ai/dsh-tool-subagent": "workspace:*", + "@deepseek-ai/dsh-tool-tasks": "workspace:*", "@deepseek-ai/dsh-tool-todo": "workspace:*", "@deepseek-ai/dsh-tool-web": "workspace:*", "@deepseek-ai/dsh-tool-workflow": "workspace:*", "@deepseek-ai/dsh-tools": "workspace:*", - "@deepseek-ai/dsh-tui-demo": "workspace:*", + "@deepseek-ai/dsh-tui": "workspace:*", "@deepseek-ai/dsh-user-approval": "workspace:*", + "@deepseek-ai/dsh-user-interaction": "workspace:*", "@deepseek-ai/dsh-web": "workspace:*", "@deepseek-ai/dsh-web-fetch-local": "workspace:*", - "@deepseek-ai/dsh-workflow-workerthread": "workspace:*" - }, - "devDependencies": { - "node-pty": "1.1.0" + "@deepseek-ai/dsh-workflow-workerthread": "workspace:*", + "@deepseek-ai/dsh-workspace-context": "workspace:*" } } diff --git a/examples/tui-agent/README.md b/examples/tui-agent/README.md deleted file mode 100644 index eae5994fd8..0000000000 --- a/examples/tui-agent/README.md +++ /dev/null @@ -1,80 +0,0 @@ -# tui-agent - -English | [中文](README.zh.md) - -The full-screen interactive coding agent: DeepSeek V4, local bash and filesystem tools, compaction, subagents, workflows and fresh-agent Ralph iteration, plan mode (`/plan` enters and `exit_plan_mode` reviews the exit), timeout/spill policy, and JSONL persistence through [`@deepseek-ai/dsh-tui-demo`](../../packages/examples/tui-demo), loaded from `cordis.yml`. The sibling [`headless-agent`](../headless-agent/README.md) runs the same capability class as a one-shot pipe-friendly task, and [`acp-agent`](../acp-agent/README.md) serves it over JSON-RPC. - -## Run it - -```sh -# repo root .env (gitignored) or exported env: -# DEEPSEEK_API_KEY=sk-… -# DEEPSEEK_BASE_URL=https://… # optional; defaults to the public API -pnpm run demo:tui -``` - -Both the demo script and the installable `dsh` CLI ([`apps/cli`](../../apps/cli/README.md)) boot this example's `cordis.yml` as the shipped default config; `dsh` additionally applies the personal overlay from `~/.dsh` and uses the invoking directory as the workspace. - -Type a coding task. The agent works through the `read`/`write`/`edit` filesystem tools for ordinary file operations and `bash` (+ the generic `task_output` / `task_list` / `task_kill` for background tasks) for shell commands, searches, and test runs, each in a fresh `bash -c` (the system prompt tells the model to pass `workdir` instead of `cd`). Both the fs tools and bash resolve relative paths against the session workspace. It can also delegate with `subagent`/`subagent_fork`. - -The `todo_write` task tracker is opt-in and not in the shipped config: add `@deepseek-ai/dsh-tool-todo` to `cordis.yml` (or a personal-config overlay under `~/.dsh`) to expose it. Once loaded, the model records a whole-list plan to the session log and the TUI renders it. - -The TUI renders Markdown history, reasoning, tool-owned terminal/diff/generic cards, token totals, and — when `todo_write` is loaded — the latest plan. Long tool bodies keep a head/tail preview; Ctrl+O expands or collapses every card. Enter submits or steers while the agent runs, Ctrl+R toggles reasoning, Escape cancels, and `/help` lists commands. `/plan` selects plan mode for the next step; `/plan ` also submits the message into that step, while `/plan off` selects the default mode without model input. `/status` expands the current session's identity, activity counts, exact token/cache buckets, context use, and timestamps without interrupting a running turn. `/model` opens a keyboard selector for the current provider catalog; use Up/Down to focus a model, Shift+Tab to cycle its advertised reasoning efforts, and Enter to select, or use `/model ` and `/model /` for direct selection. `ask_user_question` opens a wide bottom-left keyboard panel with batch progress and numbered options. - -### Resuming a prior session - -Each run starts a fresh session by default (its event log lands under `./.sessions/`). To **continue** a previous conversation, pass its id to the installed `dsh` CLI — the `main` agent then rehydrates the persisted log instead of starting fresh, so the model sees the earlier turns as history: - -```sh -dsh --resume -``` - -`/resume` opens a searchable keyboard selector with titles, activity, last-turn results, model route, durable goal phase, and live/persisted state. The installed `dsh` host flushes and disposes the current app, then replaces the process with `dsh --resume `; a host that cannot hand off in place says so and leaves the session running. Resume needs no key in this file: `dsh` provides the session identity and the exit line on the boot context, so `--resume ` and the printed resume command survive any personal-overlay patch of the `tui-agent` entry. With no flag the agent starts a new session. A missing or unreadable id starts no agent and emits `agent-loop/config-start-failed`: the TUI prints the failure and exits nonzero. The selector has no cross-process session lock, so deployments with concurrent hosts must coordinate session ownership separately. - -## Code Mode - -[`code-mode.cordis.yml`](code-mode.cordis.yml) overlays the same tree with the worker-thread runtime and `tools: { mode: code }`. The model receives one `run_code` transport plus a generated TypeScript SDK for the visible tools; only program output returns to model context. Use `mode: both` to expose native calls alongside `run_code`. See the [Code Mode Agent Note](../../.agents/notes/implemented/feature/2026-06-15-code-mode.md) for the execution contract. - -```sh -pnpm run demo:code-mode # this overlay under the TUI (default UI) -pnpm run demo:code-mode acp # the acp-agent example's same-shaped overlay -``` - -Try a task that spans several tool calls, e.g.: - -> Count the lines of every `*.md` file under docs/ and write the three largest to summary.txt. - -and watch the transcript: one `run_code` call, a program looping over tools, and a result the model curated instead of five round-trips of raw tool output. - -## What each leaf entry demonstrates - -This example is a thin leaf `cordis.yml`: it picks the swappable backends, loads one app package, and adds product tools that are intentionally outside the shared spine. The spine (sessions, system-prompt, tools, agents, invariants, `agent-loop`) and the front-door cluster (JSONL persistence, the pi-tui channel, the pre-created `main` agent) live inside the [`@deepseek-ai/dsh-tui-demo`](../../packages/examples/tui-demo) app and the [`@deepseek-ai/dsh-agent-spine-demo`](../../packages/examples/agent-spine-demo) bundle it loads; the leaf wires the backends and model-facing optional tools: - -| Entry | Demonstrates | -|---|---| -| `hmr` (`@cordisjs/plugin-hmr`) | the dev/demo edit-reload loop — a **leaf** entry (not baked into the app) because it depends on the Loader's internal module access | -| `llm-deepseek` | the default native adapter | -| `bash` (`dsh-bash-local`) | the executor implementation — the swappable half of the bash seam. The model-facing `bash` schema (`tool-bash`) and generic `task_*` controls (`tool-tasks`) come from `dsh-agent-spine-demo`, so only the executor is a leaf choice | -| `tui-agent` (`@deepseek-ai/dsh-tui-demo`) | the app bundle: the agent-spine demo + JSONL persistence + the pi-tui channel + a pre-created `main` agent | -| `subagent`, `subagent-spawn`, `subagent-fork` | the subagent provider registry plus the two in-process backends: a fresh child and a child seeded with the parent's completed-turn prefix | -| `tool-subagent`, `tool-subagent-fork` | two model-facing `dsh-tool-subagent` loads, each bound to a different provider and exposed under a distinct tool name (`subagent`, `subagent_fork`) | -| `workflow-workerthread`, `tool-workflow` | the worker-thread workflow engine and its model-facing `workflow` tool, with child calls routed through the spawn backend | -| `plan-mode` | the plugin-owned `/plan [message]` entry and `/plan off` exit commands, plan-mode prompt policy, tool restrictions, and reviewed `exit_plan_mode` transition | -| `fs-local`, `fs-policy`, `tool-fs` | the filesystem stack: the local `ctx.fs` provider, the read-before-write/edit policy gate (on the `fs/*` event gate), and the model-facing `read`/`write`/`edit` tools. Relative paths resolve against the session workspace | - -## End-to-end tests (`pnpm run test:e2e`) - -The UI-independent with-key suites assemble the full stack programmatically through `tests/harness.ts` (no PTY, no Loader): - -- `tests/full-loop.e2e.ts` — the canary: real model runs `echo e2e-ok` through the real bash tool; asserts `tool/call`/`tool/result` session events and the final answer. -- `tests/coding-task.e2e.ts` — the swebench-style smoke: a temp dir holds `add.js` (with `a - b` where `a + b` belongs) and a failing `add.test.js`; the agent must fix the bug and verify. The test re-runs `node add.test.js` ITSELF and inspects the files — agent claims are not trusted. -- `tests/resume.e2e.ts` — durable continuity across processes: run 1 tells the real model a secret code and persists the turn to a temp JSONL root, then the whole context is disposed; run 2 is a fresh context over the same root that RESUMES the session id and asks the model to recall the code. The recall can only come from the rehydrated log. -- `tests/compaction.e2e.ts` — the compaction smoke: a real multi-step bash task runs with a deliberately tiny context window so the auto-compaction listener fires MID-SESSION. Verifies the WORLD — a `compact/start…end` pair landed in the real log, the surface shrank (a replace node shadowed older nodes), and the agent still produced a correct final answer after compaction. -- `tests/todo-write.e2e.ts` — loads the opt-in `todo_write` tool, then a real model drives it and the test verifies the resulting `todo/write` session event. -- `tests/code-mode.e2e.ts` — the with-key Code Mode proof: a real model, a two-tool task, asserting the wire tool list was exactly `[run_code]`, the `tool/code-dispatch` events landed under the parent call, and the curated answer came back. - -These self-skip without `DEEPSEEK_API_KEY`. The keyless `tests/tui-keyless-smoke.e2e.ts` boots the real Loader tree in a PTY (the one sanctioned PTY surface): the base boot + `/plan` + `/exit`, a scripted-LLM conversation with a question dialog and tool round-trip, the Code Mode overlay welcome line, and the resume-failure exit path. - -## Snapshot tests - -`tests/snapshots//session.jsonl` supplies recorded user prompts and model chunks; sibling child logs drive subagents and workflows. The keyless suite executes those scripts through the real loop and tool implementations, then compares readable expected terminal cell/style output. Use `pnpm run test:snapshot:refresh` for presentation-only changes and `pnpm run test:snapshot:record` with a DeepSeek key when a recorded model journey changes. The implemented [TUI snapshot Agent Note](../../.agents/notes/implemented/testing/2026-07-18-tui-terminal-state-snapshots.md) owns the scenario matrix and the split between recorded journeys, transient package snapshots, and PTY coverage. diff --git a/examples/tui-agent/code-mode.cordis.yml b/examples/tui-agent/code-mode.cordis.yml deleted file mode 100644 index eec54fe6ad..0000000000 --- a/examples/tui-agent/code-mode.cordis.yml +++ /dev/null @@ -1,30 +0,0 @@ -# Code Mode keeps the TUI composition while adding the worker runtime and -# reducing the model-facing registry to the `run_code` transport. -- id: base - name: '@cordisjs/plugin-include' - config: - path: ./cordis.yml - patches: - - id: tui-agent - name: '@deepseek-ai/dsh-tui-demo' - config: - provider: deepseek - model: deepseek-v4-pro - persistenceRoot: './.sessions' - workspaceContext: - maxBytes: 65536 - tools: - mode: code - welcome: 'TUI Code Mode ready. Give it a multi-tool task.' - ui: - showReasoning: true - maxToolOutputLines: 6 - persona: | - You are a coding agent powered by the {{model}} model. - - You work by writing TypeScript programs for run_code: batch related - tool work into one program, loop and branch where it helps, and print - or return ONLY the findings that matter. - - insert: - - id: code-runtime - name: '@deepseek-ai/dsh-code-runtime-worker' diff --git a/examples/tui-agent/cordis.yml b/examples/tui-agent/cordis.yml deleted file mode 100644 index b3f31ee928..0000000000 --- a/examples/tui-agent/cordis.yml +++ /dev/null @@ -1,201 +0,0 @@ -# Full-screen TUI coding agent with swappable model and local-bash backends. -# `dsh-tui-demo` supplies the agent spine, workspace instructions, generic -# task controls, JSONL persistence, the pi-tui front door, and `main`. -# HMR remains a leaf because it depends on Loader internals. The app bin loads -# the gitignored root `.env`; this file reads `DEEPSEEK_API_KEY` and optional -# `DEEPSEEK_BASE_URL` through `!!js`. - -- id: hmr - name: '@cordisjs/plugin-hmr' - config: - root: ['.'] - -# The native DeepSeek adapter. Shipped default: full thinking at max effort on -# every request (wire-only defaults; they never enter the request header). -- id: llm-deepseek - name: '@deepseek-ai/dsh-llm-deepseek' - config: - apiKey: !!js process.env.DEEPSEEK_API_KEY - baseURL: !!js process.env.DEEPSEEK_BASE_URL - thinking: enabled - reasoningEffort: max - -- id: llm-pi-ai - name: '@deepseek-ai/dsh-llm-pi-ai' - config: - providers: - - provider: openai - apiKey: !!js process.env.OPENAI_API_KEY - baseURL: !!js process.env.OPENAI_BASE_URL - - provider: anthropic - apiKey: !!js process.env.ANTHROPIC_API_KEY - baseURL: !!js process.env.ANTHROPIC_BASE_URL - -# Local executor for the app bundle's bash tool. -# Managed child-process groups for the bash executor (spawn/kill/output plumbing). -- id: subprocess - name: '@deepseek-ai/dsh-subprocess-local' - -- id: bash - name: '@deepseek-ai/dsh-bash-local' - config: - timeoutMs: 60000 - -# The app bundle pre-creates the TUI's `main` agent. -- id: tui-agent - name: '@deepseek-ai/dsh-tui-demo' - config: - provider: deepseek - model: deepseek-v4-pro - # Session identity and the resume command printed on exit are launcher-owned: - # `dsh` provides both on the boot context, so `--resume ` and the exit - # hint need no key here. `persistenceRoot` is omitted: the dsh launcher - # supplies its shared Harness-home store through a boot slot, and a bare - # example boot falls back to the bundle's project-local `./.sessions`. - workspaceContext: - maxBytes: 65536 - ui: - showReasoning: true - maxToolOutputLines: 6 - # Keep the persona to identity and behavior; tool plugins own tool guidance. - # The loop resolves {{model}} from this agent's configuration. - persona: | - You are a coding agent powered by the {{model}} model. - - Verify your work by running the code or tests. Keep answers brief and - factual. - -# Model-made session titles on the first-message cadence: replaces the spine's -# deterministic fallback title with a short model summary. The TUI renders the -# logged `session/title` as the banner subtitle and the terminal window title. -# Omitting provider/model inherits the main request's exact route. -- id: session-title-llm - name: '@deepseek-ai/dsh-session-title-first-message-llm' - config: - targetWords: 5 - targetCjkCharacters: 10 - maxInputBytes: 4096 - maxOutputTokens: 64 - timeoutMs: 60000 - -# Replay-aware request pressure with one service-wide context window. -- id: token-meter - name: '@deepseek-ai/dsh-token-meter' - -- id: tool-result-prune - name: '@deepseek-ai/dsh-compact-tool-result-prune' - -# Summarize an older range after measured pressure or a canonical provider overflow. -# Service-wide policy provides pressure, retention, and one overflow-retry default. -- id: compact-basic - name: '@deepseek-ai/dsh-compact-basic' - -# Expose fresh-child `spawn` and completed-prefix `fork` through independent -# in-process backends. Each tool instance needs a distinct `toolName`; the registry -# rejects duplicates. These leaves follow the app because it provides `ctx.agents` and `ctx.tools`. -- id: subagent - name: '@deepseek-ai/dsh-subagent' - -- id: subagent-spawn - name: '@deepseek-ai/dsh-subagent-spawn' - config: - providerName: spawn - -- id: subagent-fork - name: '@deepseek-ai/dsh-subagent-fork' - config: - providerName: fork - -- id: tool-subagent - name: '@deepseek-ai/dsh-tool-subagent' - config: - provider: spawn - toolName: subagent - -- id: tool-subagent-fork - name: '@deepseek-ai/dsh-tool-subagent' - config: - provider: fork - toolName: subagent_fork - - -# The worker-thread workflow engine fans a model-written JavaScript script's -# `agent()` calls out through the spawn backend; the adjacent tool exposes it to the model. -- id: workflow-workerthread - name: '@deepseek-ai/dsh-workflow-workerthread' - config: - provider: spawn - -- id: tool-workflow - name: '@deepseek-ai/dsh-tool-workflow' - -# A separate fixed consumer demonstrates fresh-agent Ralph iteration without -# changing the workflow tool or same-session goal behavior. -- id: tool-ralph - name: '@deepseek-ai/dsh-tool-ralph' - -# Plan mode gives the TUI plugin-owned /plan [message] entry and /plan off exit -# commands; the reviewed exit rides the TUI's user-interaction provider. -- id: plan-mode - name: '@deepseek-ai/dsh-plan-mode' - config: - section: | - You are in plan mode. Stay in plan mode until exit_plan_mode succeeds or the user switches the session mode. Imperative language to implement changes means plan the implementation, not execute it. A user's conversational agreement — including an answer confirming something you asked — approves nothing and does not end plan mode; fold the confirmed decision into the plan and submit it through exit_plan_mode. - - Explore first. Use non-mutating reads, searches, static analysis, and checks to ground the plan in the actual repository. Do not edit or write files, change configuration, run formatters or code generation that rewrites tracked files, commit, or otherwise carry out the plan. Prefer existing functions and patterns over new machinery. - - The tool catalog stays the same across modes for request-cache stability. These plan-mode rules override any later tool description or guidance that suggests using mutation tools; those tools remain listed only to keep the request shape stable. Do not use todo_write to track this planning phase: it tracks implementation after an approved plan, while the plan itself belongs in exit_plan_mode. - - Resolve discoverable facts by inspection. Use ask_user_question only for user-owned choices or material ambiguity that inspection cannot answer. Do not ask the user where code lives or how current behavior works when you can find out. - - Make the plan decision-complete: state the goal and success criteria; group implementation changes by subsystem; identify public API, schema, and data-flow changes; cover edge cases, failure modes, tests, acceptance criteria, and explicit assumptions. Keep it concise enough to review but detailed enough that another engineer can implement it without making design decisions. - - When ready, call exit_plan_mode with the complete plan markdown, starting with a # title. Make exit_plan_mode the only and final tool call in that assistant response: it presents the plan for approval, and implementation begins only in a later step after approval. Do not paste the final plan as a plain reply or ask "should I proceed?" through prose or ask_user_question. If review rejects it, incorporate the feedback and present again. If the review channel is unavailable or aborted, stay in plan mode and ask the user to switch modes manually; do not proceed with implementation. - -# Policy loads before the model-facing filesystem tools so writes and edits require -# an observed file. This single-session app resolves relative paths from the process cwd. -- id: fs-local - name: '@deepseek-ai/dsh-fs-local' - config: - cwd: !!js process.cwd() - -- id: fs-policy - name: '@deepseek-ai/dsh-fs-policy' - -- id: tool-fs - name: '@deepseek-ai/dsh-tool-fs' - -# Refuses write/edit inside the dsh checkout this launcher runs from, on that -# checkout's own branch, until the session loads dsh-customize — the skill whose -# workflow (task worktree, then integrate under the staging lock) the refusal -# points at. Inert everywhere else: another repository, a task worktree nested -# under the protected one, a sibling checkout on a different branch, and any -# workspace outside a dsh source install all pass through untouched. -- id: source-guard - name: '@deepseek-ai/dsh-source-guard' - -# Bash-backed discovery tools (glob/grep): fixed ripgrep commands through the -# local bash executor above — not ctx.fs. Capped results save the complete -# formatted list through the spill backend below (ctx.spillStore, optional). -- id: tool-fs-search - name: '@deepseek-ai/dsh-tool-fs-search' - -# The tool-call timeout enforcer: arms each declared ToolDefinition.timeoutMs -# (the search tools above declare 30s) as a deadline on exec.signal. Without -# it a declared budget is advisory and only the bash executor's own timeout -# backstop applies. -- id: timeout-policy - name: '@deepseek-ai/dsh-timeout-policy' - -# Tool-output spill stack: a local backend that saves oversized tool text under -# a private session-scoped dir, and the tools/post-execute policy that replaces -# an over-budget plain-text result with a preview + the spill locator/retrieval -# hint. A leaf pair after the app (needs ctx.tools). The policy is a no-op until -# a tool returns more than maxInlineBytes of plain text. -- id: spill-local - name: '@deepseek-ai/dsh-spill-local' - -- id: spill-policy - name: '@deepseek-ai/dsh-spill-policy' - config: - maxInlineBytes: 50000 diff --git a/examples/tui-agent/package.json b/examples/tui-agent/package.json deleted file mode 100644 index f45e6746a3..0000000000 --- a/examples/tui-agent/package.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "name": "tui-agent-example", - "private": true, - "version": "0.0.1", - "type": "module", - "description": "Runnable demo: the coding agent through the full-screen terminal UI" -} diff --git a/examples/tui-agent/tests/fixtures/tui-scripted.cordis.yml b/examples/tui-agent/tests/fixtures/tui-scripted.cordis.yml deleted file mode 100644 index e615a7ec09..0000000000 --- a/examples/tui-agent/tests/fixtures/tui-scripted.cordis.yml +++ /dev/null @@ -1,53 +0,0 @@ -# Real Loader composition for the keyless conversational PTY test. The app -# bundle supplies the production agent/TUI/user-question stack; only the model -# is scripted so the terminal interaction is deterministic and network-free. -- id: scripted-llm - name: './tui-scripted-llm.ts' - -# Managed child-process groups for the bash executor (spawn/kill/output plumbing). -- id: subprocess - name: '@deepseek-ai/dsh-subprocess-local' - -- id: bash - name: '@deepseek-ai/dsh-bash-local' - -- id: fs-local - name: '@deepseek-ai/dsh-fs-local' - config: - cwd: !!js process.cwd() - -- id: token-meter - name: '@deepseek-ai/dsh-token-meter' - -- id: plan-mode - name: '@deepseek-ai/dsh-plan-mode' - config: - section: 'Stay in plan mode for this scripted TUI test.' - -- id: tui-agent - name: '@deepseek-ai/dsh-tui-demo' - config: - provider: tui-scripted - model: tui-scripted-model - persistenceRoot: './.sessions' - # The smoke's log inspection reads plain `.jsonl`; keep the scripted - # fixture uncompressed like the other snapshot-facing configs. - persistenceCompression: none - workspaceContext: - maxBytes: 65536 - welcome: 'scripted TUI ready.' - persona: 'Scripted model {{model}}.' - ui: - showReasoning: true - -# Model-made session titles, as in the shipped cordis.yml: the scripted adapter -# answers the tool-less title request with a fixed string so the PTY test can -# assert the logged title reaches the terminal window title. -- id: session-title-llm - name: '@deepseek-ai/dsh-session-title-first-message-llm' - config: - targetWords: 5 - targetCjkCharacters: 10 - maxInputBytes: 4096 - maxOutputTokens: 64 - timeoutMs: 10000 diff --git a/examples/web-cordis/cordis.yml b/examples/web-cordis/cordis.yml index 80b598c696..4cd96e396f 100644 --- a/examples/web-cordis/cordis.yml +++ b/examples/web-cordis/cordis.yml @@ -1,18 +1,19 @@ # Opt-in Web composition for inspecting the self-referential Cordis tools. # Temporary Plugin code can reach every injected live capability; treat this # deployment like shell access, not as a security boundary. -- id: base - name: '@cordisjs/plugin-include' +# This file is an OVERLAY over the shipped web composition (`base.cordis.yml` + +# `web.cordis.yml`), not a tree: `dsh web --config` applies it as one more +# sibling patch list at the same include level, so these patches reach base and +# overlay rows alike. A patch replaces the targeted row's whole `config`. + +# AppCLIEntry normally injects the assembly-owned dist path before `dsh web` +# boots; pinning the port here keeps this demo off the default 3080. +- id: webserver config: - path: ../../apps/cli/cordis.yml - patches: - # AppCLIEntry normally injects this assembly-owned path before `dsh web` - # boots; the standalone Cordis launcher needs the equivalent patch here. - - id: webserver - config: - host: 127.0.0.1 - port: 3081 - distIndex: !!js "new URL('./apps/web/dist/index.html', 'file://' + process.cwd() + '/').pathname" - - insert: - - id: tool-cordis - name: '@deepseek-ai/dsh-tool-cordis' + host: 127.0.0.1 + port: 3081 + distIndex: !!js "new URL('./apps/web/dist/index.html', 'file://' + process.cwd() + '/').pathname" + +- insert: + - id: tool-cordis + name: '@deepseek-ai/dsh-tool-cordis' diff --git a/knip.json b/knip.json index 0d616c8388..1db0b5d257 100644 --- a/knip.json +++ b/knip.json @@ -43,7 +43,6 @@ "headless-agent/tests/fixtures/tmux-context-mock-llm.ts", "headless-agent/tests/fixtures/tmux-context-mock-bash.ts", "acp-agent/tests/snapshots/lsp-definition/workspace/subject.ts", - "tui-agent/tests/fixtures/tui-scripted-llm.ts", "acp-agent/tests/fixtures/subagent/subagent-acp/mock-delegating-llm.ts", "acp-agent/tests/fixtures/subagent/subagent-acp/driver.ts", "jsonrpc-agent/tests/fixtures/subagent/subagent-dsh-sdk/driver.ts", @@ -482,15 +481,6 @@ "tests/**/*.ts" ] }, - "packages/examples/tui-demo": { - "entry": [ - "tests/**/*.spec.ts" - ], - "project": [ - "src/**/*.ts", - "tests/**/*.ts" - ] - }, "packages/examples/cli-demo": { "entry": [ "tests/**/*.spec.ts", @@ -632,8 +622,15 @@ ] }, "apps/cli": { + "entry": [ + "tests/**/*.spec.ts", + "tests/**/*.e2e.ts", + "tests/**/*.snapshot.ts", + "tests/fixtures/tui-scripted-llm.ts" + ], "project": [ - "src/**/*.ts" + "src/**/*.ts", + "tests/**/*.ts" ], "ignoreDependencies": [ "@deepseek-ai/.+", diff --git a/packages/client/AGENTS.md b/packages/client/AGENTS.md index a7d80b3232..02e453415a 100644 --- a/packages/client/AGENTS.md +++ b/packages/client/AGENTS.md @@ -86,7 +86,7 @@ If `test:gui` is red on code you did not touch, neither silently fix nor ignore Bringing up a new `packages/client/` plugin package (ui-workspace is the latest walked example; ui-sidebar/ui-question are good skeletons to copy): 1. **Package skeleton**: `package.json` (`@deepseek-ai/dsh-client-`, exports `.`/`./invariant`/`./client`/`./src/*`/`./package.json`, `dshClient` manifest, `files` list), `tsconfig.json` (extends `tsconfig.base.client.json`, one `references` entry per workspace dependency plus `support/invariants`), `tsdown.config.ts` (`clientBundle(id, ['lib/types/index.js', 'lib/types/invariant.js'])`), `src/index.ts` (empty node-half apply), `src/invariant.ts` (companion with a real reason), `src/css-modules.d.ts` when using CSS Modules, `README.md` with the Model Experience section. -2. **Three registration surfaces, all required** (missing any one fails at a different, later point): the `tsconfig.client.json` aggregate `references` entry; a `dshClient` row in `apps/cli/cordis.yml`; an `apps/cli/package.json` dependency (Loader resolves each config-tree package against the composing app's URL — a row whose package is not an `apps/cli` dependency fails to import). `pnpm-workspace.yaml` already globs `packages/*/*`. +2. **Three registration surfaces, all required** (missing any one fails at a different, later point): the `tsconfig.client.json` aggregate `references` entry; a `dshClient` row in `apps/cli/base.cordis.yml`; an `apps/cli/package.json` dependency (Loader resolves each config-tree package against the composing app's URL — a row whose package is not an `apps/cli` dependency fails to import). `pnpm-workspace.yaml` already globs `packages/*/*`. 3. **dshClient manifest semantics**: `platform: 'web'` always; `immediately: true` only for stage-one-prefetch infrastructure rows. `inject` lists package-name dependency edges — they are **informational only** (preflight display, HMR diffing); they do not sequence entry activation or apply order. Activation order is cordis fiber inject waiting on *services*, nothing else. 4. **Registering into another package's slot**: if the declaring host provides no waitable service, your apply's order relative to the host's is unconstrained — a bare `slots.register` into its slot races boot (intermittent `slot "..." is not declared` page failures). Register with declaration-aware deferral: check `ctx.slots.spec(name)`, otherwise `ctx.slots.subscribe(name)` and register on the declaration event (SlotCore supports subscribing ahead of declaration); make the registration idempotent, and unsubscribe + dispose in the effect disposer. Only take a service edge in `inject` when the host actually provides one (ui-question → `'conversation'` is that case). 5. Rebuild the bundle (`pnpm --filter bundle`) before probing a live `dsh web` server — the registry serves `lib/client.js`, not sources. diff --git a/packages/core/agent-loop/src/index.ts b/packages/core/agent-loop/src/index.ts index ca6b83dadb..f200154104 100644 --- a/packages/core/agent-loop/src/index.ts +++ b/packages/core/agent-loop/src/index.ts @@ -134,6 +134,15 @@ interface PreparedAgent { declare module 'cordis' { interface Context { agentLoop: AgentLoop + /** + * Launcher-owned exact session identities for configured agents, keyed by + * the agent's config `id` and set with `ctx.provide()` before any Loader + * entry mounts (see {@link CONFIGURED_AGENT_IDENTITIES_KEY}). A launcher + * owns identity because only it knows whether the session already exists, + * while the `cordis.yml` row keeps the model route as ordinary patchable + * config. An entry with no matching key keeps its configured identity. + */ + configuredAgentIdentities?: ConfiguredAgentIdentities } interface Events { /** @@ -151,6 +160,53 @@ declare module 'cordis' { export { DEFAULT_MAX_PARALLEL_TOOL_CALLS } +/** + * One launcher-selected session identity for a configured agent. `resume` + * distinguishes rehydrating existing persisted history from creating the + * session fresh under that exact id, which the two config keys express as + * `resumeSessionId` and `sessionId`. + */ +export interface LauncherAgentIdentity { + /** Exact session id to create fresh or resume. */ + id: SessionId + /** Resume existing persisted history instead of creating the session fresh. */ + resume: boolean +} + +/** Launcher-selected identities keyed by the configured agent's `id`. */ +export type ConfiguredAgentIdentities = Readonly> + +/** + * Context key a launcher sets before any Loader entry mounts + * (`ctx.provide(CONFIGURED_AGENT_IDENTITIES_KEY, identities)`) to fix + * configured agents' session identities without a config key, so an overlay + * repointing the row's model route cannot drop them. + */ +export const CONFIGURED_AGENT_IDENTITIES_KEY = 'configuredAgentIdentities' + +/** + * Apply launcher-owned identities over the configured agents, replacing both + * identity keys for every entry the launcher named so a config-supplied + * identity can never survive alongside a launcher-supplied one. + * @param agents - the configured agent entries. + * @param identities - launcher identities keyed by configured agent `id`, or `undefined`. + * @returns the entries with launcher-owned identities applied. + */ +function applyLauncherIdentities( + agents: Config['agents'], + identities: ConfiguredAgentIdentities | undefined, +): Config['agents'] { + if (identities === undefined) return agents + return agents.map((agent) => { + const identity = identities[agent.id] + if (identity === undefined) return agent + const { sessionId: _sessionId, resumeSessionId: _resumeSessionId, ...rest } = agent + return identity.resume + ? { ...rest, resumeSessionId: identity.id } + : { ...rest, sessionId: identity.id } + }) +} + /** Agent-loop plugin configuration. */ export interface Config { /** @@ -220,6 +276,7 @@ export class AgentLoop extends Service implements AgentFactory { super(ctx, 'agentLoop') this.config = { ...config, + agents: applyLauncherIdentities(config.agents, ctx.get(CONFIGURED_AGENT_IDENTITIES_KEY)), maxParallelToolCalls: resolveMaxParallelToolCalls(config.maxParallelToolCalls), } validateConfiguredAgents(this.config.agents) diff --git a/packages/examples/README.i18n.yaml b/packages/examples/README.i18n.yaml index ceb4a2df7a..ddf815f1b1 100644 --- a/packages/examples/README.i18n.yaml +++ b/packages/examples/README.i18n.yaml @@ -2,5 +2,10 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/examples/README.md +<<<<<<< HEAD README.md: c229cef22087ac290bf862d6b3e31fdb533858c4 README.zh.md: 208b9a138506785ea1dd2d83ddfbba29e4b7968e +======= +README.md: d3ad432e71036db0d21f059e52f5d32e58010c42 +README.zh.md: 0218e60df2511974b8eb222e23331c5a70c9df60 +>>>>>>> a1c6a2c3f (refactor(cli)!: one shared base config with per-surface overlays) diff --git a/packages/examples/README.md b/packages/examples/README.md index c229cef220..d3ad432e71 100644 --- a/packages/examples/README.md +++ b/packages/examples/README.md @@ -7,12 +7,11 @@ Pre-composed plugin bundles a thin leaf `cordis.yml` loads instead of assembling | Package | npm name | Role | |---|---|---| | `agent-spine-demo/` | `@deepseek-ai/dsh-agent-spine-demo` | The executor-less/UI-less agent spine as one bundle plugin, with fallback session titles and an opt-in persisted-goal stack | -| `tui-demo/` | `@deepseek-ai/dsh-tui-demo` | Full-screen terminal app bundle: the spine + persisted goals + `/goal` command + JSONL persistence + `dsh-tui` + a pre-created `main` agent; no bin, booted by the [`dsh`](../../apps/cli/README.md) CLI | | `cli-demo/` | `@deepseek-ai/dsh-cli-demo` | Headless one-shot app: the spine + JSONL persistence + a pre-created `main` agent, with text and DSH-native JSON output | | `acp-demo/` | `@deepseek-ai/dsh-acp-demo` | ACP automation server app: the spine + persisted goals + JSONL persistence + the [`acp`](../acp/acp/README.md) bridge (no stdout logger), with a boot `bin` | | `jsonrpc-demo/` | `@deepseek-ai/dsh-jsonrpc-demo` | Bin-only runtime that boots an external `cordis.yml` for the stdio JSON-RPC SDK client | -`agent-spine-demo` is the shared bundle; `tui-demo`, `cli-demo`, and `acp-demo` compose it with full-screen terminal, headless one-shot, and ACP automation front doors. `cli-demo` and `acp-demo` own their boot bins; `tui-demo` ships only the bundle plugin, and the product [`dsh`](../../apps/cli/README.md) CLI is its terminal front door. `jsonrpc-demo` mounts no composition of its own — it boots whatever tree the deployment's `cordis.yml` names, and is what the Python SDK runtime launches. +`agent-spine-demo` is the shared bundle; `cli-demo` and `acp-demo` compose it with headless one-shot and ACP automation front doors, and own their boot bins. The product [`dsh`](../../apps/cli/README.md) CLI uses no bundle: its TUI and web surfaces are a shared `base.cordis.yml` plus one overlay each. `jsonrpc-demo` mounts no composition of its own — it boots whatever tree the deployment's `cordis.yml` names, and is what the Python SDK runtime launches. These are **not** product API. The spine pieces they bundle live in [`core/`](../core/README.md), human/SDK channels and boot glue in [`ui/`](../ui/README.md), the automation transport in [`acp/`](../acp/README.md), and swappable backends in their capability groups; a demo bundle just picks one concrete composition of them. Swap or fork one freely. diff --git a/packages/examples/README.zh.md b/packages/examples/README.zh.md index 208b9a1385..7ec12eb1c6 100644 --- a/packages/examples/README.zh.md +++ b/packages/examples/README.zh.md @@ -6,13 +6,21 @@ | 包 | npm 名称 | 角色 | |---|---|---| +<<<<<<< HEAD | `agent-spine-demo/` | `@deepseek-ai/dsh-agent-spine-demo` | 不含执行器和 UI 的 agent(智能体)主干,打包为一个组合包插件,带后备会话标题和可选择启用的持久化目标栈 | | `tui-demo/` | `@deepseek-ai/dsh-tui-demo` | 全屏终端应用组合包:主干 + 持久化目标 + `/goal` 命令 + JSONL 持久化 + `dsh-tui` + 预创建的 `main` agent;没有 bin,由 [`dsh`](../../apps/cli/README.md) CLI(命令行界面)启动 | +======= +| `agent-spine-demo/` | `@deepseek-ai/dsh-agent-spine-demo` | 不含执行器和 UI 的 agent 主干,打包为一个组合包插件,带后备会话标题和选用的持久目标栈 | +>>>>>>> a1c6a2c3f (refactor(cli)!: one shared base config with per-surface overlays) | `cli-demo/` | `@deepseek-ai/dsh-cli-demo` | 无头单次应用:主干 + JSONL 持久化 + 预创建的 `main` agent,提供文本和 DSH 原生 JSON 输出 | | `acp-demo/` | `@deepseek-ai/dsh-acp-demo` | ACP(Agent Client Protocol)自动化服务器应用:主干 + 持久化目标 + JSONL 持久化 + [`acp`](../acp/acp/README.md) 桥接层(无 stdout logger),带启动 `bin` | | `jsonrpc-demo/` | `@deepseek-ai/dsh-jsonrpc-demo` | 只有 bin 的运行时,用于启动外部 `cordis.yml`,供 stdio JSON-RPC SDK 客户端使用 | +<<<<<<< HEAD `agent-spine-demo` 是共享组合包;`tui-demo`、`cli-demo` 和 `acp-demo` 分别将它与全屏终端、无头单次和 ACP 自动化前端入口组合。`cli-demo` 与 `acp-demo` 拥有各自的启动 bin;`tui-demo` 只交付组合包插件,产品 [`dsh`](../../apps/cli/README.md) CLI 是它的终端前端入口。`jsonrpc-demo` 自身不挂载任何组合,而是启动部署的 `cordis.yml` 所指名的任意插件树;Python SDK 运行时会启动它。 +======= +`agent-spine-demo` 是共享组合包;`cli-demo` 和 `acp-demo` 分别将它与无头单次和 ACP 自动化前端入口组合,并拥有各自的启动 bin。产品 [`dsh`](../../apps/cli/README.md) CLI 不使用组合包:其 TUI 与 web surface 都是一份共享的 `base.cordis.yml` 加各自一份 overlay。`jsonrpc-demo` 自身不挂载任何组合,而是启动部署的 `cordis.yml` 所指名的任意插件树;Python SDK runtime 会启动它。 +>>>>>>> a1c6a2c3f (refactor(cli)!: one shared base config with per-surface overlays) 这些 **不是** 产品 API。它们打包的主干组件位于 [`core/`](../core/README.md),人类/SDK 通道和启动粘合代码位于 [`ui/`](../ui/README.md),自动化传输位于 [`acp/`](../acp/README.md),可替换后端位于各自能力组;演示组合包只选定其中一种具体组合。可以自由替换或 fork。 diff --git a/packages/examples/agent-spine-demo/README.i18n.yaml b/packages/examples/agent-spine-demo/README.i18n.yaml index e08ce5d9ac..48d17b5391 100644 --- a/packages/examples/agent-spine-demo/README.i18n.yaml +++ b/packages/examples/agent-spine-demo/README.i18n.yaml @@ -2,5 +2,10 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/examples/agent-spine-demo/README.md +<<<<<<< HEAD README.md: 359e7153be2f480ba3fea4b06782acdc9f89ebb9 README.zh.md: acd8c06940b03e90e368314cd725846a2b92b656 +======= +README.md: 6fde5c64052e369541787f90c12c33a7b0b8f9eb +README.zh.md: d5b0e2b50707750132bdeae9d7e56d513b0f40a7 +>>>>>>> a1c6a2c3f (refactor(cli)!: one shared base config with per-surface overlays) diff --git a/packages/examples/agent-spine-demo/README.md b/packages/examples/agent-spine-demo/README.md index 359e7153be..6fde5c6405 100644 --- a/packages/examples/agent-spine-demo/README.md +++ b/packages/examples/agent-spine-demo/README.md @@ -47,7 +47,7 @@ The spine is everything COMMON to every front door. The swappable and front-door - **model-backed session-title providers** — the bundle mounts the fallback service with overridable example limits (5 words, 40 fallback bytes, 80 accepted-title bytes); a leaf may opt into exactly one first-message or all-messages LLM provider. - **the bash executor** — the bundle ships `tool-bash` (the consumer schema); the leaf provides `ctx.bash` (`bash-local` or a sandboxed impl). - **non-local skill providers** — the bundle ships the skill registry, the local filesystem provider, and the `skill` tool; deployments can add other providers such as embedded or remote catalogs as siblings. -- **front-door + per-app infra** — the terminal TUI or ACP automation transport and `hmr`. App packages ([`dsh-tui-demo`](../tui-demo/README.md), [`dsh-acp-demo`](../acp-demo/README.md)) own those choices. `timer` is in the spine because it is common and stdout-silent; front doors own stdout and remain outside. +- **front-door + per-app infra** — the terminal TUI or ACP automation transport and `hmr`. App packages ([`dsh-cli-demo`](../cli-demo/README.md), [`dsh-acp-demo`](../acp-demo/README.md)) own those choices. `timer` is in the spine because it is common and stdout-silent; front doors own stdout and remain outside. This is the [interface/implementation/consumer seam](../../../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md) raised to the composition level: the bundle owns the shared spine, the leaf owns the backends, the app package owns the front door. diff --git a/packages/examples/agent-spine-demo/README.zh.md b/packages/examples/agent-spine-demo/README.zh.md index acd8c06940..96a4354b03 100644 --- a/packages/examples/agent-spine-demo/README.zh.md +++ b/packages/examples/agent-spine-demo/README.zh.md @@ -47,7 +47,11 @@ - **基于模型的会话标题提供方**:组合包挂载带可覆盖示例限制的后备服务(5 个词、40 个后备字节、80 个可接受标题字节);叶节点可以恰好选用一个首消息或全消息 LLM 提供方。 - **bash 执行器**:组合包交付 `tool-bash`(消费方 schema);叶节点提供 `ctx.bash`(`bash-local` 或沙箱化实现)。 - **非本地 skill 提供方**:组合包交付 skill 注册表、本地文件系统提供方和 `skill` 工具;部署可以把嵌入式目录或远程目录等其他提供方作为同级插件添加。 +<<<<<<< HEAD - **前端入口与各应用基础设施**:终端 TUI 或 ACP(Agent Client Protocol)自动化传输,以及 `hmr`。应用包([`dsh-tui-demo`](../tui-demo/README.md)、[`dsh-acp-demo`](../acp-demo/README.md))拥有这些选择。`timer` 位于主干中,因为它是共有组件且不写 stdout;前端入口拥有 stdout,因此留在组合包外。 +======= +- **前端入口与各应用基础设施**:终端 TUI 或 ACP 自动化传输,以及 `hmr`。应用包([`dsh-cli-demo`](../cli-demo/README.md)、[`dsh-acp-demo`](../acp-demo/README.md))拥有这些选择。`timer` 位于主干中,因为它是共有组件且不写 stdout;前端入口拥有 stdout,因此留在组合包外。 +>>>>>>> a1c6a2c3f (refactor(cli)!: one shared base config with per-surface overlays) 这把[接口/实现/消费方 seam](../../../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md) 提升到组合层:组合包拥有共享主干,叶节点拥有后端,应用包拥有前端入口。 diff --git a/packages/examples/tui-demo/README.md b/packages/examples/tui-demo/README.md deleted file mode 100644 index cbe8c79780..0000000000 --- a/packages/examples/tui-demo/README.md +++ /dev/null @@ -1,112 +0,0 @@ -# @deepseek-ai/dsh-tui-demo - -English | [中文](README.zh.md) - -The full-screen terminal app bundle: a Cordis plugin that composes [`@deepseek-ai/dsh-agent-spine-demo`](../agent-spine-demo/README.md), persisted same-session goals, the human-command registry and `/goal` producer, JSONL persistence, keyboard-backed user interaction, a pre-created `main` agent, and [`@deepseek-ai/dsh-tui`](../../ui/tui/README.md). A `cordis.yml` mounts it as one entry; the [`dsh`](../../../apps/cli/README.md) CLI is the front door that boots such a config. - -Use [`@deepseek-ai/dsh-cli-demo`](../cli-demo/README.md) for pipes, scripts, and other non-interactive runs. This bundle requires a TTY pair and has no line-oriented fallback. - -## What it bakes in - -| Plugin | Why it is here | -|---|---| -| `@deepseek-ai/dsh-agent-spine-demo` | Shared services, model-facing tools, and one configured `main` agent | -| `@deepseek-ai/dsh-commands` | Human-only discovery and dispatch consumed by the TUI and command plugins | -| `@deepseek-ai/dsh-command-goal` | Direct `/goal` status and mutation over the spine's persisted-goal stack | -| `@deepseek-ai/dsh-session-persistence-jsonl` | Durable session log under `persistenceRoot` | -| `@deepseek-ai/dsh-session-checkpoint-policy` | Semantic durability barriers before model requests and top-level tool effects, plus completed-step checkpoints | -| `@deepseek-ai/dsh-session-query-sqlite` + `@deepseek-ai/dsh-session-reference` | Combined exact/FTS session queries and bounded `@session` snapshots consumed by the TUI; model-facing query tools remain a leaf opt-in | -| `@deepseek-ai/dsh-user-interaction` | Provider-neutral human question service | -| `@deepseek-ai/dsh-tui` | Full-screen transcript, editor, tool cards, plan, and question overlays | -| `@deepseek-ai/dsh-tool-ask-user` | Model-facing `ask_user_question` tool | - -Swappable LLM, bash, filesystem, and other capability providers remain in the leaf config. `@cordisjs/plugin-hmr` also remains a leaf-only development entry because it requires Loader internals. - -## Config - -| Key | Default | Routed to | -|---|---|---| -| `provider` | required | Configured `main` agent provider | -| `model` | required | Configured `main` agent model | -| `maxParallelToolCalls` | agent-loop default | Bundled loop concurrency cap | -| `persona` | — | System-prompt persona template | -| `toolOrder` | lexicographic | Explicit model-facing tool order | -| `tools` | owner default | Tool presentation mode | -| `dshHome` | owner default | Harness home used by bash and skills | -| `sessionTitle` | spine example limits | Fallback title word/byte limits | -| `skills` | owner defaults | Skill registry, local provider, and tool config | -| `toolBash` | owner defaults | Model-facing bash tool config | -| `toolTasks` | owner defaults | Background-task control-tool config, or `false` | -| `goals` | owner defaults | Persisted goal-domain and model-tool config; `false` removes the goal stack and `/goal` producer | -| `workspaceContext` | required | Workspace-instruction config, or `false` | -| `persistenceRoot` | `./.sessions` (launcher boot slot overrides) | JSONL persistence root and parent of the derived `session-query.db` index | -| `persistenceCompression` | `'zstd'` | JSONL artifact encoding (`'zstd'` or raw `'none'`) | -| `sessionReferences` | service defaults | Cross-session candidate and snapshot limits routed to `dsh-session-reference` | -| `welcome` | `ready.` | TUI subtitle | -| `ui` | owner defaults | TUI presentation settings such as reasoning, color, and card height | - -Session identity is launcher-owned rather than configurable: a launcher provides `MAIN_SESSION_ID_KEY` on the boot context, and this app binds both the TUI and the configured agent to that id, loading persisted history only when the launcher also set `resume`. With no such slot the app mints a `main-session-` and creates it fresh. The TUI mounts before the spine so it can render a matching config-start failure instead of leaving a blank terminal. The app composes persistence and session query for `/resume`; a launcher may additionally provide `tuiResumeHost` for in-place process handoff and `TUI_GOODBYE_MESSAGE_KEY` for the line printed on exit. - -`persistenceRoot` defaults to project-local `./.sessions`: an app bundle must not assume the user's shared session store. A launcher that wants one store across every cwd states that policy through the `SESSIONS_ROOT_KEY` boot slot (`ctx.provide` before any Loader entry mounts) — the dsh CLI provides its Harness-home root there, so its `/resume` lists sessions from every workspace. Precedence is explicit config, then the launcher slot, then the project-local default. - -## Front door - -This package ships no bin. The [`dsh`](../../../apps/cli/README.md) CLI is the terminal front door: bare `dsh` boots the shipped `examples/tui-agent/cordis.yml` (which mounts this bundle), and `dsh --config ` boots an alternate leaf config that mounts it. It loads the optional cwd `.env`, drives the Cordis Loader, and waits for the full plugin tree. The repository installs Loader's optional native helper, so bare package specifiers resolve under plain Node. - -## Example leaf - -```yaml -- id: llm-deepseek - name: '@deepseek-ai/dsh-llm-deepseek' - config: - apiKey: !!js process.env.DEEPSEEK_API_KEY -- id: bash - name: '@deepseek-ai/dsh-bash-local' -- id: tui-agent - name: '@deepseek-ai/dsh-tui-demo' - config: - provider: deepseek - model: deepseek-v4-flash - workspaceContext: - maxBytes: 65536 - welcome: 'Coding agent ready.' - ui: - showReasoning: true -``` - -## Model Experience - -### Interactive terminal turn - -#### What the model sees - -Each non-empty non-command editor submission becomes a user message; a submission during a running turn becomes steering. Slash-command input and output remain human-only, while accepted `/goal` mutations append domain-owned model-visible state. The shared spine contributes the configured persona, workspace instructions, skill catalog, goal controls, and visible tool schemas. TUI rendering itself is not model-visible. - -#### Token effect - -User, assistant, and tool history grows under the normal session and compaction rules. Headers, cards, plans, Markdown styling, and keybindings add no tokens. - -#### KV Cache effect - -Append-only while the composed prompt, schemas, route, and retained history prefix remain stable. Composition changes and compaction can invalidate reuse from the first changed token. - -### Human-question answer - -#### What the model sees - -`ask_user_question` retains the tool call and the compact answer or stable interruption error defined by `dsh-tool-ask-user`. The question overlay is terminal-only. - -#### Token effect - -Only the completed or failed tool result adds retained tokens. - -#### KV Cache effect - -Append-only; the answer follows the reusable request prefix. - -## Known Limitations and Deferred Work - -- **TTY-only** — stdin and stdout must both be terminals; automation uses `dsh-cli-demo`. -- **One configured terminal session** — the transcript and editor bind to one exact session id. -- **The app cluster is fixed** — JSONL persistence and ask-user tooling are baked in; different policy requires another composition. -- **Approval is separate** — this app answers `ctx.userInteraction`, not `ctx.approval`; permission prompts require an approval service and answerer. diff --git a/packages/examples/tui-demo/package.json b/packages/examples/tui-demo/package.json deleted file mode 100644 index fc59453f0e..0000000000 --- a/packages/examples/tui-demo/package.json +++ /dev/null @@ -1,76 +0,0 @@ -{ - "name": "@deepseek-ai/dsh-tui-demo", - "description": "Full-screen TUI app bundle plugin: agent spine + persisted goals + human commands + JSONL persistence + pi-tui front door + pre-created main agent (mounted by the dsh CLI's config)", - "version": "0.0.1", - "private": true, - "type": "module", - "main": "lib/index.js", - "types": "lib/types/index.d.ts", - "exports": { - ".": { - "types": "./lib/types/index.d.ts", - "default": "./lib/index.js" - }, - "./invariant": { - "types": "./lib/types/invariant.d.ts", - "default": "./lib/invariant.js" - }, - "./src/*": "./src/*", - "./package.json": "./package.json" - }, - "files": [ - "lib/index.js", - "lib/invariant.js", - "lib/types/**/*.d.ts", - "lib/types/**/*.d.ts.map", - "src" - ], - "license": "BSD-3-Clause", - "peerDependencies": { - "@cordisjs/plugin-loader": "^1.0.0-rc.5", - "@deepseek-ai/dsh-agent": "^0.0.1", - "@deepseek-ai/dsh-agent-loop": "^0.0.1", - "@deepseek-ai/dsh-agent-spine-demo": "^0.0.1", - "@deepseek-ai/dsh-command-goal": "^0.0.1", - "@deepseek-ai/dsh-commands": "^0.0.1", - "@deepseek-ai/dsh-invariants": "^0.0.1", - "@deepseek-ai/dsh-llm": "^0.0.1", - "@deepseek-ai/dsh-session": "^0.0.1", - "@deepseek-ai/dsh-session-checkpoint-policy": "^0.0.1", - "@deepseek-ai/dsh-session-persistence-jsonl": "^0.0.1", - "@deepseek-ai/dsh-session-query": "^0.0.1", - "@deepseek-ai/dsh-session-query-sqlite": "^0.0.1", - "@deepseek-ai/dsh-session-reference": "^0.0.1", - "@deepseek-ai/dsh-tool-ask-user": "^0.0.1", - "@deepseek-ai/dsh-tools": "^0.0.1", - "@deepseek-ai/dsh-tui": "^0.0.1", - "@deepseek-ai/dsh-user-interaction": "^0.0.1", - "@deepseek-ai/dsh-workspace-context": "^0.0.1", - "cordis": "^4.0.0-rc.7", - "schemastery": "^3.17.0" - }, - "devDependencies": { - "@cordisjs/plugin-loader": "workspace:^", - "@deepseek-ai/dsh-agent": "workspace:^", - "@deepseek-ai/dsh-agent-loop": "workspace:^", - "@deepseek-ai/dsh-agent-spine-demo": "workspace:^", - "@deepseek-ai/dsh-command-goal": "workspace:^", - "@deepseek-ai/dsh-commands": "workspace:^", - "@deepseek-ai/dsh-invariants": "workspace:^", - "@deepseek-ai/dsh-llm": "workspace:^", - "@deepseek-ai/dsh-session": "workspace:^", - "@deepseek-ai/dsh-session-checkpoint-policy": "workspace:^", - "@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^", - "@deepseek-ai/dsh-session-query": "workspace:^", - "@deepseek-ai/dsh-session-query-sqlite": "workspace:^", - "@deepseek-ai/dsh-session-reference": "workspace:^", - "@deepseek-ai/dsh-system-prompt": "workspace:^", - "@deepseek-ai/dsh-tool-ask-user": "workspace:^", - "@deepseek-ai/dsh-tools": "workspace:^", - "@deepseek-ai/dsh-tui": "workspace:^", - "@deepseek-ai/dsh-user-interaction": "workspace:^", - "@deepseek-ai/dsh-workspace-context": "workspace:^", - "cordis": "^4.0.0-rc.7", - "schemastery": "^3.17.0" - } -} diff --git a/packages/examples/tui-demo/src/index.ts b/packages/examples/tui-demo/src/index.ts deleted file mode 100644 index df644090c8..0000000000 --- a/packages/examples/tui-demo/src/index.ts +++ /dev/null @@ -1,169 +0,0 @@ -/** - * Full-screen terminal app: the default agent spine ({@link @deepseek-ai/dsh-agent-spine-demo}) - * plus persisted goals, human commands, JSONL persistence, keyboard-backed - * user interaction, and one pre-created agent whose exact session identity the - * TUI drives. Swappable adapters, executors, optional tools, and HMR stay in the leaf. This Loader plugin - * intentionally exposes named exports only; a default export would hide its - * `Config` schema (see docs/postmortem/0001). - * @module @deepseek-ai/dsh-tui-demo - */ - -import type { Context } from 'cordis' -import { randomUUID } from 'node:crypto' -import { join } from 'node:path' -import z from 'schemastery' -import { SessionId } from '@deepseek-ai/dsh-session' -import ToolRegistry, { type Config as ToolsConfig } from '@deepseek-ai/dsh-tools' -import CommandService from '@deepseek-ai/dsh-commands' -import * as commandGoal from '@deepseek-ai/dsh-command-goal' -import * as agentCore from '@deepseek-ai/dsh-agent-spine-demo' -import * as workspaceContext from '@deepseek-ai/dsh-workspace-context' -import SessionPersistenceJsonl, { - JsonlCompressionSchema, - type JsonlCompression, -} from '@deepseek-ai/dsh-session-persistence-jsonl' -import * as sessionCheckpointPolicy from '@deepseek-ai/dsh-session-checkpoint-policy' -import UserInteractionService from '@deepseek-ai/dsh-user-interaction' -import SessionQuerySqlite from '@deepseek-ai/dsh-session-query-sqlite' -import SessionReferenceService, { type Config as SessionReferenceConfig } from '@deepseek-ai/dsh-session-reference' -import * as toolAskUser from '@deepseek-ai/dsh-tool-ask-user' -import * as uiTui from '@deepseek-ai/dsh-tui' - -export const name = 'tui-demo' - -// The bundle's own fallback stays project-local: a plugin must never assume -// the user's shared session store. The dsh launcher's SESSIONS_ROOT_KEY slot -// (opaque here — the CLI resolves it to DSH_HOME/sessions) carries any -// shared-store policy, and explicit config wins over both. -const DEFAULT_PERSISTENCE_ROOT = './.sessions' - -// Each front door keeps a complete Loader contract so its deployment config is -// readable without a cross-package facade. -/* jscpd:ignore-start */ -/** App config routed to the spine, TUI, configured agent, and JSONL backend. */ -export interface Config { - /** Provider route for the `main` agent. */ - provider: string - /** Model name for the `main` agent; a matching adapter must be registered. */ - model: string - /** Bundled agent-loop concurrency cap; `1` is serial and omission uses its default. */ - maxParallelToolCalls?: number - /** Deployment persona forwarded to the system-prompt plugin. */ - persona?: string - /** Explicit model-facing tool order forwarded to the system-prompt plugin. */ - toolOrder?: string[] - /** Tool-registry presentation config forwarded through agent-spine-demo. */ - tools?: ToolsConfig - /** DeepSeek Harness home directory exposed to bash and used for local skill discovery. */ - dshHome?: string - /** Fallback session-title limits forwarded through agent-spine-demo. */ - sessionTitle?: NonNullable - /** - * Directory for JSONL sessions and the derived query index. Precedence: - * this explicit config, then the launcher's opaque `SESSIONS_ROOT_KEY` boot - * slot (the dsh CLI resolves it to `DSH_HOME/sessions`), then a project-local - * `./.sessions` fallback — the bundle itself never assumes a global store. - */ - persistenceRoot?: string - /** JSONL artifact encoding; defaults to checksummed Zstandard frames. */ - persistenceCompression?: JsonlCompression - /** Cross-session reference discovery and snapshot byte budgets. */ - sessionReferences?: SessionReferenceConfig - /** TUI transcript's optional first line; absent renders nothing on start. */ - welcome?: string - /** Full-screen TUI presentation settings. */ - ui?: uiTui.TuiConfig - /** Skill registry, local-provider, and model-facing consumer config. */ - skills?: agentCore.SkillConfig - /** Model-facing bash tool config forwarded through agent-spine-demo. */ - toolBash?: NonNullable - /** Generic background-task controls forwarded through agent-spine-demo; set false to omit them. */ - toolTasks?: NonNullable - /** Persisted same-session goals; owner defaults enable them, or false disables the stack and command. */ - goals?: agentCore.GoalConfig | false - /** Controls automatic AGENTS.md/CLAUDE.md loading; configure a byte budget or set `false`. */ - workspaceContext: agentCore.Config['workspaceContext'] -} - -export const Config: z = z.object({ - provider: z.string().required(), - model: z.string().required(), - maxParallelToolCalls: z.number().step(1).min(1), - persona: z.string(), - // Absent means lexicographic order; schemastery's native array default is []. - toolOrder: z.array(z.string()).default(undefined as unknown as string[]), - tools: ToolRegistry.Config, - dshHome: z.string(), - sessionTitle: agentCore.SessionTitleConfigSchema, - // No schema default: schemastery would materialize it before composeTuiApp - // runs, shadowing the launcher's SESSIONS_ROOT_KEY slot for a Loader mount. - persistenceRoot: z.string(), - persistenceCompression: JsonlCompressionSchema, - sessionReferences: SessionReferenceService.Config, - welcome: z.string(), - ui: uiTui.TuiConfigSchema, - skills: agentCore.SkillConfigSchema, - toolBash: agentCore.ToolBashConfigSchema, - toolTasks: z.union([z.const(false), agentCore.ToolTasksConfigSchema]), - goals: z.union([z.const(false), agentCore.GoalConfigSchema]), - workspaceContext: z.union([z.const(false), workspaceContext.Config]).required(), -}) -/* jscpd:ignore-end */ - -/** - * Compose the spine, TUI, JSONL persistence, and user-question tool around one - * exact fresh or resumed session identity, taken from the launcher's - * {@link uiTui.MAIN_SESSION_ID_KEY} slot. The TUI subscribes to startup failures - * before the spine creates the agent. - * @param ctx - context receiving the app's child plugins. - * @param config - validated app configuration. - */ -export function composeTuiApp(ctx: Context, config: Config): void { - // The launcher, not the deployment config, owns `main`'s session identity: it - // reaches a Loader-mounted bundle only through this context slot. A launcher - // that supplies an id knows whether that session already exists, so it also - // states whether to load persisted history. No launcher means mint one here. - const identity = ctx.get(uiTui.MAIN_SESSION_ID_KEY) - const sessionId = SessionId(identity?.id ?? `main-session-${randomUUID()}`) - const goals = config.goals ?? {} - const persistenceRoot = config.persistenceRoot ?? ctx.get(uiTui.SESSIONS_ROOT_KEY) ?? DEFAULT_PERSISTENCE_ROOT - ctx.plugin(CommandService) - if (goals !== false) ctx.plugin(commandGoal) - ctx.plugin(SessionPersistenceJsonl, { - root: persistenceRoot, - ...(config.persistenceCompression === undefined ? {} : { compression: config.persistenceCompression }), - }) - ctx.plugin(sessionCheckpointPolicy) - ctx.plugin(SessionQuerySqlite, { path: join(persistenceRoot, 'session-query.db') }) - ctx.plugin(SessionReferenceService, config.sessionReferences ?? {}) - ctx.plugin(UserInteractionService) - ctx.plugin(uiTui.TuiPromptService) - ctx.plugin(uiTui, { - ...config.ui, - ...config.welcome === undefined ? {} : { welcome: config.welcome }, - sessionId, - }) - ctx.plugin(agentCore, { - ...agentCore.pickSpineConfig(config), - goals, - agents: [{ - id: SessionId('main'), - provider: config.provider, - model: config.model, - cwd: process.cwd(), - // `resumeSessionId` requires existing persisted history and rejects a - // missing log, so only a launcher that asked to resume takes that path. - ...identity?.resume === true ? { resumeSessionId: sessionId } : { sessionId }, - }], - }) - ctx.plugin(toolAskUser) -} - -/** - * Compose the configured full-screen terminal app. - * @param ctx - context receiving the app's child plugins. - * @param config - validated app configuration. - */ -export function apply(ctx: Context, config: Config): void { - composeTuiApp(ctx, config) -} diff --git a/packages/examples/tui-demo/src/invariant.ts b/packages/examples/tui-demo/src/invariant.ts deleted file mode 100644 index 1bb55546bf..0000000000 --- a/packages/examples/tui-demo/src/invariant.ts +++ /dev/null @@ -1,30 +0,0 @@ -/** - * Package-owned invariant companion for `@deepseek-ai/dsh-tui-demo`. - * @module @deepseek-ai/dsh-tui-demo/invariant - */ - -/* jscpd:ignore-start */ -import type { Context } from 'cordis' -import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' - -const PACKAGE_NAME = '@deepseek-ai/dsh-tui-demo' - -/** Cordis companion plugin name. */ -export const name = 'tui-demo-invariant' -/** Service required before the companion can reserve package ownership. */ -export const inject = ['invariants'] - -/** - * No runtime invariant: this composition-only package delegates mutable state and event streams - * to the agent spine, persistence, and TUI packages that own their checks. - */ -const install: InvariantInstaller = () => {} - -/** - * Register this package's invariant companion. - * @param ctx - Cordis context carrying the invariant service. - * @returns the installed registration's disposer after setup succeeds. - */ -export const apply = (ctx: Context): Promise<() => void> => - Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) -/* jscpd:ignore-end */ diff --git a/packages/examples/tui-demo/tests/tui-agent.spec.ts b/packages/examples/tui-demo/tests/tui-agent.spec.ts deleted file mode 100644 index 88519e33a1..0000000000 --- a/packages/examples/tui-demo/tests/tui-agent.spec.ts +++ /dev/null @@ -1,203 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { join } from 'node:path' -import { Context } from 'cordis' -import Loader from '@cordisjs/plugin-loader' -import { TOOL_ORDER_REST } from '@deepseek-ai/dsh-system-prompt' -import { SessionId } from '@deepseek-ai/dsh-session' -import { MAIN_SESSION_ID_KEY, SESSIONS_ROOT_KEY, type MainSessionIdentity } from '@deepseek-ai/dsh-tui' -import * as tuiAgent from '../src/index.ts' - -interface PluginCall { - readonly name: string - readonly config: unknown -} - -/** - * Record the composed plugin tree. `identity` stands in for the launcher-owned - * {@link MAIN_SESSION_ID_KEY} slot; omitting it means no launcher chose a session. - */ -function recordingContext( - identity?: MainSessionIdentity, - sessionsRoot?: string, -): { readonly ctx: Context; readonly calls: PluginCall[] } { - const calls: PluginCall[] = [] - const ctx = { - plugin(plugin: { name?: string }, config?: unknown) { - calls.push({ name: plugin.name ?? '', config }) - }, - get: (key: string) => key === MAIN_SESSION_ID_KEY ? identity - : key === SESSIONS_ROOT_KEY ? sessionsRoot : undefined, - } as unknown as Context - return { ctx, calls } -} - -describe('dsh-tui-demo app', () => { - it('composes the TUI cluster around one fresh exact session identity', () => { - const { ctx, calls } = recordingContext() - tuiAgent.composeTuiApp(ctx, { - provider: 'mock', - model: 'mock-model', - maxParallelToolCalls: 3, - persona: 'test persona', - toolOrder: ['zulu', TOOL_ORDER_REST], - tools: { mode: 'code' }, - dshHome: '/tmp/dsh-home', - persistenceRoot: '/tmp/tui-sessions', - persistenceCompression: 'none', - sessionReferences: { - maxReferences: 2, - candidateLimit: 7, - maxReferenceBytes: 1234, - }, - welcome: 'TUI ready', - ui: { theme: { color: false }, maxToolOutputLines: 3 }, - skills: { tool: { catalogDescriptionMaxLength: 8 } }, - toolBash: { enableRunInBackground: false }, - toolTasks: { waitTimeoutMs: 7, maxWaitTimeoutMs: 11 }, - workspaceContext: false, - }) - - expect(calls.map(call => call.name)).toEqual([ - 'CommandService', - 'command-goal', - 'SessionPersistenceJsonl', - 'session-checkpoint-policy', - 'SessionQuerySqlite', - 'SessionReferenceService', - 'UserInteractionService', - 'TuiPromptService', - 'ui-tui', - 'agent-spine-demo', - 'tool-ask-user', - ]) - expect(calls[0]?.config).toBeUndefined() - expect(calls[2]?.config).toEqual({ root: '/tmp/tui-sessions', compression: 'none' }) - expect(calls[4]?.config).toEqual({ path: join('/tmp/tui-sessions', 'session-query.db') }) - expect(calls[5]?.config).toEqual({ - maxReferences: 2, - candidateLimit: 7, - maxReferenceBytes: 1234, - }) - const tuiConfig = calls[8]?.config as { sessionId: string } - expect(tuiConfig).toMatchObject({ - welcome: 'TUI ready', - theme: { color: false }, - maxToolOutputLines: 3, - }) - expect(tuiConfig.sessionId).toMatch(/^main-session-[0-9a-f-]{36}$/) - const spineConfig = calls[9]?.config as { - readonly agents: Array> - readonly goals: Record - readonly maxParallelToolCalls: number - readonly persona: string - readonly toolOrder: string[] - readonly tools: { mode: string } - } - expect(spineConfig).toMatchObject({ - maxParallelToolCalls: 3, - persona: 'test persona', - toolOrder: ['zulu', TOOL_ORDER_REST], - tools: { mode: 'code' }, - goals: {}, - }) - expect(spineConfig.agents[0]).toMatchObject({ - id: 'main', - provider: 'mock', - model: 'mock-model', - cwd: process.cwd(), - sessionId: tuiConfig.sessionId, - }) - }) - - it('uses the launcher sessions-root slot through schema-normalized config', () => { - // The Loader normalizes config through the schemastery Config BEFORE apply - // runs. A schema .default() on persistenceRoot would materialize here and - // permanently shadow the launcher slot — the regression this test pins. - const normalized = tuiAgent.Config({ - provider: 'mock', - model: 'mock-model', - workspaceContext: false, - } as never) - expect(normalized.persistenceRoot).toBeUndefined() - - const { ctx, calls } = recordingContext(undefined, '/launcher/sessions') - tuiAgent.composeTuiApp(ctx, normalized) - expect(calls[2]?.config).toMatchObject({ root: '/launcher/sessions' }) - expect(calls[4]?.config).toEqual({ path: join('/launcher/sessions', 'session-query.db') }) - }) - - it('lets an explicit persistenceRoot win over the launcher slot', () => { - const { ctx, calls } = recordingContext(undefined, '/launcher/sessions') - tuiAgent.composeTuiApp(ctx, { - provider: 'mock', - model: 'mock-model', - persistenceRoot: '/explicit/root', - workspaceContext: false, - }) - expect(calls[2]?.config).toEqual({ root: '/explicit/root' }) - }) - - it('loads persisted history for a launcher-selected resume identity', () => { - // The bundle default stays project-local: shared-store policy is the - // launcher's, which patches `persistenceRoot` itself (the dsh CLI does). - const { ctx, calls } = recordingContext({ id: SessionId('persisted-session'), resume: true }) - tuiAgent.composeTuiApp(ctx, { - provider: 'mock', - model: 'mock-model', - workspaceContext: false, - }) - - expect(calls[2]?.config).toEqual({ root: './.sessions' }) - expect(calls[4]?.config).toEqual({ path: join('./.sessions', 'session-query.db') }) - expect(calls[5]?.config).toEqual({}) - // No configured welcome forwards none: the TUI banner sweeps in without a subtitle. - expect(calls[8]?.config).toEqual({ sessionId: 'persisted-session' }) - expect((calls[9]?.config as { agents: Array> }).agents[0]).toMatchObject({ - id: 'main', - resumeSessionId: 'persisted-session', - }) - }) - - it('creates a launcher-minted identity fresh rather than loading history', () => { - const { ctx, calls } = recordingContext({ id: SessionId('minted-session'), resume: false }) - tuiAgent.composeTuiApp(ctx, { - provider: 'mock', - model: 'mock-model', - workspaceContext: false, - }) - - expect(calls[8]?.config).toEqual({ sessionId: 'minted-session' }) - expect((calls[9]?.config as { agents: Array> }).agents[0]) - .toMatchObject({ id: 'main', sessionId: 'minted-session' }) - }) - - it('mints a fresh session with no launcher slot and routes apply through the same composition', () => { - const { ctx, calls } = recordingContext() - tuiAgent.apply(ctx, { - provider: 'mock', - model: 'mock-model', - goals: false, - workspaceContext: false, - }) - - const tuiConfig = calls[7]?.config as { sessionId: string } - expect(tuiConfig.sessionId).toMatch(/^main-session-[0-9a-f-]{36}$/) - expect((calls[8]?.config as { agents: Array> }).agents[0]) - .toMatchObject({ sessionId: tuiConfig.sessionId }) - expect(calls.map(call => call.name)).not.toContain('command-goal') - expect(calls[8]?.config).toMatchObject({ goals: false }) - }) - - it('has the namespace-plugin export shape so the Loader keeps its schema', () => { - expect(tuiAgent.name).toBe('tui-demo') - expect(tuiAgent.Config).toBeDefined() - expect('default' in tuiAgent).toBe(false) - expect(typeof tuiAgent.apply).toBe('function') - - const loader = Object.create(Loader.prototype) as Loader - const unwrapped = loader.unwrapExports(tuiAgent) as Record - expect(unwrapped).toBe(tuiAgent) - expect(unwrapped.name).toBe('tui-demo') - expect(unwrapped.Config).toBeDefined() - }) -}) diff --git a/packages/examples/tui-demo/tsconfig.json b/packages/examples/tui-demo/tsconfig.json deleted file mode 100644 index 0ddf3b411c..0000000000 --- a/packages/examples/tui-demo/tsconfig.json +++ /dev/null @@ -1,66 +0,0 @@ -{ - "extends": "../../../tsconfig.base.json", - "compilerOptions": { - "rootDir": "src", - "outDir": "lib/types" - }, - "include": [ - "src" - ], - "references": [ - { - "path": "../../../vendor/cordis" - }, - { - "path": "../../../vendor/schemastery" - }, - { - "path": "../../core/agent" - }, - { - "path": "../../core/session" - }, - { - "path": "../../util/paths" - }, - { - "path": "../../session-query/session-query" - }, - { - "path": "../../session-query/session-query-sqlite" - }, - { - "path": "../../context/session-reference" - }, - { - "path": "../../ui/commands" - }, - { - "path": "../../goal/command-goal" - }, - { - "path": "../agent-spine-demo" - }, - { - "path": "../../context/workspace-context" - }, - { - "path": "../../ui/user-interaction" - }, - { - "path": "../../ui/tui" - }, - { - "path": "../../ui/tool-ask-user" - }, - { - "path": "../../session-persistence/session-checkpoint-policy" - }, - { - "path": "../../session-persistence/session-persistence-jsonl" - }, - { - "path": "../../support/invariants" - } - ] -} diff --git a/packages/examples/tui-demo/tsdown.config.ts b/packages/examples/tui-demo/tsdown.config.ts deleted file mode 100644 index 1033dc08df..0000000000 --- a/packages/examples/tui-demo/tsdown.config.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { defineConfig } from 'tsdown' - -/** - * tui-demo ships the plugin (`index`) and its invariant companion; the CLI - * front door is `dsh` (apps/cli), which mounts this bundle through its config. - * The root tsdown builds only `lib/types/index.js`, so this override adds the - * invariant entry. Declarations come from `tsc -b` (dts: false), matching - * every package. - */ -export default defineConfig({ - entry: ['lib/types/index.js', 'lib/types/invariant.js'], - outDir: 'lib', - format: ['esm'], - platform: 'node', - target: 'es2024', - fixedExtension: false, - dts: false, - clean: false, -}) diff --git a/packages/guard/source-guard/README.i18n.yaml b/packages/guard/source-guard/README.i18n.yaml index 801d0979b6..79f2f9f423 100644 --- a/packages/guard/source-guard/README.i18n.yaml +++ b/packages/guard/source-guard/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/guard/source-guard/README.md -README.md: f083ef0af53c4d4f7c4a0875837ac3a3c851c54c -README.zh.md: 7c9efa7b8d3aec1a95b62416ef624a887fd48aa0 +README.md: a7406d71c6591f78d12b6c02ec22bc4b0b3d517f +README.zh.md: 916d0513b77e17989730fdf27ef50d21013f4e58 diff --git a/packages/guard/source-guard/README.md b/packages/guard/source-guard/README.md index f083ef0af5..a7406d71c6 100644 --- a/packages/guard/source-guard/README.md +++ b/packages/guard/source-guard/README.md @@ -19,7 +19,7 @@ Every field fails loud at plugin load: an empty `tools` list, a blank `requiredS `protectedCheckout` names a path inside the checkout to guard, and its worktree supplies BOTH protected identities: the repository and the exact branch. Its default is this module's own file, which resolves the checkout the running harness was launched from — the live deployment, whatever its branch is named. Nothing about the branch is configured or pattern-matched, so a maintainer whose staging branch follows no naming convention is protected identically. A harness running from an installed copy resolves a different repository, or none, and therefore guards nothing; the rule is meaningless outside a source checkout. -The shipped TUI composition (`examples/tui-agent/cordis.yml`) loads this plugin with defaults. It is inert for anyone whose workspace is not the launcher's own checkout, so an ordinary project sees no change. +The shipped TUI composition (`apps/cli/base.cordis.yml`) loads this plugin with defaults. It is inert for anyone whose workspace is not the launcher's own checkout, so an ordinary project sees no change. ## Which paths are protected diff --git a/packages/guard/source-guard/README.zh.md b/packages/guard/source-guard/README.zh.md index 7c9efa7b8d..916d0513b7 100644 --- a/packages/guard/source-guard/README.zh.md +++ b/packages/guard/source-guard/README.zh.md @@ -19,7 +19,7 @@ `protectedCheckout` 指定位于待保护检出目录内的一条路径;其 worktree 会提供两项受保护身份:仓库和确切分支。其默认值是本模块自己的文件,由此解析出运行中 harness 启动来源的检出目录——当前运行的部署,无论其分支采用什么名称。分支既无需配置,也不会通过模式匹配,因此 staging 分支不遵循任何命名约定的维护者同样会受到保护。若 harness 从已安装副本运行,则会解析到另一个仓库,或根本解析不到仓库,因此不会保护任何内容;这条规则在源码检出目录之外没有意义。 -已交付的 TUI 组合(`examples/tui-agent/cordis.yml`)会以默认配置加载本插件。若用户的工作区并非启动器自身所在的检出目录,本插件不会生效,因此普通项目不会发生任何变化。 +已交付的 TUI 组合(`apps/cli/base.cordis.yml`)会以默认配置加载本插件。若用户的工作区并非启动器自身所在的检出目录,本插件不会生效,因此普通项目不会发生任何变化。 ## 受保护的路径 diff --git a/packages/host/apiproxy/README.i18n.yaml b/packages/host/apiproxy/README.i18n.yaml index 73b0845370..8375afe22d 100644 --- a/packages/host/apiproxy/README.i18n.yaml +++ b/packages/host/apiproxy/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/host/apiproxy/README.md -README.md: ca4471454f5be5d3fcba38ce665d4fb3fbd85e74 -README.zh.md: 953539e1198a52b2bf7cdd9ca1b0d263cc2ae6f9 +README.md: c9325d7f0a0feacbc1198d0b737c744ee2075472 +README.zh.md: 1d755bbc2852d6bebed5bd738c19c793657d0afa diff --git a/packages/host/apiproxy/README.md b/packages/host/apiproxy/README.md index ca4471454f..0b085e9cb8 100644 --- a/packages/host/apiproxy/README.md +++ b/packages/host/apiproxy/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -The API gateway every client shape shares: the TS contract (`src/api/`, zero Node dependencies, importable from the browser), the fetch carrier pair (`src/fetch/`: `toFetchHandler` on the host side, `AbstractApiClient` plus platform subclasses on the client side), and the host-side implementation (`src/api-proxy.ts`: `createApiProxy` plus the default-exported `ApiProxyService` gateway plugin — config `{provider, model, workspaceRoot?}`, provides `ctx.apiProxy`). Transport-agnostic by design: this package registers no routes; carriers (HTTP today, IPC later) wrap `ctx.apiProxy` themselves. The shipped core composition lives in [`apps/cli/cordis.yml`](../../../apps/cli/cordis.yml). +The API gateway every client shape shares: the TS contract (`src/api/`, zero Node dependencies, importable from the browser), the fetch carrier pair (`src/fetch/`: `toFetchHandler` on the host side, `AbstractApiClient` plus platform subclasses on the client side), and the host-side implementation (`src/api-proxy.ts`: `createApiProxy` plus the default-exported `ApiProxyService` gateway plugin — config `{provider, model, workspaceRoot?}`, provides `ctx.apiProxy`). Transport-agnostic by design: this package registers no routes; carriers (HTTP today, IPC later) wrap `ctx.apiProxy` themselves. The shipped core composition lives in [`apps/cli/base.cordis.yml`](../../../apps/cli/base.cordis.yml). ## Contract layer (`/api`) diff --git a/packages/host/apiproxy/README.zh.md b/packages/host/apiproxy/README.zh.md index 953539e119..6646945b79 100644 --- a/packages/host/apiproxy/README.zh.md +++ b/packages/host/apiproxy/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -所有客户端形态共用的 API 网关:TS 契约(`src/api/`,不依赖 Node,可从浏览器导入)、fetch 载体对(`src/fetch/`:宿主侧的 `toFetchHandler`,以及客户端侧的 `AbstractApiClient` 与平台子类)和宿主侧实现(`src/api-proxy.ts`:`createApiProxy` 加上默认导出的 `ApiProxyService` 网关插件,其配置为 `{provider, model, workspaceRoot?}`,提供 `ctx.apiProxy`)。该包(package)在设计上与传输方式无关,不注册任何路由;载体(目前为 HTTP,未来可以是 IPC)自行包装 `ctx.apiProxy`。已发布的核心组合位于 [`apps/cli/cordis.yml`](../../../apps/cli/cordis.yml)。 +所有客户端形态共用的 API 网关:TS 契约(`src/api/`,不依赖 Node,可从浏览器导入)、fetch 载体对(`src/fetch/`:宿主侧的 `toFetchHandler`,以及客户端侧的 `AbstractApiClient` 与平台子类)和宿主侧实现(`src/api-proxy.ts`:`createApiProxy` 加上默认导出的 `ApiProxyService` 网关插件,其配置为 `{provider, model, workspaceRoot?}`,提供 `ctx.apiProxy`)。该包(package)在设计上与传输方式无关,不注册任何路由;载体(目前为 HTTP,未来可以是 IPC)自行包装 `ctx.apiProxy`。已发布的核心组合位于 [`apps/cli/base.cordis.yml`](../../../apps/cli/base.cordis.yml)。 ## 契约层(`/api`) diff --git a/packages/todo/README.i18n.yaml b/packages/todo/README.i18n.yaml index a7b1f6f529..e726755dcf 100644 --- a/packages/todo/README.i18n.yaml +++ b/packages/todo/README.i18n.yaml @@ -2,5 +2,10 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/todo/README.md +<<<<<<< HEAD README.md: 1e5ae9a1583b9e9d3913fcd1dca7ef11a5f391fe README.zh.md: a77f788a41353ea547059864dc7cf73ac5025219 +======= +README.md: 495851a13f70bc8e3cb2dc99da47ab305ca205fa +README.zh.md: abdac592a312cb36d145e7d16e01821561090dcb +>>>>>>> a1c6a2c3f (refactor(cli)!: one shared base config with per-surface overlays) diff --git a/packages/todo/README.md b/packages/todo/README.md index 1e5ae9a158..495851a13f 100644 --- a/packages/todo/README.md +++ b/packages/todo/README.md @@ -8,4 +8,4 @@ The model-facing todo tool. A single **product** package — there is no interfa |---|---|---| | `tool-todo/` | Model-facing `todo_write` tool; writes the whole list to the session log (`todo/write`) | (registers on `ctx.tools`) | -The list lives on the event-sourced session log (`SessionEventMap['todo/write']`, owned by [`dsh-session`](../core/session)); this package is the thin consumer that appends the snapshot. UIs such as the [TUI app](../examples/tui-demo) and the host/client runtime render the durable list from session events. +The list lives on the event-sourced session log (`SessionEventMap['todo/write']`, owned by [`dsh-session`](../core/session)); this package is the thin consumer that appends the snapshot. UIs such as the [TUI front door](../ui/tui) and the host/client runtime render the durable list from session events. diff --git a/packages/todo/README.zh.md b/packages/todo/README.zh.md index a77f788a41..d8d8ffc4db 100644 --- a/packages/todo/README.zh.md +++ b/packages/todo/README.zh.md @@ -8,4 +8,8 @@ |---|---|---| | `tool-todo/` | 面向模型的 `todo_write` 工具;将完整列表写入会话日志(`todo/write`) | (注册到 `ctx.tools`) | +<<<<<<< HEAD 列表存在于事件溯源会话日志中(`SessionEventMap['todo/write']`,由 [`dsh-session`](../core/session) 拥有);本包是追加快照的轻量消费方。[TUI 应用](../examples/tui-demo)等 UI 以及宿主/客户端运行时会根据会话事件渲染该持久化列表。 +======= +列表存在于事件溯源会话日志中(`SessionEventMap['todo/write']`,由 [`dsh-session`](../core/session) 拥有);本包是追加快照的轻量消费方。[TUI 前端入口](../ui/tui)等 UI 以及宿主/客户端运行时会根据会话事件渲染该持久列表。 +>>>>>>> a1c6a2c3f (refactor(cli)!: one shared base config with per-surface overlays) diff --git a/packages/todo/tool-todo/README.i18n.yaml b/packages/todo/tool-todo/README.i18n.yaml index 603d2cfef0..8b1ac9bcc1 100644 --- a/packages/todo/tool-todo/README.i18n.yaml +++ b/packages/todo/tool-todo/README.i18n.yaml @@ -2,5 +2,10 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/todo/tool-todo/README.md +<<<<<<< HEAD README.md: b05ef43e7137dcf5678b1f1ad6d8c00b8a43baef README.zh.md: 88a9f5d69ff52dcedbd8f27a4ace5fde3e0dc5a2 +======= +README.md: f86a0d7331cc822a6568eeb14b89a64abaa23378 +README.zh.md: 778c920198599546890c90d21badf2d7bb0f2551 +>>>>>>> a1c6a2c3f (refactor(cli)!: one shared base config with per-surface overlays) diff --git a/packages/todo/tool-todo/README.md b/packages/todo/tool-todo/README.md index b05ef43e71..f86a0d7331 100644 --- a/packages/todo/tool-todo/README.md +++ b/packages/todo/tool-todo/README.md @@ -20,7 +20,7 @@ Beyond the schema's type/required/enum checks, `execute` rejects an empty or dup ## Rendering -The canonical result is `{ todos, counts: { pending, inProgress, completed } }`; its Native renderer returns the compact update acknowledgement. The tool also writes the full `todo/write` session event. UIs subscribe to the event stream and render that durable list themselves: the [TUI app](../../examples/tui-demo) and the [web client](../../client/ui-conversation) show a plan strip (plus a dedicated web tool row) off the standing plan — latest `todo/write` with no later `turn/start` ([display](../../../.agents/notes/implemented/feature/2026-07-23-web-todo-display.md), [lifetime](../../../.agents/notes/implemented/feature/2026-07-28-todo-plan-clears-on-next-turn.md)). +The canonical result is `{ todos, counts: { pending, inProgress, completed } }`; its Native renderer returns the compact update acknowledgement. The tool also writes the full `todo/write` session event. UIs subscribe to the event stream and render that durable list themselves: the [TUI front door](../../ui/tui) and the [web client](../../client/ui-conversation) show a plan strip (plus a dedicated web tool row) off the standing plan — latest `todo/write` with no later `turn/start` ([display](../../../.agents/notes/implemented/feature/2026-07-23-web-todo-display.md), [lifetime](../../../.agents/notes/implemented/feature/2026-07-28-todo-plan-clears-on-next-turn.md)). ## Session projection diff --git a/packages/todo/tool-todo/README.zh.md b/packages/todo/tool-todo/README.zh.md index 88a9f5d69f..26328dde33 100644 --- a/packages/todo/tool-todo/README.zh.md +++ b/packages/todo/tool-todo/README.zh.md @@ -20,7 +20,11 @@ ## 渲染 +<<<<<<< HEAD 规范结果为 `{ todos, counts: { pending, inProgress, completed } }`;其 Native 渲染器返回精简的更新确认。工具还会写入完整 `todo/write` 会话事件。UI 订阅事件流,并自行渲染该持久化列表:[TUI 应用](../../examples/tui-demo)与 [web 客户端](../../client/ui-conversation)基于当前有效计划(其后没有更晚 `turn/start` 的最近一次 `todo/write`)显示计划条(web 另有专属工具行)([展示](../../../.agents/notes/implemented/feature/2026-07-23-web-todo-display.md)、[生命周期](../../../.agents/notes/implemented/feature/2026-07-28-todo-plan-clears-on-next-turn.md))。 +======= +规范结果为 `{ todos, counts: { pending, inProgress, completed } }`;其 Native 渲染器返回精简的更新确认。工具还会写入完整 `todo/write` 会话事件。UI 订阅事件流,并自行渲染该持久列表:[TUI 前端入口](../../ui/tui)与 [web 客户端](../../client/ui-conversation)基于站立计划(其后没有更晚 `turn/start` 的最近一次 `todo/write`)显示计划条(web 另有专属工具行)([展示](../../../.agents/notes/implemented/feature/2026-07-23-web-todo-display.md)、[生命周期](../../../.agents/notes/implemented/feature/2026-07-28-todo-plan-clears-on-next-turn.md))。 +>>>>>>> a1c6a2c3f (refactor(cli)!: one shared base config with per-surface overlays) ## 会话投影 diff --git a/packages/ui/README.i18n.yaml b/packages/ui/README.i18n.yaml index 7a6175f898..06460978a8 100644 --- a/packages/ui/README.i18n.yaml +++ b/packages/ui/README.i18n.yaml @@ -2,5 +2,10 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/ui/README.md +<<<<<<< HEAD README.md: f08157d411a018141cdc21c487f81ae198f4de56 README.zh.md: ed4fbf576224a61e680fca337ac5e60829f8a90e +======= +README.md: 307ae14709b59e5c233aa930f20e30c87cdbab69 +README.zh.md: f02f9d902d0b61ad0299fb3fb24b97cf99c56b21 +>>>>>>> a1c6a2c3f (refactor(cli)!: one shared base config with per-surface overlays) diff --git a/packages/ui/README.md b/packages/ui/README.md index f08157d411..307ae14709 100644 --- a/packages/ui/README.md +++ b/packages/ui/README.md @@ -19,4 +19,4 @@ A UI integration is a client-driver plugin, not a loop change: it consumes the e `user-approval`, `user-interaction`, and `tool-ask-user` live here because asking a human is a UI-backed product affordance, not part of the providerless core spine. `user-approval` owns the one-shot `ctx.approval` decision mechanism and its policy tier; answerers remain with the channel or automation transport that owns the agent. `user-interaction` remains provider-neutral (`ctx.userInteraction`), while `tool-ask-user` is its model-facing consumer and interactive app packages provide concrete providers. -The runnable app bundles composed over [`agent-spine-demo`](../examples/agent-spine-demo/README.md) live in [`examples/`](../examples/README.md) (`tui-demo`, `acp-demo`, `jsonrpc-demo`). `acp-demo` and `jsonrpc-demo` own boot bins; the `tui-demo` bundle is booted by the product [`dsh`](../../apps/cli/README.md) CLI. `ui/` keeps the reusable human/SDK channel plugins and shared `app-boot` glue; the automation-only ACP transport lives in [`acp/`](../acp/README.md). Each front door owns its stdout policy, and a leaf `cordis.yml` supplies backends and optional tools. +The runnable app bundles composed over [`agent-spine-demo`](../examples/agent-spine-demo/README.md) live in [`examples/`](../examples/README.md) (`cli-demo`, `acp-demo`, `jsonrpc-demo`). `acp-demo` and `jsonrpc-demo` own boot bins; The product [`dsh`](../../apps/cli/README.md) CLI uses no bundle: it boots the flat config trees in `apps/cli`. `ui/` keeps the reusable human/SDK channel plugins and shared `app-boot` glue; the automation-only ACP transport lives in [`acp/`](../acp/README.md). Each front door owns its stdout policy, and a leaf `cordis.yml` supplies backends and optional tools. diff --git a/packages/ui/README.zh.md b/packages/ui/README.zh.md index ed4fbf5762..9ef39eee8d 100644 --- a/packages/ui/README.zh.md +++ b/packages/ui/README.zh.md @@ -19,4 +19,8 @@ UI 集成属于由客户端驱动的插件,而非对循环的修改:它使 `user-approval`、`user-interaction` 和 `tool-ask-user` 位于此处,因为向用户提问是由 UI 支持的产品功能,并不属于无提供方的核心主干。`user-approval` 负责一次性的 `ctx.approval` 决策机制及其策略层级;应答逻辑仍由负责 agent(智能体)的通道或自动化传输层提供。`user-interaction` 保持提供方无关(`ctx.userInteraction`),`tool-ask-user` 是其模型侧消费方,而交互式 app 包提供具体的提供方。 +<<<<<<< HEAD 基于 [`agent-spine-demo`](../examples/agent-spine-demo/README.md) 组合的可运行 app bundle 位于 [`examples/`](../examples/README.md)(`tui-demo`、`acp-demo`、`jsonrpc-demo`)。`acp-demo` 和 `jsonrpc-demo` 各自提供启动 bin;`tui-demo` bundle 则由产品 [`dsh`](../../apps/cli/README.md) CLI(命令行界面)启动。`ui/` 保留可复用的用户/SDK 通道插件和共享 `app-boot` 粘合层;仅供自动化使用的 ACP(Agent Client Protocol)传输层位于 [`acp/`](../acp/README.md)。每个入口都负责自己的 stdout 策略,叶子 `cordis.yml` 则提供后端与可选工具。 +======= +基于 [`agent-spine-demo`](../examples/agent-spine-demo/README.md) 组合的可运行 app bundle 位于 [`examples/`](../examples/README.md)(`cli-demo`、`acp-demo`、`jsonrpc-demo`)。`acp-demo` 和 `jsonrpc-demo` 持有启动 bin;产品 [`dsh`](../../apps/cli/README.md) CLI 不使用 bundle:它启动 `apps/cli` 中的平铺 config tree。`ui/` 保留可复用的用户/SDK 通道插件和共享 `app-boot` 粘合层;仅供自动化使用的 ACP 传输层位于 [`acp/`](../acp/README.md)。每个入口都持有自己的 stdout 策略,叶子 `cordis.yml` 则提供后端与可选工具。 +>>>>>>> a1c6a2c3f (refactor(cli)!: one shared base config with per-surface overlays) diff --git a/packages/ui/app-boot/src/index.ts b/packages/ui/app-boot/src/index.ts index 9882d22603..2a2460f18e 100644 --- a/packages/ui/app-boot/src/index.ts +++ b/packages/ui/app-boot/src/index.ts @@ -94,20 +94,56 @@ export function loadPersonalPatches( if ((error as NodeJS.ErrnoException | null)?.code === 'ENOENT') return undefined throw new Error(`${binName}: failed to read personal patches ${file}: ${String(error)}`) } + return parsePatchList(binName, file, content, 'personal patches') +} + +/** + * Load a required overlay patch list: a surface overlay (`tui.cordis.yml`) or a + * `--config ` overlay applied over the shared base. Same file format as + * {@link loadPersonalPatches}, but a missing file throws, because the caller + * named this file — its absence is a misconfiguration, not "no overlay". + * @param binName - the diagnostic prefix on the thrown error. + * @param file - absolute path of the overlay file. + * @returns the parsed patch list. + */ +export function loadOverlayPatches(binName: string, file: string): PatchOptions[] { + let content: string + try { + content = readFileSync(file, 'utf8') + } catch (error) { + throw new Error(`${binName}: failed to read overlay ${file}: ${String(error)}`) + } + return parsePatchList(binName, file, content, 'overlay') +} + +/** + * Parse one loader patch list: a top-level YAML array of + * `@cordisjs/plugin-include` `PatchOptions` (id-targeted config overrides and + * `insert` lists, `!!js` expressions allowed). Every shape failure throws, + * because a patch file that cannot be applied at all is a misconfiguration; a + * single patch whose target row is absent stays a per-entry Loader warning, so + * one overlay shared across surfaces does not have to match every tree. + * @param binName - the diagnostic prefix on the thrown error. + * @param file - the source path, quoted in errors. + * @param content - the file's text. + * @param label - what to call this list in errors (`personal patches`, `overlay`). + * @returns the parsed patch list. + */ +function parsePatchList( + binName: string, file: string, content: string, label: string, +): PatchOptions[] { let parsed: unknown try { parsed = yaml.load(content, { schema: personalPatchesSchema }) } catch (error) { - throw new Error(`${binName}: failed to parse personal patches ${file}: ${String(error)}`) + throw new Error(`${binName}: failed to parse ${label} ${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`) + throw new Error(`${binName}: ${label} ${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)`) + throw new Error(`${binName}: ${label} entry ${index + 1} in ${file} must be a mapping (a loader patch entry)`) } }) return parsed as PatchOptions[] diff --git a/packages/ui/app-boot/tests/config-reload.spec.ts b/packages/ui/app-boot/tests/config-reload.spec.ts index 05ebe80b58..c4f6c48d7f 100644 --- a/packages/ui/app-boot/tests/config-reload.spec.ts +++ b/packages/ui/app-boot/tests/config-reload.spec.ts @@ -126,3 +126,52 @@ describe('include refresh with overlay patches', () => { } }) }) + +describe('include patches layered over one base', () => { + it('lets a later patch configure or disable a row an earlier patch inserted', async () => { + // The surface/`--config`/personal composition: `dsh` includes one shared + // base and applies each source as its own patch list at the SAME include + // level, because patches never cross an include boundary. A later layer + // must therefore be able to reach a row an earlier layer inserted — + // otherwise every surface-only row (the whole TUI front door) would be + // invisible to the user's `~/.dsh/config.yaml`. + const dir = mkdtempSync(join(tmpdir(), 'dsh-config-layered-')) + writeFileSync(join(dir, 'noop.mjs'), NOOP_PLUGIN) + writeFileSync(join(dir, 'base.yml'), '- id: shared\n name: ./noop.mjs\n config:\n value: base\n') + writeFileSync(join(dir, 'cordis.yml'), [ + '- id: base', + " name: 'cordis:include'", + ' config:', + ' path: ./base.yml', + ' patches:', + // Layer 1 (a surface overlay): patch a base row and add two of its own. + ' - id: shared', + ' config:', + ' value: surface', + ' - insert:', + ' - id: surface-kept', + ' name: ./noop.mjs', + ' config:', + ' value: surface-default', + ' - id: surface-dropped', + ' name: ./noop.mjs', + // Layer 2 (the user): reconfigure one inserted row and disable the other. + ' - id: surface-kept', + ' config:', + ' value: personal', + ' - id: surface-dropped', + ' disabled: true', + '', + ].join('\n')) + const ctx = await boot(NAME, join(dir, 'cordis.yml')) + try { + expect(entryConfig(ctx, 'shared')).toEqual({ value: 'surface' }) + expect(entryConfig(ctx, 'surface-kept')).toEqual({ value: 'personal' }) + const dropped = [...ctx.loader.entries()].find(entry => entry.options.id === 'surface-dropped') + expect(dropped?.options.disabled).toBe(true) + expect(dropped?.fiber).toBeUndefined() + } finally { + await ctx.fiber.dispose() + } + }) +}) diff --git a/packages/ui/tui/src/chat/resume.ts b/packages/ui/tui/src/chat/resume.ts index 9521bd7368..6e90101bb8 100644 --- a/packages/ui/tui/src/chat/resume.ts +++ b/packages/ui/tui/src/chat/resume.ts @@ -29,7 +29,13 @@ import type { ChannelNotice, ChatChannelDeps } from './channel.ts' export interface ResumeControllerDeps extends ChatChannelDeps, ChannelNotice { readonly agent: Agent readonly runtime: TuiRuntime - readonly sessionQuery: SessionQueryService | undefined + /** + * The optional session-query service, re-read at each use. `sessionQuery` is + * mounted by an independent plugin, and a flat config tree gives no ordering + * guarantee between it and this front door, so a value captured once at + * construction can be `undefined` even though the service arrives moments later. + */ + readonly sessionQuery: (this: void) => SessionQueryService | undefined readonly ui: TUI readonly editor: HintEditor /** Current agent status, re-read at each resume precondition point. */ @@ -75,8 +81,9 @@ export function createResumeController(deps: ResumeControllerDeps): ResumeContro } } else { /* v8 ignore next -- caller checks the optional service before mapping records */ - if (sessionQuery === undefined) throw new Error('session query is unavailable') - snapshot = await sessionQuery.readSession(record.header.id) + const readQuery = sessionQuery() + if (readQuery === undefined) throw new Error('session query is unavailable') + snapshot = await readQuery.readSession(record.header.id) } return summarizeResumeCandidate( record, @@ -105,10 +112,11 @@ export function createResumeController(deps: ResumeControllerDeps): ResumeContro */ const preflightResume = async (sessionId: SessionId): Promise<{ id: SessionId; cwd: string }> => { /* v8 ignore next -- only showResume can call this closure, after proving the optional service exists */ - if (sessionQuery === undefined) throw new Error('Resume is unavailable: session query is not mounted.') + const query = sessionQuery() + if (query === undefined) throw new Error('Resume is unavailable: session query is not mounted.') const initialStatus = deps.agentStatus() if (initialStatus !== 'idle') throw new Error(`Resume requires an idle agent (status: ${initialStatus}).`) - const record = (await sessionQuery.listSessions()).find(candidate => candidate.header.id === sessionId) + const record = (await query.listSessions()).find(candidate => candidate.header.id === sessionId) if (record === undefined) throw new Error(`Session "${sessionId}" is no longer available.`) const candidate = await readResumeCandidate( record, @@ -177,13 +185,14 @@ export function createResumeController(deps: ResumeControllerDeps): ResumeContro deps.appendNotice('Resume requires the current turn to finish or be cancelled first.', 'warning') return } - if (sessionQuery === undefined) { + const listQuery = sessionQuery() + if (listQuery === undefined) { deps.appendNotice('Resume is not available: session query is not mounted.', 'warning') return } const scan = ++resumeScan void resumeOverlay?.close() - void sessionQuery.listSessions().then(async (records) => { + void listQuery.listSessions().then(async (records) => { if (deps.isDisposed() || scan !== resumeScan) return // Every workspace in the store is summarized; the picker owns the // current-workspace/all-workspaces scope split over the whole set. diff --git a/packages/ui/tui/src/index.ts b/packages/ui/tui/src/index.ts index d1ba37e61d..78c3505db9 100644 --- a/packages/ui/tui/src/index.ts +++ b/packages/ui/tui/src/index.ts @@ -306,7 +306,6 @@ export function createTuiChat( const sessionId = SessionId(config.sessionId ?? 'main') const agent = ctx.agents.get(sessionId) if (agent === undefined) throw new Error(`ui-tui: session "${sessionId}" is not running`) - const sessionQuery = ctx.get('sessionQuery') const resolved = resolveTuiConfig(config) const palette = createPalette(resolved.theme.color) const mdTheme = markdownTheme(palette) @@ -853,7 +852,9 @@ export function createTuiChat( resolved, palette, overlayManager, - sessionQuery, + // Optional and independently mounted: read at each use so config row order + // cannot decide whether /resume works. + sessionQuery: () => ctx.get('sessionQuery'), ui, editor, appendNotice, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e3be18db21..9355d06117 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -396,6 +396,9 @@ importers: '@types/js-yaml': specifier: ^4.0.9 version: 4.0.9 + node-pty: + specifier: 1.1.0 + version: 1.1.0 apps/web: dependencies: @@ -454,9 +457,18 @@ importers: '@cordisjs/plugin-include': specifier: workspace:* version: link:../vendor/include + '@cordisjs/plugin-timer': + specifier: workspace:* + version: link:../vendor/timer '@deepseek-ai/dsh-acp-demo': specifier: workspace:* version: link:../packages/examples/acp-demo + '@deepseek-ai/dsh-agent': + specifier: workspace:* + version: link:../packages/core/agent + '@deepseek-ai/dsh-agent-loop': + specifier: workspace:* + version: link:../packages/core/agent-loop '@deepseek-ai/dsh-agent-spine-demo': specifier: workspace:* version: link:../packages/examples/agent-spine-demo @@ -478,6 +490,12 @@ importers: '@deepseek-ai/dsh-code-runtime-worker': specifier: workspace:* version: link:../packages/code-runtime/code-runtime-worker + '@deepseek-ai/dsh-command-goal': + specifier: workspace:* + version: link:../packages/goal/command-goal + '@deepseek-ai/dsh-commands': + specifier: workspace:* + version: link:../packages/ui/commands '@deepseek-ai/dsh-compact-basic': specifier: workspace:* version: link:../packages/compact/compact-basic @@ -505,6 +523,9 @@ importers: '@deepseek-ai/dsh-hooks-codex': specifier: workspace:* version: link:../packages/hooks/hooks-codex + '@deepseek-ai/dsh-invariants': + specifier: workspace:* + version: link:../packages/support/invariants '@deepseek-ai/dsh-jsonrpc': specifier: workspace:* version: link:../packages/ui/jsonrpc @@ -547,6 +568,12 @@ importers: '@deepseek-ai/dsh-sandbox-policy': specifier: workspace:^ version: link:../packages/sandbox/sandbox-policy + '@deepseek-ai/dsh-scope': + specifier: workspace:* + version: link:../packages/core/scope + '@deepseek-ai/dsh-session': + specifier: workspace:* + version: link:../packages/core/session '@deepseek-ai/dsh-session-checkpoint-policy': specifier: workspace:* version: link:../packages/session-persistence/session-checkpoint-policy @@ -559,12 +586,24 @@ importers: '@deepseek-ai/dsh-session-query-sqlite': specifier: workspace:* version: link:../packages/session-query/session-query-sqlite + '@deepseek-ai/dsh-session-reference': + specifier: workspace:* + version: link:../packages/context/session-reference '@deepseek-ai/dsh-session-telemetry-otel': specifier: workspace:* version: link:../packages/telemetry/session-telemetry-otel + '@deepseek-ai/dsh-session-title': + specifier: workspace:* + version: link:../packages/session-title/session-title '@deepseek-ai/dsh-session-title-first-message-llm': specifier: workspace:* version: link:../packages/session-title/session-title-first-message-llm + '@deepseek-ai/dsh-skill': + specifier: workspace:* + version: link:../packages/skill/skill + '@deepseek-ai/dsh-skill-local': + specifier: workspace:* + version: link:../packages/skill/skill-local '@deepseek-ai/dsh-source-guard': specifier: workspace:* version: link:../packages/guard/source-guard @@ -592,6 +631,9 @@ importers: '@deepseek-ai/dsh-subprocess-local': specifier: workspace:* version: link:../packages/subprocess/subprocess-local + '@deepseek-ai/dsh-system-prompt': + specifier: workspace:* + version: link:../packages/core/system-prompt '@deepseek-ai/dsh-tasks-local': specifier: workspace:* version: link:../packages/tasks/tasks-local @@ -610,6 +652,9 @@ importers: '@deepseek-ai/dsh-tool-ask-user': specifier: workspace:* version: link:../packages/ui/tool-ask-user + '@deepseek-ai/dsh-tool-bash': + specifier: workspace:* + version: link:../packages/bash/tool-bash '@deepseek-ai/dsh-tool-cordis': specifier: workspace:* version: link:../packages/cordis/tool-cordis @@ -634,9 +679,15 @@ importers: '@deepseek-ai/dsh-tool-session-query': specifier: workspace:* version: link:../packages/session-query/tool-session-query + '@deepseek-ai/dsh-tool-skill': + specifier: workspace:* + version: link:../packages/skill/tool-skill '@deepseek-ai/dsh-tool-subagent': specifier: workspace:* version: link:../packages/subagent/tool-subagent + '@deepseek-ai/dsh-tool-tasks': + specifier: workspace:* + version: link:../packages/tasks/tool-tasks '@deepseek-ai/dsh-tool-todo': specifier: workspace:* version: link:../packages/todo/tool-todo @@ -649,12 +700,15 @@ importers: '@deepseek-ai/dsh-tools': specifier: workspace:* version: link:../packages/core/tools - '@deepseek-ai/dsh-tui-demo': + '@deepseek-ai/dsh-tui': specifier: workspace:* - version: link:../packages/examples/tui-demo + version: link:../packages/ui/tui '@deepseek-ai/dsh-user-approval': specifier: workspace:* version: link:../packages/ui/user-approval + '@deepseek-ai/dsh-user-interaction': + specifier: workspace:* + version: link:../packages/ui/user-interaction '@deepseek-ai/dsh-web': specifier: workspace:* version: link:../packages/web/web @@ -664,10 +718,9 @@ importers: '@deepseek-ai/dsh-workflow-workerthread': specifier: workspace:* version: link:../packages/workflow/workflow-workerthread - devDependencies: - node-pty: - specifier: 1.1.0 - version: 1.1.0 + '@deepseek-ai/dsh-workspace-context': + specifier: workspace:* + version: link:../packages/context/workspace-context packages/acp/acp: dependencies: @@ -2383,75 +2436,6 @@ importers: specifier: ^4.0.0-rc.7 version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) - packages/examples/tui-demo: - devDependencies: - '@cordisjs/plugin-loader': - specifier: workspace:^ - version: link:../../../vendor/loader - '@deepseek-ai/dsh-agent': - specifier: workspace:^ - version: link:../../core/agent - '@deepseek-ai/dsh-agent-loop': - specifier: workspace:^ - version: link:../../core/agent-loop - '@deepseek-ai/dsh-agent-spine-demo': - specifier: workspace:^ - version: link:../agent-spine-demo - '@deepseek-ai/dsh-command-goal': - specifier: workspace:^ - version: link:../../goal/command-goal - '@deepseek-ai/dsh-commands': - specifier: workspace:^ - version: link:../../ui/commands - '@deepseek-ai/dsh-invariants': - specifier: workspace:^ - version: link:../../support/invariants - '@deepseek-ai/dsh-llm': - specifier: workspace:^ - version: link:../../llm/llm - '@deepseek-ai/dsh-session': - specifier: workspace:^ - version: link:../../core/session - '@deepseek-ai/dsh-session-checkpoint-policy': - specifier: workspace:^ - version: link:../../session-persistence/session-checkpoint-policy - '@deepseek-ai/dsh-session-persistence-jsonl': - specifier: workspace:^ - version: link:../../session-persistence/session-persistence-jsonl - '@deepseek-ai/dsh-session-query': - specifier: workspace:^ - version: link:../../session-query/session-query - '@deepseek-ai/dsh-session-query-sqlite': - specifier: workspace:^ - version: link:../../session-query/session-query-sqlite - '@deepseek-ai/dsh-session-reference': - specifier: workspace:^ - version: link:../../context/session-reference - '@deepseek-ai/dsh-system-prompt': - specifier: workspace:^ - version: link:../../core/system-prompt - '@deepseek-ai/dsh-tool-ask-user': - specifier: workspace:^ - version: link:../../ui/tool-ask-user - '@deepseek-ai/dsh-tools': - specifier: workspace:^ - version: link:../../core/tools - '@deepseek-ai/dsh-tui': - specifier: workspace:^ - version: link:../../ui/tui - '@deepseek-ai/dsh-user-interaction': - specifier: workspace:^ - version: link:../../ui/user-interaction - '@deepseek-ai/dsh-workspace-context': - specifier: workspace:^ - version: link:../../context/workspace-context - cordis: - specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@vendor+loader) - schemastery: - specifier: ^3.17.0 - version: 3.18.0 - packages/fs/fs: devDependencies: '@deepseek-ai/dsh-brand': diff --git a/scripts/demo-code-mode.mjs b/scripts/demo-code-mode.mjs index 994bea32d0..5a98fdb5e8 100644 --- a/scripts/demo-code-mode.mjs +++ b/scripts/demo-code-mode.mjs @@ -12,7 +12,7 @@ const UIS = new Map([ 'tsx/esm', 'apps/cli/src/bin.ts', '--config', - 'examples/tui-agent/code-mode.cordis.yml', + 'examples/code-mode/cordis.yml', ]], ['acp', ['--import', 'tsx', 'packages/examples/acp-demo/src/bin.ts', '--config', 'examples/acp-agent/code-mode.cordis.yml']], ]) diff --git a/scripts/demo-cordis.mjs b/scripts/demo-cordis.mjs index 94bbea1332..64fbe0e72d 100644 --- a/scripts/demo-cordis.mjs +++ b/scripts/demo-cordis.mjs @@ -1,21 +1,19 @@ /** - * Boot the self-referential Cordis tools under TUI, Web, or ACP, defaulting - * to TUI. This is a repository demo wrapper, not a product CLI feature. + * Boot the self-referential Cordis tools under Web or ACP, defaulting to Web. This is a repository demo wrapper, not a product CLI feature. */ import { spawn } from 'node:child_process' const SURFACES = new Map([ - ['tui', ['--import', 'tsx', 'apps/cli/src/bin.ts', '--config', 'examples/cordis-agent/cordis.yml']], - // `dsh web` does not accept alternate configs yet. The TUI config escape - // hatch still boots this browser-only tree; the config owns port 3081. - ['web', ['--import', 'tsx', 'apps/cli/src/bin.ts', '--config', 'examples/web-cordis/cordis.yml']], + // The browser surface with the cordis toolset layered on: `dsh web --config` + // applies this overlay over the shipped web composition; it owns port 3081. + ['web', ['--import', 'tsx', 'apps/cli/src/bin.ts', 'web', '--config', 'examples/web-cordis/cordis.yml']], ['acp', ['--import', 'tsx', 'packages/examples/acp-demo/src/bin.ts', '--config', 'examples/acp-agent/cordis-tools.cordis.yml']], ]) -const surface = process.argv[2] ?? 'tui' +const surface = process.argv[2] ?? 'web' const args = SURFACES.get(surface) if (args === undefined || process.argv.length > 3) { - console.error('usage: pnpm run demo:cordis [tui|web|acp]') + console.error('usage: pnpm run demo:cordis [web|acp]') process.exit(2) } diff --git a/scripts/gen-doc-graphs.ts b/scripts/gen-doc-graphs.ts index 6df0c9acee..c836289049 100644 --- a/scripts/gen-doc-graphs.ts +++ b/scripts/gen-doc-graphs.ts @@ -282,7 +282,7 @@ const SERVICE_ROLES: ServiceRole[] = [ pkg: 'agent', title: 'Agent service', mode: 'core', - consumers: ['agent-loop', 'acp', 'cli-demo', 'subagent-inprocess', 'tui-demo'], + consumers: ['agent-loop', 'acp', 'cli-demo', 'subagent-inprocess'], note: 'Owns live Agent handles, the create/resume factory seam, and process-local initiator propagation.', }, { @@ -603,10 +603,10 @@ function stripYamlScalar(value: string): string { const APP_EXAMPLES = [ { id: 'tui', - rel: 'examples/tui-agent/composition.md', - title: 'TUI Agent App Composition', - label: 'examples/tui-agent', - config: 'examples/tui-agent/cordis.yml', + rel: 'apps/cli/composition.md', + title: 'dsh TUI Composition', + label: 'apps/cli (dsh)', + config: 'apps/cli/base.cordis.yml', summary: 'The TUI agent combines the real DeepSeek adapter, coding tools, compaction, subagents, and workflows with the full-screen terminal app package.', }, { @@ -617,14 +617,6 @@ const APP_EXAMPLES = [ config: 'examples/headless-agent/cordis.yml', summary: 'The headless demo combines the real DeepSeek adapter and coding capabilities with the one-shot app package, format-pure stdout, and one fresh persisted top-level session.', }, - { - id: 'cordis', - rel: 'examples/cordis-agent/composition.md', - title: 'Cordis Agent App Composition', - label: 'examples/cordis-agent', - config: 'examples/cordis-agent/cordis.yml', - summary: 'The self-referential demo puts @deepseek-ai/dsh-tool-cordis on the coding spine, letting the agent inspect its current-process runtime and mount or unmount in-memory temporary Plugins.', - }, { id: 'acp', rel: 'examples/acp-agent/composition.md', @@ -642,9 +634,7 @@ function renderAppExpansion(lines: string[], appNode: string, pluginName: string const jsonl = nodeId('bundle', 'jsonl') lines.push(` ${appNode} --> ${agentCore}["@deepseek-ai/dsh-agent-spine-demo"]`) lines.push(` ${appNode} --> ${jsonl}["@deepseek-ai/dsh-session-persistence-jsonl"]`) - if (pluginName === '@deepseek-ai/dsh-tui-demo') { - lines.push(` ${appNode} --> ${nodeId('frontdoor', 'tui')}["@deepseek-ai/dsh-tui
pre-created main agent"]`) - } else if (pluginName === '@deepseek-ai/dsh-cli-demo') { + if (pluginName === '@deepseek-ai/dsh-cli-demo') { lines.push(` ${appNode} --> ${nodeId('frontdoor', 'cli')}["one-shot driver
format-pure stdout
fresh top-level agent"]`) } else if (pluginName === '@deepseek-ai/dsh-acp-demo') { lines.push(` ${appNode} --> ${nodeId('frontdoor', 'acp')}["@deepseek-ai/dsh-acp
automation-only JSON-RPC stdio
fresh sessions created by client"]`) @@ -672,7 +662,7 @@ function renderAppComposition(example: AppExample): string { const pluginNode = nodeId(`plugin_${example.id}`, plugin.id) lines.push(` ${pluginNode}["${escLabel(plugin.id)}
${escLabel(plugin.name)}"]`) lines.push(` cfg --> ${pluginNode}`) - if (plugin.name === '@deepseek-ai/dsh-tui-demo' || plugin.name === '@deepseek-ai/dsh-cli-demo' || plugin.name === '@deepseek-ai/dsh-acp-demo') { + if (plugin.name === '@deepseek-ai/dsh-cli-demo' || plugin.name === '@deepseek-ai/dsh-acp-demo') { renderAppExpansion(lines, pluginNode, plugin.name) } } @@ -1184,8 +1174,7 @@ function renderIndex(docs: GraphDoc[]): string { const labels: Record = { 'docs/capability-seams.md': 'capability seams and core services', 'examples/headless-agent/composition.md': 'headless-agent app composition', - 'examples/tui-agent/composition.md': 'tui-agent app composition', - 'examples/cordis-agent/composition.md': 'cordis-agent app composition', + 'apps/cli/composition.md': 'dsh TUI composition', 'examples/acp-agent/composition.md': 'acp-agent app composition', 'docs/event-producer-consumer.md': 'event producer/consumer matrix', 'docs/agent-lifecycle.md': 'agent turn and step lifecycle', @@ -1194,8 +1183,7 @@ function renderIndex(docs: GraphDoc[]): string { const modes: Record = { 'docs/capability-seams.md': 'hybrid generated', 'examples/headless-agent/composition.md': 'hybrid generated', - 'examples/tui-agent/composition.md': 'hybrid generated', - 'examples/cordis-agent/composition.md': 'hybrid generated', + 'apps/cli/composition.md': 'hybrid generated', 'examples/acp-agent/composition.md': 'hybrid generated', 'docs/event-producer-consumer.md': 'hybrid generated', 'docs/agent-lifecycle.md': 'curated', diff --git a/scripts/gen-tool-catalog.ts b/scripts/gen-tool-catalog.ts index 7bdc8b68a8..663c2763c9 100644 --- a/scripts/gen-tool-catalog.ts +++ b/scripts/gen-tool-catalog.ts @@ -215,7 +215,7 @@ const TOOL_PACKAGES: ToolPackage[] = [ await ctx.plugin(ToolCordis) }, note: - 'Ships in examples/cordis-agent only (a deliberate opt-in — temporary Plugin code reaches the real runtime, see .agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md). Plugins created by cordis_mount may register ADDITIONAL model-visible tools until unmounted or DSH restarts; a full changed request header logs those tool-set changes.', + 'Not in any shipped tree (a deliberate opt-in — temporary Plugin code reaches the real runtime, see .agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md). Plugins created by cordis_mount may register ADDITIONAL model-visible tools until unmounted or DSH restarts; a full changed request header logs those tool-set changes.', }, { pkg: '@deepseek-ai/dsh-tool-fs', @@ -348,7 +348,7 @@ const TOOL_PACKAGES: ToolPackage[] = [ await ctx.plugin(ToolSubagent, { provider: 'mock' }) }, note: - 'The registered tool name is the load-time `toolName` config (default `subagent`); the schema above is that default. The shipped example agents load this package once per subagent backend, so the model additionally sees `subagent_fork` (bound to the fork backend) with an identical schema — see `examples/tui-agent/cordis.yml` and `examples/acp-agent/cordis.yml`.', + 'The registered tool name is the load-time `toolName` config (default `subagent`); the schema above is that default. The shipped example agents load this package once per subagent backend, so the model additionally sees `subagent_fork` (bound to the fork backend) with an identical schema — see `apps/cli/base.cordis.yml` and `examples/acp-agent/cordis.yml`.', }, { pkg: '@deepseek-ai/dsh-tool-tasks', diff --git a/scripts/run-gates.ts b/scripts/run-gates.ts index d10a9ccba7..26f633b47e 100644 --- a/scripts/run-gates.ts +++ b/scripts/run-gates.ts @@ -526,7 +526,7 @@ function builtBinSmokeGate(needs: string[] = ['build']): Gate { '--config', 'vitest.e2e.config.ts', 'examples/headless-agent/tests/keyless-smoke.e2e.ts', - 'examples/tui-agent/tests/tui-keyless-smoke.e2e.ts', + 'apps/cli/tests/tui-keyless-smoke.e2e.ts', 'packages/examples/cli-demo/tests/built-bin.e2e.ts', 'packages/examples/acp-demo/tests/built-bin.e2e.ts', 'packages/ui/jsonrpc/tests/built-scope-carrier.e2e.ts', diff --git a/scripts/snapshots/translation-prompt-v4/request-response.expected.json b/scripts/snapshots/translation-prompt-v4/request-response.expected.json index d26d2e8d22..5cfb198296 100644 --- a/scripts/snapshots/translation-prompt-v4/request-response.expected.json +++ b/scripts/snapshots/translation-prompt-v4/request-response.expected.json @@ -16,11 +16,11 @@ }, { "role": "user", - "content": "# Development guide\n\nEnglish | [中文](development.zh.md)\n\nThis onboarding guide helps project contributors get started with the local environment, daily workflow, and CI flow; see the Agent Notes for design rationale and technical trade-offs.\n\n## Prerequisites\n\n- Node.js supports 22.19+ and 24+. CI covers 22.19, 24, and 26; see the [Node engine floor Agent Note](../.agents/notes/implemented/process/2026-07-06-node-engine-floor.md).\n- Corepack-enabled pnpm. The repo pins `pnpm@11.7.0` in `package.json`; run `corepack enable` if `pnpm --version` does not resolve through Corepack.\n- Git 2.26 or newer; hook setup enables Git's worktree-specific configuration extension.\n- Optional: a DeepSeek API key for the TUI, headless, and ACP automation demos and real-API e2e tests.\n\n## First-time setup\n\nInstall dependencies from the repo root:\n\n```sh\npnpm install\n```\n\nThe install also runs the root `postinstall` script, which installs lefthook from the repo dev dependency through `scripts/install-lefthook.mjs`. With `CI=true` or `GITHUB_ACTIONS=true`, the wrapper returns before Git discovery because automated jobs do not consume contributor hooks. Otherwise, it requires Git 2.26 or newer and gives the current worktree an explicit hook directory under its own Git directory; linked worktrees therefore use their own lefthook binary and configuration instead of rewriting common hooks. The first install enables Git's worktree-specific configuration extension and repository format 1; see the [worktree-local hooks Agent Note](../.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.md).\n\nIf hooks are missing because dependencies were restored from cache or `postinstall` was skipped, install them manually:\n\n```sh\nnode scripts/install-lefthook.mjs\n```\n\nThe wrapper refuses user-owned `core.hooksPath` values. An inherited system, global, or common-repository path requires `DSH_LEFTHOOK_ALLOW_HOOKS_PATH_OVERRIDE=1`. When Git seeds a new worktree with another registered worktree's marker-backed hook path, the wrapper replaces that copied value with the new worktree's own path; command-scoped and other worktree-scoped paths must be integrated or removed explicitly.\n\nBefore enabling worktree config, migrate direct `extensions.*` in a format-0 common config, direct `core.worktree` or `core.bare=true`, and any non-empty dormant `config.worktree`. The common config and every worktree config must be regular files, while the owned hook directory may contain only unaliased regular files.\n\nAfter moving a checkout, rerun the wrapper to relocate its owned path and regenerate hooks. For a stale or invalid installer lock, first confirm no installer is running, then remove the reported lock and retry. If installation and hook-path rollback both fail, inspect the reported worktree config before retrying. The [worktree-local hooks Agent Note](../.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.md) owns the full safety contract.\n\nRun typecheck once after a fresh clone:\n\n```sh\npnpm run typecheck\n```\n\nThat first typecheck runs the whole-repo `tsc -b` graph: it emits every package/vendor `lib/types` and checks examples, tests, and scripts through the two no-emit aggregates described below.\n\n## TypeScript project layout\n\nThe repository's TypeScript configuration has exactly three roles; every tsconfig file plays one of them.\n\n| File | Role | Forms a program? |\n|---|---|---|\n| `tsconfig.json` | Solution root: `extends` base, `files: []`, references to the two aggregates. The whole-repo `tsc -b tsconfig.json` graph, the tsserver discovery entry, and — through the inherited `paths` — the resolution config for tsx running `examples/` and `scripts/` (their nearest tsconfig is this file). | No |\n| `tsconfig.host.json` | Host aggregate: host-side packages (via references), examples, tests, scripts, website. Excludes `packages/client`. | Yes |\n| `tsconfig.client.json` | Client aggregate: `packages/client/*` packages and their tests, `apps/web`. | Yes |\n| `tsconfig.base.json` | Shared compilerOptions and the source `paths` map. Also the resolution facade the vitest configs point vite-tsconfig-paths at: it has no `include`, so its `paths` apply to every importer. | No |\n| `tsconfig.base.client.json` | Browser compiler shape (`jsx`, DOM libs, `types: []`) extended by the client aggregate and every `packages/client/*` package. | No |\n\nHost and client stay two aggregate programs because both sides declaration-merge the cordis `Context` interface under the same keys with different services; one program seeing both merges reports a collision. The collision exists only inside a `ts.Program` — module resolution never triggers it — which is why the solution may reference both aggregates and one paths facade may span both sides. Two disciplines follow:\n\n- `tsconfig.base.json` never gains `include` or `files`: they would leak into every extending package project and narrow the facade's match-all scope.\n- A script that builds a repo-wide `ts.Program` seeds `tsconfig.host.json` or `tsconfig.client.json` explicitly — never the root solution, because flattening both aggregates into one program collides the `Context` merges. Program-backed generators and gates (`scripts/ts-project.ts` consumers, doc-typecheck standalone mode) are host-only by decision; the client side gains program-backed tooling only with a concrete need.\n\nStatic analysis and tests resolve workspace imports through the base `paths` map to `src` and must pass on a clean tree; gates that consume built `lib/` output declare that dependency explicitly. Decision record: [solution-root note](../.agents/notes/implemented/process/2026-07-22-tsconfig-solution-root-two-aggregates.md); the tsc-first emit pipeline is the [ts-build-config note](../.agents/notes/implemented/process/2026-06-17-ts-build-config.md).\n\nIf a relevant local check consumes built package output, build once first:\n\n```sh\npnpm run build\n```\n\n`pnpm run hygiene` includes `publint`, which validates package entrypoints against the built `lib/*.js` files, and `verify-node-next-types`, which validates built declarations against a temporary NodeNext consumer. A fresh worktree has no bundled JS or declarations until `pnpm run build` runs; ordinary commits and pushes do not require that build unless their selected checks consume it.\n\n## Environment variables\n\nThe real DeepSeek adapter and key-backed agent demos read credentials from the environment or from a gitignored `.env` at the repo root:\n\n```sh\nDEEPSEEK_API_KEY=sk-...\nDEEPSEEK_BASE_URL=https://... # optional\n```\n\n`DEEPSEEK_BASE_URL` is optional and defaults to the public API. Never commit real credentials. The real-API e2e suites self-skip when `DEEPSEEK_API_KEY` is not set.\n\n## Git hooks\n\nlefthook is configured in `lefthook.yml` as a fast local checkpoint:\n\n- `pre-commit` runs staged-file ESLint fixes, checks the staged diff for whitespace errors, and runs the vendor manifest guard.\n- `pre-push` runs only the incremental repository typecheck (`tsc -b` over the root solution, covering both the host and client aggregates).\n\nThe vendor manifest guard checks that changes under `vendor/*/src` are staged with the matching `vendor/README.md` manifest update. See `vendor/README.md` before editing vendored code.\n\nThe hooks intentionally do not run tests, snapshots, documentation checks, builds, or hygiene. Contributors run the [checks relevant to the changed behavior](../AGENTS.md#run-relevant-checks-locally) once; CI owns exhaustive coverage, built-artifact smokes, and the Node 22.19, 24, and 26 compatibility matrix.\n\nContributors can opt into the comprehensive local gate set with `pnpm run check:all`. The command is independent of both Git hooks and is not an agent instruction.\n\n## CI gates\n\nThe keyless [CI workflow](../.github/workflows/ci.yml) groups independent gates into broad lanes and runs a smaller compatibility signal across supported Node versions. Artifact consumers wait for one build within their lane. The separate real-API workflow runs `pnpm run test:e2e` with its configured worker bound. See [scripts/run-gates.ts](../scripts/run-gates.ts) and the workflow files for the current gate and job inventory.\n\n## Daily commands\n\nUse these from the repo root:\n\n```sh\npnpm run test # unit tests\npnpm run test:coverage # unit tests with per-file coverage gates\npnpm run test:e2e # real-API tests; self-skips without DEEPSEEK_API_KEY\npnpm run check:all # comprehensive opt-in gate set; not wired to Git hooks\npnpm run typecheck # tsc -b over the root solution: emits package/vendor lib/types, checks both aggregates\npnpm run lint # eslint .\npnpm run lint:fix # eslint . --fix\npnpm run doc-typecheck # compile checked TypeScript snippets in Markdown docs\npnpm run gen-cordis-catalog # regenerate docs/cordis-catalog/events.md + services.md from source\npnpm run verify-cordis-catalog # fail if either cordis catalog is stale\npnpm run verify-export-jsdoc # fail if a module-level package export lacks complete JSDoc\npnpm run gen-doc-graphs # regenerate generated relationship docs from source and curated graph definitions\npnpm run verify-doc-graphs # fail if generated relationship docs are stale\npnpm run verify-md-wrap # fail on hard-wrapped prose paragraphs in docs/README markdown\npnpm run verify-mermaid # fail if a ```mermaid diagram has invalid Mermaid syntax\npnpm run verify-type-equiv # fail if a ```ts type-equiv doc block drifts from its source type\npnpm run verify-doc-budgets # fail if a budgeted standing doc exceeds its word ceiling\npnpm run gen-translation-brief # print the minimal-update briefing for out-of-sync translation pairs (--apply splices code-only edits)\npnpm run doc-sync # all Markdown/doc gates, scheduled concurrently; the doc-sync leaf list in scripts/run-gates.ts is the full list\npnpm run gen-module-graph # regenerate docs/module-graph.md from package peerDeps\npnpm run verify-module-graph # fail if docs/module-graph.md is stale\npnpm run build # emit lib/types intermediates, then bundle lib/index.* runtime files\npnpm run verify-node-next-types # fail if built declarations are not NodeNext-consumable\npnpm run hygiene # knip, publint, workspace constraints, and NodeNext declaration check\n```\n\nWhen changing package public behavior, update the relevant README or JSDoc in the same change. `pnpm run doc-sync` catches checked TypeScript snippets, generated doc freshness, markdown wrap/link drift, type equivalence, translation pairing, Mermaid syntax, and doc budgets, but broader prose/API sync still needs review.\n\n## Demos\n\nThe one-shot Headless coding agent needs `DEEPSEEK_API_KEY` in the environment or repo-root `.env`:\n\n```sh\npnpm run demo:headless \"summarize this workspace\"\n```\n\nThe full-screen interactive coding agent needs `DEEPSEEK_API_KEY` in the environment or repo-root `.env`:\n\n```sh\npnpm run demo:tui\n```\n\nThe self-referential cordis-agent demo can inspect and modify its live plugin runtime and needs the same credentials:\n\n```sh\npnpm run demo:cordis\n```\n\nThe ACP automation server exposes fresh agent sessions over JSON-RPC stdio and also needs `DEEPSEEK_API_KEY`:\n\n```sh\npnpm run demo:acp\n```\n\n## TODO markers\n\nUse one of three comment tags to flag known issues in the code, ordered by urgency:\n\n- `FIXME` — an issue that should block a new release. A release should not ship with an open `FIXME` unless reviewers explicitly agree the change can be merged anyway.\n- `TODO` — an issue that should be fixed soon, once we have the resources.\n- `XXX` — an issue that we may fix someday; lowest priority, no commitment.\n\nPick the tag that matches the urgency so anyone scanning the code can tell a release blocker from a someday-maybe.\n\n## Documenting types verbatim (`ts type-equiv`)\n\nThe [core data structures](core-data-structures/core.md) docs paste source-equivalent declarations together with their original JSDoc so a reader sees the exact shape and source contract. To keep a paste from drifting when source changes, fence it as ` ```ts type-equiv ` (instead of ` ```ts `) and register it in `scripts/type-equiv.manifest.json` with the source file and symbol it mirrors:\n\n```json\n{ \"doc\": \"docs/core-data-structures/session.md\", \"symbol\": \"SessionEvent\", \"source\": \"packages/core/session/src/types.ts\" }\n```\n\n`pnpm run verify-type-equiv` (part of `doc-sync`) then extracts that symbol's declaration and attached JSDoc from source via the TypeScript parser and asserts the block matches both. For a class whose implementation bodies do not belong in the catalog, use ` ```ts public-api ` and set `\"projection\": \"public-api\"`; the checked projection retains the public fields, constructor, accessors, methods, and original class/member JSDoc while omitting bodies and private or protected members. Comparison ignores whitespace and non-JSDoc comments but requires every original JSDoc comment, including member documentation, so readers see the source contract beside the exact shape. The gate enforces a 1:1 correspondence by document, symbol, and projection between primary blocks and manifest entries; a paired `.zh.md` block reuses its unsuffixed sibling's entry only when the whole tracked fence sequence is byte-identical and ordered identically. `doc-typecheck` applies the same derivative rule to compilable fences, while skipping both source-equivalence fence kinds from compilation and its opt-out ratio. When you change a documented declaration or its JSDoc, the gate fails until you update the paste; when you add or remove a primary block, update the manifest in the same change.\n\n## Architecture context\n\nRead `docs/architecture.md` before changing anything under `packages/`. The codebase is built around Cordis plugins, event-sourced sessions, typed service seams, and explicit extension points.\n" + "content": "# Development guide\n\nEnglish | [中文](development.zh.md)\n\nThis onboarding guide helps project contributors get started with the local environment, daily workflow, and CI flow; see the Agent Notes for design rationale and technical trade-offs.\n\n## Prerequisites\n\n- Node.js supports 22.19+ and 24+. CI covers 22.19, 24, and 26; see the [Node engine floor Agent Note](../.agents/notes/implemented/process/2026-07-06-node-engine-floor.md).\n- Corepack-enabled pnpm. The repo pins `pnpm@11.7.0` in `package.json`; run `corepack enable` if `pnpm --version` does not resolve through Corepack.\n- Git 2.26 or newer; hook setup enables Git's worktree-specific configuration extension.\n- Optional: a DeepSeek API key for the TUI, headless, and ACP automation demos and real-API e2e tests.\n\n## First-time setup\n\nInstall dependencies from the repo root:\n\n```sh\npnpm install\n```\n\nThe install also runs the root `postinstall` script, which installs lefthook from the repo dev dependency through `scripts/install-lefthook.mjs`. With `CI=true` or `GITHUB_ACTIONS=true`, the wrapper returns before Git discovery because automated jobs do not consume contributor hooks. Otherwise, it requires Git 2.26 or newer and gives the current worktree an explicit hook directory under its own Git directory; linked worktrees therefore use their own lefthook binary and configuration instead of rewriting common hooks. The first install enables Git's worktree-specific configuration extension and repository format 1; see the [worktree-local hooks Agent Note](../.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.md).\n\nIf hooks are missing because dependencies were restored from cache or `postinstall` was skipped, install them manually:\n\n```sh\nnode scripts/install-lefthook.mjs\n```\n\nThe wrapper refuses user-owned `core.hooksPath` values. An inherited system, global, or common-repository path requires `DSH_LEFTHOOK_ALLOW_HOOKS_PATH_OVERRIDE=1`; command-scoped and worktree-scoped custom paths must be integrated or removed explicitly.\n\nBefore enabling worktree config, migrate direct `extensions.*` in a format-0 common config, direct `core.worktree` or `core.bare=true`, and any non-empty dormant `config.worktree`. The common config and every worktree config must be regular files, while the owned hook directory may contain only unaliased regular files.\n\nAfter moving a checkout, rerun the wrapper to relocate its owned path and regenerate hooks. For a stale or invalid installer lock, first confirm no installer is running, then remove the reported lock and retry. If installation and hook-path rollback both fail, inspect the reported worktree config before retrying. The [worktree-local hooks Agent Note](../.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.md) owns the full safety contract.\n\nRun typecheck once after a fresh clone:\n\n```sh\npnpm run typecheck\n```\n\nThat first typecheck runs the whole-repo `tsc -b` graph: it emits every package/vendor `lib/types` and checks examples, tests, and scripts through the two no-emit aggregates described below.\n\n## TypeScript project layout\n\nThe repository's TypeScript configuration has exactly three roles; every tsconfig file plays one of them.\n\n| File | Role | Forms a program? |\n|---|---|---|\n| `tsconfig.json` | Solution root: `extends` base, `files: []`, references to the two aggregates. The whole-repo `tsc -b tsconfig.json` graph, the tsserver discovery entry, and — through the inherited `paths` — the resolution config for tsx running `examples/` and `scripts/` (their nearest tsconfig is this file). | No |\n| `tsconfig.host.json` | Host aggregate: host-side packages (via references), examples, tests, scripts, website. Excludes `packages/client`. | Yes |\n| `tsconfig.client.json` | Client aggregate: `packages/client/*` packages and their tests, `apps/web`. | Yes |\n| `tsconfig.base.json` | Shared compilerOptions and the source `paths` map. Also the resolution facade the vitest configs point vite-tsconfig-paths at: it has no `include`, so its `paths` apply to every importer. | No |\n| `tsconfig.base.client.json` | Browser compiler shape (`jsx`, DOM libs, `types: []`) extended by the client aggregate and every `packages/client/*` package. | No |\n\nHost and client stay two aggregate programs because both sides declaration-merge the cordis `Context` interface under the same keys with different services; one program seeing both merges reports a collision. The collision exists only inside a `ts.Program` — module resolution never triggers it — which is why the solution may reference both aggregates and one paths facade may span both sides. Two disciplines follow:\n\n- `tsconfig.base.json` never gains `include` or `files`: they would leak into every extending package project and narrow the facade's match-all scope.\n- A script that builds a repo-wide `ts.Program` seeds `tsconfig.host.json` or `tsconfig.client.json` explicitly — never the root solution, because flattening both aggregates into one program collides the `Context` merges. Program-backed generators and gates (`scripts/ts-project.ts` consumers, doc-typecheck standalone mode) are host-only by decision; the client side gains program-backed tooling only with a concrete need.\n\nStatic analysis and tests resolve workspace imports through the base `paths` map to `src` and must pass on a clean tree; gates that consume built `lib/` output declare that dependency explicitly. Decision record: [solution-root note](../.agents/notes/implemented/process/2026-07-22-tsconfig-solution-root-two-aggregates.md); the tsc-first emit pipeline is the [ts-build-config note](../.agents/notes/implemented/process/2026-06-17-ts-build-config.md).\n\nIf a relevant local check consumes built package output, build once first:\n\n```sh\npnpm run build\n```\n\n`pnpm run hygiene` includes `publint`, which validates package entrypoints against the built `lib/*.js` files, and `verify-node-next-types`, which validates built declarations against a temporary NodeNext consumer. A fresh worktree has no bundled JS or declarations until `pnpm run build` runs; ordinary commits and pushes do not require that build unless their selected checks consume it.\n\n## Environment variables\n\nThe real DeepSeek adapter and key-backed agent demos read credentials from the environment or from a gitignored `.env` at the repo root:\n\n```sh\nDEEPSEEK_API_KEY=sk-...\nDEEPSEEK_BASE_URL=https://... # optional\n```\n\n`DEEPSEEK_BASE_URL` is optional and defaults to the public API. Never commit real credentials. The real-API e2e suites self-skip when `DEEPSEEK_API_KEY` is not set.\n\n## Git hooks\n\nlefthook is configured in `lefthook.yml` as a fast local checkpoint:\n\n- `pre-commit` runs staged-file ESLint fixes, checks the staged diff for whitespace errors, and runs the vendor manifest guard.\n- `pre-push` runs only the incremental repository typecheck (`tsc -b` over the root solution, covering both the host and client aggregates).\n\nThe vendor manifest guard checks that changes under `vendor/*/src` are staged with the matching `vendor/README.md` manifest update. See `vendor/README.md` before editing vendored code.\n\nThe hooks intentionally do not run tests, snapshots, documentation checks, builds, or hygiene. Contributors run the [checks relevant to the changed behavior](../AGENTS.md#run-relevant-checks-locally) once; CI owns exhaustive coverage, built-artifact smokes, and the Node 22.19, 24, and 26 compatibility matrix.\n\nContributors can opt into the comprehensive local gate set with `pnpm run check:all`. The command is independent of both Git hooks and is not an agent instruction.\n\n## CI gates\n\nThe keyless [CI workflow](../.github/workflows/ci.yml) groups independent gates into broad lanes and runs a smaller compatibility signal across supported Node versions. Artifact consumers wait for one build within their lane. The separate real-API workflow runs `pnpm run test:e2e` with its configured worker bound. See [scripts/run-gates.ts](../scripts/run-gates.ts) and the workflow files for the current gate and job inventory.\n\n## Daily commands\n\nUse these from the repo root:\n\n```sh\npnpm run test # unit tests\npnpm run test:coverage # unit tests with per-file coverage gates\npnpm run test:e2e # real-API tests; self-skips without DEEPSEEK_API_KEY\npnpm run check:all # comprehensive opt-in gate set; not wired to Git hooks\npnpm run typecheck # tsc -b over the root solution: emits package/vendor lib/types, checks both aggregates\npnpm run lint # eslint .\npnpm run lint:fix # eslint . --fix\npnpm run doc-typecheck # compile checked TypeScript snippets in Markdown docs\npnpm run gen-cordis-catalog # regenerate docs/cordis-catalog/events.md + services.md from source\npnpm run verify-cordis-catalog # fail if either cordis catalog is stale\npnpm run verify-export-jsdoc # fail if a module-level package export lacks complete JSDoc\npnpm run gen-doc-graphs # regenerate generated relationship docs from source and curated graph definitions\npnpm run verify-doc-graphs # fail if generated relationship docs are stale\npnpm run verify-md-wrap # fail on hard-wrapped prose paragraphs in docs/README markdown\npnpm run verify-mermaid # fail if a ```mermaid diagram has invalid Mermaid syntax\npnpm run verify-type-equiv # fail if a ```ts type-equiv doc block drifts from its source type\npnpm run verify-doc-budgets # fail if a budgeted standing doc exceeds its word ceiling\npnpm run gen-translation-brief # print the minimal-update briefing for out-of-sync translation pairs (--apply splices code-only edits)\npnpm run doc-sync # all Markdown/doc gates, scheduled concurrently; the doc-sync leaf list in scripts/run-gates.ts is the full list\npnpm run gen-module-graph # regenerate docs/module-graph.md from package peerDeps\npnpm run verify-module-graph # fail if docs/module-graph.md is stale\npnpm run build # emit lib/types intermediates, then bundle lib/index.* runtime files\npnpm run verify-node-next-types # fail if built declarations are not NodeNext-consumable\npnpm run hygiene # knip, publint, workspace constraints, and NodeNext declaration check\n```\n\nWhen changing package public behavior, update the relevant README or JSDoc in the same change. `pnpm run doc-sync` catches checked TypeScript snippets, generated doc freshness, markdown wrap/link drift, type equivalence, translation pairing, Mermaid syntax, and doc budgets, but broader prose/API sync still needs review.\n\n## Demos\n\nThe one-shot Headless coding agent needs `DEEPSEEK_API_KEY` in the environment or repo-root `.env`:\n\n```sh\npnpm run demo:headless \"summarize this workspace\"\n```\n\nThe full-screen interactive coding agent needs `DEEPSEEK_API_KEY` in the environment or repo-root `.env`:\n\n```sh\npnpm run demo:tui\n```\n\nThe self-referential cordis demo can inspect and modify its live plugin runtime and needs the same credentials (`web` by default, or `acp`):\n\n```sh\npnpm run demo:cordis\n```\n\nThe ACP automation server exposes fresh agent sessions over JSON-RPC stdio and also needs `DEEPSEEK_API_KEY`:\n\n```sh\npnpm run demo:acp\n```\n\n## TODO markers\n\nUse one of three comment tags to flag known issues in the code, ordered by urgency:\n\n- `FIXME` — an issue that should block a new release. A release should not ship with an open `FIXME` unless reviewers explicitly agree the change can be merged anyway.\n- `TODO` — an issue that should be fixed soon, once we have the resources.\n- `XXX` — an issue that we may fix someday; lowest priority, no commitment.\n\nPick the tag that matches the urgency so anyone scanning the code can tell a release blocker from a someday-maybe.\n\n## Documenting types verbatim (`ts type-equiv`)\n\nThe [core data structures](core-data-structures/core.md) docs paste source-equivalent declarations together with their original JSDoc so a reader sees the exact shape and source contract. To keep a paste from drifting when source changes, fence it as ` ```ts type-equiv ` (instead of ` ```ts `) and register it in `scripts/type-equiv.manifest.json` with the source file and symbol it mirrors:\n\n```json\n{ \"doc\": \"docs/core-data-structures/session.md\", \"symbol\": \"SessionEvent\", \"source\": \"packages/core/session/src/types.ts\" }\n```\n\n`pnpm run verify-type-equiv` (part of `doc-sync`) then extracts that symbol's declaration and attached JSDoc from source via the TypeScript parser and asserts the block matches both. For a class whose implementation bodies do not belong in the catalog, use ` ```ts public-api ` and set `\"projection\": \"public-api\"`; the checked projection retains the public fields, constructor, accessors, methods, and original class/member JSDoc while omitting bodies and private or protected members. Comparison ignores whitespace and non-JSDoc comments but requires every original JSDoc comment, including member documentation, so readers see the source contract beside the exact shape. The gate enforces a 1:1 correspondence by document, symbol, and projection between primary blocks and manifest entries; a paired `.zh.md` block reuses its unsuffixed sibling's entry only when the whole tracked fence sequence is byte-identical and ordered identically. `doc-typecheck` applies the same derivative rule to compilable fences, while skipping both source-equivalence fence kinds from compilation and its opt-out ratio. When you change a documented declaration or its JSDoc, the gate fails until you update the paste; when you add or remove a primary block, update the manifest in the same change.\n\n## Architecture context\n\nRead `docs/architecture.md` before changing anything under `packages/`. The codebase is built around Cordis plugins, event-sourced sessions, typed service seams, and explicit extension points.\n" }, { "role": "assistant", - "content": "# 开发指南\n\n[English](development.md) | 中文\n\n本指南覆盖参与 DeepSeek Harness 开发所需的本地环境搭建、日常工作流与 CI 流程;设计动机与技术权衡请查阅相应 Agent Note。\n\n## 前置条件\n\n- Node.js 支持 22.19+ 与 24+。CI 覆盖 22.19、24 和 26;见 [Node 引擎下限 Agent Note](../.agents/notes/implemented/process/2026-07-06-node-engine-floor.md)。\n- 启用了 Corepack 的 pnpm。仓库在 `package.json` 中固定使用 `pnpm@11.7.0`;如果 `pnpm --version` 无法通过 Corepack 解析,请先运行 `corepack enable`。\n- Git 2.26 或更高版本;钩子设置会启用 Git 的 worktree 专属配置扩展。\n- 可选:一个 DeepSeek API key,用于 TUI、headless 和 ACP(Agent Client Protocol)自动化 agent(智能体)演示以及真实 API 的 e2e 测试。\n\n## 首次搭建\n\n在仓库根目录安装依赖:\n\n```sh\npnpm install\n```\n\n安装过程同时会运行根目录的 `postinstall` 脚本,该脚本通过 `scripts/install-lefthook.mjs` 从仓库 dev 依赖安装 lefthook。当 `CI=true` 或 `GITHUB_ACTIONS=true` 时,该脚本会在探测 Git 前返回,因为自动化任务不会使用贡献者钩子。否则,包装脚本要求使用 Git 2.26 或更高版本,并会为当前 worktree 在其自身的 Git 目录下设置显式钩子目录;因此,关联 worktree 会使用各自的 lefthook 二进制文件和配置,而不会改写共用钩子。首次安装会启用 Git 的 worktree 专属配置扩展和仓库格式 1;见 [worktree 本地钩子 Agent Note](../.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.md)。\n\n如果依赖是从缓存恢复或 `postinstall` 被跳过而导致缺少钩子,请手动安装:\n\n```sh\nnode scripts/install-lefthook.mjs\n```\n\n包装层会拒绝用户自有的 `core.hooksPath` 值。继承自系统、全局或共用仓库配置的路径必须设置 `DSH_LEFTHOOK_ALLOW_HOOKS_PATH_OVERRIDE=1`。当 Git 使用另一个已注册 worktree 中由所有权标记佐证的钩子路径初始化新 worktree 时,包装层会将这个复制值替换为新 worktree 自有的路径;命令作用域和其他 worktree 作用域的路径必须显式集成或移除。\n\n启用 worktree 配置之前,请迁移格式 0 共用配置中直接设置的 `extensions.*`,并迁移直接设置的 `core.worktree` 或 `core.bare=true`,以及任何非空且尚未生效的 `config.worktree`。共用配置和每个 worktree 配置都必须是常规文件,而自有钩子目录只能包含不带别名的常规文件。\n\n检出目录移动后,请重新运行包装层,使其重新定位自有路径并重新生成钩子。对于陈旧或无效的安装程序锁,请先确认没有安装程序正在运行,再移除报告的锁并重试。若安装和钩子路径回滚都失败,请在重试前检查报告的 worktree 配置。完整安全契约由 [worktree 本地钩子 Agent Note](../.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.md) 统一定义。\n\n新克隆后请先运行一次类型检查:\n\n```sh\npnpm run typecheck\n```\n\n首次类型检查会执行全仓 `tsc -b tsconfig.json` 图:发射每个 package/vendor 的 `lib/types`,并通过下述两个 no-emit 聚合检查示例、测试和脚本。\n\n## TypeScript 项目布局\n\n仓库的 TypeScript 配置只有三种角色;每个 tsconfig 文件恰好扮演其中一种。\n\n| 文件 | 角色 | 是否构成 program? |\n|---|---|---|\n| `tsconfig.json` | solution 根:`extends` base、`files: []`、引用两个聚合。全仓 `tsc -b tsconfig.json` 图、tsserver 发现入口,并经继承的 `paths` 充当 tsx 运行 `examples/` 与 `scripts/` 时的解析配置(它们最近的 tsconfig 就是此文件)。 | 否 |\n| `tsconfig.host.json` | host 聚合:host 侧各包(经 references)、示例、测试、脚本、website。排除 `packages/client`。 | 是 |\n| `tsconfig.client.json` | client 聚合:`packages/client/*` 各包及其测试、`apps/web`。 | 是 |\n| `tsconfig.base.json` | 共享 compilerOptions 与源码 `paths` 映射。同时是各 vitest 配置让 vite-tsconfig-paths 指向的解析门面:它没有 `include`,因此其 `paths` 适用于任何 importer。 | 否 |\n| `tsconfig.base.client.json` | 浏览器编译形状(`jsx`、DOM lib、`types: []`),由 client 聚合和每个 `packages/client/*` 包 extends。 | 否 |\n\nhost 与 client 保持两个聚合 program,是因为两侧在相同键下以不同服务对 cordis `Context` 接口做声明合并;单一 program 同时看到两份合并会报冲突。这种冲突只存在于 `ts.Program` 内部——模块解析永远不会触发它——所以 solution 可以同时引用两个聚合,一个 paths 门面也可以横跨两侧。由此推出两条纪律:\n\n- `tsconfig.base.json` 永不添加 `include` 或 `files`:它们会泄漏进每个 extends 它的包项目,并收窄门面的全匹配范围。\n- 构造全仓 `ts.Program` 的脚本显式种子 `tsconfig.host.json` 或 `tsconfig.client.json`——永不种子根 solution,因为把两个聚合展平进一个 program 会撞上 `Context` 合并冲突。基于 program 的生成器与门禁(`scripts/ts-project.ts` 的消费者、doc-typecheck standalone 模式)按决策仅覆盖 host 侧;client 侧只在出现真实需求时再获得基于 program 的工具。\n\n静态分析和测试通过 base 的 `paths` 映射把工作区 import 解析到 `src`,且必须在干净树上通过;消费构建产物 `lib/` 的门禁显式声明该依赖。决策记录:[solution-root note](../.agents/notes/implemented/process/2026-07-22-tsconfig-solution-root-two-aggregates.md);tsc-first 发射管线见 [ts-build-config note](../.agents/notes/implemented/process/2026-06-17-ts-build-config.md)。\n\n如果相关的本地检查需要使用构建后的包产物,请先构建一次:\n\n```sh\npnpm run build\n```\n\n`pnpm run hygiene` 包含 `publint`(用构建出的 `lib/*.js` 文件校验 package 入口点)和 `verify-node-next-types`(用一个临时的 NodeNext 消费方校验构建出的声明文件)。新 worktree 在 `pnpm run build` 运行之前没有打包的 JS 和声明文件;普通提交和推送无需构建,除非所选检查会使用这些产物。\n\n## 环境变量\n\n真实的 DeepSeek 适配器和需要密钥的 agent 演示从环境变量或仓库根目录一个被 gitignore 的 `.env` 文件读取凭证:\n\n```sh\nDEEPSEEK_API_KEY=sk-...\nDEEPSEEK_BASE_URL=https://... # optional\n```\n\n`DEEPSEEK_BASE_URL` 可选,默认为公开 API。请勿提交真实凭证。未设置 `DEEPSEEK_API_KEY` 时,真实 API 的 e2e 套件会自动跳过。\n\n## Git 钩子\n\nlefthook 在 `lefthook.yml` 中配置,作为快速的本地检查点:\n\n- `pre-commit` 运行对暂存文件的 ESLint 修复,检查暂存 diff 中的空白错误,并运行 vendor manifest(元数据清单)守卫;\n- `pre-push` 只运行仓库增量类型检查(对根 solution 执行 `tsc -b`,覆盖 host 与 client 两个聚合)。\n\nvendor manifest 守卫检查 `vendor/*/src` 下的改动是否连同对应的 `vendor/README.md` manifest 更新一起暂存。请在编辑 vendor 代码前先阅读 `vendor/README.md`。\n\n这些钩子有意不运行测试、快照、文档检查、构建或 `hygiene`。贡献者只运行一次[与改动行为相关的检查](../AGENTS.md#run-relevant-checks-locally);CI 负责全量覆盖率门禁、构建产物冒烟测试,以及 Node 22.19、24 和 26 兼容性矩阵。\n\n贡献者可以选择运行 `pnpm run check:all`,执行全面的本地门禁集。该命令独立于两个 Git 钩子,也不是对 agent 的指令。\n\n## CI 门禁\n\nkeyless [CI 工作流](../.github/workflows/ci.yml) 将独立门禁分组到若干宽粒度 lane,并在受支持的 Node 版本上运行一组较小的兼容性检查。产物消费方在各自 lane 内等待一次 build。单独的真实 API 工作流按其配置的 worker 上限运行 `pnpm run test:e2e`。当前门禁和 job 清单以 [scripts/run-gates.ts](../scripts/run-gates.ts) 和工作流文件为准。\n\n## 日常命令\n\n在仓库根目录使用:\n\n```sh\npnpm run test # unit tests\npnpm run test:coverage # unit tests with per-file coverage gates\npnpm run test:e2e # real-API tests; self-skips without DEEPSEEK_API_KEY\npnpm run check:all # comprehensive opt-in gate set; not wired to Git hooks\npnpm run typecheck # tsc -b over the root solution: emits package/vendor lib/types, checks both aggregates\npnpm run lint # eslint .\npnpm run lint:fix # eslint . --fix\npnpm run doc-typecheck # compile checked TypeScript snippets in Markdown docs\npnpm run gen-cordis-catalog # regenerate docs/cordis-catalog/events.md + services.md from source\npnpm run verify-cordis-catalog # fail if either cordis catalog is stale\npnpm run verify-export-jsdoc # fail if a module-level package export lacks complete JSDoc\npnpm run gen-doc-graphs # regenerate generated relationship docs from source and curated graph definitions\npnpm run verify-doc-graphs # fail if generated relationship docs are stale\npnpm run verify-md-wrap # fail on hard-wrapped prose paragraphs in docs/README markdown\npnpm run verify-mermaid # fail if a ```mermaid diagram has invalid Mermaid syntax\npnpm run verify-type-equiv # fail if a ```ts type-equiv doc block drifts from its source type\npnpm run verify-doc-budgets # fail if a budgeted standing doc exceeds its word ceiling\npnpm run gen-translation-brief # print the minimal-update briefing for out-of-sync translation pairs (--apply splices code-only edits)\npnpm run doc-sync # all Markdown/doc gates, scheduled concurrently; the doc-sync leaf list in scripts/run-gates.ts is the full list\npnpm run gen-module-graph # regenerate docs/module-graph.md from package peerDeps\npnpm run verify-module-graph # fail if docs/module-graph.md is stale\npnpm run build # emit lib/types intermediates, then bundle lib/index.* runtime files\npnpm run verify-node-next-types # fail if built declarations are not NodeNext-consumable\npnpm run hygiene # knip, publint, workspace constraints, and NodeNext declaration check\n```\n\n修改 package 的公开行为时,请在同一个变更中更新相关 README 或 JSDoc。`pnpm run doc-sync` 能检测到被检查的 TypeScript 片段、生成文档的新鲜度、Markdown 换行/链接漂移、type-equiv、翻译配对、Mermaid 语法和文档预算,但更广泛的行文/API 同步仍需评审把关。\n\n## 演示\n\n单次运行的 Headless coding agent 需要环境变量或仓库根目录 `.env` 中的 `DEEPSEEK_API_KEY`:\n\n```sh\npnpm run demo:headless \"summarize this workspace\"\n```\n\n全屏交互式 coding agent 需要环境变量或仓库根目录 `.env` 中的 `DEEPSEEK_API_KEY`:\n\n```sh\npnpm run demo:tui\n```\n\n自指的 cordis-agent 演示可以检查并修改其实时插件运行时,并需要相同的凭证:\n\n```sh\npnpm run demo:cordis\n```\n\nACP 自动化服务器通过 JSON-RPC stdio 提供全新 agent 会话,同样需要 `DEEPSEEK_API_KEY`:\n\n```sh\npnpm run demo:acp\n```\n\n## TODO 标记\n\n请使用以下三种注释标签之一标记代码中的已知问题,按紧急程度排序:\n\n- `FIXME`:应当阻塞新版本发布的问题。除非评审者明确同意该更改可以合并,否则发布版本不应包含未解决的 `FIXME`;\n- `TODO`:应当尽快修复的问题,等资源到位即可处理;\n- `XXX`:也许某天会修复的问题,优先级最低,不作承诺。\n\n请选择与紧急程度匹配的标签,让浏览代码的人一眼分清「发布阻塞」和「有空再说」。\n\n## 逐字记录类型(`ts type-equiv`)\n\n[核心数据结构](core-data-structures/core.md)文档会把与源码等价的声明及其原始 JSDoc 一并粘贴,让读者看到确切形状和源码契约。为防止粘贴内容在源码变化时漂移,请将其围栏为 ` ```ts type-equiv `(而不是 ` ```ts `),并在 `scripts/type-equiv.manifest.json` 中登记它镜像的源文件和符号:\n\n```json\n{ \"doc\": \"docs/core-data-structures/session.md\", \"symbol\": \"SessionEvent\", \"source\": \"packages/core/session/src/types.ts\" }\n```\n\n`pnpm run verify-type-equiv`(`doc-sync` 的一环)随后通过 TypeScript 解析器从源码提取该符号的声明及其附带的 JSDoc,并断言代码块同时匹配两者。对于不应把实现体写进目录的类,请使用 ` ```ts public-api ` 并设置 `\"projection\": \"public-api\"`;门禁检查的投影会保留公共字段、构造函数、访问器、方法以及类和成员的原始 JSDoc,同时省略实现体和私有或受保护成员。比对会忽略空白和非 JSDoc 注释,但要求保留每条原始 JSDoc(包括成员文档),让读者同时看到源码契约和确切形状。该门禁按文档、符号和投影,在主块与 manifest 条目之间强制 1:1 对应;只有当配对 `.zh.md` 块的完整受跟踪围栏序列与其无后缀兄弟文件按字节一致且顺序相同时,才会复用后者的条目。`doc-typecheck` 对可编译围栏应用同一派生规则,同时跳过两种源码等价围栏的编译,并将其排除在 opt-out 比例之外。当你改动一个已记录的类型声明或其 JSDoc 时,门禁会失败直到你更新粘贴内容;当你增删一个主块时,请在同一个变更里更新 manifest。\n\n## 架构上下文\n\n在修改 `packages/` 目录下的任何内容之前,请先阅读 `docs/architecture.md`。这套代码围绕 Cordis 插件、事件溯源的会话、类型化的服务 seam 与显式扩展点构建。\n" + "content": "# 开发指南\n\n[English](development.md) | 中文\n\n本指南覆盖参与 DeepSeek Harness 开发所需的本地环境搭建、日常工作流与 CI 流程;设计动机与技术权衡请查阅相应 Agent Note。\n\n## 前置条件\n\n- Node.js 支持 22.19+ 与 24+。CI 覆盖 22.19、24 和 26;见 [Node 引擎下限 Agent Note](../.agents/notes/implemented/process/2026-07-06-node-engine-floor.md)。\n- 启用了 Corepack 的 pnpm。仓库在 `package.json` 中固定使用 `pnpm@11.7.0`;如果 `pnpm --version` 无法通过 Corepack 解析,请先运行 `corepack enable`。\n- Git 2.26 或更高版本;钩子设置会启用 Git 的 worktree 专属配置扩展。\n- 可选:一个 DeepSeek API key,用于 TUI、headless 和 ACP(Agent Client Protocol)自动化 agent(智能体)演示以及真实 API 的 e2e 测试。\n\n## 首次搭建\n\n在仓库根目录安装依赖:\n\n```sh\npnpm install\n```\n\n安装过程同时会运行根目录的 `postinstall` 脚本,该脚本通过 `scripts/install-lefthook.mjs` 从仓库 dev 依赖安装 lefthook。当 `CI=true` 或 `GITHUB_ACTIONS=true` 时,该脚本会在探测 Git 前返回,因为自动化任务不会使用贡献者钩子。否则,包装脚本要求使用 Git 2.26 或更高版本,并会为当前 worktree 在其自身的 Git 目录下设置显式钩子目录;因此,关联 worktree 会使用各自的 lefthook 二进制文件和配置,而不会改写共用钩子。首次安装会启用 Git 的 worktree 专属配置扩展和仓库格式 1;见 [worktree 本地钩子 Agent Note](../.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.md)。\n\n如果依赖是从缓存恢复或 `postinstall` 被跳过而导致缺少钩子,请手动安装:\n\n```sh\nnode scripts/install-lefthook.mjs\n```\n\n包装层会拒绝用户自有的 `core.hooksPath` 值。继承自系统、全局或共用仓库配置的路径必须设置 `DSH_LEFTHOOK_ALLOW_HOOKS_PATH_OVERRIDE=1`;命令作用域和 worktree 作用域的自定义路径必须显式集成或移除。\n\n启用 worktree 配置之前,请迁移格式 0 共用配置中直接设置的 `extensions.*`,并迁移直接设置的 `core.worktree` 或 `core.bare=true`,以及任何非空且尚未生效的 `config.worktree`。共用配置和每个 worktree 配置都必须是常规文件,而自有钩子目录只能包含不带别名的常规文件。\n\n检出目录移动后,请重新运行包装层,使其重新定位自有路径并重新生成钩子。对于陈旧或无效的安装程序锁,请先确认没有安装程序正在运行,再移除报告的锁并重试。若安装和钩子路径回滚都失败,请在重试前检查报告的 worktree 配置。完整安全契约由 [worktree 本地钩子 Agent Note](../.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.md) 统一定义。\n\n新克隆后请先运行一次类型检查:\n\n```sh\npnpm run typecheck\n```\n\n首次类型检查会执行全仓 `tsc -b tsconfig.json` 图:发射每个 package/vendor 的 `lib/types`,并通过下述两个 no-emit 聚合检查示例、测试和脚本。\n\n## TypeScript 项目布局\n\n仓库的 TypeScript 配置只有三种角色;每个 tsconfig 文件恰好扮演其中一种。\n\n| 文件 | 角色 | 是否构成 program? |\n|---|---|---|\n| `tsconfig.json` | solution 根:`extends` base、`files: []`、引用两个聚合。全仓 `tsc -b tsconfig.json` 图、tsserver 发现入口,并经继承的 `paths` 充当 tsx 运行 `examples/` 与 `scripts/` 时的解析配置(它们最近的 tsconfig 就是此文件)。 | 否 |\n| `tsconfig.host.json` | host 聚合:host 侧各包(经 references)、示例、测试、脚本、website。排除 `packages/client`。 | 是 |\n| `tsconfig.client.json` | client 聚合:`packages/client/*` 各包及其测试、`apps/web`。 | 是 |\n| `tsconfig.base.json` | 共享 compilerOptions 与源码 `paths` 映射。同时是各 vitest 配置让 vite-tsconfig-paths 指向的解析门面:它没有 `include`,因此其 `paths` 适用于任何 importer。 | 否 |\n| `tsconfig.base.client.json` | 浏览器编译形状(`jsx`、DOM lib、`types: []`),由 client 聚合和每个 `packages/client/*` 包 extends。 | 否 |\n\nhost 与 client 保持两个聚合 program,是因为两侧在相同键下以不同服务对 cordis `Context` 接口做声明合并;单一 program 同时看到两份合并会报冲突。这种冲突只存在于 `ts.Program` 内部——模块解析永远不会触发它——所以 solution 可以同时引用两个聚合,一个 paths 门面也可以横跨两侧。由此推出两条纪律:\n\n- `tsconfig.base.json` 永不添加 `include` 或 `files`:它们会泄漏进每个 extends 它的包项目,并收窄门面的全匹配范围。\n- 构造全仓 `ts.Program` 的脚本显式种子 `tsconfig.host.json` 或 `tsconfig.client.json`——永不种子根 solution,因为把两个聚合展平进一个 program 会撞上 `Context` 合并冲突。基于 program 的生成器与门禁(`scripts/ts-project.ts` 的消费者、doc-typecheck standalone 模式)按决策仅覆盖 host 侧;client 侧只在出现真实需求时再获得基于 program 的工具。\n\n静态分析和测试通过 base 的 `paths` 映射把工作区 import 解析到 `src`,且必须在干净树上通过;消费构建产物 `lib/` 的门禁显式声明该依赖。决策记录:[solution-root note](../.agents/notes/implemented/process/2026-07-22-tsconfig-solution-root-two-aggregates.md);tsc-first 发射管线见 [ts-build-config note](../.agents/notes/implemented/process/2026-06-17-ts-build-config.md)。\n\n如果相关的本地检查需要使用构建后的包产物,请先构建一次:\n\n```sh\npnpm run build\n```\n\n`pnpm run hygiene` 包含 `publint`(用构建出的 `lib/*.js` 文件校验 package 入口点)和 `verify-node-next-types`(用一个临时的 NodeNext 消费方校验构建出的声明文件)。新 worktree 在 `pnpm run build` 运行之前没有打包的 JS 和声明文件;普通提交和推送无需构建,除非所选检查会使用这些产物。\n\n## 环境变量\n\n真实的 DeepSeek 适配器和需要密钥的 agent 演示从环境变量或仓库根目录一个被 gitignore 的 `.env` 文件读取凭证:\n\n```sh\nDEEPSEEK_API_KEY=sk-...\nDEEPSEEK_BASE_URL=https://... # optional\n```\n\n`DEEPSEEK_BASE_URL` 可选,默认为公开 API。请勿提交真实凭证。未设置 `DEEPSEEK_API_KEY` 时,真实 API 的 e2e 套件会自动跳过。\n\n## Git 钩子\n\nlefthook 在 `lefthook.yml` 中配置,作为快速的本地检查点:\n\n- `pre-commit` 运行对暂存文件的 ESLint 修复,检查暂存 diff 中的空白错误,并运行 vendor manifest(元数据清单)守卫;\n- `pre-push` 只运行仓库增量类型检查(对根 solution 执行 `tsc -b`,覆盖 host 与 client 两个聚合)。\n\nvendor manifest 守卫检查 `vendor/*/src` 下的改动是否连同对应的 `vendor/README.md` manifest 更新一起暂存。请在编辑 vendor 代码前先阅读 `vendor/README.md`。\n\n这些钩子有意不运行测试、快照、文档检查、构建或 `hygiene`。贡献者只运行一次[与改动行为相关的检查](../AGENTS.md#run-relevant-checks-locally);CI 负责全量覆盖率门禁、构建产物冒烟测试,以及 Node 22.19、24 和 26 兼容性矩阵。\n\n贡献者可以选择运行 `pnpm run check:all`,执行全面的本地门禁集。该命令独立于两个 Git 钩子,也不是对 agent 的指令。\n\n## CI 门禁\n\nkeyless [CI 工作流](../.github/workflows/ci.yml) 将独立门禁分组到若干宽粒度 lane,并在受支持的 Node 版本上运行一组较小的兼容性检查。产物消费方在各自 lane 内等待一次 build。单独的真实 API 工作流按其配置的 worker 上限运行 `pnpm run test:e2e`。当前门禁和 job 清单以 [scripts/run-gates.ts](../scripts/run-gates.ts) 和工作流文件为准。\n\n## 日常命令\n\n在仓库根目录使用:\n\n```sh\npnpm run test # unit tests\npnpm run test:coverage # unit tests with per-file coverage gates\npnpm run test:e2e # real-API tests; self-skips without DEEPSEEK_API_KEY\npnpm run check:all # comprehensive opt-in gate set; not wired to Git hooks\npnpm run typecheck # tsc -b over the root solution: emits package/vendor lib/types, checks both aggregates\npnpm run lint # eslint .\npnpm run lint:fix # eslint . --fix\npnpm run doc-typecheck # compile checked TypeScript snippets in Markdown docs\npnpm run gen-cordis-catalog # regenerate docs/cordis-catalog/events.md + services.md from source\npnpm run verify-cordis-catalog # fail if either cordis catalog is stale\npnpm run verify-export-jsdoc # fail if a module-level package export lacks complete JSDoc\npnpm run gen-doc-graphs # regenerate generated relationship docs from source and curated graph definitions\npnpm run verify-doc-graphs # fail if generated relationship docs are stale\npnpm run verify-md-wrap # fail on hard-wrapped prose paragraphs in docs/README markdown\npnpm run verify-mermaid # fail if a ```mermaid diagram has invalid Mermaid syntax\npnpm run verify-type-equiv # fail if a ```ts type-equiv doc block drifts from its source type\npnpm run verify-doc-budgets # fail if a budgeted standing doc exceeds its word ceiling\npnpm run gen-translation-brief # print the minimal-update briefing for out-of-sync translation pairs (--apply splices code-only edits)\npnpm run doc-sync # all Markdown/doc gates, scheduled concurrently; the doc-sync leaf list in scripts/run-gates.ts is the full list\npnpm run gen-module-graph # regenerate docs/module-graph.md from package peerDeps\npnpm run verify-module-graph # fail if docs/module-graph.md is stale\npnpm run build # emit lib/types intermediates, then bundle lib/index.* runtime files\npnpm run verify-node-next-types # fail if built declarations are not NodeNext-consumable\npnpm run hygiene # knip, publint, workspace constraints, and NodeNext declaration check\n```\n\n修改 package 的公开行为时,请在同一个变更中更新相关 README 或 JSDoc。`pnpm run doc-sync` 能检测到被检查的 TypeScript 片段、生成文档的新鲜度、Markdown 换行/链接漂移、type-equiv、翻译配对、Mermaid 语法和文档预算,但更广泛的行文/API 同步仍需评审把关。\n\n## 演示\n\n单次运行的 Headless coding agent 需要环境变量或仓库根目录 `.env` 中的 `DEEPSEEK_API_KEY`:\n\n```sh\npnpm run demo:headless \"summarize this workspace\"\n```\n\n全屏交互式 coding agent 需要环境变量或仓库根目录 `.env` 中的 `DEEPSEEK_API_KEY`:\n\n```sh\npnpm run demo:tui\n```\n\n自指的 cordis 演示可以检查并修改其实时插件运行时,并需要相同的凭证(默认 `web`,也可用 `acp`):\n\n```sh\npnpm run demo:cordis\n```\n\nACP 自动化服务器通过 JSON-RPC stdio 提供全新 agent 会话,同样需要 `DEEPSEEK_API_KEY`:\n\n```sh\npnpm run demo:acp\n```\n\n## TODO 标记\n\n请使用以下三种注释标签之一标记代码中的已知问题,按紧急程度排序:\n\n- `FIXME`:应当阻塞新版本发布的问题。除非评审者明确同意该更改可以合并,否则发布版本不应包含未解决的 `FIXME`;\n- `TODO`:应当尽快修复的问题,等资源到位即可处理;\n- `XXX`:也许某天会修复的问题,优先级最低,不作承诺。\n\n请选择与紧急程度匹配的标签,让浏览代码的人一眼分清「发布阻塞」和「有空再说」。\n\n## 逐字记录类型(`ts type-equiv`)\n\n[核心数据结构](core-data-structures/core.md)文档会把与源码等价的声明及其原始 JSDoc 一并粘贴,让读者看到确切形状和源码契约。为防止粘贴内容在源码变化时漂移,请将其围栏为 ` ```ts type-equiv `(而不是 ` ```ts `),并在 `scripts/type-equiv.manifest.json` 中登记它镜像的源文件和符号:\n\n```json\n{ \"doc\": \"docs/core-data-structures/session.md\", \"symbol\": \"SessionEvent\", \"source\": \"packages/core/session/src/types.ts\" }\n```\n\n`pnpm run verify-type-equiv`(`doc-sync` 的一环)随后通过 TypeScript 解析器从源码提取该符号的声明及其附带的 JSDoc,并断言代码块同时匹配两者。对于不应把实现体写进目录的类,请使用 ` ```ts public-api ` 并设置 `\"projection\": \"public-api\"`;门禁检查的投影会保留公共字段、构造函数、访问器、方法以及类和成员的原始 JSDoc,同时省略实现体和私有或受保护成员。比对会忽略空白和非 JSDoc 注释,但要求保留每条原始 JSDoc(包括成员文档),让读者同时看到源码契约和确切形状。该门禁按文档、符号和投影,在主块与 manifest 条目之间强制 1:1 对应;只有当配对 `.zh.md` 块的完整受跟踪围栏序列与其无后缀兄弟文件按字节一致且顺序相同时,才会复用后者的条目。`doc-typecheck` 对可编译围栏应用同一派生规则,同时跳过两种源码等价围栏的编译,并将其排除在 opt-out 比例之外。当你改动一个已记录的类型声明或其 JSDoc 时,门禁会失败直到你更新粘贴内容;当你增删一个主块时,请在同一个变更里更新 manifest。\n\n## 架构上下文\n\n在修改 `packages/` 目录下的任何内容之前,请先阅读 `docs/architecture.md`。这套代码围绕 Cordis 插件、事件溯源的会话、类型化的服务 seam 与显式扩展点构建。\n" }, { "role": "user", diff --git a/scripts/verify-cordis-config.ts b/scripts/verify-cordis-config.ts index 66d6e0f2a3..b0f7b06eb6 100644 --- a/scripts/verify-cordis-config.ts +++ b/scripts/verify-cordis-config.ts @@ -124,7 +124,7 @@ function validateExampleResolution(): string[] { function validateAppResolution(): string[] { const dependencies = readManifest('apps/cli/package.json').dependencies ?? {} - const references = pluginReferences.filter(reference => reference.file === 'apps/cli/cordis.yml') + const references = pluginReferences.filter(reference => reference.file === 'apps/cli/base.cordis.yml') return missingPluginDependencies(references, dependencies, 'apps/cli/package.json') } diff --git a/tsconfig.base.json b/tsconfig.base.json index 62033becd0..ba1f88779c 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -57,6 +57,7 @@ "@deepseek-ai/dsh-tools/presentation": ["./packages/core/tools/src/presentation.ts"], "@deepseek-ai/dsh-user-approval/types": ["./packages/ui/user-approval/src/types.ts"], "@deepseek-ai/dsh-user-interaction/types": ["./packages/ui/user-interaction/src/types.ts"], + "@deepseek-ai/dsh-tui/prompt": ["./packages/ui/tui/src/prompt.ts"], "@deepseek-ai/dsh-agent/invariant": ["./packages/core/agent/src/invariant.ts"], "@deepseek-ai/dsh-scope/invariant": ["./packages/core/scope/src/invariant.ts"], "@deepseek-ai/dsh-agent-loop/invariant": ["./packages/core/agent-loop/src/invariant.ts"], diff --git a/tsconfig.host.json b/tsconfig.host.json index 71157bca2a..51ed1cdc3b 100644 --- a/tsconfig.host.json +++ b/tsconfig.host.json @@ -144,7 +144,6 @@ { "path": "./packages/ui/jsonrpc" }, { "path": "./packages/examples/jsonrpc-demo" }, { "path": "./packages/ui/tui" }, - { "path": "./packages/examples/tui-demo" }, { "path": "./packages/support/llm-replay" }, { "path": "./packages/support/acp-snapshot" }, { "path": "./packages/support/loader-smoke" }, diff --git a/vendor/README.md b/vendor/README.md index a7e3183841..28277733c9 100644 --- a/vendor/README.md +++ b/vendor/README.md @@ -37,7 +37,7 @@ Keep this log exhaustive — every divergence from upstream must be listed. 5. **`schemastery/tsdown.config.ts` and `logger-console/tsdown.config.ts`**: ours, not upstream files — per-package build-shape overrides (dual ESM+CJS output; separate node/browser entries) for the repo-root tsdown build. They read the JS emitted under `lib/types` and then write the publish runtime entries under `lib/`. Like the regenerated tsconfigs, they are not part of the upstream sync surface. 6. **`cordis/src/fiber.ts` lifecycle hardening**: locally closes three reentrant disposal gaps. An effect's owner-list wrapper is registered before its setup body runs, so an unload begun from inside setup awaits setup and every collected cleanup; synchronous setup failure removes the wrapper and rolls back collected cleanup. Async cleanup stays owner-visible until quiescence, and Cordis's internal effect composition joins an already-running cleanup while repeated public disposer calls retain their upstream single-shot result. Effect creation is rejected while the owner is `UNLOADING` (while `PENDING` and `LOADING` remain legal), preventing cleanup-time registrations from escaping the unload snapshot. Child fibers register and receive their parent-owned disposer before `internal/plugin` publication, resolve dependency declarations added by that notification before activation, drain effects attached while pending, skip plugin execution when reentrant disposal invalidates the load epoch before its first checkpoint, and contain teardown-notification failures per observer so one callback cannot starve peers or interrupt ownership cleanup. 7. **`cordis/src/*.ts` JSDoc enrichment**: added `@param`/`@returns` tags and contract documentation (disposal semantics, waterfall veto, bail conditions, error cases) across the public plugin-author surface — `Context` (class, statics, and the `Context` interface properties incl. `root`), `EventsService`, `Fiber`, `RegistryService`, `ReflectService`, `Service`, `LoggerService` and their `declare module './context.ts'` overloads. Comment-only; no code changes. Motivation: the website API-reference generator renders these docs and hard-errors on undocumented members. Retire this entry when the enrichment is upstreamed to the fork. -8. **`include/src/index.ts` hot-reload hardening**: `refresh()` awaits the full read-and-update and catches failures (logging a warning and keeping the last good entry tree) instead of rethrowing — upstream's throw escaped `@cordisjs/plugin-hmr`'s async watcher callback as an unhandled rejection, so one bad `cordis.yml` edit killed a live app. `read()` rejects a non-array parse result (an empty or mid-write truncated file parses to `undefined`, which upstream later crashed on) and commits `content`/`data` only on success, so reverting an edit to the exact last good content reads as "unchanged". `refresh()` and the `internal/update` listener re-apply `config.patches` before `root.update()`, matching initial load; upstream applied patches only in `[Service.init]`, so any config hot-reload silently reverted overlay-patched entries and removed inserted ones. `applyPatches` deep-copies via `structuredClone` instead of mutating the cached parse (repeated application converges; removing a patch reverts), and the veto-style `internal/update` listener persists the incoming config itself (`Fiber.update` only assigns behind `next()`), so later re-reads use the new patches. `[Service.init]` falls back to `initial` only on `ENOENT`; an existing-but-invalid file fails loud with its real parse error instead of "config file not found" (or a silent overwrite). Covered by `packages/ui/app-boot/tests/config-reload.spec.ts`. +8. **`include/src/index.ts` hot-reload hardening**: `refresh()` awaits the full read-and-update and catches failures (logging a warning and keeping the last good entry tree) instead of rethrowing — upstream's throw escaped `@cordisjs/plugin-hmr`'s async watcher callback as an unhandled rejection, so one bad `cordis.yml` edit killed a live app. `read()` rejects a non-array parse result (an empty or mid-write truncated file parses to `undefined`, which upstream later crashed on) and commits `content`/`data` only on success, so reverting an edit to the exact last good content reads as "unchanged". `refresh()` and the `internal/update` listener re-apply `config.patches` before `root.update()`, matching initial load; upstream applied patches only in `[Service.init]`, so any config hot-reload silently reverted overlay-patched entries and removed inserted ones. `applyPatches` deep-copies via `structuredClone` instead of mutating the cached parse (repeated application converges; removing a patch reverts), and the veto-style `internal/update` listener persists the incoming config itself (`Fiber.update` only assigns behind `next()`), so later re-reads use the new patches. `[Service.init]` falls back to `initial` only on `ENOENT`; an existing-but-invalid file fails loud with its real parse error instead of "config file not found" (or a silent overwrite). `applyPatches` also indexes each `insert`ed entry as it is added, so a later patch in the same list can configure or disable a row an earlier patch inserted; upstream built the id index once before the patch loop, leaving inserted rows silently unpatchable. That matters because `dsh` composes one shared base (`apps/cli/base.cordis.yml`) with a surface overlay, an optional `--config` overlay, and the personal `~/.dsh/config.yaml` as sibling patch lists at one include level — patches never cross an include boundary, so surface-only rows would otherwise be unreachable from user config. Covered by `packages/ui/app-boot/tests/config-reload.spec.ts`. 9. **Vendored Node-compatible TypeScript**: marked erased imports explicitly across `cordis`, `loader`, `include`, `hmr`, and `schemastery` so Node's native TypeScript transform does not request types as runtime exports. Schemastery's source uses an ESM default export and its package declares `type: module`; its built ESM/CJS entries retain explicit `.mjs`/`.cjs` extensions. ## Sync procedure diff --git a/vendor/include/src/index.ts b/vendor/include/src/index.ts index 2ca8fb41ad..29f6a3a951 100644 --- a/vendor/include/src/index.ts +++ b/vendor/include/src/index.ts @@ -163,6 +163,12 @@ export class Include extends EntryTree { } else { data.push(...insert) } + // Index what this patch added so a LATER patch in the same list can + // target it. Patch lists compose one layer per source (surface overlay, + // then `--config`, then the user's), and a layer must be able to + // configure or disable a row an earlier layer inserted; without this, + // inserted rows were silently unpatchable. + buildMap(insert) continue } diff --git a/vitest.snapshot.config.ts b/vitest.snapshot.config.ts index 2ccec62702..b14422d2f8 100644 --- a/vitest.snapshot.config.ts +++ b/vitest.snapshot.config.ts @@ -48,6 +48,8 @@ export default defineConfig({ // mode remains the zero-build path, while lib mode requires a prior build. ...(process.env.DSH_EXAMPLE_MODE === 'lib' ? ['apps/web/tests/**/*.snapshot.ts'] : []), 'examples/*/tests/**/*.snapshot.ts', + // The shipped TUI's terminal-journey scenarios moved here with its config. + 'apps/cli/tests/**/*.snapshot.ts', 'packages/sdk/*/tests/**/*.snapshot.ts', 'packages/ui/tui/tests/**/*.snapshot.ts', ], From 10ff76de9c8f57238106b21ab04f5c92cb3a9c71 Mon Sep 17 00:00:00 2001 From: Turtle Date: Wed, 29 Jul 2026 14:05:58 +0800 Subject: [PATCH 015/113] fix(cli): declare every plugin the shipped config trees name The Loader resolves each config row against the composing app, so moving the TUI composition from examples/ into apps/cli left eleven rows unresolvable from the built bin: `node apps/cli/lib/bin.js` died at boot with "plugin(s) failed to load". Source-mode runs hid it, because tsconfig paths resolve the whole workspace regardless of who declares what. Also declares `dsh-llm-pi-ai` and `dsh-tmux-context`, which a personal `~/.dsh/config.yaml` inserts to swap the model adapter. Those previously resolved only because the config lived under examples/, whose package.json declares the union of every leaf's plugins. Caught by running the built bin from a scratch directory, which is how a user actually starts it. --- apps/cli/package.json | 28 +++--- pnpm-lock.yaml | 205 ++++++++++-------------------------------- 2 files changed, 67 insertions(+), 166 deletions(-) diff --git a/apps/cli/package.json b/apps/cli/package.json index c815cf037a..3c61f474aa 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -14,13 +14,14 @@ ], "license": "BSD-3-Clause", "dependencies": { + "@cordisjs/plugin-hmr": "workspace:*", "@cordisjs/plugin-include": "workspace:*", "@cordisjs/plugin-loader": "workspace:*", "@cordisjs/plugin-timer": "workspace:*", "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-agent-loop": "workspace:^", "@deepseek-ai/dsh-app-boot": "workspace:^", - "@deepseek-ai/dsh-bash-sandbox": "workspace:^", + "@deepseek-ai/dsh-bash-local": "workspace:^", "@deepseek-ai/dsh-client-connection": "workspace:^", "@deepseek-ai/dsh-client-hmr": "workspace:^", "@deepseek-ai/dsh-client-locale": "workspace:^", @@ -32,7 +33,6 @@ "@deepseek-ai/dsh-client-ui-layout": "workspace:^", "@deepseek-ai/dsh-client-ui-model": "workspace:^", "@deepseek-ai/dsh-client-ui-models": "workspace:^", - "@deepseek-ai/dsh-client-ui-permission": "workspace:^", "@deepseek-ai/dsh-client-ui-plan": "workspace:^", "@deepseek-ai/dsh-client-ui-question": "workspace:^", "@deepseek-ai/dsh-client-ui-settings": "workspace:^", @@ -48,28 +48,30 @@ "@deepseek-ai/dsh-command-goal": "workspace:^", "@deepseek-ai/dsh-commands": "workspace:^", "@deepseek-ai/dsh-compact-basic": "workspace:^", + "@deepseek-ai/dsh-compact-tool-result-prune": "workspace:^", "@deepseek-ai/dsh-frontend": "workspace:^", + "@deepseek-ai/dsh-fs-local": "workspace:^", "@deepseek-ai/dsh-fs-policy": "workspace:^", - "@deepseek-ai/dsh-fs-sandbox": "workspace:^", "@deepseek-ai/dsh-goal": "workspace:^", "@deepseek-ai/dsh-goal-session": "workspace:^", + "@deepseek-ai/dsh-helper": "workspace:^", "@deepseek-ai/dsh-host-apiproxy": "workspace:^", - "@deepseek-ai/dsh-host-directory-picker-browse": "workspace:^", - "@deepseek-ai/dsh-host-directory-picker-native": "workspace:^", "@deepseek-ai/dsh-host-webserver": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-llm-deepseek": "workspace:^", "@deepseek-ai/dsh-llm-pi-ai": "workspace:^", "@deepseek-ai/dsh-llm-retry": "workspace:^", "@deepseek-ai/dsh-paths": "workspace:^", - "@deepseek-ai/dsh-permission": "workspace:^", "@deepseek-ai/dsh-plan-mode": "workspace:^", - "@deepseek-ai/dsh-sandbox-local": "workspace:^", - "@deepseek-ai/dsh-sandbox-policy": "workspace:^", + "@deepseek-ai/dsh-scope": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/dsh-session-checkpoint-policy": "workspace:^", "@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^", "@deepseek-ai/dsh-session-projection": "workspace:^", "@deepseek-ai/dsh-session-projection-cache": "workspace:^", + "@deepseek-ai/dsh-session-query-sqlite": "workspace:^", + "@deepseek-ai/dsh-session-reference": "workspace:^", "@deepseek-ai/dsh-session-registry": "workspace:^", "@deepseek-ai/dsh-session-registry-file": "workspace:^", "@deepseek-ai/dsh-session-registry-live": "workspace:^", @@ -77,6 +79,7 @@ "@deepseek-ai/dsh-session-title-first-message-llm": "workspace:^", "@deepseek-ai/dsh-skill": "workspace:^", "@deepseek-ai/dsh-skill-local": "workspace:^", + "@deepseek-ai/dsh-source-guard": "workspace:^", "@deepseek-ai/dsh-spill-local": "workspace:^", "@deepseek-ai/dsh-spill-policy": "workspace:^", "@deepseek-ai/dsh-storage": "workspace:^", @@ -89,10 +92,14 @@ "@deepseek-ai/dsh-system-prompt": "workspace:^", "@deepseek-ai/dsh-tasks-local": "workspace:^", "@deepseek-ai/dsh-timeout-policy": "workspace:^", + "@deepseek-ai/dsh-tmux-context": "workspace:^", "@deepseek-ai/dsh-token-meter": "workspace:^", + "@deepseek-ai/dsh-tool-ask-user": "workspace:^", "@deepseek-ai/dsh-tool-bash": "workspace:^", "@deepseek-ai/dsh-tool-fs": "workspace:^", "@deepseek-ai/dsh-tool-fs-search": "workspace:^", + "@deepseek-ai/dsh-tool-goal": "workspace:^", + "@deepseek-ai/dsh-tool-ralph": "workspace:^", "@deepseek-ai/dsh-tool-skill": "workspace:^", "@deepseek-ai/dsh-tool-subagent": "workspace:^", "@deepseek-ai/dsh-tool-tasks": "workspace:^", @@ -100,14 +107,15 @@ "@deepseek-ai/dsh-tool-workflow": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", "@deepseek-ai/dsh-tui": "workspace:^", - "@deepseek-ai/dsh-user-approval": "workspace:^", "@deepseek-ai/dsh-user-interaction": "workspace:^", "@deepseek-ai/dsh-workflow-workerthread": "workspace:^", "@deepseek-ai/dsh-workspace": "workspace:^", "@deepseek-ai/dsh-workspace-context": "workspace:^", + "@earendil-works/pi-ai": "^0.82.1", "commander": "^15.0.0", "cordis": "^4.0.0-rc.7", - "js-yaml": "^4.2.0" + "js-yaml": "^4.2.0", + "yaml": "^2.9.0" }, "devDependencies": { "@types/js-yaml": "^4.0.9", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9355d06117..02c6d0fd73 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -110,6 +110,9 @@ importers: apps/cli: dependencies: + '@cordisjs/plugin-hmr': + specifier: workspace:* + version: link:../../vendor/hmr '@cordisjs/plugin-include': specifier: workspace:* version: link:../../vendor/include @@ -128,9 +131,9 @@ importers: '@deepseek-ai/dsh-app-boot': specifier: workspace:^ version: link:../../packages/ui/app-boot - '@deepseek-ai/dsh-bash-sandbox': + '@deepseek-ai/dsh-bash-local': specifier: workspace:^ - version: link:../../packages/bash/bash-sandbox + version: link:../../packages/bash/bash-local '@deepseek-ai/dsh-client-connection': specifier: workspace:^ version: link:../../packages/client/connection @@ -164,9 +167,6 @@ importers: '@deepseek-ai/dsh-client-ui-models': specifier: workspace:^ version: link:../../packages/client/ui-models - '@deepseek-ai/dsh-client-ui-permission': - specifier: workspace:^ - version: link:../../packages/client/ui-permission '@deepseek-ai/dsh-client-ui-plan': specifier: workspace:^ version: link:../../packages/client/ui-plan @@ -212,33 +212,36 @@ importers: '@deepseek-ai/dsh-compact-basic': specifier: workspace:^ version: link:../../packages/compact/compact-basic + '@deepseek-ai/dsh-compact-tool-result-prune': + specifier: workspace:^ + version: link:../../packages/compact/compact-tool-result-prune '@deepseek-ai/dsh-frontend': specifier: workspace:^ version: link:../web + '@deepseek-ai/dsh-fs-local': + specifier: workspace:^ + version: link:../../packages/fs/fs-local '@deepseek-ai/dsh-fs-policy': specifier: workspace:^ version: link:../../packages/fs/fs-policy - '@deepseek-ai/dsh-fs-sandbox': - specifier: workspace:^ - version: link:../../packages/fs/fs-sandbox '@deepseek-ai/dsh-goal': specifier: workspace:^ version: link:../../packages/goal/goal '@deepseek-ai/dsh-goal-session': specifier: workspace:^ version: link:../../packages/goal/goal-session + '@deepseek-ai/dsh-helper': + specifier: workspace:^ + version: link:../../packages/sdk/helper '@deepseek-ai/dsh-host-apiproxy': specifier: workspace:^ version: link:../../packages/host/apiproxy - '@deepseek-ai/dsh-host-directory-picker-browse': - specifier: workspace:^ - version: link:../../packages/host/directory-picker-browse - '@deepseek-ai/dsh-host-directory-picker-native': - specifier: workspace:^ - version: link:../../packages/host/directory-picker-native '@deepseek-ai/dsh-host-webserver': specifier: workspace:^ version: link:../../packages/host/webserver + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../packages/support/invariants '@deepseek-ai/dsh-llm': specifier: workspace:^ version: link:../../packages/llm/llm @@ -254,21 +257,18 @@ importers: '@deepseek-ai/dsh-paths': specifier: workspace:^ version: link:../../packages/util/paths - '@deepseek-ai/dsh-permission': - specifier: workspace:^ - version: link:../../packages/ui/permission '@deepseek-ai/dsh-plan-mode': specifier: workspace:^ version: link:../../packages/plan/plan-mode - '@deepseek-ai/dsh-sandbox-local': + '@deepseek-ai/dsh-scope': specifier: workspace:^ - version: link:../../packages/sandbox/sandbox-local - '@deepseek-ai/dsh-sandbox-policy': - specifier: workspace:^ - version: link:../../packages/sandbox/sandbox-policy + version: link:../../packages/core/scope '@deepseek-ai/dsh-session': specifier: workspace:^ version: link:../../packages/core/session + '@deepseek-ai/dsh-session-checkpoint-policy': + specifier: workspace:^ + version: link:../../packages/session-persistence/session-checkpoint-policy '@deepseek-ai/dsh-session-persistence-jsonl': specifier: workspace:^ version: link:../../packages/session-persistence/session-persistence-jsonl @@ -278,6 +278,12 @@ importers: '@deepseek-ai/dsh-session-projection-cache': specifier: workspace:^ version: link:../../packages/session-projection/session-projection-cache + '@deepseek-ai/dsh-session-query-sqlite': + specifier: workspace:^ + version: link:../../packages/session-query/session-query-sqlite + '@deepseek-ai/dsh-session-reference': + specifier: workspace:^ + version: link:../../packages/context/session-reference '@deepseek-ai/dsh-session-registry': specifier: workspace:^ version: link:../../packages/session-registry/session-registry @@ -299,6 +305,9 @@ importers: '@deepseek-ai/dsh-skill-local': specifier: workspace:^ version: link:../../packages/skill/skill-local + '@deepseek-ai/dsh-source-guard': + specifier: workspace:^ + version: link:../../packages/guard/source-guard '@deepseek-ai/dsh-spill-local': specifier: workspace:^ version: link:../../packages/spill/spill-local @@ -335,9 +344,15 @@ importers: '@deepseek-ai/dsh-timeout-policy': specifier: workspace:^ version: link:../../packages/timeout/timeout-policy + '@deepseek-ai/dsh-tmux-context': + specifier: workspace:^ + version: link:../../packages/context/tmux-context '@deepseek-ai/dsh-token-meter': specifier: workspace:^ version: link:../../packages/llm/token-meter + '@deepseek-ai/dsh-tool-ask-user': + specifier: workspace:^ + version: link:../../packages/ui/tool-ask-user '@deepseek-ai/dsh-tool-bash': specifier: workspace:^ version: link:../../packages/bash/tool-bash @@ -347,6 +362,12 @@ importers: '@deepseek-ai/dsh-tool-fs-search': specifier: workspace:^ version: link:../../packages/fs/tool-fs-search + '@deepseek-ai/dsh-tool-goal': + specifier: workspace:^ + version: link:../../packages/goal/tool-goal + '@deepseek-ai/dsh-tool-ralph': + specifier: workspace:^ + version: link:../../packages/workflow/tool-ralph '@deepseek-ai/dsh-tool-skill': specifier: workspace:^ version: link:../../packages/skill/tool-skill @@ -368,9 +389,6 @@ importers: '@deepseek-ai/dsh-tui': specifier: workspace:^ version: link:../../packages/ui/tui - '@deepseek-ai/dsh-user-approval': - specifier: workspace:^ - version: link:../../packages/ui/user-approval '@deepseek-ai/dsh-user-interaction': specifier: workspace:^ version: link:../../packages/ui/user-interaction @@ -383,6 +401,9 @@ importers: '@deepseek-ai/dsh-workspace-context': specifier: workspace:^ version: link:../../packages/context/workspace-context + '@earendil-works/pi-ai': + specifier: ^0.82.1 + version: 0.82.1(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(ws@8.21.0)(zod@4.4.3) commander: specifier: ^15.0.0 version: 15.0.0 @@ -392,6 +413,9 @@ importers: js-yaml: specifier: ^4.2.0 version: 4.2.0 + yaml: + specifier: ^2.9.0 + version: 2.9.0 devDependencies: '@types/js-yaml': specifier: ^4.0.9 @@ -916,9 +940,6 @@ importers: '@deepseek-ai/dsh-tools': specifier: workspace:^ version: link:../../core/tools - schemastery: - specifier: ^3.18.0 - version: 3.18.0 devDependencies: '@deepseek-ai/dsh-host-webserver': specifier: workspace:^ @@ -1138,9 +1159,6 @@ importers: '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../support/invariants - '@deepseek-ai/dsh-permission': - specifier: workspace:^ - version: link:../../ui/permission '@deepseek-ai/dsh-plan-mode': specifier: workspace:^ version: link:../../plan/plan-mode @@ -1292,27 +1310,6 @@ importers: specifier: ^18.2.0 version: 18.3.1 - packages/client/ui-permission: - devDependencies: - '@deepseek-ai/dsh-client-runtime': - specifier: workspace:^ - version: link:../runtime - '@deepseek-ai/dsh-client-ui-command': - specifier: workspace:^ - version: link:../ui-command - '@deepseek-ai/dsh-client-ui-slash': - specifier: workspace:^ - version: link:../ui-slash - '@deepseek-ai/dsh-invariants': - specifier: workspace:^ - version: link:../../support/invariants - '@deepseek-ai/dsh-permission': - specifier: workspace:^ - version: link:../../ui/permission - cordis: - specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) - packages/client/ui-plan: devDependencies: '@deepseek-ai/dsh-client-connection': @@ -2948,15 +2945,9 @@ importers: '@deepseek-ai/dsh-goal': specifier: workspace:^ version: link:../../goal/goal - '@deepseek-ai/dsh-host-directory-picker': - specifier: workspace:^ - version: link:../directory-picker '@deepseek-ai/dsh-llm': specifier: workspace:^ version: link:../../llm/llm - '@deepseek-ai/dsh-native-command': - specifier: workspace:^ - version: link:../../util/native-command '@deepseek-ai/dsh-session': specifier: workspace:^ version: link:../../core/session @@ -3004,86 +2995,6 @@ importers: specifier: ^4.0.0-rc.7 version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) - packages/host/directory-picker: - devDependencies: - '@deepseek-ai/dsh-invariants': - specifier: workspace:^ - version: link:../../support/invariants - cordis: - specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) - - packages/host/directory-picker-browse: - dependencies: - '@deepseek-ai/dsh-host-directory-picker': - specifier: workspace:^ - version: link:../directory-picker - clsx: - specifier: ^2.0.0 - version: 2.1.1 - schemastery: - specifier: ^3.18.0 - version: 3.18.0 - devDependencies: - '@deepseek-ai/dsh-client-locale': - specifier: workspace:^ - version: link:../../client/locale - '@deepseek-ai/dsh-client-runtime': - specifier: workspace:^ - version: link:../../client/runtime - '@deepseek-ai/dsh-client-ui-primitives': - specifier: workspace:^ - version: link:../../client/ui-primitives - '@deepseek-ai/dsh-client-ui-slots': - specifier: workspace:^ - version: link:../../client/ui-slots - '@deepseek-ai/dsh-client-ui-workspace': - specifier: workspace:^ - version: link:../../client/ui-workspace - '@deepseek-ai/dsh-invariants': - specifier: workspace:^ - version: link:../../support/invariants - '@types/react': - specifier: ~18.3.1 - version: 18.3.31 - cordis: - specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) - react: - specifier: ^18.2.0 - version: 18.3.1 - - packages/host/directory-picker-native: - dependencies: - '@deepseek-ai/dsh-host-directory-picker': - specifier: workspace:^ - version: link:../directory-picker - '@deepseek-ai/dsh-native-command': - specifier: workspace:^ - version: link:../../util/native-command - devDependencies: - '@deepseek-ai/dsh-client-runtime': - specifier: workspace:^ - version: link:../../client/runtime - '@deepseek-ai/dsh-client-ui-slots': - specifier: workspace:^ - version: link:../../client/ui-slots - '@deepseek-ai/dsh-client-ui-workspace': - specifier: workspace:^ - version: link:../../client/ui-workspace - '@deepseek-ai/dsh-invariants': - specifier: workspace:^ - version: link:../../support/invariants - '@types/react': - specifier: ~18.3.1 - version: 18.3.31 - cordis: - specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) - react: - specifier: ^18.2.0 - version: 18.3.1 - packages/host/webserver: dependencies: schemastery: @@ -5050,16 +4961,10 @@ importers: schemastery: specifier: ^3.18.0 version: 3.18.0 - zod: - specifier: ^4.4.3 - version: 4.4.3 devDependencies: '@deepseek-ai/dsh-bash': specifier: workspace:^ version: link:../../bash/bash - '@deepseek-ai/dsh-commands': - specifier: workspace:^ - version: link:../commands '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../support/invariants @@ -5072,9 +4977,6 @@ importers: '@deepseek-ai/dsh-session': specifier: workspace:^ version: link:../../core/session - '@deepseek-ai/dsh-session-projection': - specifier: workspace:^ - version: link:../../session-projection/session-projection '@deepseek-ai/dsh-user-approval': specifier: workspace:^ version: link:../user-approval @@ -5243,15 +5145,6 @@ importers: specifier: ^4.0.0-rc.7 version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) - packages/util/native-command: - devDependencies: - '@deepseek-ai/dsh-invariants': - specifier: workspace:^ - version: link:../../support/invariants - cordis: - specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) - packages/util/paths: devDependencies: '@deepseek-ai/dsh-invariants': From 571562788df8e27c84ec00a75805e1bcb772a88c Mon Sep 17 00:00:00 2001 From: Turtle Date: Wed, 29 Jul 2026 15:03:40 +0800 Subject: [PATCH 016/113] chore: reconcile extracted foundation with current master --- apps/cli/README.i18n.yaml | 4 +- docs/event-producer-consumer.md | 12 +-- docs/user/guide/quickstart.i18n.yaml | 4 +- packages/host/README.md | 2 +- packages/host/README.zh.md | 2 +- packages/host/apiproxy/README.i18n.yaml | 4 +- pnpm-lock.yaml | 131 ++++++++++++++++++++++++ 7 files changed, 145 insertions(+), 14 deletions(-) diff --git a/apps/cli/README.i18n.yaml b/apps/cli/README.i18n.yaml index ba60b83086..616f396d79 100644 --- a/apps/cli/README.i18n.yaml +++ b/apps/cli/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write apps/cli/README.md -README.md: 8b52c8bd5c5b7b75855e2bfbe28d061c5f3742b0 -README.zh.md: 0df55c52f723e901b090673b8fea663f38cbeaaf +README.md: e5a64f5a7f17d0084e6d030e7faf819d38a1df1d +README.zh.md: 7459f356c3c7d494b4e6e8825a9b4751c3d84d1d diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index 141f770748..6048296973 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -21,7 +21,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `agent/session-start` | `emit` | [`packages/core/agent/src/types.ts:299`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`workspace-context`](../packages/context/workspace-context) | | `agent/settled` | `emit` | [`packages/core/agent/src/types.ts:387`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`compact-basic`](../packages/compact/compact-basic) | | `agent/status` | `emit` | [`packages/core/agent/src/types.ts:236`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`agent`](../packages/core/agent), `apiproxy`, [`goal-session`](../packages/goal/goal-session), [`tui`](../packages/ui/tui) | -| `agent/step` | `serial` | [`packages/core/agent/src/types.ts:326`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`compact-basic`](../packages/compact/compact-basic), [`plan-mode`](../packages/plan/plan-mode), [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy), [`time-context`](../packages/context/time-context), [`tool-skill`](../packages/skill/tool-skill), [`user-approval`](../packages/ui/user-approval), [`workspace-context`](../packages/context/workspace-context) | +| `agent/step` | `serial` | [`packages/core/agent/src/types.ts:326`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`compact-basic`](../packages/compact/compact-basic), [`plan-mode`](../packages/plan/plan-mode), [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy), [`time-context`](../packages/context/time-context), [`tmux-context`](../packages/context/tmux-context), [`tool-skill`](../packages/skill/tool-skill), [`user-approval`](../packages/ui/user-approval), [`workspace-context`](../packages/context/workspace-context) | | `agent/turn-stopping` | `serial` | [`packages/core/agent/src/types.ts:373`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) | | `approval/request` | `waterfall` | [`packages/ui/user-approval/src/index.ts:30`](../packages/ui/user-approval/src/index.ts) | [`user-approval`](../packages/ui/user-approval) (`waterfall`) | [`acp`](../packages/acp/acp), `apiproxy` | | `commands/change` | `emit` | [`packages/ui/commands/src/index.ts:154`](../packages/ui/commands/src/index.ts) | [`commands`](../packages/ui/commands) (`events.dispatch`) | `apiproxy`, [`tui`](../packages/ui/tui) | @@ -31,9 +31,9 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `fs/write-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:54`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`) | [`fs-policy`](../packages/fs/fs-policy) | | `goal/changed` | `emit` | [`packages/goal/goal/src/domain.ts:135`](../packages/goal/goal/src/domain.ts) | [`goal`](../packages/goal/goal) (`emit`) | [`goal-session`](../packages/goal/goal-session) | | `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:58`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`agent-loop`](../packages/core/agent-loop), [`llm`](../packages/llm/llm), [`llm-replay`](../packages/support/llm-replay), [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy), [`session-title`](../packages/session-title/session-title) | -| `session/created` | `emit` | [`packages/core/session/src/index.ts:71`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | `apiproxy`, [`compact`](../packages/compact/compact), [`goal`](../packages/goal/goal), [`hook-protocol`](../packages/hooks/hook-protocol), [`jsonrpc`](../packages/ui/jsonrpc), [`llm-retry`](../packages/llm/llm-retry), [`plan-mode`](../packages/plan/plan-mode), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-telemetry`](../packages/telemetry/session-telemetry), [`tools`](../packages/core/tools), [`user-approval`](../packages/ui/user-approval) | -| `session/disposed` | `emit` | [`packages/core/session/src/index.ts:81`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `apiproxy`, [`session-persistence`](../packages/session-persistence/session-persistence), [`session-projection-cache`](../packages/session-projection/session-projection-cache), [`session-telemetry`](../packages/telemetry/session-telemetry), [`session-title`](../packages/session-title/session-title) | -| `session/event` | `emit` | [`packages/core/session/src/index.ts:93`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/acp/acp), `apiproxy`, [`cli-demo`](../packages/examples/cli-demo), [`compact`](../packages/compact/compact), [`compact-basic`](../packages/compact/compact-basic), [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hook-protocol`](../packages/hooks/hook-protocol), [`jsonrpc`](../packages/ui/jsonrpc), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-projection`](../packages/session-projection/session-projection), [`session-projection-cache`](../packages/session-projection/session-projection-cache), [`session-telemetry`](../packages/telemetry/session-telemetry), [`session-title`](../packages/session-title/session-title), [`token-meter`](../packages/llm/token-meter), [`tools`](../packages/core/tools), [`tui`](../packages/ui/tui), [`user-approval`](../packages/ui/user-approval), [`workspace-context`](../packages/context/workspace-context) | +| `session/created` | `emit` | [`packages/core/session/src/index.ts:71`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | `apiproxy`, [`compact`](../packages/compact/compact), [`goal`](../packages/goal/goal), [`hook-protocol`](../packages/hooks/hook-protocol), [`jsonrpc`](../packages/ui/jsonrpc), [`llm-retry`](../packages/llm/llm-retry), [`plan-mode`](../packages/plan/plan-mode), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-registry-live`](../packages/session-registry/session-registry-live), [`session-telemetry`](../packages/telemetry/session-telemetry), [`tools`](../packages/core/tools), [`user-approval`](../packages/ui/user-approval) | +| `session/disposed` | `emit` | [`packages/core/session/src/index.ts:81`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `apiproxy`, [`session-persistence`](../packages/session-persistence/session-persistence), [`session-projection-cache`](../packages/session-projection/session-projection-cache), [`session-registry-live`](../packages/session-registry/session-registry-live), [`session-telemetry`](../packages/telemetry/session-telemetry), [`session-title`](../packages/session-title/session-title) | +| `session/event` | `emit` | [`packages/core/session/src/index.ts:93`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/acp/acp), `apiproxy`, [`cli-demo`](../packages/examples/cli-demo), [`compact`](../packages/compact/compact), [`compact-basic`](../packages/compact/compact-basic), [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hook-protocol`](../packages/hooks/hook-protocol), [`jsonrpc`](../packages/ui/jsonrpc), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-projection`](../packages/session-projection/session-projection), [`session-projection-cache`](../packages/session-projection/session-projection-cache), [`session-registry-live`](../packages/session-registry/session-registry-live), [`session-telemetry`](../packages/telemetry/session-telemetry), [`session-title`](../packages/session-title/session-title), [`token-meter`](../packages/llm/token-meter), [`tools`](../packages/core/tools), [`tui`](../packages/ui/tui), [`user-approval`](../packages/ui/user-approval), [`workspace-context`](../packages/context/workspace-context) | | `session/flush` | `parallel` | [`packages/core/session/src/index.ts:103`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`session-persistence`](../packages/session-persistence/session-persistence), [`session-telemetry`](../packages/telemetry/session-telemetry) | | `slash/input-begin-command` | `bail` | [`packages/client/ui-slash/src/types.ts:230`](../packages/client/ui-slash/src/types.ts) | - | `ui-conversation` | | `slash/input-consume-token` | `bail` | [`packages/client/ui-slash/src/types.ts:244`](../packages/client/ui-slash/src/types.ts) | - | `ui-conversation` | @@ -50,7 +50,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `tools/code-dispatch-log` | `waterfall` | [`packages/core/tools/src/index.ts:138`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`spill-policy`](../packages/spill/spill-policy) | | `tools/execute` | `waterfall` | [`packages/core/tools/src/index.ts:113`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy), [`timeout-policy`](../packages/timeout/timeout-policy) | | `tools/post-execute` | `waterfall` | [`packages/core/tools/src/index.ts:125`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard), [`spill-policy`](../packages/spill/spill-policy), [`tool-fs-search`](../packages/fs/tool-fs-search), [`workspace-context`](../packages/context/workspace-context) | -| `tools/pre-execute` | `waterfall` | [`packages/core/tools/src/index.ts:102`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`tool-tasks`](../packages/tasks/tool-tasks) | +| `tools/pre-execute` | `waterfall` | [`packages/core/tools/src/index.ts:102`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`source-guard`](../packages/guard/source-guard), [`tool-tasks`](../packages/tasks/tool-tasks) | | `tools/result` | `emit` | [`packages/core/tools/src/index.ts:146`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`events.dispatch`) | [`subagent-inprocess`](../packages/subagent/subagent-inprocess), [`workspace-context`](../packages/context/workspace-context) | | `workflow/agent-end` | `emit` | [`packages/workflow/workflow/src/index.ts:81`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`workflow`](../packages/workflow/workflow) | | `workflow/agent-start` | `emit` | [`packages/workflow/workflow/src/index.ts:70`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`workflow`](../packages/workflow/workflow) | @@ -65,7 +65,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac | --- | --- | --- | | `commands/changed` | `runtime` (`emit`) | `ui-command` | | `connection/reset` | `runtime` (`emit`) | `ui-command` | -| `internal/dispatch` | - | [`commands`](../packages/ui/commands), [`compact`](../packages/compact/compact), [`fs`](../packages/fs/fs), [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm-retry`](../packages/llm/llm-retry), [`permission`](../packages/ui/permission), [`plan-mode`](../packages/plan/plan-mode), [`pty-local`](../packages/pty/pty-local), `runtime`, [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`time-context`](../packages/context/time-context), [`tool-todo`](../packages/todo/tool-todo), [`tools`](../packages/core/tools), [`user-approval`](../packages/ui/user-approval), [`workflow`](../packages/workflow/workflow) | +| `internal/dispatch` | - | [`commands`](../packages/ui/commands), [`compact`](../packages/compact/compact), [`fs`](../packages/fs/fs), [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm-retry`](../packages/llm/llm-retry), [`permission`](../packages/ui/permission), [`plan-mode`](../packages/plan/plan-mode), [`pty-local`](../packages/pty/pty-local), `runtime`, [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`source-guard`](../packages/guard/source-guard), [`subagent`](../packages/subagent/subagent), [`time-context`](../packages/context/time-context), [`tool-todo`](../packages/todo/tool-todo), [`tools`](../packages/core/tools), [`user-approval`](../packages/ui/user-approval), [`workflow`](../packages/workflow/workflow) | | `internal/plugin` | - | `hmr`, `modules`, `webserver` | | `internal/status` | - | [`agent`](../packages/core/agent) | | `locale/change` | `locale` (`emit`) | `locale`, `ui-models`, `ui-settings-general` | diff --git a/docs/user/guide/quickstart.i18n.yaml b/docs/user/guide/quickstart.i18n.yaml index 71065d0781..8105617a8d 100644 --- a/docs/user/guide/quickstart.i18n.yaml +++ b/docs/user/guide/quickstart.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/user/guide/quickstart.md -quickstart.md: 75c0d7096f09092106aca46a5a078673708040e7 -quickstart.zh.md: 0beadb65f32f21d18bb4550eda967ed2b275b10f +quickstart.md: be41d00d0f618d62a0f6ee2350cbc5f43c76d981 +quickstart.zh.md: f592fdb3fb9a71f19a5ca356f586667d0674ea49 diff --git a/packages/host/README.md b/packages/host/README.md index d44770f70b..ec7eda7f44 100644 --- a/packages/host/README.md +++ b/packages/host/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -The host side of the dsh web GUI: the API gateway every client shape shares, and the plain HTTP server it rides on. The browser side lives in [`client/`](../client/README.md); the composed application is [`apps/cli`](../../apps/cli/cordis.yml) serving [`apps/web`](../../apps/web/). All **product** packages. +The host side of the dsh web GUI: the API gateway every client shape shares, and the plain HTTP server it rides on. The browser side lives in [`client/`](../client/README.md); the composed application is [`apps/cli`](../../apps/cli/base.cordis.yml) serving [`apps/web`](../../apps/web/). All **product** packages. | Package | Role | ctx key | |---|---|---| diff --git a/packages/host/README.zh.md b/packages/host/README.zh.md index 2b6878b08b..3c295d8a84 100644 --- a/packages/host/README.zh.md +++ b/packages/host/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -dsh web GUI 的宿主侧:所有客户端形态共用的 API 网关,以及承载它的纯 HTTP 服务器。浏览器侧位于 [`client/`](../client/README.md);组合后的应用是 [`apps/cli`](../../apps/cli/cordis.yml),它负责服务 [`apps/web`](../../apps/web/)。全部为**产品**包。 +dsh web GUI 的宿主侧:所有客户端形态共用的 API 网关,以及承载它的纯 HTTP 服务器。浏览器侧位于 [`client/`](../client/README.md);组合后的应用是 [`apps/cli`](../../apps/cli/base.cordis.yml),它负责服务 [`apps/web`](../../apps/web/)。全部为**产品**包。 | 包 | 角色 | ctx 键 | |---|---|---| diff --git a/packages/host/apiproxy/README.i18n.yaml b/packages/host/apiproxy/README.i18n.yaml index 8375afe22d..5237550801 100644 --- a/packages/host/apiproxy/README.i18n.yaml +++ b/packages/host/apiproxy/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/host/apiproxy/README.md -README.md: c9325d7f0a0feacbc1198d0b737c744ee2075472 -README.zh.md: 1d755bbc2852d6bebed5bd738c19c793657d0afa +README.md: 0b085e9cb88fe20d9a493fdd26b9cc98ffe0ad52 +README.zh.md: 6646945b79d6e1e5eb72b713a33a4355ae8bc3f8 diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 02c6d0fd73..e3666ecb36 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -940,6 +940,9 @@ importers: '@deepseek-ai/dsh-tools': specifier: workspace:^ version: link:../../core/tools + schemastery: + specifier: ^3.18.0 + version: 3.18.0 devDependencies: '@deepseek-ai/dsh-host-webserver': specifier: workspace:^ @@ -1159,6 +1162,9 @@ importers: '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../support/invariants + '@deepseek-ai/dsh-permission': + specifier: workspace:^ + version: link:../../ui/permission '@deepseek-ai/dsh-plan-mode': specifier: workspace:^ version: link:../../plan/plan-mode @@ -1310,6 +1316,27 @@ importers: specifier: ^18.2.0 version: 18.3.1 + packages/client/ui-permission: + devDependencies: + '@deepseek-ai/dsh-client-runtime': + specifier: workspace:^ + version: link:../runtime + '@deepseek-ai/dsh-client-ui-command': + specifier: workspace:^ + version: link:../ui-command + '@deepseek-ai/dsh-client-ui-slash': + specifier: workspace:^ + version: link:../ui-slash + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants + '@deepseek-ai/dsh-permission': + specifier: workspace:^ + version: link:../../ui/permission + cordis: + specifier: ^4.0.0-rc.7 + version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + packages/client/ui-plan: devDependencies: '@deepseek-ai/dsh-client-connection': @@ -2945,9 +2972,15 @@ importers: '@deepseek-ai/dsh-goal': specifier: workspace:^ version: link:../../goal/goal + '@deepseek-ai/dsh-host-directory-picker': + specifier: workspace:^ + version: link:../directory-picker '@deepseek-ai/dsh-llm': specifier: workspace:^ version: link:../../llm/llm + '@deepseek-ai/dsh-native-command': + specifier: workspace:^ + version: link:../../util/native-command '@deepseek-ai/dsh-session': specifier: workspace:^ version: link:../../core/session @@ -2995,6 +3028,86 @@ importers: specifier: ^4.0.0-rc.7 version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + packages/host/directory-picker: + devDependencies: + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants + cordis: + specifier: ^4.0.0-rc.7 + version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + + packages/host/directory-picker-browse: + dependencies: + '@deepseek-ai/dsh-host-directory-picker': + specifier: workspace:^ + version: link:../directory-picker + clsx: + specifier: ^2.0.0 + version: 2.1.1 + schemastery: + specifier: ^3.18.0 + version: 3.18.0 + devDependencies: + '@deepseek-ai/dsh-client-locale': + specifier: workspace:^ + version: link:../../client/locale + '@deepseek-ai/dsh-client-runtime': + specifier: workspace:^ + version: link:../../client/runtime + '@deepseek-ai/dsh-client-ui-primitives': + specifier: workspace:^ + version: link:../../client/ui-primitives + '@deepseek-ai/dsh-client-ui-slots': + specifier: workspace:^ + version: link:../../client/ui-slots + '@deepseek-ai/dsh-client-ui-workspace': + specifier: workspace:^ + version: link:../../client/ui-workspace + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants + '@types/react': + specifier: ~18.3.1 + version: 18.3.31 + cordis: + specifier: ^4.0.0-rc.7 + version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + react: + specifier: ^18.2.0 + version: 18.3.1 + + packages/host/directory-picker-native: + dependencies: + '@deepseek-ai/dsh-host-directory-picker': + specifier: workspace:^ + version: link:../directory-picker + '@deepseek-ai/dsh-native-command': + specifier: workspace:^ + version: link:../../util/native-command + devDependencies: + '@deepseek-ai/dsh-client-runtime': + specifier: workspace:^ + version: link:../../client/runtime + '@deepseek-ai/dsh-client-ui-slots': + specifier: workspace:^ + version: link:../../client/ui-slots + '@deepseek-ai/dsh-client-ui-workspace': + specifier: workspace:^ + version: link:../../client/ui-workspace + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants + '@types/react': + specifier: ~18.3.1 + version: 18.3.31 + cordis: + specifier: ^4.0.0-rc.7 + version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + react: + specifier: ^18.2.0 + version: 18.3.1 + packages/host/webserver: dependencies: schemastery: @@ -4961,10 +5074,16 @@ importers: schemastery: specifier: ^3.18.0 version: 3.18.0 + zod: + specifier: ^4.4.3 + version: 4.4.3 devDependencies: '@deepseek-ai/dsh-bash': specifier: workspace:^ version: link:../../bash/bash + '@deepseek-ai/dsh-commands': + specifier: workspace:^ + version: link:../commands '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../support/invariants @@ -4977,6 +5096,9 @@ importers: '@deepseek-ai/dsh-session': specifier: workspace:^ version: link:../../core/session + '@deepseek-ai/dsh-session-projection': + specifier: workspace:^ + version: link:../../session-projection/session-projection '@deepseek-ai/dsh-user-approval': specifier: workspace:^ version: link:../user-approval @@ -5145,6 +5267,15 @@ importers: specifier: ^4.0.0-rc.7 version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + packages/util/native-command: + devDependencies: + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants + cordis: + specifier: ^4.0.0-rc.7 + version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) + packages/util/paths: devDependencies: '@deepseek-ai/dsh-invariants': From 98086a88d56067fc5878e30e39abc2939f1142c8 Mon Sep 17 00:00:00 2001 From: Turtle Date: Wed, 29 Jul 2026 15:05:23 +0800 Subject: [PATCH 017/113] docs(host): update config-tree link pairing --- packages/host/README.i18n.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/host/README.i18n.yaml b/packages/host/README.i18n.yaml index afc0e2a695..80bbcefa62 100644 --- a/packages/host/README.i18n.yaml +++ b/packages/host/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/host/README.md -README.md: d44770f70be16c12f44b78155089e092a3e9bba0 -README.zh.md: 2b6878b08be6489dcd510a0a0e0f0e833c2a8014 +README.md: ec7eda7f44ee7a2e8716f65aaa8d800b2c5abc5d +README.zh.md: 3c295d8a84a29cfd196f0089434cbd6d0341e08b From a459a918e206ca1ed7314a75e447174f4bc12e7a Mon Sep 17 00:00:00 2001 From: Turtle Date: Wed, 29 Jul 2026 15:07:07 +0800 Subject: [PATCH 018/113] fix(cli): remove obsolete resolver dependencies --- apps/cli/package.json | 4 +--- pnpm-lock.yaml | 6 ------ 2 files changed, 1 insertion(+), 9 deletions(-) diff --git a/apps/cli/package.json b/apps/cli/package.json index 3c61f474aa..c463bde1a6 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -111,11 +111,9 @@ "@deepseek-ai/dsh-workflow-workerthread": "workspace:^", "@deepseek-ai/dsh-workspace": "workspace:^", "@deepseek-ai/dsh-workspace-context": "workspace:^", - "@earendil-works/pi-ai": "^0.82.1", "commander": "^15.0.0", "cordis": "^4.0.0-rc.7", - "js-yaml": "^4.2.0", - "yaml": "^2.9.0" + "js-yaml": "^4.2.0" }, "devDependencies": { "@types/js-yaml": "^4.0.9", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e3666ecb36..5b543c8227 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -401,9 +401,6 @@ importers: '@deepseek-ai/dsh-workspace-context': specifier: workspace:^ version: link:../../packages/context/workspace-context - '@earendil-works/pi-ai': - specifier: ^0.82.1 - version: 0.82.1(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(ws@8.21.0)(zod@4.4.3) commander: specifier: ^15.0.0 version: 15.0.0 @@ -413,9 +410,6 @@ importers: js-yaml: specifier: ^4.2.0 version: 4.2.0 - yaml: - specifier: ^2.9.0 - version: 2.9.0 devDependencies: '@types/js-yaml': specifier: ^4.0.9 From 8f2f6ef0ac488ea12cce0c5ffd721bd6823910e2 Mon Sep 17 00:00:00 2001 From: Turtle Date: Wed, 29 Jul 2026 15:22:17 +0800 Subject: [PATCH 019/113] refactor(cli): exclude live-session registry surface --- apps/cli/README.i18n.yaml | 4 +- apps/cli/README.md | 4 +- apps/cli/README.zh.md | 4 +- apps/cli/package.json | 3 - apps/cli/src/args.ts | 171 ++++----------------------- apps/cli/src/bin.ts | 20 +--- apps/cli/src/headless.ts | 5 +- apps/cli/src/list-sessions.ts | 101 ---------------- apps/cli/src/register-session.ts | 44 ------- apps/cli/src/tui.ts | 11 +- apps/cli/src/web.ts | 14 +-- apps/cli/tests/args.spec.ts | 39 +----- apps/cli/tests/built-bin.e2e.ts | 55 +-------- apps/cli/tests/list-sessions.spec.ts | 74 ------------ apps/cli/tsconfig.json | 9 -- pnpm-lock.yaml | 9 -- 16 files changed, 42 insertions(+), 525 deletions(-) delete mode 100644 apps/cli/src/list-sessions.ts delete mode 100644 apps/cli/src/register-session.ts delete mode 100644 apps/cli/tests/list-sessions.spec.ts diff --git a/apps/cli/README.i18n.yaml b/apps/cli/README.i18n.yaml index 616f396d79..a38bcc2eaa 100644 --- a/apps/cli/README.i18n.yaml +++ b/apps/cli/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write apps/cli/README.md -README.md: e5a64f5a7f17d0084e6d030e7faf819d38a1df1d -README.zh.md: 7459f356c3c7d494b4e6e8825a9b4751c3d84d1d +README.md: 5c2b3eb77f2606928520c937d96d8f728afb062b +README.zh.md: 927934d7265b8a81518d770e57f2b21249b67e56 diff --git a/apps/cli/README.md b/apps/cli/README.md index 72b168ed98..2c1d373b39 100644 --- a/apps/cli/README.md +++ b/apps/cli/README.md @@ -2,9 +2,8 @@ English | [中文](README.zh.md) -The `dsh` command-line entry follows the `apps/` assembly tier: `apps/*` are product assemblies over `packages/*` libraries. Plain `dsh` boots the interactive TUI coding agent, `dsh -p "task"` runs one headless turn, `dsh meta` boots that same TUI over this harness checkout, `dsh migrate` and `dsh upgrade` boot a fresh guided TUI session whose first turn invokes a bundled skill, `dsh list-sessions` lists the sessions running right now, and `dsh web` serves the browser UI. -Argv is parsed once through a [Commander](https://github.com/tj/commander.js) adapter ([`src/args.ts`](src/args.ts)): one program whose default (no subcommand) is the TUI/headless surface (`--config`, `-p`/`--prompt`, `--resume`), whose `meta` subcommand is the same TUI over this checkout, whose `migrate`/`upgrade` subcommands are option-less guided-session entries, whose `list-sessions` subcommand (alias `ps`) lists live sessions, and whose `web` subcommand is the browser UI. `src/bin.ts` switches on the resolved mode and dynamic-imports only that mode's module. `dsh --help` lists every mode and `dsh web --help` renders the web usage, `dsh --version` prints this app's version, and an unknown option or a mistyped `--resume` fails loud (stderr, exit 1) instead of misrouting. Every subcommand that shares no option with the default surface — `migrate`, `upgrade`, `list-sessions`, `web` — rejects a leaked `--config`/`-p`/`--resume` rather than running and dropping it. `dsh web`'s `--host`/`--port` are unvalidated pass-through overrides: the `dsh-host-webserver` schema is the single source of both the default (the shipped `cordis.yml` value when a flag is absent) and validity, and rejects a bad value at boot. `--trusted-host` appends named authorities for the /api browser-trust fence; an all-interfaces bind additionally derives the machine's LAN IP literals itself ([`src/app-cli-entry.ts`](src/app-cli-entry.ts)), so the printed LAN URL works without flags. +Argv is parsed once through a [Commander](https://github.com/tj/commander.js) adapter ([`src/args.ts`](src/args.ts)): one program whose default (no subcommand) is the TUI/headless surface (`--config`, `-p`/`--prompt`, `--resume`), whose `meta` subcommand is the same TUI over this checkout, whose `migrate`/`upgrade` subcommands are option-less guided-session entries, and whose `web` subcommand is the browser UI. `src/bin.ts` switches on the resolved mode and dynamic-imports only that mode's module. `dsh --help` lists every mode and `dsh web --help` renders the web usage, `dsh --version` prints this app's version, and an unknown option or a mistyped `--resume` fails loud (stderr, exit 1) instead of misrouting. Every subcommand that shares no option with the default surface — `migrate`, `upgrade`, `web` — rejects a leaked `--config`/`-p`/`--resume` rather than running and dropping it. `dsh web`'s `--host`/`--port` are unvalidated pass-through overrides: the `dsh-host-webserver` schema is the single source of both the default (the shipped `cordis.yml` value when a flag is absent) and validity, and rejects a bad value at boot. `--trusted-host` appends named authorities for the /api browser-trust fence; an all-interfaces bind additionally derives the machine's LAN IP literals itself ([`src/app-cli-entry.ts`](src/app-cli-entry.ts)), so the printed LAN URL works without flags. The TUI surface: @@ -18,7 +17,6 @@ The TUI surface: `dsh migrate` and `dsh upgrade` are guided fresh-session entries over the default TUI surface: each mints a fresh session in the invoking directory and seeds its first turn with a bundled skill (`dsh-migrate` for migrating from another coding agent — opencode, pi, Claude Code, Codex; `dsh-upgrade` for upgrading this checkout), exactly as if the user typed `/skill:`. The launcher passes the skill name on the boot context ([`INITIAL_SKILL_KEY`](../../packages/ui/tui/README.md)), which the TUI auto-invokes once the chat is live. Both take no options — `--config`, `-p`, and `--resume` fail loud — and seed only on this first launch, so a later `dsh --resume ` of the session is an ordinary TUI session with no re-injection. -`dsh list-sessions` lists the sessions running right now: session id, pid, uptime, workspace, and title, newest first. It is read-only and boots no agent tree — it mounts the [session registry](../../packages/session-registry/session-registry/README.md) alone, so listing is fast and cannot start model work as a side effect. Every surface publishes its sessions into that registry through [`dsh-session-registry-live`](../../packages/session-registry/session-registry-live/README.md), and records whose process is gone are pruned on read, so a crashed session disappears without cleanup. `--json` emits the same records as a machine-readable array; an empty listing prints one line and exits 0. There is no workspace filter: the listing is always every live session, whatever directory it runs in. Only top-level surfaces appear — subagents share or spawn other processes and are deliberately invisible. The Web and headless surfaces boot one shared composition (`cordis.yml`): both treat the invoking directory as the default project and Workspace root, create named Workspaces beneath that root unless `--workspace-root ` overrides it, load applicable `AGENTS.md`/`CLAUDE.md` instructions into each agent-loop request prefix with a 65,536-byte render budget, and opt into first-message model titles. Headless differs only in listening on an OS-assigned port (parallel `dsh -p` runs never collide; the stderr-printed URL opens the live session in a browser). Both need the frontend dist and client bundles built (`pnpm run build && pnpm run build:web`). diff --git a/apps/cli/README.zh.md b/apps/cli/README.zh.md index dc0f73aaba..b4c1e8a913 100644 --- a/apps/cli/README.zh.md +++ b/apps/cli/README.zh.md @@ -2,9 +2,8 @@ [English](README.md) | 中文 -`dsh` 命令行入口遵循 `apps/` 组装层:`apps/*` 是位于 `packages/*` 库之上的产品组装。直接运行 `dsh` 会启动交互式 TUI 编码 agent(智能体),`dsh -p "task"` 运行一个无头轮次,`dsh meta` 以本 harness checkout 为 workspace 启动同一个 TUI,`dsh migrate` 和 `dsh upgrade` 启动一个全新的引导式 TUI 会话并在首轮调用内置 skill,`dsh list-sessions` 列出此刻正在运行的会话,`dsh web` 则提供浏览器 UI。 -Argv 只会通过 [Commander](https://github.com/tj/commander.js) 适配器([`src/args.ts`](src/args.ts))解析一次:同一个程序的默认形式(无子命令)是 TUI/无头界面(`--config`、`-p`/`--prompt`、`--resume`),`meta` 子命令是以本 checkout 为 workspace 的同一个 TUI,`migrate`/`upgrade` 子命令是无选项的引导会话入口,`list-sessions` 子命令(别名 `ps`)列出存活会话,`web` 子命令则是浏览器 UI。`src/bin.ts` 按解析后的 mode 分支,仅动态导入该 mode 的模块。`dsh --help` 列出所有 mode,`dsh web --help` 渲染 Web 用法,`dsh --version` 打印此应用的版本;未知选项或拼错的 `--resume` 会明确报错(stderr,退出码 1),而不会被错路由。凡与默认界面不共享任何选项的子命令(`migrate`、`upgrade`、`list-sessions`、`web`)都会拒绝泄漏进来的 `--config`/`-p`/`--resume`,而不会照常运行并丢弃它。`dsh web` 的 `--host`/`--port` 是未验证的直通覆盖:`dsh-host-webserver` schema 是默认值(标志缺失时使用已交付的 `cordis.yml` 值)和有效性的唯一真源,并在启动时拒绝错误值。`--trusted-host` 为 /api 浏览器信任栅栏追加具名权威;全接口绑定还会自行推导本机的 LAN IP 字面量([`src/app-cli-entry.ts`](src/app-cli-entry.ts)),因此打印出的 LAN URL 无需任何标志即可使用。 +Argv 只会通过 [Commander](https://github.com/tj/commander.js) 适配器([`src/args.ts`](src/args.ts))解析一次:同一个程序的默认形式(无子命令)是 TUI/无头界面(`--config`、`-p`/`--prompt`、`--resume`),`meta` 子命令是以本 checkout 为 workspace 的同一个 TUI,`migrate`/`upgrade` 子命令是无选项的引导会话入口,`web` 子命令则是浏览器 UI。`src/bin.ts` 按解析后的 mode 分支,仅动态导入该 mode 的模块。`dsh --help` 列出所有 mode,`dsh web --help` 渲染 Web 用法,`dsh --version` 打印此应用的版本;未知选项或拼错的 `--resume` 会明确报错(stderr,退出码 1),而不会被错路由。凡与默认界面不共享任何选项的子命令(`migrate`、`upgrade`、`web`)都会拒绝泄漏进来的 `--config`/`-p`/`--resume`,而不会照常运行并丢弃它。`dsh web` 的 `--host`/`--port` 是未验证的直通覆盖:`dsh-host-webserver` schema 是默认值(标志缺失时使用已交付的 `cordis.yml` 值)和有效性的唯一真源,并在启动时拒绝错误值。`--trusted-host` 为 /api 浏览器信任栅栏追加具名权威;全接口绑定还会自行推导本机的 LAN IP 字面量([`src/app-cli-entry.ts`](src/app-cli-entry.ts)),因此打印出的 LAN URL 无需任何标志即可使用。 TUI 界面: @@ -18,7 +17,6 @@ TUI 界面: `dsh migrate` 与 `dsh upgrade` 是默认 TUI 界面之上的引导式全新会话入口:各自在调用目录中创建一个全新会话,并以一个内置 skill 播种其首轮(`dsh-migrate` 用于从其他编码 agent 迁移——opencode、pi、Claude Code、Codex;`dsh-upgrade` 用于升级本 checkout),效果等同于用户手动键入 `/skill:`。启动器将 skill 名称提供到启动上下文([`INITIAL_SKILL_KEY`](../../packages/ui/tui/README.md)),TUI 在聊天就绪后自动调用它。两者都不接受任何选项——`--config`、`-p`、`--resume` 都会明确报错——且仅在首次启动时播种,因此之后 `dsh --resume ` 恢复该会话时是普通 TUI 会话,不会重复注入。 -`dsh list-sessions` 列出此刻正在运行的会话:会话 id、pid、运行时长、工作区和标题,最新的在前。它是只读的,不启动任何 agent 树——它只挂载[会话注册表](../../packages/session-registry/session-registry/README.md),因此列表既快,也不会作为副作用启动模型工作。每个界面都通过 [`dsh-session-registry-live`](../../packages/session-registry/session-registry-live/README.md) 把自己的会话发布到该注册表,进程已不存在的记录会在读取时被剪除,因此崩溃的会话无需清理便会消失。`--json` 以机器可读的数组形式输出同样的记录;空列表打印一行并以 0 退出。没有工作区过滤:列表始终是全部存活会话,无论它们运行在哪个目录下。只有顶层界面会出现——subagent 共用别的进程,或 spawn 出别的进程,因此被刻意排除在列表之外。 Web 和无头界面启动同一个共享组合(`cordis.yml`):两者都将调用目录视为默认项目和 Workspace 根目录,除非通过 `--workspace-root ` 覆盖,否则会在该根目录下创建具名 Workspace;它们会把适用的 `AGENTS.md`/`CLAUDE.md` 指令加载到每个 agent-loop 请求前缀中,渲染预算为 65,536 字节,并选用首条消息模型标题。无头界面唯一的差异是监听操作系统分配的端口(并行 `dsh -p` 运行绝不冲突;stderr 打印的 URL 会在浏览器中打开实时会话)。两者都需要先构建前端 dist 和客户端 bundle(`pnpm run build && pnpm run build:web`)。 diff --git a/apps/cli/package.json b/apps/cli/package.json index c463bde1a6..4a054fa559 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -72,9 +72,6 @@ "@deepseek-ai/dsh-session-projection-cache": "workspace:^", "@deepseek-ai/dsh-session-query-sqlite": "workspace:^", "@deepseek-ai/dsh-session-reference": "workspace:^", - "@deepseek-ai/dsh-session-registry": "workspace:^", - "@deepseek-ai/dsh-session-registry-file": "workspace:^", - "@deepseek-ai/dsh-session-registry-live": "workspace:^", "@deepseek-ai/dsh-session-title": "workspace:^", "@deepseek-ai/dsh-session-title-first-message-llm": "workspace:^", "@deepseek-ai/dsh-skill": "workspace:^", diff --git a/apps/cli/src/args.ts b/apps/cli/src/args.ts index 16cf22eda0..b929dc73f2 100644 --- a/apps/cli/src/args.ts +++ b/apps/cli/src/args.ts @@ -2,24 +2,19 @@ * Commander adapter for the `dsh` command-line entry: the one place argv is * parsed and routed to a mode. `bin.ts` switches on the returned discriminant * and dynamic-imports that mode's module. One program: the default (no - * subcommand) is the TUI/headless surface with option-only flags; `meta` and - * `web` are real subcommands. Commander owns `--help`/`--version` and parse - * errors — it prints and exits at the point of failure (a domain failure routes through + * subcommand) is the TUI/headless surface with option-only flags; `web` is a + * real subcommand. Commander owns `--help`/`--version` and parse errors — it + * prints and exits at the point of failure (a domain failure routes through * `command.error`), so this returns only a resolved mode. * @module @deepseek-ai/dsh/args */ import { Command, CommanderError } from 'commander' -/** - * Interactive TUI: the default mode. `--config` applies an overlay over the - * shipped composition in place of the personal one, `--config-replace` boots a - * file as the whole tree instead, and `--resume ` rehydrates a session. - */ +/** Interactive TUI: the default mode. `--config` swaps the tree; `--resume ` rehydrates a session. */ interface TuiInvocation { mode: 'tui' config?: string - configReplace?: string resume?: string } @@ -29,39 +24,6 @@ interface HeadlessInvocation { prompt: string } -/** - * Interactive TUI over this harness checkout: `dsh meta`. Identical to - * {@link TuiInvocation} except the workspace is the launcher's own source tree - * rather than the invoking directory. No `--config`: booting a foreign tree - * against the harness workspace is the `--config` case, not this one. - */ -interface MetaInvocation { - mode: 'meta' - resume?: string -} - -/** - * Guided fresh-session entries: `dsh migrate` seeds the first turn with the - * `dsh-migrate` skill, `dsh upgrade` with `dsh-upgrade`. Each always mints a - * fresh session in the invoking directory and takes no options — `--resume`, - * `--config`, and `-p` are rejected as mistyped, so there is nothing to carry. - */ -interface SkillSessionInvocation { - mode: 'migrate' | 'upgrade' -} - -/** - * List live sessions: `dsh list-sessions` (alias `dsh ps`). A read-only surface - * that boots no agent tree — it reads the cross-process session registry and - * exits. `json` selects the machine-readable form over the human table. There - * is no workspace filter: the listing is always every live session, whatever - * directory it runs in. - */ -interface ListSessionsInvocation { - mode: 'list-sessions' - json: boolean -} - /** * Browser UI: `dsh web`. `host`/`port` are present only when the flag was * passed — pass-through overrides with no CLI default and no CLI validation: @@ -74,8 +36,6 @@ interface ListSessionsInvocation { */ interface WebInvocation { mode: 'web' - /** Overlay of loader patches applied over the shipped web composition. */ - config?: string host?: string port?: number dev: boolean @@ -85,17 +45,10 @@ interface WebInvocation { } /** The resolved `dsh` invocation: exactly one mode. `--help`/`--version`/errors exit inside {@link parseDshArgs}. */ -export type DshInvocation = - | TuiInvocation - | HeadlessInvocation - | MetaInvocation - | SkillSessionInvocation - | ListSessionsInvocation - | WebInvocation +export type DshInvocation = TuiInvocation | HeadlessInvocation | WebInvocation /** Raw web-subcommand options straight from Commander. */ interface WebOptions { - config?: string host?: string port?: string dev?: boolean @@ -112,7 +65,6 @@ interface WebOptions { function resolveWeb(options: WebOptions): WebInvocation { return { mode: 'web', - ...options.config !== undefined && { config: options.config }, ...options.host !== undefined && { host: options.host }, ...options.port !== undefined && { port: Number(options.port) }, dev: options.dev === true, @@ -134,31 +86,21 @@ export function parseDshArgs(argv: readonly string[], version: string): DshInvoc const program = new Command() .name('dsh') .version(version, '-V, --version', 'output the version number') - .description('dsh: DeepSeek Harness — an interactive coding agent for your terminal.\nRun `dsh` with no arguments to start a session in the current directory.') - // The default surface takes no positional task, so `dsh "task"` fails - // commander's arity check with no hint; these examples are where a first - // reader learns the entry points and that a one-shot task rides `-p`. - .addHelpText('after', ` -Examples: - dsh start an interactive session in this directory - dsh -p "run the tests" answer one task, print the result, and exit - dsh --resume continue a past session (list ids with \`dsh ps\`) -`) + .description('dsh: interactive TUI (default), headless task, and browser UI') .exitOverride() // Default surface: option-only (no positional), so `web` can be a real // subcommand without a positional collision. - .option('-p, --prompt ', 'answer this task without the interactive UI, then exit') - .option('--resume ', 'continue a past session by id (list ids with `dsh ps`)') - .option('--config ', 'apply this overlay of loader patches instead of the personal one') - .option('--config-replace ', 'boot this file as the entire tree, ignoring the shipped and personal configuration') - .action((options: { config?: string; configReplace?: string; prompt?: string; resume?: string }) => { + .option('--config ', 'boot an alternate cordis.yml instead of the shipped tree (TUI mode)') + .option('-p, --prompt ', 'run one headless turn for this task, print the result, and exit') + .option('--resume ', 'resume the persisted session with this id (TUI mode)') + .action((options: { config?: string; prompt?: string; resume?: string }) => { if (options.prompt !== undefined) { // A headless prompt owns the invocation; an empty task has nothing to // run, and --config/--resume are TUI inputs that must not silently // vanish from a headless run. if (options.prompt === '') program.error('error: --prompt needs a task') - if (options.config !== undefined || options.configReplace !== undefined || options.resume !== undefined) { - program.error('error: --prompt takes no --config, --config-replace, or --resume') + if (options.config !== undefined || options.resume !== undefined) { + program.error('error: --prompt takes no --config or --resume') } resolved = { mode: 'headless', prompt: options.prompt } return @@ -166,97 +108,30 @@ Examples: // An empty --resume= id would silently start a fresh session downstream // (agent-loop treats '' as no-resume), so a mistyped resume must fail loud. if (options.resume === '') program.error('error: --resume needs a session id') - // The two config flags are mutually exclusive: one layers over the shipped - // tree, the other discards it, so accepting both would silently drop one. - if (options.config !== undefined && options.configReplace !== undefined) { - program.error('error: --config and --config-replace are mutually exclusive') - } resolved = { mode: 'tui', ...options.config !== undefined && { config: options.config }, - ...options.configReplace !== undefined && { configReplace: options.configReplace }, ...options.resume !== undefined && { resume: options.resume }, } }) - // Commander parses the parent (default-surface) options on either side of a - // subcommand into `program.opts()`. For a subcommand that shares none of them, - // a leaked `--config`/`-p`/`--resume` is a mistyped invocation that must fail - // loud rather than silently run and drop the input. - const rejectParentOptions = (command: string): void => { - const parent = program.opts<{ config?: string; configReplace?: string; prompt?: string; resume?: string }>() - if (parent.config !== undefined || parent.configReplace !== undefined - || parent.prompt !== undefined || parent.resume !== undefined) { - program.error(`error: ${command} takes none of --config, -p/--prompt, or --resume`) - } - } - - // Registration order is the rendered help order, so daily use comes first - // and the harness-development surfaces (`web --dev`, `meta`) come last. - // `migrate` and `upgrade` are guided fresh-session entries: they take no - // options and always mint a fresh session, so nothing is left to carry. Each - // description names the outcome, not the skill the first turn invokes. - const guided = { - migrate: 'import settings from another coding agent (Claude Code, Codex, opencode)', - upgrade: 'update this dsh installation to the latest version', - } as const - for (const mode of ['migrate', 'upgrade'] as const) { - program - .command(mode) - .description(guided[mode]) - .action(() => { - rejectParentOptions(mode) - resolved = { mode } - }) - } - - program - .command('list-sessions') - .alias('ps') - .description('list sessions running right now') - .option('--json', 'print the records as a JSON array instead of a table') - .action((options: { json?: boolean }) => { - rejectParentOptions('list-sessions') - resolved = { mode: 'list-sessions', json: options.json === true } - }) - - // Host and port name no default: the CLI passes neither through when the flag - // is absent, so the shipped `cordis.yml` value stands and restating it here - // would duplicate a fact this file does not own. - const web = program.command('web').description('serve the browser UI on the configured host and port') + const web = program.command('web').description('serve the browser UI (host/port default to the shipped config)') web - .option('--config ', 'apply this overlay of loader patches over the shipped configuration') - .option('--host ', 'bind host; pass 0.0.0.0 to reach it from another machine') - .option('--port ', 'listen port; pass 0 to let the OS pick a free one') - .option('--dev', 'developer mode: hot-reload the browser client') - .option('--workspace-root ', 'parent directory for workspaces created from the browser UI') + .option('--host ', 'override the config bind host (127.0.0.1 or 0.0.0.0)') + .option('--port ', 'override the config listen port (0 requests an OS-assigned port)') + .option('--dev', 'mount the client HMR driver and watch plugin bundles for rebuilds') + .option('--workspace-root ', 'parent directory for name-created workspaces') .option('--trusted-host ', 'extra authority the /api browser-trust fence accepts (host or host:port; repeatable)') .action((options: WebOptions) => { - rejectParentOptions('web') - resolved = resolveWeb(options) - }) - - // `--resume` is NOT redeclared here: an option a subcommand shares with its - // parent parses into `program.opts()` and leaves the subcommand's own options - // empty, so redeclaring it would silently drop the id. Commander therefore - // omits it from this subcommand's option list, hence the trailing help text. - program - .command('meta') - .description('work on the dsh source that runs this command, from any directory') - .addHelpText('after', '\nAccepts --resume to resume a persisted session from this checkout.\n') - .action(() => { // Commander parses the parent (default-surface) options on either side of - // the subcommand into `program.opts()`. `meta` accepts only `--resume`, so - // a leaked `--config`/`-p` is a mistyped invocation that must fail loud - // rather than silently be dropped. + // the subcommand into `program.opts()`. `web` shares none of them, so a + // leaked `--config`/`-p`/`--resume` is a mistyped invocation that must + // fail loud rather than silently start the web server and drop it. const parent = program.opts<{ config?: string; prompt?: string; resume?: string }>() - if (parent.config !== undefined || parent.prompt !== undefined) { - program.error('error: meta takes neither --config nor -p/--prompt') + if (parent.config !== undefined || parent.prompt !== undefined || parent.resume !== undefined) { + program.error('error: web takes none of --config, -p/--prompt, or --resume') } - // Same reason as the default surface: an empty id would start a fresh - // session downstream instead of failing the mistyped resume. - if (parent.resume === '') program.error('error: --resume needs a session id') - resolved = { mode: 'meta', ...parent.resume !== undefined && { resume: parent.resume } } + resolved = resolveWeb(options) }) try { diff --git a/apps/cli/src/bin.ts b/apps/cli/src/bin.ts index caff90d7f2..88dbece55a 100644 --- a/apps/cli/src/bin.ts +++ b/apps/cli/src/bin.ts @@ -30,7 +30,7 @@ const invocation = parseDshArgs(process.argv.slice(2), readVersion()) switch (invocation.mode) { case 'web': { const { runWeb } = await import('./web.ts') - await runWeb(invocation.host, invocation.port, invocation.dev, invocation.workspaceRoot, invocation.trustedHosts, invocation.config) + await runWeb(invocation.host, invocation.port, invocation.dev, invocation.workspaceRoot, invocation.trustedHosts) break } case 'headless': { @@ -40,23 +40,7 @@ switch (invocation.mode) { } case 'tui': { const { runTui } = await import('./tui.ts') - await runTui(invocation.config, invocation.resume, undefined, undefined, invocation.configReplace) - break - } - case 'meta': { - const { runMeta } = await import('./tui.ts') - await runMeta(invocation.resume) - break - } - case 'list-sessions': { - const { runListSessions } = await import('./list-sessions.ts') - await runListSessions(invocation.json) - break - } - case 'migrate': - case 'upgrade': { - const { runSkillSession } = await import('./tui.ts') - await runSkillSession(`dsh-${invocation.mode}`) + await runTui(invocation.config, invocation.resume) break } default: diff --git a/apps/cli/src/headless.ts b/apps/cli/src/headless.ts index 7c73623907..d7588ccae4 100644 --- a/apps/cli/src/headless.ts +++ b/apps/cli/src/headless.ts @@ -14,7 +14,6 @@ import type { MuxFrame } from '@deepseek-ai/dsh-host-apiproxy/api' import type { RpcRequest, RpcResponse } from '@deepseek-ai/dsh-host-apiproxy/api/rpc' import type { SessionId } from '@deepseek-ai/dsh-session' import { AppCLIEntry } from './app-cli-entry.ts' -import { registerLiveSessions } from './register-session.ts' /** Outcome of one headless turn: aggregated final text plus the turn-end reason kind. */ interface TurnOutcome { @@ -76,13 +75,11 @@ async function consumeUntilTurnEnd(frames: AsyncIterable>, export async function runHeadless(task: string): Promise { // A missing DEEPSEEK_API_KEY throws here (plugin load is fail-loud, uncaught by design). const entry = new AppCLIEntry({ - configPath: fileURLToPath(new URL('../base.cordis.yml', import.meta.url)), - overlayPath: fileURLToPath(new URL('../web.cordis.yml', import.meta.url)), + configPath: fileURLToPath(new URL('../cordis.yml', import.meta.url)), dev: false, port: 0, }) const { ctx, port } = await entry.run() - await registerLiveSessions(ctx) const dispose = async (): Promise => { await ctx.fiber.dispose() } // The headless session is web-observable while it runs (same composition). process.stderr.write(`dsh: observing at http://127.0.0.1:${String(port)}\n`) diff --git a/apps/cli/src/list-sessions.ts b/apps/cli/src/list-sessions.ts deleted file mode 100644 index 08a972f402..0000000000 --- a/apps/cli/src/list-sessions.ts +++ /dev/null @@ -1,101 +0,0 @@ -/** - * `dsh list-sessions` (alias `dsh ps`) — list the sessions running right now. - * - * A read-only surface: it mounts the session registry alone and never boots an - * agent tree, so listing stays fast and cannot start model work as a side - * effect. Liveness comes from the registry, which prunes records whose process - * is gone, and every displayed field including the title comes from the record, - * so no session log is opened and no backend format is assumed. - * @module @deepseek-ai/dsh/list-sessions - */ - -import { Context } from 'cordis' -import { type SessionRegistryRecord } from '@deepseek-ai/dsh-session-registry' -import SessionRegistryFile from '@deepseek-ai/dsh-session-registry-file' -import { registryRoot } from './register-session.ts' - -/** Column header text, also the minimum width of each column. */ -const HEADERS = ['SESSION', 'PID', 'UPTIME', 'WORKSPACE', 'TITLE'] as const - -/** Shown when a session has no title yet. */ -const NO_TITLE = '—' - -/** - * Render milliseconds of uptime as a compact human duration. - * @param ms - elapsed milliseconds since the session registered. - * @returns a short duration such as `12s`, `4m`, or `2h14m`. - */ -export function formatUptime(ms: number): string { - const seconds = Math.max(0, Math.floor(ms / 1000)) - if (seconds < 60) return `${String(seconds)}s` - const minutes = Math.floor(seconds / 60) - if (minutes < 60) return `${String(minutes)}m` - const hours = Math.floor(minutes / 60) - const remainder = minutes % 60 - if (hours < 24) return remainder === 0 ? `${String(hours)}h` : `${String(hours)}h${String(remainder)}m` - const days = Math.floor(hours / 24) - const leftoverHours = hours % 24 - return leftoverHours === 0 ? `${String(days)}d` : `${String(days)}d${String(leftoverHours)}h` -} - -/** One fully-resolved listing row, in column order. */ -type Row = readonly [string, string, string, string, string] - -/** - * Build the display rows for a listing, newest session first. - * @param records - the live records to render. - * @param now - the current epoch milliseconds uptime is measured against. - * @returns one row per record, each already stringified per column. - */ -export function buildRows(records: readonly SessionRegistryRecord[], now: number): Row[] { - return [...records] - .sort((left, right) => right.startedAt - left.startedAt) - .map(record => [ - record.sessionId, - String(record.pid), - formatUptime(now - record.startedAt), - record.cwd, - record.title ?? NO_TITLE, - ] as const) -} - -/** - * Render rows as a left-aligned table with a header line. - * - * The last column is never padded, so a long title cannot add trailing - * whitespace to every line. - * @param rows - the rows to render, already stringified. - * @returns the complete table text, newline-terminated. - */ -export function renderTable(rows: readonly Row[]): string { - const widths = HEADERS.map((header, column) => - Math.max(header.length, ...rows.map(row => row[column]?.length ?? 0))) - const line = (cells: readonly string[]): string => - cells.map((cell, column) => column === cells.length - 1 ? cell : cell.padEnd(widths[column] ?? 0)).join(' ').trimEnd() - return [line(HEADERS), ...rows.map(row => line(row))].join('\n') + '\n' -} - -/** - * List live sessions and exit. Prints a table by default, or a JSON array with - * `--json`; an empty listing is a success, not an error. - * @param json - emit the machine-readable JSON array instead of the table. - */ -export async function runListSessions(json: boolean): Promise { - const ctx = new Context() - await ctx.plugin(SessionRegistryFile, { root: registryRoot() }) - const records = await ctx.sessionRegistry.list() - await ctx.fiber.dispose() - - if (json) { - const rows = [...records] - .sort((left, right) => right.startedAt - left.startedAt) - .map(record => ({ ...record, uptimeMs: Date.now() - record.startedAt, title: record.title ?? null })) - process.stdout.write(`${JSON.stringify(rows, undefined, 2)}\n`) - return - } - if (records.length === 0) { - process.stdout.write('no dsh sessions running\n') - return - } - process.stdout.write(renderTable(buildRows(records, Date.now()))) -} diff --git a/apps/cli/src/register-session.ts b/apps/cli/src/register-session.ts deleted file mode 100644 index ff78456e8a..0000000000 --- a/apps/cli/src/register-session.ts +++ /dev/null @@ -1,44 +0,0 @@ -/** - * Mounts the cross-process live-session registry that `dsh list-sessions` reads, plus the - * publisher that keeps it in step with this process's sessions. - * - * Both plugins mount on the booted app's own context, so records share that - * fiber's lifetime: an ordinary exit disposes the fiber and deregisters, while a - * killed process leaves records the next reader prunes by pid. Only top-level - * surfaces a user launches mount this — in-process subagents have no process of - * their own, and out-of-process subagent backends spawn `dsh-jsonrpc-agent` - * rather than this CLI, so neither reaches this path. - * @module @deepseek-ai/dsh/register-session - */ - -import { join } from 'node:path' -import type { Context } from 'cordis' -import { resolveDshHome } from '@deepseek-ai/dsh-paths' -import SessionRegistryFile from '@deepseek-ai/dsh-session-registry-file' -import * as sessionRegistryLive from '@deepseek-ai/dsh-session-registry-live' - -/** Registry root under the Harness home, shared by every surface and by `dsh list-sessions`. */ -export const registryRoot = (): string => join(resolveDshHome(), 'run') - -/** - * Publish this process's sessions for the lifetime of `ctx`. - * - * Publication follows session lifecycle rather than a launcher-known id, so one - * path serves every surface identically — the TUI's single session and a - * server's on-demand ones alike — and titles reach the listing as they are - * logged. - * - * Mounting is best-effort: a registry failure must not take down a working agent - * session, because the registry is an observability aid rather than part of the - * agent's contract. Failures warn through the context logger. - * @param ctx - the booted app context whose lifetime the records share. - */ -export async function registerLiveSessions(ctx: Context): Promise { - try { - const scope = ctx.isolate('sessionRegistry') - await scope.plugin(SessionRegistryFile, { root: registryRoot() }) - await scope.plugin(sessionRegistryLive) - } catch (error) { - ctx.logger('dsh').warn('session registry unavailable; `dsh list-sessions` will not list these sessions: %s', String(error)) - } -} diff --git a/apps/cli/src/tui.ts b/apps/cli/src/tui.ts index c65f01fbc3..f0ef5ddc02 100644 --- a/apps/cli/src/tui.ts +++ b/apps/cli/src/tui.ts @@ -33,7 +33,6 @@ import { resolveDshHome, resolveSessionsRoot } from '@deepseek-ai/dsh-paths' import { SessionId } from '@deepseek-ai/dsh-session' import { CONFIGURED_AGENT_IDENTITIES_KEY } from '@deepseek-ai/dsh-agent-loop' import type { Context } from 'cordis' -import { registerLiveSessions } from './register-session.ts' import { INITIAL_SKILL_KEY, MAIN_SESSION_ID_KEY, @@ -71,8 +70,7 @@ const SOURCE_ROOT = fileURLToPath(new URL('../../..', import.meta.url)) * shared session-store root, `sessions` under the Harness home. Shared-store * policy is the launcher's alone — the app bundle treats the slot as opaque and * keeps a project-local fallback, so only `dsh` decides that sessions are - * shared across working directories (making `/resume` and `list-sessions` span - * every workspace). + * shared across working directories (making `/resume` span every workspace). * @returns the absolute session-store root this launcher shares. */ export function launcherSessionsRoot(): string { @@ -238,8 +236,8 @@ export async function runTui( hostCtx.provide(MAIN_SESSION_ID_KEY, identity) hostCtx.provide(TUI_GOODBYE_MESSAGE_KEY, goodbye) // Shared-store policy is the launcher's: sessions live in one root under - // the Harness home across every cwd, so /resume and list-sessions see - // every workspace. The bundle treats the slot as opaque. + // the Harness home across every cwd, so /resume sees every workspace. + // The bundle treats the slot as opaque. hostCtx.provide(SESSIONS_ROOT_KEY, launcherSessionsRoot()) // The agent-loop row reads this to bind `main`, and the tui row reads the // same id, so a personal overlay repointing the model route cannot drop @@ -258,8 +256,5 @@ export async function runTui( ) app.current = ctx addHarnessSourceSection(ctx, SOURCE_ROOT) - // Publication follows the store; meta mode already chdir'd, so each session - // reports its own cwd. - await registerLiveSessions(ctx) } /* v8 ignore stop */ diff --git a/apps/cli/src/web.ts b/apps/cli/src/web.ts index 24d21a6eb4..69e79ab5d9 100644 --- a/apps/cli/src/web.ts +++ b/apps/cli/src/web.ts @@ -7,13 +7,9 @@ */ import { fileURLToPath } from 'node:url' -import { resolveConfigPath } from '@deepseek-ai/dsh-app-boot' import { AppCLIEntry } from './app-cli-entry.ts' -import { registerLiveSessions } from './register-session.ts' -// The shared core every `dsh` surface mounts, plus this surface's overlay over it. -const BASE_CONFIG = fileURLToPath(new URL('../base.cordis.yml', import.meta.url)) -const WEB_OVERLAY = fileURLToPath(new URL('../web.cordis.yml', import.meta.url)) +const CONFIG_PATH = fileURLToPath(new URL('../cordis.yml', import.meta.url)) // Display-only mirror of the webserver schema's loopback host: the address the // local URL always prints. Not a source of truth — the schema is. @@ -27,8 +23,6 @@ const LOOPBACK_HOST = '127.0.0.1' * @param dev - mount the client HMR driver and watch plugin bundles for rebuilds. * @param workspaceRoot - parent directory for name-created workspaces, or `undefined` for the gateway's cwd fallback. * @param trustedHosts - extra authorities for the /api browser-trust fence, or `undefined` for the derived LAN literals alone. - * @param config - an overlay of loader patches applied over the shipped web - * composition, or `undefined` for none; already parsed from `--config`. */ export async function runWeb( host: string | undefined, @@ -36,12 +30,9 @@ export async function runWeb( dev: boolean, workspaceRoot: string | undefined, trustedHosts: string[] | undefined, - config?: string, ): Promise { const entry = new AppCLIEntry({ - configPath: BASE_CONFIG, - overlayPath: WEB_OVERLAY, - ...config !== undefined && { extraOverlayPath: resolveConfigPath(config, undefined) }, + configPath: CONFIG_PATH, dev, ...host !== undefined && { host }, ...port !== undefined && { port }, @@ -49,7 +40,6 @@ export async function runWeb( ...trustedHosts !== undefined && { trustedHosts }, }) const { ctx, port: boundPort } = await entry.run() - await registerLiveSessions(ctx) let exiting = false const shutdown = (code: number): void => { diff --git a/apps/cli/tests/args.spec.ts b/apps/cli/tests/args.spec.ts index 99569c7cf0..45830eee30 100644 --- a/apps/cli/tests/args.spec.ts +++ b/apps/cli/tests/args.spec.ts @@ -24,33 +24,17 @@ function exitCode(argv: string[]): number { afterEach(() => { vi.restoreAllMocks() }) describe('parseDshArgs', () => { - it('routes each mode by its shape: default TUI, -p headless, meta and web subcommands', () => { + it('routes each mode by its shape: default TUI, -p headless, web subcommand', () => { expect(parse([])).toEqual({ mode: 'tui' }) expect(parse(['--config', 'custom.yml'])).toEqual({ mode: 'tui', config: 'custom.yml' }) expect(parse(['--resume', 'sess', '--config', 'app.yml'])).toEqual({ mode: 'tui', config: 'app.yml', resume: 'sess' }) expect(parse(['-p', 'do the thing'])).toEqual({ mode: 'headless', prompt: 'do the thing' }) - // `meta` accepts `--resume` but does not redeclare it: a shared option parses - // into program.opts() on either side of the subcommand, and redeclaring it - // would leave the subcommand's own options empty and drop the id. - expect(parse(['meta'])).toEqual({ mode: 'meta' }) - expect(parse(['meta', '--resume', 'sess'])).toEqual({ mode: 'meta', resume: 'sess' }) - expect(parse(['--resume', 'sess', 'meta'])).toEqual({ mode: 'meta', resume: 'sess' }) - // Credential setup is option-free: it writes the Harness-home .env, so - // there is nothing for a flag to select. // Bare `web` carries no host/port: the shipped cordis.yml owns the default. expect(parse(['web'])).toEqual({ mode: 'web', dev: false }) // Host/port are unvalidated pass-throughs (the webserver schema gates them // at boot); the adapter only coerces the port string to a number. expect(parse(['web', '--host', '0.0.0.0', '--port', '8080', '--dev', '--workspace-root', '/w'])) .toEqual({ mode: 'web', host: '0.0.0.0', port: 8080, dev: true, workspaceRoot: '/w' }) - // Guided fresh-session entries carry nothing: bare mode discriminant only. - expect(parse(['migrate'])).toEqual({ mode: 'migrate' }) - expect(parse(['upgrade'])).toEqual({ mode: 'upgrade' }) - // `list-sessions` has one option and no workspace filter: the listing is always - // global. `ps` is its alias and resolves to the same mode. - expect(parse(['list-sessions'])).toEqual({ mode: 'list-sessions', json: false }) - expect(parse(['list-sessions', '--json'])).toEqual({ mode: 'list-sessions', json: true }) - expect(parse(['ps', '--json'])).toEqual({ mode: 'list-sessions', json: true }) // --trusted-host is variadic and repeatable; authorities pass through unvalidated. expect(parse(['web', '--trusted-host', 'harness.internal:3080', 'lab.internal', '--trusted-host', '10.0.0.9'])) .toEqual({ mode: 'web', dev: false, trustedHosts: ['harness.internal:3080', 'lab.internal', '10.0.0.9'] }) @@ -71,27 +55,6 @@ describe('parseDshArgs', () => { expect(exitCode(['web', '-p', 'task'])).toBe(1) expect(exitCode(['web', '--resume', 's'])).toBe(1) expect(exitCode(['--config', 'c.yml', 'web'])).toBe(1) - // Same rule for credential setup: it shares no option with the default - // surface, so a leaked flag is a typo, not something to ignore. - // `meta` fixes its own config tree and is interactive, so --config/-p are - // rejected; an empty id is swallowed downstream exactly as above. - expect(exitCode(['meta', '--resume='])).toBe(1) - expect(exitCode(['meta', '--config', 'c.yml'])).toBe(1) - expect(exitCode(['meta', '-p', 'task'])).toBe(1) - // `migrate`/`upgrade` take no options: any leaked default-surface flag is a - // mistyped invocation, not a silently-dropped input. - expect(exitCode(['migrate', '--resume', 's'])).toBe(1) - expect(exitCode(['migrate', '--config', 'c.yml'])).toBe(1) - expect(exitCode(['migrate', '-p', 'task'])).toBe(1) - expect(exitCode(['upgrade', '--resume', 's'])).toBe(1) - expect(exitCode(['upgrade', '--config', 'c.yml'])).toBe(1) - expect(exitCode(['-p', 'task', 'upgrade'])).toBe(1) - // `list-sessions`/`ps` is read-only and shares no default-surface option: a leaked flag is a - // mistyped invocation, not a listing with a silently dropped input. - expect(exitCode(['ps', '--resume', 's'])).toBe(1) - expect(exitCode(['list-sessions', '--config', 'c.yml'])).toBe(1) - expect(exitCode(['list-sessions', '-p', 'task'])).toBe(1) - expect(exitCode(['--resume', 's', 'ps'])).toBe(1) }) it('exits 0 for --help (disclosing web) and --version', () => { diff --git a/apps/cli/tests/built-bin.e2e.ts b/apps/cli/tests/built-bin.e2e.ts index 71c8257b08..1ea7d9f0db 100644 --- a/apps/cli/tests/built-bin.e2e.ts +++ b/apps/cli/tests/built-bin.e2e.ts @@ -1,9 +1,8 @@ -import { existsSync, mkdtempSync, rmSync } from 'node:fs' -import { tmpdir } from 'node:os' +import { existsSync } from 'node:fs' import { join } from 'node:path' import { fileURLToPath } from 'node:url' import { execa } from 'execa' -import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { describe, expect, it } from 'vitest' /** * Published-entry smoke for the `dsh` bin: run the built `lib/bin.js` under @@ -17,28 +16,19 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest' * node_modules, so no external consumer is assembled; missing-config fail-loud * and full-boot coverage for the shared dsh-app-boot glue live in cli-demo's * built-bin suite, and interactive TTY behavior is PTY-covered by - * examples/tui-agent. `dsh list-sessions` is covered here too: it is the one surface that - * boots no agent tree, so the built bin is the whole product path. - * Skips before the bin is built. + * examples/tui-agent. Skips before the bin is built. */ const repoRoot = fileURLToPath(new URL('../../../', import.meta.url)) const dshBin = join(repoRoot, 'apps/cli/lib/bin.js') -/** - * Run the built bin with PIPED stdio (stdin closed at EOF); resolve with output - * + exit code. `env` isolates the Harness home for surfaces that read it. - */ -async function runBuiltBin( - args: readonly string[] = [], - env: Record = {}, -): Promise<{ stdout: string; code: number; stderr: string }> { - const result = await execa(process.execPath, [dshBin, ...args], { +/** Run the built bin with PIPED stdio (stdin closed at EOF); resolve with output + exit code. */ +async function runBuiltBin(): Promise<{ stdout: string; code: number; stderr: string }> { + const result = await execa(process.execPath, [dshBin], { input: '', timeout: 25_000, killSignal: 'SIGKILL', reject: false, - env, }) if (result.timedOut) { throw new Error(`dsh built bin did not exit within 25s. stdout:\n${result.stdout}\nstderr:\n${result.stderr}`) @@ -55,37 +45,4 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)', // The refusal happens before any plugin mounts: stdout stays silent. expect(stdout).toBe('') }, 30_000) - - describe('dsh list-sessions', () => { - let home: string - beforeEach(() => { home = mkdtempSync(join(tmpdir(), 'dsh-ls-bin-')) }) - afterEach(() => { rmSync(home, { recursive: true, force: true }) }) - - it('reports an empty listing as success, not an error', async () => { - const { stdout, code, stderr } = await runBuiltBin(['list-sessions'], { DSH_HOME: home }) - expect(code).toBe(0) - expect(stdout.trim()).toBe('no dsh sessions running') - expect(stderr).toBe('') - }, 30_000) - - it('emits an empty JSON array for machines', async () => { - const { stdout, code } = await runBuiltBin(['ps', '--json'], { DSH_HOME: home }) - expect(code).toBe(0) - expect(JSON.parse(stdout)).toEqual([]) - }, 30_000) - - it('runs without a TTY, unlike the TUI surface', async () => { - // The listing is read-only and boots no agent tree, so piped stdio — the - // launch the TUI refuses — is a supported way to run it. - const { code, stderr } = await runBuiltBin(['ps'], { DSH_HOME: home }) - expect(code).toBe(0) - expect(stderr).not.toContain('interactive TTYs') - }, 30_000) - - it('rejects a leaked default-surface flag instead of listing', async () => { - const { code, stderr } = await runBuiltBin(['list-sessions', '--resume', 'sess'], { DSH_HOME: home }) - expect(code).not.toBe(0) - expect(stderr).toContain('list-sessions takes none of') - }, 30_000) - }) }) diff --git a/apps/cli/tests/list-sessions.spec.ts b/apps/cli/tests/list-sessions.spec.ts deleted file mode 100644 index a73faf1b20..0000000000 --- a/apps/cli/tests/list-sessions.spec.ts +++ /dev/null @@ -1,74 +0,0 @@ -/** - * Tests for the `dsh list-sessions` presentation layer: uptime formatting, row building - * (newest first, absent-title placeholder) and table alignment without trailing - * padding. Every displayed field comes from the record, so there is no log - * reading to cover here. - */ - -import { describe, expect, it } from 'vitest' -import { SessionId } from '@deepseek-ai/dsh-session' -import { BootId, type SessionRegistryRecord } from '@deepseek-ai/dsh-session-registry' -import { buildRows, formatUptime, renderTable } from '../src/list-sessions.ts' - -function record(overrides: Partial = {}): SessionRegistryRecord { - return { - sessionId: SessionId('sess-1'), - pid: 4242, - cwd: '/work/project', - startedAt: 1_000, - bootId: BootId('boot-1'), - ...overrides, - } -} - -describe('formatUptime', () => { - it.each([ - [0, '0s'], - [999, '0s'], - [12_000, '12s'], - [59_999, '59s'], - [60_000, '1m'], - [3_540_000, '59m'], - [3_600_000, '1h'], - [8_040_000, '2h14m'], - [86_400_000, '1d'], - [90_000_000, '1d1h'], - ])('renders %ims as %s', (ms, expected) => { - expect(formatUptime(ms)).toBe(expected) - }) - - it('never renders a negative duration for a clock that moved backwards', () => { - expect(formatUptime(-5_000)).toBe('0s') - }) -}) - -describe('buildRows', () => { - it('orders newest first and marks a missing title', () => { - const rows = buildRows([ - record({ sessionId: SessionId('older'), startedAt: 1_000 }), - record({ sessionId: SessionId('newer'), startedAt: 5_000 }), - ], 65_000) - expect(rows.map(row => row[0])).toEqual(['newer', 'older']) - expect(rows[0]).toEqual(['newer', '4242', '1m', '/work/project', '—']) - }) -}) - -describe('renderTable', () => { - it('aligns columns and leaves no trailing whitespace', () => { - const table = renderTable(buildRows([ - record({ sessionId: SessionId('short'), startedAt: 0, title: 'a title' }), - record({ sessionId: SessionId('a-much-longer-session-id'), startedAt: 1, title: 'a title' }), - ], 1_000)) - const lines = table.split('\n') - expect(lines[0]).toMatch(/^SESSION {18}\s+PID/) - for (const line of lines) expect(line).toBe(line.trimEnd()) - // The header and every row align on the same column starts. - const pidColumn = (line: string): number => line.includes('4242') ? line.indexOf('4242') : line.indexOf('PID') - expect(pidColumn(lines[1] ?? '')).toBe(pidColumn(lines[0] ?? '')) - expect(pidColumn(lines[2] ?? '')).toBe(pidColumn(lines[0] ?? '')) - }) - - it('renders a header even with no rows, so the columns stay discoverable', () => { - expect(renderTable([])).toBe('SESSION PID UPTIME WORKSPACE TITLE\n') - }) -}) diff --git a/apps/cli/tsconfig.json b/apps/cli/tsconfig.json index ef0a6705ad..ce43c0f31c 100644 --- a/apps/cli/tsconfig.json +++ b/apps/cli/tsconfig.json @@ -29,15 +29,6 @@ { "path": "../../packages/util/paths" }, - { - "path": "../../packages/session-registry/session-registry" - }, - { - "path": "../../packages/session-registry/session-registry-file" - }, - { - "path": "../../packages/session-registry/session-registry-live" - }, { "path": "../../packages/client/connection" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5b543c8227..e9281011c6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -284,15 +284,6 @@ importers: '@deepseek-ai/dsh-session-reference': specifier: workspace:^ version: link:../../packages/context/session-reference - '@deepseek-ai/dsh-session-registry': - specifier: workspace:^ - version: link:../../packages/session-registry/session-registry - '@deepseek-ai/dsh-session-registry-file': - specifier: workspace:^ - version: link:../../packages/session-registry/session-registry-file - '@deepseek-ai/dsh-session-registry-live': - specifier: workspace:^ - version: link:../../packages/session-registry/session-registry-live '@deepseek-ai/dsh-session-title': specifier: workspace:^ version: link:../../packages/session-title/session-title From c1324ee8969b4658dccb3d16057cbf7a913bb957 Mon Sep 17 00:00:00 2001 From: Turtle Date: Wed, 29 Jul 2026 15:28:38 +0800 Subject: [PATCH 020/113] fix(cli): preserve overlays without session registration --- apps/cli/src/args.ts | 152 ++++++++++++++++++++++++++++++------ apps/cli/src/bin.ts | 15 +++- apps/cli/src/headless.ts | 3 +- apps/cli/src/web.ts | 12 ++- apps/cli/tests/args.spec.ts | 28 ++++++- 5 files changed, 179 insertions(+), 31 deletions(-) diff --git a/apps/cli/src/args.ts b/apps/cli/src/args.ts index b929dc73f2..5cdf9d20d4 100644 --- a/apps/cli/src/args.ts +++ b/apps/cli/src/args.ts @@ -2,19 +2,24 @@ * Commander adapter for the `dsh` command-line entry: the one place argv is * parsed and routed to a mode. `bin.ts` switches on the returned discriminant * and dynamic-imports that mode's module. One program: the default (no - * subcommand) is the TUI/headless surface with option-only flags; `web` is a - * real subcommand. Commander owns `--help`/`--version` and parse errors — it - * prints and exits at the point of failure (a domain failure routes through + * subcommand) is the TUI/headless surface with option-only flags; `meta` and + * `web` are real subcommands. Commander owns `--help`/`--version` and parse + * errors — it prints and exits at the point of failure (a domain failure routes through * `command.error`), so this returns only a resolved mode. * @module @deepseek-ai/dsh/args */ import { Command, CommanderError } from 'commander' -/** Interactive TUI: the default mode. `--config` swaps the tree; `--resume ` rehydrates a session. */ +/** + * Interactive TUI: the default mode. `--config` applies an overlay over the + * shipped composition in place of the personal one, `--config-replace` boots a + * file as the whole tree instead, and `--resume ` rehydrates a session. + */ interface TuiInvocation { mode: 'tui' config?: string + configReplace?: string resume?: string } @@ -24,6 +29,27 @@ interface HeadlessInvocation { prompt: string } +/** + * Interactive TUI over this harness checkout: `dsh meta`. Identical to + * {@link TuiInvocation} except the workspace is the launcher's own source tree + * rather than the invoking directory. No `--config`: booting a foreign tree + * against the harness workspace is the `--config` case, not this one. + */ +interface MetaInvocation { + mode: 'meta' + resume?: string +} + +/** + * Guided fresh-session entries: `dsh migrate` seeds the first turn with the + * `dsh-migrate` skill, `dsh upgrade` with `dsh-upgrade`. Each always mints a + * fresh session in the invoking directory and takes no options — `--resume`, + * `--config`, and `-p` are rejected as mistyped, so there is nothing to carry. + */ +interface SkillSessionInvocation { + mode: 'migrate' | 'upgrade' +} + /** * Browser UI: `dsh web`. `host`/`port` are present only when the flag was * passed — pass-through overrides with no CLI default and no CLI validation: @@ -36,6 +62,8 @@ interface HeadlessInvocation { */ interface WebInvocation { mode: 'web' + /** Overlay of loader patches applied over the shipped web composition. */ + config?: string host?: string port?: number dev: boolean @@ -45,10 +73,16 @@ interface WebInvocation { } /** The resolved `dsh` invocation: exactly one mode. `--help`/`--version`/errors exit inside {@link parseDshArgs}. */ -export type DshInvocation = TuiInvocation | HeadlessInvocation | WebInvocation +export type DshInvocation = + | TuiInvocation + | HeadlessInvocation + | MetaInvocation + | SkillSessionInvocation + | WebInvocation /** Raw web-subcommand options straight from Commander. */ interface WebOptions { + config?: string host?: string port?: string dev?: boolean @@ -65,6 +99,7 @@ interface WebOptions { function resolveWeb(options: WebOptions): WebInvocation { return { mode: 'web', + ...options.config !== undefined && { config: options.config }, ...options.host !== undefined && { host: options.host }, ...options.port !== undefined && { port: Number(options.port) }, dev: options.dev === true, @@ -86,21 +121,31 @@ export function parseDshArgs(argv: readonly string[], version: string): DshInvoc const program = new Command() .name('dsh') .version(version, '-V, --version', 'output the version number') - .description('dsh: interactive TUI (default), headless task, and browser UI') + .description('dsh: DeepSeek Harness — an interactive coding agent for your terminal.\nRun `dsh` with no arguments to start a session in the current directory.') + // The default surface takes no positional task, so `dsh "task"` fails + // commander's arity check with no hint; these examples are where a first + // reader learns the entry points and that a one-shot task rides `-p`. + .addHelpText('after', ` +Examples: + dsh start an interactive session in this directory + dsh -p "run the tests" answer one task, print the result, and exit + dsh --resume continue a past session +`) .exitOverride() // Default surface: option-only (no positional), so `web` can be a real // subcommand without a positional collision. - .option('--config ', 'boot an alternate cordis.yml instead of the shipped tree (TUI mode)') - .option('-p, --prompt ', 'run one headless turn for this task, print the result, and exit') - .option('--resume ', 'resume the persisted session with this id (TUI mode)') - .action((options: { config?: string; prompt?: string; resume?: string }) => { + .option('-p, --prompt ', 'answer this task without the interactive UI, then exit') + .option('--resume ', 'continue a past session by id') + .option('--config ', 'apply this overlay of loader patches instead of the personal one') + .option('--config-replace ', 'boot this file as the entire tree, ignoring the shipped and personal configuration') + .action((options: { config?: string; configReplace?: string; prompt?: string; resume?: string }) => { if (options.prompt !== undefined) { // A headless prompt owns the invocation; an empty task has nothing to // run, and --config/--resume are TUI inputs that must not silently // vanish from a headless run. if (options.prompt === '') program.error('error: --prompt needs a task') - if (options.config !== undefined || options.resume !== undefined) { - program.error('error: --prompt takes no --config or --resume') + if (options.config !== undefined || options.configReplace !== undefined || options.resume !== undefined) { + program.error('error: --prompt takes no --config, --config-replace, or --resume') } resolved = { mode: 'headless', prompt: options.prompt } return @@ -108,32 +153,89 @@ export function parseDshArgs(argv: readonly string[], version: string): DshInvoc // An empty --resume= id would silently start a fresh session downstream // (agent-loop treats '' as no-resume), so a mistyped resume must fail loud. if (options.resume === '') program.error('error: --resume needs a session id') + // The two config flags are mutually exclusive: one layers over the shipped + // tree, the other discards it, so accepting both would silently drop one. + if (options.config !== undefined && options.configReplace !== undefined) { + program.error('error: --config and --config-replace are mutually exclusive') + } resolved = { mode: 'tui', ...options.config !== undefined && { config: options.config }, + ...options.configReplace !== undefined && { configReplace: options.configReplace }, ...options.resume !== undefined && { resume: options.resume }, } }) - const web = program.command('web').description('serve the browser UI (host/port default to the shipped config)') + // Commander parses the parent (default-surface) options on either side of a + // subcommand into `program.opts()`. For a subcommand that shares none of them, + // a leaked `--config`/`-p`/`--resume` is a mistyped invocation that must fail + // loud rather than silently run and drop the input. + const rejectParentOptions = (command: string): void => { + const parent = program.opts<{ config?: string; configReplace?: string; prompt?: string; resume?: string }>() + if (parent.config !== undefined || parent.configReplace !== undefined + || parent.prompt !== undefined || parent.resume !== undefined) { + program.error(`error: ${command} takes none of --config, -p/--prompt, or --resume`) + } + } + + // Registration order is the rendered help order, so daily use comes first + // and the harness-development surfaces (`web --dev`, `meta`) come last. + // `migrate` and `upgrade` are guided fresh-session entries: they take no + // options and always mint a fresh session, so nothing is left to carry. Each + // description names the outcome, not the skill the first turn invokes. + const guided = { + migrate: 'import settings from another coding agent (Claude Code, Codex, opencode)', + upgrade: 'update this dsh installation to the latest version', + } as const + for (const mode of ['migrate', 'upgrade'] as const) { + program + .command(mode) + .description(guided[mode]) + .action(() => { + rejectParentOptions(mode) + resolved = { mode } + }) + } + + // Host and port name no default: the CLI passes neither through when the flag + // is absent, so the shipped `cordis.yml` value stands and restating it here + // would duplicate a fact this file does not own. + const web = program.command('web').description('serve the browser UI on the configured host and port') web - .option('--host ', 'override the config bind host (127.0.0.1 or 0.0.0.0)') - .option('--port ', 'override the config listen port (0 requests an OS-assigned port)') - .option('--dev', 'mount the client HMR driver and watch plugin bundles for rebuilds') - .option('--workspace-root ', 'parent directory for name-created workspaces') + .option('--config ', 'apply this overlay of loader patches over the shipped configuration') + .option('--host ', 'bind host; pass 0.0.0.0 to reach it from another machine') + .option('--port ', 'listen port; pass 0 to let the OS pick a free one') + .option('--dev', 'developer mode: hot-reload the browser client') + .option('--workspace-root ', 'parent directory for workspaces created from the browser UI') .option('--trusted-host ', 'extra authority the /api browser-trust fence accepts (host or host:port; repeatable)') .action((options: WebOptions) => { - // Commander parses the parent (default-surface) options on either side of - // the subcommand into `program.opts()`. `web` shares none of them, so a - // leaked `--config`/`-p`/`--resume` is a mistyped invocation that must - // fail loud rather than silently start the web server and drop it. - const parent = program.opts<{ config?: string; prompt?: string; resume?: string }>() - if (parent.config !== undefined || parent.prompt !== undefined || parent.resume !== undefined) { - program.error('error: web takes none of --config, -p/--prompt, or --resume') - } + rejectParentOptions('web') resolved = resolveWeb(options) }) + // `--resume` is NOT redeclared here: an option a subcommand shares with its + // parent parses into `program.opts()` and leaves the subcommand's own options + // empty, so redeclaring it would silently drop the id. Commander therefore + // omits it from this subcommand's option list, hence the trailing help text. + program + .command('meta') + .description('work on the dsh source that runs this command, from any directory') + .addHelpText('after', '\nAccepts --resume to resume a persisted session from this checkout.\n') + .action(() => { + // Commander parses the parent (default-surface) options on either side of + // the subcommand into `program.opts()`. `meta` accepts only `--resume`, so + // a leaked `--config`/`-p` is a mistyped invocation that must fail loud + // rather than silently be dropped. + const parent = program.opts<{ config?: string; prompt?: string; resume?: string }>() + if (parent.config !== undefined || parent.prompt !== undefined) { + program.error('error: meta takes neither --config nor -p/--prompt') + } + // Same reason as the default surface: an empty id would start a fresh + // session downstream instead of failing the mistyped resume. + if (parent.resume === '') program.error('error: --resume needs a session id') + resolved = { mode: 'meta', ...parent.resume !== undefined && { resume: parent.resume } } + }) + try { program.parse(argv, { from: 'user' }) } catch (error) { diff --git a/apps/cli/src/bin.ts b/apps/cli/src/bin.ts index 88dbece55a..c5003cf795 100644 --- a/apps/cli/src/bin.ts +++ b/apps/cli/src/bin.ts @@ -30,7 +30,7 @@ const invocation = parseDshArgs(process.argv.slice(2), readVersion()) switch (invocation.mode) { case 'web': { const { runWeb } = await import('./web.ts') - await runWeb(invocation.host, invocation.port, invocation.dev, invocation.workspaceRoot, invocation.trustedHosts) + await runWeb(invocation.host, invocation.port, invocation.dev, invocation.workspaceRoot, invocation.trustedHosts, invocation.config) break } case 'headless': { @@ -40,7 +40,18 @@ switch (invocation.mode) { } case 'tui': { const { runTui } = await import('./tui.ts') - await runTui(invocation.config, invocation.resume) + await runTui(invocation.config, invocation.resume, undefined, undefined, invocation.configReplace) + break + } + case 'meta': { + const { runMeta } = await import('./tui.ts') + await runMeta(invocation.resume) + break + } + case 'migrate': + case 'upgrade': { + const { runSkillSession } = await import('./tui.ts') + await runSkillSession(`dsh-${invocation.mode}`) break } default: diff --git a/apps/cli/src/headless.ts b/apps/cli/src/headless.ts index d7588ccae4..8007b30d44 100644 --- a/apps/cli/src/headless.ts +++ b/apps/cli/src/headless.ts @@ -75,7 +75,8 @@ async function consumeUntilTurnEnd(frames: AsyncIterable>, export async function runHeadless(task: string): Promise { // A missing DEEPSEEK_API_KEY throws here (plugin load is fail-loud, uncaught by design). const entry = new AppCLIEntry({ - configPath: fileURLToPath(new URL('../cordis.yml', import.meta.url)), + configPath: fileURLToPath(new URL('../base.cordis.yml', import.meta.url)), + overlayPath: fileURLToPath(new URL('../web.cordis.yml', import.meta.url)), dev: false, port: 0, }) diff --git a/apps/cli/src/web.ts b/apps/cli/src/web.ts index 69e79ab5d9..85f79d5fc4 100644 --- a/apps/cli/src/web.ts +++ b/apps/cli/src/web.ts @@ -7,9 +7,12 @@ */ import { fileURLToPath } from 'node:url' +import { resolveConfigPath } from '@deepseek-ai/dsh-app-boot' import { AppCLIEntry } from './app-cli-entry.ts' -const CONFIG_PATH = fileURLToPath(new URL('../cordis.yml', import.meta.url)) +// The shared core every `dsh` surface mounts, plus this surface's overlay over it. +const BASE_CONFIG = fileURLToPath(new URL('../base.cordis.yml', import.meta.url)) +const WEB_OVERLAY = fileURLToPath(new URL('../web.cordis.yml', import.meta.url)) // Display-only mirror of the webserver schema's loopback host: the address the // local URL always prints. Not a source of truth — the schema is. @@ -23,6 +26,8 @@ const LOOPBACK_HOST = '127.0.0.1' * @param dev - mount the client HMR driver and watch plugin bundles for rebuilds. * @param workspaceRoot - parent directory for name-created workspaces, or `undefined` for the gateway's cwd fallback. * @param trustedHosts - extra authorities for the /api browser-trust fence, or `undefined` for the derived LAN literals alone. + * @param config - an overlay of loader patches applied over the shipped web + * composition, or `undefined` for none; already parsed from `--config`. */ export async function runWeb( host: string | undefined, @@ -30,9 +35,12 @@ export async function runWeb( dev: boolean, workspaceRoot: string | undefined, trustedHosts: string[] | undefined, + config?: string, ): Promise { const entry = new AppCLIEntry({ - configPath: CONFIG_PATH, + configPath: BASE_CONFIG, + overlayPath: WEB_OVERLAY, + ...config !== undefined && { extraOverlayPath: resolveConfigPath(config, undefined) }, dev, ...host !== undefined && { host }, ...port !== undefined && { port }, diff --git a/apps/cli/tests/args.spec.ts b/apps/cli/tests/args.spec.ts index 45830eee30..831a969d1f 100644 --- a/apps/cli/tests/args.spec.ts +++ b/apps/cli/tests/args.spec.ts @@ -24,17 +24,28 @@ function exitCode(argv: string[]): number { afterEach(() => { vi.restoreAllMocks() }) describe('parseDshArgs', () => { - it('routes each mode by its shape: default TUI, -p headless, web subcommand', () => { + it('routes each mode by its shape: default TUI, -p headless, meta and web subcommands', () => { expect(parse([])).toEqual({ mode: 'tui' }) expect(parse(['--config', 'custom.yml'])).toEqual({ mode: 'tui', config: 'custom.yml' }) expect(parse(['--resume', 'sess', '--config', 'app.yml'])).toEqual({ mode: 'tui', config: 'app.yml', resume: 'sess' }) expect(parse(['-p', 'do the thing'])).toEqual({ mode: 'headless', prompt: 'do the thing' }) + // `meta` accepts `--resume` but does not redeclare it: a shared option parses + // into program.opts() on either side of the subcommand, and redeclaring it + // would leave the subcommand's own options empty and drop the id. + expect(parse(['meta'])).toEqual({ mode: 'meta' }) + expect(parse(['meta', '--resume', 'sess'])).toEqual({ mode: 'meta', resume: 'sess' }) + expect(parse(['--resume', 'sess', 'meta'])).toEqual({ mode: 'meta', resume: 'sess' }) + // Credential setup is option-free: it writes the Harness-home .env, so + // there is nothing for a flag to select. // Bare `web` carries no host/port: the shipped cordis.yml owns the default. expect(parse(['web'])).toEqual({ mode: 'web', dev: false }) // Host/port are unvalidated pass-throughs (the webserver schema gates them // at boot); the adapter only coerces the port string to a number. expect(parse(['web', '--host', '0.0.0.0', '--port', '8080', '--dev', '--workspace-root', '/w'])) .toEqual({ mode: 'web', host: '0.0.0.0', port: 8080, dev: true, workspaceRoot: '/w' }) + // Guided fresh-session entries carry nothing: bare mode discriminant only. + expect(parse(['migrate'])).toEqual({ mode: 'migrate' }) + expect(parse(['upgrade'])).toEqual({ mode: 'upgrade' }) // --trusted-host is variadic and repeatable; authorities pass through unvalidated. expect(parse(['web', '--trusted-host', 'harness.internal:3080', 'lab.internal', '--trusted-host', '10.0.0.9'])) .toEqual({ mode: 'web', dev: false, trustedHosts: ['harness.internal:3080', 'lab.internal', '10.0.0.9'] }) @@ -55,6 +66,21 @@ describe('parseDshArgs', () => { expect(exitCode(['web', '-p', 'task'])).toBe(1) expect(exitCode(['web', '--resume', 's'])).toBe(1) expect(exitCode(['--config', 'c.yml', 'web'])).toBe(1) + // Same rule for credential setup: it shares no option with the default + // surface, so a leaked flag is a typo, not something to ignore. + // `meta` fixes its own config tree and is interactive, so --config/-p are + // rejected; an empty id is swallowed downstream exactly as above. + expect(exitCode(['meta', '--resume='])).toBe(1) + expect(exitCode(['meta', '--config', 'c.yml'])).toBe(1) + expect(exitCode(['meta', '-p', 'task'])).toBe(1) + // `migrate`/`upgrade` take no options: any leaked default-surface flag is a + // mistyped invocation, not a silently-dropped input. + expect(exitCode(['migrate', '--resume', 's'])).toBe(1) + expect(exitCode(['migrate', '--config', 'c.yml'])).toBe(1) + expect(exitCode(['migrate', '-p', 'task'])).toBe(1) + expect(exitCode(['upgrade', '--resume', 's'])).toBe(1) + expect(exitCode(['upgrade', '--config', 'c.yml'])).toBe(1) + expect(exitCode(['-p', 'task', 'upgrade'])).toBe(1) }) it('exits 0 for --help (disclosing web) and --version', () => { From 9e2c3d309309d44f2523b7a5db34a57f025a4cef Mon Sep 17 00:00:00 2001 From: Turtle Date: Wed, 29 Jul 2026 15:43:54 +0800 Subject: [PATCH 021/113] refactor(cli): exclude tmux context and source guard --- ...2026-07-27-tmux-location-context.i18n.yaml | 6 - .../2026-07-27-tmux-location-context.md | 61 -- .../2026-07-27-tmux-location-context.zh.md | 61 -- ...8-source-guard-staging-edit-gate.i18n.yaml | 6 - ...26-07-28-source-guard-staging-edit-gate.md | 76 --- ...07-28-source-guard-staging-edit-gate.zh.md | 76 --- apps/cli/package.json | 2 - apps/cli/tui.cordis.yml | 3 - docs/config-catalog.md | 127 ++-- docs/event-producer-consumer.md | 14 +- docs/module-graph.md | 53 +- .../fixtures/guard/source-guard/cordis.yml | 38 -- .../fixtures/guard/source-guard/mock-llm.ts | 43 -- .../guard/source-guard/mount-guard.ts | 14 - .../tests/fixtures/tmux-context-driver.ts | 16 - .../tests/fixtures/tmux-context-mock-bash.ts | 46 -- .../tests/fixtures/tmux-context-mock-llm.ts | 22 - .../tests/fixtures/tmux-context.cordis.yml | 21 - examples/package.json | 2 - knip.json | 25 - packages/context/README.i18n.yaml | 4 +- packages/context/README.md | 3 +- packages/context/README.zh.md | 8 +- .../context/tmux-context/README.i18n.yaml | 6 - packages/context/tmux-context/README.md | 68 -- packages/context/tmux-context/README.zh.md | 68 -- packages/context/tmux-context/package.json | 49 -- packages/context/tmux-context/src/index.ts | 227 ------- .../context/tmux-context/src/invariant.ts | 30 - .../tmux-context/tests/tmux-context.e2e.ts | 77 --- .../tmux-context/tests/tmux-context.spec.ts | 367 ----------- packages/context/tmux-context/tsconfig.json | 40 -- packages/guard/README.i18n.yaml | 4 +- packages/guard/README.md | 1 - packages/guard/README.zh.md | 6 +- packages/guard/source-guard/README.i18n.yaml | 6 - packages/guard/source-guard/README.md | 88 --- packages/guard/source-guard/README.zh.md | 88 --- packages/guard/source-guard/package.json | 56 -- packages/guard/source-guard/src/index.ts | 319 ---------- packages/guard/source-guard/src/invariant.ts | 85 --- .../source-guard/tests/invariant.spec.ts | 134 ---- .../tests/loader-composition.e2e.ts | 93 --- .../source-guard/tests/source-guard.spec.ts | 581 ------------------ packages/guard/source-guard/tsconfig.json | 42 -- pnpm-lock.yaml | 86 --- tsconfig.host.json | 2 - 47 files changed, 97 insertions(+), 3153 deletions(-) delete mode 100644 .agents/notes/implemented/feature/2026-07-27-tmux-location-context.i18n.yaml delete mode 100644 .agents/notes/implemented/feature/2026-07-27-tmux-location-context.md delete mode 100644 .agents/notes/implemented/feature/2026-07-27-tmux-location-context.zh.md delete mode 100644 .agents/notes/implemented/feature/2026-07-28-source-guard-staging-edit-gate.i18n.yaml delete mode 100644 .agents/notes/implemented/feature/2026-07-28-source-guard-staging-edit-gate.md delete mode 100644 .agents/notes/implemented/feature/2026-07-28-source-guard-staging-edit-gate.zh.md delete mode 100644 examples/headless-agent/tests/fixtures/guard/source-guard/cordis.yml delete mode 100644 examples/headless-agent/tests/fixtures/guard/source-guard/mock-llm.ts delete mode 100644 examples/headless-agent/tests/fixtures/guard/source-guard/mount-guard.ts delete mode 100644 examples/headless-agent/tests/fixtures/tmux-context-driver.ts delete mode 100644 examples/headless-agent/tests/fixtures/tmux-context-mock-bash.ts delete mode 100644 examples/headless-agent/tests/fixtures/tmux-context-mock-llm.ts delete mode 100644 examples/headless-agent/tests/fixtures/tmux-context.cordis.yml delete mode 100644 packages/context/tmux-context/README.i18n.yaml delete mode 100644 packages/context/tmux-context/README.md delete mode 100644 packages/context/tmux-context/README.zh.md delete mode 100644 packages/context/tmux-context/package.json delete mode 100644 packages/context/tmux-context/src/index.ts delete mode 100644 packages/context/tmux-context/src/invariant.ts delete mode 100644 packages/context/tmux-context/tests/tmux-context.e2e.ts delete mode 100644 packages/context/tmux-context/tests/tmux-context.spec.ts delete mode 100644 packages/context/tmux-context/tsconfig.json delete mode 100644 packages/guard/source-guard/README.i18n.yaml delete mode 100644 packages/guard/source-guard/README.md delete mode 100644 packages/guard/source-guard/README.zh.md delete mode 100644 packages/guard/source-guard/package.json delete mode 100644 packages/guard/source-guard/src/index.ts delete mode 100644 packages/guard/source-guard/src/invariant.ts delete mode 100644 packages/guard/source-guard/tests/invariant.spec.ts delete mode 100644 packages/guard/source-guard/tests/loader-composition.e2e.ts delete mode 100644 packages/guard/source-guard/tests/source-guard.spec.ts delete mode 100644 packages/guard/source-guard/tsconfig.json diff --git a/.agents/notes/implemented/feature/2026-07-27-tmux-location-context.i18n.yaml b/.agents/notes/implemented/feature/2026-07-27-tmux-location-context.i18n.yaml deleted file mode 100644 index cbac9d1251..0000000000 --- a/.agents/notes/implemented/feature/2026-07-27-tmux-location-context.i18n.yaml +++ /dev/null @@ -1,6 +0,0 @@ -# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each -# side as of the last confirmed-consistent state. Both languages carry equal authority; -# after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-27-tmux-location-context.md -2026-07-27-tmux-location-context.md: b6f0b0cc85fa4808bd761f30bdbab8ad65e61717 -2026-07-27-tmux-location-context.zh.md: e79214e03296a87c622df91437385950c00eded6 diff --git a/.agents/notes/implemented/feature/2026-07-27-tmux-location-context.md b/.agents/notes/implemented/feature/2026-07-27-tmux-location-context.md deleted file mode 100644 index b6f0b0cc85..0000000000 --- a/.agents/notes/implemented/feature/2026-07-27-tmux-location-context.md +++ /dev/null @@ -1,61 +0,0 @@ -# Agent Note: tmux-location context - -Status: implemented - -English | [中文](2026-07-27-tmux-location-context.zh.md) - -## Problem - -An agent running inside tmux has no way to tell the model where it is: which session, window, and pane the process occupies, and how the window is laid out. A user directing several panes wants the model to orient itself to its own location so instructions like "the pane below" or "this window" resolve. The location must reach the model as durable, reconstructable context, not a system-prompt value rewritten in place, and must cost nothing when the location has not changed. - -tmux exposes this without a daemon: `$TMUX_PANE` names the process's pane, and `tmux display-message -t "$TMUX_PANE" -p ''` prints any pane/window/session field. The open question was how to observe it — pull on each preparation, or push from a tmux hook — and how to avoid a per-step token cost and hidden process-local state. - -## Decision - -`@deepseek-ai/dsh-tmux-context` is an opt-in function plugin in `packages/context/tmux-context/`, alongside the other bounded request-context enrichments that define neither a tool nor a service. Shipped examples do not mount it because tmux-location disclosure and its token cost are deployment policy. - -**Pull on the first step of each turn, not a tmux push.** The plugin prepends an `agent/step` listener and acts only when `step === 1`. A pull model needs no background process, no hook installation in the user's tmux, and no teardown; it re-reads current state each turn so a moved, renamed, or re-laid-out pane is picked up naturally. Gating on the first step makes the reading per-turn: a location is stable within a turn, and re-querying every step would add cost without new information. A pane moved mid-turn is reflected on the next turn, which is the accepted tradeoff for the simpler design. - -**Read through the `ctx.bash` seam, never raw `child_process`.** The listener runs the tmux/`ps` read commands through `ctx.bash`, so the deployment's sandbox and policy apply and the plugin owns no subprocess code. Absent `ctx.bash`, absent tmux env, a wrong field count, or an empty pane id each make the attempt a no-op, matching how `workspace-context` no-ops without an `fs` provider. - -**Detect a real pane by tty, not by `$TMUX_PANE` alone.** `$TMUX_PANE` is inherited: a terminal launched from a tmux shell (a VS Code integrated terminal, a desktop launcher) carries `$TMUX`/`$TMUX_PANE` from that ancestor even though the process does not live in that pane, which otherwise injects a stale, wrong location. The command resolves this process's controlling terminal with `ps -o tty= -p ` (the agent's own pid, passed in-process) and compares it to the pane's `#{pane_tty}`; fields are emitted only on a match. A genuine pane owns this process's tty; an inherited environment names some other pane's tty and reads as "not in tmux". Checking `$TMUX` instead does not help — it is inherited identically. This is the definitive discriminator and needs no allowlist of terminal emulators. - -**Own location and layout only.** The queried fields are session name, window index/name, pane index/id, window/pane active flags, and `window_layout`. Pane and window pixel sizes are excluded (layout tree conveys structure; sizes are noisy and change on every terminal resize). Sibling-pane contents are never captured (`capture-pane`), keeping the reading small and avoiding scraping unrelated, possibly sensitive, output. - -**Inject only on change, with optional interval floor.** When due, the plugin calls `agent.inject()` for one `user/message` with source `{ kind: 'plugin', plugin: 'tmux-context' }`. Change suppression compares the rendered state block (everything after the turn preamble line) against the latest injection of this source, found by scanning raw durable session events — so the schedule survives compaction and process resume without a process-local cache. The optional `refreshIntervalMs` (manually validated as a non-negative safe integer at plugin load) additionally suppresses injections within that window of the latest one. - -### Text - -```text -tmux location (turn ): -session , window "", pane -window active=<0|1>, pane active=<0|1>, layout -``` - -The turn preamble is the volatile first line; the two-line state block below it is the unit compared for change suppression, so re-injection is driven by tmux state, not loop position. - -### Durability and request reconstruction - -Each reading is a normal surface node until compaction shadows it; the plugin contributes nothing to system-prompt assembly and `request/header` carries no tmux-context text. The reading records a preparation attempt, not a committed step: because the prepended listener runs first, its append may remain when a later `agent/step` listener cancels or fails the attempt, and the append-only log performs no rollback. - -The published `./invariant` companion registers no runtime check: a reading is a per-turn snapshot of external tmux state, so the session holds no cross-event relation to validate, and scheduling and format stay pinned by the package's pipeline tests. - -## Consequences - -An agent booted inside tmux now receives its own session/window/pane location and window layout as durable, source-attributed context, updated per turn when the location changes. Deployments opt in through cordis.yml; the default spine and shipped examples stay silent. Outside a real tmux pane — including a terminal that merely inherited `$TMUX`/`$TMUX_PANE` — or without a `ctx.bash` executor, the plugin is inert with no error, so composing it is safe everywhere. Because the reading is one durable `user/message`, it survives compaction as ordinary history, contributes nothing to system-prompt assembly or request headers, and costs at most one two-line message per changed turn. The pull model adds one bash execution (through the sandboxed bash seam) on the first step of each turn that is due — internally a `ps` tty probe, a `tmux display-message` tty query, and the field query. Only the optional interval floor suppresses the query itself; an unchanged location is known only after querying, so it suppresses the injection alone. - -## Testing - -Unit tests pin: first-step injection and source/surface metadata; the `$TMUX_PANE`-keyed command including its `#{pane_tty}`-vs-`ps -o tty=` guard; step-gating; change suppression across turns and re-injection on a moved pane; positive-interval suppression and threshold; every no-op path (no bash, nonzero exit, wrong field count, empty pane id, aborted signal); prepended ordering before ordinary `agent/step` listeners; resilience to a corrupt prior reading (non-text block, single-line text); and config rejection of negative and non-integer intervals. Per-file coverage is 100%. - -## Alternatives considered - -- **Push from a tmux hook / background watcher** — rejected: requires installing hooks in the user's tmux and a background process with teardown, to gain mid-step freshness that per-turn context does not need. -- **Run every step** — rejected: location is stable within a turn; re-querying adds token cost without new information. Gating on `step === 1` yields per-turn readings. -- **Raw `child_process`** — rejected: bypasses the sandbox/policy seam and hand-rolls subprocess code the `ctx.bash` executor already owns. -- **Include pane/window pixel sizes** — rejected: sizes churn on every resize and add noise; the layout tree already conveys structure. -- **Scrape sibling panes with `capture-pane`** — rejected: large, noisy, and privacy-sensitive; out of scope for "own location". -- **Dynamic system-prompt section** — rejected: replacing a value erases the earlier readings behind prior reasoning and is not reconstructable; one durable attributed message records each location where it became visible. -- **Trust `$TMUX_PANE` (or `$TMUX`) presence** — rejected: both are inherited by terminals launched from a tmux shell (VS Code integrated terminal), so a non-pane process injects a stale location. The pane `#{pane_tty}` vs. this process's controlling tty is the definitive check. -- **Denylist known terminal emulators (e.g. `TERM_PROGRAM=vscode`)** — rejected: a partial, ever-growing list that still misses other launchers; the tty match is exact and launcher-agnostic. -- **A runtime invariant validating each reading's turn, position, and format** — shipped initially, then removed: it re-derived the producer's own scheduling from the log and asserted a regex over text the same package had just rendered, so it restated `apply()` rather than checking an independent relation. Every failure it could report required an edit to this package, which its pipeline tests already catch. Reintroduce a companion check only for a relation the plugin does not itself compute — for example if readings gain cross-turn ordering or enclosure obligations that another package can violate. diff --git a/.agents/notes/implemented/feature/2026-07-27-tmux-location-context.zh.md b/.agents/notes/implemented/feature/2026-07-27-tmux-location-context.zh.md deleted file mode 100644 index e79214e032..0000000000 --- a/.agents/notes/implemented/feature/2026-07-27-tmux-location-context.zh.md +++ /dev/null @@ -1,61 +0,0 @@ -# Agent Note:tmux 位置上下文 - -Status: implemented - -[English](2026-07-27-tmux-location-context.md) | 中文 - -## 问题 - -运行在 tmux 内的 agent 无法告诉模型自己身在何处:进程占据哪个 session、window、pane,以及 window 如何布局。当用户操作多个 pane 时,希望模型能对自身位置有所定位,从而让"下方的 pane""这个 window"之类的指令得以解析。位置必须以持久、可重建的上下文形式送达模型,而非在原地被改写的系统提示值,并且当位置未变化时不产生任何成本。 - -tmux 无需守护进程即可暴露这些信息:`$TMUX_PANE` 标识进程所在 pane,`tmux display-message -t "$TMUX_PANE" -p ''` 可打印任意 pane/window/session 字段。待决问题在于如何观测——在每次准备时拉取,还是由 tmux hook 推送——以及如何避免逐步骤 token 成本与隐藏的进程内状态。 - -## 决策 - -`@deepseek-ai/dsh-tmux-context` 是位于 `packages/context/tmux-context/` 的可选启用型函数插件,与其他既不定义工具也不定义服务的有界请求上下文增强并列。随附示例不挂载它,因为 tmux 位置披露及其 token 成本属于部署策略。 - -**在每轮的第一个 step 拉取,而非 tmux 推送。** 插件前置注册一个 `agent/step` 监听器,仅在 `step === 1` 时动作。拉取模型无需后台进程、无需在用户的 tmux 中安装 hook、也无需清理;它每轮重新读取当前状态,因此被移动、改名或重新布局的 pane 都会被自然感知。以第一个 step 为门槛使读数按轮次生成:位置在一轮内是稳定的,逐步骤重复查询只会增加成本而不带来新信息。轮次中途移动的 pane 会在下一轮反映,这是换取更简单设计所接受的取舍。 - -**通过 `ctx.bash` seam 读取,绝不用裸 `child_process`。** 监听器通过 `ctx.bash` 运行 tmux/`ps` 只读命令,从而应用部署方的沙箱与策略,插件不拥有任何子进程代码。`ctx.bash` 缺失、tmux 环境缺失、字段数不符或 pane id 为空,都会使本次尝试成为空操作,与 `workspace-context` 在无 `fs` provider 时的空操作一致。 - -**以 tty 判定真实 pane,而非仅凭 `$TMUX_PANE`。** `$TMUX_PANE` 会被继承:从 tmux shell 启动的终端(VS Code 集成终端、桌面启动器)会从该祖先进程带上 `$TMUX`/`$TMUX_PANE`,即使进程并不位于那个 pane 中,否则就会注入一个陈旧且错误的位置。命令用 `ps -o tty= -p `(在进程内传入 agent 自身的 pid)解析本进程的控制终端,并与 pane 的 `#{pane_tty}` 比较;只有匹配时才输出字段。真正的 pane 拥有本进程的 tty;继承而来的环境指向的是另一个 pane 的 tty,因而被读作"不在 tmux 中"。改为检查 `$TMUX` 也无济于事——它同样会被继承。这是决定性的判别依据,且无需维护终端模拟器名单。 - -**仅自身位置与布局。** 查询字段为 session name、window index/name、pane index/id、window/pane 活动标志以及 `window_layout`。省略 pane 与 window 像素尺寸(布局树已传达结构;尺寸嘈杂且每次终端缩放都会变化)。从不采集相邻 pane 内容(`capture-pane`),使读数保持小巧,并避免抓取无关、可能敏感的输出。 - -**仅在变化时注入,并可选间隔下限。** 需要时,插件调用 `agent.inject()` 注入一条来源为 `{ kind: 'plugin', plugin: 'tmux-context' }` 的 `user/message`。变化抑制将渲染出的状态块(轮次前缀行之后的全部内容)与该来源的最近一次注入比较,后者通过扫描原始持久会话事件获得——因此调度可跨压缩与进程恢复存续,无需进程内缓存。可选的 `refreshIntervalMs`(在插件加载时手动校验为非负安全整数)会额外抑制距最近一次注入不足该窗口的注入。 - -### 文本 - -```text -tmux location (turn ): -session , window "", pane -window active=<0|1>, pane active=<0|1>, layout -``` - -轮次前缀是易变的首行;其下的两行状态块才是变化抑制所比较的单元,因此重新注入由 tmux 状态驱动,而非循环位置。 - -### 持久性与请求重建 - -每条读数在被压缩遮蔽前都是普通表层节点;插件对系统提示装配毫无贡献,`request/header` 也不携带任何 tmux-context 文本。读数记录的是一次准备尝试,而非已提交的 step:由于前置监听器最先运行,当后续 `agent/step` 监听器取消或失败时其追加可能仍会保留,只追加的日志不做回滚。 - -发布的 `./invariant` 伴生插件不注册任何运行时检查:读数是外部 tmux 状态的按轮快照,会话中不存在需要校验的跨事件关系,调度与格式由本包的管线测试固定。 - -## 后果 - -启动于 tmux 内的 agent 现在会以持久、带来源标记的上下文收到自身的 session/window/pane 位置及 window 布局,并在位置变化时按轮次更新。部署方通过 cordis.yml 选择启用;默认 spine 与随附示例保持沉默。在真实 tmux pane 之外——包括仅继承了 `$TMUX`/`$TMUX_PANE` 的终端——或没有 `ctx.bash` 执行器时,插件保持惰性且不报错,因此在任何地方组合它都安全。由于读数是一条持久的 `user/message`,它作为普通历史经受压缩,对系统提示装配与请求头毫无贡献,且每个发生变化的轮次至多花费一条两行消息。拉取模型在每个到期轮次的第一个 step 增加一次 bash 执行(经沙箱化的 bash seam)——内部包含一次 `ps` tty 探测、一次 `tmux display-message` tty 查询和字段查询。只有可选的间隔下限会抑制查询本身;位置是否变化只有在查询之后才知道,因此它只抑制注入。 - -## 测试 - -单元测试固定了:首个 step 的注入及来源/表层元数据;以 `$TMUX_PANE` 为键的命令(含其 `#{pane_tty}` 与 `ps -o tty=` 的比对守卫);step 门槛;跨轮次的变化抑制与 pane 移动时的重新注入;正间隔抑制与阈值;每条空操作路径(无 bash、非零退出、字段数不符、pane id 为空、信号已取消);前置排序先于普通 `agent/step` 监听器;对损坏的历史读数(非文本块、单行文本)的容错;以及配置对负值与非整数间隔的拒绝。逐文件覆盖率为 100%。 - -## 考虑过的替代方案 - -- **由 tmux hook / 后台监视器推送**——否决:需要在用户的 tmux 中安装 hook,并引入带清理的后台进程,只为换取按轮次上下文并不需要的步内新鲜度。 -- **每个 step 都运行**——否决:位置在一轮内稳定;重复查询只增加 token 成本而无新信息。以 `step === 1` 为门槛得到按轮次读数。 -- **裸 `child_process`**——否决:绕过沙箱/策略 seam,并手写 `ctx.bash` 执行器已拥有的子进程代码。 -- **包含 pane/window 像素尺寸**——否决:尺寸每次缩放都变动、徒增噪声;布局树已传达结构。 -- **用 `capture-pane` 抓取相邻 pane**——否决:庞大、嘈杂且涉及隐私;超出"自身位置"范围。 -- **动态系统提示区块**——否决:替换某个值会抹去支撑先前推理的历史读数且不可重建;单条持久且带来源的消息在每个位置变得可见时予以记录。 -- **信任 `$TMUX_PANE`(或 `$TMUX`)存在即可**——否决:两者都会被从 tmux shell 启动的终端(VS Code 集成终端)继承,于是非 pane 进程会注入陈旧位置。pane 的 `#{pane_tty}` 与本进程控制终端的比对才是决定性检查。 -- **对已知终端模拟器设黑名单(如 `TERM_PROGRAM=vscode`)**——否决:名单不完整且会不断增长,仍会漏掉其他启动器;tty 比对精确且与启动器无关。 -- **用运行时 invariant 校验每条读数的轮次、位置与格式**——最初随包发布,随后移除:它从日志中重新推导生产者自身的调度,并对同一个包刚刚渲染出的文本断言正则,因此只是重述 `apply()`,而非检查一条独立关系。它能报出的每种失败都必须先修改本包,而这些本包的管线测试已经覆盖。仅当出现插件自身并不计算的关系时才重新引入伴生检查——例如读数将来具备可被其他包破坏的跨轮次顺序或包裹义务。 diff --git a/.agents/notes/implemented/feature/2026-07-28-source-guard-staging-edit-gate.i18n.yaml b/.agents/notes/implemented/feature/2026-07-28-source-guard-staging-edit-gate.i18n.yaml deleted file mode 100644 index 91227d80e1..0000000000 --- a/.agents/notes/implemented/feature/2026-07-28-source-guard-staging-edit-gate.i18n.yaml +++ /dev/null @@ -1,6 +0,0 @@ -# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each -# side as of the last confirmed-consistent state. Both languages carry equal authority; -# after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-28-source-guard-staging-edit-gate.md -2026-07-28-source-guard-staging-edit-gate.md: 8452006190166c783efafc398566ef7f4da10323 -2026-07-28-source-guard-staging-edit-gate.zh.md: 83589ce833e4aa74968b40247848685a1e030f6b diff --git a/.agents/notes/implemented/feature/2026-07-28-source-guard-staging-edit-gate.md b/.agents/notes/implemented/feature/2026-07-28-source-guard-staging-edit-gate.md deleted file mode 100644 index 8452006190..0000000000 --- a/.agents/notes/implemented/feature/2026-07-28-source-guard-staging-edit-gate.md +++ /dev/null @@ -1,76 +0,0 @@ -# Agent Note: source-guard denies direct staging-checkout edits - -Status: implemented - -English | [中文](2026-07-28-source-guard-staging-edit-gate.zh.md) - -## Problem - -The [`dsh-customize`](../../../../skills/dsh-customize/SKILL.md) skill governs every personal change to a dsh source checkout: implement in a task worktree branched from the staging tip, then integrate under `.agents/merge.lock`. Its central rule is negative — do not edit the personal staging checkout directly — and a negative rule delivered only as prompt text fails in exactly the case that matters. An agent that never loads the skill never sees the rule, and one that loads it early can still forget it thirty tool calls later. The failure is silent and expensive: commits land on the staging branch that the launcher runs from, outside any task branch, with no lock held and no rollback worktree. - -Prompt guidance cannot fix this, because the guidance is what went unread. The rule needs an enforcement point. - -## Decision - -`@deepseek-ai/dsh-source-guard` (`packages/guard/source-guard/`) is a `tools/pre-execute` listener that returns `{kind: 'deny', reason}` for a `write` or `edit` whose target resolves inside a protected staging worktree, unless the calling session's durable log already records a successful `skill` call naming `dsh-customize`. It registers no service and contributes no prompt text or tool schema; an allowed call is indistinguishable from one made without the plugin. It is not in any shipped default composition. - -### Git identity from files, not a path prefix and not `git` - -Whether a path is protected is decided by reading `.git`, its `gitdir:` pointer, and `HEAD`. Three shapes resolve: a plain clone (`.git` is a directory that is its own common dir), a linked worktree (`.git` is a file pointing at `/worktrees/`, whose common dir is two levels up), and a detached HEAD (`HEAD` holds a raw object id and names no branch). A `gitdir:` pointer resolves whether absolute — what `git worktree add` writes — or relative, which git resolves against the worktree directory holding it. - -Denial requires the target's worktree to match the launcher's on both identities: the same shared git directory and the same branch. Both come from resolving `protectedCheckout`, so nothing about the protected branch is configured. An earlier revision matched a `dsh-staging/*` name pattern instead; the exact-branch rule replaced it because a pattern is wrong in both directions. It denied every sibling staging worktree an old install had left behind, none of which runs a launcher, and it silently protected nothing for a maintainer whose staging branch follows no naming convention — a fatal property for a shipped default that must hold for checkouts [`scripts/install.sh`](../../../../scripts/install.sh) did not create. - -A path-prefix rule would have been wrong, not merely imprecise. The task worktrees the skill prescribes live *inside* the protected tree at `/.worktrees/...`, so a prefix rule would deny every edit the workflow requires. Resolution walks outward from the target and stops at the first enclosing worktree, so it reports the innermost one: a nested task worktree answers with its own task branch and is allowed, while the launcher's own tree answers with the launcher's branch and is denied. - -Two path details decide whether the gate holds at all, and both are enforcement, not polish. Repository identity is compared on symlink-resolved paths (`canonicalPath` from `dsh-sandbox`), because a session cwd under `/var/...` and a configured path under `/private/var/...` are the same macOS directory and a lexical comparison would fail open on every write. And a relative `file_path` is resolved against the calling session's workspace, exactly as `dsh-tool-fs` resolves it; judging only absolute paths would have left a relative path as an unguarded route to a protected file. - -`protectedCheckout` names a path inside the guarded checkout, defaulting to this module's own file. That resolves the checkout the running harness was launched from — the live deployment, whatever its branch is named. A harness running from an installed copy resolves a different repository, or none, and guards nothing; the rule is meaningless outside a source checkout. - -The shipped TUI composition loads the plugin with these defaults, so every source install is protected without configuration. It is inert for an ordinary project: a workspace in another repository, or none, never matches the launcher's identities. - -### Satisfaction replayed from the durable log - -The gate lifts on a `tool/call` naming the `skill` tool whose arguments parse to `{name: }`, paired by call id with a non-error `tool/result`. Both fields are already durable (`packages/core/session/src/types.ts`), so this needs no new session event and no coupling to skill-provider internals. - -The log is the only state. In-memory satisfaction (the `WeakMap` shape [`repeat-tool-guard`](../../archived/feature/2026-07-08-repeat-tool-guard.md) uses for its chains) would be smaller, but it loses satisfaction on resume: a resumed session that already read the skill would be told to read it again, and the denial would look like a bug rather than a rule. Replay costs a scan bounded by the first hit and buys resume correctness. - -### Fail open, deliberately - -A path outside any worktree, a detached HEAD, a foreign repository, a malformed `gitdir:` pointer, and unreadable metadata all leave the call to the rest of the chain. The alternative — denying whenever git identity is unavailable — converts any `.git` permission problem into a harness that cannot write files at all. The guard exists to prevent one specific, recoverable mistake; it must not become a larger outage than the mistake. - -### Narrow scope - -`read` is never gated: inspecting staging violates nothing, and the skill explicitly permits read-only questions. `bash` is not gated either. Reliably classifying mutating shell commands is a matcher problem with no honest completion condition, so a determined model can still change staging through a shell. This is a boundary against forgetting, not a sandbox against intent. - -## Alternatives considered - -- **Advisory reminder instead of denial** (`additionalContexts` on `tools/post-execute`, the `repeat-tool-guard` shape). Rejected: the write has already happened when the reminder arrives, so the violation is committed and the guidance is again just text. -- **`{kind: 'ask'}` routed to approval.** Rejected: it prompts on every legitimate task-worktree edit in the common case, and degrades to denial in a composition without approval support, making behavior depend on unrelated plugins. -- **Running `git rev-parse` through `ctx.subprocess`.** Rejected after measuring the alternative: two file reads answer the same question with no process spawn per gated write, no `git` on `PATH` requirement, and no subprocess dependency. Reading `.git` and `HEAD` is a stable on-disk format, not an implementation detail. -- **Explicit `protectedRoots` config with no detection.** Rejected: it makes the common case require configuration to be correct, and a stale absolute path silently disables protection. -- **A configurable staging-branch name pattern** (`stagingBranchPatterns`, default `dsh-staging/*`). Shipped first, then removed: it protects the wrong set in both directions — every stale sibling worktree that runs no launcher, and nothing at all for a maintainer whose branch is named otherwise. Deriving the branch from the launcher needs no configuration and cannot be misconfigured. -- **Auto-detecting the checkout with no override.** Rejected: the detection is a default, not a law; a deployment guarding a different checkout, or running from an installed copy, needs the explicit value. -- **Denying everything under the checkout root, `.worktrees/` included.** Rejected: it blocks the workflow the skill prescribes, so the guard would fire on every legitimate task edit. -- **Gating `bash` with a mutating-command matcher.** Deferred, not rejected: worth revisiting if bypasses are observed in practice. A matcher that is wrong in either direction is worse than an honestly narrow gate. - -## Consequences - -The rule now holds without depending on the model having read it, and the denial names the path, the branch, and the skill, so the model's next action is determined rather than guessed. Enforcement sits at the operation boundary that owns the decision, so it cannot be bypassed by prompt filtering or listener order. - -Shipping it in the TUI default means every source install is protected without configuration, and the protection follows the launcher across upgrades because the branch is derived rather than named. The cost of that reach is that the plugin loads for every user, including those whose workspace it can never match. - -What it cost otherwise: the guard is only as complete as its tool list, and `bash` remains open. Worktree identity is cached per directory for the plugin's lifetime, so a mid-session branch switch is not observed on either side. Only the launcher's own checkout is protected, so a stale sibling stays editable. Loading the skill lifts the gate for the whole session without verifying the workflow was actually followed — the gate proves the instructions were read, not obeyed. Satisfaction is per session, so a subagent with its own session must load the skill itself. - -## Testing - -Unit suites drive a real agent loop against a mock adapter over real git-metadata fixtures — a staging worktree, a task worktree nested inside it, a plain clone, a foreign repository on a staging-named branch, a detached HEAD, absolute and relative `gitdir:` pointers, a symlinked route to one repository, a malformed pointer, and unreadable metadata — covering both source files to per-file 100%. A companion `invariant.ts` validates the durable denial's shape, since the refusal text is the package's only model-visible output and is actionable only when it names the path, branch, and skill. - -The real-composition smoke boots `examples/headless-agent/tests/fixtures/guard/source-guard/cordis.yml` through the Loader and the headless app, and asserts three things about the assembled run: the tool result is an error, its text is the exact denial, and the targeted file still holds its original bytes — enforcement before dispatch, not advice after it. - -An ACP snapshot scenario (`source-guard-staging-deny`) originally owned the assembled transcript, seeding a staging worktree in the harness's generated cwd through a new `Scenario.prepareCwd` hook — git never tracks an entry named `.git` and `.gitignore` excludes every `worktrees/` directory, so the fixture committed the two `HEAD` bodies and the hook assembled the real layout. Authoring it paid for itself immediately: it exposed both path defects above (the transcript showed `fs-policy` answering first wherever the guard had quietly declined to judge) and then caught its own first fixture, whose ignored `worktrees/` path passed locally from an untracked file. The scenario was later removed with the assembled-run evidence consolidated into the Loader-composition smoke; the `prepareCwd` hook it introduced remains part of the snapshot harness for repository-shaped fixtures. - -## Related - -- [The personal-staging maintenance skills Agent Note](../process/2026-07-23-personal-staging-maintenance-skills.md) — the workflow this gate enforces one rule of. That note owns the skills' content and discovery; this one owns the enforcement point and holds no authority over the workflow itself. -- [The interception-seams Agent Note](2026-06-30-interception-seams.md) — the `tools/pre-execute` `allow`/`deny`/`ask` vocabulary this gate's denial uses. -- [The repeat-tool-guard Agent Note](../../archived/feature/2026-07-08-repeat-tool-guard.md) — the sibling guard whose advisory shape this one deliberately does not take. diff --git a/.agents/notes/implemented/feature/2026-07-28-source-guard-staging-edit-gate.zh.md b/.agents/notes/implemented/feature/2026-07-28-source-guard-staging-edit-gate.zh.md deleted file mode 100644 index 83589ce833..0000000000 --- a/.agents/notes/implemented/feature/2026-07-28-source-guard-staging-edit-gate.zh.md +++ /dev/null @@ -1,76 +0,0 @@ -# Agent Note: source-guard 拒绝直接编辑 staging 检出目录 - -Status: implemented - -[English](2026-07-28-source-guard-staging-edit-gate.md) | 中文 - -## Problem - -[`dsh-customize`](../../../../skills/dsh-customize/SKILL.md) skill(技能)规范对 dsh 源码检出的每项个人变更:先在从 staging 分支顶端分出的任务 worktree 中实现,再于 `.agents/merge.lock` 保护下完成集成。它的核心规则是一条禁令:不得直接编辑个人 staging 检出目录;而若只通过提示词文本传达禁令,它恰好会在最要紧的场景中失效。从未加载该 skill 的 agent(智能体)根本看不到规则;即便尽早加载,也仍可能在三十次工具调用后将其忘掉。这种失败既静默又代价高昂:提交会落在启动器实际运行的 staging 分支上,不属于任何任务分支,既未持有锁,也没有用于回滚的 worktree。 - -提示词指导无法解决这个问题,因为未被阅读的正是这些指导。该规则需要一个强制执行点。 - -## Decision - -`@deepseek-ai/dsh-source-guard`(`packages/guard/source-guard/`)是一个 `tools/pre-execute` 监听器;它会返回 `{kind: 'deny', reason}`,拒绝目标解析到受保护 staging worktree 内的 `write` 或 `edit`,除非调用会话的持久日志已经记录过一次成功的 `skill` 调用,且名称为 `dsh-customize`。它不注册服务,也不贡献提示词文本或工具 schema;获准的调用与未加载该插件时的调用没有区别。任何已交付的默认组合都不包含它。 - -### 从文件而非路径前缀或 `git` 判定 Git 身份 - -系统通过读取 `.git`、其中的 `gitdir:` 指针以及 `HEAD` 来判断路径是否受保护。它可以解析三种形态:普通克隆(`.git` 是目录,且自身就是共享目录)、链接 worktree(`.git` 是文件,指向 `/worktrees/`,其共享目录位于上两级)以及 HEAD 分离状态(`HEAD` 保存原始对象 id,不指向任何分支)。`gitdir:` 指针无论是绝对路径(`git worktree add` 写入的形式)还是相对路径都可以解析;Git 会以包含该指针的 worktree 目录为基准解析相对路径。 - -只有目标的 worktree 在两项身份上都与启动器的 worktree 匹配时才会拒绝:共用同一个共享 Git 目录,且分支相同。这两项身份均通过解析 `protectedCheckout` 得出,因此无需配置受保护分支的任何信息。较早版本则匹配 `dsh-staging/*` 名称模式;现已改用确切分支规则,因为模式会在两个方向上出错。它会拒绝旧安装留下的每一个同级 staging worktree,尽管其中没有任何一个运行着启动器;对于 staging 分支不遵循任何命名约定的维护者,它又会静默地完全不提供保护——而已交付的默认配置必须在 [`scripts/install.sh`](../../../../scripts/install.sh) 未创建的检出目录上也能生效,这一属性是致命的。 - -路径前缀规则不仅不精确,而且本身就是错误的。该 skill 规定的任务 worktree 位于受保护树*内部*的 `/.worktrees/...`,因此前缀规则会拒绝工作流要求的每一次编辑。解析过程从目标向外逐层查找,遇到第一个所属 worktree 时停止,因此返回最内层的 worktree:嵌套的任务 worktree 会返回自身的任务分支并获准,而启动器自身所在的树会返回启动器的分支并被拒绝。 - -有两个路径细节决定门禁究竟能否生效,二者都是强制执行要求,而非细节润色。仓库身份会按解析符号链接后的路径进行比较(使用 `dsh-sandbox` 的 `canonicalPath`),因为位于 `/var/...` 下的会话 cwd 和位于 `/private/var/...` 下的配置路径在 macOS 上是同一个目录,若按路径字符串比较,每次写入都会故障放行(fail-open)。此外,相对 `file_path` 会完全按照 `dsh-tool-fs` 的方式,相对于调用会话的工作区解析;若只判断绝对路径,相对路径就会成为绕过门禁访问受保护文件的路径。 - -`protectedCheckout` 指定受保护检出目录内的一条路径,默认值为本模块自身的文件。由此解析出运行中 harness 的启动来源检出目录——当前运行的部署,无论其分支采用什么名称。若 harness 从已安装副本运行,解析出的会是另一个仓库或没有仓库,因此不会保护任何内容;该规则在源码检出之外没有意义。 - -已交付的 TUI 组合会以这些默认值加载插件,因此每个源码安装无需配置即可受到保护。对于普通项目,它不会生效:若工作区位于其他仓库中,或不存在工作区,就绝不会匹配启动器的身份。 - -### 从持久日志回放满足状态 - -如果日志中存在一条 `tool/call`,它调用名为 `skill` 的工具,参数可解析为 `{name: }`,且按调用 id 能配对到非错误的 `tool/result`,门禁即解除。二者都已持久化(`packages/core/session/src/types.ts`),因此无需新增会话事件,也不与 skill 提供方内部实现耦合。 - -日志是唯一状态。在内存中记录满足状态(`WeakMap` 结构,[`repeat-tool-guard`](../../archived/feature/2026-07-08-repeat-tool-guard.md) 将其用于调用链)所需实现会更小,但恢复后满足状态会丢失:一个已经读取过该 skill 的恢复会话会被要求再次读取,而这次拒绝看起来会像缺陷而不是规则。回放的代价是扫描日志,但首次命中即停止,并换来恢复行为正确。 - -### 刻意采用故障放行 - -目标路径不在任何 worktree 内、HEAD 分离、属于其他仓库、`gitdir:` 指针格式错误或元数据不可读时,调用都会交给调用链的其余部分处理。反过来,只要无法判定 Git 身份就拒绝,会让任何 `.git` 权限问题都导致 harness 完全无法写文件。该 guard 旨在防止一种特定且可恢复的错误,不得造成比该错误更严重的故障。 - -### 范围收窄 - -`read` 从不受门禁限制:检查 staging 不会违反任何规则,而且该 skill 明确允许只读提问。`bash` 同样不受门禁限制。要可靠判定哪些 shell 命令会修改状态,需要构造一个无法给出可信完备标准的匹配器,因此执意修改的模型仍可通过 shell 修改 staging。这是一道防止遗忘的边界,不是阻止刻意操作的沙箱。 - -## Alternatives considered - -- **用建议性提醒代替拒绝**(使用 `additionalContexts`,挂载在 `tools/post-execute` 上,采用 `repeat-tool-guard` 的形态)。不予采纳:提醒到达时写入已经发生,违规已成事实,而指导又一次沦为纯文本。 -- **将 `{kind: 'ask'}` 交给审批。** 不予采纳:在常见场景中,它会对任务 worktree 内每次合法编辑都发起询问;在没有审批支持的组合中还会退化为拒绝,使行为取决于无关插件。 -- **运行 `git rev-parse`,并通过 `ctx.subprocess` 执行。** 对替代方案进行实测后不予采纳:读取两个文件即可回答同一问题,每次受门禁限制的写入都无需 spawn 进程,不要求 `git` 存在于 `PATH` 中,也不依赖子进程。读取 `.git` 与 `HEAD` 所依据的是稳定的磁盘格式,而非实现细节。 -- **显式配置 `protectedRoots`,不做检测。** 不予采纳:这会让常见场景的保护效果依赖配置正确性,而陈旧的绝对路径会静默禁用保护。 -- **可配置的 staging 分支名称模式**(`stagingBranchPatterns`,默认 `dsh-staging/*`)。最初随产品交付,随后删除:它从两个方向划错了保护范围——既纳入每个不运行启动器的陈旧同级 worktree,又完全不保护分支另有名称的维护者。由启动器派生分支无需配置,也不可能配置错误。 -- **自动检测检出目录,不提供覆盖项。** 不予采纳:检测只是默认行为,而非不可更改的规定;若部署要保护另一个检出目录,或自身从已安装副本运行,就需要显式值。 -- **拒绝检出根目录下的一切操作,包括 `.worktrees/`。** 不予采纳:这会阻断该 skill 规定的工作流,让 guard 在每次合法任务编辑时触发。 -- **用修改类命令匹配器把守 `bash`。** 推迟而非否决:如果实际观察到绕过行为,值得重新考虑。任一方向判断错误的匹配器,都不如如实限定范围的门禁。 - -## Consequences - -如今,该规则无需依赖模型已经读过它也能生效;拒绝理由会列出路径、分支与 skill,让模型的下一步操作明确,无需猜测。强制执行位于拥有该决策的操作边界,因此提示词过滤或监听器顺序都无法绕过它。 - -将其纳入 TUI 默认组合意味着每个源码安装无需配置即可受到保护;由于分支是派生而非按名称指定,保护会在升级时跟随启动器。这种覆盖范围的代价是插件会为每位用户加载,包括工作区永远不可能匹配启动器身份的用户。 - -除此之外的代价是:guard 的完整程度受限于其工具列表,`bash` 仍保持开放。worktree 身份在插件生命周期内按目录缓存,因此无法观察到任一侧在会话中途切换分支。只保护启动器自身的检出目录,因此陈旧的同级检出目录仍可编辑。加载该 skill 会为整个会话解除门禁,却不会验证工作流是否确实得到遵循——门禁只能证明指令已被阅读,不能证明已被执行。满足状态按会话隔离,因此拥有独立会话的 subagent 必须自行加载该 skill。 - -## Testing - -单元测试套件基于真实 Git 元数据 fixture(测试前置数据),使用 mock 适配器驱动真实 agent loop(智能体循环):覆盖一个 staging worktree、嵌套其中的任务 worktree、普通克隆、位于 staging 命名分支上的其他仓库、HEAD 分离状态、绝对和相对 `gitdir:` 指针、指向同一仓库的符号链接路径、格式错误的指针以及不可读元数据,使两个源码文件都达到逐文件 100% 覆盖率。配套的 `invariant.ts` 会验证持久拒绝的结构,因为拒绝文本是该包唯一面向模型的输出,且只有其中列出路径、分支和 skill 时才具有可操作性。 - -真实组合冒烟测试通过 Loader 与 headless 应用启动 `examples/headless-agent/tests/fixtures/guard/source-guard/cordis.yml`,并对组装后的运行断言三项事实:工具结果是错误、文本与拒绝理由逐字一致、目标文件仍保留原始字节。这证明系统在分发前强制执行规则,而不是事后给出建议。 - -一个 ACP(Agent Client Protocol)快照场景(`source-guard-staging-deny`)最初负责组装后的 transcript(文本记录),通过新的 `Scenario.prepareCwd` 钩子在 harness 生成的 cwd 中植入 staging worktree——Git 永远不会跟踪名为 `.git` 的条目,且 `.gitignore` 会排除所有 `worktrees/` 目录,因此 fixture 提交两个 `HEAD` 的内容,由钩子组装真实布局。编写它立刻证明了投入的价值:它暴露了上述两个路径缺陷(transcript 显示每当 guard 悄然不作判断时 `fs-policy` 都会率先响应),随后又发现了自身首版 fixture 的问题——被忽略的 `worktrees/` 路径因未跟踪文件而在本地通过。该场景后来被移除,组装运行证据合并进 Loader 组合冒烟测试;它引入的 `prepareCwd` 钩子仍留在快照 harness 中,服务于仓库形态的 fixture。 - -## Related - -- [个人 staging 维护 skill 的 Agent Note](../process/2026-07-23-personal-staging-maintenance-skills.md):本门禁负责执行该工作流的一条规则。对方 Agent Note 负责这些 skill 的内容与发现机制;本文只负责强制执行点,对工作流本身不具有定义权。 -- [拦截 seam Agent Note](2026-06-30-interception-seams.md):本门禁拒绝时使用的 `tools/pre-execute` `allow`/`deny`/`ask` 词汇。 -- [repeat-tool-guard Agent Note](../../archived/feature/2026-07-08-repeat-tool-guard.md):同类 guard;本文刻意不采用其建议性形态。 diff --git a/apps/cli/package.json b/apps/cli/package.json index 4a054fa559..ddbef2cd03 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -76,7 +76,6 @@ "@deepseek-ai/dsh-session-title-first-message-llm": "workspace:^", "@deepseek-ai/dsh-skill": "workspace:^", "@deepseek-ai/dsh-skill-local": "workspace:^", - "@deepseek-ai/dsh-source-guard": "workspace:^", "@deepseek-ai/dsh-spill-local": "workspace:^", "@deepseek-ai/dsh-spill-policy": "workspace:^", "@deepseek-ai/dsh-storage": "workspace:^", @@ -89,7 +88,6 @@ "@deepseek-ai/dsh-system-prompt": "workspace:^", "@deepseek-ai/dsh-tasks-local": "workspace:^", "@deepseek-ai/dsh-timeout-policy": "workspace:^", - "@deepseek-ai/dsh-tmux-context": "workspace:^", "@deepseek-ai/dsh-token-meter": "workspace:^", "@deepseek-ai/dsh-tool-ask-user": "workspace:^", "@deepseek-ai/dsh-tool-bash": "workspace:^", diff --git a/apps/cli/tui.cordis.yml b/apps/cli/tui.cordis.yml index 0a6f2213c1..13b8c8930d 100644 --- a/apps/cli/tui.cordis.yml +++ b/apps/cli/tui.cordis.yml @@ -97,9 +97,6 @@ # Refuses write/edit inside the dsh checkout this launcher runs from, on that # checkout's own branch, until the session loads dsh-customize. Inert # everywhere else, so an ordinary project sees no change. - - id: source-guard - name: '@deepseek-ai/dsh-source-guard' - - id: tool-result-prune name: '@deepseek-ai/dsh-compact-tool-result-prune' diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 35280954e7..3c0543e4ca 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -108,7 +108,7 @@ export interface Config { Depends on: [`AgentOptions`](core-data-structures/core.md) · [`SessionId`](core-data-structures/core.md) -Source: [`packages/core/agent-loop/src/index.ts:211`](../packages/core/agent-loop/src/index.ts) +Source: [`packages/core/agent-loop/src/index.ts:155`](../packages/core/agent-loop/src/index.ts) ## `@deepseek-ai/dsh-agent-spine-demo` @@ -1153,26 +1153,6 @@ export interface Config { Source: [`packages/context/session-reference/src/config.ts:11`](../packages/context/session-reference/src/config.ts) -## `@deepseek-ai/dsh-session-registry-file` - -```ts config-catalog -/** - * Plugin config as callers write it: `root` is required — a cwd fallback would - * scatter registries — while the lock tunables are optional because - * `static Config` supplies their defaults. - */ -export interface Config { - /** Directory holding the registry file; created `0o700` on demand. */ - root: string - /** Milliseconds after which a held lock is considered abandoned and reclaimed. */ - lockStaleMs?: number - /** Retries before a contended acquisition fails loud. */ - lockRetries?: number -} -``` - -Source: [`packages/session-registry/session-registry-file/src/index.ts:43`](../packages/session-registry/session-registry-file/src/index.ts) - ## `@deepseek-ai/dsh-session-telemetry-otel` Requires: `sessions` @@ -1283,38 +1263,6 @@ export interface Config { Source: [`packages/skill/skill-local/src/index.ts:41`](../packages/skill/skill-local/src/index.ts) -## `@deepseek-ai/dsh-source-guard` - -Requires: `fs` - -```ts config-catalog -/** - * Plugin config, validated by the same-named schemastery schema plus the - * load-time checks in `apply` (misconfiguration fails loud: an empty `tools` - * list, a blank `requiredSkill`, or a relative `protectedCheckout` throws at - * plugin load, never a silent fall-back). - */ -export interface Config { - /** Skill whose loaded presence in the session lifts the denial (default `dsh-customize`). */ - requiredSkill?: string - /** Tool names to gate (default `['write', 'edit']`). */ - tools?: string[] - /** - * Absolute path inside the checkout this guard protects. Its worktree - * supplies BOTH protected identities: the repository (targets in any other - * repository are ignored) and the exact branch (only that branch's worktree - * is protected). Defaults to this module's own location, which resolves the - * checkout the running harness was launched from — the live deployment, - * whatever its branch is named. Set it explicitly to guard a different - * checkout, or when the harness runs from an installed copy whose own - * location is not a checkout at all. - */ - protectedCheckout?: string -} -``` - -Source: [`packages/guard/source-guard/src/index.ts:30`](../packages/guard/source-guard/src/index.ts) - ## `@deepseek-ai/dsh-spill-local` ```ts config-catalog @@ -1593,20 +1541,6 @@ export interface Config { Source: [`packages/context/time-context/src/index.ts:20`](../packages/context/time-context/src/index.ts) -## `@deepseek-ai/dsh-tmux-context` - -Requires: `agents` - -```ts config-catalog -/** Per-turn tmux-location scheduling. Invalid values fail plugin load. */ -export interface Config { - /** Minimum milliseconds between durable injections in one session. Omit or set to 0 to inject on every eligible change. */ - refreshIntervalMs?: number -} -``` - -Source: [`packages/context/tmux-context/src/index.ts:33`](../packages/context/tmux-context/src/index.ts) - ## `@deepseek-ai/dsh-token-meter` ```ts config-catalog @@ -2005,6 +1939,63 @@ export interface TuiThemeConfig { Source: [`packages/ui/tui/src/config.ts:117`](../packages/ui/tui/src/config.ts) +## `@deepseek-ai/dsh-tui-demo` + +```ts config-catalog +/** App config routed to the spine, TUI, configured agent, and JSONL backend. */ +export interface Config { + /** Provider route for the `main` agent. */ + provider: string + /** Model name for the `main` agent; a matching adapter must be registered. */ + model: string + /** Bundled agent-loop concurrency cap; `1` is serial and omission uses its default. */ + maxParallelToolCalls?: number + /** Deployment persona forwarded to the system-prompt plugin. */ + persona?: string + /** Explicit model-facing tool order forwarded to the system-prompt plugin. */ + toolOrder?: string[] + /** Tool-registry presentation config forwarded through agent-spine-demo. */ + tools?: ToolsConfig + /** DeepSeek Harness home directory exposed to bash and used for local skill discovery. */ + dshHome?: string + /** Fallback session-title limits forwarded through agent-spine-demo. */ + sessionTitle?: NonNullable + /** Directory for JSONL sessions and the derived query index. Defaults to `./.sessions`. */ + persistenceRoot?: string + /** JSONL artifact encoding; defaults to checksummed Zstandard frames. */ + persistenceCompression?: JsonlCompression + /** Cross-session reference discovery and snapshot byte budgets. */ + sessionReferences?: SessionReferenceConfig + /** 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 the session, e.g. + * `dsh --resume {session}`. + */ + resumeCommand?: string + /** Full-screen TUI presentation settings. */ + ui?: uiTui.TuiConfig + /** Skill registry, local-provider, and model-facing consumer config. */ + skills?: agentCore.SkillConfig + /** Model-facing bash tool config forwarded through agent-spine-demo. */ + toolBash?: NonNullable + /** Generic background-task controls forwarded through agent-spine-demo; set false to omit them. */ + toolTasks?: NonNullable + /** Persisted same-session goals; owner defaults enable them, or false disables the stack and command. */ + goals?: agentCore.GoalConfig | false + /** Persisted session id to resume instead of creating a fresh session. */ + resumeSessionId?: string + /** Controls automatic AGENTS.md/CLAUDE.md loading; configure a byte budget or set `false`. */ + workspaceContext: agentCore.Config['workspaceContext'] +} +``` + +Depends on: [`agentCore`](../packages/examples/agent-spine-demo/src/index.ts) · [`JsonlCompression`](../packages/session-persistence/session-persistence-jsonl/src/index.ts) · [`SessionReferenceConfig`](#deepseek-aidsh-session-reference) · [`ToolsConfig`](#deepseek-aidsh-tools) · [`uiTui`](../packages/ui/tui/src/index.ts) + +Source: [`packages/examples/tui-demo/src/index.ts:39`](../packages/examples/tui-demo/src/index.ts) + ## `@deepseek-ai/dsh-user-approval` ```ts config-catalog @@ -2240,7 +2231,6 @@ These load from a `cordis.yml` entry with no `config:` block; they declare no co - `@deepseek-ai/dsh-session` ([`packages/core/session/src/index.ts`](../packages/core/session/src/index.ts)) - `@deepseek-ai/dsh-session-checkpoint-policy` — requires `llm` · `sessionPersistence` · `sessions` · `tools` ([`packages/session-persistence/session-checkpoint-policy/src/index.ts`](../packages/session-persistence/session-checkpoint-policy/src/index.ts)) - `@deepseek-ai/dsh-session-projection` ([`packages/session-projection/session-projection/src/index.ts`](../packages/session-projection/session-projection/src/index.ts)) -- `@deepseek-ai/dsh-session-registry-live` — requires `sessions` · `sessionRegistry` ([`packages/session-registry/session-registry-live/src/index.ts`](../packages/session-registry/session-registry-live/src/index.ts)) - `@deepseek-ai/dsh-storage` ([`packages/storage/storage/src/index.ts`](../packages/storage/storage/src/index.ts)) - `@deepseek-ai/dsh-subagent` ([`packages/subagent/subagent/src/index.ts`](../packages/subagent/subagent/src/index.ts)) - `@deepseek-ai/dsh-subprocess-local` ([`packages/subprocess/subprocess-local/src/index.ts`](../packages/subprocess/subprocess-local/src/index.ts)) @@ -2263,7 +2253,6 @@ Abstract service classes — a deployment loads a concrete implementation packag - `@deepseek-ai/dsh-sandbox` — abstract `SandboxProvider` ([`packages/sandbox/sandbox/src/index.ts`](../packages/sandbox/sandbox/src/index.ts)) - `@deepseek-ai/dsh-session-persistence` — abstract `SessionPersistence` ([`packages/session-persistence/session-persistence/src/index.ts`](../packages/session-persistence/session-persistence/src/index.ts)) - `@deepseek-ai/dsh-session-query` — abstract `SessionQueryService` ([`packages/session-query/session-query/src/index.ts`](../packages/session-query/session-query/src/index.ts)) -- `@deepseek-ai/dsh-session-registry` — abstract `SessionRegistry` ([`packages/session-registry/session-registry/src/index.ts`](../packages/session-registry/session-registry/src/index.ts)) - `@deepseek-ai/dsh-spill` — abstract `SpillStore` ([`packages/spill/spill/src/index.ts`](../packages/spill/spill/src/index.ts)) - `@deepseek-ai/dsh-subprocess` — abstract `SubprocessService` ([`packages/subprocess/subprocess/src/index.ts`](../packages/subprocess/subprocess/src/index.ts)) - `@deepseek-ai/dsh-tasks` — abstract `TaskService` ([`packages/tasks/tasks/src/index.ts`](../packages/tasks/tasks/src/index.ts)) diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index 6048296973..71238305e5 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -7,7 +7,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac | Event | Mode | Declared in | Dispatchers | Listeners | | --- | --- | --- | --- | --- | -| `agent-loop/config-start-failed` | `emit` | [`packages/core/agent-loop/src/index.ts:157`](../packages/core/agent-loop/src/index.ts) | [`agent-loop`](../packages/core/agent-loop) (`events.dispatch`) | [`tui`](../packages/ui/tui) | +| `agent-loop/config-start-failed` | `emit` | [`packages/core/agent-loop/src/index.ts:148`](../packages/core/agent-loop/src/index.ts) | [`agent-loop`](../packages/core/agent-loop) (`events.dispatch`) | [`tui`](../packages/ui/tui) | | `agent/cancel-requested` | `emit` | [`packages/core/agent/src/types.ts:286`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`goal-session`](../packages/goal/goal-session) | | `agent/created` | `emit` | [`packages/core/agent/src/types.ts:218`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`goal-session`](../packages/goal/goal-session), [`tui`](../packages/ui/tui) | | `agent/disposed` | `emit` | [`packages/core/agent/src/types.ts:227`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), [`goal-session`](../packages/goal/goal-session), [`tui`](../packages/ui/tui) | @@ -21,7 +21,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `agent/session-start` | `emit` | [`packages/core/agent/src/types.ts:299`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`workspace-context`](../packages/context/workspace-context) | | `agent/settled` | `emit` | [`packages/core/agent/src/types.ts:387`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`compact-basic`](../packages/compact/compact-basic) | | `agent/status` | `emit` | [`packages/core/agent/src/types.ts:236`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`agent`](../packages/core/agent), `apiproxy`, [`goal-session`](../packages/goal/goal-session), [`tui`](../packages/ui/tui) | -| `agent/step` | `serial` | [`packages/core/agent/src/types.ts:326`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`compact-basic`](../packages/compact/compact-basic), [`plan-mode`](../packages/plan/plan-mode), [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy), [`time-context`](../packages/context/time-context), [`tmux-context`](../packages/context/tmux-context), [`tool-skill`](../packages/skill/tool-skill), [`user-approval`](../packages/ui/user-approval), [`workspace-context`](../packages/context/workspace-context) | +| `agent/step` | `serial` | [`packages/core/agent/src/types.ts:326`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`compact-basic`](../packages/compact/compact-basic), [`plan-mode`](../packages/plan/plan-mode), [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy), [`time-context`](../packages/context/time-context), [`tool-skill`](../packages/skill/tool-skill), [`user-approval`](../packages/ui/user-approval), [`workspace-context`](../packages/context/workspace-context) | | `agent/turn-stopping` | `serial` | [`packages/core/agent/src/types.ts:373`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`serial`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex) | | `approval/request` | `waterfall` | [`packages/ui/user-approval/src/index.ts:30`](../packages/ui/user-approval/src/index.ts) | [`user-approval`](../packages/ui/user-approval) (`waterfall`) | [`acp`](../packages/acp/acp), `apiproxy` | | `commands/change` | `emit` | [`packages/ui/commands/src/index.ts:154`](../packages/ui/commands/src/index.ts) | [`commands`](../packages/ui/commands) (`events.dispatch`) | `apiproxy`, [`tui`](../packages/ui/tui) | @@ -31,9 +31,9 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `fs/write-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:54`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`) | [`fs-policy`](../packages/fs/fs-policy) | | `goal/changed` | `emit` | [`packages/goal/goal/src/domain.ts:135`](../packages/goal/goal/src/domain.ts) | [`goal`](../packages/goal/goal) (`emit`) | [`goal-session`](../packages/goal/goal-session) | | `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:58`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`agent-loop`](../packages/core/agent-loop), [`llm`](../packages/llm/llm), [`llm-replay`](../packages/support/llm-replay), [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy), [`session-title`](../packages/session-title/session-title) | -| `session/created` | `emit` | [`packages/core/session/src/index.ts:71`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | `apiproxy`, [`compact`](../packages/compact/compact), [`goal`](../packages/goal/goal), [`hook-protocol`](../packages/hooks/hook-protocol), [`jsonrpc`](../packages/ui/jsonrpc), [`llm-retry`](../packages/llm/llm-retry), [`plan-mode`](../packages/plan/plan-mode), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-registry-live`](../packages/session-registry/session-registry-live), [`session-telemetry`](../packages/telemetry/session-telemetry), [`tools`](../packages/core/tools), [`user-approval`](../packages/ui/user-approval) | -| `session/disposed` | `emit` | [`packages/core/session/src/index.ts:81`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `apiproxy`, [`session-persistence`](../packages/session-persistence/session-persistence), [`session-projection-cache`](../packages/session-projection/session-projection-cache), [`session-registry-live`](../packages/session-registry/session-registry-live), [`session-telemetry`](../packages/telemetry/session-telemetry), [`session-title`](../packages/session-title/session-title) | -| `session/event` | `emit` | [`packages/core/session/src/index.ts:93`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/acp/acp), `apiproxy`, [`cli-demo`](../packages/examples/cli-demo), [`compact`](../packages/compact/compact), [`compact-basic`](../packages/compact/compact-basic), [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hook-protocol`](../packages/hooks/hook-protocol), [`jsonrpc`](../packages/ui/jsonrpc), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-projection`](../packages/session-projection/session-projection), [`session-projection-cache`](../packages/session-projection/session-projection-cache), [`session-registry-live`](../packages/session-registry/session-registry-live), [`session-telemetry`](../packages/telemetry/session-telemetry), [`session-title`](../packages/session-title/session-title), [`token-meter`](../packages/llm/token-meter), [`tools`](../packages/core/tools), [`tui`](../packages/ui/tui), [`user-approval`](../packages/ui/user-approval), [`workspace-context`](../packages/context/workspace-context) | +| `session/created` | `emit` | [`packages/core/session/src/index.ts:71`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | `apiproxy`, [`compact`](../packages/compact/compact), [`goal`](../packages/goal/goal), [`hook-protocol`](../packages/hooks/hook-protocol), [`jsonrpc`](../packages/ui/jsonrpc), [`llm-retry`](../packages/llm/llm-retry), [`plan-mode`](../packages/plan/plan-mode), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-telemetry`](../packages/telemetry/session-telemetry), [`tools`](../packages/core/tools), [`user-approval`](../packages/ui/user-approval) | +| `session/disposed` | `emit` | [`packages/core/session/src/index.ts:81`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `apiproxy`, [`session-persistence`](../packages/session-persistence/session-persistence), [`session-projection-cache`](../packages/session-projection/session-projection-cache), [`session-telemetry`](../packages/telemetry/session-telemetry), [`session-title`](../packages/session-title/session-title) | +| `session/event` | `emit` | [`packages/core/session/src/index.ts:93`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/acp/acp), `apiproxy`, [`cli-demo`](../packages/examples/cli-demo), [`compact`](../packages/compact/compact), [`compact-basic`](../packages/compact/compact-basic), [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hook-protocol`](../packages/hooks/hook-protocol), [`jsonrpc`](../packages/ui/jsonrpc), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-projection`](../packages/session-projection/session-projection), [`session-projection-cache`](../packages/session-projection/session-projection-cache), [`session-telemetry`](../packages/telemetry/session-telemetry), [`session-title`](../packages/session-title/session-title), [`token-meter`](../packages/llm/token-meter), [`tools`](../packages/core/tools), [`tui`](../packages/ui/tui), [`user-approval`](../packages/ui/user-approval), [`workspace-context`](../packages/context/workspace-context) | | `session/flush` | `parallel` | [`packages/core/session/src/index.ts:103`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`session-persistence`](../packages/session-persistence/session-persistence), [`session-telemetry`](../packages/telemetry/session-telemetry) | | `slash/input-begin-command` | `bail` | [`packages/client/ui-slash/src/types.ts:230`](../packages/client/ui-slash/src/types.ts) | - | `ui-conversation` | | `slash/input-consume-token` | `bail` | [`packages/client/ui-slash/src/types.ts:244`](../packages/client/ui-slash/src/types.ts) | - | `ui-conversation` | @@ -50,7 +50,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `tools/code-dispatch-log` | `waterfall` | [`packages/core/tools/src/index.ts:138`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`spill-policy`](../packages/spill/spill-policy) | | `tools/execute` | `waterfall` | [`packages/core/tools/src/index.ts:113`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy), [`timeout-policy`](../packages/timeout/timeout-policy) | | `tools/post-execute` | `waterfall` | [`packages/core/tools/src/index.ts:125`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`repeat-tool-guard`](../packages/guard/repeat-tool-guard), [`spill-policy`](../packages/spill/spill-policy), [`tool-fs-search`](../packages/fs/tool-fs-search), [`workspace-context`](../packages/context/workspace-context) | -| `tools/pre-execute` | `waterfall` | [`packages/core/tools/src/index.ts:102`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`source-guard`](../packages/guard/source-guard), [`tool-tasks`](../packages/tasks/tool-tasks) | +| `tools/pre-execute` | `waterfall` | [`packages/core/tools/src/index.ts:102`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`tool-tasks`](../packages/tasks/tool-tasks) | | `tools/result` | `emit` | [`packages/core/tools/src/index.ts:146`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`events.dispatch`) | [`subagent-inprocess`](../packages/subagent/subagent-inprocess), [`workspace-context`](../packages/context/workspace-context) | | `workflow/agent-end` | `emit` | [`packages/workflow/workflow/src/index.ts:81`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`workflow`](../packages/workflow/workflow) | | `workflow/agent-start` | `emit` | [`packages/workflow/workflow/src/index.ts:70`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`workflow`](../packages/workflow/workflow) | @@ -65,7 +65,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac | --- | --- | --- | | `commands/changed` | `runtime` (`emit`) | `ui-command` | | `connection/reset` | `runtime` (`emit`) | `ui-command` | -| `internal/dispatch` | - | [`commands`](../packages/ui/commands), [`compact`](../packages/compact/compact), [`fs`](../packages/fs/fs), [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm-retry`](../packages/llm/llm-retry), [`permission`](../packages/ui/permission), [`plan-mode`](../packages/plan/plan-mode), [`pty-local`](../packages/pty/pty-local), `runtime`, [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`source-guard`](../packages/guard/source-guard), [`subagent`](../packages/subagent/subagent), [`time-context`](../packages/context/time-context), [`tool-todo`](../packages/todo/tool-todo), [`tools`](../packages/core/tools), [`user-approval`](../packages/ui/user-approval), [`workflow`](../packages/workflow/workflow) | +| `internal/dispatch` | - | [`commands`](../packages/ui/commands), [`compact`](../packages/compact/compact), [`fs`](../packages/fs/fs), [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm-retry`](../packages/llm/llm-retry), [`permission`](../packages/ui/permission), [`plan-mode`](../packages/plan/plan-mode), [`pty-local`](../packages/pty/pty-local), `runtime`, [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`time-context`](../packages/context/time-context), [`tool-todo`](../packages/todo/tool-todo), [`tools`](../packages/core/tools), [`user-approval`](../packages/ui/user-approval), [`workflow`](../packages/workflow/workflow) | | `internal/plugin` | - | `hmr`, `modules`, `webserver` | | `internal/status` | - | [`agent`](../packages/core/agent) | | `locale/change` | `locale` (`emit`) | `locale`, `ui-models`, `ui-settings-general` | diff --git a/docs/module-graph.md b/docs/module-graph.md index 3c928282ee..d3e60978e7 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -173,7 +173,6 @@ flowchart TD subgraph group_context["packages/context"] pkg_session_reference["session-reference"] pkg_time_context["time-context"] - pkg_tmux_context["tmux-context"] pkg_workspace_context["workspace-context"] end subgraph group_examples["packages/examples"] @@ -181,10 +180,10 @@ flowchart TD pkg_agent_spine_demo["agent-spine-demo"] pkg_cli_demo["cli-demo"] pkg_jsonrpc_demo["jsonrpc-demo"] + pkg_tui_demo["tui-demo"] end subgraph group_guard["packages/guard"] pkg_repeat_tool_guard["repeat-tool-guard"] - pkg_source_guard["source-guard"] end subgraph group_host["packages/host"] pkg_host_apiproxy["host-apiproxy"] @@ -222,11 +221,6 @@ flowchart TD pkg_session_projection["session-projection"] pkg_session_projection_cache["session-projection-cache"] end - subgraph group_session_registry["packages/session-registry"] - pkg_session_registry["session-registry"] - pkg_session_registry_file["session-registry-file"] - pkg_session_registry_live["session-registry-live"] - end subgraph group_storage["packages/storage"] pkg_storage["storage"] pkg_storage_domain["storage-domain"] @@ -456,9 +450,6 @@ flowchart TD pkg_sandbox_policy --> pkg_session pkg_session_projection --> pkg_invariants pkg_session_projection --> pkg_session - pkg_session_registry --> pkg_brand - pkg_session_registry --> pkg_invariants - pkg_session_registry --> pkg_session pkg_llm_retry --> pkg_agent pkg_llm_retry --> pkg_invariants pkg_llm_retry --> pkg_llm @@ -532,10 +523,6 @@ flowchart TD pkg_time_context --> pkg_agent pkg_time_context --> pkg_invariants pkg_time_context --> pkg_session - pkg_tmux_context --> pkg_agent - pkg_tmux_context --> pkg_bash - pkg_tmux_context --> pkg_invariants - pkg_tmux_context --> pkg_session pkg_pty --> pkg_agent pkg_pty --> pkg_brand pkg_pty --> pkg_invariants @@ -546,9 +533,6 @@ flowchart TD pkg_session_projection_cache --> pkg_session_persistence pkg_session_projection_cache --> pkg_session_projection pkg_session_projection_cache --> pkg_storage_domain - pkg_session_registry_file --> pkg_invariants - pkg_session_registry_file --> pkg_session - pkg_session_registry_file --> pkg_session_registry pkg_tasks --> pkg_agent pkg_tasks --> pkg_brand pkg_tasks --> pkg_invariants @@ -630,10 +614,6 @@ flowchart TD pkg_pty_local --> pkg_sandbox_policy pkg_pty_local --> pkg_session pkg_pty_local --> pkg_subprocess - pkg_session_registry_live --> pkg_invariants - pkg_session_registry_live --> pkg_session - pkg_session_registry_live --> pkg_session_registry - pkg_session_registry_live --> pkg_session_title pkg_tasks_local --> pkg_agent pkg_tasks_local --> pkg_invariants pkg_tasks_local --> pkg_tasks @@ -796,13 +776,6 @@ flowchart TD pkg_repeat_tool_guard --> pkg_agent pkg_repeat_tool_guard --> pkg_invariants pkg_repeat_tool_guard --> pkg_tools - pkg_source_guard --> pkg_agent - pkg_source_guard --> pkg_fs - pkg_source_guard --> pkg_invariants - pkg_source_guard --> pkg_llm - pkg_source_guard --> pkg_sandbox - pkg_source_guard --> pkg_session - pkg_source_guard --> pkg_tools pkg_tool_lsp --> pkg_invariants pkg_tool_lsp --> pkg_llm pkg_tool_lsp --> pkg_lsp @@ -962,6 +935,24 @@ flowchart TD pkg_cli_demo --> pkg_session_persistence_jsonl pkg_cli_demo --> pkg_tools pkg_cli_demo --> pkg_workspace_context + pkg_tui_demo --> pkg_agent + pkg_tui_demo --> pkg_agent_loop + pkg_tui_demo --> pkg_agent_spine_demo + pkg_tui_demo --> pkg_command_goal + pkg_tui_demo --> pkg_commands + pkg_tui_demo --> pkg_invariants + pkg_tui_demo --> pkg_llm + pkg_tui_demo --> pkg_session + pkg_tui_demo --> pkg_session_checkpoint_policy + pkg_tui_demo --> pkg_session_persistence_jsonl + pkg_tui_demo --> pkg_session_query + pkg_tui_demo --> pkg_session_query_sqlite + pkg_tui_demo --> pkg_session_reference + pkg_tui_demo --> pkg_tool_ask_user + pkg_tui_demo --> pkg_tools + pkg_tui_demo --> pkg_tui + pkg_tui_demo --> pkg_user_interaction + pkg_tui_demo --> pkg_workspace_context pkg_sdk_client --> pkg_invariants pkg_sdk_client --> pkg_llm pkg_sdk_client --> pkg_sdk_protocol @@ -1054,7 +1045,6 @@ flowchart TD | [`sandbox-local`](../packages/sandbox/sandbox-local) | `sandbox` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox) | | [`sandbox-policy`](../packages/sandbox/sandbox-policy) | `sandbox` | [`invariants`](../packages/support/invariants), [`sandbox`](../packages/sandbox/sandbox), [`session`](../packages/core/session) | | [`session-projection`](../packages/session-projection/session-projection) | `session-projection` | [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) | -| [`session-registry`](../packages/session-registry/session-registry) | `session-registry` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) | | [`llm-retry`](../packages/llm/llm-retry) | `llm` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`timeout`](../packages/util/timeout) | | [`goal`](../packages/goal/goal) | `goal` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-projection`](../packages/session-projection/session-projection) | | [`bash-local`](../packages/bash/bash-local) | `bash` | [`bash`](../packages/bash/bash), [`invariants`](../packages/support/invariants), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | @@ -1072,11 +1062,9 @@ flowchart TD | [`user-interaction`](../packages/ui/user-interaction) | `ui` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm) | | [`client-ui-model`](../packages/client/ui-model) | `client` | [`client-connection`](../packages/client/connection), [`client-runtime`](../packages/client/runtime), [`client-ui-command`](../packages/client/ui-command), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | | [`time-context`](../packages/context/time-context) | `context` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) | -| [`tmux-context`](../packages/context/tmux-context) | `context` | [`agent`](../packages/core/agent), [`bash`](../packages/bash/bash), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) | | [`pty`](../packages/pty/pty) | `pty` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants) | | [`scripts`](../packages/sdk/scripts) | `sdk` | [`app-boot`](../packages/ui/app-boot), [`invariants`](../packages/support/invariants) | | [`session-projection-cache`](../packages/session-projection/session-projection-cache) | `session-projection` | [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-projection`](../packages/session-projection/session-projection), [`storage-domain`](../packages/storage/storage-domain) | -| [`session-registry-file`](../packages/session-registry/session-registry-file) | `session-registry` | [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`session-registry`](../packages/session-registry/session-registry) | | [`tasks`](../packages/tasks/tasks) | `tasks` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) | | [`session-telemetry`](../packages/telemetry/session-telemetry) | `telemetry` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) | | [`workflow`](../packages/workflow/workflow) | `workflow` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | @@ -1092,7 +1080,6 @@ flowchart TD | [`permission`](../packages/ui/permission) | `ui` | [`bash`](../packages/bash/bash), [`commands`](../packages/ui/commands), [`invariants`](../packages/support/invariants), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`session-projection`](../packages/session-projection/session-projection), [`user-approval`](../packages/ui/user-approval) | | [`client-ui-goal`](../packages/client/ui-goal) | `client` | [`client-connection`](../packages/client/connection), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`goal`](../packages/goal/goal), [`invariants`](../packages/support/invariants) | | [`pty-local`](../packages/pty/pty-local) | `pty` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`pty`](../packages/pty/pty), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`subprocess`](../packages/subprocess/subprocess) | -| [`session-registry-live`](../packages/session-registry/session-registry-live) | `session-registry` | [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`session-registry`](../packages/session-registry/session-registry), [`session-title`](../packages/session-title/session-title) | | [`tasks-local`](../packages/tasks/tasks-local) | `tasks` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`tasks`](../packages/tasks/tasks), [`timeout`](../packages/util/timeout) | | [`session-telemetry-otel`](../packages/telemetry/session-telemetry-otel) | `telemetry` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-telemetry`](../packages/telemetry/session-telemetry) | | [`agent-loop`](../packages/core/agent-loop) | `core` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | @@ -1120,7 +1107,6 @@ flowchart TD | [`session-reference`](../packages/context/session-reference) | `context` | [`agent`](../packages/core/agent), [`compact`](../packages/compact/compact), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`retention`](../packages/util/retention), [`session`](../packages/core/session), [`session-query`](../packages/session-query/session-query) | | [`workspace-context`](../packages/context/workspace-context) | `context` | [`agent`](../packages/core/agent), [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`paths`](../packages/util/paths), [`session`](../packages/core/session), [`tools`](../packages/core/tools) | | [`repeat-tool-guard`](../packages/guard/repeat-tool-guard) | `guard` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`tools`](../packages/core/tools) | -| [`source-guard`](../packages/guard/source-guard) | `guard` | [`agent`](../packages/core/agent), [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`session`](../packages/core/session), [`tools`](../packages/core/tools) | | [`tool-lsp`](../packages/lsp/tool-lsp) | `lsp` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`lsp`](../packages/lsp/lsp), [`system-prompt`](../packages/core/system-prompt), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) | | [`mcp-client`](../packages/mcp/mcp-client) | `mcp` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`subprocess`](../packages/subprocess/subprocess), [`tools`](../packages/core/tools) | | [`tool-pty`](../packages/pty/tool-pty) | `pty` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`pty`](../packages/pty/pty), [`retention`](../packages/util/retention), [`system-prompt`](../packages/core/system-prompt), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools) | @@ -1141,5 +1127,6 @@ flowchart TD | [`jsonrpc`](../packages/ui/jsonrpc) | `ui` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`llm-deepseek`](../packages/llm/llm-deepseek), [`scope`](../packages/core/scope), [`sdk-protocol`](../packages/sdk/sdk-protocol), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) | | [`acp-demo`](../packages/examples/acp-demo) | `examples` | [`acp`](../packages/acp/acp), [`agent-spine-demo`](../packages/examples/agent-spine-demo), [`app-boot`](../packages/ui/app-boot), [`invariants`](../packages/support/invariants), [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy), [`session-persistence-jsonl`](../packages/session-persistence/session-persistence-jsonl), [`session-query`](../packages/session-query/session-query), [`session-query-sqlite`](../packages/session-query/session-query-sqlite), [`tools`](../packages/core/tools), [`workspace-context`](../packages/context/workspace-context) | | [`cli-demo`](../packages/examples/cli-demo) | `examples` | [`agent`](../packages/core/agent), [`agent-spine-demo`](../packages/examples/agent-spine-demo), [`app-boot`](../packages/ui/app-boot), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy), [`session-persistence-jsonl`](../packages/session-persistence/session-persistence-jsonl), [`tools`](../packages/core/tools), [`workspace-context`](../packages/context/workspace-context) | +| [`tui-demo`](../packages/examples/tui-demo) | `examples` | [`agent`](../packages/core/agent), [`agent-loop`](../packages/core/agent-loop), [`agent-spine-demo`](../packages/examples/agent-spine-demo), [`command-goal`](../packages/goal/command-goal), [`commands`](../packages/ui/commands), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy), [`session-persistence-jsonl`](../packages/session-persistence/session-persistence-jsonl), [`session-query`](../packages/session-query/session-query), [`session-query-sqlite`](../packages/session-query/session-query-sqlite), [`session-reference`](../packages/context/session-reference), [`tool-ask-user`](../packages/ui/tool-ask-user), [`tools`](../packages/core/tools), [`tui`](../packages/ui/tui), [`user-interaction`](../packages/ui/user-interaction), [`workspace-context`](../packages/context/workspace-context) | | [`sdk-client`](../packages/sdk/sdk-client) | `sdk` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sdk-protocol`](../packages/sdk/sdk-protocol), [`session`](../packages/core/session) | | [`subagent-dsh-sdk`](../packages/subagent/subagent-dsh-sdk) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sdk-client`](../packages/sdk/sdk-client), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subprocess`](../packages/subprocess/subprocess) | diff --git a/examples/headless-agent/tests/fixtures/guard/source-guard/cordis.yml b/examples/headless-agent/tests/fixtures/guard/source-guard/cordis.yml deleted file mode 100644 index a8a83302bb..0000000000 --- a/examples/headless-agent/tests/fixtures/guard/source-guard/cordis.yml +++ /dev/null @@ -1,38 +0,0 @@ -# Test-only composition: the model attempts one `write` into a staging-shaped -# git fixture, so the guard's denial is observed through the real Loader and app. -- id: source-guard-mock-llm - name: './mock-llm.ts' - -# Managed child-process groups for the bash executor (spawn/kill/output plumbing). -- id: subprocess - name: '@deepseek-ai/dsh-subprocess-local' - -- id: bash - name: '@deepseek-ai/dsh-bash-local' - -- id: fs - name: '@deepseek-ai/dsh-fs-local' - -# Read-before-edit policy: without it the write would resolve `createIfAbsent` -# and the transcript would not show the guard as the sole reason for refusal. -- id: fs-policy - name: '@deepseek-ai/dsh-fs-policy' - -- id: tool-fs - name: '@deepseek-ai/dsh-tool-fs' - -# Mounts the guard with `protectedCheckout` resolved against the process cwd, so -# it arms for the staging fixture the smoke builds there rather than for the -# checkout running the test (the config default is this module's own location). -- id: source-guard-fixture - name: './mount-guard.ts' - -- id: cli-agent - name: '@deepseek-ai/dsh-cli-demo' - config: - provider: source-guard-mock - model: source-guard-mock - persona: 'Test the source guard.' - persistenceRoot: './.sessions' - persistenceCompression: none - workspaceContext: false diff --git a/examples/headless-agent/tests/fixtures/guard/source-guard/mock-llm.ts b/examples/headless-agent/tests/fixtures/guard/source-guard/mock-llm.ts deleted file mode 100644 index 44baccd617..0000000000 --- a/examples/headless-agent/tests/fixtures/guard/source-guard/mock-llm.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { resolve } from 'node:path' -import type { Context } from 'cordis' -import { CallId, LlmAdapter, type GenerateOptions, type StreamChunk } from '@deepseek-ai/dsh-llm' - -/** The staged file the smoke builds in the process cwd; the guard must refuse to write it. */ -const TARGET = resolve('staging/guarded.ts') - -/** - * Two-step adapter for the source-guard Loader fixture: the first step calls - * `write` on the staged file, the second closes the turn once a tool result has - * come back, so the transcript records what the model received. - */ -class SourceGuardMockAdapter extends LlmAdapter { - async * stream(options: GenerateOptions): AsyncIterable { - const alreadyCalled = options.messages.some(message => message.content.some( - block => block.type === 'tool-result', - )) - if (alreadyCalled) { - const text = 'denied as expected' - yield { type: 'block-start', index: 0, blockType: 'text' } - yield { type: 'text-delta', index: 0, text } - yield { type: 'block-end', index: 0, block: { type: 'text', text } } - yield { type: 'usage', usage: { inputTokens: 1, outputTokens: 1 } } - yield { type: 'finish', reason: { kind: 'stop' } } - return - } - const callId = CallId('source-guard-write') - const args = JSON.stringify({ file_path: TARGET, content: 'edited\n' }) - yield { type: 'block-start', index: 0, blockType: 'tool-call' } - yield { type: 'tool-call-delta', index: 0, id: callId, name: 'write', argumentsDelta: args } - yield { type: 'block-end', index: 0, block: { type: 'tool-call', id: callId, name: 'write', arguments: args } } - yield { type: 'usage', usage: { inputTokens: 1, outputTokens: 1 } } - yield { type: 'finish', reason: { kind: 'tool-calls' } } - } -} - -export const name = 'source-guard-mock-llm' -export const inject = ['llm'] - -/** Register the test-only `source-guard-mock` adapter. */ -export function apply(ctx: Context): void { - ctx.llm.registerAdapter(['source-guard-mock'], new SourceGuardMockAdapter()) -} diff --git a/examples/headless-agent/tests/fixtures/guard/source-guard/mount-guard.ts b/examples/headless-agent/tests/fixtures/guard/source-guard/mount-guard.ts deleted file mode 100644 index 90a89a2897..0000000000 --- a/examples/headless-agent/tests/fixtures/guard/source-guard/mount-guard.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { resolve } from 'node:path' -import type { Context } from 'cordis' -import * as SourceGuard from '@deepseek-ai/dsh-source-guard' - -export const name = 'source-guard-fixture' - -/** - * Mount the real guard against the staging fixture in the process cwd. The - * checkout under protection is a runtime fact of the isolated smoke directory, - * which no static config value can name. - */ -export async function apply(ctx: Context): Promise { - await ctx.plugin(SourceGuard, { protectedCheckout: resolve('staging/guard-anchor.ts') }) -} diff --git a/examples/headless-agent/tests/fixtures/tmux-context-driver.ts b/examples/headless-agent/tests/fixtures/tmux-context-driver.ts deleted file mode 100644 index 2fd6d0f5ec..0000000000 --- a/examples/headless-agent/tests/fixtures/tmux-context-driver.ts +++ /dev/null @@ -1,16 +0,0 @@ -#!/usr/bin/env node -/** Test driver that sends two turns through one Headless Loader composition. */ - -import { boot, resolveConfigPath } from '@deepseek-ai/dsh-app-boot' -import { runOneShot } from '@deepseek-ai/dsh-cli-demo/src/cli.ts' - -const configPath = process.argv[2] -if (configPath === undefined) throw new Error('tmux-context driver requires a config path') - -const ctx = await boot('tmux-context-e2e', resolveConfigPath(configPath, undefined)) -try { - await runOneShot(ctx, { task: 'first' }) - await runOneShot(ctx, { task: 'second' }) -} finally { - await ctx.fiber.dispose() -} diff --git a/examples/headless-agent/tests/fixtures/tmux-context-mock-bash.ts b/examples/headless-agent/tests/fixtures/tmux-context-mock-bash.ts deleted file mode 100644 index 3e9540abff..0000000000 --- a/examples/headless-agent/tests/fixtures/tmux-context-mock-bash.ts +++ /dev/null @@ -1,46 +0,0 @@ -import type { Context } from 'cordis' -import { BashExecutor } from '@deepseek-ai/dsh-bash' -import type { BashExecRequest, BashExecSpec, BashProcess, BashRunResult } from '@deepseek-ai/dsh-bash' - -/** - * Deterministic `ctx.bash` for the tmux-context Loader fixture: any command - * (the plugin's `tmux display-message`) returns a fixed tab-delimited reading, - * so the injected tmux location is stable without a real tmux server. `start()` - * throws — tmux-context must never spawn a background process. - */ -class TmuxMockBash extends BashExecutor { - override resolve(request: BashExecRequest): BashExecSpec { - return { - command: request.command, - workdir: request.workdir ?? process.cwd(), - timeoutMs: request.timeoutMs ?? 60_000, - stdoutMaxBytes: request.stdoutMaxBytes ?? 64_000, - signal: request.signal, - sandboxPolicy: request.sandboxPolicy, - } - } - - override run(_spec: BashExecSpec): Promise { - const line = ['work', '0', 'editor', '1', '%3', '1', '1', 'a1b2,80x24,0,0,4'].join('\\t') - return Promise.resolve({ - exitCode: 0, - signal: null, - timedOut: false, - aborted: false, - timeoutMs: 60_000, - stdout: { text: `${line}\n`, truncated: false }, - stderr: { text: '', truncated: false }, - }) - } - - override start(): BashProcess { - throw new Error('tmux-context must never start a background task') - } -} - -export const name = 'tmux-context-mock-bash' - -/** Register the deterministic `ctx.bash` executor for the fixture. */ -export function apply(ctx: Context): void { - ctx.plugin(TmuxMockBash) -} diff --git a/examples/headless-agent/tests/fixtures/tmux-context-mock-llm.ts b/examples/headless-agent/tests/fixtures/tmux-context-mock-llm.ts deleted file mode 100644 index 2f6c7a6408..0000000000 --- a/examples/headless-agent/tests/fixtures/tmux-context-mock-llm.ts +++ /dev/null @@ -1,22 +0,0 @@ -import type { Context } from 'cordis' -import { LlmAdapter, type StreamChunk } from '@deepseek-ai/dsh-llm' - -/** Deterministic one-step adapter for the tmux-context Loader fixture. */ -class TmuxContextMockAdapter extends LlmAdapter { - async * stream(): AsyncIterable { - const text = 'tmux context sampled' - yield { type: 'block-start', index: 0, blockType: 'text' } - yield { type: 'text-delta', index: 0, text } - yield { type: 'block-end', index: 0, block: { type: 'text', text } } - yield { type: 'usage', usage: { inputTokens: 1, outputTokens: 1 } } - yield { type: 'finish', reason: { kind: 'stop' } } - } -} - -export const name = 'tmux-context-mock-llm' -export const inject = ['llm'] - -/** Register the test-only `tmux-context-mock` adapter. */ -export function apply(ctx: Context): void { - ctx.llm.registerAdapter(['tmux-context-mock'], new TmuxContextMockAdapter()) -} diff --git a/examples/headless-agent/tests/fixtures/tmux-context.cordis.yml b/examples/headless-agent/tests/fixtures/tmux-context.cordis.yml deleted file mode 100644 index 419922a0e3..0000000000 --- a/examples/headless-agent/tests/fixtures/tmux-context.cordis.yml +++ /dev/null @@ -1,21 +0,0 @@ -# Test-only composition: keep tmux-context opt-in while exercising its real Loader/app path. -# A deterministic mock ctx.bash returns a fixed tmux reading, so the injected location -# is stable without a real tmux server on the test host. -- id: tmux-context-mock-llm - name: './tmux-context-mock-llm.ts' - -- id: bash - name: './tmux-context-mock-bash.ts' - -- id: tmux-context - name: '@deepseek-ai/dsh-tmux-context' - -- id: cli-agent - name: '@deepseek-ai/dsh-cli-demo' - config: - provider: tmux-context-mock - model: tmux-context-mock - persona: 'Test the tmux-context plugin.' - persistenceRoot: './.sessions' - persistenceCompression: 'none' - workspaceContext: false diff --git a/examples/package.json b/examples/package.json index 88e1e8f57a..1de7ab71c1 100644 --- a/examples/package.json +++ b/examples/package.json @@ -56,7 +56,6 @@ "@deepseek-ai/dsh-session-title-first-message-llm": "workspace:*", "@deepseek-ai/dsh-skill": "workspace:*", "@deepseek-ai/dsh-skill-local": "workspace:*", - "@deepseek-ai/dsh-source-guard": "workspace:*", "@deepseek-ai/dsh-spill-local": "workspace:*", "@deepseek-ai/dsh-spill-policy": "workspace:*", "@deepseek-ai/dsh-subagent": "workspace:*", @@ -69,7 +68,6 @@ "@deepseek-ai/dsh-tasks-local": "workspace:*", "@deepseek-ai/dsh-time-context": "workspace:*", "@deepseek-ai/dsh-timeout-policy": "workspace:*", - "@deepseek-ai/dsh-tmux-context": "workspace:*", "@deepseek-ai/dsh-token-meter": "workspace:*", "@deepseek-ai/dsh-tool-ask-user": "workspace:*", "@deepseek-ai/dsh-tool-bash": "workspace:*", diff --git a/knip.json b/knip.json index 1db0b5d257..e40f343dff 100644 --- a/knip.json +++ b/knip.json @@ -33,15 +33,10 @@ "headless-agent/tests/fixtures/semantic-checkpoint-agent.ts", "headless-agent/tests/fixtures/subagent-inheritance-agent.ts", "headless-agent/tests/fixtures/goal-domain/seed-goal.ts", - "headless-agent/tests/fixtures/guard/source-guard/mock-llm.ts", - "headless-agent/tests/fixtures/guard/source-guard/mount-guard.ts", "headless-agent/tests/fixtures/time-context-driver.ts", "headless-agent/tests/fixtures/time-context-mock-llm.ts", "headless-agent/tests/fixtures/telemetry-otel-driver.ts", "headless-agent/tests/fixtures/telemetry-redact-rule.ts", - "headless-agent/tests/fixtures/tmux-context-driver.ts", - "headless-agent/tests/fixtures/tmux-context-mock-llm.ts", - "headless-agent/tests/fixtures/tmux-context-mock-bash.ts", "acp-agent/tests/snapshots/lsp-definition/workspace/subject.ts", "acp-agent/tests/fixtures/subagent/subagent-acp/mock-delegating-llm.ts", "acp-agent/tests/fixtures/subagent/subagent-acp/driver.ts", @@ -178,16 +173,6 @@ "tests/**/*.ts" ] }, - "packages/context/tmux-context": { - "entry": [ - "tests/**/*.spec.ts", - "tests/**/*.e2e.ts" - ], - "project": [ - "src/**/*.ts", - "tests/**/*.ts" - ] - }, "packages/lsp/lsp-local": { "entry": [ "tests/**/*.spec.ts", @@ -303,16 +288,6 @@ "tests/**/*.ts" ] }, - "packages/guard/source-guard": { - "entry": [ - "tests/**/*.spec.ts", - "tests/**/*.e2e.ts" - ], - "project": [ - "src/**/*.ts", - "tests/**/*.ts" - ] - }, "packages/session-registry/session-registry-file": { "entry": [ "tests/**/*.spec.ts", diff --git a/packages/context/README.i18n.yaml b/packages/context/README.i18n.yaml index cb3398b919..de3bc8c70f 100644 --- a/packages/context/README.i18n.yaml +++ b/packages/context/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/context/README.md -README.md: a5244dfe99a714605744b57d33f97359d4d6fa4e -README.zh.md: 6036d58c6937d025adc36d9b2d12f51e396ee3d0 +README.md: bc3237b98732c23e6a2b120e055f7713b91f9b7c +README.zh.md: b195a4c0b96b1f6f0b66bc6efa99a4a66b3c2fa2 diff --git a/packages/context/README.md b/packages/context/README.md index ffe9aaa9e2..bc3237b987 100644 --- a/packages/context/README.md +++ b/packages/context/README.md @@ -2,13 +2,12 @@ English | [中文](README.zh.md) -Product plugins that add model-visible request context without defining a tool. `workspace-context` is included by the default `dsh-agent-spine-demo` bundle and can be disabled through bundle config; `time-context` and `tmux-context` are opt-in, while the standard TUI bundle composes `session-reference` explicitly. +Product plugins that add model-visible request context without defining a tool. `workspace-context` is included by the default `dsh-agent-spine-demo` bundle and can be disabled through bundle config; `time-context` is opt-in, while the standard TUI bundle composes `session-reference` explicitly. | Package | Role | ctx key | |---|---|---| | `session-reference/` | Bounded current-surface snapshots of other sessions | `ctx.sessionReferences` | | `time-context/` | Durable per-step current time and elapsed-time context | (none) | -| `tmux-context/` | Durable per-turn context with this agent's tmux pane/window location | (listens on `agent/pre-step`, reads `ctx.bash`) | | `workspace-context/` | `AGENTS.md`/`CLAUDE.md` workspace context loader | (listens on `agent/session-prefix` + `tools/post-execute`) | The [`workspace-context` decision record](../../.agents/notes/implemented/feature/2026-06-24-workspace-context.md) explains its per-agent/session isolation and lifecycle split. diff --git a/packages/context/README.zh.md b/packages/context/README.zh.md index 6036d58c69..b195a4c0b9 100644 --- a/packages/context/README.zh.md +++ b/packages/context/README.zh.md @@ -2,12 +2,12 @@ [English](README.md) | 中文 -这些产品插件无需定义工具,即可增加模型可见的请求上下文。`workspace-context` 包含在默认的 `dsh-agent-spine-demo` 组合包中,且可通过组合包配置将其禁用;`time-context` 需显式启用,标准 TUI 组合包则会显式组合 `session-reference`。 +这些产品插件无需定义工具,即可增加模型可见的请求上下文。`workspace-context` 包含在默认的 `dsh-agent-spine-demo` 组合包中,且可通过组合包配置将其禁用;`time-context` 需要选择启用,标准 TUI 组合包则会显式组合 `session-reference`。 | 包 | 职责 | ctx key | |---|---|---| | `session-reference/` | 其他会话当前表层的有界快照 | `ctx.sessionReferences` | -| `time-context/` | 持久化的逐步骤当前时间与已用时上下文 | (无) | -| `workspace-context/` | `AGENTS.md`/`CLAUDE.md` 工作区上下文 loader | (监听 `agent/step` + `tools/post-execute`) | +| `time-context/` | 持久的逐步骤当前时间与耗时上下文 | (无) | +| `workspace-context/` | `AGENTS.md`/`CLAUDE.md` 工作区上下文 loader | (监听 `agent/session-prefix` + `tools/post-execute`) | -[`workspace-context` 决策记录](../../.agents/notes/implemented/feature/2026-06-24-workspace-context.md)解释了每个 agent(智能体)和会话各自隔离的方式,以及相应的生命周期拆分。 +[`workspace-context` 决策记录](../../.agents/notes/implemented/feature/2026-06-24-workspace-context.md)解释了它的逐 agent/会话隔离与生命周期拆分。 diff --git a/packages/context/tmux-context/README.i18n.yaml b/packages/context/tmux-context/README.i18n.yaml deleted file mode 100644 index ec4562eea4..0000000000 --- a/packages/context/tmux-context/README.i18n.yaml +++ /dev/null @@ -1,6 +0,0 @@ -# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each -# side as of the last confirmed-consistent state. Both languages carry equal authority; -# after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write packages/context/tmux-context/README.md -README.md: 5ea36948d6d83135c5aa97650c0d77e942adbbaa -README.zh.md: 914d8d7c99c37de2c64541bcf4968996d819077d diff --git a/packages/context/tmux-context/README.md b/packages/context/tmux-context/README.md deleted file mode 100644 index 5ea36948d6..0000000000 --- a/packages/context/tmux-context/README.md +++ /dev/null @@ -1,68 +0,0 @@ -# @deepseek-ai/dsh-tmux-context - -English | [中文](README.zh.md) - -Opt-in durable context naming the tmux session, window, and pane this agent process runs in, plus the window's pane-tree layout. Sampled once per turn during model-request preparation. `dsh-agent-spine-demo` and shipped examples do not mount it. Decision record: [the tmux-context Agent Note](../../../.agents/notes/implemented/feature/2026-07-27-tmux-location-context.md). - -## Config - -```yaml -- id: tmux-context - name: '@deepseek-ai/dsh-tmux-context' - config: - refreshIntervalMs: 60000 # optional; omit or set to 0 to inject on every changed turn -``` - -`refreshIntervalMs` must be a non-negative safe integer. Omission or `0` injects whenever the tmux state changed since the last injection. A positive value additionally suppresses injections that fall within that many milliseconds of the latest one. - -## How it reads tmux - -The plugin prepends an `agent/step` listener that runs only on the first step of each turn. When due, it runs one read-only command through the `ctx.bash` executor seam: - -```sh -[ -n "$TMUX_PANE" ] || exit 1 -self_tty=$(ps -o tty= -p | tr -d ' ') -pane_tty=$(tmux display-message -t "$TMUX_PANE" -p '#{pane_tty}') || exit 1 -[ "$pane_tty" = "/dev/$self_tty" ] || exit 1 -exec tmux display-message -t "$TMUX_PANE" -p '' -``` - -`$TMUX_PANE` alone is insufficient: a terminal launched from a tmux shell (a VS Code integrated terminal, a desktop launcher) **inherits** `$TMUX` and `$TMUX_PANE` from that ancestor, so the variables are present even though the process does not live in that pane. The command therefore also compares the pane's `#{pane_tty}` against this process's own controlling terminal (`ps -o tty=` for its pid): a genuine pane owns this process's tty, while an inherited environment names some other pane's tty. Running through `ctx.bash` applies the deployment's sandbox and policy; the plugin owns no subprocess code. When `ctx.bash` is absent, the process is not in a real tmux pane (`$TMUX_PANE` unset, or the tty does not match ⇒ nonzero exit), or the reading is malformed, the attempt is a no-op, never an error. - -State is pulled on every eligible turn — a moved, renamed, or re-laid-out pane is picked up without any tmux hook or background process. The plugin re-injects only when the rendered tmux state differs from its last injection, so an unchanged location adds nothing. - -## Timing semantics - -When an injection is due, the plugin appends one injected `user/message` through `agent.inject()` before `step/start`, with source `{ kind: 'plugin', plugin: 'tmux-context' }`. Change suppression and interval scheduling scan the raw durable session events for the latest injection of this source, so the schedule survives compaction and resumed processes without process-local cache state; sessions schedule independently. The reading records a request-preparation attempt, not a committed step; because the listener runs first, its append may remain when a later pre-step listener cancels or fails the attempt (the log is append-only and the plugin performs no rollback). - -## Model Experience - -### Preparation-time tmux location - -#### What the model sees - -On each turn whose tmux state changed, one source-tagged context message with the three lines below. `` is tmux's compact pane-tree description; pane and window pixel sizes are intentionally excluded, and the contents of sibling panes are never captured. - -##### Changed-turn reading - -```markdown -tmux location (turn ): -session , window "", pane -window active=<0|1>, pane active=<0|1>, layout -``` - -#### Token effect - -Each two-line reading accumulates until compaction shadows it. Unchanged locations and interval suppression add nothing. - -#### KV Cache effect - -Append-only; newly visible content follows the reusable request prefix and does not invalidate existing KV-cache entries. - -## Known Limitations and Deferred Work - -- **First step only** — a pane moved or resized mid-turn is reflected on the next turn, not between steps. -- **Own location only** — the plugin never captures the visible text of sibling panes. -- **Layout, not size** — pane/window pixel dimensions are omitted; only the layout tree and active flags are reported. -- **Tab-delimited fields** — a tmux window name containing the literal two-character sequence `\t` would mis-split the reading and be skipped as malformed; ordinary names are unaffected. -- **tty-based pane detection** — the process is considered "in tmux" only when its controlling terminal matches `$TMUX_PANE`'s `#{pane_tty}`. This deliberately excludes terminals that inherited `$TMUX`/`$TMUX_PANE` from a tmux ancestor (e.g. a VS Code integrated terminal). `ps -o tty=` is POSIX; the check is a no-op wherever it or `#{pane_tty}` is unavailable. diff --git a/packages/context/tmux-context/README.zh.md b/packages/context/tmux-context/README.zh.md deleted file mode 100644 index 914d8d7c99..0000000000 --- a/packages/context/tmux-context/README.zh.md +++ /dev/null @@ -1,68 +0,0 @@ -# @deepseek-ai/dsh-tmux-context - -[English](README.md) | 中文 - -可选启用的持久上下文,记录本 agent 进程所在的 tmux session、window、pane,以及该 window 的 pane 树布局。在准备模型请求时每轮采样一次。`dsh-agent-spine-demo` 与随附示例均不挂载它。决策记录见:[tmux-context Agent Note](../../../.agents/notes/implemented/feature/2026-07-27-tmux-location-context.md)。 - -## 配置 - -```yaml -- id: tmux-context - name: '@deepseek-ai/dsh-tmux-context' - config: - refreshIntervalMs: 60000 # optional; omit or set to 0 to inject on every changed turn -``` - -`refreshIntervalMs` 必须是非负安全整数。省略或 `0` 表示只要 tmux 状态自上次注入以来发生变化就注入。正值会额外抑制距最近一次注入不足该毫秒数的注入。 - -## 如何读取 tmux - -插件前置注册一个 `agent/step` 监听器,仅在每轮的第一个 step 运行。当需要注入时,它通过 `ctx.bash` 执行器 seam 运行一条只读命令: - -```sh -[ -n "$TMUX_PANE" ] || exit 1 -self_tty=$(ps -o tty= -p | tr -d ' ') -pane_tty=$(tmux display-message -t "$TMUX_PANE" -p '#{pane_tty}') || exit 1 -[ "$pane_tty" = "/dev/$self_tty" ] || exit 1 -exec tmux display-message -t "$TMUX_PANE" -p '' -``` - -仅凭 `$TMUX_PANE` 并不足够:从 tmux shell 启动的终端(VS Code 集成终端、桌面启动器)会从该祖先进程**继承** `$TMUX` 与 `$TMUX_PANE`,因此即使进程并不位于那个 pane 中,这些变量依然存在。为此该命令还会把 pane 的 `#{pane_tty}` 与本进程自己的控制终端(对其 pid 执行 `ps -o tty=`)作比较:真正的 pane 拥有本进程的 tty,而继承而来的环境指向的是另一个 pane 的 tty。通过 `ctx.bash` 运行会应用部署方的沙箱与策略;插件不拥有任何子进程代码。当 `ctx.bash` 缺失、进程不在真实的 tmux pane 内(`$TMUX_PANE` 未设置,或 tty 不匹配 ⇒ 非零退出)或读取结果格式非法时,本次尝试为空操作,绝不报错。 - -状态在每个符合条件的轮次拉取——pane 被移动、改名或重新布局都会被感知,无需任何 tmux hook 或后台进程。插件仅在渲染出的 tmux 状态与上次注入不同时才重新注入,因此位置不变时不会新增任何内容。 - -## 时序语义 - -当需要注入时,插件在 `step/start` 之前通过 `agent.inject()` 追加一条注入的 `user/message`,来源为 `{ kind: 'plugin', plugin: 'tmux-context' }`。变化抑制与间隔调度会扫描原始持久会话事件中该来源的最近一次注入,因此调度可跨压缩与恢复的进程存续,无需进程内缓存状态;各会话独立调度。该读数记录的是一次请求准备尝试,而非已提交的 step;由于监听器最先运行,当后续 pre-step 监听器取消或失败时,它的追加可能仍会保留(日志只追加,插件不做回滚)。 - -## 模型体验 - -### 准备期 tmux 位置 - -#### 模型看到的内容 - -在 tmux 状态发生变化的每一轮,注入一条带来源标记、含以下三行的上下文消息。`` 是 tmux 紧凑的 pane 树描述;pane 与 window 的像素尺寸有意省略,相邻 pane 的内容从不采集。 - -##### 变化轮次读数 - -```markdown -tmux location (turn ): -session , window "", pane -window active=<0|1>, pane active=<0|1>, layout -``` - -#### Token 影响 - -每条两行读数会累积,直到压缩将其遮蔽。位置未变化以及间隔抑制不会新增内容。 - -#### KV 缓存影响 - -只追加;新增可见内容位于可复用的请求前缀之后,不会使已有 KV 缓存条目失效。 - -## 已知限制与后续工作 - -- **仅第一个 step**——轮次中途移动或缩放的 pane 会在下一轮反映,而非在 step 之间。 -- **仅自身位置**——插件从不采集相邻 pane 的可见文本。 -- **只有布局,没有尺寸**——省略 pane/window 像素尺寸;仅报告布局树与活动标志。 -- **制表符分隔字段**——若 tmux window 名称包含字面两字符序列 `\t`,会使读数分割错误并作为非法读数跳过;常规名称不受影响。 -- **基于 tty 的 pane 判定**——只有当进程的控制终端与 `$TMUX_PANE` 的 `#{pane_tty}` 一致时,才视为“位于 tmux 中”。这会有意排除从 tmux 祖先进程继承 `$TMUX`/`$TMUX_PANE` 的终端(如 VS Code 集成终端)。`ps -o tty=` 属于 POSIX;在其或 `#{pane_tty}` 不可用的环境中,该检查即为空操作。 diff --git a/packages/context/tmux-context/package.json b/packages/context/tmux-context/package.json deleted file mode 100644 index a874ebb08c..0000000000 --- a/packages/context/tmux-context/package.json +++ /dev/null @@ -1,49 +0,0 @@ -{ - "name": "@deepseek-ai/dsh-tmux-context", - "description": "Opt-in durable per-step context with this agent's tmux pane and window location", - "version": "0.0.1", - "private": true, - "type": "module", - "main": "lib/index.js", - "types": "lib/types/index.d.ts", - "exports": { - ".": { - "types": "./lib/types/index.d.ts", - "default": "./lib/index.js" - }, - "./invariant": { - "types": "./lib/types/invariant.d.ts", - "default": "./lib/invariant.js" - }, - "./src/*": "./src/*", - "./package.json": "./package.json" - }, - "files": [ - "lib/index.js", - "lib/invariant.js", - "lib/types/**/*.d.ts", - "lib/types/**/*.d.ts.map", - "src" - ], - "license": "BSD-3-Clause", - "dependencies": { - "schemastery": "^3.18.0" - }, - "peerDependencies": { - "@deepseek-ai/dsh-agent": "^0.0.1", - "@deepseek-ai/dsh-bash": "^0.0.1", - "@deepseek-ai/dsh-invariants": "^0.0.1", - "@deepseek-ai/dsh-session": "^0.0.1", - "cordis": "^4.0.0-rc.7" - }, - "devDependencies": { - "@deepseek-ai/dsh-agent": "workspace:^", - "@deepseek-ai/dsh-bash": "workspace:^", - "@deepseek-ai/dsh-invariants": "workspace:^", - "@deepseek-ai/dsh-llm": "workspace:^", - "@deepseek-ai/dsh-loader-smoke": "workspace:^", - "@deepseek-ai/dsh-session": "workspace:^", - "@deepseek-ai/dsh-system-prompt": "workspace:^", - "cordis": "^4.0.0-rc.7" - } -} diff --git a/packages/context/tmux-context/src/index.ts b/packages/context/tmux-context/src/index.ts deleted file mode 100644 index 495ab886a0..0000000000 --- a/packages/context/tmux-context/src/index.ts +++ /dev/null @@ -1,227 +0,0 @@ -/** - * Opt-in request-preparation tmux-location context. Eligible step attempts - * append durable, source-attributed context naming the tmux session, window, - * and pane this agent process runs in, plus the window's pane-tree layout. - * - * The plugin pulls state once per turn, on the first step (`step === 1`), by - * running one `tmux display-message` through the `ctx.bash` executor seam. It - * confirms this process genuinely runs inside the pane `$TMUX_PANE` names by - * matching the pane's `#{pane_tty}` against this process's controlling terminal, - * so a terminal that merely inherited `$TMUX`/`$TMUX_PANE` from a tmux ancestor - * (e.g. a VS Code integrated terminal) reads as "not in tmux". It re-injects - * only when the rendered tmux state changes since the last injection (a moved, - * renamed, or re-laid-out pane), with an optional `refreshIntervalMs` floor - * between injections. Absent tmux environment, an inherited-only environment, - * absent `ctx.bash`, or a failed query is a no-op, never an error. - * - * @module @deepseek-ai/dsh-tmux-context - */ - -import type { Context } from 'cordis' -import z from 'schemastery' -import type { Agent } from '@deepseek-ai/dsh-agent' -import type { BashExecutor } from '@deepseek-ai/dsh-bash' -import { createUserMessage } from '@deepseek-ai/dsh-llm' - -/** Cordis plugin name used by loader diagnostics. */ -export const name = 'tmux-context' - -/** The agent registry that owns the `agent/step` lifecycle seam. */ -export const inject = ['agents'] - -/** Per-turn tmux-location scheduling. Invalid values fail plugin load. */ -export interface Config { - /** Minimum milliseconds between durable injections in one session. Omit or set to 0 to inject on every eligible change. */ - refreshIntervalMs?: number -} - -/** Schemastery validation for {@link Config}. */ -export const Config: z = z.object({ - refreshIntervalMs: z.number(), -}) - -/** - * Tab-separated tmux format fields, in query order. Layout (`window_layout`) - * is the pane-tree description; pane/window pixel sizes are intentionally - * excluded (own location and layout only, per the package scope). - */ -const TMUX_FIELDS = [ - '#{session_name}', - '#{window_index}', - '#{window_name}', - '#{pane_index}', - '#{pane_id}', - '#{window_active}', - '#{pane_active}', - '#{window_layout}', -] as const - -/** Structured tmux location parsed from one `display-message` reading. */ -interface TmuxLocation { - sessionName: string - windowIndex: string - windowName: string - paneIndex: string - paneId: string - windowActive: string - paneActive: string - windowLayout: string -} - -/** Prefix marking the volatile turn/step preamble line of a rendered reading. */ -const READING_PREFIX = 'tmux location (turn ' - -/** - * Field separator between tmux format fields. tmux does not interpret C escapes - * in a format, so the literal two-character sequence `\t` is emitted verbatim - * and split back out here; this avoids embedding raw whitespace in the command. - */ -const FIELD_SEP = '\\t' - -/** - * Read this process's tmux location through the bash seam, or `undefined` when - * this process is not genuinely running inside a tmux pane or the query fails. - * - * `$TMUX_PANE` alone is insufficient: a terminal launched from a tmux shell - * (e.g. VS Code's integrated terminal, a desktop launcher) inherits `$TMUX` and - * `$TMUX_PANE` from that ancestor, so the variables are present even though this - * process does not live in that pane. The command therefore also compares the - * pane's `#{pane_tty}` against this process's own controlling terminal - * (`ps -o tty=` for {@link processId}); a genuine pane owns this process's tty, - * an inherited environment names some other pane's tty. Fields are emitted only - * on a match, so an inherited environment reads as "not in tmux" and injects - * nothing. - * - * @param bash - the executor seam used to run the read-only tmux/ps commands. - * @param processId - this agent process's pid, whose controlling tty must match the pane. - * @param signal - abort signal forwarded to the executor. - * @returns the parsed location, or `undefined` when not in a real pane or on any failure. - */ -async function queryTmuxLocation( - bash: BashExecutor, - processId: number, - signal: AbortSignal, -): Promise { - const format = TMUX_FIELDS.join(FIELD_SEP) - const command = [ - '[ -n "$TMUX_PANE" ] || exit 1', - `self_tty=$(ps -o tty= -p ${processId} | tr -d ' ')`, - '[ -n "$self_tty" ] || exit 1', - 'pane_tty=$(tmux display-message -t "$TMUX_PANE" -p \'#{pane_tty}\') || exit 1', - '[ "$pane_tty" = "/dev/$self_tty" ] || exit 1', - `exec tmux display-message -t "$TMUX_PANE" -p '${format}'`, - ].join('\n') - const spec = bash.resolve({ command, signal }) - const result = await bash.run(spec) - if (result.exitCode !== 0) return undefined - const line = result.stdout.text.split('\n', 1)[0] as string - const parts = line.split(FIELD_SEP) - if (parts.length !== TMUX_FIELDS.length) return undefined - const [ - sessionName, - windowIndex, - windowName, - paneIndex, - paneId, - windowActive, - paneActive, - windowLayout, - ] = parts as [string, string, string, string, string, string, string, string] - if (paneId.length === 0) return undefined - return { - sessionName, - windowIndex, - windowName, - paneIndex, - paneId, - windowActive, - paneActive, - windowLayout, - } -} - -/** - * Render the stable tmux state block: the part of a reading compared for - * change suppression. It excludes the turn preamble so re-injection is driven - * only by tmux state, not by loop position. - */ -function renderState(location: TmuxLocation): string { - return `session ${location.sessionName}, ` - + `window ${location.windowIndex} ${JSON.stringify(location.windowName)}, ` - + `pane ${location.paneIndex} ${location.paneId}\n` - + `window active=${location.windowActive}, pane active=${location.paneActive}, ` - + `layout ${location.windowLayout}` -} - -/** Render the full durable reading, including the volatile turn preamble. */ -function renderReading(location: TmuxLocation, turn: number): string { - return `${READING_PREFIX}${turn}):\n${renderState(location)}` -} - -/** - * The stable state block of this plugin's latest durable injection, or - * `undefined` when the session has none. Scans raw durable events so the - * schedule survives compaction and resumed processes without process-local - * cache state. - */ -function latestInjectedState(agent: Agent): { state: string; time: number } | undefined { - for (const event of [...agent.session.events].reverse()) { - if (event.type === 'user/message' - && event.data.source.kind === 'plugin' - && event.data.source.plugin === name) { - const [block] = event.data.content - if (block?.type !== 'text') return undefined - const newline = block.text.indexOf('\n') - const state = newline === -1 ? '' : block.text.slice(newline + 1) - return { state, time: event.time } - } - } - return undefined -} - -/** Reject refresh intervals that cannot represent an exact elapsed-millisecond threshold. */ -function validateRefreshInterval(refreshIntervalMs: number | undefined): void { - if (refreshIntervalMs !== undefined && ( - !Number.isSafeInteger(refreshIntervalMs) - || refreshIntervalMs < 0 - )) { - throw new TypeError( - `tmux-context: refreshIntervalMs must be a non-negative safe integer, got ${String(refreshIntervalMs)}`, - ) - } -} - -/** - * Register a prepended `agent/step` listener for the lifetime of `ctx`. - * @param ctx - plugin context; the listener is disposed with it. - * @param config - durable refresh scheduling configuration. - * @throws when the refresh interval is invalid. - */ -export function apply(ctx: Context, config: Config): void { - const refreshIntervalMs = config.refreshIntervalMs - validateRefreshInterval(refreshIntervalMs) - - ctx.on('agent/step', async ( - agent: Agent, - turn: number, - step: number, - signal: AbortSignal, - ): Promise => { - if (signal.aborted || step !== 1) return - const bash = ctx.get('bash') - if (bash === undefined) return - const previous = latestInjectedState(agent) - if (refreshIntervalMs !== undefined && refreshIntervalMs > 0 && previous !== undefined) { - const now = Date.now() - if (now >= previous.time && now - previous.time < refreshIntervalMs) return - } - const location = await queryTmuxLocation(bash, process.pid, signal) - if (location === undefined) return - const state = renderState(location) - if (previous !== undefined && previous.state === state) return - agent.inject(createUserMessage({ - content: [{ type: 'text', text: renderReading(location, turn) }], - source: { kind: 'plugin', plugin: name }, - })) - }, { prepend: true }) -} diff --git a/packages/context/tmux-context/src/invariant.ts b/packages/context/tmux-context/src/invariant.ts deleted file mode 100644 index 181f1a2289..0000000000 --- a/packages/context/tmux-context/src/invariant.ts +++ /dev/null @@ -1,30 +0,0 @@ -/** - * Package-owned invariant companion for `@deepseek-ai/dsh-tmux-context`. - * @module @deepseek-ai/dsh-tmux-context/invariant - */ - -/* jscpd:ignore-start */ -import type { Context } from 'cordis' -import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' - -const PACKAGE_NAME = '@deepseek-ai/dsh-tmux-context' - -/** Cordis companion plugin name. */ -export const name = 'tmux-context-invariant' -/** Service required before the companion can reserve package ownership. */ -export const inject = ['invariants'] - -/** - * No runtime invariant: a reading is a per-turn snapshot of external tmux state, so the session - * holds no cross-event relation to check; scheduling and format are owned by pipeline tests. - */ -const install: InvariantInstaller = () => {} - -/** - * Register this package's invariant companion. - * @param ctx - Cordis context carrying the invariant service. - * @returns the installed registration's disposer after setup succeeds. - */ -export const apply = (ctx: Context): Promise<() => void> => - Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) -/* jscpd:ignore-end */ diff --git a/packages/context/tmux-context/tests/tmux-context.e2e.ts b/packages/context/tmux-context/tests/tmux-context.e2e.ts deleted file mode 100644 index 06e3c1f3f5..0000000000 --- a/packages/context/tmux-context/tests/tmux-context.e2e.ts +++ /dev/null @@ -1,77 +0,0 @@ -import { readFile, readdir } from 'node:fs/promises' -import { join } from 'node:path' -import { fileURLToPath } from 'node:url' -import { describe, expect, it } from 'vitest' -import { type SessionEvent } from '@deepseek-ai/dsh-session' -import { LOADER_SMOKE_TEST_TIMEOUT_MS, runLoaderSmoke } from '@deepseek-ai/dsh-loader-smoke' - -// Keep the Loader config under examples so both modes exercise the same deployable -// topology: local fixture source plus bare plugins owned by the examples workspace. -const driver = fileURLToPath(new URL( - '../../../../examples/headless-agent/tests/fixtures/tmux-context-driver.ts', - import.meta.url, -)) -const configPath = fileURLToPath(new URL( - '../../../../examples/headless-agent/tests/fixtures/tmux-context.cordis.yml', - import.meta.url, -)) -const repoTsconfig = fileURLToPath(new URL('../../../../tsconfig.json', import.meta.url)) - -async function jsonlFiles(dir: string): Promise { - const entries = await readdir(dir, { withFileTypes: true }) - const paths = await Promise.all(entries.map(async (entry) => { - const path = join(dir, entry.name) - if (entry.isDirectory()) return jsonlFiles(path) - return entry.isFile() && entry.name.endsWith('.jsonl') ? [path] : [] - })) - return paths.flat() -} - -describe('tmux-context through a real headless cordis.yml', () => { - it('injects one ordered tmux-location event on the first turn and suppresses the unchanged second', async () => { - let events: SessionEvent[] = [] - const { stderr } = await runLoaderSmoke({ - label: 'tmux-context headless smoke', - tempDirPrefix: 'tmux-context-e2e-', - binScript: driver, - libBinScript: driver, - configPath, - tsconfigPath: repoTsconfig, - inspect: async (cwd) => { - const logs = await jsonlFiles(join(cwd, '.sessions')) - expect(logs).toHaveLength(1) - const lines = (await readFile(logs[0] as string, 'utf8')).trimEnd().split('\n') - events = lines.slice(1).map(line => JSON.parse(line) as SessionEvent) - }, - }) - expect(stderr).not.toContain('UNHANDLED') - expect(events.filter(event => event.type === 'turn/end')).toHaveLength(2) - - const contexts = events.filter( - (event): event is SessionEvent<'user/message'> => - event.type === 'user/message' - && event.data.source.kind === 'plugin' - && event.data.source.plugin === 'tmux-context') - // Two identical-state turns: the location injects once and is suppressed after. - expect(contexts).toHaveLength(1) - - const [reading] = contexts - if (reading === undefined) throw new Error('missing tmux-context reading') - const starts = events.filter(event => event.type === 'step/start') - expect(reading.seq).toBeLessThan(starts[0]!.seq) - expect(reading.surfaceOp).toBe('append') - - const text = reading.data.content - .filter(block => block.type === 'text') - .map(block => block.text) - .join('\n') - expect(text).toBe( - 'tmux location (turn 1):\n' - + 'session work, window 0 "editor", pane 1 %3\n' - + 'window active=1, pane active=1, layout a1b2,80x24,0,0,4', - ) - - const headers = events.filter(event => event.type === 'request/header') - expect(JSON.stringify(headers)).not.toContain('tmux location (turn') - }, LOADER_SMOKE_TEST_TIMEOUT_MS) -}) diff --git a/packages/context/tmux-context/tests/tmux-context.spec.ts b/packages/context/tmux-context/tests/tmux-context.spec.ts deleted file mode 100644 index fedd672f57..0000000000 --- a/packages/context/tmux-context/tests/tmux-context.spec.ts +++ /dev/null @@ -1,367 +0,0 @@ -import { afterEach, describe, expect, it, vi } from 'vitest' -import { Context } from 'cordis' -import { Session, SessionId } from '@deepseek-ai/dsh-session' -import AgentRegistry, { agentEvents, type Agent } from '@deepseek-ai/dsh-agent' -import { createUserMessage } from '@deepseek-ai/dsh-llm' -import { BashExecutor } from '@deepseek-ai/dsh-bash' -import type { BashExecRequest, BashExecSpec, BashProcess, BashRunResult } from '@deepseek-ai/dsh-bash' -import * as tmuxContext from '@deepseek-ai/dsh-tmux-context' -import type { Config } from '@deepseek-ai/dsh-tmux-context' - -const SIGNAL = new AbortController().signal - -/** One `#{...}`-joined tmux reading line for the eight queried fields. */ -function tmuxLine(fields: { - sessionName?: string - windowIndex?: string - windowName?: string - paneIndex?: string - paneId?: string - windowActive?: string - paneActive?: string - windowLayout?: string -} = {}): string { - return [ - fields.sessionName ?? '0', - fields.windowIndex ?? '1', - fields.windowName ?? 'node', - fields.paneIndex ?? '2', - fields.paneId ?? '%90', - fields.windowActive ?? '1', - fields.paneActive ?? '0', - fields.windowLayout ?? 'd517,270x71,0,0{135x71,0,0,87,134x71,136,0[134x35,136,0,90,134x35,136,36,93]}', - ].join('\\t') -} - -function runResult(stdout: string, overrides: Partial = {}): BashRunResult { - return { - exitCode: 0, - signal: null, - timedOut: false, - aborted: false, - timeoutMs: 60_000, - stdout: { text: stdout, truncated: false }, - stderr: { text: '', truncated: false }, - ...overrides, - } -} - -/** A scriptable fake `ctx.bash` recording the command it was asked to run. */ -class FakeBash extends BashExecutor { - commands: string[] = [] - result: BashRunResult = runResult(`${tmuxLine()}\n`) - runError?: Error - - override resolve(request: BashExecRequest): BashExecSpec { - return { - command: request.command, - workdir: request.workdir ?? '/work', - timeoutMs: request.timeoutMs ?? 60_000, - stdoutMaxBytes: request.stdoutMaxBytes ?? 64_000, - signal: request.signal, - sandboxPolicy: request.sandboxPolicy, - } - } - override async run(spec: BashExecSpec): Promise { - this.commands.push(spec.command) - if (this.runError) throw this.runError - return this.result - } - override start(): BashProcess { - throw new Error('tmux-context must never start a background task') - } -} - -async function mount(config: Config, withBash: true): Promise<{ ctx: Context; bash: FakeBash }> -async function mount(config?: Config, withBash?: boolean): Promise<{ ctx: Context; bash: FakeBash | undefined }> -async function mount( - config: Config = {}, - withBash = false, -): Promise<{ ctx: Context; bash: FakeBash | undefined }> { - const ctx = new Context() - await ctx.plugin(AgentRegistry) - let bash: FakeBash | undefined - if (withBash) { - await ctx.plugin(FakeBash) - bash = ctx.bash as FakeBash - } - await ctx.plugin(tmuxContext, config) - return { ctx, bash } -} - -function sessionAgent(session: Session, id = 'agent'): Agent { - return { - id: SessionId(id), - options: {}, - session, - status: 'running', - acceptsNextStep: true, - ctx: new Context(), - followup: () => {}, - steer: () => {}, - inject(input) { - session.append('user/message', input, { surfaceOp: 'append' }) - }, - send: () => {}, - cancel() {}, - whenIdle: () => Promise.resolve(), - } -} - -function openMessageTurn(session: Session, turn: number): void { - session.append('turn/start', { turn, trigger: { kind: 'message', source: { kind: 'user' } } }) - session.append('user/message', createUserMessage({ - content: [{ type: 'text', text: `turn ${turn}` }], - source: { kind: 'user' }, - }), { surfaceOp: 'append' }) -} - -function contextTexts(session: Session): string[] { - const texts: string[] = [] - for (const event of session.events) { - if (event.type === 'user/message' - && event.data.source.kind === 'plugin' - && event.data.source.plugin === 'tmux-context') { - texts.push(event.data.content.find(block => block.type === 'text')?.text ?? '') - } - } - return texts -} - -async function fire( - ctx: Context, - agent: Agent, - turn: number, - step: number, - signal: AbortSignal = SIGNAL, -): Promise { - await agentEvents(ctx, agent).serial('agent/step', turn, step, signal) -} - -afterEach(() => { - vi.restoreAllMocks() - vi.useRealTimers() -}) - -describe('tmux-context injection', () => { - it('injects the tmux location on the first step of a turn', async () => { - const { ctx } = await mount({}, true) - const session = new Session(SessionId('first')) - openMessageTurn(session, 1) - - await fire(ctx, sessionAgent(session), 1, 1) - - expect(contextTexts(session)).toEqual([ - 'tmux location (turn 1):\n' - + 'session 0, window 1 "node", pane 2 %90\n' - + 'window active=1, pane active=0, ' - + 'layout d517,270x71,0,0{135x71,0,0,87,134x71,136,0[134x35,136,0,90,134x35,136,36,93]}', - ]) - const event = session.events.at(-1) - if (event?.type !== 'user/message') throw new Error('missing tmux context') - expect(event.data.source).toEqual({ kind: 'plugin', plugin: 'tmux-context' }) - expect(event.surfaceOp).toBe('append') - }) - - it('queries the pane this process runs in and matches its controlling tty', async () => { - const { ctx, bash } = await mount({}, true) - const session = new Session(SessionId('command')) - openMessageTurn(session, 1) - - await fire(ctx, sessionAgent(session), 1, 1) - - expect(bash.commands).toHaveLength(1) - const command = bash.commands[0]! - expect(command).toContain('[ -n "$TMUX_PANE" ]') - expect(command).toContain('tmux display-message -t "$TMUX_PANE" -p') - // Guards against an inherited $TMUX_PANE: the pane's tty must equal this - // process's controlling tty (resolved for this exact pid). - expect(command).toContain(`ps -o tty= -p ${process.pid}`) - // The exact fragment matters: unquoted, `#` starts a shell comment and the - // substitution silently breaks while a substring check still passes. - expect(command).toContain('pane_tty=$(tmux display-message -t "$TMUX_PANE" -p \'#{pane_tty}\') || exit 1') - expect(command).toContain('[ "$pane_tty" = "/dev/$self_tty" ]') - }) - - it('does not run on later steps of a turn', async () => { - const { ctx, bash } = await mount({}, true) - const session = new Session(SessionId('later-step')) - openMessageTurn(session, 1) - - await fire(ctx, sessionAgent(session), 1, 2) - - expect(bash.commands).toHaveLength(0) - expect(contextTexts(session)).toHaveLength(0) - }) - - it('re-injects a new turn only when tmux state changed', async () => { - const { ctx, bash } = await mount({}, true) - const session = new Session(SessionId('change')) - const agent = sessionAgent(session) - - openMessageTurn(session, 1) - await fire(ctx, agent, 1, 1) - session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) - - // Same state on turn 2: suppressed. - openMessageTurn(session, 2) - await fire(ctx, agent, 2, 1) - session.append('turn/end', { turn: 2, reason: { kind: 'completed' } }) - expect(contextTexts(session)).toHaveLength(1) - - // Moved pane on turn 3: re-injected. - bash.result = runResult(`${tmuxLine({ windowName: 'shell', paneId: '%12' })}\n`) - openMessageTurn(session, 3) - await fire(ctx, agent, 3, 1) - - const texts = contextTexts(session) - expect(texts).toHaveLength(2) - expect(texts[1]).toContain('tmux location (turn 3):') - expect(texts[1]).toContain('window 1 "shell", pane 2 %12') - }) - - it('honors a positive refresh interval between injections', async () => { - vi.useFakeTimers() - vi.setSystemTime(1_000) - const { ctx, bash } = await mount({ refreshIntervalMs: 10_000 }, true) - const session = new Session(SessionId('interval')) - const agent = sessionAgent(session) - - openMessageTurn(session, 1) - await fire(ctx, agent, 1, 1) - session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) - - // Changed state but inside the interval: suppressed, and never queried. - bash.result = runResult(`${tmuxLine({ paneId: '%99' })}\n`) - vi.setSystemTime(5_000) - openMessageTurn(session, 2) - await fire(ctx, agent, 2, 1) - expect(contextTexts(session)).toHaveLength(1) - expect(bash.commands).toHaveLength(1) - - // Past the interval: queried and re-injected. - vi.setSystemTime(12_000) - openMessageTurn(session, 3) - await fire(ctx, agent, 3, 1) - expect(contextTexts(session)).toHaveLength(2) - expect(bash.commands).toHaveLength(2) - }) -}) - -describe('tmux-context prior-reading resilience', () => { - it('treats a prior non-text plugin reading as absent and injects afresh', async () => { - const { ctx, bash } = await mount({}, true) - const session = new Session(SessionId('prior-non-text')) - const agent = sessionAgent(session) - openMessageTurn(session, 1) - session.append('user/message', createUserMessage({ - content: [{ type: 'reasoning', text: 'not a location' }], - source: { kind: 'plugin', plugin: 'tmux-context' }, - }), { surfaceOp: 'append' }) - - await fire(ctx, agent, 1, 1) - - expect(bash.commands).toHaveLength(1) - expect(contextTexts(session).at(-1)).toContain('tmux location (turn 1):') - }) - - it('treats a prior single-line plugin reading (no newline) as empty state', async () => { - const { ctx, bash } = await mount({}, true) - const session = new Session(SessionId('prior-single-line')) - const agent = sessionAgent(session) - openMessageTurn(session, 1) - session.append('user/message', createUserMessage({ - content: [{ type: 'text', text: 'single line, no newline' }], - source: { kind: 'plugin', plugin: 'tmux-context' }, - }), { surfaceOp: 'append' }) - - await fire(ctx, agent, 1, 1) - - // Empty prior state never equals the multi-line reading, so it re-injects. - expect(bash.commands).toHaveLength(1) - expect(contextTexts(session).at(-1)).toContain('tmux location (turn 1):') - }) -}) - -describe('tmux-context no-op paths', () => { - it('is a no-op when no bash executor is mounted', async () => { - const { ctx } = await mount() - const session = new Session(SessionId('no-bash')) - openMessageTurn(session, 1) - - await fire(ctx, sessionAgent(session), 1, 1) - - expect(contextTexts(session)).toHaveLength(0) - }) - - it('is a no-op when the tmux query exits nonzero (outside tmux, or an inherited env whose tty does not match the pane)', async () => { - const { ctx, bash } = await mount({}, true) - bash.result = runResult('', { exitCode: 1 }) - const session = new Session(SessionId('outside-tmux')) - openMessageTurn(session, 1) - - await fire(ctx, sessionAgent(session), 1, 1) - - expect(contextTexts(session)).toHaveLength(0) - }) - - it('is a no-op when the reading has the wrong field count', async () => { - const { ctx, bash } = await mount({}, true) - bash.result = runResult('0\\t1\\tnode\n') - const session = new Session(SessionId('malformed')) - openMessageTurn(session, 1) - - await fire(ctx, sessionAgent(session), 1, 1) - - expect(contextTexts(session)).toHaveLength(0) - }) - - it('is a no-op when the pane id is empty', async () => { - const { ctx, bash } = await mount({}, true) - bash.result = runResult(`${tmuxLine({ paneId: '' })}\n`) - const session = new Session(SessionId('empty-pane')) - openMessageTurn(session, 1) - - await fire(ctx, sessionAgent(session), 1, 1) - - expect(contextTexts(session)).toHaveLength(0) - }) - - it('skips an already-aborted step and runs before ordinary agent/step listeners', async () => { - const { ctx } = await mount({}, true) - const session = new Session(SessionId('ordering')) - const agent = sessionAgent(session) - openMessageTurn(session, 1) - let ordinarySawContext = false - ctx.on('agent/step', (subject) => { - ordinarySawContext = subject.session.events.some( - event => event.type === 'user/message' - && event.data.source.kind === 'plugin' - && event.data.source.plugin === 'tmux-context', - ) - }) - - const abort = new AbortController() - abort.abort() - await fire(ctx, agent, 1, 1, abort.signal) - expect(contextTexts(session)).toHaveLength(0) - - await fire(ctx, agent, 1, 1) - expect(ordinarySawContext).toBe(true) - expect(contextTexts(session)).toHaveLength(1) - }) -}) - -describe('tmux-context configuration', () => { - it('rejects a negative refresh interval at plugin load', async () => { - await expect(mount({ refreshIntervalMs: -1 })).rejects.toThrow( - /refreshIntervalMs must be a non-negative safe integer/, - ) - }) - - it('rejects a non-integer refresh interval at plugin load', async () => { - await expect(mount({ refreshIntervalMs: 1.5 })).rejects.toThrow( - /refreshIntervalMs must be a non-negative safe integer/, - ) - }) -}) diff --git a/packages/context/tmux-context/tsconfig.json b/packages/context/tmux-context/tsconfig.json deleted file mode 100644 index fe893f9402..0000000000 --- a/packages/context/tmux-context/tsconfig.json +++ /dev/null @@ -1,40 +0,0 @@ -{ - "extends": "../../../tsconfig.base.json", - "compilerOptions": { - "rootDir": "src", - "outDir": "lib/types" - }, - "include": ["src"], - "references": [ - { - "path": "../../../vendor/cosmokit" - }, - { - "path": "../../../vendor/cordis" - }, - { - "path": "../../../vendor/schemastery" - }, - { - "path": "../../llm/llm" - }, - { - "path": "../../bash/bash" - }, - { - "path": "../../core/agent" - }, - { - "path": "../../core/system-prompt" - }, - { - "path": "../../support/loader-smoke" - }, - { - "path": "../../support/invariants" - }, - { - "path": "../../core/session" - } - ] -} diff --git a/packages/guard/README.i18n.yaml b/packages/guard/README.i18n.yaml index c323a9b295..0f6859f941 100644 --- a/packages/guard/README.i18n.yaml +++ b/packages/guard/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/guard/README.md -README.md: b7375fd2bb12ae0cec94b13e6a1012c6f143bdad -README.zh.md: bba5c144d0663266e4327e388b9915cde176ce08 +README.md: 59ab2fcea91bbbb9f6628523f3c6f13497d6f742 +README.zh.md: caec5618f9ddc27825ad68cd4145f6197c7b0517 diff --git a/packages/guard/README.md b/packages/guard/README.md index a164bdcddb..59ab2fcea9 100644 --- a/packages/guard/README.md +++ b/packages/guard/README.md @@ -7,6 +7,5 @@ Behavioral guard plugins that watch the agent loop and correct it — some by nu | Package | Role | ctx key | |---|---|---| | `repeat-tool-guard/` | Advisory reminders when an agent loops on identical tool calls | (listens on `ctx.tools`' waterfalls) | -| `source-guard/` | Denies file edits inside a dsh staging worktree until the required skill is loaded | (listens on `ctx.tools`' waterfalls) | An advisory guard's reminders travel as `additionalContexts` on the `tools/post-execute` decision; the agent loop appends them as logged plugin-sourced `user/message` events after the step's tool results (see [the tools package](../core/tools)), so everything such a guard says to the model is reconstructable from the session log. An enforcing guard instead decides on `tools/pre-execute`, where a `deny` becomes the call's error result and the operation never dispatches. diff --git a/packages/guard/README.zh.md b/packages/guard/README.zh.md index bba5c144d0..caec5618f9 100644 --- a/packages/guard/README.zh.md +++ b/packages/guard/README.zh.md @@ -2,10 +2,10 @@ [English](README.md) | 中文 -这组行为 guard 插件会监视 agent loop(智能体循环)中的低效模式,并提醒模型调整方向。这里只有一个**产品**包(package),不设接口/实现 seam:guard 是现有核心 seam(`tools/post-execute`、`agent/prompt-submit`、`agent/status`)的自包含消费方,并非可替换能力。 +这组行为 guard 插件会监视 agent(智能体)循环并加以纠正:一部分提醒模型调整方向,另一部分则直接拒绝某个操作。它们都是**产品**包,不设接口/实现 seam:guard 是现有核心 seam(`tools/pre-execute`、`tools/post-execute`、`agent/prompt-submit`、`agent/status`)的自包含消费方,并非可替换能力。 | 包 | 职责 | ctx 键 | |---|---|---| -| `repeat-tool-guard/` | 当 agent 对完全相同的工具调用反复循环时给出提示 | (监听 `ctx.tools` 的 waterfall,即瀑布式事件) | +| `repeat-tool-guard/` | 当 agent 对完全相同的工具调用反复循环时给出提示 | (监听 `ctx.tools` 的 waterfall(瀑布式事件)) | -提示以 `additionalContexts` 形式附在 `tools/post-execute` 决策中传递;agent loop 会在该步骤的工具结果之后,将其追加为有日志记录、来源为插件的 `user/message` 事件(参见[工具包](../core/tools))。因此,guard 告诉模型的所有内容都能从会话日志中重建。 +建议型 guard 的提示以 `additionalContexts` 形式附在 `tools/post-execute` 决策中传递;agent loop 会在该步骤的工具结果之后,将其追加为有日志记录、来源为插件的 `user/message` 事件(参见[工具包](../core/tools))。因此,此类 guard 告诉模型的所有内容都能从会话日志中重建。强制型 guard 则在 `tools/pre-execute` 上做出决策,其 `deny` 会成为该调用的错误结果,操作绝不会分派执行。 diff --git a/packages/guard/source-guard/README.i18n.yaml b/packages/guard/source-guard/README.i18n.yaml deleted file mode 100644 index 79f2f9f423..0000000000 --- a/packages/guard/source-guard/README.i18n.yaml +++ /dev/null @@ -1,6 +0,0 @@ -# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each -# side as of the last confirmed-consistent state. Both languages carry equal authority; -# after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write packages/guard/source-guard/README.md -README.md: a7406d71c6591f78d12b6c02ec22bc4b0b3d517f -README.zh.md: 916d0513b77e17989730fdf27ef50d21013f4e58 diff --git a/packages/guard/source-guard/README.md b/packages/guard/source-guard/README.md deleted file mode 100644 index a7406d71c6..0000000000 --- a/packages/guard/source-guard/README.md +++ /dev/null @@ -1,88 +0,0 @@ -# @deepseek-ai/dsh-source-guard - -English | [中文](README.zh.md) - -An enforcement gate, not a model-facing tool: it never appears in the tool list and adds exactly one behavior — it denies a `write` or `edit` whose target sits inside the dsh checkout the running harness was launched from, on that checkout's own branch, until the calling session's durable log shows a successful load of the `dsh-customize` skill. That skill requires personal changes to be implemented in a task worktree and integrated under the staging lock; this plugin turns its central rule ("do not edit the personal staging checkout directly") from prompt guidance into a boundary the model cannot cross by forgetting. - -## Config - -```yaml -- id: source-guard - name: '@deepseek-ai/dsh-source-guard' - config: - requiredSkill: dsh-customize # default; the skill whose load lifts the denial - tools: [write, edit] # default; the gated tool names - protectedCheckout: /path/to/checkout # defaults to this module's own location -``` - -Every field fails loud at plugin load: an empty `tools` list, a blank `requiredSkill`, or a relative `protectedCheckout` throws, never a silent fall-back. - -`protectedCheckout` names a path inside the checkout to guard, and its worktree supplies BOTH protected identities: the repository and the exact branch. Its default is this module's own file, which resolves the checkout the running harness was launched from — the live deployment, whatever its branch is named. Nothing about the branch is configured or pattern-matched, so a maintainer whose staging branch follows no naming convention is protected identically. A harness running from an installed copy resolves a different repository, or none, and therefore guards nothing; the rule is meaningless outside a source checkout. - -The shipped TUI composition (`apps/cli/base.cordis.yml`) loads this plugin with defaults. It is inert for anyone whose workspace is not the launcher's own checkout, so an ordinary project sees no change. - -## Which paths are protected - -Protection is decided by git identity read from files — `.git`, its `gitdir:` pointer, and `HEAD` — never by path prefix and never by running `git`. Prefix matching would be wrong here: the task worktrees the skill prescribes live *inside* the staging tree, at `/.worktrees/...`, and are exactly where edits belong. - -Resolution walks OUTWARD from the target and stops at the first enclosing worktree, so it reports the INNERMOST one. Denial needs that worktree to match the launcher's on BOTH identities: the same shared git directory and the same branch. A task worktree nested under the protected tree answers with its own task branch and passes; the launcher's own tree answers with the launcher's branch and is denied. Repository identity is compared on symlink-resolved paths, so two routes to one repository — a session cwd under `/var/...` and a configured path under `/private/var/...` on macOS — match rather than falling open. - -Requiring the exact branch, not a name pattern, keeps the gate on the live deployment only. A stale sibling checkout left by an earlier install shares the repository but runs no launcher, so the workflow rule does not apply to it and it stays editable. - -A `gitdir:` pointer may be absolute (what `git worktree add` writes) or relative, which git resolves against the worktree directory holding it; both resolve here. A relative `file_path` resolves against the calling session's workspace, exactly as the filesystem tools resolve it, so it is not an unguarded route to a protected file. - -The gate is deliberately narrow: - -- **`read` is never gated.** Inspecting the staging checkout violates nothing, so only mutating tools are candidates. -- **`bash` is not gated.** Reliably classifying mutating shell commands is out of scope, so a determined model can still change staging through a shell. -- **Calls without an agent are allowed.** A direct `ctx.tools.execute()` caller has no session to replay and no model to correct. -- **Unresolvable git state fails OPEN.** A path outside any worktree, a detached HEAD on either side, a different repository or branch, a malformed `.git` pointer, or unreadable metadata all leave the call to the rest of the chain. A gate that blocked every write whenever git identity was unavailable would cause more harm than the violation it prevents. -- **An unresolvable target is not judged.** An empty `file_path`, a non-string one, or a relative one in a session that names no workspace leaves the call to the tool's own validation. - -Worktree identity is cached per target directory for the plugin's lifetime, so repeated writes in one directory read git metadata once; a mid-session branch switch is therefore not observed. - -## How the denial lifts - -Satisfaction is replayed from the session's durable log: a `tool/call` naming the `skill` tool whose arguments parse to `{name: }`, paired by call id with a non-error `tool/result`. Because the log is the only state, satisfaction survives a session resume — a resumed session that already loaded the skill is not asked again. A failed load, a differently-named skill, and malformed argument JSON all leave the denial in place. - -Satisfaction is per session, so a subagent with its own session must load the skill itself. - -## Enforcement point - -The gate is a `tools/pre-execute` listener returning `{kind: 'deny', reason}`, so the call never dispatches and the file is never touched. It delegates via `next()` in every non-violating case. Denial — not an advisory reminder — is the point: an advisory nudge leaves the violation committed, and `ask` degrades to denial in a composition without approval support. - -## Testing - -Unit suites drive a real agent loop against a mock adapter over real git-metadata fixtures — a staging worktree, a task worktree nested inside it, a plain clone, a foreign repository on a staging-named branch, a detached HEAD, absolute and relative `gitdir:` pointers, a symlinked route to the same repository, and unreadable metadata — to per-file 100%. The assembled-run evidence is the Loader-composition smoke (`tests/loader-composition.e2e.ts`): it boots a real headless app over `examples/headless-agent/tests/fixtures/guard/source-guard/cordis.yml`, seeds a staging worktree in a temporary cwd, and asserts the tool result is an error carrying the exact denial while the targeted file keeps its original bytes. - -## Model Experience - -### Denied filesystem call - -#### What the model sees - -A gated call into a protected worktree without the required skill loaded returns an error result carrying exactly the text below. No prompt section, tool schema, or successful-call text is added, and an allowed call is indistinguishable from one made without this plugin. - -##### Denial result - -```markdown -Error: Editing "" directly is not allowed: it is inside the dsh checkout this session is running from, on branch . Load the skill first and follow it — implement in a task worktree, then integrate under the staging lock. -``` - -#### Token effect - -Zero tokens while no denial occurs. A denial adds its small retained error result and avoids the success payload the call would have produced. - -#### KV Cache effect - -Append-only; newly visible content follows the reusable request prefix and does not invalidate existing KV-cache entries. - -## Known Limitations and Deferred Work - -- **`bash` is ungated** — the guard is a boundary for the filesystem tools only; a shell command can still mutate a protected worktree. -- **Worktree identity is cached per directory for the plugin's lifetime** — switching a protected worktree's branch mid-session does not change decisions until the next load, on either the target or the launcher side. -- **Only the launcher's own checkout is protected** — a stale sibling checkout of the same repository stays editable, deliberately; run `dsh` from it to protect it. -- **Disarmed outside a source checkout** — a harness running from an installed copy protects nothing unless `protectedCheckout` names a real checkout explicitly. -- **Satisfaction is per session** — a subagent's session must load the skill itself; a parent's load does not carry over. -- **Fail-open on unresolvable git state** — a broken or unreadable `.git` means no protection, chosen deliberately over blocking every edit. -- **One skill lifts the whole gate for the session** — loading it does not verify the workflow was actually followed, only that the instructions were read. diff --git a/packages/guard/source-guard/README.zh.md b/packages/guard/source-guard/README.zh.md deleted file mode 100644 index 916d0513b7..0000000000 --- a/packages/guard/source-guard/README.zh.md +++ /dev/null @@ -1,88 +0,0 @@ -# @deepseek-ai/dsh-source-guard - -[English](README.md) | 中文 - -这是一道强制执行门禁,而非面向模型的工具:它不会出现在工具列表中,只增加一种行为。若 `write` 或 `edit` 的目标位于运行中 harness 启动来源的 dsh 检出目录内,并处于该检出目录自身的分支上,它会拒绝调用,直到调用方会话的持久日志表明已成功加载 `dsh-customize` skill(技能)。该 skill 要求在任务 worktree 中实现个人变更,并在 staging 锁保护下完成集成;本插件把其核心规则(「不要直接编辑个人 staging 检出目录」)从提示词指导变成一道模型无法因遗忘而越过的边界。 - -## 配置 - -```yaml -- id: source-guard - name: '@deepseek-ai/dsh-source-guard' - config: - requiredSkill: dsh-customize # default; the skill whose load lifts the denial - tools: [write, edit] # default; the gated tool names - protectedCheckout: /path/to/checkout # defaults to this module's own location -``` - -插件加载时,每个字段都会对错误配置快速失败:`tools` 为空列表、`requiredSkill` 为空白字符串,或 `protectedCheckout` 使用相对路径时,都会抛出错误,绝不静默回退。 - -`protectedCheckout` 指定位于待保护检出目录内的一条路径;其 worktree 会提供两项受保护身份:仓库和确切分支。其默认值是本模块自己的文件,由此解析出运行中 harness 启动来源的检出目录——当前运行的部署,无论其分支采用什么名称。分支既无需配置,也不会通过模式匹配,因此 staging 分支不遵循任何命名约定的维护者同样会受到保护。若 harness 从已安装副本运行,则会解析到另一个仓库,或根本解析不到仓库,因此不会保护任何内容;这条规则在源码检出目录之外没有意义。 - -已交付的 TUI 组合(`apps/cli/base.cordis.yml`)会以默认配置加载本插件。若用户的工作区并非启动器自身所在的检出目录,本插件不会生效,因此普通项目不会发生任何变化。 - -## 受保护的路径 - -保护范围根据从文件读取的 Git 身份确定,即 `.git`、其中的 `gitdir:` 指针和 `HEAD`;既不按路径前缀判断,也不运行 `git`。此处若匹配路径前缀就会出错:skill 要求使用的任务 worktree 位于 staging 树*内部*的 `/.worktrees/...`,而这正是应该进行编辑的位置。 - -解析过程从目标路径开始向外逐层查找,遇到第一个所属 worktree 就停止,因此返回最内层的 worktree。只有该 worktree 在两项身份上都与启动器的 worktree 匹配,才会拒绝:共用同一个共享 Git 目录,且分支相同。嵌套在受保护树下的任务 worktree 会返回自己的任务分支并获准;启动器自身所在的树会返回启动器的分支并被拒绝。仓库身份会按解析符号链接后的路径进行比较,因此指向同一仓库的两条路径——macOS 上位于 `/var/...` 下的会话 cwd 和位于 `/private/var/...` 下的配置路径——会相互匹配,而不会触发故障放行(fail-open)。 - -要求匹配确切分支而非名称模式,可确保门禁仅作用于当前运行的部署。先前安装留下的陈旧同级检出目录虽然共享仓库,却没有运行启动器,因此该工作流规则不适用于它,它仍可编辑。 - -`gitdir:` 指针既可以是绝对路径(`git worktree add` 写入的形式),也可以是相对路径;Git 会以包含该指针的 worktree 目录为基准解析相对路径,本插件对两者都能解析。相对 `file_path` 会像文件系统工具一样,相对于调用会话的工作区解析,因此不会成为绕过门禁访问受保护文件的路径。 - -门禁刻意保持较窄的范围: - -- **`read` 从不受门禁限制。** 检查 staging 检出不构成违规,因此只有修改类工具是候选项。 -- **`bash` 不受门禁限制。** 可靠识别会修改内容的 shell 命令不在范围内,因此执意修改的模型仍可通过 shell 修改 staging。 -- **没有 agent(智能体)的调用会被放行。** 直接调用 `ctx.tools.execute()` 的调用方没有可供回放的会话,也没有需要纠正的模型。 -- **无法解析 Git 状态时故障放行。** 不属于任何 worktree 的路径、任一侧的 HEAD 分离状态、其他仓库或分支、格式错误的 `.git` 指针或不可读的元数据,都会把调用交给链中后续环节处理。若每逢 Git 身份不可用就阻止所有写入,这道门禁造成的危害将大于它所防止的违规。 -- **无法解析的目标不会被判断。** `file_path` 为空、不是字符串,或它是相对路径而会话未指定工作区时,调用会交给工具自身校验。 - -插件会在其整个生命周期内按目标目录缓存 worktree 身份,因此同一目录中的重复写入只读取一次 Git 元数据;由此,系统不会观察到会话中途的分支切换。 - -## 如何解除拒绝 - -是否满足解锁条件由会话的持久日志回放得出:日志中存在一条 `tool/call`,它调用名为 `skill` 的工具,参数可解析为 `{name: }`,并且有一条调用 id 相同的非错误 `tool/result` 与之配对。由于日志是唯一状态源,恢复会话时仍能保留这一结果:若恢复的会话已经加载该 skill,系统不会再次要求加载。加载失败、skill 名称不同或参数 JSON 格式错误,都会让拒绝继续生效。 - -解锁条件按会话独立满足,因此拥有独立会话的 subagent 必须自行加载该 skill。 - -## 强制执行点 - -门禁是一个 `tools/pre-execute` 监听器,返回 `{kind: 'deny', reason}`,因此调用绝不会分派执行,文件也绝不会被修改。在所有不违规的情况下,它都会通过 `next()` 委派。这里刻意采用拒绝而非建议性提醒:建议性提醒仍会让违规落地,而在不支持批准的组合中,`ask` 会退化为拒绝。 - -## 测试 - -单元测试套件基于真实 Git 元数据 fixture(测试前置数据),使用 mock 适配器驱动真实 agent loop(智能体循环):覆盖 staging worktree、嵌套其中的任务 worktree、普通克隆、位于 staging 命名分支上的其他仓库、HEAD 分离状态、绝对和相对 `gitdir:` 指针、指向同一仓库的符号链接路径以及不可读元数据,达到逐文件 100% 覆盖率。组装运行层面的证据来自 Loader 组合冒烟测试(`tests/loader-composition.e2e.ts`):它通过 `examples/headless-agent/tests/fixtures/guard/source-guard/cordis.yml` 启动一个真实的 headless 应用,在临时 cwd 中植入 staging worktree,并断言工具结果是携带精确拒绝文本的错误,同时目标文件保持原始字节不变。 - -## 模型体验 - -### 被拒绝的文件系统调用 - -#### 模型看到的内容 - -如果未加载必需 skill 就对受保护 worktree 发起受门禁限制的调用,系统会返回错误结果,其中的文本与下文完全一致。系统不会添加提示词段、工具 schema 或成功调用文本;允许的调用与未启用此插件时的调用完全无法区分。 - -##### 拒绝结果 - -```markdown -Error: Editing "" directly is not allowed: it is inside the dsh checkout this session is running from, on branch . Load the skill first and follow it — implement in a task worktree, then integrate under the staging lock. -``` - -#### Token 影响 - -未发生拒绝时为零 token。一次拒绝会添加一条会保留在历史中的短小错误结果,同时避免生成该调用原本会产生的成功载荷。 - -#### KV Cache 影响 - -仅追加;新出现的内容位于可复用请求前缀之后,不会使现有 KV-cache 条目失效。 - -## 已知限制与暂缓工作 - -- **`bash` 不受门禁限制**:此插件只为文件系统工具提供边界;shell 命令仍可修改受保护的 worktree。 -- **插件生命周期内按目录缓存 worktree 身份**:在会话中途切换受保护 worktree 的分支,不会改变判断结果,直至下次加载插件;目标侧和启动器侧都是如此。 -- **仅保护启动器自身的检出目录**:同一仓库中的陈旧同级检出目录会被刻意保留为可编辑状态;若要保护它,请从中运行 `dsh`。 -- **源码检出之外不启用**:从已安装副本运行的 harness 不保护任何内容,除非 `protectedCheckout` 明确指定真实检出目录。 -- **解锁条件按会话独立满足**:subagent 的会话必须自行加载该 skill;父会话的加载状态不会继承。 -- **无法解析 Git 状态时故障放行**:损坏或不可读的 `.git` 会使保护失效;这是刻意选择的结果,因为另一方案是阻止所有编辑。 -- **仅加载一个 skill 即可为会话解除整道门禁**:加载该 skill 并不能验证是否实际遵循工作流,只能证明已阅读这些指令。 diff --git a/packages/guard/source-guard/package.json b/packages/guard/source-guard/package.json deleted file mode 100644 index 4e893e0a69..0000000000 --- a/packages/guard/source-guard/package.json +++ /dev/null @@ -1,56 +0,0 @@ -{ - "name": "@deepseek-ai/dsh-source-guard", - "description": "Source-guard plugin: denies direct file edits inside a dsh staging worktree until the required customization skill is loaded", - "version": "0.0.1", - "private": true, - "type": "module", - "main": "lib/index.js", - "types": "lib/types/index.d.ts", - "exports": { - ".": { - "types": "./lib/types/index.d.ts", - "default": "./lib/index.js" - }, - "./invariant": { - "types": "./lib/types/invariant.d.ts", - "default": "./lib/invariant.js" - }, - "./src/*": "./src/*", - "./package.json": "./package.json" - }, - "files": [ - "lib/index.js", - "lib/invariant.js", - "lib/types/**/*.d.ts", - "lib/types/**/*.d.ts.map", - "src" - ], - "license": "BSD-3-Clause", - "dependencies": { - "schemastery": "^3.18.0" - }, - "peerDependencies": { - "@deepseek-ai/dsh-agent": "^0.0.1", - "@deepseek-ai/dsh-fs": "^0.0.1", - "@deepseek-ai/dsh-invariants": "^0.0.1", - "@deepseek-ai/dsh-llm": "^0.0.1", - "@deepseek-ai/dsh-sandbox": "^0.0.1", - "@deepseek-ai/dsh-session": "^0.0.1", - "@deepseek-ai/dsh-tools": "^0.0.1", - "cordis": "^4.0.0-rc.7" - }, - "devDependencies": { - "@deepseek-ai/dsh-agent": "workspace:^", - "@deepseek-ai/dsh-agent-loop": "workspace:^", - "@deepseek-ai/dsh-agent-loop-testkit": "workspace:^", - "@deepseek-ai/dsh-fs": "workspace:^", - "@deepseek-ai/dsh-fs-local": "workspace:^", - "@deepseek-ai/dsh-invariants": "workspace:^", - "@deepseek-ai/dsh-llm": "workspace:^", - "@deepseek-ai/dsh-loader-smoke": "workspace:^", - "@deepseek-ai/dsh-sandbox": "workspace:^", - "@deepseek-ai/dsh-session": "workspace:^", - "@deepseek-ai/dsh-tools": "workspace:^", - "cordis": "^4.0.0-rc.7" - } -} diff --git a/packages/guard/source-guard/src/index.ts b/packages/guard/source-guard/src/index.ts deleted file mode 100644 index 4e242efbd0..0000000000 --- a/packages/guard/source-guard/src/index.ts +++ /dev/null @@ -1,319 +0,0 @@ -/** - * Denies model-driven file mutation inside a dsh staging worktree until the - * calling session has loaded the required customization skill. Config, git - * resolution, and satisfaction semantics live in the package README; rationale - * lives in the source-guard Agent Note. - * @module @deepseek-ai/dsh-source-guard - */ - -import { dirname, isAbsolute, resolve } from 'node:path' -import { fileURLToPath } from 'node:url' -import type { Context } from 'cordis' -import z from 'schemastery' -import { canonicalPath } from '@deepseek-ai/dsh-sandbox' -import type {} from '@deepseek-ai/dsh-fs' -import type { CallId } from '@deepseek-ai/dsh-llm' -import type { Session } from '@deepseek-ai/dsh-session' -import type { PreToolDecision, ToolExecution } from '@deepseek-ai/dsh-tools' - -export const name = 'source-guard' - -/** The `ctx.fs` provider supplies the git-metadata reads this guard resolves paths with. */ -export const inject = ['fs'] - -/** - * Plugin config, validated by the same-named schemastery schema plus the - * load-time checks in `apply` (misconfiguration fails loud: an empty `tools` - * list, a blank `requiredSkill`, or a relative `protectedCheckout` throws at - * plugin load, never a silent fall-back). - */ -export interface Config { - /** Skill whose loaded presence in the session lifts the denial (default `dsh-customize`). */ - requiredSkill?: string - /** Tool names to gate (default `['write', 'edit']`). */ - tools?: string[] - /** - * Absolute path inside the checkout this guard protects. Its worktree - * supplies BOTH protected identities: the repository (targets in any other - * repository are ignored) and the exact branch (only that branch's worktree - * is protected). Defaults to this module's own location, which resolves the - * checkout the running harness was launched from — the live deployment, - * whatever its branch is named. Set it explicitly to guard a different - * checkout, or when the harness runs from an installed copy whose own - * location is not a checkout at all. - */ - protectedCheckout?: string -} - -export const Config: z = z.object({ - requiredSkill: z.string().default('dsh-customize'), - tools: z.array(z.string()).default(['write', 'edit']), - protectedCheckout: z.string().default(fileURLToPath(import.meta.url)), -}) - -/** - * The tool whose successful call satisfies the guard. Fixed, not configurable: - * this is the harness's own skill-loading tool name, so a deployment that - * renamed it has no skill to load and nothing for this guard to observe. - */ -const SKILL_TOOL = 'skill' - -/** - * The argument key every gated tool names its target with. `write` and `edit` - * share it (`dsh-tool-fs`), and gating a tool that does not is a - * misconfiguration the guard reports rather than silently allowing. - */ -const PATH_ARGUMENT = 'file_path' - -/** - * The absolute `file_path` a gated call targets, or `undefined` when the - * arguments carry no usable one. Arguments arrive as the loop's parsed model - * JSON, so this is a model-input boundary: any shape is possible. - * - * A relative path resolves against the calling session's workspace, exactly as - * the filesystem tools resolve it (`dsh-tool-fs`'s `sessionCwd`). Judging only - * absolute paths would leave `write` with a relative `file_path` as an - * unguarded path to the same file. - */ -function targetPath(argumentsValue: unknown, sessionCwd: string | undefined): string | undefined { - if (typeof argumentsValue !== 'object' || argumentsValue === null) return undefined - const value = (argumentsValue as Record)[PATH_ARGUMENT] - if (typeof value !== 'string' || value.length === 0) return undefined - if (isAbsolute(value)) return resolve(value) - // Without a session cwd the tools fall back to a provider-owned default this - // guard cannot observe, so the target is genuinely unresolvable here. - return sessionCwd === undefined ? undefined : resolve(sessionCwd, value) -} - -/** One resolved worktree's identity: the branch its HEAD names, and the repository it belongs to. */ -interface Worktree { - /** Branch name from `HEAD`, or `undefined` for a detached HEAD. */ - branch: string | undefined - /** - * Symlink-resolved absolute path of the shared git directory, identifying the - * repository across worktrees. Canonical because two paths reaching one - * repository by different symlink routes must compare equal — on macOS a - * session cwd under `/var/...` and a configured path under `/private/var/...` - * name the same directory, and a lexical comparison would fail open. - */ - commonDir: string -} - -/** - * What one git-metadata path holds: a file's text, the fact that it is a - * directory, or nothing resolvable. Every caller treats the unresolvable case - * as "not a worktree" and lets the call proceed, so distinguishing absence - * from a permission error would change no decision. - */ -type GitEntry = - | { kind: 'file'; text: string } - | { kind: 'directory' } - | { kind: 'absent' } - -/** Probe one git-metadata path, reading its text when it is a regular file. */ -async function readGitEntry(ctx: Context, path: string): Promise { - try { - const target = await ctx.fs.resolve(path) - const info = await ctx.fs.stat(target) - if (info?.type === 'directory') return { kind: 'directory' } - if (info?.type !== 'file') return { kind: 'absent' } - return { kind: 'file', text: await ctx.fs.readText(target) } - } catch { - // Any resolve/stat/read failure (absent, denied, unreadable encoding) - // yields no git identity. Nothing else can reach here: the guard performs - // no other IO. - return { kind: 'absent' } - } -} - -/** - * Branch name from a `HEAD` file's contents. A symbolic ref names a branch; a - * detached HEAD holds a raw object id and has no branch, which no staging - * pattern can match. - */ -function branchFromHead(head: string): string | undefined { - const trimmed = head.trim() - const ref = 'ref: refs/heads/' - return trimmed.startsWith(ref) ? trimmed.slice(ref.length) : undefined -} - -/** - * Resolve the git directory a worktree root's `.git` entry designates, plus - * the shared common directory. A plain clone's `.git` is a directory that is - * its own common dir; a linked worktree's `.git` is a file pointing into the - * main repository's `worktrees/`, whose common dir is two levels up. - * A `gitdir:` pointer may be relative, which git resolves against the worktree - * directory holding it. - */ -async function resolveGitDir(ctx: Context, root: string): Promise<{ gitDir: string; commonDir: string } | undefined> { - const dotGit = resolve(root, '.git') - const entry = await readGitEntry(ctx, dotGit) - // A plain clone keeps a `.git` DIRECTORY, which is both the git dir and the - // common dir; a linked worktree keeps a `.git` FILE pointing elsewhere. - if (entry.kind === 'directory') return { gitDir: dotGit, commonDir: canonicalPath(dotGit) } - if (entry.kind === 'absent') return undefined - const prefix = 'gitdir:' - const trimmed = entry.text.trim() - if (!trimmed.startsWith(prefix)) return undefined - const pointer = trimmed.slice(prefix.length).trim() - if (pointer.length === 0) return undefined - const gitDir = resolve(root, pointer) - // `/worktrees/` — the shared repository is two levels up. - return { gitDir, commonDir: canonicalPath(dirname(dirname(gitDir))) } -} - -/** - * Walk from a path toward the filesystem root and resolve the first enclosing - * worktree, or `undefined` when the path is inside none. - */ -async function findWorktree(ctx: Context, from: string): Promise { - let current = from - for (;;) { - const dirs = await resolveGitDir(ctx, current) - if (dirs !== undefined) { - const head = await readGitEntry(ctx, resolve(dirs.gitDir, 'HEAD')) - return { - branch: head.kind === 'file' ? branchFromHead(head.text) : undefined, - commonDir: dirs.commonDir, - } - } - const parent = dirname(current) - if (parent === current) return undefined - current = parent - } -} - -/** - * The skill name a `skill` call's raw argument JSON requested, or `undefined` - * when the JSON is malformed or carries no string `name`. The log stores the - * model's unparsed argument string, so this is a model-JSON boundary. - */ -function skillNameOf(rawArguments: string): string | undefined { - let parsed: unknown - try { - parsed = JSON.parse(rawArguments) - } catch { - // The model produced argument text that is not JSON; the call cannot have - // named a skill. Nothing else in this try can throw. - return undefined - } - if (typeof parsed !== 'object' || parsed === null) return undefined - const value = (parsed as Record).name - return typeof value === 'string' ? value : undefined -} - -/** - * Whether the session's durable log records a successful load of - * `requiredSkill`. Replayed from `tool/call` + `tool/result` pairs, so - * satisfaction survives a session resume: the log is the only state. - */ -function skillLoaded(session: Session, requiredSkill: string): boolean { - const requested = new Map() - for (const event of session.events) { - if (event.type === 'tool/call') { - if (event.data.name === SKILL_TOOL) requested.set(event.data.callId, event.data.arguments) - continue - } - const block = event.type === 'tool/result' ? event.data.message.content[0] : undefined - if (block === undefined || block.isError === true) continue - const rawArguments = requested.get(block.toolCallId) - if (rawArguments !== undefined && skillNameOf(rawArguments) === requiredSkill) return true - } - return false -} - -/** The denial text a blocked call reports to the model. */ -function denialReason(path: string, branch: string, requiredSkill: string): string { - return `Editing "${path}" directly is not allowed: it is inside the dsh checkout this session is running from, on branch ${branch}. ` - + `Load the ${requiredSkill} skill first and follow it — implement in a task worktree, then integrate under the staging lock.` -} - -/** - * Install the guard's listener. - * @param ctx - plugin context; the listener is scoped to it and disposed with it. - * @param config - validated {@link Config}; re-checked fail-loud here. - */ -export function apply(ctx: Context, config: Config): void { - // schemastery's .default() guarantees the fields are set after validation. - const requiredSkill = config.requiredSkill as string - const tools = config.tools as string[] - if (tools.length === 0) { - throw new Error('source-guard: `tools` must not be empty') - } - if (requiredSkill.trim().length === 0) { - throw new Error('source-guard: `requiredSkill` must not be blank') - } - const gated = new Set(tools) - - const protectedCheckout = config.protectedCheckout as string - if (!isAbsolute(protectedCheckout)) { - throw new Error(`source-guard: \`protectedCheckout\` must be an absolute path, got "${protectedCheckout}"`) - } - // Resolved once per plugin lifetime: the worktree this guard arms for, which - // supplies both the protected repository and the protected branch. A harness - // running from an installed copy resolves a different repository (or none) - // and therefore guards nothing, which is correct — the rule is meaningless - // outside a source checkout. - let protectedRepository: Promise | undefined - - /** The repository containing {@link Config.protectedCheckout}. */ - function repository(): Promise { - protectedRepository ??= findWorktree(ctx, dirname(protectedCheckout)) - return protectedRepository - } - - // Worktree identity per directory, cached for the plugin's lifetime: a - // directory's repository and branch are stable in practice, and re-reading - // git metadata on every write would repeat identical IO. A mid-session - // branch switch is therefore not observed (see the README). - const worktrees = new Map>() - - /** Resolve (and memoize) the worktree enclosing a target path's directory. */ - function worktreeOf(path: string): Promise { - const directory = dirname(path) - let pending = worktrees.get(directory) - if (pending === undefined) { - pending = findWorktree(ctx, directory) - worktrees.set(directory, pending) - } - return pending - } - - /** - * The target path and the staging branch protecting it, or `undefined` when - * the call may proceed. Fails open on every unresolvable case: a path outside - * any worktree, a detached HEAD, a different repository, or unreadable git - * metadata leaves the call to the rest of the chain, because a guard that - * blocked writes whenever git identity was unavailable would be worse than - * the violation it prevents. - */ - async function protectedTarget(exec: ToolExecution, session: Session): Promise<{ path: string; branch: string } | undefined> { - if (!gated.has(exec.name)) return undefined - const path = targetPath(exec.arguments, session.header.cwd) - if (path === undefined) return undefined - const launcher = await repository() - // A detached launcher checkout names no branch to protect, so nothing is. - if (launcher?.branch === undefined) return undefined - // Resolution walks OUTWARD from the target, so it reports the INNERMOST - // enclosing worktree: a task worktree nested under the protected tree - // answers with its own task branch, which is not the launcher's. That is - // what keeps the prescribed workflow unblocked. - const worktree = await worktreeOf(path) - if (worktree === undefined || worktree.commonDir !== launcher.commonDir) return undefined - // Only the branch the launcher itself runs from is protected: a stale - // sibling checkout of the same repository is not the live deployment. - if (worktree.branch !== launcher.branch) return undefined - return { path, branch: launcher.branch } - } - - ctx.on('tools/pre-execute', async (exec, next): Promise => { - // A direct `ctx.tools.execute()` caller has no session to replay and no - // model to correct; only agent-loop calls are gated. - if (exec.agent === undefined) return next() - const { session } = exec.agent - const target = await protectedTarget(exec, session) - if (target === undefined) return next() - if (skillLoaded(session, requiredSkill)) return next() - return { kind: 'deny', reason: denialReason(target.path, target.branch, requiredSkill) } - }) -} diff --git a/packages/guard/source-guard/src/invariant.ts b/packages/guard/source-guard/src/invariant.ts deleted file mode 100644 index f5b68cf24d..0000000000 --- a/packages/guard/source-guard/src/invariant.ts +++ /dev/null @@ -1,85 +0,0 @@ -/** - * Package-owned invariant companion for `@deepseek-ai/dsh-source-guard`. - * @module @deepseek-ai/dsh-source-guard/invariant - */ - -import type { Context } from 'cordis' -import type { InvariantFailure, InvariantInstaller } from '@deepseek-ai/dsh-invariants' -import type { Session, SessionEvent } from '@deepseek-ai/dsh-session' - -const PACKAGE_NAME = '@deepseek-ai/dsh-source-guard' - -/** Cordis companion plugin name. */ -export const name = 'source-guard-invariant' -/** Service required before the companion can reserve package ownership. */ -export const inject = ['invariants'] - -/** - * The durable shape of this guard's refusal. The denial is the package's only - * model-visible output, and it is actionable only when it names all three of - * the offending path, the branch that protects it, and the skill that lifts - * the denial — a refusal missing any of them tells the model to stop without - * telling it how to proceed. - */ -const DENIAL = new RegExp( - '^Error: Editing "(?.+)" directly is not allowed: ' - + 'it is inside the dsh checkout this session is running from, on branch (?\\S+)\\. ' - + 'Load the (?\\S+) skill first and follow it ' - + '— implement in a task worktree, then integrate under the staging lock\\.$', -) - -/** The denial prefix identifying a result this package produced, before its full shape is validated. */ -const DENIAL_PREFIX = 'Error: Editing "' - -/** Validate one guard-produced denial result's model-facing text. */ -function validateDenial(text: string, fail: InvariantFailure): void { - const match = DENIAL.exec(text) - if (match === null) { - fail('source-guard denial must name the path, the protecting branch, and the skill that lifts it') - } - // The pattern's `\S+` groups already establish a non-empty branch and skill; - // only path absoluteness remains to check. - const { path } = match.groups as { path: string } - if (!path.startsWith('/') && !/^[A-Za-z]:[\\/]/.test(path)) { - fail(`source-guard denial must name an absolute path, got ${JSON.stringify(path)}`) - } -} - -/** Validate every guard denial carried by one session's durable log. */ -function validateSession(session: Session, fail: InvariantFailure): void { - for (const event of session.events) { - if (event.type !== 'tool/result') continue - validateEvent(event, fail) - } -} - -/** Validate one durable tool result, when it carries this package's denial. */ -function validateEvent(event: SessionEvent<'tool/result'>, fail: InvariantFailure): void { - const result = event.data.message.content[0] - if (result.isError !== true) return - for (const block of result.content) { - if (block.type !== 'text' || !block.text.startsWith(DENIAL_PREFIX)) continue - validateDenial(block.text, fail) - } -} - -/* jscpd:ignore-start -- package companions share replay and dispatch plumbing */ -/** Install validation for loaded and newly appended denial results. */ -const install: InvariantInstaller = Object.assign((ctx: Context, fail: InvariantFailure) => { - for (const session of ctx.sessions.list()) validateSession(session, fail) - ctx.on('internal/dispatch', (_mode, eventName, args) => { - if (eventName !== 'session/event') return - const [, event] = args as [Session, SessionEvent] - if (event.type !== 'tool/result') return - validateEvent(event, fail) - }, { global: true }) -}, { inject: ['sessions'] }) -/* jscpd:ignore-end */ - -/** - * Register this package's invariant companion. - * @param ctx - Cordis context carrying the invariant service. - * @returns the installed registration's disposer after setup succeeds. - */ -export const apply = (ctx: Context): Promise<() => void> => - Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) diff --git a/packages/guard/source-guard/tests/invariant.spec.ts b/packages/guard/source-guard/tests/invariant.spec.ts deleted file mode 100644 index cd51c1a9a1..0000000000 --- a/packages/guard/source-guard/tests/invariant.spec.ts +++ /dev/null @@ -1,134 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { Context } from 'cordis' -import { CallId, createToolResultMessage, createUserMessage, type ContentBlock } from '@deepseek-ai/dsh-llm' -import SessionStore, { SessionId, type SessionEvent } from '@deepseek-ai/dsh-session' -import InvariantService from '@deepseek-ai/dsh-invariants' -import * as SourceGuardInvariant from '@deepseek-ai/dsh-source-guard/invariant' - -/** - * The companion validates the durable shape of this package's only - * model-visible output: its refusal must name the offending path, the branch - * that protects it, and the skill that lifts it, so the model can act on the - * denial instead of merely stopping. - */ - -const PATH = '/repo/staging/file.ts' - -/** A well-formed denial for `path`, as the guard materializes it into a tool result. */ -function denial(path = PATH, branch = 'dsh-staging/20260101T000000Z', skill = 'dsh-customize'): string { - return `Error: Editing "${path}" directly is not allowed: it is inside the dsh checkout this session is running from, ` - + `on branch ${branch}. Load the ${skill} skill first and follow it ` - + '— implement in a task worktree, then integrate under the staging lock.' -} - -async function setup(): Promise { - const ctx = new Context() - await ctx.plugin(SessionStore) - await ctx.plugin(InvariantService, { enabled: true }) - await ctx.plugin(SourceGuardInvariant) - return ctx -} - -/** One durable tool result carrying `content`, error-flagged unless told otherwise. */ -function result(content: unknown[], isError = true): SessionEvent { - return { - type: 'tool/result', - seq: 0, - time: 1, - surfaceOp: 'append', - sourceEventSeqs: [0], - data: { - turn: 1, - step: 1, - message: createToolResultMessage({ - callId: CallId('c0'), - content: content as ContentBlock[], - isError, - }), - }, - } -} - -describe('source-guard invariants', () => { - it('accepts a denial naming the path, branch, and skill', async () => { - const ctx = await setup() - const session = ctx.sessions.create(SessionId('accept')) - expect(() => { ctx.emit('session/event', session, result([{ type: 'text', text: denial() }])) }).not.toThrow() - }) - - it('accepts a Windows-style absolute path', async () => { - const ctx = await setup() - const session = ctx.sessions.create(SessionId('accept-windows')) - const event = result([{ type: 'text', text: denial(String.raw`C:\repo\staging\file.ts`) }]) - expect(() => { ctx.emit('session/event', session, event) }).not.toThrow() - }) - - it.each([ - ['a successful result that merely quotes the prefix', false], - ])('ignores %s', async (_label, isError) => { - const ctx = await setup() - const session = ctx.sessions.create(SessionId('ignore-success')) - const event = result([{ type: 'text', text: 'Error: Editing "x" was fine' }], isError) - expect(() => { ctx.emit('session/event', session, event) }).not.toThrow() - }) - - it.each([ - ['a non-text block', [{ type: 'image', data: 'x', mimeType: 'image/png' }]], - ['text that is not this package\'s denial', [{ type: 'text', text: 'Error: something else' }]], - ])('ignores %s', async (_label, content) => { - const ctx = await setup() - const session = ctx.sessions.create(SessionId('ignore-other')) - expect(() => { ctx.emit('session/event', session, result(content)) }).not.toThrow() - }) - - it('ignores an event that is not a tool result', async () => { - const ctx = await setup() - const session = ctx.sessions.create(SessionId('ignore-kind')) - const event: SessionEvent = { - type: 'user/message', - seq: 0, - time: 1, - surfaceOp: 'append', - data: createUserMessage({ content: [{ type: 'text', text: denial() }], source: { kind: 'user' } }), - } - expect(() => { ctx.emit('session/event', session, event) }).not.toThrow() - }) - - it.each([ - [ - 'omits the skill that lifts it', - `Error: Editing "${PATH}" directly is not allowed: it is inside the dsh checkout this session is running from, on branch main.`, - ], - [ - 'names a relative path', - denial('relative/file.ts'), - ], - ])('rejects a denial that %s', async (_label, text) => { - const ctx = await setup() - const session = ctx.sessions.create(SessionId('reject')) - expect(() => { ctx.emit('session/event', session, result([{ type: 'text', text }])) }).toThrow(/source-guard denial/) - }) - - it('rejects an invalid denial already present on late registration', async () => { - const ctx = new Context() - await ctx.plugin(SessionStore) - const session = ctx.sessions.create(SessionId('late')) - session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) - session.append('step/start', { turn: 1, step: 1 }) - const call = session.append('tool/call', { - turn: 1, step: 1, callId: CallId('c0'), name: 'write', arguments: '{}', - }) - session.append('tool/result', { - turn: 1, - step: 1, - message: createToolResultMessage({ - callId: CallId('c0'), - content: [{ type: 'text', text: denial('relative/file.ts') }], - isError: true, - }), - }, { surfaceOp: 'append', sourceEventSeqs: [call.seq] }) - - await ctx.plugin(InvariantService, { enabled: true }) - await expect(ctx.plugin(SourceGuardInvariant).then(() => undefined)).rejects.toThrow(/source-guard denial/) - }) -}) diff --git a/packages/guard/source-guard/tests/loader-composition.e2e.ts b/packages/guard/source-guard/tests/loader-composition.e2e.ts deleted file mode 100644 index 2595dc8d2a..0000000000 --- a/packages/guard/source-guard/tests/loader-composition.e2e.ts +++ /dev/null @@ -1,93 +0,0 @@ -import { mkdir, readdir, readFile, realpath, writeFile } from 'node:fs/promises' -import { join } from 'node:path' -import { fileURLToPath } from 'node:url' -import { describe, expect, it } from 'vitest' -import { type SessionEvent } from '@deepseek-ai/dsh-session' -import { LOADER_SMOKE_TEST_TIMEOUT_MS, runLoaderSmoke } from '@deepseek-ai/dsh-loader-smoke' - -// The Loader config lives under examples so both launch modes exercise the same -// deployable topology: a local fixture adapter plus bare workspace plugins. -const configPath = fileURLToPath(new URL( - '../../../../examples/headless-agent/tests/fixtures/guard/source-guard/cordis.yml', - import.meta.url, -)) -const binScript = fileURLToPath(new URL('../../../examples/cli-demo/src/bin.ts', import.meta.url)) -const repoTsconfig = fileURLToPath(new URL('../../../../tsconfig.json', import.meta.url)) - -/** Every `.jsonl` session log under `dir`. */ -async function jsonlFiles(dir: string): Promise { - const entries = await readdir(dir, { withFileTypes: true }) - const paths = await Promise.all(entries.map(async (entry) => { - const path = join(dir, entry.name) - if (entry.isDirectory()) return jsonlFiles(path) - return entry.isFile() && entry.name.endsWith('.jsonl') ? [path] : [] - })) - return paths.flat() -} - -/** - * Write git metadata mirroring the installer layout — a master clone owning the - * shared git directory and one linked worktree on a staging branch — and return - * the worktree file the model will try to write. - */ -async function stagingFixture(cwd: string): Promise<{ checkout: string; target: string }> { - const gitDir = join(cwd, 'master', '.git') - const worktreeGitDir = join(gitDir, 'worktrees', 'staging') - await mkdir(worktreeGitDir, { recursive: true }) - await writeFile(join(gitDir, 'HEAD'), 'ref: refs/heads/master\n') - await writeFile(join(worktreeGitDir, 'HEAD'), 'ref: refs/heads/dsh-staging/20260101T000000Z\n') - const checkout = join(cwd, 'staging') - await mkdir(checkout, { recursive: true }) - await writeFile(join(checkout, '.git'), `gitdir: ${worktreeGitDir}\n`) - const target = join(checkout, 'guarded.ts') - await writeFile(target, 'original\n') - return { checkout, target } -} - -describe('source-guard through a real headless cordis.yml', () => { - it('denies the model-requested write and leaves the staged file untouched', async () => { - let events: SessionEvent[] = [] - let contents = '' - let target = '' - const { stderr } = await runLoaderSmoke({ - label: 'source-guard headless smoke', - tempDirPrefix: 'source-guard-e2e-', - binScript, - configPath, - tsconfigPath: repoTsconfig, - binArgs: ['--config', configPath, 'edit the guarded file'], - // The isolated cwd is not known when these options are built, so the - // config and adapter resolve their fixture paths against the child's own - // cwd, which is that directory. - prepare: async (cwd) => { - // macOS puts the temp directory behind the /var -> /private/var - // symlink; the child resolves its cwd, so compare against the same - // real path rather than the symlinked one this process was handed. - target = (await stagingFixture(await realpath(cwd))).target - }, - inspect: async (cwd) => { - const logs = await jsonlFiles(join(cwd, '.sessions')) - expect(logs).toHaveLength(1) - const lines = (await readFile(logs[0] as string, 'utf8')).trimEnd().split('\n') - events = lines.slice(1).map(line => JSON.parse(line) as SessionEvent) - contents = await readFile(target, 'utf8') - }, - }) - expect(stderr).not.toContain('UNHANDLED') - - const results = events.filter( - (event): event is SessionEvent<'tool/result'> => event.type === 'tool/result') - expect(results).toHaveLength(1) - const result = results[0]?.data.message.content[0] - expect(result?.isError).toBe(true) - const text = result?.content.map(block => block.type === 'text' ? block.text : '').join('') - expect(text).toBe( - `Error: Editing "${target}" directly is not allowed: it is inside the dsh checkout this session is running from, ` - + 'on branch dsh-staging/20260101T000000Z. Load the dsh-customize skill first and follow it ' - + '— implement in a task worktree, then integrate under the staging lock.', - ) - // Enforcement, not advice: the guard denies before dispatch, so the file - // the model targeted still holds its original bytes. - expect(contents).toBe('original\n') - }, LOADER_SMOKE_TEST_TIMEOUT_MS) -}) diff --git a/packages/guard/source-guard/tests/source-guard.spec.ts b/packages/guard/source-guard/tests/source-guard.spec.ts deleted file mode 100644 index ccbc7d7c2b..0000000000 --- a/packages/guard/source-guard/tests/source-guard.spec.ts +++ /dev/null @@ -1,581 +0,0 @@ -import { chmod, mkdir, mkdtemp, rm, symlink, writeFile } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { dirname, join } from 'node:path' -import { afterEach, describe, expect, it } from 'vitest' -import { Context } from 'cordis' -import AgentLoop from '@deepseek-ai/dsh-agent-loop' -import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit' -import LocalFileSystem from '@deepseek-ai/dsh-fs-local' -import { CallId, createToolResultMessage, createUserMessage } from '@deepseek-ai/dsh-llm' -import { SessionId, type SessionEvent } from '@deepseek-ai/dsh-session' -import { defineContentToolFixture } from '@deepseek-ai/dsh-tools' -import type { Agent } from '@deepseek-ai/dsh-agent' -import * as SourceGuard from '@deepseek-ai/dsh-source-guard' -import type { Config } from '@deepseek-ai/dsh-source-guard' -import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts' - -/** - * Behavior suite for the staging-source guard: worktree resolution over REAL - * git metadata fixtures (a staging worktree, a nested task worktree, a plain - * clone, an unrelated repository, a detached HEAD), skill satisfaction replayed - * from the durable session log, and fail-loud config validation — all driven - * through a real agent loop against a scripted mock adapter (no network). - */ - -const roots: string[] = [] - -afterEach(async () => { - await Promise.all(roots.splice(0).map(root => rm(root, { recursive: true, force: true }))) -}) - -/** - * Build a git-metadata fixture tree that mirrors the real installer layout: a - * `master` clone holding the shared git directory, linked worktrees registered - * under `master/.git/worktrees/`, and one file per worktree to target. - */ -async function fixture(): Promise<{ - /** Absolute path of the fixture container. */ - root: string - /** A file inside the staging worktree — the protected target. */ - stagingFile: string - /** A file inside a task worktree NESTED under the staging tree. */ - taskFile: string - /** A file inside a SIBLING staging worktree of the same repository, on another branch. */ - siblingFile: string - /** A file inside the plain master clone. */ - masterFile: string - /** A file inside a worktree whose HEAD is detached. */ - detachedFile: string - /** A file inside an unrelated repository sharing no git directory. */ - outsideFile: string - /** A file under no repository at all. */ - looseFile: string -}> { - const root = await mkdtemp(join(tmpdir(), 'source-guard-')) - roots.push(root) - const master = join(root, 'master') - const gitDir = join(master, '.git') - await mkdir(join(gitDir, 'worktrees'), { recursive: true }) - await writeFile(join(gitDir, 'HEAD'), 'ref: refs/heads/master\n') - await writeFile(join(master, 'file.ts'), 'master\n') - - /** Register one linked worktree at `path` whose HEAD file holds `head`. */ - async function linked(path: string, name: string, head: string): Promise { - const worktreeGitDir = join(gitDir, 'worktrees', name) - await mkdir(worktreeGitDir, { recursive: true }) - await writeFile(join(worktreeGitDir, 'HEAD'), head) - await mkdir(path, { recursive: true }) - await writeFile(join(path, '.git'), `gitdir: ${worktreeGitDir}\n`) - const file = join(path, 'file.ts') - await writeFile(file, 'content\n') - return file - } - - const staging = join(root, 'staging-20260728T022827Z') - const stagingFile = await linked(staging, 'staging-20260728T022827Z', 'ref: refs/heads/dsh-staging/20260728T022827Z\n') - // The prescribed workflow's task worktree lives INSIDE the staging tree. - const taskFile = await linked(join(staging, '.worktrees', 'task', 'x'), 'task-x', 'ref: refs/heads/task/x\n') - // A stale staging worktree from an earlier install: same repository, different branch. - const siblingFile = await linked( - join(root, 'staging-20260727T045831Z'), - 'staging-20260727T045831Z', - 'ref: refs/heads/dsh-staging/20260727T045831Z\n', - ) - const detachedFile = await linked(join(root, 'detached'), 'detached', '0123456789abcdef0123456789abcdef01234567\n') - - const outside = join(root, 'outside') - await mkdir(join(outside, '.git'), { recursive: true }) - await writeFile(join(outside, '.git', 'HEAD'), 'ref: refs/heads/dsh-staging/20260728T022827Z\n') - const outsideFile = join(outside, 'file.ts') - await writeFile(outsideFile, 'outside\n') - - const loose = join(root, 'loose') - await mkdir(loose, { recursive: true }) - const looseFile = join(loose, 'file.ts') - await writeFile(looseFile, 'loose\n') - - return { - root, stagingFile, taskFile, siblingFile, masterFile: join(master, 'file.ts'), detachedFile, outsideFile, looseFile, - } -} - -/** - * Boot the core spine, a real local filesystem, and the guard, pointing - * `protectedCheckout` at a fixture path so the guard arms for the fixture - * repository instead of the checkout these tests actually run in. - */ -async function harness(protectedCheckout: string, config: Partial = {}): Promise { - const ctx = new Context() - await mountAgentLoopTestDependencies(ctx) - await ctx.plugin(LocalFileSystem, {}) - await ctx.plugin(AgentLoop, { agents: [] }) - await ctx.plugin(SourceGuard, { ...config, protectedCheckout }) - for (const name of ['write', 'edit', 'read', 'skill']) { - ctx.tools.register(defineContentToolFixture({ - name, - description: name, - parameters: { file_path: { type: 'string' }, name: { type: 'string' } }, - async execute() { return [{ type: 'text', text: 'ok' }] }, - })) - } - return ctx -} - -function waitForIdle(ctx: Context, agent: Agent): Promise { - return new Promise((resolve) => { - const dispose = ctx.on('agent/status', (subject, status) => { - if (subject === agent && status === 'idle') { dispose(); resolve() } - }) - }) -} - -/** Every tool result in the agent's log as `{ isError, text }`, in log order. */ -function results(agent: Agent): { isError: boolean; text: string }[] { - return [...agent.session.events] - .filter((event): event is SessionEvent<'tool/result'> => event.type === 'tool/result') - .map(event => event.data.message.content[0]) - .map(result => ({ - isError: result.isError === true, - text: result.content.map(block => block.type === 'text' ? block.text : '').join(''), - })) -} - -/** - * Durable events recording completed `skill` calls, as a RESUMED session's seed: - * the guard's satisfaction check then has nothing but the log to read, with no - * in-memory state from an original run to fall back on. - */ -function priorSkillCalls(calls: { arguments: string; isError?: boolean }[]): SessionEvent[] { - const events: SessionEvent[] = [ - { type: 'turn/start', seq: 0, time: 1, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } }, - { type: 'step/start', seq: 1, time: 2, data: { turn: 1, step: 1 } }, - ] - for (const [index, call] of calls.entries()) { - const callId = CallId(`prior${index}`) - const seq = events.length - events.push({ - type: 'tool/call', - seq, - time: seq + 1, - data: { turn: 1, step: 1, callId, name: 'skill', arguments: call.arguments }, - }) - events.push({ - type: 'tool/result', - seq: seq + 1, - time: seq + 2, - surfaceOp: 'append', - sourceEventSeqs: [seq], - data: { - turn: 1, - step: 1, - message: createToolResultMessage({ - callId, - content: [{ type: 'text', text: 'loaded' }], - isError: call.isError ?? false, - }), - }, - }) - } - const tail = events.length - events.push({ type: 'step/end', seq: tail, time: tail + 1, data: { turn: 1, step: 1 } }) - events.push({ type: 'turn/end', seq: tail + 1, time: tail + 2, data: { turn: 1, reason: { kind: 'completed' } } }) - return events -} - -/** Resume a session from durable seed events and let the model attempt one write at `path`. */ -async function resume(ctx: Context, id: string, seed: SessionEvent[], path: string): Promise { - const adapter = new MockAdapter([ - toolCallResponse(CallId('c0'), 'write', { file_path: path }), - textResponse('done'), - ]) - ctx.llm.registerAdapter(['mock'], adapter) - const { agent } = await ctx.agentLoop.createAgent(ctx, { - sessionId: SessionId(id), - seed, - agentOptions: { provider: 'mock', model: 'mock' }, - }) - agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })) - await waitForIdle(ctx, agent) - return agent -} - -/** Drive one turn whose scripted model output is the given tool calls, then a closing text. */ -async function run( - ctx: Context, - calls: { name: string; args: Record }[], - cwd?: string, -): Promise { - const adapter = new MockAdapter([ - ...calls.map((call, index) => toolCallResponse(CallId(`c${index}`), call.name, call.args)), - textResponse('done'), - ]) - ctx.llm.registerAdapter(['mock'], adapter) - const agent = ctx.agentLoop.create( - SessionId('s1'), - { provider: 'mock', model: 'mock' }, - cwd === undefined ? {} : { cwd }, - ) - agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })) - await waitForIdle(ctx, agent) - return agent -} - -describe('staging protection', () => { - it('denies a write inside the staging worktree and names the path, branch, and skill', async () => { - const paths = await fixture() - const ctx = await harness(paths.stagingFile) - const agent = await run(ctx, [{ name: 'write', args: { file_path: paths.stagingFile } }]) - const [result] = results(agent) - expect(result?.isError).toBe(true) - expect(result?.text).toBe( - `Error: Editing "${paths.stagingFile}" directly is not allowed: it is inside the dsh checkout this session is running from, ` - + 'on branch dsh-staging/20260728T022827Z. Load the dsh-customize skill first and follow it ' - + '— implement in a task worktree, then integrate under the staging lock.', - ) - }) - - it('denies an edit inside the staging worktree', async () => { - const paths = await fixture() - const ctx = await harness(paths.stagingFile) - const agent = await run(ctx, [{ name: 'edit', args: { file_path: paths.stagingFile } }]) - expect(results(agent)[0]?.isError).toBe(true) - }) - - it('allows a read inside the staging worktree, since inspection never violates the skill', async () => { - const paths = await fixture() - const ctx = await harness(paths.stagingFile) - const agent = await run(ctx, [{ name: 'read', args: { file_path: paths.stagingFile } }]) - expect(results(agent)).toEqual([{ isError: false, text: 'ok' }]) - }) - - it('allows a write inside a task worktree nested under the staging tree', async () => { - const paths = await fixture() - const ctx = await harness(paths.stagingFile) - const agent = await run(ctx, [{ name: 'write', args: { file_path: paths.taskFile } }]) - expect(results(agent)).toEqual([{ isError: false, text: 'ok' }]) - }) - - it('allows a write in the plain clone that owns the shared git directory', async () => { - const paths = await fixture() - const ctx = await harness(paths.stagingFile) - const agent = await run(ctx, [{ name: 'write', args: { file_path: paths.masterFile } }]) - expect(results(agent)).toEqual([{ isError: false, text: 'ok' }]) - }) - - it('allows a write on a staging-named branch in an unrelated repository', async () => { - const paths = await fixture() - const ctx = await harness(paths.stagingFile) - const agent = await run(ctx, [{ name: 'write', args: { file_path: paths.outsideFile } }]) - expect(results(agent)).toEqual([{ isError: false, text: 'ok' }]) - }) - - it('allows a write under a detached HEAD, which names no branch to match', async () => { - const paths = await fixture() - const ctx = await harness(paths.stagingFile) - const agent = await run(ctx, [{ name: 'write', args: { file_path: paths.detachedFile } }]) - expect(results(agent)).toEqual([{ isError: false, text: 'ok' }]) - }) - - it('allows a write when the git metadata exists but cannot be read', async () => { - const paths = await fixture() - // A `.git` pointer that stats as a file yet fails to read leaves the guard - // with no branch to judge; failing open beats blocking every edit. - const unreadable = join(paths.root, 'unreadable') - await mkdir(unreadable, { recursive: true }) - await writeFile(join(unreadable, '.git'), `gitdir: ${join(paths.root, 'master', '.git')}\n`) - await chmod(join(unreadable, '.git'), 0o000) - const file = join(unreadable, 'file.ts') - await writeFile(file, 'content\n') - const ctx = await harness(paths.stagingFile) - const agent = await run(ctx, [{ name: 'write', args: { file_path: file } }]) - expect(results(agent)).toEqual([{ isError: false, text: 'ok' }]) - }) - - it('allows a write under no repository at all', async () => { - const paths = await fixture() - const ctx = await harness(paths.stagingFile) - const agent = await run(ctx, [{ name: 'write', args: { file_path: paths.looseFile } }]) - expect(results(agent)).toEqual([{ isError: false, text: 'ok' }]) - }) - - it('arms for nothing when its own location is inside no repository', async () => { - const paths = await fixture() - const ctx = await harness(paths.looseFile) - const agent = await run(ctx, [{ name: 'write', args: { file_path: paths.stagingFile } }]) - expect(results(agent)).toEqual([{ isError: false, text: 'ok' }]) - }) - - it('arms for nothing when the launcher checkout has a detached HEAD', async () => { - const paths = await fixture() - // A detached launcher names no branch, so there is no branch to protect. - const ctx = await harness(paths.detachedFile) - const agent = await run(ctx, [{ name: 'write', args: { file_path: paths.stagingFile } }]) - expect(results(agent)).toEqual([{ isError: false, text: 'ok' }]) - }) - - it('denies when the target and the protected checkout reach one repository through different symlinks', async () => { - const paths = await fixture() - // macOS reaches the temp directory through both `/var/...` and - // `/private/var/...`; a lexical repository comparison would treat the two - // routes as different repositories and fail open on every write. - const link = join(paths.root, 'link') - await symlink(dirname(paths.stagingFile), link, 'dir') - const ctx = await harness(paths.stagingFile) - const agent = await run(ctx, [{ name: 'write', args: { file_path: join(link, 'file.ts') } }]) - expect(results(agent)[0]?.text).toContain('on branch dsh-staging/20260728T022827Z') - }) - - it('denies a RELATIVE target path resolved against the session workspace', async () => { - const paths = await fixture() - const ctx = await harness(paths.stagingFile) - // The filesystem tools resolve a relative `file_path` against the session - // cwd, so judging only absolute paths would leave this as an unguarded - // route to the same file. - const agent = await run(ctx, [{ name: 'write', args: { file_path: 'file.ts' } }], dirname(paths.stagingFile)) - expect(results(agent)[0]?.text).toContain('directly is not allowed') - }) - - it('ignores a relative target path when the session names no workspace', async () => { - const paths = await fixture() - const ctx = await harness(paths.stagingFile) - const agent = await run(ctx, [{ name: 'write', args: { file_path: 'file.ts' } }]) - expect(results(agent)).toEqual([{ isError: false, text: 'ok' }]) - }) - - it('ignores a call whose target path is an empty string', async () => { - const paths = await fixture() - const ctx = await harness(paths.stagingFile) - const agent = await run(ctx, [{ name: 'write', args: { file_path: '' } }]) - expect(results(agent)).toEqual([{ isError: false, text: 'ok' }]) - }) - - it.each([ - ['a non-string file_path', { file_path: 7 }], - ['no file_path at all', { other: 'x' }], - ])('ignores a gated call carrying %s', async (_label, args) => { - const paths = await fixture() - const ctx = await harness(paths.stagingFile) - const agent = await run(ctx, [{ name: 'write', args }]) - expect(results(agent)[0]?.text).not.toContain('directly is not allowed') - }) - - it('ignores a gated call whose arguments are not JSON, which the loop keeps as raw text', async () => { - const paths = await fixture() - const ctx = await harness(paths.stagingFile) - const callId = CallId('raw') - const adapter = new MockAdapter([ - [ - { type: 'block-start', index: 0, blockType: 'tool-call' }, - { type: 'tool-call-delta', index: 0, id: callId, name: 'write', argumentsDelta: 'not json' }, - { type: 'block-end', index: 0, block: { type: 'tool-call', id: callId, name: 'write', arguments: 'not json' } }, - { type: 'finish', reason: { kind: 'tool-calls' } }, - ], - textResponse('done'), - ]) - ctx.llm.registerAdapter(['mock'], adapter) - const agent = ctx.agentLoop.create(SessionId('raw'), { provider: 'mock', model: 'mock' }) - agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } })) - await waitForIdle(ctx, agent) - expect(results(agent)[0]?.text).not.toContain('directly is not allowed') - }) - - it.each([ - ['a `.git` pointer that names no git directory', 'not a gitdir pointer\n'], - ['an empty `.git` pointer', 'gitdir:\n'], - ['a `.git` pointer into a nonexistent git directory', 'gitdir: /nonexistent/worktrees/x\n'], - ])('allows a write behind %s', async (_label, pointer) => { - const paths = await fixture() - const broken = join(paths.root, 'broken') - await mkdir(broken, { recursive: true }) - await writeFile(join(broken, '.git'), pointer) - const file = join(broken, 'file.ts') - await writeFile(file, 'content\n') - const ctx = await harness(paths.stagingFile) - const agent = await run(ctx, [{ name: 'write', args: { file_path: file } }]) - expect(results(agent)).toEqual([{ isError: false, text: 'ok' }]) - }) - - it('denies behind a RELATIVE `.git` pointer, which git resolves against the worktree', async () => { - const paths = await fixture() - // `git worktree add` writes an absolute pointer, but a relocated or - // hand-written one may be relative; git accepts both, so the guard must - // resolve both or it would fail open on a real repository layout. - const relative = join(paths.root, 'relative-pointer') - await mkdir(relative, { recursive: true }) - await writeFile(join(relative, '.git'), 'gitdir: ../master/.git/worktrees/staging-20260728T022827Z\n') - const file = join(relative, 'file.ts') - await writeFile(file, 'content\n') - const ctx = await harness(paths.stagingFile) - const agent = await run(ctx, [{ name: 'write', args: { file_path: file } }]) - expect(results(agent)[0]?.text).toContain('on branch dsh-staging/20260728T022827Z') - }) - - it('allows a write when the worktree resolves but its HEAD is missing', async () => { - const paths = await fixture() - const gitDir = join(paths.root, 'master', '.git', 'worktrees', 'headless') - await mkdir(gitDir, { recursive: true }) - const headless = join(paths.root, 'headless') - await mkdir(headless, { recursive: true }) - await writeFile(join(headless, '.git'), `gitdir: ${gitDir}\n`) - const file = join(headless, 'file.ts') - await writeFile(file, 'content\n') - const ctx = await harness(paths.stagingFile) - const agent = await run(ctx, [{ name: 'write', args: { file_path: file } }]) - expect(results(agent)).toEqual([{ isError: false, text: 'ok' }]) - }) - - it('reuses one resolution for sibling targets in the same directory', async () => { - const paths = await fixture() - const sibling = join(dirname(paths.stagingFile), 'other.ts') - await writeFile(sibling, 'content\n') - const ctx = await harness(paths.stagingFile) - const agent = await run(ctx, [ - { name: 'write', args: { file_path: paths.stagingFile } }, - { name: 'write', args: { file_path: sibling } }, - ]) - expect(results(agent).map(result => result.isError)).toEqual([true, true]) - }) - - it('protects whichever branch the launcher checkout is on, whatever its name', async () => { - const paths = await fixture() - // The protected branch is read from `protectedCheckout`'s own worktree, so - // a checkout on an unconventional branch name is still protected — a - // hardcoded name pattern would have silently guarded nothing. - const ctx = await harness(paths.taskFile) - const agent = await run(ctx, [{ name: 'write', args: { file_path: paths.taskFile } }]) - expect(results(agent)[0]?.text).toContain('on branch task/x') - }) - - it('allows a write in a SIBLING checkout of the same repository on another branch', async () => { - const paths = await fixture() - // A stale staging worktree left by an earlier install shares the - // repository but is not the live deployment, so the workflow rule the - // guard enforces does not apply to it. - const ctx = await harness(paths.siblingFile) - const agent = await run(ctx, [{ name: 'write', args: { file_path: paths.stagingFile } }]) - expect(results(agent)).toEqual([{ isError: false, text: 'ok' }]) - }) - - it('gates only the configured tools', async () => { - const paths = await fixture() - const ctx = await harness(paths.stagingFile, { tools: ['edit'] }) - const agent = await run(ctx, [ - { name: 'write', args: { file_path: paths.stagingFile } }, - { name: 'edit', args: { file_path: paths.stagingFile } }, - ]) - expect(results(agent).map(result => result.isError)).toEqual([false, true]) - }) -}) - -describe('skill satisfaction', () => { - it('allows the write after a successful load of the required skill in the same turn', async () => { - const paths = await fixture() - const ctx = await harness(paths.stagingFile) - const agent = await run(ctx, [ - { name: 'skill', args: { name: 'dsh-customize' } }, - { name: 'write', args: { file_path: paths.stagingFile } }, - ]) - expect(results(agent)).toEqual([ - { isError: false, text: 'ok' }, - { isError: false, text: 'ok' }, - ]) - }) - - it('allows the write when the skill load is only in the REPLAYED log, so resume keeps satisfaction', async () => { - const paths = await fixture() - const ctx = await harness(paths.stagingFile) - const seed = priorSkillCalls([{ arguments: JSON.stringify({ name: 'dsh-customize' }) }]) - const agent = await resume(ctx, 'resumed', seed, paths.stagingFile) - expect(results(agent).at(-1)).toEqual({ isError: false, text: 'ok' }) - }) - - it('does not accept a failed skill load', async () => { - const paths = await fixture() - const ctx = await harness(paths.stagingFile) - const seed = priorSkillCalls([{ arguments: JSON.stringify({ name: 'dsh-customize' }), isError: true }]) - const agent = await resume(ctx, 'failed', seed, paths.stagingFile) - expect(results(agent).at(-1)?.isError).toBe(true) - }) - - it('does not accept a different skill', async () => { - const paths = await fixture() - const ctx = await harness(paths.stagingFile) - const agent = await run(ctx, [ - { name: 'skill', args: { name: 'dsh-upgrade' } }, - { name: 'write', args: { file_path: paths.stagingFile } }, - ]) - expect(results(agent).map(result => result.isError)).toEqual([false, true]) - }) - - it('does not accept a skill call whose arguments are not a JSON object naming a string', async () => { - const paths = await fixture() - const ctx = await harness(paths.stagingFile) - const seed = priorSkillCalls([ - { arguments: 'not json' }, - { arguments: '[]' }, - { arguments: '{"name":7}' }, - { arguments: 'null' }, - ]) - const agent = await resume(ctx, 'malformed', seed, paths.stagingFile) - expect(results(agent).at(-1)?.isError).toBe(true) - }) - - it('honours a configured skill name other than the default', async () => { - const paths = await fixture() - const ctx = await harness(paths.stagingFile, { requiredSkill: 'other-skill' }) - const agent = await run(ctx, [ - { name: 'skill', args: { name: 'other-skill' } }, - { name: 'write', args: { file_path: paths.stagingFile } }, - ]) - expect(results(agent).map(result => result.isError)).toEqual([false, false]) - }) -}) - -describe('non-agent callers', () => { - it('leaves a direct registry call ungated, having no session to replay', async () => { - const paths = await fixture() - const ctx = await harness(paths.stagingFile) - const result = await ctx.tools.execute({ - callId: CallId('direct'), - name: 'write', - arguments: { file_path: paths.stagingFile }, - signal: new AbortController().signal, - }) - expect(result.isError).toBe(false) - }) -}) - -describe('config validation', () => { - it.each([ - ['tools', { tools: [] }, '`tools` must not be empty'], - ['requiredSkill', { requiredSkill: ' ' }, '`requiredSkill` must not be blank'], - ['protectedCheckout', { protectedCheckout: 'relative/path' }, '`protectedCheckout` must be an absolute path'], - ])('rejects an invalid %s at load', async (_field, config, message) => { - const ctx = new Context() - await mountAgentLoopTestDependencies(ctx) - await ctx.plugin(LocalFileSystem, {}) - await expect(ctx.plugin(SourceGuard, config as Config)).rejects.toThrow(message) - }) -}) - -describe('disposal', () => { - it('stops gating once the plugin fiber is disposed', async () => { - const paths = await fixture() - const ctx = new Context() - await mountAgentLoopTestDependencies(ctx) - await ctx.plugin(LocalFileSystem, {}) - await ctx.plugin(AgentLoop, { agents: [] }) - const fiber = await ctx.plugin(SourceGuard, { protectedCheckout: paths.stagingFile }) - for (const name of ['write', 'skill']) { - ctx.tools.register(defineContentToolFixture({ - name, - description: name, - parameters: { file_path: { type: 'string' }, name: { type: 'string' } }, - async execute() { return [{ type: 'text', text: 'ok' }] }, - })) - } - await fiber.dispose() - const agent = await run(ctx, [{ name: 'write', args: { file_path: paths.stagingFile } }]) - expect(results(agent)).toEqual([{ isError: false, text: 'ok' }]) - }) -}) diff --git a/packages/guard/source-guard/tsconfig.json b/packages/guard/source-guard/tsconfig.json deleted file mode 100644 index 133b7fefdf..0000000000 --- a/packages/guard/source-guard/tsconfig.json +++ /dev/null @@ -1,42 +0,0 @@ -{ - "extends": "../../../tsconfig.base.json", - "compilerOptions": { - "rootDir": "src", - "outDir": "lib/types" - }, - "include": [ - "src" - ], - "references": [ - { - "path": "../../../vendor/cosmokit" - }, - { - "path": "../../../vendor/cordis" - }, - { - "path": "../../../vendor/schemastery" - }, - { - "path": "../../core/tools" - }, - { - "path": "../../core/agent" - }, - { - "path": "../../core/session" - }, - { - "path": "../../fs/fs" - }, - { - "path": "../../llm/llm" - }, - { - "path": "../../sandbox/sandbox" - }, - { - "path": "../../support/invariants" - } - ] -} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e9281011c6..8dc9613dc0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -296,9 +296,6 @@ importers: '@deepseek-ai/dsh-skill-local': specifier: workspace:^ version: link:../../packages/skill/skill-local - '@deepseek-ai/dsh-source-guard': - specifier: workspace:^ - version: link:../../packages/guard/source-guard '@deepseek-ai/dsh-spill-local': specifier: workspace:^ version: link:../../packages/spill/spill-local @@ -335,9 +332,6 @@ importers: '@deepseek-ai/dsh-timeout-policy': specifier: workspace:^ version: link:../../packages/timeout/timeout-policy - '@deepseek-ai/dsh-tmux-context': - specifier: workspace:^ - version: link:../../packages/context/tmux-context '@deepseek-ai/dsh-token-meter': specifier: workspace:^ version: link:../../packages/llm/token-meter @@ -613,9 +607,6 @@ importers: '@deepseek-ai/dsh-skill-local': specifier: workspace:* version: link:../packages/skill/skill-local - '@deepseek-ai/dsh-source-guard': - specifier: workspace:* - version: link:../packages/guard/source-guard '@deepseek-ai/dsh-spill-local': specifier: workspace:* version: link:../packages/spill/spill-local @@ -652,9 +643,6 @@ importers: '@deepseek-ai/dsh-timeout-policy': specifier: workspace:* version: link:../packages/timeout/timeout-policy - '@deepseek-ai/dsh-tmux-context': - specifier: workspace:* - version: link:../packages/context/tmux-context '@deepseek-ai/dsh-token-meter': specifier: workspace:* version: link:../packages/llm/token-meter @@ -1963,37 +1951,6 @@ importers: specifier: ^4.0.0-rc.7 version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) - packages/context/tmux-context: - dependencies: - schemastery: - specifier: ^3.18.0 - version: 3.18.0 - devDependencies: - '@deepseek-ai/dsh-agent': - specifier: workspace:^ - version: link:../../core/agent - '@deepseek-ai/dsh-bash': - specifier: workspace:^ - version: link:../../bash/bash - '@deepseek-ai/dsh-invariants': - specifier: workspace:^ - version: link:../../support/invariants - '@deepseek-ai/dsh-llm': - specifier: workspace:^ - version: link:../../llm/llm - '@deepseek-ai/dsh-loader-smoke': - specifier: workspace:^ - version: link:../../support/loader-smoke - '@deepseek-ai/dsh-session': - specifier: workspace:^ - version: link:../../core/session - '@deepseek-ai/dsh-system-prompt': - specifier: workspace:^ - version: link:../../core/system-prompt - cordis: - specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) - packages/context/workspace-context: dependencies: schemastery: @@ -2784,49 +2741,6 @@ importers: specifier: ^4.0.0-rc.7 version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) - packages/guard/source-guard: - dependencies: - schemastery: - specifier: ^3.18.0 - version: 3.18.0 - devDependencies: - '@deepseek-ai/dsh-agent': - specifier: workspace:^ - version: link:../../core/agent - '@deepseek-ai/dsh-agent-loop': - specifier: workspace:^ - version: link:../../core/agent-loop - '@deepseek-ai/dsh-agent-loop-testkit': - specifier: workspace:^ - version: link:../../support/agent-loop-testkit - '@deepseek-ai/dsh-fs': - specifier: workspace:^ - version: link:../../fs/fs - '@deepseek-ai/dsh-fs-local': - specifier: workspace:^ - version: link:../../fs/fs-local - '@deepseek-ai/dsh-invariants': - specifier: workspace:^ - version: link:../../support/invariants - '@deepseek-ai/dsh-llm': - specifier: workspace:^ - version: link:../../llm/llm - '@deepseek-ai/dsh-loader-smoke': - specifier: workspace:^ - version: link:../../support/loader-smoke - '@deepseek-ai/dsh-sandbox': - specifier: workspace:^ - version: link:../../sandbox/sandbox - '@deepseek-ai/dsh-session': - specifier: workspace:^ - version: link:../../core/session - '@deepseek-ai/dsh-tools': - specifier: workspace:^ - version: link:../../core/tools - cordis: - specifier: ^4.0.0-rc.7 - version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) - packages/hooks/hook-protocol: devDependencies: '@deepseek-ai/dsh-bash': diff --git a/tsconfig.host.json b/tsconfig.host.json index 51ed1cdc3b..b0121f2a0a 100644 --- a/tsconfig.host.json +++ b/tsconfig.host.json @@ -86,7 +86,6 @@ { "path": "./packages/goal/goal-session" }, { "path": "./packages/goal/command-goal" }, { "path": "./packages/context/time-context" }, - { "path": "./packages/context/tmux-context" }, { "path": "./packages/context/session-reference" }, { "path": "./packages/ui/user-interaction" }, { "path": "./packages/ui/user-approval" }, @@ -165,7 +164,6 @@ { "path": "./packages/todo/tool-todo" }, { "path": "./packages/plan/plan-mode" }, { "path": "./packages/guard/repeat-tool-guard" }, - { "path": "./packages/guard/source-guard" }, { "path": "./packages/cordis/tool-cordis" }, { "path": "./packages/hooks/hook-protocol" }, { "path": "./packages/hooks/hooks-claude" }, From 9e3383f727751803d176a2857965ac0253219e7f Mon Sep 17 00:00:00 2001 From: Turtle Date: Wed, 29 Jul 2026 15:44:55 +0800 Subject: [PATCH 022/113] docs: regenerate catalogs after feature exclusions --- docs/config-catalog.md | 81 ++++++++++----------------------- docs/event-producer-consumer.md | 8 ++-- 2 files changed, 27 insertions(+), 62 deletions(-) diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 3c0543e4ca..870c00e051 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -108,7 +108,7 @@ export interface Config { Depends on: [`AgentOptions`](core-data-structures/core.md) · [`SessionId`](core-data-structures/core.md) -Source: [`packages/core/agent-loop/src/index.ts:155`](../packages/core/agent-loop/src/index.ts) +Source: [`packages/core/agent-loop/src/index.ts:211`](../packages/core/agent-loop/src/index.ts) ## `@deepseek-ai/dsh-agent-spine-demo` @@ -1153,6 +1153,26 @@ export interface Config { Source: [`packages/context/session-reference/src/config.ts:11`](../packages/context/session-reference/src/config.ts) +## `@deepseek-ai/dsh-session-registry-file` + +```ts config-catalog +/** + * Plugin config as callers write it: `root` is required — a cwd fallback would + * scatter registries — while the lock tunables are optional because + * `static Config` supplies their defaults. + */ +export interface Config { + /** Directory holding the registry file; created `0o700` on demand. */ + root: string + /** Milliseconds after which a held lock is considered abandoned and reclaimed. */ + lockStaleMs?: number + /** Retries before a contended acquisition fails loud. */ + lockRetries?: number +} +``` + +Source: [`packages/session-registry/session-registry-file/src/index.ts:43`](../packages/session-registry/session-registry-file/src/index.ts) + ## `@deepseek-ai/dsh-session-telemetry-otel` Requires: `sessions` @@ -1939,63 +1959,6 @@ export interface TuiThemeConfig { Source: [`packages/ui/tui/src/config.ts:117`](../packages/ui/tui/src/config.ts) -## `@deepseek-ai/dsh-tui-demo` - -```ts config-catalog -/** App config routed to the spine, TUI, configured agent, and JSONL backend. */ -export interface Config { - /** Provider route for the `main` agent. */ - provider: string - /** Model name for the `main` agent; a matching adapter must be registered. */ - model: string - /** Bundled agent-loop concurrency cap; `1` is serial and omission uses its default. */ - maxParallelToolCalls?: number - /** Deployment persona forwarded to the system-prompt plugin. */ - persona?: string - /** Explicit model-facing tool order forwarded to the system-prompt plugin. */ - toolOrder?: string[] - /** Tool-registry presentation config forwarded through agent-spine-demo. */ - tools?: ToolsConfig - /** DeepSeek Harness home directory exposed to bash and used for local skill discovery. */ - dshHome?: string - /** Fallback session-title limits forwarded through agent-spine-demo. */ - sessionTitle?: NonNullable - /** Directory for JSONL sessions and the derived query index. Defaults to `./.sessions`. */ - persistenceRoot?: string - /** JSONL artifact encoding; defaults to checksummed Zstandard frames. */ - persistenceCompression?: JsonlCompression - /** Cross-session reference discovery and snapshot byte budgets. */ - sessionReferences?: SessionReferenceConfig - /** 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 the session, e.g. - * `dsh --resume {session}`. - */ - resumeCommand?: string - /** Full-screen TUI presentation settings. */ - ui?: uiTui.TuiConfig - /** Skill registry, local-provider, and model-facing consumer config. */ - skills?: agentCore.SkillConfig - /** Model-facing bash tool config forwarded through agent-spine-demo. */ - toolBash?: NonNullable - /** Generic background-task controls forwarded through agent-spine-demo; set false to omit them. */ - toolTasks?: NonNullable - /** Persisted same-session goals; owner defaults enable them, or false disables the stack and command. */ - goals?: agentCore.GoalConfig | false - /** Persisted session id to resume instead of creating a fresh session. */ - resumeSessionId?: string - /** Controls automatic AGENTS.md/CLAUDE.md loading; configure a byte budget or set `false`. */ - workspaceContext: agentCore.Config['workspaceContext'] -} -``` - -Depends on: [`agentCore`](../packages/examples/agent-spine-demo/src/index.ts) · [`JsonlCompression`](../packages/session-persistence/session-persistence-jsonl/src/index.ts) · [`SessionReferenceConfig`](#deepseek-aidsh-session-reference) · [`ToolsConfig`](#deepseek-aidsh-tools) · [`uiTui`](../packages/ui/tui/src/index.ts) - -Source: [`packages/examples/tui-demo/src/index.ts:39`](../packages/examples/tui-demo/src/index.ts) - ## `@deepseek-ai/dsh-user-approval` ```ts config-catalog @@ -2231,6 +2194,7 @@ These load from a `cordis.yml` entry with no `config:` block; they declare no co - `@deepseek-ai/dsh-session` ([`packages/core/session/src/index.ts`](../packages/core/session/src/index.ts)) - `@deepseek-ai/dsh-session-checkpoint-policy` — requires `llm` · `sessionPersistence` · `sessions` · `tools` ([`packages/session-persistence/session-checkpoint-policy/src/index.ts`](../packages/session-persistence/session-checkpoint-policy/src/index.ts)) - `@deepseek-ai/dsh-session-projection` ([`packages/session-projection/session-projection/src/index.ts`](../packages/session-projection/session-projection/src/index.ts)) +- `@deepseek-ai/dsh-session-registry-live` — requires `sessions` · `sessionRegistry` ([`packages/session-registry/session-registry-live/src/index.ts`](../packages/session-registry/session-registry-live/src/index.ts)) - `@deepseek-ai/dsh-storage` ([`packages/storage/storage/src/index.ts`](../packages/storage/storage/src/index.ts)) - `@deepseek-ai/dsh-subagent` ([`packages/subagent/subagent/src/index.ts`](../packages/subagent/subagent/src/index.ts)) - `@deepseek-ai/dsh-subprocess-local` ([`packages/subprocess/subprocess-local/src/index.ts`](../packages/subprocess/subprocess-local/src/index.ts)) @@ -2253,6 +2217,7 @@ Abstract service classes — a deployment loads a concrete implementation packag - `@deepseek-ai/dsh-sandbox` — abstract `SandboxProvider` ([`packages/sandbox/sandbox/src/index.ts`](../packages/sandbox/sandbox/src/index.ts)) - `@deepseek-ai/dsh-session-persistence` — abstract `SessionPersistence` ([`packages/session-persistence/session-persistence/src/index.ts`](../packages/session-persistence/session-persistence/src/index.ts)) - `@deepseek-ai/dsh-session-query` — abstract `SessionQueryService` ([`packages/session-query/session-query/src/index.ts`](../packages/session-query/session-query/src/index.ts)) +- `@deepseek-ai/dsh-session-registry` — abstract `SessionRegistry` ([`packages/session-registry/session-registry/src/index.ts`](../packages/session-registry/session-registry/src/index.ts)) - `@deepseek-ai/dsh-spill` — abstract `SpillStore` ([`packages/spill/spill/src/index.ts`](../packages/spill/spill/src/index.ts)) - `@deepseek-ai/dsh-subprocess` — abstract `SubprocessService` ([`packages/subprocess/subprocess/src/index.ts`](../packages/subprocess/subprocess/src/index.ts)) - `@deepseek-ai/dsh-tasks` — abstract `TaskService` ([`packages/tasks/tasks/src/index.ts`](../packages/tasks/tasks/src/index.ts)) diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index 71238305e5..13448e2c5b 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -7,7 +7,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac | Event | Mode | Declared in | Dispatchers | Listeners | | --- | --- | --- | --- | --- | -| `agent-loop/config-start-failed` | `emit` | [`packages/core/agent-loop/src/index.ts:148`](../packages/core/agent-loop/src/index.ts) | [`agent-loop`](../packages/core/agent-loop) (`events.dispatch`) | [`tui`](../packages/ui/tui) | +| `agent-loop/config-start-failed` | `emit` | [`packages/core/agent-loop/src/index.ts:157`](../packages/core/agent-loop/src/index.ts) | [`agent-loop`](../packages/core/agent-loop) (`events.dispatch`) | [`tui`](../packages/ui/tui) | | `agent/cancel-requested` | `emit` | [`packages/core/agent/src/types.ts:286`](../packages/core/agent/src/types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emitAgentEvent`) | [`goal-session`](../packages/goal/goal-session) | | `agent/created` | `emit` | [`packages/core/agent/src/types.ts:218`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`goal-session`](../packages/goal/goal-session), [`tui`](../packages/ui/tui) | | `agent/disposed` | `emit` | [`packages/core/agent/src/types.ts:227`](../packages/core/agent/src/types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), [`goal-session`](../packages/goal/goal-session), [`tui`](../packages/ui/tui) | @@ -31,9 +31,9 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `fs/write-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:54`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`) | [`fs-policy`](../packages/fs/fs-policy) | | `goal/changed` | `emit` | [`packages/goal/goal/src/domain.ts:135`](../packages/goal/goal/src/domain.ts) | [`goal`](../packages/goal/goal) (`emit`) | [`goal-session`](../packages/goal/goal-session) | | `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:58`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`agent-loop`](../packages/core/agent-loop), [`llm`](../packages/llm/llm), [`llm-replay`](../packages/support/llm-replay), [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy), [`session-title`](../packages/session-title/session-title) | -| `session/created` | `emit` | [`packages/core/session/src/index.ts:71`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | `apiproxy`, [`compact`](../packages/compact/compact), [`goal`](../packages/goal/goal), [`hook-protocol`](../packages/hooks/hook-protocol), [`jsonrpc`](../packages/ui/jsonrpc), [`llm-retry`](../packages/llm/llm-retry), [`plan-mode`](../packages/plan/plan-mode), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-telemetry`](../packages/telemetry/session-telemetry), [`tools`](../packages/core/tools), [`user-approval`](../packages/ui/user-approval) | -| `session/disposed` | `emit` | [`packages/core/session/src/index.ts:81`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `apiproxy`, [`session-persistence`](../packages/session-persistence/session-persistence), [`session-projection-cache`](../packages/session-projection/session-projection-cache), [`session-telemetry`](../packages/telemetry/session-telemetry), [`session-title`](../packages/session-title/session-title) | -| `session/event` | `emit` | [`packages/core/session/src/index.ts:93`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/acp/acp), `apiproxy`, [`cli-demo`](../packages/examples/cli-demo), [`compact`](../packages/compact/compact), [`compact-basic`](../packages/compact/compact-basic), [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hook-protocol`](../packages/hooks/hook-protocol), [`jsonrpc`](../packages/ui/jsonrpc), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-projection`](../packages/session-projection/session-projection), [`session-projection-cache`](../packages/session-projection/session-projection-cache), [`session-telemetry`](../packages/telemetry/session-telemetry), [`session-title`](../packages/session-title/session-title), [`token-meter`](../packages/llm/token-meter), [`tools`](../packages/core/tools), [`tui`](../packages/ui/tui), [`user-approval`](../packages/ui/user-approval), [`workspace-context`](../packages/context/workspace-context) | +| `session/created` | `emit` | [`packages/core/session/src/index.ts:71`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | `apiproxy`, [`compact`](../packages/compact/compact), [`goal`](../packages/goal/goal), [`hook-protocol`](../packages/hooks/hook-protocol), [`jsonrpc`](../packages/ui/jsonrpc), [`llm-retry`](../packages/llm/llm-retry), [`plan-mode`](../packages/plan/plan-mode), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-registry-live`](../packages/session-registry/session-registry-live), [`session-telemetry`](../packages/telemetry/session-telemetry), [`tools`](../packages/core/tools), [`user-approval`](../packages/ui/user-approval) | +| `session/disposed` | `emit` | [`packages/core/session/src/index.ts:81`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `apiproxy`, [`session-persistence`](../packages/session-persistence/session-persistence), [`session-projection-cache`](../packages/session-projection/session-projection-cache), [`session-registry-live`](../packages/session-registry/session-registry-live), [`session-telemetry`](../packages/telemetry/session-telemetry), [`session-title`](../packages/session-title/session-title) | +| `session/event` | `emit` | [`packages/core/session/src/index.ts:93`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/acp/acp), `apiproxy`, [`cli-demo`](../packages/examples/cli-demo), [`compact`](../packages/compact/compact), [`compact-basic`](../packages/compact/compact-basic), [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hook-protocol`](../packages/hooks/hook-protocol), [`jsonrpc`](../packages/ui/jsonrpc), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-projection`](../packages/session-projection/session-projection), [`session-projection-cache`](../packages/session-projection/session-projection-cache), [`session-registry-live`](../packages/session-registry/session-registry-live), [`session-telemetry`](../packages/telemetry/session-telemetry), [`session-title`](../packages/session-title/session-title), [`token-meter`](../packages/llm/token-meter), [`tools`](../packages/core/tools), [`tui`](../packages/ui/tui), [`user-approval`](../packages/ui/user-approval), [`workspace-context`](../packages/context/workspace-context) | | `session/flush` | `parallel` | [`packages/core/session/src/index.ts:103`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`session-persistence`](../packages/session-persistence/session-persistence), [`session-telemetry`](../packages/telemetry/session-telemetry) | | `slash/input-begin-command` | `bail` | [`packages/client/ui-slash/src/types.ts:230`](../packages/client/ui-slash/src/types.ts) | - | `ui-conversation` | | `slash/input-consume-token` | `bail` | [`packages/client/ui-slash/src/types.ts:244`](../packages/client/ui-slash/src/types.ts) | - | `ui-conversation` | From 991c1f14b639d06ee29cb08672457ae7d9301d8a Mon Sep 17 00:00:00 2001 From: Turtle Date: Wed, 29 Jul 2026 15:46:30 +0800 Subject: [PATCH 023/113] docs: remove retired TUI demo graph row --- docs/module-graph.md | 1 - 1 file changed, 1 deletion(-) diff --git a/docs/module-graph.md b/docs/module-graph.md index d3e60978e7..f20dd1c5fd 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -1127,6 +1127,5 @@ flowchart TD | [`jsonrpc`](../packages/ui/jsonrpc) | `ui` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`llm-deepseek`](../packages/llm/llm-deepseek), [`scope`](../packages/core/scope), [`sdk-protocol`](../packages/sdk/sdk-protocol), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) | | [`acp-demo`](../packages/examples/acp-demo) | `examples` | [`acp`](../packages/acp/acp), [`agent-spine-demo`](../packages/examples/agent-spine-demo), [`app-boot`](../packages/ui/app-boot), [`invariants`](../packages/support/invariants), [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy), [`session-persistence-jsonl`](../packages/session-persistence/session-persistence-jsonl), [`session-query`](../packages/session-query/session-query), [`session-query-sqlite`](../packages/session-query/session-query-sqlite), [`tools`](../packages/core/tools), [`workspace-context`](../packages/context/workspace-context) | | [`cli-demo`](../packages/examples/cli-demo) | `examples` | [`agent`](../packages/core/agent), [`agent-spine-demo`](../packages/examples/agent-spine-demo), [`app-boot`](../packages/ui/app-boot), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy), [`session-persistence-jsonl`](../packages/session-persistence/session-persistence-jsonl), [`tools`](../packages/core/tools), [`workspace-context`](../packages/context/workspace-context) | -| [`tui-demo`](../packages/examples/tui-demo) | `examples` | [`agent`](../packages/core/agent), [`agent-loop`](../packages/core/agent-loop), [`agent-spine-demo`](../packages/examples/agent-spine-demo), [`command-goal`](../packages/goal/command-goal), [`commands`](../packages/ui/commands), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy), [`session-persistence-jsonl`](../packages/session-persistence/session-persistence-jsonl), [`session-query`](../packages/session-query/session-query), [`session-query-sqlite`](../packages/session-query/session-query-sqlite), [`session-reference`](../packages/context/session-reference), [`tool-ask-user`](../packages/ui/tool-ask-user), [`tools`](../packages/core/tools), [`tui`](../packages/ui/tui), [`user-interaction`](../packages/ui/user-interaction), [`workspace-context`](../packages/context/workspace-context) | | [`sdk-client`](../packages/sdk/sdk-client) | `sdk` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sdk-protocol`](../packages/sdk/sdk-protocol), [`session`](../packages/core/session) | | [`subagent-dsh-sdk`](../packages/subagent/subagent-dsh-sdk) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sdk-client`](../packages/sdk/sdk-client), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subprocess`](../packages/subprocess/subprocess) | From ea9315841fca10430e940e74ea167ec88d9e49bc Mon Sep 17 00:00:00 2001 From: Turtle Date: Wed, 29 Jul 2026 16:28:17 +0800 Subject: [PATCH 024/113] refactor(session): exclude live-session registry foundation --- ...026-07-28-cross-workspace-resume.i18n.yaml | 4 +- .../2026-07-28-cross-workspace-resume.md | 1 - .../2026-07-28-cross-workspace-resume.zh.md | 1 - ...live-session-registry-and-dsh-ls.i18n.yaml | 6 - ...-07-28-live-session-registry-and-dsh-ls.md | 65 --- ...-28-live-session-registry-and-dsh-ls.zh.md | 65 --- apps/cli/base.cordis.yml | 2 +- docs/capability-seams.md | 8 - docs/config-catalog.md | 22 - docs/cordis-catalog/services.md | 38 -- docs/event-producer-consumer.md | 6 +- knip.json | 1 - .../cordis/tool-cordis/src/api-catalog.ts | 22 - packages/session-registry/README.i18n.yaml | 6 - packages/session-registry/README.md | 15 - packages/session-registry/README.zh.md | 15 - .../session-registry-file/README.i18n.yaml | 6 - .../session-registry-file/README.md | 42 -- .../session-registry-file/README.zh.md | 42 -- .../session-registry-file/package.json | 47 --- .../session-registry-file/src/file.ts | 97 ----- .../session-registry-file/src/index.ts | 233 ----------- .../session-registry-file/src/invariant.ts | 32 -- .../session-registry-file/src/liveness.ts | 30 -- .../tests/fixtures/register-once.ts | 27 -- .../tests/session-registry-file.spec.ts | 382 ------------------ .../session-registry-file/tsconfig.json | 21 - .../session-registry-live/README.i18n.yaml | 6 - .../session-registry-live/README.md | 32 -- .../session-registry-live/README.zh.md | 32 -- .../session-registry-live/package.json | 44 -- .../session-registry-live/src/index.ts | 84 ---- .../session-registry-live/src/invariant.ts | 31 -- .../tests/session-registry-live.spec.ts | 227 ----------- .../session-registry-live/tsconfig.json | 24 -- .../session-registry/README.i18n.yaml | 6 - .../session-registry/README.md | 30 -- .../session-registry/README.zh.md | 30 -- .../session-registry/package.json | 41 -- .../session-registry/src/index.ts | 82 ---- .../session-registry/src/invariant.ts | 58 --- .../session-registry/src/types.ts | 55 --- .../session-registry/tests/invariant.spec.ts | 98 ----- .../session-registry/tsconfig.json | 21 - pnpm-lock.yaml | 96 ----- scripts/gen-cordis-catalog.ts | 2 - scripts/gen-doc-graphs.ts | 9 - .../verify-package-readme-model-experience.ts | 3 - tsconfig.base.json | 2 - tsconfig.host.json | 3 - 50 files changed, 6 insertions(+), 2246 deletions(-) delete mode 100644 .agents/notes/implemented/feature/2026-07-28-live-session-registry-and-dsh-ls.i18n.yaml delete mode 100644 .agents/notes/implemented/feature/2026-07-28-live-session-registry-and-dsh-ls.md delete mode 100644 .agents/notes/implemented/feature/2026-07-28-live-session-registry-and-dsh-ls.zh.md delete mode 100644 packages/session-registry/README.i18n.yaml delete mode 100644 packages/session-registry/README.md delete mode 100644 packages/session-registry/README.zh.md delete mode 100644 packages/session-registry/session-registry-file/README.i18n.yaml delete mode 100644 packages/session-registry/session-registry-file/README.md delete mode 100644 packages/session-registry/session-registry-file/README.zh.md delete mode 100644 packages/session-registry/session-registry-file/package.json delete mode 100644 packages/session-registry/session-registry-file/src/file.ts delete mode 100644 packages/session-registry/session-registry-file/src/index.ts delete mode 100644 packages/session-registry/session-registry-file/src/invariant.ts delete mode 100644 packages/session-registry/session-registry-file/src/liveness.ts delete mode 100644 packages/session-registry/session-registry-file/tests/fixtures/register-once.ts delete mode 100644 packages/session-registry/session-registry-file/tests/session-registry-file.spec.ts delete mode 100644 packages/session-registry/session-registry-file/tsconfig.json delete mode 100644 packages/session-registry/session-registry-live/README.i18n.yaml delete mode 100644 packages/session-registry/session-registry-live/README.md delete mode 100644 packages/session-registry/session-registry-live/README.zh.md delete mode 100644 packages/session-registry/session-registry-live/package.json delete mode 100644 packages/session-registry/session-registry-live/src/index.ts delete mode 100644 packages/session-registry/session-registry-live/src/invariant.ts delete mode 100644 packages/session-registry/session-registry-live/tests/session-registry-live.spec.ts delete mode 100644 packages/session-registry/session-registry-live/tsconfig.json delete mode 100644 packages/session-registry/session-registry/README.i18n.yaml delete mode 100644 packages/session-registry/session-registry/README.md delete mode 100644 packages/session-registry/session-registry/README.zh.md delete mode 100644 packages/session-registry/session-registry/package.json delete mode 100644 packages/session-registry/session-registry/src/index.ts delete mode 100644 packages/session-registry/session-registry/src/invariant.ts delete mode 100644 packages/session-registry/session-registry/src/types.ts delete mode 100644 packages/session-registry/session-registry/tests/invariant.spec.ts delete mode 100644 packages/session-registry/session-registry/tsconfig.json diff --git a/.agents/notes/implemented/feature/2026-07-28-cross-workspace-resume.i18n.yaml b/.agents/notes/implemented/feature/2026-07-28-cross-workspace-resume.i18n.yaml index 848b39e600..cd42eaf755 100644 --- a/.agents/notes/implemented/feature/2026-07-28-cross-workspace-resume.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-28-cross-workspace-resume.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-28-cross-workspace-resume.md -2026-07-28-cross-workspace-resume.md: d559b73a5ba0f8136d20ef6dcf7c62989d1527e9 -2026-07-28-cross-workspace-resume.zh.md: 404b81cbc07a455e5553a9c227d663e491456c9e +2026-07-28-cross-workspace-resume.md: be455496346b9242585c1aace18f4f55cba905c0 +2026-07-28-cross-workspace-resume.zh.md: 0384cf204e51e4086b05c75e691f59d6b60a7d11 diff --git a/.agents/notes/implemented/feature/2026-07-28-cross-workspace-resume.md b/.agents/notes/implemented/feature/2026-07-28-cross-workspace-resume.md index d559b73a5b..be45549634 100644 --- a/.agents/notes/implemented/feature/2026-07-28-cross-workspace-resume.md +++ b/.agents/notes/implemented/feature/2026-07-28-cross-workspace-resume.md @@ -43,7 +43,6 @@ The shared base states that precedence in the row itself: `apps/cli/base.cordis. ## Consequences - Sessions already stored under a project-local `./.sessions` disappear from `/resume`. This is the accepted cost of no migration. -- One shared root makes the pre-existing absence of a cross-process session lock reachable in one step: colliding used to require two terminals in the same directory, and is now one Tab away. `record.live` comes from the in-process `SessionQueryService`, so preflight rejects only sessions live in *this* runtime, while the JSONL backend takes no lock and two processes appending one log with independent `seq` counters would interleave. Closing this is no longer speculative hardening: `SessionRegistry.list()` already publishes live sessions cross-process under the same Harness home for `dsh list-sessions`, so consulting it in `summarizeResumeCandidate` is a small follow-up. It stays out of this change as pre-existing scope. - A resumed session can change the process's working directory, so a foreign resume is not a pure transcript restoration — every path-resolving tool moves with it. - The Harness home now holds session logs for every project on the machine. Its growth is no longer bounded by one checkout, and no retention policy is introduced here. diff --git a/.agents/notes/implemented/feature/2026-07-28-cross-workspace-resume.zh.md b/.agents/notes/implemented/feature/2026-07-28-cross-workspace-resume.zh.md index 404b81cbc0..0384cf204e 100644 --- a/.agents/notes/implemented/feature/2026-07-28-cross-workspace-resume.zh.md +++ b/.agents/notes/implemented/feature/2026-07-28-cross-workspace-resume.zh.md @@ -43,7 +43,6 @@ dsh 启动器通过启动槽位提供其 Harness home 下的同一个会话根 ## Consequences - 已经存放在项目本地 `./.sessions` 下的会话会从 `/resume` 中消失。这是不做迁移所接受的代价。 -- 同一个共享根目录让原本就缺失的跨进程会话锁一步之内即可触达:过去要造成冲突需要在同一个目录里开两个终端,如今只差一次 Tab。`record.live` 来自进程内的 `SessionQueryService`,因此预检只会拒绝在*本*运行时中处于活跃状态的会话,而 JSONL 后端不加任何锁,两个进程用各自独立的 `seq` 计数器追加同一份日志会互相交错。解决这一点已不再是投机性加固:`SessionRegistry.list()` 已经为 `dsh list-sessions` 在同一个 Harness home 下跨进程发布活跃会话,因此在 `summarizeResumeCandidate` 中查询它是一项小的后续工作。它作为既有范围之外的问题不纳入本次改动。 - 恢复一个会话可以改变进程的工作目录,因此恢复外部会话不是单纯的 transcript 还原——每个解析路径的工具都会随之移动。 - Harness home 现在保存着这台机器上每个项目的会话日志。它的增长不再受单个 checkout 约束,而本记录也没有引入任何保留策略。 diff --git a/.agents/notes/implemented/feature/2026-07-28-live-session-registry-and-dsh-ls.i18n.yaml b/.agents/notes/implemented/feature/2026-07-28-live-session-registry-and-dsh-ls.i18n.yaml deleted file mode 100644 index eb4893717e..0000000000 --- a/.agents/notes/implemented/feature/2026-07-28-live-session-registry-and-dsh-ls.i18n.yaml +++ /dev/null @@ -1,6 +0,0 @@ -# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each -# side as of the last confirmed-consistent state. Both languages carry equal authority; -# after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-28-live-session-registry-and-dsh-ls.md -2026-07-28-live-session-registry-and-dsh-ls.md: 02343c83ccee7b67e3b3e4c72de842415d4a9f6e -2026-07-28-live-session-registry-and-dsh-ls.zh.md: 722ac45f2eb07256f196d2828b8969ae9f4965b8 diff --git a/.agents/notes/implemented/feature/2026-07-28-live-session-registry-and-dsh-ls.md b/.agents/notes/implemented/feature/2026-07-28-live-session-registry-and-dsh-ls.md deleted file mode 100644 index 02343c83cc..0000000000 --- a/.agents/notes/implemented/feature/2026-07-28-live-session-registry-and-dsh-ls.md +++ /dev/null @@ -1,65 +0,0 @@ -# Agent Note: live-session registry and `dsh list-sessions` - -Status: implemented - -English | [中文](2026-07-28-live-session-registry-and-dsh-ls.zh.md) - -## Problem - -Nothing could answer "which dsh sessions am I running right now". A user with sessions across several projects had no way to enumerate them, and no way to recover the id needed for `--resume` except the exit line of the terminal that printed it. Session persistence records every session that ever existed, so it cannot answer the question: it has no notion of liveness, and no `process.pid` appeared anywhere in the session, persistence, or storage packages. - -## Decision - -`dsh list-sessions` (alias `dsh ps`) lists the sessions running right now — session id, pid, uptime, workspace, title — newest first, across every workspace, with `--json` for machines. Three packages back it, as a capability seam. - -[`dsh-session-registry`](../../../../packages/session-registry/session-registry/README.md) (`ctx.sessionRegistry`) is the seam: the abstract service contract and record vocabulary, so the medium can later move to a database without touching consumers. [`dsh-session-registry-file`](../../../../packages/session-registry/session-registry-file/README.md) implements it over one lock-guarded JSON file under the Harness home. [`dsh-session-registry-live`](../../../../packages/session-registry/session-registry-live/README.md) follows `session/created`, `session/disposed`, and `session/title` and keeps the registry in step. `apps/cli` mounts both on every launcher surface — the TUI, `dsh meta`, headless, and web — and `dsh list-sessions` mounts only the service, booting no agent tree. No surface label is recorded: a launcher's mode is not a property of the session, and the workspace column already distinguishes a `dsh meta` session from a project one. - -### Liveness is derived, never stored - -`list()` probes each record's pid with `kill(pid, 0)` and drops the dead ones, writing the pruned result back. A process killed without running its disposer leaves a record that the next read removes, so there is no daemon, no heartbeat, and no permanent phantom. A per-process `bootId` distinguishes a recycled pid, so deregistration cannot delete a namesake record from a different incarnation. `EPERM` counts as alive: a live session owned by another user must not be dropped. - -### Two independent concurrency layers - -The file is written by every dsh process and by several sessions inside one process, and the two cases need different mechanisms. - -Across processes, each read-modify-write holds a [`proper-lockfile`](https://github.com/moxystudio/node-proper-lockfile) advisory lock. Within one process, calls queue on an internal chain, because the advisory lock is tracked per process: overlapping same-process callers contend for its bounded retry budget rather than queueing, and past roughly a dozen concurrent calls that budget runs out and a registration rejects. Since publication is fire-and-forget, such a rejection silently drops a live session from the listing — the exact "listing that lies" failure this feature exists to avoid. Both layers are load-bearing and each is pinned by a test that fails without it. - -### Records carry their own title - -The title is the one mutable field, replaced through `retitle` as `session/title` events arrive. It lives in the record rather than being read from the session log because the log's location, format, and compression are per-deployment backend choices: the TUI writes project-local zstd-compressed JSONL, the web and headless surfaces write to a global root, a user profile overrides either, and SQLite has no per-session file at all. An independent reader cannot portably parse that, so `dsh list-sessions` opens no log and assumes no backend. - -### Subagents are invisible by construction - -Only top-level launcher surfaces mount the publisher. In-process subagents (`spawn`, `fork`) have no process of their own, and the out-of-process backends spawn `dsh-jsonrpc-agent` rather than this CLI. No filter flag is needed, and no subagent package changed. - -## Alternatives considered - -**One file per session under `~/.dsh/run/`.** No lock at all, since each process only writes and deletes its own file. Rejected in favour of the single file the user chose, which then made a real advisory lock mandatory rather than optional. - -**A domain over the `storage-json` backend.** The obvious reuse, and wrong: that backend documents "no cross-process write locking … last write wins" and names single-host-process as its assumption, and the [domain KV storage note](../../proposed/architecture/2026-07-24-domain-kv-storage-and-workspace.md) puts multi-process explicitly out of scope. A registry written concurrently by every launcher is precisely that excluded case. Widening the backend's contract would have changed a shipped guarantee for one consumer; a separate package owns the multi-process medium instead. - -**Hand-rolled `O_EXCL` lock directory.** Rejected under the [dependencies-over-hand-rolling policy](../process/2026-07-26-dependencies-over-hand-rolling.md): stale-lock detection, retry backoff, and compromise handling are exactly the surface a maintained dependency should own. - -**Accept last-write-wins on the single file.** Cheapest to build, and it silently omits real running sessions when two start close together. A listing tool that lies is worse than no listing tool. - -**Read the title from the session log in `dsh list-sessions`.** Implemented first, then verified live: the shipped TUI writes `session.jsonl.zstd`, whose frame helpers are internal to the jsonl backend. Exporting them would have hard-coded one backend's file format into the CLI and still shown nothing for SQLite. - -**Register the web server itself with a placeholder session id.** `dsh web` owns no session — its sessions are created later by browser clients — so a server row would have put a fake id in a session table. Following session lifecycle instead makes browser sessions appear and disappear as they are opened, which also subsumed the TUI's launcher-side registration and deleted that separate path. - -**A `--here`/`--workspace` filter.** Dropped on request: the listing is always global, and narrowing is the user's `grep`. - -## Consequences - -The registry is an observability aid, so every write is best-effort: a registry fault warns and never fails a working agent session. The cost is that a listing can lag reality by one failed write, healed by the next. - -Title mirroring costs one locked read-modify-write per revision, so an aggressive retitling cadence pays that write each time. - -`bootId` bounds pid reuse only for records this process wrote. A foreign record whose pid the operating system has reassigned to an unrelated live process is reported alive until its owner removes it — accepted because the portable alternative, reading real process start times, is `/proc`-only. - -Liveness is pid existence, not health: a hung process still lists as running. The registry deliberately makes no progress judgement. - -## Testing - -Unit coverage pins durable-format validation (torn text, foreign version, per-row damage that must not hide siblings), pid pruning against a genuinely reaped pid, `EPERM`-is-alive, incarnation-scoped deregistration, and `retitle` scoping. Both concurrency layers have a regression test verified to fail when its mechanism is removed: 8 real processes for the cross-process lock, 24 overlapping in-process calls for the chain. The publisher is tested over the real `SessionStore` rather than a hand-built emitter, because publication depends on the store's actual lifecycle dispatch. - -Verified live in tmux against the assembled application: two concurrent TUI sessions in different workspaces both listed, a title appeared after the first turn, clean exit deregistered, and `SIGKILL` left a stale record that the next `dsh list-sessions` pruned and durably rewrote. diff --git a/.agents/notes/implemented/feature/2026-07-28-live-session-registry-and-dsh-ls.zh.md b/.agents/notes/implemented/feature/2026-07-28-live-session-registry-and-dsh-ls.zh.md deleted file mode 100644 index 722ac45f2e..0000000000 --- a/.agents/notes/implemented/feature/2026-07-28-live-session-registry-and-dsh-ls.zh.md +++ /dev/null @@ -1,65 +0,0 @@ -# Agent Note: 活跃会话注册表与 `dsh list-sessions` - -Status: implemented - -[English](2026-07-28-live-session-registry-and-dsh-ls.md) | 中文 - -## 问题 - -没有任何东西能回答「我此刻正在运行哪些 dsh 会话」。会话散落在多个项目中的用户既无法枚举它们,也无法找回 `--resume` 所需的 id,唯一的来源是打印过它的那个终端的退出行。会话持久化记录了曾经存在过的每个会话,因此它答不了这个问题:它没有存活状态的概念,而且 session、persistence、storage 这几个包里任何位置都没有出现过 `process.pid`。 - -## 决策 - -`dsh list-sessions`(别名 `dsh ps`)列出此刻正在运行的会话(会话 id、pid、运行时长、工作区、标题),最新的排在最前,覆盖所有工作区,并提供面向机器的 `--json`。背后由三个包(package)以能力 seam 的形式支撑。 - -[`dsh-session-registry`](../../../../packages/session-registry/session-registry/README.md)(`ctx.sessionRegistry`)是 seam:抽象服务契约与记录词汇,使介质将来可以换成数据库而不触及消费方。[`dsh-session-registry-file`](../../../../packages/session-registry/session-registry-file/README.md) 在 Harness home 下的一个加锁保护的 JSON 文件上实现它。[`dsh-session-registry-live`](../../../../packages/session-registry/session-registry-live/README.md) 跟随 `session/created`、`session/disposed` 和 `session/title`,让注册表保持同步。`apps/cli` 在每个启动方接口(TUI、`dsh meta`、headless、web)上都挂载这两个包,而 `dsh list-sessions` 只挂载该服务,不启动任何 agent(智能体)树。不记录任何接口标签:启动方的模式并不是会话的属性,而工作区那一列已经能把 `dsh meta` 会话和项目会话区分开。 - -### 存活状态是推导出来的,绝不存储 - -`list()` 用 `kill(pid, 0)` 探测每条记录的 pid,剪除已消亡的记录,并把剪除后的结果写回。未运行 disposer(资源释放)就被杀掉的进程留下的记录,会被下一次读取移除,因此不需要 daemon,不需要心跳,也不会有永久残留的幽灵记录。每个进程独有的 `bootId` 用于区分被复用的 pid,因此注销不会删除属于另一个 incarnation 的同名记录。`EPERM` 算作存活:归属于另一个用户的存活会话绝不能被丢掉。 - -### 两层相互独立的并发机制 - -该文件既被每个 dsh 进程写入,也被同一进程内的多个会话写入,这两种情形需要不同的机制。 - -跨进程时,每次读-改-写都持有一个 [`proper-lockfile`](https://github.com/moxystudio/node-proper-lockfile) 咨询锁。进程内则由各次调用在内部链上排队,因为咨询锁是按进程跟踪的:同一进程中重叠的调用方会争抢它有界的重试预算,而不是排队等待;大约超过十几次并发调用后,该预算耗尽,某次注册就会被拒绝。由于发布采用 fire-and-forget 方式,这样一次拒绝会静默地把一个存活会话从列表中丢掉——而这正是本功能要避免的「列表说谎」故障。两层机制都是必需的,且各有一个测试固定它:移除该机制,对应测试就会失败。 - -### 记录自带标题 - -标题是唯一的可变字段,随 `session/title` 事件到达,通过 `retitle` 替换。它存放在记录里,而不是从会话日志读取,因为日志的位置、格式和压缩都是逐部署的后端选择:TUI 写入项目本地的 zstd 压缩 JSONL,web 与 headless 界面写入全局根目录,用户配置文件可以覆盖二者,而 SQLite 根本没有逐会话的文件。独立读取方无法以可移植的方式解析这些内容,因此 `dsh list-sessions` 不打开任何日志,也不假定任何后端。 - -### subagent 在设计上就不可见 - -只有顶层启动方接口才挂载发布方。进程内 subagent(`spawn`、`fork`)没有自己的进程,而进程外后端 spawn 的是 `dsh-jsonrpc-agent` 而不是本 CLI(命令行界面)。不需要任何过滤开关,也没有改动任何 subagent 包。 - -## 考虑过的替代方案 - -**在 `~/.dsh/run/` 下每个会话一个文件。** 完全不需要锁,因为每个进程只写入和删除自己的文件。不予采纳,改用用户选定的单文件方案,而这也使真正的咨询锁从可选变为必需。 - -**在 `storage-json` 后端之上做一个 domain。** 这是最显而易见的复用,但它是错的:该后端明确记载「无跨进程写锁……最后写入者胜出」,并把单一宿主进程列为自身前提,而[domain KV 存储 note](../../proposed/architecture/2026-07-24-domain-kv-storage-and-workspace.md)明确把多进程排除在范围之外。被每个启动方并发写入的注册表恰恰就是这个被排除的场景。放宽该后端的契约,等于为一个消费方改动一项已上线的保证;改由一个独立的包拥有这套多进程介质。 - -**手写 `O_EXCL` 锁目录。** 依据[优先使用依赖而非手写政策](../process/2026-07-26-dependencies-over-hand-rolling.md)不予采纳:陈旧锁检测、重试退避和受损处理,恰恰是应当由一个有人维护的依赖拥有的那部分工作。 - -**在单文件上接受最后写入者胜出。** 这是最省事的实现,但当两个会话相近时间启动时,它会静默漏掉真实运行中的会话。一个会说谎的列表工具比没有列表工具更糟。 - -**在 `dsh list-sessions` 中从会话日志读取标题。** 该方案先落地实现,随后经实机验证否决:上线的 TUI 写入 `session.jsonl.zstd`,其帧处理辅助函数是 jsonl 后端的内部实现。把它们导出,等于把某一个后端的文件格式硬编码进 CLI,而且对 SQLite 仍然什么都显示不出来。 - -**用占位会话 id 注册 web 服务器本身。** `dsh web` 不拥有任何会话(它的会话由浏览器客户端稍后创建),因此一行服务器记录会把一个假 id 放进会话表。改为跟随会话生命周期后,浏览器会话会随打开与关闭而出现和消失,这同时也涵盖了 TUI 启动方一侧的注册,并删除了那条独立路径。 - -**加一个 `--here`/`--workspace` 过滤开关。** 按要求放弃:列表始终是全局的,收窄范围交给用户自己的 `grep`。 - -## 后果 - -注册表是一项可观测性辅助设施,因此每次写入都是尽力而为:注册表故障只发出警告,绝不让正常工作的 agent 会话失败。代价是列表可能因一次失败的写入而落后于现实一步,并由下一次写入修复。 - -标题镜像每次修订都要付出一次加锁的读-改-写,因此改名节奏激进时,每次改名都要付出这一次写入。 - -`bootId` 只对本进程写入的记录约束 pid 复用。如果一条外来记录的 pid 已被操作系统重新分配给一个无关的存活进程,那么在其所有者移除它之前,该记录会被报告为存活——之所以接受,是因为可移植的替代方案(读取进程真实启动时间)仅在 `/proc` 上可用。 - -存活状态只表示 pid 存在,不表示健康:挂死的进程仍会被列为正在运行。注册表刻意不对进展作出判断。 - -## 测试 - -单元覆盖固定了持久格式校验(截断文本、外来版本、不得遮蔽同级记录的单条损坏)、针对真正已回收 pid 的剪除、`EPERM` 算存活、按 incarnation 限定范围的注销,以及 `retitle` 的作用范围。两层并发机制各有一个回归测试,且都已验证在移除对应机制后会失败:跨进程锁用 8 个真实进程,进程内链用 24 次重叠调用。发布方在真实的 `SessionStore` 上测试,而非手搭的事件发射器,因为发布依赖该 store 实际的生命周期派发。 - -已在 tmux 中针对组装后的应用实机验证:位于不同工作区的两个并发 TUI 会话都被列出,第一轮之后出现标题,正常退出完成注销,而 `SIGKILL` 留下的陈旧记录被下一次 `dsh list-sessions` 剪除并持久重写。 diff --git a/apps/cli/base.cordis.yml b/apps/cli/base.cordis.yml index 8ece88a40d..40bb3fe946 100644 --- a/apps/cli/base.cordis.yml +++ b/apps/cli/base.cordis.yml @@ -53,7 +53,7 @@ # The session store root is the launcher's policy, not a plugin's: `dsh` shares # one store under the Harness home across every cwd, so `/resume` and -# `dsh ps` span workspaces. Without a launcher the project-local fallback keeps +# `/resume` spans workspaces. Without a launcher the project-local fallback keeps # an embedder's sessions beside its project. - id: session-persistence-jsonl name: '@deepseek-ai/dsh-session-persistence-jsonl' diff --git a/docs/capability-seams.md b/docs/capability-seams.md index f4607ce0f2..a985ecd2ba 100644 --- a/docs/capability-seams.md +++ b/docs/capability-seams.md @@ -47,10 +47,6 @@ flowchart LR pkg_workspace["workspace"] svc_workspace["ctx.workspace
Workspace entity registry"] pkg_apiproxy["apiproxy"] - pkg_session_registry["session-registry"] - svc_sessionRegistry["ctx.sessionRegistry
Live-session registry"] - pkg_session_registry_file["session-registry-file"] - pkg_session_registry_live["session-registry-live"] svc_sessionQuery["ctx.sessionQuery
Session reads, traces, filters, and search"] pkg_session_reference["session-reference"] pkg_tool_session_query["tool-session-query"] @@ -200,8 +196,6 @@ flowchart LR pkg_session_query --> svc_sessionQuery pkg_session_query_sqlite --> svc_sessionQuery pkg_session_reference --> svc_sessionReferences - pkg_session_registry --> svc_sessionRegistry - pkg_session_registry_file --> svc_sessionRegistry pkg_session_telemetry --> svc_telemetry pkg_session_telemetry_otel --> svc_telemetry pkg_session_title --> svc_sessionTitle @@ -283,7 +277,6 @@ flowchart LR svc_sessionQuery --> pkg_session_reference svc_sessionQuery --> pkg_tool_session_query svc_sessionReferences --> pkg_tui - svc_sessionRegistry --> pkg_session_registry_live svc_sessions --> pkg_agent svc_sessions --> pkg_agent_loop svc_sessions --> pkg_cli_demo @@ -344,7 +337,6 @@ flowchart LR | `ctx.storage` | `seam` | [`storage`](../packages/storage/storage) | [`storage-json`](../packages/storage/storage-json), [`storage-sqlite`](../packages/storage/storage-sqlite) | [`storage-domain`](../packages/storage/storage-domain) | - | Backends register side by side under names; data forms (domain first) mount on the hub and translate typed operations into opaque KV-unit primitives. | | `ctx.storageDomain` | `core` | [`storage-domain`](../packages/storage/storage-domain) | - | [`workspace`](../packages/workspace/workspace) | - | Waits for every configured backend, then publishes the domain form as one lifecycle-bound service for typed durable state. | | `ctx.workspace` | `core` | [`workspace`](../packages/workspace/workspace) | - | `apiproxy` | - | Owns WorkspaceId-branded records over the domain facility; stable sessionIds accounts drive Host RPC and GUI projections. | -| `ctx.sessionRegistry` | `seam` | [`session-registry`](../packages/session-registry/session-registry) | [`session-registry-file`](../packages/session-registry/session-registry-file) | [`session-registry-live`](../packages/session-registry/session-registry-live) | - | Seam contract for live-session records; the file backend owns the lock-guarded medium, liveness is derived from the recorded pid at read time, and the publisher mirrors lifecycle and title events for `dsh list-sessions`. | | `ctx.sessionQuery` | `seam` | [`session-query`](../packages/session-query/session-query) | [`session-query-sqlite`](../packages/session-query/session-query-sqlite) | [`session-reference`](../packages/context/session-reference), [`tool-session-query`](../packages/session-query/tool-session-query) | - | The interface supplies exact reads, filters, and traces; its concrete backend adds full-text reconciliation, ranking, snippets, and cursor generations, while the model consumer owns workspace authority and cursor-free rendering. | | `ctx.sessionReferences` | `core` | [`session-reference`](../packages/context/session-reference) | - | [`tui`](../packages/ui/tui) | - | Projects bounded current-surface conversation snapshots into durable untrusted message context; host adapters own mention syntax. | | `ctx.sessionTitle` | `seam` | [`session-title`](../packages/session-title/session-title) | [`session-title-first-message-llm`](../packages/session-title/session-title-first-message-llm), [`session-title-all-messages-llm`](../packages/session-title/session-title-all-messages-llm) | - | - | Owns the deterministic fallback, latest-title fold, and sole optional asynchronous provider registration. | diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 870c00e051..49e23c662e 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -1153,26 +1153,6 @@ export interface Config { Source: [`packages/context/session-reference/src/config.ts:11`](../packages/context/session-reference/src/config.ts) -## `@deepseek-ai/dsh-session-registry-file` - -```ts config-catalog -/** - * Plugin config as callers write it: `root` is required — a cwd fallback would - * scatter registries — while the lock tunables are optional because - * `static Config` supplies their defaults. - */ -export interface Config { - /** Directory holding the registry file; created `0o700` on demand. */ - root: string - /** Milliseconds after which a held lock is considered abandoned and reclaimed. */ - lockStaleMs?: number - /** Retries before a contended acquisition fails loud. */ - lockRetries?: number -} -``` - -Source: [`packages/session-registry/session-registry-file/src/index.ts:43`](../packages/session-registry/session-registry-file/src/index.ts) - ## `@deepseek-ai/dsh-session-telemetry-otel` Requires: `sessions` @@ -2194,7 +2174,6 @@ These load from a `cordis.yml` entry with no `config:` block; they declare no co - `@deepseek-ai/dsh-session` ([`packages/core/session/src/index.ts`](../packages/core/session/src/index.ts)) - `@deepseek-ai/dsh-session-checkpoint-policy` — requires `llm` · `sessionPersistence` · `sessions` · `tools` ([`packages/session-persistence/session-checkpoint-policy/src/index.ts`](../packages/session-persistence/session-checkpoint-policy/src/index.ts)) - `@deepseek-ai/dsh-session-projection` ([`packages/session-projection/session-projection/src/index.ts`](../packages/session-projection/session-projection/src/index.ts)) -- `@deepseek-ai/dsh-session-registry-live` — requires `sessions` · `sessionRegistry` ([`packages/session-registry/session-registry-live/src/index.ts`](../packages/session-registry/session-registry-live/src/index.ts)) - `@deepseek-ai/dsh-storage` ([`packages/storage/storage/src/index.ts`](../packages/storage/storage/src/index.ts)) - `@deepseek-ai/dsh-subagent` ([`packages/subagent/subagent/src/index.ts`](../packages/subagent/subagent/src/index.ts)) - `@deepseek-ai/dsh-subprocess-local` ([`packages/subprocess/subprocess-local/src/index.ts`](../packages/subprocess/subprocess-local/src/index.ts)) @@ -2217,7 +2196,6 @@ Abstract service classes — a deployment loads a concrete implementation packag - `@deepseek-ai/dsh-sandbox` — abstract `SandboxProvider` ([`packages/sandbox/sandbox/src/index.ts`](../packages/sandbox/sandbox/src/index.ts)) - `@deepseek-ai/dsh-session-persistence` — abstract `SessionPersistence` ([`packages/session-persistence/session-persistence/src/index.ts`](../packages/session-persistence/session-persistence/src/index.ts)) - `@deepseek-ai/dsh-session-query` — abstract `SessionQueryService` ([`packages/session-query/session-query/src/index.ts`](../packages/session-query/session-query/src/index.ts)) -- `@deepseek-ai/dsh-session-registry` — abstract `SessionRegistry` ([`packages/session-registry/session-registry/src/index.ts`](../packages/session-registry/session-registry/src/index.ts)) - `@deepseek-ai/dsh-spill` — abstract `SpillStore` ([`packages/spill/spill/src/index.ts`](../packages/spill/spill/src/index.ts)) - `@deepseek-ai/dsh-subprocess` — abstract `SubprocessService` ([`packages/subprocess/subprocess/src/index.ts`](../packages/subprocess/subprocess/src/index.ts)) - `@deepseek-ai/dsh-tasks` — abstract `TaskService` ([`packages/tasks/tasks/src/index.ts`](../packages/tasks/tasks/src/index.ts)) diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index f576af1243..59a5405bfb 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -1464,44 +1464,6 @@ Types: [Agent](../core-data-structures/core.md) · [ContentBlock](../core-data-s Source: [`packages/context/session-reference/src/index.ts:70`](../../packages/context/session-reference/src/index.ts) -## `ctx.sessionRegistry` — `SessionRegistry` (abstract seam) - -Cross-process live-session registry. Reads prune dead records, so every returned record's process existed at observation time. Backends serialize mutations against concurrent registrars — other processes and overlapping calls in this one — so records are never lost to a torn read-modify-write. - -```ts cordis-catalog -/** - * Publish this process's record, replacing any stale record for the same - * session id, and prune records whose process is gone. - * @param registration - the session, surface, and workspace to publish. - * @returns the effect disposer that removes this record again; awaiting it - * waits for the removal to reach durability. - */ -abstract register(registration: SessionRegistration): Promise<() => Promise> - -/** - * Replace the recorded title of a session this process registered. - * - * Titles arrive after registration and can be revised, so this is the one - * mutable field. Only a record matching this process and incarnation is - * touched, leaving a same-id record owned by another process alone. An unknown - * session id is a no-op rather than an error: a title can resolve after the - * session's record has already been removed. - * @param sessionId - the session whose recorded title changes. - * @param title - the new title text. - */ -abstract retitle(sessionId: SessionId, title: string): Promise - -/** - * List live sessions, pruning records whose process no longer exists. - * @returns one record per live registered session, newest registration last. - */ -abstract list(): Promise -``` - -Types: [SessionId](../core-data-structures/core.md) - -Source: [`packages/session-registry/session-registry/src/index.ts:44`](../../packages/session-registry/session-registry/src/index.ts) - ## `ctx.sessions` — `SessionStore` In-memory session store (`ctx.sessions`). diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index 13448e2c5b..141f770748 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -31,9 +31,9 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `fs/write-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:54`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`) | [`fs-policy`](../packages/fs/fs-policy) | | `goal/changed` | `emit` | [`packages/goal/goal/src/domain.ts:135`](../packages/goal/goal/src/domain.ts) | [`goal`](../packages/goal/goal) (`emit`) | [`goal-session`](../packages/goal/goal-session) | | `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:58`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`agent-loop`](../packages/core/agent-loop), [`llm`](../packages/llm/llm), [`llm-replay`](../packages/support/llm-replay), [`session-checkpoint-policy`](../packages/session-persistence/session-checkpoint-policy), [`session-title`](../packages/session-title/session-title) | -| `session/created` | `emit` | [`packages/core/session/src/index.ts:71`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | `apiproxy`, [`compact`](../packages/compact/compact), [`goal`](../packages/goal/goal), [`hook-protocol`](../packages/hooks/hook-protocol), [`jsonrpc`](../packages/ui/jsonrpc), [`llm-retry`](../packages/llm/llm-retry), [`plan-mode`](../packages/plan/plan-mode), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-registry-live`](../packages/session-registry/session-registry-live), [`session-telemetry`](../packages/telemetry/session-telemetry), [`tools`](../packages/core/tools), [`user-approval`](../packages/ui/user-approval) | -| `session/disposed` | `emit` | [`packages/core/session/src/index.ts:81`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `apiproxy`, [`session-persistence`](../packages/session-persistence/session-persistence), [`session-projection-cache`](../packages/session-projection/session-projection-cache), [`session-registry-live`](../packages/session-registry/session-registry-live), [`session-telemetry`](../packages/telemetry/session-telemetry), [`session-title`](../packages/session-title/session-title) | -| `session/event` | `emit` | [`packages/core/session/src/index.ts:93`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/acp/acp), `apiproxy`, [`cli-demo`](../packages/examples/cli-demo), [`compact`](../packages/compact/compact), [`compact-basic`](../packages/compact/compact-basic), [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hook-protocol`](../packages/hooks/hook-protocol), [`jsonrpc`](../packages/ui/jsonrpc), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-projection`](../packages/session-projection/session-projection), [`session-projection-cache`](../packages/session-projection/session-projection-cache), [`session-registry-live`](../packages/session-registry/session-registry-live), [`session-telemetry`](../packages/telemetry/session-telemetry), [`session-title`](../packages/session-title/session-title), [`token-meter`](../packages/llm/token-meter), [`tools`](../packages/core/tools), [`tui`](../packages/ui/tui), [`user-approval`](../packages/ui/user-approval), [`workspace-context`](../packages/context/workspace-context) | +| `session/created` | `emit` | [`packages/core/session/src/index.ts:71`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | `apiproxy`, [`compact`](../packages/compact/compact), [`goal`](../packages/goal/goal), [`hook-protocol`](../packages/hooks/hook-protocol), [`jsonrpc`](../packages/ui/jsonrpc), [`llm-retry`](../packages/llm/llm-retry), [`plan-mode`](../packages/plan/plan-mode), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-telemetry`](../packages/telemetry/session-telemetry), [`tools`](../packages/core/tools), [`user-approval`](../packages/ui/user-approval) | +| `session/disposed` | `emit` | [`packages/core/session/src/index.ts:81`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `apiproxy`, [`session-persistence`](../packages/session-persistence/session-persistence), [`session-projection-cache`](../packages/session-projection/session-projection-cache), [`session-telemetry`](../packages/telemetry/session-telemetry), [`session-title`](../packages/session-title/session-title) | +| `session/event` | `emit` | [`packages/core/session/src/index.ts:93`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/acp/acp), `apiproxy`, [`cli-demo`](../packages/examples/cli-demo), [`compact`](../packages/compact/compact), [`compact-basic`](../packages/compact/compact-basic), [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hook-protocol`](../packages/hooks/hook-protocol), [`jsonrpc`](../packages/ui/jsonrpc), [`session`](../packages/core/session), [`session-persistence`](../packages/session-persistence/session-persistence), [`session-projection`](../packages/session-projection/session-projection), [`session-projection-cache`](../packages/session-projection/session-projection-cache), [`session-telemetry`](../packages/telemetry/session-telemetry), [`session-title`](../packages/session-title/session-title), [`token-meter`](../packages/llm/token-meter), [`tools`](../packages/core/tools), [`tui`](../packages/ui/tui), [`user-approval`](../packages/ui/user-approval), [`workspace-context`](../packages/context/workspace-context) | | `session/flush` | `parallel` | [`packages/core/session/src/index.ts:103`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`session-persistence`](../packages/session-persistence/session-persistence), [`session-telemetry`](../packages/telemetry/session-telemetry) | | `slash/input-begin-command` | `bail` | [`packages/client/ui-slash/src/types.ts:230`](../packages/client/ui-slash/src/types.ts) | - | `ui-conversation` | | `slash/input-consume-token` | `bail` | [`packages/client/ui-slash/src/types.ts:244`](../packages/client/ui-slash/src/types.ts) | - | `ui-conversation` | diff --git a/knip.json b/knip.json index e40f343dff..b79a4d8977 100644 --- a/knip.json +++ b/knip.json @@ -288,7 +288,6 @@ "tests/**/*.ts" ] }, - "packages/session-registry/session-registry-file": { "entry": [ "tests/**/*.spec.ts", "tests/fixtures/register-once.ts" diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index a27c126b75..d238897cf3 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -688,24 +688,6 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ }, ], }, - { - key: 'sessionRegistry', - summary: 'Cross-process live-session registry.', - methods: [ - { - signature: 'abstract register(registration: SessionRegistration): Promise<() => Promise>', - jsDoc: '/**\n * Publish this process\'s record, replacing any stale record for the same\n * session id, and prune records whose process is gone.\n * @param registration - the session, surface, and workspace to publish.\n * @returns the effect disposer that removes this record again; awaiting it\n * waits for the removal to reach durability.\n */', - }, - { - signature: 'abstract retitle(sessionId: SessionId, title: string): Promise', - jsDoc: '/**\n * Replace the recorded title of a session this process registered.\n *\n * Titles arrive after registration and can be revised, so this is the one\n * mutable field. Only a record matching this process and incarnation is\n * touched, leaving a same-id record owned by another process alone. An unknown\n * session id is a no-op rather than an error: a title can resolve after the\n * session\'s record has already been removed.\n * @param sessionId - the session whose recorded title changes.\n * @param title - the new title text.\n */', - }, - { - signature: 'abstract list(): Promise', - jsDoc: '/**\n * List live sessions, pruning records whose process no longer exists.\n * @returns one record per live registered session, newest registration last.\n */', - }, - ], - }, { key: 'sessions', summary: 'In-memory session store (`ctx.sessions`).', @@ -2270,12 +2252,8 @@ export const TYPE_API: readonly TypeApiEntry[] = [ declaration: 'export interface SessionReferenceInput {\n sessionId: SessionId;\n label?: string;\n}', }, { - name: 'SessionRegistration', - declaration: 'export interface SessionRegistration {\n sessionId: SessionId;\n cwd: string;\n title?: string;\n}', }, { - name: 'SessionRegistryRecord', - declaration: 'export interface SessionRegistryRecord {\n readonly sessionId: SessionId;\n readonly pid: number;\n readonly cwd: string;\n readonly startedAt: number;\n readonly bootId: BootId;\n readonly title?: string;\n}', }, { name: 'SessionResultFilter', diff --git a/packages/session-registry/README.i18n.yaml b/packages/session-registry/README.i18n.yaml deleted file mode 100644 index 6eb22cf334..0000000000 --- a/packages/session-registry/README.i18n.yaml +++ /dev/null @@ -1,6 +0,0 @@ -# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each -# side as of the last confirmed-consistent state. Both languages carry equal authority; -# after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write packages/session-registry/README.md -README.md: c79caccd05d6cbdda0663dd897490fb6004b8250 -README.zh.md: c3fff3bc6b0e0417dd291d129d7fb1001b50258f diff --git a/packages/session-registry/README.md b/packages/session-registry/README.md deleted file mode 100644 index c79caccd05..0000000000 --- a/packages/session-registry/README.md +++ /dev/null @@ -1,15 +0,0 @@ -# session-registry/ — live-session registry family - -English | [中文](README.zh.md) - -Which sessions are running right now, readable from a different process. `dsh list-sessions` is the consumer. - -| Package | Role | ctx key | -|---|---|---| -| [`session-registry/`](session-registry/README.md) | The seam: abstract registry service contract and record vocabulary | `ctx.sessionRegistry` | -| [`session-registry-file/`](session-registry-file/README.md) | Backend: one lock-guarded JSON file, pid-derived liveness | — | -| [`session-registry-live/`](session-registry-live/README.md) | Publisher: follows session lifecycle and title events, keeping the registry in step | — | - -The split follows the three-package capability-seam convention: the seam answers "what is live" for a short-lived reader that mounts nothing else, the file backend owns today's medium and can be replaced by a database without touching consumers, and the publisher needs the session store and runs inside a full agent composition. Liveness is derived from the recorded pid at read time rather than stored, so a killed process leaves nothing to clean up. Records carry their own title because log location, format, and compression are per-deployment backend choices an independent reader cannot portably parse. - -This family is independent of session persistence: it records which processes hold which sessions, never conversation content, and a session that is never persisted still lists. diff --git a/packages/session-registry/README.zh.md b/packages/session-registry/README.zh.md deleted file mode 100644 index c3fff3bc6b..0000000000 --- a/packages/session-registry/README.zh.md +++ /dev/null @@ -1,15 +0,0 @@ -# session-registry/:活跃会话注册表家族 - -[English](README.md) | 中文 - -当前正在运行哪些会话,可以从另一个进程读取。消费方是 `dsh list-sessions`。 - -| 包 | 职责 | ctx 键 | -|---|---|---| -| [`session-registry/`](session-registry/README.md) | seam:抽象注册表服务契约与记录词汇 | `ctx.sessionRegistry` | -| [`session-registry-file/`](session-registry-file/README.md) | 后端:单个加锁保护的 JSON 文件、由 pid 推导的存活状态 | — | -| [`session-registry-live/`](session-registry-live/README.md) | 发布方:跟随会话生命周期与标题事件,让注册表保持同步 | — | - -这样拆分遵循由三个包构成的能力 seam 惯例:seam 要回答「哪些会话是活跃的」,供一个不挂载其他任何东西的短生命周期读取方使用;文件后端拥有今天的介质,将来可以换成数据库而不触及消费方;发布方需要会话存储,运行在完整的 agent(智能体)组合体内。存活状态在读取时由记录的 pid 推导,而不是存下来,因此进程被杀掉后不留下任何需要清理的东西。记录自带标题,因为日志位置、格式和压缩都是各部署自行选择的后端方案,独立的读取方无法以可移植的方式解析。 - -这个家族与会话持久化相互独立:它只记录哪些进程持有哪些会话,绝不记录对话内容;从未被持久化的会话同样能被列出。 diff --git a/packages/session-registry/session-registry-file/README.i18n.yaml b/packages/session-registry/session-registry-file/README.i18n.yaml deleted file mode 100644 index d08b25d86f..0000000000 --- a/packages/session-registry/session-registry-file/README.i18n.yaml +++ /dev/null @@ -1,6 +0,0 @@ -# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each -# side as of the last confirmed-consistent state. Both languages carry equal authority; -# after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write packages/session-registry/session-registry-file/README.md -README.md: b6f29a459e5f7f6484aed9f4968d54f969ca6e73 -README.zh.md: 35861466ed5fb826ee118c506943c3a08024a827 diff --git a/packages/session-registry/session-registry-file/README.md b/packages/session-registry/session-registry-file/README.md deleted file mode 100644 index b6f29a459e..0000000000 --- a/packages/session-registry/session-registry-file/README.md +++ /dev/null @@ -1,42 +0,0 @@ -# @deepseek-ai/dsh-session-registry-file - -English | [中文](README.zh.md) - -File-backed implementation of the [live-session registry seam](../session-registry/README.md): one lock-guarded JSON file under the Harness home is the whole medium. Mounting it publishes `ctx.sessionRegistry`; `file` exposes the absolute registry path (`/sessions.json`). - -## Liveness and crash safety - -Liveness is derived at read time from the recorded pid via `kill(pid, 0)`: `ESRCH` is dead, `EPERM` is alive under another user, and any other errno propagates rather than being read as an answer. A process killed without running its disposer therefore leaves a record that the next `list()` prunes and rewrites — no daemon, no heartbeat, and no permanent phantom. `bootId` distinguishes a recycled pid, so deregistration cannot delete a namesake record belonging to a different incarnation. - -## Concurrency - -Both layers are required and neither substitutes for the other. - -- **Across processes**, each read-modify-write cycle holds a [`proper-lockfile`](https://github.com/moxystudio/node-proper-lockfile) advisory lock. Unlocked whole-file republication loses records under concurrent launchers, which is why the storage-hub JSON backend — documented last-write-wins, single-host-process — cannot serve this medium. -- **Within one process**, calls queue on an internal chain. The advisory lock is tracked per process, so overlapping same-process callers contend for its bounded retry budget instead of queueing; past roughly a dozen concurrent calls that budget runs out and a registration rejects. Callers publish fire-and-forget, so such a rejection would silently drop a live session from the listing. - -Writes are temp-file plus atomic `rename` (no fsync: a listing lost to a crash is rebuilt by the next process's read, so crash durability buys nothing here), under a `0o700` root with a `0o600` file. - -## Durable format - -`sessions.json` carries a `version` stamp pinned at `0` under the pre-release stance: a differing version is rejected rather than migrated. Reads validate every field because the medium is shared and user-visible. An individually unusable row is dropped while its siblings survive, and unparsable text or a foreign version reads as empty — one malformed record written by another harness version must not hide every other live session. Any of these marks the medium damaged, so the next write republishes and heals it. - -## Config - -| Key | Type | Default | Meaning | -| --- | --- | --- | --- | -| `root` | string | required — no default (a cwd fallback would scatter registries) | Directory holding `sessions.json`; created `0o700` on demand | -| `lockStaleMs` | natural | `10000` | Milliseconds after which a held lock is treated as abandoned and reclaimed | -| `lockRetries` | natural | `10` | Retries before a contended acquisition fails loud | - -## Model Experience - -None, as this package registers no tools, injects no prompts, and appends no session events; it stores host-side process records for the CLI listing surface only. - -#### KV Cache effect - -Independent of live requests: the registry never touches a request prefix, so nothing here can invalidate provider cache reuse. - -## Known Limitations and Deferred Work - -- **A reused pid within the stale window is trusted** — `bootId` distinguishes incarnations of records this process wrote, but a foreign record whose pid the operating system has since reassigned to an unrelated live process is reported alive until its owner removes it. diff --git a/packages/session-registry/session-registry-file/README.zh.md b/packages/session-registry/session-registry-file/README.zh.md deleted file mode 100644 index 35861466ed..0000000000 --- a/packages/session-registry/session-registry-file/README.zh.md +++ /dev/null @@ -1,42 +0,0 @@ -# @deepseek-ai/dsh-session-registry-file - -[English](README.md) | 中文 - -[存活会话注册表 seam](../session-registry/README.md) 的文件后端实现:整套介质就是 Harness home 下的一个加锁保护的 JSON 文件。挂载它即发布 `ctx.sessionRegistry`;`file` 暴露注册表文件的绝对路径(`/sessions.json`)。 - -## 存活状态与崩溃安全 - -存活状态在读取时由记录的 pid 经 `kill(pid, 0)` 推导:`ESRCH` 表示已消亡,`EPERM` 表示存活于另一个用户之下,其他任何 errno 都向外抛出,而不会被当成一个答案来解读。因此,未运行 disposer 就被杀掉的进程留下的记录,会被下一次 `list()` 剪除并重写——不需要 daemon,不需要心跳,也不会有永久残留的幽灵记录。`bootId` 用于区分被复用的 pid,因此注销不会删除属于另一个 incarnation 的同名记录。 - -## 并发 - -两层机制都是必需的,任何一层都无法替代另一层。 - -- **跨进程**:每个读改写周期都持有 [`proper-lockfile`](https://github.com/moxystudio/node-proper-lockfile) 咨询锁。无锁的全文件重发布会在并发启动器下丢失记录,这正是 storage-hub JSON 后端(文档声明 last-write-wins、单宿主进程)无法承担该介质的原因。 -- **进程内**:调用在内部链上排队。咨询锁按进程跟踪,因此同进程的重叠调用者会争用其有限的重试预算而非排队;并发调用超过十来个时预算耗尽,注册会被拒绝。调用方以 fire-and-forget 方式发布,这样的拒绝会静默地把一个存活会话从列表中丢掉。 - -写入采用临时文件加原子 `rename`(不做 fsync:崩溃丢失的列表会被下一个进程的读取重建,崩溃持久性在这里没有收益),根目录 `0o700`,文件 `0o600`。 - -## 持久化格式 - -`sessions.json` 携带一个 `version` 戳,在预发布立场下固定为 `0`:版本不同将被拒绝而非迁移。由于介质是共享且用户可见的,读取会校验每个字段。单条不可用的行会被丢弃而其同伴保留;无法解析的文本或异版本文件读作空——另一个 harness 版本写入的一条损坏记录,不得隐藏所有其他存活会话。上述任一情况都会把介质标记为受损,下一次写入将重新发布并修复它。 - -## 配置 - -| 键 | 类型 | 默认值 | 含义 | -| --- | --- | --- | --- | -| `root` | string | 必填——无默认值(回退到 cwd 会使注册表散落各处) | 存放 `sessions.json` 的目录;按需以 `0o700` 创建 | -| `lockStaleMs` | natural | `10000` | 持有的锁超过该毫秒数即视为被遗弃并被回收 | -| `lockRetries` | natural | `10` | 锁争用时在明确失败前的重试次数 | - -## 模型体验 - -无。本包不注册工具、不注入提示词、不追加会话事件;它只为 CLI 列表界面存储宿主侧进程记录。 - -#### KV 缓存影响 - -与在途请求无关:注册表从不触碰请求前缀,因此这里不会使提供方缓存复用失效。 - -## 已知限制与后续工作 - -- **陈旧窗口内被复用的 pid 会被信任**——`bootId` 能区分本进程所写记录的 incarnation,但外来记录的 pid 若已被操作系统重新分配给无关的存活进程,在其属主移除之前会一直被报告为存活。 diff --git a/packages/session-registry/session-registry-file/package.json b/packages/session-registry/session-registry-file/package.json deleted file mode 100644 index 321a59902d..0000000000 --- a/packages/session-registry/session-registry-file/package.json +++ /dev/null @@ -1,47 +0,0 @@ -{ - "name": "@deepseek-ai/dsh-session-registry-file", - "description": "Lock-guarded JSON-file backend for the dsh live-session registry seam", - "version": "0.0.1", - "private": true, - "type": "module", - "main": "lib/index.js", - "types": "lib/types/index.d.ts", - "exports": { - ".": { - "types": "./lib/types/index.d.ts", - "default": "./lib/index.js" - }, - "./src/*": "./src/*", - "./package.json": "./package.json", - "./invariant": { - "types": "./lib/types/invariant.d.ts", - "default": "./lib/invariant.js" - } - }, - "files": [ - "lib/index.js", - "lib/invariant.js", - "lib/types/**/*.d.ts", - "lib/types/**/*.d.ts.map", - "src" - ], - "license": "BSD-3-Clause", - "dependencies": { - "proper-lockfile": "^4.1.2", - "schemastery": "^3.15.0" - }, - "peerDependencies": { - "@deepseek-ai/dsh-invariants": "^0.0.1", - "@deepseek-ai/dsh-session": "^0.0.1", - "@deepseek-ai/dsh-session-registry": "^0.0.1", - "cordis": "^4.0.0-rc.6" - }, - "devDependencies": { - "@deepseek-ai/dsh-brand": "workspace:^", - "@deepseek-ai/dsh-invariants": "workspace:^", - "@deepseek-ai/dsh-session": "workspace:^", - "@deepseek-ai/dsh-session-registry": "workspace:^", - "@types/proper-lockfile": "^4.1.4", - "cordis": "^4.0.0-rc.6" - } -} diff --git a/packages/session-registry/session-registry-file/src/file.ts b/packages/session-registry/session-registry-file/src/file.ts deleted file mode 100644 index 0c9a5775fb..0000000000 --- a/packages/session-registry/session-registry-file/src/file.ts +++ /dev/null @@ -1,97 +0,0 @@ -/** - * Registry file format: the durable boundary between independent `dsh` - * processes. Every field is validated on read because the medium is shared, - * user-visible, and writable by other harness versions — a foreign or truncated - * file must not crash `dsh list-sessions` into an empty listing that hides live sessions. - * @module @deepseek-ai/dsh-session-registry-file/file - */ - -import { SessionId } from '@deepseek-ai/dsh-session' -import { BootId, type SessionRegistryRecord } from '@deepseek-ai/dsh-session-registry' - -/** - * On-disk format version. Pinned at `0` under the pre-release stance: a - * differing version is rejected rather than migrated, matching every other - * harness backend. - */ -export const SESSION_REGISTRY_FORMAT_VERSION = 0 - -/** The complete registry file: a version stamp plus the live records. */ -export interface RegistryFileContents { - /** Format stamp, always {@link SESSION_REGISTRY_FORMAT_VERSION} when written. */ - readonly version: number - /** One record per registered process, in no significant order. */ - readonly records: readonly SessionRegistryRecord[] -} - -/** An empty registry: the value a missing file reads as. */ -export const EMPTY_REGISTRY: RegistryFileContents = { version: SESSION_REGISTRY_FORMAT_VERSION, records: [] } - -/** Narrow an unknown JSON value to a record shape, or reject it as unusable. */ -function parseRecord(value: unknown): SessionRegistryRecord | undefined { - if (typeof value !== 'object' || value === null) return undefined - const row = value as Record - const { sessionId, pid, cwd, startedAt, bootId } = row - if (typeof sessionId !== 'string' || sessionId === '') return undefined - // A non-integer or non-positive pid cannot be probed for liveness. - if (typeof pid !== 'number' || !Number.isSafeInteger(pid) || pid <= 0) return undefined - if (typeof cwd !== 'string' || cwd === '') return undefined - if (typeof startedAt !== 'number' || !Number.isSafeInteger(startedAt) || startedAt < 0) return undefined - if (typeof bootId !== 'string' || bootId === '') return undefined - // An absent title is legal (a fresh session has none); a present but - // non-string one is a damaged row rather than a missing optional field. - const { title } = row - if (title !== undefined && typeof title !== 'string') return undefined - return { - sessionId: SessionId(sessionId), - pid, - cwd, - startedAt, - bootId: BootId(bootId), - ...title !== undefined && { title }, - } -} - -/** - * Parse registry file text into records, dropping individually unusable rows. - * - * A row that cannot be interpreted is dropped rather than rejected wholesale: - * one malformed record written by a different harness version must not hide - * every other live session. Unparsable text and a version mismatch yield an - * empty registry for the same reason — the caller republishes the whole file, so - * the next write heals the medium. - * @param text - the raw file contents. - * @returns the records that parsed, and whether the text was fully understood. - */ -export function parseRegistry(text: string): { records: SessionRegistryRecord[]; intact: boolean } { - let parsed: unknown - try { - parsed = JSON.parse(text) - } catch { - // Swallows only SyntaxError from this one JSON.parse: a torn or foreign - // file heals on the next write, and nothing else can reach this catch. - return { records: [], intact: false } - } - if (typeof parsed !== 'object' || parsed === null) return { records: [], intact: false } - const file = parsed as Record - if (file.version !== SESSION_REGISTRY_FORMAT_VERSION) return { records: [], intact: false } - if (!Array.isArray(file.records)) return { records: [], intact: false } - const records: SessionRegistryRecord[] = [] - let intact = true - for (const row of file.records) { - const record = parseRecord(row) - if (record === undefined) intact = false - else records.push(record) - } - return { records, intact } -} - -/** - * Serialize records as registry file text. - * @param records - the live records to publish. - * @returns pretty-printed JSON with a trailing newline, for a legible medium. - */ -export function serializeRegistry(records: readonly SessionRegistryRecord[]): string { - const file: RegistryFileContents = { version: SESSION_REGISTRY_FORMAT_VERSION, records } - return `${JSON.stringify(file, undefined, 2)}\n` -} diff --git a/packages/session-registry/session-registry-file/src/index.ts b/packages/session-registry/session-registry-file/src/index.ts deleted file mode 100644 index 41e080b79a..0000000000 --- a/packages/session-registry/session-registry-file/src/index.ts +++ /dev/null @@ -1,233 +0,0 @@ -/** - * File-backed live-session registry: one lock-guarded JSON file under the - * Harness home implements the `@deepseek-ai/dsh-session-registry` seam. Every - * operation is a read-modify-write under an advisory lock, because concurrent - * launchers write the same file — the storage-hub JSON backend documents - * last-write-wins for exactly this case and cannot be reused. Liveness is - * derived at read time from the recorded pid. - * @module @deepseek-ai/dsh-session-registry-file - */ - -import { randomUUID } from 'node:crypto' -import { mkdir, readFile, rename, writeFile, open } from 'node:fs/promises' -import { dirname, join } from 'node:path' -import type { Context } from 'cordis' -import lockfile from 'proper-lockfile' -import z from 'schemastery' -import type { SessionId } from '@deepseek-ai/dsh-session' -import { - SessionRegistry, BootId, - type SessionRegistration, type SessionRegistryRecord, -} from '@deepseek-ai/dsh-session-registry' -import { EMPTY_REGISTRY, parseRegistry, serializeRegistry } from './file.ts' -import { isPidAlive } from './liveness.ts' - -export { SESSION_REGISTRY_FORMAT_VERSION, parseRegistry, serializeRegistry } from './file.ts' -export type { RegistryFileContents } from './file.ts' -export { isPidAlive } from './liveness.ts' - -/** The file name holding the registry, relative to {@link Config.root}. */ -export const REGISTRY_FILE_NAME = 'sessions.json' - -/** Default lock staleness threshold; a held lock older than this is reclaimed. */ -const DEFAULT_LOCK_STALE_MS = 10_000 - -/** Default retry budget for a contended lock acquisition. */ -const DEFAULT_LOCK_RETRIES = 10 - -/** - * Plugin config as callers write it: `root` is required — a cwd fallback would - * scatter registries — while the lock tunables are optional because - * `static Config` supplies their defaults. - */ -export interface Config { - /** Directory holding the registry file; created `0o700` on demand. */ - root: string - /** Milliseconds after which a held lock is considered abandoned and reclaimed. */ - lockStaleMs?: number - /** Retries before a contended acquisition fails loud. */ - lockRetries?: number -} - -/** The file-backed {@link SessionRegistry} implementation. */ -export class SessionRegistryFile extends SessionRegistry { - static Config: z = z.object({ - root: z.string().required(), - lockStaleMs: z.natural().default(DEFAULT_LOCK_STALE_MS), - lockRetries: z.natural().default(DEFAULT_LOCK_RETRIES), - }) - - /** Absolute path of the registry file this service reads and writes. */ - readonly file: string - - /** Directory holding {@link file}, created `0o700` on demand. */ - private readonly root: string - - /** Tail of the in-process serialization chain; see {@link mutate}. */ - private chain: Promise = Promise.resolve() - - /** Resolved lock staleness threshold in milliseconds, fixed at construction. */ - private readonly stale: number - - /** Resolved contended-acquisition retry budget, fixed at construction. */ - private readonly retries: number - - constructor(ctx: Context, config: Config) { - super(ctx, BootId(randomUUID())) - this.root = config.root - this.file = join(this.root, REGISTRY_FILE_NAME) - // Resolve the optional tunables here, once: `static Config` supplies these - // same defaults for a Loader mount, and a direct programmatic mount that - // omits them gets them too rather than an undefined lock option. - this.stale = config.lockStaleMs ?? DEFAULT_LOCK_STALE_MS - this.retries = config.lockRetries ?? DEFAULT_LOCK_RETRIES - } - - /** @inheritdoc */ - async register(registration: SessionRegistration): Promise<() => Promise> { - const record: SessionRegistryRecord = { - sessionId: registration.sessionId, - pid: process.pid, - cwd: registration.cwd, - startedAt: Date.now(), - bootId: this.bootId, - ...registration.title !== undefined && { title: registration.title }, - } - await this.mutate(records => [ - ...records.filter(other => other.sessionId !== record.sessionId), - record, - ]) - // The disposer is awaited by Cordis teardown, so the record is durably gone - // before disposal completes rather than racing process exit. A failure here - // is reported, not thrown: the record is already pid-prunable, and an - // unwinding teardown must not be turned into a rejection. - return this.ctx.effect(() => async () => { - try { - await this.mutate(records => records.filter(other => !this.isSelf(other, record))) - } catch (error) { - this.ctx.logger.warn('failed to deregister %s: %s', record.sessionId, String(error)) - } - }) - } - - /** @inheritdoc */ - async retitle(sessionId: SessionId, title: string): Promise { - await this.mutate(records => records.map(record => - record.sessionId === sessionId && record.pid === process.pid && record.bootId === this.bootId - ? { ...record, title } - : record)) - } - - /** @inheritdoc */ - async list(): Promise { - // Pruning is a write, so the read path takes the same lock: a listing that - // observed a half-written file could omit a live session. - return this.mutate(records => [...records]) - } - - /** True when a stored record is this exact registration (pid AND incarnation). */ - private isSelf(candidate: SessionRegistryRecord, self: SessionRegistryRecord): boolean { - return candidate.sessionId === self.sessionId - && candidate.pid === self.pid - && candidate.bootId === self.bootId - } - - /** - * Serialize one read-modify-write cycle against every other cycle in THIS - * process, then run it under the cross-process lock. - * - * Both layers are required and neither substitutes for the other. The advisory - * lock excludes other processes but is tracked per process, so it rejects a - * same-process concurrent acquisition outright (`ELOCKED`) instead of queueing - * — and a composition that creates several sessions at once really does - * overlap these calls. This chain gives those callers a queue; the lock gives - * independent processes exclusion. - */ - private mutate( - change: (records: readonly SessionRegistryRecord[]) => SessionRegistryRecord[], - ): Promise { - // Failures must not poison the chain for later callers, so the tail only - // tracks settlement, never the rejection itself. - const result = this.chain.then(() => this.mutateExclusively(change)) - this.chain = result.then(() => undefined, () => undefined) - return result - } - - /** - * Run one locked read-modify-write cycle: read, prune dead records, apply - * `change`, and republish when the result differs from what was stored. - */ - private async mutateExclusively( - change: (records: readonly SessionRegistryRecord[]) => SessionRegistryRecord[], - ): Promise { - await mkdir(this.root, { recursive: true, mode: 0o700 }) - // proper-lockfile needs the target to exist before it can guard it; an - // exclusive create loses harmlessly to a concurrent launcher doing the same. - await this.ensureFile() - const release = await lockfile.lock(this.file, { - stale: this.stale, - retries: { retries: this.retries, minTimeout: 20, maxTimeout: 500 }, - }) - try { - const before = await this.read() - const live = before.records.filter(record => isPidAlive(record.pid)) - const next = change(live) - // Republish when a record changed or the medium itself was damaged, so a - // foreign or torn file heals instead of being re-parsed on every read. - if (!before.intact || !sameRecords(before.records, next)) await this.write(next) - return next - } finally { - await release() - } - } - - /** Create the registry file if absent, without disturbing existing content. */ - private async ensureFile(): Promise { - try { - const handle = await open(this.file, 'wx', 0o600) - try { - await handle.writeFile(serializeRegistry(EMPTY_REGISTRY.records)) - } finally { - await handle.close() - } - } catch (error) { - // Swallows only EEXIST: another launcher created the file first, which is - // the intended outcome. Every other errno propagates. - /* v8 ignore next -- a non-EEXIST create failure needs a permission or IO fault on a root this cycle just created 0o700. */ - if ((error as NodeJS.ErrnoException).code !== 'EEXIST') throw error - } - } - - /** Read and parse the registry file; a missing file reads as empty. */ - private async read(): Promise<{ records: SessionRegistryRecord[]; intact: boolean }> { - // The caller holds the lock, and acquiring it requires the file to exist, so - // a read failure here is real corruption rather than an absent registry and - // propagates: a swallowed error would report "no live sessions" for a medium - // that could not be read. - return parseRegistry(await readFile(this.file, 'utf8')) - } - - /** Publish the complete record set via temp-write plus atomic rename. */ - private async write(records: readonly SessionRegistryRecord[]): Promise { - const temp = join(dirname(this.file), `.${REGISTRY_FILE_NAME}.${process.pid}.${randomUUID()}.tmp`) - await writeFile(temp, serializeRegistry(records), { mode: 0o600 }) - await rename(temp, this.file) - } -} - -/** Compare record lists by identity fields, to decide whether a write is needed. */ -function sameRecords(left: readonly SessionRegistryRecord[], right: readonly SessionRegistryRecord[]): boolean { - if (left.length !== right.length) return false - return left.every((record, index) => { - const other = right[index] - return other !== undefined - && record.sessionId === other.sessionId - && record.pid === other.pid - && record.bootId === other.bootId - && record.cwd === other.cwd - && record.startedAt === other.startedAt - && record.title === other.title - }) -} - -export default SessionRegistryFile diff --git a/packages/session-registry/session-registry-file/src/invariant.ts b/packages/session-registry/session-registry-file/src/invariant.ts deleted file mode 100644 index 9fd175abc4..0000000000 --- a/packages/session-registry/session-registry-file/src/invariant.ts +++ /dev/null @@ -1,32 +0,0 @@ -/** - * Package-owned invariant companion for `@deepseek-ai/dsh-session-registry-file`. - * @module @deepseek-ai/dsh-session-registry-file/invariant - */ - -import type { Context } from 'cordis' -import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' - -const PACKAGE_NAME = '@deepseek-ai/dsh-session-registry-file' - -/** Cordis companion plugin name. */ -export const name = 'session-registry-file-invariant' -/** Service required before the companion can reserve package ownership. */ -export const inject = ['invariants'] - -/** - * No runtime invariant: the relations a reader must trust (unique live session - * ids, attributable pids) are contract-level and validated by the seam's - * companion around the authoritative `list()`, whatever backend serves it. The - * file medium's own correctness — locking, atomic republication, and - * foreign-row rejection — requires cross-process round-trip tests, not a - * continuously observable in-process relation. - */ -const install: InvariantInstaller = () => {} - -/** - * Register this package's invariant companion. - * @param ctx - Cordis context carrying the invariant service. - * @returns the installed registration's disposer after setup succeeds. - */ -export const apply = (ctx: Context): Promise<() => void> => - Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) diff --git a/packages/session-registry/session-registry-file/src/liveness.ts b/packages/session-registry/session-registry-file/src/liveness.ts deleted file mode 100644 index ff7421edd9..0000000000 --- a/packages/session-registry/session-registry-file/src/liveness.ts +++ /dev/null @@ -1,30 +0,0 @@ -/** - * Process-liveness probe for stored registry records. - * @module @deepseek-ai/dsh-session-registry-file/liveness - */ - -/** - * Signal-0 probe: report whether a pid currently exists. - * - * `kill(pid, 0)` sends no signal and only tests existence. `ESRCH` means no such - * process. `EPERM` means the process exists but is owned by another user, which - * is still alive — reporting it dead would drop a live record. Any other errno - * is unexpected and propagates rather than being read as a liveness answer. - * @param pid - the operating-system process id to probe. - * @param kill - signal sender, defaulting to `process.kill`; injected by tests. - * @returns whether a process with this pid exists. - */ -export function isPidAlive( - pid: number, - kill: (pid: number, signal: number) => void = process.kill.bind(process), -): boolean { - try { - kill(pid, 0) - return true - } catch (error) { - const code = (error as NodeJS.ErrnoException).code - if (code === 'ESRCH') return false - if (code === 'EPERM') return true - throw error - } -} diff --git a/packages/session-registry/session-registry-file/tests/fixtures/register-once.ts b/packages/session-registry/session-registry-file/tests/fixtures/register-once.ts deleted file mode 100644 index 56e05d11fd..0000000000 --- a/packages/session-registry/session-registry-file/tests/fixtures/register-once.ts +++ /dev/null @@ -1,27 +0,0 @@ -/** - * Concurrency-test driver: register one session in a real separate process, - * report readiness on stdout, then stay alive until the parent closes stdin. - * - * Staying alive is load-bearing. The registry prunes records whose process is - * gone, so a driver that exited after writing would be pruned by the next - * writer — the test would then measure pruning instead of the concurrent - * read-modify-write it exists to cover. Argv: ` `. - */ - -import { Context } from 'cordis' -import { SessionId } from '@deepseek-ai/dsh-session' -import SessionRegistryFile from '@deepseek-ai/dsh-session-registry-file' - -const [root, sessionId] = process.argv.slice(2) -if (root === undefined || sessionId === undefined) throw new Error('usage: register-once ') - -const ctx = new Context() -await ctx.plugin(SessionRegistryFile, { root, lockStaleMs: 10_000, lockRetries: 60 }) -await ctx.sessionRegistry.register({ sessionId: SessionId(sessionId), cwd: process.cwd() }) -process.stdout.write('registered\n') - -// Hold the process open so its record stays live; the parent ends the run by -// closing stdin, and never disposes the fiber, so no deregistration races the -// parent's read. -process.stdin.resume() -process.stdin.on('end', () => { process.exit(0) }) diff --git a/packages/session-registry/session-registry-file/tests/session-registry-file.spec.ts b/packages/session-registry/session-registry-file/tests/session-registry-file.spec.ts deleted file mode 100644 index e48bd46385..0000000000 --- a/packages/session-registry/session-registry-file/tests/session-registry-file.spec.ts +++ /dev/null @@ -1,382 +0,0 @@ -/** - * Tests for the cross-process live-session registry: records survive a - * round-trip, dead pids are pruned, a recycled pid cannot resurrect a foreign - * record, the file format rejects foreign and torn media without hiding live - * sessions, disposal deregisters, and concurrent registrations from independent - * processes all survive (the failure the advisory lock exists to prevent). - */ - -import { afterEach, beforeEach, describe, expect, it } from 'vitest' -import { Context } from 'cordis' -import { mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs' -import { execFile, spawn } from 'node:child_process' -import { tmpdir } from 'node:os' -import { fileURLToPath } from 'node:url' -import { join } from 'node:path' -import { promisify } from 'node:util' -import { SessionId } from '@deepseek-ai/dsh-session' -import { BootId } from '@deepseek-ai/dsh-session-registry' -import SessionRegistryFile, { - REGISTRY_FILE_NAME, - SESSION_REGISTRY_FORMAT_VERSION, - isPidAlive, - parseRegistry, - serializeRegistry, -} from '@deepseek-ai/dsh-session-registry-file' - -const run = promisify(execFile) - -let root: string - -beforeEach(() => { - root = mkdtempSync(join(tmpdir(), 'dsh-session-registry-test-')) -}) -afterEach(() => { - rmSync(root, { recursive: true, force: true }) -}) - -/** Mount the service on a fresh Cordis fiber, returning it with its context. */ -async function service(): Promise<{ ctx: Context; registry: SessionRegistryFile }> { - const ctx = new Context() - await ctx.plugin(SessionRegistryFile, { root }) - return { ctx, registry: ctx.sessionRegistry as SessionRegistryFile } -} - -const file = (): string => join(root, REGISTRY_FILE_NAME) - -describe('config resolution', () => { - it('applies the shipped lock defaults when a caller omits them', async () => { - // `ctx.plugin` runs the schema, which fills these in, so the constructor's - // own resolution is reachable only by constructing the service directly — - // the path a programmatic embedder takes. - const ctx = new Context() - const service = new SessionRegistryFile(ctx, { root }) - await service.register({ sessionId: SessionId('defaulted'), cwd: '/w' }) - expect((await service.list()).map(record => record.sessionId)).toEqual(['defaulted']) - await ctx.fiber.dispose() - }) - - it('honors explicitly configured lock tunables', async () => { - const ctx = new Context() - await ctx.plugin(SessionRegistryFile, { root, lockStaleMs: 5_000, lockRetries: 3 }) - await ctx.sessionRegistry.register({ sessionId: SessionId('tuned'), cwd: '/w' }) - expect((await ctx.sessionRegistry.list()).map(record => record.sessionId)).toEqual(['tuned']) - await ctx.fiber.dispose() - }) -}) - -describe('register and list', () => { - it('publishes a record readable by an independent service instance', async () => { - const first = await service() - await first.registry.register({ sessionId: SessionId('sess-1'), cwd: '/tmp/project' }) - - // A second instance stands in for another process reading the same file. - const reader = await service() - const listed = await reader.registry.list() - expect(listed).toHaveLength(1) - expect(listed[0]).toMatchObject({ - sessionId: 'sess-1', - cwd: '/tmp/project', - pid: process.pid, - }) - await first.ctx.fiber.dispose() - await reader.ctx.fiber.dispose() - }) - - it('replaces an earlier record for the same session id', async () => { - const { ctx, registry } = await service() - await registry.register({ sessionId: SessionId('sess-1'), cwd: '/a' }) - await registry.register({ sessionId: SessionId('sess-1'), cwd: '/b' }) - const listed = await registry.list() - expect(listed).toHaveLength(1) - // The later registration wins: `cwd` distinguishes the two calls. - expect(listed[0]?.cwd).toBe('/b') - await ctx.fiber.dispose() - }) - - it('creates the registry root private and the file owner-only', async () => { - const { ctx, registry } = await service() - await registry.register({ sessionId: SessionId('sess-1'), cwd: '/a' }) - expect(statSync(root).mode & 0o777).toBe(0o700) - expect(statSync(file()).mode & 0o777).toBe(0o600) - await ctx.fiber.dispose() - }) -}) - -describe('liveness pruning', () => { - it('drops a record whose process is gone', async () => { - const { ctx, registry } = await service() - await registry.register({ sessionId: SessionId('live'), cwd: '/a' }) - - // A real exited pid: spawn a process, wait for it, then claim its id. The - // kernel has reaped it, so signal 0 reports ESRCH. - const dead = await run(process.execPath, ['-e', 'process.stdout.write(String(process.pid))']) - const deadPid = Number(dead.stdout) - expect(isPidAlive(deadPid)).toBe(false) - const stored = parseRegistry(readFileSync(file(), 'utf8')).records - writeFileSync(file(), serializeRegistry([ - ...stored, - { sessionId: SessionId('ghost'), pid: deadPid, cwd: '/b', startedAt: 1, bootId: BootId('boot-x') }, - ])) - - const listed = await registry.list() - expect(listed.map(record => record.sessionId)).toEqual(['live']) - // The prune is durable, not just filtered in memory. - expect(parseRegistry(readFileSync(file(), 'utf8')).records.map(r => r.sessionId)).toEqual(['live']) - await ctx.fiber.dispose() - }) - - it('keeps a live record owned by another user (EPERM means alive)', () => { - const eperm = (): never => { - const error = new Error('operation not permitted') as NodeJS.ErrnoException - error.code = 'EPERM' - throw error - } - expect(isPidAlive(1, eperm)).toBe(true) - }) - - it('propagates an unexpected errno instead of guessing liveness', () => { - const einval = (): never => { - const error = new Error('invalid') as NodeJS.ErrnoException - error.code = 'EINVAL' - throw error - } - expect(() => isPidAlive(1, einval)).toThrow('invalid') - }) -}) - -describe('pid recycling', () => { - it('deregistration removes only this incarnation, not a namesake pid', async () => { - const { ctx, registry } = await service() - const disposer = await registry.register({ sessionId: SessionId('mine'), cwd: '/a' }) - - // A foreign record reusing THIS live pid under a different session and boot - // id: deregistering must not delete it. - const stored = parseRegistry(readFileSync(file(), 'utf8')).records - writeFileSync(file(), serializeRegistry([ - ...stored, - { sessionId: SessionId('other'), pid: process.pid, cwd: '/b', startedAt: 2, bootId: BootId('boot-other') }, - ])) - - // Awaiting the disposer is the contract: the record is durably gone when it - // settles, so the assertion needs no timing slack. - await disposer() - const listed = await registry.list() - expect(listed.map(record => record.sessionId)).toEqual(['other']) - await ctx.fiber.dispose() - }) -}) - -describe('file format', () => { - it('round-trips records', () => { - const records = [{ - sessionId: SessionId('s'), pid: 5 as const, cwd: '/c', startedAt: 7, bootId: BootId('b'), - }] - expect(parseRegistry(serializeRegistry(records))).toEqual({ records, intact: true }) - }) - - it('stamps the format version', () => { - const stamped = JSON.parse(serializeRegistry([])) as { version: number } - expect(stamped.version).toBe(SESSION_REGISTRY_FORMAT_VERSION) - }) - - it.each([ - ['torn json', '{"version":0,"records":[{'], - ['a foreign version', '{"version":99,"records":[]}'], - ['a non-object root', '[]'], - ['a null root', 'null'], - ['a non-array records field', '{"version":0,"records":{}}'], - ])('reads %s as an empty, non-intact registry', (_label, text) => { - expect(parseRegistry(text)).toEqual({ records: [], intact: false }) - }) - - it.each([ - ['a missing session id', { pid: 1, cwd: '/a', startedAt: 0, bootId: 'b' }], - ['a non-integer pid', { sessionId: 's', pid: 1.5, cwd: '/a', startedAt: 0, bootId: 'b' }], - ['a non-positive pid', { sessionId: 's', pid: 0, cwd: '/a', startedAt: 0, bootId: 'b' }], - ['an empty cwd', { sessionId: 's', pid: 1, cwd: '', startedAt: 0, bootId: 'b' }], - ['a negative startedAt', { sessionId: 's', pid: 1, cwd: '/a', startedAt: -1, bootId: 'b' }], - ['a missing boot id', { sessionId: 's', pid: 1, cwd: '/a', startedAt: 0 }], - ['a non-string title', { sessionId: 's', pid: 1, cwd: '/a', startedAt: 0, bootId: 'b', title: 7 }], - ['a non-object row', 'nonsense'], - ])('drops a row with %s but keeps its intact siblings', (_label, row) => { - const good = { sessionId: 'keep', pid: 1, cwd: '/a', startedAt: 0, bootId: 'b' } - const text = JSON.stringify({ version: SESSION_REGISTRY_FORMAT_VERSION, records: [row, good] }) - const parsed = parseRegistry(text) - expect(parsed.records.map(record => record.sessionId)).toEqual(['keep']) - expect(parsed.intact).toBe(false) - }) - - it('heals a damaged medium on the next locked write', async () => { - writeFileSync(file(), 'not json at all') - const { ctx, registry } = await service() - await registry.list() - expect(parseRegistry(readFileSync(file(), 'utf8')).intact).toBe(true) - await ctx.fiber.dispose() - }) - - it('reads a missing file as no live sessions', async () => { - const { ctx, registry } = await service() - rmSync(file(), { force: true }) - expect(await registry.list()).toEqual([]) - await ctx.fiber.dispose() - }) - -}) - -describe('failure reporting', () => { - it('tolerates a registry file another process created first', async () => { - // Two services racing `ensureFile`: the loser sees EEXIST, which is the - // intended outcome rather than an error, and both still publish. - const first = await service() - const second = await service() - await Promise.all([ - first.registry.register({ sessionId: SessionId('a'), cwd: '/a' }), - second.registry.register({ sessionId: SessionId('b'), cwd: '/b' }), - ]) - expect((await first.registry.list()).map(record => record.sessionId).sort()).toEqual(['a', 'b']) - await first.ctx.fiber.dispose() - await second.ctx.fiber.dispose() - }) - - it('warns instead of throwing when deregistration fails during teardown', async () => { - const { ctx, registry } = await service() - await registry.register({ sessionId: SessionId('doomed'), cwd: '/w' }) - // Make the registry path unusable, so the disposer's own write fails while the - // fiber is already unwinding. Teardown must still complete. - rmSync(root, { recursive: true, force: true }) - mkdirSync(join(root, REGISTRY_FILE_NAME), { recursive: true }) - await expect(ctx.fiber.dispose()).resolves.not.toThrow() - }) - - it('propagates a read failure that is not a missing file', async () => { - const { ctx, registry } = await service() - await registry.register({ sessionId: SessionId('sess-1'), cwd: '/w' }) - // A directory where the file belongs makes the read fail with EISDIR, which - // is corruption rather than "no live sessions" and must not read as empty. - rmSync(file(), { force: true }) - mkdirSync(file(), { recursive: true }) - await expect(registry.list()).rejects.toThrow() - rmSync(file(), { recursive: true, force: true }) - await ctx.fiber.dispose() - }) -}) - -describe('retitle', () => { - it('replaces the recorded title of a session this process owns', async () => { - const { ctx, registry } = await service() - await registry.register({ sessionId: SessionId('sess-1'), cwd: '/a' }) - expect((await registry.list())[0]?.title).toBeUndefined() - - await registry.retitle(SessionId('sess-1'), 'first') - expect((await registry.list())[0]?.title).toBe('first') - await registry.retitle(SessionId('sess-1'), 'second') - expect((await registry.list())[0]?.title).toBe('second') - await ctx.fiber.dispose() - }) - - it('accepts a registration that already carries a title', async () => { - const { ctx, registry } = await service() - await registry.register({ sessionId: SessionId('sess-1'), cwd: '/a', title: 'preset' }) - expect((await registry.list())[0]?.title).toBe('preset') - await ctx.fiber.dispose() - }) - - it('leaves a same-id record owned by another incarnation untouched', async () => { - const { ctx, registry } = await service() - // Same live pid, different boot id: another incarnation's record must not be - // retitled by this one. - writeFileSync(file(), serializeRegistry([ - { sessionId: SessionId('foreign'), pid: process.pid, cwd: '/b', startedAt: 2, bootId: BootId('boot-other') }, - ])) - await registry.retitle(SessionId('foreign'), 'not mine') - expect((await registry.list())[0]?.title).toBeUndefined() - await ctx.fiber.dispose() - }) - - it('ignores an unknown session id, since a title can resolve after removal', async () => { - const { ctx, registry } = await service() - await expect(registry.retitle(SessionId('never-registered'), 'ghost')).resolves.toBeUndefined() - expect(await registry.list()).toEqual([]) - await ctx.fiber.dispose() - }) -}) - -describe('same-process concurrency', () => { - it('keeps every record when one process registers several sessions at once', async () => { - // The advisory lock is tracked per process, so same-process callers contend - // for it through its bounded retry budget instead of queueing. Past a dozen - // or so overlapping calls that budget runs out and a registration rejects — - // and callers publish fire-and-forget, so the rejection is swallowed and the - // session silently vanishes from the listing. The service therefore - // serializes its own callers; the lock only excludes other processes. - const { ctx, registry } = await service() - // Register once first so the file and directory already exist: without that, - // the concurrent calls serialize behind their own mkdir/create awaits and the - // overlap under test never happens. - await registry.register({ sessionId: SessionId('warm'), cwd: '/w' }) - const settled = await Promise.allSettled(Array.from({ length: 24 }, (_unused, index) => - registry.register({ sessionId: SessionId(`bulk-${String(index)}`), cwd: `/w/${String(index)}` }))) - - // Every call must SUCCEED, not merely leave the file consistent. Callers - // publish fire-and-forget, so a rejection is swallowed and the session - // silently vanishes from the listing rather than failing loudly. - expect(settled.filter(outcome => outcome.status === 'rejected')).toEqual([]) - const expected = [...Array.from({ length: 24 }, (_unused, index) => `bulk-${String(index)}`), 'warm'].sort() - expect((await registry.list()).map(record => record.sessionId).sort()).toEqual(expected) - await ctx.fiber.dispose() - }) - - it('keeps serving later callers after one cycle fails', async () => { - const { ctx, registry } = await service() - // A directory sitting where the registry file must be makes one cycle fail - // without breaking the shared chain for the calls queued behind it. - rmSync(root, { recursive: true, force: true }) - mkdirSync(join(root, REGISTRY_FILE_NAME), { recursive: true }) - await expect(registry.register({ sessionId: SessionId('doomed'), cwd: '/w' })).rejects.toThrow() - - rmSync(root, { recursive: true, force: true }) - await registry.register({ sessionId: SessionId('after'), cwd: '/w' }) - expect((await registry.list()).map(record => record.sessionId)).toEqual(['after']) - await ctx.fiber.dispose() - }) -}) - -describe('cross-process concurrency', () => { - it('keeps every record when independent processes register at once', async () => { - // The regression that motivates the advisory lock: unlocked whole-file - // republication loses records under concurrent writers. Real processes are - // required — same-process promises would serialize on the event loop. - const driver = fileURLToPath(new URL('./fixtures/register-once.ts', import.meta.url)) - const count = 8 - const repoRoot = fileURLToPath(new URL('../../../../', import.meta.url)) - const tsx = join(repoRoot, 'node_modules/tsx/dist/loader.mjs') - // Source plane: tsx resolves the workspace import through the root - // tsconfig `paths` to `src`, so this runs without a build step. - const env = { ...process.env, TSX_TSCONFIG_PATH: join(repoRoot, 'tsconfig.json') } - - const children = Array.from({ length: count }, (_unused, index) => - spawn(process.execPath, ['--import', tsx, driver, root, `sess-${String(index)}`], { - env, - stdio: ['pipe', 'pipe', 'inherit'], - })) - try { - // Every child must have committed its record AND still be alive when the - // file is read, so the assertion sees concurrent writes rather than prunes. - await Promise.all(children.map(child => new Promise((resolve, reject) => { - child.stdout.once('data', () => { resolve() }) - child.once('error', reject) - child.once('exit', (code) => { reject(new Error(`driver exited early with ${String(code)}`)) }) - }))) - - const stored = parseRegistry(readFileSync(file(), 'utf8')) - expect(stored.intact).toBe(true) - expect(stored.records.map(record => record.sessionId).sort()).toEqual( - Array.from({ length: count }, (_unused, index) => `sess-${String(index)}`).sort(), - ) - } finally { - for (const child of children) child.stdin.end() - await Promise.all(children.map(child => new Promise((resolve) => { child.once('exit', () => { resolve() }) }))) - } - }, 60_000) -}) diff --git a/packages/session-registry/session-registry-file/tsconfig.json b/packages/session-registry/session-registry-file/tsconfig.json deleted file mode 100644 index 0ab9c6a3a4..0000000000 --- a/packages/session-registry/session-registry-file/tsconfig.json +++ /dev/null @@ -1,21 +0,0 @@ -{ - "extends": "../../../tsconfig.base.json", - "compilerOptions": { - "rootDir": "src", - "outDir": "lib/types" - }, - "include": [ - "src" - ], - "references": [ - { - "path": "../session-registry" - }, - { - "path": "../../support/invariants" - }, - { - "path": "../../core/session" - } - ] -} diff --git a/packages/session-registry/session-registry-live/README.i18n.yaml b/packages/session-registry/session-registry-live/README.i18n.yaml deleted file mode 100644 index 2d4554f70c..0000000000 --- a/packages/session-registry/session-registry-live/README.i18n.yaml +++ /dev/null @@ -1,6 +0,0 @@ -# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each -# side as of the last confirmed-consistent state. Both languages carry equal authority; -# after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write packages/session-registry/session-registry-live/README.md -README.md: 0404915a97c03999210b0c4e0356cd2cecb2b040 -README.zh.md: 5bfb9a90db2578bfe6517dd9cd3d11ebd043f515 diff --git a/packages/session-registry/session-registry-live/README.md b/packages/session-registry/session-registry-live/README.md deleted file mode 100644 index 0404915a97..0000000000 --- a/packages/session-registry/session-registry-live/README.md +++ /dev/null @@ -1,32 +0,0 @@ -# @deepseek-ai/dsh-session-registry-live - -English | [中文](README.zh.md) - -Publishes every live session in this process into the [session registry](../session-registry/README.md), so `dsh list-sessions` lists the sessions a server creates on demand rather than only the one a launcher minted up front. - -## Behavior - -Registration follows session lifecycle rather than a launcher-known identity: the plugin publishes every session present at mount and every later `session/created`, and removes a record when its session is disposed. One path therefore serves both the TUI's single session and the browser UI's one-per-conversation sessions. - -A session whose header carries no `cwd` is skipped — the listing's workspace column would have nothing truthful to show. - -`session/title` events are mirrored onto the record through `retitle`, so the latest logged title reaches the listing. Carrying the title in the record is what keeps the reader backend-agnostic: the log's location, file format, and compression are per-deployment choices (the shipped TUI writes zstd-compressed JSONL), so an independent process cannot portably parse one. - -Publication is fire-and-forget with a warning on failure: the registry is an observability aid, so a registry fault must not fail a working agent session. A session that ends while its registration is still in flight leaves a tombstone the completing registration observes, so its record cannot outlive the session until a pid-based prune. - -## Config - -None. Every published record is derived from the session itself, so no deployment-varying choice is left to configure. - -## Model Experience - -None, as this package registers no tools, injects no prompts, and appends no session events; it only mirrors existing lifecycle and title events into a host-side process record. - -#### KV Cache effect - -Independent of live requests: the plugin reads session events and writes a separate registry file without touching any request prefix, so it cannot invalidate provider cache reuse. - -## Known Limitations and Deferred Work - -- **A skipped session is invisible, not deferred** — a session created without a `cwd` is never published, even if a workspace becomes known later; there is no re-check. -- **Title mirroring costs one registry write per revision** — each `session/title` event triggers a locked read-modify-write, so a deployment with an aggressive retitling cadence pays that write per revision. diff --git a/packages/session-registry/session-registry-live/README.zh.md b/packages/session-registry/session-registry-live/README.zh.md deleted file mode 100644 index 5bfb9a90db..0000000000 --- a/packages/session-registry/session-registry-live/README.zh.md +++ /dev/null @@ -1,32 +0,0 @@ -# @deepseek-ai/dsh-session-registry-live - -[English](README.md) | 中文 - -把本进程内每个活跃会话发布到[会话注册表](../session-registry/README.md),因此 `dsh list-sessions` 能列出服务端按需创建的所有会话,而不是只列出启动器一开始铸出的那一个。 - -## 行为 - -注册跟随会话生命周期,而不依赖启动器已知的身份:插件会发布挂载时已存在的每个会话,以及此后每个 `session/created`,并在会话被 dispose(资源释放)时移除对应记录。因此同一条路径既服务 TUI 的单个会话,也服务浏览器 UI 的每对话一个的多个会话。 - -会话头不带 `cwd` 时会被跳过:列表的工作区列拿不到任何真实内容可展示。 - -`session/title` 事件通过 `retitle` 镜像到记录上,因此最新记录的标题能到达列表。把标题带在记录里,正是让读取方与后端无关的原因:日志的位置、文件格式和压缩都是逐部署的选择(随附的 TUI 写入 Zstandard 压缩的 JSONL),因此独立进程无法以可移植的方式解析它。 - -发布是 fire-and-forget,失败只发出警告:注册表是一项可观测性辅助设施,因此注册表故障绝不能让正常工作的 agent(智能体)会话失败。会话在其注册仍在途中时结束,会留下一个 tombstone,让即将完成的注册观测到,因此它的记录不会一直存活到某次基于 pid 的清理才消失。 - -## 配置 - -无。每条发布的记录都从会话本身派生而来,因此没有留下任何逐部署的选择需要配置。 - -## 模型体验 - -无。该包(package)不注册工具、不注入提示词,也不追加会话事件;它只把既有的生命周期事件和标题事件镜像进宿主侧的进程记录。 - -#### KV 缓存影响 - -与实时请求相互独立:该插件读取会话事件,并写入一个独立的注册表文件,不触碰任何请求前缀,因此它无法使提供方 cache 复用失效。 - -## 已知限制与延期工作 - -- **被跳过的会话是不可见,而非延后处理**——创建时不带 `cwd` 的会话永不发布,即使之后工作区变为已知也不会;没有重新检查机制。 -- **标题镜像每次修订都要付出一次注册表写入**——每个 `session/title` 事件都会触发一次加锁的读取、修改和写入,因此改名节奏激进的部署要按修订次数付出这些写入。 diff --git a/packages/session-registry/session-registry-live/package.json b/packages/session-registry/session-registry-live/package.json deleted file mode 100644 index f0c5aeddc6..0000000000 --- a/packages/session-registry/session-registry-live/package.json +++ /dev/null @@ -1,44 +0,0 @@ -{ - "name": "@deepseek-ai/dsh-session-registry-live", - "description": "Publishes every live session into the cross-process session registry that `dsh list-sessions` reads", - "version": "0.0.1", - "private": true, - "type": "module", - "main": "lib/index.js", - "types": "lib/types/index.d.ts", - "exports": { - ".": { - "types": "./lib/types/index.d.ts", - "default": "./lib/index.js" - }, - "./invariant": { - "types": "./lib/types/invariant.d.ts", - "default": "./lib/invariant.js" - }, - "./src/*": "./src/*", - "./package.json": "./package.json" - }, - "files": [ - "lib/index.js", - "lib/invariant.js", - "lib/types/**/*.d.ts", - "lib/types/**/*.d.ts.map", - "src" - ], - "license": "BSD-3-Clause", - "peerDependencies": { - "@deepseek-ai/dsh-invariants": "^0.0.1", - "@deepseek-ai/dsh-session": "^0.0.1", - "@deepseek-ai/dsh-session-registry": "^0.0.1", - "@deepseek-ai/dsh-session-title": "^0.0.1", - "cordis": "^4.0.0-rc.6" - }, - "devDependencies": { - "@deepseek-ai/dsh-invariants": "workspace:^", - "@deepseek-ai/dsh-session": "workspace:^", - "@deepseek-ai/dsh-session-registry": "workspace:^", - "@deepseek-ai/dsh-session-registry-file": "workspace:^", - "@deepseek-ai/dsh-session-title": "workspace:^", - "cordis": "^4.0.0-rc.6" - } -} diff --git a/packages/session-registry/session-registry-live/src/index.ts b/packages/session-registry/session-registry-live/src/index.ts deleted file mode 100644 index 8e34487e65..0000000000 --- a/packages/session-registry/session-registry-live/src/index.ts +++ /dev/null @@ -1,84 +0,0 @@ -/** - * Publishes every live session in this process into the cross-process session - * registry, so `dsh list-sessions` lists sessions a server creates on demand rather than - * only the one a launcher minted up front. - * - * Mounted in a composition whose sessions come and go — the browser UI creates - * one per conversation — this plugin follows `session/created` and - * `session/disposed` instead of registering a single launcher-known identity. - * A session with no `cwd` in its header is skipped: the registry's workspace - * column would have nothing truthful to show, and a subagent child is exactly - * that case. Titles are mirrored into the record as `session/title` events - * arrive, so a reader never has to parse a backend's log format. - * @module @deepseek-ai/dsh-session-registry-live - */ - -import type { Context } from 'cordis' -import type { Session } from '@deepseek-ai/dsh-session' -// Empty type imports carry the Context merges this plugin relies on: the -// `sessionRegistry` service and the `session/title` session event. -import type {} from '@deepseek-ai/dsh-session-registry' -import type {} from '@deepseek-ai/dsh-session-title' - -/** Cordis plugin name. */ -export const name = 'session-registry-live' - -/** Services required before sessions can be followed and records published. */ -export const inject = ['sessions', 'sessionRegistry'] - -/** - * Follow session lifecycle and keep the registry in step. - * @param ctx - context carrying the session store and the registry service. - */ -export function apply(ctx: Context): void { - /** - * Per-session registration state. `'disposing'` is a tombstone written when a - * session ends while its registration is still in flight: without it the - * late-arriving disposer would be stored for a session that no longer exists - * and its record would outlive the session until a pid-based prune. - */ - const registered = new Map Promise) | 'disposing'>() - - const publish = (session: Session): void => { - const cwd = session.header.cwd - // A session without a workspace has no listable location; skipping keeps the - // registry free of rows `dsh list-sessions` could not render truthfully. - if (cwd === undefined) return - void ctx.sessionRegistry.register({ sessionId: session.id, cwd }) - .then((dispose) => { - if (registered.get(session) === 'disposing') { - registered.delete(session) - void dispose() - return - } - registered.set(session, dispose) - }) - .catch((error: unknown) => { - registered.delete(session) - ctx.logger.warn('failed to publish session %s: %s', session.id, String(error)) - }) - } - - for (const session of ctx.sessions.list()) publish(session) - ctx.on('session/created', (session) => { publish(session) }, { global: true }) - ctx.on('session/disposed', (session) => { - const entry = registered.get(session) - if (typeof entry === 'function') { - registered.delete(session) - void entry() - return - } - // Registration is still in flight; leave a tombstone for it to observe. - registered.set(session, 'disposing') - }, { global: true }) - - // Mirror title revisions onto the record. A title arrives after registration - // and may be replaced, so the listing tracks the latest logged value. - ctx.on('session/event', (session, event) => { - if (event.type !== 'session/title') return - const { title } = event.data - void ctx.sessionRegistry.retitle(session.id, title).catch((error: unknown) => { - ctx.logger.warn('failed to retitle %s: %s', session.id, String(error)) - }) - }, { global: true }) -} diff --git a/packages/session-registry/session-registry-live/src/invariant.ts b/packages/session-registry/session-registry-live/src/invariant.ts deleted file mode 100644 index 19d2a9f483..0000000000 --- a/packages/session-registry/session-registry-live/src/invariant.ts +++ /dev/null @@ -1,31 +0,0 @@ -/** - * Package-owned invariant companion for `@deepseek-ai/dsh-session-registry-live`. - * @module @deepseek-ai/dsh-session-registry-live/invariant - */ - -/* jscpd:ignore-start */ -import type { Context } from 'cordis' -import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' - -const PACKAGE_NAME = '@deepseek-ai/dsh-session-registry-live' - -/** Cordis companion plugin name. */ -export const name = 'session-registry-live-invariant' -/** Service required before the companion can reserve package ownership. */ -export const inject = ['invariants'] - -/** - * No runtime invariant: this plugin owns no durable state of its own — the - * uniqueness and liveness relations over published records are checked by the - * companion in `@deepseek-ai/dsh-session-registry`, which owns that file. - */ -const install: InvariantInstaller = () => {} - -/** - * Register this package's invariant companion. - * @param ctx - Cordis context carrying the invariant service. - * @returns the installed registration's disposer after setup succeeds. - */ -export const apply = (ctx: Context): Promise<() => void> => - Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) -/* jscpd:ignore-end */ diff --git a/packages/session-registry/session-registry-live/tests/session-registry-live.spec.ts b/packages/session-registry/session-registry-live/tests/session-registry-live.spec.ts deleted file mode 100644 index ec0973259e..0000000000 --- a/packages/session-registry/session-registry-live/tests/session-registry-live.spec.ts +++ /dev/null @@ -1,227 +0,0 @@ -/** - * Tests for the live-session publisher over the REAL session store, so - * publication follows the store's actual lifecycle dispatch rather than a - * hand-built event emitter: sessions created after mount are published, - * disposal removes their records, a session without a workspace is skipped, and - * logged title revisions are mirrored onto the record so a reader never parses a - * backend's log format. - */ - -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { Context } from 'cordis' -import { mkdtempSync, rmSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' -import { type SessionRegistryRecord } from '@deepseek-ai/dsh-session-registry' -import SessionRegistryFile from '@deepseek-ai/dsh-session-registry-file' -import * as live from '@deepseek-ai/dsh-session-registry-live' -// Empty type import carries the `session/title` event into the session-event map. -import type {} from '@deepseek-ai/dsh-session-title' - -let root: string - -beforeEach(() => { root = mkdtempSync(join(tmpdir(), 'dsh-registry-live-test-')) }) -afterEach(() => { - rmSync(root, { recursive: true, force: true }) - vi.restoreAllMocks() -}) - -/** Mount the real store plus the publisher. */ -async function mount(): Promise { - const ctx = new Context() - await ctx.plugin(SessionStore) - await ctx.plugin(SessionRegistryFile, { root, lockStaleMs: 10_000, lockRetries: 20 }) - await ctx.plugin(live) - return ctx -} - -/** Let the publisher's fire-and-forget registration reach durability. */ -const settle = (): Promise => new Promise((resolve) => { setTimeout(resolve, 200) }) - -/** Read the registry through an independent service, as `dsh list-sessions` would. */ -async function listExternally(): Promise { - const reader = new Context() - await reader.plugin(SessionRegistryFile, { root, lockStaleMs: 10_000, lockRetries: 20 }) - const records = await reader.sessionRegistry.list() - await reader.fiber.dispose() - return records -} - -describe('publishing', () => { - it('publishes sessions that already exist when the plugin mounts', async () => { - // A composition may mount the publisher after sessions exist (a resumed - // session, or plugin order), so mount-time adoption is its own path. - const ctx = new Context() - await ctx.plugin(SessionStore) - ctx.sessions.create(SessionId('preexisting'), { meta: { cwd: '/work/a' } }) - await ctx.plugin(SessionRegistryFile, { root, lockStaleMs: 10_000, lockRetries: 20 }) - await ctx.plugin(live) - await settle() - - expect((await ctx.sessionRegistry.list()).map(record => record.sessionId)).toEqual(['preexisting']) - await ctx.fiber.dispose() - }) - - it('publishes a session created after mount', async () => { - const ctx = await mount() - ctx.sessions.create(SessionId('later'), { meta: { cwd: '/work/b' } }) - await settle() - - const listed = await ctx.sessionRegistry.list() - expect(listed).toHaveLength(1) - expect(listed[0]).toMatchObject({ sessionId: 'later', cwd: '/work/b' }) - await ctx.fiber.dispose() - }) - - it('skips a session with no workspace, having nothing truthful to list', async () => { - const ctx = await mount() - ctx.sessions.create(SessionId('no-cwd')) - await settle() - expect(await ctx.sessionRegistry.list()).toEqual([]) - await ctx.fiber.dispose() - }) - - it('has no title until one is logged', async () => { - const ctx = await mount() - ctx.sessions.create(SessionId('fresh'), { meta: { cwd: '/work/c' } }) - await settle() - expect((await ctx.sessionRegistry.list())[0]?.title).toBeUndefined() - await ctx.fiber.dispose() - }) - - it('mirrors the latest logged title onto the record', async () => { - const ctx = await mount() - const session = ctx.sessions.create(SessionId('titled'), { meta: { cwd: '/work/d' } }) - await settle() - - session.append('session/title', { title: 'first guess', messageSeqs: [0], source: { kind: 'fallback' } }) - await settle() - expect((await ctx.sessionRegistry.list())[0]?.title).toBe('first guess') - - // A revision replaces the previous value rather than accumulating. - session.append('session/title', { title: 'better title', messageSeqs: [0], source: { kind: 'fallback' } }) - await settle() - expect((await ctx.sessionRegistry.list())[0]?.title).toBe('better title') - await ctx.fiber.dispose() - }) - - it('ignores session events other than a title revision', async () => { - const ctx = await mount() - const session = ctx.sessions.create(SessionId('busy'), { meta: { cwd: '/work/z' } }) - await settle() - const retitle = vi.spyOn(ctx.sessionRegistry, 'retitle') - - session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } }) - await settle() - expect(retitle).not.toHaveBeenCalled() - await ctx.fiber.dispose() - }) - - it('retitles only the session that logged the event', async () => { - const ctx = await mount() - const first = ctx.sessions.create(SessionId('one'), { meta: { cwd: '/work/e' } }) - ctx.sessions.create(SessionId('two'), { meta: { cwd: '/work/f' } }) - await settle() - - first.append('session/title', { title: 'only mine', messageSeqs: [0], source: { kind: 'fallback' } }) - await settle() - const byId = new Map((await ctx.sessionRegistry.list()).map(record => [record.sessionId, record.title])) - expect(byId.get(SessionId('one'))).toBe('only mine') - expect(byId.get(SessionId('two'))).toBeUndefined() - await ctx.fiber.dispose() - }) - - it('publishes every concurrently created session', async () => { - const ctx = await mount() - for (let index = 0; index < 5; index += 1) { - ctx.sessions.create(SessionId(`bulk-${String(index)}`), { meta: { cwd: `/work/bulk-${String(index)}` } }) - } - await settle() - expect((await ctx.sessionRegistry.list()).map(record => record.sessionId).sort()) - .toEqual(['bulk-0', 'bulk-1', 'bulk-2', 'bulk-3', 'bulk-4']) - await ctx.fiber.dispose() - }) -}) - -describe('failure and race handling', () => { - it('removes the record when a session is disposed mid-registration', async () => { - // The tombstone path: the session ends before its registration resolves, so - // the late disposer must be applied instead of stored for a dead session. - const ctx = await mount() - let owner: Context | undefined - await ctx.plugin({ - inject: ['sessions'], - apply: (child: Context) => { - owner = child - child.sessions.create(SessionId('raced'), { meta: { cwd: '/work/race' } }) - }, - }) - // No settle: dispose while `register` is still in flight. - await owner?.fiber.dispose() - await settle() - expect(await ctx.sessionRegistry.list()).toEqual([]) - await ctx.fiber.dispose() - }) - - it('warns and drops the record when publication fails', async () => { - const ctx = await mount() - ctx.sessionRegistry.register = () => Promise.reject(new Error('registry offline')) - const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => undefined) - - ctx.sessions.create(SessionId('unpublishable'), { meta: { cwd: '/work/x' } }) - await settle() - expect(warn.mock.calls.flat().join(' ')).toMatch(/failed to publish session/) - await ctx.fiber.dispose() - }) - - it('warns when a title revision cannot be recorded', async () => { - const ctx = await mount() - const session = ctx.sessions.create(SessionId('titled'), { meta: { cwd: '/work/y' } }) - await settle() - - ctx.sessionRegistry.retitle = () => Promise.reject(new Error('registry offline')) - const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => undefined) - - session.append('session/title', { title: 'doomed', messageSeqs: [0], source: { kind: 'fallback' } }) - await settle() - expect(warn.mock.calls.flat().join(' ')).toMatch(/failed to retitle/) - await ctx.fiber.dispose() - }) -}) - -describe('disposal', () => { - it('removes a record when its own session is disposed, keeping the others', async () => { - const ctx = await mount() - // A session belongs to the fiber that created it, so a child plugin fiber - // gives one session an independent lifetime without disposing the services. - let owner: Context | undefined - await ctx.plugin({ - inject: ['sessions'], - apply: (child: Context) => { - owner = child - child.sessions.create(SessionId('ephemeral'), { meta: { cwd: '/work/e' } }) - }, - }) - ctx.sessions.create(SessionId('durable'), { meta: { cwd: '/work/f' } }) - await settle() - expect(await ctx.sessionRegistry.list()).toHaveLength(2) - - // Disposing only that fiber ends its session, which the publisher follows. - await owner?.fiber.dispose() - await settle() - expect((await ctx.sessionRegistry.list()).map(record => record.sessionId)).toEqual(['durable']) - await ctx.fiber.dispose() - }) - - it('leaves no record behind after the whole tree unloads', async () => { - const ctx = await mount() - ctx.sessions.create(SessionId('a'), { meta: { cwd: '/work/g' } }) - ctx.sessions.create(SessionId('b'), { meta: { cwd: '/work/h' } }) - await settle() - expect(await ctx.sessionRegistry.list()).toHaveLength(2) - - await ctx.fiber.dispose() - expect(await listExternally()).toEqual([]) - }) -}) diff --git a/packages/session-registry/session-registry-live/tsconfig.json b/packages/session-registry/session-registry-live/tsconfig.json deleted file mode 100644 index 456adfc51b..0000000000 --- a/packages/session-registry/session-registry-live/tsconfig.json +++ /dev/null @@ -1,24 +0,0 @@ -{ - "extends": "../../../tsconfig.base.json", - "compilerOptions": { - "rootDir": "src", - "outDir": "lib/types" - }, - "include": [ - "src" - ], - "references": [ - { - "path": "../../support/invariants" - }, - { - "path": "../../core/session" - }, - { - "path": "../session-registry" - }, - { - "path": "../../session-title/session-title" - } - ] -} diff --git a/packages/session-registry/session-registry/README.i18n.yaml b/packages/session-registry/session-registry/README.i18n.yaml deleted file mode 100644 index 211c8222e8..0000000000 --- a/packages/session-registry/session-registry/README.i18n.yaml +++ /dev/null @@ -1,6 +0,0 @@ -# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each -# side as of the last confirmed-consistent state. Both languages carry equal authority; -# after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write packages/session-registry/session-registry/README.md -README.md: 8ab8e232e6cd041e5476d4e6cadcb8b64a85f586 -README.zh.md: 62774e8a2a892c97ba6343f12dd35f827ad63e33 diff --git a/packages/session-registry/session-registry/README.md b/packages/session-registry/session-registry/README.md deleted file mode 100644 index 8ab8e232e6..0000000000 --- a/packages/session-registry/session-registry/README.md +++ /dev/null @@ -1,30 +0,0 @@ -# @deepseek-ai/dsh-session-registry - -English | [中文](README.zh.md) - -Live-session registry seam (`ctx.sessionRegistry`): the contract and record vocabulary for a cross-process registry of the sessions running right now, so a separate short-lived process such as `dsh list-sessions` can answer "what am I running". This package owns no medium — a backend (the lock-guarded JSON file in [`session-registry-file`](../session-registry-file/README.md) today, a database later) implements the abstract service. - -## Shape - -- `register(registration)` — publish `{ sessionId, cwd, title? }` stamped with this process's pid, a per-incarnation `bootId`, and `startedAt`. Replaces any existing record for the same session id. Returns the `ctx.effect` disposer; awaiting it waits for the removal to reach durability. -- `retitle(sessionId, title)` — replace the recorded title of a session **this** process registered. Titles arrive after registration and can be revised, so it is the one mutable field. A record owned by another pid or incarnation is left alone, and an unknown id is a no-op because a title can resolve after the record is gone. -- `list()` — every live record, newest registration last. Liveness is part of the contract, not the backend's discretion: every returned record's process existed at observation time, so a process killed without running its disposer leaves no permanent phantom. - -Backends serialize mutations against concurrent registrars — other processes and overlapping calls in this one — so records are never lost to a torn read-modify-write. - -## Record vocabulary - -`SessionRegistryRecord` carries `sessionId` (unique across live records), `pid`, `cwd`, `startedAt`, a `bootId` distinguishing a recycled pid from the original incarnation, and an optional `title`. The title travels in the record rather than being read from the session log because log location, format, and compression are per-deployment backend choices an independent reader cannot portably parse. - -## Model Experience - -None, as this package registers no tools, injects no prompts, and appends no session events; it defines the host-side listing contract only. - -#### KV Cache effect - -Independent of live requests: the registry never touches a request prefix, so nothing here can invalidate provider cache reuse. - -## Known Limitations and Deferred Work - -- **Records are process-scoped, not agent-scoped** — only top-level launcher surfaces publish. In-process subagents have no process of their own, and out-of-process subagent backends spawn `dsh-jsonrpc-agent` rather than the CLI, so neither appears in a listing. -- **Liveness is pid existence, not health** — a hung or stopped process still lists as running; the contract deliberately makes no judgement about whether a session is making progress. diff --git a/packages/session-registry/session-registry/README.zh.md b/packages/session-registry/session-registry/README.zh.md deleted file mode 100644 index 62774e8a2a..0000000000 --- a/packages/session-registry/session-registry/README.zh.md +++ /dev/null @@ -1,30 +0,0 @@ -# @deepseek-ai/dsh-session-registry - -[English](README.md) | 中文 - -存活会话注册表 seam(`ctx.sessionRegistry`):定义跨进程「当前正在运行哪些会话」注册表的契约与记录词汇,使 `dsh list-sessions` 这类独立的短生命周期进程能够回答「我正在运行什么」。本包不拥有任何介质——由后端实现该抽象服务(今天是 [`session-registry-file`](../session-registry-file/README.md) 中加锁保护的 JSON 文件,将来可以是数据库)。 - -## 形状 - -- `register(registration)`:发布 `{ sessionId, cwd, title? }`,并盖上本进程的 pid、每个 incarnation 独有的 `bootId` 和 `startedAt`。同一会话 id 的既有记录会被替换。返回 `ctx.effect` disposer;await 它即等待移除达到持久性。 -- `retitle(sessionId, title)`:替换**本**进程注册的某个会话的已记录标题。标题在注册之后才到达,并且可以修订,因此它是唯一的可变字段。归属于其他 pid 或其他 incarnation 的记录不受影响;未知 id 为空操作,因为标题可能在记录消失之后才解析出来。 -- `list()`:返回全部存活记录,按注册时间从旧到新排列。存活性属于契约本身,而非后端的自由裁量:每条返回记录的进程在观察时刻都存在,因此未运行 disposer 就被杀掉的进程不会留下永久的幽灵记录。 - -后端必须将变更与并发注册方(其他进程,以及本进程内相互重叠的调用)串行化,使记录不会因撕裂的读改写而丢失。 - -## 记录词汇 - -`SessionRegistryRecord` 携带 `sessionId`(在存活记录中唯一)、`pid`、`cwd`、`startedAt`、用于区分被复用 pid 与原 incarnation 的 `bootId`,以及可选的 `title`。标题随记录传递而非从会话日志读取,因为日志的位置、格式与压缩是各部署后端的选择,独立读取方无法可移植地解析。 - -## 模型体验 - -无。本包不注册工具、不注入提示词、不追加会话事件;它只定义宿主侧的列表契约。 - -#### KV 缓存影响 - -与在途请求无关:注册表从不触碰请求前缀,因此这里不会使提供方缓存复用失效。 - -## 已知限制与后续工作 - -- **记录以进程为粒度,而非以 agent 为粒度**——只有用户直接启动的顶层界面会发布。进程内 subagent 没有自己的进程,进程外 subagent 后端启动的是 `dsh-jsonrpc-agent` 而非本 CLI,两者都不会出现在列表中。 -- **存活性只表示 pid 存在,不表示健康**——挂起或停止的进程仍会被列为运行中;契约刻意不判断会话是否在推进。 diff --git a/packages/session-registry/session-registry/package.json b/packages/session-registry/session-registry/package.json deleted file mode 100644 index 75308999f5..0000000000 --- a/packages/session-registry/session-registry/package.json +++ /dev/null @@ -1,41 +0,0 @@ -{ - "name": "@deepseek-ai/dsh-session-registry", - "description": "Live-session registry seam for the DeepSeek Harness: contract and record vocabulary", - "version": "0.0.1", - "private": true, - "type": "module", - "main": "lib/index.js", - "types": "lib/types/index.d.ts", - "exports": { - ".": { - "types": "./lib/types/index.d.ts", - "default": "./lib/index.js" - }, - "./invariant": { - "types": "./lib/types/invariant.d.ts", - "default": "./lib/invariant.js" - }, - "./src/*": "./src/*", - "./package.json": "./package.json" - }, - "files": [ - "lib/index.js", - "lib/invariant.js", - "lib/types/**/*.d.ts", - "lib/types/**/*.d.ts.map", - "src" - ], - "license": "BSD-3-Clause", - "peerDependencies": { - "@deepseek-ai/dsh-brand": "^0.0.1", - "@deepseek-ai/dsh-invariants": "^0.0.1", - "@deepseek-ai/dsh-session": "^0.0.1", - "cordis": "^4.0.0-rc.6" - }, - "devDependencies": { - "@deepseek-ai/dsh-brand": "workspace:^", - "@deepseek-ai/dsh-invariants": "workspace:^", - "@deepseek-ai/dsh-session": "workspace:^", - "cordis": "^4.0.0-rc.6" - } -} diff --git a/packages/session-registry/session-registry/src/index.ts b/packages/session-registry/session-registry/src/index.ts deleted file mode 100644 index 413317c31c..0000000000 --- a/packages/session-registry/session-registry/src/index.ts +++ /dev/null @@ -1,82 +0,0 @@ -/** - * Live-session registry seam (`ctx.sessionRegistry`): a cross-process registry - * of live `dsh` sessions, so a separate short-lived process such as - * `dsh list-sessions` can answer "what am I running right now". - * - * This package owns only the service contract and the record vocabulary; a - * backend (the lock-guarded JSON file in - * `@deepseek-ai/dsh-session-registry-file` today, a database later) owns the - * medium. Whatever the medium, liveness is part of the contract: {@link list} - * returns only records whose process existed at observation time, so a process - * killed without running its disposer leaves no permanent phantom. - * @module @deepseek-ai/dsh-session-registry - */ - -import { Context, Service } from 'cordis' -import type { SessionId } from '@deepseek-ai/dsh-session' -import { BootId, type SessionRegistryRecord } from './types.ts' - -export { BootId } from './types.ts' -export type { SessionRegistryRecord } from './types.ts' - -declare module 'cordis' { - interface Context { - sessionRegistry: SessionRegistry - } -} - -/** What one process publishes about itself; the service supplies pid and timing. */ -export interface SessionRegistration { - /** The session this process runs. */ - sessionId: SessionId - /** Absolute workspace directory the session acts on. */ - cwd: string - /** Human-readable session title, when one already exists. */ - title?: string -} - -/** - * Cross-process live-session registry. Reads prune dead records, so every - * returned record's process existed at observation time. Backends serialize - * mutations against concurrent registrars — other processes and overlapping - * calls in this one — so records are never lost to a torn read-modify-write. - */ -export abstract class SessionRegistry extends Service { - /** This process incarnation's id, stamped into every record it publishes. */ - protected readonly bootId: BootId - - constructor(ctx: Context, bootId: BootId) { - super(ctx, 'sessionRegistry') - this.bootId = bootId - } - - /** - * Publish this process's record, replacing any stale record for the same - * session id, and prune records whose process is gone. - * @param registration - the session, surface, and workspace to publish. - * @returns the effect disposer that removes this record again; awaiting it - * waits for the removal to reach durability. - */ - abstract register(registration: SessionRegistration): Promise<() => Promise> - - /** - * Replace the recorded title of a session this process registered. - * - * Titles arrive after registration and can be revised, so this is the one - * mutable field. Only a record matching this process and incarnation is - * touched, leaving a same-id record owned by another process alone. An unknown - * session id is a no-op rather than an error: a title can resolve after the - * session's record has already been removed. - * @param sessionId - the session whose recorded title changes. - * @param title - the new title text. - */ - abstract retitle(sessionId: SessionId, title: string): Promise - - /** - * List live sessions, pruning records whose process no longer exists. - * @returns one record per live registered session, newest registration last. - */ - abstract list(): Promise -} - -export default SessionRegistry diff --git a/packages/session-registry/session-registry/src/invariant.ts b/packages/session-registry/session-registry/src/invariant.ts deleted file mode 100644 index f41207fbed..0000000000 --- a/packages/session-registry/session-registry/src/invariant.ts +++ /dev/null @@ -1,58 +0,0 @@ -/** - * Package-owned invariant companion for `@deepseek-ai/dsh-session-registry`. - * @module @deepseek-ai/dsh-session-registry/invariant - */ - -import type { Context } from 'cordis' -import type { InvariantFailure, InvariantInstaller } from '@deepseek-ai/dsh-invariants' -import type { SessionRegistryRecord } from './types.ts' - -const PACKAGE_NAME = '@deepseek-ai/dsh-session-registry' - -/** Cordis companion plugin name. */ -export const name = 'session-registry-invariant' -/** Service required before the companion can reserve package ownership. */ -export const inject = ['invariants'] - -/** - * Cross-check every published listing against the relations the seam contract - * owns: a session id identifies at most one live record, and each listed record - * carries the identity fields a reader must be able to trust. Only a backend's - * mutation path can break either, so the check wraps the authoritative read - * rather than inspecting any medium. - * - * Liveness itself is deliberately not re-probed here. A backend derives it at - * read time, so a second probe would race the first and report a process that - * exited in between as a violation of a contract the seam never made. - */ -const install: InvariantInstaller = Object.assign((ctx: Context, fail: InvariantFailure) => { - const service = ctx.sessionRegistry - const listed = service.list.bind(service) - ctx.effect(() => { - service.list = async (): Promise => { - const records = await listed() - const seen = new Set() - for (const record of records) { - if (seen.has(record.sessionId)) { - fail(`session ${record.sessionId} appears in more than one live registry record`) - } - seen.add(record.sessionId) - // A record a reader cannot attribute to a process is unusable: `dsh list-sessions` - // renders the pid and derives liveness from it. - if (!Number.isSafeInteger(record.pid) || record.pid <= 0) { - fail(`listed session ${record.sessionId} carries unusable pid ${String(record.pid)}`) - } - } - return records - } - return () => { service.list = listed } - }) -}, { inject: ['sessionRegistry'] }) - -/** - * Register this package's invariant companion. - * @param ctx - Cordis context carrying the invariant service. - * @returns the installed registration's disposer after setup succeeds. - */ -export const apply = (ctx: Context): Promise<() => void> => - Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) diff --git a/packages/session-registry/session-registry/src/types.ts b/packages/session-registry/session-registry/src/types.ts deleted file mode 100644 index 6fff91648e..0000000000 --- a/packages/session-registry/session-registry/src/types.ts +++ /dev/null @@ -1,55 +0,0 @@ -/** - * Registry record vocabulary: the durable shape one live `dsh` process - * publishes about itself and `dsh list-sessions` reads back. - * @module @deepseek-ai/dsh-session-registry/types - */ - -import type { Branded } from '@deepseek-ai/dsh-brand' -import type { SessionId } from '@deepseek-ai/dsh-session' - -/** - * Identifies one process incarnation. Minted per registering process, so a - * record whose `pid` was recycled by the operating system cannot be mistaken - * for the original: the boot id differs even when the pid matches. - */ -export type BootId = Branded<'BootId'> - -/** - * Brand a string as a {@link BootId}. - * @param id - the raw boot id string. - * @returns the same string, branded (a compile-time cast — no runtime cost). - */ -export function BootId(id: string): BootId { - return id as BootId -} - -/** - * One live session's self-published registration. Every field is immutable for - * the lifetime of the registration: a process publishes once at startup and - * removes the record on exit, never mutating it in place. - * - * Only top-level surfaces a user starts directly register: in-process subagents - * have no process of their own, and out-of-process subagent backends spawn - * `dsh-jsonrpc-agent` rather than this CLI, so neither can reach the registry. - */ -export interface SessionRegistryRecord { - /** The session this process is running. Unique across live records. */ - readonly sessionId: SessionId - /** Operating-system process id, used with `bootId` to decide liveness. */ - readonly pid: number - /** Absolute workspace directory the session acts on. */ - readonly cwd: string - /** Non-negative safe-integer Unix epoch milliseconds when the process registered. */ - readonly startedAt: number - /** This process incarnation's id, distinguishing a recycled `pid`. */ - readonly bootId: BootId - /** - * Human-readable session title, as the registering process last knew it. - * - * Carried in the record rather than read from the session log: the log's - * location, file format, and compression are per-deployment backend choices, - * so an independent reader cannot portably parse one. Absent until a title - * exists — a fresh session has none. - */ - readonly title?: string -} diff --git a/packages/session-registry/session-registry/tests/invariant.spec.ts b/packages/session-registry/session-registry/tests/invariant.spec.ts deleted file mode 100644 index 6d97b3d129..0000000000 --- a/packages/session-registry/session-registry/tests/invariant.spec.ts +++ /dev/null @@ -1,98 +0,0 @@ -/** - * Tests for the registry's invariant companion: each acceptance path is proven - * to REJECT an invalid case, since a check that cannot fail is not a check. - * The backend is a minimal in-memory stub — the companion owns contract-level - * relations over `list()` results, whatever medium serves them. - */ - -import { describe, expect, it } from 'vitest' -import { Context } from 'cordis' -import InvariantService from '@deepseek-ai/dsh-invariants' -import { SessionId } from '@deepseek-ai/dsh-session' -import { BootId, SessionRegistry, type SessionRegistration, type SessionRegistryRecord } from '@deepseek-ai/dsh-session-registry' -import * as invariant from '@deepseek-ai/dsh-session-registry/src/invariant.ts' - -/** Minimal in-memory backend whose listings the test scripts directly. */ -class StubRegistry extends SessionRegistry { - records: SessionRegistryRecord[] = [] - - constructor(ctx: Context) { - super(ctx, BootId('stub-boot')) - } - - register(registration: SessionRegistration): Promise<() => Promise> { - this.records.push({ - sessionId: registration.sessionId, - pid: process.pid, - cwd: registration.cwd, - startedAt: Date.now(), - bootId: this.bootId, - }) - return Promise.resolve(() => Promise.resolve()) - } - - retitle(): Promise { - return Promise.resolve() - } - - list(): Promise { - return Promise.resolve([...this.records]) - } -} - -/** One record with the given identity fields, live by construction. */ -function record(sessionId: string, boot: string, pid = process.pid): SessionRegistryRecord { - return { sessionId: SessionId(sessionId), pid, cwd: '/w', startedAt: 1, bootId: BootId(boot) } -} - -/** Mount the stub backend, optionally seeding records before the companion wraps `list`. */ -async function mount(records?: SessionRegistryRecord[]): Promise<{ ctx: Context; stub: StubRegistry }> { - const ctx = new Context() - await ctx.plugin(InvariantService, { enabled: true }) - await ctx.plugin(StubRegistry) - const stub = ctx.sessionRegistry as StubRegistry - if (records !== undefined) stub.records = records - await ctx.plugin(invariant) - return { ctx, stub } -} - -describe('listing invariants', () => { - it('accepts a well-formed listing', async () => { - const { ctx } = await mount() - await ctx.sessionRegistry.register({ sessionId: SessionId('ok'), cwd: '/w' }) - await expect(ctx.sessionRegistry.list()).resolves.toHaveLength(1) - await ctx.fiber.dispose() - }) - - it('rejects a listing where one session id appears twice', async () => { - // Two live records for one session: only a broken mutation path (or an - // out-of-band writer) can produce this, and it would make - // `dsh list-sessions` show one session twice. - const { ctx } = await mount([record('dup', 'boot-a'), record('dup', 'boot-b')]) - await expect(ctx.sessionRegistry.list()).rejects.toThrow(/appears in more than one live registry record/) - await ctx.fiber.dispose() - }) - - it('rejects a listing whose record carries an unusable pid', async () => { - // A record no reader could attribute to a process: `dsh list-sessions` - // renders the pid and derives liveness from it. - const { ctx } = await mount([record('ghost', 'boot-x', 0)]) - await expect(ctx.sessionRegistry.list()).rejects.toThrow(/carries unusable pid/) - await ctx.fiber.dispose() - }) - - it('stops checking, and keeps working, when the companion unloads', async () => { - // A duplicate-id listing the mounted companion rejects, so the post-disposal - // read proves the wrapper is gone rather than merely bypassed. - const ctx = new Context() - await ctx.plugin(InvariantService, { enabled: true }) - await ctx.plugin(StubRegistry) - ;(ctx.sessionRegistry as StubRegistry).records = [record('dup', 'boot-a'), record('dup', 'boot-b')] - const companion = await ctx.plugin(invariant) - await expect(ctx.sessionRegistry.list()).rejects.toThrow(/appears in more than one/) - - await companion.dispose() - await expect(ctx.sessionRegistry.list()).resolves.toHaveLength(2) - await ctx.fiber.dispose() - }) -}) diff --git a/packages/session-registry/session-registry/tsconfig.json b/packages/session-registry/session-registry/tsconfig.json deleted file mode 100644 index cca8f9282b..0000000000 --- a/packages/session-registry/session-registry/tsconfig.json +++ /dev/null @@ -1,21 +0,0 @@ -{ - "extends": "../../../tsconfig.base.json", - "compilerOptions": { - "rootDir": "src", - "outDir": "lib/types" - }, - "include": [ - "src" - ], - "references": [ - { - "path": "../../support/invariants" - }, - { - "path": "../../util/brand" - }, - { - "path": "../../core/session" - } - ] -} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8dc9613dc0..6e54ba3a05 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -3889,70 +3889,6 @@ importers: specifier: ^4.0.0-rc.7 version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) - packages/session-registry/session-registry: - devDependencies: - '@deepseek-ai/dsh-brand': - specifier: workspace:^ - version: link:../../util/brand - '@deepseek-ai/dsh-invariants': - specifier: workspace:^ - version: link:../../support/invariants - '@deepseek-ai/dsh-session': - specifier: workspace:^ - version: link:../../core/session - cordis: - specifier: ^4.0.0-rc.6 - version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) - - packages/session-registry/session-registry-file: - dependencies: - proper-lockfile: - specifier: ^4.1.2 - version: 4.1.2 - schemastery: - specifier: ^3.15.0 - version: 3.18.0 - devDependencies: - '@deepseek-ai/dsh-brand': - specifier: workspace:^ - version: link:../../util/brand - '@deepseek-ai/dsh-invariants': - specifier: workspace:^ - version: link:../../support/invariants - '@deepseek-ai/dsh-session': - specifier: workspace:^ - version: link:../../core/session - '@deepseek-ai/dsh-session-registry': - specifier: workspace:^ - version: link:../session-registry - '@types/proper-lockfile': - specifier: ^4.1.4 - version: 4.1.4 - cordis: - specifier: ^4.0.0-rc.6 - version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) - - packages/session-registry/session-registry-live: - devDependencies: - '@deepseek-ai/dsh-invariants': - specifier: workspace:^ - version: link:../../support/invariants - '@deepseek-ai/dsh-session': - specifier: workspace:^ - version: link:../../core/session - '@deepseek-ai/dsh-session-registry': - specifier: workspace:^ - version: link:../session-registry - '@deepseek-ai/dsh-session-registry-file': - specifier: workspace:^ - version: link:../session-registry-file - '@deepseek-ai/dsh-session-title': - specifier: workspace:^ - version: link:../../session-title/session-title - cordis: - specifier: ^4.0.0-rc.6 - version: 4.0.0-rc.7(@cordisjs/plugin-include@1.0.4)(@cordisjs/plugin-loader@1.0.0-rc.5) - packages/session-title/session-title: dependencies: schemastery: @@ -8063,9 +7999,6 @@ packages: '@types/prop-types@15.7.15': resolution: {integrity: sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==} - '@types/proper-lockfile@4.1.4': - resolution: {integrity: sha512-uo2ABllncSqg9F1D4nugVl9v93RmjxF6LJzQLMLDdPaXCUIDPeOJ21Gbqi43xNKzBi/WQ0Q0dICqufzQbMjipQ==} - '@types/react-dom@18.3.7': resolution: {integrity: sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==} peerDependencies: @@ -9169,9 +9102,6 @@ packages: resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} engines: {node: '>= 0.4'} - graceful-fs@4.2.11: - resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} - hachure-fill@0.5.2: resolution: {integrity: sha512-3GKBOn+m2LX9iq+JC1064cSFprJY4jL1jCXTcpnfER5HYE2l/4EfWSGzkPa/ZDBmYI0ZOEj5VHV/eKnPGkHuOg==} @@ -10159,9 +10089,6 @@ packages: process-nextick-args@2.0.1: resolution: {integrity: sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==} - proper-lockfile@4.1.2: - resolution: {integrity: sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA==} - property-information@7.2.0: resolution: {integrity: sha512-IAtzIB6sUiWaJYrX9smp3V46pBGbBeLFRGdh25kg1334VcBlD8HzhPeNIWQH9zhGmo2itIe25EHt9dQP7G5hmg==} @@ -10265,10 +10192,6 @@ packages: resolve-pkg-maps@1.0.0: resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} - retry@0.12.0: - resolution: {integrity: sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==} - engines: {node: '>= 4'} - retry@0.13.1: resolution: {integrity: sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==} engines: {node: '>= 4'} @@ -10410,9 +10333,6 @@ packages: siginfo@2.0.0: resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} - signal-exit@3.0.7: - resolution: {integrity: sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==} - signal-exit@4.1.0: resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} engines: {node: '>=14'} @@ -12982,10 +12902,6 @@ snapshots: '@types/prop-types@15.7.15': {} - '@types/proper-lockfile@4.1.4': - dependencies: - '@types/retry': 0.12.0 - '@types/react-dom@18.3.7(@types/react@18.3.31)': dependencies: '@types/react': 18.3.31 @@ -14287,8 +14203,6 @@ snapshots: gopd@1.2.0: {} - graceful-fs@4.2.11: {} - hachure-fill@0.5.2: {} handlebars@4.7.9: @@ -15469,12 +15383,6 @@ snapshots: process-nextick-args@2.0.1: {} - proper-lockfile@4.1.2: - dependencies: - graceful-fs: 4.2.11 - retry: 0.12.0 - signal-exit: 3.0.7 - property-information@7.2.0: {} protobufjs@7.6.4: @@ -15624,8 +15532,6 @@ snapshots: resolve-pkg-maps@1.0.0: {} - retry@0.12.0: {} - retry@0.13.1: {} rfdc@1.4.1: {} @@ -15862,8 +15768,6 @@ snapshots: siginfo@2.0.0: {} - signal-exit@3.0.7: {} - signal-exit@4.1.0: {} sisteransi@1.0.5: {} diff --git a/scripts/gen-cordis-catalog.ts b/scripts/gen-cordis-catalog.ts index 90a5bef153..0f3270f8ee 100644 --- a/scripts/gen-cordis-catalog.ts +++ b/scripts/gen-cordis-catalog.ts @@ -229,8 +229,6 @@ const TYPE_LINK_EXEMPTIONS: Readonly> = { DirectoryPickerCapability: 'picker interaction contract is owned by packages/host/directory-picker/README.md', CreateAgentOptions: 'agent creation contract is owned by packages/core/agent/README.md', Domain: 'domain interface is owned by packages/storage/storage-domain/README.md', - SessionRegistration: 'registry publication input is owned by packages/session-registry/session-registry/README.md', - SessionRegistryRecord: 'live-session record vocabulary is owned by packages/session-registry/session-registry/README.md', DomainChanged: 'event-local snapshot is owned by packages/storage/storage-domain/src/events.ts', DomainFacility: 'domain form facility is owned by packages/storage/storage-domain/README.md', DomainImpl: 'domain implementation contract is owned by packages/storage/storage-domain/README.md', diff --git a/scripts/gen-doc-graphs.ts b/scripts/gen-doc-graphs.ts index c836289049..73ba05f748 100644 --- a/scripts/gen-doc-graphs.ts +++ b/scripts/gen-doc-graphs.ts @@ -171,15 +171,6 @@ const SERVICE_ROLES: ServiceRole[] = [ consumers: ['apiproxy'], note: 'Owns WorkspaceId-branded records over the domain facility; stable sessionIds accounts drive Host RPC and GUI projections.', }, - { - key: 'sessionRegistry', - pkg: 'session-registry', - title: 'Live-session registry', - mode: 'seam', - implementations: ['session-registry-file'], - consumers: ['session-registry-live'], - note: 'Seam contract for live-session records; the file backend owns the lock-guarded medium, liveness is derived from the recorded pid at read time, and the publisher mirrors lifecycle and title events for `dsh list-sessions`.', - }, { key: 'sessionQuery', pkg: 'session-query', diff --git a/scripts/verify-package-readme-model-experience.ts b/scripts/verify-package-readme-model-experience.ts index 58ac34a3cf..fd74c6d13e 100644 --- a/scripts/verify-package-readme-model-experience.ts +++ b/scripts/verify-package-readme-model-experience.ts @@ -98,9 +98,6 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly> = { 'packages/session-projection/session-projection': { kind: 'none', reason: 'The projection registry serves client-facing read models of already-logged session state and registers no model surface.' }, 'packages/session-projection/session-projection-cache': { kind: 'none', reason: 'The persisted cache accelerates host-side cold reads of projection state and registers no model surface.' }, 'packages/session-query/session-query': { kind: 'none', reason: 'The trusted query service exposes cloned records only to callers and registers no model surface.' }, - 'packages/session-registry/session-registry': { kind: 'none', reason: 'The seam defines the host-side listing contract and registers no model surface.' }, - 'packages/session-registry/session-registry-file': { kind: 'none', reason: 'The file backend stores host-side process records for the CLI listing surface and registers no model surface.' }, - 'packages/session-registry/session-registry-live': { kind: 'none', reason: 'The publisher mirrors lifecycle and title events into host-side process records and registers no model surface.' }, 'packages/session-query/session-query-sqlite': { kind: 'none', reason: 'The search backend returns hits only to callers and registers no model surface.' }, 'packages/telemetry/session-telemetry': { kind: 'none', reason: 'The seam observes the session stream and hands redacted copies outward; it registers no model surface.' }, 'packages/telemetry/session-telemetry-otel': { kind: 'none', reason: 'The backend forwards seam records into the OTel SDK pipeline and registers no model surface.' }, diff --git a/tsconfig.base.json b/tsconfig.base.json index ba1f88779c..2e35d53c43 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -88,7 +88,6 @@ "./packages/session-persistence/*/src/invariant.ts", "./packages/session-projection/*/src/invariant.ts", "./packages/session-query/*/src/invariant.ts", - "./packages/session-registry/*/src/invariant.ts", "./packages/telemetry/*/src/invariant.ts", "./packages/acp/*/src/invariant.ts", "./packages/storage/*/src/invariant.ts", @@ -180,7 +179,6 @@ "./packages/session-persistence/*/src", "./packages/session-projection/*/src", "./packages/session-query/*/src", - "./packages/session-registry/*/src", "./packages/session-title/*/src", "./packages/telemetry/*/src", "./packages/acp/*/src", diff --git a/tsconfig.host.json b/tsconfig.host.json index b0121f2a0a..d46a167eb5 100644 --- a/tsconfig.host.json +++ b/tsconfig.host.json @@ -181,9 +181,6 @@ { "path": "./packages/lsp/lsp" }, { "path": "./packages/lsp/lsp-local" }, { "path": "./packages/lsp/tool-lsp" }, - { "path": "./packages/session-registry/session-registry" }, - { "path": "./packages/session-registry/session-registry-file" }, - { "path": "./packages/session-registry/session-registry-live" }, { "path": "./apps/cli" } ] } From 030b044350c5317856d0c4bffb5e96eb69de4733 Mon Sep 17 00:00:00 2001 From: Turtle Date: Wed, 29 Jul 2026 16:29:55 +0800 Subject: [PATCH 025/113] fix(cli): reject leaked config replacement flags --- apps/cli/src/args.ts | 6 +++--- apps/cli/tests/args.spec.ts | 6 ++++++ 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/apps/cli/src/args.ts b/apps/cli/src/args.ts index 5cdf9d20d4..56827f2764 100644 --- a/apps/cli/src/args.ts +++ b/apps/cli/src/args.ts @@ -226,9 +226,9 @@ Examples: // the subcommand into `program.opts()`. `meta` accepts only `--resume`, so // a leaked `--config`/`-p` is a mistyped invocation that must fail loud // rather than silently be dropped. - const parent = program.opts<{ config?: string; prompt?: string; resume?: string }>() - if (parent.config !== undefined || parent.prompt !== undefined) { - program.error('error: meta takes neither --config nor -p/--prompt') + const parent = program.opts<{ config?: string; configReplace?: string; prompt?: string; resume?: string }>() + if (parent.config !== undefined || parent.configReplace !== undefined || parent.prompt !== undefined) { + program.error('error: meta takes none of --config, --config-replace, or -p/--prompt') } // Same reason as the default surface: an empty id would start a fresh // session downstream instead of failing the mistyped resume. diff --git a/apps/cli/tests/args.spec.ts b/apps/cli/tests/args.spec.ts index 831a969d1f..9e79e333f2 100644 --- a/apps/cli/tests/args.spec.ts +++ b/apps/cli/tests/args.spec.ts @@ -27,6 +27,7 @@ describe('parseDshArgs', () => { it('routes each mode by its shape: default TUI, -p headless, meta and web subcommands', () => { expect(parse([])).toEqual({ mode: 'tui' }) expect(parse(['--config', 'custom.yml'])).toEqual({ mode: 'tui', config: 'custom.yml' }) + expect(parse(['--config-replace', 'tree.yml'])).toEqual({ mode: 'tui', configReplace: 'tree.yml' }) expect(parse(['--resume', 'sess', '--config', 'app.yml'])).toEqual({ mode: 'tui', config: 'app.yml', resume: 'sess' }) expect(parse(['-p', 'do the thing'])).toEqual({ mode: 'headless', prompt: 'do the thing' }) // `meta` accepts `--resume` but does not redeclare it: a shared option parses @@ -58,6 +59,8 @@ describe('parseDshArgs', () => { expect(exitCode(['--resume='])).toBe(1) expect(exitCode(['-p', ''])).toBe(1) expect(exitCode(['-p', 'x', '--config', 'c.yml'])).toBe(1) + expect(exitCode(['-p', 'x', '--config-replace', 'tree.yml'])).toBe(1) + expect(exitCode(['--config', 'c.yml', '--config-replace', 'tree.yml'])).toBe(1) expect(exitCode(['-p', 'x', '--resume', 's'])).toBe(1) expect(exitCode(['--bogus'])).toBe(1) expect(exitCode(['bogus-positional'])).toBe(1) @@ -66,17 +69,20 @@ describe('parseDshArgs', () => { expect(exitCode(['web', '-p', 'task'])).toBe(1) expect(exitCode(['web', '--resume', 's'])).toBe(1) expect(exitCode(['--config', 'c.yml', 'web'])).toBe(1) + expect(exitCode(['--config-replace', 'tree.yml', 'web'])).toBe(1) // Same rule for credential setup: it shares no option with the default // surface, so a leaked flag is a typo, not something to ignore. // `meta` fixes its own config tree and is interactive, so --config/-p are // rejected; an empty id is swallowed downstream exactly as above. expect(exitCode(['meta', '--resume='])).toBe(1) expect(exitCode(['meta', '--config', 'c.yml'])).toBe(1) + expect(exitCode(['meta', '--config-replace', 'tree.yml'])).toBe(1) expect(exitCode(['meta', '-p', 'task'])).toBe(1) // `migrate`/`upgrade` take no options: any leaked default-surface flag is a // mistyped invocation, not a silently-dropped input. expect(exitCode(['migrate', '--resume', 's'])).toBe(1) expect(exitCode(['migrate', '--config', 'c.yml'])).toBe(1) + expect(exitCode(['migrate', '--config-replace', 'tree.yml'])).toBe(1) expect(exitCode(['migrate', '-p', 'task'])).toBe(1) expect(exitCode(['upgrade', '--resume', 's'])).toBe(1) expect(exitCode(['upgrade', '--config', 'c.yml'])).toBe(1) From 33286a0ff578fb32e58f5f299433dbe6875108d5 Mon Sep 17 00:00:00 2001 From: Turtle Date: Wed, 29 Jul 2026 16:30:42 +0800 Subject: [PATCH 026/113] docs(cordis): remove registry API remnants --- packages/cordis/tool-cordis/src/api-catalog.ts | 4 ---- 1 file changed, 4 deletions(-) diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index d238897cf3..9e5134dd9e 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -2251,10 +2251,6 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'SessionReferenceInput', declaration: 'export interface SessionReferenceInput {\n sessionId: SessionId;\n label?: string;\n}', }, - { - }, - { - }, { name: 'SessionResultFilter', declaration: 'export type SessionResultFilter = {\n kind: \'id\';\n values: readonly SessionId[];\n} | {\n kind: \'cwd\';\n values: readonly (string | null)[];\n} | ({\n kind: \'created-at\';\n} & SessionResultRange) | {\n kind: \'parent\';\n values: readonly (SessionId | null)[];\n} | {\n kind: \'availability\';\n values: readonly SessionAvailability[];\n};', From bee0a77aadb5cde07312c37243f5200bddf7ac12 Mon Sep 17 00:00:00 2001 From: Turtle Date: Wed, 29 Jul 2026 16:31:21 +0800 Subject: [PATCH 027/113] chore: refresh generated review records --- .../2026-07-28-dsh-native-typescript-source-launch.i18n.yaml | 4 ++-- .../testing/2026-07-24-web-gui-browser-e2e-lane.i18n.yaml | 4 ++-- apps/cli/README.i18n.yaml | 4 ++-- docs/development.i18n.yaml | 4 ++-- packages/cordis/tool-cordis/src/api-catalog.ts | 4 ---- 5 files changed, 8 insertions(+), 12 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-07-28-dsh-native-typescript-source-launch.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-28-dsh-native-typescript-source-launch.i18n.yaml index fae9bf86b2..e10d181359 100644 --- a/.agents/notes/implemented/architecture/2026-07-28-dsh-native-typescript-source-launch.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-28-dsh-native-typescript-source-launch.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-28-dsh-native-typescript-source-launch.md -2026-07-28-dsh-native-typescript-source-launch.md: 3fa6ee3711ab5a44391e5c261cff1e7c05fa495a -2026-07-28-dsh-native-typescript-source-launch.zh.md: b749687b3a8c0f0fb92826dabbcedffa21e433f8 +2026-07-28-dsh-native-typescript-source-launch.md: e128ae1a39c6a3838d948b40936dca6e30d324e1 +2026-07-28-dsh-native-typescript-source-launch.zh.md: 5ff1d975f8012e7d831bc3ecf6e5c53a9d43151f diff --git a/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.i18n.yaml b/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.i18n.yaml index 0863874748..52f8014d7d 100644 --- a/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.i18n.yaml +++ b/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.md -2026-07-24-web-gui-browser-e2e-lane.md: 37e2bccddb3725073ebb38377c2cd7464b418012 -2026-07-24-web-gui-browser-e2e-lane.zh.md: f2bbdbb028a3e35831298fea4d5a0f3dec410b82 +2026-07-24-web-gui-browser-e2e-lane.md: 04240456d1df4eab79372de0ebd900f5831cd819 +2026-07-24-web-gui-browser-e2e-lane.zh.md: 6be7056b3d46be875b74c56d93045a37fd3d2c08 diff --git a/apps/cli/README.i18n.yaml b/apps/cli/README.i18n.yaml index a38bcc2eaa..72fe6eff6f 100644 --- a/apps/cli/README.i18n.yaml +++ b/apps/cli/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write apps/cli/README.md -README.md: 5c2b3eb77f2606928520c937d96d8f728afb062b -README.zh.md: 927934d7265b8a81518d770e57f2b21249b67e56 +README.md: 2c1d373b39ecd188dde42d24800a1602f39da582 +README.zh.md: b4c1e8a913e3efbac828b22568abe85ee0f7ab37 diff --git a/docs/development.i18n.yaml b/docs/development.i18n.yaml index b6cd7c4170..302d371e93 100644 --- a/docs/development.i18n.yaml +++ b/docs/development.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/development.md -development.md: 53e545aabb074d4ad4c5ad62722c88f9ae4c06ae -development.zh.md: a5c95f0fb7e16b7678b5165856b0bc8e2e274d8c +development.md: 263056e66d705ecd7a9af862757daaecc44df847 +development.zh.md: f6c127dc3f8ddb359a2d9fc9b278f15600e6fb45 diff --git a/packages/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index 9e5134dd9e..7d9b087b8f 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -1543,10 +1543,6 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'BashSandboxInfo', declaration: 'export interface BashSandboxInfo {\n mode: SandboxMode;\n denied: boolean;\n enforcement?: SandboxEnforcement;\n runnerFailed?: boolean;\n}', }, - { - name: 'BootId', - declaration: 'export type BootId = Branded<\'BootId\'>;', - }, { name: 'Branded', declaration: 'export type Branded = string & {\n readonly [BRAND]: B;\n};', From 857a4941beb890dd8a893f5d6a2ed86a21e6423d Mon Sep 17 00:00:00 2001 From: Turtle Date: Wed, 29 Jul 2026 17:34:50 +0800 Subject: [PATCH 028/113] fix(cli): restore shipped surface capabilities --- apps/cli/base.cordis.yml | 19 +++++++--- apps/cli/composition.md | 12 +++++-- apps/cli/package.json | 9 ++++- apps/cli/src/args.ts | 19 ++-------- apps/cli/src/tui.ts | 12 ++++--- apps/cli/tests/args.spec.ts | 11 ++---- apps/cli/tui.cordis.yml | 5 ++- apps/cli/web.cordis.yml | 48 ++++++++++++++++++++++++++ examples/acp-agent/composition.md | 2 +- examples/headless-agent/composition.md | 2 +- pnpm-lock.yaml | 27 +++++++++++++-- scripts/gen-doc-graphs.ts | 14 ++++---- scripts/verify-cordis-config.ts | 7 +++- 13 files changed, 133 insertions(+), 54 deletions(-) diff --git a/apps/cli/base.cordis.yml b/apps/cli/base.cordis.yml index 40bb3fe946..cb1fd7925d 100644 --- a/apps/cli/base.cordis.yml +++ b/apps/cli/base.cordis.yml @@ -8,8 +8,8 @@ # A patch replaces the targeted row's whole `config` rather than merging into # it, so a row whose value differs per surface does NOT live here: it belongs to # each overlay, keeping any single row down to one overlay layer plus the user's. -# That is why `agent-loop`, `system-prompt`, `tools`, `fs-local`, and -# `llm-deepseek` thinking defaults are absent below. +# Rows with surface-specific values appear below only with shared plugin identity +# and neutral defaults; each overlay restates the complete surface configuration. # # Row order carries no load semantics (activation is service-availability # driven); the grouping is for readers. @@ -51,9 +51,20 @@ - id: llm-retry name: '@deepseek-ai/dsh-llm-retry' +- id: llm-pi-ai + name: '@deepseek-ai/dsh-llm-pi-ai' + config: + providers: + - provider: openai + apiKey: !!js process.env.OPENAI_API_KEY + baseURL: !!js process.env.OPENAI_BASE_URL + - provider: anthropic + apiKey: !!js process.env.ANTHROPIC_API_KEY + baseURL: !!js process.env.ANTHROPIC_BASE_URL + # The session store root is the launcher's policy, not a plugin's: `dsh` shares -# one store under the Harness home across every cwd, so `/resume` and -# `/resume` spans workspaces. Without a launcher the project-local fallback keeps +# one store under the Harness home across every cwd, so `/resume` spans +# workspaces. Without a launcher the project-local fallback keeps # an embedder's sessions beside its project. - id: session-persistence-jsonl name: '@deepseek-ai/dsh-session-persistence-jsonl' diff --git a/apps/cli/composition.md b/apps/cli/composition.md index 47d14ce88d..9c0d5b5f06 100644 --- a/apps/cli/composition.md +++ b/apps/cli/composition.md @@ -7,7 +7,7 @@ The TUI agent combines the real DeepSeek adapter, coding tools, compaction, suba ```mermaid flowchart LR - cfg["apps/cli (dsh)
cordis.yml"] + cfg["apps/cli (dsh)
base.cordis.yml + tui.cordis.yml"] plugin_tui_timer["timer
@cordisjs/plugin-timer"] cfg --> plugin_tui_timer plugin_tui_llm["llm
@deepseek-ai/dsh-llm"] @@ -26,6 +26,8 @@ flowchart LR cfg --> plugin_tui_tasks plugin_tui_llm_retry["llm-retry
@deepseek-ai/dsh-llm-retry"] cfg --> plugin_tui_llm_retry + plugin_tui_llm_pi_ai["llm-pi-ai
@deepseek-ai/dsh-llm-pi-ai"] + cfg --> plugin_tui_llm_pi_ai plugin_tui_session_persistence_jsonl["session-persistence-jsonl
@deepseek-ai/dsh-session-persistence-jsonl"] cfg --> plugin_tui_session_persistence_jsonl plugin_tui_subprocess["subprocess
@deepseek-ai/dsh-subprocess-local"] @@ -94,6 +96,8 @@ flowchart LR cfg --> plugin_tui_fs_local plugin_tui_llm_deepseek["llm-deepseek
@deepseek-ai/dsh-llm-deepseek"] cfg --> plugin_tui_llm_deepseek + plugin_tui_tools["tools
@deepseek-ai/dsh-tool-ask-user"] + cfg --> plugin_tui_tools ``` | Plugin id | Package / module | @@ -107,6 +111,7 @@ flowchart LR | `agent` | `@deepseek-ai/dsh-agent` | | `tasks` | `@deepseek-ai/dsh-tasks-local` | | `llm-retry` | `@deepseek-ai/dsh-llm-retry` | +| `llm-pi-ai` | `@deepseek-ai/dsh-llm-pi-ai` | | `session-persistence-jsonl` | `@deepseek-ai/dsh-session-persistence-jsonl` | | `subprocess` | `@deepseek-ai/dsh-subprocess-local` | | `bash-local` | `@deepseek-ai/dsh-bash-local` | @@ -141,7 +146,8 @@ flowchart LR | `agent-loop` | `@deepseek-ai/dsh-agent-loop` | | `fs-local` | `@deepseek-ai/dsh-fs-local` | | `llm-deepseek` | `@deepseek-ai/dsh-llm-deepseek` | +| `tools` | `@deepseek-ai/dsh-tool-ask-user` | -Source config: [`apps/cli/base.cordis.yml`](base.cordis.yml). +Source configs: [`apps/cli/base.cordis.yml`](base.cordis.yml), [`apps/cli/tui.cordis.yml`](tui.cordis.yml). -Maintenance mode: hybrid: the leaf plugin list is parsed from its `cordis.yml`; app package expansion is curated from package source. +Maintenance mode: hybrid: the leaf plugin list is parsed from its shipped config files; app package expansion is curated from package source. diff --git a/apps/cli/package.json b/apps/cli/package.json index ddbef2cd03..87cbf29060 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -22,6 +22,7 @@ "@deepseek-ai/dsh-agent-loop": "workspace:^", "@deepseek-ai/dsh-app-boot": "workspace:^", "@deepseek-ai/dsh-bash-local": "workspace:^", + "@deepseek-ai/dsh-bash-sandbox": "workspace:^", "@deepseek-ai/dsh-client-connection": "workspace:^", "@deepseek-ai/dsh-client-hmr": "workspace:^", "@deepseek-ai/dsh-client-locale": "workspace:^", @@ -33,6 +34,7 @@ "@deepseek-ai/dsh-client-ui-layout": "workspace:^", "@deepseek-ai/dsh-client-ui-model": "workspace:^", "@deepseek-ai/dsh-client-ui-models": "workspace:^", + "@deepseek-ai/dsh-client-ui-permission": "workspace:^", "@deepseek-ai/dsh-client-ui-plan": "workspace:^", "@deepseek-ai/dsh-client-ui-question": "workspace:^", "@deepseek-ai/dsh-client-ui-settings": "workspace:^", @@ -52,10 +54,11 @@ "@deepseek-ai/dsh-frontend": "workspace:^", "@deepseek-ai/dsh-fs-local": "workspace:^", "@deepseek-ai/dsh-fs-policy": "workspace:^", + "@deepseek-ai/dsh-fs-sandbox": "workspace:^", "@deepseek-ai/dsh-goal": "workspace:^", "@deepseek-ai/dsh-goal-session": "workspace:^", - "@deepseek-ai/dsh-helper": "workspace:^", "@deepseek-ai/dsh-host-apiproxy": "workspace:^", + "@deepseek-ai/dsh-host-directory-picker-browse": "workspace:^", "@deepseek-ai/dsh-host-webserver": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", @@ -63,7 +66,10 @@ "@deepseek-ai/dsh-llm-pi-ai": "workspace:^", "@deepseek-ai/dsh-llm-retry": "workspace:^", "@deepseek-ai/dsh-paths": "workspace:^", + "@deepseek-ai/dsh-permission": "workspace:^", "@deepseek-ai/dsh-plan-mode": "workspace:^", + "@deepseek-ai/dsh-sandbox-local": "workspace:^", + "@deepseek-ai/dsh-sandbox-policy": "workspace:^", "@deepseek-ai/dsh-scope": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-session-checkpoint-policy": "workspace:^", @@ -102,6 +108,7 @@ "@deepseek-ai/dsh-tool-workflow": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", "@deepseek-ai/dsh-tui": "workspace:^", + "@deepseek-ai/dsh-user-approval": "workspace:^", "@deepseek-ai/dsh-user-interaction": "workspace:^", "@deepseek-ai/dsh-workflow-workerthread": "workspace:^", "@deepseek-ai/dsh-workspace": "workspace:^", diff --git a/apps/cli/src/args.ts b/apps/cli/src/args.ts index 56827f2764..e470af1299 100644 --- a/apps/cli/src/args.ts +++ b/apps/cli/src/args.ts @@ -213,27 +213,12 @@ Examples: resolved = resolveWeb(options) }) - // `--resume` is NOT redeclared here: an option a subcommand shares with its - // parent parses into `program.opts()` and leaves the subcommand's own options - // empty, so redeclaring it would silently drop the id. Commander therefore - // omits it from this subcommand's option list, hence the trailing help text. program .command('meta') .description('work on the dsh source that runs this command, from any directory') - .addHelpText('after', '\nAccepts --resume to resume a persisted session from this checkout.\n') .action(() => { - // Commander parses the parent (default-surface) options on either side of - // the subcommand into `program.opts()`. `meta` accepts only `--resume`, so - // a leaked `--config`/`-p` is a mistyped invocation that must fail loud - // rather than silently be dropped. - const parent = program.opts<{ config?: string; configReplace?: string; prompt?: string; resume?: string }>() - if (parent.config !== undefined || parent.configReplace !== undefined || parent.prompt !== undefined) { - program.error('error: meta takes none of --config, --config-replace, or -p/--prompt') - } - // Same reason as the default surface: an empty id would start a fresh - // session downstream instead of failing the mistyped resume. - if (parent.resume === '') program.error('error: --resume needs a session id') - resolved = { mode: 'meta', ...parent.resume !== undefined && { resume: parent.resume } } + rejectParentOptions('meta') + resolved = { mode: 'meta' } }) try { diff --git a/apps/cli/src/tui.ts b/apps/cli/src/tui.ts index f0ef5ddc02..e9d108bfb0 100644 --- a/apps/cli/src/tui.ts +++ b/apps/cli/src/tui.ts @@ -18,7 +18,7 @@ */ import { randomUUID } from 'node:crypto' -import { join } from 'node:path' +import { join, resolve } from 'node:path' import { fileURLToPath } from 'node:url' import { addHarnessSourceSection, @@ -155,6 +155,8 @@ export async function runTui( // selected session may belong to another workspace, so the handoff also enters // that directory. The host is offered only when Node exposes `process.execve` // and knows its own entry. + const resolvedConfig = config === undefined ? undefined : resolve(config) + const resolvedConfigReplace = configReplace === undefined ? undefined : resolve(configReplace) const entry = process.argv[1] const execve = process.execve?.bind(process) const app: { current?: Context } = {} @@ -172,8 +174,8 @@ export async function runTui( `--resume=${sessionId}`, // Both config flags must survive the handoff: resuming into a different // tree than the session was created in would silently change the agent. - ...config !== undefined ? ['--config', config] : [], - ...configReplace !== undefined ? ['--config-replace', configReplace] : [], + ...resolvedConfig !== undefined ? ['--config', resolvedConfig] : [], + ...resolvedConfigReplace !== undefined ? ['--config-replace', resolvedConfigReplace] : [], ] // Mint the fresh id here rather than in the app bundle: the exit line names // the session to resume, so the launcher must know it before the tree boots. @@ -223,11 +225,11 @@ export async function runTui( ...loadOverlayPatches(NAME, TUI_OVERLAY), ...config === undefined ? loadPersonalPatches(NAME) ?? [] - : loadOverlayPatches(NAME, resolveConfigPath(config, undefined)), + : loadOverlayPatches(NAME, resolveConfigPath(resolve(config), undefined)), ] const ctx = await boot( NAME, - replaceTree ? resolveConfigPath(configReplace, undefined) : BASE_CONFIG, + replaceTree ? resolveConfigPath(resolve(configReplace), undefined) : BASE_CONFIG, patches, (hostCtx) => { // The launcher owns session identity and the exit line: a config-mounted diff --git a/apps/cli/tests/args.spec.ts b/apps/cli/tests/args.spec.ts index 9e79e333f2..7eea63b68d 100644 --- a/apps/cli/tests/args.spec.ts +++ b/apps/cli/tests/args.spec.ts @@ -30,12 +30,7 @@ describe('parseDshArgs', () => { expect(parse(['--config-replace', 'tree.yml'])).toEqual({ mode: 'tui', configReplace: 'tree.yml' }) expect(parse(['--resume', 'sess', '--config', 'app.yml'])).toEqual({ mode: 'tui', config: 'app.yml', resume: 'sess' }) expect(parse(['-p', 'do the thing'])).toEqual({ mode: 'headless', prompt: 'do the thing' }) - // `meta` accepts `--resume` but does not redeclare it: a shared option parses - // into program.opts() on either side of the subcommand, and redeclaring it - // would leave the subcommand's own options empty and drop the id. expect(parse(['meta'])).toEqual({ mode: 'meta' }) - expect(parse(['meta', '--resume', 'sess'])).toEqual({ mode: 'meta', resume: 'sess' }) - expect(parse(['--resume', 'sess', 'meta'])).toEqual({ mode: 'meta', resume: 'sess' }) // Credential setup is option-free: it writes the Harness-home .env, so // there is nothing for a flag to select. // Bare `web` carries no host/port: the shipped cordis.yml owns the default. @@ -72,9 +67,9 @@ describe('parseDshArgs', () => { expect(exitCode(['--config-replace', 'tree.yml', 'web'])).toBe(1) // Same rule for credential setup: it shares no option with the default // surface, so a leaked flag is a typo, not something to ignore. - // `meta` fixes its own config tree and is interactive, so --config/-p are - // rejected; an empty id is swallowed downstream exactly as above. - expect(exitCode(['meta', '--resume='])).toBe(1) + // `meta` fixes its own config tree and always starts fresh, so every + // default-surface option is rejected. + expect(exitCode(['meta', '--resume', 's'])).toBe(1) expect(exitCode(['meta', '--config', 'c.yml'])).toBe(1) expect(exitCode(['meta', '--config-replace', 'tree.yml'])).toBe(1) expect(exitCode(['meta', '-p', 'task'])).toBe(1) diff --git a/apps/cli/tui.cordis.yml b/apps/cli/tui.cordis.yml index 13b8c8930d..82f5e7aa9e 100644 --- a/apps/cli/tui.cordis.yml +++ b/apps/cli/tui.cordis.yml @@ -94,9 +94,8 @@ - id: session-reference name: '@deepseek-ai/dsh-session-reference' - # Refuses write/edit inside the dsh checkout this launcher runs from, on that - # checkout's own branch, until the session loads dsh-customize. Inert - # everywhere else, so an ordinary project sees no change. + # Compacts oversized tool results before the broader conversation compactor + # runs, preserving the model-visible result within the configured budget. - id: tool-result-prune name: '@deepseek-ai/dsh-compact-tool-result-prune' diff --git a/apps/cli/web.cordis.yml b/apps/cli/web.cordis.yml index 9cbd73a6f6..6a2d5df9e5 100644 --- a/apps/cli/web.cordis.yml +++ b/apps/cli/web.cordis.yml @@ -31,7 +31,49 @@ apiKey: !!js process.env.DEEPSEEK_API_KEY baseURL: !!js process.env.DEEPSEEK_BASE_URL +# The web surface replaces the unrestricted local executors with the shared +# sandbox policy. Its default preserves the previous unrestricted behavior; +# DSH_PERMISSION_MODE and the browser permission picker can confine a session. +- insert: + - id: sandbox + name: '@deepseek-ai/dsh-sandbox-local' + + - id: sandbox-policy + name: '@deepseek-ai/dsh-sandbox-policy' + config: + mode: !!js process.env.DSH_PERMISSION_MODE ?? 'danger-full-access' + workspaceRoot: !!js process.cwd() + + - id: bash-sandbox + name: '@deepseek-ai/dsh-bash-sandbox' + + - id: approval + name: '@deepseek-ai/dsh-user-approval' + config: + policy: !!js "(process.env.DSH_PERMISSION_MODE ?? 'danger-full-access') === 'danger-full-access' ? 'never' : 'ask'" + + - id: permission + name: '@deepseek-ai/dsh-permission' + config: + presets: + read-only: + sandbox: read-only + approval: ask + workspace-write: + sandbox: workspace-write + approval: ask + danger-full-access: + sandbox: danger-full-access + approval: never + + - id: fs-sandbox + name: '@deepseek-ai/dsh-fs-sandbox' + +- id: bash-local + remove: true + - id: fs-local + remove: true # ── web-only host rows, the transport layer, and the browser roster ───────── @@ -72,6 +114,9 @@ # The API gateway: the transport-agnostic dispatch face every client shape # shares. provider/model are the host default routing — the profile json's # mapping target (user config overrides these engineering defaults). + - id: directory-picker + name: '@deepseek-ai/dsh-host-directory-picker-browse' + - id: api-gateway name: '@deepseek-ai/dsh-host-apiproxy' config: @@ -156,6 +201,9 @@ - id: ui-model name: '@deepseek-ai/dsh-client-ui-model' + - id: ui-permission + name: '@deepseek-ai/dsh-client-ui-permission' + # Plan control: the composer plan seat over the plan projection + /plan channel. - id: ui-plan name: '@deepseek-ai/dsh-client-ui-plan' diff --git a/examples/acp-agent/composition.md b/examples/acp-agent/composition.md index b4a3236920..6cd896ca31 100644 --- a/examples/acp-agent/composition.md +++ b/examples/acp-agent/composition.md @@ -94,4 +94,4 @@ flowchart LR Source config: [`examples/acp-agent/cordis.yml`](cordis.yml). -Maintenance mode: hybrid: the leaf plugin list is parsed from its `cordis.yml`; app package expansion is curated from package source. +Maintenance mode: hybrid: the leaf plugin list is parsed from its shipped config files; app package expansion is curated from package source. diff --git a/examples/headless-agent/composition.md b/examples/headless-agent/composition.md index 53a01260e1..7c55a80fdd 100644 --- a/examples/headless-agent/composition.md +++ b/examples/headless-agent/composition.md @@ -76,4 +76,4 @@ flowchart LR Source config: [`examples/headless-agent/cordis.yml`](cordis.yml). -Maintenance mode: hybrid: the leaf plugin list is parsed from its `cordis.yml`; app package expansion is curated from package source. +Maintenance mode: hybrid: the leaf plugin list is parsed from its shipped config files; app package expansion is curated from package source. diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6e54ba3a05..26d195da58 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -134,6 +134,9 @@ importers: '@deepseek-ai/dsh-bash-local': specifier: workspace:^ version: link:../../packages/bash/bash-local + '@deepseek-ai/dsh-bash-sandbox': + specifier: workspace:^ + version: link:../../packages/bash/bash-sandbox '@deepseek-ai/dsh-client-connection': specifier: workspace:^ version: link:../../packages/client/connection @@ -167,6 +170,9 @@ importers: '@deepseek-ai/dsh-client-ui-models': specifier: workspace:^ version: link:../../packages/client/ui-models + '@deepseek-ai/dsh-client-ui-permission': + specifier: workspace:^ + version: link:../../packages/client/ui-permission '@deepseek-ai/dsh-client-ui-plan': specifier: workspace:^ version: link:../../packages/client/ui-plan @@ -224,18 +230,21 @@ importers: '@deepseek-ai/dsh-fs-policy': specifier: workspace:^ version: link:../../packages/fs/fs-policy + '@deepseek-ai/dsh-fs-sandbox': + specifier: workspace:^ + version: link:../../packages/fs/fs-sandbox '@deepseek-ai/dsh-goal': specifier: workspace:^ version: link:../../packages/goal/goal '@deepseek-ai/dsh-goal-session': specifier: workspace:^ version: link:../../packages/goal/goal-session - '@deepseek-ai/dsh-helper': - specifier: workspace:^ - version: link:../../packages/sdk/helper '@deepseek-ai/dsh-host-apiproxy': specifier: workspace:^ version: link:../../packages/host/apiproxy + '@deepseek-ai/dsh-host-directory-picker-browse': + specifier: workspace:^ + version: link:../../packages/host/directory-picker-browse '@deepseek-ai/dsh-host-webserver': specifier: workspace:^ version: link:../../packages/host/webserver @@ -257,9 +266,18 @@ importers: '@deepseek-ai/dsh-paths': specifier: workspace:^ version: link:../../packages/util/paths + '@deepseek-ai/dsh-permission': + specifier: workspace:^ + version: link:../../packages/ui/permission '@deepseek-ai/dsh-plan-mode': specifier: workspace:^ version: link:../../packages/plan/plan-mode + '@deepseek-ai/dsh-sandbox-local': + specifier: workspace:^ + version: link:../../packages/sandbox/sandbox-local + '@deepseek-ai/dsh-sandbox-policy': + specifier: workspace:^ + version: link:../../packages/sandbox/sandbox-policy '@deepseek-ai/dsh-scope': specifier: workspace:^ version: link:../../packages/core/scope @@ -374,6 +392,9 @@ importers: '@deepseek-ai/dsh-tui': specifier: workspace:^ version: link:../../packages/ui/tui + '@deepseek-ai/dsh-user-approval': + specifier: workspace:^ + version: link:../../packages/ui/user-approval '@deepseek-ai/dsh-user-interaction': specifier: workspace:^ version: link:../../packages/ui/user-interaction diff --git a/scripts/gen-doc-graphs.ts b/scripts/gen-doc-graphs.ts index 73ba05f748..54cdc11a50 100644 --- a/scripts/gen-doc-graphs.ts +++ b/scripts/gen-doc-graphs.ts @@ -597,7 +597,7 @@ const APP_EXAMPLES = [ rel: 'apps/cli/composition.md', title: 'dsh TUI Composition', label: 'apps/cli (dsh)', - config: 'apps/cli/base.cordis.yml', + configs: ['apps/cli/base.cordis.yml', 'apps/cli/tui.cordis.yml'], summary: 'The TUI agent combines the real DeepSeek adapter, coding tools, compaction, subagents, and workflows with the full-screen terminal app package.', }, { @@ -605,7 +605,7 @@ const APP_EXAMPLES = [ rel: 'examples/headless-agent/composition.md', title: 'Headless Agent App Composition', label: 'examples/headless-agent', - config: 'examples/headless-agent/cordis.yml', + configs: ['examples/headless-agent/cordis.yml'], summary: 'The headless demo combines the real DeepSeek adapter and coding capabilities with the one-shot app package, format-pure stdout, and one fresh persisted top-level session.', }, { @@ -613,7 +613,7 @@ const APP_EXAMPLES = [ rel: 'examples/acp-agent/composition.md', title: 'ACP Automation App Composition', label: 'examples/acp-agent', - config: 'examples/acp-agent/cordis.yml', + configs: ['examples/acp-agent/cordis.yml'], summary: 'The ACP demo exposes fresh baseline-prompt agent sessions to programmatic clients over JSON-RPC stdio, with no stdout logger, human UI, or pre-created agent.', }, ] @@ -639,15 +639,15 @@ function renderAppExpansion(lines: string[], appNode: string, pluginName: string } function renderAppComposition(example: AppExample): string { - const plugins = parseExampleCordis(example.config) - const maintenance = 'hybrid: the leaf plugin list is parsed from its `cordis.yml`; app package expansion is curated from package source' + const plugins = example.configs.flatMap(parseExampleCordis) + const maintenance = 'hybrid: the leaf plugin list is parsed from its shipped config files; app package expansion is curated from package source' const lines = generatedHeader(example.title) lines.push( example.summary, '', '```mermaid', 'flowchart LR', - ` cfg["${escLabel(example.label)}
cordis.yml"]`, + ` cfg["${escLabel(example.label)}
${escLabel(example.configs.map(config => config.split('/').at(-1)).join(' + '))}"]`, ) for (const plugin of plugins) { const pluginNode = nodeId(`plugin_${example.id}`, plugin.id) @@ -664,7 +664,7 @@ function renderAppComposition(example: AppExample): string { '| --- | --- |', ...plugins.map(plugin => `| \`${plugin.id}\` | \`${plugin.name}\` |`), '', - `Source config: [\`${example.config}\`](${linkFromDoc(example.rel, example.config)}).`, + `Source config${example.configs.length === 1 ? '' : 's'}: ${example.configs.map(config => `[\`${config}\`](${linkFromDoc(example.rel, config)})`).join(', ')}.`, ) lines.push('', ...maintenanceFooter(maintenance)) return lines.join('\n') diff --git a/scripts/verify-cordis-config.ts b/scripts/verify-cordis-config.ts index b0f7b06eb6..9a188a5c75 100644 --- a/scripts/verify-cordis-config.ts +++ b/scripts/verify-cordis-config.ts @@ -78,6 +78,11 @@ function validateEntry(value: unknown, file: string, path: string): void { validateEntry(value.config[index], file, `${path}.config[${index}]`) } } + if (isUnknownArray(value.insert)) { + for (let index = 0; index < value.insert.length; index++) { + validateEntry(value.insert[index], file, `${path}.insert[${index}]`) + } + } if (value.name !== '@cordisjs/plugin-include') return const config = value.config if (!isRecord(config) || !isUnknownArray(config.patches)) return @@ -124,7 +129,7 @@ function validateExampleResolution(): string[] { function validateAppResolution(): string[] { const dependencies = readManifest('apps/cli/package.json').dependencies ?? {} - const references = pluginReferences.filter(reference => reference.file === 'apps/cli/base.cordis.yml') + const references = pluginReferences.filter(reference => reference.file.startsWith('apps/cli/')) return missingPluginDependencies(references, dependencies, 'apps/cli/package.json') } From d44fce8a39ddcbf5ca6cde7390537591e0f37cd4 Mon Sep 17 00:00:00 2001 From: Turtle Date: Wed, 29 Jul 2026 17:59:28 +0800 Subject: [PATCH 029/113] fix(cli): keep meta as a fresh-session command --- apps/cli/src/bin.ts | 2 +- apps/cli/src/tui.ts | 7 ++----- 2 files changed, 3 insertions(+), 6 deletions(-) diff --git a/apps/cli/src/bin.ts b/apps/cli/src/bin.ts index c5003cf795..619d0dcd58 100644 --- a/apps/cli/src/bin.ts +++ b/apps/cli/src/bin.ts @@ -45,7 +45,7 @@ switch (invocation.mode) { } case 'meta': { const { runMeta } = await import('./tui.ts') - await runMeta(invocation.resume) + await runMeta() break } case 'migrate': diff --git a/apps/cli/src/tui.ts b/apps/cli/src/tui.ts index e9d108bfb0..f4b4b18f40 100644 --- a/apps/cli/src/tui.ts +++ b/apps/cli/src/tui.ts @@ -82,12 +82,9 @@ export function launcherSessionsRoot(): string { /** * Run the interactive TUI with this harness checkout as the workspace * (`dsh meta`), whatever directory it was launched from. - * @param resumeSessionId - a persisted session id to resume, or `undefined`; - * see {@link runTui}. Meta-mode sessions live under the checkout, so an id from - * an ordinary `dsh` run in another directory is not found here. */ -export async function runMeta(resumeSessionId: string | undefined): Promise { - return runTui(undefined, resumeSessionId, SOURCE_ROOT) +export async function runMeta(): Promise { + return runTui(undefined, undefined, SOURCE_ROOT) } /** From fdd113ce5ecfb04d72c40816a8037ca7b7a9d6cc Mon Sep 17 00:00:00 2001 From: Turtle Date: Wed, 29 Jul 2026 18:18:10 +0800 Subject: [PATCH 030/113] fix(tui): read mounted session query during sibling activation --- packages/ui/tui/src/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/ui/tui/src/index.ts b/packages/ui/tui/src/index.ts index 78c3505db9..a860400e2b 100644 --- a/packages/ui/tui/src/index.ts +++ b/packages/ui/tui/src/index.ts @@ -854,7 +854,7 @@ export function createTuiChat( overlayManager, // Optional and independently mounted: read at each use so config row order // cannot decide whether /resume works. - sessionQuery: () => ctx.get('sessionQuery'), + sessionQuery: () => ctx.get('sessionQuery', false), ui, editor, appendNotice, From 984830c94076f2d405f0bc47761ee78bc16fd8b1 Mon Sep 17 00:00:00 2001 From: Turtle Date: Wed, 29 Jul 2026 18:21:14 +0800 Subject: [PATCH 031/113] fix(tui): isolate each process query index --- apps/cli/src/tui.ts | 12 +++++++----- apps/cli/tsconfig.json | 3 +++ apps/cli/tui.cordis.yml | 7 +++---- .../session-query/session-query-sqlite/src/index.ts | 10 ++++++++++ 4 files changed, 23 insertions(+), 9 deletions(-) diff --git a/apps/cli/src/tui.ts b/apps/cli/src/tui.ts index f4b4b18f40..6964ac9753 100644 --- a/apps/cli/src/tui.ts +++ b/apps/cli/src/tui.ts @@ -19,6 +19,7 @@ import { randomUUID } from 'node:crypto' import { join, resolve } from 'node:path' +import { tmpdir } from 'node:os' import { fileURLToPath } from 'node:url' import { addHarnessSourceSection, @@ -31,6 +32,7 @@ import { } from '@deepseek-ai/dsh-app-boot' import { resolveDshHome, resolveSessionsRoot } from '@deepseek-ai/dsh-paths' import { SessionId } from '@deepseek-ai/dsh-session' +import { SESSION_QUERY_SQLITE_PATH_KEY } from '@deepseek-ai/dsh-session-query-sqlite' import { CONFIGURED_AGENT_IDENTITIES_KEY } from '@deepseek-ai/dsh-agent-loop' import type { Context } from 'cordis' import { @@ -57,8 +59,8 @@ const TUI_OVERLAY = fileURLToPath(new URL('../tui.cordis.yml', import.meta.url)) // session identity by this config id. const MAIN_AGENT_ID = 'main' -/** Filename of the derived `/resume` index, kept beside the session logs. */ -const SESSION_QUERY_DB = 'session-query.db' +/** Per-process filename of the disposable `/resume` index. */ +const SESSION_QUERY_DB = `session-query-${String(process.pid)}-${randomUUID()}.db` // The harness checkout root: three hops up from apps/cli/{src,lib}, resolved // from this bin's location so it holds however `dsh` is launched (a PATH @@ -242,9 +244,9 @@ export async function runTui( // same id, so a personal overlay repointing the model route cannot drop // the session identity or desynchronise the two. hostCtx.provide(CONFIGURED_AGENT_IDENTITIES_KEY, { [MAIN_AGENT_ID]: identity }) - // The launcher owns the session store location, so it also owns the - // derived index path that must sit beside those logs. - hostCtx.provide('launcherSessionQueryPath', join(launcherSessionsRoot(), SESSION_QUERY_DB)) + // The query database is a disposable derived index with single-process + // ownership. Keep it process-local while it indexes the shared logs. + hostCtx.provide(SESSION_QUERY_SQLITE_PATH_KEY, join(tmpdir(), SESSION_QUERY_DB)) if (resumeHost !== undefined) hostCtx.provide('tuiResumeHost', resumeHost) // Seed the first turn only for a fresh session, so resuming never // re-invokes the skill. diff --git a/apps/cli/tsconfig.json b/apps/cli/tsconfig.json index ce43c0f31c..a13d015a7a 100644 --- a/apps/cli/tsconfig.json +++ b/apps/cli/tsconfig.json @@ -29,6 +29,9 @@ { "path": "../../packages/util/paths" }, + { + "path": "../../packages/session-query/session-query-sqlite" + }, { "path": "../../packages/client/connection" }, diff --git a/apps/cli/tui.cordis.yml b/apps/cli/tui.cordis.yml index 82f5e7aa9e..f71be09e85 100644 --- a/apps/cli/tui.cordis.yml +++ b/apps/cli/tui.cordis.yml @@ -82,10 +82,9 @@ - id: session-checkpoint-policy name: '@deepseek-ai/dsh-session-checkpoint-policy' - # The derived query index behind `/resume`. The launcher owns the session - # store location, so it provides the resolved index path on the boot context - # (`launcherSessionQueryPath`); the index and the logs it indexes therefore - # cannot diverge. The project-local fallback applies when no launcher sets it. + # The derived query index behind `/resume`. The launcher provides a unique + # process-local path because this SQLite backend has one writer owner; the + # project-local fallback applies when no launcher sets the typed slot. - id: session-query-sqlite name: '@deepseek-ai/dsh-session-query-sqlite' config: diff --git a/packages/session-query/session-query-sqlite/src/index.ts b/packages/session-query/session-query-sqlite/src/index.ts index 0c073ccea5..e76e0a5a43 100644 --- a/packages/session-query/session-query-sqlite/src/index.ts +++ b/packages/session-query/session-query-sqlite/src/index.ts @@ -62,6 +62,16 @@ export { type JournalMode, } from './schema.ts' +/** Boot-context slot for a launcher-owned absolute path to this process's derived query index. */ +export const SESSION_QUERY_SQLITE_PATH_KEY = 'launcherSessionQueryPath' + +declare module 'cordis' { + interface Context { + /** Launcher-owned absolute path to this process's disposable derived query index. */ + launcherSessionQueryPath?: string + } +} + /** Default result page size. */ export const SESSION_QUERY_SQLITE_DEFAULT_LIMIT = 20 /** Maximum accepted result page size. */ From 9bbaea45fd014e861e07dd2dbac57267f0987721 Mon Sep 17 00:00:00 2001 From: Turtle Date: Wed, 29 Jul 2026 18:24:48 +0800 Subject: [PATCH 032/113] docs(cli): explain config overlay modes --- apps/cli/README.i18n.yaml | 4 ++-- apps/cli/README.md | 6 +++--- apps/cli/README.zh.md | 6 +++--- docs/config-catalog.md | 2 +- 4 files changed, 9 insertions(+), 9 deletions(-) diff --git a/apps/cli/README.i18n.yaml b/apps/cli/README.i18n.yaml index 72fe6eff6f..6ef7959159 100644 --- a/apps/cli/README.i18n.yaml +++ b/apps/cli/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write apps/cli/README.md -README.md: 2c1d373b39ecd188dde42d24800a1602f39da582 -README.zh.md: b4c1e8a913e3efbac828b22568abe85ee0f7ab37 +README.md: 2589462dfb5fc600b0e480a5a41c32860bf6837d +README.zh.md: 3b5e5319b76d9b18b2719cc8e943faaf398c51af diff --git a/apps/cli/README.md b/apps/cli/README.md index 2c1d373b39..2589462dfb 100644 --- a/apps/cli/README.md +++ b/apps/cli/README.md @@ -7,18 +7,18 @@ Argv is parsed once through a [Commander](https://github.com/tj/commander.js) ad The TUI surface: -- boots the shipped default config (`apps/cli/base.cordis.yml`), or the tree named by `--config ` (the demo/test escape for booting an alternate example tree), through [`dsh-app-boot`](../../packages/ui/app-boot/README.md); +- boots `base.cordis.yml` plus `tui.cordis.yml` through [`dsh-app-boot`](../../packages/ui/app-boot/README.md); `--config ` applies a patch-list overlay instead of the personal overlay, while `--config-replace ` boots that file as the complete tree; - resumes a persisted session with `dsh --resume ` and, when the Node host exposes `process.execve`, supplies the TUI's in-place handoff host: after selector preflight and current-session flush, the host disposes the app and replaces the process with a normalized resume invocation; runtimes without process replacement leave the session running and say so. This CLI owns session identity and the exit line rather than the config: it mints or selects the `main` session id and provides it, plus the exact command that reproduces this invocation, on the boot context ([`MAIN_SESSION_ID_KEY`](../../packages/ui/tui/README.md) and `TUI_GOODBYE_MESSAGE_KEY`). No `cordis.yml` key can drop resume, and a missing or unreadable id fails loud instead of creating a fresh session; - treats the **invoking directory** as the workspace — sessions, relative paths, and workspace instructions resolve from the cwd (`dsh meta` is the sole exception, below); - tells the agent where its own source lives: after boot it adds a prompt section naming this harness checkout, resolved from the launcher's real path so it holds under a PATH symlink and an arbitrary cwd, so the self-referential `cordis` toolset can read and modify it; - applies the personal overlay from `~/.dsh` (see [app-boot's Personal config](../../packages/ui/app-boot/README.md#personal-config)): `.env` fills environment gaps (ambient > project `.env` > personal `.env`), `config.yaml` patches the booted tree. -`dsh meta` is that same TUI with this harness checkout as the workspace, so working on dsh itself needs no `cd`. It chdirs to the checkout root — resolved from the launcher's real path, the same root the source-path prompt section names — after both `.env` layers are loaded, so environment precedence is unchanged while the session cwd, the `./.sessions` persistence root, and the HMR watch root all move together. It accepts only `--resume `; `--config` (which would boot a foreign tree) and `-p` (which is not interactive) fail loud. Because meta sessions live under the checkout, `--resume` here sees only other meta sessions, and both the in-place handoff and the printed exit line reproduce the mode as `dsh meta --resume `, so a copied command resumes the right session from any directory. +`dsh meta` is that same TUI with this harness checkout as the workspace, so working on dsh itself needs no `cd`. It chdirs to the checkout root — resolved from the launcher's real path, the same root the source-path prompt section names — after both `.env` layers are loaded, so environment precedence is unchanged while the session cwd and HMR watch root move together. Meta always starts a fresh session and accepts no default-surface options; use ordinary `dsh --resume ` to resume a persisted session. `dsh migrate` and `dsh upgrade` are guided fresh-session entries over the default TUI surface: each mints a fresh session in the invoking directory and seeds its first turn with a bundled skill (`dsh-migrate` for migrating from another coding agent — opencode, pi, Claude Code, Codex; `dsh-upgrade` for upgrading this checkout), exactly as if the user typed `/skill:`. The launcher passes the skill name on the boot context ([`INITIAL_SKILL_KEY`](../../packages/ui/tui/README.md)), which the TUI auto-invokes once the chat is live. Both take no options — `--config`, `-p`, and `--resume` fail loud — and seed only on this first launch, so a later `dsh --resume ` of the session is an ordinary TUI session with no re-injection. -The Web and headless surfaces boot one shared composition (`cordis.yml`): both treat the invoking directory as the default project and Workspace root, create named Workspaces beneath that root unless `--workspace-root ` overrides it, load applicable `AGENTS.md`/`CLAUDE.md` instructions into each agent-loop request prefix with a 65,536-byte render budget, and opt into first-message model titles. Headless differs only in listening on an OS-assigned port (parallel `dsh -p` runs never collide; the stderr-printed URL opens the live session in a browser). Both need the frontend dist and client bundles built (`pnpm run build && pnpm run build:web`). +The Web and headless surfaces boot `base.cordis.yml` plus `web.cordis.yml`; `dsh web --config ` adds an overlay after the web surface defaults. Both surfaces otherwise share the same composition: both treat the invoking directory as the default project and Workspace root, create named Workspaces beneath that root unless `--workspace-root ` overrides it, load applicable `AGENTS.md`/`CLAUDE.md` instructions into each agent-loop request prefix with a 65,536-byte render budget, and opt into first-message model titles. Headless differs only in listening on an OS-assigned port (parallel `dsh -p` runs never collide; the stderr-printed URL opens the live session in a browser). Both need the frontend dist and client bundles built (`pnpm run build && pnpm run build:web`). The shipped TUI and Web compositions register the native DeepSeek adapter plus pi-ai OpenAI and Anthropic profiles. Credentials and endpoint overrides come from the provider-standard `DEEPSEEK_API_KEY` / `DEEPSEEK_BASE_URL`, `OPENAI_API_KEY` / `OPENAI_BASE_URL`, and `ANTHROPIC_API_KEY` / `ANTHROPIC_BASE_URL` pairs in the boot's layered environment. diff --git a/apps/cli/README.zh.md b/apps/cli/README.zh.md index b4c1e8a913..3b5e5319b7 100644 --- a/apps/cli/README.zh.md +++ b/apps/cli/README.zh.md @@ -7,18 +7,18 @@ Argv 只会通过 [Commander](https://github.com/tj/commander.js) 适配器([` TUI 界面: -- 启动已交付的默认配置(`apps/cli/base.cordis.yml`),或由 `--config ` 指定的树(演示/测试用于启动其他示例树的逃生口),并通过 [`dsh-app-boot`](../../packages/ui/app-boot/README.md) 完成启动; +- 通过 [`dsh-app-boot`](../../packages/ui/app-boot/README.md) 启动 `base.cordis.yml` 与 `tui.cordis.yml`;`--config ` 应用一个补丁列表覆盖并替代个人覆盖,而 `--config-replace ` 将指定文件作为完整配置树启动; - 使用 `dsh --resume ` 恢复已持久化会话。当 Node 宿主公开 `process.execve` 时,还会提供 TUI 的原地移交宿主:选择器预检并刷新当前会话后,宿主会释放应用,并以规范化的恢复调用替换进程;不支持进程替换的运行时会让会话继续运行并给出提示。会话身份与退出行由本 CLI 拥有,而非由配置指定:它创建或选定 `main` 会话 id,并把该 id 以及可复现本次调用的确切命令一起提供到启动上下文([`MAIN_SESSION_ID_KEY`](../../packages/ui/tui/README.md) 与 `TUI_GOODBYE_MESSAGE_KEY`)。任何 `cordis.yml` 键都无法移除恢复能力;缺失或无法读取的 id 会明确报错,而不会创建新会话; - 将 **调用目录** 视为 workspace:会话、相对路径和 workspace 指令都从 cwd 解析(`dsh meta` 是唯一例外,见下文); - 告知 agent 自身源码所在位置:启动后添加一个命名此 harness checkout 的提示词段。该路径从启动器的真实路径解析,因此在 PATH 符号链接和任意 cwd 下仍然有效,使自指的 `cordis` 工具集可以读取并修改它; - 应用 `~/.dsh` 中的个人覆盖(参见 [app-boot 的个人配置](../../packages/ui/app-boot/README.md#personal-config)):`.env` 填补环境缺口(环境中已有的值 > 项目 `.env` > 个人 `.env`),`config.yaml` 则修补已启动的树。 -`dsh meta` 是以本 harness checkout 为 workspace 的同一个 TUI,因此开发 dsh 自身无需 `cd`。它在两层 `.env` 都加载之后才 chdir 到 checkout 根目录(从启动器的真实路径解析,与源码路径提示词段所指的根目录相同),因此环境优先级不变,而会话 cwd、`./.sessions` 持久化根目录与 HMR 监视根目录会一并移动。它只接受 `--resume `;`--config`(会启动其他配置树)和 `-p`(非交互)都会明确报错。由于 meta 会话位于该 checkout 之下,此处的 `--resume` 只能看到其他 meta 会话;原地移交与打印的退出行都会以 `dsh meta --resume ` 复现该 mode,因此复制的命令在任何目录下都能恢复到正确的会话。 +`dsh meta` 是以本 harness checkout 为 workspace 的同一个 TUI,因此开发 dsh 自身无需 `cd`。它在两层 `.env` 都加载之后才 chdir 到 checkout 根目录(从启动器的真实路径解析,与源码路径提示词段所指的根目录相同),因此环境优先级不变,而会话 cwd 与 HMR 监视根目录会一并移动。Meta 始终创建新会话,不接受默认界面的任何选项;恢复已持久化会话应使用普通的 `dsh --resume `。 `dsh migrate` 与 `dsh upgrade` 是默认 TUI 界面之上的引导式全新会话入口:各自在调用目录中创建一个全新会话,并以一个内置 skill 播种其首轮(`dsh-migrate` 用于从其他编码 agent 迁移——opencode、pi、Claude Code、Codex;`dsh-upgrade` 用于升级本 checkout),效果等同于用户手动键入 `/skill:`。启动器将 skill 名称提供到启动上下文([`INITIAL_SKILL_KEY`](../../packages/ui/tui/README.md)),TUI 在聊天就绪后自动调用它。两者都不接受任何选项——`--config`、`-p`、`--resume` 都会明确报错——且仅在首次启动时播种,因此之后 `dsh --resume ` 恢复该会话时是普通 TUI 会话,不会重复注入。 -Web 和无头界面启动同一个共享组合(`cordis.yml`):两者都将调用目录视为默认项目和 Workspace 根目录,除非通过 `--workspace-root ` 覆盖,否则会在该根目录下创建具名 Workspace;它们会把适用的 `AGENTS.md`/`CLAUDE.md` 指令加载到每个 agent-loop 请求前缀中,渲染预算为 65,536 字节,并选用首条消息模型标题。无头界面唯一的差异是监听操作系统分配的端口(并行 `dsh -p` 运行绝不冲突;stderr 打印的 URL 会在浏览器中打开实时会话)。两者都需要先构建前端 dist 和客户端 bundle(`pnpm run build && pnpm run build:web`)。 +Web 和无头界面启动 `base.cordis.yml` 与 `web.cordis.yml`;`dsh web --config ` 会在 Web 界面默认值之后追加一个覆盖。除此之外,两者共享同一套组合:两者都将调用目录视为默认项目和 Workspace 根目录,除非通过 `--workspace-root ` 覆盖,否则会在该根目录下创建具名 Workspace;它们会把适用的 `AGENTS.md`/`CLAUDE.md` 指令加载到每个 agent-loop 请求前缀中,渲染预算为 65,536 字节,并选用首条消息模型标题。无头界面唯一的差异是监听操作系统分配的端口(并行 `dsh -p` 运行绝不冲突;stderr 打印的 URL 会在浏览器中打开实时会话)。两者都需要先构建前端 dist 和客户端 bundle(`pnpm run build && pnpm run build:web`)。 已交付的 TUI 和 Web 组合会注册原生 DeepSeek 适配器,以及 pi-ai 的 OpenAI 和 Anthropic 提供方配置。凭据和端点覆盖来自启动分层环境中的提供方标准变量对:`DEEPSEEK_API_KEY` / `DEEPSEEK_BASE_URL`、`OPENAI_API_KEY` / `OPENAI_BASE_URL` 和 `ANTHROPIC_API_KEY` / `ANTHROPIC_BASE_URL`。 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 49e23c662e..2d4dab9d78 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -1133,7 +1133,7 @@ export type JournalMode = 'wal' | 'delete' | 'truncate' | 'persist' Depends on: [`SessionQueryConfig`](../packages/session-query/session-query/src/index.ts) -Source: [`packages/session-query/session-query-sqlite/src/index.ts:76`](../packages/session-query/session-query-sqlite/src/index.ts) +Source: [`packages/session-query/session-query-sqlite/src/index.ts:86`](../packages/session-query/session-query-sqlite/src/index.ts) ## `@deepseek-ai/dsh-session-reference` From fa1ca61cfd176c5c44c20cf87cea56d477e61613 Mon Sep 17 00:00:00 2001 From: Turtle Date: Wed, 29 Jul 2026 18:27:37 +0800 Subject: [PATCH 033/113] fix(hygiene): remove stale session registry fragment --- knip.json | 9 --------- 1 file changed, 9 deletions(-) diff --git a/knip.json b/knip.json index b79a4d8977..a492f5a895 100644 --- a/knip.json +++ b/knip.json @@ -287,15 +287,6 @@ "src/**/*.ts", "tests/**/*.ts" ] - }, - "entry": [ - "tests/**/*.spec.ts", - "tests/fixtures/register-once.ts" - ], - "project": [ - "src/**/*.ts", - "tests/**/*.ts" - ] }, "packages/session-query/session-query-sqlite": { "entry": [ From 291d6d27729e502dc85dfaed93dac6dda238f130 Mon Sep 17 00:00:00 2001 From: Turtle Date: Wed, 29 Jul 2026 18:31:32 +0800 Subject: [PATCH 034/113] docs: refresh module graph after query dependency --- docs/module-graph.md | 19 ------------------- 1 file changed, 19 deletions(-) diff --git a/docs/module-graph.md b/docs/module-graph.md index f20dd1c5fd..c30cee6162 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -180,7 +180,6 @@ flowchart TD pkg_agent_spine_demo["agent-spine-demo"] pkg_cli_demo["cli-demo"] pkg_jsonrpc_demo["jsonrpc-demo"] - pkg_tui_demo["tui-demo"] end subgraph group_guard["packages/guard"] pkg_repeat_tool_guard["repeat-tool-guard"] @@ -935,24 +934,6 @@ flowchart TD pkg_cli_demo --> pkg_session_persistence_jsonl pkg_cli_demo --> pkg_tools pkg_cli_demo --> pkg_workspace_context - pkg_tui_demo --> pkg_agent - pkg_tui_demo --> pkg_agent_loop - pkg_tui_demo --> pkg_agent_spine_demo - pkg_tui_demo --> pkg_command_goal - pkg_tui_demo --> pkg_commands - pkg_tui_demo --> pkg_invariants - pkg_tui_demo --> pkg_llm - pkg_tui_demo --> pkg_session - pkg_tui_demo --> pkg_session_checkpoint_policy - pkg_tui_demo --> pkg_session_persistence_jsonl - pkg_tui_demo --> pkg_session_query - pkg_tui_demo --> pkg_session_query_sqlite - pkg_tui_demo --> pkg_session_reference - pkg_tui_demo --> pkg_tool_ask_user - pkg_tui_demo --> pkg_tools - pkg_tui_demo --> pkg_tui - pkg_tui_demo --> pkg_user_interaction - pkg_tui_demo --> pkg_workspace_context pkg_sdk_client --> pkg_invariants pkg_sdk_client --> pkg_llm pkg_sdk_client --> pkg_sdk_protocol From 9970f609691d570b637ce396773a02733643962e Mon Sep 17 00:00:00 2001 From: Turtle Date: Wed, 29 Jul 2026 21:12:36 +0800 Subject: [PATCH 035/113] test: cover overlays and refresh translation snapshot --- packages/ui/app-boot/tests/app-boot.spec.ts | 21 ++++++++++++++++++- .../request-response.expected.json | 4 ++-- 2 files changed, 22 insertions(+), 3 deletions(-) diff --git a/packages/ui/app-boot/tests/app-boot.spec.ts b/packages/ui/app-boot/tests/app-boot.spec.ts index db425c301a..3da19e67c2 100644 --- a/packages/ui/app-boot/tests/app-boot.spec.ts +++ b/packages/ui/app-boot/tests/app-boot.spec.ts @@ -6,7 +6,7 @@ import { Context } from 'cordis' import SystemPrompt, { renderPrompt } from '@deepseek-ai/dsh-system-prompt' import { addHarnessSourceSection, assertEntriesLoaded, boot, HARNESS_SOURCE_SECTION, - installFailLoud, loadEnv, resolveConfigPath, type FailLoudProcess, + installFailLoud, loadEnv, loadOverlayPatches, resolveConfigPath, type FailLoudProcess, } from '../src/index.ts' const NAME = 'dsh-test-bin' @@ -157,6 +157,25 @@ describe('assertEntriesLoaded', () => { }) }) +describe('loadOverlayPatches', () => { + it('loads expressions and rejects missing, malformed, non-array, and non-mapping overlays', () => { + const dir = tmp() + const valid = join(dir, 'valid.yml') + writeFileSync(valid, '- id: target\n config:\n value: !!js process.env.VALUE\n') + expect(loadOverlayPatches(NAME, valid)).toEqual([{ id: 'target', config: { value: { __jsExpr: 'process.env.VALUE' } } }]) + expect(() => loadOverlayPatches(NAME, join(dir, 'missing.yml'))).toThrow(`${NAME}: failed to read overlay`) + const malformed = join(dir, 'malformed.yml') + writeFileSync(malformed, ': bad') + expect(() => loadOverlayPatches(NAME, malformed)).toThrow(`${NAME}: failed to parse overlay`) + const mapping = join(dir, 'mapping.yml') + writeFileSync(mapping, 'id: target\n') + expect(() => loadOverlayPatches(NAME, mapping)).toThrow('must be a top-level YAML array') + const scalar = join(dir, 'scalar.yml') + writeFileSync(scalar, '- scalar\n') + expect(() => loadOverlayPatches(NAME, scalar)).toThrow('entry 1') + }) +}) + describe('boot', () => { it('boots a leaf config through the real Loader and settles the tree', async () => { const dir = tmp() diff --git a/scripts/snapshots/translation-prompt-v4/request-response.expected.json b/scripts/snapshots/translation-prompt-v4/request-response.expected.json index 5cfb198296..093a42c479 100644 --- a/scripts/snapshots/translation-prompt-v4/request-response.expected.json +++ b/scripts/snapshots/translation-prompt-v4/request-response.expected.json @@ -16,11 +16,11 @@ }, { "role": "user", - "content": "# Development guide\n\nEnglish | [中文](development.zh.md)\n\nThis onboarding guide helps project contributors get started with the local environment, daily workflow, and CI flow; see the Agent Notes for design rationale and technical trade-offs.\n\n## Prerequisites\n\n- Node.js supports 22.19+ and 24+. CI covers 22.19, 24, and 26; see the [Node engine floor Agent Note](../.agents/notes/implemented/process/2026-07-06-node-engine-floor.md).\n- Corepack-enabled pnpm. The repo pins `pnpm@11.7.0` in `package.json`; run `corepack enable` if `pnpm --version` does not resolve through Corepack.\n- Git 2.26 or newer; hook setup enables Git's worktree-specific configuration extension.\n- Optional: a DeepSeek API key for the TUI, headless, and ACP automation demos and real-API e2e tests.\n\n## First-time setup\n\nInstall dependencies from the repo root:\n\n```sh\npnpm install\n```\n\nThe install also runs the root `postinstall` script, which installs lefthook from the repo dev dependency through `scripts/install-lefthook.mjs`. With `CI=true` or `GITHUB_ACTIONS=true`, the wrapper returns before Git discovery because automated jobs do not consume contributor hooks. Otherwise, it requires Git 2.26 or newer and gives the current worktree an explicit hook directory under its own Git directory; linked worktrees therefore use their own lefthook binary and configuration instead of rewriting common hooks. The first install enables Git's worktree-specific configuration extension and repository format 1; see the [worktree-local hooks Agent Note](../.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.md).\n\nIf hooks are missing because dependencies were restored from cache or `postinstall` was skipped, install them manually:\n\n```sh\nnode scripts/install-lefthook.mjs\n```\n\nThe wrapper refuses user-owned `core.hooksPath` values. An inherited system, global, or common-repository path requires `DSH_LEFTHOOK_ALLOW_HOOKS_PATH_OVERRIDE=1`; command-scoped and worktree-scoped custom paths must be integrated or removed explicitly.\n\nBefore enabling worktree config, migrate direct `extensions.*` in a format-0 common config, direct `core.worktree` or `core.bare=true`, and any non-empty dormant `config.worktree`. The common config and every worktree config must be regular files, while the owned hook directory may contain only unaliased regular files.\n\nAfter moving a checkout, rerun the wrapper to relocate its owned path and regenerate hooks. For a stale or invalid installer lock, first confirm no installer is running, then remove the reported lock and retry. If installation and hook-path rollback both fail, inspect the reported worktree config before retrying. The [worktree-local hooks Agent Note](../.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.md) owns the full safety contract.\n\nRun typecheck once after a fresh clone:\n\n```sh\npnpm run typecheck\n```\n\nThat first typecheck runs the whole-repo `tsc -b` graph: it emits every package/vendor `lib/types` and checks examples, tests, and scripts through the two no-emit aggregates described below.\n\n## TypeScript project layout\n\nThe repository's TypeScript configuration has exactly three roles; every tsconfig file plays one of them.\n\n| File | Role | Forms a program? |\n|---|---|---|\n| `tsconfig.json` | Solution root: `extends` base, `files: []`, references to the two aggregates. The whole-repo `tsc -b tsconfig.json` graph, the tsserver discovery entry, and — through the inherited `paths` — the resolution config for tsx running `examples/` and `scripts/` (their nearest tsconfig is this file). | No |\n| `tsconfig.host.json` | Host aggregate: host-side packages (via references), examples, tests, scripts, website. Excludes `packages/client`. | Yes |\n| `tsconfig.client.json` | Client aggregate: `packages/client/*` packages and their tests, `apps/web`. | Yes |\n| `tsconfig.base.json` | Shared compilerOptions and the source `paths` map. Also the resolution facade the vitest configs point vite-tsconfig-paths at: it has no `include`, so its `paths` apply to every importer. | No |\n| `tsconfig.base.client.json` | Browser compiler shape (`jsx`, DOM libs, `types: []`) extended by the client aggregate and every `packages/client/*` package. | No |\n\nHost and client stay two aggregate programs because both sides declaration-merge the cordis `Context` interface under the same keys with different services; one program seeing both merges reports a collision. The collision exists only inside a `ts.Program` — module resolution never triggers it — which is why the solution may reference both aggregates and one paths facade may span both sides. Two disciplines follow:\n\n- `tsconfig.base.json` never gains `include` or `files`: they would leak into every extending package project and narrow the facade's match-all scope.\n- A script that builds a repo-wide `ts.Program` seeds `tsconfig.host.json` or `tsconfig.client.json` explicitly — never the root solution, because flattening both aggregates into one program collides the `Context` merges. Program-backed generators and gates (`scripts/ts-project.ts` consumers, doc-typecheck standalone mode) are host-only by decision; the client side gains program-backed tooling only with a concrete need.\n\nStatic analysis and tests resolve workspace imports through the base `paths` map to `src` and must pass on a clean tree; gates that consume built `lib/` output declare that dependency explicitly. Decision record: [solution-root note](../.agents/notes/implemented/process/2026-07-22-tsconfig-solution-root-two-aggregates.md); the tsc-first emit pipeline is the [ts-build-config note](../.agents/notes/implemented/process/2026-06-17-ts-build-config.md).\n\nIf a relevant local check consumes built package output, build once first:\n\n```sh\npnpm run build\n```\n\n`pnpm run hygiene` includes `publint`, which validates package entrypoints against the built `lib/*.js` files, and `verify-node-next-types`, which validates built declarations against a temporary NodeNext consumer. A fresh worktree has no bundled JS or declarations until `pnpm run build` runs; ordinary commits and pushes do not require that build unless their selected checks consume it.\n\n## Environment variables\n\nThe real DeepSeek adapter and key-backed agent demos read credentials from the environment or from a gitignored `.env` at the repo root:\n\n```sh\nDEEPSEEK_API_KEY=sk-...\nDEEPSEEK_BASE_URL=https://... # optional\n```\n\n`DEEPSEEK_BASE_URL` is optional and defaults to the public API. Never commit real credentials. The real-API e2e suites self-skip when `DEEPSEEK_API_KEY` is not set.\n\n## Git hooks\n\nlefthook is configured in `lefthook.yml` as a fast local checkpoint:\n\n- `pre-commit` runs staged-file ESLint fixes, checks the staged diff for whitespace errors, and runs the vendor manifest guard.\n- `pre-push` runs only the incremental repository typecheck (`tsc -b` over the root solution, covering both the host and client aggregates).\n\nThe vendor manifest guard checks that changes under `vendor/*/src` are staged with the matching `vendor/README.md` manifest update. See `vendor/README.md` before editing vendored code.\n\nThe hooks intentionally do not run tests, snapshots, documentation checks, builds, or hygiene. Contributors run the [checks relevant to the changed behavior](../AGENTS.md#run-relevant-checks-locally) once; CI owns exhaustive coverage, built-artifact smokes, and the Node 22.19, 24, and 26 compatibility matrix.\n\nContributors can opt into the comprehensive local gate set with `pnpm run check:all`. The command is independent of both Git hooks and is not an agent instruction.\n\n## CI gates\n\nThe keyless [CI workflow](../.github/workflows/ci.yml) groups independent gates into broad lanes and runs a smaller compatibility signal across supported Node versions. Artifact consumers wait for one build within their lane. The separate real-API workflow runs `pnpm run test:e2e` with its configured worker bound. See [scripts/run-gates.ts](../scripts/run-gates.ts) and the workflow files for the current gate and job inventory.\n\n## Daily commands\n\nUse these from the repo root:\n\n```sh\npnpm run test # unit tests\npnpm run test:coverage # unit tests with per-file coverage gates\npnpm run test:e2e # real-API tests; self-skips without DEEPSEEK_API_KEY\npnpm run check:all # comprehensive opt-in gate set; not wired to Git hooks\npnpm run typecheck # tsc -b over the root solution: emits package/vendor lib/types, checks both aggregates\npnpm run lint # eslint .\npnpm run lint:fix # eslint . --fix\npnpm run doc-typecheck # compile checked TypeScript snippets in Markdown docs\npnpm run gen-cordis-catalog # regenerate docs/cordis-catalog/events.md + services.md from source\npnpm run verify-cordis-catalog # fail if either cordis catalog is stale\npnpm run verify-export-jsdoc # fail if a module-level package export lacks complete JSDoc\npnpm run gen-doc-graphs # regenerate generated relationship docs from source and curated graph definitions\npnpm run verify-doc-graphs # fail if generated relationship docs are stale\npnpm run verify-md-wrap # fail on hard-wrapped prose paragraphs in docs/README markdown\npnpm run verify-mermaid # fail if a ```mermaid diagram has invalid Mermaid syntax\npnpm run verify-type-equiv # fail if a ```ts type-equiv doc block drifts from its source type\npnpm run verify-doc-budgets # fail if a budgeted standing doc exceeds its word ceiling\npnpm run gen-translation-brief # print the minimal-update briefing for out-of-sync translation pairs (--apply splices code-only edits)\npnpm run doc-sync # all Markdown/doc gates, scheduled concurrently; the doc-sync leaf list in scripts/run-gates.ts is the full list\npnpm run gen-module-graph # regenerate docs/module-graph.md from package peerDeps\npnpm run verify-module-graph # fail if docs/module-graph.md is stale\npnpm run build # emit lib/types intermediates, then bundle lib/index.* runtime files\npnpm run verify-node-next-types # fail if built declarations are not NodeNext-consumable\npnpm run hygiene # knip, publint, workspace constraints, and NodeNext declaration check\n```\n\nWhen changing package public behavior, update the relevant README or JSDoc in the same change. `pnpm run doc-sync` catches checked TypeScript snippets, generated doc freshness, markdown wrap/link drift, type equivalence, translation pairing, Mermaid syntax, and doc budgets, but broader prose/API sync still needs review.\n\n## Demos\n\nThe one-shot Headless coding agent needs `DEEPSEEK_API_KEY` in the environment or repo-root `.env`:\n\n```sh\npnpm run demo:headless \"summarize this workspace\"\n```\n\nThe full-screen interactive coding agent needs `DEEPSEEK_API_KEY` in the environment or repo-root `.env`:\n\n```sh\npnpm run demo:tui\n```\n\nThe self-referential cordis demo can inspect and modify its live plugin runtime and needs the same credentials (`web` by default, or `acp`):\n\n```sh\npnpm run demo:cordis\n```\n\nThe ACP automation server exposes fresh agent sessions over JSON-RPC stdio and also needs `DEEPSEEK_API_KEY`:\n\n```sh\npnpm run demo:acp\n```\n\n## TODO markers\n\nUse one of three comment tags to flag known issues in the code, ordered by urgency:\n\n- `FIXME` — an issue that should block a new release. A release should not ship with an open `FIXME` unless reviewers explicitly agree the change can be merged anyway.\n- `TODO` — an issue that should be fixed soon, once we have the resources.\n- `XXX` — an issue that we may fix someday; lowest priority, no commitment.\n\nPick the tag that matches the urgency so anyone scanning the code can tell a release blocker from a someday-maybe.\n\n## Documenting types verbatim (`ts type-equiv`)\n\nThe [core data structures](core-data-structures/core.md) docs paste source-equivalent declarations together with their original JSDoc so a reader sees the exact shape and source contract. To keep a paste from drifting when source changes, fence it as ` ```ts type-equiv ` (instead of ` ```ts `) and register it in `scripts/type-equiv.manifest.json` with the source file and symbol it mirrors:\n\n```json\n{ \"doc\": \"docs/core-data-structures/session.md\", \"symbol\": \"SessionEvent\", \"source\": \"packages/core/session/src/types.ts\" }\n```\n\n`pnpm run verify-type-equiv` (part of `doc-sync`) then extracts that symbol's declaration and attached JSDoc from source via the TypeScript parser and asserts the block matches both. For a class whose implementation bodies do not belong in the catalog, use ` ```ts public-api ` and set `\"projection\": \"public-api\"`; the checked projection retains the public fields, constructor, accessors, methods, and original class/member JSDoc while omitting bodies and private or protected members. Comparison ignores whitespace and non-JSDoc comments but requires every original JSDoc comment, including member documentation, so readers see the source contract beside the exact shape. The gate enforces a 1:1 correspondence by document, symbol, and projection between primary blocks and manifest entries; a paired `.zh.md` block reuses its unsuffixed sibling's entry only when the whole tracked fence sequence is byte-identical and ordered identically. `doc-typecheck` applies the same derivative rule to compilable fences, while skipping both source-equivalence fence kinds from compilation and its opt-out ratio. When you change a documented declaration or its JSDoc, the gate fails until you update the paste; when you add or remove a primary block, update the manifest in the same change.\n\n## Architecture context\n\nRead `docs/architecture.md` before changing anything under `packages/`. The codebase is built around Cordis plugins, event-sourced sessions, typed service seams, and explicit extension points.\n" + "content": "# Development guide\n\nEnglish | [中文](development.zh.md)\n\nThis onboarding guide helps project contributors get started with the local environment, daily workflow, and CI flow; see the Agent Notes for design rationale and technical trade-offs.\n\n## Prerequisites\n\n- Node.js supports 22.19+ and 24+. CI covers 22.19, 24, and 26; see the [Node engine floor Agent Note](../.agents/notes/implemented/process/2026-07-06-node-engine-floor.md).\n- Corepack-enabled pnpm. The repo pins `pnpm@11.7.0` in `package.json`; run `corepack enable` if `pnpm --version` does not resolve through Corepack.\n- Git 2.26 or newer; hook setup enables Git's worktree-specific configuration extension.\n- Optional: a DeepSeek API key for the TUI, headless, and ACP automation demos and real-API e2e tests.\n\n## First-time setup\n\nInstall dependencies from the repo root:\n\n```sh\npnpm install\n```\n\nThe install also runs the root `postinstall` script, which installs lefthook from the repo dev dependency through `scripts/install-lefthook.mjs`. With `CI=true` or `GITHUB_ACTIONS=true`, the wrapper returns before Git discovery because automated jobs do not consume contributor hooks. Otherwise, it requires Git 2.26 or newer and gives the current worktree an explicit hook directory under its own Git directory; linked worktrees therefore use their own lefthook binary and configuration instead of rewriting common hooks. The first install enables Git's worktree-specific configuration extension and repository format 1; see the [worktree-local hooks Agent Note](../.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.md).\n\nIf hooks are missing because dependencies were restored from cache or `postinstall` was skipped, install them manually:\n\n```sh\nnode scripts/install-lefthook.mjs\n```\n\nThe wrapper refuses user-owned `core.hooksPath` values. An inherited system, global, or common-repository path requires `DSH_LEFTHOOK_ALLOW_HOOKS_PATH_OVERRIDE=1`. When Git seeds a new worktree with another registered worktree's marker-backed hook path, the wrapper replaces that copied value with the new worktree's own path; command-scoped and other worktree-scoped paths must be integrated or removed explicitly.\n\nBefore enabling worktree config, migrate direct `extensions.*` in a format-0 common config, direct `core.worktree` or `core.bare=true`, and any non-empty dormant `config.worktree`. The common config and every worktree config must be regular files, while the owned hook directory may contain only unaliased regular files.\n\nAfter moving a checkout, rerun the wrapper to relocate its owned path and regenerate hooks. For a stale or invalid installer lock, first confirm no installer is running, then remove the reported lock and retry. If installation and hook-path rollback both fail, inspect the reported worktree config before retrying. The [worktree-local hooks Agent Note](../.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.md) owns the full safety contract.\n\nRun typecheck once after a fresh clone:\n\n```sh\npnpm run typecheck\n```\n\nThat first typecheck runs the whole-repo `tsc -b` graph: it emits every package/vendor `lib/types` and checks examples, tests, and scripts through the two no-emit aggregates described below.\n\n## TypeScript project layout\n\nThe repository's TypeScript configuration has exactly three roles; every tsconfig file plays one of them.\n\n| File | Role | Forms a program? |\n|---|---|---|\n| `tsconfig.json` | Solution root: `extends` base, `files: []`, references to the two aggregates. The whole-repo `tsc -b tsconfig.json` graph, the tsserver discovery entry, and — through the inherited `paths` — the resolution config for tsx running `examples/` and `scripts/` (their nearest tsconfig is this file). | No |\n| `tsconfig.host.json` | Host aggregate: host-side packages (via references), examples, tests, scripts, website. Excludes `packages/client`. | Yes |\n| `tsconfig.client.json` | Client aggregate: `packages/client/*` packages and their tests, `apps/web`. | Yes |\n| `tsconfig.base.json` | Shared compilerOptions and the source `paths` map. Also the resolution facade the vitest configs point vite-tsconfig-paths at: it has no `include`, so its `paths` apply to every importer. | No |\n| `tsconfig.base.client.json` | Browser compiler shape (`jsx`, DOM libs, `types: []`) extended by the client aggregate and every `packages/client/*` package. | No |\n\nHost and client stay two aggregate programs because both sides declaration-merge the cordis `Context` interface under the same keys with different services; one program seeing both merges reports a collision. The collision exists only inside a `ts.Program` — module resolution never triggers it — which is why the solution may reference both aggregates and one paths facade may span both sides. Two disciplines follow:\n\n- `tsconfig.base.json` never gains `include` or `files`: they would leak into every extending package project and narrow the facade's match-all scope.\n- A script that builds a repo-wide `ts.Program` seeds `tsconfig.host.json` or `tsconfig.client.json` explicitly — never the root solution, because flattening both aggregates into one program collides the `Context` merges. Program-backed generators and gates (`scripts/ts-project.ts` consumers, doc-typecheck standalone mode) are host-only by decision; the client side gains program-backed tooling only with a concrete need.\n\nStatic analysis and tests resolve workspace imports through the base `paths` map to `src` and must pass on a clean tree; gates that consume built `lib/` output declare that dependency explicitly. Decision record: [solution-root note](../.agents/notes/implemented/process/2026-07-22-tsconfig-solution-root-two-aggregates.md); the tsc-first emit pipeline is the [ts-build-config note](../.agents/notes/implemented/process/2026-06-17-ts-build-config.md).\n\nIf a relevant local check consumes built package output, build once first:\n\n```sh\npnpm run build\n```\n\n`pnpm run hygiene` includes `publint`, which validates package entrypoints against the built `lib/*.js` files, and `verify-node-next-types`, which validates built declarations against a temporary NodeNext consumer. A fresh worktree has no bundled JS or declarations until `pnpm run build` runs; ordinary commits and pushes do not require that build unless their selected checks consume it.\n\n## Environment variables\n\nThe real DeepSeek adapter and key-backed agent demos read credentials from the environment or from a gitignored `.env` at the repo root:\n\n```sh\nDEEPSEEK_API_KEY=sk-...\nDEEPSEEK_BASE_URL=https://... # optional\n```\n\n`DEEPSEEK_BASE_URL` is optional and defaults to the public API. Never commit real credentials. The real-API e2e suites self-skip when `DEEPSEEK_API_KEY` is not set.\n\n## Git hooks\n\nlefthook is configured in `lefthook.yml` as a fast local checkpoint:\n\n- `pre-commit` runs staged-file ESLint fixes, checks the staged diff for whitespace errors, and runs the vendor manifest guard.\n- `pre-push` runs only the incremental repository typecheck (`tsc -b` over the root solution, covering both the host and client aggregates).\n\nThe vendor manifest guard checks that changes under `vendor/*/src` are staged with the matching `vendor/README.md` manifest update. See `vendor/README.md` before editing vendored code.\n\nThe hooks intentionally do not run tests, snapshots, documentation checks, builds, or hygiene. Contributors run the [checks relevant to the changed behavior](../AGENTS.md#run-relevant-checks-locally) once; CI owns exhaustive coverage, built-artifact smokes, and the Node 22.19, 24, and 26 compatibility matrix.\n\nContributors can opt into the comprehensive local gate set with `pnpm run check:all`. The command is independent of both Git hooks and is not an agent instruction.\n\n## CI gates\n\nThe keyless [CI workflow](../.github/workflows/ci.yml) groups independent gates into broad lanes and runs a smaller compatibility signal across supported Node versions. Artifact consumers wait for one build within their lane. The separate real-API workflow runs `pnpm run test:e2e` with its configured worker bound. See [scripts/run-gates.ts](../scripts/run-gates.ts) and the workflow files for the current gate and job inventory.\n\n## Daily commands\n\nUse these from the repo root:\n\n```sh\npnpm run test # unit tests\npnpm run test:coverage # unit tests with per-file coverage gates\npnpm run test:e2e # real-API tests; self-skips without DEEPSEEK_API_KEY\npnpm run check:all # comprehensive opt-in gate set; not wired to Git hooks\npnpm run typecheck # tsc -b over the root solution: emits package/vendor lib/types, checks both aggregates\npnpm run lint # eslint .\npnpm run lint:fix # eslint . --fix\npnpm run doc-typecheck # compile checked TypeScript snippets in Markdown docs\npnpm run gen-cordis-catalog # regenerate docs/cordis-catalog/events.md + services.md from source\npnpm run verify-cordis-catalog # fail if either cordis catalog is stale\npnpm run verify-export-jsdoc # fail if a module-level package export lacks complete JSDoc\npnpm run gen-doc-graphs # regenerate generated relationship docs from source and curated graph definitions\npnpm run verify-doc-graphs # fail if generated relationship docs are stale\npnpm run verify-md-wrap # fail on hard-wrapped prose paragraphs in docs/README markdown\npnpm run verify-mermaid # fail if a ```mermaid diagram has invalid Mermaid syntax\npnpm run verify-type-equiv # fail if a ```ts type-equiv doc block drifts from its source type\npnpm run verify-doc-budgets # fail if a budgeted standing doc exceeds its word ceiling\npnpm run gen-translation-brief # print the minimal-update briefing for out-of-sync translation pairs (--apply splices code-only edits)\npnpm run doc-sync # all Markdown/doc gates, scheduled concurrently; the doc-sync leaf list in scripts/run-gates.ts is the full list\npnpm run gen-module-graph # regenerate docs/module-graph.md from package peerDeps\npnpm run verify-module-graph # fail if docs/module-graph.md is stale\npnpm run build # emit lib/types intermediates, then bundle lib/index.* runtime files\npnpm run verify-node-next-types # fail if built declarations are not NodeNext-consumable\npnpm run hygiene # knip, publint, workspace constraints, and NodeNext declaration check\n```\n\nWhen changing package public behavior, update the relevant README or JSDoc in the same change. `pnpm run doc-sync` catches checked TypeScript snippets, generated doc freshness, markdown wrap/link drift, type equivalence, translation pairing, Mermaid syntax, and doc budgets, but broader prose/API sync still needs review.\n\n## Demos\n\nThe one-shot Headless coding agent needs `DEEPSEEK_API_KEY` in the environment or repo-root `.env`:\n\n```sh\npnpm run demo:headless \"summarize this workspace\"\n```\n\nThe full-screen interactive coding agent needs `DEEPSEEK_API_KEY` in the environment or repo-root `.env`:\n\n```sh\npnpm run demo:tui\n```\n\nThe self-referential cordis demo can inspect and modify its live plugin runtime and needs the same credentials (`web` by default, or `acp`):\n\n```sh\npnpm run demo:cordis\n```\n\nThe ACP automation server exposes fresh agent sessions over JSON-RPC stdio and also needs `DEEPSEEK_API_KEY`:\n\n```sh\npnpm run demo:acp\n```\n\n## TODO markers\n\nUse one of three comment tags to flag known issues in the code, ordered by urgency:\n\n- `FIXME` — an issue that should block a new release. A release should not ship with an open `FIXME` unless reviewers explicitly agree the change can be merged anyway.\n- `TODO` — an issue that should be fixed soon, once we have the resources.\n- `XXX` — an issue that we may fix someday; lowest priority, no commitment.\n\nPick the tag that matches the urgency so anyone scanning the code can tell a release blocker from a someday-maybe.\n\n## Documenting types verbatim (`ts type-equiv`)\n\nThe [core data structures](core-data-structures/core.md) docs paste source-equivalent declarations together with their original JSDoc so a reader sees the exact shape and source contract. To keep a paste from drifting when source changes, fence it as ` ```ts type-equiv ` (instead of ` ```ts `) and register it in `scripts/type-equiv.manifest.json` with the source file and symbol it mirrors:\n\n```json\n{ \"doc\": \"docs/core-data-structures/session.md\", \"symbol\": \"SessionEvent\", \"source\": \"packages/core/session/src/types.ts\" }\n```\n\n`pnpm run verify-type-equiv` (part of `doc-sync`) then extracts that symbol's declaration and attached JSDoc from source via the TypeScript parser and asserts the block matches both. For a class whose implementation bodies do not belong in the catalog, use ` ```ts public-api ` and set `\"projection\": \"public-api\"`; the checked projection retains the public fields, constructor, accessors, methods, and original class/member JSDoc while omitting bodies and private or protected members. Comparison ignores whitespace and non-JSDoc comments but requires every original JSDoc comment, including member documentation, so readers see the source contract beside the exact shape. The gate enforces a 1:1 correspondence by document, symbol, and projection between primary blocks and manifest entries; a paired `.zh.md` block reuses its unsuffixed sibling's entry only when the whole tracked fence sequence is byte-identical and ordered identically. `doc-typecheck` applies the same derivative rule to compilable fences, while skipping both source-equivalence fence kinds from compilation and its opt-out ratio. When you change a documented declaration or its JSDoc, the gate fails until you update the paste; when you add or remove a primary block, update the manifest in the same change.\n\n## Architecture context\n\nRead `docs/architecture.md` before changing anything under `packages/`. The codebase is built around Cordis plugins, event-sourced sessions, typed service seams, and explicit extension points.\n" }, { "role": "assistant", - "content": "# 开发指南\n\n[English](development.md) | 中文\n\n本指南覆盖参与 DeepSeek Harness 开发所需的本地环境搭建、日常工作流与 CI 流程;设计动机与技术权衡请查阅相应 Agent Note。\n\n## 前置条件\n\n- Node.js 支持 22.19+ 与 24+。CI 覆盖 22.19、24 和 26;见 [Node 引擎下限 Agent Note](../.agents/notes/implemented/process/2026-07-06-node-engine-floor.md)。\n- 启用了 Corepack 的 pnpm。仓库在 `package.json` 中固定使用 `pnpm@11.7.0`;如果 `pnpm --version` 无法通过 Corepack 解析,请先运行 `corepack enable`。\n- Git 2.26 或更高版本;钩子设置会启用 Git 的 worktree 专属配置扩展。\n- 可选:一个 DeepSeek API key,用于 TUI、headless 和 ACP(Agent Client Protocol)自动化 agent(智能体)演示以及真实 API 的 e2e 测试。\n\n## 首次搭建\n\n在仓库根目录安装依赖:\n\n```sh\npnpm install\n```\n\n安装过程同时会运行根目录的 `postinstall` 脚本,该脚本通过 `scripts/install-lefthook.mjs` 从仓库 dev 依赖安装 lefthook。当 `CI=true` 或 `GITHUB_ACTIONS=true` 时,该脚本会在探测 Git 前返回,因为自动化任务不会使用贡献者钩子。否则,包装脚本要求使用 Git 2.26 或更高版本,并会为当前 worktree 在其自身的 Git 目录下设置显式钩子目录;因此,关联 worktree 会使用各自的 lefthook 二进制文件和配置,而不会改写共用钩子。首次安装会启用 Git 的 worktree 专属配置扩展和仓库格式 1;见 [worktree 本地钩子 Agent Note](../.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.md)。\n\n如果依赖是从缓存恢复或 `postinstall` 被跳过而导致缺少钩子,请手动安装:\n\n```sh\nnode scripts/install-lefthook.mjs\n```\n\n包装层会拒绝用户自有的 `core.hooksPath` 值。继承自系统、全局或共用仓库配置的路径必须设置 `DSH_LEFTHOOK_ALLOW_HOOKS_PATH_OVERRIDE=1`;命令作用域和 worktree 作用域的自定义路径必须显式集成或移除。\n\n启用 worktree 配置之前,请迁移格式 0 共用配置中直接设置的 `extensions.*`,并迁移直接设置的 `core.worktree` 或 `core.bare=true`,以及任何非空且尚未生效的 `config.worktree`。共用配置和每个 worktree 配置都必须是常规文件,而自有钩子目录只能包含不带别名的常规文件。\n\n检出目录移动后,请重新运行包装层,使其重新定位自有路径并重新生成钩子。对于陈旧或无效的安装程序锁,请先确认没有安装程序正在运行,再移除报告的锁并重试。若安装和钩子路径回滚都失败,请在重试前检查报告的 worktree 配置。完整安全契约由 [worktree 本地钩子 Agent Note](../.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.md) 统一定义。\n\n新克隆后请先运行一次类型检查:\n\n```sh\npnpm run typecheck\n```\n\n首次类型检查会执行全仓 `tsc -b tsconfig.json` 图:发射每个 package/vendor 的 `lib/types`,并通过下述两个 no-emit 聚合检查示例、测试和脚本。\n\n## TypeScript 项目布局\n\n仓库的 TypeScript 配置只有三种角色;每个 tsconfig 文件恰好扮演其中一种。\n\n| 文件 | 角色 | 是否构成 program? |\n|---|---|---|\n| `tsconfig.json` | solution 根:`extends` base、`files: []`、引用两个聚合。全仓 `tsc -b tsconfig.json` 图、tsserver 发现入口,并经继承的 `paths` 充当 tsx 运行 `examples/` 与 `scripts/` 时的解析配置(它们最近的 tsconfig 就是此文件)。 | 否 |\n| `tsconfig.host.json` | host 聚合:host 侧各包(经 references)、示例、测试、脚本、website。排除 `packages/client`。 | 是 |\n| `tsconfig.client.json` | client 聚合:`packages/client/*` 各包及其测试、`apps/web`。 | 是 |\n| `tsconfig.base.json` | 共享 compilerOptions 与源码 `paths` 映射。同时是各 vitest 配置让 vite-tsconfig-paths 指向的解析门面:它没有 `include`,因此其 `paths` 适用于任何 importer。 | 否 |\n| `tsconfig.base.client.json` | 浏览器编译形状(`jsx`、DOM lib、`types: []`),由 client 聚合和每个 `packages/client/*` 包 extends。 | 否 |\n\nhost 与 client 保持两个聚合 program,是因为两侧在相同键下以不同服务对 cordis `Context` 接口做声明合并;单一 program 同时看到两份合并会报冲突。这种冲突只存在于 `ts.Program` 内部——模块解析永远不会触发它——所以 solution 可以同时引用两个聚合,一个 paths 门面也可以横跨两侧。由此推出两条纪律:\n\n- `tsconfig.base.json` 永不添加 `include` 或 `files`:它们会泄漏进每个 extends 它的包项目,并收窄门面的全匹配范围。\n- 构造全仓 `ts.Program` 的脚本显式种子 `tsconfig.host.json` 或 `tsconfig.client.json`——永不种子根 solution,因为把两个聚合展平进一个 program 会撞上 `Context` 合并冲突。基于 program 的生成器与门禁(`scripts/ts-project.ts` 的消费者、doc-typecheck standalone 模式)按决策仅覆盖 host 侧;client 侧只在出现真实需求时再获得基于 program 的工具。\n\n静态分析和测试通过 base 的 `paths` 映射把工作区 import 解析到 `src`,且必须在干净树上通过;消费构建产物 `lib/` 的门禁显式声明该依赖。决策记录:[solution-root note](../.agents/notes/implemented/process/2026-07-22-tsconfig-solution-root-two-aggregates.md);tsc-first 发射管线见 [ts-build-config note](../.agents/notes/implemented/process/2026-06-17-ts-build-config.md)。\n\n如果相关的本地检查需要使用构建后的包产物,请先构建一次:\n\n```sh\npnpm run build\n```\n\n`pnpm run hygiene` 包含 `publint`(用构建出的 `lib/*.js` 文件校验 package 入口点)和 `verify-node-next-types`(用一个临时的 NodeNext 消费方校验构建出的声明文件)。新 worktree 在 `pnpm run build` 运行之前没有打包的 JS 和声明文件;普通提交和推送无需构建,除非所选检查会使用这些产物。\n\n## 环境变量\n\n真实的 DeepSeek 适配器和需要密钥的 agent 演示从环境变量或仓库根目录一个被 gitignore 的 `.env` 文件读取凭证:\n\n```sh\nDEEPSEEK_API_KEY=sk-...\nDEEPSEEK_BASE_URL=https://... # optional\n```\n\n`DEEPSEEK_BASE_URL` 可选,默认为公开 API。请勿提交真实凭证。未设置 `DEEPSEEK_API_KEY` 时,真实 API 的 e2e 套件会自动跳过。\n\n## Git 钩子\n\nlefthook 在 `lefthook.yml` 中配置,作为快速的本地检查点:\n\n- `pre-commit` 运行对暂存文件的 ESLint 修复,检查暂存 diff 中的空白错误,并运行 vendor manifest(元数据清单)守卫;\n- `pre-push` 只运行仓库增量类型检查(对根 solution 执行 `tsc -b`,覆盖 host 与 client 两个聚合)。\n\nvendor manifest 守卫检查 `vendor/*/src` 下的改动是否连同对应的 `vendor/README.md` manifest 更新一起暂存。请在编辑 vendor 代码前先阅读 `vendor/README.md`。\n\n这些钩子有意不运行测试、快照、文档检查、构建或 `hygiene`。贡献者只运行一次[与改动行为相关的检查](../AGENTS.md#run-relevant-checks-locally);CI 负责全量覆盖率门禁、构建产物冒烟测试,以及 Node 22.19、24 和 26 兼容性矩阵。\n\n贡献者可以选择运行 `pnpm run check:all`,执行全面的本地门禁集。该命令独立于两个 Git 钩子,也不是对 agent 的指令。\n\n## CI 门禁\n\nkeyless [CI 工作流](../.github/workflows/ci.yml) 将独立门禁分组到若干宽粒度 lane,并在受支持的 Node 版本上运行一组较小的兼容性检查。产物消费方在各自 lane 内等待一次 build。单独的真实 API 工作流按其配置的 worker 上限运行 `pnpm run test:e2e`。当前门禁和 job 清单以 [scripts/run-gates.ts](../scripts/run-gates.ts) 和工作流文件为准。\n\n## 日常命令\n\n在仓库根目录使用:\n\n```sh\npnpm run test # unit tests\npnpm run test:coverage # unit tests with per-file coverage gates\npnpm run test:e2e # real-API tests; self-skips without DEEPSEEK_API_KEY\npnpm run check:all # comprehensive opt-in gate set; not wired to Git hooks\npnpm run typecheck # tsc -b over the root solution: emits package/vendor lib/types, checks both aggregates\npnpm run lint # eslint .\npnpm run lint:fix # eslint . --fix\npnpm run doc-typecheck # compile checked TypeScript snippets in Markdown docs\npnpm run gen-cordis-catalog # regenerate docs/cordis-catalog/events.md + services.md from source\npnpm run verify-cordis-catalog # fail if either cordis catalog is stale\npnpm run verify-export-jsdoc # fail if a module-level package export lacks complete JSDoc\npnpm run gen-doc-graphs # regenerate generated relationship docs from source and curated graph definitions\npnpm run verify-doc-graphs # fail if generated relationship docs are stale\npnpm run verify-md-wrap # fail on hard-wrapped prose paragraphs in docs/README markdown\npnpm run verify-mermaid # fail if a ```mermaid diagram has invalid Mermaid syntax\npnpm run verify-type-equiv # fail if a ```ts type-equiv doc block drifts from its source type\npnpm run verify-doc-budgets # fail if a budgeted standing doc exceeds its word ceiling\npnpm run gen-translation-brief # print the minimal-update briefing for out-of-sync translation pairs (--apply splices code-only edits)\npnpm run doc-sync # all Markdown/doc gates, scheduled concurrently; the doc-sync leaf list in scripts/run-gates.ts is the full list\npnpm run gen-module-graph # regenerate docs/module-graph.md from package peerDeps\npnpm run verify-module-graph # fail if docs/module-graph.md is stale\npnpm run build # emit lib/types intermediates, then bundle lib/index.* runtime files\npnpm run verify-node-next-types # fail if built declarations are not NodeNext-consumable\npnpm run hygiene # knip, publint, workspace constraints, and NodeNext declaration check\n```\n\n修改 package 的公开行为时,请在同一个变更中更新相关 README 或 JSDoc。`pnpm run doc-sync` 能检测到被检查的 TypeScript 片段、生成文档的新鲜度、Markdown 换行/链接漂移、type-equiv、翻译配对、Mermaid 语法和文档预算,但更广泛的行文/API 同步仍需评审把关。\n\n## 演示\n\n单次运行的 Headless coding agent 需要环境变量或仓库根目录 `.env` 中的 `DEEPSEEK_API_KEY`:\n\n```sh\npnpm run demo:headless \"summarize this workspace\"\n```\n\n全屏交互式 coding agent 需要环境变量或仓库根目录 `.env` 中的 `DEEPSEEK_API_KEY`:\n\n```sh\npnpm run demo:tui\n```\n\n自指的 cordis 演示可以检查并修改其实时插件运行时,并需要相同的凭证(默认 `web`,也可用 `acp`):\n\n```sh\npnpm run demo:cordis\n```\n\nACP 自动化服务器通过 JSON-RPC stdio 提供全新 agent 会话,同样需要 `DEEPSEEK_API_KEY`:\n\n```sh\npnpm run demo:acp\n```\n\n## TODO 标记\n\n请使用以下三种注释标签之一标记代码中的已知问题,按紧急程度排序:\n\n- `FIXME`:应当阻塞新版本发布的问题。除非评审者明确同意该更改可以合并,否则发布版本不应包含未解决的 `FIXME`;\n- `TODO`:应当尽快修复的问题,等资源到位即可处理;\n- `XXX`:也许某天会修复的问题,优先级最低,不作承诺。\n\n请选择与紧急程度匹配的标签,让浏览代码的人一眼分清「发布阻塞」和「有空再说」。\n\n## 逐字记录类型(`ts type-equiv`)\n\n[核心数据结构](core-data-structures/core.md)文档会把与源码等价的声明及其原始 JSDoc 一并粘贴,让读者看到确切形状和源码契约。为防止粘贴内容在源码变化时漂移,请将其围栏为 ` ```ts type-equiv `(而不是 ` ```ts `),并在 `scripts/type-equiv.manifest.json` 中登记它镜像的源文件和符号:\n\n```json\n{ \"doc\": \"docs/core-data-structures/session.md\", \"symbol\": \"SessionEvent\", \"source\": \"packages/core/session/src/types.ts\" }\n```\n\n`pnpm run verify-type-equiv`(`doc-sync` 的一环)随后通过 TypeScript 解析器从源码提取该符号的声明及其附带的 JSDoc,并断言代码块同时匹配两者。对于不应把实现体写进目录的类,请使用 ` ```ts public-api ` 并设置 `\"projection\": \"public-api\"`;门禁检查的投影会保留公共字段、构造函数、访问器、方法以及类和成员的原始 JSDoc,同时省略实现体和私有或受保护成员。比对会忽略空白和非 JSDoc 注释,但要求保留每条原始 JSDoc(包括成员文档),让读者同时看到源码契约和确切形状。该门禁按文档、符号和投影,在主块与 manifest 条目之间强制 1:1 对应;只有当配对 `.zh.md` 块的完整受跟踪围栏序列与其无后缀兄弟文件按字节一致且顺序相同时,才会复用后者的条目。`doc-typecheck` 对可编译围栏应用同一派生规则,同时跳过两种源码等价围栏的编译,并将其排除在 opt-out 比例之外。当你改动一个已记录的类型声明或其 JSDoc 时,门禁会失败直到你更新粘贴内容;当你增删一个主块时,请在同一个变更里更新 manifest。\n\n## 架构上下文\n\n在修改 `packages/` 目录下的任何内容之前,请先阅读 `docs/architecture.md`。这套代码围绕 Cordis 插件、事件溯源的会话、类型化的服务 seam 与显式扩展点构建。\n" + "content": "# 开发指南\n\n[English](development.md) | 中文\n\n本指南覆盖参与 DeepSeek Harness 开发所需的本地环境搭建、日常工作流与 CI 流程;设计动机与技术权衡请查阅相应 Agent Note。\n\n## 前置条件\n\n- Node.js 支持 22.19+ 与 24+。CI 覆盖 22.19、24 和 26;见 [Node 引擎下限 Agent Note](../.agents/notes/implemented/process/2026-07-06-node-engine-floor.md)。\n- 启用了 Corepack 的 pnpm。仓库在 `package.json` 中固定使用 `pnpm@11.7.0`;如果 `pnpm --version` 无法通过 Corepack 解析,请先运行 `corepack enable`。\n- Git 2.26 或更高版本;钩子设置会启用 Git 的 worktree 专属配置扩展。\n- 可选:一个 DeepSeek API key,用于 TUI、headless 和 ACP(Agent Client Protocol)自动化 agent(智能体)演示以及真实 API 的 e2e 测试。\n\n## 首次搭建\n\n在仓库根目录安装依赖:\n\n```sh\npnpm install\n```\n\n安装过程同时会运行根目录的 `postinstall` 脚本,该脚本通过 `scripts/install-lefthook.mjs` 从仓库 dev 依赖安装 lefthook。当 `CI=true` 或 `GITHUB_ACTIONS=true` 时,该脚本会在探测 Git 前返回,因为自动化任务不会使用贡献者钩子。否则,包装脚本要求使用 Git 2.26 或更高版本,并会为当前 worktree 在其自身的 Git 目录下设置显式钩子目录;因此,关联 worktree 会使用各自的 lefthook 二进制文件和配置,而不会改写共用钩子。首次安装会启用 Git 的 worktree 专属配置扩展和仓库格式 1;见 [worktree 本地钩子 Agent Note](../.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.md)。\n\n如果依赖是从缓存恢复或 `postinstall` 被跳过而导致缺少钩子,请手动安装:\n\n```sh\nnode scripts/install-lefthook.mjs\n```\n\n包装层会拒绝用户自有的 `core.hooksPath` 值。继承自系统、全局或共用仓库配置的路径必须设置 `DSH_LEFTHOOK_ALLOW_HOOKS_PATH_OVERRIDE=1`。当 Git 使用另一个已注册 worktree 中由所有权标记佐证的钩子路径初始化新 worktree 时,包装层会将这个复制值替换为新 worktree 自有的路径;命令作用域和其他 worktree 作用域的路径必须显式集成或移除。\n\n启用 worktree 配置之前,请迁移格式 0 共用配置中直接设置的 `extensions.*`,并迁移直接设置的 `core.worktree` 或 `core.bare=true`,以及任何非空且尚未生效的 `config.worktree`。共用配置和每个 worktree 配置都必须是常规文件,而自有钩子目录只能包含不带别名的常规文件。\n\n检出目录移动后,请重新运行包装层,使其重新定位自有路径并重新生成钩子。对于陈旧或无效的安装程序锁,请先确认没有安装程序正在运行,再移除报告的锁并重试。若安装和钩子路径回滚都失败,请在重试前检查报告的 worktree 配置。完整安全契约由 [worktree 本地钩子 Agent Note](../.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.md) 统一定义。\n\n新克隆后请先运行一次类型检查:\n\n```sh\npnpm run typecheck\n```\n\n首次类型检查会执行全仓 `tsc -b tsconfig.json` 图:发射每个 package/vendor 的 `lib/types`,并通过下述两个 no-emit 聚合检查示例、测试和脚本。\n\n## TypeScript 项目布局\n\n仓库的 TypeScript 配置只有三种角色;每个 tsconfig 文件恰好扮演其中一种。\n\n| 文件 | 角色 | 是否构成 program? |\n|---|---|---|\n| `tsconfig.json` | solution 根:`extends` base、`files: []`、引用两个聚合。全仓 `tsc -b tsconfig.json` 图、tsserver 发现入口,并经继承的 `paths` 充当 tsx 运行 `examples/` 与 `scripts/` 时的解析配置(它们最近的 tsconfig 就是此文件)。 | 否 |\n| `tsconfig.host.json` | host 聚合:host 侧各包(经 references)、示例、测试、脚本、website。排除 `packages/client`。 | 是 |\n| `tsconfig.client.json` | client 聚合:`packages/client/*` 各包及其测试、`apps/web`。 | 是 |\n| `tsconfig.base.json` | 共享 compilerOptions 与源码 `paths` 映射。同时是各 vitest 配置让 vite-tsconfig-paths 指向的解析门面:它没有 `include`,因此其 `paths` 适用于任何 importer。 | 否 |\n| `tsconfig.base.client.json` | 浏览器编译形状(`jsx`、DOM lib、`types: []`),由 client 聚合和每个 `packages/client/*` 包 extends。 | 否 |\n\nhost 与 client 保持两个聚合 program,是因为两侧在相同键下以不同服务对 cordis `Context` 接口做声明合并;单一 program 同时看到两份合并会报冲突。这种冲突只存在于 `ts.Program` 内部——模块解析永远不会触发它——所以 solution 可以同时引用两个聚合,一个 paths 门面也可以横跨两侧。由此推出两条纪律:\n\n- `tsconfig.base.json` 永不添加 `include` 或 `files`:它们会泄漏进每个 extends 它的包项目,并收窄门面的全匹配范围。\n- 构造全仓 `ts.Program` 的脚本显式种子 `tsconfig.host.json` 或 `tsconfig.client.json`——永不种子根 solution,因为把两个聚合展平进一个 program 会撞上 `Context` 合并冲突。基于 program 的生成器与门禁(`scripts/ts-project.ts` 的消费者、doc-typecheck standalone 模式)按决策仅覆盖 host 侧;client 侧只在出现真实需求时再获得基于 program 的工具。\n\n静态分析和测试通过 base 的 `paths` 映射把工作区 import 解析到 `src`,且必须在干净树上通过;消费构建产物 `lib/` 的门禁显式声明该依赖。决策记录:[solution-root note](../.agents/notes/implemented/process/2026-07-22-tsconfig-solution-root-two-aggregates.md);tsc-first 发射管线见 [ts-build-config note](../.agents/notes/implemented/process/2026-06-17-ts-build-config.md)。\n\n如果相关的本地检查需要使用构建后的包产物,请先构建一次:\n\n```sh\npnpm run build\n```\n\n`pnpm run hygiene` 包含 `publint`(用构建出的 `lib/*.js` 文件校验 package 入口点)和 `verify-node-next-types`(用一个临时的 NodeNext 消费方校验构建出的声明文件)。新 worktree 在 `pnpm run build` 运行之前没有打包的 JS 和声明文件;普通提交和推送无需构建,除非所选检查会使用这些产物。\n\n## 环境变量\n\n真实的 DeepSeek 适配器和需要密钥的 agent 演示从环境变量或仓库根目录一个被 gitignore 的 `.env` 文件读取凭证:\n\n```sh\nDEEPSEEK_API_KEY=sk-...\nDEEPSEEK_BASE_URL=https://... # optional\n```\n\n`DEEPSEEK_BASE_URL` 可选,默认为公开 API。请勿提交真实凭证。未设置 `DEEPSEEK_API_KEY` 时,真实 API 的 e2e 套件会自动跳过。\n\n## Git 钩子\n\nlefthook 在 `lefthook.yml` 中配置,作为快速的本地检查点:\n\n- `pre-commit` 运行对暂存文件的 ESLint 修复,检查暂存 diff 中的空白错误,并运行 vendor manifest(元数据清单)守卫;\n- `pre-push` 只运行仓库增量类型检查(对根 solution 执行 `tsc -b`,覆盖 host 与 client 两个聚合)。\n\nvendor manifest 守卫检查 `vendor/*/src` 下的改动是否连同对应的 `vendor/README.md` manifest 更新一起暂存。请在编辑 vendor 代码前先阅读 `vendor/README.md`。\n\n这些钩子有意不运行测试、快照、文档检查、构建或 `hygiene`。贡献者只运行一次[与改动行为相关的检查](../AGENTS.md#run-relevant-checks-locally);CI 负责全量覆盖率门禁、构建产物冒烟测试,以及 Node 22.19、24 和 26 兼容性矩阵。\n\n贡献者可以选择运行 `pnpm run check:all`,执行全面的本地门禁集。该命令独立于两个 Git 钩子,也不是对 agent 的指令。\n\n## CI 门禁\n\nkeyless [CI 工作流](../.github/workflows/ci.yml) 将独立门禁分组到若干宽粒度 lane,并在受支持的 Node 版本上运行一组较小的兼容性检查。产物消费方在各自 lane 内等待一次 build。单独的真实 API 工作流按其配置的 worker 上限运行 `pnpm run test:e2e`。当前门禁和 job 清单以 [scripts/run-gates.ts](../scripts/run-gates.ts) 和工作流文件为准。\n\n## 日常命令\n\n在仓库根目录使用:\n\n```sh\npnpm run test # unit tests\npnpm run test:coverage # unit tests with per-file coverage gates\npnpm run test:e2e # real-API tests; self-skips without DEEPSEEK_API_KEY\npnpm run check:all # comprehensive opt-in gate set; not wired to Git hooks\npnpm run typecheck # tsc -b over the root solution: emits package/vendor lib/types, checks both aggregates\npnpm run lint # eslint .\npnpm run lint:fix # eslint . --fix\npnpm run doc-typecheck # compile checked TypeScript snippets in Markdown docs\npnpm run gen-cordis-catalog # regenerate docs/cordis-catalog/events.md + services.md from source\npnpm run verify-cordis-catalog # fail if either cordis catalog is stale\npnpm run verify-export-jsdoc # fail if a module-level package export lacks complete JSDoc\npnpm run gen-doc-graphs # regenerate generated relationship docs from source and curated graph definitions\npnpm run verify-doc-graphs # fail if generated relationship docs are stale\npnpm run verify-md-wrap # fail on hard-wrapped prose paragraphs in docs/README markdown\npnpm run verify-mermaid # fail if a ```mermaid diagram has invalid Mermaid syntax\npnpm run verify-type-equiv # fail if a ```ts type-equiv doc block drifts from its source type\npnpm run verify-doc-budgets # fail if a budgeted standing doc exceeds its word ceiling\npnpm run gen-translation-brief # print the minimal-update briefing for out-of-sync translation pairs (--apply splices code-only edits)\npnpm run doc-sync # all Markdown/doc gates, scheduled concurrently; the doc-sync leaf list in scripts/run-gates.ts is the full list\npnpm run gen-module-graph # regenerate docs/module-graph.md from package peerDeps\npnpm run verify-module-graph # fail if docs/module-graph.md is stale\npnpm run build # emit lib/types intermediates, then bundle lib/index.* runtime files\npnpm run verify-node-next-types # fail if built declarations are not NodeNext-consumable\npnpm run hygiene # knip, publint, workspace constraints, and NodeNext declaration check\n```\n\n修改 package 的公开行为时,请在同一个变更中更新相关 README 或 JSDoc。`pnpm run doc-sync` 能检测到被检查的 TypeScript 片段、生成文档的新鲜度、Markdown 换行/链接漂移、type-equiv、翻译配对、Mermaid 语法和文档预算,但更广泛的行文/API 同步仍需评审把关。\n\n## 演示\n\n单次运行的 Headless coding agent 需要环境变量或仓库根目录 `.env` 中的 `DEEPSEEK_API_KEY`:\n\n```sh\npnpm run demo:headless \"summarize this workspace\"\n```\n\n全屏交互式 coding agent 需要环境变量或仓库根目录 `.env` 中的 `DEEPSEEK_API_KEY`:\n\n```sh\npnpm run demo:tui\n```\n\n自指的 cordis 演示可以检查并修改其实时插件运行时,并需要相同的凭证(默认 `web`,也可用 `acp`):\n\n```sh\npnpm run demo:cordis\n```\n\nACP 自动化服务器通过 JSON-RPC stdio 提供全新 agent 会话,同样需要 `DEEPSEEK_API_KEY`:\n\n```sh\npnpm run demo:acp\n```\n\n## TODO 标记\n\n请使用以下三种注释标签之一标记代码中的已知问题,按紧急程度排序:\n\n- `FIXME`:应当阻塞新版本发布的问题。除非评审者明确同意该更改可以合并,否则发布版本不应包含未解决的 `FIXME`;\n- `TODO`:应当尽快修复的问题,等资源到位即可处理;\n- `XXX`:也许某天会修复的问题,优先级最低,不作承诺。\n\n请选择与紧急程度匹配的标签,让浏览代码的人一眼分清「发布阻塞」和「有空再说」。\n\n## 逐字记录类型(`ts type-equiv`)\n\n[核心数据结构](core-data-structures/core.md)文档会把与源码等价的声明及其原始 JSDoc 一并粘贴,让读者看到确切形状和源码契约。为防止粘贴内容在源码变化时漂移,请将其围栏为 ` ```ts type-equiv `(而不是 ` ```ts `),并在 `scripts/type-equiv.manifest.json` 中登记它镜像的源文件和符号:\n\n```json\n{ \"doc\": \"docs/core-data-structures/session.md\", \"symbol\": \"SessionEvent\", \"source\": \"packages/core/session/src/types.ts\" }\n```\n\n`pnpm run verify-type-equiv`(`doc-sync` 的一环)随后通过 TypeScript 解析器从源码提取该符号的声明及其附带的 JSDoc,并断言代码块同时匹配两者。对于不应把实现体写进目录的类,请使用 ` ```ts public-api ` 并设置 `\"projection\": \"public-api\"`;门禁检查的投影会保留公共字段、构造函数、访问器、方法以及类和成员的原始 JSDoc,同时省略实现体和私有或受保护成员。比对会忽略空白和非 JSDoc 注释,但要求保留每条原始 JSDoc(包括成员文档),让读者同时看到源码契约和确切形状。该门禁按文档、符号和投影,在主块与 manifest 条目之间强制 1:1 对应;只有当配对 `.zh.md` 块的完整受跟踪围栏序列与其无后缀兄弟文件按字节一致且顺序相同时,才会复用后者的条目。`doc-typecheck` 对可编译围栏应用同一派生规则,同时跳过两种源码等价围栏的编译,并将其排除在 opt-out 比例之外。当你改动一个已记录的类型声明或其 JSDoc 时,门禁会失败直到你更新粘贴内容;当你增删一个主块时,请在同一个变更里更新 manifest。\n\n## 架构上下文\n\n在修改 `packages/` 目录下的任何内容之前,请先阅读 `docs/architecture.md`。这套代码围绕 Cordis 插件、事件溯源的会话、类型化的服务 seam 与显式扩展点构建。\n" }, { "role": "user", From d2c7aaddc73925b7050c2ee3e4604a32494828f7 Mon Sep 17 00:00:00 2001 From: Turtle Date: Wed, 29 Jul 2026 21:16:31 +0800 Subject: [PATCH 036/113] fix(rebase): restore consolidated documentation state --- examples/README.i18n.yaml | 5 - examples/README.zh.md | 18 +-- examples/code-mode/README.i18n.yaml | 6 - examples/cordis-agent/README.i18n.yaml | 6 - examples/cordis-agent/README.zh.md | 37 ------ examples/tui-agent/README.zh.md | 80 ------------- packages/examples/README.i18n.yaml | 5 - packages/examples/README.zh.md | 17 +-- .../agent-spine-demo/README.i18n.yaml | 5 - .../examples/agent-spine-demo/README.zh.md | 24 ++-- packages/examples/tui-demo/README.i18n.yaml | 6 - packages/examples/tui-demo/README.zh.md | 112 ------------------ packages/todo/README.i18n.yaml | 5 - packages/todo/README.zh.md | 8 +- packages/todo/tool-todo/README.i18n.yaml | 5 - packages/todo/tool-todo/README.zh.md | 32 +++-- packages/ui/README.i18n.yaml | 5 - packages/ui/README.zh.md | 14 +-- 18 files changed, 40 insertions(+), 350 deletions(-) delete mode 100644 examples/cordis-agent/README.i18n.yaml delete mode 100644 examples/cordis-agent/README.zh.md delete mode 100644 examples/tui-agent/README.zh.md delete mode 100644 packages/examples/tui-demo/README.i18n.yaml delete mode 100644 packages/examples/tui-demo/README.zh.md diff --git a/examples/README.i18n.yaml b/examples/README.i18n.yaml index a05f495338..6ac301268c 100644 --- a/examples/README.i18n.yaml +++ b/examples/README.i18n.yaml @@ -2,10 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write examples/README.md -<<<<<<< HEAD -README.md: 7f12178d1b67f1ebfac6f4f0e31403c54106e98f -README.zh.md: c7c1bf76593661616464558e554d57340d7c03b1 -======= README.md: eb425ad152579ee10bbd54e99c667606fc8a649a README.zh.md: 5f00b4471b516bb78679f09b41dcb1bdbc3ebdbc ->>>>>>> a1c6a2c3f (refactor(cli)!: one shared base config with per-surface overlays) diff --git a/examples/README.zh.md b/examples/README.zh.md index de91e30217..5f00b4471b 100644 --- a/examples/README.zh.md +++ b/examples/README.zh.md @@ -2,15 +2,11 @@ [English](README.md) | 中文 -<<<<<<< HEAD -展示 harness 如何组装的可运行演示(不是 workspace)。每个示例都是一个 **轻量叶节点**:一份选择可替换后端、加载一个应用包(package)并可添加可选产品工具的 `cordis.yml`。组合和启动粘合代码位于 [`@deepseek-ai/dsh-tui-demo`](../packages/examples/tui-demo)、[`@deepseek-ai/dsh-cli-demo`](../packages/examples/cli-demo)、[`@deepseek-ai/dsh-acp-demo`](../packages/examples/acp-demo) 及它们共享的 [`@deepseek-ai/dsh-agent-spine-demo`](../packages/examples/agent-spine-demo) 组合包中。没有 `start.ts`;终端 `demo:*` 脚本通过 [`dsh`](../apps/cli/README.md) CLI(命令行界面)启动(该 CLI 挂载 `tui-demo` 组合包),无头/ACP(Agent Client Protocol)脚本则调用 `cli-demo`/`acp-demo` bin。 -======= 展示 harness 如何接线的可运行演示(不是 workspace)。每个示例都是一个 **轻量叶节点**:要么是一份选择可替换后端、加载一个应用包(package)的 `cordis.yml` 配置树,要么是一个 **overlay**——由 `dsh --config` 叠加到交付组合([`apps/cli/base.cordis.yml`](../apps/cli/base.cordis.yml) 加一份 surface overlay)之上的 patch 列表。成组的组合位于 [`@deepseek-ai/dsh-cli-demo`](../packages/examples/cli-demo)、[`@deepseek-ai/dsh-acp-demo`](../packages/examples/acp-demo) 及它们共享的 [`@deepseek-ai/dsh-agent-spine-demo`](../packages/examples/agent-spine-demo) 组合包中;`dsh` 的各 surface 则改用平铺 config tree。没有 `start.ts`;终端 `demo:*` 脚本通过 [`dsh`](../apps/cli/README.md) CLI(命令行界面)启动,无头/ACP(Agent Client Protocol)脚本则调用 `cli-demo`/`acp-demo` bin。 ->>>>>>> a1c6a2c3f (refactor(cli)!: one shared base config with per-surface overlays) ## headless-agent -非交互式 agent(智能体)演示:接受一个位置参数形式的任务,在 `@deepseek-ai/dsh-cli-demo` 应用上运行一个完整模型/工具轮次,持久化新会话,打印 `text`、`json` 或 `stream-json`,然后退出。 +非交互式 agent(智能体)演示:接受一个位置任务,在 `@deepseek-ai/dsh-cli-demo` 应用上运行一个完整模型/工具轮次,持久化新会话,打印 `text`、`json` 或 `stream-json`,然后退出。 运行:`pnpm run demo:headless "task"`(需要 `DEEPSEEK_API_KEY`)。输出契约、安全边界和快照套件详见 [headless-agent/README.md](headless-agent/README.md)。 @@ -22,22 +18,18 @@ ## jsonrpc-agent -通过 Python SDK 驱动的无人值守编码 agent:JSON-RPC stdio、仅前台 `bash`、`read`/`write`/`edit`、一个前台 `subagent`、`todo_write`、JSONL 持久化和压缩。它不包含终端 UI、stdout 日志、批准、skill(技能)和后台任务控制。详见 [jsonrpc-agent/README.md](jsonrpc-agent/README.md)。 +通过 Python SDK 驱动的无人值守编码 agent:JSON-RPC stdio、仅前台 `bash`、`read`/`write`/`edit`、一个前台 `subagent`、`todo_write`、JSONL 持久化和压缩。它不包含终端 UI、stdout 日志、批准、skill 和后台任务控制。详见 [jsonrpc-agent/README.md](jsonrpc-agent/README.md)。 ## web-cordis -**自指** 演示:编码主干加 [`@deepseek-ai/dsh-tool-cordis`](../packages/cordis/tool-cordis),其三个工具(`cordis_inspect`/`cordis_mount`/`cordis_unmount`)使 agent 可以检查当前 DSH 进程、挂载模型编写的临时插件(事件监听器、一个全新工具,或一个供另一个临时插件注入的服务),并再次卸载它们。这些插件只存在于内存中,共享一个内部 `cordis-dynamic` fiber 子树;`ctx.fs`/`ctx.web` 仅作为它们可用的能力提供方。 +**自指** 演示:编码主干加 [`@deepseek-ai/dsh-tool-cordis`](../packages/cordis/tool-cordis),其三个工具(`cordis_inspect`/`cordis_mount`/`cordis_unmount`)使 agent 可以检查当前 DSH 进程、挂载模型编写的临时 Plugin(事件监听器、一个全新工具,或一个供另一临时 Plugin 注入的服务),并再次卸载它们。这些 Plugin 只存在于内存中,共享一个内部 `cordis-dynamic` fiber 子树;`ctx.fs`/`ctx.web` 仅作为它们可用的能力提供方。 -<<<<<<< HEAD -使用 `pnpm run demo:cordis` 运行 TUI,使用 `pnpm run demo:cordis web` 在 `http://127.0.0.1:3081` 启动浏览器 UI,或使用 `pnpm run demo:cordis acp` 启动 ACP 服务器(三者均需 `DEEPSEEK_API_KEY`)。分阶段演示脚本详见 [cordis-agent/README.md](cordis-agent/README.md),设计与沙箱注意事项详见[工具集 Agent Note(agent 决策记录)](../.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md)。 -======= 使用 `pnpm run demo:cordis` 在 `http://127.0.0.1:3081` 启动浏览器 UI,或使用 `pnpm run demo:cordis acp` 启动 ACP 服务器(两者均需 `DEEPSEEK_API_KEY`)。设计与沙箱注意事项详见[工具集 Agent Note](../.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md)。 ->>>>>>> a1c6a2c3f (refactor(cli)!: one shared base config with per-surface overlays) ## acp-agent -一个通过 JSON-RPC stdio 公开、作为 **Agent Client Protocol (ACP)** 自动化服务器运行的 agent,由 [`@deepseek-ai/dsh-acp-demo`](../packages/examples/acp-demo) 提供。程序化客户端可以创建新会话、发送文本提示词、消费已提交的 assistant 文本、回答一次性权限请求并取消工作。它拥有 ACP 无密钥快照套件。 +作为 **Agent Client Protocol (ACP)** 自动化服务器通过 JSON-RPC stdio 公开的 agent,由 [`@deepseek-ai/dsh-acp-demo`](../packages/examples/acp-demo) 提供。程序化客户端可以创建新会话、发送文本提示词、消费已提交的 assistant 文本、回答一次性权限请求并取消工作。它拥有 ACP 无密钥快照套件。 运行:`pnpm run demo:acp`(需要 `DEEPSEEK_API_KEY`);`pnpm run demo:code-mode acp` 通过 `code-mode.cordis.yml` 覆盖以 Code Mode 启动同一服务器。协议与快照测试契约详见 [acp-agent/README.md](acp-agent/README.md)。 -默认 `cordis.yml` 组合 [`@deepseek-ai/dsh-sandbox-local`](../packages/sandbox/sandbox-local)、[`@deepseek-ai/dsh-bash-sandbox`](../packages/bash/bash-sandbox) 和 [`@deepseek-ai/dsh-user-approval`](../packages/ui/user-approval)。`workspace-write` 将 bash 和文件系统变更限制在每个会话 workspace 中;请求更广泛沙箱权限的重试会通过 ACP 触发一次性的机器权限请求。 +默认 `cordis.yml` 组合 [`@deepseek-ai/dsh-sandbox-local`](../packages/sandbox/sandbox-local)、[`@deepseek-ai/dsh-bash-sandbox`](../packages/bash/bash-sandbox) 和 [`@deepseek-ai/dsh-user-approval`](../packages/ui/user-approval)。`workspace-write` 将 bash 和文件系统变更限制在每个会话 workspace 中;范围更广的重试会通过 ACP 成为一次性机器权限请求。 diff --git a/examples/code-mode/README.i18n.yaml b/examples/code-mode/README.i18n.yaml index 2b1171f393..03d2c40a85 100644 --- a/examples/code-mode/README.i18n.yaml +++ b/examples/code-mode/README.i18n.yaml @@ -1,12 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -<<<<<<< HEAD:examples/tui-agent/README.i18n.yaml -# pnpm run verify-translation-pairing --write examples/tui-agent/README.md -README.md: ea8695d37ea247a38644392a4572c1ea9855fd44 -README.zh.md: c6acd39d8713816d870c00fa8597754d0d09880a -======= # pnpm run verify-translation-pairing --write examples/code-mode/README.md README.md: 1557483ee98fab60aa63f8c5ec40f7e592c275ba README.zh.md: c20a9a7dde80eb0e03f17b62b5dc21189ef65f06 ->>>>>>> a1c6a2c3f (refactor(cli)!: one shared base config with per-surface overlays):examples/code-mode/README.i18n.yaml diff --git a/examples/cordis-agent/README.i18n.yaml b/examples/cordis-agent/README.i18n.yaml deleted file mode 100644 index 4cbfbb67cb..0000000000 --- a/examples/cordis-agent/README.i18n.yaml +++ /dev/null @@ -1,6 +0,0 @@ -# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each -# side as of the last confirmed-consistent state. Both languages carry equal authority; -# after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write examples/cordis-agent/README.md -README.md: 55970e932bc16d8361932daa9ea55af83ef73d33 -README.zh.md: a8ec332d8b3673d6656663eb3bfd7d37a4e328f6 diff --git a/examples/cordis-agent/README.zh.md b/examples/cordis-agent/README.zh.md deleted file mode 100644 index a8ec332d8b..0000000000 --- a/examples/cordis-agent/README.zh.md +++ /dev/null @@ -1,37 +0,0 @@ -# cordis-agent - -[English](README.md) | 中文 - -自指 harness 演示:在全屏 TUI 上运行 DeepSeek V4 编码主干,并加载 [`@deepseek-ai/dsh-tool-cordis`](../../packages/cordis/tool-cordis/README.md)。后者让模型检查当前 DSH 进程、挂载仅存于内存的临时插件,并卸载它们。临时插件可跨轮次保持活跃,但会在卸载、工具集卸载或 DSH 重启后消失;它们不创建文件或配置,也可能影响同一进程中的其他会话。`ctx.fs` 和 `ctx.web` 仅以能力提供方形式加载,供这些插件使用。设计详见[工具集 Agent Note(agent 决策记录)](../../.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md)。 - -## 运行 - -```sh -# repo root .env (gitignored) or exported env: -# DEEPSEEK_API_KEY=sk-… -# DEEPSEEK_BASE_URL=https://… # optional; defaults to the public API -pnpm run demo:cordis # TUI (default) -pnpm run demo:cordis web # browser UI at http://127.0.0.1:3081 -pnpm run demo:cordis acp # ACP server -``` - -预期演示分阶段进行:先验证监听器链路,再让 agent(智能体)扩展自身: - -``` -> Mount a temporary Plugin that listens to the 'agent/status' event and logs every status change, then run `echo hi` with bash. - [tool call] cordis_mount({"code": "return { name: 'status-logger', apply(ctx) { ctx.on('agent/status', (agent, status) => console.log('status →', status)) } }"}) - [tool result] Temporary Plugin dyn-1 is running (plugin "status-logger"; available until unmounted or DSH restarts). - [tool call] bash({"command": "echo hi"}) -[cordis:dyn-1] status → … ← the temporary listener firing, live -> Now give yourself a reverse_text tool and use it on "harness". - [tool call] cordis_mount({"code": "return { name: 'reverse-text', inject: ['tools'], apply(ctx) { ctx.tools.register(harness.defineTool({ name: 'reverse_text', … })) } }"}) - [tool call] reverse_text({"text": "harness"}) ← a tool the agent built for itself, one step earlier -> Unmount both temporary Plugins. - [tool call] cordis_unmount({"id": "dyn-1"}) -``` - -请求 `cordis_inspect` 并使用 `what: "api"` 或 `what: "events"`,即可查看编写插件代码所用的生成服务/事件资料。还可挂载两个协作临时插件(一个中调用 `ctx.provide`,另一个中使用 `inject`),观察 Cordis 如何暂停并恢复消费方。 - -## 端到端测试 - -`tests/keyless-smoke.e2e.ts` 使用虚拟密钥通过 Loader 启动真实 `cordis.yml`,并断言横幅、包名解析,以及收到 EOF 后正常退出。`tests/cordis-tools.e2e.ts` 是带密钥的冒烟测试:真实模型挂载一个临时状态监听器,测试验证其带标记的控制台输出行;然后创建并使用 `reverse_text` 工具,再通过 provide/inject 组合两个临时插件。[`packages/cordis/tool-cordis`](../../packages/cordis/tool-cordis) 包含相关单元测试,并受逐文件 100% 覆盖率门禁约束。 diff --git a/examples/tui-agent/README.zh.md b/examples/tui-agent/README.zh.md deleted file mode 100644 index c6acd39d87..0000000000 --- a/examples/tui-agent/README.zh.md +++ /dev/null @@ -1,80 +0,0 @@ -# tui-agent - -[English](README.md) | 中文 - -全屏交互式编码 agent(智能体):DeepSeek V4、本地 bash 与文件系统工具、压缩(compaction)、subagent、工作流与全新 agent Ralph 迭代、plan mode(`/plan` 进入,`exit_plan_mode` 评审退出)、超时/溢出策略,以及通过 [`@deepseek-ai/dsh-tui-demo`](../../packages/examples/tui-demo) 提供的 JSONL 持久化;该应用从 `cordis.yml` 加载。同级 [`headless-agent`](../headless-agent/README.md) 以适合管道调用的单次任务形式运行同一能力类,[`acp-agent`](../acp-agent/README.md) 则通过 JSON-RPC 提供该能力。 - -## 运行 - -```sh -# repo root .env (gitignored) or exported env: -# DEEPSEEK_API_KEY=sk-… -# DEEPSEEK_BASE_URL=https://… # optional; defaults to the public API -pnpm run demo:tui -``` - -演示脚本和可安装的 `dsh` CLI(命令行界面,见 [`apps/cli`](../../apps/cli/README.md))都会以此示例的 `cordis.yml` 作为已交付的默认配置启动;`dsh` 还会应用 `~/.dsh` 中的个人覆盖,并将调用目录作为工作区。 - -输入一项编码任务。agent 使用 `read`/`write`/`edit` 文件系统工具处理常规文件操作,使用 `bash`(加上面向后台任务的通用 `task_output`/`task_list`/`task_kill`)执行 shell 命令、搜索和测试。每次 bash 调用都在新的 `bash -c` 中运行(系统提示词要求模型传递 `workdir`,而不是使用 `cd`)。文件系统工具和 bash 都会相对于会话工作区解析相对路径。agent 还可以通过 `subagent`/`subagent_fork` 委托。 - -`todo_write` 任务跟踪器是选用的,不在已交付配置中:请将 `@deepseek-ai/dsh-tool-todo` 添加到 `cordis.yml`(或在 `~/.dsh` 下使用个人配置覆盖)以公开该工具。加载后,模型会把整表计划记录到会话日志,TUI 则渲染它。 - -TUI 渲染 Markdown 历史、推理(reasoning)、工具自有的终端/diff/通用卡片、token 总量,以及加载 `todo_write` 时的最新计划。较长的工具正文保留首尾预览;Ctrl+O 展开或折叠所有卡片。Enter 用于提交,或在 agent 运行时进行 steering(中途引导);Ctrl+R 切换推理,Escape 取消,`/help` 列出命令。`/plan` 为下一步骤选择 plan mode;`/plan ` 还会将消息提交到该步骤,`/plan off` 则在没有模型输入的情况下选择默认 mode。`/status` 会展开当前会话的标识、活动计数、精确 token/缓存 bucket、上下文用量和时间戳,而不中断正在运行的轮次。`/model` 打开当前提供方目录的键盘选择器;使用 Up/Down 聚焦模型,使用 Shift+Tab 循环切换为该模型公布的推理强度,再用 Enter 选择;也可以使用 `/model ` 和 `/model /` 直接选择。`ask_user_question` 会打开一个位于左下方的宽键盘面板,包含批次进度和编号选项。 - -### 恢复早先的会话 - -每次运行默认都会启动新会话(其事件日志落在 `./.sessions/` 下)。如需 **继续** 先前对话,请将其 id 传给已安装的 `dsh` CLI:此时 `main` agent 会重新水化持久日志,而不会从头开始,因此模型会将早先轮次视为历史: - -```sh -dsh --resume -``` - -`/resume` 打开可搜索键盘选择器,显示标题、活动、上一轮结果、模型路由、持久化目标阶段和实时/已持久化状态。已安装的 `dsh` 宿主会等待刷写完成,对当前应用执行 dispose(资源释放),然后以 `dsh --resume ` 替换进程。TUI 仍会在退出时打印该命令,并在自定义宿主无法移交时显示它。`dsh --resume ` 在启动上下文中提供 id,`cordis.yml` 会读取它(`resumeSessionId: !!js "typeof resumeSessionId === 'string' ? resumeSessionId : undefined"`);没有标志时,agent 会开始新会话。缺失或无法读取的 id 不会启动 agent,而会发出 `agent-loop/config-start-failed`:TUI 打印失败并以非零状态退出。选择器没有跨进程会话锁,因此拥有并发宿主的部署必须自行协调会话所有权。 - -## Code Mode - -[`code-mode.cordis.yml`](code-mode.cordis.yml) 在同一树上覆盖 worker 线程运行时和 `tools: { mode: code }`。模型会收到一个 `run_code` 传输工具,加上一份为可见工具生成的 TypeScript SDK;只有程序输出会返回模型上下文。使用 `mode: both` 可在 `run_code` 旁同时公开原生调用。执行契约详见 [Code Mode Agent Note(agent 决策记录)](../../.agents/notes/implemented/feature/2026-06-15-code-mode.md)。 - -```sh -pnpm run demo:code-mode # this overlay under the TUI (default UI) -pnpm run demo:code-mode acp # the acp-agent example's same-shaped overlay -``` - -尝试一项横跨多个工具调用的任务,例如: - -> 统计 docs/ 下每个 `*.md` 文件的行数,并将最大的三个写入 summary.txt。 - -然后观察 transcript(文本记录):一次 `run_code` 调用、一个循环调用工具的程序,以及模型筛选后的结果,而不是五次原始工具输出往返。 - -## 每个叶节点配置项所演示的内容 - -此示例是轻量叶节点 `cordis.yml`:它选择可替换后端、加载一个应用包(package),并添加有意放在共享主干外的产品工具。主干(会话、系统提示词、工具、agent、不变式、`agent-loop`)和入口集群(JSONL 持久化、pi-tui 通道、预创建的 `main` agent)位于 [`@deepseek-ai/dsh-tui-demo`](../../packages/examples/tui-demo) 应用及其加载的 [`@deepseek-ai/dsh-agent-spine-demo`](../../packages/examples/agent-spine-demo) 组合包中;叶节点负责接线后端与面向模型的可选工具: - -| 配置项 | 演示内容 | -|---|---| -| `hmr` (`@cordisjs/plugin-hmr`) | 开发/演示的编辑-重载循环:它是 **叶节点** 配置项(不内置到应用),因为它依赖 Loader 的内部模块访问 | -| `llm-deepseek` | 默认原生适配器 | -| `bash` (`dsh-bash-local`) | 执行器实现:bash seam 中可替换的实现侧。面向模型的 `bash` schema(`tool-bash`)和通用 `task_*` 控制(`tool-tasks`)由 `dsh-agent-spine-demo` 提供,因此叶节点只选择执行器 | -| `tui-agent` (`@deepseek-ai/dsh-tui-demo`) | 应用组合包:agent-spine 演示 + JSONL 持久化 + pi-tui 通道 + 预创建的 `main` agent | -| `subagent`, `subagent-spawn`, `subagent-fork` | subagent 提供方注册表加两个进程内后端:新子 agent,以及用父 agent 已完成轮次前缀播种的子 agent | -| `tool-subagent`, `tool-subagent-fork` | 两次面向模型的 `dsh-tool-subagent` 加载,每次绑定不同提供方,并以不同工具名(`subagent`、`subagent_fork`)公开 | -| `workflow-workerthread`, `tool-workflow` | worker 线程工作流引擎及其面向模型的 `workflow` 工具,子调用通过 spawn 后端路由 | -| `plan-mode` | 插件拥有的 `/plan [message]` 进入命令和 `/plan off` 退出命令、plan-mode 提示词策略、工具限制,以及经评审的 `exit_plan_mode` 转换 | -| `fs-local`, `fs-policy`, `tool-fs` | 文件系统栈:本地 `ctx.fs` 提供方、先读后写/编辑策略门禁(位于 `fs/*` 事件门禁),以及面向模型的 `read`/`write`/`edit` 工具。相对路径相对于会话工作区解析 | - -## 端到端测试(`pnpm run test:e2e`) - -与 UI 无关的带密钥套件通过 `tests/harness.ts` 以程序方式组装完整栈(无 PTY、无 Loader): - -- `tests/full-loop.e2e.ts`:canary 测试:真实模型通过真实 bash 工具运行 `echo e2e-ok`;断言 `tool/call`/`tool/result` 会话事件和最终答案。 -- `tests/coding-task.e2e.ts`:类 swebench 冒烟测试:临时目录包含 `add.js`(其中 `a - b` 写在本应是 `a + b` 的位置)和失败的 `add.test.js`;agent 必须修复错误并验证。测试会自行重新运行 `node add.test.js` 并检查文件,不信任 agent 的说法。 -- `tests/resume.e2e.ts`:跨进程持久连续性:第一次运行告诉真实模型一个密码并将轮次持久化到临时 JSONL 根目录,然后 dispose 整个上下文;第二次运行在同一根目录上创建新上下文,恢复会话 id 并要求模型回忆密码。只有重新水化的日志能够提供该回忆。 -- `tests/compaction.e2e.ts`:压缩冒烟测试:一项真实多步 bash 任务在故意设得很小的上下文窗口中运行,使自动压缩监听器在会话中途触发。测试验证外部状态:真实日志中出现 `compact/start…end` 对,模型可见内容缩减(一个替换节点遮蔽了较旧节点),且 agent 在压缩后仍给出正确最终答案。 -- `tests/todo-write.e2e.ts`:加载选用的 `todo_write` 工具,由真实模型驱动,测试验证产生的 `todo/write` 会话事件。 -- `tests/code-mode.e2e.ts`:带密钥 Code Mode 证明:使用真实模型和双工具任务,断言协议层工具列表精确为 `[run_code]`,`tool/code-dispatch` 事件位于父调用下,且筛选后的答案已返回。 - -这些测试在没有 `DEEPSEEK_API_KEY` 时自行跳过。无密钥 `tests/tui-keyless-smoke.e2e.ts` 通过 PTY 启动真实 Loader 树(唯一获准的 PTY 界面):基础启动 + `/plan` + `/exit`,一次带问题对话框和工具往返的脚本 LLM(大语言模型)对话,Code Mode 覆盖配置的欢迎行,以及恢复失败退出路径。 - -## 快照测试 - -`tests/snapshots//session.jsonl` 提供已录制的用户提示词和模型分片;同级子日志驱动 subagent 和工作流。无密钥套件通过真实循环和工具实现执行这些脚本,然后比较可读的预期终端单元格/样式输出。对于仅涉及展示的变更,使用 `pnpm run test:snapshot:refresh`;已录制的模型流程改变时,使用 DeepSeek 密钥运行 `pnpm run test:snapshot:record`。已实现的 [TUI 快照 Agent Note](../../.agents/notes/implemented/testing/2026-07-18-tui-terminal-state-snapshots.md) 规定了场景矩阵,以及已录制旅程、包级瞬态快照与 PTY 覆盖之间的分工。 diff --git a/packages/examples/README.i18n.yaml b/packages/examples/README.i18n.yaml index ddf815f1b1..0b0ee3db7c 100644 --- a/packages/examples/README.i18n.yaml +++ b/packages/examples/README.i18n.yaml @@ -2,10 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/examples/README.md -<<<<<<< HEAD -README.md: c229cef22087ac290bf862d6b3e31fdb533858c4 -README.zh.md: 208b9a138506785ea1dd2d83ddfbba29e4b7968e -======= README.md: d3ad432e71036db0d21f059e52f5d32e58010c42 README.zh.md: 0218e60df2511974b8eb222e23331c5a70c9df60 ->>>>>>> a1c6a2c3f (refactor(cli)!: one shared base config with per-surface overlays) diff --git a/packages/examples/README.zh.md b/packages/examples/README.zh.md index 7ec12eb1c6..0218e60df2 100644 --- a/packages/examples/README.zh.md +++ b/packages/examples/README.zh.md @@ -2,25 +2,16 @@ [English](README.md) | 中文 -预先组合的插件 bundle(组合包),供轻量叶节点 `cordis.yml` 加载,无需手工组装主干和前端入口。这些是 **演示/参考** 包(package);npm 名称的 `-demo` 后缀把每个包标为非产品表层,直接查看包名即可辨认。仓库根目录 [`examples/`](../../examples/AGENTS.md) 下的可运行叶节点与 [Python SDK 运行时](../../python/sdk-runtime/README.md) 是消费方;每个消费方都只包含可替换后端和一个组合包入口。 +预先组合的插件 bundle(组合包),供轻量叶节点 `cordis.yml` 加载,无需手工组装主干和前端入口。这些是 **演示/参考** 包;npm 名称的 `-demo` 后缀把每个包标为非产品表层,直接查看包名即可辨认。仓库根目录 [`examples/`](../../examples/AGENTS.md) 下的可运行叶节点与 [Python SDK runtime](../../python/sdk-runtime/README.md) 是消费方;每个叶节点都只包含可替换后端和一个组合包入口。 | 包 | npm 名称 | 角色 | |---|---|---| -<<<<<<< HEAD -| `agent-spine-demo/` | `@deepseek-ai/dsh-agent-spine-demo` | 不含执行器和 UI 的 agent(智能体)主干,打包为一个组合包插件,带后备会话标题和可选择启用的持久化目标栈 | -| `tui-demo/` | `@deepseek-ai/dsh-tui-demo` | 全屏终端应用组合包:主干 + 持久化目标 + `/goal` 命令 + JSONL 持久化 + `dsh-tui` + 预创建的 `main` agent;没有 bin,由 [`dsh`](../../apps/cli/README.md) CLI(命令行界面)启动 | -======= | `agent-spine-demo/` | `@deepseek-ai/dsh-agent-spine-demo` | 不含执行器和 UI 的 agent 主干,打包为一个组合包插件,带后备会话标题和选用的持久目标栈 | ->>>>>>> a1c6a2c3f (refactor(cli)!: one shared base config with per-surface overlays) | `cli-demo/` | `@deepseek-ai/dsh-cli-demo` | 无头单次应用:主干 + JSONL 持久化 + 预创建的 `main` agent,提供文本和 DSH 原生 JSON 输出 | -| `acp-demo/` | `@deepseek-ai/dsh-acp-demo` | ACP(Agent Client Protocol)自动化服务器应用:主干 + 持久化目标 + JSONL 持久化 + [`acp`](../acp/acp/README.md) 桥接层(无 stdout logger),带启动 `bin` | -| `jsonrpc-demo/` | `@deepseek-ai/dsh-jsonrpc-demo` | 只有 bin 的运行时,用于启动外部 `cordis.yml`,供 stdio JSON-RPC SDK 客户端使用 | +| `acp-demo/` | `@deepseek-ai/dsh-acp-demo` | ACP 自动化服务器应用:主干 + 持久目标 + JSONL 持久化 + [`acp`](../acp/acp/README.md) 桥接层(无 stdout logger),带启动 `bin` | +| `jsonrpc-demo/` | `@deepseek-ai/dsh-jsonrpc-demo` | 只有 bin 的 runtime,用于启动外部 `cordis.yml`,供 stdio JSON-RPC SDK 客户端使用 | -<<<<<<< HEAD -`agent-spine-demo` 是共享组合包;`tui-demo`、`cli-demo` 和 `acp-demo` 分别将它与全屏终端、无头单次和 ACP 自动化前端入口组合。`cli-demo` 与 `acp-demo` 拥有各自的启动 bin;`tui-demo` 只交付组合包插件,产品 [`dsh`](../../apps/cli/README.md) CLI 是它的终端前端入口。`jsonrpc-demo` 自身不挂载任何组合,而是启动部署的 `cordis.yml` 所指名的任意插件树;Python SDK 运行时会启动它。 -======= `agent-spine-demo` 是共享组合包;`cli-demo` 和 `acp-demo` 分别将它与无头单次和 ACP 自动化前端入口组合,并拥有各自的启动 bin。产品 [`dsh`](../../apps/cli/README.md) CLI 不使用组合包:其 TUI 与 web surface 都是一份共享的 `base.cordis.yml` 加各自一份 overlay。`jsonrpc-demo` 自身不挂载任何组合,而是启动部署的 `cordis.yml` 所指名的任意插件树;Python SDK runtime 会启动它。 ->>>>>>> a1c6a2c3f (refactor(cli)!: one shared base config with per-surface overlays) 这些 **不是** 产品 API。它们打包的主干组件位于 [`core/`](../core/README.md),人类/SDK 通道和启动粘合代码位于 [`ui/`](../ui/README.md),自动化传输位于 [`acp/`](../acp/README.md),可替换后端位于各自能力组;演示组合包只选定其中一种具体组合。可以自由替换或 fork。 @@ -28,4 +19,4 @@ ## jsonrpc bin/exe 名称是历史遗留 -`jsonrpc-demo` 已像同级包一样重命名,但其 bin 仍为 `dsh-jsonrpc-agent`,单文件可执行程序仍为 `dsh-jsonrpc-agent-pkg`(在 [Python 分发](../../python/sdk-runtime/README.md)各处被引用)。这些名称属于 SDK 的运行时启动表层;只有 SDK 统一该启动流程时才会协调它们,而不会在此次移动中处理。 +`jsonrpc-demo` 已像同级包一样重命名,但其 bin 仍为 `dsh-jsonrpc-agent`,单文件可执行程序仍为 `dsh-jsonrpc-agent-pkg`(在 [Python 分发](../../python/sdk-runtime/README.md)各处被引用)。这些名称属于 SDK 的 runtime 启动表层;只有 SDK 统一该启动流程时才会协调它们,而不会在此次移动中处理。 diff --git a/packages/examples/agent-spine-demo/README.i18n.yaml b/packages/examples/agent-spine-demo/README.i18n.yaml index 48d17b5391..59caccb8a1 100644 --- a/packages/examples/agent-spine-demo/README.i18n.yaml +++ b/packages/examples/agent-spine-demo/README.i18n.yaml @@ -2,10 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/examples/agent-spine-demo/README.md -<<<<<<< HEAD -README.md: 359e7153be2f480ba3fea4b06782acdc9f89ebb9 -README.zh.md: acd8c06940b03e90e368314cd725846a2b92b656 -======= README.md: 6fde5c64052e369541787f90c12c33a7b0b8f9eb README.zh.md: d5b0e2b50707750132bdeae9d7e56d513b0f40a7 ->>>>>>> a1c6a2c3f (refactor(cli)!: one shared base config with per-surface overlays) diff --git a/packages/examples/agent-spine-demo/README.zh.md b/packages/examples/agent-spine-demo/README.zh.md index 96a4354b03..d5b0e2b507 100644 --- a/packages/examples/agent-spine-demo/README.zh.md +++ b/packages/examples/agent-spine-demo/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -将 **默认的不含执行器、不含 UI 的 agent(智能体)主干** 作为一个 Cordis 组合包插件。它加载每个 harness agent 所需的固定服务集合,包括本地 skill(技能)提供方,并将循环的 `agents` 列表作为自身配置转发。因此,应用包(package)只需添加前端入口和可替换后端,就能组合出可工作的 agent。 +将 **默认的不含执行器、不含 UI 的 agent 主干** 作为一个 Cordis 组合包插件。它加载每个 harness agent 所需的固定服务集合,包括本地 skill 提供方,并将循环的 `agents` 列表作为自身配置转发。因此,应用包只需添加前端入口和可替换后端,就能组合出可工作的 agent。 阅读此包可了解完整插件树及其组合顺序。 @@ -43,15 +43,11 @@ 主干包含每个前端入口都共有的全部组件。可替换组件和与前端入口耦合的组件留在外部,由加载组合包的一方选择: -- **LLM(大语言模型)适配器**:组合包交付抽象 `llm` 服务;叶节点在 `ctx.llm` 上注册具体适配器(`llm-deepseek`、`llm-pi-ai`、`llm-replay`)。 -- **基于模型的会话标题提供方**:组合包挂载带可覆盖示例限制的后备服务(5 个词、40 个后备字节、80 个可接受标题字节);叶节点可以恰好选用一个首消息或全消息 LLM 提供方。 +- **LLM 适配器**:组合包交付抽象 `llm` 服务;叶节点在 `ctx.llm` 上注册具体适配器(`llm-deepseek`、`llm-pi-ai`、`llm-replay`)。 +- **模型支持的会话标题提供方**:组合包挂载带可覆盖示例限制的后备服务(5 个词、40 个后备字节、80 个可接受标题字节);叶节点可以恰好选用一个首消息或全消息 LLM 提供方。 - **bash 执行器**:组合包交付 `tool-bash`(消费方 schema);叶节点提供 `ctx.bash`(`bash-local` 或沙箱化实现)。 - **非本地 skill 提供方**:组合包交付 skill 注册表、本地文件系统提供方和 `skill` 工具;部署可以把嵌入式目录或远程目录等其他提供方作为同级插件添加。 -<<<<<<< HEAD -- **前端入口与各应用基础设施**:终端 TUI 或 ACP(Agent Client Protocol)自动化传输,以及 `hmr`。应用包([`dsh-tui-demo`](../tui-demo/README.md)、[`dsh-acp-demo`](../acp-demo/README.md))拥有这些选择。`timer` 位于主干中,因为它是共有组件且不写 stdout;前端入口拥有 stdout,因此留在组合包外。 -======= - **前端入口与各应用基础设施**:终端 TUI 或 ACP 自动化传输,以及 `hmr`。应用包([`dsh-cli-demo`](../cli-demo/README.md)、[`dsh-acp-demo`](../acp-demo/README.md))拥有这些选择。`timer` 位于主干中,因为它是共有组件且不写 stdout;前端入口拥有 stdout,因此留在组合包外。 ->>>>>>> a1c6a2c3f (refactor(cli)!: one shared base config with per-surface overlays) 这把[接口/实现/消费方 seam](../../../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md) 提升到组合层:组合包拥有共享主干,叶节点拥有后端,应用包拥有前端入口。 @@ -63,25 +59,25 @@ import type { Config } from '@deepseek-ai/dsh-agent-spine-demo' // workspaceContext requires { maxBytes } or false; the other owner schemas supply defaults. ``` -组合包将每个字段转发给拥有它的子节点:`agents` 与 `maxParallelToolCalls` 交给 `agent-loop`(`agents` 默认为 `[]`,上限在该处默认),因此每个应用提供自己的预创建 agent;TUI 和无头应用预创建 `main`,ACP 应用则在 `session/new` 按需创建 agent;`persona` 与 `toolOrder` 交给 `dsh-system-prompt`;`tools` 交给工具注册表以配置呈现模式;`sessionTitle` 交给后备标题服务;`skills.registry`、`skills.local` 与 `skills.tool` 分别交给 skill 注册表、本地提供方和面向模型的消费方;必填的 `workspaceContext` 选择交给 `dsh-workspace-context`(`{ maxBytes }` 启用加载,`false` 禁用);`invariants` 交给不变式服务;`toolBash`/`toolTasks` 交给组合包拥有的两个面向模型工具插件。组合包始终挂载 `dsh-llm-retry`,而每个叶节点适配器拥有自己的嵌套 `retryPolicy`。省略 `sessionTitle` 时采用显式示例策略:5 个词、40 个后备字节、80 个可接受标题字节。`goals` 对象会选用持久化领域、模型工具和同会话 Goal Round 驱动器,并将 `goals.domain` 与 `goals.tool` 转发给各自拥有者;省略或设为 `false` 会让整个栈缺席,使无头调用方继续以单轮次结算。设置 `skills.enabled: false` 会同时省略本地提供方和面向模型的 skill 工具;设置 `toolTasks: false` 会保留供前台生产方使用的任务服务,但不公开 `task_output`/`task_list`/`task_kill`。它对 `dshHome` 只解析一次,解析通过 [`@deepseek-ai/dsh-paths`](../../util/paths/README.md) 完成,并将所得绝对值转发给 tool-bash 的托管环境和已启用的本地 skill 发现。顶层 `dshHome` 缺席时采用 `skills.local.dshHome`;两者同时提供但解析后的路径不同会明确失败。`toolBash.enableRunInBackground` 只控制 bash 生产方;独立加载的生产方保留各自配置。工作区指令先于 skill 目录注册,因此其会话前缀消息先渲染。应用包使用 `pickSpineConfig()`,只复制这些由组合包拥有的字段。 +组合包将每个字段转发给拥有它的子节点:`agents` 与 `maxParallelToolCalls` 交给 `agent-loop`(`agents` 默认为 `[]`,上限在该处默认),因此每个应用提供自己的预创建 agent;TUI 和无头应用预创建 `main`,ACP 应用则在 `session/new` 按需创建 agent;`persona` 与 `toolOrder` 交给 `dsh-system-prompt`;`tools` 交给工具注册表以配置呈现 mode;`sessionTitle` 交给后备标题服务;`skills.registry`、`skills.local` 与 `skills.tool` 分别交给 skill 注册表、本地提供方和面向模型的消费方;必填的 `workspaceContext` 选择交给 `dsh-workspace-context`(`{ maxBytes }` 启用加载,`false` 禁用);`invariants` 交给不变式服务;`toolBash`/`toolTasks` 交给组合包拥有的两个面向模型工具插件。组合包始终挂载 `dsh-llm-retry`,而每个叶节点适配器拥有自己的嵌套 `retryPolicy`。省略 `sessionTitle` 时采用显式示例策略:5 个词、40 个后备字节、80 个可接受标题字节。`goals` 对象会选用持久领域、模型工具和同会话驱动器,并将 `goals.domain` 与 `goals.tool` 转发给各自拥有者;省略或设为 `false` 会让整个栈缺席,使无头调用方继续以一轮结算。设置 `skills.enabled: false` 会同时省略本地提供方和面向模型的 skill 工具;设置 `toolTasks: false` 会保留供前台生产方使用的任务服务,但不公开 `task_output`/`task_list`/`task_kill`。它对 `dshHome` 只解析一次,解析通过 [`@deepseek-ai/dsh-paths`](../../util/paths/README.md) 完成,并将所得绝对值转发给 tool-bash 的托管环境和已启用的本地 skill 发现。顶层 `dshHome` 缺席时采用 `skills.local.dshHome`;两者同时提供但解析后的路径不同会明确失败。`toolBash.enableRunInBackground` 只控制 bash 生产方;独立加载的生产方保留各自配置。Workspace 指令先于 skill 目录注册,因此其会话前缀消息先渲染。应用包使用 `pickSpineConfig()`,只复制这些由组合包拥有的字段。 例如,`{ invariants: { enabled: true, package_allowlist: ['^@deepseek-ai/dsh-'], package_blocklist: ['agent-loop$'] } }` 会让包拥有的配套插件保持挂载,但抑制被阻止的拥有者。Blocklist 匹配优先于 allowlist 匹配;正则表达式与生命周期规则见 [`dsh-invariants`](../../support/invariants/README.md)。 ## 为何使用代码组合包,而非共享 YAML include -YAML include 可以去重配置,却无法拥有 bin 或提供前端入口默认值。ACP 应用包默认接出协议纯净的 stdout,但叶节点仍可添加不安全的 logger。组合包子节点把服务注册到根 isolate-keyed store,因此叶节点的同级插件无需依赖加载顺序即可通过注入看到它们。 +YAML include 可以去重配置,却无法拥有 bin 或提供前端入口默认值。ACP 应用包默认接出协议纯净的 stdout,但叶节点仍可添加不安全的 logger。组合包子节点把服务注册到根 isolate-keyed store,因此注入这些服务的叶节点同级插件无需依赖加载顺序即可看到它们。 -重试策略可能在新的编号步骤中重复失败的请求。重试状态、提供方错误和失败的部分分片不进入模型历史;每次提供方尝试仍可能产生计费;always 模式没有尝试次数上限;前端入口从所有已记录步骤推导用量;重建的请求保留先前前缀,以便复用提供方缓存。 +重试策略可能在新的编号步骤中重复失败的请求。重试状态、提供方错误和失败的部分 chunk 不进入模型历史;每次提供方尝试仍可能产生计费;always mode 没有尝试次数上限;前端入口从所有已记录步骤推导用量;重建的请求保留先前前缀,以便复用提供方 cache。 ## 模型体验 -模型通过 `dsh-system-prompt`、`dsh-tool-skill`、`dsh-tool-bash`、`dsh-tools` 和 `dsh-llm-retry` 间接获得体验;还会通过 `dsh-tool-goal` 与 Goal Round 提示词获得体验,前提是启用 `goals`。组合包自身不添加面向模型的包装内容。 +模型通过 `dsh-system-prompt`、`dsh-tool-skill`、`dsh-tool-bash`、`dsh-tools` 和 `dsh-llm-retry` 间接获得体验;还会通过 `dsh-tool-goal` 与目标轮次提示词获得体验,前提是启用 `goals`。组合包自身不添加面向模型的包装内容。 #### KV Cache 影响 -不会直接失效;上述消费方负责请求前缀的任何变更。 +不会直接失效;具名消费方拥有请求前缀的任何变更。 -## 已知限制与暂缓事项 +## 已知限制与延后工作 -- **大部分主干集合固定在代码中**:`apply()` 始终挂载核心服务与 `tool-bash`;配置可以省略组合包内的目标、skill 与任务控制工具,但要替换循环或删除其他主干成员,就必须组合另一个组合包。 +- **大部分主干集合固定在代码中**:`apply()` 始终挂载核心服务与 `tool-bash`;配置可以省略组合包内的目标、skill 与任务控制工具,但要替换循环或删除其他主干成员,就必须组合另一个 bundle。 - **不变式 seam 与配套插件仍是固定成员**:`invariants.enabled: false` 或包筛选器会抑制检查,但不会移除服务或配套插件注册;Session 始终启用的校验与冻结是另一套机制。 diff --git a/packages/examples/tui-demo/README.i18n.yaml b/packages/examples/tui-demo/README.i18n.yaml deleted file mode 100644 index a9815be3a3..0000000000 --- a/packages/examples/tui-demo/README.i18n.yaml +++ /dev/null @@ -1,6 +0,0 @@ -# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each -# side as of the last confirmed-consistent state. Both languages carry equal authority; -# after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write packages/examples/tui-demo/README.md -README.md: 058ebe87af5f041bd19fbfb205a97753ccacf6b9 -README.zh.md: 43681e9c77cbd77459ec0539b69e0ab73274d2c1 diff --git a/packages/examples/tui-demo/README.zh.md b/packages/examples/tui-demo/README.zh.md deleted file mode 100644 index 43681e9c77..0000000000 --- a/packages/examples/tui-demo/README.zh.md +++ /dev/null @@ -1,112 +0,0 @@ -# @deepseek-ai/dsh-tui-demo - -[English](README.md) | 中文 - -全屏终端应用组合包:一个 Cordis 插件,组合 [`@deepseek-ai/dsh-agent-spine-demo`](../agent-spine-demo/README.md)、持久化的同会话目标、人类命令注册表与 `/goal` 生产方、JSONL 持久化、键盘支持的用户交互、预创建的 `main` agent(智能体),以及 [`@deepseek-ai/dsh-tui`](../../ui/tui/README.md)。一份 `cordis.yml` 将它作为一个 Cordis 配置项挂载;[`dsh`](../../../apps/cli/README.md) CLI(命令行界面)是启动此类配置的入口。 - -管道、脚本和其他非交互式运行应使用 [`@deepseek-ai/dsh-cli-demo`](../cli-demo/README.md)。此组合包需要一对 TTY,不提供面向行的回退。 - -## 内置组件 - -| 插件 | 设置在此处的原因 | -|---|---| -| `@deepseek-ai/dsh-agent-spine-demo` | 共享服务、面向模型的工具,以及一个已配置的 `main` agent | -| `@deepseek-ai/dsh-commands` | 供 TUI 和命令插件消费、仅面向人类的命令发现与分发 | -| `@deepseek-ai/dsh-command-goal` | 直接在主干的持久化目标栈上提供 `/goal` 状态与变更 | -| `@deepseek-ai/dsh-session-persistence-jsonl` | 位于 `persistenceRoot` 下的持久会话日志 | -| `@deepseek-ai/dsh-session-checkpoint-policy` | 模型请求和顶层工具 effect 前的语义持久性屏障,以及已完成步骤的检查点 | -| `@deepseek-ai/dsh-session-query-sqlite` + `@deepseek-ai/dsh-session-reference` | TUI 消费的组合式精确/FTS 会话查询与有界 `@session` 快照;面向模型的查询工具仍由叶节点选用 | -| `@deepseek-ai/dsh-user-interaction` | 与提供方无关的人类问题服务 | -| `@deepseek-ai/dsh-tui` | 全屏 transcript(文本记录)、编辑器、工具卡片、计划与问题 overlay | -| `@deepseek-ai/dsh-tool-ask-user` | 面向模型的 `ask_user_question` 工具 | - -可替换的 LLM(大语言模型)、bash、文件系统和其他能力提供方仍留在叶节点配置中。`@cordisjs/plugin-hmr` 也仍是仅叶节点使用的开发条目,因为它需要 Loader 内部实现。 - -## 配置 - -| 键 | 默认值 | 路由目标 | -|---|---|---| -| `provider` | 必填 | 已配置 `main` agent 的提供方 | -| `model` | 必填 | 已配置 `main` agent 的模型 | -| `maxParallelToolCalls` | agent-loop 默认值 | 组合包内循环的并发上限 | -| `persona` | 无 | 系统提示词 persona 模板 | -| `toolOrder` | 字典序 | 显式的面向模型工具顺序 | -| `tools` | 拥有者默认值 | 工具呈现模式 | -| `dshHome` | 拥有者默认值 | bash 与 skill(技能)使用的 harness 主目录 | -| `sessionTitle` | 主干示例限制 | 后备标题词数/字节限制 | -| `skills` | 拥有者默认值 | skill 注册表、本地提供方和工具配置 | -| `toolBash` | 拥有者默认值 | 面向模型的 bash 工具配置 | -| `toolTasks` | 拥有者默认值 | 后台任务控制工具配置,或 `false` | -| `goals` | 拥有者默认值 | 持久目标领域与模型工具配置;`false` 会移除目标栈与 `/goal` 生产方 | -| `workspaceContext` | 必填 | Workspace 指令配置,或 `false` | -| `persistenceRoot` | `./.sessions` | JSONL 持久化根目录,以及派生 `session-query.db` 索引的父目录 | -| `persistenceCompression` | `'zstd'` | JSONL 产物编码(`'zstd'` 或原始 `'none'`) | -| `sessionReferences` | 服务默认值 | 路由到 `dsh-session-reference` 的跨会话候选项与快照限制 | -| `welcome` | `ready.` | TUI 副标题 | -| `resumeCommand` | 无 | 退出和无宿主回退的命令模板;选择器本身使用会话查询与宿主移交 | -| `ui` | 拥有者默认值 | 推理(reasoning)、颜色、卡片高度等 TUI 呈现设置 | -| `resumeSessionId` | 无 | 要恢复的确切持久化会话 | - -新运行会创建 `main-session-` 会话 id,并将它同时传给 TUI 与已配置的 agent。恢复运行会将两个组件都绑定到 `resumeSessionId`。TUI 先于主干挂载,因此它可以渲染匹配的配置启动失败,而不会留下空白终端。应用为 `/resume` 组合持久化和会话查询;嵌入宿主还可以提供 `tuiResumeHost`,用于原地移交进程。 - -## 入口 - -此包(package)不交付 bin。[`dsh`](../../../apps/cli/README.md) CLI 是终端入口:裸 `dsh` 启动已交付的 `examples/tui-agent/cordis.yml`(它挂载此组合包),而 `dsh --config ` 启动另一个挂载此组合包的叶节点配置。它加载 cwd 下可选的 `.env`,驱动 Cordis Loader,并等待完整插件树。仓库安装了 Loader 的可选原生辅助程序,因此裸包说明符可以在纯 Node 下解析。 - -## 叶节点示例 - -```yaml -- id: llm-deepseek - name: '@deepseek-ai/dsh-llm-deepseek' - config: - apiKey: !!js process.env.DEEPSEEK_API_KEY -- id: bash - name: '@deepseek-ai/dsh-bash-local' -- id: tui-agent - name: '@deepseek-ai/dsh-tui-demo' - config: - provider: deepseek - model: deepseek-v4-flash - workspaceContext: - maxBytes: 65536 - welcome: 'Coding agent ready.' - ui: - showReasoning: true -``` - -## 模型体验 - -### 交互式终端轮次 - -#### 模型看到的内容 - -每次非空、非命令的编辑器提交都会成为用户消息;运行中轮次内的提交成为 steering(中途引导)。斜杠命令输入和输出仍只面向人类,而已接受的 `/goal` 变更会追加领域拥有的模型可见状态。共享主干提供已配置的 persona、workspace 指令、skill 目录、目标控制和可见工具 schema。TUI 渲染本身对模型不可见。 - -#### Token 影响 - -用户、assistant 与工具历史按常规会话和压缩(compaction)规则增长。Header、卡片、计划、Markdown 样式和快捷键不增加 token。 - -#### KV Cache 影响 - -只要组合后的提示词、schema、路由和保留历史前缀保持稳定,就保持仅追加。组合方式变更与 compaction 可能从第一个变化的 token 起使复用失效。 - -### 人类问题答案 - -#### 模型看到的内容 - -`ask_user_question` 会保留工具调用,以及 `dsh-tool-ask-user` 定义的精简答案或稳定中断错误。问题 overlay 只在终端显示。 - -#### Token 影响 - -只有已完成或失败的工具结果会增加保留 token。 - -#### KV Cache 影响 - -仅追加;答案跟在可复用请求前缀之后。 - -## 已知限制与暂缓事项 - -- **只支持 TTY**:stdin 与 stdout 都必须是终端;自动化使用 `dsh-cli-demo`。 -- **一个已配置的终端会话**:transcript 与编辑器绑定到一个确切会话 id。 -- **应用集群固定不变**:JSONL 持久化与 ask-user 工具内置;不同策略需要另一种组合。 -- **批准机制独立存在**:此应用回答 `ctx.userInteraction`,而不是 `ctx.approval`;权限提示需要批准服务和回答方。 diff --git a/packages/todo/README.i18n.yaml b/packages/todo/README.i18n.yaml index e726755dcf..12b2ce442d 100644 --- a/packages/todo/README.i18n.yaml +++ b/packages/todo/README.i18n.yaml @@ -2,10 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/todo/README.md -<<<<<<< HEAD -README.md: 1e5ae9a1583b9e9d3913fcd1dca7ef11a5f391fe -README.zh.md: a77f788a41353ea547059864dc7cf73ac5025219 -======= README.md: 495851a13f70bc8e3cb2dc99da47ab305ca205fa README.zh.md: abdac592a312cb36d145e7d16e01821561090dcb ->>>>>>> a1c6a2c3f (refactor(cli)!: one shared base config with per-surface overlays) diff --git a/packages/todo/README.zh.md b/packages/todo/README.zh.md index d8d8ffc4db..abdac592a3 100644 --- a/packages/todo/README.zh.md +++ b/packages/todo/README.zh.md @@ -1,15 +1,11 @@ -# todo/:todo/规划能力家族 +# todo/:todo/规划能力系列 [English](README.md) | 中文 -面向模型的 todo 工具。它是单一 **产品**包(package):这里没有接口/实现 seam,因为该列表是由单一所有者管理的会话状态(每个 agent(智能体)会话拥有自己的列表),而非可替换能力。 +面向模型的 todo 工具。它是单一 **产品** 包(package):这里没有接口/实现 seam,因为该列表是由单一所有者管理的会话状态(每个 agent(智能体)会话拥有自己的列表),而非可替换能力。 | 包 | 职责 | ctx 键 | |---|---|---| | `tool-todo/` | 面向模型的 `todo_write` 工具;将完整列表写入会话日志(`todo/write`) | (注册到 `ctx.tools`) | -<<<<<<< HEAD -列表存在于事件溯源会话日志中(`SessionEventMap['todo/write']`,由 [`dsh-session`](../core/session) 拥有);本包是追加快照的轻量消费方。[TUI 应用](../examples/tui-demo)等 UI 以及宿主/客户端运行时会根据会话事件渲染该持久化列表。 -======= 列表存在于事件溯源会话日志中(`SessionEventMap['todo/write']`,由 [`dsh-session`](../core/session) 拥有);本包是追加快照的轻量消费方。[TUI 前端入口](../ui/tui)等 UI 以及宿主/客户端运行时会根据会话事件渲染该持久列表。 ->>>>>>> a1c6a2c3f (refactor(cli)!: one shared base config with per-surface overlays) diff --git a/packages/todo/tool-todo/README.i18n.yaml b/packages/todo/tool-todo/README.i18n.yaml index 8b1ac9bcc1..eb152a048e 100644 --- a/packages/todo/tool-todo/README.i18n.yaml +++ b/packages/todo/tool-todo/README.i18n.yaml @@ -2,10 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/todo/tool-todo/README.md -<<<<<<< HEAD -README.md: b05ef43e7137dcf5678b1f1ad6d8c00b8a43baef -README.zh.md: 88a9f5d69ff52dcedbd8f27a4ace5fde3e0dc5a2 -======= README.md: f86a0d7331cc822a6568eeb14b89a64abaa23378 README.zh.md: 778c920198599546890c90d21badf2d7bb0f2551 ->>>>>>> a1c6a2c3f (refactor(cli)!: one shared base config with per-surface overlays) diff --git a/packages/todo/tool-todo/README.zh.md b/packages/todo/tool-todo/README.zh.md index 26328dde33..778c920198 100644 --- a/packages/todo/tool-todo/README.zh.md +++ b/packages/todo/tool-todo/README.zh.md @@ -6,66 +6,62 @@ ## 功能 -注册一个工具 `todo_write(todos: [{ content, status }])` 到 `ctx.tools`。模型每次调用都会发送完整列表,不存在部分更新或单项编辑。每次调用都会向调用 agent 的会话日志追加 `todo/write` 事件(完整列表快照),具体调用 `agent.session.append('todo/write', { todos })`;当前列表是最新的该类事件(回放时后写覆盖先写)。 +注册一个工具 `todo_write(todos: [{ content, status }])` 到 `ctx.tools`。模型每次调用都会发送完整列表,不存在部分更新或单项编辑。每次调用都会向调用 agent 的会话日志追加 `todo/write` 事件(完整列表快照),具体调用 `agent.session.append('todo/write', { todos })`;当前列表是最新的该类事件(回放时后写者胜)。 `status` 是 `pending`、`in_progress` 或 `completed` 之一。 ## 单一所有者 -该列表属于调用工具的唯一 agent 会话。不存在 subagent/共享/swarm scope:非 agent 调用方(没有 `exec.agent`)无处写入列表,因此会被拒绝。这是有意设置的 scope 限制,详见 Agent Note(agent 决策记录)。 +该列表属于调用工具的唯一 agent 会话。不存在 subagent/共享/swarm scope:非 agent 调用方(没有 `exec.agent`)无处写入列表,因此会被拒绝。这是有意设置的 scope 限制,详见 Agent Note。 ## 验证 -除 schema 的类型/必填/枚举检查外,`execute` 还会拒绝空或重复的 `content`、同时存在多个 `in_progress` 任务的情况(连贯计划最多只有一个活跃任务),以及 `content`/`status` 之外的任何条目键——扩展条目形状(id、嵌套)会明确报错而不是被静默压平,保证落日志的快照与模型自认为写入的内容一致。列表的顺序及及时更新由模型依照工具描述负责。 +除 schema 的类型/必填/枚举检查外,`execute` 还会拒绝空或重复的 `content`、同时存在多个 `in_progress` 任务的情况(连贯计划最多只有一个活跃任务),以及 `content`/`status` 之外的任何条目键——扩展条目形状(id、嵌套)会响亮失败而不是被静默压平,保证落日志的快照与模型自认为写入的内容一致。顺序与保持列表最新的纪律由模型根据工具描述负责。 ## 渲染 -<<<<<<< HEAD -规范结果为 `{ todos, counts: { pending, inProgress, completed } }`;其 Native 渲染器返回精简的更新确认。工具还会写入完整 `todo/write` 会话事件。UI 订阅事件流,并自行渲染该持久化列表:[TUI 应用](../../examples/tui-demo)与 [web 客户端](../../client/ui-conversation)基于当前有效计划(其后没有更晚 `turn/start` 的最近一次 `todo/write`)显示计划条(web 另有专属工具行)([展示](../../../.agents/notes/implemented/feature/2026-07-23-web-todo-display.md)、[生命周期](../../../.agents/notes/implemented/feature/2026-07-28-todo-plan-clears-on-next-turn.md))。 -======= 规范结果为 `{ todos, counts: { pending, inProgress, completed } }`;其 Native 渲染器返回精简的更新确认。工具还会写入完整 `todo/write` 会话事件。UI 订阅事件流,并自行渲染该持久列表:[TUI 前端入口](../../ui/tui)与 [web 客户端](../../client/ui-conversation)基于站立计划(其后没有更晚 `turn/start` 的最近一次 `todo/write`)显示计划条(web 另有专属工具行)([展示](../../../.agents/notes/implemented/feature/2026-07-23-web-todo-display.md)、[生命周期](../../../.agents/notes/implemented/feature/2026-07-28-todo-plan-clears-on-next-turn.md))。 ->>>>>>> a1c6a2c3f (refactor(cli)!: one shared base config with per-surface overlays) ## 会话投影 -当组合挂载了 `ctx.sessionProjections`([`@deepseek-ai/dsh-session-projection`](../../session-projection/session-projection/README.md))时,本包在一个注入的子插件中注册 `todos` 投影单元:`init` = `null`(尚无写入)、`apply` = 从每个 `todo/write` 取整表,并在每个 `turn/start` 清为 `null`(当前有效计划;`turn/end` 保留刚完成的清单;其余事件都返回同一个状态引用)、`view` = 恒等、`stateVersion` = 2。该键在本包中合并进 `SessionProjectionMap`(经接口包的 `/types` 出口);框架驱动该单元,载体通过历史尾页与 `session/projection` 推送帧提供该值。未挂载注册表的组合不受影响。生命周期理由见 [在下一轮次清空 todo 计划](../../../.agents/notes/implemented/feature/2026-07-28-todo-plan-clears-on-next-turn.md)。 +当组合挂载了 `ctx.sessionProjections`([`@deepseek-ai/dsh-session-projection`](../../session-projection/session-projection/README.md))时,本包在一个注入式子插件下注册 `todos` 投影单元:`init` = `null`(尚无写入)、`apply` = 从每个 `todo/write` 取整表,并在每个 `turn/start` 清为 `null`(站立计划;`turn/end` 保留刚完成的清单;其余事件都返回同一个状态引用)、`view` = 恒等、`stateVersion` = 2。key 在本包合并进 `SessionProjectionMap`(经接口包的 `/types` 出口);框架驱动该单元,载体在历史尾页与 `session/projection` 推送帧上供给该值。未装注册表的组合不受影响。生命周期理由见 [下一轮清空 todo 计划条](../../../.agents/notes/implemented/feature/2026-07-28-todo-plan-clears-on-next-turn.md)。 ## 导出形状 -函数/命名空间插件:导出 `name`/`inject`/`apply`,不提供默认导出。意外的 `export default` 会被 Loader 的 `unwrapExports` 折叠为默认导出,并导致 `inject` 丢失(参见 [docs/postmortem/0001](../../../docs/postmortem/0001-acp-default-export-drops-inject.md))。 +函数/命名空间插件:导出 `name`/`inject`/`apply`,不提供默认导出。意外的 `export default` 会通过 Loader 的 `unwrapExports` 折叠模块并丢弃 `inject`(参见 [docs/postmortem/0001](../../../docs/postmortem/0001-acp-default-export-drops-inject.md))。 ## 模型体验 ### 工具 schema -#### 模型看到的内容 +#### 模型所见内容 模型会看到生成的 [`todo_write` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tool-todo)。 #### Token 影响 -工具可见的每个请求都有固定的 schema token 开销。 +工具可见的每个请求都有固定 schema 成本。 #### KV Cache 影响 -只要定义和可见性不变,前缀就保持稳定。插件生命周期或 scope 限制可能会使从此 schema 起的缓存复用失效。 +只要定义和可见性不变,前缀就保持稳定。插件生命周期或 scope 限制可能会使此 schema 之后的复用失效。 ### 工具调用历史与结果 -#### 模型看到的内容 +#### 模型所见内容 -每个 assistant 工具调用都会在参数中保留整个替换列表。成功时原样返回 `Updated todo list: pending, in progress, completed.`。稳定失败文本为 ``Error: invalid todo: `content` must be a non-empty string``、`Error: invalid todos: duplicate content ""`、`Error: invalid todos: at most one task may be in_progress, got ` 和 `Error: todo_write requires an owning agent session`。完整 `todo/write` 会话事件是 UI 与回放状态,而非第二条模型消息。 +每个 assistant 工具调用都会在参数中保留整个替换列表。成功时精确返回 `Updated todo list: pending, in progress, completed.`。稳定失败文本为 ``Error: invalid todo: `content` must be a non-empty string``、`Error: invalid todos: duplicate content ""`、`Error: invalid todos: at most one task may be in_progress, got ` 和 `Error: todo_write requires an owning agent session`。完整 `todo/write` 会话事件是 UI 与回放状态,而非第二条模型消息。 #### Token 影响 -token 用量会随模型每次提交的完整列表增长,且这些调用参数会保留到压缩(compaction)。结果本身很小,且形状固定。 +Token 增长与模型每次提交的完整列表成比例,且这些调用参数会保留到压缩(compaction)。结果本身很小,且形状固定。 #### KV Cache 影响 仅追加;新可见内容位于可复用请求前缀之后,不会使现有 KV-cache 条目失效。 -## 已知限制与暂缓事项 +## 已知限制与延后工作 -- **仅单一所有者 scope**:列表属于唯一调用 agent 会话;subagent/共享/swarm scope 是有意设置的限制(参见「单一所有者」一节),非 agent 调用方会被拒绝。 -- **条目形状有意保持最小**:`content` 加三态 `status`;整表替换不需要稳定 id、优先级或 active-form 字段。 +- **仅单一所有者 scope**:列表属于唯一调用 agent 会话;subagent/共享/swarm scope 是有意裁减(参见「单一所有者」一节),非 agent 调用方会被拒绝。 +- **项目形状有意保持最小**:`content` 加三态 `status`;整表替换不需要稳定 id、优先级或 active-form 字段。 - **整表替换是唯一操作**:没有部分更新,也没有回读工具;模型每次调用都必须重新发送完整列表。 diff --git a/packages/ui/README.i18n.yaml b/packages/ui/README.i18n.yaml index 06460978a8..0c9f3488d6 100644 --- a/packages/ui/README.i18n.yaml +++ b/packages/ui/README.i18n.yaml @@ -2,10 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/ui/README.md -<<<<<<< HEAD -README.md: f08157d411a018141cdc21c487f81ae198f4de56 -README.zh.md: ed4fbf576224a61e680fca337ac5e60829f8a90e -======= README.md: 307ae14709b59e5c233aa930f20e30c87cdbab69 README.zh.md: f02f9d902d0b61ad0299fb3fb24b97cf99c56b21 ->>>>>>> a1c6a2c3f (refactor(cli)!: one shared base config with per-surface overlays) diff --git a/packages/ui/README.zh.md b/packages/ui/README.zh.md index 9ef39eee8d..f02f9d902d 100644 --- a/packages/ui/README.zh.md +++ b/packages/ui/README.zh.md @@ -2,25 +2,21 @@ [English](README.md) | 中文 -面向用户的交互通道和进程外 SDK 服务器。这些是**产品**包(package):由用户或 SDK 客户端直接操作的真实接口。 +面向用户的交互通道和进程外 SDK 服务器。这些是**产品** 包(package):由用户或 SDK 客户端直接操作的真实接口。 | 包 | 职责 | ctx 键 | |---|---|---| | `commands/` | 用户命令注册表:共享发现元数据、作用域遮蔽、取消以及 UI 直接分派 | `ctx.commands` | | `user-approval/` | 一次性用户审批机制、封闭的结果词汇、审计事件和逐会话审批策略 | `ctx.approval` | -| `permission/` | 面向用户的权限预设(`workspace-write`/`danger-full-access`):通过一项产品级选择组合沙箱模式与审批策略两个可调参数,并写入各自的会话事件 | `ctx.permission` | +| `permission/` | 面向用户的权限预设(`workspace-write`/`danger-full-access`):用一个产品级选择器组合沙箱模式与审批策略两个调节项,并写入各自的会话事件 | `ctx.permission` | | `user-interaction/` | UI 支持的确认工具所使用的抽象用户问答 seam | `ctx.userInteraction` | | `tool-ask-user/` | 模型侧 `ask_user_question` 工具,基于 `ctx.userInteraction` 实现 | (注册到 `ctx.tools`) | | `tui/` | 交互式 pi-tui 终端通道:渲染会话标题、事件和工具意图,响应 `ctx.userInteraction`,并托管由 effect 持有的插件浮层 | `ctx.tui`(驱动 `ctx.agents`) | | `jsonrpc/` | 面向进程外 SDK 客户端的 stdio JSON-RPC 服务器 | (驱动 `ctx.agents`) | -| `app-boot/` | app bin 的共享启动粘合层:加载 `.env`、会明确报错的 Loader 保护机制、感知快照的配置解析,以及等待整棵树停稳的启动序列 | (供各 bin 使用的库) | +| `app-boot/` | app bin 的共享启动粘合层:加载 `.env`、Loader 快速失败保护、感知快照的配置解析,以及等待整棵树停稳的启动序列 | (供各 bin 使用的库) | -UI 集成属于由客户端驱动的插件,而非对循环的修改:它使用现有的 `agent/*` 事件分类和 `dsh-agent` 工厂。[`tui`](tui/README.md) 是交互式终端入口,并提供终端本地的 `ctx.tui` 扩展服务;[`jsonrpc`](jsonrpc/README.md) 为进程外 SDK 客户端提供服务,非交互式的一次性任务则使用 `cli-demo`。[`commands`](commands/README.md) 是 TUI 使用的仅面向用户的发现与分派通道;命令输入和输出不会成为模型消息。 +UI 集成属于客户端驱动插件,而非对循环的修改:它使用现有的 `agent/*` 事件分类和 `dsh-agent` 工厂。[`tui`](tui/README.md) 是交互式终端入口,并提供终端本地的 `ctx.tui` 扩展服务;[`jsonrpc`](jsonrpc/README.md) 为进程外 SDK 客户端提供服务,而非交互式单次任务使用 `cli-demo`。[`commands`](commands/README.md) 是 TUI 使用的纯用户发现与分派通道;命令输入和输出不会成为模型消息。 -`user-approval`、`user-interaction` 和 `tool-ask-user` 位于此处,因为向用户提问是由 UI 支持的产品功能,并不属于无提供方的核心主干。`user-approval` 负责一次性的 `ctx.approval` 决策机制及其策略层级;应答逻辑仍由负责 agent(智能体)的通道或自动化传输层提供。`user-interaction` 保持提供方无关(`ctx.userInteraction`),`tool-ask-user` 是其模型侧消费方,而交互式 app 包提供具体的提供方。 +`user-approval`、`user-interaction` 和 `tool-ask-user` 位于此处,因为向用户提问是由 UI 支持的产品功能,并不属于提供方无关的核心主干。`user-approval` 持有一次性的 `ctx.approval` 决策机制及其策略层级;应答方仍归拥有 agent(智能体)的通道或自动化传输层所有。`user-interaction` 保持提供方无关(`ctx.userInteraction`),`tool-ask-user` 是其模型侧消费方,而交互式 app 包提供具体实现。 -<<<<<<< HEAD -基于 [`agent-spine-demo`](../examples/agent-spine-demo/README.md) 组合的可运行 app bundle 位于 [`examples/`](../examples/README.md)(`tui-demo`、`acp-demo`、`jsonrpc-demo`)。`acp-demo` 和 `jsonrpc-demo` 各自提供启动 bin;`tui-demo` bundle 则由产品 [`dsh`](../../apps/cli/README.md) CLI(命令行界面)启动。`ui/` 保留可复用的用户/SDK 通道插件和共享 `app-boot` 粘合层;仅供自动化使用的 ACP(Agent Client Protocol)传输层位于 [`acp/`](../acp/README.md)。每个入口都负责自己的 stdout 策略,叶子 `cordis.yml` 则提供后端与可选工具。 -======= 基于 [`agent-spine-demo`](../examples/agent-spine-demo/README.md) 组合的可运行 app bundle 位于 [`examples/`](../examples/README.md)(`cli-demo`、`acp-demo`、`jsonrpc-demo`)。`acp-demo` 和 `jsonrpc-demo` 持有启动 bin;产品 [`dsh`](../../apps/cli/README.md) CLI 不使用 bundle:它启动 `apps/cli` 中的平铺 config tree。`ui/` 保留可复用的用户/SDK 通道插件和共享 `app-boot` 粘合层;仅供自动化使用的 ACP 传输层位于 [`acp/`](../acp/README.md)。每个入口都持有自己的 stdout 策略,叶子 `cordis.yml` 则提供后端与可选工具。 ->>>>>>> a1c6a2c3f (refactor(cli)!: one shared base config with per-surface overlays) From b9fb7ef6d2f2311f7956603f344b60c485da05b0 Mon Sep 17 00:00:00 2001 From: Turtle Date: Wed, 29 Jul 2026 21:21:58 +0800 Subject: [PATCH 037/113] test(agent-loop): cover launcher-owned identities --- .../tests/config-session-id.spec.ts | 22 ++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/packages/core/agent-loop/tests/config-session-id.spec.ts b/packages/core/agent-loop/tests/config-session-id.spec.ts index 761791d89f..fd41dc098a 100644 --- a/packages/core/agent-loop/tests/config-session-id.spec.ts +++ b/packages/core/agent-loop/tests/config-session-id.spec.ts @@ -11,7 +11,7 @@ import ToolRegistry from '@deepseek-ai/dsh-tools' import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent' import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl' -import AgentLoop from '@deepseek-ai/dsh-agent-loop' +import AgentLoop, { CONFIGURED_AGENT_IDENTITIES_KEY } from '@deepseek-ai/dsh-agent-loop' import { MockAdapter, textResponse } from './mock-adapter.ts' const dirs: string[] = [] @@ -36,6 +36,26 @@ async function makeCoreContext(): Promise { } describe('config-driven session id', () => { + it('applies launcher identities by configured id without changing unmatched entries', async () => { + const ctx = await makeCoreContext() + ctx.provide(CONFIGURED_AGENT_IDENTITIES_KEY, { + fresh: { id: SessionId('launcher-fresh'), resume: false }, + resumed: { id: SessionId('launcher-resumed'), resume: true }, + }) + await ctx.plugin(AgentLoop, { + agents: [ + { id: 'fresh', sessionId: SessionId('config-fresh'), model: 'mock' }, + { id: 'resumed', sessionId: SessionId('config-resumed'), model: 'mock' }, + { id: 'unchanged', sessionId: SessionId('config-unchanged'), model: 'mock' }, + ], + }) + expect(ctx.agents.get(SessionId('launcher-fresh'))?.session.id).toBe('launcher-fresh') + expect(ctx.agents.get(SessionId('launcher-resumed'))).toBeUndefined() + expect(ctx.agents.get(SessionId('config-resumed'))).toBeUndefined() + expect(ctx.agents.get(SessionId('config-unchanged'))?.session.id).toBe('config-unchanged') + await ctx.fiber.dispose() + }) + it('rejects an empty exact id before publishing an agent', async () => { const ctx = await makeCoreContext() await expect(ctx.plugin(AgentLoop, { From bddf6591115043cca21b4039789d78a02d361786 Mon Sep 17 00:00:00 2001 From: Turtle Date: Wed, 29 Jul 2026 21:23:33 +0800 Subject: [PATCH 038/113] chore: trigger CI after coverage fix From b1697ffd29e25bac67f263949f9b190793595f3a Mon Sep 17 00:00:00 2001 From: Turtle Date: Wed, 29 Jul 2026 21:28:05 +0800 Subject: [PATCH 039/113] fix(web): assemble and package the shipped overlay --- apps/cli/package.json | 4 +++- apps/cli/web.cordis.yml | 4 ++-- apps/web/tests/scaffold.ts | 13 ++++++++----- 3 files changed, 13 insertions(+), 8 deletions(-) diff --git a/apps/cli/package.json b/apps/cli/package.json index 87cbf29060..d7684e22fa 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -9,7 +9,9 @@ }, "files": [ "lib/bin.js", - "cordis.yml", + "base.cordis.yml", + "tui.cordis.yml", + "web.cordis.yml", "src" ], "license": "BSD-3-Clause", diff --git a/apps/cli/web.cordis.yml b/apps/cli/web.cordis.yml index 6a2d5df9e5..30ab13e8b2 100644 --- a/apps/cli/web.cordis.yml +++ b/apps/cli/web.cordis.yml @@ -70,10 +70,10 @@ name: '@deepseek-ai/dsh-fs-sandbox' - id: bash-local - remove: true + disabled: true - id: fs-local - remove: true + disabled: true # ── web-only host rows, the transport layer, and the browser roster ───────── diff --git a/apps/web/tests/scaffold.ts b/apps/web/tests/scaffold.ts index 753cdc2953..b942040f0b 100644 --- a/apps/web/tests/scaffold.ts +++ b/apps/web/tests/scaffold.ts @@ -1,6 +1,6 @@ // Shared scaffold for the keyless browser e2e lane (Agent Note: // .agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.md). -// Boots the REAL web composition — the shipped apps/cli/cordis.yml through +// Boots the REAL web composition — the shipped base plus web overlay through // the vendored Loader (the same include boot AppCLIEntry drives), patched the // snapshot way — so a real chromium exercises the real HTTP/SSE wire, the // api-gateway, agent loop, tools, and persistence. Modes ride $DSH_SNAPSHOT: @@ -9,7 +9,7 @@ // from live session memory), refresh (keyless replay that rewrites goldens). // // Composition divergences from `dsh web`, all deliberate, all via include -// patches over the SAME tree (never a second yml): temp persistenceRoot; +// patches after the shipped surface overlay: temp persistenceRoot; // workspace-context disabled (recorded fixtures must not embed this repo's // AGENTS.md); session-title-llm disabled (its fire-and-forget title call // would race the loop for the session's replay cursor); webserver pinned to @@ -28,7 +28,7 @@ import { Context } from 'cordis' import Loader from '@cordisjs/plugin-loader' import Include, { type PatchOptions } from '@cordisjs/plugin-include' import { scrubRequestHeaders } from '@deepseek-ai/dsh-acp-snapshot' -import { assertEntriesLoaded } from '@deepseek-ai/dsh-app-boot' +import { assertEntriesLoaded, loadOverlayPatches } from '@deepseek-ai/dsh-app-boot' import type { ReplayHandle } from '@deepseek-ai/dsh-llm-replay' import { installLlmReplay, parseSessionLog } from '@deepseek-ai/dsh-llm-replay' import SessionStore, { @@ -60,8 +60,9 @@ export function webSnapshotMode(): WebSnapshotMode { throw new Error(`DSH_SNAPSHOT must be replay, record, or refresh; got ${JSON.stringify(value)}`) } -/** The shipped composition under test: apps/cli's config tree. */ -const CONFIG_PATH = join(REPO_ROOT, 'apps/cli/cordis.yml') +/** The shipped composition under test: apps/cli's shared base and web overlay. */ +const CONFIG_PATH = join(REPO_ROOT, 'apps/cli/base.cordis.yml') +const WEB_OVERLAY_PATH = join(REPO_ROOT, 'apps/cli/web.cordis.yml') // Replay publishes the provider catalog the gateway routes to (providers // mode, never catch-all: with llm-deepseek disabled no adapter exists, so a @@ -163,7 +164,9 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise Date: Wed, 29 Jul 2026 21:30:51 +0800 Subject: [PATCH 040/113] fix(docs): parse inserted composition rows --- apps/cli/composition.md | 48 ++++++++++++++++++++++++++++++++++++--- scripts/gen-doc-graphs.ts | 44 +++++++++++++++++------------------ 2 files changed, 67 insertions(+), 25 deletions(-) diff --git a/apps/cli/composition.md b/apps/cli/composition.md index 9c0d5b5f06..0e2af8b863 100644 --- a/apps/cli/composition.md +++ b/apps/cli/composition.md @@ -96,8 +96,36 @@ flowchart LR cfg --> plugin_tui_fs_local plugin_tui_llm_deepseek["llm-deepseek
@deepseek-ai/dsh-llm-deepseek"] cfg --> plugin_tui_llm_deepseek - plugin_tui_tools["tools
@deepseek-ai/dsh-tool-ask-user"] - cfg --> plugin_tui_tools + plugin_tui_hmr["hmr
@cordisjs/plugin-hmr"] + cfg --> plugin_tui_hmr + plugin_tui_invariants["invariants
@deepseek-ai/dsh-invariants"] + cfg --> plugin_tui_invariants + plugin_tui_session_invariant["session-invariant
@deepseek-ai/dsh-session/invariant"] + cfg --> plugin_tui_session_invariant + plugin_tui_agent_invariant["agent-invariant
@deepseek-ai/dsh-agent/invariant"] + cfg --> plugin_tui_agent_invariant + plugin_tui_scope_invariant["scope-invariant
@deepseek-ai/dsh-scope/invariant"] + cfg --> plugin_tui_scope_invariant + plugin_tui_agent_loop_invariant["agent-loop-invariant
@deepseek-ai/dsh-agent-loop/invariant"] + cfg --> plugin_tui_agent_loop_invariant + plugin_tui_session_checkpoint_policy["session-checkpoint-policy
@deepseek-ai/dsh-session-checkpoint-policy"] + cfg --> plugin_tui_session_checkpoint_policy + plugin_tui_session_query_sqlite["session-query-sqlite
@deepseek-ai/dsh-session-query-sqlite"] + cfg --> plugin_tui_session_query_sqlite + plugin_tui_session_reference["session-reference
@deepseek-ai/dsh-session-reference"] + cfg --> plugin_tui_session_reference + plugin_tui_tool_result_prune["tool-result-prune
@deepseek-ai/dsh-compact-tool-result-prune"] + cfg --> plugin_tui_tool_result_prune + plugin_tui_tool_goal["tool-goal
@deepseek-ai/dsh-tool-goal"] + cfg --> plugin_tui_tool_goal + plugin_tui_tool_ralph["tool-ralph
@deepseek-ai/dsh-tool-ralph"] + cfg --> plugin_tui_tool_ralph + plugin_tui_tui_prompt["tui-prompt
@deepseek-ai/dsh-tui/prompt"] + cfg --> plugin_tui_tui_prompt + plugin_tui_tui["tui
@deepseek-ai/dsh-tui"] + cfg --> plugin_tui_tui + plugin_tui_tool_ask_user["tool-ask-user
@deepseek-ai/dsh-tool-ask-user"] + cfg --> plugin_tui_tool_ask_user ``` | Plugin id | Package / module | @@ -146,7 +174,21 @@ flowchart LR | `agent-loop` | `@deepseek-ai/dsh-agent-loop` | | `fs-local` | `@deepseek-ai/dsh-fs-local` | | `llm-deepseek` | `@deepseek-ai/dsh-llm-deepseek` | -| `tools` | `@deepseek-ai/dsh-tool-ask-user` | +| `hmr` | `@cordisjs/plugin-hmr` | +| `invariants` | `@deepseek-ai/dsh-invariants` | +| `session-invariant` | `@deepseek-ai/dsh-session/invariant` | +| `agent-invariant` | `@deepseek-ai/dsh-agent/invariant` | +| `scope-invariant` | `@deepseek-ai/dsh-scope/invariant` | +| `agent-loop-invariant` | `@deepseek-ai/dsh-agent-loop/invariant` | +| `session-checkpoint-policy` | `@deepseek-ai/dsh-session-checkpoint-policy` | +| `session-query-sqlite` | `@deepseek-ai/dsh-session-query-sqlite` | +| `session-reference` | `@deepseek-ai/dsh-session-reference` | +| `tool-result-prune` | `@deepseek-ai/dsh-compact-tool-result-prune` | +| `tool-goal` | `@deepseek-ai/dsh-tool-goal` | +| `tool-ralph` | `@deepseek-ai/dsh-tool-ralph` | +| `tui-prompt` | `@deepseek-ai/dsh-tui/prompt` | +| `tui` | `@deepseek-ai/dsh-tui` | +| `tool-ask-user` | `@deepseek-ai/dsh-tool-ask-user` | Source configs: [`apps/cli/base.cordis.yml`](base.cordis.yml), [`apps/cli/tui.cordis.yml`](tui.cordis.yml). diff --git a/scripts/gen-doc-graphs.ts b/scripts/gen-doc-graphs.ts index 54cdc11a50..642f97db66 100644 --- a/scripts/gen-doc-graphs.ts +++ b/scripts/gen-doc-graphs.ts @@ -8,6 +8,7 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs' import { dirname, relative, resolve } from 'node:path' import ts from 'typescript' +import { DEFAULT_SCHEMA, load, Type } from 'js-yaml' import { collectEvents, collectServices } from './gen-cordis-catalog.ts' import { collectPackageGraph, @@ -566,29 +567,28 @@ function renderCapabilitySeams(pkgs: Pkg[]): string { return lines.join('\n') } -function parseExampleCordis(rel: string): ExamplePlugin[] { - const text = readFileSync(resolve(root, rel), 'utf8') - const plugins: ExamplePlugin[] = [] - let current: { id: string; name?: string } | null = null - const flush = (): void => { - if (current?.name) plugins.push({ id: current.id, name: current.name }) - } - for (const line of text.split('\n')) { - const id = /^-\s+id:\s+(.+?)\s*$/.exec(line) - if (id?.[1] !== undefined) { - flush() - current = { id: stripYamlScalar(id[1]) } - continue - } - const name = /^\s+name:\s+(.+?)\s*$/.exec(line) - if (name?.[1] !== undefined && current) current.name = stripYamlScalar(name[1]) - } - flush() - return plugins -} +const jsExpressionType = new Type('tag:yaml.org,2002:js', { + kind: 'scalar', + construct: (value: string | null): string => value ?? '', +}) +const cordisSchema = DEFAULT_SCHEMA.extend([jsExpressionType]) -function stripYamlScalar(value: string): string { - return value.trim().replace(/^['"]|['"]$/g, '') +function parseExampleCordis(rel: string): ExamplePlugin[] { + const document = load(readFileSync(resolve(root, rel), 'utf8'), { schema: cordisSchema }) + if (!Array.isArray(document)) throw new Error(`${rel}: expected a top-level config array`) + const plugins: ExamplePlugin[] = [] + const visit = (entries: unknown[]): void => { + for (const value of entries) { + if (typeof value !== 'object' || value === null || Array.isArray(value)) continue + const entry = value as { id?: unknown; name?: unknown; insert?: unknown } + if (typeof entry.id === 'string' && typeof entry.name === 'string') { + plugins.push({ id: entry.id, name: entry.name }) + } + if (Array.isArray(entry.insert)) visit(entry.insert) + } + } + visit(document) + return plugins } const APP_EXAMPLES = [ From 0a2ed84665a7f00962cdf114de1023d634dbcf95 Mon Sep 17 00:00:00 2001 From: Turtle Date: Wed, 29 Jul 2026 21:33:58 +0800 Subject: [PATCH 041/113] docs(tui): explain non-strict sibling service lookup --- packages/ui/tui/src/index.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/ui/tui/src/index.ts b/packages/ui/tui/src/index.ts index a860400e2b..b1588f46f1 100644 --- a/packages/ui/tui/src/index.ts +++ b/packages/ui/tui/src/index.ts @@ -854,6 +854,9 @@ export function createTuiChat( overlayManager, // Optional and independently mounted: read at each use so config row order // cannot decide whether /resume works. + // The TUI and query provider are sibling Loader fibers. During a command + // callback Cordis may transiently mark the provider non-ACTIVE even though + // its init completed and its disposal is ordered after this consumer. sessionQuery: () => ctx.get('sessionQuery', false), ui, editor, From eac3b378fa8b8aac919403dd146e05ea8648dc60 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Wed, 29 Jul 2026 21:34:30 +0800 Subject: [PATCH 042/113] =?UTF-8?q?fix(host):=20review=20round=206=20?= =?UTF-8?q?=E2=80=94=20progressive=20selection-anchored=20landing;=20ancho?= =?UTF-8?q?r=20on=20actual=20parent=20entries;=20focus-flag=20hygiene?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...directory-picker-capability-seam.i18n.yaml | 4 +- ...-07-28-directory-picker-capability-seam.md | 2 +- ...-28-directory-picker-capability-seam.zh.md | 2 +- .../directory-picker-browse/README.i18n.yaml | 4 +- .../host/directory-picker-browse/README.md | 2 +- .../host/directory-picker-browse/README.zh.md | 2 +- .../src/client/DirectoryBrowser.module.css | 5 +- .../src/client/DirectoryBrowser.tsx | 98 ++++++++------ .../tests/directory-browser.spec.tsx | 127 +++++++++++++++--- 9 files changed, 182 insertions(+), 64 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.i18n.yaml index e270a79a11..900e1d01b6 100644 --- a/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md -2026-07-28-directory-picker-capability-seam.md: 51d71d15cb5144d555a5b156d9b108d7a2ad41b8 -2026-07-28-directory-picker-capability-seam.zh.md: 60bc14c13d1e4655faefbd5eaa63469119bf0ca3 +2026-07-28-directory-picker-capability-seam.md: ad2aa904beddb2fe941883c3c1827702dbec9964 +2026-07-28-directory-picker-capability-seam.zh.md: 30e719ad9b4e8374496106b447e961a042c7d8b6 diff --git a/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md b/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md index 51d71d15cb..ad2aa904be 100644 --- a/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md +++ b/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md @@ -20,7 +20,7 @@ Placement and policy rulings folded into this decision: - **Dependency survey (hand-roll vs adopt).** Node's stdlib *is* the maintained cross-platform OS layer (`readdir(withFileTypes)`, `homedir`, path semantics); surveyed alternatives fail the dependency bar — file-manager packages (`node-file-manager`, `files-and-folders`, Syncfusion's provider) are whole HTTP apps (fit), drive-letter helpers (`drivelist` native addon, `windows-drive-letters` ~7y stale) fail health/proportionality. The browse backend is a thin adapter over stdlib. - **Hidden entries: return-and-flag.** The host stamps `hidden` (POSIX dot convention) and returns everything; the client filters. Display policy stays client-side, and the show-hidden toggle shipped as exactly that client-only change: a fixed-label footer toggle whose state lives in the pressed presentation (`aria-pressed` + check glyph), a dot-led path-draft prefix reveals the hidden entries it names, and the current selection is exempt from both the hidden and the prefix filter (it anchors the two-pane view). Windows' `FILE_ATTRIBUTE_HIDDEN` is not exposed by dirents — documented limitation until a native probe pays for itself. - **Path-editor cancel scope: the dialog card.** The browse client's path editor cancels on Escape and on focus leaving the card, both observed at a card-scope wrapper rather than the input — after Tab parks focus on a filtered row the input is off the event path, yet Escape must collapse the editor (not the dialog) and a later focus departure must still cancel. Non-cancel exemptions: window/tab focus loss, in-card focus moves, and pointer paths (rows and the toggle suppress focus steal on mousedown while editing). Separators for seeding and draft-tail filtering are inferred from `listing.home`; the wire-field alternative below records the deferred authoritative form. Combobox semantics between the editor and the list it filters (`aria-expanded`/`aria-controls`/active-descendant, result announcements) are likewise deferred — today they read to assistive tech as separate widgets. -- **Navigation lands selection-anchored.** Away from the display root, the browse client's navigate (a crumb jump or a submitted path) lists the target's parent level with the target selected and its children on the right — two panes throughout, so a crumb jump reads as stepping back one pane rather than collapsing to a single column. The display root (home, or a rootward chain with no parent crumb) keeps the single wide level; the parent leg runs under the same supersession scope as the landing, and its failure falls back to the single-pane landing quietly — the target listed fine, and nobody asked to see the parent. +- **Navigation lands selection-anchored, progressively.** Away from the display root (the same collapse the crumb header renders, so crumbs and pane shape never disagree), the browse client's navigate commits the target level the moment it arrives — the editor closes and loading ends on that first settlement, so an Enter-submitted navigation is never withdrawn waiting on more — and a parent leg then upgrades the landing in place: the target's actual parent-level entry re-selected (platform case folding on Windows), its children on the right, so a crumb jump reads as stepping back one pane rather than collapsing to a single column. The parent leg runs under the landing's supersession scope and is aborted on the wire by any newer intent; a failed parent leg, or a truncated parent window lacking the target, leaves the committed single-pane landing — the upgrade must never orphan the selection it exists to anchor. - **Symlinks: follow for enterability.** `stat` probes symlinks (broken/cyclic → skipped); crumbs keep the logical path the operator navigated, and `workspace.create` already canonicalizes via realpath at adoption. - **Listing levels are bounded, and streamed.** One `list` call returns at most `maxEntries` rows (config, default 1000 — GitHub's web-UI directory-listing bound). The level streams via `opendir` into a name-sorted window of `maxEntries + 1` candidates, so memory stays O(maxEntries) and enterability probing touches only windowed candidates; the wire `DirectoryListing` carries a required `truncated` flag so the client states incompleteness instead of silently missing tail entries. A windowed broken symlink is not backfilled from beyond the window — the eviction already marks the level truncated. Window insertion is binary with an O(1) full-window tail rejection (an oversized level must not pay a window scan per dirent), and `list(path, signal)` threads the carrier's request signal so a scan of a stalled network directory cannot outlive a disconnected caller — every await in the scan (open, each read, each symlink probe) races the signal, an aborted exit abandons rather than awaits the close (Node queues close behind in-flight reads), and abandoned settlements are swallowed so cleanup can never surface as an unhandled rejection. An unbounded level is a memory/responsiveness hole for large or adversarial directories. - **Whole-filesystem scope, no roots config.** `workspace.create` accepts arbitrary paths and the API serves bash-driving methods, so a browse root would be UX scoping, not a boundary; configurability without a consumer fails the evidence bar. Deferred until a deployment needs it. diff --git a/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.zh.md b/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.zh.md index 60bc14c13d..30e719ad9b 100644 --- a/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.zh.md @@ -20,7 +20,7 @@ web GUI 的"打开本地文件夹"流程被焊死在一种交互上:`host.pick - **依赖调研(手写 vs 引入)。** Node 标准库本身就是维护中的跨平台 OS 层(`readdir(withFileTypes)`、`homedir`、路径语义);调研过的替代品都过不了依赖门槛——文件管理器包(`node-file-manager`、`files-and-folders`、Syncfusion 的 provider)是整套 HTTP 应用(契合度不过),盘符工具(原生插件 `drivelist`、约七年未更的 `windows-drive-letters`)健康度/比例失当。browse 后端是标准库上的薄适配。 - **隐藏条目:返回并打标。** 宿主标注 `hidden`(POSIX 点前缀约定)并返回全部条目;客户端过滤。展示策略留在客户端,"显示隐藏"开关正是作为这一纯客户端改动落地:标签固定的 footer 开关,其状态由按下态呈现承载(`aria-pressed` + 勾选符号);以点开头的路径草稿前缀会显出它所指名的隐藏条目;当前选中项则不受隐藏与前缀两种过滤影响(它锚定着双栏视图)。Windows 的 `FILE_ATTRIBUTE_HIDDEN` 不被 dirent 暴露——记为限制,直到原生探测值回其成本。 - **路径编辑器的取消范围:对话框卡片。** browse 客户端的路径编辑器在按 Escape 与焦点离开卡片时取消,两者都在卡片范围的包装层而非输入框上监听——Tab 把焦点停到某个过滤命中的行之后,输入框已不在事件路径上,但 Escape 仍须收起编辑器(而非对话框),其后的焦点离开也仍须取消。不取消的豁免:窗口/标签页失焦、卡片内焦点移动,以及指针路径(编辑期间行与开关在 mousedown 时抑制焦点夺取)。预填与草稿末段过滤所用的分隔符从 `listing.home` 推断;下文的线上字段替代方案记录了被延期的权威形态。编辑器与其过滤的列表之间的 combobox 语义(`aria-expanded`/`aria-controls`/active-descendant、结果播报)同样被延期——目前二者在辅助技术看来是彼此独立的控件。 -- **导航以选中项为锚落地。** 在展示根之外,browse 客户端的导航(crumb 跳转或提交的路径)列出目标的父层级,选中目标并在右侧展示其子项——全程双栏,因此 crumb 跳转读作后退一栏,而不是塌缩成单列。展示根(home,或没有父 crumb 的抵根链)保持单个宽层级;父层级这一程与落地共用同一 supersession 范围,其失败会静默回退到单栏落地——目标本身列举无误,本也没有人要求查看父层级。 +- **导航以选中项为锚、渐进落地。** 在展示根之外(与 crumb 头部渲染的是同一塌缩,因此 crumb 与分栏形态永不相左),browse 客户端的导航在目标层级到达的那一刻即提交它——这次首个落定即关闭编辑器并结束加载,因此 Enter 提交的导航绝不会为等待更多内容而被撤回——随后父层级这一程就地升级这次落地:重新选中目标在父层级中的实际条目(Windows 上按平台惯例折叠大小写),右侧展示其子项,因此 crumb 跳转读作后退一栏,而不是塌缩成单列。父层级这一程在落地的 supersession 范围下运行,任何较新的意图都会在线上将其中止;父层级这一程失败,或被截断的父窗口缺少目标时,都保留已提交的单栏落地——升级的存在正是为了锚定选中项,绝不能反而让它悬空。 - **符号链接:为可进入性而跟随。** 用 `stat` 探测符号链接(断链/循环→跳过);面包屑保留操作者导航的逻辑路径,`workspace.create` 在接纳时本就做 realpath 规范化。 - **列举层级有上限,且流式处理。** 单次 `list` 至多返回 `maxEntries` 行(配置项,默认 1000——GitHub 网页端目录列举的同一上限)。层级经 `opendir` 流入一个按名排序、容量 `maxEntries + 1` 的候选窗口,内存保持 O(maxEntries),可进入性探测只触及窗口内候选;线上 `DirectoryListing` 携带必填的 `truncated` 标志,让客户端明示不完整而不是静默缺尾。窗口内的断链符号链接不从窗口外回填——发生过驱逐本身已把层级标记为截断。窗口插入为二分查找、满窗尾部单次比较即拒绝(超大层级不能为每个 dirent 付出一次全窗扫描),且 `list(path, signal)` 透传载体的请求信号,滞塞网络目录的扫描不会在调用方断连后继续存活——扫描中的每个 await(打开、每次读取、每次符号链接探测)都与信号赛跑,中止路径放弃而非等待 close(Node 会把 close 排在在飞读取之后),被放弃的 settlement 全部吞掉,清理不会以未处理拒绝的形式冒出。无上限的层级对超大或恶意构造的目录就是内存/响应性漏洞。 - **全盘可浏览,不做 roots 配置。** `workspace.create` 接受任意路径且 API 本就提供驱动 bash 的方法,浏览根只会是 UX 范围而非边界;没有消费方的可配置性过不了证据门槛。等到有部署需要再做。 diff --git a/packages/host/directory-picker-browse/README.i18n.yaml b/packages/host/directory-picker-browse/README.i18n.yaml index 842e91481c..d807bd737a 100644 --- a/packages/host/directory-picker-browse/README.i18n.yaml +++ b/packages/host/directory-picker-browse/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/host/directory-picker-browse/README.md -README.md: 51e27115b5179796810c19b618b08a3523de8d10 -README.zh.md: 4caeff1c2ebfea2c12e6bb598dae2c34f2e34d84 +README.md: 23153881b84dcb71dfb05d4f297a5818c410ca77 +README.zh.md: d7010e2941a801ba6358082824330eaae46e42b7 diff --git a/packages/host/directory-picker-browse/README.md b/packages/host/directory-picker-browse/README.md index 51e27115b5..23153881b8 100644 --- a/packages/host/directory-picker-browse/README.md +++ b/packages/host/directory-picker-browse/README.md @@ -6,7 +6,7 @@ The **in-app browsing backend** of the [directory-picker seam](../directory-pick Behavior facts: listings return **directories only**, name-sorted, with symlinks-to-directories followed (broken/cyclic links skipped — the probe `stat` failing means "not enterable") and a host-owned `hidden` flag (POSIX dot convention) left for the client to act on; `crumbs` is the root-to-target ancestor chain, the root crumb labeled by its full path (`/`, `C:\`); an absent `list` path means the host account's home directory. `createDirectory` is non-recursive (a missing parent is a real failure, not a level to invent) and validates the name as a single non-blank segment even when called directly, mirroring the wire schema's fence. Both primitives reject an explicit path that is not fully qualified — relative forms, and on Windows the rooted drive-less forms (`\foo`, `/foo`) and incomplete UNC prefixes (`\\`, `\\server`) that `isAbsolute` accepts — with `directory-unreadable`/`directory-create-failed`, instead of letting `resolve` rebase it under the host process cwd or current drive. One `list` call returns at most `maxEntries` rows (config, default 1000 — the bound GitHub's web UI applies to directory listings), and the level streams through a bounded window so memory stays O(maxEntries) no matter how many children the directory holds: a cut level keeps the name-sorted head, counts hidden rows against the bound, probes only windowed candidates, and reports `truncated: true` so the client can say the level is incomplete (a windowed broken symlink is not backfilled from beyond the window — the eviction already marks the level truncated); window insertion is binary with an O(1) full-window tail rejection, and `list` threads the caller's `AbortSignal` so a disconnect or timeout stops the scan instead of letting it outlive the caller. Failures throw the seam's typed `DirectoryPickerError`. Policy rationale: [the directory-picker capability seam Agent Note](../../../.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md). -**Dual-face package**: the browser half (`./client`) fills [ui-workspace's](../../client/ui-workspace/README.md) two directory-flow holes with the in-app **Select Workspace Directory** dialog (figma `Harness` 813-23126 family — Miller two-column view whose navigations land selection-anchored: a crumb jump or a submitted path lists the target's parent level with the target selected, so stepping back keeps two panes while the display root keeps the single wide level; breadcrumb with a click-to-edit path zone whose editor seeds a trailing separator, prefix-filters the listed level from the draft's final segment while typing (case-insensitively, over the listed — possibly truncated — rows only; Enter still navigates by the exact text), and cancels on Escape or when focus leaves the dialog card (window/tab switches and in-card focus moves keep the draft); a fixed-label show-hidden footer toggle over the host's `hidden` flags, with a dot-led typed prefix revealing its matches and the current selection exempt from both filters; nested New-folder dialog), driving `host.listDirectory`/`host.createDirectory` and registering its own locale namespace (`directory-browser`, zh default / en). One cordis.yml row therefore composes both sides of the browse interaction; the client carries no capability-kind branching, and mounting a second flow package fails at load (the holes are `single` kind). +**Dual-face package**: the browser half (`./client`) fills [ui-workspace's](../../client/ui-workspace/README.md) two directory-flow holes with the in-app **Select Workspace Directory** dialog (figma `Harness` 813-23126 family — Miller two-column view whose navigations land selection-anchored: a crumb jump or a submitted path commits the target immediately, then re-selects its actual entry in its parent level once that level arrives — two panes, so stepping back never collapses (a failed or truncated parent leg keeps the single-pane landing; the display root keeps the single wide level); breadcrumb with a click-to-edit path zone whose editor seeds a trailing separator, prefix-filters the listed level from the draft's final segment while typing (case-insensitively, over the listed — possibly truncated — rows only; Enter still navigates by the exact text), and cancels on Escape or when focus leaves the dialog card (window/tab switches and in-card focus moves keep the draft); a fixed-label show-hidden footer toggle over the host's `hidden` flags, with a dot-led typed prefix revealing its matches and the current selection exempt from both filters; nested New-folder dialog), driving `host.listDirectory`/`host.createDirectory` and registering its own locale namespace (`directory-browser`, zh default / en). One cordis.yml row therefore composes both sides of the browse interaction; the client carries no capability-kind branching, and mounting a second flow package fails at load (the holes are `single` kind). ## Model Experience diff --git a/packages/host/directory-picker-browse/README.zh.md b/packages/host/directory-picker-browse/README.zh.md index 4caeff1c2e..d7010e2941 100644 --- a/packages/host/directory-picker-browse/README.zh.md +++ b/packages/host/directory-picker-browse/README.zh.md @@ -6,7 +6,7 @@ 行为事实:列举**只返回目录**、按名称排序,指向目录的符号链接会被跟随(断链/循环链接被跳过——探测 `stat` 失败即"不可进入"),并携带宿主判定的 `hidden` 标志(POSIX 点前缀约定),展示决策留给客户端;`crumbs` 是从根到目标的祖先链,根 crumb 以完整路径标注(`/`、`C:\`);`list` 不带路径即列举宿主账户的家目录。`createDirectory` 不递归(父目录缺失是真实失败,不是要补造的层级),且即便被直接调用也把名称校验为单个非空段,与协议 schema 的栅栏一致。两个原语都拒绝非完全限定的显式路径——相对形态,以及 Windows 上 `isAbsolute` 会放行的无盘符有根形态(`\foo`、`/foo`)与不完整的 UNC 前缀(`\\`、`\\server`)——报 `directory-unreadable`/`directory-create-failed`,而不是任由 `resolve` 把它重定位到宿主进程 cwd 或当前盘符之下。单次 `list` 至多返回 `maxEntries` 行(配置项,默认 1000——GitHub 网页端对目录列举采用的同一上限),且层级以流式方式经过一个有界窗口,无论目录有多少子项内存都保持 O(maxEntries):被截断的层级保留按名排序的头部、隐藏行计入上限、只探测窗口内候选,并报告 `truncated: true`,供客户端提示层级不完整(窗口内的断链符号链接不会从窗口外回填——发生过驱逐本身已把层级标记为截断);窗口插入为二分查找、满窗尾部单次比较即拒绝,且 `list` 透传调用方的 `AbortSignal`,断连或超时会停止扫描而不是让它在调用方离开后继续。失败抛出 seam 的类型化 `DirectoryPickerError`。策略依据:[目录选择能力 seam Agent Note](../../../.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md)。 -**双面包**:browser half(`./client`)以应用内 **选择工作区目录** 对话框(figma `Harness` 813-23126 家族——Miller 双列视图,其导航以选中项为锚落地:crumb 跳转或提交的路径会列出目标的父层级并选中目标,因此后退仍保持双栏,而展示根保持单个宽层级;带点击即编辑路径区的面包屑,其编辑器预填尾随分隔符、输入时以草稿末段对所列层级做前缀过滤(不区分大小写,且仅作用于已列出、可能被截断的行;Enter 仍按确切文本导航)、按 Escape 或焦点离开对话框卡片即取消(窗口/标签页切换与卡片内焦点移动保留草稿);基于宿主 `hidden` 标志、标签固定的"显示隐藏"footer 开关,键入以点开头的前缀会显出其匹配项,且当前选中项不受这两种过滤影响;嵌套新建文件夹对话框)填入 [ui-workspace](../../client/ui-workspace/README.md) 的两个目录流洞,驱动 `host.listDirectory`/`host.createDirectory`,并注册自己的 locale 命名空间(`directory-browser`,zh 默认/en)。因此一行 cordis.yml 同时组合浏览交互的两侧;client 侧不含任何能力 kind 分支,挂载第二个流程包会在加载期失败(洞为 `single` kind)。 +**双面包**:browser half(`./client`)以应用内 **选择工作区目录** 对话框(figma `Harness` 813-23126 家族——Miller 双列视图,其导航以选中项为锚落地:crumb 跳转或提交的路径会立即提交目标,待父层级到达后再在其中重新选中目标的实际条目——双栏,因此后退绝不塌缩(父层级这一程失败或被截断时保持单栏落地;展示根保持单个宽层级);带点击即编辑路径区的面包屑,其编辑器预填尾随分隔符、输入时以草稿末段对所列层级做前缀过滤(不区分大小写,且仅作用于已列出、可能被截断的行;Enter 仍按确切文本导航)、按 Escape 或焦点离开对话框卡片即取消(窗口/标签页切换与卡片内焦点移动保留草稿);基于宿主 `hidden` 标志、标签固定的"显示隐藏"footer 开关,键入以点开头的前缀会显出其匹配项,且当前选中项不受这两种过滤影响;嵌套新建文件夹对话框)填入 [ui-workspace](../../client/ui-workspace/README.md) 的两个目录流洞,驱动 `host.listDirectory`/`host.createDirectory`,并注册自己的 locale 命名空间(`directory-browser`,zh 默认/en)。因此一行 cordis.yml 同时组合浏览交互的两侧;client 侧不含任何能力 kind 分支,挂载第二个流程包会在加载期失败(洞为 `single` kind)。 ## 模型体验 diff --git a/packages/host/directory-picker-browse/src/client/DirectoryBrowser.module.css b/packages/host/directory-picker-browse/src/client/DirectoryBrowser.module.css index 2c09dd2940..85349962e5 100644 --- a/packages/host/directory-picker-browse/src/client/DirectoryBrowser.module.css +++ b/packages/host/directory-picker-browse/src/client/DirectoryBrowser.module.css @@ -261,8 +261,9 @@ } /* Show-hidden toggle: a subtle fixed-label text button left of the gap; - * the pressed state seats a check glyph before the label (Menu's selected - * vocabulary) instead of flipping the wording. */ + * the pressed state seats a check glyph after the label (Menu's selected + * vocabulary; trailing so the label never shifts) instead of flipping the + * wording. */ .showHiddenToggle { display: inline-flex; align-items: center; diff --git a/packages/host/directory-picker-browse/src/client/DirectoryBrowser.tsx b/packages/host/directory-picker-browse/src/client/DirectoryBrowser.tsx index ba0502fc65..859667d115 100644 --- a/packages/host/directory-picker-browse/src/client/DirectoryBrowser.tsx +++ b/packages/host/directory-picker-browse/src/client/DirectoryBrowser.tsx @@ -5,7 +5,10 @@ * breadcrumb, and a click-to-edit path zone; below it a Miller view — one * full-width level until a row is selected, then two columns splitting the * row evenly (256px floor; level | selected folder's children) around a - * hairline divider. Selecting in the + * hairline divider. Navigations land selection-anchored: a crumb jump or a + * submitted path commits the target immediately, then re-selects it in its + * parent level once that level arrives, so stepping back keeps two panes + * away from the display root. Selecting in the * right column shifts the view one level deeper. "New folder" opens a nested * create dialog targeting the selected folder (or the level itself) and * selects the created folder. Open adopts the selected folder, falling back @@ -215,12 +218,27 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen, }, [supersede, listDirectory]) /** - * Replace the whole view with a freshly navigated level. Away from the - * display root the landing keeps the navigated directory SELECTED inside - * its parent level (left pane = parent, right pane = its children), so a - * crumb jump or a submitted path reads as stepping back one pane instead - * of collapsing to a single column; the display root (the home level, or - * a chain with no parent) keeps the single wide level. + * Launch a follow-up listing under the CURRENT supersession seq: a newer + * intent aborts it like the leg it continues, and it supersedes nothing. + */ + const continueScan = useCallback((path: string): Promise => { + const controller = new AbortController() + scanController.current = controller + return listDirectory(path, controller.signal) + }, [listDirectory]) + + /** + * Replace the whole view with a freshly navigated level. The target level + * commits the moment it arrives (single wide level: the editor closes and + * loading ends on this first settlement, so an Enter-submitted navigation + * is never withdrawn waiting on anything further). Away from the display + * root — the same collapse the crumb header renders, so crumbs and pane + * shape never disagree — a parent leg then upgrades the landing in place: + * the target's ACTUAL parent-level entry re-selected (left pane = parent, + * right pane = the target), so a crumb jump reads as stepping back one + * pane. A failed parent leg, or a truncated parent window that lacks the + * target, leaves the committed single-pane landing — the upgrade must + * never orphan the selection it exists to anchor. */ const navigate = useCallback((path?: string) => { const { seq, scan } = launchListing(path) @@ -228,46 +246,38 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen, setError(null) scan.then((target) => { if (seq !== requestSeq.current) return + setParent(target) + setSelected(null) + setChild(null) + setLoading(false) + setPathDraft(null) + // Arity is label-independent: only the collapsed chain's depth decides. + if (displayCrumbs(target, '').length < 2) return const parentCrumb = target.crumbs.at(-2) - const anchor = target.crumbs.at(-1) - if (target.path === target.home || parentCrumb === undefined - /* v8 ignore next -- narrowing: the anchor crumb exists whenever a parent crumb does (root-to-target inclusive chain). */ - || anchor === undefined) { - setParent(target) - setSelected(null) - setChild(null) - setLoading(false) - setPathDraft(null) - return - } - // Two-pane landing: the parent leg runs under the same supersession - // scope (a newer intent aborts it like the first leg). - const controller = new AbortController() - scanController.current = controller - listDirectory(parentCrumb.path, controller.signal).then((parentLevel) => { + /* v8 ignore next -- narrowing: a two-deep display chain implies a parent crumb (root-to-target inclusive). */ + if (parentCrumb === undefined) return + continueScan(parentCrumb.path).then((parentLevel) => { if (seq !== requestSeq.current) return + // Windows resolves a typed path preserving its case; anchor on the + // parent level's actual entry so selection comparisons hold. + const sep = separatorOf(parentLevel) + const fold = (value: string): string => (sep === '\\' ? value.toLowerCase() : value) + const match = parentLevel.entries.find(entry => fold(entry.path) === fold(target.path)) + if (match === undefined) return setParent(parentLevel) - setSelected(anchor) + setSelected(match) setChild(target) - setLoading(false) - setPathDraft(null) }, () => { - if (seq !== requestSeq.current) return - // The target listed fine and is what the user asked for; a parent - // leg failure quietly falls back to the single-pane landing rather - // than surfacing an error for a level nobody requested. - setParent(target) - setSelected(null) - setChild(null) - setLoading(false) - setPathDraft(null) + // Swallows the parent-leg failure (its abort included): the + // committed single-pane landing stands, and nobody asked to see + // the parent level. }) }, (reason: unknown) => { if (seq !== requestSeq.current) return setLoading(false) setError(failureText(reason)) }) - }, [launchListing, listDirectory]) + }, [launchListing, continueScan]) // Editor-close focus parking (consumed by the refocus effect below the // miller-row ref): a pick parks on the selection's row, Enter and an @@ -302,6 +312,10 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen, // An unreadable selection cannot be the committing target while the // breadcrumb still names the level: fall back to the single pane. setSelected(null) + // Clearing the selection can unmount the very row the pick parked + // focus on (a dot-revealed hidden row re-hides); the refocus effect + // re-parks on the edit zone only if focus actually fell to body. + refocusEditZone.current = true }) }, [launchListing, pathDraft]) @@ -350,6 +364,10 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen, setPathDraft(null) setFolderDraft(null) setCreateError(null) + // A close mid-flight (failed Enter, then Cancel) may leave refocus + // flags armed; retire them so a later render cannot consume them. + refocusPick.current = false + refocusEditZone.current = false }, [open, navigate, supersede]) /** The folder a create or Open acts on: the selection, else the listed level. */ @@ -436,6 +454,9 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen, } if (refocusEditZone.current) { refocusEditZone.current = false + // Re-park only when the close actually dropped focus to body; focus + // the user parked elsewhere (a surviving row) stays theirs. + if (document.activeElement !== document.body) return const zone = editZoneRef.current /* v8 ignore next -- narrowing guard: crumb mode renders the edit zone whenever the editor just closed. */ if (zone === null) return @@ -483,8 +504,9 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen, event.stopPropagation() // Escape while the input holds focus is about to unmount it; with // focus already parked on a row, that row survives the cancel and - // keeps focus naturally. - if (document.activeElement === pathInputRef.current) refocusEditZone.current = true + // keeps focus naturally. Assignment (not a conditional set) also + // retires a stale flag a failed or still-upgrading Enter left. + refocusEditZone.current = document.activeElement === pathInputRef.current cancelPathEdit() }} // Focus leaving THIS dialog card while editing cancels like Escape. diff --git a/packages/host/directory-picker-browse/tests/directory-browser.spec.tsx b/packages/host/directory-picker-browse/tests/directory-browser.spec.tsx index ab60efcc5d..6fa9bb999f 100644 --- a/packages/host/directory-picker-browse/tests/directory-browser.spec.tsx +++ b/packages/host/directory-picker-browse/tests/directory-browser.spec.tsx @@ -194,7 +194,7 @@ describe('DirectoryBrowser', () => { expect(signals[2]?.aborted).toBe(true) }) - it('jumps back through a crumb into a fresh single-column level', async () => { + it('a crumb jump to the display root (home) lands the single wide level', async () => { mount() await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() }) fireEvent.click(rowButton(screen.getByRole('listitem'))) @@ -235,35 +235,130 @@ describe('DirectoryBrowser', () => { expect(columns()).toHaveLength(1) }) - it('drops a parent leg that settles after a newer intent, resolving or rejecting', async () => { - const settlers: { resolve: (value: DirectoryListing) => void; reject: (reason: unknown) => void }[] = [] - const listDirectory = vi.fn(async (path?: string) => { - if (path === HOME) { - return new Promise((resolve, reject) => { settlers.push({ resolve, reject }) }) + it('commits the target immediately, aborts a superseded parent leg on the wire, and drops its late resolution', async () => { + const signals: (AbortSignal | undefined)[] = [] + const settlers: ((value: DirectoryListing) => void)[] = [] + // Only the FIRST explicit HOME request (the parent leg) hangs; the later + // home crumb jump lists normally. + let homeCalls = 0 + const listDirectory = vi.fn(async (path?: string, signal?: AbortSignal) => { + signals.push(signal) + if (path === HOME && ++homeCalls === 1) { + return new Promise((resolve) => { settlers.push(resolve) }) } return listingFor(path) }) mount({ listDirectory }) await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() }) - // Enter lands the target leg; the parent leg hangs. Escape supersedes - // the landing, and the late parent RESOLUTION must change nothing. fireEvent.click(screen.getByRole('button', { name: 'browser.editPath' })) fireEvent.change(screen.getByLabelText('browser.editPath'), { target: { value: DOCS } }) fireEvent.keyDown(screen.getByLabelText('browser.editPath'), { key: 'Enter' }) + // The target leg commits at once: editor closed, single-pane DOCS level, + // while the parent leg (upgrade) is still in flight. + await waitFor(() => { expect(screen.getByRole('listitem').textContent).toBe('harness') }) + expect(screen.queryByLabelText('browser.editPath', { selector: 'input' })).toBeNull() + expect(columns()).toHaveLength(1) await waitFor(() => { expect(settlers).toHaveLength(1) }) - fireEvent.keyDown(screen.getByLabelText('browser.editPath'), { key: 'Escape' }) - await act(async () => { settlers[0]!.resolve(listingFor(HOME)) }) + // A newer jump aborts the pending parent leg ON THE WIRE, not merely + // dropping its settlement. + fireEvent.click(screen.getByRole('button', { name: 'browser.home' })) + expect(signals[2]?.aborted).toBe(true) + await waitFor(() => { expect(screen.getByRole('listitem').textContent).toBe('Documents') }) + // Its late resolution changes nothing either. + await act(async () => { settlers[0]!(listingFor(HOME)) }) expect(columns()).toHaveLength(1) - expect(screen.getByRole('listitem').textContent).toBe('Documents') - // Same shape, late parent REJECTION: equally silent. + expect(rowButton(screen.getByRole('listitem')).getAttribute('aria-current')).toBeNull() + }) + + it('keeps the single-pane landing when the truncated parent level lacks the target', async () => { + const listDirectory = vi.fn(async (path?: string) => { + // The parent leg names HOME explicitly; serve it a truncated window + // that misses Documents (the initial open uses the absent-path form). + if (path === HOME) return { ...listingFor(HOME), entries: [], truncated: true } + return listingFor(path) + }) + mount({ listDirectory }) + await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() }) fireEvent.click(screen.getByRole('button', { name: 'browser.editPath' })) fireEvent.change(screen.getByLabelText('browser.editPath'), { target: { value: DOCS } }) fireEvent.keyDown(screen.getByLabelText('browser.editPath'), { key: 'Enter' }) - await waitFor(() => { expect(settlers).toHaveLength(2) }) - fireEvent.keyDown(screen.getByLabelText('browser.editPath'), { key: 'Escape' }) - await act(async () => { settlers[1]!.reject(new Error('late')) }) + await waitFor(() => { expect(screen.getByRole('listitem').textContent).toBe('harness') }) + // The upgrade would orphan the selection (no source row): it stays off. + await act(async () => {}) expect(columns()).toHaveLength(1) - expect(screen.queryByRole('alert')).toBeNull() + expect(screen.queryByText('browser.truncated')).toBeNull() + }) + + it('anchors the upgrade on the parent level actual entry under Windows case folding', async () => { + const ROOT = 'C:\\' + const TYPED = 'c:\\users' + const winRoot: DirectoryListing = { + path: ROOT, + home: ROOT, + crumbs: [{ name: 'C:\\', path: ROOT, hidden: false }], + entries: [{ name: 'Users', path: 'C:\\Users', hidden: false }], + truncated: false, + } + const winUsers: DirectoryListing = { + path: TYPED, + home: ROOT, + crumbs: [{ name: 'C:\\', path: ROOT, hidden: false }, { name: 'users', path: TYPED, hidden: false }], + entries: [], + truncated: false, + } + mount({ listDirectory: vi.fn(async (path?: string) => (path === TYPED ? winUsers : winRoot)) }) + await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() }) + fireEvent.click(screen.getByRole('button', { name: 'browser.editPath' })) + fireEvent.change(screen.getByLabelText('browser.editPath'), { target: { value: TYPED } }) + fireEvent.keyDown(screen.getByLabelText('browser.editPath'), { key: 'Enter' }) + // The typed case differs from the real entry; the upgrade selects the + // parent level's ACTUAL entry so aria-current and exemptions hold. + await waitFor(() => { + expect(rowButton(within(columns()[0]!).getByRole('listitem')).getAttribute('aria-current')).toBe('true') + }) + expect(within(columns()[0]!).getByText('Users')).toBeTruthy() + }) + + it('re-parks focus on the edit zone when a failed pick unmounts a dot-revealed row', async () => { + const listDirectory = vi.fn(async (path?: string) => { + if (path === `${HOME}/.config`) { + throw new DirectoryBrowseError({ code: 'directory-unreadable', message: 'denied', details: { path } }) + } + return listingFor(path) + }) + mount({ listDirectory }) + await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() }) + fireEvent.click(screen.getByRole('button', { name: 'browser.editPath' })) + fireEvent.change(screen.getByLabelText('browser.editPath'), { target: { value: `${HOME}/.co` } }) + const row = rowButton(screen.getByRole('listitem')) + fireEvent.mouseDown(row) + fireEvent.click(row) + // The failed selection re-hides the picked row; focus fell to body and + // re-parks on the crumb edit zone. + await screen.findByRole('alert') + expect(screen.queryByText('.config')).toBeNull() + expect(document.activeElement).toBe(screen.getByRole('button', { name: 'browser.editPath' })) + }) + + it('leaves focus on a surviving row when its pick fails', async () => { + const listDirectory = vi.fn(async (path?: string) => { + if (path === DOCS) { + throw new DirectoryBrowseError({ code: 'directory-unreadable', message: 'denied', details: { path } }) + } + return listingFor(path) + }) + mount({ listDirectory }) + await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() }) + fireEvent.click(screen.getByRole('button', { name: 'browser.editPath' })) + fireEvent.change(screen.getByLabelText('browser.editPath'), { target: { value: `${HOME}/do` } }) + const row = rowButton(screen.getByRole('listitem')) + row.focus() + fireEvent.mouseDown(row) + fireEvent.click(row) + // Documents survives the cleared selection (it is not hidden): the + // user's focus on it is not yanked to the edit zone. + await screen.findByRole('alert') + expect(document.activeElement).toBe(row) }) it('falls back to the single-pane landing when the parent leg of a navigation fails', async () => { From b7401d064acef5a3233a35f2be8756696a75da12 Mon Sep 17 00:00:00 2001 From: Turtle Date: Wed, 29 Jul 2026 21:36:07 +0800 Subject: [PATCH 043/113] docs(ui): drop unrelated translation churn --- packages/ui/README.i18n.yaml | 4 ++-- packages/ui/README.md | 2 +- packages/ui/README.zh.md | 12 ++++++------ 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/packages/ui/README.i18n.yaml b/packages/ui/README.i18n.yaml index 0c9f3488d6..7a6175f898 100644 --- a/packages/ui/README.i18n.yaml +++ b/packages/ui/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/ui/README.md -README.md: 307ae14709b59e5c233aa930f20e30c87cdbab69 -README.zh.md: f02f9d902d0b61ad0299fb3fb24b97cf99c56b21 +README.md: f08157d411a018141cdc21c487f81ae198f4de56 +README.zh.md: ed4fbf576224a61e680fca337ac5e60829f8a90e diff --git a/packages/ui/README.md b/packages/ui/README.md index 307ae14709..f08157d411 100644 --- a/packages/ui/README.md +++ b/packages/ui/README.md @@ -19,4 +19,4 @@ A UI integration is a client-driver plugin, not a loop change: it consumes the e `user-approval`, `user-interaction`, and `tool-ask-user` live here because asking a human is a UI-backed product affordance, not part of the providerless core spine. `user-approval` owns the one-shot `ctx.approval` decision mechanism and its policy tier; answerers remain with the channel or automation transport that owns the agent. `user-interaction` remains provider-neutral (`ctx.userInteraction`), while `tool-ask-user` is its model-facing consumer and interactive app packages provide concrete providers. -The runnable app bundles composed over [`agent-spine-demo`](../examples/agent-spine-demo/README.md) live in [`examples/`](../examples/README.md) (`cli-demo`, `acp-demo`, `jsonrpc-demo`). `acp-demo` and `jsonrpc-demo` own boot bins; The product [`dsh`](../../apps/cli/README.md) CLI uses no bundle: it boots the flat config trees in `apps/cli`. `ui/` keeps the reusable human/SDK channel plugins and shared `app-boot` glue; the automation-only ACP transport lives in [`acp/`](../acp/README.md). Each front door owns its stdout policy, and a leaf `cordis.yml` supplies backends and optional tools. +The runnable app bundles composed over [`agent-spine-demo`](../examples/agent-spine-demo/README.md) live in [`examples/`](../examples/README.md) (`tui-demo`, `acp-demo`, `jsonrpc-demo`). `acp-demo` and `jsonrpc-demo` own boot bins; the `tui-demo` bundle is booted by the product [`dsh`](../../apps/cli/README.md) CLI. `ui/` keeps the reusable human/SDK channel plugins and shared `app-boot` glue; the automation-only ACP transport lives in [`acp/`](../acp/README.md). Each front door owns its stdout policy, and a leaf `cordis.yml` supplies backends and optional tools. diff --git a/packages/ui/README.zh.md b/packages/ui/README.zh.md index f02f9d902d..ed4fbf5762 100644 --- a/packages/ui/README.zh.md +++ b/packages/ui/README.zh.md @@ -2,21 +2,21 @@ [English](README.md) | 中文 -面向用户的交互通道和进程外 SDK 服务器。这些是**产品** 包(package):由用户或 SDK 客户端直接操作的真实接口。 +面向用户的交互通道和进程外 SDK 服务器。这些是**产品**包(package):由用户或 SDK 客户端直接操作的真实接口。 | 包 | 职责 | ctx 键 | |---|---|---| | `commands/` | 用户命令注册表:共享发现元数据、作用域遮蔽、取消以及 UI 直接分派 | `ctx.commands` | | `user-approval/` | 一次性用户审批机制、封闭的结果词汇、审计事件和逐会话审批策略 | `ctx.approval` | -| `permission/` | 面向用户的权限预设(`workspace-write`/`danger-full-access`):用一个产品级选择器组合沙箱模式与审批策略两个调节项,并写入各自的会话事件 | `ctx.permission` | +| `permission/` | 面向用户的权限预设(`workspace-write`/`danger-full-access`):通过一项产品级选择组合沙箱模式与审批策略两个可调参数,并写入各自的会话事件 | `ctx.permission` | | `user-interaction/` | UI 支持的确认工具所使用的抽象用户问答 seam | `ctx.userInteraction` | | `tool-ask-user/` | 模型侧 `ask_user_question` 工具,基于 `ctx.userInteraction` 实现 | (注册到 `ctx.tools`) | | `tui/` | 交互式 pi-tui 终端通道:渲染会话标题、事件和工具意图,响应 `ctx.userInteraction`,并托管由 effect 持有的插件浮层 | `ctx.tui`(驱动 `ctx.agents`) | | `jsonrpc/` | 面向进程外 SDK 客户端的 stdio JSON-RPC 服务器 | (驱动 `ctx.agents`) | -| `app-boot/` | app bin 的共享启动粘合层:加载 `.env`、Loader 快速失败保护、感知快照的配置解析,以及等待整棵树停稳的启动序列 | (供各 bin 使用的库) | +| `app-boot/` | app bin 的共享启动粘合层:加载 `.env`、会明确报错的 Loader 保护机制、感知快照的配置解析,以及等待整棵树停稳的启动序列 | (供各 bin 使用的库) | -UI 集成属于客户端驱动插件,而非对循环的修改:它使用现有的 `agent/*` 事件分类和 `dsh-agent` 工厂。[`tui`](tui/README.md) 是交互式终端入口,并提供终端本地的 `ctx.tui` 扩展服务;[`jsonrpc`](jsonrpc/README.md) 为进程外 SDK 客户端提供服务,而非交互式单次任务使用 `cli-demo`。[`commands`](commands/README.md) 是 TUI 使用的纯用户发现与分派通道;命令输入和输出不会成为模型消息。 +UI 集成属于由客户端驱动的插件,而非对循环的修改:它使用现有的 `agent/*` 事件分类和 `dsh-agent` 工厂。[`tui`](tui/README.md) 是交互式终端入口,并提供终端本地的 `ctx.tui` 扩展服务;[`jsonrpc`](jsonrpc/README.md) 为进程外 SDK 客户端提供服务,非交互式的一次性任务则使用 `cli-demo`。[`commands`](commands/README.md) 是 TUI 使用的仅面向用户的发现与分派通道;命令输入和输出不会成为模型消息。 -`user-approval`、`user-interaction` 和 `tool-ask-user` 位于此处,因为向用户提问是由 UI 支持的产品功能,并不属于提供方无关的核心主干。`user-approval` 持有一次性的 `ctx.approval` 决策机制及其策略层级;应答方仍归拥有 agent(智能体)的通道或自动化传输层所有。`user-interaction` 保持提供方无关(`ctx.userInteraction`),`tool-ask-user` 是其模型侧消费方,而交互式 app 包提供具体实现。 +`user-approval`、`user-interaction` 和 `tool-ask-user` 位于此处,因为向用户提问是由 UI 支持的产品功能,并不属于无提供方的核心主干。`user-approval` 负责一次性的 `ctx.approval` 决策机制及其策略层级;应答逻辑仍由负责 agent(智能体)的通道或自动化传输层提供。`user-interaction` 保持提供方无关(`ctx.userInteraction`),`tool-ask-user` 是其模型侧消费方,而交互式 app 包提供具体的提供方。 -基于 [`agent-spine-demo`](../examples/agent-spine-demo/README.md) 组合的可运行 app bundle 位于 [`examples/`](../examples/README.md)(`cli-demo`、`acp-demo`、`jsonrpc-demo`)。`acp-demo` 和 `jsonrpc-demo` 持有启动 bin;产品 [`dsh`](../../apps/cli/README.md) CLI 不使用 bundle:它启动 `apps/cli` 中的平铺 config tree。`ui/` 保留可复用的用户/SDK 通道插件和共享 `app-boot` 粘合层;仅供自动化使用的 ACP 传输层位于 [`acp/`](../acp/README.md)。每个入口都持有自己的 stdout 策略,叶子 `cordis.yml` 则提供后端与可选工具。 +基于 [`agent-spine-demo`](../examples/agent-spine-demo/README.md) 组合的可运行 app bundle 位于 [`examples/`](../examples/README.md)(`tui-demo`、`acp-demo`、`jsonrpc-demo`)。`acp-demo` 和 `jsonrpc-demo` 各自提供启动 bin;`tui-demo` bundle 则由产品 [`dsh`](../../apps/cli/README.md) CLI(命令行界面)启动。`ui/` 保留可复用的用户/SDK 通道插件和共享 `app-boot` 粘合层;仅供自动化使用的 ACP(Agent Client Protocol)传输层位于 [`acp/`](../acp/README.md)。每个入口都负责自己的 stdout 策略,叶子 `cordis.yml` 则提供后端与可选工具。 From adacacaa4f8aa83431c855cf9275aa058e756ece Mon Sep 17 00:00:00 2001 From: Turtle Date: Wed, 29 Jul 2026 21:37:18 +0800 Subject: [PATCH 044/113] docs(client): point registration at web overlay --- packages/client/AGENTS.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/client/AGENTS.md b/packages/client/AGENTS.md index 02e453415a..964f463841 100644 --- a/packages/client/AGENTS.md +++ b/packages/client/AGENTS.md @@ -86,7 +86,7 @@ If `test:gui` is red on code you did not touch, neither silently fix nor ignore Bringing up a new `packages/client/` plugin package (ui-workspace is the latest walked example; ui-sidebar/ui-question are good skeletons to copy): 1. **Package skeleton**: `package.json` (`@deepseek-ai/dsh-client-`, exports `.`/`./invariant`/`./client`/`./src/*`/`./package.json`, `dshClient` manifest, `files` list), `tsconfig.json` (extends `tsconfig.base.client.json`, one `references` entry per workspace dependency plus `support/invariants`), `tsdown.config.ts` (`clientBundle(id, ['lib/types/index.js', 'lib/types/invariant.js'])`), `src/index.ts` (empty node-half apply), `src/invariant.ts` (companion with a real reason), `src/css-modules.d.ts` when using CSS Modules, `README.md` with the Model Experience section. -2. **Three registration surfaces, all required** (missing any one fails at a different, later point): the `tsconfig.client.json` aggregate `references` entry; a `dshClient` row in `apps/cli/base.cordis.yml`; an `apps/cli/package.json` dependency (Loader resolves each config-tree package against the composing app's URL — a row whose package is not an `apps/cli` dependency fails to import). `pnpm-workspace.yaml` already globs `packages/*/*`. +2. **Three registration surfaces, all required** (missing any one fails at a different, later point): the `tsconfig.client.json` aggregate `references` entry; a `dshClient` row in `apps/cli/web.cordis.yml`; an `apps/cli/package.json` dependency (Loader resolves each config-tree package against the composing app's URL — a row whose package is not an `apps/cli` dependency fails to import). `pnpm-workspace.yaml` already globs `packages/*/*`. 3. **dshClient manifest semantics**: `platform: 'web'` always; `immediately: true` only for stage-one-prefetch infrastructure rows. `inject` lists package-name dependency edges — they are **informational only** (preflight display, HMR diffing); they do not sequence entry activation or apply order. Activation order is cordis fiber inject waiting on *services*, nothing else. 4. **Registering into another package's slot**: if the declaring host provides no waitable service, your apply's order relative to the host's is unconstrained — a bare `slots.register` into its slot races boot (intermittent `slot "..." is not declared` page failures). Register with declaration-aware deferral: check `ctx.slots.spec(name)`, otherwise `ctx.slots.subscribe(name)` and register on the declaration event (SlotCore supports subscribing ahead of declaration); make the registration idempotent, and unsubscribe + dispose in the effect disposer. Only take a service edge in `inject` when the host actually provides one (ui-question → `'conversation'` is that case). 5. Rebuild the bundle (`pnpm --filter bundle`) before probing a live `dsh web` server — the registry serves `lib/client.js`, not sources. From 7135703a5ee6eb11d4cae49287d80c0252565ad1 Mon Sep 17 00:00:00 2001 From: Turtle Date: Wed, 29 Jul 2026 21:38:03 +0800 Subject: [PATCH 045/113] fix(gates): scope CLI dependencies to shipped trees --- scripts/verify-cordis-config.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/scripts/verify-cordis-config.ts b/scripts/verify-cordis-config.ts index 9a188a5c75..d0a0aa53a5 100644 --- a/scripts/verify-cordis-config.ts +++ b/scripts/verify-cordis-config.ts @@ -129,7 +129,12 @@ function validateExampleResolution(): string[] { function validateAppResolution(): string[] { const dependencies = readManifest('apps/cli/package.json').dependencies ?? {} - const references = pluginReferences.filter(reference => reference.file.startsWith('apps/cli/')) + const shipped = new Set([ + 'apps/cli/base.cordis.yml', + 'apps/cli/tui.cordis.yml', + 'apps/cli/web.cordis.yml', + ]) + const references = pluginReferences.filter(reference => shipped.has(reference.file)) return missingPluginDependencies(references, dependencies, 'apps/cli/package.json') } From d8e4fd3b7502c5ba3edcc88171656edcafc95dca Mon Sep 17 00:00:00 2001 From: Turtle Date: Wed, 29 Jul 2026 21:39:38 +0800 Subject: [PATCH 046/113] fix(cli): align meta invocation contract --- apps/cli/src/args.ts | 12 +++--------- 1 file changed, 3 insertions(+), 9 deletions(-) diff --git a/apps/cli/src/args.ts b/apps/cli/src/args.ts index e470af1299..038ac96af4 100644 --- a/apps/cli/src/args.ts +++ b/apps/cli/src/args.ts @@ -29,15 +29,9 @@ interface HeadlessInvocation { prompt: string } -/** - * Interactive TUI over this harness checkout: `dsh meta`. Identical to - * {@link TuiInvocation} except the workspace is the launcher's own source tree - * rather than the invoking directory. No `--config`: booting a foreign tree - * against the harness workspace is the `--config` case, not this one. - */ +/** Interactive fresh TUI over this harness checkout; accepts no default-surface options. */ interface MetaInvocation { mode: 'meta' - resume?: string } /** @@ -168,13 +162,13 @@ Examples: // Commander parses the parent (default-surface) options on either side of a // subcommand into `program.opts()`. For a subcommand that shares none of them, - // a leaked `--config`/`-p`/`--resume` is a mistyped invocation that must fail + // a leaked config/prompt/resume option is a mistyped invocation that must fail // loud rather than silently run and drop the input. const rejectParentOptions = (command: string): void => { const parent = program.opts<{ config?: string; configReplace?: string; prompt?: string; resume?: string }>() if (parent.config !== undefined || parent.configReplace !== undefined || parent.prompt !== undefined || parent.resume !== undefined) { - program.error(`error: ${command} takes none of --config, -p/--prompt, or --resume`) + program.error(`error: ${command} takes none of --config, --config-replace, -p/--prompt, or --resume`) } } From ff126751d87c08331b3356ecdb063d5c1ebf2e65 Mon Sep 17 00:00:00 2001 From: Turtle Date: Wed, 29 Jul 2026 21:41:51 +0800 Subject: [PATCH 047/113] refactor(cli): keep migrate out of scope --- apps/cli/README.i18n.yaml | 4 +-- apps/cli/README.md | 4 +-- apps/cli/README.zh.md | 4 +-- apps/cli/src/args.ts | 31 ++++++++------------ apps/cli/src/bin.ts | 1 - apps/cli/src/tui.ts | 4 +-- apps/cli/tests/args.spec.ts | 7 +---- skills/dsh-migrate/SKILL.md | 57 ------------------------------------- 8 files changed, 21 insertions(+), 91 deletions(-) delete mode 100644 skills/dsh-migrate/SKILL.md diff --git a/apps/cli/README.i18n.yaml b/apps/cli/README.i18n.yaml index 6ef7959159..80c388280b 100644 --- a/apps/cli/README.i18n.yaml +++ b/apps/cli/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write apps/cli/README.md -README.md: 2589462dfb5fc600b0e480a5a41c32860bf6837d -README.zh.md: 3b5e5319b76d9b18b2719cc8e943faaf398c51af +README.md: 7cde0dd8c9c6cf794cf8d1676ed6938e204117cf +README.zh.md: 3b21d563cbd8458810cd05f1ef71bd88074f294f diff --git a/apps/cli/README.md b/apps/cli/README.md index 2589462dfb..7cde0dd8c9 100644 --- a/apps/cli/README.md +++ b/apps/cli/README.md @@ -3,7 +3,7 @@ English | [中文](README.zh.md) -Argv is parsed once through a [Commander](https://github.com/tj/commander.js) adapter ([`src/args.ts`](src/args.ts)): one program whose default (no subcommand) is the TUI/headless surface (`--config`, `-p`/`--prompt`, `--resume`), whose `meta` subcommand is the same TUI over this checkout, whose `migrate`/`upgrade` subcommands are option-less guided-session entries, and whose `web` subcommand is the browser UI. `src/bin.ts` switches on the resolved mode and dynamic-imports only that mode's module. `dsh --help` lists every mode and `dsh web --help` renders the web usage, `dsh --version` prints this app's version, and an unknown option or a mistyped `--resume` fails loud (stderr, exit 1) instead of misrouting. Every subcommand that shares no option with the default surface — `migrate`, `upgrade`, `web` — rejects a leaked `--config`/`-p`/`--resume` rather than running and dropping it. `dsh web`'s `--host`/`--port` are unvalidated pass-through overrides: the `dsh-host-webserver` schema is the single source of both the default (the shipped `cordis.yml` value when a flag is absent) and validity, and rejects a bad value at boot. `--trusted-host` appends named authorities for the /api browser-trust fence; an all-interfaces bind additionally derives the machine's LAN IP literals itself ([`src/app-cli-entry.ts`](src/app-cli-entry.ts)), so the printed LAN URL works without flags. +Argv is parsed once through a [Commander](https://github.com/tj/commander.js) adapter ([`src/args.ts`](src/args.ts)): one program whose default (no subcommand) is the TUI/headless surface (`--config`, `-p`/`--prompt`, `--resume`), whose `meta` subcommand is the same TUI over this checkout, whose `upgrade` subcommands are option-less guided-session entries, and whose `web` subcommand is the browser UI. `src/bin.ts` switches on the resolved mode and dynamic-imports only that mode's module. `dsh --help` lists every mode and `dsh web --help` renders the web usage, `dsh --version` prints this app's version, and an unknown option or a mistyped `--resume` fails loud (stderr, exit 1) instead of misrouting. Every subcommand that shares no option with the default surface — `migrate`, `upgrade`, `web` — rejects a leaked `--config`/`-p`/`--resume` rather than running and dropping it. `dsh web`'s `--host`/`--port` are unvalidated pass-through overrides: the `dsh-host-webserver` schema is the single source of both the default (the shipped `cordis.yml` value when a flag is absent) and validity, and rejects a bad value at boot. `--trusted-host` appends named authorities for the /api browser-trust fence; an all-interfaces bind additionally derives the machine's LAN IP literals itself ([`src/app-cli-entry.ts`](src/app-cli-entry.ts)), so the printed LAN URL works without flags. The TUI surface: @@ -15,7 +15,7 @@ The TUI surface: `dsh meta` is that same TUI with this harness checkout as the workspace, so working on dsh itself needs no `cd`. It chdirs to the checkout root — resolved from the launcher's real path, the same root the source-path prompt section names — after both `.env` layers are loaded, so environment precedence is unchanged while the session cwd and HMR watch root move together. Meta always starts a fresh session and accepts no default-surface options; use ordinary `dsh --resume ` to resume a persisted session. -`dsh migrate` and `dsh upgrade` are guided fresh-session entries over the default TUI surface: each mints a fresh session in the invoking directory and seeds its first turn with a bundled skill (`dsh-migrate` for migrating from another coding agent — opencode, pi, Claude Code, Codex; `dsh-upgrade` for upgrading this checkout), exactly as if the user typed `/skill:`. The launcher passes the skill name on the boot context ([`INITIAL_SKILL_KEY`](../../packages/ui/tui/README.md)), which the TUI auto-invokes once the chat is live. Both take no options — `--config`, `-p`, and `--resume` fail loud — and seed only on this first launch, so a later `dsh --resume ` of the session is an ordinary TUI session with no re-injection. +`dsh upgrade` is a guided fresh-session entry over the default TUI surface: it mints a fresh session in the invoking directory and seeds its first turn with the bundled `dsh-upgrade` skill, exactly as if the user typed `/skill:`. The launcher passes the skill name on the boot context ([`INITIAL_SKILL_KEY`](../../packages/ui/tui/README.md)), which the TUI auto-invokes once the chat is live. Both take no options — `--config`, `-p`, and `--resume` fail loud — and seed only on this first launch, so a later `dsh --resume ` of the session is an ordinary TUI session with no re-injection. The Web and headless surfaces boot `base.cordis.yml` plus `web.cordis.yml`; `dsh web --config ` adds an overlay after the web surface defaults. Both surfaces otherwise share the same composition: both treat the invoking directory as the default project and Workspace root, create named Workspaces beneath that root unless `--workspace-root ` overrides it, load applicable `AGENTS.md`/`CLAUDE.md` instructions into each agent-loop request prefix with a 65,536-byte render budget, and opt into first-message model titles. Headless differs only in listening on an OS-assigned port (parallel `dsh -p` runs never collide; the stderr-printed URL opens the live session in a browser). Both need the frontend dist and client bundles built (`pnpm run build && pnpm run build:web`). diff --git a/apps/cli/README.zh.md b/apps/cli/README.zh.md index 3b5e5319b7..3b21d563cb 100644 --- a/apps/cli/README.zh.md +++ b/apps/cli/README.zh.md @@ -3,7 +3,7 @@ [English](README.md) | 中文 -Argv 只会通过 [Commander](https://github.com/tj/commander.js) 适配器([`src/args.ts`](src/args.ts))解析一次:同一个程序的默认形式(无子命令)是 TUI/无头界面(`--config`、`-p`/`--prompt`、`--resume`),`meta` 子命令是以本 checkout 为 workspace 的同一个 TUI,`migrate`/`upgrade` 子命令是无选项的引导会话入口,`web` 子命令则是浏览器 UI。`src/bin.ts` 按解析后的 mode 分支,仅动态导入该 mode 的模块。`dsh --help` 列出所有 mode,`dsh web --help` 渲染 Web 用法,`dsh --version` 打印此应用的版本;未知选项或拼错的 `--resume` 会明确报错(stderr,退出码 1),而不会被错路由。凡与默认界面不共享任何选项的子命令(`migrate`、`upgrade`、`web`)都会拒绝泄漏进来的 `--config`/`-p`/`--resume`,而不会照常运行并丢弃它。`dsh web` 的 `--host`/`--port` 是未验证的直通覆盖:`dsh-host-webserver` schema 是默认值(标志缺失时使用已交付的 `cordis.yml` 值)和有效性的唯一真源,并在启动时拒绝错误值。`--trusted-host` 为 /api 浏览器信任栅栏追加具名权威;全接口绑定还会自行推导本机的 LAN IP 字面量([`src/app-cli-entry.ts`](src/app-cli-entry.ts)),因此打印出的 LAN URL 无需任何标志即可使用。 +Argv 只会通过 [Commander](https://github.com/tj/commander.js) 适配器([`src/args.ts`](src/args.ts))解析一次:同一个程序的默认形式(无子命令)是 TUI/无头界面(`--config`、`-p`/`--prompt`、`--resume`),`meta` 子命令是以本 checkout 为 workspace 的同一个 TUI,`upgrade` 子命令是无选项的引导会话入口,`web` 子命令则是浏览器 UI。`src/bin.ts` 按解析后的 mode 分支,仅动态导入该 mode 的模块。`dsh --help` 列出所有 mode,`dsh web --help` 渲染 Web 用法,`dsh --version` 打印此应用的版本;未知选项或拼错的 `--resume` 会明确报错(stderr,退出码 1),而不会被错路由。凡与默认界面不共享任何选项的子命令(`migrate`、`upgrade`、`web`)都会拒绝泄漏进来的 `--config`/`-p`/`--resume`,而不会照常运行并丢弃它。`dsh web` 的 `--host`/`--port` 是未验证的直通覆盖:`dsh-host-webserver` schema 是默认值(标志缺失时使用已交付的 `cordis.yml` 值)和有效性的唯一真源,并在启动时拒绝错误值。`--trusted-host` 为 /api 浏览器信任栅栏追加具名权威;全接口绑定还会自行推导本机的 LAN IP 字面量([`src/app-cli-entry.ts`](src/app-cli-entry.ts)),因此打印出的 LAN URL 无需任何标志即可使用。 TUI 界面: @@ -15,7 +15,7 @@ TUI 界面: `dsh meta` 是以本 harness checkout 为 workspace 的同一个 TUI,因此开发 dsh 自身无需 `cd`。它在两层 `.env` 都加载之后才 chdir 到 checkout 根目录(从启动器的真实路径解析,与源码路径提示词段所指的根目录相同),因此环境优先级不变,而会话 cwd 与 HMR 监视根目录会一并移动。Meta 始终创建新会话,不接受默认界面的任何选项;恢复已持久化会话应使用普通的 `dsh --resume `。 -`dsh migrate` 与 `dsh upgrade` 是默认 TUI 界面之上的引导式全新会话入口:各自在调用目录中创建一个全新会话,并以一个内置 skill 播种其首轮(`dsh-migrate` 用于从其他编码 agent 迁移——opencode、pi、Claude Code、Codex;`dsh-upgrade` 用于升级本 checkout),效果等同于用户手动键入 `/skill:`。启动器将 skill 名称提供到启动上下文([`INITIAL_SKILL_KEY`](../../packages/ui/tui/README.md)),TUI 在聊天就绪后自动调用它。两者都不接受任何选项——`--config`、`-p`、`--resume` 都会明确报错——且仅在首次启动时播种,因此之后 `dsh --resume ` 恢复该会话时是普通 TUI 会话,不会重复注入。 +`dsh upgrade` 是默认 TUI 界面之上的引导式全新会话入口:它在调用目录中创建一个全新会话,并以内置 `dsh-upgrade` skill 播种其首轮,效果等同于用户手动键入 `/skill:`。启动器将 skill 名称提供到启动上下文([`INITIAL_SKILL_KEY`](../../packages/ui/tui/README.md)),TUI 在聊天就绪后自动调用它。两者都不接受任何选项——`--config`、`-p`、`--resume` 都会明确报错——且仅在首次启动时播种,因此之后 `dsh --resume ` 恢复该会话时是普通 TUI 会话,不会重复注入。 Web 和无头界面启动 `base.cordis.yml` 与 `web.cordis.yml`;`dsh web --config ` 会在 Web 界面默认值之后追加一个覆盖。除此之外,两者共享同一套组合:两者都将调用目录视为默认项目和 Workspace 根目录,除非通过 `--workspace-root ` 覆盖,否则会在该根目录下创建具名 Workspace;它们会把适用的 `AGENTS.md`/`CLAUDE.md` 指令加载到每个 agent-loop 请求前缀中,渲染预算为 65,536 字节,并选用首条消息模型标题。无头界面唯一的差异是监听操作系统分配的端口(并行 `dsh -p` 运行绝不冲突;stderr 打印的 URL 会在浏览器中打开实时会话)。两者都需要先构建前端 dist 和客户端 bundle(`pnpm run build && pnpm run build:web`)。 diff --git a/apps/cli/src/args.ts b/apps/cli/src/args.ts index 038ac96af4..ca898b08ea 100644 --- a/apps/cli/src/args.ts +++ b/apps/cli/src/args.ts @@ -35,13 +35,13 @@ interface MetaInvocation { } /** - * Guided fresh-session entries: `dsh migrate` seeds the first turn with the - * `dsh-migrate` skill, `dsh upgrade` with `dsh-upgrade`. Each always mints a + * Guided fresh-session entry: `dsh upgrade` seeds the first turn with the + * `dsh-upgrade` skill. It always mints a * fresh session in the invoking directory and takes no options — `--resume`, * `--config`, and `-p` are rejected as mistyped, so there is nothing to carry. */ interface SkillSessionInvocation { - mode: 'migrate' | 'upgrade' + mode: 'upgrade' } /** @@ -174,22 +174,15 @@ Examples: // Registration order is the rendered help order, so daily use comes first // and the harness-development surfaces (`web --dev`, `meta`) come last. - // `migrate` and `upgrade` are guided fresh-session entries: they take no - // options and always mint a fresh session, so nothing is left to carry. Each - // description names the outcome, not the skill the first turn invokes. - const guided = { - migrate: 'import settings from another coding agent (Claude Code, Codex, opencode)', - upgrade: 'update this dsh installation to the latest version', - } as const - for (const mode of ['migrate', 'upgrade'] as const) { - program - .command(mode) - .description(guided[mode]) - .action(() => { - rejectParentOptions(mode) - resolved = { mode } - }) - } + // `upgrade` is a guided fresh-session entry: it takes no options and always + // mints a fresh session, so nothing is left to carry. + program + .command('upgrade') + .description('update this dsh installation to the latest version') + .action(() => { + rejectParentOptions('upgrade') + resolved = { mode: 'upgrade' } + }) // Host and port name no default: the CLI passes neither through when the flag // is absent, so the shipped `cordis.yml` value stands and restating it here diff --git a/apps/cli/src/bin.ts b/apps/cli/src/bin.ts index 619d0dcd58..6f7ae77c76 100644 --- a/apps/cli/src/bin.ts +++ b/apps/cli/src/bin.ts @@ -48,7 +48,6 @@ switch (invocation.mode) { await runMeta() break } - case 'migrate': case 'upgrade': { const { runSkillSession } = await import('./tui.ts') await runSkillSession(`dsh-${invocation.mode}`) diff --git a/apps/cli/src/tui.ts b/apps/cli/src/tui.ts index 6964ac9753..e96f9b515e 100644 --- a/apps/cli/src/tui.ts +++ b/apps/cli/src/tui.ts @@ -10,7 +10,7 @@ * workspace, and an in-place resume enters the selected session's own directory. * `dsh meta` * ({@link runMeta}) is the one exception — it makes this harness checkout the - * workspace. `dsh migrate`/`dsh upgrade` ({@link runSkillSession}) are fresh + * workspace. `dsh upgrade` ({@link runSkillSession}) are fresh * sessions whose first turn auto-invokes a bundled skill. After boot, the * agent's system prompt is told the path to this harness checkout so it can * find its own source. @@ -91,7 +91,7 @@ export async function runMeta(): Promise { /** * Run the interactive TUI as a guided fresh session whose first turn invokes a - * bundled skill (`dsh migrate` → `dsh-migrate`, `dsh upgrade` → `dsh-upgrade`). + * bundled skill (`dsh upgrade` → `dsh-upgrade`). * Always mints a fresh session in the invoking directory; the skill is seeded * only on this first launch, so a later `--resume` of the session is an ordinary * TUI session with no re-injection. diff --git a/apps/cli/tests/args.spec.ts b/apps/cli/tests/args.spec.ts index 7eea63b68d..ccbd5fa326 100644 --- a/apps/cli/tests/args.spec.ts +++ b/apps/cli/tests/args.spec.ts @@ -40,7 +40,6 @@ describe('parseDshArgs', () => { expect(parse(['web', '--host', '0.0.0.0', '--port', '8080', '--dev', '--workspace-root', '/w'])) .toEqual({ mode: 'web', host: '0.0.0.0', port: 8080, dev: true, workspaceRoot: '/w' }) // Guided fresh-session entries carry nothing: bare mode discriminant only. - expect(parse(['migrate'])).toEqual({ mode: 'migrate' }) expect(parse(['upgrade'])).toEqual({ mode: 'upgrade' }) // --trusted-host is variadic and repeatable; authorities pass through unvalidated. expect(parse(['web', '--trusted-host', 'harness.internal:3080', 'lab.internal', '--trusted-host', '10.0.0.9'])) @@ -73,12 +72,8 @@ describe('parseDshArgs', () => { expect(exitCode(['meta', '--config', 'c.yml'])).toBe(1) expect(exitCode(['meta', '--config-replace', 'tree.yml'])).toBe(1) expect(exitCode(['meta', '-p', 'task'])).toBe(1) - // `migrate`/`upgrade` take no options: any leaked default-surface flag is a + // `upgrade` take no options: any leaked default-surface flag is a // mistyped invocation, not a silently-dropped input. - expect(exitCode(['migrate', '--resume', 's'])).toBe(1) - expect(exitCode(['migrate', '--config', 'c.yml'])).toBe(1) - expect(exitCode(['migrate', '--config-replace', 'tree.yml'])).toBe(1) - expect(exitCode(['migrate', '-p', 'task'])).toBe(1) expect(exitCode(['upgrade', '--resume', 's'])).toBe(1) expect(exitCode(['upgrade', '--config', 'c.yml'])).toBe(1) expect(exitCode(['-p', 'task', 'upgrade'])).toBe(1) diff --git a/skills/dsh-migrate/SKILL.md b/skills/dsh-migrate/SKILL.md deleted file mode 100644 index fbea31f04f..0000000000 --- a/skills/dsh-migrate/SKILL.md +++ /dev/null @@ -1,57 +0,0 @@ ---- -name: dsh-migrate -description: Migrate a user's setup from another coding agent (opencode, pi, Claude Code, Codex) to DSH — porting instruction files, custom commands and skills, hooks, MCP servers, and API/env configuration into their DSH equivalents. Use when the user asks to migrate, switch, or move from another coding agent to DSH. ---- - -# DSH Migrate - -Move a user's existing coding-agent setup onto DSH: instruction files, custom commands, skills, hooks, MCP servers, and API/environment configuration. Port only what has a real DSH equivalent; tell the user plainly when something has none. - -## First: identify the source - -Ask which agent the user is migrating from if they have not said: **opencode**, **pi**, **Claude Code**, or **Codex**. The mapping differs per source. Then locate that agent's config (ask the user, or inspect the obvious locations: `~/.claude/` and `.claude/` for Claude Code, `~/.codex/` and `.codex/` for Codex, the opencode/pi config dir the user names). Read what exists before proposing changes; never invent files the user does not have. - -## DSH targets - -Every migration lands in one of these DSH surfaces. Verify the exact path against the running install rather than assuming. - -- **Workspace instructions**: DSH reads `AGENTS.md` and `CLAUDE.md` (and `AGENTS.local.md` / `CLAUDE.local.md`) from the project, walking up to the project root, plus a user-global `~/.dsh/AGENTS.md`. `CLAUDE.md` is read as-is, so a Claude Code project needs no rename. -- **Personal overlay** (user-global, applies to every DSH session): the Harness home `~/.dsh/` holds `config.yaml` (a top-level YAML array of Loader patch entries that patch the booted plugin tree), `.env` (fills environment gaps only — ambient env and the invoking directory's `.env` win), `AGENTS.md`, and `skills/`. -- **Skills**: directory-bundle or flat-Markdown skills load from `.dsh/skills/` and `.agents/skills/` in the project, and `~/.dsh/skills/` and `~/.agents/skills/` for the user. Personal skills go in `~/.dsh/skills//SKILL.md`. Use the `skill-creator` skill to author them. -- **Hooks**: DSH runs a mapped subset of an existing Claude Code or Codex hook config through compatibility bridges — no rewrite needed for the supported subset. See the per-source sections. -- **MCP servers**: DSH has no native MCP client. Reach MCP servers through the `mcporter` skill / CLI, which can call servers already configured for other tools. -- **API / model config**: DSH uses `DEEPSEEK_API_KEY` (and optional `DEEPSEEK_BASE_URL`) from `.env` (root, invoking directory, or `~/.dsh/.env`). Model and provider are chosen in the booted `cordis.yml` / personal overlay, not per-provider config files. - -## Per-source mapping - -### Claude Code - -- `CLAUDE.md` → read as-is by DSH workspace instructions; keep it, or consolidate into `AGENTS.md`. User-global rules → `~/.dsh/AGENTS.md`. -- `.claude/hooks.json` (or a settings file's `hooks` key) → the `@deepseek-ai/dsh-hooks-claude` bridge runs the mapped command-hook subset on DSH's interception seams, with `${CLAUDE_PLUGIN_ROOT}` / `${CLAUDE_PROJECT_DIR}` substitution. Add it to the booted `cordis.yml` (or personal overlay) pointing `configPath` at the existing file. Anything outside the mapped subset should become a native DSH plugin, not a shimmed hook. -- Slash commands → DSH commands are plugin-provided; there is no drop-in import. Reimplement genuinely needed ones as skills (`~/.dsh/skills/`) or plugins. -- MCP servers in Claude config → use `mcporter` to reach them; DSH has no native MCP. -- `ANTHROPIC_API_KEY` etc. do not transfer; DSH is DeepSeek-backed via `DEEPSEEK_API_KEY`. - -### Codex - -- Codex `AGENTS.md` → DSH already reads `AGENTS.md`; keep it. User-global → `~/.dsh/AGENTS.md`. -- Codex hook config → the `@deepseek-ai/dsh-hooks-codex` bridge runs a deliberate subset (`PreToolUse`, `PostToolUse`, `SessionStart`, `UserPromptSubmit`, `Stop`; regex-only matchers; no plugin env injection; no pre-tool approval/rewrite). Add the bridge to the booted config with `configPath` at the existing Codex hooks file. State the unsupported points to the user rather than implying full parity. -- MCP servers → `mcporter`. -- API/env → `DEEPSEEK_API_KEY` in `.env`. - -### opencode / pi - -- These have no compatibility bridge. Port by concept, not by file: - - Agent/system instructions → `AGENTS.md` (project) and `~/.dsh/AGENTS.md` (user-global). - - Provider/model and any plugin-style tuning → the booted `cordis.yml` or `~/.dsh/config.yaml` overlay patches; API keys → `.env`. - - Reusable prompts/commands → skills under `~/.dsh/skills/`. - - MCP servers → `mcporter`. -- pi has no native MCP by design; the `mcporter` route is the same as for DSH. - -## Do the migration - -1. Confirm the source agent and read its actual config. -2. For each capability (instructions, hooks, commands/skills, MCP, API/env), map it to the DSH target above, or tell the user it has no equivalent. -3. Write the ported files (`AGENTS.md`, `~/.dsh/AGENTS.md`, `~/.dsh/config.yaml`, `~/.dsh/.env`, skills). For hook bridges, add the plugin entry to the booted config. -4. Verify: hooks need the bridge plugin present in the running tree; MCP needs `mcporter` reachable; API needs `DEEPSEEK_API_KEY` set. Test in a real DSH session, not just on paper. -5. Summarize what was ported, what was reimplemented, and what has no DSH equivalent. From bdc2b4667fce8a17ab70702a516c068360df2b9c Mon Sep 17 00:00:00 2001 From: Turtle Date: Wed, 29 Jul 2026 21:43:33 +0800 Subject: [PATCH 048/113] refactor(cli): keep hmr in shared base --- apps/cli/base.cordis.yml | 5 +++++ apps/cli/composition.md | 6 +++--- apps/cli/tui.cordis.yml | 7 ------- 3 files changed, 8 insertions(+), 10 deletions(-) diff --git a/apps/cli/base.cordis.yml b/apps/cli/base.cordis.yml index cb1fd7925d..9f69fcc627 100644 --- a/apps/cli/base.cordis.yml +++ b/apps/cli/base.cordis.yml @@ -17,6 +17,11 @@ - id: timer name: '@cordisjs/plugin-timer' +- id: hmr + name: '@cordisjs/plugin-hmr' + config: + root: ['.'] + - id: llm name: '@deepseek-ai/dsh-llm' diff --git a/apps/cli/composition.md b/apps/cli/composition.md index 0e2af8b863..4abe13c79b 100644 --- a/apps/cli/composition.md +++ b/apps/cli/composition.md @@ -10,6 +10,8 @@ flowchart LR cfg["apps/cli (dsh)
base.cordis.yml + tui.cordis.yml"] plugin_tui_timer["timer
@cordisjs/plugin-timer"] cfg --> plugin_tui_timer + plugin_tui_hmr["hmr
@cordisjs/plugin-hmr"] + cfg --> plugin_tui_hmr plugin_tui_llm["llm
@deepseek-ai/dsh-llm"] cfg --> plugin_tui_llm plugin_tui_session["session
@deepseek-ai/dsh-session"] @@ -96,8 +98,6 @@ flowchart LR cfg --> plugin_tui_fs_local plugin_tui_llm_deepseek["llm-deepseek
@deepseek-ai/dsh-llm-deepseek"] cfg --> plugin_tui_llm_deepseek - plugin_tui_hmr["hmr
@cordisjs/plugin-hmr"] - cfg --> plugin_tui_hmr plugin_tui_invariants["invariants
@deepseek-ai/dsh-invariants"] cfg --> plugin_tui_invariants plugin_tui_session_invariant["session-invariant
@deepseek-ai/dsh-session/invariant"] @@ -131,6 +131,7 @@ flowchart LR | Plugin id | Package / module | | --- | --- | | `timer` | `@cordisjs/plugin-timer` | +| `hmr` | `@cordisjs/plugin-hmr` | | `llm` | `@deepseek-ai/dsh-llm` | | `session` | `@deepseek-ai/dsh-session` | | `session-title` | `@deepseek-ai/dsh-session-title` | @@ -174,7 +175,6 @@ flowchart LR | `agent-loop` | `@deepseek-ai/dsh-agent-loop` | | `fs-local` | `@deepseek-ai/dsh-fs-local` | | `llm-deepseek` | `@deepseek-ai/dsh-llm-deepseek` | -| `hmr` | `@cordisjs/plugin-hmr` | | `invariants` | `@deepseek-ai/dsh-invariants` | | `session-invariant` | `@deepseek-ai/dsh-session/invariant` | | `agent-invariant` | `@deepseek-ai/dsh-agent/invariant` | diff --git a/apps/cli/tui.cordis.yml b/apps/cli/tui.cordis.yml index f71be09e85..b1ba86e6c7 100644 --- a/apps/cli/tui.cordis.yml +++ b/apps/cli/tui.cordis.yml @@ -59,13 +59,6 @@ # ── TUI-only rows ─────────────────────────────────────────────────────────── - insert: - # Development-only hot reload; it depends on Loader internals, so it stays a - # surface row rather than joining the shared base. - - id: hmr - name: '@cordisjs/plugin-hmr' - config: - root: ['.'] - # Relational runtime checks over the authoritative event streams; each # companion registers the assertions its own package owns. - id: invariants From c20f94335ae7eaa6307a13ea73fb7cb372018113 Mon Sep 17 00:00:00 2001 From: Turtle Date: Wed, 29 Jul 2026 21:47:20 +0800 Subject: [PATCH 049/113] docs(user): explain CLI overlay precedence --- docs/user/guide/config.i18n.yaml | 4 ++-- docs/user/guide/config.md | 6 ++++++ docs/user/guide/config.zh.md | 6 ++++++ 3 files changed, 14 insertions(+), 2 deletions(-) diff --git a/docs/user/guide/config.i18n.yaml b/docs/user/guide/config.i18n.yaml index a45fbb2186..bf4ccbc63d 100644 --- a/docs/user/guide/config.i18n.yaml +++ b/docs/user/guide/config.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/user/guide/config.md -config.md: c9c760cbdb9650e608809fee4498b7f884b339dc -config.zh.md: b80e31c6f77f51d013aaebe5c4e9e43f5819e33f +config.md: 34a2a902e11fae36b21affc136be70c2e7bd89f9 +config.zh.md: 62d2e35c4501be2cea9d58f2c0d54c3c770e8a08 diff --git a/docs/user/guide/config.md b/docs/user/guide/config.md index c9c760cbdb..34a2a902e1 100644 --- a/docs/user/guide/config.md +++ b/docs/user/guide/config.md @@ -48,6 +48,12 @@ A minimal configuration is a list of plugin entries: Plugins load in file order. Place plugins that depend on services after the applications or capability plugins that provide them. Missing models, tools, and plugins fail as early as possible instead of being silently ignored. +## CLI overlays + +The TUI composes `base.cordis.yml` and `tui.cordis.yml`, then applies one optional patch list. By default that final list is `~/.dsh/config.yaml`; `dsh --config ` replaces the personal list with the named overlay. `dsh --config-replace ` instead boots the named file as the complete tree, without shipped or personal layers. `dsh web --config ` adds its overlay after the shared base and Web surface defaults and before Web profile and CLI-flag patches. + +A patch replaces a row's entire `config` value; it does not deep-merge keys. For example, patching `llm-deepseek` with only `config: { thinking: disabled }` also removes that row's configured `apiKey` and `baseURL`, so restate every key the row must retain. + ## JavaScript values and environment variables The Cordis loader evaluates runtime expressions tagged with `!!js`. Keep API keys and other secrets in the gitignored `.env` file at the repository root, never in committed configuration. diff --git a/docs/user/guide/config.zh.md b/docs/user/guide/config.zh.md index b80e31c6f7..62d2e35c45 100644 --- a/docs/user/guide/config.zh.md +++ b/docs/user/guide/config.zh.md @@ -48,6 +48,12 @@ Harness 使用 `cordis.yml` 描述 Agent 加载哪些插件以及每个插件的 插件按文件中的顺序加载。依赖其他服务的插件应该排在提供这些服务的应用或能力插件之后;引用不存在的模型、工具或插件会尽早报错,而不是被静默忽略。 +## CLI 覆盖层 + +TUI 先组合 `base.cordis.yml` 与 `tui.cordis.yml`,再应用一个可选补丁列表。默认的最后一层是 `~/.dsh/config.yaml`;`dsh --config ` 会以指定覆盖替代个人补丁列表。`dsh --config-replace ` 则把指定文件作为完整配置树启动,不使用已交付配置或个人层。`dsh web --config ` 会在共享基础配置与 Web 界面默认值之后、Web profile 与命令行标志补丁之前添加覆盖。 + +补丁会替换目标行的整个 `config` 值,而不是深度合并各个键。例如,只用 `config: { thinking: disabled }` 修补 `llm-deepseek`,也会移除该行原有的 `apiKey` 与 `baseURL`;因此必须重新写出该行需要保留的全部键。 + ## JavaScript 值和环境变量 Cordis loader 使用 `!!js` 标签读取运行时表达式。API key 等凭据应放在仓库根目录、已被 Git 忽略的 `.env` 中,不能提交到配置文件。 From f6a16e48f27cfe99f0924885c29e52066be91c29 Mon Sep 17 00:00:00 2001 From: Turtle Date: Wed, 29 Jul 2026 21:49:20 +0800 Subject: [PATCH 050/113] docs(cli): record restored web and index behavior --- .../2026-07-29-shared-base-config-overlays.i18n.yaml | 4 ++-- .../2026-07-29-shared-base-config-overlays.md | 6 +++--- .../2026-07-29-shared-base-config-overlays.zh.md | 4 ++-- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/.agents/notes/implemented/simplification/2026-07-29-shared-base-config-overlays.i18n.yaml b/.agents/notes/implemented/simplification/2026-07-29-shared-base-config-overlays.i18n.yaml index ac8a924568..bd8e785272 100644 --- a/.agents/notes/implemented/simplification/2026-07-29-shared-base-config-overlays.i18n.yaml +++ b/.agents/notes/implemented/simplification/2026-07-29-shared-base-config-overlays.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/simplification/2026-07-29-shared-base-config-overlays.md -2026-07-29-shared-base-config-overlays.md: c3c1bd962dd61201958053d1f1c04e05646f78bd -2026-07-29-shared-base-config-overlays.zh.md: dcf7eaafa7f46b8c2fc1a45e627d31770ba3b234 +2026-07-29-shared-base-config-overlays.md: 006cc6b08f41648f6a0cd0512cd51fc26af96aab +2026-07-29-shared-base-config-overlays.zh.md: 0bc95c78d1e13c2d57f54e1719c07a4823d2b1d3 diff --git a/.agents/notes/implemented/simplification/2026-07-29-shared-base-config-overlays.md b/.agents/notes/implemented/simplification/2026-07-29-shared-base-config-overlays.md index c3c1bd962d..006cc6b08f 100644 --- a/.agents/notes/implemented/simplification/2026-07-29-shared-base-config-overlays.md +++ b/.agents/notes/implemented/simplification/2026-07-29-shared-base-config-overlays.md @@ -40,13 +40,13 @@ A patch replaces its target row's whole `config` rather than merging, which shap An overlay or `--config` tree that named `@deepseek-ai/dsh-tui-demo`, or patched the `tui-agent` row, no longer resolves. Overlays now patch the row that owns each key: the model route on `agent-loop`, the persona on `system-prompt`, presentation on `tui`. -A patch whose `id` matches no row stays a Loader warning rather than an error. That is deliberate: one personal overlay is shared across surfaces, and `insert` rows match nothing by design, so a row that exists only under `web` must not fail the TUI's boot. +A patch whose `id` matches no row stays a no-op rather than an error. That is deliberate: one personal overlay is shared across surfaces, and `insert` rows match nothing by design, so a row that exists only under `web` must not fail the TUI's boot. -`dsh web` gains `--config`, threaded into `AppCLIEntry` as an extra overlay. `AppCLIEntry` reads both the base and its surface overlay when recovering row defaults for its own patch merge, since a flag override must preserve the overlay's other fields on the same row. +`dsh web` gains `--config`, threaded into `AppCLIEntry` as an extra overlay. Web keeps sandboxed Bash and filesystem providers plus approval, permission presets, directory picking, and browser permission UI; the overlay disables the shared local providers because patches can disable rows but cannot delete them. The TUI query index uses a unique process-local temporary database because the SQLite backend requires one writer owner. It is a disposable derived index rebuilt by each process; `/resume` lists the underlying corpus directly and does not depend on index reuse. `AppCLIEntry` reads both the base and its surface overlay when recovering row defaults for its own patch merge, since a flag override must preserve the overlay's other fields on the same row. ## Verification -Composition is checked by booting each tree through the real Loader and inspecting settled entries, not by reading YAML: the TUI settles 55 entries and web 75, both with zero unloaded or unsettled rows, and web's `httpServer` up. The three-layer case (`base` + `tui` + `code-mode`) confirms `tools.mode` reaching `code` over the TUI overlay's `native`. +Composition is checked by booting each tree through the real Loader and inspecting settled entries, not by reading YAML; both surfaces settle with zero unloaded rows, and Web starts its `httpServer` with sandboxed Bash and filesystem providers. The three-layer case (`base` + `tui` + `code-mode`) confirms `tools.mode` reaching `code` over the TUI overlay's `native`. All eight terminal snapshot scenarios replay byte-identically after moving, and the 14-case PTY smoke passes, including two cases that assert a personal overlay reaches an **inserted** row — the behavior the vendored `plugin-include` fix enables ([`vendor/README.md`](../../../../vendor/README.md) local modification 8, covered by `packages/ui/app-boot/tests/config-reload.spec.ts`). diff --git a/.agents/notes/implemented/simplification/2026-07-29-shared-base-config-overlays.zh.md b/.agents/notes/implemented/simplification/2026-07-29-shared-base-config-overlays.zh.md index dcf7eaafa7..0bc95c78d1 100644 --- a/.agents/notes/implemented/simplification/2026-07-29-shared-base-config-overlays.zh.md +++ b/.agents/notes/implemented/simplification/2026-07-29-shared-base-config-overlays.zh.md @@ -42,11 +42,11 @@ patch 会整体替换目标配置项的 `config` 而不合并,这决定了拆 若某个 patch 的 `id` 不匹配任何配置项,Loader 仍只告警而不报错。这是有意为之:同一份个人 overlay 会跨 surface 共用,而 `insert` 配置项按设计本就不匹配任何目标,因此仅在 `web` 下存在的配置项不能让 TUI 启动失败。 -`dsh web` 新增 `--config`,作为一份额外 overlay 传入 `AppCLIEntry`。`AppCLIEntry` 在为自身 patch 合并恢复配置项默认值时会同时读取 base 与其 surface overlay,因为 flag 覆盖必须保留同一配置项上 overlay 的其他字段。 +`dsh web` 新增 `--config`,作为一份额外 overlay 传入 `AppCLIEntry`。Web 保留沙箱化 Bash 与文件系统提供方,以及审批、权限预设、目录选择和浏览器权限界面;覆盖层会禁用共享的本地提供方,因为补丁可以禁用条目但不能删除条目。TUI 查询索引使用进程唯一的临时数据库,因为 SQLite 后端要求单写入者所有权。该索引是每个进程重新构建的可丢弃派生数据;`/resume` 直接列出底层语料,不依赖索引复用。`AppCLIEntry` 在为自身 patch 合并恢复配置项默认值时会同时读取 base 与其 surface overlay,因为 flag 覆盖必须保留同一配置项上 overlay 的其他字段。 ## 验证 -组合的正确性通过用真实 Loader 启动每棵树并检查已就绪的条目来核对,而不是靠阅读 YAML:TUI 就绪 55 个条目、web 就绪 75 个,两者都没有未加载或未就绪的配置项,且 web 的 `httpServer` 已启动。三层叠加的情形(`base` + `tui` + `code-mode`)确认 `tools.mode` 越过 TUI overlay 的 `native` 达到了 `code`。 +组合的正确性通过用真实 Loader 启动每棵树并检查已就绪的条目来核对,而不是靠阅读 YAML:两个界面都能稳定完成且没有未加载项;Web 会以沙箱化 Bash 与文件系统提供方启动 `httpServer`。三层叠加的情形(`base` + `tui` + `code-mode`)确认 `tools.mode` 越过 TUI overlay 的 `native` 达到了 `code`。 全部八个终端快照场景在迁移后逐字节重放一致,14 个用例的 PTY 冒烟测试全部通过,其中两个用例断言个人 overlay 能触达一个 **insert 进来的**配置项——这正是 vendored `plugin-include` 修复所启用的行为([`vendor/README.md`](../../../../vendor/README.md) 本地修改第 8 条,由 `packages/ui/app-boot/tests/config-reload.spec.ts` 覆盖)。 From dfca95e93f84309e7c5b5cedf275bc11a54ef230 Mon Sep 17 00:00:00 2001 From: Turtle Date: Wed, 29 Jul 2026 21:51:22 +0800 Subject: [PATCH 051/113] fix(tui): remove disposable query index on exit --- apps/cli/src/tui.ts | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/apps/cli/src/tui.ts b/apps/cli/src/tui.ts index e96f9b515e..9fcc3336d9 100644 --- a/apps/cli/src/tui.ts +++ b/apps/cli/src/tui.ts @@ -18,6 +18,7 @@ */ import { randomUUID } from 'node:crypto' +import { rm } from 'node:fs/promises' import { join, resolve } from 'node:path' import { tmpdir } from 'node:os' import { fileURLToPath } from 'node:url' @@ -226,6 +227,7 @@ export async function runTui( ? loadPersonalPatches(NAME) ?? [] : loadOverlayPatches(NAME, resolveConfigPath(resolve(config), undefined)), ] + const queryIndexPath = join(tmpdir(), SESSION_QUERY_DB) const ctx = await boot( NAME, replaceTree ? resolveConfigPath(resolve(configReplace), undefined) : BASE_CONFIG, @@ -246,7 +248,14 @@ export async function runTui( hostCtx.provide(CONFIGURED_AGENT_IDENTITIES_KEY, { [MAIN_AGENT_ID]: identity }) // The query database is a disposable derived index with single-process // ownership. Keep it process-local while it indexes the shared logs. - hostCtx.provide(SESSION_QUERY_SQLITE_PATH_KEY, join(tmpdir(), SESSION_QUERY_DB)) + hostCtx.provide(SESSION_QUERY_SQLITE_PATH_KEY, queryIndexPath) + hostCtx.effect(() => async () => { + await Promise.all([ + rm(queryIndexPath, { force: true }), + rm(`${queryIndexPath}-wal`, { force: true }), + rm(`${queryIndexPath}-shm`, { force: true }), + ]) + }, 'launcherSessionQueryPath.cleanup') if (resumeHost !== undefined) hostCtx.provide('tuiResumeHost', resumeHost) // Seed the first turn only for a fresh session, so resuming never // re-invokes the skill. From 915121ff407deac81ec0b2335f7209095f400712 Mon Sep 17 00:00:00 2001 From: Turtle Date: Wed, 29 Jul 2026 21:53:38 +0800 Subject: [PATCH 052/113] docs(cli): remove stale source config ownership --- .../2026-07-28-dsh-native-typescript-source-launch.i18n.yaml | 4 ++-- .../2026-07-28-dsh-native-typescript-source-launch.md | 4 ++-- .../2026-07-28-dsh-native-typescript-source-launch.zh.md | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-07-28-dsh-native-typescript-source-launch.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-28-dsh-native-typescript-source-launch.i18n.yaml index e10d181359..9f165ccbf1 100644 --- a/.agents/notes/implemented/architecture/2026-07-28-dsh-native-typescript-source-launch.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-28-dsh-native-typescript-source-launch.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-28-dsh-native-typescript-source-launch.md -2026-07-28-dsh-native-typescript-source-launch.md: e128ae1a39c6a3838d948b40936dca6e30d324e1 -2026-07-28-dsh-native-typescript-source-launch.zh.md: 5ff1d975f8012e7d831bc3ecf6e5c53a9d43151f +2026-07-28-dsh-native-typescript-source-launch.md: 2e69a93f74bb056ce654086641c3123c499a3ffb +2026-07-28-dsh-native-typescript-source-launch.zh.md: 8be7f8527de8b9ff4eaadac9af3137fca8d153ac diff --git a/.agents/notes/implemented/architecture/2026-07-28-dsh-native-typescript-source-launch.md b/.agents/notes/implemented/architecture/2026-07-28-dsh-native-typescript-source-launch.md index e128ae1a39..2e69a93f74 100644 --- a/.agents/notes/implemented/architecture/2026-07-28-dsh-native-typescript-source-launch.md +++ b/.agents/notes/implemented/architecture/2026-07-28-dsh-native-typescript-source-launch.md @@ -20,9 +20,9 @@ The `dsh` TUI, Web, and headless source launches use `node --experimental-transf `scripts/tspath-loader.ts` registers only a module resolve hook. It uses `TSX_TSCONFIG_PATH` when set (resolving relative values from the invoking cwd) and otherwise reads the root `tsconfig.json`; `TsconfigPathsResolver` follows that config's `extends` chain through the repository's existing TypeScript development tool, selects exact or wildcard `paths` entries according to tsconfig rules, and maps matching workspace bare specifiers to `.ts`/`.mts`/`.cts` source files or directory index files. Node remains solely responsible for code transformation. The source-only loader is not part of the built CLI and `apps/cli` does not declare `typescript` as a runtime dependency. -Source imports are redirected only when the target package is either the nearest package manifest's own name or one of that manifest's declared runtime dependencies. The Cordis Loader uses the configuration directory URL as the import parent; the resolver then searches upward for the workspace manifest that declares the plugin, so dependency ownership for `examples/tui-agent/cordis.yml` lies with `examples/package.json`, and dependency ownership for `apps/cli/base.cordis.yml` plus its surface overlay lies with `apps/cli/package.json`. Specifiers that do not match tsconfig paths, refer to undeclared dependencies, or are not bare all fall back to Node's default resolution. +Source imports are redirected only when the target package is either the nearest package manifest's own name or one of that manifest's declared runtime dependencies. The Cordis Loader uses the configuration directory URL as the import parent; the resolver then searches upward for the workspace manifest that declares the plugin, so dependency ownership for the shipped `apps/cli/base.cordis.yml` plus its surface overlay lies with `apps/cli/package.json`. Specifiers that do not match tsconfig paths, refer to undeclared dependencies, or are not bare all fall back to Node's default resolution. -`verify-cordis-config` performs a one-way completeness check on both resolver manifests: every bare plugin package in a configuration must appear in the corresponding manifest's `dependencies`, while the manifest may contain extra dependencies not referenced by that configuration. The root `AGENTS.md` makes updating the configuration and dependencies together a standing rule. +`verify-cordis-config` performs a one-way completeness check on the resolver manifest: every bare plugin package in a configuration must appear in the corresponding manifest's `dependencies`, while the manifest may contain extra dependencies not referenced by that configuration. The root `AGENTS.md` makes updating the configuration and dependencies together a standing rule. After the Loader settles, the shared `dsh-app-boot` checks every enabled entry that has no fiber and rejects startup with `plugin(s) failed to load: ...; Cordis startup failed because these plugin(s) could not be resolved`, listing all failed plugins. This diagnostic lives at the app layer and does not change the vendored Loader's startup behavior. diff --git a/.agents/notes/implemented/architecture/2026-07-28-dsh-native-typescript-source-launch.zh.md b/.agents/notes/implemented/architecture/2026-07-28-dsh-native-typescript-source-launch.zh.md index 5ff1d975f8..8be7f8527d 100644 --- a/.agents/notes/implemented/architecture/2026-07-28-dsh-native-typescript-source-launch.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-28-dsh-native-typescript-source-launch.zh.md @@ -20,9 +20,9 @@ Cordis 配置还引入了另一条解析边界。`cordis.yml` 中的 bare plugin `scripts/tspath-loader.ts` 只注册一个模块 resolve hook。设置 `TSX_TSCONFIG_PATH` 时,它会使用该路径(相对路径从调用方的 cwd 解析),否则读取根 `tsconfig.json`;`TsconfigPathsResolver` 使用仓库已有的 TypeScript 开发工具沿该配置的 `extends` 链解析,按 tsconfig 规则选择精确或 wildcard `paths` 条目,并将命中的 workspace bare specifier 映射到 `.ts`/`.mts`/`.cts` 源文件或目录 index 文件。代码转换始终只由 Node 负责。该源码专用 loader 不属于构建后的 CLI,`apps/cli` 也不会把 `typescript` 声明为运行时依赖。 -只有当目标包是最近 package manifest 的自身名称或其已声明的运行时依赖时,源码 import 才会重定向。Cordis Loader 使用配置目录 URL 作为 import parent;此时 resolver 会向上查找声明该插件的 workspace manifest。因此,`examples/tui-agent/cordis.yml` 的依赖由 `examples/package.json` 持有,`apps/cli/base.cordis.yml` plus its surface overlay 的依赖由 `apps/cli/package.json` 持有。未命中 tsconfig paths、引用未声明依赖或不是 bare specifier 的说明符全部交回 Node 默认解析。 +只有当目标包是最近 package manifest 的自身名称或其已声明的运行时依赖时,源码 import 才会重定向。Cordis Loader 使用配置目录 URL 作为 import parent;此时 resolver 会向上查找声明该插件的 workspace manifest。因此,已交付的 `apps/cli/base.cordis.yml` 及其界面覆盖层所需依赖由 `apps/cli/package.json` 持有。未命中 tsconfig paths、引用未声明依赖或不是 bare specifier 的说明符全部交回 Node 默认解析。 -`verify-cordis-config` 对这两个解析方 manifest 执行单向完整性检查:配置中的每个 bare plugin package 都必须出现在对应 manifest 的 `dependencies` 中,manifest 可以包含该配置未引用的额外依赖。根 `AGENTS.md` 将同步更新配置和依赖定为常驻规则。 +`verify-cordis-config` 对该解析方 manifest 执行单向完整性检查:配置中的每个 bare plugin package 都必须出现在对应 manifest 的 `dependencies` 中,manifest 可以包含该配置未引用的额外依赖。根 `AGENTS.md` 将同步更新配置和依赖定为常驻规则。 Loader 完成结算后,共享的 `dsh-app-boot` 会检查每个已启用但没有 fiber 的 entry,并以 `plugin(s) failed to load: ...; Cordis startup failed because these plugin(s) could not be resolved` 拒绝启动,同时列出全部加载失败的插件。该诊断位于应用层,不改变 vendor 中 Loader 的启动行为。 From 87c5c46249d59dcd9b49ff8637cb282fc9273174 Mon Sep 17 00:00:00 2001 From: Turtle Date: Wed, 29 Jul 2026 21:54:51 +0800 Subject: [PATCH 053/113] docs(i18n): remove unrelated terminology --- docs/i18n/terminology.md | 1 - 1 file changed, 1 deletion(-) diff --git a/docs/i18n/terminology.md b/docs/i18n/terminology.md index 74392c18ef..5072c2684d 100644 --- a/docs/i18n/terminology.md +++ b/docs/i18n/terminology.md @@ -116,7 +116,6 @@ | extension | 扩展 | | | | | extension point | 扩展点 | | | 注意与 `seam` 区分 | | fail-fast | 快速失败 | | | | -| fail-open | 故障放行 | 故障放行(fail-open) | 故障开放 | 与 `fail-fast` 对称;指无法判定时放行而非阻断 | | fenced code block | 围栏代码块 | | | 沿用 MDN 中文翻译 | | fingerprint | 指纹 | | | 通用内容指纹;双语配对机制使用 sidecar record 记录两侧 blob hash | | finish reason | 结束原因 | | | | From 2994342f2317c627e8193e5aa0f23ed8be8ae5b7 Mon Sep 17 00:00:00 2001 From: Turtle Date: Wed, 29 Jul 2026 21:56:55 +0800 Subject: [PATCH 054/113] docs(web): align browser roster composition --- .../feature/2026-07-24-web-session-model-selector.i18n.yaml | 4 ++-- .../feature/2026-07-24-web-session-model-selector.md | 2 +- .../feature/2026-07-24-web-session-model-selector.zh.md | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-07-24-web-session-model-selector.i18n.yaml b/.agents/notes/implemented/feature/2026-07-24-web-session-model-selector.i18n.yaml index 55b9d222b4..1b4365fdcf 100644 --- a/.agents/notes/implemented/feature/2026-07-24-web-session-model-selector.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-24-web-session-model-selector.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-24-web-session-model-selector.md -2026-07-24-web-session-model-selector.md: bf2f1f3c677ef576b1a00fb8bd0133154462d705 -2026-07-24-web-session-model-selector.zh.md: b2e17bdf487c717dc85e9b16b8f8e526557cdfb9 +2026-07-24-web-session-model-selector.md: 7e0355e272c6b5c62df5eb50acce7bc5de5d9795 +2026-07-24-web-session-model-selector.zh.md: d5cd7eb7e679901fcd62945af7b19ede42e74a9d diff --git a/.agents/notes/implemented/feature/2026-07-24-web-session-model-selector.md b/.agents/notes/implemented/feature/2026-07-24-web-session-model-selector.md index bf2f1f3c67..7e0355e272 100644 --- a/.agents/notes/implemented/feature/2026-07-24-web-session-model-selector.md +++ b/.agents/notes/implemented/feature/2026-07-24-web-session-model-selector.md @@ -18,7 +18,7 @@ The browser `ModelService` owns one `ModelDirectory` per live session. Its snaps `@deepseek-ai/dsh-client-ui-conversation` declares the session-scoped single slot `conversation.input.model` as a child of its composer-bar entry. InputBar renders the seat in its trailing controls immediately before the pending indicator and primary button; the seat receives the bar's `locked` owner prop and session scope. `@deepseek-ai/dsh-client-ui-model` occupies that seat and also contributes `/model` over the same directory. Its compact trigger displays the catalog model name and effective reasoning label, falling back to ids when metadata is absent. The upward menu first offers Model and, when the current exact model supports it, Effort; Model drills into provider groups, while Effort drills into the adapter-ordered levels. The provider-default row appears only when the adapter does not configure a model default. -The production browser roster is the flat config tree in `apps/cli/web.cordis.yml`; the model feature is one `dshClient` row rather than a package hardcoded in Web boot code. Its package manifest orders it after the runtime and command feature, while Cordis service injection waits for the conversation slot before registering the composer occupant. +The production browser roster is assembled from `apps/cli/base.cordis.yml` plus `apps/cli/web.cordis.yml`; the model feature is one `dshClient` row rather than a package hardcoded in Web boot code. Its package manifest orders it after the runtime and command feature, while Cordis service injection waits for the conversation slot before registering the composer occupant. ## Alternatives considered diff --git a/.agents/notes/implemented/feature/2026-07-24-web-session-model-selector.zh.md b/.agents/notes/implemented/feature/2026-07-24-web-session-model-selector.zh.md index b2e17bdf48..d5cd7eb7e6 100644 --- a/.agents/notes/implemented/feature/2026-07-24-web-session-model-selector.zh.md +++ b/.agents/notes/implemented/feature/2026-07-24-web-session-model-selector.zh.md @@ -18,7 +18,7 @@ Web Host 为每个新建或恢复的 agent(智能体)复用 `installAgentLlm `@deepseek-ai/dsh-client-ui-conversation` 将会话作用域的单实例 slot `conversation.input.model` 声明为其输入栏 entry 的子 slot。InputBar 在尾部控件区将该 seat 渲染于 pending 指示器与主按钮之前;该 seat 接收输入栏的 `locked` owner prop 与会话作用域。`@deepseek-ai/dsh-client-ui-model` 占用该 seat,并在同一目录上提供 `/model`。其紧凑型触发器显示目录中的模型名称与生效的推理强度标签;元数据缺失时则回退到相应 ID。向上展开的菜单首先提供 Model,并在当前精确模型支持时提供 Effort;Model 可深入提供方分组,Effort 可深入适配器排序的级别。仅当适配器没有配置模型默认值时,才显示提供方默认值行。 -生产环境的浏览器名册是 `apps/cli/base.cordis.yml` plus its surface overlay 中的平铺 config tree;模型功能对应其中一行 `dshClient` 配置项,而不是 Web boot 代码中硬编码的包。其包 manifest(元数据清单)将加载顺序置于运行时与命令功能之后;Cordis 服务注入则等待 conversation slot 可用,再注册 composer 占用方。 +生产环境的浏览器名册由 `apps/cli/base.cordis.yml` 与 `apps/cli/web.cordis.yml` 共同组装;模型功能对应其中一行 `dshClient` 配置项,而不是 Web boot 代码中硬编码的包。其包 manifest(元数据清单)将加载顺序置于运行时与命令功能之后;Cordis 服务注入则等待 conversation slot 可用,再注册 composer 占用方。 ## 考虑过的替代方案 From 3224ad92af202d483f915f2b8cd875f3a3478768 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Wed, 29 Jul 2026 21:57:06 +0800 Subject: [PATCH 055/113] =?UTF-8?q?fix(host):=20review=20round=207=20?= =?UTF-8?q?=E2=80=94=20upgrade=20re-parks=20displaced=20row=20focus;=20cas?= =?UTF-8?q?e-folded=20display=20root;=20boundary=20notes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...directory-picker-capability-seam.i18n.yaml | 4 +- ...-07-28-directory-picker-capability-seam.md | 2 +- ...-28-directory-picker-capability-seam.zh.md | 2 +- .../src/client/DirectoryBrowser.tsx | 56 ++++++++++++++----- .../tests/directory-browser.spec.tsx | 53 ++++++++++++++++++ 5 files changed, 100 insertions(+), 17 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.i18n.yaml index 900e1d01b6..7d06152986 100644 --- a/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md -2026-07-28-directory-picker-capability-seam.md: ad2aa904beddb2fe941883c3c1827702dbec9964 -2026-07-28-directory-picker-capability-seam.zh.md: 30e719ad9b4e8374496106b447e961a042c7d8b6 +2026-07-28-directory-picker-capability-seam.md: a9f9bc81a8aeff4f9592257b574a199063c681ae +2026-07-28-directory-picker-capability-seam.zh.md: 6331440b04cc8ee6994cb49ff3fd1e6b49403fa4 diff --git a/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md b/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md index ad2aa904be..a9f9bc81a8 100644 --- a/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md +++ b/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md @@ -20,7 +20,7 @@ Placement and policy rulings folded into this decision: - **Dependency survey (hand-roll vs adopt).** Node's stdlib *is* the maintained cross-platform OS layer (`readdir(withFileTypes)`, `homedir`, path semantics); surveyed alternatives fail the dependency bar — file-manager packages (`node-file-manager`, `files-and-folders`, Syncfusion's provider) are whole HTTP apps (fit), drive-letter helpers (`drivelist` native addon, `windows-drive-letters` ~7y stale) fail health/proportionality. The browse backend is a thin adapter over stdlib. - **Hidden entries: return-and-flag.** The host stamps `hidden` (POSIX dot convention) and returns everything; the client filters. Display policy stays client-side, and the show-hidden toggle shipped as exactly that client-only change: a fixed-label footer toggle whose state lives in the pressed presentation (`aria-pressed` + check glyph), a dot-led path-draft prefix reveals the hidden entries it names, and the current selection is exempt from both the hidden and the prefix filter (it anchors the two-pane view). Windows' `FILE_ATTRIBUTE_HIDDEN` is not exposed by dirents — documented limitation until a native probe pays for itself. - **Path-editor cancel scope: the dialog card.** The browse client's path editor cancels on Escape and on focus leaving the card, both observed at a card-scope wrapper rather than the input — after Tab parks focus on a filtered row the input is off the event path, yet Escape must collapse the editor (not the dialog) and a later focus departure must still cancel. Non-cancel exemptions: window/tab focus loss, in-card focus moves, and pointer paths (rows and the toggle suppress focus steal on mousedown while editing). Separators for seeding and draft-tail filtering are inferred from `listing.home`; the wire-field alternative below records the deferred authoritative form. Combobox semantics between the editor and the list it filters (`aria-expanded`/`aria-controls`/active-descendant, result announcements) are likewise deferred — today they read to assistive tech as separate widgets. -- **Navigation lands selection-anchored, progressively.** Away from the display root (the same collapse the crumb header renders, so crumbs and pane shape never disagree), the browse client's navigate commits the target level the moment it arrives — the editor closes and loading ends on that first settlement, so an Enter-submitted navigation is never withdrawn waiting on more — and a parent leg then upgrades the landing in place: the target's actual parent-level entry re-selected (platform case folding on Windows), its children on the right, so a crumb jump reads as stepping back one pane rather than collapsing to a single column. The parent leg runs under the landing's supersession scope and is aborted on the wire by any newer intent; a failed parent leg, or a truncated parent window lacking the target, leaves the committed single-pane landing — the upgrade must never orphan the selection it exists to anchor. +- **Navigation lands selection-anchored, progressively.** Away from the display root (the same collapse the crumb header renders, so crumbs and pane shape never disagree), the browse client's navigate commits the target level the moment it arrives — the editor closes and loading ends on that first settlement, so an Enter-submitted navigation is never withdrawn waiting on more — and a parent leg then upgrades the landing in place: the target's actual parent-level entry re-selected (platform case folding on Windows), its children on the right, so a crumb jump reads as stepping back one pane rather than collapsing to a single column. The parent leg runs under the landing's supersession scope and is aborted on the wire by any newer intent; a failed parent leg, or a truncated parent window lacking the target, leaves the committed single-pane landing — the upgrade must never orphan the selection it exists to anchor. Known boundaries of the progressive shape: a pointer press landing exactly inside the one-RTT upgrade window can lose its click when the pressed row node is replaced (keyboard focus is re-parked on the re-selected row; the pointer window is accepted); slash-platform typed-case drift (macOS) misses the parent-entry match and keeps the single-pane landing; and navigate always relists both legs even when the target is the currently shown level — a crumb tap doubles as the refresh gesture, so freshness wins over reusing possibly-stale in-hand listings at the cost of up to two host scans. - **Symlinks: follow for enterability.** `stat` probes symlinks (broken/cyclic → skipped); crumbs keep the logical path the operator navigated, and `workspace.create` already canonicalizes via realpath at adoption. - **Listing levels are bounded, and streamed.** One `list` call returns at most `maxEntries` rows (config, default 1000 — GitHub's web-UI directory-listing bound). The level streams via `opendir` into a name-sorted window of `maxEntries + 1` candidates, so memory stays O(maxEntries) and enterability probing touches only windowed candidates; the wire `DirectoryListing` carries a required `truncated` flag so the client states incompleteness instead of silently missing tail entries. A windowed broken symlink is not backfilled from beyond the window — the eviction already marks the level truncated. Window insertion is binary with an O(1) full-window tail rejection (an oversized level must not pay a window scan per dirent), and `list(path, signal)` threads the carrier's request signal so a scan of a stalled network directory cannot outlive a disconnected caller — every await in the scan (open, each read, each symlink probe) races the signal, an aborted exit abandons rather than awaits the close (Node queues close behind in-flight reads), and abandoned settlements are swallowed so cleanup can never surface as an unhandled rejection. An unbounded level is a memory/responsiveness hole for large or adversarial directories. - **Whole-filesystem scope, no roots config.** `workspace.create` accepts arbitrary paths and the API serves bash-driving methods, so a browse root would be UX scoping, not a boundary; configurability without a consumer fails the evidence bar. Deferred until a deployment needs it. diff --git a/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.zh.md b/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.zh.md index 30e719ad9b..6331440b04 100644 --- a/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.zh.md @@ -20,7 +20,7 @@ web GUI 的"打开本地文件夹"流程被焊死在一种交互上:`host.pick - **依赖调研(手写 vs 引入)。** Node 标准库本身就是维护中的跨平台 OS 层(`readdir(withFileTypes)`、`homedir`、路径语义);调研过的替代品都过不了依赖门槛——文件管理器包(`node-file-manager`、`files-and-folders`、Syncfusion 的 provider)是整套 HTTP 应用(契合度不过),盘符工具(原生插件 `drivelist`、约七年未更的 `windows-drive-letters`)健康度/比例失当。browse 后端是标准库上的薄适配。 - **隐藏条目:返回并打标。** 宿主标注 `hidden`(POSIX 点前缀约定)并返回全部条目;客户端过滤。展示策略留在客户端,"显示隐藏"开关正是作为这一纯客户端改动落地:标签固定的 footer 开关,其状态由按下态呈现承载(`aria-pressed` + 勾选符号);以点开头的路径草稿前缀会显出它所指名的隐藏条目;当前选中项则不受隐藏与前缀两种过滤影响(它锚定着双栏视图)。Windows 的 `FILE_ATTRIBUTE_HIDDEN` 不被 dirent 暴露——记为限制,直到原生探测值回其成本。 - **路径编辑器的取消范围:对话框卡片。** browse 客户端的路径编辑器在按 Escape 与焦点离开卡片时取消,两者都在卡片范围的包装层而非输入框上监听——Tab 把焦点停到某个过滤命中的行之后,输入框已不在事件路径上,但 Escape 仍须收起编辑器(而非对话框),其后的焦点离开也仍须取消。不取消的豁免:窗口/标签页失焦、卡片内焦点移动,以及指针路径(编辑期间行与开关在 mousedown 时抑制焦点夺取)。预填与草稿末段过滤所用的分隔符从 `listing.home` 推断;下文的线上字段替代方案记录了被延期的权威形态。编辑器与其过滤的列表之间的 combobox 语义(`aria-expanded`/`aria-controls`/active-descendant、结果播报)同样被延期——目前二者在辅助技术看来是彼此独立的控件。 -- **导航以选中项为锚、渐进落地。** 在展示根之外(与 crumb 头部渲染的是同一塌缩,因此 crumb 与分栏形态永不相左),browse 客户端的导航在目标层级到达的那一刻即提交它——这次首个落定即关闭编辑器并结束加载,因此 Enter 提交的导航绝不会为等待更多内容而被撤回——随后父层级这一程就地升级这次落地:重新选中目标在父层级中的实际条目(Windows 上按平台惯例折叠大小写),右侧展示其子项,因此 crumb 跳转读作后退一栏,而不是塌缩成单列。父层级这一程在落地的 supersession 范围下运行,任何较新的意图都会在线上将其中止;父层级这一程失败,或被截断的父窗口缺少目标时,都保留已提交的单栏落地——升级的存在正是为了锚定选中项,绝不能反而让它悬空。 +- **导航以选中项为锚、渐进落地。** 在展示根之外(与 crumb 头部渲染的是同一塌缩,因此 crumb 与分栏形态永不相左),browse 客户端的导航在目标层级到达的那一刻即提交它——这次首个落定即关闭编辑器并结束加载,因此 Enter 提交的导航绝不会为等待更多内容而被撤回——随后父层级这一程就地升级这次落地:重新选中目标在父层级中的实际条目(Windows 上按平台惯例折叠大小写),右侧展示其子项,因此 crumb 跳转读作后退一栏,而不是塌缩成单列。父层级这一程在落地的 supersession 范围下运行,任何较新的意图都会在线上将其中止;父层级这一程失败,或被截断的父窗口缺少目标时,都保留已提交的单栏落地——升级的存在正是为了锚定选中项,绝不能反而让它悬空。渐进形态的已知边界:指针按压若恰好落在单个 RTT 的升级窗口内,可能因所按行节点被替换而丢失点击(键盘焦点会重新停靠到重新选中的行上;指针的这段窗口则被接受);斜杠平台的键入大小写偏差(macOS)会错过父层级条目匹配,保留单栏落地;且导航总是重新列举两程,哪怕目标就是当前展示的层级——crumb 点按兼作刷新手势,因此宁要新鲜度也不复用手头可能已陈旧的列举,代价是至多两次宿主扫描。 - **符号链接:为可进入性而跟随。** 用 `stat` 探测符号链接(断链/循环→跳过);面包屑保留操作者导航的逻辑路径,`workspace.create` 在接纳时本就做 realpath 规范化。 - **列举层级有上限,且流式处理。** 单次 `list` 至多返回 `maxEntries` 行(配置项,默认 1000——GitHub 网页端目录列举的同一上限)。层级经 `opendir` 流入一个按名排序、容量 `maxEntries + 1` 的候选窗口,内存保持 O(maxEntries),可进入性探测只触及窗口内候选;线上 `DirectoryListing` 携带必填的 `truncated` 标志,让客户端明示不完整而不是静默缺尾。窗口内的断链符号链接不从窗口外回填——发生过驱逐本身已把层级标记为截断。窗口插入为二分查找、满窗尾部单次比较即拒绝(超大层级不能为每个 dirent 付出一次全窗扫描),且 `list(path, signal)` 透传载体的请求信号,滞塞网络目录的扫描不会在调用方断连后继续存活——扫描中的每个 await(打开、每次读取、每次符号链接探测)都与信号赛跑,中止路径放弃而非等待 close(Node 会把 close 排在在飞读取之后),被放弃的 settlement 全部吞掉,清理不会以未处理拒绝的形式冒出。无上限的层级对超大或恶意构造的目录就是内存/响应性漏洞。 - **全盘可浏览,不做 roots 配置。** `workspace.create` 接受任意路径且 API 本就提供驱动 bash 的方法,浏览根只会是 UX 范围而非边界;没有消费方的可配置性过不了证据门槛。等到有部署需要再做。 diff --git a/packages/host/directory-picker-browse/src/client/DirectoryBrowser.tsx b/packages/host/directory-picker-browse/src/client/DirectoryBrowser.tsx index 859667d115..5c44492b77 100644 --- a/packages/host/directory-picker-browse/src/client/DirectoryBrowser.tsx +++ b/packages/host/directory-picker-browse/src/client/DirectoryBrowser.tsx @@ -55,13 +55,27 @@ function failureText(error: unknown): string { return error instanceof Error ? error.message : String(error) } +/** + * Case-folds a path for comparisons under the listing's platform: backslash + * (Windows) paths compare case-insensitively — a typed path legally differs + * in case from the host's stamped one — while slash platforms compare + * exactly (the filesystem may be case-sensitive; macOS typed-case drift + * degrades to the single-pane landing instead of a wrong match). + */ +function foldPathFor(listing: DirectoryListing): (value: string) => string { + const sep = separatorOf(listing) + return value => (sep === '\\' ? value.toLowerCase() : value) +} + /** * Breadcrumb rows for display: inside the home subtree the chain starts at a * localized Home crumb; outside it the full ancestry shows, the root labeled - * by its own path. + * by its own path. The home comparison folds per platform so a typed-case + * Windows path still collapses to the Home crumb. */ function displayCrumbs(listing: DirectoryListing, homeLabel: string): DirectoryEntry[] { - const homeIndex = listing.crumbs.findIndex(crumb => crumb.path === listing.home) + const fold = foldPathFor(listing) + const homeIndex = listing.crumbs.findIndex(crumb => fold(crumb.path) === fold(listing.home)) if (homeIndex === -1) return listing.crumbs const tail = listing.crumbs.slice(homeIndex + 1) return [{ name: homeLabel, path: listing.home, hidden: false }, ...tail] @@ -222,6 +236,12 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen, * intent aborts it like the leg it continues, and it supersedes nothing. */ const continueScan = useCallback((path: string): Promise => { + // Abort whatever the slot last tracked before overwriting it (the + // caller's settled leg: a no-op) — the slot must never silently strand + // a live scan, the exact waste supersede() exists to prevent. + const displaced = scanController.current + /* v8 ignore next -- narrowing guard: the landing's target leg installed a controller before any follow-up runs. */ + if (displaced !== null) displaced.abort() const controller = new AbortController() scanController.current = controller return listDirectory(path, controller.signal) @@ -236,9 +256,10 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen, * shape never disagree — a parent leg then upgrades the landing in place: * the target's ACTUAL parent-level entry re-selected (left pane = parent, * right pane = the target), so a crumb jump reads as stepping back one - * pane. A failed parent leg, or a truncated parent window that lacks the - * target, leaves the committed single-pane landing — the upgrade must - * never orphan the selection it exists to anchor. + * pane (Windows folds case; slash-platform typed-case drift degrades to + * the single-pane landing). A failed parent leg, or a truncated parent + * window that lacks the target, leaves the committed single-pane landing + * — the upgrade must never orphan the selection it exists to anchor. */ const navigate = useCallback((path?: string) => { const { seq, scan } = launchListing(path) @@ -259,11 +280,18 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen, continueScan(parentCrumb.path).then((parentLevel) => { if (seq !== requestSeq.current) return // Windows resolves a typed path preserving its case; anchor on the - // parent level's actual entry so selection comparisons hold. - const sep = separatorOf(parentLevel) - const fold = (value: string): string => (sep === '\\' ? value.toLowerCase() : value) + // parent level's actual entry so selection comparisons hold (slash + // platforms compare exactly — see foldPathFor). + const fold = foldPathFor(parentLevel) const match = parentLevel.entries.find(entry => fold(entry.path) === fold(target.path)) if (match === undefined) return + // The upgrade replaces every committed row node; if focus lives + // among them (Tab reached the rows during the parent leg), arm the + // refocus effect so it re-parks on the re-selected row. + const rowHost = millerRowRef.current + /* v8 ignore next -- narrowing guard: the committed landing just rendered the miller row. */ + const focusInRows = rowHost !== null && rowHost.contains(document.activeElement) + if (focusInRows) refocusPick.current = true setParent(parentLevel) setSelected(match) setChild(target) @@ -279,11 +307,13 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen, }) }, [launchListing, continueScan]) - // Editor-close focus parking (consumed by the refocus effect below the - // miller-row ref): a pick parks on the selection's row, Enter and an - // input-focused Escape park on the crumb edit zone that replaces the - // input. Pointer-out cancels never set (or clear) these — yanking focus - // back from wherever the user clicked would be worse than the fall. + // Focus parking (consumed by the refocus effect below the miller-row + // ref): a pick — and a parent-leg upgrade that displaces focused rows — + // parks on the selection's row; Enter, an input-focused Escape, and a + // failed pick whose row unmounts park on the crumb edit zone (the latter + // only when focus actually fell to body). Pointer-out cancels never set + // (or clear) these — yanking focus back from wherever the user clicked + // would be worse than the fall. const refocusPick = useRef(false) const refocusEditZone = useRef(false) const pathInputRef = useRef(null) diff --git a/packages/host/directory-picker-browse/tests/directory-browser.spec.tsx b/packages/host/directory-picker-browse/tests/directory-browser.spec.tsx index 6fa9bb999f..fc2b6f564b 100644 --- a/packages/host/directory-picker-browse/tests/directory-browser.spec.tsx +++ b/packages/host/directory-picker-browse/tests/directory-browser.spec.tsx @@ -270,6 +270,59 @@ describe('DirectoryBrowser', () => { expect(rowButton(screen.getByRole('listitem')).getAttribute('aria-current')).toBeNull() }) + it('re-parks focus on the re-selected row when the upgrade displaces focused rows', async () => { + const settlers: ((value: DirectoryListing) => void)[] = [] + const listDirectory = vi.fn(async (path?: string) => { + if (path === HOME) { + return new Promise((resolve) => { settlers.push(resolve) }) + } + return listingFor(path) + }) + mount({ listDirectory }) + await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() }) + fireEvent.click(screen.getByRole('button', { name: 'browser.editPath' })) + fireEvent.change(screen.getByLabelText('browser.editPath'), { target: { value: DOCS } }) + fireEvent.keyDown(screen.getByLabelText('browser.editPath'), { key: 'Enter' }) + // The committed landing is interactive; Tab reaches its rows while the + // parent leg is still in flight. + await waitFor(() => { expect(screen.getByRole('listitem').textContent).toBe('harness') }) + rowButton(screen.getByRole('listitem')).focus() + await waitFor(() => { expect(settlers).toHaveLength(1) }) + // The upgrade replaces every committed row node; focus re-parks on the + // re-selected row instead of falling to body. + await act(async () => { settlers[0]!(listingFor(HOME)) }) + await waitFor(() => { expect(columns()).toHaveLength(2) }) + expect(document.activeElement?.textContent).toBe('Documents') + expect(document.activeElement?.getAttribute('aria-current')).toBe('true') + }) + + it('collapses a typed-case Windows home to the display root (single pane, Home crumb)', async () => { + const CANON = 'C:\\Users\\Alice' + const TYPED = 'c:\\users\\alice' + const typedHome: DirectoryListing = { + path: TYPED, + home: CANON, + crumbs: [ + { name: 'C:\\', path: 'C:\\', hidden: false }, + { name: 'users', path: 'c:\\users', hidden: false }, + { name: 'alice', path: TYPED, hidden: false }, + ], + entries: [{ name: 'Desktop', path: `${CANON}\\Desktop`, hidden: false }], + truncated: false, + } + const canonHome: DirectoryListing = { ...typedHome, path: CANON, crumbs: typedHome.crumbs } + mount({ listDirectory: vi.fn(async (path?: string) => (path === TYPED ? typedHome : canonHome)) }) + await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() }) + fireEvent.click(screen.getByRole('button', { name: 'browser.editPath' })) + fireEvent.change(screen.getByLabelText('browser.editPath'), { target: { value: TYPED } }) + fireEvent.keyDown(screen.getByLabelText('browser.editPath'), { key: 'Enter' }) + // Case-folded home comparison: the typed-case home is still the display + // root — single pane, collapsed Home crumb, no parent leg. + await waitFor(() => { expect(screen.getByRole('listitem').textContent).toBe('Desktop') }) + expect(columns()).toHaveLength(1) + expect(screen.getByRole('button', { name: 'browser.home' })).toBeTruthy() + }) + it('keeps the single-pane landing when the truncated parent level lacks the target', async () => { const listDirectory = vi.fn(async (path?: string) => { // The parent leg names HOME explicitly; serve it a truncated window From 3d446da9c713a024399d333c41c3b2e9b1feea4a Mon Sep 17 00:00:00 2001 From: Turtle Date: Wed, 29 Jul 2026 21:57:54 +0800 Subject: [PATCH 056/113] docs(web): align bilingual composition wording --- .../2026-07-23-client-plugin-loading-model.i18n.yaml | 2 +- .../architecture/2026-07-23-client-plugin-loading-model.zh.md | 2 +- ...7-24-web-config-tree-boot-and-transport-layering.i18n.yaml | 4 ++-- .../2026-07-24-web-config-tree-boot-and-transport-layering.md | 2 +- ...26-07-24-web-config-tree-boot-and-transport-layering.zh.md | 2 +- .../testing/2026-07-24-web-gui-browser-e2e-lane.i18n.yaml | 4 ++-- .../testing/2026-07-24-web-gui-browser-e2e-lane.md | 4 ++-- .../testing/2026-07-24-web-gui-browser-e2e-lane.zh.md | 4 ++-- 8 files changed, 12 insertions(+), 12 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.i18n.yaml index bab12b6785..229a5fb221 100644 --- a/.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.md 2026-07-23-client-plugin-loading-model.md: ef9f14f2a150b0845ff89939bce5f8863dc15707 -2026-07-23-client-plugin-loading-model.zh.md: 0fbcbb0717be9d5aac27c33d9482c7e13bf7f4ea +2026-07-23-client-plugin-loading-model.zh.md: 71dc1e2303b657a3793b4ddd5c263337177ad671 diff --git a/.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.zh.md b/.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.zh.md index 0fbcbb0717..71dc1e2303 100644 --- a/.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-23-client-plugin-loading-model.zh.md @@ -116,7 +116,7 @@ wire 两侧跑着同一份治理实现;浏览器特有的表面只是一套模 接受的代价:vendored Loader 在浏览器里背着闲置机件(EntryTree 持久化是 no-op,分组/隔离未用);开发期每次修改插件都要付一次 bundle 重建加 fiber 重挂;图中 `inject` 行仅是信息性说明——激活的真相在服务层——因此不匹配会在 settled 扫描时浮出,而不是在图校验时被拦下;三个尚未升格的库在各自的 DI 转换落地之前保持静态 import 的导出面。 -名册的终局(2026-07-25 随配置树 boot 迁移落地):名册住 `apps/cli/base.cordis.yml` plus its surface overlay,`mountWebPlugins` 与 `CLIENT_PACKAGES` 常量已消失,重组一次部署等于换 yml/overlay。图的组合器从 webserver 侧的注册表迁进 `dsh-client-modules` 的 node 半(该包按本 note 的升级法则升格为双面——其消费方现经 cordis DI 到达),传输拆分同轮落地:webserver 变为朴素路由注册插件,`/api/*` 绑定迁到 connection 的 node 半、走升格后的 `api-gateway` 插件(`dsh-host-apiproxy` 提供 `ctx.apiProxy`),dev 的 bundle 监视与 SSE 通道迁到 hmr 的 node 半。 +名册的终局(2026-07-25 随配置树 boot 迁移落地):名册住 `apps/cli/base.cordis.yml` 与 `apps/cli/web.cordis.yml`,`mountWebPlugins` 与 `CLIENT_PACKAGES` 常量已消失,重组一次部署等于换 yml/overlay。图的组合器从 webserver 侧的注册表迁进 `dsh-client-modules` 的 node 半(该包按本 note 的升级法则升格为双面——其消费方现经 cordis DI 到达),传输拆分同轮落地:webserver 变为朴素路由注册插件,`/api/*` 绑定迁到 connection 的 node 半、走升格后的 `api-gateway` 插件(`dsh-host-apiproxy` 提供 `ctx.apiProxy`),dev 的 bundle 监视与 SSE 通道迁到 hmr 的 node 半。 ## Alternatives considered diff --git a/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.i18n.yaml index 1d723d4d94..32dd80bff1 100644 --- a/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.md -2026-07-24-web-config-tree-boot-and-transport-layering.md: bf1dd829af73e61f2545c1f9034bc7ffda7042ed -2026-07-24-web-config-tree-boot-and-transport-layering.zh.md: 03c1b64f3a9a17cb0f607764ea2c1a52b77551a3 +2026-07-24-web-config-tree-boot-and-transport-layering.md: af9d5b4d19c96da186be85e16681ba9c38f2a25a +2026-07-24-web-config-tree-boot-and-transport-layering.zh.md: 475b546e05ce7d9ce405266585882ffd36a27fad diff --git a/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.md b/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.md index bf1dd829af..af9d5b4d19 100644 --- a/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.md +++ b/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.md @@ -12,7 +12,7 @@ English | [中文](2026-07-24-web-config-tree-boot-and-transport-layering.zh.md) ## Decision -**Composition is one flat config tree.** `apps/cli/base.cordis.yml` plus its surface overlay holds every row — the host runtime (32 rows), the `api-gateway` row, the `webserver` row, and the `dshClient` rows (the browser roster; the modules row is simultaneously a host row). No spine bundle: every plugin is one row and every config field is yml-editable. That stance later became repository-wide, with the rows both surfaces share factored into `apps/cli/base.cordis.yml` and each surface reduced to an overlay ([shared-base overlays](../simplification/2026-07-29-shared-base-config-overlays.md)). `--dev` appends the `dsh-client-hmr` row in code before the settle sweep — prod and dev differ by exactly that row. Row order carries no load semantics; activation is service-availability driven, and the boot compensates with a fail-loud triple: `assertEntriesLoaded` (import failures), `installFailLoud` (late apply rejections), and an all-ACTIVE sweep (PENDING fibers — cordis inject waiting has no timeout). +**Composition is one flat assembled tree.** `apps/cli/base.cordis.yml` plus `apps/cli/web.cordis.yml` holds every row — the host runtime (32 rows), the `api-gateway` row, the `webserver` row, and the `dshClient` rows (the browser roster; the modules row is simultaneously a host row). No spine bundle: every plugin is one row and every config field is yml-editable. That stance later became repository-wide, with the rows both surfaces share factored into `apps/cli/base.cordis.yml` and each surface reduced to an overlay ([shared-base overlays](../simplification/2026-07-29-shared-base-config-overlays.md)). `--dev` appends the `dsh-client-hmr` row in code before the settle sweep — prod and dev differ by exactly that row. Row order carries no load semantics; activation is service-availability driven, and the boot compensates with a fail-loud triple: `assertEntriesLoaded` (import failures), `installFailLoud` (late apply rejections), and an all-ACTIVE sweep (PENDING fibers — cordis inject waiting has no timeout). **Boot glue is a class pair.** `AppCLIEntry` (apps/cli) and `AppWebEntry` (the shell kernel) hold only what must exist independently of cordis: argv facts, the composed patch set, the parsed boot manifest, the module system instance, loading-page handles — everything else lives in plugins. `AppCLIEntry.run()` is three stages: layered env (ambient > cwd `.env` > `$DSH_HOME/.env`, closing the defect above) → patch composition → Loader include boot plus the triple. `AppWebEntry.run()` mirrors it browser-side: parse `window.__DSH_BOOT__` into a `BootManifest` (two views: npm-package rows for the module table, cordis-plugin rows for entry composition; malformed wire throws), build the module system, render the loading page, prefetch the `immediately` tier in parallel with Context/Loader setup, **await the prefetch before creating entries** (materialization is `tree.import`'s synchronous require, unprotected by fiber inject waiting; cross-package require edges such as i18n → runtime/client need every immediately-tier factory registered first — an empirically found 10–25% boot race otherwise), adopt the modules entry, create the graph rows, settle, sweep. diff --git a/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.zh.md b/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.zh.md index 03c1b64f3a..475b546e05 100644 --- a/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-24-web-config-tree-boot-and-transport-layering.zh.md @@ -12,7 +12,7 @@ Status: implemented ## 决策 -**组合是一棵平铺 config tree。** `apps/cli/base.cordis.yml` plus its surface overlay 持有全部行——host runtime(32 行)、`api-gateway` 行、`webserver` 行、`dshClient` 行(浏览器 roster;modules 行同时是 host 行)。不做 spine bundle:每插件一行、每个 config 字段 yml 可改。这一立场后来推广到全仓:两个 surface 共享的配置项被抽取进 `apps/cli/base.cordis.yml`,各 surface 则收敛为一份 overlay([共享 base overlay](../simplification/2026-07-29-shared-base-config-overlays.md))。`--dev` 在 settle sweep 之前由代码追加 `dsh-client-hmr` 行——prod 与 dev 的全部差异就是这一行。行序无装载语义;激活由服务可用性驱动,boot 以 fail-loud 三件套补偿:`assertEntriesLoaded`(import 失败)、`installFailLoud`(迟到的 apply 拒绝)、all-ACTIVE sweep(PENDING fiber——cordis inject 等待没有超时)。 +**组合结果是一棵平铺配置树。** `apps/cli/base.cordis.yml` 与 `apps/cli/web.cordis.yml` 共同持有全部行——host runtime(32 行)、`api-gateway` 行、`webserver` 行、`dshClient` 行(浏览器 roster;modules 行同时是 host 行)。不做 spine bundle:每插件一行、每个 config 字段 yml 可改。这一立场后来推广到全仓:两个 surface 共享的配置项被抽取进 `apps/cli/base.cordis.yml`,各 surface 则收敛为一份 overlay([共享 base overlay](../simplification/2026-07-29-shared-base-config-overlays.md))。`--dev` 在 settle sweep 之前由代码追加 `dsh-client-hmr` 行——prod 与 dev 的全部差异就是这一行。行序无装载语义;激活由服务可用性驱动,boot 以 fail-loud 三件套补偿:`assertEntriesLoaded`(import 失败)、`installFailLoud`(迟到的 apply 拒绝)、all-ACTIVE sweep(PENDING fiber——cordis inject 等待没有超时)。 **boot 胶水是一对 class。** `AppCLIEntry`(apps/cli)与 `AppWebEntry`(壳内核)只持有独立于 cordis 必须提前存在的东西:argv 事实、合成的 patch 集、解析出的 boot manifest、模块系统实例、loading 页句柄——其余一律进插件。`AppCLIEntry.run()` 三段:分层 env(ambient > cwd `.env` > `$DSH_HOME/.env`,顺手关掉上述缺陷)→ patch 合成 → Loader include boot 加三件套。`AppWebEntry.run()` 在浏览器侧镜像它:把 `window.__DSH_BOOT__` 解析成 `BootManifest`(双视角:npm 包行给模块表、cordis 插件行给 entry 组合;畸形 wire 大声抛)、建模块系统、渲染 loading 页、immediately 层预取与 Context/Loader 准备并行、**create entry 之前等预取齐**(物化是 `tree.import` 的同步 require,不受 fiber inject 等待保护;i18n → runtime/client 这类跨包 require 边要求 immediately 层工厂全部注册完——否则有实测 10–25% 的 boot 竞态)、收编 modules entry、逐图行 create、settle、sweep。 diff --git a/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.i18n.yaml b/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.i18n.yaml index 52f8014d7d..71a866630f 100644 --- a/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.i18n.yaml +++ b/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.md -2026-07-24-web-gui-browser-e2e-lane.md: 04240456d1df4eab79372de0ebd900f5831cd819 -2026-07-24-web-gui-browser-e2e-lane.zh.md: 6be7056b3d46be875b74c56d93045a37fd3d2c08 +2026-07-24-web-gui-browser-e2e-lane.md: 763c6b4b06e951932626babb7a3f48261a475e1d +2026-07-24-web-gui-browser-e2e-lane.zh.md: 978148fec0c4ae1c177f181802ec980f2cf196a1 diff --git a/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.md b/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.md index 04240456d1..763c6b4b06 100644 --- a/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.md +++ b/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.md @@ -16,7 +16,7 @@ The web GUI ships as a real assembled chain — chromium page → client plugin A plain shared-fixture module (the [testing-policy sanctioned shape](../../../../docs/testing.md)), not a package: the gate-worthy logic — replay derivation, session parsing, log scrubbing, persistence — lives in the gated packages `dsh-llm-replay`, `dsh-acp-snapshot`, and `dsh-session-persistence-jsonl`; what remains is boot wiring and browser glue, and chromium-driving code cannot hold per-file 100% coverage on the browserless coverage runners. -`launchWebScaffold()` boots the real web composition from the shipped `apps/cli/base.cordis.yml` plus its surface overlay through the vendored Loader's include mechanism — the same tree and mechanism `AppCLIEntry` drives for `dsh web`. Divergences ride include patches over that tree, the ACP `cordis.snapshot.yml` pattern expressed in-process: temp `persistenceRoot`, `workspace-context` disabled (recorded fixtures must not embed this repo's AGENTS.md), `session-title-llm` disabled (its fire-and-forget title call would race the loop for the session's replay cursor), the webserver row pinned to port 0 with the built dist, and in keyless modes `llm-deepseek` disabled. A patch id that stops matching a row fails the boot sweep loudly instead of drifting. The boot runs `chdir`'d to the temp workspace so the api-gateway's `process.cwd()` session default, tool cwds, and fixtures agree; the `dsh web` bin's own glue (argv, profile json, AppCLIEntry) stays held by the keyless CLI smokes in `smoke-real.e2e.ts`. Setup rollback and ordinary close both dispose the Cordis tree before removing the two owned temp roots, attempt every cleanup independently, and report cleanup failures without masking the setup failure. +`launchWebScaffold()` boots the real web composition from the shipped `apps/cli/base.cordis.yml` plus `apps/cli/web.cordis.yml` through the vendored Loader's include mechanism — the same tree and mechanism `AppCLIEntry` drives for `dsh web`. Divergences ride include patches over that tree, the ACP `cordis.snapshot.yml` pattern expressed in-process: temp `persistenceRoot`, `workspace-context` disabled (recorded fixtures must not embed this repo's AGENTS.md), `session-title-llm` disabled (its fire-and-forget title call would race the loop for the session's replay cursor), the webserver row pinned to port 0 with the built dist, and in keyless modes `llm-deepseek` disabled. A patch id that stops matching a row fails the boot sweep loudly instead of drifting. The boot runs `chdir`'d to the temp workspace so the api-gateway's `process.cwd()` session default, tool cwds, and fixtures agree; the `dsh web` bin's own glue (argv, profile json, AppCLIEntry) stays held by the keyless CLI smokes in `smoke-real.e2e.ts`. Setup rollback and ordinary close both dispose the Cordis tree before removing the two owned temp roots, attempt every cleanup independently, and report cleanup failures without masking the setup failure. Keyless model displacement is the disabled adapter row plus `installLlmReplay` filling the open seam on the settled root ctx in providers-catalog mode — never catch-all: with the adapter row disabled no adapter exists, so catch-all would leave `resolveModelInfo` unroutable and `compact-basic`'s post-step pressure check would warn every step instead of being provably inert (the published 128k `contextWindow` keeps it inert for small fixtures). The direct install rather than an inserted replay plugin row is deliberate: it returns the `ReplayHandle` the teardown consumption check needs. A scenario with no fixture leaves the seam empty, so a stray stream fails loud with NO_ADAPTER. @@ -66,7 +66,7 @@ Surveyed AI-chat/agent web UIs and mocking layers (LibreChat, vercel/ai-chatbot **A committed normalized-session-log golden as a second expected surface.** Rejected: the log surface is pinned by the ACP/headless/TUI suites through the same loop and persistence; here it would double refresh cost and re-test lower tiers. Inline world-state assertions on root-context events keep the world-verification duty. -**Spawning the `dsh web` bin with a `DSH_SNAPSHOT` replay branch.** Rejected: it needs a test-only replay branch plus environment plumbing in the shipped CLI. The in-process scaffold already loads the same `apps/cli/base.cordis.yml` plus its surface overlay; only argv, profile JSON, and `AppCLIEntry` glue remain outside it, and the keyless CLI smokes cover those paths. +**Spawning the `dsh web` bin with a `DSH_SNAPSHOT` replay branch.** Rejected: it needs a test-only replay branch plus environment plumbing in the shipped CLI. The in-process scaffold already loads the same `apps/cli/base.cordis.yml` plus `apps/cli/web.cordis.yml`; only argv, profile JSON, and `AppCLIEntry` glue remain outside it, and the keyless CLI smokes cover those paths. **Changing the wire protocol for testability.** Rejected: the contract already has a first-class keyless isomorphic seam (`InProcessApiClient(toFetchHandler(api))`), the per-event unbatched SSE is exactly what makes replay observable in a browser, and testing a wire we no longer ship would invert the tier's purpose. diff --git a/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.zh.md b/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.zh.md index 6be7056b3d..978148fec0 100644 --- a/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.zh.md +++ b/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.zh.md @@ -16,7 +16,7 @@ Web GUI 以一条真实组装链交付——chromium 页面 → client 插件 bu 一个普通的共享 fixture 模块([测试政策认可的形态](../../../../docs/testing.md)),不是包:值得门禁把守的逻辑——回放推导、会话解析、日志脱敏、持久化——都在已受门禁的包 `dsh-llm-replay`、`dsh-acp-snapshot`、`dsh-session-persistence-jsonl` 中;剩下的只是启动接线和浏览器胶水,而驱动 chromium 的源码在无浏览器的覆盖率 runner 上无法诚实保持逐文件 100% 覆盖率。 -`launchWebScaffold()` 通过 vendored Loader 的 include 机制,从交付的 `apps/cli/base.cordis.yml` plus its surface overlay 启动真实 web 组合——与 `AppCLIEntry` 为 `dsh web` 驱动的是同一棵树、同一套机制。差异全部经 include patch 覆盖在这棵树上,即 ACP `cordis.snapshot.yml` 模式的进程内表达:临时 `persistenceRoot`;禁用 `workspace-context`(录制的 fixture 不得嵌入本仓库的 AGENTS.md);禁用 `session-title-llm`(其发后不管的标题调用会与循环争抢会话的回放游标);webserver 行钉到端口 0 加已构建 dist;无密钥模式下禁用 `llm-deepseek`。patch 的 id 一旦不再匹配任何行,boot 扫描会大声失败而不是漂移。boot 在临时工作区 `chdir` 下运行,使 api-gateway 的 `process.cwd()` 会话默认值、工具 cwd 与 fixture 一致;`dsh web` bin 自身的胶水(argv、profile json、AppCLIEntry)仍由 `smoke-real.e2e.ts` 中的无密钥 CLI 冒烟把守。初始化回滚和正常关闭都会先对 Cordis 树执行 dispose(资源释放),再删除 scaffold 持有的两个临时根目录;每项清理都会独立尝试,并会报告清理失败而不掩盖初始化失败。 +`launchWebScaffold()` 通过 vendored Loader 的 include 机制,从交付的 `apps/cli/base.cordis.yml` 与 `apps/cli/web.cordis.yml` 启动真实 web 组合——与 `AppCLIEntry` 为 `dsh web` 驱动的是同一棵树、同一套机制。差异全部经 include patch 覆盖在这棵树上,即 ACP `cordis.snapshot.yml` 模式的进程内表达:临时 `persistenceRoot`;禁用 `workspace-context`(录制的 fixture 不得嵌入本仓库的 AGENTS.md);禁用 `session-title-llm`(其发后不管的标题调用会与循环争抢会话的回放游标);webserver 行钉到端口 0 加已构建 dist;无密钥模式下禁用 `llm-deepseek`。patch 的 id 一旦不再匹配任何行,boot 扫描会大声失败而不是漂移。boot 在临时工作区 `chdir` 下运行,使 api-gateway 的 `process.cwd()` 会话默认值、工具 cwd 与 fixture 一致;`dsh web` bin 自身的胶水(argv、profile json、AppCLIEntry)仍由 `smoke-real.e2e.ts` 中的无密钥 CLI 冒烟把守。初始化回滚和正常关闭都会先对 Cordis 树执行 dispose(资源释放),再删除 scaffold 持有的两个临时根目录;每项清理都会独立尝试,并会报告清理失败而不掩盖初始化失败。 无密钥的模型替换 = 禁用适配器行的 patch 加 `installLlmReplay` 在停稳的根 ctx 上以提供方目录(providers-catalog)模式填充开放的 seam——绝不用 catch-all:适配器行被禁用后不存在任何适配器,catch-all 会让 `resolveModelInfo` 无路由可走,`compact-basic` 的步后压力检查将步步告警,而不是被可证明地闲置(发布的 128k `contextWindow` 使该路径对小 fixture 保持闲置)。选择直接安装而非插入回放插件行是刻意的:直接安装返回收尾消费检查所需的 `ReplayHandle`。没有 fixture 的场景让 seam 保持空置,任何离群的流式调用都会以 NO_ADAPTER 大声失败。 @@ -66,7 +66,7 @@ Web GUI 以一条真实组装链交付——chromium 页面 → client 插件 bu **第二份提交的规范化会话日志预期输出。** 已否决:日志表面已由 ACP/headless/TUI 套件经同一循环与持久化钉住;在此只会翻倍刷新成本并重复测试下层。内联在根上下文事件上的世界状态断言保住了验证世界的义务。 -**以 `DSH_SNAPSHOT` 回放分支拉起 `dsh web` bin。** 已否决:它需要在交付的 CLI 中增加测试专用回放分支和环境变量管道。进程内 scaffold 已加载同一份 `apps/cli/base.cordis.yml` plus its surface overlay;只剩 argv、profile JSON 和 `AppCLIEntry` 胶水不在其覆盖范围内,而这些路径已由无密钥 CLI 冒烟覆盖。 +**以 `DSH_SNAPSHOT` 回放分支拉起 `dsh web` bin。** 已否决:它需要在交付的 CLI 中增加测试专用回放分支和环境变量管道。进程内 scaffold 已加载同一份 `apps/cli/base.cordis.yml` 与 `apps/cli/web.cordis.yml`;只剩 argv、profile JSON 和 `AppCLIEntry` 胶水不在其覆盖范围内,而这些路径已由无密钥 CLI 冒烟覆盖。 **为可测试性改 wire 协议。** 已否决:契约已有第一等的无密钥同构 seam(`InProcessApiClient(toFetchHandler(api))`),逐事件不合批的 SSE 恰是回放在浏览器中可观测的原因,测试一条不再交付的 wire 会颠倒该层的存在意义。 From 3d3a2617932edf52c07f6ba9259d8d4e67994231 Mon Sep 17 00:00:00 2001 From: Turtle Date: Wed, 29 Jul 2026 21:59:44 +0800 Subject: [PATCH 057/113] docs(cli): remove stale composition references --- apps/cli/README.i18n.yaml | 4 ++-- apps/cli/README.md | 2 +- apps/cli/README.zh.md | 2 +- apps/cli/src/app-cli-entry.ts | 4 ++-- apps/cli/src/args.ts | 4 ++-- apps/cli/src/headless.ts | 2 +- apps/cli/src/tui.ts | 7 ++----- apps/cli/src/web.ts | 4 ++-- apps/cli/tests/args.spec.ts | 4 ++-- apps/cli/tests/built-bin.e2e.ts | 2 +- apps/cli/tests/sessions-root.spec.ts | 2 +- apps/cli/tests/tui.snapshot.ts | 2 +- 12 files changed, 18 insertions(+), 21 deletions(-) diff --git a/apps/cli/README.i18n.yaml b/apps/cli/README.i18n.yaml index 80c388280b..3b54366fab 100644 --- a/apps/cli/README.i18n.yaml +++ b/apps/cli/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write apps/cli/README.md -README.md: 7cde0dd8c9c6cf794cf8d1676ed6938e204117cf -README.zh.md: 3b21d563cbd8458810cd05f1ef71bd88074f294f +README.md: 25f201a082961db9cd4760334d2ab07a13deab89 +README.zh.md: f288bf61b77e5fe5cfdafafdc1fdfbf9f5669582 diff --git a/apps/cli/README.md b/apps/cli/README.md index 7cde0dd8c9..25f201a082 100644 --- a/apps/cli/README.md +++ b/apps/cli/README.md @@ -3,7 +3,7 @@ English | [中文](README.zh.md) -Argv is parsed once through a [Commander](https://github.com/tj/commander.js) adapter ([`src/args.ts`](src/args.ts)): one program whose default (no subcommand) is the TUI/headless surface (`--config`, `-p`/`--prompt`, `--resume`), whose `meta` subcommand is the same TUI over this checkout, whose `upgrade` subcommands are option-less guided-session entries, and whose `web` subcommand is the browser UI. `src/bin.ts` switches on the resolved mode and dynamic-imports only that mode's module. `dsh --help` lists every mode and `dsh web --help` renders the web usage, `dsh --version` prints this app's version, and an unknown option or a mistyped `--resume` fails loud (stderr, exit 1) instead of misrouting. Every subcommand that shares no option with the default surface — `migrate`, `upgrade`, `web` — rejects a leaked `--config`/`-p`/`--resume` rather than running and dropping it. `dsh web`'s `--host`/`--port` are unvalidated pass-through overrides: the `dsh-host-webserver` schema is the single source of both the default (the shipped `cordis.yml` value when a flag is absent) and validity, and rejects a bad value at boot. `--trusted-host` appends named authorities for the /api browser-trust fence; an all-interfaces bind additionally derives the machine's LAN IP literals itself ([`src/app-cli-entry.ts`](src/app-cli-entry.ts)), so the printed LAN URL works without flags. +Argv is parsed once through a [Commander](https://github.com/tj/commander.js) adapter ([`src/args.ts`](src/args.ts)): one program whose default (no subcommand) is the TUI/headless surface (`--config`, `-p`/`--prompt`, `--resume`), whose `meta` subcommand is the same TUI over this checkout, whose `upgrade` subcommand is an option-less guided-session entry, and whose `web` subcommand is the browser UI. `src/bin.ts` switches on the resolved mode and dynamic-imports only that mode's module. `dsh --help` lists every mode and `dsh web --help` renders the web usage, `dsh --version` prints this app's version, and an unknown option or a mistyped `--resume` fails loud (stderr, exit 1) instead of misrouting. Every subcommand that shares no option with the default surface — `upgrade`, `web`, `meta` — rejects a leaked `--config`/`-p`/`--resume` rather than running and dropping it. `dsh web`'s `--host`/`--port` are unvalidated pass-through overrides: the `dsh-host-webserver` schema is the single source of both the default (the shipped Web overlay value when a flag is absent) and validity, and rejects a bad value at boot. `--trusted-host` appends named authorities for the /api browser-trust fence; an all-interfaces bind additionally derives the machine's LAN IP literals itself ([`src/app-cli-entry.ts`](src/app-cli-entry.ts)), so the printed LAN URL works without flags. The TUI surface: diff --git a/apps/cli/README.zh.md b/apps/cli/README.zh.md index 3b21d563cb..f288bf61b7 100644 --- a/apps/cli/README.zh.md +++ b/apps/cli/README.zh.md @@ -3,7 +3,7 @@ [English](README.md) | 中文 -Argv 只会通过 [Commander](https://github.com/tj/commander.js) 适配器([`src/args.ts`](src/args.ts))解析一次:同一个程序的默认形式(无子命令)是 TUI/无头界面(`--config`、`-p`/`--prompt`、`--resume`),`meta` 子命令是以本 checkout 为 workspace 的同一个 TUI,`upgrade` 子命令是无选项的引导会话入口,`web` 子命令则是浏览器 UI。`src/bin.ts` 按解析后的 mode 分支,仅动态导入该 mode 的模块。`dsh --help` 列出所有 mode,`dsh web --help` 渲染 Web 用法,`dsh --version` 打印此应用的版本;未知选项或拼错的 `--resume` 会明确报错(stderr,退出码 1),而不会被错路由。凡与默认界面不共享任何选项的子命令(`migrate`、`upgrade`、`web`)都会拒绝泄漏进来的 `--config`/`-p`/`--resume`,而不会照常运行并丢弃它。`dsh web` 的 `--host`/`--port` 是未验证的直通覆盖:`dsh-host-webserver` schema 是默认值(标志缺失时使用已交付的 `cordis.yml` 值)和有效性的唯一真源,并在启动时拒绝错误值。`--trusted-host` 为 /api 浏览器信任栅栏追加具名权威;全接口绑定还会自行推导本机的 LAN IP 字面量([`src/app-cli-entry.ts`](src/app-cli-entry.ts)),因此打印出的 LAN URL 无需任何标志即可使用。 +Argv 只会通过 [Commander](https://github.com/tj/commander.js) 适配器([`src/args.ts`](src/args.ts))解析一次:同一个程序的默认形式(无子命令)是 TUI/无头界面(`--config`、`-p`/`--prompt`、`--resume`),`meta` 子命令是以本 checkout 为 workspace 的同一个 TUI,`upgrade` 子命令是无选项的引导会话入口,`web` 子命令则是浏览器 UI。`src/bin.ts` 按解析后的 mode 分支,仅动态导入该 mode 的模块。`dsh --help` 列出所有 mode,`dsh web --help` 渲染 Web 用法,`dsh --version` 打印此应用的版本;未知选项或拼错的 `--resume` 会明确报错(stderr,退出码 1),而不会被错路由。凡与默认界面不共享任何选项的子命令(`upgrade`、`web`、`meta`)都会拒绝泄漏进来的 `--config`/`-p`/`--resume`,而不会照常运行并丢弃它。`dsh web` 的 `--host`/`--port` 是未验证的直通覆盖:`dsh-host-webserver` schema 是默认值(标志缺失时使用已交付的 Web 覆盖层值)和有效性的唯一真源,并在启动时拒绝错误值。`--trusted-host` 为 /api 浏览器信任栅栏追加具名权威;全接口绑定还会自行推导本机的 LAN IP 字面量([`src/app-cli-entry.ts`](src/app-cli-entry.ts)),因此打印出的 LAN URL 无需任何标志即可使用。 TUI 界面: diff --git a/apps/cli/src/app-cli-entry.ts b/apps/cli/src/app-cli-entry.ts index c0e67bae00..ab86f1f77f 100644 --- a/apps/cli/src/app-cli-entry.ts +++ b/apps/cli/src/app-cli-entry.ts @@ -1,8 +1,8 @@ /** * AppCLIEntry — the pre-cordis boot glue the config-tree dsh surfaces share - * (`dsh web` and `dsh -p` boot the one composition; TUI migrates later). + * for the Web/headless surface. * Everything here is what must exist before the Loader runs: layered env, - * the patch composition over the shipped cordis.yml (profile json + CLI + * the patch composition over the shipped base and surface overlay (profile json + CLI * flags + the resolved frontend dist), and the fail-loud triple after the * tree settles. */ diff --git a/apps/cli/src/args.ts b/apps/cli/src/args.ts index ca898b08ea..f5600ff610 100644 --- a/apps/cli/src/args.ts +++ b/apps/cli/src/args.ts @@ -49,7 +49,7 @@ interface SkillSessionInvocation { * passed — pass-through overrides with no CLI default and no CLI validation: * the `dsh-host-webserver` schema (`host` a loopback/all-interfaces literal, * `port` a natural ≤ 65535) is the single source of both the default (the - * shipped `cordis.yml` value stands when a flag is absent) and validity (a bad + * shipped Web overlay value stands when a flag is absent) and validity (a bad * value fails loud at boot). `port` is `Number`-coerced only because the schema * wants a number, not a string. `dev` mounts the client HMR driver; * `workspaceRoot` is the parent directory for name-created workspaces. @@ -185,7 +185,7 @@ Examples: }) // Host and port name no default: the CLI passes neither through when the flag - // is absent, so the shipped `cordis.yml` value stands and restating it here + // is absent, so the shipped Web overlay value stands and restating it here // would duplicate a fact this file does not own. const web = program.command('web').description('serve the browser UI on the configured host and port') web diff --git a/apps/cli/src/headless.ts b/apps/cli/src/headless.ts index 8007b30d44..41381a3616 100644 --- a/apps/cli/src/headless.ts +++ b/apps/cli/src/headless.ts @@ -1,6 +1,6 @@ /** * `dsh -p "task"` — headless over the one shared composition: AppCLIEntry - * boots the same cordis.yml as `dsh web` (port 0, so parallel runs never + * boots the same base plus Web overlay as `dsh web` (port 0, so parallel runs never * collide), then in-process isomorphic injection (InProcessApiClient over * toFetchHandler(ctx.apiProxy), so the full carrier chain — wire * serialization, zod, SSE framing — really runs). The printed URL opens the diff --git a/apps/cli/src/tui.ts b/apps/cli/src/tui.ts index 9fcc3336d9..f1324d8b26 100644 --- a/apps/cli/src/tui.ts +++ b/apps/cli/src/tui.ts @@ -1,6 +1,6 @@ /** * `dsh` default surface — the interactive TUI coding agent. Boots the shipped - * tui-agent config (or the `--config` override) with the personal overlay + * shared base and TUI overlay, followed by either `--config` or the personal overlay * from the Harness home (`~/.dsh`): its `.env` fills environment gaps (precedence: * ambient environment, then the invoking directory's `.env`, then the personal one) * and its `config.yaml` patches the booted tree. The workspace is the invoking @@ -47,9 +47,6 @@ import { const NAME = 'dsh' -// Both the source tree (apps/cli/src) and the bundled bin (apps/cli/lib) sit -// one directory under apps/cli, so the shipped default config resolves with -// the same relative hop from either artifact. // The shared core every `dsh` surface mounts, and the TUI's own overlay over // it. Both the source tree (apps/cli/src) and the bundled bin (apps/cli/lib) // sit one directory under apps/cli, so each resolves with the same hop. @@ -81,7 +78,7 @@ export function launcherSessionsRoot(): string { } /* v8 ignore start -- composition over the unit-tested dsh-app-boot helpers; - the tui-agent PTY smoke drives this path end to end, personal overlay included */ + the CLI PTY smoke drives this path end to end, personal overlay included */ /** * Run the interactive TUI with this harness checkout as the workspace * (`dsh meta`), whatever directory it was launched from. diff --git a/apps/cli/src/web.ts b/apps/cli/src/web.ts index 85f79d5fc4..d6e0c159d4 100644 --- a/apps/cli/src/web.ts +++ b/apps/cli/src/web.ts @@ -1,7 +1,7 @@ /** * `dsh web` — thin bin over the config-tree boot: run AppCLIEntry with the * already-parsed host/port/dev, print the URL line, wire signals. All - * composition lives in cordis.yml; all boot glue lives in AppCLIEntry. Host and + * composition lives in the shared base plus Web overlay; all boot glue lives in AppCLIEntry. Host and * port are unvalidated pass-through overrides — the `dsh-host-webserver` schema * gates them at boot. */ @@ -20,7 +20,7 @@ const LOOPBACK_HOST = '127.0.0.1' /** * Serve the browser UI from the shipped config tree. `host`/`port` are passed - * through only when the flag was given; absent, the `cordis.yml` value stands. + * through only when the flag was given; absent, the shipped Web overlay value stands. * @param host - the bind host, or `undefined` to keep the config default. * @param port - the listen port (`0` requests an OS-assigned port), or `undefined` to keep the config default. * @param dev - mount the client HMR driver and watch plugin bundles for rebuilds. diff --git a/apps/cli/tests/args.spec.ts b/apps/cli/tests/args.spec.ts index ccbd5fa326..6e7211694c 100644 --- a/apps/cli/tests/args.spec.ts +++ b/apps/cli/tests/args.spec.ts @@ -33,7 +33,7 @@ describe('parseDshArgs', () => { expect(parse(['meta'])).toEqual({ mode: 'meta' }) // Credential setup is option-free: it writes the Harness-home .env, so // there is nothing for a flag to select. - // Bare `web` carries no host/port: the shipped cordis.yml owns the default. + // Bare `web` carries no host/port: the shipped Web overlay owns the default. expect(parse(['web'])).toEqual({ mode: 'web', dev: false }) // Host/port are unvalidated pass-throughs (the webserver schema gates them // at boot); the adapter only coerces the port string to a number. @@ -64,7 +64,7 @@ describe('parseDshArgs', () => { expect(exitCode(['web', '--resume', 's'])).toBe(1) expect(exitCode(['--config', 'c.yml', 'web'])).toBe(1) expect(exitCode(['--config-replace', 'tree.yml', 'web'])).toBe(1) - // Same rule for credential setup: it shares no option with the default + // Same rule for each subcommand that shares no option with the default // surface, so a leaked flag is a typo, not something to ignore. // `meta` fixes its own config tree and always starts fresh, so every // default-surface option is rejected. diff --git a/apps/cli/tests/built-bin.e2e.ts b/apps/cli/tests/built-bin.e2e.ts index 1ea7d9f0db..d5bcbfd378 100644 --- a/apps/cli/tests/built-bin.e2e.ts +++ b/apps/cli/tests/built-bin.e2e.ts @@ -16,7 +16,7 @@ import { describe, expect, it } from 'vitest' * node_modules, so no external consumer is assembled; missing-config fail-loud * and full-boot coverage for the shared dsh-app-boot glue live in cli-demo's * built-bin suite, and interactive TTY behavior is PTY-covered by - * examples/tui-agent. Skips before the bin is built. + * apps/cli/tests. Skips before the bin is built. */ const repoRoot = fileURLToPath(new URL('../../../', import.meta.url)) diff --git a/apps/cli/tests/sessions-root.spec.ts b/apps/cli/tests/sessions-root.spec.ts index 8f12455f1b..cb9982615a 100644 --- a/apps/cli/tests/sessions-root.spec.ts +++ b/apps/cli/tests/sessions-root.spec.ts @@ -3,7 +3,7 @@ * its opaque `SESSIONS_ROOT_KEY` boot-slot value to `DSH_HOME/sessions`. The * plugin side — the slot treated as opaque, explicit config winning, and a * project-local fallback with no globality assumption — is pinned by - * `packages/examples/tui-demo/tests/tui-agent.spec.ts`. + * the former bundled TUI tests. */ import { join, resolve } from 'node:path' diff --git a/apps/cli/tests/tui.snapshot.ts b/apps/cli/tests/tui.snapshot.ts index af7c027d6d..f841c817e6 100644 --- a/apps/cli/tests/tui.snapshot.ts +++ b/apps/cli/tests/tui.snapshot.ts @@ -53,7 +53,7 @@ interface Scenario { recorded: boolean seedWorkspace?: boolean /** - * Load the opt-in `todo_write` tool for this scenario. The shipped tui-agent + * Load the opt-in `todo_write` tool for this scenario. The shipped TUI * config omits it, so only the todo-plan scenario (the enabled-path proof) * mounts it; the rest cover the default, todo-free composition. */ From 6b2ed3e24dfe3493ec865b84e014f9b22a0b6f49 Mon Sep 17 00:00:00 2001 From: Turtle Date: Wed, 29 Jul 2026 22:06:54 +0800 Subject: [PATCH 058/113] test(tui): scope unreachable resume coverage branches --- packages/ui/tui/src/chat/resume.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/ui/tui/src/chat/resume.ts b/packages/ui/tui/src/chat/resume.ts index 6e90101bb8..18f7944fc2 100644 --- a/packages/ui/tui/src/chat/resume.ts +++ b/packages/ui/tui/src/chat/resume.ts @@ -80,9 +80,10 @@ export function createResumeController(deps: ResumeControllerDeps): ResumeContro events: live.events.map(event => structuredClone(event)), } } else { - /* v8 ignore next -- caller checks the optional service before mapping records */ const readQuery = sessionQuery() + /* v8 ignore start -- caller proves the optional service before mapping records */ if (readQuery === undefined) throw new Error('session query is unavailable') + /* v8 ignore stop */ snapshot = await readQuery.readSession(record.header.id) } return summarizeResumeCandidate( @@ -111,9 +112,10 @@ export function createResumeController(deps: ResumeControllerDeps): ResumeContro * resolve the exact identity and workspace the host will re-exec into. */ const preflightResume = async (sessionId: SessionId): Promise<{ id: SessionId; cwd: string }> => { - /* v8 ignore next -- only showResume can call this closure, after proving the optional service exists */ const query = sessionQuery() + /* v8 ignore start -- showResume alone calls this after proving the optional service exists */ if (query === undefined) throw new Error('Resume is unavailable: session query is not mounted.') + /* v8 ignore stop */ const initialStatus = deps.agentStatus() if (initialStatus !== 'idle') throw new Error(`Resume requires an idle agent (status: ${initialStatus}).`) const record = (await query.listSessions()).find(candidate => candidate.header.id === sessionId) From 8b1a8d383aeabac28088fed0e6d96eb9a6d24cbe Mon Sep 17 00:00:00 2001 From: creatixchu Date: Wed, 29 Jul 2026 22:20:48 +0800 Subject: [PATCH 059/113] =?UTF-8?q?fix(host):=20review=20round=208=20?= =?UTF-8?q?=E2=80=94=20platform-folded=20draft=20matching;=20honest=20slas?= =?UTF-8?q?h-platform=20boundary;=20coverage-visible=20guards?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...directory-picker-capability-seam.i18n.yaml | 4 +- ...-07-28-directory-picker-capability-seam.md | 2 +- ...-28-directory-picker-capability-seam.zh.md | 2 +- .../src/client/DirectoryBrowser.tsx | 75 +++++++++++-------- .../tests/directory-browser.spec.tsx | 12 ++- 5 files changed, 59 insertions(+), 36 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.i18n.yaml index 7d06152986..9fe765766f 100644 --- a/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md -2026-07-28-directory-picker-capability-seam.md: a9f9bc81a8aeff4f9592257b574a199063c681ae -2026-07-28-directory-picker-capability-seam.zh.md: 6331440b04cc8ee6994cb49ff3fd1e6b49403fa4 +2026-07-28-directory-picker-capability-seam.md: 17131eaf390db03fb037216c2b72d3db0fab93bb +2026-07-28-directory-picker-capability-seam.zh.md: 41e9a89ddff05d9e659a93e306348480ea3db5dc diff --git a/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md b/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md index a9f9bc81a8..17131eaf39 100644 --- a/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md +++ b/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md @@ -20,7 +20,7 @@ Placement and policy rulings folded into this decision: - **Dependency survey (hand-roll vs adopt).** Node's stdlib *is* the maintained cross-platform OS layer (`readdir(withFileTypes)`, `homedir`, path semantics); surveyed alternatives fail the dependency bar — file-manager packages (`node-file-manager`, `files-and-folders`, Syncfusion's provider) are whole HTTP apps (fit), drive-letter helpers (`drivelist` native addon, `windows-drive-letters` ~7y stale) fail health/proportionality. The browse backend is a thin adapter over stdlib. - **Hidden entries: return-and-flag.** The host stamps `hidden` (POSIX dot convention) and returns everything; the client filters. Display policy stays client-side, and the show-hidden toggle shipped as exactly that client-only change: a fixed-label footer toggle whose state lives in the pressed presentation (`aria-pressed` + check glyph), a dot-led path-draft prefix reveals the hidden entries it names, and the current selection is exempt from both the hidden and the prefix filter (it anchors the two-pane view). Windows' `FILE_ATTRIBUTE_HIDDEN` is not exposed by dirents — documented limitation until a native probe pays for itself. - **Path-editor cancel scope: the dialog card.** The browse client's path editor cancels on Escape and on focus leaving the card, both observed at a card-scope wrapper rather than the input — after Tab parks focus on a filtered row the input is off the event path, yet Escape must collapse the editor (not the dialog) and a later focus departure must still cancel. Non-cancel exemptions: window/tab focus loss, in-card focus moves, and pointer paths (rows and the toggle suppress focus steal on mousedown while editing). Separators for seeding and draft-tail filtering are inferred from `listing.home`; the wire-field alternative below records the deferred authoritative form. Combobox semantics between the editor and the list it filters (`aria-expanded`/`aria-controls`/active-descendant, result announcements) are likewise deferred — today they read to assistive tech as separate widgets. -- **Navigation lands selection-anchored, progressively.** Away from the display root (the same collapse the crumb header renders, so crumbs and pane shape never disagree), the browse client's navigate commits the target level the moment it arrives — the editor closes and loading ends on that first settlement, so an Enter-submitted navigation is never withdrawn waiting on more — and a parent leg then upgrades the landing in place: the target's actual parent-level entry re-selected (platform case folding on Windows), its children on the right, so a crumb jump reads as stepping back one pane rather than collapsing to a single column. The parent leg runs under the landing's supersession scope and is aborted on the wire by any newer intent; a failed parent leg, or a truncated parent window lacking the target, leaves the committed single-pane landing — the upgrade must never orphan the selection it exists to anchor. Known boundaries of the progressive shape: a pointer press landing exactly inside the one-RTT upgrade window can lose its click when the pressed row node is replaced (keyboard focus is re-parked on the re-selected row; the pointer window is accepted); slash-platform typed-case drift (macOS) misses the parent-entry match and keeps the single-pane landing; and navigate always relists both legs even when the target is the currently shown level — a crumb tap doubles as the refresh gesture, so freshness wins over reusing possibly-stale in-hand listings at the cost of up to two host scans. +- **Navigation lands selection-anchored, progressively.** Away from the display root (the same collapse the crumb header renders, so crumbs and pane shape never disagree), the browse client's navigate commits the target level the moment it arrives — the editor closes and loading ends on that first settlement, so an Enter-submitted navigation is never withdrawn waiting on more — and a parent leg then upgrades the landing in place: the target's actual parent-level entry re-selected (platform case folding on Windows), its children on the right, so a crumb jump reads as stepping back one pane rather than collapsing to a single column. The parent leg runs under the landing's supersession scope and is aborted on the wire by any newer intent; a failed parent leg, or a truncated parent window lacking the target, leaves the committed single-pane landing — the upgrade must never orphan the selection it exists to anchor. Known boundaries of the progressive shape: a pointer press landing exactly inside the one-RTT upgrade window can lose its click when the pressed row node is replaced (keyboard focus is re-parked on the re-selected row; the pointer window is accepted); on slash platforms (macOS) only a final-segment case drift misses the parent-entry match and keeps the single-pane landing, while ancestor-segment drift still matches — parent entry paths inherit the typed prefix — and lands two panes at the cost of the Home collapse; and navigate always relists both legs even when the target is the currently shown level — a crumb tap doubles as the refresh gesture, so freshness wins over reusing possibly-stale in-hand listings at the cost of up to two host scans. - **Symlinks: follow for enterability.** `stat` probes symlinks (broken/cyclic → skipped); crumbs keep the logical path the operator navigated, and `workspace.create` already canonicalizes via realpath at adoption. - **Listing levels are bounded, and streamed.** One `list` call returns at most `maxEntries` rows (config, default 1000 — GitHub's web-UI directory-listing bound). The level streams via `opendir` into a name-sorted window of `maxEntries + 1` candidates, so memory stays O(maxEntries) and enterability probing touches only windowed candidates; the wire `DirectoryListing` carries a required `truncated` flag so the client states incompleteness instead of silently missing tail entries. A windowed broken symlink is not backfilled from beyond the window — the eviction already marks the level truncated. Window insertion is binary with an O(1) full-window tail rejection (an oversized level must not pay a window scan per dirent), and `list(path, signal)` threads the carrier's request signal so a scan of a stalled network directory cannot outlive a disconnected caller — every await in the scan (open, each read, each symlink probe) races the signal, an aborted exit abandons rather than awaits the close (Node queues close behind in-flight reads), and abandoned settlements are swallowed so cleanup can never surface as an unhandled rejection. An unbounded level is a memory/responsiveness hole for large or adversarial directories. - **Whole-filesystem scope, no roots config.** `workspace.create` accepts arbitrary paths and the API serves bash-driving methods, so a browse root would be UX scoping, not a boundary; configurability without a consumer fails the evidence bar. Deferred until a deployment needs it. diff --git a/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.zh.md b/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.zh.md index 6331440b04..41e9a89ddf 100644 --- a/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.zh.md @@ -20,7 +20,7 @@ web GUI 的"打开本地文件夹"流程被焊死在一种交互上:`host.pick - **依赖调研(手写 vs 引入)。** Node 标准库本身就是维护中的跨平台 OS 层(`readdir(withFileTypes)`、`homedir`、路径语义);调研过的替代品都过不了依赖门槛——文件管理器包(`node-file-manager`、`files-and-folders`、Syncfusion 的 provider)是整套 HTTP 应用(契合度不过),盘符工具(原生插件 `drivelist`、约七年未更的 `windows-drive-letters`)健康度/比例失当。browse 后端是标准库上的薄适配。 - **隐藏条目:返回并打标。** 宿主标注 `hidden`(POSIX 点前缀约定)并返回全部条目;客户端过滤。展示策略留在客户端,"显示隐藏"开关正是作为这一纯客户端改动落地:标签固定的 footer 开关,其状态由按下态呈现承载(`aria-pressed` + 勾选符号);以点开头的路径草稿前缀会显出它所指名的隐藏条目;当前选中项则不受隐藏与前缀两种过滤影响(它锚定着双栏视图)。Windows 的 `FILE_ATTRIBUTE_HIDDEN` 不被 dirent 暴露——记为限制,直到原生探测值回其成本。 - **路径编辑器的取消范围:对话框卡片。** browse 客户端的路径编辑器在按 Escape 与焦点离开卡片时取消,两者都在卡片范围的包装层而非输入框上监听——Tab 把焦点停到某个过滤命中的行之后,输入框已不在事件路径上,但 Escape 仍须收起编辑器(而非对话框),其后的焦点离开也仍须取消。不取消的豁免:窗口/标签页失焦、卡片内焦点移动,以及指针路径(编辑期间行与开关在 mousedown 时抑制焦点夺取)。预填与草稿末段过滤所用的分隔符从 `listing.home` 推断;下文的线上字段替代方案记录了被延期的权威形态。编辑器与其过滤的列表之间的 combobox 语义(`aria-expanded`/`aria-controls`/active-descendant、结果播报)同样被延期——目前二者在辅助技术看来是彼此独立的控件。 -- **导航以选中项为锚、渐进落地。** 在展示根之外(与 crumb 头部渲染的是同一塌缩,因此 crumb 与分栏形态永不相左),browse 客户端的导航在目标层级到达的那一刻即提交它——这次首个落定即关闭编辑器并结束加载,因此 Enter 提交的导航绝不会为等待更多内容而被撤回——随后父层级这一程就地升级这次落地:重新选中目标在父层级中的实际条目(Windows 上按平台惯例折叠大小写),右侧展示其子项,因此 crumb 跳转读作后退一栏,而不是塌缩成单列。父层级这一程在落地的 supersession 范围下运行,任何较新的意图都会在线上将其中止;父层级这一程失败,或被截断的父窗口缺少目标时,都保留已提交的单栏落地——升级的存在正是为了锚定选中项,绝不能反而让它悬空。渐进形态的已知边界:指针按压若恰好落在单个 RTT 的升级窗口内,可能因所按行节点被替换而丢失点击(键盘焦点会重新停靠到重新选中的行上;指针的这段窗口则被接受);斜杠平台的键入大小写偏差(macOS)会错过父层级条目匹配,保留单栏落地;且导航总是重新列举两程,哪怕目标就是当前展示的层级——crumb 点按兼作刷新手势,因此宁要新鲜度也不复用手头可能已陈旧的列举,代价是至多两次宿主扫描。 +- **导航以选中项为锚、渐进落地。** 在展示根之外(与 crumb 头部渲染的是同一塌缩,因此 crumb 与分栏形态永不相左),browse 客户端的导航在目标层级到达的那一刻即提交它——这次首个落定即关闭编辑器并结束加载,因此 Enter 提交的导航绝不会为等待更多内容而被撤回——随后父层级这一程就地升级这次落地:重新选中目标在父层级中的实际条目(Windows 上按平台惯例折叠大小写),右侧展示其子项,因此 crumb 跳转读作后退一栏,而不是塌缩成单列。父层级这一程在落地的 supersession 范围下运行,任何较新的意图都会在线上将其中止;父层级这一程失败,或被截断的父窗口缺少目标时,都保留已提交的单栏落地——升级的存在正是为了锚定选中项,绝不能反而让它悬空。渐进形态的已知边界:指针按压若恰好落在单个 RTT 的升级窗口内,可能因所按行节点被替换而丢失点击(键盘焦点会重新停靠到重新选中的行上;指针的这段窗口则被接受);斜杠平台(macOS)上仅末段的大小写偏差会错过父层级条目匹配,保留单栏落地,而祖先段的偏差仍能匹配——父层级条目路径继承键入的前缀——并落地双栏,代价是 Home 塌缩;且导航总是重新列举两程,哪怕目标就是当前展示的层级——crumb 点按兼作刷新手势,因此宁要新鲜度也不复用手头可能已陈旧的列举,代价是至多两次宿主扫描。 - **符号链接:为可进入性而跟随。** 用 `stat` 探测符号链接(断链/循环→跳过);面包屑保留操作者导航的逻辑路径,`workspace.create` 在接纳时本就做 realpath 规范化。 - **列举层级有上限,且流式处理。** 单次 `list` 至多返回 `maxEntries` 行(配置项,默认 1000——GitHub 网页端目录列举的同一上限)。层级经 `opendir` 流入一个按名排序、容量 `maxEntries + 1` 的候选窗口,内存保持 O(maxEntries),可进入性探测只触及窗口内候选;线上 `DirectoryListing` 携带必填的 `truncated` 标志,让客户端明示不完整而不是静默缺尾。窗口内的断链符号链接不从窗口外回填——发生过驱逐本身已把层级标记为截断。窗口插入为二分查找、满窗尾部单次比较即拒绝(超大层级不能为每个 dirent 付出一次全窗扫描),且 `list(path, signal)` 透传载体的请求信号,滞塞网络目录的扫描不会在调用方断连后继续存活——扫描中的每个 await(打开、每次读取、每次符号链接探测)都与信号赛跑,中止路径放弃而非等待 close(Node 会把 close 排在在飞读取之后),被放弃的 settlement 全部吞掉,清理不会以未处理拒绝的形式冒出。无上限的层级对超大或恶意构造的目录就是内存/响应性漏洞。 - **全盘可浏览,不做 roots 配置。** `workspace.create` 接受任意路径且 API 本就提供驱动 bash 的方法,浏览根只会是 UX 范围而非边界;没有消费方的可配置性过不了证据门槛。等到有部署需要再做。 diff --git a/packages/host/directory-picker-browse/src/client/DirectoryBrowser.tsx b/packages/host/directory-picker-browse/src/client/DirectoryBrowser.tsx index 5c44492b77..ab6ecb731c 100644 --- a/packages/host/directory-picker-browse/src/client/DirectoryBrowser.tsx +++ b/packages/host/directory-picker-browse/src/client/DirectoryBrowser.tsx @@ -95,20 +95,23 @@ function separatorOf(listing: DirectoryListing): '\\' | '/' { } /** - * The path draft's final segment, when its directory part is exactly the - * level `listing` lists — the segment the level prefix-filters on while the - * user types. Any other draft (no separator yet, or naming some other - * directory) leaves the level unfiltered. The directory part compares - * exactly (it is the host's own path text, reached by seeding or erasing); - * only the name filter downstream is case-insensitive. + * The path draft's final segment, when its directory part names the level + * `listing` lists — the segment the level prefix-filters on while the user + * types. Any other draft (no separator yet, or naming some other directory) + * leaves the level unfiltered. The directory part compares under the + * platform fold (exact on slash platforms; Windows folds case, since an + * upgraded selection may carry the actual entry's case while the level + * below still carries the typed one); the name filter downstream is + * case-insensitive everywhere. */ function draftPrefixFor(listing: DirectoryListing, draft: string | null): string | null { if (draft === null) return null const sep = separatorOf(listing) const cut = draft.lastIndexOf(sep) if (cut === -1) return null + const fold = foldPathFor(listing) const level = listing.path.endsWith(sep) ? listing.path : `${listing.path}${sep}` - return draft.slice(0, cut + 1) === level ? draft.slice(cut + 1) : null + return fold(draft.slice(0, cut + 1)) === fold(level) ? draft.slice(cut + 1) : null } /** One column of folder rows (the Miller view renders one or two of these). */ @@ -231,6 +234,21 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen, return { seq, scan: listDirectory(path, controller.signal) } }, [supersede, listDirectory]) + // The miller row's scroll host, shared by the pin and refocus effects + // below and read by navigate's upgrade leg (declared ahead of both). + const millerRowRef = useRef(null) + // Focus parking (consumed by the refocus effect below): a pick — and a + // parent-leg upgrade that displaces focused rows — parks on the + // selection's row; Enter, an input-focused Escape, and a failed pick + // whose row unmounts park on the crumb edit zone (the latter only when + // focus actually fell to body). Pointer-out cancels never set (or clear) + // these — yanking focus back from wherever the user clicked would be + // worse than the fall. + const refocusPick = useRef(false) + const refocusEditZone = useRef(false) + const pathInputRef = useRef(null) + const editZoneRef = useRef(null) + /** * Launch a follow-up listing under the CURRENT supersession seq: a newer * intent aborts it like the leg it continues, and it supersedes nothing. @@ -240,8 +258,11 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen, // caller's settled leg: a no-op) — the slot must never silently strand // a live scan, the exact waste supersede() exists to prevent. const displaced = scanController.current - /* v8 ignore next -- narrowing guard: the landing's target leg installed a controller before any follow-up runs. */ - if (displaced !== null) displaced.abort() + // Inverted so the live abort below stays in coverage: a supersede + // would have bumped the seq before any follow-up could run. + /* v8 ignore next -- narrowing guard: the landing's target leg installed a controller first. */ + if (displaced === null) throw new Error('continueScan launched before any leg installed a controller') + displaced.abort() const controller = new AbortController() scanController.current = controller return listDirectory(path, controller.signal) @@ -256,10 +277,13 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen, * shape never disagree — a parent leg then upgrades the landing in place: * the target's ACTUAL parent-level entry re-selected (left pane = parent, * right pane = the target), so a crumb jump reads as stepping back one - * pane (Windows folds case; slash-platform typed-case drift degrades to - * the single-pane landing). A failed parent leg, or a truncated parent - * window that lacks the target, leaves the committed single-pane landing - * — the upgrade must never orphan the selection it exists to anchor. + * pane (Windows folds case; on slash platforms only a FINAL-segment case + * drift misses the match and keeps the single-pane landing — parent + * entries inherit the typed prefix, so ancestor-segment drift still + * matches, at the cost of the Home collapse). A failed parent leg, or a + * truncated parent window that lacks the target, leaves the committed + * single-pane landing — the upgrade must never orphan the selection it + * exists to anchor. */ const navigate = useCallback((path?: string) => { const { seq, scan } = launchListing(path) @@ -289,9 +313,10 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen, // among them (Tab reached the rows during the parent leg), arm the // refocus effect so it re-parks on the re-selected row. const rowHost = millerRowRef.current + // Inverted so the live contains() probe below stays in coverage. /* v8 ignore next -- narrowing guard: the committed landing just rendered the miller row. */ - const focusInRows = rowHost !== null && rowHost.contains(document.activeElement) - if (focusInRows) refocusPick.current = true + if (rowHost === null) throw new Error('parent-leg upgrade before the miller row rendered') + if (rowHost.contains(document.activeElement)) refocusPick.current = true setParent(parentLevel) setSelected(match) setChild(target) @@ -307,18 +332,6 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen, }) }, [launchListing, continueScan]) - // Focus parking (consumed by the refocus effect below the miller-row - // ref): a pick — and a parent-leg upgrade that displaces focused rows — - // parks on the selection's row; Enter, an input-focused Escape, and a - // failed pick whose row unmounts park on the crumb edit zone (the latter - // only when focus actually fell to body). Pointer-out cancels never set - // (or clear) these — yanking focus back from wherever the user clicked - // would be worse than the fall. - const refocusPick = useRef(false) - const refocusEditZone = useRef(false) - const pathInputRef = useRef(null) - const editZoneRef = useRef(null) - /** Select a row of the listed level and preview its children on the right. */ const select = useCallback((entry: DirectoryEntry) => { const { seq, scan } = launchListing(entry.path) @@ -455,8 +468,10 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen, }, [crumbTail]) // On viewports too narrow for both fixed panes the Miller row scrolls; // whenever a child preview lands, pin it into view the way the crumb tail - // pins — otherwise descent is unreachable on a phone-width window. - const millerRowRef = useRef(null) + // pins — otherwise descent is unreachable on a phone-width window. On a + // parent-leg upgrade the refocus effect below runs after this pin and its + // row.focus() may scroll the selected LEFT row back into view: for that + // one landing, focus placement wins over the child pin by design. const childPath = child?.path useEffect(() => { const row = millerRowRef.current @@ -712,8 +727,6 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen, onClick={() => { setShowHidden(prev => !prev) }} > {t('browser.showHidden')} - {/* Trailing check (Menu's selected vocabulary): the label never - * shifts when the pressed state toggles. */} {showHidden && } diff --git a/packages/host/directory-picker-browse/tests/directory-browser.spec.tsx b/packages/host/directory-picker-browse/tests/directory-browser.spec.tsx index fc2b6f564b..95b60ced2f 100644 --- a/packages/host/directory-picker-browse/tests/directory-browser.spec.tsx +++ b/packages/host/directory-picker-browse/tests/directory-browser.spec.tsx @@ -356,7 +356,7 @@ describe('DirectoryBrowser', () => { path: TYPED, home: ROOT, crumbs: [{ name: 'C:\\', path: ROOT, hidden: false }, { name: 'users', path: TYPED, hidden: false }], - entries: [], + entries: [{ name: 'Alpha', path: `${TYPED}\\Alpha`, hidden: false }], truncated: false, } mount({ listDirectory: vi.fn(async (path?: string) => (path === TYPED ? winUsers : winRoot)) }) @@ -370,6 +370,16 @@ describe('DirectoryBrowser', () => { expect(rowButton(within(columns()[0]!).getByRole('listitem')).getAttribute('aria-current')).toBe('true') }) expect(within(columns()[0]!).getByText('Users')).toBeTruthy() + // The editor seeds from the actual-cased selection while the child + // level still carries the typed case: the draft's directory part folds + // per platform, so the right pane keeps prefix-filtering. + fireEvent.click(screen.getByRole('button', { name: 'browser.editPath' })) + const input = screen.getByLabelText('browser.editPath') + expect(input.value).toBe('C:\\Users\\') + fireEvent.change(input, { target: { value: 'C:\\Users\\a' } }) + expect(within(columns()[1]!).getByText('Alpha')).toBeTruthy() + fireEvent.change(input, { target: { value: 'C:\\Users\\z' } }) + expect(within(columns()[1]!).queryAllByRole('listitem')).toHaveLength(0) }) it('re-parks focus on the edit zone when a failed pick unmounts a dot-revealed row', async () => { From 5b35132e62048a296f214387bf7fcb9dd88e7c84 Mon Sep 17 00:00:00 2001 From: Turtle Date: Wed, 29 Jul 2026 22:27:30 +0800 Subject: [PATCH 060/113] test(cli): allow artifact PTY startup contention --- apps/cli/tests/tui-keyless-smoke.e2e.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/apps/cli/tests/tui-keyless-smoke.e2e.ts b/apps/cli/tests/tui-keyless-smoke.e2e.ts index 289f716f6a..fdef19b824 100644 --- a/apps/cli/tests/tui-keyless-smoke.e2e.ts +++ b/apps/cli/tests/tui-keyless-smoke.e2e.ts @@ -132,6 +132,8 @@ function smoke(overrides: Partial & { label: string }): Prom binScript: dshBinScript, tsconfigPath, env: { DEEPSEEK_API_KEY: 'keyless-tui-no-call' }, + // Artifact CI builds and smokes concurrently on a contended runner. + ...(process.env.DSH_EXAMPLE_MODE === 'lib' ? { timeoutMs: 60_000 } : {}), ...overrides, }) } From df9c03fbc2c19046a4092f480656cea40429abae Mon Sep 17 00:00:00 2001 From: creatixchu Date: Wed, 29 Jul 2026 22:42:11 +0800 Subject: [PATCH 061/113] =?UTF-8?q?fix(host):=20review=20round=209=20?= =?UTF-8?q?=E2=80=94=20universal=20pick=20refocus;=20always-armed=20scan?= =?UTF-8?q?=20controller;=20graceful=20close-race=20guards?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/client/DirectoryBrowser.tsx | 97 ++++++++++--------- .../tests/directory-browser.spec.tsx | 50 ++++++++++ 2 files changed, 103 insertions(+), 44 deletions(-) diff --git a/packages/host/directory-picker-browse/src/client/DirectoryBrowser.tsx b/packages/host/directory-picker-browse/src/client/DirectoryBrowser.tsx index ab6ecb731c..c4c4068d2e 100644 --- a/packages/host/directory-picker-browse/src/client/DirectoryBrowser.tsx +++ b/packages/host/directory-picker-browse/src/client/DirectoryBrowser.tsx @@ -2,13 +2,14 @@ * The in-app workspace-directory browser (figma Harness 813-23126 family): a * 680×500 dialog (clamped to short/narrow viewports — the Miller row scrolls * sideways, the columns scroll down) whose header carries the title, the selection-path - * breadcrumb, and a click-to-edit path zone; below it a Miller view — one - * full-width level until a row is selected, then two columns splitting the - * row evenly (256px floor; level | selected folder's children) around a - * hairline divider. Navigations land selection-anchored: a crumb jump or a - * submitted path commits the target immediately, then re-selects it in its - * parent level once that level arrives, so stepping back keeps two panes - * away from the display root. Selecting in the + * breadcrumb, and a click-to-edit path zone; below it a Miller view of one + * or two columns splitting the row evenly (256px floor; level | selected + * folder's children) around a hairline divider — the display root and + * degraded landings keep the single wide level, while any selection opens + * the second pane, including the one a navigation lands with: a crumb jump + * or a submitted path commits the target immediately, then re-selects it + * in its parent level once that level arrives, so stepping back keeps two + * panes away from the display root. Selecting in the * right column shifts the view one level deeper. "New folder" opens a nested * create dialog targeting the selected folder (or the level itself) and * selects the created folder. Open adopts the selected folder, falling back @@ -56,14 +57,15 @@ function failureText(error: unknown): string { } /** - * Case-folds a path for comparisons under the listing's platform: backslash - * (Windows) paths compare case-insensitively — a typed path legally differs - * in case from the host's stamped one — while slash platforms compare - * exactly (the filesystem may be case-sensitive; macOS typed-case drift - * degrades to the single-pane landing instead of a wrong match). + * Case-folds a path for comparisons under the given separator's platform: + * backslash (Windows) paths compare case-insensitively — a typed path + * legally differs in case from the host's stamped one — while slash + * platforms compare exactly (the filesystem may be case-sensitive; only a + * FINAL-segment macOS case drift misses parent-entry matching and keeps + * the single-pane landing, since parent entry paths inherit the typed + * prefix). */ -function foldPathFor(listing: DirectoryListing): (value: string) => string { - const sep = separatorOf(listing) +function foldPathFor(sep: '\\' | '/'): (value: string) => string { return value => (sep === '\\' ? value.toLowerCase() : value) } @@ -74,7 +76,7 @@ function foldPathFor(listing: DirectoryListing): (value: string) => string { * Windows path still collapses to the Home crumb. */ function displayCrumbs(listing: DirectoryListing, homeLabel: string): DirectoryEntry[] { - const fold = foldPathFor(listing) + const fold = foldPathFor(separatorOf(listing)) const homeIndex = listing.crumbs.findIndex(crumb => fold(crumb.path) === fold(listing.home)) if (homeIndex === -1) return listing.crumbs const tail = listing.crumbs.slice(homeIndex + 1) @@ -109,7 +111,7 @@ function draftPrefixFor(listing: DirectoryListing, draft: string | null): string const sep = separatorOf(listing) const cut = draft.lastIndexOf(sep) if (cut === -1) return null - const fold = foldPathFor(listing) + const fold = foldPathFor(sep) const level = listing.path.endsWith(sep) ? listing.path : `${listing.path}${sep}` return fold(draft.slice(0, cut + 1)) === fold(level) ? draft.slice(cut + 1) : null } @@ -195,8 +197,10 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen, const requestSeq = useRef(0) // The in-flight listing's controller: superseding intent aborts the wire // request too — the Host stops scanning — instead of only discarding the - // eventual result while the scan keeps consuming host resources. - const scanController = useRef(null) + // eventual result while the scan keeps consuming host resources. Always + // holds a controller (a settled or aborted one between scans) so no + // consumer needs a null guard. + const scanController = useRef(new AbortController()) // Bumped on every open/close edge: settlements from a previous open (a // pending creation included) must never mutate a reopened dialog. const openGeneration = useRef(0) @@ -212,7 +216,7 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen, useEffect(() => () => { requestSeq.current += 1 openGeneration.current += 1 - scanController.current?.abort() + scanController.current.abort() }, []) const compositionGuard = { onCompositionStart: () => { composingRef.current = true }, @@ -221,8 +225,7 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen, /** Newer intent wins: invalidate the pending listing's settlement AND abort its wire request. */ const supersede = useCallback((): number => { - scanController.current?.abort() - scanController.current = null + scanController.current.abort() return ++requestSeq.current }, []) @@ -257,12 +260,7 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen, // Abort whatever the slot last tracked before overwriting it (the // caller's settled leg: a no-op) — the slot must never silently strand // a live scan, the exact waste supersede() exists to prevent. - const displaced = scanController.current - // Inverted so the live abort below stays in coverage: a supersede - // would have bumped the seq before any follow-up could run. - /* v8 ignore next -- narrowing guard: the landing's target leg installed a controller first. */ - if (displaced === null) throw new Error('continueScan launched before any leg installed a controller') - displaced.abort() + scanController.current.abort() const controller = new AbortController() scanController.current = controller return listDirectory(path, controller.signal) @@ -306,16 +304,18 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen, // Windows resolves a typed path preserving its case; anchor on the // parent level's actual entry so selection comparisons hold (slash // platforms compare exactly — see foldPathFor). - const fold = foldPathFor(parentLevel) + const fold = foldPathFor(separatorOf(parentLevel)) const match = parentLevel.entries.find(entry => fold(entry.path) === fold(target.path)) if (match === undefined) return // The upgrade replaces every committed row node; if focus lives // among them (Tab reached the rows during the parent leg), arm the // refocus effect so it re-parks on the re-selected row. const rowHost = millerRowRef.current - // Inverted so the live contains() probe below stays in coverage. - /* v8 ignore next -- narrowing guard: the committed landing just rendered the miller row. */ - if (rowHost === null) throw new Error('parent-leg upgrade before the miller row rendered') + // A close race can clear the ref before the close effect's + // supersede runs (commit precedes passive effects): drop the + // upgrade, the dialog is going away. + /* v8 ignore next -- close-race guard: the commit-to-effect window is not deterministically reproducible. */ + if (rowHost === null) return if (rowHost.contains(document.activeElement)) refocusPick.current = true setParent(parentLevel) setSelected(match) @@ -336,9 +336,13 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen, const select = useCallback((entry: DirectoryEntry) => { const { seq, scan } = launchListing(entry.path) // A pick while the path editor is open adopts the (filtered) row and - // closes the editor — the draft served its purpose. Focus re-parks on - // the selection after commit (see the refocus effect below). - if (pathDraft !== null) refocusPick.current = true + // closes the editor — the draft served its purpose. EVERY pick re-parks + // focus on the selection after commit (see the refocus effect below): + // a left-pane pick lands on the very row that was clicked (a near + // no-op), while a right-pane advance and a create landing replace the + // picked button's column entirely and would otherwise drop focus to + // body. + refocusPick.current = true setPathDraft(null) setSelected(entry) setChild(null) @@ -477,12 +481,13 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen, const row = millerRowRef.current if (row !== null && childPath !== undefined) row.scrollLeft = row.scrollWidth }, [childPath]) - // Every editor exit that would drop focus to body re-parks it after - // commit, so keyboard traversal stays inside the dialog (the Modal has no - // focus trap): a pick lands on the selection's row — aria-current in the - // freshly rendered left pane, which survives even a right-pane advance - // replacing the picked button's column — while Enter and an input-focused - // Escape land on the crumb edit zone that replaces the input. + // Every pick and editor exit that would drop focus to body re-parks it + // after commit, so keyboard traversal stays inside the dialog (the Modal + // has no focus trap): a pick lands on the selection's row — aria-current + // in the freshly rendered left pane, which survives even a right-pane + // advance or a create landing replacing the picked button's column — + // while Enter and an input-focused Escape land on the crumb edit zone + // that replaces the input. useEffect(() => { if (pathDraft !== null) return if (refocusPick.current) { @@ -492,10 +497,14 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen, /* v8 ignore next -- narrowing guard: the miller row is mounted whenever a pick just committed. */ if (rowHost === null) return const row = rowHost.querySelector('button[aria-current="true"]') - /* v8 ignore next -- narrowing guard: the pick that set the flag just rendered its aria-current row. */ - if (row === null) return - row.focus() - return + if (row !== null) { + row.focus() + return + } + // The pick lost its row (a truncated relist after Create can drop + // the created directory outside the window): fall through to the + // edit-zone parking below instead of leaving focus where it fell. + refocusEditZone.current = true } if (refocusEditZone.current) { refocusEditZone.current = false diff --git a/packages/host/directory-picker-browse/tests/directory-browser.spec.tsx b/packages/host/directory-picker-browse/tests/directory-browser.spec.tsx index 95b60ced2f..c5ed60fa5e 100644 --- a/packages/host/directory-picker-browse/tests/directory-browser.spec.tsx +++ b/packages/host/directory-picker-browse/tests/directory-browser.spec.tsx @@ -629,6 +629,56 @@ describe('DirectoryBrowser', () => { expect(screen.getByRole('button', { name: 'browser.home' })).toBeTruthy() }) + it('a plain right-pane advance parks focus on the new selection (no editor involved)', async () => { + mount() + await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() }) + fireEvent.click(rowButton(screen.getByRole('listitem'))) + await waitFor(() => { expect(columns()).toHaveLength(2) }) + // Keyboard reached the right pane; the advance replaces that whole + // column, so focus re-parks on the new left pane's selected row. + const row = rowButton(within(columns()[1]!).getByRole('listitem')) + row.focus() + fireEvent.click(row) + await waitFor(() => { expect(document.activeElement?.textContent).toBe('harness') }) + expect(document.activeElement?.getAttribute('aria-current')).toBe('true') + }) + + it('a create landing parks focus on the created row, or the edit zone when the relist lost it', async () => { + // First create: the relist contains the created directory. + const b = mount() + await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() }) + b.listDirectory.mockImplementation(async (path?: string) => { + // The created directory is not in listingFor's fixed tree: serve its + // level before the fixture lookup can reject the unknown path. + if (path === `${HOME}/fresh`) return { ...listingFor(HOME), path: `${HOME}/fresh`, entries: [] } + const base = listingFor(path) + if (path === HOME) { + return { ...base, entries: [...base.entries, { name: 'fresh', path: `${HOME}/fresh`, hidden: false }] } + } + return base + }) + fireEvent.click(screen.getByRole('button', { name: 'browser.newFolder' })) + fireEvent.change(screen.getByLabelText('browser.folderName'), { target: { value: 'fresh' } }) + fireEvent.click(screen.getByRole('button', { name: 'browser.create' })) + await waitFor(() => { expect(document.activeElement?.textContent).toBe('fresh') }) + expect(document.activeElement?.getAttribute('aria-current')).toBe('true') + }) + + it('a create landing whose truncated relist lost the created row parks on the edit zone', async () => { + const b = mount() + await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() }) + // The relist window misses the created directory (truncated tail). + b.listDirectory.mockImplementation(async (path?: string) => ({ ...listingFor(path), truncated: true })) + fireEvent.click(screen.getByRole('button', { name: 'browser.newFolder' })) + fireEvent.change(screen.getByLabelText('browser.folderName'), { target: { value: 'zzz-tail' } }) + fireEvent.click(screen.getByRole('button', { name: 'browser.create' })) + // No aria-current row exists for the selection: focus falls back to the + // crumb edit zone instead of staying wherever it fell. + await waitFor(() => { + expect(document.activeElement).toBe(screen.getByRole('button', { name: 'browser.editPath' })) + }) + }) + it('a right-pane pick while editing parks focus on the advanced selection', async () => { mount() await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() }) From a22d9aea0bd584ffbd86336868b9eaed8eebe291 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Wed, 29 Jul 2026 23:00:49 +0800 Subject: [PATCH 062/113] =?UTF-8?q?fix(host):=20review=20round=2010=20?= =?UTF-8?q?=E2=80=94=20trailing-separator=20home=20root;=20dead=20dep;=20l?= =?UTF-8?q?azy=20controller;=20focus-invariant=20doc=20home?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...directory-picker-capability-seam.i18n.yaml | 4 +- ...-07-28-directory-picker-capability-seam.md | 2 +- ...-28-directory-picker-capability-seam.zh.md | 2 +- .../src/client/DirectoryBrowser.tsx | 50 ++++++++++++------- .../tests/directory-browser.spec.tsx | 15 +++++- 5 files changed, 50 insertions(+), 23 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.i18n.yaml index 9fe765766f..2a98ace12d 100644 --- a/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md -2026-07-28-directory-picker-capability-seam.md: 17131eaf390db03fb037216c2b72d3db0fab93bb -2026-07-28-directory-picker-capability-seam.zh.md: 41e9a89ddff05d9e659a93e306348480ea3db5dc +2026-07-28-directory-picker-capability-seam.md: b860feab1fecead8130b77218005ab950575d5c9 +2026-07-28-directory-picker-capability-seam.zh.md: fea181800243b27499fa0a97acc44253078ad9db diff --git a/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md b/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md index 17131eaf39..b860feab1f 100644 --- a/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md +++ b/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md @@ -19,7 +19,7 @@ Placement and policy rulings folded into this decision: - **Not the `ctx.fs` seam.** `packages/fs/` is the model/session-facing storage stack (policy events, sandbox-swappable backends). Riding it would couple GUI browsing to the model's confinement backend — swapping `fs-sandbox` for the model must never change GUI behavior — and OS facts (home anchoring, hidden conventions) are not storage primitives. The picker seam stays presentation-free and model-free; `packages/host/` is its consumer-domain home. - **Dependency survey (hand-roll vs adopt).** Node's stdlib *is* the maintained cross-platform OS layer (`readdir(withFileTypes)`, `homedir`, path semantics); surveyed alternatives fail the dependency bar — file-manager packages (`node-file-manager`, `files-and-folders`, Syncfusion's provider) are whole HTTP apps (fit), drive-letter helpers (`drivelist` native addon, `windows-drive-letters` ~7y stale) fail health/proportionality. The browse backend is a thin adapter over stdlib. - **Hidden entries: return-and-flag.** The host stamps `hidden` (POSIX dot convention) and returns everything; the client filters. Display policy stays client-side, and the show-hidden toggle shipped as exactly that client-only change: a fixed-label footer toggle whose state lives in the pressed presentation (`aria-pressed` + check glyph), a dot-led path-draft prefix reveals the hidden entries it names, and the current selection is exempt from both the hidden and the prefix filter (it anchors the two-pane view). Windows' `FILE_ATTRIBUTE_HIDDEN` is not exposed by dirents — documented limitation until a native probe pays for itself. -- **Path-editor cancel scope: the dialog card.** The browse client's path editor cancels on Escape and on focus leaving the card, both observed at a card-scope wrapper rather than the input — after Tab parks focus on a filtered row the input is off the event path, yet Escape must collapse the editor (not the dialog) and a later focus departure must still cancel. Non-cancel exemptions: window/tab focus loss, in-card focus moves, and pointer paths (rows and the toggle suppress focus steal on mousedown while editing). Separators for seeding and draft-tail filtering are inferred from `listing.home`; the wire-field alternative below records the deferred authoritative form. Combobox semantics between the editor and the list it filters (`aria-expanded`/`aria-controls`/active-descendant, result announcements) are likewise deferred — today they read to assistive tech as separate widgets. +- **Path-editor cancel scope: the dialog card.** The browse client's path editor cancels on Escape and on focus leaving the card, both observed at a card-scope wrapper rather than the input — after Tab parks focus on a filtered row the input is off the event path, yet Escape must collapse the editor (not the dialog) and a later focus departure must still cancel. Non-cancel exemptions: window/tab focus loss, in-card focus moves, and pointer paths (rows and the toggle suppress focus steal on mousedown while editing). Separators for seeding and draft-tail filtering are inferred from `listing.home`; the wire-field alternative below records the deferred authoritative form. Combobox semantics between the editor and the list it filters (`aria-expanded`/`aria-controls`/active-descendant, result announcements) are likewise deferred — today they read to assistive tech as separate widgets. Focus parking is a card-wide invariant, not an editor-only one: every pick — editing or not, including right-pane advances and create landings whose columns are replaced — re-parks focus on the selection's row after commit, while Enter, an input-focused Escape, and a pick whose row vanished park on the crumb edit zone; keyboard traversal never falls out of the card (the Modal has no focus trap). - **Navigation lands selection-anchored, progressively.** Away from the display root (the same collapse the crumb header renders, so crumbs and pane shape never disagree), the browse client's navigate commits the target level the moment it arrives — the editor closes and loading ends on that first settlement, so an Enter-submitted navigation is never withdrawn waiting on more — and a parent leg then upgrades the landing in place: the target's actual parent-level entry re-selected (platform case folding on Windows), its children on the right, so a crumb jump reads as stepping back one pane rather than collapsing to a single column. The parent leg runs under the landing's supersession scope and is aborted on the wire by any newer intent; a failed parent leg, or a truncated parent window lacking the target, leaves the committed single-pane landing — the upgrade must never orphan the selection it exists to anchor. Known boundaries of the progressive shape: a pointer press landing exactly inside the one-RTT upgrade window can lose its click when the pressed row node is replaced (keyboard focus is re-parked on the re-selected row; the pointer window is accepted); on slash platforms (macOS) only a final-segment case drift misses the parent-entry match and keeps the single-pane landing, while ancestor-segment drift still matches — parent entry paths inherit the typed prefix — and lands two panes at the cost of the Home collapse; and navigate always relists both legs even when the target is the currently shown level — a crumb tap doubles as the refresh gesture, so freshness wins over reusing possibly-stale in-hand listings at the cost of up to two host scans. - **Symlinks: follow for enterability.** `stat` probes symlinks (broken/cyclic → skipped); crumbs keep the logical path the operator navigated, and `workspace.create` already canonicalizes via realpath at adoption. - **Listing levels are bounded, and streamed.** One `list` call returns at most `maxEntries` rows (config, default 1000 — GitHub's web-UI directory-listing bound). The level streams via `opendir` into a name-sorted window of `maxEntries + 1` candidates, so memory stays O(maxEntries) and enterability probing touches only windowed candidates; the wire `DirectoryListing` carries a required `truncated` flag so the client states incompleteness instead of silently missing tail entries. A windowed broken symlink is not backfilled from beyond the window — the eviction already marks the level truncated. Window insertion is binary with an O(1) full-window tail rejection (an oversized level must not pay a window scan per dirent), and `list(path, signal)` threads the carrier's request signal so a scan of a stalled network directory cannot outlive a disconnected caller — every await in the scan (open, each read, each symlink probe) races the signal, an aborted exit abandons rather than awaits the close (Node queues close behind in-flight reads), and abandoned settlements are swallowed so cleanup can never surface as an unhandled rejection. An unbounded level is a memory/responsiveness hole for large or adversarial directories. diff --git a/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.zh.md b/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.zh.md index 41e9a89ddf..fea1818002 100644 --- a/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.zh.md @@ -19,7 +19,7 @@ web GUI 的"打开本地文件夹"流程被焊死在一种交互上:`host.pick - **不用 `ctx.fs` seam。** `packages/fs/` 是面向模型/会话的存储栈(policy 事件、sandbox 可换后端)。骑上去会把 GUI 浏览耦合进模型的限制后端——为模型换 `fs-sandbox` 绝不能改变 GUI 行为——而 OS 事实(home 锚定、隐藏约定)也不是存储原语。picker seam 保持无展示、无模型;`packages/host/` 是它消费方域的家。 - **依赖调研(手写 vs 引入)。** Node 标准库本身就是维护中的跨平台 OS 层(`readdir(withFileTypes)`、`homedir`、路径语义);调研过的替代品都过不了依赖门槛——文件管理器包(`node-file-manager`、`files-and-folders`、Syncfusion 的 provider)是整套 HTTP 应用(契合度不过),盘符工具(原生插件 `drivelist`、约七年未更的 `windows-drive-letters`)健康度/比例失当。browse 后端是标准库上的薄适配。 - **隐藏条目:返回并打标。** 宿主标注 `hidden`(POSIX 点前缀约定)并返回全部条目;客户端过滤。展示策略留在客户端,"显示隐藏"开关正是作为这一纯客户端改动落地:标签固定的 footer 开关,其状态由按下态呈现承载(`aria-pressed` + 勾选符号);以点开头的路径草稿前缀会显出它所指名的隐藏条目;当前选中项则不受隐藏与前缀两种过滤影响(它锚定着双栏视图)。Windows 的 `FILE_ATTRIBUTE_HIDDEN` 不被 dirent 暴露——记为限制,直到原生探测值回其成本。 -- **路径编辑器的取消范围:对话框卡片。** browse 客户端的路径编辑器在按 Escape 与焦点离开卡片时取消,两者都在卡片范围的包装层而非输入框上监听——Tab 把焦点停到某个过滤命中的行之后,输入框已不在事件路径上,但 Escape 仍须收起编辑器(而非对话框),其后的焦点离开也仍须取消。不取消的豁免:窗口/标签页失焦、卡片内焦点移动,以及指针路径(编辑期间行与开关在 mousedown 时抑制焦点夺取)。预填与草稿末段过滤所用的分隔符从 `listing.home` 推断;下文的线上字段替代方案记录了被延期的权威形态。编辑器与其过滤的列表之间的 combobox 语义(`aria-expanded`/`aria-controls`/active-descendant、结果播报)同样被延期——目前二者在辅助技术看来是彼此独立的控件。 +- **路径编辑器的取消范围:对话框卡片。** browse 客户端的路径编辑器在按 Escape 与焦点离开卡片时取消,两者都在卡片范围的包装层而非输入框上监听——Tab 把焦点停到某个过滤命中的行之后,输入框已不在事件路径上,但 Escape 仍须收起编辑器(而非对话框),其后的焦点离开也仍须取消。不取消的豁免:窗口/标签页失焦、卡片内焦点移动,以及指针路径(编辑期间行与开关在 mousedown 时抑制焦点夺取)。预填与草稿末段过滤所用的分隔符从 `listing.home` 推断;下文的线上字段替代方案记录了被延期的权威形态。编辑器与其过滤的列表之间的 combobox 语义(`aria-expanded`/`aria-controls`/active-descendant、结果播报)同样被延期——目前二者在辅助技术看来是彼此独立的控件。焦点停靠是卡片全域的不变量,而非编辑器独有:每次选取——无论是否处于编辑态,包括右栏推进与各列被替换的创建落地——提交后都把焦点重新停靠到选中项所在的行上,而 Enter、焦点在输入框上时的 Escape,以及所在行已消失的选取则停靠到 crumb 编辑区;键盘遍历绝不会落出卡片之外(Modal 没有焦点陷阱)。 - **导航以选中项为锚、渐进落地。** 在展示根之外(与 crumb 头部渲染的是同一塌缩,因此 crumb 与分栏形态永不相左),browse 客户端的导航在目标层级到达的那一刻即提交它——这次首个落定即关闭编辑器并结束加载,因此 Enter 提交的导航绝不会为等待更多内容而被撤回——随后父层级这一程就地升级这次落地:重新选中目标在父层级中的实际条目(Windows 上按平台惯例折叠大小写),右侧展示其子项,因此 crumb 跳转读作后退一栏,而不是塌缩成单列。父层级这一程在落地的 supersession 范围下运行,任何较新的意图都会在线上将其中止;父层级这一程失败,或被截断的父窗口缺少目标时,都保留已提交的单栏落地——升级的存在正是为了锚定选中项,绝不能反而让它悬空。渐进形态的已知边界:指针按压若恰好落在单个 RTT 的升级窗口内,可能因所按行节点被替换而丢失点击(键盘焦点会重新停靠到重新选中的行上;指针的这段窗口则被接受);斜杠平台(macOS)上仅末段的大小写偏差会错过父层级条目匹配,保留单栏落地,而祖先段的偏差仍能匹配——父层级条目路径继承键入的前缀——并落地双栏,代价是 Home 塌缩;且导航总是重新列举两程,哪怕目标就是当前展示的层级——crumb 点按兼作刷新手势,因此宁要新鲜度也不复用手头可能已陈旧的列举,代价是至多两次宿主扫描。 - **符号链接:为可进入性而跟随。** 用 `stat` 探测符号链接(断链/循环→跳过);面包屑保留操作者导航的逻辑路径,`workspace.create` 在接纳时本就做 realpath 规范化。 - **列举层级有上限,且流式处理。** 单次 `list` 至多返回 `maxEntries` 行(配置项,默认 1000——GitHub 网页端目录列举的同一上限)。层级经 `opendir` 流入一个按名排序、容量 `maxEntries + 1` 的候选窗口,内存保持 O(maxEntries),可进入性探测只触及窗口内候选;线上 `DirectoryListing` 携带必填的 `truncated` 标志,让客户端明示不完整而不是静默缺尾。窗口内的断链符号链接不从窗口外回填——发生过驱逐本身已把层级标记为截断。窗口插入为二分查找、满窗尾部单次比较即拒绝(超大层级不能为每个 dirent 付出一次全窗扫描),且 `list(path, signal)` 透传载体的请求信号,滞塞网络目录的扫描不会在调用方断连后继续存活——扫描中的每个 await(打开、每次读取、每次符号链接探测)都与信号赛跑,中止路径放弃而非等待 close(Node 会把 close 排在在飞读取之后),被放弃的 settlement 全部吞掉,清理不会以未处理拒绝的形式冒出。无上限的层级对超大或恶意构造的目录就是内存/响应性漏洞。 diff --git a/packages/host/directory-picker-browse/src/client/DirectoryBrowser.tsx b/packages/host/directory-picker-browse/src/client/DirectoryBrowser.tsx index c4c4068d2e..b2e007ec2f 100644 --- a/packages/host/directory-picker-browse/src/client/DirectoryBrowser.tsx +++ b/packages/host/directory-picker-browse/src/client/DirectoryBrowser.tsx @@ -69,15 +69,23 @@ function foldPathFor(sep: '\\' | '/'): (value: string) => string { return value => (sep === '\\' ? value.toLowerCase() : value) } +/** Drops one trailing separator (`HOME=/home/u/` ships verbatim while resolve() strips it) unless the path IS the bare root. */ +function trimTrailingSeparator(path: string, sep: '\\' | '/'): string { + return path.length > sep.length && path.endsWith(sep) ? path.slice(0, -sep.length) : path +} + /** * Breadcrumb rows for display: inside the home subtree the chain starts at a * localized Home crumb; outside it the full ancestry shows, the root labeled - * by its own path. The home comparison folds per platform so a typed-case - * Windows path still collapses to the Home crumb. + * by its own path. The home comparison folds per platform and normalizes a + * trailing separator, so a typed-case Windows path or a `HOME=/home/u/` + * shape still collapses to the Home crumb. */ function displayCrumbs(listing: DirectoryListing, homeLabel: string): DirectoryEntry[] { - const fold = foldPathFor(separatorOf(listing)) - const homeIndex = listing.crumbs.findIndex(crumb => fold(crumb.path) === fold(listing.home)) + const sep = separatorOf(listing) + const fold = foldPathFor(sep) + const home = fold(trimTrailingSeparator(listing.home, sep)) + const homeIndex = listing.crumbs.findIndex(crumb => fold(trimTrailingSeparator(crumb.path, sep)) === home) if (homeIndex === -1) return listing.crumbs const tail = listing.crumbs.slice(homeIndex + 1) return [{ name: homeLabel, path: listing.home, hidden: false }, ...tail] @@ -154,10 +162,10 @@ function LevelColumn({ entries, selectedPath, busy, onPick, showHidden, filterPr // where the blur lands before our guards) drop this click. // Outside editing, rows keep native focus behavior. onMouseDown={pathEditing ? (event) => { event.preventDefault() } : undefined} - // Editing-time focus parking happens after commit (the - // DirectoryBrowser refocus effect): a right-pane pick replaces - // this very column, so focusing the clicked node here would - // still fall to body. + // Focus parking happens after commit (the DirectoryBrowser + // refocus effect): a right-pane pick replaces this very + // column, so focusing the clicked node here would still fall + // to body. onClick={() => { onPick(entry) }} > {selected @@ -198,9 +206,12 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen, // The in-flight listing's controller: superseding intent aborts the wire // request too — the Host stops scanning — instead of only discarding the // eventual result while the scan keeps consuming host resources. Always - // holds a controller (a settled or aborted one between scans) so no - // consumer needs a null guard. - const scanController = useRef(new AbortController()) + // holds a controller so no consumer needs a null guard: initially a + // placeholder that the first supersede aborts unused (minted lazily — + // useRef evaluates its argument every render), afterwards the latest + // scan's, settled or aborted between scans. + const [initialScanController] = useState(() => new AbortController()) + const scanController = useRef(initialScanController) // Bumped on every open/close edge: settlements from a previous open (a // pending creation included) must never mutate a reopened dialog. const openGeneration = useRef(0) @@ -364,7 +375,7 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen, // re-parks on the edit zone only if focus actually fell to body. refocusEditZone.current = true }) - }, [launchListing, pathDraft]) + }, [launchListing]) /** Abandon path editing (Escape or clicking away) and restore the crumb view. */ const cancelPathEdit = useCallback(() => { @@ -472,10 +483,13 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen, }, [crumbTail]) // On viewports too narrow for both fixed panes the Miller row scrolls; // whenever a child preview lands, pin it into view the way the crumb tail - // pins — otherwise descent is unreachable on a phone-width window. On a - // parent-leg upgrade the refocus effect below runs after this pin and its - // row.focus() may scroll the selected LEFT row back into view: for that - // one landing, focus placement wins over the child pin by design. + // pins — otherwise descent is unreachable on a phone-width window. The + // refocus effect's row.focus() and this pin can fight on such viewports, + // and whichever commit runs later wins by design: on a parent-leg + // upgrade (one commit) focus placement runs after the pin and keeps the + // selected LEFT row in view; on a plain advance or create landing the + // child arrives in a later commit, so the pin runs after the focus and + // descent reachability wins. const childPath = child?.path useEffect(() => { const row = millerRowRef.current @@ -512,7 +526,9 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen, // the user parked elsewhere (a surviving row) stays theirs. if (document.activeElement !== document.body) return const zone = editZoneRef.current - /* v8 ignore next -- narrowing guard: crumb mode renders the edit zone whenever the editor just closed. */ + // The effect already returned while a draft is open, and the close + // reset cleared both flags — so crumb mode's zone is always mounted. + /* v8 ignore next -- narrowing guard: crumb mode always renders the edit zone. */ if (zone === null) return zone.focus() } diff --git a/packages/host/directory-picker-browse/tests/directory-browser.spec.tsx b/packages/host/directory-picker-browse/tests/directory-browser.spec.tsx index c5ed60fa5e..909714ae7d 100644 --- a/packages/host/directory-picker-browse/tests/directory-browser.spec.tsx +++ b/packages/host/directory-picker-browse/tests/directory-browser.spec.tsx @@ -224,6 +224,18 @@ describe('DirectoryBrowser', () => { expect(within(columns()[1]!).getByText('harness')).toBeTruthy() }) + it('a trailing-separator home is still the display root (single pane on open)', async () => { + const listDirectory = vi.fn(async (path?: string) => ({ ...listingFor(path), home: `${HOME}/` })) + mount({ listDirectory }) + await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() }) + // `HOME=/home/u/` ships verbatim while the listing path resolves without + // the trailing separator; the normalized comparison still collapses to + // the Home crumb and no parent leg launches. + expect(columns()).toHaveLength(1) + expect(screen.getByRole('button', { name: 'browser.home' })).toBeTruthy() + expect(listDirectory).toHaveBeenCalledTimes(1) + }) + it('a navigation to the filesystem root keeps the single wide level', async () => { mount() await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() }) @@ -643,8 +655,7 @@ describe('DirectoryBrowser', () => { expect(document.activeElement?.getAttribute('aria-current')).toBe('true') }) - it('a create landing parks focus on the created row, or the edit zone when the relist lost it', async () => { - // First create: the relist contains the created directory. + it('a create landing parks focus on the created row', async () => { const b = mount() await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() }) b.listDirectory.mockImplementation(async (path?: string) => { From 4171f8eaa95295edeedb480017839c58710fc627 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Wed, 29 Jul 2026 23:21:35 +0800 Subject: [PATCH 063/113] =?UTF-8?q?fix(host):=20review=20round=2011=20?= =?UTF-8?q?=E2=80=94=20every=20displacing=20exit=20re-parks=20focus;=20ful?= =?UTF-8?q?l=20trailing-separator=20trim?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...directory-picker-capability-seam.i18n.yaml | 4 +- ...-07-28-directory-picker-capability-seam.md | 2 +- ...-28-directory-picker-capability-seam.zh.md | 2 +- .../src/client/DirectoryBrowser.tsx | 62 +++++++++++++----- .../tests/directory-browser.spec.tsx | 65 +++++++++++++++++++ 5 files changed, 114 insertions(+), 21 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.i18n.yaml index 2a98ace12d..b35392209c 100644 --- a/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md -2026-07-28-directory-picker-capability-seam.md: b860feab1fecead8130b77218005ab950575d5c9 -2026-07-28-directory-picker-capability-seam.zh.md: fea181800243b27499fa0a97acc44253078ad9db +2026-07-28-directory-picker-capability-seam.md: c6241ecca4abfafcee104a0c5d9ecc1150627e42 +2026-07-28-directory-picker-capability-seam.zh.md: 1e559a3c95352b08f8ad1aafdf8ce3efa0edc5cb diff --git a/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md b/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md index b860feab1f..c6241ecca4 100644 --- a/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md +++ b/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md @@ -19,7 +19,7 @@ Placement and policy rulings folded into this decision: - **Not the `ctx.fs` seam.** `packages/fs/` is the model/session-facing storage stack (policy events, sandbox-swappable backends). Riding it would couple GUI browsing to the model's confinement backend — swapping `fs-sandbox` for the model must never change GUI behavior — and OS facts (home anchoring, hidden conventions) are not storage primitives. The picker seam stays presentation-free and model-free; `packages/host/` is its consumer-domain home. - **Dependency survey (hand-roll vs adopt).** Node's stdlib *is* the maintained cross-platform OS layer (`readdir(withFileTypes)`, `homedir`, path semantics); surveyed alternatives fail the dependency bar — file-manager packages (`node-file-manager`, `files-and-folders`, Syncfusion's provider) are whole HTTP apps (fit), drive-letter helpers (`drivelist` native addon, `windows-drive-letters` ~7y stale) fail health/proportionality. The browse backend is a thin adapter over stdlib. - **Hidden entries: return-and-flag.** The host stamps `hidden` (POSIX dot convention) and returns everything; the client filters. Display policy stays client-side, and the show-hidden toggle shipped as exactly that client-only change: a fixed-label footer toggle whose state lives in the pressed presentation (`aria-pressed` + check glyph), a dot-led path-draft prefix reveals the hidden entries it names, and the current selection is exempt from both the hidden and the prefix filter (it anchors the two-pane view). Windows' `FILE_ATTRIBUTE_HIDDEN` is not exposed by dirents — documented limitation until a native probe pays for itself. -- **Path-editor cancel scope: the dialog card.** The browse client's path editor cancels on Escape and on focus leaving the card, both observed at a card-scope wrapper rather than the input — after Tab parks focus on a filtered row the input is off the event path, yet Escape must collapse the editor (not the dialog) and a later focus departure must still cancel. Non-cancel exemptions: window/tab focus loss, in-card focus moves, and pointer paths (rows and the toggle suppress focus steal on mousedown while editing). Separators for seeding and draft-tail filtering are inferred from `listing.home`; the wire-field alternative below records the deferred authoritative form. Combobox semantics between the editor and the list it filters (`aria-expanded`/`aria-controls`/active-descendant, result announcements) are likewise deferred — today they read to assistive tech as separate widgets. Focus parking is a card-wide invariant, not an editor-only one: every pick — editing or not, including right-pane advances and create landings whose columns are replaced — re-parks focus on the selection's row after commit, while Enter, an input-focused Escape, and a pick whose row vanished park on the crumb edit zone; keyboard traversal never falls out of the card (the Modal has no focus trap). +- **Path-editor cancel scope: the dialog card.** The browse client's path editor cancels on Escape and on focus leaving the card, both observed at a card-scope wrapper rather than the input — after Tab parks focus on a filtered row the input is off the event path, yet Escape must collapse the editor (not the dialog) and a later focus departure must still cancel. Non-cancel exemptions: window/tab focus loss, in-card focus moves, and pointer paths (rows and the toggle suppress focus steal on mousedown while editing). Separators for seeding and draft-tail filtering are inferred from `listing.home`; the wire-field alternative below records the deferred authoritative form. Combobox semantics between the editor and the list it filters (`aria-expanded`/`aria-controls`/active-descendant, result announcements) are likewise deferred — today they read to assistive tech as separate widgets. Focus parking is a card-wide invariant, not an editor-only one: every pick — editing or not, including right-pane advances and create landings whose columns are replaced — re-parks focus on the selection's row after commit, while every other displacing exit (Enter, Escape, a navigation landing whose new level dropped the focused row, a failed pick or create relist, and the nested create dialog closing) falls back to the crumb edit zone whenever focus actually fell to body; keyboard traversal never falls out of the card (the Modal has no focus trap). - **Navigation lands selection-anchored, progressively.** Away from the display root (the same collapse the crumb header renders, so crumbs and pane shape never disagree), the browse client's navigate commits the target level the moment it arrives — the editor closes and loading ends on that first settlement, so an Enter-submitted navigation is never withdrawn waiting on more — and a parent leg then upgrades the landing in place: the target's actual parent-level entry re-selected (platform case folding on Windows), its children on the right, so a crumb jump reads as stepping back one pane rather than collapsing to a single column. The parent leg runs under the landing's supersession scope and is aborted on the wire by any newer intent; a failed parent leg, or a truncated parent window lacking the target, leaves the committed single-pane landing — the upgrade must never orphan the selection it exists to anchor. Known boundaries of the progressive shape: a pointer press landing exactly inside the one-RTT upgrade window can lose its click when the pressed row node is replaced (keyboard focus is re-parked on the re-selected row; the pointer window is accepted); on slash platforms (macOS) only a final-segment case drift misses the parent-entry match and keeps the single-pane landing, while ancestor-segment drift still matches — parent entry paths inherit the typed prefix — and lands two panes at the cost of the Home collapse; and navigate always relists both legs even when the target is the currently shown level — a crumb tap doubles as the refresh gesture, so freshness wins over reusing possibly-stale in-hand listings at the cost of up to two host scans. - **Symlinks: follow for enterability.** `stat` probes symlinks (broken/cyclic → skipped); crumbs keep the logical path the operator navigated, and `workspace.create` already canonicalizes via realpath at adoption. - **Listing levels are bounded, and streamed.** One `list` call returns at most `maxEntries` rows (config, default 1000 — GitHub's web-UI directory-listing bound). The level streams via `opendir` into a name-sorted window of `maxEntries + 1` candidates, so memory stays O(maxEntries) and enterability probing touches only windowed candidates; the wire `DirectoryListing` carries a required `truncated` flag so the client states incompleteness instead of silently missing tail entries. A windowed broken symlink is not backfilled from beyond the window — the eviction already marks the level truncated. Window insertion is binary with an O(1) full-window tail rejection (an oversized level must not pay a window scan per dirent), and `list(path, signal)` threads the carrier's request signal so a scan of a stalled network directory cannot outlive a disconnected caller — every await in the scan (open, each read, each symlink probe) races the signal, an aborted exit abandons rather than awaits the close (Node queues close behind in-flight reads), and abandoned settlements are swallowed so cleanup can never surface as an unhandled rejection. An unbounded level is a memory/responsiveness hole for large or adversarial directories. diff --git a/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.zh.md b/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.zh.md index fea1818002..1e559a3c95 100644 --- a/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.zh.md @@ -19,7 +19,7 @@ web GUI 的"打开本地文件夹"流程被焊死在一种交互上:`host.pick - **不用 `ctx.fs` seam。** `packages/fs/` 是面向模型/会话的存储栈(policy 事件、sandbox 可换后端)。骑上去会把 GUI 浏览耦合进模型的限制后端——为模型换 `fs-sandbox` 绝不能改变 GUI 行为——而 OS 事实(home 锚定、隐藏约定)也不是存储原语。picker seam 保持无展示、无模型;`packages/host/` 是它消费方域的家。 - **依赖调研(手写 vs 引入)。** Node 标准库本身就是维护中的跨平台 OS 层(`readdir(withFileTypes)`、`homedir`、路径语义);调研过的替代品都过不了依赖门槛——文件管理器包(`node-file-manager`、`files-and-folders`、Syncfusion 的 provider)是整套 HTTP 应用(契合度不过),盘符工具(原生插件 `drivelist`、约七年未更的 `windows-drive-letters`)健康度/比例失当。browse 后端是标准库上的薄适配。 - **隐藏条目:返回并打标。** 宿主标注 `hidden`(POSIX 点前缀约定)并返回全部条目;客户端过滤。展示策略留在客户端,"显示隐藏"开关正是作为这一纯客户端改动落地:标签固定的 footer 开关,其状态由按下态呈现承载(`aria-pressed` + 勾选符号);以点开头的路径草稿前缀会显出它所指名的隐藏条目;当前选中项则不受隐藏与前缀两种过滤影响(它锚定着双栏视图)。Windows 的 `FILE_ATTRIBUTE_HIDDEN` 不被 dirent 暴露——记为限制,直到原生探测值回其成本。 -- **路径编辑器的取消范围:对话框卡片。** browse 客户端的路径编辑器在按 Escape 与焦点离开卡片时取消,两者都在卡片范围的包装层而非输入框上监听——Tab 把焦点停到某个过滤命中的行之后,输入框已不在事件路径上,但 Escape 仍须收起编辑器(而非对话框),其后的焦点离开也仍须取消。不取消的豁免:窗口/标签页失焦、卡片内焦点移动,以及指针路径(编辑期间行与开关在 mousedown 时抑制焦点夺取)。预填与草稿末段过滤所用的分隔符从 `listing.home` 推断;下文的线上字段替代方案记录了被延期的权威形态。编辑器与其过滤的列表之间的 combobox 语义(`aria-expanded`/`aria-controls`/active-descendant、结果播报)同样被延期——目前二者在辅助技术看来是彼此独立的控件。焦点停靠是卡片全域的不变量,而非编辑器独有:每次选取——无论是否处于编辑态,包括右栏推进与各列被替换的创建落地——提交后都把焦点重新停靠到选中项所在的行上,而 Enter、焦点在输入框上时的 Escape,以及所在行已消失的选取则停靠到 crumb 编辑区;键盘遍历绝不会落出卡片之外(Modal 没有焦点陷阱)。 +- **路径编辑器的取消范围:对话框卡片。** browse 客户端的路径编辑器在按 Escape 与焦点离开卡片时取消,两者都在卡片范围的包装层而非输入框上监听——Tab 把焦点停到某个过滤命中的行之后,输入框已不在事件路径上,但 Escape 仍须收起编辑器(而非对话框),其后的焦点离开也仍须取消。不取消的豁免:窗口/标签页失焦、卡片内焦点移动,以及指针路径(编辑期间行与开关在 mousedown 时抑制焦点夺取)。预填与草稿末段过滤所用的分隔符从 `listing.home` 推断;下文的线上字段替代方案记录了被延期的权威形态。编辑器与其过滤的列表之间的 combobox 语义(`aria-expanded`/`aria-controls`/active-descendant、结果播报)同样被延期——目前二者在辅助技术看来是彼此独立的控件。焦点停靠是卡片全域的不变量,而非编辑器独有:每次选取——无论是否处于编辑态,包括右栏推进与各列被替换的创建落地——提交后都把焦点重新停靠到选中项所在的行上,而其余所有会顶离焦点的退出(Enter、Escape、新层级已不含焦点所在行的导航落地、选取或创建失败后的重新列举,以及嵌套创建对话框的关闭)只要焦点确实落到了 body 上,就回落到 crumb 编辑区;键盘遍历绝不会落出卡片之外(Modal 没有焦点陷阱)。 - **导航以选中项为锚、渐进落地。** 在展示根之外(与 crumb 头部渲染的是同一塌缩,因此 crumb 与分栏形态永不相左),browse 客户端的导航在目标层级到达的那一刻即提交它——这次首个落定即关闭编辑器并结束加载,因此 Enter 提交的导航绝不会为等待更多内容而被撤回——随后父层级这一程就地升级这次落地:重新选中目标在父层级中的实际条目(Windows 上按平台惯例折叠大小写),右侧展示其子项,因此 crumb 跳转读作后退一栏,而不是塌缩成单列。父层级这一程在落地的 supersession 范围下运行,任何较新的意图都会在线上将其中止;父层级这一程失败,或被截断的父窗口缺少目标时,都保留已提交的单栏落地——升级的存在正是为了锚定选中项,绝不能反而让它悬空。渐进形态的已知边界:指针按压若恰好落在单个 RTT 的升级窗口内,可能因所按行节点被替换而丢失点击(键盘焦点会重新停靠到重新选中的行上;指针的这段窗口则被接受);斜杠平台(macOS)上仅末段的大小写偏差会错过父层级条目匹配,保留单栏落地,而祖先段的偏差仍能匹配——父层级条目路径继承键入的前缀——并落地双栏,代价是 Home 塌缩;且导航总是重新列举两程,哪怕目标就是当前展示的层级——crumb 点按兼作刷新手势,因此宁要新鲜度也不复用手头可能已陈旧的列举,代价是至多两次宿主扫描。 - **符号链接:为可进入性而跟随。** 用 `stat` 探测符号链接(断链/循环→跳过);面包屑保留操作者导航的逻辑路径,`workspace.create` 在接纳时本就做 realpath 规范化。 - **列举层级有上限,且流式处理。** 单次 `list` 至多返回 `maxEntries` 行(配置项,默认 1000——GitHub 网页端目录列举的同一上限)。层级经 `opendir` 流入一个按名排序、容量 `maxEntries + 1` 的候选窗口,内存保持 O(maxEntries),可进入性探测只触及窗口内候选;线上 `DirectoryListing` 携带必填的 `truncated` 标志,让客户端明示不完整而不是静默缺尾。窗口内的断链符号链接不从窗口外回填——发生过驱逐本身已把层级标记为截断。窗口插入为二分查找、满窗尾部单次比较即拒绝(超大层级不能为每个 dirent 付出一次全窗扫描),且 `list(path, signal)` 透传载体的请求信号,滞塞网络目录的扫描不会在调用方断连后继续存活——扫描中的每个 await(打开、每次读取、每次符号链接探测)都与信号赛跑,中止路径放弃而非等待 close(Node 会把 close 排在在飞读取之后),被放弃的 settlement 全部吞掉,清理不会以未处理拒绝的形式冒出。无上限的层级对超大或恶意构造的目录就是内存/响应性漏洞。 diff --git a/packages/host/directory-picker-browse/src/client/DirectoryBrowser.tsx b/packages/host/directory-picker-browse/src/client/DirectoryBrowser.tsx index b2e007ec2f..3ddf5ac540 100644 --- a/packages/host/directory-picker-browse/src/client/DirectoryBrowser.tsx +++ b/packages/host/directory-picker-browse/src/client/DirectoryBrowser.tsx @@ -69,9 +69,17 @@ function foldPathFor(sep: '\\' | '/'): (value: string) => string { return value => (sep === '\\' ? value.toLowerCase() : value) } -/** Drops one trailing separator (`HOME=/home/u/` ships verbatim while resolve() strips it) unless the path IS the bare root. */ +/** + * Drops every trailing separator (`HOME=/home/u//` ships verbatim while + * resolve() strips them) down to, but never past, one leading character — + * which keeps the POSIX root `/` intact. A backslash drive root (`C:\`) + * does lose its separator; that stays safe only because every comparison + * trims both sides symmetrically. + */ function trimTrailingSeparator(path: string, sep: '\\' | '/'): string { - return path.length > sep.length && path.endsWith(sep) ? path.slice(0, -sep.length) : path + let end = path.length + while (end > sep.length && path.endsWith(sep, end)) end -= sep.length + return path.slice(0, end) } /** @@ -253,14 +261,14 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen, const millerRowRef = useRef(null) // Focus parking (consumed by the refocus effect below): a pick — and a // parent-leg upgrade that displaces focused rows — parks on the - // selection's row; Enter, an input-focused Escape, and a failed pick - // whose row unmounts park on the crumb edit zone (the latter only when - // focus actually fell to body). Pointer-out cancels never set (or clear) - // these — yanking focus back from wherever the user clicked would be - // worse than the fall. + // selection's row; every other displacing exit (Enter, Escape, a landing + // whose new level dropped the focused row, a failed pick or relist, and + // the nested create dialog closing) parks on the crumb edit zone, each + // only when focus actually fell to body. Pointer-out cancels never set + // (or clear) these — yanking focus back from wherever the user clicked + // would be worse than the fall. const refocusPick = useRef(false) const refocusEditZone = useRef(false) - const pathInputRef = useRef(null) const editZoneRef = useRef(null) /** @@ -300,6 +308,14 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen, setError(null) scan.then((target) => { if (seq !== requestSeq.current) return + // The landing replaces every row key; a slow jump leaves the OLD + // rows tabbable meanwhile (parentInert excludes loading), so focus + // may live among them. With no selection yet the edit zone is the + // park target (body-guarded, like every other exit). + const rowHost = millerRowRef.current + /* v8 ignore next -- close-race guard: the commit-to-effect window is not deterministically reproducible. */ + if (rowHost === null) return + if (rowHost.contains(document.activeElement)) refocusEditZone.current = true setParent(target) setSelected(null) setChild(null) @@ -343,6 +359,17 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen, }) }, [launchListing, continueScan]) + /** + * Close the nested create dialog. Its unmount drops focus to body (the + * Modal has no focus trap), so every exit — Escape, mask, Cancel, and a + * successful create — arms the body-guarded edit-zone parking; a create + * landing's later select() re-parks on the created row instead. + */ + const closeCreateDialog = useCallback(() => { + setFolderDraft(null) + refocusEditZone.current = true + }, []) + /** Select a row of the listed level and preview its children on the right. */ const select = useCallback((entry: DirectoryEntry) => { const { seq, scan } = launchListing(entry.path) @@ -449,7 +476,7 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen, // the fresh dialog or issue a relist against the stale target. if (generation !== openGeneration.current) return setCreatingFolder(false) - setFolderDraft(null) + closeCreateDialog() // Land like a right-column pick (figma 802:57446 → 813:23278 flow): the // create target becomes the listed level and the new folder its selection. const { seq, scan } = launchListing(targetPath) @@ -572,11 +599,13 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen, // document listener — the same containment the input previously // provided for itself. event.stopPropagation() - // Escape while the input holds focus is about to unmount it; with - // focus already parked on a row, that row survives the cancel and - // keeps focus naturally. Assignment (not a conditional set) also + // The cancel may unmount whatever holds focus — the input, or a + // dot-revealed row the cleared draft re-hides. Arm the parking + // unconditionally: the refocus effect's body guard already + // distinguishes a surviving focused row (left alone) from focus + // that actually fell. Assignment (not a conditional set) also // retires a stale flag a failed or still-upgrading Enter left. - refocusEditZone.current = document.activeElement === pathInputRef.current + refocusEditZone.current = true cancelPathEdit() }} // Focus leaving THIS dialog card while editing cancels like Escape. @@ -660,7 +689,6 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen, value={pathDraft} aria-label={t('browser.editPath')} autoFocus - ref={pathInputRef} disabled={parentInert} onChange={(event) => { // Editing the draft supersedes any in-flight navigation: @@ -770,7 +798,7 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen, {/* Nested create dialog (figma 813:23278): names one folder inside the target. */} { if (!creatingFolder) setFolderDraft(null) }} + onClose={() => { if (!creatingFolder) closeCreateDialog() }} title={t('browser.newFolder')} className={clsx(css.createDialog)} headless @@ -794,13 +822,13 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen, } if (event.key === 'Escape') { event.stopPropagation() - if (!creatingFolder) setFolderDraft(null) + if (!creatingFolder) closeCreateDialog() } }} /> {createError !== null &&
{createError}
}
- + diff --git a/packages/host/directory-picker-browse/tests/directory-browser.spec.tsx b/packages/host/directory-picker-browse/tests/directory-browser.spec.tsx index c27522a6cf..e2adc75881 100644 --- a/packages/host/directory-picker-browse/tests/directory-browser.spec.tsx +++ b/packages/host/directory-picker-browse/tests/directory-browser.spec.tsx @@ -349,6 +349,16 @@ describe('DirectoryBrowser', () => { expect(document.activeElement).toBe(screen.getByRole('button', { name: 'browser.editPath' })) }) + it('a home carrying dot segments is still the display root', async () => { + // os.homedir() ships HOME verbatim; the backend resolves listing paths. + const listDirectory = vi.fn(async (path?: string) => ({ ...listingFor(path), home: `${HOME}/foo/../.` })) + mount({ listDirectory }) + await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() }) + expect(columns()).toHaveLength(1) + expect(screen.getByRole('button', { name: 'browser.home' })).toBeTruthy() + expect(listDirectory).toHaveBeenCalledTimes(1) + }) + it('a navigation to the filesystem root keeps the single wide level', async () => { mount() await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() }) @@ -1176,6 +1186,7 @@ describe('DirectoryBrowser', () => { expect(cancels.map(button => button.disabled).sort()).toEqual([false, true]) expect(screen.getByRole('button', { name: 'browser.open' }).disabled).toBe(true) expect(screen.getByRole('button', { name: 'browser.editPath' }).disabled).toBe(true) + expect(screen.getByRole('button', { name: 'browser.showHidden' }).disabled).toBe(true) for (const row of screen.getAllByRole('listitem')) { expect(rowButton(row).disabled).toBe(true) } From 4921b63fe353006c4319213e228c11a5ac1184ee Mon Sep 17 00:00:00 2001 From: creatixchu Date: Thu, 30 Jul 2026 00:49:18 +0800 Subject: [PATCH 075/113] =?UTF-8?q?fix(host):=20review=20round=2016=20?= =?UTF-8?q?=E2=80=94=20UNC/forward-slash=20home=20forms;=20root-crumb=20se?= =?UTF-8?q?parator;=20scrollbar=20symmetry;=20test=20dedup?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/client/DirectoryBrowser.module.css | 11 ++- .../src/client/DirectoryBrowser.tsx | 53 +++++++------ .../tests/directory-browser.spec.tsx | 75 +++++++++++++------ 3 files changed, 91 insertions(+), 48 deletions(-) diff --git a/packages/host/directory-picker-browse/src/client/DirectoryBrowser.module.css b/packages/host/directory-picker-browse/src/client/DirectoryBrowser.module.css index 8c8033d825..8bd044f610 100644 --- a/packages/host/directory-picker-browse/src/client/DirectoryBrowser.module.css +++ b/packages/host/directory-picker-browse/src/client/DirectoryBrowser.module.css @@ -62,6 +62,12 @@ scrollbar-width: none; } +/* Pseudo-element-path engines (see .millerRow's twin rule): the 20px crumb + * bar has no room for a bar at all. */ +.crumbTrail::-webkit-scrollbar { + display: none; +} + .crumbSeat { display: inline-flex; align-items: center; @@ -299,9 +305,8 @@ color: var(--dsw-alias-label-primary); } -/* Trailing pressed check (Menu's .check parallel): flex-none so wrap or - * narrow-viewport clamp pressure never squashes the glyph — the nowrap - * label refuses to shrink, leaving the icon as the only compressible item. */ +/* Flex-none: the nowrap label refuses to shrink, which would leave the + * glyph as the only compressible item under wrap or narrow-viewport clamp. */ .toggleCheck { flex: none; } diff --git a/packages/host/directory-picker-browse/src/client/DirectoryBrowser.tsx b/packages/host/directory-picker-browse/src/client/DirectoryBrowser.tsx index 8507681150..d82b4f0abd 100644 --- a/packages/host/directory-picker-browse/src/client/DirectoryBrowser.tsx +++ b/packages/host/directory-picker-browse/src/client/DirectoryBrowser.tsx @@ -73,30 +73,37 @@ function foldPathFor(sep: '\\' | '/'): (value: string) => string { } /** - * Lexically normalizes an absolute host path for comparisons: collapses - * repeated and trailing separators, drops `.` segments, and applies `..` — - * mirroring the backend's resolve() for the shapes an environment-supplied - * HOME legally carries verbatim (`/home/u/`, `/home//u`, `/home/u/.`) - * while the backend's paths arrive already resolved. A lexical mirror - * only: symlinks are the backend's business, and the input always - * contains the separator (it is an absolute path). + * Lexically normalizes an absolute host path for comparisons: folds + * forward slashes on Windows (win32 accepts either), collapses repeated + * and trailing separators, drops `.` segments, and applies `..` without + * ever crossing the root — POSIX's `/`, a drive's `C:`, or UNC's + * `\\server\share` pair — mirroring the backend's resolve() for the + * shapes an environment-supplied HOME legally carries verbatim while the + * backend's own paths arrive already resolved. A lexical mirror only: + * symlinks are the backend's business. */ function normalizePathFor(sep: '\\' | '/'): (value: string) => string { - return (value) => { - const segments = value.split(sep) - const head = segments.shift() - /* v8 ignore next -- narrowing guard: split always yields at least one segment. */ - if (head === undefined) return value - const out: string[] = [] - for (const segment of segments) { + return (raw) => { + // win32 treats a forward slash as a separator too (resolve() folds + // them); POSIX must not — a backslash there is a name character. + const value = sep === '\\' ? raw.replaceAll('/', sep) : raw + const unc = sep === '\\' && value.startsWith(`${sep}${sep}`) + const segments = (unc ? value.slice(2) : value).split(sep) + // The unpoppable root: POSIX's leading empty segment / the drive + // segment, or UNC's server + share pair. + const rootLength = unc ? 2 : 1 + const out = segments.slice(0, rootLength) + for (const segment of segments.slice(rootLength)) { if (segment === '' || segment === '.') continue if (segment === '..') { - out.pop() + if (out.length > rootLength) out.pop() continue } out.push(segment) } - return `${head}${sep}${out.join(sep)}` + // A bare root keeps (or regains) the trailing separator resolve() + // emits for `/`, `C:\`, and `\\server\share\`. + return `${unc ? sep + sep : ''}${out.join(sep)}${out.length === rootLength ? sep : ''}` } } @@ -119,16 +126,20 @@ function displayCrumbs(listing: DirectoryListing, homeLabel: string): DirectoryE } /** - * The listing's platform separator, inferred from the home path the host - * stamped — never from typed text or entry paths, where a backslash is a - * legal POSIX name character. Still a heuristic at the last step: a POSIX - * home directory whose own name contains a backslash would misread. + * The listing's platform separator, read from the host-resolved root crumb + * (`/`, `C:\`, `\\server\share\`) — exact for every root form the backend + * emits, immune both to a home delivered in the other slash flavor + * (`USERPROFILE=C:/Users/Alice`) and to backslashes inside POSIX names. * TODO: replace with a host-stamped `separator` field on the wire * DirectoryListing so the platform fact travels verbatim (the trade-off is * recorded in the directory-picker capability seam Agent Note). */ function separatorOf(listing: DirectoryListing): '\\' | '/' { - return listing.home.includes('\\') ? '\\' : '/' + const rootCrumb = listing.crumbs.at(0) + // Home is the honest fallback for an impossible empty chain. + /* v8 ignore next -- narrowing guard: the wire chain is root-to-target inclusive. */ + if (rootCrumb === undefined) return listing.home.includes('\\') ? '\\' : '/' + return rootCrumb.path.includes('\\') ? '\\' : '/' } /** diff --git a/packages/host/directory-picker-browse/tests/directory-browser.spec.tsx b/packages/host/directory-picker-browse/tests/directory-browser.spec.tsx index e2adc75881..fcc2b21f92 100644 --- a/packages/host/directory-picker-browse/tests/directory-browser.spec.tsx +++ b/packages/host/directory-picker-browse/tests/directory-browser.spec.tsx @@ -240,20 +240,12 @@ describe('DirectoryBrowser', () => { expect(within(columns()[1]!).getByText('harness')).toBeTruthy() }) - it('a trailing-separator home is still the display root (single pane on open)', async () => { - const listDirectory = vi.fn(async (path?: string) => ({ ...listingFor(path), home: `${HOME}/` })) - mount({ listDirectory }) - await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() }) - // `HOME=/home/u/` ships verbatim while the listing path resolves without - // the trailing separator; the normalized comparison still collapses to - // the Home crumb and no parent leg launches. - expect(columns()).toHaveLength(1) - expect(screen.getByRole('button', { name: 'browser.home' })).toBeTruthy() - expect(listDirectory).toHaveBeenCalledTimes(1) - }) - - it('a home with several trailing separators is still the display root', async () => { - const listDirectory = vi.fn(async (path?: string) => ({ ...listingFor(path), home: `${HOME}//` })) + // HOME ships verbatim from the environment while listing paths arrive + // resolved; each decoration (trailing, repeated, dot, dot-dot segments) + // must still normalize to the display root — single pane, Home crumb, + // and no parent leg launched. + it.each(['/', '//', '/foo/../.'])('a home decorated with "%s" is still the display root', async (decoration) => { + const listDirectory = vi.fn(async (path?: string) => ({ ...listingFor(path), home: `${HOME}${decoration}` })) mount({ listDirectory }) await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() }) expect(columns()).toHaveLength(1) @@ -349,16 +341,6 @@ describe('DirectoryBrowser', () => { expect(document.activeElement).toBe(screen.getByRole('button', { name: 'browser.editPath' })) }) - it('a home carrying dot segments is still the display root', async () => { - // os.homedir() ships HOME verbatim; the backend resolves listing paths. - const listDirectory = vi.fn(async (path?: string) => ({ ...listingFor(path), home: `${HOME}/foo/../.` })) - mount({ listDirectory }) - await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() }) - expect(columns()).toHaveLength(1) - expect(screen.getByRole('button', { name: 'browser.home' })).toBeTruthy() - expect(listDirectory).toHaveBeenCalledTimes(1) - }) - it('a navigation to the filesystem root keeps the single wide level', async () => { mount() await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() }) @@ -425,6 +407,51 @@ describe('DirectoryBrowser', () => { expect(document.activeElement?.getAttribute('aria-current')).toBe('true') }) + it('a UNC home with dot-dot never pops the share root and still collapses to Home', async () => { + const SHARE = '\\\\server\\share' + const listing: DirectoryListing = { + path: `${SHARE}\\x`, + // USERPROFILE ships verbatim; win32.resolve keeps \\server\share as + // the unpoppable root, so this normalizes to \\server\share\x. + home: `${SHARE}\\..\\x`, + crumbs: [ + { name: `${SHARE}\\`, path: `${SHARE}\\`, hidden: false }, + { name: 'x', path: `${SHARE}\\x`, hidden: false }, + ], + entries: [], + truncated: false, + } + const listDirectory = vi.fn(async () => listing) + mount({ listDirectory }) + await waitFor(() => { expect(listDirectory).toHaveBeenCalled() }) + await waitFor(() => { expect(screen.getByRole('button', { name: 'browser.home' })).toBeTruthy() }) + expect(columns()).toHaveLength(1) + expect(listDirectory).toHaveBeenCalledTimes(1) + }) + + it('a forward-slash Windows home still reads as the display root', async () => { + const listing: DirectoryListing = { + path: 'C:\\Users\\Alice', + // USERPROFILE may legally use forward slashes; the root crumb (not + // the home text) carries the platform, and normalization folds the + // slashes before comparing. + home: 'C:/Users/Alice', + crumbs: [ + { name: 'C:\\', path: 'C:\\', hidden: false }, + { name: 'Users', path: 'C:\\Users', hidden: false }, + { name: 'Alice', path: 'C:\\Users\\Alice', hidden: false }, + ], + entries: [{ name: 'Desktop', path: 'C:\\Users\\Alice\\Desktop', hidden: false }], + truncated: false, + } + const listDirectory = vi.fn(async () => listing) + mount({ listDirectory }) + await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() }) + expect(columns()).toHaveLength(1) + expect(screen.getByRole('button', { name: 'browser.home' })).toBeTruthy() + expect(listDirectory).toHaveBeenCalledTimes(1) + }) + it('collapses a typed-case Windows home to the display root (single pane, Home crumb)', async () => { const CANON = 'C:\\Users\\Alice' const TYPED = 'c:\\users\\alice' From 12d302ea591bc4439fc9c6102f495033b3b4cb18 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Thu, 30 Jul 2026 01:10:37 +0800 Subject: [PATCH 076/113] =?UTF-8?q?fix(host):=20review=20round=2017=20?= =?UTF-8?q?=E2=80=94=20separator-fold=20drafts;=20UNC=20noise=20scrub;=20h?= =?UTF-8?q?onest=20empty-chain=20fallback;=20toggle=20focus=20keep?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...directory-picker-capability-seam.i18n.yaml | 4 +- ...-07-28-directory-picker-capability-seam.md | 4 +- ...-28-directory-picker-capability-seam.zh.md | 4 +- .../src/client/DirectoryBrowser.tsx | 47 ++++++++++---- .../tests/directory-browser.spec.tsx | 62 ++++++++++++++++++- 5 files changed, 102 insertions(+), 19 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.i18n.yaml index 0b17e08ce7..81c065227f 100644 --- a/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md -2026-07-28-directory-picker-capability-seam.md: 75fda86788ec2ab1966f7919cdca7d503238da91 -2026-07-28-directory-picker-capability-seam.zh.md: 1e2c7819133366d5b9a0b26db195b46ac5ea62f4 +2026-07-28-directory-picker-capability-seam.md: 9008ba928c16a19fae601db0b8a83d44eaeeaae0 +2026-07-28-directory-picker-capability-seam.zh.md: 95ba66062aafb4ebe1ed37219efd5ad94f13f842 diff --git a/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md b/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md index 75fda86788..9008ba928c 100644 --- a/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md +++ b/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md @@ -19,7 +19,7 @@ Placement and policy rulings folded into this decision: - **Not the `ctx.fs` seam.** `packages/fs/` is the model/session-facing storage stack (policy events, sandbox-swappable backends). Riding it would couple GUI browsing to the model's confinement backend — swapping `fs-sandbox` for the model must never change GUI behavior — and OS facts (home anchoring, hidden conventions) are not storage primitives. The picker seam stays presentation-free and model-free; `packages/host/` is its consumer-domain home. - **Dependency survey (hand-roll vs adopt).** Node's stdlib *is* the maintained cross-platform OS layer (`readdir(withFileTypes)`, `homedir`, path semantics); surveyed alternatives fail the dependency bar — file-manager packages (`node-file-manager`, `files-and-folders`, Syncfusion's provider) are whole HTTP apps (fit), drive-letter helpers (`drivelist` native addon, `windows-drive-letters` ~7y stale) fail health/proportionality. The browse backend is a thin adapter over stdlib. - **Hidden entries: return-and-flag.** The host stamps `hidden` (POSIX dot convention) and returns everything; the client filters. Display policy stays client-side, and the show-hidden toggle shipped as exactly that client-only change: a fixed-label footer toggle whose state lives in the pressed presentation (`aria-pressed` + check glyph), a dot-led path-draft prefix reveals the hidden entries it names, and the current selection is exempt from both the hidden and the prefix filter (it anchors the two-pane view). Windows' `FILE_ATTRIBUTE_HIDDEN` is not exposed by dirents — documented limitation until a native probe pays for itself. -- **Path-editor cancel scope: the dialog card.** The browse client's path editor cancels on Escape and on focus leaving the card, both observed at a card-scope wrapper rather than the input — after Tab parks focus on a filtered row the input is off the event path, yet Escape must collapse the editor (not the dialog) and a later focus departure must still cancel. Non-cancel exemptions: window/tab focus loss, in-card focus moves, and pointer paths (rows and the toggle suppress focus steal on mousedown while editing). Separators for seeding and draft-tail filtering are inferred from `listing.home`; the wire-field alternative below records the deferred authoritative form. Combobox semantics between the editor and the list it filters (`aria-expanded`/`aria-controls`/active-descendant, result announcements) are likewise deferred — today they read to assistive tech as separate widgets. Focus parking is a card-wide invariant, not an editor-only one: every pick — editing or not, including right-pane advances and create landings whose columns are replaced — re-parks focus on the selection's row after commit, while every other displacing exit (Enter, Escape, a navigation landing whose new level dropped the focused row, a failed pick or create relist, and the nested create dialog closing) falls back to the crumb edit zone whenever focus actually fell to body; keyboard traversal never falls out of the card (the Modal has no focus trap), except during the owner's adopt window, where `busy` inerts every control and the dialog is closing either way. +- **Path-editor cancel scope: the dialog card.** The browse client's path editor cancels on Escape and on focus leaving the card, both observed at a card-scope wrapper rather than the input — after Tab parks focus on a filtered row the input is off the event path, yet Escape must collapse the editor (not the dialog) and a later focus departure must still cancel. Non-cancel exemptions: window/tab focus loss, in-card focus moves, and pointer paths (rows and the toggle suppress focus steal on mousedown while editing). Separators for seeding and draft-tail filtering are read from the host-resolved root crumb (exact for every root form this backend emits: `/`, `C:\`, `\\server\share\`); the wire-field alternative below records the deferred authoritative form. Combobox semantics between the editor and the list it filters (`aria-expanded`/`aria-controls`/active-descendant, result announcements) are likewise deferred — today they read to assistive tech as separate widgets. Focus parking is a card-wide invariant, not an editor-only one: every pick — editing or not, including right-pane advances and create landings whose columns are replaced — re-parks focus on the selection's row after commit, while every other displacing exit (Enter, Escape, a navigation landing whose new level dropped the focused row, a failed pick or create relist, and the nested create dialog closing) falls back to the crumb edit zone whenever focus actually fell to body; keyboard traversal never falls out of the card (the Modal has no focus trap), except during the owner's adopt window, where `busy` inerts every control and the dialog is closing either way. - **Navigation lands selection-anchored, progressively.** Away from the display root (the same collapse the crumb header renders, so crumbs and pane shape never disagree), the browse client's navigate commits the target level the moment it arrives — the editor closes and loading ends on that first settlement, so an Enter-submitted navigation is never withdrawn waiting on more — and a parent leg then upgrades the landing in place: the target's actual parent-level entry re-selected (platform case folding on Windows), its children on the right, so a crumb jump reads as stepping back one pane rather than collapsing to a single column. The parent leg runs under the landing's supersession scope and is aborted on the wire by any newer intent; a failed parent leg, or a truncated parent window lacking the target, leaves the committed single-pane landing — the upgrade must never orphan the selection it exists to anchor. Known boundaries of the progressive shape: a pointer press landing exactly inside the one-RTT upgrade window can lose its click when the pressed row node is replaced (keyboard focus is re-parked on the re-selected row; the pointer window is accepted); on slash platforms (macOS) only a final-segment case drift misses the parent-entry match and keeps the single-pane landing, while ancestor-segment drift still matches — parent entry paths inherit the typed prefix — and lands two panes at the cost of the Home collapse; and navigate always relists both legs even when the target is the currently shown level — a crumb tap doubles as the refresh gesture, so freshness wins over reusing possibly-stale in-hand listings at the cost of up to two host scans. - **Symlinks: follow for enterability.** `stat` probes symlinks (broken/cyclic → skipped); crumbs keep the logical path the operator navigated, and `workspace.create` already canonicalizes via realpath at adoption. - **Listing levels are bounded, and streamed.** One `list` call returns at most `maxEntries` rows (config, default 1000 — GitHub's web-UI directory-listing bound). The level streams via `opendir` into a name-sorted window of `maxEntries + 1` candidates, so memory stays O(maxEntries) and enterability probing touches only windowed candidates; the wire `DirectoryListing` carries a required `truncated` flag so the client states incompleteness instead of silently missing tail entries. A windowed broken symlink is not backfilled from beyond the window — the eviction already marks the level truncated. Window insertion is binary with an O(1) full-window tail rejection (an oversized level must not pay a window scan per dirent), and `list(path, signal)` threads the carrier's request signal so a scan of a stalled network directory cannot outlive a disconnected caller — every await in the scan (open, each read, each symlink probe) races the signal, an aborted exit abandons rather than awaits the close (Node queues close behind in-flight reads), and abandoned settlements are swallowed so cleanup can never surface as an unhandled rejection. An unbounded level is a memory/responsiveness hole for large or adversarial directories. @@ -34,7 +34,7 @@ Placement and policy rulings folded into this decision: - **Adopting a file-manager/drive-enumeration dependency.** Rejected per the survey above; recorded here as the dependency policy requires. - **A flip-label show-hidden toggle ("Hide hidden files").** Rejected: a flipping action label is ambiguous between state and action and doubles the negative; the fixed label with a pressed presentation states both at once. - **Pure relatedTarget blur cancellation (no mousedown suppression).** Rejected: Safari does not focus buttons on pointer down, so a click's focusout carries a null `relatedTarget` and would cancel the editor before the click lands; editing-scoped mousedown suppression plus the card-anchored relatedTarget guard covers pointer and keyboard paths together. -- **A wire `separator` field on `DirectoryListing` (host stamps `path.sep`).** Deferred, not rejected: it is the authoritative form — a POSIX home directory containing a backslash defeats the `listing.home` heuristic — but it touches the seam type and every backend; the browse client's `separatorOf` carries a TODO pointing at this alternative until a wire change is next scheduled. +- **A wire `separator` field on `DirectoryListing` (host stamps `path.sep`).** Deferred, not rejected: it is the authoritative form. The root-crumb read the client uses today is exact for every root shape this backend emits, but it still infers a platform fact from path text and promotes "the chain starts at the root" from backend behavior into a client-relied invariant (the `crumbs` JSDoc does promise it), and it degrades to the old home-text heuristic on an empty chain; a wire field would travel verbatim and survive empty chains and future backends. It touches the seam type and every backend, so the browse client's `separatorOf` carries a TODO pointing at this alternative until a wire change is next scheduled. ## Consequences diff --git a/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.zh.md b/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.zh.md index 1e2c781913..95ba66062a 100644 --- a/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.zh.md @@ -19,7 +19,7 @@ web GUI 的"打开本地文件夹"流程被焊死在一种交互上:`host.pick - **不用 `ctx.fs` seam。** `packages/fs/` 是面向模型/会话的存储栈(policy 事件、sandbox 可换后端)。骑上去会把 GUI 浏览耦合进模型的限制后端——为模型换 `fs-sandbox` 绝不能改变 GUI 行为——而 OS 事实(home 锚定、隐藏约定)也不是存储原语。picker seam 保持无展示、无模型;`packages/host/` 是它消费方域的家。 - **依赖调研(手写 vs 引入)。** Node 标准库本身就是维护中的跨平台 OS 层(`readdir(withFileTypes)`、`homedir`、路径语义);调研过的替代品都过不了依赖门槛——文件管理器包(`node-file-manager`、`files-and-folders`、Syncfusion 的 provider)是整套 HTTP 应用(契合度不过),盘符工具(原生插件 `drivelist`、约七年未更的 `windows-drive-letters`)健康度/比例失当。browse 后端是标准库上的薄适配。 - **隐藏条目:返回并打标。** 宿主标注 `hidden`(POSIX 点前缀约定)并返回全部条目;客户端过滤。展示策略留在客户端,"显示隐藏"开关正是作为这一纯客户端改动落地:标签固定的 footer 开关,其状态由按下态呈现承载(`aria-pressed` + 勾选符号);以点开头的路径草稿前缀会显出它所指名的隐藏条目;当前选中项则不受隐藏与前缀两种过滤影响(它锚定着双栏视图)。Windows 的 `FILE_ATTRIBUTE_HIDDEN` 不被 dirent 暴露——记为限制,直到原生探测值回其成本。 -- **路径编辑器的取消范围:对话框卡片。** browse 客户端的路径编辑器在按 Escape 与焦点离开卡片时取消,两者都在卡片范围的包装层而非输入框上监听——Tab 把焦点停到某个过滤命中的行之后,输入框已不在事件路径上,但 Escape 仍须收起编辑器(而非对话框),其后的焦点离开也仍须取消。不取消的豁免:窗口/标签页失焦、卡片内焦点移动,以及指针路径(编辑期间行与开关在 mousedown 时抑制焦点夺取)。预填与草稿末段过滤所用的分隔符从 `listing.home` 推断;下文的线上字段替代方案记录了被延期的权威形态。编辑器与其过滤的列表之间的 combobox 语义(`aria-expanded`/`aria-controls`/active-descendant、结果播报)同样被延期——目前二者在辅助技术看来是彼此独立的控件。焦点停靠是卡片全域的不变量,而非编辑器独有:每次选取——无论是否处于编辑态,包括右栏推进与各列被替换的创建落地——提交后都把焦点重新停靠到选中项所在的行上,而其余所有会顶离焦点的退出(Enter、Escape、新层级已不含焦点所在行的导航落地、选取或创建失败后的重新列举,以及嵌套创建对话框的关闭)只要焦点确实落到了 body 上,就回落到 crumb 编辑区;键盘遍历绝不会落出卡片之外(Modal 没有焦点陷阱),仅 owner 的接纳窗口期间例外——此时 `busy` 把每个控件置为惰性,且对话框反正正在关闭。 +- **路径编辑器的取消范围:对话框卡片。** browse 客户端的路径编辑器在按 Escape 与焦点离开卡片时取消,两者都在卡片范围的包装层而非输入框上监听——Tab 把焦点停到某个过滤命中的行之后,输入框已不在事件路径上,但 Escape 仍须收起编辑器(而非对话框),其后的焦点离开也仍须取消。不取消的豁免:窗口/标签页失焦、卡片内焦点移动,以及指针路径(编辑期间行与开关在 mousedown 时抑制焦点夺取)。预填与草稿末段过滤所用的分隔符从宿主解析的根 crumb 读取(对该后端发出的每种根形态都精确:`/`、`C:\`、`\\server\share\`);下文的线上字段替代方案记录了被延期的权威形态。编辑器与其过滤的列表之间的 combobox 语义(`aria-expanded`/`aria-controls`/active-descendant、结果播报)同样被延期——目前二者在辅助技术看来是彼此独立的控件。焦点停靠是卡片全域的不变量,而非编辑器独有:每次选取——无论是否处于编辑态,包括右栏推进与各列被替换的创建落地——提交后都把焦点重新停靠到选中项所在的行上,而其余所有会顶离焦点的退出(Enter、Escape、新层级已不含焦点所在行的导航落地、选取或创建失败后的重新列举,以及嵌套创建对话框的关闭)只要焦点确实落到了 body 上,就回落到 crumb 编辑区;键盘遍历绝不会落出卡片之外(Modal 没有焦点陷阱),仅 owner 的接纳窗口期间例外——此时 `busy` 把每个控件置为惰性,且对话框反正正在关闭。 - **导航以选中项为锚、渐进落地。** 在展示根之外(与 crumb 头部渲染的是同一塌缩,因此 crumb 与分栏形态永不相左),browse 客户端的导航在目标层级到达的那一刻即提交它——这次首个落定即关闭编辑器并结束加载,因此 Enter 提交的导航绝不会为等待更多内容而被撤回——随后父层级这一程就地升级这次落地:重新选中目标在父层级中的实际条目(Windows 上按平台惯例折叠大小写),右侧展示其子项,因此 crumb 跳转读作后退一栏,而不是塌缩成单列。父层级这一程在落地的 supersession 范围下运行,任何较新的意图都会在线上将其中止;父层级这一程失败,或被截断的父窗口缺少目标时,都保留已提交的单栏落地——升级的存在正是为了锚定选中项,绝不能反而让它悬空。渐进形态的已知边界:指针按压若恰好落在单个 RTT 的升级窗口内,可能因所按行节点被替换而丢失点击(键盘焦点会重新停靠到重新选中的行上;指针的这段窗口则被接受);斜杠平台(macOS)上仅末段的大小写偏差会错过父层级条目匹配,保留单栏落地,而祖先段的偏差仍能匹配——父层级条目路径继承键入的前缀——并落地双栏,代价是 Home 塌缩;且导航总是重新列举两程,哪怕目标就是当前展示的层级——crumb 点按兼作刷新手势,因此宁要新鲜度也不复用手头可能已陈旧的列举,代价是至多两次宿主扫描。 - **符号链接:为可进入性而跟随。** 用 `stat` 探测符号链接(断链/循环→跳过);面包屑保留操作者导航的逻辑路径,`workspace.create` 在接纳时本就做 realpath 规范化。 - **列举层级有上限,且流式处理。** 单次 `list` 至多返回 `maxEntries` 行(配置项,默认 1000——GitHub 网页端目录列举的同一上限)。层级经 `opendir` 流入一个按名排序、容量 `maxEntries + 1` 的候选窗口,内存保持 O(maxEntries),可进入性探测只触及窗口内候选;线上 `DirectoryListing` 携带必填的 `truncated` 标志,让客户端明示不完整而不是静默缺尾。窗口内的断链符号链接不从窗口外回填——发生过驱逐本身已把层级标记为截断。窗口插入为二分查找、满窗尾部单次比较即拒绝(超大层级不能为每个 dirent 付出一次全窗扫描),且 `list(path, signal)` 透传载体的请求信号,滞塞网络目录的扫描不会在调用方断连后继续存活——扫描中的每个 await(打开、每次读取、每次符号链接探测)都与信号赛跑,中止路径放弃而非等待 close(Node 会把 close 排在在飞读取之后),被放弃的 settlement 全部吞掉,清理不会以未处理拒绝的形式冒出。无上限的层级对超大或恶意构造的目录就是内存/响应性漏洞。 @@ -34,7 +34,7 @@ web GUI 的"打开本地文件夹"流程被焊死在一种交互上:`host.pick - **引入文件管理器/盘符枚举依赖。** 按上文调研否决;依赖政策要求记录于此。 - **动作标签随状态翻转的"显示隐藏"开关("隐藏隐藏文件")。** 否决:会翻转的动作标签在状态与动作之间有歧义,还把否定叠了两层;固定标签加按下态呈现一次说清两者。 - **纯 relatedTarget 失焦取消(不做 mousedown 抑制)。** 否决:Safari 在指针按下时不给按钮聚焦,点击触发的 focusout 因而携带空 `relatedTarget`,会在点击落地前就取消编辑器;编辑期作用的 mousedown 抑制加上锚定卡片的 relatedTarget 守卫才能同时覆盖指针与键盘路径。 -- **在 `DirectoryListing` 上增设线上 `separator` 字段(宿主标注 `path.sep`)。** 延期而非否决:它才是权威形态——含反斜杠的 POSIX 家目录会击穿 `listing.home` 启发式——但它触及 seam 类型与每个后端;browse 客户端的 `separatorOf` 挂着指向本方案的 TODO,直到下次安排线上变更。 +- **在 `DirectoryListing` 上增设线上 `separator` 字段(宿主标注 `path.sep`)。** 延期而非否决:它才是权威形态。客户端今天所用的根 crumb 读取对该后端发出的每种根形态都精确,但它仍是从路径文本推断平台事实,还把"链从根开始"从后端行为提升为客户端所依赖的不变量(`crumbs` 的 JSDoc 确实承诺了这一点),并在链为空时退化回旧的 home 文本启发式;线上字段则会原样随线传输,经得住空链与未来的后端。它触及 seam 类型与每个后端,因此 browse 客户端的 `separatorOf` 挂着指向本方案的 TODO,直到下次安排线上变更。 ## 后果 diff --git a/packages/host/directory-picker-browse/src/client/DirectoryBrowser.tsx b/packages/host/directory-picker-browse/src/client/DirectoryBrowser.tsx index d82b4f0abd..2e5e732548 100644 --- a/packages/host/directory-picker-browse/src/client/DirectoryBrowser.tsx +++ b/packages/host/directory-picker-browse/src/client/DirectoryBrowser.tsx @@ -82,13 +82,26 @@ function foldPathFor(sep: '\\' | '/'): (value: string) => string { * backend's own paths arrive already resolved. A lexical mirror only: * symlinks are the backend's business. */ +/** + * Folds separators to the platform's canonical one: win32 treats a forward + * slash as a separator too (resolve() folds them the same way), while + * POSIX must not — a backslash there is a name character. + */ +function foldSeparatorsFor(sep: '\\' | '/'): (value: string) => string { + return value => (sep === '\\' ? value.replaceAll('/', sep) : value) +} + function normalizePathFor(sep: '\\' | '/'): (value: string) => string { + const foldSeparators = foldSeparatorsFor(sep) return (raw) => { - // win32 treats a forward slash as a separator too (resolve() folds - // them); POSIX must not — a backslash there is a name character. - const value = sep === '\\' ? raw.replaceAll('/', sep) : raw + const value = foldSeparators(raw) const unc = sep === '\\' && value.startsWith(`${sep}${sep}`) - const segments = (unc ? value.slice(2) : value).split(sep) + const rawSegments = (unc ? value.slice(2) : value).split(sep) + // Empty segments are separator noise everywhere except POSIX's leading + // root marker, which must survive as the first segment; scrubbing them + // up front keeps a doubled separator from being locked into the UNC + // server + share root below. + const segments = unc ? rawSegments.filter(segment => segment !== '') : rawSegments // The unpoppable root: POSIX's leading empty segment / the drive // segment, or UNC's server + share pair. const rootLength = unc ? 2 : 1 @@ -136,8 +149,10 @@ function displayCrumbs(listing: DirectoryListing, homeLabel: string): DirectoryE */ function separatorOf(listing: DirectoryListing): '\\' | '/' { const rootCrumb = listing.crumbs.at(0) - // Home is the honest fallback for an impossible empty chain. - /* v8 ignore next -- narrowing guard: the wire chain is root-to-target inclusive. */ + // The seam type allows an empty chain (this backend never emits one, but + // create-target naming supports it, see targetName): degrade to a + // best-effort read of the home text — the pre-root-crumb heuristic, with + // its backslash-in-a-POSIX-name blind spot. if (rootCrumb === undefined) return listing.home.includes('\\') ? '\\' : '/' return rootCrumb.path.includes('\\') ? '\\' : '/' } @@ -149,17 +164,19 @@ function separatorOf(listing: DirectoryListing): '\\' | '/' { * leaves the level unfiltered. The directory part compares under the * platform fold (exact on slash platforms; Windows folds case, since an * upgraded selection may carry the actual entry's case while the level - * below still carries the typed one); the name filter downstream is - * case-insensitive everywhere. + * below still carries the typed one — and folds forward slashes, which + * win32 and the backend both accept in typed paths); the name filter + * downstream is case-insensitive everywhere. */ function draftPrefixFor(listing: DirectoryListing, draft: string | null): string | null { if (draft === null) return null const sep = separatorOf(listing) - const cut = draft.lastIndexOf(sep) + const folded = foldSeparatorsFor(sep)(draft) + const cut = folded.lastIndexOf(sep) if (cut === -1) return null const fold = foldPathFor(sep) const level = listing.path.endsWith(sep) ? listing.path : `${listing.path}${sep}` - return fold(draft.slice(0, cut + 1)) === fold(level) ? draft.slice(cut + 1) : null + return fold(folded.slice(0, cut + 1)) === fold(level) ? folded.slice(cut + 1) : null } /** One column of folder rows (the Miller view renders one or two of these). */ @@ -824,7 +841,15 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen, // toggling never blur-cancels a draft mid-thought. Outside editing // it keeps native focus behavior. onMouseDown={draftPending ? (event) => { event.preventDefault() } : undefined} - onClick={() => { setShowHidden(prev => !prev) }} + onClick={(event) => { + // The suppression above exists to protect the INPUT's focus; + // when focus is instead on a row that this very toggle may + // re-hide, restore the native outcome — the clicked toggle + // keeps focus in the card (the editing-time refocus effect + // deliberately stays out of the way). + if (focusInMillerRows()) event.currentTarget.focus() + setShowHidden(prev => !prev) + }} > {t('browser.showHidden')} {showHidden && } diff --git a/packages/host/directory-picker-browse/tests/directory-browser.spec.tsx b/packages/host/directory-picker-browse/tests/directory-browser.spec.tsx index fcc2b21f92..4434e31eaa 100644 --- a/packages/host/directory-picker-browse/tests/directory-browser.spec.tsx +++ b/packages/host/directory-picker-browse/tests/directory-browser.spec.tsx @@ -412,8 +412,9 @@ describe('DirectoryBrowser', () => { const listing: DirectoryListing = { path: `${SHARE}\\x`, // USERPROFILE ships verbatim; win32.resolve keeps \\server\share as - // the unpoppable root, so this normalizes to \\server\share\x. - home: `${SHARE}\\..\\x`, + // the unpoppable root and folds the doubled separator, so this + // normalizes to \\server\share\x. + home: '\\\\server\\\\share\\..\\x', crumbs: [ { name: `${SHARE}\\`, path: `${SHARE}\\`, hidden: false }, { name: 'x', path: `${SHARE}\\x`, hidden: false }, @@ -536,6 +537,10 @@ describe('DirectoryBrowser', () => { expect(within(columns()[1]!).getByText('Alpha')).toBeTruthy() fireEvent.change(input, { target: { value: 'C:\\Users\\z' } }) expect(within(columns()[1]!).queryAllByRole('listitem')).toHaveLength(0) + // Forward-slash drafts are equally legal on win32 (Enter navigates + // them); the filter folds them instead of going silent. + fireEvent.change(input, { target: { value: 'C:/Users/a' } }) + expect(within(columns()[1]!).getByText('Alpha')).toBeTruthy() }) it('re-parks focus on the edit zone when a failed pick unmounts a dot-revealed row', async () => { @@ -1423,6 +1428,59 @@ describe('DirectoryBrowser', () => { expect(screen.getByText('browser.createIn:/srv/data')).toBeTruthy() }) + it('a crumb-less Windows level still seeds the editor with a backslash', async () => { + // The empty chain degrades separatorOf to the home-text read; the + // backslash side of that fallback is the Windows shape. + const bare: DirectoryListing = { path: 'C:\\srv', home: 'C:\\Users\\u', crumbs: [], entries: [], truncated: false } + mount({ listDirectory: vi.fn(async () => bare) }) + await waitFor(() => { + expect(screen.getByRole('button', { name: 'browser.editPath' }).disabled).toBe(false) + }) + fireEvent.click(screen.getByRole('button', { name: 'browser.editPath' })) + expect(screen.getByLabelText('browser.editPath').value).toBe('C:\\srv\\') + }) + + it('a POSIX home whose name contains a backslash still reads as the display root', async () => { + const WEIRD = '/home/we\\ird' + const listing: DirectoryListing = { + path: WEIRD, + home: WEIRD, + crumbs: [ + { name: '/', path: '/', hidden: false }, + { name: 'home', path: '/home', hidden: false }, + { name: 'we\\ird', path: WEIRD, hidden: false }, + ], + entries: [{ name: 'notes', path: `${WEIRD}/notes`, hidden: false }], + truncated: false, + } + const listDirectory = vi.fn(async () => listing) + mount({ listDirectory }) + await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() }) + // The root crumb ('/') decides the platform: the backslash in the name + // neither flips the fold nor breaks the Home collapse. + expect(columns()).toHaveLength(1) + expect(screen.getByRole('button', { name: 'browser.home' })).toBeTruthy() + expect(listDirectory).toHaveBeenCalledTimes(1) + }) + + it('pointer-toggling hidden off keeps focus on the toggle as the focused row re-hides', async () => { + mount() + await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() }) + const toggle = screen.getByRole('button', { name: 'browser.showHidden' }) + fireEvent.click(toggle) + fireEvent.click(screen.getByRole('button', { name: 'browser.editPath' })) + // Tab parked focus on the revealed hidden row; the pointer click below + // would unmount it (toggle off + empty seeded prefix hides it again). + const hiddenRow = within(columns()[0]!).getByText('.config').closest('button')! + hiddenRow.focus() + fireEvent.mouseDown(toggle) + fireEvent.click(toggle) + expect(screen.queryByText('.config')).toBeNull() + expect(document.activeElement).toBe(toggle) + // The editor survives the whole exchange. + expect(screen.getByLabelText('browser.editPath', { selector: 'input' })).toBeTruthy() + }) + it('refuses to close the nested dialog while the creation is in flight', async () => { const b = mount() await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() }) From 9545914c10bce92630dc89688114cc73a90f1517 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Thu, 30 Jul 2026 01:28:55 +0800 Subject: [PATCH 077/113] =?UTF-8?q?fix(host):=20review=20round=2018=20?= =?UTF-8?q?=E2=80=94=20draft=20dirs=20fully=20normalized;=20JSDoc=20reatta?= =?UTF-8?q?ched;=20third=20parking=20target=20documented?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...directory-picker-capability-seam.i18n.yaml | 4 +- ...-07-28-directory-picker-capability-seam.md | 2 +- ...-28-directory-picker-capability-seam.zh.md | 2 +- .../src/client/DirectoryBrowser.tsx | 70 +++++++++++-------- .../tests/directory-browser.spec.tsx | 9 +++ 5 files changed, 53 insertions(+), 34 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.i18n.yaml index 81c065227f..f5b5312f73 100644 --- a/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md -2026-07-28-directory-picker-capability-seam.md: 9008ba928c16a19fae601db0b8a83d44eaeeaae0 -2026-07-28-directory-picker-capability-seam.zh.md: 95ba66062aafb4ebe1ed37219efd5ad94f13f842 +2026-07-28-directory-picker-capability-seam.md: a22b313649a9efb1784f88a30fff3f82b9828347 +2026-07-28-directory-picker-capability-seam.zh.md: fb800fe75bda07198f9b3dc67ac03fb385df919a diff --git a/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md b/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md index 9008ba928c..a22b313649 100644 --- a/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md +++ b/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md @@ -19,7 +19,7 @@ Placement and policy rulings folded into this decision: - **Not the `ctx.fs` seam.** `packages/fs/` is the model/session-facing storage stack (policy events, sandbox-swappable backends). Riding it would couple GUI browsing to the model's confinement backend — swapping `fs-sandbox` for the model must never change GUI behavior — and OS facts (home anchoring, hidden conventions) are not storage primitives. The picker seam stays presentation-free and model-free; `packages/host/` is its consumer-domain home. - **Dependency survey (hand-roll vs adopt).** Node's stdlib *is* the maintained cross-platform OS layer (`readdir(withFileTypes)`, `homedir`, path semantics); surveyed alternatives fail the dependency bar — file-manager packages (`node-file-manager`, `files-and-folders`, Syncfusion's provider) are whole HTTP apps (fit), drive-letter helpers (`drivelist` native addon, `windows-drive-letters` ~7y stale) fail health/proportionality. The browse backend is a thin adapter over stdlib. - **Hidden entries: return-and-flag.** The host stamps `hidden` (POSIX dot convention) and returns everything; the client filters. Display policy stays client-side, and the show-hidden toggle shipped as exactly that client-only change: a fixed-label footer toggle whose state lives in the pressed presentation (`aria-pressed` + check glyph), a dot-led path-draft prefix reveals the hidden entries it names, and the current selection is exempt from both the hidden and the prefix filter (it anchors the two-pane view). Windows' `FILE_ATTRIBUTE_HIDDEN` is not exposed by dirents — documented limitation until a native probe pays for itself. -- **Path-editor cancel scope: the dialog card.** The browse client's path editor cancels on Escape and on focus leaving the card, both observed at a card-scope wrapper rather than the input — after Tab parks focus on a filtered row the input is off the event path, yet Escape must collapse the editor (not the dialog) and a later focus departure must still cancel. Non-cancel exemptions: window/tab focus loss, in-card focus moves, and pointer paths (rows and the toggle suppress focus steal on mousedown while editing). Separators for seeding and draft-tail filtering are read from the host-resolved root crumb (exact for every root form this backend emits: `/`, `C:\`, `\\server\share\`); the wire-field alternative below records the deferred authoritative form. Combobox semantics between the editor and the list it filters (`aria-expanded`/`aria-controls`/active-descendant, result announcements) are likewise deferred — today they read to assistive tech as separate widgets. Focus parking is a card-wide invariant, not an editor-only one: every pick — editing or not, including right-pane advances and create landings whose columns are replaced — re-parks focus on the selection's row after commit, while every other displacing exit (Enter, Escape, a navigation landing whose new level dropped the focused row, a failed pick or create relist, and the nested create dialog closing) falls back to the crumb edit zone whenever focus actually fell to body; keyboard traversal never falls out of the card (the Modal has no focus trap), except during the owner's adopt window, where `busy` inerts every control and the dialog is closing either way. +- **Path-editor cancel scope: the dialog card.** The browse client's path editor cancels on Escape and on focus leaving the card, both observed at a card-scope wrapper rather than the input — after Tab parks focus on a filtered row the input is off the event path, yet Escape must collapse the editor (not the dialog) and a later focus departure must still cancel. Non-cancel exemptions: window/tab focus loss, in-card focus moves, and pointer paths (rows and the toggle suppress focus steal on mousedown while editing). Separators for seeding and draft-tail filtering are read from the host-resolved root crumb (exact for every root form this backend emits: `/`, `C:\`, `\\server\share\`); the wire-field alternative below records the deferred authoritative form. Combobox semantics between the editor and the list it filters (`aria-expanded`/`aria-controls`/active-descendant, result announcements) are likewise deferred — today they read to assistive tech as separate widgets. Focus parking is a card-wide invariant, not an editor-only one: every pick — editing or not, including right-pane advances and create landings whose columns are replaced — re-parks focus on the selection's row after commit, while every other displacing exit (Enter, Escape, a navigation landing whose new level dropped the focused row, a failed pick or create relist, and the nested create dialog closing) falls back to the crumb edit zone whenever focus actually fell to body, and a show-hidden toggle click that finds focus among the rows parks synchronously on the toggle itself; keyboard traversal never falls out of the card (the Modal has no focus trap), except during the owner's adopt window, where `busy` inerts every control and the dialog is closing either way. - **Navigation lands selection-anchored, progressively.** Away from the display root (the same collapse the crumb header renders, so crumbs and pane shape never disagree), the browse client's navigate commits the target level the moment it arrives — the editor closes and loading ends on that first settlement, so an Enter-submitted navigation is never withdrawn waiting on more — and a parent leg then upgrades the landing in place: the target's actual parent-level entry re-selected (platform case folding on Windows), its children on the right, so a crumb jump reads as stepping back one pane rather than collapsing to a single column. The parent leg runs under the landing's supersession scope and is aborted on the wire by any newer intent; a failed parent leg, or a truncated parent window lacking the target, leaves the committed single-pane landing — the upgrade must never orphan the selection it exists to anchor. Known boundaries of the progressive shape: a pointer press landing exactly inside the one-RTT upgrade window can lose its click when the pressed row node is replaced (keyboard focus is re-parked on the re-selected row; the pointer window is accepted); on slash platforms (macOS) only a final-segment case drift misses the parent-entry match and keeps the single-pane landing, while ancestor-segment drift still matches — parent entry paths inherit the typed prefix — and lands two panes at the cost of the Home collapse; and navigate always relists both legs even when the target is the currently shown level — a crumb tap doubles as the refresh gesture, so freshness wins over reusing possibly-stale in-hand listings at the cost of up to two host scans. - **Symlinks: follow for enterability.** `stat` probes symlinks (broken/cyclic → skipped); crumbs keep the logical path the operator navigated, and `workspace.create` already canonicalizes via realpath at adoption. - **Listing levels are bounded, and streamed.** One `list` call returns at most `maxEntries` rows (config, default 1000 — GitHub's web-UI directory-listing bound). The level streams via `opendir` into a name-sorted window of `maxEntries + 1` candidates, so memory stays O(maxEntries) and enterability probing touches only windowed candidates; the wire `DirectoryListing` carries a required `truncated` flag so the client states incompleteness instead of silently missing tail entries. A windowed broken symlink is not backfilled from beyond the window — the eviction already marks the level truncated. Window insertion is binary with an O(1) full-window tail rejection (an oversized level must not pay a window scan per dirent), and `list(path, signal)` threads the carrier's request signal so a scan of a stalled network directory cannot outlive a disconnected caller — every await in the scan (open, each read, each symlink probe) races the signal, an aborted exit abandons rather than awaits the close (Node queues close behind in-flight reads), and abandoned settlements are swallowed so cleanup can never surface as an unhandled rejection. An unbounded level is a memory/responsiveness hole for large or adversarial directories. diff --git a/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.zh.md b/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.zh.md index 95ba66062a..fb800fe75b 100644 --- a/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.zh.md @@ -19,7 +19,7 @@ web GUI 的"打开本地文件夹"流程被焊死在一种交互上:`host.pick - **不用 `ctx.fs` seam。** `packages/fs/` 是面向模型/会话的存储栈(policy 事件、sandbox 可换后端)。骑上去会把 GUI 浏览耦合进模型的限制后端——为模型换 `fs-sandbox` 绝不能改变 GUI 行为——而 OS 事实(home 锚定、隐藏约定)也不是存储原语。picker seam 保持无展示、无模型;`packages/host/` 是它消费方域的家。 - **依赖调研(手写 vs 引入)。** Node 标准库本身就是维护中的跨平台 OS 层(`readdir(withFileTypes)`、`homedir`、路径语义);调研过的替代品都过不了依赖门槛——文件管理器包(`node-file-manager`、`files-and-folders`、Syncfusion 的 provider)是整套 HTTP 应用(契合度不过),盘符工具(原生插件 `drivelist`、约七年未更的 `windows-drive-letters`)健康度/比例失当。browse 后端是标准库上的薄适配。 - **隐藏条目:返回并打标。** 宿主标注 `hidden`(POSIX 点前缀约定)并返回全部条目;客户端过滤。展示策略留在客户端,"显示隐藏"开关正是作为这一纯客户端改动落地:标签固定的 footer 开关,其状态由按下态呈现承载(`aria-pressed` + 勾选符号);以点开头的路径草稿前缀会显出它所指名的隐藏条目;当前选中项则不受隐藏与前缀两种过滤影响(它锚定着双栏视图)。Windows 的 `FILE_ATTRIBUTE_HIDDEN` 不被 dirent 暴露——记为限制,直到原生探测值回其成本。 -- **路径编辑器的取消范围:对话框卡片。** browse 客户端的路径编辑器在按 Escape 与焦点离开卡片时取消,两者都在卡片范围的包装层而非输入框上监听——Tab 把焦点停到某个过滤命中的行之后,输入框已不在事件路径上,但 Escape 仍须收起编辑器(而非对话框),其后的焦点离开也仍须取消。不取消的豁免:窗口/标签页失焦、卡片内焦点移动,以及指针路径(编辑期间行与开关在 mousedown 时抑制焦点夺取)。预填与草稿末段过滤所用的分隔符从宿主解析的根 crumb 读取(对该后端发出的每种根形态都精确:`/`、`C:\`、`\\server\share\`);下文的线上字段替代方案记录了被延期的权威形态。编辑器与其过滤的列表之间的 combobox 语义(`aria-expanded`/`aria-controls`/active-descendant、结果播报)同样被延期——目前二者在辅助技术看来是彼此独立的控件。焦点停靠是卡片全域的不变量,而非编辑器独有:每次选取——无论是否处于编辑态,包括右栏推进与各列被替换的创建落地——提交后都把焦点重新停靠到选中项所在的行上,而其余所有会顶离焦点的退出(Enter、Escape、新层级已不含焦点所在行的导航落地、选取或创建失败后的重新列举,以及嵌套创建对话框的关闭)只要焦点确实落到了 body 上,就回落到 crumb 编辑区;键盘遍历绝不会落出卡片之外(Modal 没有焦点陷阱),仅 owner 的接纳窗口期间例外——此时 `busy` 把每个控件置为惰性,且对话框反正正在关闭。 +- **路径编辑器的取消范围:对话框卡片。** browse 客户端的路径编辑器在按 Escape 与焦点离开卡片时取消,两者都在卡片范围的包装层而非输入框上监听——Tab 把焦点停到某个过滤命中的行之后,输入框已不在事件路径上,但 Escape 仍须收起编辑器(而非对话框),其后的焦点离开也仍须取消。不取消的豁免:窗口/标签页失焦、卡片内焦点移动,以及指针路径(编辑期间行与开关在 mousedown 时抑制焦点夺取)。预填与草稿末段过滤所用的分隔符从宿主解析的根 crumb 读取(对该后端发出的每种根形态都精确:`/`、`C:\`、`\\server\share\`);下文的线上字段替代方案记录了被延期的权威形态。编辑器与其过滤的列表之间的 combobox 语义(`aria-expanded`/`aria-controls`/active-descendant、结果播报)同样被延期——目前二者在辅助技术看来是彼此独立的控件。焦点停靠是卡片全域的不变量,而非编辑器独有:每次选取——无论是否处于编辑态,包括右栏推进与各列被替换的创建落地——提交后都把焦点重新停靠到选中项所在的行上,而其余所有会顶离焦点的退出(Enter、Escape、新层级已不含焦点所在行的导航落地、选取或创建失败后的重新列举,以及嵌套创建对话框的关闭)只要焦点确实落到了 body 上,就回落到 crumb 编辑区,而点击"显示隐藏"开关时若发现焦点落在行间,则把焦点同步停靠到开关自身;键盘遍历绝不会落出卡片之外(Modal 没有焦点陷阱),仅 owner 的接纳窗口期间例外——此时 `busy` 把每个控件置为惰性,且对话框反正正在关闭。 - **导航以选中项为锚、渐进落地。** 在展示根之外(与 crumb 头部渲染的是同一塌缩,因此 crumb 与分栏形态永不相左),browse 客户端的导航在目标层级到达的那一刻即提交它——这次首个落定即关闭编辑器并结束加载,因此 Enter 提交的导航绝不会为等待更多内容而被撤回——随后父层级这一程就地升级这次落地:重新选中目标在父层级中的实际条目(Windows 上按平台惯例折叠大小写),右侧展示其子项,因此 crumb 跳转读作后退一栏,而不是塌缩成单列。父层级这一程在落地的 supersession 范围下运行,任何较新的意图都会在线上将其中止;父层级这一程失败,或被截断的父窗口缺少目标时,都保留已提交的单栏落地——升级的存在正是为了锚定选中项,绝不能反而让它悬空。渐进形态的已知边界:指针按压若恰好落在单个 RTT 的升级窗口内,可能因所按行节点被替换而丢失点击(键盘焦点会重新停靠到重新选中的行上;指针的这段窗口则被接受);斜杠平台(macOS)上仅末段的大小写偏差会错过父层级条目匹配,保留单栏落地,而祖先段的偏差仍能匹配——父层级条目路径继承键入的前缀——并落地双栏,代价是 Home 塌缩;且导航总是重新列举两程,哪怕目标就是当前展示的层级——crumb 点按兼作刷新手势,因此宁要新鲜度也不复用手头可能已陈旧的列举,代价是至多两次宿主扫描。 - **符号链接:为可进入性而跟随。** 用 `stat` 探测符号链接(断链/循环→跳过);面包屑保留操作者导航的逻辑路径,`workspace.create` 在接纳时本就做 realpath 规范化。 - **列举层级有上限,且流式处理。** 单次 `list` 至多返回 `maxEntries` 行(配置项,默认 1000——GitHub 网页端目录列举的同一上限)。层级经 `opendir` 流入一个按名排序、容量 `maxEntries + 1` 的候选窗口,内存保持 O(maxEntries),可进入性探测只触及窗口内候选;线上 `DirectoryListing` 携带必填的 `truncated` 标志,让客户端明示不完整而不是静默缺尾。窗口内的断链符号链接不从窗口外回填——发生过驱逐本身已把层级标记为截断。窗口插入为二分查找、满窗尾部单次比较即拒绝(超大层级不能为每个 dirent 付出一次全窗扫描),且 `list(path, signal)` 透传载体的请求信号,滞塞网络目录的扫描不会在调用方断连后继续存活——扫描中的每个 await(打开、每次读取、每次符号链接探测)都与信号赛跑,中止路径放弃而非等待 close(Node 会把 close 排在在飞读取之后),被放弃的 settlement 全部吞掉,清理不会以未处理拒绝的形式冒出。无上限的层级对超大或恶意构造的目录就是内存/响应性漏洞。 diff --git a/packages/host/directory-picker-browse/src/client/DirectoryBrowser.tsx b/packages/host/directory-picker-browse/src/client/DirectoryBrowser.tsx index 2e5e732548..bdab232457 100644 --- a/packages/host/directory-picker-browse/src/client/DirectoryBrowser.tsx +++ b/packages/host/directory-picker-browse/src/client/DirectoryBrowser.tsx @@ -72,6 +72,15 @@ function foldPathFor(sep: '\\' | '/'): (value: string) => string { return value => (sep === '\\' ? value.toLowerCase() : value) } +/** + * Folds separators to the platform's canonical one: win32 treats a forward + * slash as a separator too (resolve() folds them the same way), while + * POSIX must not — a backslash there is a name character. + */ +function foldSeparatorsFor(sep: '\\' | '/'): (value: string) => string { + return value => (sep === '\\' ? value.replaceAll('/', sep) : value) +} + /** * Lexically normalizes an absolute host path for comparisons: folds * forward slashes on Windows (win32 accepts either), collapses repeated @@ -82,15 +91,6 @@ function foldPathFor(sep: '\\' | '/'): (value: string) => string { * backend's own paths arrive already resolved. A lexical mirror only: * symlinks are the backend's business. */ -/** - * Folds separators to the platform's canonical one: win32 treats a forward - * slash as a separator too (resolve() folds them the same way), while - * POSIX must not — a backslash there is a name character. - */ -function foldSeparatorsFor(sep: '\\' | '/'): (value: string) => string { - return value => (sep === '\\' ? value.replaceAll('/', sep) : value) -} - function normalizePathFor(sep: '\\' | '/'): (value: string) => string { const foldSeparators = foldSeparatorsFor(sep) return (raw) => { @@ -161,12 +161,13 @@ function separatorOf(listing: DirectoryListing): '\\' | '/' { * The path draft's final segment, when its directory part names the level * `listing` lists — the segment the level prefix-filters on while the user * types. Any other draft (no separator yet, or naming some other directory) - * leaves the level unfiltered. The directory part compares under the - * platform fold (exact on slash platforms; Windows folds case, since an - * upgraded selection may carry the actual entry's case while the level - * below still carries the typed one — and folds forward slashes, which - * win32 and the backend both accept in typed paths); the name filter - * downstream is case-insensitive everywhere. + * leaves the level unfiltered. The directory part compares lexically + * normalized (dot segments, repeated separators, and win32 forward + * slashes all match what Enter would navigate to) and under the platform + * case fold (exact on slash platforms; Windows folds, since an upgraded + * selection may carry the actual entry's case while the level below still + * carries the typed one); the name filter downstream is case-insensitive + * everywhere. */ function draftPrefixFor(listing: DirectoryListing, draft: string | null): string | null { if (draft === null) return null @@ -175,8 +176,10 @@ function draftPrefixFor(listing: DirectoryListing, draft: string | null): string const cut = folded.lastIndexOf(sep) if (cut === -1) return null const fold = foldPathFor(sep) - const level = listing.path.endsWith(sep) ? listing.path : `${listing.path}${sep}` - return fold(folded.slice(0, cut + 1)) === fold(level) ? folded.slice(cut + 1) : null + const normalize = normalizePathFor(sep) + return fold(normalize(folded.slice(0, cut + 1))) === fold(normalize(listing.path)) + ? folded.slice(cut + 1) + : null } /** One column of folder rows (the Miller view renders one or two of these). */ @@ -312,25 +315,31 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen, // whose new level dropped the focused row, a failed pick, and every // create-dialog exit, whose close-time parking is also what a failed // relist inherits) parks on the crumb edit zone, each only when focus - // actually fell to body. Pointer-out cancels never set (or clear) these - // — yanking focus back from wherever the user clicked would be worse - // than the fall. + // actually fell to body. One parking bypasses both flags: the + // show-hidden toggle's click reclaims focus onto itself, synchronously, + // when the click finds focus among the rows. Pointer-out cancels never + // set (or clear) these — yanking focus back from wherever the user + // clicked would be worse than the fall. const refocusPick = useRef(false) const refocusEditZone = useRef(false) const editZoneRef = useRef(null) /** * Whether the focused element sits among the miller rows — probed before - * a landing replaces the row nodes, to decide focus parking. A probe - * only: it never gates the landing itself — a torn-down ref in a close - * race merely skips the parking, and committing the landing into a - * closing dialog is safe (the component already renders null, and the - * open effect resets parent/selected/child on the next open). + * a landing replaces the row nodes to decide focus parking, and by the + * show-hidden toggle's click to decide whether to reclaim the native + * focus outcome. A probe only: it never gates its caller — a torn-down + * ref in a landing's close race merely skips the parking, and + * committing the landing into a closing dialog is safe (the component + * already renders null, and the open effect resets parent/selected/child + * on the next open). * @returns true when `document.activeElement` is inside the miller row. */ const focusInMillerRows = useCallback((): boolean => { const rowHost = millerRowRef.current - /* v8 ignore next -- close-race guard: the commit-to-effect window is not deterministically reproducible. */ + // Only the landing callers can race a close (commit precedes the reset + // effect); the toggle's click caller always finds the host mounted. + /* v8 ignore next -- close-race guard: not deterministically reproducible. */ if (rowHost === null) return false return rowHost.contains(document.activeElement) }, []) @@ -843,10 +852,11 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen, onMouseDown={draftPending ? (event) => { event.preventDefault() } : undefined} onClick={(event) => { // The suppression above exists to protect the INPUT's focus; - // when focus is instead on a row that this very toggle may - // re-hide, restore the native outcome — the clicked toggle - // keeps focus in the card (the editing-time refocus effect - // deliberately stays out of the way). + // with focus among the rows instead, hand back the native + // click outcome wholesale — the clicked toggle takes focus + // and stays in the card. Accepted cost: this also moves + // focus off a row the toggle would NOT have hidden; tracking + // which rows a direction change unmounts is not worth it. if (focusInMillerRows()) event.currentTarget.focus() setShowHidden(prev => !prev) }} diff --git a/packages/host/directory-picker-browse/tests/directory-browser.spec.tsx b/packages/host/directory-picker-browse/tests/directory-browser.spec.tsx index 4434e31eaa..af40e08fe7 100644 --- a/packages/host/directory-picker-browse/tests/directory-browser.spec.tsx +++ b/packages/host/directory-picker-browse/tests/directory-browser.spec.tsx @@ -541,6 +541,9 @@ describe('DirectoryBrowser', () => { // them); the filter folds them instead of going silent. fireEvent.change(input, { target: { value: 'C:/Users/a' } }) expect(within(columns()[1]!).getByText('Alpha')).toBeTruthy() + // Dot segments normalize on win32 too. + fireEvent.change(input, { target: { value: 'C:\\Users\\.\\a' } }) + expect(within(columns()[1]!).getByText('Alpha')).toBeTruthy() }) it('re-parks focus on the edit zone when a failed pick unmounts a dot-revealed row', async () => { @@ -673,6 +676,12 @@ describe('DirectoryBrowser', () => { // A draft naming some other directory (or none) leaves the level whole. fireEvent.change(input, { target: { value: 'no-separator' } }) expect(screen.getByRole('listitem').textContent).toBe('Documents') + // Dot segments and repeated separators are legal for Enter, so the + // filter's directory comparison normalizes them the same way. + fireEvent.change(input, { target: { value: `${HOME}/./do` } }) + expect(screen.getByRole('listitem').textContent).toBe('Documents') + fireEvent.change(input, { target: { value: `${HOME}//do` } }) + expect(screen.getByRole('listitem').textContent).toBe('Documents') }) it('filters the child pane in two-pane mode and follows the draft back up a level', async () => { From cc92b6b578d930505c57123896ef2f3e39b59793 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Thu, 30 Jul 2026 01:50:45 +0800 Subject: [PATCH 078/113] =?UTF-8?q?fix(host,client):=20review=20round=2019?= =?UTF-8?q?=20=E2=80=94=20home=20ships=20resolved=20on=20the=20wire;=20cli?= =?UTF-8?q?ent=20mirror=20shrinks=20to=20the=20draft=20side;=20scoped=20fo?= =?UTF-8?q?cus=20guarantee?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...directory-picker-capability-seam.i18n.yaml | 4 +- ...-07-28-directory-picker-capability-seam.md | 2 +- ...-28-directory-picker-capability-seam.zh.md | 2 +- packages/host/apiproxy/src/api/host.ts | 2 +- .../src/client/DirectoryBrowser.tsx | 66 +++++++++---------- .../host/directory-picker-browse/src/index.ts | 7 +- .../tests/directory-browser.spec.tsx | 57 ++++------------ .../tests/home-shape.spec.ts | 28 ++++++++ .../tests/service.spec.ts | 6 +- packages/host/directory-picker/src/index.ts | 2 +- 10 files changed, 90 insertions(+), 86 deletions(-) create mode 100644 packages/host/directory-picker-browse/tests/home-shape.spec.ts diff --git a/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.i18n.yaml index f5b5312f73..0502f5cd0d 100644 --- a/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md -2026-07-28-directory-picker-capability-seam.md: a22b313649a9efb1784f88a30fff3f82b9828347 -2026-07-28-directory-picker-capability-seam.zh.md: fb800fe75bda07198f9b3dc67ac03fb385df919a +2026-07-28-directory-picker-capability-seam.md: 6cba49f9a5817d05e4933e397918a0b9a17ce845 +2026-07-28-directory-picker-capability-seam.zh.md: bfa9f80db32b1e73a198442826c7aea9972c7412 diff --git a/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md b/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md index a22b313649..6cba49f9a5 100644 --- a/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md +++ b/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md @@ -19,7 +19,7 @@ Placement and policy rulings folded into this decision: - **Not the `ctx.fs` seam.** `packages/fs/` is the model/session-facing storage stack (policy events, sandbox-swappable backends). Riding it would couple GUI browsing to the model's confinement backend — swapping `fs-sandbox` for the model must never change GUI behavior — and OS facts (home anchoring, hidden conventions) are not storage primitives. The picker seam stays presentation-free and model-free; `packages/host/` is its consumer-domain home. - **Dependency survey (hand-roll vs adopt).** Node's stdlib *is* the maintained cross-platform OS layer (`readdir(withFileTypes)`, `homedir`, path semantics); surveyed alternatives fail the dependency bar — file-manager packages (`node-file-manager`, `files-and-folders`, Syncfusion's provider) are whole HTTP apps (fit), drive-letter helpers (`drivelist` native addon, `windows-drive-letters` ~7y stale) fail health/proportionality. The browse backend is a thin adapter over stdlib. - **Hidden entries: return-and-flag.** The host stamps `hidden` (POSIX dot convention) and returns everything; the client filters. Display policy stays client-side, and the show-hidden toggle shipped as exactly that client-only change: a fixed-label footer toggle whose state lives in the pressed presentation (`aria-pressed` + check glyph), a dot-led path-draft prefix reveals the hidden entries it names, and the current selection is exempt from both the hidden and the prefix filter (it anchors the two-pane view). Windows' `FILE_ATTRIBUTE_HIDDEN` is not exposed by dirents — documented limitation until a native probe pays for itself. -- **Path-editor cancel scope: the dialog card.** The browse client's path editor cancels on Escape and on focus leaving the card, both observed at a card-scope wrapper rather than the input — after Tab parks focus on a filtered row the input is off the event path, yet Escape must collapse the editor (not the dialog) and a later focus departure must still cancel. Non-cancel exemptions: window/tab focus loss, in-card focus moves, and pointer paths (rows and the toggle suppress focus steal on mousedown while editing). Separators for seeding and draft-tail filtering are read from the host-resolved root crumb (exact for every root form this backend emits: `/`, `C:\`, `\\server\share\`); the wire-field alternative below records the deferred authoritative form. Combobox semantics between the editor and the list it filters (`aria-expanded`/`aria-controls`/active-descendant, result announcements) are likewise deferred — today they read to assistive tech as separate widgets. Focus parking is a card-wide invariant, not an editor-only one: every pick — editing or not, including right-pane advances and create landings whose columns are replaced — re-parks focus on the selection's row after commit, while every other displacing exit (Enter, Escape, a navigation landing whose new level dropped the focused row, a failed pick or create relist, and the nested create dialog closing) falls back to the crumb edit zone whenever focus actually fell to body, and a show-hidden toggle click that finds focus among the rows parks synchronously on the toggle itself; keyboard traversal never falls out of the card (the Modal has no focus trap), except during the owner's adopt window, where `busy` inerts every control and the dialog is closing either way. +- **Path-editor cancel scope: the dialog card.** The browse client's path editor cancels on Escape and on focus leaving the card, both observed at a card-scope wrapper rather than the input — after Tab parks focus on a filtered row the input is off the event path, yet Escape must collapse the editor (not the dialog) and a later focus departure must still cancel. Non-cancel exemptions: window/tab focus loss, in-card focus moves, and pointer paths (rows and the toggle suppress focus steal on mousedown while editing). Separators for seeding and draft-tail filtering are read from the host-resolved root crumb (exact for every root form this backend emits: `/`, `C:\`, `\\server\share\`); the wire-field alternative below records the deferred authoritative form. Combobox semantics between the editor and the list it filters (`aria-expanded`/`aria-controls`/active-descendant, result announcements) are likewise deferred — today they read to assistive tech as separate widgets. Focus parking is a card-wide invariant, not an editor-only one: every pick — editing or not, including right-pane advances and create landings whose columns are replaced — re-parks focus on the selection's row after commit, while every other displacing exit (Enter, Escape, a navigation landing whose new level dropped the focused row, a failed pick or create relist, and the nested create dialog closing) falls back to the crumb edit zone whenever focus actually fell to body, and a show-hidden toggle click that finds focus among the rows parks synchronously on the toggle itself. The guarantee is scoped to the dialog's own node replacements — the Modal has no focus trap, so tabbing past the card's edge legitimately leaves, and the owner's adopt window (where `busy` inerts every control and the dialog is closing either way) is likewise outside it. - **Navigation lands selection-anchored, progressively.** Away from the display root (the same collapse the crumb header renders, so crumbs and pane shape never disagree), the browse client's navigate commits the target level the moment it arrives — the editor closes and loading ends on that first settlement, so an Enter-submitted navigation is never withdrawn waiting on more — and a parent leg then upgrades the landing in place: the target's actual parent-level entry re-selected (platform case folding on Windows), its children on the right, so a crumb jump reads as stepping back one pane rather than collapsing to a single column. The parent leg runs under the landing's supersession scope and is aborted on the wire by any newer intent; a failed parent leg, or a truncated parent window lacking the target, leaves the committed single-pane landing — the upgrade must never orphan the selection it exists to anchor. Known boundaries of the progressive shape: a pointer press landing exactly inside the one-RTT upgrade window can lose its click when the pressed row node is replaced (keyboard focus is re-parked on the re-selected row; the pointer window is accepted); on slash platforms (macOS) only a final-segment case drift misses the parent-entry match and keeps the single-pane landing, while ancestor-segment drift still matches — parent entry paths inherit the typed prefix — and lands two panes at the cost of the Home collapse; and navigate always relists both legs even when the target is the currently shown level — a crumb tap doubles as the refresh gesture, so freshness wins over reusing possibly-stale in-hand listings at the cost of up to two host scans. - **Symlinks: follow for enterability.** `stat` probes symlinks (broken/cyclic → skipped); crumbs keep the logical path the operator navigated, and `workspace.create` already canonicalizes via realpath at adoption. - **Listing levels are bounded, and streamed.** One `list` call returns at most `maxEntries` rows (config, default 1000 — GitHub's web-UI directory-listing bound). The level streams via `opendir` into a name-sorted window of `maxEntries + 1` candidates, so memory stays O(maxEntries) and enterability probing touches only windowed candidates; the wire `DirectoryListing` carries a required `truncated` flag so the client states incompleteness instead of silently missing tail entries. A windowed broken symlink is not backfilled from beyond the window — the eviction already marks the level truncated. Window insertion is binary with an O(1) full-window tail rejection (an oversized level must not pay a window scan per dirent), and `list(path, signal)` threads the carrier's request signal so a scan of a stalled network directory cannot outlive a disconnected caller — every await in the scan (open, each read, each symlink probe) races the signal, an aborted exit abandons rather than awaits the close (Node queues close behind in-flight reads), and abandoned settlements are swallowed so cleanup can never surface as an unhandled rejection. An unbounded level is a memory/responsiveness hole for large or adversarial directories. diff --git a/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.zh.md b/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.zh.md index fb800fe75b..bfa9f80db3 100644 --- a/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.zh.md @@ -19,7 +19,7 @@ web GUI 的"打开本地文件夹"流程被焊死在一种交互上:`host.pick - **不用 `ctx.fs` seam。** `packages/fs/` 是面向模型/会话的存储栈(policy 事件、sandbox 可换后端)。骑上去会把 GUI 浏览耦合进模型的限制后端——为模型换 `fs-sandbox` 绝不能改变 GUI 行为——而 OS 事实(home 锚定、隐藏约定)也不是存储原语。picker seam 保持无展示、无模型;`packages/host/` 是它消费方域的家。 - **依赖调研(手写 vs 引入)。** Node 标准库本身就是维护中的跨平台 OS 层(`readdir(withFileTypes)`、`homedir`、路径语义);调研过的替代品都过不了依赖门槛——文件管理器包(`node-file-manager`、`files-and-folders`、Syncfusion 的 provider)是整套 HTTP 应用(契合度不过),盘符工具(原生插件 `drivelist`、约七年未更的 `windows-drive-letters`)健康度/比例失当。browse 后端是标准库上的薄适配。 - **隐藏条目:返回并打标。** 宿主标注 `hidden`(POSIX 点前缀约定)并返回全部条目;客户端过滤。展示策略留在客户端,"显示隐藏"开关正是作为这一纯客户端改动落地:标签固定的 footer 开关,其状态由按下态呈现承载(`aria-pressed` + 勾选符号);以点开头的路径草稿前缀会显出它所指名的隐藏条目;当前选中项则不受隐藏与前缀两种过滤影响(它锚定着双栏视图)。Windows 的 `FILE_ATTRIBUTE_HIDDEN` 不被 dirent 暴露——记为限制,直到原生探测值回其成本。 -- **路径编辑器的取消范围:对话框卡片。** browse 客户端的路径编辑器在按 Escape 与焦点离开卡片时取消,两者都在卡片范围的包装层而非输入框上监听——Tab 把焦点停到某个过滤命中的行之后,输入框已不在事件路径上,但 Escape 仍须收起编辑器(而非对话框),其后的焦点离开也仍须取消。不取消的豁免:窗口/标签页失焦、卡片内焦点移动,以及指针路径(编辑期间行与开关在 mousedown 时抑制焦点夺取)。预填与草稿末段过滤所用的分隔符从宿主解析的根 crumb 读取(对该后端发出的每种根形态都精确:`/`、`C:\`、`\\server\share\`);下文的线上字段替代方案记录了被延期的权威形态。编辑器与其过滤的列表之间的 combobox 语义(`aria-expanded`/`aria-controls`/active-descendant、结果播报)同样被延期——目前二者在辅助技术看来是彼此独立的控件。焦点停靠是卡片全域的不变量,而非编辑器独有:每次选取——无论是否处于编辑态,包括右栏推进与各列被替换的创建落地——提交后都把焦点重新停靠到选中项所在的行上,而其余所有会顶离焦点的退出(Enter、Escape、新层级已不含焦点所在行的导航落地、选取或创建失败后的重新列举,以及嵌套创建对话框的关闭)只要焦点确实落到了 body 上,就回落到 crumb 编辑区,而点击"显示隐藏"开关时若发现焦点落在行间,则把焦点同步停靠到开关自身;键盘遍历绝不会落出卡片之外(Modal 没有焦点陷阱),仅 owner 的接纳窗口期间例外——此时 `busy` 把每个控件置为惰性,且对话框反正正在关闭。 +- **路径编辑器的取消范围:对话框卡片。** browse 客户端的路径编辑器在按 Escape 与焦点离开卡片时取消,两者都在卡片范围的包装层而非输入框上监听——Tab 把焦点停到某个过滤命中的行之后,输入框已不在事件路径上,但 Escape 仍须收起编辑器(而非对话框),其后的焦点离开也仍须取消。不取消的豁免:窗口/标签页失焦、卡片内焦点移动,以及指针路径(编辑期间行与开关在 mousedown 时抑制焦点夺取)。预填与草稿末段过滤所用的分隔符从宿主解析的根 crumb 读取(对该后端发出的每种根形态都精确:`/`、`C:\`、`\\server\share\`);下文的线上字段替代方案记录了被延期的权威形态。编辑器与其过滤的列表之间的 combobox 语义(`aria-expanded`/`aria-controls`/active-descendant、结果播报)同样被延期——目前二者在辅助技术看来是彼此独立的控件。焦点停靠是卡片全域的不变量,而非编辑器独有:每次选取——无论是否处于编辑态,包括右栏推进与各列被替换的创建落地——提交后都把焦点重新停靠到选中项所在的行上,而其余所有会顶离焦点的退出(Enter、Escape、新层级已不含焦点所在行的导航落地、选取或创建失败后的重新列举,以及嵌套创建对话框的关闭)只要焦点确实落到了 body 上,就回落到 crumb 编辑区,而点击"显示隐藏"开关时若发现焦点落在行间,则把焦点同步停靠到开关自身。该保证的范围仅限对话框自身的节点替换——Modal 没有焦点陷阱,所以 Tab 越过卡片边缘属于正当离开,而 owner 的接纳窗口(其间 `busy` 把每个控件置为惰性,且对话框反正正在关闭)同样在此范围之外。 - **导航以选中项为锚、渐进落地。** 在展示根之外(与 crumb 头部渲染的是同一塌缩,因此 crumb 与分栏形态永不相左),browse 客户端的导航在目标层级到达的那一刻即提交它——这次首个落定即关闭编辑器并结束加载,因此 Enter 提交的导航绝不会为等待更多内容而被撤回——随后父层级这一程就地升级这次落地:重新选中目标在父层级中的实际条目(Windows 上按平台惯例折叠大小写),右侧展示其子项,因此 crumb 跳转读作后退一栏,而不是塌缩成单列。父层级这一程在落地的 supersession 范围下运行,任何较新的意图都会在线上将其中止;父层级这一程失败,或被截断的父窗口缺少目标时,都保留已提交的单栏落地——升级的存在正是为了锚定选中项,绝不能反而让它悬空。渐进形态的已知边界:指针按压若恰好落在单个 RTT 的升级窗口内,可能因所按行节点被替换而丢失点击(键盘焦点会重新停靠到重新选中的行上;指针的这段窗口则被接受);斜杠平台(macOS)上仅末段的大小写偏差会错过父层级条目匹配,保留单栏落地,而祖先段的偏差仍能匹配——父层级条目路径继承键入的前缀——并落地双栏,代价是 Home 塌缩;且导航总是重新列举两程,哪怕目标就是当前展示的层级——crumb 点按兼作刷新手势,因此宁要新鲜度也不复用手头可能已陈旧的列举,代价是至多两次宿主扫描。 - **符号链接:为可进入性而跟随。** 用 `stat` 探测符号链接(断链/循环→跳过);面包屑保留操作者导航的逻辑路径,`workspace.create` 在接纳时本就做 realpath 规范化。 - **列举层级有上限,且流式处理。** 单次 `list` 至多返回 `maxEntries` 行(配置项,默认 1000——GitHub 网页端目录列举的同一上限)。层级经 `opendir` 流入一个按名排序、容量 `maxEntries + 1` 的候选窗口,内存保持 O(maxEntries),可进入性探测只触及窗口内候选;线上 `DirectoryListing` 携带必填的 `truncated` 标志,让客户端明示不完整而不是静默缺尾。窗口内的断链符号链接不从窗口外回填——发生过驱逐本身已把层级标记为截断。窗口插入为二分查找、满窗尾部单次比较即拒绝(超大层级不能为每个 dirent 付出一次全窗扫描),且 `list(path, signal)` 透传载体的请求信号,滞塞网络目录的扫描不会在调用方断连后继续存活——扫描中的每个 await(打开、每次读取、每次符号链接探测)都与信号赛跑,中止路径放弃而非等待 close(Node 会把 close 排在在飞读取之后),被放弃的 settlement 全部吞掉,清理不会以未处理拒绝的形式冒出。无上限的层级对超大或恶意构造的目录就是内存/响应性漏洞。 diff --git a/packages/host/apiproxy/src/api/host.ts b/packages/host/apiproxy/src/api/host.ts index 3d0713e523..25a0698234 100644 --- a/packages/host/apiproxy/src/api/host.ts +++ b/packages/host/apiproxy/src/api/host.ts @@ -19,7 +19,7 @@ export interface DirectoryEntry { export interface DirectoryListing { /** Absolute path of the listed directory. */ path: string - /** The host account's home directory (breadcrumb "Home" rooting). */ + /** The host account's home directory (breadcrumb "Home" rooting), in the same resolved shape as `path` and `crumbs[].path`. */ home: string /** * Ancestor chain from the filesystem root to the listed directory diff --git a/packages/host/directory-picker-browse/src/client/DirectoryBrowser.tsx b/packages/host/directory-picker-browse/src/client/DirectoryBrowser.tsx index bdab232457..0ed8cf3eaa 100644 --- a/packages/host/directory-picker-browse/src/client/DirectoryBrowser.tsx +++ b/packages/host/directory-picker-browse/src/client/DirectoryBrowser.tsx @@ -82,19 +82,17 @@ function foldSeparatorsFor(sep: '\\' | '/'): (value: string) => string { } /** - * Lexically normalizes an absolute host path for comparisons: folds - * forward slashes on Windows (win32 accepts either), collapses repeated - * and trailing separators, drops `.` segments, and applies `..` without - * ever crossing the root — POSIX's `/`, a drive's `C:`, or UNC's - * `\\server\share` pair — mirroring the backend's resolve() for the - * shapes an environment-supplied HOME legally carries verbatim while the - * backend's own paths arrive already resolved. A lexical mirror only: - * symlinks are the backend's business. + * Lexically normalizes a typed absolute path for comparisons against the + * backend's resolved ones (the wire contract keeps `path`, `crumbs[].path`, + * and `home` in resolved shape; only the DRAFT side needs this): collapses + * repeated and trailing separators, drops `.` segments, and applies `..` + * without ever crossing the root — POSIX's `/`, a drive's `C:`, or UNC's + * `\\server\share` pair — mirroring resolve()'s lexical behavior. Expects + * separators already folded to `sep` (foldSeparatorsFor); a lexical mirror + * only, symlinks are the backend's business. */ function normalizePathFor(sep: '\\' | '/'): (value: string) => string { - const foldSeparators = foldSeparatorsFor(sep) - return (raw) => { - const value = foldSeparators(raw) + return (value) => { const unc = sep === '\\' && value.startsWith(`${sep}${sep}`) const rawSegments = (unc ? value.slice(2) : value).split(sep) // Empty segments are separator noise everywhere except POSIX's leading @@ -123,16 +121,14 @@ function normalizePathFor(sep: '\\' | '/'): (value: string) => string { /** * Breadcrumb rows for display: inside the home subtree the chain starts at a * localized Home crumb; outside it the full ancestry shows, the root labeled - * by its own path. The home comparison folds per platform and lexically - * normalizes both sides, so a typed-case Windows path or a `HOME=/home/u/.` - * shape still collapses to the Home crumb. + * by its own path. `home` and every crumb path arrive in the same resolved + * shape (the wire contract), so only the platform case fold remains — a + * typed-case Windows chain still collapses to the Home crumb. */ function displayCrumbs(listing: DirectoryListing, homeLabel: string): DirectoryEntry[] { - const sep = separatorOf(listing) - const fold = foldPathFor(sep) - const normalize = normalizePathFor(sep) - const home = fold(normalize(listing.home)) - const homeIndex = listing.crumbs.findIndex(crumb => fold(normalize(crumb.path)) === home) + const fold = foldPathFor(separatorOf(listing)) + const home = fold(listing.home) + const homeIndex = listing.crumbs.findIndex(crumb => fold(crumb.path) === home) if (homeIndex === -1) return listing.crumbs const tail = listing.crumbs.slice(homeIndex + 1) return [{ name: homeLabel, path: listing.home, hidden: false }, ...tail] @@ -161,13 +157,14 @@ function separatorOf(listing: DirectoryListing): '\\' | '/' { * The path draft's final segment, when its directory part names the level * `listing` lists — the segment the level prefix-filters on while the user * types. Any other draft (no separator yet, or naming some other directory) - * leaves the level unfiltered. The directory part compares lexically - * normalized (dot segments, repeated separators, and win32 forward - * slashes all match what Enter would navigate to) and under the platform - * case fold (exact on slash platforms; Windows folds, since an upgraded - * selection may carry the actual entry's case while the level below still - * carries the typed one); the name filter downstream is case-insensitive - * everywhere. + * leaves the level unfiltered. Only the directory part is lexically + * normalized (dot segments, repeated separators, and win32 forward slashes + * all match what Enter would navigate to) and platform-case-folded (exact + * on slash platforms; Windows folds, since an upgraded selection may carry + * the actual entry's case while the level below still carries the typed + * one); the FINAL segment stays a literal name prefix — a lone `.` reads + * as the dot-reveal, `..` matches no entry (Enter still navigates it) — + * and the name filter downstream is case-insensitive everywhere. */ function draftPrefixFor(listing: DirectoryListing, draft: string | null): string | null { if (draft === null) return null @@ -177,7 +174,7 @@ function draftPrefixFor(listing: DirectoryListing, draft: string | null): string if (cut === -1) return null const fold = foldPathFor(sep) const normalize = normalizePathFor(sep) - return fold(normalize(folded.slice(0, cut + 1))) === fold(normalize(listing.path)) + return fold(normalize(folded.slice(0, cut + 1))) === fold(listing.path) ? folded.slice(cut + 1) : null } @@ -593,15 +590,16 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen, if (row !== null && childPath !== undefined) row.scrollLeft = row.scrollWidth }, [childPath]) // Every pick and editor exit that would drop focus to body re-parks it - // after commit, so keyboard traversal stays inside the dialog (the Modal - // has no focus trap): a pick lands on the selection's row — aria-current - // in the freshly rendered left pane, which survives even a right-pane + // after commit, so THIS DIALOG'S OWN node replacements never leak focus + // out of the card: a pick lands on the selection's row — aria-current in + // the freshly rendered left pane, which survives even a right-pane // advance or a create landing replacing the picked button's column — // while the edit-zone exits enumerated at the flag declarations fall - // back to the crumb edit zone. The one window outside this invariant is - // the owner's adopt: busy inerts every control in the card (browsers - // blur disabled elements to body) and no parking applies — the owner - // closes the dialog either way. + // back to the crumb edit zone. Outside the guarantee: the Modal has no + // focus trap, so tabbing past the card's edge legitimately leaves, and + // the owner's adopt window (busy inerts every control; browsers blur + // disabled elements to body) gets no parking — the owner closes the + // dialog either way. useEffect(() => { if (pathDraft !== null) return if (refocusPick.current) { diff --git a/packages/host/directory-picker-browse/src/index.ts b/packages/host/directory-picker-browse/src/index.ts index 6fa157ea87..3a2b3d4cdf 100644 --- a/packages/host/directory-picker-browse/src/index.ts +++ b/packages/host/directory-picker-browse/src/index.ts @@ -215,7 +215,12 @@ export default class BrowseDirectoryPicker extends DirectoryPicker { } private async list(path?: string, signal?: AbortSignal): Promise { - const home = homedir() + // Resolved like every other path in the listing: the environment may + // decorate HOME (trailing or repeated separators, dot segments, win32 + // forward slashes) and homedir() ships it verbatim, while clients + // compare home against the resolved `path`/`crumbs` — the wire contract + // promises one canonical shape for all three. + const home = resolve(homedir()) // The seam contract takes fully qualified paths only; resolve() would // silently rebase a relative or empty wire value under the host process // cwd (or, for rooted drive-less Windows forms, its current drive). diff --git a/packages/host/directory-picker-browse/tests/directory-browser.spec.tsx b/packages/host/directory-picker-browse/tests/directory-browser.spec.tsx index af40e08fe7..38fe2a5cba 100644 --- a/packages/host/directory-picker-browse/tests/directory-browser.spec.tsx +++ b/packages/host/directory-picker-browse/tests/directory-browser.spec.tsx @@ -240,19 +240,6 @@ describe('DirectoryBrowser', () => { expect(within(columns()[1]!).getByText('harness')).toBeTruthy() }) - // HOME ships verbatim from the environment while listing paths arrive - // resolved; each decoration (trailing, repeated, dot, dot-dot segments) - // must still normalize to the display root — single pane, Home crumb, - // and no parent leg launched. - it.each(['/', '//', '/foo/../.'])('a home decorated with "%s" is still the display root', async (decoration) => { - const listDirectory = vi.fn(async (path?: string) => ({ ...listingFor(path), home: `${HOME}${decoration}` })) - mount({ listDirectory }) - await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() }) - expect(columns()).toHaveLength(1) - expect(screen.getByRole('button', { name: 'browser.home' })).toBeTruthy() - expect(listDirectory).toHaveBeenCalledTimes(1) - }) - it('a landing whose new level dropped the focused row parks on the edit zone', async () => { mount() await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() }) @@ -407,42 +394,16 @@ describe('DirectoryBrowser', () => { expect(document.activeElement?.getAttribute('aria-current')).toBe('true') }) - it('a UNC home with dot-dot never pops the share root and still collapses to Home', async () => { + it('a UNC level is home-collapsed and filters decorated UNC drafts without popping the share root', async () => { const SHARE = '\\\\server\\share' const listing: DirectoryListing = { path: `${SHARE}\\x`, - // USERPROFILE ships verbatim; win32.resolve keeps \\server\share as - // the unpoppable root and folds the doubled separator, so this - // normalizes to \\server\share\x. - home: '\\\\server\\\\share\\..\\x', + home: `${SHARE}\\x`, crumbs: [ { name: `${SHARE}\\`, path: `${SHARE}\\`, hidden: false }, { name: 'x', path: `${SHARE}\\x`, hidden: false }, ], - entries: [], - truncated: false, - } - const listDirectory = vi.fn(async () => listing) - mount({ listDirectory }) - await waitFor(() => { expect(listDirectory).toHaveBeenCalled() }) - await waitFor(() => { expect(screen.getByRole('button', { name: 'browser.home' })).toBeTruthy() }) - expect(columns()).toHaveLength(1) - expect(listDirectory).toHaveBeenCalledTimes(1) - }) - - it('a forward-slash Windows home still reads as the display root', async () => { - const listing: DirectoryListing = { - path: 'C:\\Users\\Alice', - // USERPROFILE may legally use forward slashes; the root crumb (not - // the home text) carries the platform, and normalization folds the - // slashes before comparing. - home: 'C:/Users/Alice', - crumbs: [ - { name: 'C:\\', path: 'C:\\', hidden: false }, - { name: 'Users', path: 'C:\\Users', hidden: false }, - { name: 'Alice', path: 'C:\\Users\\Alice', hidden: false }, - ], - entries: [{ name: 'Desktop', path: 'C:\\Users\\Alice\\Desktop', hidden: false }], + entries: [{ name: 'Alpha', path: `${SHARE}\\x\\Alpha`, hidden: false }], truncated: false, } const listDirectory = vi.fn(async () => listing) @@ -450,7 +411,15 @@ describe('DirectoryBrowser', () => { await waitFor(() => { expect(screen.getByRole('listitem')).toBeTruthy() }) expect(columns()).toHaveLength(1) expect(screen.getByRole('button', { name: 'browser.home' })).toBeTruthy() - expect(listDirectory).toHaveBeenCalledTimes(1) + // A decorated UNC draft (doubled separator, share-root-crossing dot-dot) + // still normalizes to the listed level: the filter matches what Enter + // would navigate to, and \\server\share stays unpoppable. + fireEvent.click(screen.getByRole('button', { name: 'browser.editPath' })) + const input = screen.getByLabelText('browser.editPath') + fireEvent.change(input, { target: { value: '\\\\server\\\\share\\..\\x\\a' } }) + expect(screen.getByText('Alpha')).toBeTruthy() + fireEvent.change(input, { target: { value: `${SHARE}\\x\\z` } }) + expect(screen.queryByRole('listitem')).toBeNull() }) it('collapses a typed-case Windows home to the display root (single pane, Home crumb)', async () => { @@ -682,6 +651,8 @@ describe('DirectoryBrowser', () => { expect(screen.getByRole('listitem').textContent).toBe('Documents') fireEvent.change(input, { target: { value: `${HOME}//do` } }) expect(screen.getByRole('listitem').textContent).toBe('Documents') + fireEvent.change(input, { target: { value: `${HOME}/foo/../do` } }) + expect(screen.getByRole('listitem').textContent).toBe('Documents') }) it('filters the child pane in two-pane mode and follows the draft back up a level', async () => { diff --git a/packages/host/directory-picker-browse/tests/home-shape.spec.ts b/packages/host/directory-picker-browse/tests/home-shape.spec.ts new file mode 100644 index 0000000000..153551f7f9 --- /dev/null +++ b/packages/host/directory-picker-browse/tests/home-shape.spec.ts @@ -0,0 +1,28 @@ +/** + * The wire contract's home shape: a decorated HOME (trailing/repeated + * separators, dot segments — homedir() ships it verbatim) still leaves the + * listing carrying the resolved form, matching `path` and `crumbs[].path`. + */ + +import { resolve } from 'node:path' +import { expect, it, vi } from 'vitest' +import { Context } from 'cordis' + +vi.mock('node:os', async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, homedir: () => `${actual.homedir()}/.//.` } +}) + +it('resolves a decorated homedir before stamping listing.home', async () => { + const { homedir } = await vi.importActual('node:os') + const { default: BrowseDirectoryPicker } = await import('../src/index.ts') + const ctx = new Context() + const fiber = ctx.plugin(BrowseDirectoryPicker) + await fiber.await() + const picked = ctx.get('directoryPicker')!.capability() + if (picked.kind !== 'browse') throw new Error('browse backend must advertise the browse capability') + const listing = await picked.list() + expect(listing.home).toBe(resolve(homedir())) + expect(listing.path).toBe(listing.home) + await fiber.dispose() +}) diff --git a/packages/host/directory-picker-browse/tests/service.spec.ts b/packages/host/directory-picker-browse/tests/service.spec.ts index 002d42e516..98adab3c5d 100644 --- a/packages/host/directory-picker-browse/tests/service.spec.ts +++ b/packages/host/directory-picker-browse/tests/service.spec.ts @@ -2,7 +2,7 @@ import { mkdir, mkdtemp, rm, symlink, writeFile } from 'node:fs/promises' import { homedir, tmpdir } from 'node:os' -import { basename, join } from 'node:path' +import { basename, join, resolve } from 'node:path' import { afterAll, beforeAll, describe, expect, it } from 'vitest' import { Context } from 'cordis' import { DirectoryPickerError } from '@deepseek-ai/dsh-host-directory-picker' @@ -48,7 +48,9 @@ describe('BrowseDirectoryPicker', () => { it('lists directories only, flags hidden rows, follows symlinks, skips broken links, sorts by name', async () => { const listing = await capability.list(root) expect(listing.path).toBe(root) - expect(listing.home).toBe(homedir()) + // Resolved like path and crumbs — the environment may decorate HOME, + // and the wire contract promises one canonical shape for all three. + expect(listing.home).toBe(resolve(homedir())) expect(listing.entries.map(entry => entry.name)).toEqual(['.hidden-dir', 'linked', 'projects']) expect(listing.entries.map(entry => entry.hidden)).toEqual([true, false, false]) // Every entry path is absolute and host-joined — clients never join segments. diff --git a/packages/host/directory-picker/src/index.ts b/packages/host/directory-picker/src/index.ts index 4dba9c9c22..e6aa5791aa 100644 --- a/packages/host/directory-picker/src/index.ts +++ b/packages/host/directory-picker/src/index.ts @@ -38,7 +38,7 @@ export interface DirectoryEntry { export interface DirectoryListing { /** Absolute path of the listed directory. */ path: string - /** The host account's home directory (breadcrumb "Home" rooting). */ + /** The host account's home directory (breadcrumb "Home" rooting), in the same resolved shape as `path` and `crumbs[].path`. */ home: string /** * Ancestor chain from the filesystem root to the listed directory From 983e6d07a45f5e7c2912ed610d66e0ef3968e5ed Mon Sep 17 00:00:00 2001 From: creatixchu Date: Thu, 30 Jul 2026 02:10:07 +0800 Subject: [PATCH 079/113] =?UTF-8?q?fix(host):=20review=20round=2020=20?= =?UTF-8?q?=E2=80=94=20canonical=20shape=20declared=20at=20the=20interface?= =?UTF-8?q?;=20hermetic=20home-shape=20spec;=20single=20resolve?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...directory-picker-capability-seam.i18n.yaml | 4 ++-- ...-07-28-directory-picker-capability-seam.md | 1 + ...-28-directory-picker-capability-seam.zh.md | 1 + .../client/connection/src/client/fixture.ts | 4 ++++ packages/host/apiproxy/src/api/host.ts | 11 +++++++-- .../src/client/DirectoryBrowser.tsx | 5 ++-- .../host/directory-picker-browse/src/index.ts | 11 +++++---- .../tests/home-shape.spec.ts | 24 +++++++++++++++---- packages/host/directory-picker/src/index.ts | 11 +++++++-- 9 files changed, 55 insertions(+), 17 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.i18n.yaml index 0502f5cd0d..cee7a5f948 100644 --- a/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md -2026-07-28-directory-picker-capability-seam.md: 6cba49f9a5817d05e4933e397918a0b9a17ce845 -2026-07-28-directory-picker-capability-seam.zh.md: bfa9f80db32b1e73a198442826c7aea9972c7412 +2026-07-28-directory-picker-capability-seam.md: ebd40b9dbc89630c5cbbfacdcfb8be2b6a65fd49 +2026-07-28-directory-picker-capability-seam.zh.md: 6ba8eb347bf906f05ac6192cf941e76d3bc4f44a diff --git a/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md b/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md index 6cba49f9a5..ebd40b9dbc 100644 --- a/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md +++ b/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md @@ -22,6 +22,7 @@ Placement and policy rulings folded into this decision: - **Path-editor cancel scope: the dialog card.** The browse client's path editor cancels on Escape and on focus leaving the card, both observed at a card-scope wrapper rather than the input — after Tab parks focus on a filtered row the input is off the event path, yet Escape must collapse the editor (not the dialog) and a later focus departure must still cancel. Non-cancel exemptions: window/tab focus loss, in-card focus moves, and pointer paths (rows and the toggle suppress focus steal on mousedown while editing). Separators for seeding and draft-tail filtering are read from the host-resolved root crumb (exact for every root form this backend emits: `/`, `C:\`, `\\server\share\`); the wire-field alternative below records the deferred authoritative form. Combobox semantics between the editor and the list it filters (`aria-expanded`/`aria-controls`/active-descendant, result announcements) are likewise deferred — today they read to assistive tech as separate widgets. Focus parking is a card-wide invariant, not an editor-only one: every pick — editing or not, including right-pane advances and create landings whose columns are replaced — re-parks focus on the selection's row after commit, while every other displacing exit (Enter, Escape, a navigation landing whose new level dropped the focused row, a failed pick or create relist, and the nested create dialog closing) falls back to the crumb edit zone whenever focus actually fell to body, and a show-hidden toggle click that finds focus among the rows parks synchronously on the toggle itself. The guarantee is scoped to the dialog's own node replacements — the Modal has no focus trap, so tabbing past the card's edge legitimately leaves, and the owner's adopt window (where `busy` inerts every control and the dialog is closing either way) is likewise outside it. - **Navigation lands selection-anchored, progressively.** Away from the display root (the same collapse the crumb header renders, so crumbs and pane shape never disagree), the browse client's navigate commits the target level the moment it arrives — the editor closes and loading ends on that first settlement, so an Enter-submitted navigation is never withdrawn waiting on more — and a parent leg then upgrades the landing in place: the target's actual parent-level entry re-selected (platform case folding on Windows), its children on the right, so a crumb jump reads as stepping back one pane rather than collapsing to a single column. The parent leg runs under the landing's supersession scope and is aborted on the wire by any newer intent; a failed parent leg, or a truncated parent window lacking the target, leaves the committed single-pane landing — the upgrade must never orphan the selection it exists to anchor. Known boundaries of the progressive shape: a pointer press landing exactly inside the one-RTT upgrade window can lose its click when the pressed row node is replaced (keyboard focus is re-parked on the re-selected row; the pointer window is accepted); on slash platforms (macOS) only a final-segment case drift misses the parent-entry match and keeps the single-pane landing, while ancestor-segment drift still matches — parent entry paths inherit the typed prefix — and lands two panes at the cost of the Home collapse; and navigate always relists both legs even when the target is the currently shown level — a crumb tap doubles as the refresh gesture, so freshness wins over reusing possibly-stale in-hand listings at the cost of up to two host scans. - **Symlinks: follow for enterability.** `stat` probes symlinks (broken/cyclic → skipped); crumbs keep the logical path the operator navigated, and `workspace.create` already canonicalizes via realpath at adoption. +- **One canonical path shape on the wire.** A listing's `path`, `crumbs[].path`, `entries[].path`, and `home` all ship host-resolved — homedir() output included, since the environment may decorate HOME and the backend resolves it before stamping. Clients compare listing paths verbatim on that promise; the only lexical mirror left in the browse client serves the draft side, the one path a user types and hence naturally non-canonical. Normalizing at the source replaces a client-side mirror of resolve() that had to anticipate every decoration (trailing and repeated separators, dot segments, UNC roots, forward slashes), and the promise binds every browse backend. - **Listing levels are bounded, and streamed.** One `list` call returns at most `maxEntries` rows (config, default 1000 — GitHub's web-UI directory-listing bound). The level streams via `opendir` into a name-sorted window of `maxEntries + 1` candidates, so memory stays O(maxEntries) and enterability probing touches only windowed candidates; the wire `DirectoryListing` carries a required `truncated` flag so the client states incompleteness instead of silently missing tail entries. A windowed broken symlink is not backfilled from beyond the window — the eviction already marks the level truncated. Window insertion is binary with an O(1) full-window tail rejection (an oversized level must not pay a window scan per dirent), and `list(path, signal)` threads the carrier's request signal so a scan of a stalled network directory cannot outlive a disconnected caller — every await in the scan (open, each read, each symlink probe) races the signal, an aborted exit abandons rather than awaits the close (Node queues close behind in-flight reads), and abandoned settlements are swallowed so cleanup can never surface as an unhandled rejection. An unbounded level is a memory/responsiveness hole for large or adversarial directories. - **Whole-filesystem scope, no roots config.** `workspace.create` accepts arbitrary paths and the API serves bash-driving methods, so a browse root would be UX scoping, not a boundary; configurability without a consumer fails the evidence bar. Deferred until a deployment needs it. - **The native backend stays.** Plugin-form was the point: multiple providers can serve the seam (an Electron shell would provide the `native` interaction through its own dialog API). Kind naming: `dialog` was the first pick and was dropped — the browse interaction also presents a dialog (the in-app modal), so the word failed to discriminate; `native` names where the chooser runs. diff --git a/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.zh.md b/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.zh.md index bfa9f80db3..6ba8eb347b 100644 --- a/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.zh.md @@ -22,6 +22,7 @@ web GUI 的"打开本地文件夹"流程被焊死在一种交互上:`host.pick - **路径编辑器的取消范围:对话框卡片。** browse 客户端的路径编辑器在按 Escape 与焦点离开卡片时取消,两者都在卡片范围的包装层而非输入框上监听——Tab 把焦点停到某个过滤命中的行之后,输入框已不在事件路径上,但 Escape 仍须收起编辑器(而非对话框),其后的焦点离开也仍须取消。不取消的豁免:窗口/标签页失焦、卡片内焦点移动,以及指针路径(编辑期间行与开关在 mousedown 时抑制焦点夺取)。预填与草稿末段过滤所用的分隔符从宿主解析的根 crumb 读取(对该后端发出的每种根形态都精确:`/`、`C:\`、`\\server\share\`);下文的线上字段替代方案记录了被延期的权威形态。编辑器与其过滤的列表之间的 combobox 语义(`aria-expanded`/`aria-controls`/active-descendant、结果播报)同样被延期——目前二者在辅助技术看来是彼此独立的控件。焦点停靠是卡片全域的不变量,而非编辑器独有:每次选取——无论是否处于编辑态,包括右栏推进与各列被替换的创建落地——提交后都把焦点重新停靠到选中项所在的行上,而其余所有会顶离焦点的退出(Enter、Escape、新层级已不含焦点所在行的导航落地、选取或创建失败后的重新列举,以及嵌套创建对话框的关闭)只要焦点确实落到了 body 上,就回落到 crumb 编辑区,而点击"显示隐藏"开关时若发现焦点落在行间,则把焦点同步停靠到开关自身。该保证的范围仅限对话框自身的节点替换——Modal 没有焦点陷阱,所以 Tab 越过卡片边缘属于正当离开,而 owner 的接纳窗口(其间 `busy` 把每个控件置为惰性,且对话框反正正在关闭)同样在此范围之外。 - **导航以选中项为锚、渐进落地。** 在展示根之外(与 crumb 头部渲染的是同一塌缩,因此 crumb 与分栏形态永不相左),browse 客户端的导航在目标层级到达的那一刻即提交它——这次首个落定即关闭编辑器并结束加载,因此 Enter 提交的导航绝不会为等待更多内容而被撤回——随后父层级这一程就地升级这次落地:重新选中目标在父层级中的实际条目(Windows 上按平台惯例折叠大小写),右侧展示其子项,因此 crumb 跳转读作后退一栏,而不是塌缩成单列。父层级这一程在落地的 supersession 范围下运行,任何较新的意图都会在线上将其中止;父层级这一程失败,或被截断的父窗口缺少目标时,都保留已提交的单栏落地——升级的存在正是为了锚定选中项,绝不能反而让它悬空。渐进形态的已知边界:指针按压若恰好落在单个 RTT 的升级窗口内,可能因所按行节点被替换而丢失点击(键盘焦点会重新停靠到重新选中的行上;指针的这段窗口则被接受);斜杠平台(macOS)上仅末段的大小写偏差会错过父层级条目匹配,保留单栏落地,而祖先段的偏差仍能匹配——父层级条目路径继承键入的前缀——并落地双栏,代价是 Home 塌缩;且导航总是重新列举两程,哪怕目标就是当前展示的层级——crumb 点按兼作刷新手势,因此宁要新鲜度也不复用手头可能已陈旧的列举,代价是至多两次宿主扫描。 - **符号链接:为可进入性而跟随。** 用 `stat` 探测符号链接(断链/循环→跳过);面包屑保留操作者导航的逻辑路径,`workspace.create` 在接纳时本就做 realpath 规范化。 +- **线上只有一种规范路径形态。** 列举的 `path`、`crumbs[].path`、`entries[].path` 与 `home` 一律以宿主解析后的形态发出——homedir() 的输出也不例外,因为环境可能修饰 HOME,后端在标注前先行解析。客户端凭这一承诺逐字比较列举路径;browse 客户端仅剩的词法镜像服务于草稿一侧——用户键入的那一条路径,因而天然非规范。在源头做规范化,取代了客户端侧那份必须预判每种修饰(末尾与重复的分隔符、点段、UNC 根、正斜杠)的 resolve() 镜像,且这一承诺约束每一个 browse 后端。 - **列举层级有上限,且流式处理。** 单次 `list` 至多返回 `maxEntries` 行(配置项,默认 1000——GitHub 网页端目录列举的同一上限)。层级经 `opendir` 流入一个按名排序、容量 `maxEntries + 1` 的候选窗口,内存保持 O(maxEntries),可进入性探测只触及窗口内候选;线上 `DirectoryListing` 携带必填的 `truncated` 标志,让客户端明示不完整而不是静默缺尾。窗口内的断链符号链接不从窗口外回填——发生过驱逐本身已把层级标记为截断。窗口插入为二分查找、满窗尾部单次比较即拒绝(超大层级不能为每个 dirent 付出一次全窗扫描),且 `list(path, signal)` 透传载体的请求信号,滞塞网络目录的扫描不会在调用方断连后继续存活——扫描中的每个 await(打开、每次读取、每次符号链接探测)都与信号赛跑,中止路径放弃而非等待 close(Node 会把 close 排在在飞读取之后),被放弃的 settlement 全部吞掉,清理不会以未处理拒绝的形式冒出。无上限的层级对超大或恶意构造的目录就是内存/响应性漏洞。 - **全盘可浏览,不做 roots 配置。** `workspace.create` 接受任意路径且 API 本就提供驱动 bash 的方法,浏览根只会是 UX 范围而非边界;没有消费方的可配置性过不了证据门槛。等到有部署需要再做。 - **native 后端保留。** 插件化正是目的:多方都能提供该 seam(Electron 壳可以经自己的对话框 API 提供 `native` 交互)。kind 命名:最初选了 `dialog` 后被放弃——browse 交互同样以对话框呈现(应用内弹窗),这个词起不到判别作用;`native` 命名的是选择器运行的位置。 diff --git a/packages/client/connection/src/client/fixture.ts b/packages/client/connection/src/client/fixture.ts index be9ba79347..22113e7fdb 100644 --- a/packages/client/connection/src/client/fixture.ts +++ b/packages/client/connection/src/client/fixture.ts @@ -1016,6 +1016,10 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy { // same tree the browse primitives serve). pickDirectory: request => ok(request, { path: `${FIXTURE_HOME}/Documents/project` }), listDirectory: (request) => { + // The fixture accepts CANONICAL paths only: a decorated input + // (./, //, ..) misses the tree map and reads as unreadable, where + // the real backend resolve()s it first. The keyless lanes drive + // canonical paths, so the divergence stays out of transcripts. const target = request.payload.path ?? FIXTURE_HOME const children = childrenOf(target) if (children === undefined) { diff --git a/packages/host/apiproxy/src/api/host.ts b/packages/host/apiproxy/src/api/host.ts index 25a0698234..033ca2f112 100644 --- a/packages/host/apiproxy/src/api/host.ts +++ b/packages/host/apiproxy/src/api/host.ts @@ -15,11 +15,18 @@ export interface DirectoryEntry { hidden: boolean } -/** host.listDirectory response value: one directory level plus its ancestry. */ +/** + * host.listDirectory response value: one directory level plus its ancestry. + * Every path in one listing — `path`, `crumbs[].path`, `entries[].path`, + * and `home` — is host-resolved canonical form: no `.`/`..` segments, no + * repeated or trailing separators (bare roots `/`, `C:\`, `\\server\share\` + * excepted), one platform separator. Clients compare paths on this promise + * without re-normalizing. + */ export interface DirectoryListing { /** Absolute path of the listed directory. */ path: string - /** The host account's home directory (breadcrumb "Home" rooting), in the same resolved shape as `path` and `crumbs[].path`. */ + /** The host account's home directory (breadcrumb "Home" rooting), in the interface's canonical shape like every other path here. */ home: string /** * Ancestor chain from the filesystem root to the listed directory diff --git a/packages/host/directory-picker-browse/src/client/DirectoryBrowser.tsx b/packages/host/directory-picker-browse/src/client/DirectoryBrowser.tsx index 0ed8cf3eaa..109f69ff2d 100644 --- a/packages/host/directory-picker-browse/src/client/DirectoryBrowser.tsx +++ b/packages/host/directory-picker-browse/src/client/DirectoryBrowser.tsx @@ -137,8 +137,9 @@ function displayCrumbs(listing: DirectoryListing, homeLabel: string): DirectoryE /** * The listing's platform separator, read from the host-resolved root crumb * (`/`, `C:\`, `\\server\share\`) — exact for every root form the backend - * emits, immune both to a home delivered in the other slash flavor - * (`USERPROFILE=C:/Users/Alice`) and to backslashes inside POSIX names. + * emits, and immune to backslashes inside POSIX names (which the home text + * may legally carry; the wire contract already excludes non-canonical + * shapes elsewhere). * TODO: replace with a host-stamped `separator` field on the wire * DirectoryListing so the platform fact travels verbatim (the trade-off is * recorded in the directory-picker capability seam Agent Note). diff --git a/packages/host/directory-picker-browse/src/index.ts b/packages/host/directory-picker-browse/src/index.ts index 3a2b3d4cdf..042794a769 100644 --- a/packages/host/directory-picker-browse/src/index.ts +++ b/packages/host/directory-picker-browse/src/index.ts @@ -217,9 +217,12 @@ export default class BrowseDirectoryPicker extends DirectoryPicker { private async list(path?: string, signal?: AbortSignal): Promise { // Resolved like every other path in the listing: the environment may // decorate HOME (trailing or repeated separators, dot segments, win32 - // forward slashes) and homedir() ships it verbatim, while clients - // compare home against the resolved `path`/`crumbs` — the wire contract - // promises one canonical shape for all three. + // forward slashes) and homedir() ships it verbatim, while the wire + // contract promises one canonical shape for every listing path. A + // relative or drive-less HOME rebases under the process cwd / current + // drive here — the behavior the fullyQualified fence refuses for wire + // values — accepted for the host's own environment, since the listed + // target derives from home and stays consistent with it. const home = resolve(homedir()) // The seam contract takes fully qualified paths only; resolve() would // silently rebase a relative or empty wire value under the host process @@ -227,7 +230,7 @@ export default class BrowseDirectoryPicker extends DirectoryPicker { if (path !== undefined && !fullyQualified(path)) { throw new DirectoryPickerError('directory-unreadable', path, `cannot list "${path}": not a fully qualified path`) } - const target = resolve(path ?? home) + const target = path === undefined ? home : resolve(path) // Stream the level (opendir, one dirent at a time) into a name-sorted // window of maxEntries + 1 candidates: memory stays bounded no matter how // many children the directory holds, the window keeps the name-sorted diff --git a/packages/host/directory-picker-browse/tests/home-shape.spec.ts b/packages/host/directory-picker-browse/tests/home-shape.spec.ts index 153551f7f9..569a631388 100644 --- a/packages/host/directory-picker-browse/tests/home-shape.spec.ts +++ b/packages/host/directory-picker-browse/tests/home-shape.spec.ts @@ -2,19 +2,33 @@ * The wire contract's home shape: a decorated HOME (trailing/repeated * separators, dot segments — homedir() ships it verbatim) still leaves the * listing carrying the resolved form, matching `path` and `crumbs[].path`. + * The mock points homedir at a scratch tree so the probe never scans the + * running machine's real home (same hermetic reasoning as service.spec's + * temporary tree); the mock spreads the actual module, so tmpdir stays real. */ -import { resolve } from 'node:path' -import { expect, it, vi } from 'vitest' +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join, resolve } from 'node:path' +import { afterAll, beforeAll, expect, it, vi } from 'vitest' import { Context } from 'cordis' +let scratch: string + vi.mock('node:os', async (importOriginal) => { const actual = await importOriginal() - return { ...actual, homedir: () => `${actual.homedir()}/.//.` } + return { ...actual, homedir: () => `${scratch}/.//.` } +}) + +beforeAll(async () => { + scratch = await mkdtemp(join(tmpdir(), 'dsh-home-shape-')) +}) + +afterAll(async () => { + await rm(scratch, { recursive: true, force: true }) }) it('resolves a decorated homedir before stamping listing.home', async () => { - const { homedir } = await vi.importActual('node:os') const { default: BrowseDirectoryPicker } = await import('../src/index.ts') const ctx = new Context() const fiber = ctx.plugin(BrowseDirectoryPicker) @@ -22,7 +36,7 @@ it('resolves a decorated homedir before stamping listing.home', async () => { const picked = ctx.get('directoryPicker')!.capability() if (picked.kind !== 'browse') throw new Error('browse backend must advertise the browse capability') const listing = await picked.list() - expect(listing.home).toBe(resolve(homedir())) + expect(listing.home).toBe(resolve(scratch)) expect(listing.path).toBe(listing.home) await fiber.dispose() }) diff --git a/packages/host/directory-picker/src/index.ts b/packages/host/directory-picker/src/index.ts index e6aa5791aa..1dd15fefa4 100644 --- a/packages/host/directory-picker/src/index.ts +++ b/packages/host/directory-picker/src/index.ts @@ -34,11 +34,18 @@ export interface DirectoryEntry { hidden: boolean } -/** One directory level plus its ancestry, as a browse backend reports it. */ +/** + * One directory level plus its ancestry, as a browse backend reports it. + * Every path in one listing — `path`, `crumbs[].path`, `entries[].path`, + * and `home` — is host-resolved canonical form: no `.`/`..` segments, no + * repeated or trailing separators (bare roots `/`, `C:\`, `\\server\share\` + * excepted), one platform separator. Clients compare paths on this promise + * without re-normalizing; every backend must resolve before stamping. + */ export interface DirectoryListing { /** Absolute path of the listed directory. */ path: string - /** The host account's home directory (breadcrumb "Home" rooting), in the same resolved shape as `path` and `crumbs[].path`. */ + /** The host account's home directory (breadcrumb "Home" rooting), in the interface's canonical shape like every other path here. */ home: string /** * Ancestor chain from the filesystem root to the listed directory From f36bb1e882a053808258537b084199c550315704 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Thu, 30 Jul 2026 02:14:58 +0800 Subject: [PATCH 080/113] doc: regenerate cordis catalog for the canonical-shape seam JSDoc --- docs/cordis-catalog/services.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index e0dd17fa02..576b63e4d1 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -500,7 +500,7 @@ Abstract directory-picking service. Subclass, implement `capability()`, and load abstract capability(): DirectoryPickerCapability ``` -Source: [`packages/host/directory-picker/src/index.ts:131`](../../packages/host/directory-picker/src/index.ts) +Source: [`packages/host/directory-picker/src/index.ts:138`](../../packages/host/directory-picker/src/index.ts) ## `ctx.fs` — `FileSystem` (abstract seam) From 56ac56bb26029ff943ac532f95bed54b331ace13 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Thu, 30 Jul 2026 02:30:05 +0800 Subject: [PATCH 081/113] =?UTF-8?q?fix(host):=20review=20round=2021=20?= =?UTF-8?q?=E2=80=94=20create-path=20shape=20promised;=20lexical-not-realp?= =?UTF-8?q?ath=20named;=20hermetic-only=20home=20probes;=20enumerations=20?= =?UTF-8?q?point=20at=20the=20contract?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/host/apiproxy/src/api/host.ts | 9 ++++++--- .../src/client/DirectoryBrowser.tsx | 5 +++-- .../directory-picker-browse/tests/service.spec.ts | 9 ++------- packages/host/directory-picker/README.i18n.yaml | 4 ++-- packages/host/directory-picker/README.md | 2 +- packages/host/directory-picker/README.zh.md | 2 +- packages/host/directory-picker/src/index.ts | 12 +++++++++--- 7 files changed, 24 insertions(+), 19 deletions(-) diff --git a/packages/host/apiproxy/src/api/host.ts b/packages/host/apiproxy/src/api/host.ts index 033ca2f112..19e16bf628 100644 --- a/packages/host/apiproxy/src/api/host.ts +++ b/packages/host/apiproxy/src/api/host.ts @@ -20,8 +20,9 @@ export interface DirectoryEntry { * Every path in one listing — `path`, `crumbs[].path`, `entries[].path`, * and `home` — is host-resolved canonical form: no `.`/`..` segments, no * repeated or trailing separators (bare roots `/`, `C:\`, `\\server\share\` - * excepted), one platform separator. Clients compare paths on this promise - * without re-normalizing. + * excepted), one platform separator. Resolution is lexical (`resolve()`), + * never realpath: a symlinked ancestry keeps the logical path the operator + * navigated. Clients compare paths on this promise without re-normalizing. */ export interface DirectoryListing { /** Absolute path of the listed directory. */ @@ -82,7 +83,9 @@ export interface HostApi { * Create one child directory under an existing parent (the browser's * "New folder"). Only served under the `browse` capability; an existing * child fails with `directory-exists`, every other filesystem failure with - * `directory-create-failed`. + * `directory-create-failed`. The returned path is in the listing + * contract's canonical shape — verbatim equal to the child's + * `entries[].path` in the parent's next listing. */ createDirectory( request: RpcRequest<{ path: string; name: string }>, diff --git a/packages/host/directory-picker-browse/src/client/DirectoryBrowser.tsx b/packages/host/directory-picker-browse/src/client/DirectoryBrowser.tsx index 109f69ff2d..7614d77f50 100644 --- a/packages/host/directory-picker-browse/src/client/DirectoryBrowser.tsx +++ b/packages/host/directory-picker-browse/src/client/DirectoryBrowser.tsx @@ -83,8 +83,9 @@ function foldSeparatorsFor(sep: '\\' | '/'): (value: string) => string { /** * Lexically normalizes a typed absolute path for comparisons against the - * backend's resolved ones (the wire contract keeps `path`, `crumbs[].path`, - * and `home` in resolved shape; only the DRAFT side needs this): collapses + * backend's resolved ones (every listing path arrives in the + * DirectoryListing contract's canonical shape; only the DRAFT side, the + * one path a user types, needs this): collapses * repeated and trailing separators, drops `.` segments, and applies `..` * without ever crossing the root — POSIX's `/`, a drive's `C:`, or UNC's * `\\server\share` pair — mirroring resolve()'s lexical behavior. Expects diff --git a/packages/host/directory-picker-browse/tests/service.spec.ts b/packages/host/directory-picker-browse/tests/service.spec.ts index 98adab3c5d..0ace7811f1 100644 --- a/packages/host/directory-picker-browse/tests/service.spec.ts +++ b/packages/host/directory-picker-browse/tests/service.spec.ts @@ -48,8 +48,8 @@ describe('BrowseDirectoryPicker', () => { it('lists directories only, flags hidden rows, follows symlinks, skips broken links, sorts by name', async () => { const listing = await capability.list(root) expect(listing.path).toBe(root) - // Resolved like path and crumbs — the environment may decorate HOME, - // and the wire contract promises one canonical shape for all three. + // The environment may decorate HOME; every listing path ships in the + // DirectoryListing contract's canonical shape, home included. expect(listing.home).toBe(resolve(homedir())) expect(listing.entries.map(entry => entry.name)).toEqual(['.hidden-dir', 'linked', 'projects']) expect(listing.entries.map(entry => entry.hidden)).toEqual([true, false, false]) @@ -163,11 +163,6 @@ describe('BrowseDirectoryPicker', () => { expect(listing.crumbs[0]!.name).toBe(listing.crumbs[0]!.path) }) - it('lists the home directory when no path is given', async () => { - const listing = await capability.list() - expect(listing.path).toBe(homedir()) - }) - it('throws directory-unreadable for a missing target', async () => { const missing = join(root, 'no-such-dir') const failure = await capability.list(missing).catch((error: unknown) => error) diff --git a/packages/host/directory-picker/README.i18n.yaml b/packages/host/directory-picker/README.i18n.yaml index 3e5bae41b5..959608c1b5 100644 --- a/packages/host/directory-picker/README.i18n.yaml +++ b/packages/host/directory-picker/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/host/directory-picker/README.md -README.md: 8ef8889c875f5b1d07c015ddef819591041c8d7f -README.zh.md: 8aefffa7b29a47205ea42d0d1df742d1e1b2502d +README.md: 4445af3071d268c5919078278b26f369d9f3ba07 +README.zh.md: 16a90eb698b48438d04154d47afda937c71a4985 diff --git a/packages/host/directory-picker/README.md b/packages/host/directory-picker/README.md index 8ef8889c87..4445af3071 100644 --- a/packages/host/directory-picker/README.md +++ b/packages/host/directory-picker/README.md @@ -4,7 +4,7 @@ English | [中文](README.zh.md) The **workspace-directory picking seam** for the web-GUI host: an abstract `DirectoryPicker` service (`ctx.directoryPicker`) whose single contract method `capability()` returns a discriminated capability describing how an operator selects a directory. Backends differ in interaction shape, not just mechanism, so the seam models the shapes explicitly instead of one method set: `{ kind: 'native', pick(signal) }` opens one native OS chooser on the host display ([`-native`](../directory-picker-native/README.md)); `{ kind: 'browse', list(path?), createDirectory(path, name) }` serves listing/creation primitives an in-app browser drives, which works for remote clients no OS chooser can reach ([`-browse`](../directory-picker-browse/README.md)). Consumers switch on `capability().kind`; the union derives from the merge-extensible `DirectoryPickerCapabilities` map (a new backend declaration-merges its shape there), and the documented default for an unknown kind is to hide the picking affordance rather than fail. The capability object must be stable for the service lifetime. The client side mirrors the seam without a wire advertisement: each backend package is dual-face, its browser half registering the matching picking interaction into ui-workspace's directory-flow slots — so one composition row swaps both the host capability and the client flow together. -Browse primitives fail with the typed `DirectoryPickerError` (`directory-unreadable` / `directory-exists` / `directory-create-failed`, each carrying the subject `path`), which the consuming gateway maps 1:1 onto wire error codes. `DirectoryEntry` rows carry a host-owned `hidden` flag (POSIX dot convention) so display policy stays client-side; `DirectoryListing.crumbs` is the ancestor chain from the filesystem root, every crumb a jump target. Design rationale, the `ctx.fs` separation, and the policy decisions live in [the directory-picker capability seam Agent Note](../../../.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md). +Browse primitives fail with the typed `DirectoryPickerError` (`directory-unreadable` / `directory-exists` / `directory-create-failed`, each carrying the subject `path`), which the consuming gateway maps 1:1 onto wire error codes. `DirectoryEntry` rows carry a host-owned `hidden` flag (POSIX dot convention) so display policy stays client-side; `DirectoryListing.crumbs` is the ancestor chain from the filesystem root, every crumb a jump target. Every path in one listing — and `createDirectory`'s returned path — ships in host-resolved canonical shape (lexical `resolve()`, never realpath): clients compare listing paths verbatim, so every backend must resolve before stamping. Design rationale, the `ctx.fs` separation, and the policy decisions live in [the directory-picker capability seam Agent Note](../../../.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md). ## Model Experience diff --git a/packages/host/directory-picker/README.zh.md b/packages/host/directory-picker/README.zh.md index 8aefffa7b2..16a90eb698 100644 --- a/packages/host/directory-picker/README.zh.md +++ b/packages/host/directory-picker/README.zh.md @@ -4,7 +4,7 @@ web GUI 宿主的**工作区目录选择 seam**:抽象服务 `DirectoryPicker`(`ctx.directoryPicker`),唯一契约方法 `capability()` 返回一个可辨识能力对象,描述操作者以何种方式选择目录。后端之间的差异在交互形态而不只是机制,因此 seam 显式建模形态而非统一方法集:`{ kind: 'native', pick(signal) }` 在宿主屏幕上打开一个原生 OS 选择器([`-native`](../directory-picker-native/README.md));`{ kind: 'browse', list(path?), createDirectory(path, name) }` 提供应用内浏览器驱动的列举/创建原语,可服务任何 OS 对话框都触及不到的远程客户端([`-browse`](../directory-picker-browse/README.md))。消费方按 `capability().kind` 分支;联合类型由可合并扩展的 `DirectoryPickerCapabilities` 映射派生(新后端在其中声明合并自己的形态),未知 kind 的文档化默认行为是隐藏选择入口而非失败。能力对象在服务生命周期内必须保持稳定。client 侧以镜像方式承接该 seam 而不经 wire 广播:每个后端包都是双面包,其 browser half 把匹配的选取交互注册进 ui-workspace 的目录流 slot——因此一行组合同时切换宿主能力与 client 流程。 -浏览原语以带类型的 `DirectoryPickerError` 失败(`directory-unreadable`/`directory-exists`/`directory-create-failed`,各自携带主体 `path`),消费网关将其 1:1 映射为协议错误码。`DirectoryEntry` 行携带宿主判定的 `hidden` 标志(POSIX 点前缀约定),展示策略留在客户端;`DirectoryListing.crumbs` 是从文件系统根开始的祖先链,每个 crumb 都是跳转目标。设计依据、与 `ctx.fs` 的切分、策略裁决见[目录选择能力 seam Agent Note](../../../.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md)。 +浏览原语以带类型的 `DirectoryPickerError` 失败(`directory-unreadable`/`directory-exists`/`directory-create-failed`,各自携带主体 `path`),消费网关将其 1:1 映射为协议错误码。`DirectoryEntry` 行携带宿主判定的 `hidden` 标志(POSIX 点前缀约定),展示策略留在客户端;`DirectoryListing.crumbs` 是从文件系统根开始的祖先链,每个 crumb 都是跳转目标。单次列举中的每个路径——连同 `createDirectory` 返回的路径——都以宿主解析的规范形态交付(词法 `resolve()`,从不 realpath):客户端逐字比较列举路径,因此每个后端都必须先解析再标注。设计依据、与 `ctx.fs` 的切分、策略裁决见[目录选择能力 seam Agent Note](../../../.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md)。 ## 模型体验 diff --git a/packages/host/directory-picker/src/index.ts b/packages/host/directory-picker/src/index.ts index 1dd15fefa4..3f1b523f7e 100644 --- a/packages/host/directory-picker/src/index.ts +++ b/packages/host/directory-picker/src/index.ts @@ -39,8 +39,11 @@ export interface DirectoryEntry { * Every path in one listing — `path`, `crumbs[].path`, `entries[].path`, * and `home` — is host-resolved canonical form: no `.`/`..` segments, no * repeated or trailing separators (bare roots `/`, `C:\`, `\\server\share\` - * excepted), one platform separator. Clients compare paths on this promise - * without re-normalizing; every backend must resolve before stamping. + * excepted), one platform separator. Resolution is lexical (`resolve()`), + * never realpath: a symlinked ancestry keeps the logical path the operator + * navigated (the seam Agent Note's symlink ruling). Clients compare paths + * on this promise without re-normalizing; every backend must resolve + * before stamping. */ export interface DirectoryListing { /** Absolute path of the listed directory. */ @@ -86,7 +89,10 @@ export interface DirectoryPickerBrowseCapability { * Create one child directory under an existing parent. * @param path - absolute existing parent directory. * @param name - single non-blank path segment (no separators, not `.`/`..`). - * @returns the created directory's absolute path. + * @returns the created directory's absolute path, in the listing + * contract's canonical shape — verbatim equal to the child's + * `entries[].path` in the parent's next listing (clients anchor the + * create landing's selection and focus on that equality). * @throws {DirectoryPickerError} `directory-exists` for an existing child, * `directory-create-failed` for a parent that is not fully qualified or any other failure. */ From 8016591ebe757dd207b06e85c479fcefe3c8b1d2 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Thu, 30 Jul 2026 02:33:06 +0800 Subject: [PATCH 082/113] doc: regenerate cordis catalog for the create-path and lexical-shape seam JSDoc --- docs/cordis-catalog/services.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 576b63e4d1..ea109431ea 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -500,7 +500,7 @@ Abstract directory-picking service. Subclass, implement `capability()`, and load abstract capability(): DirectoryPickerCapability ``` -Source: [`packages/host/directory-picker/src/index.ts:138`](../../packages/host/directory-picker/src/index.ts) +Source: [`packages/host/directory-picker/src/index.ts:144`](../../packages/host/directory-picker/src/index.ts) ## `ctx.fs` — `FileSystem` (abstract seam) From de24461b9147b0b40e701ca9d93b71094a471412 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Thu, 30 Jul 2026 02:53:45 +0800 Subject: [PATCH 083/113] =?UTF-8?q?fix(host):=20review=20round=2022=20?= =?UTF-8?q?=E2=80=94=20note=20enumeration=20includes=20the=20create=20path?= =?UTF-8?q?;=20cross-method=20equality=20pinned;=20NFD-volume=20boundary?= =?UTF-8?q?=20recorded?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...-07-28-directory-picker-capability-seam.i18n.yaml | 4 ++-- .../2026-07-28-directory-picker-capability-seam.md | 2 +- ...2026-07-28-directory-picker-capability-seam.zh.md | 2 +- packages/client/test-runtime/src/workspaces.ts | 4 +++- .../host/directory-picker-browse/README.i18n.yaml | 4 ++-- packages/host/directory-picker-browse/README.md | 1 + packages/host/directory-picker-browse/README.zh.md | 1 + .../src/client/DirectoryBrowser.tsx | 12 ++++++------ .../directory-picker-browse/tests/home-shape.spec.ts | 1 + .../directory-picker-browse/tests/service.spec.ts | 4 ++++ 10 files changed, 22 insertions(+), 13 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.i18n.yaml index cee7a5f948..bc62880e58 100644 --- a/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md -2026-07-28-directory-picker-capability-seam.md: ebd40b9dbc89630c5cbbfacdcfb8be2b6a65fd49 -2026-07-28-directory-picker-capability-seam.zh.md: 6ba8eb347bf906f05ac6192cf941e76d3bc4f44a +2026-07-28-directory-picker-capability-seam.md: 6768ed8f898765396d6a4549a661674b5336c1f3 +2026-07-28-directory-picker-capability-seam.zh.md: f20ea0af278151cb6f9ebd963185701cc3f3f2fd diff --git a/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md b/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md index ebd40b9dbc..6768ed8f89 100644 --- a/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md +++ b/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md @@ -22,7 +22,7 @@ Placement and policy rulings folded into this decision: - **Path-editor cancel scope: the dialog card.** The browse client's path editor cancels on Escape and on focus leaving the card, both observed at a card-scope wrapper rather than the input — after Tab parks focus on a filtered row the input is off the event path, yet Escape must collapse the editor (not the dialog) and a later focus departure must still cancel. Non-cancel exemptions: window/tab focus loss, in-card focus moves, and pointer paths (rows and the toggle suppress focus steal on mousedown while editing). Separators for seeding and draft-tail filtering are read from the host-resolved root crumb (exact for every root form this backend emits: `/`, `C:\`, `\\server\share\`); the wire-field alternative below records the deferred authoritative form. Combobox semantics between the editor and the list it filters (`aria-expanded`/`aria-controls`/active-descendant, result announcements) are likewise deferred — today they read to assistive tech as separate widgets. Focus parking is a card-wide invariant, not an editor-only one: every pick — editing or not, including right-pane advances and create landings whose columns are replaced — re-parks focus on the selection's row after commit, while every other displacing exit (Enter, Escape, a navigation landing whose new level dropped the focused row, a failed pick or create relist, and the nested create dialog closing) falls back to the crumb edit zone whenever focus actually fell to body, and a show-hidden toggle click that finds focus among the rows parks synchronously on the toggle itself. The guarantee is scoped to the dialog's own node replacements — the Modal has no focus trap, so tabbing past the card's edge legitimately leaves, and the owner's adopt window (where `busy` inerts every control and the dialog is closing either way) is likewise outside it. - **Navigation lands selection-anchored, progressively.** Away from the display root (the same collapse the crumb header renders, so crumbs and pane shape never disagree), the browse client's navigate commits the target level the moment it arrives — the editor closes and loading ends on that first settlement, so an Enter-submitted navigation is never withdrawn waiting on more — and a parent leg then upgrades the landing in place: the target's actual parent-level entry re-selected (platform case folding on Windows), its children on the right, so a crumb jump reads as stepping back one pane rather than collapsing to a single column. The parent leg runs under the landing's supersession scope and is aborted on the wire by any newer intent; a failed parent leg, or a truncated parent window lacking the target, leaves the committed single-pane landing — the upgrade must never orphan the selection it exists to anchor. Known boundaries of the progressive shape: a pointer press landing exactly inside the one-RTT upgrade window can lose its click when the pressed row node is replaced (keyboard focus is re-parked on the re-selected row; the pointer window is accepted); on slash platforms (macOS) only a final-segment case drift misses the parent-entry match and keeps the single-pane landing, while ancestor-segment drift still matches — parent entry paths inherit the typed prefix — and lands two panes at the cost of the Home collapse; and navigate always relists both legs even when the target is the currently shown level — a crumb tap doubles as the refresh gesture, so freshness wins over reusing possibly-stale in-hand listings at the cost of up to two host scans. - **Symlinks: follow for enterability.** `stat` probes symlinks (broken/cyclic → skipped); crumbs keep the logical path the operator navigated, and `workspace.create` already canonicalizes via realpath at adoption. -- **One canonical path shape on the wire.** A listing's `path`, `crumbs[].path`, `entries[].path`, and `home` all ship host-resolved — homedir() output included, since the environment may decorate HOME and the backend resolves it before stamping. Clients compare listing paths verbatim on that promise; the only lexical mirror left in the browse client serves the draft side, the one path a user types and hence naturally non-canonical. Normalizing at the source replaces a client-side mirror of resolve() that had to anticipate every decoration (trailing and repeated separators, dot segments, UNC roots, forward slashes), and the promise binds every browse backend. +- **One canonical path shape on the wire.** A listing's `path`, `crumbs[].path`, `entries[].path`, and `home` — and `createDirectory`'s returned path, which clients compare verbatim against the child's next `entries[].path` to anchor the create landing — all ship host-resolved: lexical `resolve()`, never realpath (the symlink ruling above keeps ancestries logical), with homedir() output included since the environment may decorate HOME and the backend resolves it before stamping. Clients compare listing paths verbatim on that promise; the only lexical mirror left in the browse client serves the draft side, the one path a user types and hence naturally non-canonical. Normalizing at the source replaces a client-side mirror of resolve() that had to anticipate every decoration (trailing and repeated separators, dot segments, UNC roots, forward slashes), and the promise binds every browse backend. - **Listing levels are bounded, and streamed.** One `list` call returns at most `maxEntries` rows (config, default 1000 — GitHub's web-UI directory-listing bound). The level streams via `opendir` into a name-sorted window of `maxEntries + 1` candidates, so memory stays O(maxEntries) and enterability probing touches only windowed candidates; the wire `DirectoryListing` carries a required `truncated` flag so the client states incompleteness instead of silently missing tail entries. A windowed broken symlink is not backfilled from beyond the window — the eviction already marks the level truncated. Window insertion is binary with an O(1) full-window tail rejection (an oversized level must not pay a window scan per dirent), and `list(path, signal)` threads the carrier's request signal so a scan of a stalled network directory cannot outlive a disconnected caller — every await in the scan (open, each read, each symlink probe) races the signal, an aborted exit abandons rather than awaits the close (Node queues close behind in-flight reads), and abandoned settlements are swallowed so cleanup can never surface as an unhandled rejection. An unbounded level is a memory/responsiveness hole for large or adversarial directories. - **Whole-filesystem scope, no roots config.** `workspace.create` accepts arbitrary paths and the API serves bash-driving methods, so a browse root would be UX scoping, not a boundary; configurability without a consumer fails the evidence bar. Deferred until a deployment needs it. - **The native backend stays.** Plugin-form was the point: multiple providers can serve the seam (an Electron shell would provide the `native` interaction through its own dialog API). Kind naming: `dialog` was the first pick and was dropped — the browse interaction also presents a dialog (the in-app modal), so the word failed to discriminate; `native` names where the chooser runs. diff --git a/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.zh.md b/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.zh.md index 6ba8eb347b..f20ea0af27 100644 --- a/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.zh.md @@ -22,7 +22,7 @@ web GUI 的"打开本地文件夹"流程被焊死在一种交互上:`host.pick - **路径编辑器的取消范围:对话框卡片。** browse 客户端的路径编辑器在按 Escape 与焦点离开卡片时取消,两者都在卡片范围的包装层而非输入框上监听——Tab 把焦点停到某个过滤命中的行之后,输入框已不在事件路径上,但 Escape 仍须收起编辑器(而非对话框),其后的焦点离开也仍须取消。不取消的豁免:窗口/标签页失焦、卡片内焦点移动,以及指针路径(编辑期间行与开关在 mousedown 时抑制焦点夺取)。预填与草稿末段过滤所用的分隔符从宿主解析的根 crumb 读取(对该后端发出的每种根形态都精确:`/`、`C:\`、`\\server\share\`);下文的线上字段替代方案记录了被延期的权威形态。编辑器与其过滤的列表之间的 combobox 语义(`aria-expanded`/`aria-controls`/active-descendant、结果播报)同样被延期——目前二者在辅助技术看来是彼此独立的控件。焦点停靠是卡片全域的不变量,而非编辑器独有:每次选取——无论是否处于编辑态,包括右栏推进与各列被替换的创建落地——提交后都把焦点重新停靠到选中项所在的行上,而其余所有会顶离焦点的退出(Enter、Escape、新层级已不含焦点所在行的导航落地、选取或创建失败后的重新列举,以及嵌套创建对话框的关闭)只要焦点确实落到了 body 上,就回落到 crumb 编辑区,而点击"显示隐藏"开关时若发现焦点落在行间,则把焦点同步停靠到开关自身。该保证的范围仅限对话框自身的节点替换——Modal 没有焦点陷阱,所以 Tab 越过卡片边缘属于正当离开,而 owner 的接纳窗口(其间 `busy` 把每个控件置为惰性,且对话框反正正在关闭)同样在此范围之外。 - **导航以选中项为锚、渐进落地。** 在展示根之外(与 crumb 头部渲染的是同一塌缩,因此 crumb 与分栏形态永不相左),browse 客户端的导航在目标层级到达的那一刻即提交它——这次首个落定即关闭编辑器并结束加载,因此 Enter 提交的导航绝不会为等待更多内容而被撤回——随后父层级这一程就地升级这次落地:重新选中目标在父层级中的实际条目(Windows 上按平台惯例折叠大小写),右侧展示其子项,因此 crumb 跳转读作后退一栏,而不是塌缩成单列。父层级这一程在落地的 supersession 范围下运行,任何较新的意图都会在线上将其中止;父层级这一程失败,或被截断的父窗口缺少目标时,都保留已提交的单栏落地——升级的存在正是为了锚定选中项,绝不能反而让它悬空。渐进形态的已知边界:指针按压若恰好落在单个 RTT 的升级窗口内,可能因所按行节点被替换而丢失点击(键盘焦点会重新停靠到重新选中的行上;指针的这段窗口则被接受);斜杠平台(macOS)上仅末段的大小写偏差会错过父层级条目匹配,保留单栏落地,而祖先段的偏差仍能匹配——父层级条目路径继承键入的前缀——并落地双栏,代价是 Home 塌缩;且导航总是重新列举两程,哪怕目标就是当前展示的层级——crumb 点按兼作刷新手势,因此宁要新鲜度也不复用手头可能已陈旧的列举,代价是至多两次宿主扫描。 - **符号链接:为可进入性而跟随。** 用 `stat` 探测符号链接(断链/循环→跳过);面包屑保留操作者导航的逻辑路径,`workspace.create` 在接纳时本就做 realpath 规范化。 -- **线上只有一种规范路径形态。** 列举的 `path`、`crumbs[].path`、`entries[].path` 与 `home` 一律以宿主解析后的形态发出——homedir() 的输出也不例外,因为环境可能修饰 HOME,后端在标注前先行解析。客户端凭这一承诺逐字比较列举路径;browse 客户端仅剩的词法镜像服务于草稿一侧——用户键入的那一条路径,因而天然非规范。在源头做规范化,取代了客户端侧那份必须预判每种修饰(末尾与重复的分隔符、点段、UNC 根、正斜杠)的 resolve() 镜像,且这一承诺约束每一个 browse 后端。 +- **线上只有一种规范路径形态。** 列举的 `path`、`crumbs[].path`、`entries[].path` 与 `home`——连同 `createDirectory` 返回的路径,客户端拿它与该子项下一次的 `entries[].path` 逐字比较以锚定创建落地——一律以宿主解析后的形态发出:词法 `resolve()`,绝不做 realpath(上文的符号链接裁决保持祖先链为逻辑路径);homedir() 的输出也不例外,因为环境可能修饰 HOME,后端在标注前先行解析。客户端凭这一承诺逐字比较列举路径;browse 客户端仅剩的词法镜像服务于草稿一侧——用户键入的那一条路径,因而天然非规范。在源头做规范化,取代了客户端侧那份必须预判每种修饰(末尾与重复的分隔符、点段、UNC 根、正斜杠)的 resolve() 镜像,且这一承诺约束每一个 browse 后端。 - **列举层级有上限,且流式处理。** 单次 `list` 至多返回 `maxEntries` 行(配置项,默认 1000——GitHub 网页端目录列举的同一上限)。层级经 `opendir` 流入一个按名排序、容量 `maxEntries + 1` 的候选窗口,内存保持 O(maxEntries),可进入性探测只触及窗口内候选;线上 `DirectoryListing` 携带必填的 `truncated` 标志,让客户端明示不完整而不是静默缺尾。窗口内的断链符号链接不从窗口外回填——发生过驱逐本身已把层级标记为截断。窗口插入为二分查找、满窗尾部单次比较即拒绝(超大层级不能为每个 dirent 付出一次全窗扫描),且 `list(path, signal)` 透传载体的请求信号,滞塞网络目录的扫描不会在调用方断连后继续存活——扫描中的每个 await(打开、每次读取、每次符号链接探测)都与信号赛跑,中止路径放弃而非等待 close(Node 会把 close 排在在飞读取之后),被放弃的 settlement 全部吞掉,清理不会以未处理拒绝的形式冒出。无上限的层级对超大或恶意构造的目录就是内存/响应性漏洞。 - **全盘可浏览,不做 roots 配置。** `workspace.create` 接受任意路径且 API 本就提供驱动 bash 的方法,浏览根只会是 UX 范围而非边界;没有消费方的可配置性过不了证据门槛。等到有部署需要再做。 - **native 后端保留。** 插件化正是目的:多方都能提供该 seam(Electron 壳可以经自己的对话框 API 提供 `native` 交互)。kind 命名:最初选了 `dialog` 后被放弃——browse 交互同样以对话框呈现(应用内弹窗),这个词起不到判别作用;`native` 命名的是选择器运行的位置。 diff --git a/packages/client/test-runtime/src/workspaces.ts b/packages/client/test-runtime/src/workspaces.ts index 6c1a9d0aad..d24e7b8c8a 100644 --- a/packages/client/test-runtime/src/workspaces.ts +++ b/packages/client/test-runtime/src/workspaces.ts @@ -142,7 +142,9 @@ export class TestWorkspaces implements IWorkspaces { * Browse child creation (recorded). The default joins parent and name. * @param path - absolute existing parent directory. * @param name - single path segment. - * @returns the created directory's absolute path. + * @returns the created directory's absolute path, in the shape + * `DirectoryPickerBrowseCapability.createDirectory` contracts (verbatim + * equal to the child's `entries[].path` in the parent's next listing). */ async createDirectory(path: string, name: string): Promise { this.calls.push({ method: 'createDirectory', args: [path, name] }) diff --git a/packages/host/directory-picker-browse/README.i18n.yaml b/packages/host/directory-picker-browse/README.i18n.yaml index d807bd737a..7d37891821 100644 --- a/packages/host/directory-picker-browse/README.i18n.yaml +++ b/packages/host/directory-picker-browse/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/host/directory-picker-browse/README.md -README.md: 23153881b84dcb71dfb05d4f297a5818c410ca77 -README.zh.md: d7010e2941a801ba6358082824330eaae46e42b7 +README.md: 01e2b9e5afcfd7c47a3f42a76cc1388a25477334 +README.zh.md: 8cb63048713dc964f92762546de783d2ce6ce5a7 diff --git a/packages/host/directory-picker-browse/README.md b/packages/host/directory-picker-browse/README.md index 23153881b8..01e2b9e5af 100644 --- a/packages/host/directory-picker-browse/README.md +++ b/packages/host/directory-picker-browse/README.md @@ -19,5 +19,6 @@ None; this package neither assembles nor sends a provider request. ## Known Limitations and Deferred Work - **Windows hidden attribute is not read** — Node dirents do not expose `FILE_ATTRIBUTE_HIDDEN`, so `hidden` means dot-prefixed on every platform until a native probe is worth its cost. +- **Name-normalizing volumes void the create-path equality** — `createDirectory` promises its return verbatim-equal to the child's next `entries[].path`; Node's namespaced Win32 paths store even trailing-dot/space segments literally, but a volume that rewrites names on storage (NFD normalization on HFS+-style volumes) breaks the match, and the create landing degrades to the documented single-pane / edit-zone fallback. - **No drive-root enumeration** — on Windows the ancestry stops at the drive root; crossing drives waits for the browser UI's path-entry affordance rather than an enumeration primitive here. - **Whole-filesystem scope** — no per-deployment browse-root restriction; `workspace.create` accepts arbitrary paths today, so a root here would be UX scoping, not a boundary — deferred until a deployment needs it. diff --git a/packages/host/directory-picker-browse/README.zh.md b/packages/host/directory-picker-browse/README.zh.md index d7010e2941..8cb6304871 100644 --- a/packages/host/directory-picker-browse/README.zh.md +++ b/packages/host/directory-picker-browse/README.zh.md @@ -19,5 +19,6 @@ ## 已知限制与延期工作 - **不读取 Windows 隐藏属性**——Node 的 dirent 不暴露 `FILE_ATTRIBUTE_HIDDEN`,因此在所有平台上 `hidden` 都意味着点前缀,直到原生探测值回其成本为止。 +- **名称规范化的卷会使创建路径等式失效**——`createDirectory` 承诺其返回值与该子项下一次的 `entries[].path` 逐字相等;Node 带命名空间的 Win32 路径连末尾点/空格段都按字面存储,但在存储时改写名称的卷(HFS+ 风格卷上的 NFD 规范化)会破坏这一匹配,创建落地随之退化为文档所述的单栏/编辑区回退。 - **不枚举盘符根**——Windows 上祖先链止于盘符根;跨盘依赖浏览器 UI 的路径输入入口,而不是这里的枚举原语。 - **全盘可浏览**——没有按部署限定的浏览根;`workspace.create` 今天就接受任意路径,这里的根只会是 UX 范围而非边界——等到有部署需要时再做。 diff --git a/packages/host/directory-picker-browse/src/client/DirectoryBrowser.tsx b/packages/host/directory-picker-browse/src/client/DirectoryBrowser.tsx index 7614d77f50..3fff931685 100644 --- a/packages/host/directory-picker-browse/src/client/DirectoryBrowser.tsx +++ b/packages/host/directory-picker-browse/src/client/DirectoryBrowser.tsx @@ -85,12 +85,12 @@ function foldSeparatorsFor(sep: '\\' | '/'): (value: string) => string { * Lexically normalizes a typed absolute path for comparisons against the * backend's resolved ones (every listing path arrives in the * DirectoryListing contract's canonical shape; only the DRAFT side, the - * one path a user types, needs this): collapses - * repeated and trailing separators, drops `.` segments, and applies `..` - * without ever crossing the root — POSIX's `/`, a drive's `C:`, or UNC's - * `\\server\share` pair — mirroring resolve()'s lexical behavior. Expects - * separators already folded to `sep` (foldSeparatorsFor); a lexical mirror - * only, symlinks are the backend's business. + * one path a user types, needs this): collapses repeated and trailing + * separators, drops `.` segments, and applies `..` without ever crossing + * the root — POSIX's `/`, a drive's `C:`, or UNC's `\\server\share` pair — + * mirroring resolve()'s lexical behavior. Expects separators already + * folded to `sep` (foldSeparatorsFor); a lexical mirror only, symlinks are + * the backend's business. */ function normalizePathFor(sep: '\\' | '/'): (value: string) => string { return (value) => { diff --git a/packages/host/directory-picker-browse/tests/home-shape.spec.ts b/packages/host/directory-picker-browse/tests/home-shape.spec.ts index 569a631388..b0ae26a921 100644 --- a/packages/host/directory-picker-browse/tests/home-shape.spec.ts +++ b/packages/host/directory-picker-browse/tests/home-shape.spec.ts @@ -38,5 +38,6 @@ it('resolves a decorated homedir before stamping listing.home', async () => { const listing = await picked.list() expect(listing.home).toBe(resolve(scratch)) expect(listing.path).toBe(listing.home) + expect(listing.crumbs.at(-1)!.path).toBe(listing.home) await fiber.dispose() }) diff --git a/packages/host/directory-picker-browse/tests/service.spec.ts b/packages/host/directory-picker-browse/tests/service.spec.ts index 0ace7811f1..cfb65af627 100644 --- a/packages/host/directory-picker-browse/tests/service.spec.ts +++ b/packages/host/directory-picker-browse/tests/service.spec.ts @@ -207,6 +207,10 @@ describe('BrowseDirectoryPicker', () => { expect(created).toBe(join(root, 'fresh')) const listing = await capability.list(root) expect(listing.entries.map(entry => entry.name)).toContain('fresh') + // The contract's cross-method equality: the returned path is verbatim + // the child's entries[].path (clients anchor the create landing's + // selection and focus on it). + expect(listing.entries.find(entry => entry.name === 'fresh')!.path).toBe(created) }) it('refuses an existing child with directory-exists', async () => { From df313b753110e71c41f733c834bae80b482424f3 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Thu, 30 Jul 2026 03:17:30 +0800 Subject: [PATCH 084/113] =?UTF-8?q?fix(host,client):=20review=20round=2023?= =?UTF-8?q?=20=E2=80=94=20create-path=20contract=20at=20every=20client=20d?= =?UTF-8?q?eclaration;=20close-edge=20resets;=20NFD=20tripwire;=20canonica?= =?UTF-8?q?l=20double=20join?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../runtime/src/client/contract/workspaces.ts | 5 +++- .../runtime/src/client/workspaces/service.ts | 3 ++- .../client/test-runtime/src/workspaces.ts | 6 ++--- .../test-runtime/tests/runtime.spec.tsx | 4 ++++ .../directory-picker-browse/README.i18n.yaml | 4 ++-- .../host/directory-picker-browse/README.md | 2 +- .../host/directory-picker-browse/README.zh.md | 2 +- .../src/client/DirectoryBrowser.tsx | 23 +++++++++++++------ .../src/client/flow.ts | 6 ++++- .../tests/service.spec.ts | 12 ++++++---- 10 files changed, 46 insertions(+), 21 deletions(-) diff --git a/packages/client/runtime/src/client/contract/workspaces.ts b/packages/client/runtime/src/client/contract/workspaces.ts index 9238ea5fd0..ea8dfcf57f 100644 --- a/packages/client/runtime/src/client/contract/workspaces.ts +++ b/packages/client/runtime/src/client/contract/workspaces.ts @@ -48,7 +48,10 @@ export interface IWorkspaces { * Create one child directory through the Host's `browse` capability. * @param path - absolute existing parent directory. * @param name - single non-blank path segment. - * @returns the created directory's absolute path. + * @returns the created directory's absolute path, in the shape + * `DirectoryPickerBrowseCapability.createDirectory` contracts: verbatim + * equal to the child's `entries[].path` in the parent's next listing + * (the browser anchors a create landing's selection on that equality). */ createDirectory(path: string, name: string): Promise /** diff --git a/packages/client/runtime/src/client/workspaces/service.ts b/packages/client/runtime/src/client/workspaces/service.ts index 1dd3319e79..df7f69a19b 100644 --- a/packages/client/runtime/src/client/workspaces/service.ts +++ b/packages/client/runtime/src/client/workspaces/service.ts @@ -208,7 +208,8 @@ export class WorkspacesService implements IWorkspaces { * Create one child directory through the Host's `browse` capability. * @param path - absolute existing parent directory. * @param name - single non-blank path segment. - * @returns the created directory's absolute path. + * @returns the created directory's absolute path, in the shape + * `IWorkspaces.createDirectory` contracts. */ async createDirectory(path: string, name: string): Promise { const response = await this.api.host.createDirectory({ path, name }) diff --git a/packages/client/test-runtime/src/workspaces.ts b/packages/client/test-runtime/src/workspaces.ts index d24e7b8c8a..bfaa414e9f 100644 --- a/packages/client/test-runtime/src/workspaces.ts +++ b/packages/client/test-runtime/src/workspaces.ts @@ -143,14 +143,14 @@ export class TestWorkspaces implements IWorkspaces { * @param path - absolute existing parent directory. * @param name - single path segment. * @returns the created directory's absolute path, in the shape - * `DirectoryPickerBrowseCapability.createDirectory` contracts (verbatim - * equal to the child's `entries[].path` in the parent's next listing). + * `IWorkspaces.createDirectory` contracts. */ async createDirectory(path: string, name: string): Promise { this.calls.push({ method: 'createDirectory', args: [path, name] }) const stub = this.stubs.get('createDirectory') if (stub !== undefined) return await (stub(path, name) as Promise) - return `${path}/${name}` + // Canonical join: a bare-root parent must not double the separator. + return path.endsWith('/') ? `${path}${name}` : `${path}/${name}` } /** diff --git a/packages/client/test-runtime/tests/runtime.spec.tsx b/packages/client/test-runtime/tests/runtime.spec.tsx index b170f69ba2..826a5e4326 100644 --- a/packages/client/test-runtime/tests/runtime.spec.tsx +++ b/packages/client/test-runtime/tests/runtime.spec.tsx @@ -329,12 +329,16 @@ describe('workspaces', () => { await expect(runtime.workspaces.listDirectory()).resolves.toMatchObject({ path: '/home/test', entries: [] }) await expect(runtime.workspaces.listDirectory('/home/test')).resolves.toMatchObject({ path: '/home/test' }) await expect(runtime.workspaces.createDirectory('/home/test', 'fresh')).resolves.toBe('/home/test/fresh') + // Canonical join: a bare-root parent yields /top, not //top (the + // IWorkspaces contract's verbatim entries[].path equality). + await expect(runtime.workspaces.createDirectory('/', 'top')).resolves.toBe('/top') // The recorded signal seat mirrors the production face (undefined here; // cancellation tests pass and observe a real one). expect(runtime.workspaces.calls).toEqual([ { method: 'listDirectory', args: [undefined, undefined] }, { method: 'listDirectory', args: ['/home/test', undefined] }, { method: 'createDirectory', args: ['/home/test', 'fresh'] }, + { method: 'createDirectory', args: ['/', 'top'] }, ]) // Stubs replace the defaults like every sibling method. const listing = { path: '/x', home: '/x', crumbs: [], entries: [] } diff --git a/packages/host/directory-picker-browse/README.i18n.yaml b/packages/host/directory-picker-browse/README.i18n.yaml index 7d37891821..2ab8b5a667 100644 --- a/packages/host/directory-picker-browse/README.i18n.yaml +++ b/packages/host/directory-picker-browse/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/host/directory-picker-browse/README.md -README.md: 01e2b9e5afcfd7c47a3f42a76cc1388a25477334 -README.zh.md: 8cb63048713dc964f92762546de783d2ce6ce5a7 +README.md: d6ed7181ffbec85d11e0abf2aa8d0053173ba4e9 +README.zh.md: 67f5f2bc40297d96bc3fcd3cfba0a3fd26855adc diff --git a/packages/host/directory-picker-browse/README.md b/packages/host/directory-picker-browse/README.md index 01e2b9e5af..d6ed7181ff 100644 --- a/packages/host/directory-picker-browse/README.md +++ b/packages/host/directory-picker-browse/README.md @@ -19,6 +19,6 @@ None; this package neither assembles nor sends a provider request. ## Known Limitations and Deferred Work - **Windows hidden attribute is not read** — Node dirents do not expose `FILE_ATTRIBUTE_HIDDEN`, so `hidden` means dot-prefixed on every platform until a native probe is worth its cost. -- **Name-normalizing volumes void the create-path equality** — `createDirectory` promises its return verbatim-equal to the child's next `entries[].path`; Node's namespaced Win32 paths store even trailing-dot/space segments literally, but a volume that rewrites names on storage (NFD normalization on HFS+-style volumes) breaks the match, and the create landing degrades to the documented single-pane / edit-zone fallback. +- **Name-normalizing volumes void the create-path equality** — `createDirectory` promises its return verbatim-equal to the child's next `entries[].path`; Node's namespaced Win32 paths store even trailing-dot/space segments literally, but a volume that rewrites names on storage (NFD normalization on HFS+-style volumes) breaks the match, and the create landing degrades to a two-pane view whose left pane lacks the aria-current row while focus falls back to the crumb edit zone. - **No drive-root enumeration** — on Windows the ancestry stops at the drive root; crossing drives waits for the browser UI's path-entry affordance rather than an enumeration primitive here. - **Whole-filesystem scope** — no per-deployment browse-root restriction; `workspace.create` accepts arbitrary paths today, so a root here would be UX scoping, not a boundary — deferred until a deployment needs it. diff --git a/packages/host/directory-picker-browse/README.zh.md b/packages/host/directory-picker-browse/README.zh.md index 8cb6304871..67f5f2bc40 100644 --- a/packages/host/directory-picker-browse/README.zh.md +++ b/packages/host/directory-picker-browse/README.zh.md @@ -19,6 +19,6 @@ ## 已知限制与延期工作 - **不读取 Windows 隐藏属性**——Node 的 dirent 不暴露 `FILE_ATTRIBUTE_HIDDEN`,因此在所有平台上 `hidden` 都意味着点前缀,直到原生探测值回其成本为止。 -- **名称规范化的卷会使创建路径等式失效**——`createDirectory` 承诺其返回值与该子项下一次的 `entries[].path` 逐字相等;Node 带命名空间的 Win32 路径连末尾点/空格段都按字面存储,但在存储时改写名称的卷(HFS+ 风格卷上的 NFD 规范化)会破坏这一匹配,创建落地随之退化为文档所述的单栏/编辑区回退。 +- **名称规范化的卷会使创建路径等式失效**——`createDirectory` 承诺其返回值与该子项下一次的 `entries[].path` 逐字相等;Node 带命名空间的 Win32 路径连末尾点/空格段都按字面存储,但在存储时改写名称的卷(HFS+ 风格卷上的 NFD 规范化)会破坏这一匹配,创建落地随之退化为左栏缺少 aria-current 行的双栏视图,同时焦点回落至 crumb 编辑区。 - **不枚举盘符根**——Windows 上祖先链止于盘符根;跨盘依赖浏览器 UI 的路径输入入口,而不是这里的枚举原语。 - **全盘可浏览**——没有按部署限定的浏览根;`workspace.create` 今天就接受任意路径,这里的根只会是 UX 范围而非边界——等到有部署需要时再做。 diff --git a/packages/host/directory-picker-browse/src/client/DirectoryBrowser.tsx b/packages/host/directory-picker-browse/src/client/DirectoryBrowser.tsx index 3fff931685..7414e08031 100644 --- a/packages/host/directory-picker-browse/src/client/DirectoryBrowser.tsx +++ b/packages/host/directory-picker-browse/src/client/DirectoryBrowser.tsx @@ -41,7 +41,12 @@ export interface DirectoryBrowserProps { open: boolean /** List one directory level (absent path = the Host home directory); the signal aborts a superseded scan on the wire. */ listDirectory: (path?: string, signal?: AbortSignal) => Promise - /** Create one child directory under an existing parent. */ + /** + * Create one child directory under an existing parent; the returned path + * is verbatim the child's `entries[].path` in the parent's next listing + * (`IWorkspaces.createDirectory`'s contract) — the create landing anchors + * its selection and focus on that equality. + */ createDirectory: (path: string, name: string) => Promise /** The operator confirmed a directory (the selection, else the listed level). */ onOpen: (path: string) => void @@ -500,19 +505,23 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen, }, [child, select]) // Every open starts fresh at the Host home directory; closing invalidates - // any in-flight response so a late arrival cannot repopulate a closed dialog. + // any in-flight response so a late arrival cannot repopulate a closed + // dialog. The per-open state resets live on the CLOSE edge: resetting on + // open would let the reopen's first commit paint one frame of the stale + // view (revealed hidden rows, a pressed toggle) before this passive + // effect runs. useEffect(() => { openGeneration.current += 1 if (open) { - setParent(null) - setSelected(null) - setChild(null) - setCreatingFolder(false) - setShowHidden(false) navigate() return } supersede() + setParent(null) + setSelected(null) + setChild(null) + setCreatingFolder(false) + setShowHidden(false) setError(null) setPathDraft(null) setFolderDraft(null) diff --git a/packages/host/directory-picker-browse/src/client/flow.ts b/packages/host/directory-picker-browse/src/client/flow.ts index 84e49b2c98..878bf7e4db 100644 --- a/packages/host/directory-picker-browse/src/client/flow.ts +++ b/packages/host/directory-picker-browse/src/client/flow.ts @@ -15,7 +15,11 @@ import { DirectoryBrowser } from './DirectoryBrowser.tsx' export interface BrowseFlowInjected { /** List one directory level (absent path = the Host home directory); the signal aborts a superseded scan. */ listDirectory: (path?: string, signal?: AbortSignal) => Promise - /** Create one child directory under an existing parent. */ + /** + * Create one child directory under an existing parent; returns the + * created path in the shape `IWorkspaces.createDirectory` contracts + * (verbatim equal to the child's next `entries[].path`). + */ createDirectory: (path: string, name: string) => Promise /** Localized dialog copy (this package's namespace). */ t: Translate diff --git a/packages/host/directory-picker-browse/tests/service.spec.ts b/packages/host/directory-picker-browse/tests/service.spec.ts index cfb65af627..09b888f5cc 100644 --- a/packages/host/directory-picker-browse/tests/service.spec.ts +++ b/packages/host/directory-picker-browse/tests/service.spec.ts @@ -203,14 +203,18 @@ describe('BrowseDirectoryPicker', () => { }) it('creates one child directory and surfaces it in the next listing', async () => { - const created = await capability.createDirectory(root, 'fresh') - expect(created).toBe(join(root, 'fresh')) + // The composed-form name (U+00E9) doubles as the name-rewriting + // tripwire: a volume that stores names NFD-decomposed hands back a + // different dirent.name and the equality below goes red — the README's + // documented boundary. + const created = await capability.createDirectory(root, 'café') + expect(created).toBe(join(root, 'café')) const listing = await capability.list(root) - expect(listing.entries.map(entry => entry.name)).toContain('fresh') + expect(listing.entries.map(entry => entry.name)).toContain('café') // The contract's cross-method equality: the returned path is verbatim // the child's entries[].path (clients anchor the create landing's // selection and focus on it). - expect(listing.entries.find(entry => entry.name === 'fresh')!.path).toBe(created) + expect(listing.entries.find(entry => entry.name === 'café')!.path).toBe(created) }) it('refuses an existing child with directory-exists', async () => { From 53e85101b924beedacd9501e0fa839d97cf668ff Mon Sep 17 00:00:00 2001 From: creatixchu Date: Thu, 30 Jul 2026 03:38:43 +0800 Subject: [PATCH 085/113] =?UTF-8?q?fix(host,client):=20review=20round=2024?= =?UTF-8?q?=20=E2=80=94=20close-edge=20facts=20synced;=20wire-hop=20pointe?= =?UTF-8?q?r;=20platform-flavored=20double=20join;=20loading=20reset?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../runtime/src/client/contract/workspaces.ts | 8 ++++---- packages/client/test-runtime/src/workspaces.ts | 7 +++++-- .../client/test-runtime/tests/runtime.spec.tsx | 7 ++++++- .../src/client/DirectoryBrowser.tsx | 16 ++++++++++------ .../directory-picker-browse/src/client/flow.ts | 6 +----- 5 files changed, 26 insertions(+), 18 deletions(-) diff --git a/packages/client/runtime/src/client/contract/workspaces.ts b/packages/client/runtime/src/client/contract/workspaces.ts index ea8dfcf57f..2b6f5f4d34 100644 --- a/packages/client/runtime/src/client/contract/workspaces.ts +++ b/packages/client/runtime/src/client/contract/workspaces.ts @@ -48,10 +48,10 @@ export interface IWorkspaces { * Create one child directory through the Host's `browse` capability. * @param path - absolute existing parent directory. * @param name - single non-blank path segment. - * @returns the created directory's absolute path, in the shape - * `DirectoryPickerBrowseCapability.createDirectory` contracts: verbatim - * equal to the child's `entries[].path` in the parent's next listing - * (the browser anchors a create landing's selection on that equality). + * @returns the created directory's absolute path, in the shape the wire + * `HostApi.createDirectory` contracts: verbatim equal to the child's + * `entries[].path` in the parent's next listing (the browser anchors a + * create landing's selection on that equality). */ createDirectory(path: string, name: string): Promise /** diff --git a/packages/client/test-runtime/src/workspaces.ts b/packages/client/test-runtime/src/workspaces.ts index bfaa414e9f..75779f266b 100644 --- a/packages/client/test-runtime/src/workspaces.ts +++ b/packages/client/test-runtime/src/workspaces.ts @@ -149,8 +149,11 @@ export class TestWorkspaces implements IWorkspaces { this.calls.push({ method: 'createDirectory', args: [path, name] }) const stub = this.stubs.get('createDirectory') if (stub !== undefined) return await (stub(path, name) as Promise) - // Canonical join: a bare-root parent must not double the separator. - return path.endsWith('/') ? `${path}${name}` : `${path}/${name}` + // Join in the parent's own separator flavor (a canonical parent ends + // with one only when it is a bare root), so the contract's verbatim + // equality holds for POSIX and Windows fixture trees alike. + const sep = path.includes('\\') ? '\\' : '/' + return path.endsWith(sep) ? `${path}${name}` : `${path}${sep}${name}` } /** diff --git a/packages/client/test-runtime/tests/runtime.spec.tsx b/packages/client/test-runtime/tests/runtime.spec.tsx index 826a5e4326..dcd4bfaadf 100644 --- a/packages/client/test-runtime/tests/runtime.spec.tsx +++ b/packages/client/test-runtime/tests/runtime.spec.tsx @@ -329,9 +329,12 @@ describe('workspaces', () => { await expect(runtime.workspaces.listDirectory()).resolves.toMatchObject({ path: '/home/test', entries: [] }) await expect(runtime.workspaces.listDirectory('/home/test')).resolves.toMatchObject({ path: '/home/test' }) await expect(runtime.workspaces.createDirectory('/home/test', 'fresh')).resolves.toBe('/home/test/fresh') - // Canonical join: a bare-root parent yields /top, not //top (the + // Canonical join in the parent's own separator flavor: bare roots do + // not double the separator, Windows parents keep backslashes (the // IWorkspaces contract's verbatim entries[].path equality). await expect(runtime.workspaces.createDirectory('/', 'top')).resolves.toBe('/top') + await expect(runtime.workspaces.createDirectory('C:\\', 'top')).resolves.toBe('C:\\top') + await expect(runtime.workspaces.createDirectory('C:\\Users', 'Alice')).resolves.toBe('C:\\Users\\Alice') // The recorded signal seat mirrors the production face (undefined here; // cancellation tests pass and observe a real one). expect(runtime.workspaces.calls).toEqual([ @@ -339,6 +342,8 @@ describe('workspaces', () => { { method: 'listDirectory', args: ['/home/test', undefined] }, { method: 'createDirectory', args: ['/home/test', 'fresh'] }, { method: 'createDirectory', args: ['/', 'top'] }, + { method: 'createDirectory', args: ['C:\\', 'top'] }, + { method: 'createDirectory', args: ['C:\\Users', 'Alice'] }, ]) // Stubs replace the defaults like every sibling method. const listing = { path: '/x', home: '/x', crumbs: [], entries: [] } diff --git a/packages/host/directory-picker-browse/src/client/DirectoryBrowser.tsx b/packages/host/directory-picker-browse/src/client/DirectoryBrowser.tsx index 7414e08031..f0f153ecc6 100644 --- a/packages/host/directory-picker-browse/src/client/DirectoryBrowser.tsx +++ b/packages/host/directory-picker-browse/src/client/DirectoryBrowser.tsx @@ -37,7 +37,7 @@ import css from './DirectoryBrowser.module.css' /** Owner-supplied browser props: browse calls, pick semantics, and copy. */ export interface DirectoryBrowserProps { - /** Dialog visibility (owner-local; closed unmounts nothing but resets on reopen). */ + /** Dialog visibility (owner-local; closing resets the per-open state, so a reopen starts clean on its first frame). */ open: boolean /** List one directory level (absent path = the Host home directory); the signal aborts a superseded scan on the wire. */ listDirectory: (path?: string, signal?: AbortSignal) => Promise @@ -258,7 +258,7 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen, const [error, setError] = useState(null) // Path-edit state: null = breadcrumb mode; a string = the draft being typed. const [pathDraft, setPathDraft] = useState(null) - // Show-hidden toggle state (pure client-side filter, reset on each open). + // Show-hidden toggle state (pure client-side filter, reset on close). const [showHidden, setShowHidden] = useState(false) // Create-folder state: null = closed; a string = the nested dialog's draft. const [folderDraft, setFolderDraft] = useState(null) @@ -334,9 +334,10 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen, * show-hidden toggle's click to decide whether to reclaim the native * focus outcome. A probe only: it never gates its caller — a torn-down * ref in a landing's close race merely skips the parking, and - * committing the landing into a closing dialog is safe (the component - * already renders null, and the open effect resets parent/selected/child - * on the next open). + * committing the landing into a closing dialog is safe: the close edge's + * supersede() fences every later settlement, and the same close effect + * zeroes parent/selected/child for the one frame that can slip between + * the close render and its effect. * @returns true when `document.activeElement` is inside the miller row. */ const focusInMillerRows = useCallback((): boolean => { @@ -509,7 +510,9 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen, // dialog. The per-open state resets live on the CLOSE edge: resetting on // open would let the reopen's first commit paint one frame of the stale // view (revealed hidden rows, a pressed toggle) before this passive - // effect runs. + // effect runs. No automated gate observes that ordering (act() hides the + // frame in tests) — this comment is the guard; read it before moving + // these back. useEffect(() => { openGeneration.current += 1 if (open) { @@ -522,6 +525,7 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen, setChild(null) setCreatingFolder(false) setShowHidden(false) + setLoading(false) setError(null) setPathDraft(null) setFolderDraft(null) diff --git a/packages/host/directory-picker-browse/src/client/flow.ts b/packages/host/directory-picker-browse/src/client/flow.ts index 878bf7e4db..831eee9476 100644 --- a/packages/host/directory-picker-browse/src/client/flow.ts +++ b/packages/host/directory-picker-browse/src/client/flow.ts @@ -15,11 +15,7 @@ import { DirectoryBrowser } from './DirectoryBrowser.tsx' export interface BrowseFlowInjected { /** List one directory level (absent path = the Host home directory); the signal aborts a superseded scan. */ listDirectory: (path?: string, signal?: AbortSignal) => Promise - /** - * Create one child directory under an existing parent; returns the - * created path in the shape `IWorkspaces.createDirectory` contracts - * (verbatim equal to the child's next `entries[].path`). - */ + /** Create one child directory under an existing parent; returns the created path in the shape `IWorkspaces.createDirectory` contracts. */ createDirectory: (path: string, name: string) => Promise /** Localized dialog copy (this package's namespace). */ t: Translate From b46dcb16cbb570c7ff984cdb292481903f04de09 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Thu, 30 Jul 2026 09:22:16 +0800 Subject: [PATCH 086/113] revert: restore the reviewer-approved tree (97a192d7b) ZiyaZhang approved 97a192d7b; the subsequent bot-review rounds (7-24) landed after that approval and were not requested by a human reviewer. This commit restores the approved tree verbatim as a forward commit (pushed history stays intact). git diff 97a192d7b is empty. --- ...directory-picker-capability-seam.i18n.yaml | 4 +- ...-07-28-directory-picker-capability-seam.md | 7 +- ...-28-directory-picker-capability-seam.zh.md | 7 +- docs/cordis-catalog/services.md | 2 +- .../client/connection/src/client/fixture.ts | 4 - .../runtime/src/client/contract/workspaces.ts | 5 +- .../runtime/src/client/workspaces/service.ts | 3 +- .../client/test-runtime/src/workspaces.ts | 9 +- .../test-runtime/tests/runtime.spec.tsx | 9 - packages/host/apiproxy/src/api/host.ts | 16 +- .../directory-picker-browse/README.i18n.yaml | 4 +- .../host/directory-picker-browse/README.md | 1 - .../host/directory-picker-browse/README.zh.md | 1 - .../src/client/DirectoryBrowser.module.css | 59 +-- .../src/client/DirectoryBrowser.tsx | 371 +++++------------- .../src/client/flow.ts | 2 +- .../host/directory-picker-browse/src/index.ts | 12 +- .../tests/directory-browser.spec.tsx | 309 +-------------- .../tests/home-shape.spec.ts | 43 -- .../tests/service.spec.ts | 25 +- .../host/directory-picker/README.i18n.yaml | 4 +- packages/host/directory-picker/README.md | 2 +- packages/host/directory-picker/README.zh.md | 2 +- packages/host/directory-picker/src/index.ts | 19 +- 24 files changed, 149 insertions(+), 771 deletions(-) delete mode 100644 packages/host/directory-picker-browse/tests/home-shape.spec.ts diff --git a/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.i18n.yaml index bc62880e58..900e1d01b6 100644 --- a/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md -2026-07-28-directory-picker-capability-seam.md: 6768ed8f898765396d6a4549a661674b5336c1f3 -2026-07-28-directory-picker-capability-seam.zh.md: f20ea0af278151cb6f9ebd963185701cc3f3f2fd +2026-07-28-directory-picker-capability-seam.md: ad2aa904beddb2fe941883c3c1827702dbec9964 +2026-07-28-directory-picker-capability-seam.zh.md: 30e719ad9b4e8374496106b447e961a042c7d8b6 diff --git a/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md b/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md index 6768ed8f89..ad2aa904be 100644 --- a/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md +++ b/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md @@ -19,10 +19,9 @@ Placement and policy rulings folded into this decision: - **Not the `ctx.fs` seam.** `packages/fs/` is the model/session-facing storage stack (policy events, sandbox-swappable backends). Riding it would couple GUI browsing to the model's confinement backend — swapping `fs-sandbox` for the model must never change GUI behavior — and OS facts (home anchoring, hidden conventions) are not storage primitives. The picker seam stays presentation-free and model-free; `packages/host/` is its consumer-domain home. - **Dependency survey (hand-roll vs adopt).** Node's stdlib *is* the maintained cross-platform OS layer (`readdir(withFileTypes)`, `homedir`, path semantics); surveyed alternatives fail the dependency bar — file-manager packages (`node-file-manager`, `files-and-folders`, Syncfusion's provider) are whole HTTP apps (fit), drive-letter helpers (`drivelist` native addon, `windows-drive-letters` ~7y stale) fail health/proportionality. The browse backend is a thin adapter over stdlib. - **Hidden entries: return-and-flag.** The host stamps `hidden` (POSIX dot convention) and returns everything; the client filters. Display policy stays client-side, and the show-hidden toggle shipped as exactly that client-only change: a fixed-label footer toggle whose state lives in the pressed presentation (`aria-pressed` + check glyph), a dot-led path-draft prefix reveals the hidden entries it names, and the current selection is exempt from both the hidden and the prefix filter (it anchors the two-pane view). Windows' `FILE_ATTRIBUTE_HIDDEN` is not exposed by dirents — documented limitation until a native probe pays for itself. -- **Path-editor cancel scope: the dialog card.** The browse client's path editor cancels on Escape and on focus leaving the card, both observed at a card-scope wrapper rather than the input — after Tab parks focus on a filtered row the input is off the event path, yet Escape must collapse the editor (not the dialog) and a later focus departure must still cancel. Non-cancel exemptions: window/tab focus loss, in-card focus moves, and pointer paths (rows and the toggle suppress focus steal on mousedown while editing). Separators for seeding and draft-tail filtering are read from the host-resolved root crumb (exact for every root form this backend emits: `/`, `C:\`, `\\server\share\`); the wire-field alternative below records the deferred authoritative form. Combobox semantics between the editor and the list it filters (`aria-expanded`/`aria-controls`/active-descendant, result announcements) are likewise deferred — today they read to assistive tech as separate widgets. Focus parking is a card-wide invariant, not an editor-only one: every pick — editing or not, including right-pane advances and create landings whose columns are replaced — re-parks focus on the selection's row after commit, while every other displacing exit (Enter, Escape, a navigation landing whose new level dropped the focused row, a failed pick or create relist, and the nested create dialog closing) falls back to the crumb edit zone whenever focus actually fell to body, and a show-hidden toggle click that finds focus among the rows parks synchronously on the toggle itself. The guarantee is scoped to the dialog's own node replacements — the Modal has no focus trap, so tabbing past the card's edge legitimately leaves, and the owner's adopt window (where `busy` inerts every control and the dialog is closing either way) is likewise outside it. -- **Navigation lands selection-anchored, progressively.** Away from the display root (the same collapse the crumb header renders, so crumbs and pane shape never disagree), the browse client's navigate commits the target level the moment it arrives — the editor closes and loading ends on that first settlement, so an Enter-submitted navigation is never withdrawn waiting on more — and a parent leg then upgrades the landing in place: the target's actual parent-level entry re-selected (platform case folding on Windows), its children on the right, so a crumb jump reads as stepping back one pane rather than collapsing to a single column. The parent leg runs under the landing's supersession scope and is aborted on the wire by any newer intent; a failed parent leg, or a truncated parent window lacking the target, leaves the committed single-pane landing — the upgrade must never orphan the selection it exists to anchor. Known boundaries of the progressive shape: a pointer press landing exactly inside the one-RTT upgrade window can lose its click when the pressed row node is replaced (keyboard focus is re-parked on the re-selected row; the pointer window is accepted); on slash platforms (macOS) only a final-segment case drift misses the parent-entry match and keeps the single-pane landing, while ancestor-segment drift still matches — parent entry paths inherit the typed prefix — and lands two panes at the cost of the Home collapse; and navigate always relists both legs even when the target is the currently shown level — a crumb tap doubles as the refresh gesture, so freshness wins over reusing possibly-stale in-hand listings at the cost of up to two host scans. +- **Path-editor cancel scope: the dialog card.** The browse client's path editor cancels on Escape and on focus leaving the card, both observed at a card-scope wrapper rather than the input — after Tab parks focus on a filtered row the input is off the event path, yet Escape must collapse the editor (not the dialog) and a later focus departure must still cancel. Non-cancel exemptions: window/tab focus loss, in-card focus moves, and pointer paths (rows and the toggle suppress focus steal on mousedown while editing). Separators for seeding and draft-tail filtering are inferred from `listing.home`; the wire-field alternative below records the deferred authoritative form. Combobox semantics between the editor and the list it filters (`aria-expanded`/`aria-controls`/active-descendant, result announcements) are likewise deferred — today they read to assistive tech as separate widgets. +- **Navigation lands selection-anchored, progressively.** Away from the display root (the same collapse the crumb header renders, so crumbs and pane shape never disagree), the browse client's navigate commits the target level the moment it arrives — the editor closes and loading ends on that first settlement, so an Enter-submitted navigation is never withdrawn waiting on more — and a parent leg then upgrades the landing in place: the target's actual parent-level entry re-selected (platform case folding on Windows), its children on the right, so a crumb jump reads as stepping back one pane rather than collapsing to a single column. The parent leg runs under the landing's supersession scope and is aborted on the wire by any newer intent; a failed parent leg, or a truncated parent window lacking the target, leaves the committed single-pane landing — the upgrade must never orphan the selection it exists to anchor. - **Symlinks: follow for enterability.** `stat` probes symlinks (broken/cyclic → skipped); crumbs keep the logical path the operator navigated, and `workspace.create` already canonicalizes via realpath at adoption. -- **One canonical path shape on the wire.** A listing's `path`, `crumbs[].path`, `entries[].path`, and `home` — and `createDirectory`'s returned path, which clients compare verbatim against the child's next `entries[].path` to anchor the create landing — all ship host-resolved: lexical `resolve()`, never realpath (the symlink ruling above keeps ancestries logical), with homedir() output included since the environment may decorate HOME and the backend resolves it before stamping. Clients compare listing paths verbatim on that promise; the only lexical mirror left in the browse client serves the draft side, the one path a user types and hence naturally non-canonical. Normalizing at the source replaces a client-side mirror of resolve() that had to anticipate every decoration (trailing and repeated separators, dot segments, UNC roots, forward slashes), and the promise binds every browse backend. - **Listing levels are bounded, and streamed.** One `list` call returns at most `maxEntries` rows (config, default 1000 — GitHub's web-UI directory-listing bound). The level streams via `opendir` into a name-sorted window of `maxEntries + 1` candidates, so memory stays O(maxEntries) and enterability probing touches only windowed candidates; the wire `DirectoryListing` carries a required `truncated` flag so the client states incompleteness instead of silently missing tail entries. A windowed broken symlink is not backfilled from beyond the window — the eviction already marks the level truncated. Window insertion is binary with an O(1) full-window tail rejection (an oversized level must not pay a window scan per dirent), and `list(path, signal)` threads the carrier's request signal so a scan of a stalled network directory cannot outlive a disconnected caller — every await in the scan (open, each read, each symlink probe) races the signal, an aborted exit abandons rather than awaits the close (Node queues close behind in-flight reads), and abandoned settlements are swallowed so cleanup can never surface as an unhandled rejection. An unbounded level is a memory/responsiveness hole for large or adversarial directories. - **Whole-filesystem scope, no roots config.** `workspace.create` accepts arbitrary paths and the API serves bash-driving methods, so a browse root would be UX scoping, not a boundary; configurability without a consumer fails the evidence bar. Deferred until a deployment needs it. - **The native backend stays.** Plugin-form was the point: multiple providers can serve the seam (an Electron shell would provide the `native` interaction through its own dialog API). Kind naming: `dialog` was the first pick and was dropped — the browse interaction also presents a dialog (the in-app modal), so the word failed to discriminate; `native` names where the chooser runs. @@ -35,7 +34,7 @@ Placement and policy rulings folded into this decision: - **Adopting a file-manager/drive-enumeration dependency.** Rejected per the survey above; recorded here as the dependency policy requires. - **A flip-label show-hidden toggle ("Hide hidden files").** Rejected: a flipping action label is ambiguous between state and action and doubles the negative; the fixed label with a pressed presentation states both at once. - **Pure relatedTarget blur cancellation (no mousedown suppression).** Rejected: Safari does not focus buttons on pointer down, so a click's focusout carries a null `relatedTarget` and would cancel the editor before the click lands; editing-scoped mousedown suppression plus the card-anchored relatedTarget guard covers pointer and keyboard paths together. -- **A wire `separator` field on `DirectoryListing` (host stamps `path.sep`).** Deferred, not rejected: it is the authoritative form. The root-crumb read the client uses today is exact for every root shape this backend emits, but it still infers a platform fact from path text and promotes "the chain starts at the root" from backend behavior into a client-relied invariant (the `crumbs` JSDoc does promise it), and it degrades to the old home-text heuristic on an empty chain; a wire field would travel verbatim and survive empty chains and future backends. It touches the seam type and every backend, so the browse client's `separatorOf` carries a TODO pointing at this alternative until a wire change is next scheduled. +- **A wire `separator` field on `DirectoryListing` (host stamps `path.sep`).** Deferred, not rejected: it is the authoritative form — a POSIX home directory containing a backslash defeats the `listing.home` heuristic — but it touches the seam type and every backend; the browse client's `separatorOf` carries a TODO pointing at this alternative until a wire change is next scheduled. ## Consequences diff --git a/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.zh.md b/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.zh.md index f20ea0af27..30e719ad9b 100644 --- a/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.zh.md @@ -19,10 +19,9 @@ web GUI 的"打开本地文件夹"流程被焊死在一种交互上:`host.pick - **不用 `ctx.fs` seam。** `packages/fs/` 是面向模型/会话的存储栈(policy 事件、sandbox 可换后端)。骑上去会把 GUI 浏览耦合进模型的限制后端——为模型换 `fs-sandbox` 绝不能改变 GUI 行为——而 OS 事实(home 锚定、隐藏约定)也不是存储原语。picker seam 保持无展示、无模型;`packages/host/` 是它消费方域的家。 - **依赖调研(手写 vs 引入)。** Node 标准库本身就是维护中的跨平台 OS 层(`readdir(withFileTypes)`、`homedir`、路径语义);调研过的替代品都过不了依赖门槛——文件管理器包(`node-file-manager`、`files-and-folders`、Syncfusion 的 provider)是整套 HTTP 应用(契合度不过),盘符工具(原生插件 `drivelist`、约七年未更的 `windows-drive-letters`)健康度/比例失当。browse 后端是标准库上的薄适配。 - **隐藏条目:返回并打标。** 宿主标注 `hidden`(POSIX 点前缀约定)并返回全部条目;客户端过滤。展示策略留在客户端,"显示隐藏"开关正是作为这一纯客户端改动落地:标签固定的 footer 开关,其状态由按下态呈现承载(`aria-pressed` + 勾选符号);以点开头的路径草稿前缀会显出它所指名的隐藏条目;当前选中项则不受隐藏与前缀两种过滤影响(它锚定着双栏视图)。Windows 的 `FILE_ATTRIBUTE_HIDDEN` 不被 dirent 暴露——记为限制,直到原生探测值回其成本。 -- **路径编辑器的取消范围:对话框卡片。** browse 客户端的路径编辑器在按 Escape 与焦点离开卡片时取消,两者都在卡片范围的包装层而非输入框上监听——Tab 把焦点停到某个过滤命中的行之后,输入框已不在事件路径上,但 Escape 仍须收起编辑器(而非对话框),其后的焦点离开也仍须取消。不取消的豁免:窗口/标签页失焦、卡片内焦点移动,以及指针路径(编辑期间行与开关在 mousedown 时抑制焦点夺取)。预填与草稿末段过滤所用的分隔符从宿主解析的根 crumb 读取(对该后端发出的每种根形态都精确:`/`、`C:\`、`\\server\share\`);下文的线上字段替代方案记录了被延期的权威形态。编辑器与其过滤的列表之间的 combobox 语义(`aria-expanded`/`aria-controls`/active-descendant、结果播报)同样被延期——目前二者在辅助技术看来是彼此独立的控件。焦点停靠是卡片全域的不变量,而非编辑器独有:每次选取——无论是否处于编辑态,包括右栏推进与各列被替换的创建落地——提交后都把焦点重新停靠到选中项所在的行上,而其余所有会顶离焦点的退出(Enter、Escape、新层级已不含焦点所在行的导航落地、选取或创建失败后的重新列举,以及嵌套创建对话框的关闭)只要焦点确实落到了 body 上,就回落到 crumb 编辑区,而点击"显示隐藏"开关时若发现焦点落在行间,则把焦点同步停靠到开关自身。该保证的范围仅限对话框自身的节点替换——Modal 没有焦点陷阱,所以 Tab 越过卡片边缘属于正当离开,而 owner 的接纳窗口(其间 `busy` 把每个控件置为惰性,且对话框反正正在关闭)同样在此范围之外。 -- **导航以选中项为锚、渐进落地。** 在展示根之外(与 crumb 头部渲染的是同一塌缩,因此 crumb 与分栏形态永不相左),browse 客户端的导航在目标层级到达的那一刻即提交它——这次首个落定即关闭编辑器并结束加载,因此 Enter 提交的导航绝不会为等待更多内容而被撤回——随后父层级这一程就地升级这次落地:重新选中目标在父层级中的实际条目(Windows 上按平台惯例折叠大小写),右侧展示其子项,因此 crumb 跳转读作后退一栏,而不是塌缩成单列。父层级这一程在落地的 supersession 范围下运行,任何较新的意图都会在线上将其中止;父层级这一程失败,或被截断的父窗口缺少目标时,都保留已提交的单栏落地——升级的存在正是为了锚定选中项,绝不能反而让它悬空。渐进形态的已知边界:指针按压若恰好落在单个 RTT 的升级窗口内,可能因所按行节点被替换而丢失点击(键盘焦点会重新停靠到重新选中的行上;指针的这段窗口则被接受);斜杠平台(macOS)上仅末段的大小写偏差会错过父层级条目匹配,保留单栏落地,而祖先段的偏差仍能匹配——父层级条目路径继承键入的前缀——并落地双栏,代价是 Home 塌缩;且导航总是重新列举两程,哪怕目标就是当前展示的层级——crumb 点按兼作刷新手势,因此宁要新鲜度也不复用手头可能已陈旧的列举,代价是至多两次宿主扫描。 +- **路径编辑器的取消范围:对话框卡片。** browse 客户端的路径编辑器在按 Escape 与焦点离开卡片时取消,两者都在卡片范围的包装层而非输入框上监听——Tab 把焦点停到某个过滤命中的行之后,输入框已不在事件路径上,但 Escape 仍须收起编辑器(而非对话框),其后的焦点离开也仍须取消。不取消的豁免:窗口/标签页失焦、卡片内焦点移动,以及指针路径(编辑期间行与开关在 mousedown 时抑制焦点夺取)。预填与草稿末段过滤所用的分隔符从 `listing.home` 推断;下文的线上字段替代方案记录了被延期的权威形态。编辑器与其过滤的列表之间的 combobox 语义(`aria-expanded`/`aria-controls`/active-descendant、结果播报)同样被延期——目前二者在辅助技术看来是彼此独立的控件。 +- **导航以选中项为锚、渐进落地。** 在展示根之外(与 crumb 头部渲染的是同一塌缩,因此 crumb 与分栏形态永不相左),browse 客户端的导航在目标层级到达的那一刻即提交它——这次首个落定即关闭编辑器并结束加载,因此 Enter 提交的导航绝不会为等待更多内容而被撤回——随后父层级这一程就地升级这次落地:重新选中目标在父层级中的实际条目(Windows 上按平台惯例折叠大小写),右侧展示其子项,因此 crumb 跳转读作后退一栏,而不是塌缩成单列。父层级这一程在落地的 supersession 范围下运行,任何较新的意图都会在线上将其中止;父层级这一程失败,或被截断的父窗口缺少目标时,都保留已提交的单栏落地——升级的存在正是为了锚定选中项,绝不能反而让它悬空。 - **符号链接:为可进入性而跟随。** 用 `stat` 探测符号链接(断链/循环→跳过);面包屑保留操作者导航的逻辑路径,`workspace.create` 在接纳时本就做 realpath 规范化。 -- **线上只有一种规范路径形态。** 列举的 `path`、`crumbs[].path`、`entries[].path` 与 `home`——连同 `createDirectory` 返回的路径,客户端拿它与该子项下一次的 `entries[].path` 逐字比较以锚定创建落地——一律以宿主解析后的形态发出:词法 `resolve()`,绝不做 realpath(上文的符号链接裁决保持祖先链为逻辑路径);homedir() 的输出也不例外,因为环境可能修饰 HOME,后端在标注前先行解析。客户端凭这一承诺逐字比较列举路径;browse 客户端仅剩的词法镜像服务于草稿一侧——用户键入的那一条路径,因而天然非规范。在源头做规范化,取代了客户端侧那份必须预判每种修饰(末尾与重复的分隔符、点段、UNC 根、正斜杠)的 resolve() 镜像,且这一承诺约束每一个 browse 后端。 - **列举层级有上限,且流式处理。** 单次 `list` 至多返回 `maxEntries` 行(配置项,默认 1000——GitHub 网页端目录列举的同一上限)。层级经 `opendir` 流入一个按名排序、容量 `maxEntries + 1` 的候选窗口,内存保持 O(maxEntries),可进入性探测只触及窗口内候选;线上 `DirectoryListing` 携带必填的 `truncated` 标志,让客户端明示不完整而不是静默缺尾。窗口内的断链符号链接不从窗口外回填——发生过驱逐本身已把层级标记为截断。窗口插入为二分查找、满窗尾部单次比较即拒绝(超大层级不能为每个 dirent 付出一次全窗扫描),且 `list(path, signal)` 透传载体的请求信号,滞塞网络目录的扫描不会在调用方断连后继续存活——扫描中的每个 await(打开、每次读取、每次符号链接探测)都与信号赛跑,中止路径放弃而非等待 close(Node 会把 close 排在在飞读取之后),被放弃的 settlement 全部吞掉,清理不会以未处理拒绝的形式冒出。无上限的层级对超大或恶意构造的目录就是内存/响应性漏洞。 - **全盘可浏览,不做 roots 配置。** `workspace.create` 接受任意路径且 API 本就提供驱动 bash 的方法,浏览根只会是 UX 范围而非边界;没有消费方的可配置性过不了证据门槛。等到有部署需要再做。 - **native 后端保留。** 插件化正是目的:多方都能提供该 seam(Electron 壳可以经自己的对话框 API 提供 `native` 交互)。kind 命名:最初选了 `dialog` 后被放弃——browse 交互同样以对话框呈现(应用内弹窗),这个词起不到判别作用;`native` 命名的是选择器运行的位置。 @@ -35,7 +34,7 @@ web GUI 的"打开本地文件夹"流程被焊死在一种交互上:`host.pick - **引入文件管理器/盘符枚举依赖。** 按上文调研否决;依赖政策要求记录于此。 - **动作标签随状态翻转的"显示隐藏"开关("隐藏隐藏文件")。** 否决:会翻转的动作标签在状态与动作之间有歧义,还把否定叠了两层;固定标签加按下态呈现一次说清两者。 - **纯 relatedTarget 失焦取消(不做 mousedown 抑制)。** 否决:Safari 在指针按下时不给按钮聚焦,点击触发的 focusout 因而携带空 `relatedTarget`,会在点击落地前就取消编辑器;编辑期作用的 mousedown 抑制加上锚定卡片的 relatedTarget 守卫才能同时覆盖指针与键盘路径。 -- **在 `DirectoryListing` 上增设线上 `separator` 字段(宿主标注 `path.sep`)。** 延期而非否决:它才是权威形态。客户端今天所用的根 crumb 读取对该后端发出的每种根形态都精确,但它仍是从路径文本推断平台事实,还把"链从根开始"从后端行为提升为客户端所依赖的不变量(`crumbs` 的 JSDoc 确实承诺了这一点),并在链为空时退化回旧的 home 文本启发式;线上字段则会原样随线传输,经得住空链与未来的后端。它触及 seam 类型与每个后端,因此 browse 客户端的 `separatorOf` 挂着指向本方案的 TODO,直到下次安排线上变更。 +- **在 `DirectoryListing` 上增设线上 `separator` 字段(宿主标注 `path.sep`)。** 延期而非否决:它才是权威形态——含反斜杠的 POSIX 家目录会击穿 `listing.home` 启发式——但它触及 seam 类型与每个后端;browse 客户端的 `separatorOf` 挂着指向本方案的 TODO,直到下次安排线上变更。 ## 后果 diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index ea109431ea..e0dd17fa02 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -500,7 +500,7 @@ Abstract directory-picking service. Subclass, implement `capability()`, and load abstract capability(): DirectoryPickerCapability ``` -Source: [`packages/host/directory-picker/src/index.ts:144`](../../packages/host/directory-picker/src/index.ts) +Source: [`packages/host/directory-picker/src/index.ts:131`](../../packages/host/directory-picker/src/index.ts) ## `ctx.fs` — `FileSystem` (abstract seam) diff --git a/packages/client/connection/src/client/fixture.ts b/packages/client/connection/src/client/fixture.ts index 22113e7fdb..be9ba79347 100644 --- a/packages/client/connection/src/client/fixture.ts +++ b/packages/client/connection/src/client/fixture.ts @@ -1016,10 +1016,6 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy { // same tree the browse primitives serve). pickDirectory: request => ok(request, { path: `${FIXTURE_HOME}/Documents/project` }), listDirectory: (request) => { - // The fixture accepts CANONICAL paths only: a decorated input - // (./, //, ..) misses the tree map and reads as unreadable, where - // the real backend resolve()s it first. The keyless lanes drive - // canonical paths, so the divergence stays out of transcripts. const target = request.payload.path ?? FIXTURE_HOME const children = childrenOf(target) if (children === undefined) { diff --git a/packages/client/runtime/src/client/contract/workspaces.ts b/packages/client/runtime/src/client/contract/workspaces.ts index 2b6f5f4d34..9238ea5fd0 100644 --- a/packages/client/runtime/src/client/contract/workspaces.ts +++ b/packages/client/runtime/src/client/contract/workspaces.ts @@ -48,10 +48,7 @@ export interface IWorkspaces { * Create one child directory through the Host's `browse` capability. * @param path - absolute existing parent directory. * @param name - single non-blank path segment. - * @returns the created directory's absolute path, in the shape the wire - * `HostApi.createDirectory` contracts: verbatim equal to the child's - * `entries[].path` in the parent's next listing (the browser anchors a - * create landing's selection on that equality). + * @returns the created directory's absolute path. */ createDirectory(path: string, name: string): Promise /** diff --git a/packages/client/runtime/src/client/workspaces/service.ts b/packages/client/runtime/src/client/workspaces/service.ts index df7f69a19b..1dd3319e79 100644 --- a/packages/client/runtime/src/client/workspaces/service.ts +++ b/packages/client/runtime/src/client/workspaces/service.ts @@ -208,8 +208,7 @@ export class WorkspacesService implements IWorkspaces { * Create one child directory through the Host's `browse` capability. * @param path - absolute existing parent directory. * @param name - single non-blank path segment. - * @returns the created directory's absolute path, in the shape - * `IWorkspaces.createDirectory` contracts. + * @returns the created directory's absolute path. */ async createDirectory(path: string, name: string): Promise { const response = await this.api.host.createDirectory({ path, name }) diff --git a/packages/client/test-runtime/src/workspaces.ts b/packages/client/test-runtime/src/workspaces.ts index 75779f266b..6c1a9d0aad 100644 --- a/packages/client/test-runtime/src/workspaces.ts +++ b/packages/client/test-runtime/src/workspaces.ts @@ -142,18 +142,13 @@ export class TestWorkspaces implements IWorkspaces { * Browse child creation (recorded). The default joins parent and name. * @param path - absolute existing parent directory. * @param name - single path segment. - * @returns the created directory's absolute path, in the shape - * `IWorkspaces.createDirectory` contracts. + * @returns the created directory's absolute path. */ async createDirectory(path: string, name: string): Promise { this.calls.push({ method: 'createDirectory', args: [path, name] }) const stub = this.stubs.get('createDirectory') if (stub !== undefined) return await (stub(path, name) as Promise) - // Join in the parent's own separator flavor (a canonical parent ends - // with one only when it is a bare root), so the contract's verbatim - // equality holds for POSIX and Windows fixture trees alike. - const sep = path.includes('\\') ? '\\' : '/' - return path.endsWith(sep) ? `${path}${name}` : `${path}${sep}${name}` + return `${path}/${name}` } /** diff --git a/packages/client/test-runtime/tests/runtime.spec.tsx b/packages/client/test-runtime/tests/runtime.spec.tsx index dcd4bfaadf..b170f69ba2 100644 --- a/packages/client/test-runtime/tests/runtime.spec.tsx +++ b/packages/client/test-runtime/tests/runtime.spec.tsx @@ -329,21 +329,12 @@ describe('workspaces', () => { await expect(runtime.workspaces.listDirectory()).resolves.toMatchObject({ path: '/home/test', entries: [] }) await expect(runtime.workspaces.listDirectory('/home/test')).resolves.toMatchObject({ path: '/home/test' }) await expect(runtime.workspaces.createDirectory('/home/test', 'fresh')).resolves.toBe('/home/test/fresh') - // Canonical join in the parent's own separator flavor: bare roots do - // not double the separator, Windows parents keep backslashes (the - // IWorkspaces contract's verbatim entries[].path equality). - await expect(runtime.workspaces.createDirectory('/', 'top')).resolves.toBe('/top') - await expect(runtime.workspaces.createDirectory('C:\\', 'top')).resolves.toBe('C:\\top') - await expect(runtime.workspaces.createDirectory('C:\\Users', 'Alice')).resolves.toBe('C:\\Users\\Alice') // The recorded signal seat mirrors the production face (undefined here; // cancellation tests pass and observe a real one). expect(runtime.workspaces.calls).toEqual([ { method: 'listDirectory', args: [undefined, undefined] }, { method: 'listDirectory', args: ['/home/test', undefined] }, { method: 'createDirectory', args: ['/home/test', 'fresh'] }, - { method: 'createDirectory', args: ['/', 'top'] }, - { method: 'createDirectory', args: ['C:\\', 'top'] }, - { method: 'createDirectory', args: ['C:\\Users', 'Alice'] }, ]) // Stubs replace the defaults like every sibling method. const listing = { path: '/x', home: '/x', crumbs: [], entries: [] } diff --git a/packages/host/apiproxy/src/api/host.ts b/packages/host/apiproxy/src/api/host.ts index 19e16bf628..3d0713e523 100644 --- a/packages/host/apiproxy/src/api/host.ts +++ b/packages/host/apiproxy/src/api/host.ts @@ -15,19 +15,11 @@ export interface DirectoryEntry { hidden: boolean } -/** - * host.listDirectory response value: one directory level plus its ancestry. - * Every path in one listing — `path`, `crumbs[].path`, `entries[].path`, - * and `home` — is host-resolved canonical form: no `.`/`..` segments, no - * repeated or trailing separators (bare roots `/`, `C:\`, `\\server\share\` - * excepted), one platform separator. Resolution is lexical (`resolve()`), - * never realpath: a symlinked ancestry keeps the logical path the operator - * navigated. Clients compare paths on this promise without re-normalizing. - */ +/** host.listDirectory response value: one directory level plus its ancestry. */ export interface DirectoryListing { /** Absolute path of the listed directory. */ path: string - /** The host account's home directory (breadcrumb "Home" rooting), in the interface's canonical shape like every other path here. */ + /** The host account's home directory (breadcrumb "Home" rooting). */ home: string /** * Ancestor chain from the filesystem root to the listed directory @@ -83,9 +75,7 @@ export interface HostApi { * Create one child directory under an existing parent (the browser's * "New folder"). Only served under the `browse` capability; an existing * child fails with `directory-exists`, every other filesystem failure with - * `directory-create-failed`. The returned path is in the listing - * contract's canonical shape — verbatim equal to the child's - * `entries[].path` in the parent's next listing. + * `directory-create-failed`. */ createDirectory( request: RpcRequest<{ path: string; name: string }>, diff --git a/packages/host/directory-picker-browse/README.i18n.yaml b/packages/host/directory-picker-browse/README.i18n.yaml index 2ab8b5a667..d807bd737a 100644 --- a/packages/host/directory-picker-browse/README.i18n.yaml +++ b/packages/host/directory-picker-browse/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/host/directory-picker-browse/README.md -README.md: d6ed7181ffbec85d11e0abf2aa8d0053173ba4e9 -README.zh.md: 67f5f2bc40297d96bc3fcd3cfba0a3fd26855adc +README.md: 23153881b84dcb71dfb05d4f297a5818c410ca77 +README.zh.md: d7010e2941a801ba6358082824330eaae46e42b7 diff --git a/packages/host/directory-picker-browse/README.md b/packages/host/directory-picker-browse/README.md index d6ed7181ff..23153881b8 100644 --- a/packages/host/directory-picker-browse/README.md +++ b/packages/host/directory-picker-browse/README.md @@ -19,6 +19,5 @@ None; this package neither assembles nor sends a provider request. ## Known Limitations and Deferred Work - **Windows hidden attribute is not read** — Node dirents do not expose `FILE_ATTRIBUTE_HIDDEN`, so `hidden` means dot-prefixed on every platform until a native probe is worth its cost. -- **Name-normalizing volumes void the create-path equality** — `createDirectory` promises its return verbatim-equal to the child's next `entries[].path`; Node's namespaced Win32 paths store even trailing-dot/space segments literally, but a volume that rewrites names on storage (NFD normalization on HFS+-style volumes) breaks the match, and the create landing degrades to a two-pane view whose left pane lacks the aria-current row while focus falls back to the crumb edit zone. - **No drive-root enumeration** — on Windows the ancestry stops at the drive root; crossing drives waits for the browser UI's path-entry affordance rather than an enumeration primitive here. - **Whole-filesystem scope** — no per-deployment browse-root restriction; `workspace.create` accepts arbitrary paths today, so a root here would be UX scoping, not a boundary — deferred until a deployment needs it. diff --git a/packages/host/directory-picker-browse/README.zh.md b/packages/host/directory-picker-browse/README.zh.md index 67f5f2bc40..d7010e2941 100644 --- a/packages/host/directory-picker-browse/README.zh.md +++ b/packages/host/directory-picker-browse/README.zh.md @@ -19,6 +19,5 @@ ## 已知限制与延期工作 - **不读取 Windows 隐藏属性**——Node 的 dirent 不暴露 `FILE_ATTRIBUTE_HIDDEN`,因此在所有平台上 `hidden` 都意味着点前缀,直到原生探测值回其成本为止。 -- **名称规范化的卷会使创建路径等式失效**——`createDirectory` 承诺其返回值与该子项下一次的 `entries[].path` 逐字相等;Node 带命名空间的 Win32 路径连末尾点/空格段都按字面存储,但在存储时改写名称的卷(HFS+ 风格卷上的 NFD 规范化)会破坏这一匹配,创建落地随之退化为左栏缺少 aria-current 行的双栏视图,同时焦点回落至 crumb 编辑区。 - **不枚举盘符根**——Windows 上祖先链止于盘符根;跨盘依赖浏览器 UI 的路径输入入口,而不是这里的枚举原语。 - **全盘可浏览**——没有按部署限定的浏览根;`workspace.create` 今天就接受任意路径,这里的根只会是 UX 范围而非边界——等到有部署需要时再做。 diff --git a/packages/host/directory-picker-browse/src/client/DirectoryBrowser.module.css b/packages/host/directory-picker-browse/src/client/DirectoryBrowser.module.css index 8bd044f610..85349962e5 100644 --- a/packages/host/directory-picker-browse/src/client/DirectoryBrowser.module.css +++ b/packages/host/directory-picker-browse/src/client/DirectoryBrowser.module.css @@ -52,6 +52,24 @@ /* Deep chains scroll inside the trail (the effect pins the tail into view) * so the edit zone to the right never leaves the bar. */ +/* The Miller columns keep their own row so a status/error line below never + * competes with the fixed column widths for horizontal space. */ +/* A narrow viewport shrinks the dialog below two fixed panes; the row + * scrolls horizontally (the effect pins the child pane into view) so + * descent never hides behind the Modal's clipping. */ +.millerRow { + display: flex; + align-items: stretch; + flex: 1 1 0; + min-height: 0; + /* 12px of row gap on each side of the divider; the left side reads wider + * by the column's trailing 8px scrollbar clearance, which is deliberate — + * the thumb needs that room, the right pane's rows do not. */ + gap: 12px; + overflow-x: auto; + scrollbar-width: none; +} + .crumbTrail { display: flex; align-items: center; @@ -62,12 +80,6 @@ scrollbar-width: none; } -/* Pseudo-element-path engines (see .millerRow's twin rule): the 20px crumb - * bar has no room for a bar at all. */ -.crumbTrail::-webkit-scrollbar { - display: none; -} - .crumbSeat { display: inline-flex; align-items: center; @@ -134,36 +146,11 @@ flex-direction: column; flex: 1 1 0; min-height: 0; - /* Right inset is slimmer than the left: the trailing column's own - * scrollbar clearance (see .column) makes up the optical difference. */ + /* Right inset is slimmer than the left: the trailing column's own 8px + * scrollbar clearance makes up the optical difference. */ padding: 16px 16px 16px 24px; } -/* The Miller columns keep their own row so a status/error line below never - * competes with the fixed column widths for horizontal space. */ -/* A narrow viewport shrinks the dialog below two fixed panes; the row - * scrolls horizontally (the effect pins the child pane into view) so - * descent never hides behind the Modal's clipping. */ -.millerRow { - display: flex; - align-items: stretch; - flex: 1 1 0; - min-height: 0; - /* 12px of row gap on each side of the divider; the left side reads wider - * by the column's trailing scrollbar clearance (see .column) — the thumb - * needs that room, the right pane's rows do not. */ - gap: 12px; - overflow-x: auto; - scrollbar-width: none; -} - -/* Engines that predate scrollbar-width take the pseudo-element path (the - * two are mutually exclusive by construction — see ui-theme's scrollbar - * contract); hide the row's horizontal bar there too. */ -.millerRow::-webkit-scrollbar { - display: none; -} - /* Columns split the row evenly around the divider (a solo column takes the * whole row); 256px is the floor below which the row scrolls (scrollbar * hidden, the effect pins the child pane into view) instead of squeezing @@ -305,12 +292,6 @@ color: var(--dsw-alias-label-primary); } -/* Flex-none: the nowrap label refuses to shrink, which would leave the - * glyph as the only compressible item under wrap or narrow-viewport clamp. */ -.toggleCheck { - flex: none; -} - .footerGap { flex: 1 1 0; } diff --git a/packages/host/directory-picker-browse/src/client/DirectoryBrowser.tsx b/packages/host/directory-picker-browse/src/client/DirectoryBrowser.tsx index f0f153ecc6..859667d115 100644 --- a/packages/host/directory-picker-browse/src/client/DirectoryBrowser.tsx +++ b/packages/host/directory-picker-browse/src/client/DirectoryBrowser.tsx @@ -2,20 +2,16 @@ * The in-app workspace-directory browser (figma Harness 813-23126 family): a * 680×500 dialog (clamped to short/narrow viewports — the Miller row scrolls * sideways, the columns scroll down) whose header carries the title, the selection-path - * breadcrumb, and a click-to-edit path zone; below it a Miller view of one - * or two columns splitting the row evenly (256px floor; level | selected - * folder's children) around a hairline divider — the display root and - * degraded landings keep the single wide level, while any selection opens - * the second pane, including the one a navigation lands with: a crumb jump - * or a submitted path commits the target immediately, then re-selects it - * in its parent level once that level arrives, so stepping back keeps two - * panes away from the display root. Selecting in the + * breadcrumb, and a click-to-edit path zone; below it a Miller view — one + * full-width level until a row is selected, then two columns splitting the + * row evenly (256px floor; level | selected folder's children) around a + * hairline divider. Navigations land selection-anchored: a crumb jump or a + * submitted path commits the target immediately, then re-selects it in its + * parent level once that level arrives, so stepping back keeps two panes + * away from the display root. Selecting in the * right column shifts the view one level deeper. "New folder" opens a nested * create dialog targeting the selected folder (or the level itself) and - * selects the created folder — unless a newer pick or crumb jump supersedes - * the post-create relist, in which case neither the level nor the selection - * refreshes (see closeCreateDialog's two-stage parking for the matching - * focus story). Open adopts the selected folder, falling back + * selects the created folder. Open adopts the selected folder, falling back * to the listed level. Pure consumer of the injected browse calls — the * owning flow decides what "Open" means and owns the workspace-creation * error surface. Hidden entries are host-flagged and hidden by default; the @@ -37,16 +33,11 @@ import css from './DirectoryBrowser.module.css' /** Owner-supplied browser props: browse calls, pick semantics, and copy. */ export interface DirectoryBrowserProps { - /** Dialog visibility (owner-local; closing resets the per-open state, so a reopen starts clean on its first frame). */ + /** Dialog visibility (owner-local; closed unmounts nothing but resets on reopen). */ open: boolean /** List one directory level (absent path = the Host home directory); the signal aborts a superseded scan on the wire. */ listDirectory: (path?: string, signal?: AbortSignal) => Promise - /** - * Create one child directory under an existing parent; the returned path - * is verbatim the child's `entries[].path` in the parent's next listing - * (`IWorkspaces.createDirectory`'s contract) — the create landing anchors - * its selection and focus on that equality. - */ + /** Create one child directory under an existing parent. */ createDirectory: (path: string, name: string) => Promise /** The operator confirmed a directory (the selection, else the listed level). */ onOpen: (path: string) => void @@ -64,126 +55,46 @@ function failureText(error: unknown): string { return error instanceof Error ? error.message : String(error) } -/** - * Case-folds a path for comparisons under the given separator's platform: - * backslash (Windows) paths compare case-insensitively — a typed path - * legally differs in case from the host's stamped one — while slash - * platforms compare exactly (the filesystem may be case-sensitive; only a - * FINAL-segment macOS case drift misses parent-entry matching and keeps - * the single-pane landing, since parent entry paths inherit the typed - * prefix). - */ -function foldPathFor(sep: '\\' | '/'): (value: string) => string { - return value => (sep === '\\' ? value.toLowerCase() : value) -} - -/** - * Folds separators to the platform's canonical one: win32 treats a forward - * slash as a separator too (resolve() folds them the same way), while - * POSIX must not — a backslash there is a name character. - */ -function foldSeparatorsFor(sep: '\\' | '/'): (value: string) => string { - return value => (sep === '\\' ? value.replaceAll('/', sep) : value) -} - -/** - * Lexically normalizes a typed absolute path for comparisons against the - * backend's resolved ones (every listing path arrives in the - * DirectoryListing contract's canonical shape; only the DRAFT side, the - * one path a user types, needs this): collapses repeated and trailing - * separators, drops `.` segments, and applies `..` without ever crossing - * the root — POSIX's `/`, a drive's `C:`, or UNC's `\\server\share` pair — - * mirroring resolve()'s lexical behavior. Expects separators already - * folded to `sep` (foldSeparatorsFor); a lexical mirror only, symlinks are - * the backend's business. - */ -function normalizePathFor(sep: '\\' | '/'): (value: string) => string { - return (value) => { - const unc = sep === '\\' && value.startsWith(`${sep}${sep}`) - const rawSegments = (unc ? value.slice(2) : value).split(sep) - // Empty segments are separator noise everywhere except POSIX's leading - // root marker, which must survive as the first segment; scrubbing them - // up front keeps a doubled separator from being locked into the UNC - // server + share root below. - const segments = unc ? rawSegments.filter(segment => segment !== '') : rawSegments - // The unpoppable root: POSIX's leading empty segment / the drive - // segment, or UNC's server + share pair. - const rootLength = unc ? 2 : 1 - const out = segments.slice(0, rootLength) - for (const segment of segments.slice(rootLength)) { - if (segment === '' || segment === '.') continue - if (segment === '..') { - if (out.length > rootLength) out.pop() - continue - } - out.push(segment) - } - // A bare root keeps (or regains) the trailing separator resolve() - // emits for `/`, `C:\`, and `\\server\share\`. - return `${unc ? sep + sep : ''}${out.join(sep)}${out.length === rootLength ? sep : ''}` - } -} - /** * Breadcrumb rows for display: inside the home subtree the chain starts at a * localized Home crumb; outside it the full ancestry shows, the root labeled - * by its own path. `home` and every crumb path arrive in the same resolved - * shape (the wire contract), so only the platform case fold remains — a - * typed-case Windows chain still collapses to the Home crumb. + * by its own path. */ function displayCrumbs(listing: DirectoryListing, homeLabel: string): DirectoryEntry[] { - const fold = foldPathFor(separatorOf(listing)) - const home = fold(listing.home) - const homeIndex = listing.crumbs.findIndex(crumb => fold(crumb.path) === home) + const homeIndex = listing.crumbs.findIndex(crumb => crumb.path === listing.home) if (homeIndex === -1) return listing.crumbs const tail = listing.crumbs.slice(homeIndex + 1) return [{ name: homeLabel, path: listing.home, hidden: false }, ...tail] } /** - * The listing's platform separator, read from the host-resolved root crumb - * (`/`, `C:\`, `\\server\share\`) — exact for every root form the backend - * emits, and immune to backslashes inside POSIX names (which the home text - * may legally carry; the wire contract already excludes non-canonical - * shapes elsewhere). + * The listing's platform separator, inferred from the home path the host + * stamped — never from typed text or entry paths, where a backslash is a + * legal POSIX name character. Still a heuristic at the last step: a POSIX + * home directory whose own name contains a backslash would misread. * TODO: replace with a host-stamped `separator` field on the wire * DirectoryListing so the platform fact travels verbatim (the trade-off is * recorded in the directory-picker capability seam Agent Note). */ function separatorOf(listing: DirectoryListing): '\\' | '/' { - const rootCrumb = listing.crumbs.at(0) - // The seam type allows an empty chain (this backend never emits one, but - // create-target naming supports it, see targetName): degrade to a - // best-effort read of the home text — the pre-root-crumb heuristic, with - // its backslash-in-a-POSIX-name blind spot. - if (rootCrumb === undefined) return listing.home.includes('\\') ? '\\' : '/' - return rootCrumb.path.includes('\\') ? '\\' : '/' + return listing.home.includes('\\') ? '\\' : '/' } /** - * The path draft's final segment, when its directory part names the level - * `listing` lists — the segment the level prefix-filters on while the user - * types. Any other draft (no separator yet, or naming some other directory) - * leaves the level unfiltered. Only the directory part is lexically - * normalized (dot segments, repeated separators, and win32 forward slashes - * all match what Enter would navigate to) and platform-case-folded (exact - * on slash platforms; Windows folds, since an upgraded selection may carry - * the actual entry's case while the level below still carries the typed - * one); the FINAL segment stays a literal name prefix — a lone `.` reads - * as the dot-reveal, `..` matches no entry (Enter still navigates it) — - * and the name filter downstream is case-insensitive everywhere. + * The path draft's final segment, when its directory part is exactly the + * level `listing` lists — the segment the level prefix-filters on while the + * user types. Any other draft (no separator yet, or naming some other + * directory) leaves the level unfiltered. The directory part compares + * exactly (it is the host's own path text, reached by seeding or erasing); + * only the name filter downstream is case-insensitive. */ function draftPrefixFor(listing: DirectoryListing, draft: string | null): string | null { if (draft === null) return null const sep = separatorOf(listing) - const folded = foldSeparatorsFor(sep)(draft) - const cut = folded.lastIndexOf(sep) + const cut = draft.lastIndexOf(sep) if (cut === -1) return null - const fold = foldPathFor(sep) - const normalize = normalizePathFor(sep) - return fold(normalize(folded.slice(0, cut + 1))) === fold(listing.path) - ? folded.slice(cut + 1) - : null + const level = listing.path.endsWith(sep) ? listing.path : `${listing.path}${sep}` + return draft.slice(0, cut + 1) === level ? draft.slice(cut + 1) : null } /** One column of folder rows (the Miller view renders one or two of these). */ @@ -224,10 +135,10 @@ function LevelColumn({ entries, selectedPath, busy, onPick, showHidden, filterPr // where the blur lands before our guards) drop this click. // Outside editing, rows keep native focus behavior. onMouseDown={pathEditing ? (event) => { event.preventDefault() } : undefined} - // Focus parking happens after commit (the DirectoryBrowser - // refocus effect): a right-pane pick replaces this very - // column, so focusing the clicked node here would still fall - // to body. + // Editing-time focus parking happens after commit (the + // DirectoryBrowser refocus effect): a right-pane pick replaces + // this very column, so focusing the clicked node here would + // still fall to body. onClick={() => { onPick(entry) }} > {selected @@ -258,7 +169,7 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen, const [error, setError] = useState(null) // Path-edit state: null = breadcrumb mode; a string = the draft being typed. const [pathDraft, setPathDraft] = useState(null) - // Show-hidden toggle state (pure client-side filter, reset on close). + // Show-hidden toggle state (pure client-side filter, reset on each open). const [showHidden, setShowHidden] = useState(false) // Create-folder state: null = closed; a string = the nested dialog's draft. const [folderDraft, setFolderDraft] = useState(null) @@ -267,13 +178,8 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen, const requestSeq = useRef(0) // The in-flight listing's controller: superseding intent aborts the wire // request too — the Host stops scanning — instead of only discarding the - // eventual result while the scan keeps consuming host resources. Always - // holds a controller so no consumer needs a null guard: initially a - // placeholder that the first supersede aborts unused (minted lazily — - // useRef evaluates its argument every render), afterwards the latest - // scan's, settled or aborted between scans. - const [initialScanController] = useState(() => new AbortController()) - const scanController = useRef(initialScanController) + // eventual result while the scan keeps consuming host resources. + const scanController = useRef(null) // Bumped on every open/close edge: settlements from a previous open (a // pending creation included) must never mutate a reopened dialog. const openGeneration = useRef(0) @@ -289,7 +195,7 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen, useEffect(() => () => { requestSeq.current += 1 openGeneration.current += 1 - scanController.current.abort() + scanController.current?.abort() }, []) const compositionGuard = { onCompositionStart: () => { composingRef.current = true }, @@ -298,7 +204,8 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen, /** Newer intent wins: invalidate the pending listing's settlement AND abort its wire request. */ const supersede = useCallback((): number => { - scanController.current.abort() + scanController.current?.abort() + scanController.current = null return ++requestSeq.current }, []) @@ -310,54 +217,11 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen, return { seq, scan: listDirectory(path, controller.signal) } }, [supersede, listDirectory]) - // The miller row's scroll host, shared by the pin and refocus effects - // below and read by navigate's upgrade leg (declared ahead of both). - const millerRowRef = useRef(null) - // Focus parking (consumed by the refocus effect below): a pick — and a - // parent-leg upgrade that displaces focused rows — parks on the - // selection's row; every other displacing exit (Enter, Escape, a landing - // whose new level dropped the focused row, a failed pick, and every - // create-dialog exit, whose close-time parking is also what a failed - // relist inherits) parks on the crumb edit zone, each only when focus - // actually fell to body. One parking bypasses both flags: the - // show-hidden toggle's click reclaims focus onto itself, synchronously, - // when the click finds focus among the rows. Pointer-out cancels never - // set (or clear) these — yanking focus back from wherever the user - // clicked would be worse than the fall. - const refocusPick = useRef(false) - const refocusEditZone = useRef(false) - const editZoneRef = useRef(null) - - /** - * Whether the focused element sits among the miller rows — probed before - * a landing replaces the row nodes to decide focus parking, and by the - * show-hidden toggle's click to decide whether to reclaim the native - * focus outcome. A probe only: it never gates its caller — a torn-down - * ref in a landing's close race merely skips the parking, and - * committing the landing into a closing dialog is safe: the close edge's - * supersede() fences every later settlement, and the same close effect - * zeroes parent/selected/child for the one frame that can slip between - * the close render and its effect. - * @returns true when `document.activeElement` is inside the miller row. - */ - const focusInMillerRows = useCallback((): boolean => { - const rowHost = millerRowRef.current - // Only the landing callers can race a close (commit precedes the reset - // effect); the toggle's click caller always finds the host mounted. - /* v8 ignore next -- close-race guard: not deterministically reproducible. */ - if (rowHost === null) return false - return rowHost.contains(document.activeElement) - }, []) - /** * Launch a follow-up listing under the CURRENT supersession seq: a newer * intent aborts it like the leg it continues, and it supersedes nothing. */ const continueScan = useCallback((path: string): Promise => { - // Abort whatever the slot last tracked before overwriting it (the - // caller's settled leg: a no-op) — the slot must never silently strand - // a live scan, the exact waste supersede() exists to prevent. - scanController.current.abort() const controller = new AbortController() scanController.current = controller return listDirectory(path, controller.signal) @@ -372,13 +236,9 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen, * shape never disagree — a parent leg then upgrades the landing in place: * the target's ACTUAL parent-level entry re-selected (left pane = parent, * right pane = the target), so a crumb jump reads as stepping back one - * pane (Windows folds case; on slash platforms only a FINAL-segment case - * drift misses the match and keeps the single-pane landing — parent - * entries inherit the typed prefix, so ancestor-segment drift still - * matches, at the cost of the Home collapse). A failed parent leg, or a - * truncated parent window that lacks the target, leaves the committed - * single-pane landing — the upgrade must never orphan the selection it - * exists to anchor. + * pane. A failed parent leg, or a truncated parent window that lacks the + * target, leaves the committed single-pane landing — the upgrade must + * never orphan the selection it exists to anchor. */ const navigate = useCallback((path?: string) => { const { seq, scan } = launchListing(path) @@ -386,13 +246,6 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen, setError(null) scan.then((target) => { if (seq !== requestSeq.current) return - // The landing replaces every row key; a slow jump leaves the OLD - // rows tabbable meanwhile (parentInert excludes loading), so focus - // may live among them. With no selection yet the edit zone is the - // park target (body-guarded, like every other exit). The probe never - // gates the commit below — stranding the dialog in loading over a - // focus check would be far worse than a skipped parking. - if (focusInMillerRows()) refocusEditZone.current = true setParent(target) setSelected(null) setChild(null) @@ -406,15 +259,11 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen, continueScan(parentCrumb.path).then((parentLevel) => { if (seq !== requestSeq.current) return // Windows resolves a typed path preserving its case; anchor on the - // parent level's actual entry so selection comparisons hold (slash - // platforms compare exactly — see foldPathFor). - const fold = foldPathFor(separatorOf(parentLevel)) + // parent level's actual entry so selection comparisons hold. + const sep = separatorOf(parentLevel) + const fold = (value: string): string => (sep === '\\' ? value.toLowerCase() : value) const match = parentLevel.entries.find(entry => fold(entry.path) === fold(target.path)) if (match === undefined) return - // The upgrade replaces every committed row node; if focus lives - // among them (Tab reached the rows during the parent leg), arm the - // refocus effect so it re-parks on the re-selected row. - if (focusInMillerRows()) refocusPick.current = true setParent(parentLevel) setSelected(match) setChild(target) @@ -428,33 +277,25 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen, setLoading(false) setError(failureText(reason)) }) - }, [launchListing, continueScan, focusInMillerRows]) + }, [launchListing, continueScan]) - /** - * Close the nested create dialog. Its unmount drops focus to body (the - * Modal has no focus trap), so every exit — Escape, mask, Cancel, and a - * successful create — arms the body-guarded edit-zone parking. A - * successful create therefore parks in TWO stages: the edit zone on this - * close, then the relist's select() re-parks on the created row one RTT - * later — deliberately re-parking even focus the user moved during the - * relist window, and doubling as the parking a failed relist inherits. - */ - const closeCreateDialog = useCallback(() => { - setFolderDraft(null) - refocusEditZone.current = true - }, []) + // Editor-close focus parking (consumed by the refocus effect below the + // miller-row ref): a pick parks on the selection's row, Enter and an + // input-focused Escape park on the crumb edit zone that replaces the + // input. Pointer-out cancels never set (or clear) these — yanking focus + // back from wherever the user clicked would be worse than the fall. + const refocusPick = useRef(false) + const refocusEditZone = useRef(false) + const pathInputRef = useRef(null) + const editZoneRef = useRef(null) /** Select a row of the listed level and preview its children on the right. */ const select = useCallback((entry: DirectoryEntry) => { const { seq, scan } = launchListing(entry.path) // A pick while the path editor is open adopts the (filtered) row and - // closes the editor — the draft served its purpose. EVERY pick re-parks - // focus on the selection after commit (see the refocus effect below): - // a left-pane pick lands on the very row that was clicked (a near - // no-op), while a right-pane advance and a create landing replace the - // picked button's column entirely and would otherwise drop focus to - // body. - refocusPick.current = true + // closes the editor — the draft served its purpose. Focus re-parks on + // the selection after commit (see the refocus effect below). + if (pathDraft !== null) refocusPick.current = true setPathDraft(null) setSelected(entry) setChild(null) @@ -476,7 +317,7 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen, // re-parks on the edit zone only if focus actually fell to body. refocusEditZone.current = true }) - }, [launchListing]) + }, [launchListing, pathDraft]) /** Abandon path editing (Escape or clicking away) and restore the crumb view. */ const cancelPathEdit = useCallback(() => { @@ -506,26 +347,19 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen, }, [child, select]) // Every open starts fresh at the Host home directory; closing invalidates - // any in-flight response so a late arrival cannot repopulate a closed - // dialog. The per-open state resets live on the CLOSE edge: resetting on - // open would let the reopen's first commit paint one frame of the stale - // view (revealed hidden rows, a pressed toggle) before this passive - // effect runs. No automated gate observes that ordering (act() hides the - // frame in tests) — this comment is the guard; read it before moving - // these back. + // any in-flight response so a late arrival cannot repopulate a closed dialog. useEffect(() => { openGeneration.current += 1 if (open) { + setParent(null) + setSelected(null) + setChild(null) + setCreatingFolder(false) + setShowHidden(false) navigate() return } supersede() - setParent(null) - setSelected(null) - setChild(null) - setCreatingFolder(false) - setShowHidden(false) - setLoading(false) setError(null) setPathDraft(null) setFolderDraft(null) @@ -557,20 +391,19 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen, // the fresh dialog or issue a relist against the stale target. if (generation !== openGeneration.current) return setCreatingFolder(false) - closeCreateDialog() + setFolderDraft(null) // Land like a right-column pick (figma 802:57446 → 813:23278 flow): the // create target becomes the listed level and the new folder its selection. const { seq, scan } = launchListing(targetPath) setLoading(true) scan.then((level) => { - // The nested dialog closed before this relist launched, so the card - // is interactive meanwhile: a pick or crumb jump supersedes it. + /* v8 ignore next -- same fence as navigate/select; the modal blocks superseding input */ if (seq !== requestSeq.current) return setParent(level) setLoading(false) select({ name, path: createdPath, hidden: false }) }, (reason: unknown) => { - // Same interactive-window fence as the success branch above. + /* v8 ignore next -- same fence as navigate/select; the modal blocks superseding input */ if (seq !== requestSeq.current) return setLoading(false) setError(failureText(reason)) @@ -592,29 +425,19 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen, }, [crumbTail]) // On viewports too narrow for both fixed panes the Miller row scrolls; // whenever a child preview lands, pin it into view the way the crumb tail - // pins — otherwise descent is unreachable on a phone-width window. The - // refocus effect's row.focus() and this pin can fight on such viewports, - // and whichever commit runs later wins by design: on a parent-leg - // upgrade (one commit) focus placement runs after the pin and keeps the - // selected LEFT row in view; on a plain advance or create landing the - // child arrives in a later commit, so the pin runs after the focus and - // descent reachability wins. + // pins — otherwise descent is unreachable on a phone-width window. + const millerRowRef = useRef(null) const childPath = child?.path useEffect(() => { const row = millerRowRef.current if (row !== null && childPath !== undefined) row.scrollLeft = row.scrollWidth }, [childPath]) - // Every pick and editor exit that would drop focus to body re-parks it - // after commit, so THIS DIALOG'S OWN node replacements never leak focus - // out of the card: a pick lands on the selection's row — aria-current in - // the freshly rendered left pane, which survives even a right-pane - // advance or a create landing replacing the picked button's column — - // while the edit-zone exits enumerated at the flag declarations fall - // back to the crumb edit zone. Outside the guarantee: the Modal has no - // focus trap, so tabbing past the card's edge legitimately leaves, and - // the owner's adopt window (busy inerts every control; browsers blur - // disabled elements to body) gets no parking — the owner closes the - // dialog either way. + // Every editor exit that would drop focus to body re-parks it after + // commit, so keyboard traversal stays inside the dialog (the Modal has no + // focus trap): a pick lands on the selection's row — aria-current in the + // freshly rendered left pane, which survives even a right-pane advance + // replacing the picked button's column — while Enter and an input-focused + // Escape land on the crumb edit zone that replaces the input. useEffect(() => { if (pathDraft !== null) return if (refocusPick.current) { @@ -624,14 +447,10 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen, /* v8 ignore next -- narrowing guard: the miller row is mounted whenever a pick just committed. */ if (rowHost === null) return const row = rowHost.querySelector('button[aria-current="true"]') - if (row !== null) { - row.focus() - return - } - // The pick lost its row (a truncated relist after Create can drop - // the created directory outside the window): fall through to the - // edit-zone parking below instead of leaving focus where it fell. - refocusEditZone.current = true + /* v8 ignore next -- narrowing guard: the pick that set the flag just rendered its aria-current row. */ + if (row === null) return + row.focus() + return } if (refocusEditZone.current) { refocusEditZone.current = false @@ -639,9 +458,7 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen, // the user parked elsewhere (a surviving row) stays theirs. if (document.activeElement !== document.body) return const zone = editZoneRef.current - // The effect already returned while a draft is open, and the close - // reset cleared both flags — so crumb mode's zone is always mounted. - /* v8 ignore next -- narrowing guard: crumb mode always renders the edit zone. */ + /* v8 ignore next -- narrowing guard: crumb mode renders the edit zone whenever the editor just closed. */ if (zone === null) return zone.focus() } @@ -685,13 +502,11 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen, // document listener — the same containment the input previously // provided for itself. event.stopPropagation() - // The cancel may unmount whatever holds focus — the input, or a - // dot-revealed row the cleared draft re-hides. Arm the parking - // unconditionally: the refocus effect's body guard already - // distinguishes a surviving focused row (left alone) from focus - // that actually fell. Assignment (not a conditional set) also + // Escape while the input holds focus is about to unmount it; with + // focus already parked on a row, that row survives the cancel and + // keeps focus naturally. Assignment (not a conditional set) also // retires a stale flag a failed or still-upgrading Enter left. - refocusEditZone.current = true + refocusEditZone.current = document.activeElement === pathInputRef.current cancelPathEdit() }} // Focus leaving THIS dialog card while editing cancels like Escape. @@ -775,6 +590,7 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen, value={pathDraft} aria-label={t('browser.editPath')} autoFocus + ref={pathInputRef} disabled={parentInert} onChange={(event) => { // Editing the draft supersedes any in-flight navigation: @@ -863,19 +679,12 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen, // toggling never blur-cancels a draft mid-thought. Outside editing // it keeps native focus behavior. onMouseDown={draftPending ? (event) => { event.preventDefault() } : undefined} - onClick={(event) => { - // The suppression above exists to protect the INPUT's focus; - // with focus among the rows instead, hand back the native - // click outcome wholesale — the clicked toggle takes focus - // and stays in the card. Accepted cost: this also moves - // focus off a row the toggle would NOT have hidden; tracking - // which rows a direction change unmounts is not worth it. - if (focusInMillerRows()) event.currentTarget.focus() - setShowHidden(prev => !prev) - }} + onClick={() => { setShowHidden(prev => !prev) }} > {t('browser.showHidden')} - {showHidden && } + {/* Trailing check (Menu's selected vocabulary): the label never + * shifts when the pressed state toggles. */} + {showHidden && } @@ -893,7 +702,7 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen, {/* Nested create dialog (figma 813:23278): names one folder inside the target. */} { if (!creatingFolder) closeCreateDialog() }} + onClose={() => { if (!creatingFolder) setFolderDraft(null) }} title={t('browser.newFolder')} className={clsx(css.createDialog)} headless @@ -917,13 +726,13 @@ export function DirectoryBrowser({ open, listDirectory, createDirectory, onOpen, } if (event.key === 'Escape') { event.stopPropagation() - if (!creatingFolder) closeCreateDialog() + if (!creatingFolder) setFolderDraft(null) } }} /> {createError !== null &&
{createError}
}
- + + ) : ( + + {leading} + + )} + {title} + {!open && collapsedContent} +
+ {open && children} +
+ ) +} diff --git a/packages/client/ui-conversation/src/client/chat/MessageItem.tsx b/packages/client/ui-conversation/src/client/chat/MessageItem.tsx index a149d37337..aa56d35270 100644 --- a/packages/client/ui-conversation/src/client/chat/MessageItem.tsx +++ b/packages/client/ui-conversation/src/client/chat/MessageItem.tsx @@ -10,6 +10,7 @@ import type { ContextMessageNode, SteeringMessageNode, UnknownSurfaceNode, UserMessageNode, } from '@deepseek-ai/dsh-client-runtime/client' import { JsonBlock, MessageText } from '@deepseek-ai/dsh-client-ui-primitives' +import { ContextInjectionRow } from './ContextInjectionRow.tsx' import { MessageIconActions } from './MessageIconActions.tsx' import css from './MessageItem.module.css' @@ -94,9 +95,7 @@ export const MessageItem = memo(function MessageItem({ node }: MessageItemProps) } case 'context': return ( -
- -
+ ) default: return ( diff --git a/packages/client/ui-conversation/src/client/chat/ToolRow.module.css b/packages/client/ui-conversation/src/client/chat/ToolRow.module.css index 4ff289388e..de5955b9a7 100644 --- a/packages/client/ui-conversation/src/client/chat/ToolRow.module.css +++ b/packages/client/ui-conversation/src/client/chat/ToolRow.module.css @@ -9,10 +9,6 @@ .row { position: relative; /* sweep-glare overlay anchor */ overflow: hidden; - display: flex; - align-items: center; - height: 24px; - min-width: 0; } /* Running sweep (deepsuite ShimmerText pattern): a fixed-width glare band — @@ -41,24 +37,8 @@ 90%, 100% { left: 100%; } } -/* Expand-on-row (Think / code): pointer only — no row fill hover. */ -.row[data-expandable] { - cursor: pointer; -} - .leading { - position: relative; /* .chevronHover overlay anchor */ - flex: none; - width: 16px; - height: 16px; - display: inline-flex; - align-items: center; - justify-content: center; - margin-right: 6px; - padding: 0; - border: none; - background: none; - color: var(--dsw-alias-label-tertiary); + flex-shrink: 0; } /* The others-variant sparkle glyph is one gray step darker than the icon @@ -82,44 +62,8 @@ background: var(--dsw-alias-state-business-primary); } -button.leading { - cursor: pointer; -} - -.chevron { - color: var(--dsw-alias-label-secondary); -} - -/* Hover preview on expandable rows: the idle tool icon crossfades (100ms) - into a down chevron before the row is opened. The chevron overlays the - icon cell absolutely so both can stay mounted for the opacity transition. */ -.iconIdle { - display: inline-flex; - opacity: 1; - transition: opacity 100ms ease; -} - -.chevronHover { - position: absolute; - inset: 0; - margin: auto; - opacity: 0; - transition: opacity 100ms ease; -} - -.row:hover .iconIdle { - opacity: 0; -} - -.row:hover .chevronHover { - opacity: 1; -} - .title { - flex: none; - font-size: 14px; - line-height: 24px; - color: var(--dsw-alias-label-secondary); + font-weight: 400; } .sep { diff --git a/packages/client/ui-conversation/src/client/chat/ToolRow.tsx b/packages/client/ui-conversation/src/client/chat/ToolRow.tsx index d4fae4f6c5..4cf448da7c 100644 --- a/packages/client/ui-conversation/src/client/chat/ToolRow.tsx +++ b/packages/client/ui-conversation/src/client/chat/ToolRow.tsx @@ -8,12 +8,11 @@ // component-local view state. File-tool summaries are path links that open // through the host; the row itself is not a details-panel control. -import { useState, type KeyboardEvent, type MouseEvent, type ReactNode } from 'react' -import clsx from 'clsx' +import { useState, type MouseEvent, type ReactNode } from 'react' import { CodeBlock, StateDot, TerminalBlock } from '@deepseek-ai/dsh-client-ui-primitives' -import { IconChevronDownOutline14 } from '@deepseek-ai/dsh-client-ui-primitives' import { CHAT_TERMINAL_MAX_LINES, type TerminalCardModel } from '../contract/terminal-card-model.ts' import type { ToolRowState, ToolRowVariant } from '../contract/tool-call-model.ts' +import { DisclosureRow } from './DisclosureRow.tsx' import css from './ToolRow.module.css' export interface ToolRowProps { @@ -83,63 +82,28 @@ export function ToolRow({ // this substitution never shows. const text = body ?? '' const open = expanded && expandable - const rowExpands = expandable && expandOnRowClick const toggleExpand = () => { setExpanded(v => !v) } - const toggleFromLeading = (event: MouseEvent) => { - event.stopPropagation() - toggleExpand() - } - const toggleFromKeyboard = (event: KeyboardEvent) => { - if (!rowExpands || (event.key !== 'Enter' && event.key !== ' ')) return - event.preventDefault() - toggleExpand() - } const openFile = (event: MouseEvent) => { event.stopPropagation() if (filePath !== undefined) onOpenFile?.(filePath) } - // Expandable rows preview the toggle on hover: the tool icon yields to a - // down chevron (CSS swap on .row:hover); state dots still take precedence. - const collapsedIcon = expandable - ? ( - <> - {icon} - - - ) - : icon - const leading = open - ? - : leadingFor(state, collapsedIcon) + return (
-
- {expandable && !rowExpands ? ( - - ) : ( - - {leading} - - )} - {title} - {!open && ( + {fileLink ? ( @@ -155,18 +119,18 @@ export function ToolRow({ )} )} -
- {/* The terminal presenter's description belongs ABOVE the card per the - render-intent contract, so an expanded terminal row keeps showing it - even though the collapsed summary is hidden while open. */} - {open && terminalBody?.description !== undefined && ( -
{terminalBody.description}
- )} - {open && (terminalBody !== null - ? - : variant === 'code' - ? - :
{text}
)} + > + {/* The terminal presenter's description belongs above the card per + the render-intent contract. */} + {terminalBody?.description !== undefined && ( +
{terminalBody.description}
+ )} + {terminalBody !== null + ? + : variant === 'code' + ? + :
{text}
} +
) } diff --git a/packages/client/ui-conversation/tests/chat-branch-tails.spec.tsx b/packages/client/ui-conversation/tests/chat-branch-tails.spec.tsx index fc5a94af61..4104fa1da5 100644 --- a/packages/client/ui-conversation/tests/chat-branch-tails.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-branch-tails.spec.tsx @@ -107,11 +107,48 @@ describe('MessageItem arms', () => { expect(view.queryByRole('button', { name: '复制' })).toBeNull() }) - it('context and unknown nodes render their JSON rows', () => { + it('context uses the Tool calls disclosure chrome and keeps its JSON collapsed by default', () => { const ctxView = render( - , + , ) - expect(ctxView.getByText(/上下文注入/)).toBeTruthy() + const disclosure = ctxView.getByRole('button', { name: '上下文注入' }) + expect(disclosure.getAttribute('aria-expanded')).toBe('false') + expect(ctxView.container.querySelector('[data-context-injection-body]')).toBeNull() + expect(ctxView.container.querySelector('svg')).not.toBeNull() + + fireEvent.click(disclosure) + expect(disclosure.getAttribute('aria-expanded')).toBe('true') + expect(ctxView.container.querySelector('[data-context-injection-body]')?.textContent).toBe( + '{ "content": [ { "type": "text", "text": "x\\n\\"y\\":,[{}]" } ], ' + + '"source": { "kind": "plugin", "plugin": "fixture", "empty": {}, "list": [] } }', + ) + + fireEvent.keyDown(disclosure, { key: ' ' }) + expect(disclosure.getAttribute('aria-expanded')).toBe('false') + }) + + it('context preserves the bounded JSON truncation contract', () => { + const view = render( + , + ) + fireEvent.click(view.getByRole('button', { name: '上下文注入' })) + expect(view.container.querySelector('[data-context-injection-body]')?.textContent) + .toMatch(/… 已截断,共 \d+ 字符$/) + }) + + it('unknown nodes retain the generic JSON row', () => { const unknownView = render( , ) From 9c5a5155016c8a79a0c58593a48ed0795c9fb47b Mon Sep 17 00:00:00 2001 From: Turtle Date: Thu, 30 Jul 2026 16:52:14 +0800 Subject: [PATCH 110/113] fix(example): drop the never-activated bash stack from the inheritance fixture dsh-bash-local injects `subprocess`, which this tree never mounted, so the bash chain sat PENDING and the bundle's tool-bash waited with it. The shared boot() all-ACTIVE assertion now surfaces that as a load failure. The scenario probes filesystem confinement only, so the rows are removed and the bundle opts out with `toolBash: false`. The recorded transcript is unchanged because bash never reached the model. --- .../subagent-inheritance.cordis.snapshot.yml | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/examples/headless-agent/subagent-inheritance.cordis.snapshot.yml b/examples/headless-agent/subagent-inheritance.cordis.snapshot.yml index fb614a5372..554fb620fd 100644 --- a/examples/headless-agent/subagent-inheritance.cordis.snapshot.yml +++ b/examples/headless-agent/subagent-inheritance.cordis.snapshot.yml @@ -16,11 +16,6 @@ - id: replay name: '@deepseek-ai/dsh-llm-replay' -- id: bash - name: '@deepseek-ai/dsh-bash-local' - config: - timeoutMs: 60000 - # The confining filesystem stack: the wide deployment default lives on the # shared policy home; the seeded parent's read-only override must beat it # INSIDE the child for the scenario to deny. @@ -39,6 +34,9 @@ - id: tool-fs name: '@deepseek-ai/dsh-tool-fs' +# This scenario probes filesystem confinement only, so the bash stack is absent: +# without it the bundle's `toolBash: false` is required, because `tool-bash` would +# otherwise wait forever for a `bash` executor this tree never mounts. - id: agent name: '@deepseek-ai/dsh-agent-spine-demo' config: @@ -46,6 +44,7 @@ workspaceContext: false skills: enabled: false + toolBash: false toolTasks: false goals: false From d8f4a0efdaf0007a24f191ef92890d8140977bb9 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Thu, 30 Jul 2026 17:17:38 +0800 Subject: [PATCH 111/113] chore(docs): refresh merged generated records --- docs/cordis-catalog/services.md | 4 ++-- packages/host/apiproxy/README.i18n.yaml | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 8e4030c2fe..ee713636ac 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -44,7 +44,7 @@ async resume(ownerCtx: Context, options: ResumeAgentOptions): Promise Date: Thu, 30 Jul 2026 17:17:49 +0800 Subject: [PATCH 112/113] chore(docs): re-record generated artifacts after the master merge --- docs/cordis-catalog/services.md | 4 ++-- packages/host/apiproxy/README.i18n.yaml | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index 8e4030c2fe..ee713636ac 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -44,7 +44,7 @@ async resume(ownerCtx: Context, options: ResumeAgentOptions): Promise Date: Thu, 30 Jul 2026 17:26:44 +0800 Subject: [PATCH 113/113] fix(web): align composer context stack --- ...-30-composer-context-stack-order.i18n.yaml | 6 +++ ...2026-07-30-composer-context-stack-order.md | 33 +++++++++++++++ ...6-07-30-composer-context-stack-order.zh.md | 33 +++++++++++++++ .../2026-07-22-docked-web-goal-bar.i18n.yaml | 4 +- .../feature/2026-07-22-docked-web-goal-bar.md | 8 ++-- .../2026-07-22-docked-web-goal-bar.zh.md | 8 ++-- ...-composer-stats-and-input-polish.i18n.yaml | 4 +- ...-30-web-composer-stats-and-input-polish.md | 10 ++--- ...-web-composer-stats-and-input-polish.zh.md | 10 ++--- .../client/ui-conversation/README.i18n.yaml | 4 +- packages/client/ui-conversation/README.md | 2 +- packages/client/ui-conversation/README.zh.md | 2 +- .../src/client/queue/QueueDock.module.css | 8 ++-- .../src/client/queue/QueueDock.tsx | 4 +- .../skeleton/ConversationRoot.module.css | 11 ++--- .../src/client/skeleton/TodoPanel.module.css | 15 +++---- .../src/client/skeleton/TodoPanel.tsx | 4 +- .../ui-conversation/tests/queue-dock.spec.tsx | 9 +++- .../ui-conversation/tests/todo-panel.spec.tsx | 4 +- packages/client/ui-goal/README.i18n.yaml | 4 +- packages/client/ui-goal/README.md | 2 +- packages/client/ui-goal/README.zh.md | 2 +- .../ui-goal/src/client/GoalBar.module.css | 41 ++++++++----------- packages/client/ui-goal/src/client/index.ts | 2 +- .../ui-goal/tests/browser-plugin.spec.tsx | 2 +- 25 files changed, 152 insertions(+), 80 deletions(-) create mode 100644 .agents/notes/implemented/bug-fix/2026-07-30-composer-context-stack-order.i18n.yaml create mode 100644 .agents/notes/implemented/bug-fix/2026-07-30-composer-context-stack-order.md create mode 100644 .agents/notes/implemented/bug-fix/2026-07-30-composer-context-stack-order.zh.md diff --git a/.agents/notes/implemented/bug-fix/2026-07-30-composer-context-stack-order.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-07-30-composer-context-stack-order.i18n.yaml new file mode 100644 index 0000000000..a6db678efa --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-07-30-composer-context-stack-order.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-07-30-composer-context-stack-order.md +2026-07-30-composer-context-stack-order.md: 9c269bcaf7fa360b6a5d0e16fd8cda48aa285d66 +2026-07-30-composer-context-stack-order.zh.md: 47288141ea4591b29adde0f85e810fc797740488 diff --git a/.agents/notes/implemented/bug-fix/2026-07-30-composer-context-stack-order.md b/.agents/notes/implemented/bug-fix/2026-07-30-composer-context-stack-order.md new file mode 100644 index 0000000000..9c269bcaf7 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-07-30-composer-context-stack-order.md @@ -0,0 +1,33 @@ +# Agent Note: Composer context stack order + +Status: implemented + +English | [中文](2026-07-30-composer-context-stack-order.zh.md) + +## Problem + +Goal, Todo, and Queue contribute independently to the same `conversation.input.dock` list, but their registration order and spacing rules did not encode the composition matrix. The renderer therefore placed Todo before Queue and Goal, while both Queue and Goal carried negative margins intended for the composer boundary. When all three were present, Queue joined to Goal and Goal joined to the composer, reversing the design's hierarchy. + +## Decision + +The composer context stack has one canonical ascending order: Goal at `0`, Todo at `10`, and Queue at `20`, followed by the composer bar outside the list. The gaps leave room for future entries to declare their intended position without relying on plugin activation order. + +`ConversationRoot` owns the 6px space between independent context cards. Goal is a standalone 752×36px card and collapsed Todo is a standalone 752×44px card. Queue is the terminal dock entry: its 776px wrapper contains the same 752px panel column and subtracts the shared gap plus a named 5px layout overlap, so the later composer card paints over only the queue edge. Empty entries render null and consume no gap. + +The order and overlap are separate contracts. Registration order establishes semantic hierarchy; CSS variables on the stack establish shared geometry. Queue does not infer that it may overlap merely from being the last visible entry, because Goal or Todo can be the last visible context card when no queue exists and must remain separated from the composer. + +## Verification + +Registration tests pin all three order values. Browser screenshots cover the full Goal/Todo/Queue matrix, Goal+Todo without Queue, and Queue alone; together they exercise every adjacency: Goal–Todo, Todo–Queue, and Queue–Composer. + +## Alternatives considered + +**Keep independent negative margins on Goal and Queue.** Rejected because the affected neighbor changes with slot order; a local margin cannot express which relationship is allowed unless the semantic order is also fixed. + +**Render each known dock id separately in `ConversationRoot`.** Rejected because it turns an extensible list slot into a hardcoded component inventory and forces the owner to change for every new registrant. + +**Tuck whichever dock entry is last.** Rejected because Goal and Todo are standalone cards. Their absence matrix must not change the surface semantics of whichever card remains. + +## Consequences + +The visual hierarchy is stable for every presence combination, and Queue is the only context surface joined to the composer. New input-dock plugins must choose an order relative to Goal `0`, Todo `10`, and Queue `20`; an entry after Queue also requires an explicit decision about which surface owns the composer boundary. diff --git a/.agents/notes/implemented/bug-fix/2026-07-30-composer-context-stack-order.zh.md b/.agents/notes/implemented/bug-fix/2026-07-30-composer-context-stack-order.zh.md new file mode 100644 index 0000000000..47288141ea --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-07-30-composer-context-stack-order.zh.md @@ -0,0 +1,33 @@ +# Agent Note: Composer 上下文堆栈顺序 + +Status: implemented + +[English](2026-07-30-composer-context-stack-order.md) | 中文 + +## 问题 + +Goal、Todo 与 Queue 独立注册到同一个 `conversation.input.dock` 列表,但各自的注册顺序与间距规则没有编码组合矩阵。因此,渲染器将 Todo 放在 Queue 和 Goal 之前,而 Queue 与 Goal 都带有用于 composer 边界的负外边距。三者同时出现时,Queue 与 Goal 相接,Goal 与 composer 相接,颠倒了设计层级。 + +## 决策 + +composer 上下文堆栈采用唯一规范的升序排列:Goal 为 `0`,Todo 为 `10`,Queue 为 `20`,随后是位于列表外的 composer bar。顺序值之间的空档使未来条目可以声明预期位置,不必依赖插件激活顺序。 + +`ConversationRoot` 负责独立上下文卡片之间的 6px 间距。Goal 是一张独立的 752×36px 卡片,折叠后的 Todo 是一张独立的 752×44px 卡片。Queue 是末端 dock 条目:其 776px 包装层包含相同的 752px 面板列,并减去共享间距与具名的 5px 布局重叠量,因此后渲染的 composer 卡片只覆盖 Queue 边缘。空条目渲染为 null,不占用间距。 + +顺序与重叠是两项独立契约。注册顺序定义语义层级,stack 上的 CSS 变量定义共享几何。系统不能仅因 Queue 是最后一个可见条目,就推断它可以与 composer 重叠,因为没有 Queue 时,Goal 或 Todo 可能成为最后一个可见上下文卡片,而它们必须与 composer 保持间隔。 + +## 验证 + +注册测试固定了三个顺序值。浏览器截图覆盖完整的 Goal/Todo/Queue 组合矩阵、没有 Queue 的 Goal+Todo,以及仅有 Queue 的情况;这些场景共同覆盖全部相邻关系:Goal–Todo、Todo–Queue 与 Queue–Composer。 + +## 考虑过的替代方案 + +**Goal 和 Queue 分别保留独立的负外边距。** 不予采纳,因为受影响的相邻项会随 slot 顺序变化;除非语义顺序也固定,否则局部外边距无法表达允许哪一种关系。 + +**在 `ConversationRoot` 中分别渲染每个已知 dock id。** 不予采纳,因为这会把可扩展的列表 slot 变成硬编码的组件清单,并迫使 owner 在每新增一个注册方时随之修改。 + +**让最后一个 dock 条目贴卡。** 不予采纳,因为 Goal 和 Todo 是独立卡片;Goal 或 Todo 缺席时的组合不得改变剩余卡片的界面语义。 + +## 后果 + +所有存在组合下的视觉层级都保持稳定,Queue 是唯一与 composer 相接的上下文界面。新的 input-dock 插件必须相对于 Goal `0`、Todo `10` 与 Queue `20` 选择顺序;若条目位于 Queue 之后,还必须明确决定由哪个界面负责 composer 边界。 diff --git a/.agents/notes/implemented/feature/2026-07-22-docked-web-goal-bar.i18n.yaml b/.agents/notes/implemented/feature/2026-07-22-docked-web-goal-bar.i18n.yaml index 187c4dfe94..176211d25b 100644 --- a/.agents/notes/implemented/feature/2026-07-22-docked-web-goal-bar.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-22-docked-web-goal-bar.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-22-docked-web-goal-bar.md -2026-07-22-docked-web-goal-bar.md: 110aea299a260896b0098f10b337734e0c0aebcf -2026-07-22-docked-web-goal-bar.zh.md: cc0a5eda6815e6c02e97fd02197659764d4f2d69 +2026-07-22-docked-web-goal-bar.md: f014da61d2fa0bf25121c040dae99354ab15de9d +2026-07-22-docked-web-goal-bar.zh.md: f62c6efbb2d330fb7d5ab74138eb781f1a1bc06c diff --git a/.agents/notes/implemented/feature/2026-07-22-docked-web-goal-bar.md b/.agents/notes/implemented/feature/2026-07-22-docked-web-goal-bar.md index 110aea299a..f014da61d2 100644 --- a/.agents/notes/implemented/feature/2026-07-22-docked-web-goal-bar.md +++ b/.agents/notes/implemented/feature/2026-07-22-docked-web-goal-bar.md @@ -6,11 +6,11 @@ English | [中文](2026-07-22-docked-web-goal-bar.zh.md) ## Problem -The web UI had no goal surface at all: the goal stack shipped with model tools, the TUI/ACP adapters, and the `/goal` command, but the browser client exposed none of it — no runtime verbs, no indicator. This change introduces the client goal verbs (runtime session methods over RPC) and the first goal UI together. Placement follows the redesign's premise that goal presence belongs to the composer's context: the goal is a property of the work the user is about to prompt, so its indicator docks directly above the message composer as a rounded-top strip tucked under the composer card's top edge. The mock keeps only a sparkle, a phase word ("Ongoing/Paused/Blocked Goal"), the truncated objective, and edit/clear icon actions, with resume appearing only on a paused goal. +The web UI had no goal surface at all: the goal stack shipped with model tools, the TUI/ACP adapters, and the `/goal` command, but the browser client exposed none of it — no runtime verbs, no indicator. This change introduces the client goal verbs (runtime session methods over RPC) and the first goal UI together. Placement follows the redesign's premise that goal presence belongs to the composer's context: the goal is a property of the work the user is about to prompt, so its indicator belongs in the composer-context stack; the [composer context stack decision](../bug-fix/2026-07-30-composer-context-stack-order.md) owns its position among Goal, Todo, Queue, and the composer. The mock keeps only a sparkle, a phase word ("Ongoing/Paused/Blocked Goal"), the truncated objective, and edit/clear icon actions, with resume appearing only on a paused goal. ## Decision -`GoalBar` (`packages/client/ui-goal/src/client/GoalBar.tsx`) is a new props-driven, self-contained component; `ConversationRoot` mounts it immediately before the composer `InputBar`. The strip's CSS mirrors the composer's horizontal geometry (32px side padding, 776px centered cap) plus the mock's 12px inset, and a -10px bottom margin eats InputBar's 8px top padding and tucks its square bottom edge 2px under the composer card's top edge. All strip states share one fixed 38px height so switching between them never resizes it. Loading (`goal === undefined`), absent (`goal === null`), and `phase === 'complete'` render nothing — a completed goal is history, not chrome. +`GoalBar` (`packages/client/ui-goal/src/client/GoalBar.tsx`) is a props-driven, self-contained component registered first in the composer's input-dock list. Its standalone 752px card follows the composer's horizontal geometry, and every visible state shares one fixed 36px height so switching phases never resizes it. Loading (`goal === undefined`), absent (`goal === null`), and `phase === 'complete'` render nothing — a completed goal is history, not chrome. Visibility drives the label and actions: active shows "Ongoing Goal" with pause/edit/clear; paused shows "Paused Goal" and swaps pause for a resume icon button; blocked shows "Blocked Goal" and carries `blockedReason.message` as the strip's `title` tooltip. Goal creation lives on the `/goal` command, not in the bar. The pencil swaps the strip for an inline edit form prefilled with the current objective: Enter or the check button saves through `GoalBarActions.onEdit(objective)`, Esc cancels, and an all-whitespace objective keeps save disabled. The form closes only when the edit succeeds; a failure preserves the draft and displays the error in the bar. Resume and clear failures are displayed there as well. Clear otherwise calls `onClear` directly with no confirmation — a clear keeps a durable tombstone, so nothing is unrecoverable. An effect keyed on the goal's id drops the edit form when the goal's identity changes, so a surviving draft can never be written over the goal that replaced it. @@ -26,14 +26,14 @@ The strip's background is `--dsw-alias-interactive-bg-hover` rather than the moc ## Alternatives considered -- **Put the strip in the session header** — rejected because the redesign's premise is that goal presence belongs to the composer's context; a header strip cannot dock into the composer card. +- **Put the strip in the session header** — rejected because the redesign's premise is that goal presence belongs to the composer's context; a header strip separates it from Todo, Queue, and the prompt it qualifies. - **Render a "Loading goal…" placeholder for `undefined`** — rejected: the strip would flash and collapse on every session open, chrome noise for a sub-second state. - **Include an inline create affordance when no goal is set** — rejected after implementation review: goal creation lives on the `/goal` command, matching the pattern where the model creates goals on request; the bar is a status indicator, not a creation surface. - **Carry the full verb set (`onComplete` included) in `GoalBarActions`** — rejected as speculative generality: the interface carries only the rendered verbs (`onPause` joined it when the active strip gained its pause action). ## Consequences -- Goal presence in the web UI is a composer-docked strip: sparkle, phase label, truncated objective, and pause/edit/clear (resume replacing pause when paused) — the browser client's first goal surface. +- Goal presence in the web UI is a standalone composer-context strip: sparkle, phase label, truncated objective, and pause/edit/clear (resume replacing pause when paused) — the browser client's first goal surface. - The runtime session exposes the goal verbs over RPC with folded transport errors, and refreshes the snapshot's goal on open and on live goal-change meta (coalesced, guarded against stale reads). - Objective editing is reachable from the UI for the first time, through `goal.edit` with the runtime-owned ref; complete remains available to other surfaces (`/goal`, model tools). - `goal === null` renders nothing; the composer carries no persistent create affordance — creation is the `/goal` command's job. diff --git a/.agents/notes/implemented/feature/2026-07-22-docked-web-goal-bar.zh.md b/.agents/notes/implemented/feature/2026-07-22-docked-web-goal-bar.zh.md index cc0a5eda68..f62c6efbb2 100644 --- a/.agents/notes/implemented/feature/2026-07-22-docked-web-goal-bar.zh.md +++ b/.agents/notes/implemented/feature/2026-07-22-docked-web-goal-bar.zh.md @@ -6,11 +6,11 @@ Status: implemented ## 问题 -Web UI 此前没有任何目标相关的界面:目标栈已随模型工具、TUI/ACP 适配器和 `/goal` 命令交付,但浏览器客户端完全不接触它——既没有运行时动词,也没有指示器。本变更同时引入客户端目标动词(基于 RPC 的运行时会话方法)和第一个目标 UI。摆放位置遵循重新设计的前提:目标的存在感属于输入框的上下文——目标是用户即将提交的工作的属性,因此它的指示器停靠在消息输入框正上方,呈现为一条圆角顶部的横条,收进输入框卡片顶边之下。设计稿只保留一个闪光图标、一个阶段词("Ongoing/Paused/Blocked Goal")、截断后的目标内容,以及编辑/清除图标操作,恢复按钮仅在目标暂停时出现。 +Web UI 此前没有任何目标相关的界面:目标栈已随模型工具、TUI/ACP 适配器和 `/goal` 命令交付,但浏览器客户端完全不接触它——既没有运行时动词,也没有指示器。本变更同时引入客户端目标动词(基于 RPC 的运行时会话方法)和第一个目标 UI。摆放位置遵循重新设计的前提:目标的存在感属于输入框的上下文——目标是用户即将提交的工作的属性,因此它的指示器属于 composer 上下文堆栈;[composer 上下文堆栈决策](../bug-fix/2026-07-30-composer-context-stack-order.md) 规定它在 Goal、Todo、Queue 与 composer 之间的位置。设计稿只保留一个闪光图标、一个阶段词("Ongoing/Paused/Blocked Goal")、截断后的目标内容,以及编辑/清除图标操作,恢复按钮仅在目标暂停时出现。 ## 决策 -`GoalBar`(`packages/client/ui-goal/src/client/GoalBar.tsx`)是一个新的、由 props 驱动的自包含组件;`ConversationRoot` 将它挂载在输入框 `InputBar` 紧上方。横条的 CSS 对齐输入框的水平几何(两侧 32px 内边距、776px 居中上限),再加上设计稿的 12px 内缩,并用 -10px 的下外边距吃掉 InputBar 的 8px 上内边距,使它方形的底边收进输入框卡片顶边之下 2px。横条的所有状态共享固定的 38px 高度,状态切换不会引起尺寸变化。加载中(`goal === undefined`)、无目标(`goal === null`)和 `phase === 'complete'` 时不渲染任何内容:已完成的目标是历史记录,不是常驻界面元素。 +`GoalBar`(`packages/client/ui-goal/src/client/GoalBar.tsx`)是一个由 props 驱动的自包含组件,在 composer 的 input-dock 列表中注册为第一个条目。它采用独立的 752px 卡片,遵循 composer 的水平几何;所有可见状态均使用固定的 36px 高度,切换阶段不会改变尺寸。加载中(`goal === undefined`)、无目标(`goal === null`)和 `phase === 'complete'` 时不渲染任何内容:已完成的目标是历史记录,不是常驻界面元素。 可见性决定标签和操作:active 状态显示 "Ongoing Goal" 并提供暂停/编辑/清除;paused 状态显示 "Paused Goal",把暂停换成一个恢复图标按钮;blocked 状态显示 "Blocked Goal",并把 `blockedReason.message` 作为横条的 `title` 悬浮提示。创建目标的入口在 `/goal` 命令上,不在横条里。铅笔图标把横条切换为内联编辑表单,预填当前目标内容:Enter 或勾选按钮通过 `GoalBarActions.onEdit(objective)` 保存,Esc 取消,目标内容全为空白字符时保存按钮保持禁用。编辑成功后表单才会关闭;编辑失败时保留草稿,并在横条中显示错误。恢复和清除失败也显示在横条中。除此之外,清除直接调用 `onClear`,不做确认——清除会保留 durable 墓碑,没有不可恢复的损失。一个以目标 id 为键的 effect 会在目标身份变化时丢弃编辑表单,因此存留的草稿绝不可能覆盖掉替换它的新目标。 @@ -26,14 +26,14 @@ Web UI 此前没有任何目标相关的界面:目标栈已随模型工具、T ## 考虑过的替代方案 -- **把横条放在会话头部**:不予采纳,因为重新设计的前提是目标的存在感属于输入框的上下文;放在头部的横条无法停靠进输入框卡片。 +- **把横条放在会话头部**:不予采纳,因为重新设计的前提是目标的存在感属于输入框的上下文;头部横条会使目标与 Todo、Queue 及其限定的提示词彼此分离。 - **为 `undefined` 渲染 "Loading goal…" 占位**:不予采纳,每次打开会话横条都会闪现再坍缩,对一个不到一秒的状态来说只是界面噪音。 - **未设置目标时在横条内提供内联创建入口**:实现评审后不予采纳,创建目标的职责在 `/goal` 命令上,与模型按请求创建目标的模式一致;横条是状态指示器,不是创建入口。 - **在 `GoalBarActions` 中携带完整动词集合(含 `onComplete`)**:作为投机性泛化不予采纳,接口只携带实际渲染的动词(active 横条获得暂停操作后,`onPause` 随之加入)。 ## 后果 -- Web UI 中目标的存在形式是停靠在输入框上方的横条:闪光图标、阶段标签、截断的目标内容,以及暂停/编辑/清除(暂停时恢复取代暂停)——这是浏览器客户端的第一个目标界面。 +- Web UI 中目标的存在形式是独立的 composer 上下文横条:闪光图标、阶段标签、截断的目标内容,以及暂停/编辑/清除(暂停时恢复取代暂停)——这是浏览器客户端的第一个目标界面。 - 运行时会话通过 RPC 暴露目标动词并折叠传输层错误,且在打开时和 live 目标变更元数据到达时刷新快照中的目标(合并拉取,带陈旧读取守卫)。 - 目标内容首次可以从 UI 编辑,经由 `goal.edit`,ref 由运行时持有;完成对其他界面(`/goal`、模型工具)照常可用。 - `goal === null` 时不渲染任何内容;输入框不提供常驻的创建入口,创建是 `/goal` 命令的职责。 diff --git a/.agents/notes/implemented/feature/2026-07-30-web-composer-stats-and-input-polish.i18n.yaml b/.agents/notes/implemented/feature/2026-07-30-web-composer-stats-and-input-polish.i18n.yaml index c82bd65908..653b7c0554 100644 --- a/.agents/notes/implemented/feature/2026-07-30-web-composer-stats-and-input-polish.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-30-web-composer-stats-and-input-polish.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-30-web-composer-stats-and-input-polish.md -2026-07-30-web-composer-stats-and-input-polish.md: 0d90b8c1d2e283f2bcca7d9e82ac461d9fa4eb7e -2026-07-30-web-composer-stats-and-input-polish.zh.md: db47250852724e62337948aa516effb42f19066c +2026-07-30-web-composer-stats-and-input-polish.md: 78f286cb0edf58d0212492024b8706ffd432ee70 +2026-07-30-web-composer-stats-and-input-polish.zh.md: eeba56d9f3c2ef10222e32b0809e099f60181ac8 diff --git a/.agents/notes/implemented/feature/2026-07-30-web-composer-stats-and-input-polish.md b/.agents/notes/implemented/feature/2026-07-30-web-composer-stats-and-input-polish.md index 0d90b8c1d2..78f286cb0e 100644 --- a/.agents/notes/implemented/feature/2026-07-30-web-composer-stats-and-input-polish.md +++ b/.agents/notes/implemented/feature/2026-07-30-web-composer-stats-and-input-polish.md @@ -10,19 +10,19 @@ The web composer footer showed a single joined stats string (cache/tokens/turns/ ## Decision -**The stats line renders inside the InputBar's width column through a new `footer` owner prop and expands to the design's grouped detail row; the composer stack owns one 8px rhythm; the seat fades the transcript through a fixed 36px token-bound gradient; the back-to-bottom control follows a live `--dsh-composer-height`; goal and todo share one 752px tip-fill column.** +**The stats line renders inside the InputBar's width column through a new `footer` owner prop and expands to the design's grouped detail row; the composer stack owns one 6px rhythm; the seat fades the transcript through a fixed 36px token-bound gradient; the back-to-bottom control follows a live `--dsh-composer-height`; goal, todo, and queue share one 752px tip-fill column.** - `'conversation.composer.dock'` entries reach the page as the `ComposerBarOwnerProps.footer` slot, rendered under the card inside the bar's `.root`, so the stats line and the card share one width constraint. `StatsLine` derives everything client-side from the snapshot: turns/steps, LLM wall time from assistant `timing` (`completedTime - stepStartTime`), tool wall time from tool-result `time - callTime` pairs, prompt/output token split with cache-read folded into input, and cache-hit percentage. Groups render pipe-separated and drop out whole when empty; `formatTokens` (517 / 12.2K / 1.2M) and `formatDuration` (45.2s / 2m42s) are exported for tests. Durations cover only in-window nodes — the README owns that limitation. -- `.composerStack` carries `gap: 8px` and entries carry no outer margins (QueueDock's margin removed), so a dock entry that renders null costs nothing. GoalBar is the one deliberate exception: `margin: 0 auto -10px` cancels the gap and tucks its square bottom edge 2px under the card. +- `.composerStack` carries the Figma composition matrix's 6px gap. Goal and Todo remain standalone cards; the terminal Queue entry subtracts that gap plus the named 5px layout overlap so the later composer card paints over only the queue edge. The [composer context stack decision](../bug-fix/2026-07-30-composer-context-stack-order.md) owns the order and overlap contract. - The sticky seat's background is a `linear-gradient` from `color-mix(bg-base 0%, transparent)` at 0px to solid `bg-base` at 36px — pixel stops, not the figma export's percentage, so a growing draft widens only the solid region; `color-mix` keeps both themes fading from their own base. - A `useCallback` ref on the seat attaches a ResizeObserver that publishes `--dsh-composer-height` on the scroll body; ChatView's back-to-bottom slot computes `bottom` from it (152px first-paint fallback) instead of the prior hardcoded 168px. -- The textarea's 52px two-line floor applies to the hero variant only; the docked composer collapses to content height. Goal and todo strips both use the 44px-gutter / 752px-cap column with the todo `tip` fill and l1 border; the todo header is compacted (13/20 type, 8+8 padding) so its collapsed height equals the goal strip's 38px. +- The textarea's 52px two-line floor applies to the hero variant only; the docked composer collapses to content height. Goal, Todo, and Queue panels use the 44px-gutter / 752px-cap column with `tip` fill and l1 border; the standalone Goal and collapsed Todo cards are 36px and 44px tall. ## Alternatives considered **Percentage gradient stops (the figma export's 24%).** Rejected: the stop scales with seat height, so a tall draft stretches the fade band over most of the transcript; the fixed 36px band equals the design's 24% at the resting ~150px composer and stays constant as the composer grows. -**A skeleton-owned dock column with a generic "bottommost entry tucks" contract.** Built and backed out in review: a `.inputDock` wrapper owning width/rhythm plus `--dsh-dock-tuck-*` vars on `:last-child` would retarget the tuck automatically on reorder, but it rewrote every entry and the GoalBar DOM ahead of a pending merge. Per-entry CSS with GoalBar owning its own tuck was chosen; the generic column remains available if dock entries multiply. +**A generic "bottommost entry tucks" contract.** Rejected because Goal and Todo are independent cards even when either is the last visible dock entry. Queue owns the one intentional composer overlap, while the stack owns its shared gap and overlap values. **Backend-supplied duration fields for the stats line.** Unnecessary: assistant `timing` and tool call/result pairs already reach the snapshot, so wall times fold client-side with no new session event or host projection. @@ -30,4 +30,4 @@ The web composer footer showed a single joined stats string (cache/tokens/turns/ ## Consequences -The stats row now reads turns/steps, LLM and tool durations, cache hit, and input/output tokens at a glance, at the cost that durations cover only the loaded event window (README Known Limitation). The one-gap stack rhythm makes dock spacing composition-independent, but GoalBar's tuck is positional: it must stay the bottommost dock entry (`order: 1`) or its negative margin tucks it under the wrong neighbor. The fade band is a constant 36px, so any future design retune is one stop value. `chat-stats-bash-sample.spec.tsx` pins the derivation (timing/tool folds, token split), both formatters, the grouped render, and the zero-renders-during-streaming acceptance. +The stats row reads turns/steps, LLM and tool durations, cache hit, and input/output tokens at a glance, at the cost that durations cover only the loaded event window (README Known Limitation). The stack's fixed order keeps standalone context cards independent and makes Queue the only panel joined to the composer; a future dock entry must choose its order relative to those roles. The fade band is a constant 36px, so any future design retune is one stop value. `chat-stats-bash-sample.spec.tsx` pins the derivation (timing/tool folds, token split), both formatters, the grouped render, and the zero-renders-during-streaming acceptance. diff --git a/.agents/notes/implemented/feature/2026-07-30-web-composer-stats-and-input-polish.zh.md b/.agents/notes/implemented/feature/2026-07-30-web-composer-stats-and-input-polish.zh.md index db47250852..eeba56d9f3 100644 --- a/.agents/notes/implemented/feature/2026-07-30-web-composer-stats-and-input-polish.zh.md +++ b/.agents/notes/implemented/feature/2026-07-30-web-composer-stats-and-input-polish.zh.md @@ -10,19 +10,19 @@ Web 编辑器页脚原本以独立 stack 行显示一条拼接的统计字符串 ## Decision -**统计行经由新的 `footer` owner prop 渲染进 InputBar 的宽度列内,并扩展为设计稿的分组细节行;composer stack 拥有唯一的 8px 节奏;座位以固定 36px 的 token 绑定渐变淡出消息流;「回到底部」控件跟随实时的 `--dsh-composer-height`;goal 与 todo 共用一条 752px 的 tip 填充列。** +**统计行经由新的 `footer` owner prop 渲染进 InputBar 的宽度列内,并扩展为设计稿的分组细节行;composer stack 拥有唯一的 6px 节奏;座位以固定 36px 的 token 绑定渐变淡出消息流;「回到底部」控件跟随实时的 `--dsh-composer-height`;goal、todo 与 queue 共用一条 752px 的 tip 填充列。** - `'conversation.composer.dock'` 条目以 `ComposerBarOwnerProps.footer` 席位到达页面,渲染在卡片下方、bar 的 `.root` 之内,统计行与卡片因此共享同一宽度约束。`StatsLine` 全部在客户端从快照推导:turns/steps、由 assistant `timing`(`completedTime - stepStartTime`)折算的 LLM 墙钟时间、由 tool-result 的 `time - callTime` 配对折算的工具墙钟时间、把 cache-read 并入输入侧的提示/输出 token 拆分,以及缓存命中率。各组以竖线分隔、无数据时整组消失;`formatTokens`(517 / 12.2K / 1.2M)与 `formatDuration`(45.2s / 2m42s)导出供测试。耗时只覆盖窗口内节点——该限制由 README 记录。 -- `.composerStack` 携带 `gap: 8px`,条目不带外边距(QueueDock 的 margin 已删除),渲染为 null 的 dock 条目零成本。GoalBar 是唯一的刻意例外:`margin: 0 auto -10px` 抵消 gap,把方形下缘塞进卡片下方 2px。 +- `.composerStack` 采用 Figma 组合矩阵中的 6px 间距。Goal 与 Todo 保持为独立卡片;末端的 Queue 条目减去这段间距及具名的 5px 布局重叠量,使后渲染的 composer 卡片只覆盖 Queue 边缘。[composer 上下文堆栈决策](../bug-fix/2026-07-30-composer-context-stack-order.md) 规定顺序与重叠契约。 - sticky 座位的背景是从 0px 处的 `color-mix(bg-base 0%, transparent)` 到 36px 处纯色 `bg-base` 的 `linear-gradient`——像素节点而非 figma 导出的百分比,草稿长高只扩大纯色区域;`color-mix` 让两个主题都从各自的底色淡出。 - 座位上的 `useCallback` ref 挂 ResizeObserver,把 `--dsh-composer-height` 发布到滚动体上;ChatView 的回到底部席位据此计算 `bottom`(首帧回退 152px),替换先前硬编码的 168px。 -- textarea 的 52px 两行下限只保留在 hero 变体;停靠态编辑器折叠到内容高度。goal 与 todo 条统一使用 44px 边距/752px 上限的列、todo 的 `tip` 填充与 l1 边框;todo 表头紧凑化(13/20 字号、8+8 内边距),折叠高度与 goal 条的 38px 对齐。 +- textarea 的 52px 两行下限只保留在 hero 变体;停靠态编辑器折叠到内容高度。Goal、Todo 与 Queue 面板统一使用 44px 边距/752px 上限的列,并采用 `tip` 填充和 l1 边框;独立的 Goal 卡片与折叠后的 Todo 卡片高度分别为 36px 和 44px。 ## Alternatives considered **百分比渐变节点(figma 导出的 24%)。** 否决:节点随座位高度缩放,长草稿会把过渡带拉伸到消息流的大半;固定 36px 过渡带等于设计稿在静息 ~150px 编辑器下的 24%,且随编辑器长高保持恒定。 -**骨架拥有的 dock 列加通用「最底条目贴卡」契约。** 实现后在评审中撤回:由 `.inputDock` 包装层拥有宽度/节奏、在 `:last-child` 上发布 `--dsh-dock-tuck-*` 变量,重排时贴卡会自动换人,但它在一次待合并前重写了每个条目和 GoalBar 的 DOM。最终选择逐条目 CSS、GoalBar 自持贴卡;dock 条目增多时通用列方案仍然可用。 +**通用「最底条目贴卡」契约。** 不予采纳,因为 Goal 和 Todo 即使成为最后一个可见 dock 条目,也仍是独立卡片。Queue 拥有唯一一处有意的 composer 重叠,而 stack 拥有共享的间距和重叠量。 **由后端为统计行提供耗时字段。** 不必要:assistant `timing` 与工具 call/result 配对已经到达快照,墙钟时间可在客户端折算,无需新的会话事件或 host 投影。 @@ -30,4 +30,4 @@ Web 编辑器页脚原本以独立 stack 行显示一条拼接的统计字符串 ## Consequences -统计行现在一眼可读 turns/steps、LLM 与工具耗时、缓存命中和输入/输出 token,代价是耗时只覆盖已加载事件窗口(README 已知限制)。单 gap 的 stack 节奏使 dock 间距与组合无关,但 GoalBar 的贴卡是位置性的:它必须保持为最底的 dock 条目(`order: 1`),否则其负边距会塞到错误的邻居下面。过渡带恒为 36px,未来设计调整只改一个节点值。`chat-stats-bash-sample.spec.tsx` 钉住推导(timing/工具折算、token 拆分)、两个格式化器、分组渲染,以及流式期间零重渲染的验收。 +统计行现在一眼可读 turns/steps、LLM 与工具耗时、缓存命中和输入/输出 token,代价是耗时只覆盖已加载事件窗口(README 已知限制)。stack 的固定顺序使独立的上下文卡片彼此分离,并让 Queue 成为唯一与 composer 相接的面板;未来新增 dock 条目时,必须选择自己相对于这些角色的顺序。过渡带恒为 36px,未来设计调整只改一个节点值。`chat-stats-bash-sample.spec.tsx` 钉住推导(timing/工具折算、token 拆分)、两个格式化器、分组渲染,以及流式期间零重渲染的验收。 diff --git a/packages/client/ui-conversation/README.i18n.yaml b/packages/client/ui-conversation/README.i18n.yaml index 654722b589..7086e96ef1 100644 --- a/packages/client/ui-conversation/README.i18n.yaml +++ b/packages/client/ui-conversation/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-conversation/README.md -README.md: 3973c14f2b8fe746549bb74af85a7a60a7d66aea -README.zh.md: a6bb15c4cdd53d05bf28147b97d9d64d1c59da2b +README.md: b45139ae4f9c5f89927c1ba61c140bc7e0c12e78 +README.zh.md: ef989e716d1d3c860f101e27757b30a7b3d2d44c diff --git a/packages/client/ui-conversation/README.md b/packages/client/ui-conversation/README.md index 3973c14f2b..b45139ae4f 100644 --- a/packages/client/ui-conversation/README.md +++ b/packages/client/ui-conversation/README.md @@ -16,7 +16,7 @@ A tool call declaring the `terminal` render intent renders its command output in Tool rows are slots too — the standalone tool ring (`ToolViewRegistry`/`ctx.toolviews`/outlet) is retired. The chat entry declares the keyed `'conversation.chat.toolview'` hole (session scope; the key space is runtime-open); its render site dispatches per row via `entryKey: toolName` with `GenericToolCard` as the call-site `fallback`. The owner payload is the uniform `ToolRowOwnerProps` (`callId`/`toolName`/`block`/`openFile`) and `ToolRowProps` pre-composes it with the session standard kit. A registrant is a plain plugin: `ctx.slots.register({ name: 'conversation.chat.toolview', key: '', inject? }, Row)` with `inject: ['slots', 'conversation']` as the load-order seam (apply mounts ConversationService after the chat registration, so the service being present guarantees the slot is declared); session differentiation happens inside the component (`useSessions` reading `parentId` — the bash sample is the third-party-posture exemplar). Trajectory/waterfall toolview slots share this shape and land with their own render sites (RendersCheck rejects a declaration nobody renders). -The todo surfaces are two registrations over that shape, both plain registrant plugins with `inject: ['slots', 'conversation']`. `TodoRow` takes the `'conversation.chat.toolview'` key `todo_write` and summarizes what the call attempted (`/ 已完成 · ` parsed from its args, falling back to the generic summary on malformed or wrongly-shaped model JSON, and keeping the generic dot for non-ok execution states so a cancelled call never reads as a completed update). `TodoDock` takes the `'conversation.input.dock'` list slot at `order: -1` — above the queue rows — and is the plan strip: it reads the host-computed `todos` projection via `useProjection` (standing plan: latest `todo/write` with no later `turn/start`) and renders `TodoPanel`, which takes the plain list, hides itself while the list is empty, and starts collapsed as a header of title plus `"/ tasks · in progress"` (status glyphs are the figma check / progress / dashed-pending set). The dock adapter owns the selection so the panel stays a pure function of its props; the standing list lives here rather than in the row so the row stays one line. Anything the input-zone composer chain hides (a `conversation.composer` takeover such as ui-question's) hides the whole dock, this strip included. +The todo surfaces are two registrations over that shape, both plain registrant plugins with `inject: ['slots', 'conversation']`. `TodoRow` takes the `'conversation.chat.toolview'` key `todo_write` and summarizes what the call attempted (`/ 已完成 · ` parsed from its args, falling back to the generic summary on malformed or wrongly-shaped model JSON, and keeping the generic dot for non-ok execution states so a cancelled call never reads as a completed update). `TodoDock` takes the `'conversation.input.dock'` list slot at `order: 10` — between Goal and Queue — and is the plan strip: it reads the host-computed `todos` projection via `useProjection` (standing plan: latest `todo/write` with no later `turn/start`) and renders `TodoPanel`, which takes the plain list, hides itself while the list is empty, and starts collapsed as a header of title plus `"/ tasks · in progress"` (status glyphs are the figma check / progress / dashed-pending set). The dock adapter owns the selection so the panel stays a pure function of its props; the standing list lives here rather than in the row so the row stays one line. Anything the input-zone composer chain hides (a `conversation.composer` takeover such as ui-question's) hides the whole dock, this strip included. Per-session UI state for selection and the active view lives in the declared chat store (`stores.ts` `createChatStore`); the InputHub owns the composer state machine and mirrors its draft into that store for persistence. Apply passes one store handle to the strict session subtree, chat view, and details registrations, so each session shares one instance and the framework owns its lifecycle. Components are pure: the framework standard kit supplies `useSession`/`sessionId`, global `useSessions`/`useWorkspaces`, and the input machine's `useInput`/`inputActions`; store faces and inject factories supply the remaining state and callbacks. diff --git a/packages/client/ui-conversation/README.zh.md b/packages/client/ui-conversation/README.zh.md index a6bb15c4cd..ef989e716d 100644 --- a/packages/client/ui-conversation/README.zh.md +++ b/packages/client/ui-conversation/README.zh.md @@ -16,7 +16,7 @@ 审批经由本包声明的链接管编辑器:`ApprovalPanel` 注册为按选择器路由的 `'conversation.composer'` 配置项(ui-question 模式),在审批等待未决期间取代 InputBar 占据编辑器(琥珀色条、理由标题、来自运行中调用参数的配对命令行、一次性的拒绝/允许)。`contract/slots.ts` 中的 `PendingApproval` 领域面在运行时 `PendingWait` 载体之上拥有 wire 编码——带审计关联的 `ApprovalResponsePayload` 值;广播的 `approval/resolved` 帧使等待落定并恢复编辑器。侧边栏通过 manager 跟踪的 `waitingApproval` 列表位(未实例化会话同样点亮)镜像该阻塞状态,其优先级高于运行中圆环,直至问题解决。未决等待完全离开消息流:问题(ui-question)与审批(ApprovalPanel)都经编辑器接管作答,不再保留只读占位卡。编辑器底行的 Access 席位挂载 `PermissionSelect`,由 host 计算的 `permissions` 投影经标准工具包 `useProjection` 供数(key 缺席即隐藏 chip);chip 打开 Menu 原语下拉,kebab-case 预设名渲染为 Title Case 标签(与 `/permission` popup 的显示变换孪生),选中会经由输入栏注入的 `command` 回调提交 `/permission ` 命令行。 -todo 两个面就是在该形状上的两个注册项,都是普通注册方插件,`inject: ['slots', 'conversation']`。`TodoRow` 占用 `'conversation.chat.toolview'` 的 `todo_write` key,摘要该次调用「试图写入」的内容(从其 args 解析出 `<已完成>/<总数> 已完成 · <进行中条目>`;模型 JSON 残缺或形状不对时回落到通用摘要;非 ok 执行状态保留通用状态点,使被取消的调用绝不读成一次已完成的更新)。`TodoDock` 以 `order: -1` 占用 `'conversation.input.dock'` 列表 slot(位于队列行之上),是计划条:它经 `useProjection` 读取 host 计算的 `todos` 投影(站立计划:其后没有更晚 `turn/start` 的最近一次 `todo/write`)并渲染 `TodoPanel`,后者接收纯列表,在列表为空时自我隐藏;列表非空时面板初始折叠,表头显示标题加 `"<已完成>/<总数> tasks · in progress"`(状态图标为 figma 的勾选/进行中/虚线未开始一组)。选取由 dock 适配器负责,因此面板保持为其 props 的纯函数;站立列表放在此处而非行内,行才能保持单行。输入区 composer 链隐藏的一切(例如 ui-question 对 `conversation.composer` 的接管)也会隐藏整个 dock,包括这条计划条。 +todo 两个面就是在该形状上的两个注册项,都是普通注册方插件,`inject: ['slots', 'conversation']`。`TodoRow` 占用 `'conversation.chat.toolview'` 的 `todo_write` key,摘要该次调用「试图写入」的内容(从其 args 解析出 `<已完成>/<总数> 已完成 · <进行中条目>`;模型 JSON 残缺或形状不对时回落到通用摘要;非 ok 执行状态保留通用状态点,使被取消的调用绝不读成一次已完成的更新)。`TodoDock` 以 `order: 10` 占用 `'conversation.input.dock'` 列表 slot(位于 Goal 和 Queue 之间),是计划条:它经 `useProjection` 读取 host 计算的 `todos` 投影(站立计划:其后没有更晚 `turn/start` 的最近一次 `todo/write`)并渲染 `TodoPanel`,后者接收纯列表,在列表为空时自我隐藏;列表非空时面板初始折叠,表头显示标题加 `"<已完成>/<总数> tasks · in progress"`(状态图标为 figma 的勾选/进行中/虚线未开始一组)。选取由 dock 适配器负责,因此面板保持为其 props 的纯函数;站立列表放在此处而非行内,行才能保持单行。输入区 composer 链隐藏的一切(例如 ui-question 对 `conversation.composer` 的接管)也会隐藏整个 dock,包括这条计划条。 逐 Session UI 状态中的选择与活跃视图位于已声明的聊天 store(`stores.ts` `createChatStore`)中;InputHub 拥有输入区状态机,并将草稿镜像到该 store 以便持久化。apply 将同一个 store handle 传给严格限定于会话的子树、聊天视图和详情注册,因此每个会话内共享一个实例,框架拥有其生命周期。组件保持纯粹:框架标准工具包提供 `useSession`/`sessionId`、全局 `useSessions`/`useWorkspaces`,以及输入状态机的 `useInput`/`inputActions`;store 表层与 inject factory 提供其余状态和回调。 diff --git a/packages/client/ui-conversation/src/client/queue/QueueDock.module.css b/packages/client/ui-conversation/src/client/queue/QueueDock.module.css index 4c05c2cbca..51d0737ee7 100644 --- a/packages/client/ui-conversation/src/client/queue/QueueDock.module.css +++ b/packages/client/ui-conversation/src/client/queue/QueueDock.module.css @@ -5,9 +5,11 @@ flex: none; width: 100%; max-width: 776px; - /* Eat InputBar's 6px top padding and tuck the panel 2px under the card; - the later composer sibling paints its surface and shadow over this edge. */ - margin: 0 auto -10px; + /* Flex gap still applies after this item; subtract it together with the + design's overlap so the later composer paints over the queue edge. */ + margin: 0 auto calc( + 0px - var(--dsh-composer-stack-gap) - var(--dsh-queue-composer-overlap) + ); padding: 2px 12px; } diff --git a/packages/client/ui-conversation/src/client/queue/QueueDock.tsx b/packages/client/ui-conversation/src/client/queue/QueueDock.tsx index 99b91f301d..88300f6086 100644 --- a/packages/client/ui-conversation/src/client/queue/QueueDock.tsx +++ b/packages/client/ui-conversation/src/client/queue/QueueDock.tsx @@ -162,14 +162,14 @@ export const queueDockEntry = { name: 'conversation-queue-dock', inject: ['slots', 'conversation', 'sessions'], /** - * Register the queue strip into the input dock (list entry, order 0). + * Register the queue strip as the terminal input-dock entry (order 20). * @param ctx - registrant context (disposal rides ctx.effect inside slots.register). */ apply(ctx: Context): void { ctx.slots.register({ name: 'conversation.input.dock', id: 'queue', - order: 0, + order: 20, inject: (sessionId: SessionId): QueueDockInjected => { const actx = ctx.sessions.scope(sessionId) if (actx === undefined) throw new Error(`queue dock: session "${sessionId}" resolved no scope`) diff --git a/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css b/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css index e240fea889..e48d25ab45 100644 --- a/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css +++ b/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css @@ -127,14 +127,15 @@ min-height: 0; } -/* Composer stack: dock strips above the input card (design §6 MIX order). - The stack owns the vertical rhythm: one gap here, entries carry no outer - margins — an entry that renders null costs nothing, so spacing stays - correct for any dock combination. */ +/* Composer context stack (Figma 9:937): standalone dock cards share one + rhythm; the terminal queue strip additionally tucks under the input card. */ .composerStack { + --dsh-composer-stack-gap: 6px; + --dsh-queue-composer-overlap: 5px; + display: flex; flex-direction: column; - gap: 8px; + gap: var(--dsh-composer-stack-gap); } /* Common seat for the composer chain (fallback + elected overlay siblings). */ diff --git a/packages/client/ui-conversation/src/client/skeleton/TodoPanel.module.css b/packages/client/ui-conversation/src/client/skeleton/TodoPanel.module.css index 716f3d9419..5d26aa4a5e 100644 --- a/packages/client/ui-conversation/src/client/skeleton/TodoPanel.module.css +++ b/packages/client/ui-conversation/src/client/skeleton/TodoPanel.module.css @@ -1,9 +1,8 @@ -/* Todo strip above the composer (figma 772:51905 / 772:52972 / 772:53419): - tip surface, 14px radius, status icons + secondary item labels. Column is - calc(100% - 88px) / max 752 (GoalBar's column), centered; the composer - stack owns the gap. */ +/* Todo strip in the composer context stack (Figma 9:959): tip surface, + 14px radius, status icons + secondary item labels. */ .root { + box-sizing: border-box; flex: none; overflow: hidden; margin: 0 auto; @@ -21,13 +20,11 @@ --dsh-scrollbar-thumb-hover: var(--dsw-alias-scrollbar-hover-l2); } -/* Compact scale (GoalBar reference): collapsed header totals the goal - strip's 38px (8+8 pad + 20 line + 2 border). */ .body { display: flex; flex-direction: column; gap: 8px; - padding: 8px 14px; + padding: 9px 15px; } .header { @@ -44,8 +41,8 @@ .title { flex: none; - font-size: 13px; - line-height: 20px; + font-size: 14px; + line-height: 24px; font-weight: 500; color: var(--dsw-alias-label-primary); } diff --git a/packages/client/ui-conversation/src/client/skeleton/TodoPanel.tsx b/packages/client/ui-conversation/src/client/skeleton/TodoPanel.tsx index 22f5786ab1..1dd12a6564 100644 --- a/packages/client/ui-conversation/src/client/skeleton/TodoPanel.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/TodoPanel.tsx @@ -135,10 +135,10 @@ export const todoDockEntry = { name: 'conversation-todo-dock', inject: ['slots', 'conversation'], /** - * Register the plan strip into the input dock (list entry, above the queue rows). + * Register the plan strip between the goal and queue entries (order 10). * @param ctx - registrant context (disposal rides ctx.effect inside slots.register). */ apply(ctx: Context): void { - ctx.slots.register({ name: 'conversation.input.dock', id: 'todo', order: -1 }, TodoDock) + ctx.slots.register({ name: 'conversation.input.dock', id: 'todo', order: 10 }, TodoDock) }, } diff --git a/packages/client/ui-conversation/tests/queue-dock.spec.tsx b/packages/client/ui-conversation/tests/queue-dock.spec.tsx index 8d0614d2e9..6175cd352e 100644 --- a/packages/client/ui-conversation/tests/queue-dock.spec.tsx +++ b/packages/client/ui-conversation/tests/queue-dock.spec.tsx @@ -197,9 +197,14 @@ describe('QueueDock', () => { expect(container.innerHTML).toBe('') }) - it('ships the session-scoped registrant plugin shape', () => { + it('registers as the terminal composer-context entry', () => { expect(queueDockEntry.name).toBe('conversation-queue-dock') expect(queueDockEntry.inject).toEqual(['slots', 'conversation', 'sessions']) - expect(typeof queueDockEntry.apply).toBe('function') + const register = vi.fn() + queueDockEntry.apply({ slots: { register } } as never) + expect(register).toHaveBeenCalledWith( + expect.objectContaining({ name: 'conversation.input.dock', id: 'queue', order: 20 }), + QueueDock, + ) }) }) diff --git a/packages/client/ui-conversation/tests/todo-panel.spec.tsx b/packages/client/ui-conversation/tests/todo-panel.spec.tsx index 65146160da..e2303039af 100644 --- a/packages/client/ui-conversation/tests/todo-panel.spec.tsx +++ b/packages/client/ui-conversation/tests/todo-panel.spec.tsx @@ -92,12 +92,12 @@ describe('TodoDock', () => { expect(screen.queryByTestId('todo-panel')).toBeNull() }) - it('ships the registrant plugin shape (list entry above the queue rows)', () => { + it('registers between the goal and queue entries', () => { expect(todoDockEntry.name).toBe('conversation-todo-dock') expect(todoDockEntry.inject).toEqual(['slots', 'conversation']) const register = vi.fn() todoDockEntry.apply({ slots: { register } } as never) - expect(register).toHaveBeenCalledWith({ name: 'conversation.input.dock', id: 'todo', order: -1 }, TodoDock) + expect(register).toHaveBeenCalledWith({ name: 'conversation.input.dock', id: 'todo', order: 10 }, TodoDock) }) }) diff --git a/packages/client/ui-goal/README.i18n.yaml b/packages/client/ui-goal/README.i18n.yaml index 666a6e472e..191dade4fa 100644 --- a/packages/client/ui-goal/README.i18n.yaml +++ b/packages/client/ui-goal/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-goal/README.md -README.md: fed4870f73277b22760417297d668853b8afb2db -README.zh.md: cc607edc856e04c6ee42cc8f596aa658679a02ca +README.md: 2c109ab1fbe0b566b8749a6af44ec5e0055fe3b2 +README.zh.md: b81113c67566fd834b3ddb10931d4ecc630aa2f9 diff --git a/packages/client/ui-goal/README.md b/packages/client/ui-goal/README.md index fed4870f73..2c109ab1fb 100644 --- a/packages/client/ui-goal/README.md +++ b/packages/client/ui-goal/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -Goal surface plugin, browser half: the `GoalBar` strip in the `conversation.input.dock` list (order 1, tucked against the composer). The live goal arrives through `useProjection('goal')` — the host-computed whole value seeded by the history tail page and updated by `session/projection` frames — so the plugin owns no store, no refresh chain, and no event listener. The slot inject face carries only the four mutation verbs (edit / pause / resume / clear over the `goal.*` wire domain — an active goal offers the pause action, a paused one resume); each reads the CAS ref from the session's current projected value at call time and surfaces the settled RPC error inline (the RPC's compare-and-set is the staleness guard — there is no client fence). Goal creation stays on the `/goal` host command; loading, absent, and completed goals render nothing. +Goal surface plugin, browser half: the `GoalBar` strip is the first standalone card in the `conversation.input.dock` composer-context stack (order 0, before Todo and Queue). The live goal arrives through `useProjection('goal')` — the host-computed whole value seeded by the history tail page and updated by `session/projection` frames — so the plugin owns no store, no refresh chain, and no event listener. The slot inject face carries only the four mutation verbs (edit / pause / resume / clear over the `goal.*` wire domain — an active goal offers the pause action, a paused one resume); each reads the CAS ref from the session's current projected value at call time and surfaces the settled RPC error inline (the RPC's compare-and-set is the staleness guard — there is no client fence). Goal creation stays on the `/goal` host command; loading, absent, and completed goals render nothing. The `/client` export surface is the plugin body (`apply`/`inject`), the `GoalBar`/`GoalDock` components, and the injected verb face types. diff --git a/packages/client/ui-goal/README.zh.md b/packages/client/ui-goal/README.zh.md index cc607edc85..b81113c675 100644 --- a/packages/client/ui-goal/README.zh.md +++ b/packages/client/ui-goal/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -Goal 表面插件(浏览器半件):`conversation.input.dock` 列表中的 `GoalBar` 条带(order 1,紧贴 composer)。活值经 `useProjection('goal')` 到达——host 计算的全量值由历史尾页播种、由 `session/projection` 帧更新——因此本插件不持有 store、不设刷新链、不挂事件监听。slot 注入面只携带四个变更动词(edit / pause / resume / clear,走 `goal.*` 协议域——active 的 goal 提供暂停动作,paused 的提供恢复);每个动词在调用时从会话当前投影值读取 CAS ref,并把结算后的 RPC 错误内联呈现(RPC 的 compare-and-set 即陈旧性防护——客户端没有任何栅栏)。goal 的创建仍归 `/goal` host 命令;加载中、无 goal、已完成三种状态一律不渲染。 +Goal 表面插件(浏览器半件):`GoalBar` 条带是 `conversation.input.dock` composer 上下文堆栈中的第一张独立卡片(order 0,位于 Todo 和 Queue 之前)。活值经 `useProjection('goal')` 到达——host 计算的全量值由历史尾页播种、由 `session/projection` 帧更新——因此本插件不持有 store、不设刷新链、不挂事件监听。slot 注入面只携带四个变更动词(edit / pause / resume / clear,走 `goal.*` 协议域——active 的 goal 提供暂停动作,paused 的提供恢复);每个动词在调用时从会话当前投影值读取 CAS ref,并把结算后的 RPC 错误内联呈现(RPC 的 compare-and-set 即陈旧性防护——客户端没有任何栅栏)。goal 的创建仍归 `/goal` host 命令;加载中、无 goal、已完成三种状态一律不渲染。 `/client` 出口面为插件本体(`apply`/`inject`)、`GoalBar`/`GoalDock` 组件与注入动词面类型。 diff --git a/packages/client/ui-goal/src/client/GoalBar.module.css b/packages/client/ui-goal/src/client/GoalBar.module.css index aaf832464e..261c4bfa0a 100644 --- a/packages/client/ui-goal/src/client/GoalBar.module.css +++ b/packages/client/ui-goal/src/client/GoalBar.module.css @@ -1,29 +1,24 @@ -/* GoalBar: the goal strip docked above the composer card. The dock's 44px - side padding and the bar's 752px cap match the todo strip's column - (TodoPanel.module.css), 24px inside the composer card's edges. The - negative bottom margin cancels the composer stack's 8px gap and tucks the - bar's square bottom edge 2px under the composer card's top edge (the - card, later in DOM order, paints over it). Surface matches the todo - strip: tip fill, l1 border — no bottom edge where it disappears under the - card. All states share one fixed 38px height so switching between them - never resizes the strip. */ +/* GoalBar: the first standalone card in the composer context stack (Figma + 9:939). Its 752px column matches Todo and the Queue panel. */ .dock { + box-sizing: border-box; + width: 100%; padding: 0 44px; } .bar { + box-sizing: border-box; display: flex; align-items: center; - gap: 6px; - box-sizing: border-box; + gap: 10px; + width: 100%; max-width: 752px; - height: 38px; - margin: 0 auto -10px; - padding: 0 14px; + height: 36px; + margin: 0 auto; + padding: 4px 5px 4px 12px; border: 1px solid var(--dsw-alias-border-l1); - border-bottom: none; - border-radius: 14px 14px 0 0; + border-radius: 14px; background: var(--dsw-specific-tip); } @@ -37,8 +32,8 @@ flex: none; font-size: 13px; line-height: 20px; - font-weight: 600; - color: var(--dsw-alias-label-primary); + font-weight: 500; + color: var(--dsw-alias-label-primary-dimmed); } .objective { @@ -47,7 +42,7 @@ overflow: hidden; font-size: 13px; line-height: 20px; - color: var(--dsw-alias-label-secondary); + color: var(--dsw-alias-label-primary-dimmed); text-overflow: ellipsis; white-space: nowrap; } @@ -92,7 +87,7 @@ .actions { display: flex; align-items: center; - gap: 8px; + gap: 10px; flex: none; } @@ -100,11 +95,11 @@ display: inline-flex; align-items: center; justify-content: center; - width: 24px; - height: 24px; + width: 28px; + height: 28px; padding: 0; border: none; - border-radius: 6px; + border-radius: 999px; background: transparent; color: var(--dsw-alias-label-tertiary); cursor: pointer; diff --git a/packages/client/ui-goal/src/client/index.ts b/packages/client/ui-goal/src/client/index.ts index fc1cf1e035..9ea8e7134a 100644 --- a/packages/client/ui-goal/src/client/index.ts +++ b/packages/client/ui-goal/src/client/index.ts @@ -59,7 +59,7 @@ export function apply(ctx: ClientContext): void { scope.effect(() => scope.slots.register({ name: 'conversation.input.dock', id: 'goal', - order: 1, + order: 0, inject: (sessionId): GoalBarActions => ({ onEdit: async (objective) => { const ref = refOf(sessionId) diff --git a/packages/client/ui-goal/tests/browser-plugin.spec.tsx b/packages/client/ui-goal/tests/browser-plugin.spec.tsx index 348d06d1a6..c560ba4b8c 100644 --- a/packages/client/ui-goal/tests/browser-plugin.spec.tsx +++ b/packages/client/ui-goal/tests/browser-plugin.spec.tsx @@ -92,7 +92,7 @@ describe('ui-goal browser plugin', () => { it('registers the GoalBar dock entry with the documented id and order', async () => { const b = bench() await b.fiber.await() - expect(b.entry()).toMatchObject({ id: 'goal', order: 1 }) + expect(b.entry()).toMatchObject({ id: 'goal', order: 0 }) expect(b.entry()?.inject).toBeTypeOf('function') })