mirror of
https://github.com/deepseek-ai/deepseek-harness
synced 2026-08-15 21:04:50 +00:00
Merge remote-tracking branch 'origin/master' into perf/tui-resume-scan
# Conflicts: # packages/ui/tui/README.i18n.yaml
This commit is contained in:
42
scripts/dev-web.spec.ts
Normal file
42
scripts/dev-web.spec.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { mkdtemp, readFile, rm, symlink, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { expect, it } from 'vitest'
|
||||
import type { TsdownBundle } from 'tsdown'
|
||||
import { watchClientPlugins } from './dev-web.ts'
|
||||
|
||||
it('rebuilds a client-plugin bundle after its source changes', async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'dsh-dev-web-watch-'))
|
||||
let bundles: TsdownBundle[] = []
|
||||
try {
|
||||
await symlink(join(import.meta.dirname, '..', 'node_modules'), join(root, 'node_modules'), 'dir')
|
||||
await writeFile(join(root, 'package.json'), JSON.stringify({ name: '@dsh-test/dev-web-watch', private: true, type: 'module' }))
|
||||
await writeFile(join(root, 'tsdown.config.ts'), `
|
||||
import { defineConfig } from 'tsdown'
|
||||
export default defineConfig({
|
||||
entry: { client: 'src.ts' }, outDir: 'lib', format: 'cjs', platform: 'browser', dts: false, clean: false,
|
||||
outputOptions: { entryFileNames: 'client.js' },
|
||||
})
|
||||
`)
|
||||
const sourcePath = join(root, 'src.ts')
|
||||
const bundlePath = join(root, 'lib/client.js')
|
||||
await writeFile(sourcePath, 'export const version = "watch-v1"\n')
|
||||
bundles = await watchClientPlugins(root, ['.'], 50)
|
||||
await expect.poll(async () => {
|
||||
try {
|
||||
return (await readFile(bundlePath, 'utf8')).includes('watch-v1')
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}, { timeout: 10_000 }).toBe(true)
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, 1_000))
|
||||
await writeFile(sourcePath, `export const version = "watch-v2-${'x'.repeat(100)}"\n`)
|
||||
await expect.poll(async () => (await readFile(bundlePath, 'utf8')).includes('watch-v2-'), {
|
||||
timeout: 10_000,
|
||||
}).toBe(true)
|
||||
} finally {
|
||||
for (const bundle of bundles) await bundle[Symbol.asyncDispose]()
|
||||
await rm(root, { recursive: true, force: true })
|
||||
}
|
||||
}, 20_000)
|
||||
@@ -18,9 +18,10 @@
|
||||
* keys under each package's file config, and no package config defines it).
|
||||
*/
|
||||
import { globSync, readFileSync } from 'node:fs'
|
||||
import { dirname, join, sep } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { dirname, join, resolve, sep } from 'node:path'
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url'
|
||||
import { build } from 'tsdown'
|
||||
import type { TsdownBundle } from 'tsdown'
|
||||
|
||||
const repoRoot = fileURLToPath(new URL('..', import.meta.url))
|
||||
|
||||
@@ -29,46 +30,64 @@ const repoRoot = fileURLToPath(new URL('..', import.meta.url))
|
||||
* whose package.json carries `dshClient` with platform "web" is a client
|
||||
* plugin bundle emitter. Scanned once at startup — a package added while
|
||||
* watching means restarting this script.
|
||||
* @param root - repository root containing the grouped package directories.
|
||||
* @returns workspace-relative plugin package directories.
|
||||
*/
|
||||
function discoverPluginDirs(): string[] {
|
||||
export function discoverPluginDirs(root = repoRoot): string[] {
|
||||
const dirs: string[] = []
|
||||
for (const manifestPath of globSync('packages/*/*/package.json', { cwd: repoRoot }).sort()) {
|
||||
const manifest = JSON.parse(readFileSync(join(repoRoot, manifestPath), 'utf8')) as { dshClient?: { platform?: unknown } }
|
||||
for (const manifestPath of globSync('packages/*/*/package.json', { cwd: root }).sort()) {
|
||||
const manifest = JSON.parse(readFileSync(join(root, manifestPath), 'utf8')) as { dshClient?: { platform?: unknown } }
|
||||
if (manifest.dshClient?.platform === 'web') dirs.push(dirname(manifestPath).split(sep).join('/'))
|
||||
}
|
||||
return dirs
|
||||
}
|
||||
|
||||
const PLUGIN_DIRS = discoverPluginDirs()
|
||||
if (PLUGIN_DIRS.length === 0) {
|
||||
console.error('dev-web: no dshClient (platform "web") packages found under packages/')
|
||||
process.exit(1)
|
||||
/**
|
||||
* Start the tsdown watch build used by `pnpm run dev:web`.
|
||||
* @param root - repository or fixture root passed to tsdown.
|
||||
* @param pluginDirs - workspace-relative package directories to watch.
|
||||
* @param pollInterval - optional source-watcher polling interval in milliseconds.
|
||||
* @returns live bundles whose async disposers stop every watcher.
|
||||
*/
|
||||
export async function watchClientPlugins(
|
||||
root: string,
|
||||
pluginDirs: readonly string[],
|
||||
pollInterval?: number,
|
||||
): Promise<TsdownBundle[]> {
|
||||
return build({
|
||||
cwd: root,
|
||||
workspace: [...pluginDirs],
|
||||
watch: true,
|
||||
...pollInterval !== undefined
|
||||
? { inputOptions: { watch: { watcher: { usePolling: true, pollInterval } } } }
|
||||
: {},
|
||||
})
|
||||
}
|
||||
|
||||
const args = process.argv.slice(2)
|
||||
const pollArg = args.find(a => a === '--poll' || a.startsWith('--poll='))
|
||||
if (args.some(a => a !== pollArg)) {
|
||||
console.error('dev-web: usage: tsx scripts/dev-web.ts [--poll[=ms]]')
|
||||
process.exit(1)
|
||||
}
|
||||
const pollInterval = pollArg === undefined ? undefined : Number(pollArg.split('=')[1] ?? '500')
|
||||
if (pollInterval !== undefined && (!Number.isInteger(pollInterval) || pollInterval <= 0)) {
|
||||
console.error(`dev-web: invalid --poll interval "${pollArg ?? ''}"`)
|
||||
process.exit(1)
|
||||
}
|
||||
const invokedPath = process.argv[1]
|
||||
const isMain = invokedPath !== undefined && import.meta.url === pathToFileURL(resolve(invokedPath)).href
|
||||
if (isMain) {
|
||||
const pluginDirs = discoverPluginDirs()
|
||||
if (pluginDirs.length === 0) {
|
||||
console.error('dev-web: no dshClient (platform "web") packages found under packages/')
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
await build({
|
||||
cwd: repoRoot,
|
||||
workspace: PLUGIN_DIRS,
|
||||
watch: true,
|
||||
// Rolldown watch options ride through inputOptions (tsdown has no watcher
|
||||
// tuning of its own); polling is opt-in for network mounts without inotify.
|
||||
...pollInterval !== undefined
|
||||
? { inputOptions: { watch: { watcher: { usePolling: true, pollInterval } } } }
|
||||
: {},
|
||||
})
|
||||
console.log(
|
||||
`dev-web: watching ${String(PLUGIN_DIRS.length)} dshClient plugin packages`
|
||||
+ `${pollInterval !== undefined ? ` (polling ${String(pollInterval)}ms)` : ''}:\n ${PLUGIN_DIRS.join('\n ')}`,
|
||||
)
|
||||
const args = process.argv.slice(2)
|
||||
const pollArg = args.find(a => a === '--poll' || a.startsWith('--poll='))
|
||||
if (args.some(a => a !== pollArg)) {
|
||||
console.error('dev-web: usage: tsx scripts/dev-web.ts [--poll[=ms]]')
|
||||
process.exit(1)
|
||||
}
|
||||
const pollInterval = pollArg === undefined ? undefined : Number(pollArg.split('=')[1] ?? '500')
|
||||
if (pollInterval !== undefined && (!Number.isInteger(pollInterval) || pollInterval <= 0)) {
|
||||
console.error(`dev-web: invalid --poll interval "${pollArg ?? ''}"`)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
await watchClientPlugins(repoRoot, pluginDirs, pollInterval)
|
||||
console.log(
|
||||
`dev-web: watching ${String(pluginDirs.length)} dshClient plugin packages`
|
||||
+ `${pollInterval !== undefined ? ` (polling ${String(pollInterval)}ms)` : ''}:\n ${pluginDirs.join('\n ')}`,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"AGENTS.md": 1775,
|
||||
"docs/AGENTS.md": 1150,
|
||||
"docs/architecture.md": 1920,
|
||||
"docs/architecture.md": 2160,
|
||||
"docs/cordis-primer.md": 600,
|
||||
"docs/defensive-patterns.md": 550,
|
||||
"docs/testing.md": 1100,
|
||||
|
||||
@@ -162,7 +162,18 @@ export const LINK_MAP: Readonly<Record<string, string>> = {
|
||||
SkillSummary: 'skills.md',
|
||||
SaveTextSpill: 'spill.md',
|
||||
SpillRef: 'spill.md',
|
||||
ContinuableCreateRequest: 'subagent.md',
|
||||
ContinuableCreateSpec: 'subagent.md',
|
||||
ContinuableSetupContribution: 'subagent.md',
|
||||
ContinuableStart: 'subagent.md',
|
||||
ContinuableStartSpec: 'subagent.md',
|
||||
CoordinatorMessageSource: 'subagent.md',
|
||||
SubagentFollowupOptions: 'subagent.md',
|
||||
SubagentListEntry: 'subagent.md',
|
||||
SubagentProvider: 'subagent.md',
|
||||
SubagentReportDelivery: 'subagent.md',
|
||||
SubagentReportMessageSource: 'subagent.md',
|
||||
SubagentReportOptions: 'subagent.md',
|
||||
SubagentRun: 'subagent.md',
|
||||
SubagentService: 'subagent.md',
|
||||
SubagentStartRequest: 'subagent.md',
|
||||
@@ -280,8 +291,8 @@ export const TYPE_LINK_EXEMPTIONS: Readonly<Record<string, string>> = {
|
||||
PromptAssembly: 'assembly result is owned by packages/core/system-prompt/README.md',
|
||||
ResumeAgentOptions: 'agent resume contract is owned by packages/core/agent/README.md',
|
||||
SessionForkSource: 'service-local fork input is owned by packages/core/session/src/index.ts',
|
||||
SubagentRunEndInfo: 'event-local snapshot is owned by packages/subagent/subagent/src/index.ts',
|
||||
SubagentRunInfo: 'event-local snapshot is owned by packages/subagent/subagent/src/index.ts',
|
||||
SubagentRunEndInfo: 'event payload contract is owned by packages/subagent/subagent/src/types.ts',
|
||||
SubagentRunInfo: 'event payload contract is owned by packages/subagent/subagent/src/types.ts',
|
||||
TelemetryRecord: 'seam-local record contract is owned by packages/telemetry/session-telemetry/src/index.ts',
|
||||
WorkflowAgentEndInfo: 'event-local snapshot is owned by packages/workflow/workflow/src/index.ts',
|
||||
WorkflowAgentInfo: 'event-local snapshot is owned by packages/workflow/workflow/src/index.ts',
|
||||
@@ -305,7 +316,8 @@ export const CORDIS_CATALOG_POLICY: CordisCatalogPolicy = {
|
||||
{ name: 'internal/listener', summary: 'A listener was registered.', source: 'vendor/cordis/src/events.ts:340' },
|
||||
{ name: 'internal/dispatch', summary: 'An event is being dispatched to listeners.', source: 'vendor/cordis/src/events.ts:342' },
|
||||
{ name: 'hmr/change', summary: 'A watched source file changed on disk.', source: 'vendor/hmr/src/index.ts:20' },
|
||||
{ name: 'hmr/reload', summary: 'Plugins are being reloaded after a change.', source: 'vendor/hmr/src/index.ts:21' },
|
||||
{ name: 'hmr/reload', summary: 'Plugins are being reloaded after a change.', source: 'vendor/hmr/src/index.ts:22' },
|
||||
{ name: 'hmr/config-update-failed', summary: 'A watched config-file refresh failed.', source: 'vendor/hmr/src/index.ts:29' },
|
||||
{ name: 'exit', summary: 'The process is exiting on a signal.', source: 'vendor/loader/src/index.ts:23' },
|
||||
{ name: 'loader/config-update', summary: 'The loader config tree changed.', source: 'vendor/loader/src/index.ts:24' },
|
||||
{ name: 'loader/entry-init', summary: 'A config entry is being initialized.', source: 'vendor/loader/src/index.ts:25' },
|
||||
|
||||
@@ -424,11 +424,11 @@ const SERVICE_ROLES: ServiceRole[] = [
|
||||
{
|
||||
key: 'subagents',
|
||||
pkg: 'subagent',
|
||||
title: 'Subagent provider registry',
|
||||
title: 'Subagent provider and continuation service',
|
||||
mode: 'seam',
|
||||
implementations: ['subagent-spawn', 'subagent-fork', 'subagent-acp'],
|
||||
consumers: ['tool-subagent', 'tool-ralph'],
|
||||
note: 'Providers implement transports; tool-subagent exposes configured delegation while tool-ralph requires one fresh structured-output route.',
|
||||
consumers: ['tool-subagent', 'tool-subagent-control', 'tool-ralph'],
|
||||
note: 'Providers implement transports; the service also owns optional Activation-based continuation orchestration, tool-subagent selects one-shot or continuable delegation, tool-subagent-control delivers follow-ups, and tool-ralph requires one fresh structured-output route.',
|
||||
},
|
||||
{
|
||||
key: 'tasks',
|
||||
|
||||
@@ -11,7 +11,9 @@ import { basename, resolve } from 'node:path'
|
||||
import { Context } from 'cordis'
|
||||
import type { ToolSchema } from '@deepseek-ai/dsh-llm'
|
||||
import AgentRegistry from '@deepseek-ai/dsh-agent'
|
||||
import SessionStore from '@deepseek-ai/dsh-session'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import { createScope } from '@deepseek-ai/dsh-scope'
|
||||
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import SessionQuerySqlite from '@deepseek-ai/dsh-session-query-sqlite'
|
||||
import GoalService from '@deepseek-ai/dsh-goal'
|
||||
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
||||
@@ -28,6 +30,9 @@ import * as WebSearchExa from '@deepseek-ai/dsh-web-search-exa'
|
||||
import * as WebFetchLocal from '@deepseek-ai/dsh-web-fetch-local'
|
||||
import SubagentService from '@deepseek-ai/dsh-subagent'
|
||||
import type { SubagentProvider } from '@deepseek-ai/dsh-subagent'
|
||||
import * as ToolSubagentControl from '@deepseek-ai/dsh-tool-subagent-control'
|
||||
import * as ToolSubagentListAgents from '@deepseek-ai/dsh-tool-subagent-control/list-agents'
|
||||
import * as ToolSubagentReport from '@deepseek-ai/dsh-tool-subagent-report'
|
||||
import SkillService from '@deepseek-ai/dsh-skill'
|
||||
import * as SkillLocal from '@deepseek-ai/dsh-skill-local'
|
||||
import LocalTaskService from '@deepseek-ai/dsh-tasks-local'
|
||||
@@ -106,10 +111,32 @@ function registerCatalogSubagentProvider(ctx: Context, name: string): void {
|
||||
capabilities: { outputSchema: true, depthLimit: true, toolFilter: true, persona: true },
|
||||
inheritsParentContext: false,
|
||||
start: () => Promise.reject(new Error('tool-catalog provider cannot start a child')),
|
||||
// Declared so consumers configured for continuable background mode mount.
|
||||
prepareContinuable: () => Promise.reject(new Error('tool-catalog provider cannot prepare a child')),
|
||||
}
|
||||
ctx.subagents.registerProvider(provider)
|
||||
}
|
||||
|
||||
/** Minted child-scope keys for packages whose tools are never global. */
|
||||
const catalogChildScopes = new WeakMap<Context, Agent>()
|
||||
|
||||
/**
|
||||
* Install one scope-local tool package into an agent-like child scope for
|
||||
* schema harvest, without starting a model, Agent loop, or persistence backend.
|
||||
* @param ctx - catalog context owning the scope.
|
||||
* @param mountScoped - package installer for the scoped context.
|
||||
*/
|
||||
async function mountCatalogChildScope(
|
||||
ctx: Context,
|
||||
mountScoped: (childCtx: Context) => void,
|
||||
): Promise<void> {
|
||||
const key = { id: SessionId('tool-catalog-child') } as Agent
|
||||
await ctx.plugin(Object.assign((inner: Context) => {
|
||||
mountScoped(createScope(inner, key).ctx)
|
||||
}, { inject: ['tools', 'systemPrompt', 'subagents'] }))
|
||||
catalogChildScopes.set(ctx, key)
|
||||
}
|
||||
|
||||
/**
|
||||
* Tool package plus its hand-maintained boot recipe. The caller mounts the
|
||||
* prompt and registry; each recipe supplies only package-specific seams and
|
||||
@@ -120,8 +147,12 @@ interface ToolPackage {
|
||||
pkg: string
|
||||
/** The `packages/<group>/<dir>` leaf name — matched by the completeness guard. */
|
||||
dir: string
|
||||
/** Repo-relative source path linked from the catalog entry. */
|
||||
source: string
|
||||
/**
|
||||
* Repo-relative implementation source linked per harvested tool. Packages
|
||||
* whose tools share one plugin may use a string; split plugins map each tool
|
||||
* name to its own source.
|
||||
*/
|
||||
source: string | Readonly<Record<string, string>>
|
||||
/** Services or owning runtime surfaces the package requires at execution time. */
|
||||
requires: string[]
|
||||
/** Session events or other visible state the tools write or affect. */
|
||||
@@ -131,6 +162,8 @@ interface ToolPackage {
|
||||
/** Plug the injected seams + the tool plugin onto a context that already
|
||||
* carries `systemPrompt` + `tools`. */
|
||||
mount: (ctx: Context) => Promise<void>
|
||||
/** Agent-like scope key whose tool view is catalogued instead of the global view. */
|
||||
scope?: (ctx: Context) => Agent
|
||||
/**
|
||||
* Config for the caller's `ToolRegistry` mount. The registry itself ships a
|
||||
* model-facing tool (`run_code`, registered under a non-native `mode`), so
|
||||
@@ -379,6 +412,46 @@ const TOOL_PACKAGES: ToolPackage[] = [
|
||||
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 `apps/cli/config/base.cordis.yml` and `examples/acp-agent/cordis.yml`.',
|
||||
},
|
||||
{
|
||||
pkg: '@deepseek-ai/dsh-tool-subagent-control',
|
||||
dir: 'tool-subagent-control',
|
||||
source: {
|
||||
list_agents: 'packages/subagent/tool-subagent-control/src/list-agents.ts',
|
||||
send_message: 'packages/subagent/tool-subagent-control/src/index.ts',
|
||||
},
|
||||
requires: ['ctx.tools', 'ctx.subagents', 'ctx.sessionQuery (list_agents only)'],
|
||||
writes: ['tool/call', 'tool/result', 'child session events through ctx.subagents'],
|
||||
async mount(ctx) {
|
||||
await ctx.plugin(SubagentService)
|
||||
await ctx.plugin(LocalTaskService)
|
||||
await ctx.plugin(AgentRegistry)
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(SessionQuerySqlite, { path: ':memory:' })
|
||||
await ctx.plugin(ToolSubagentControl)
|
||||
await ctx.plugin(ToolSubagentListAgents)
|
||||
},
|
||||
note:
|
||||
'The globally named control tools over continuable background subagents: provider-bound `tool-subagent` instances register distinct delegation tools, while this package registers `send_message` once, plus `list_agents` from its separately loaded `/list-agents` plugin (which additionally requires session query).',
|
||||
},
|
||||
{
|
||||
pkg: '@deepseek-ai/dsh-tool-subagent-report',
|
||||
dir: 'tool-subagent-report',
|
||||
source: 'packages/subagent/tool-subagent-report/src/index.ts',
|
||||
requires: ['ctx.subagents', 'a live continuable in-process child Agent'],
|
||||
writes: ['tool/call', 'tool/result', 'a user-role message in the direct parent session'],
|
||||
async mount(ctx) {
|
||||
await ctx.plugin(AgentRegistry)
|
||||
await ctx.plugin(SubagentService)
|
||||
await mountCatalogChildScope(ctx, (childCtx) => {
|
||||
ToolSubagentReport.installReportTool(childCtx, ctx, 'quiet')
|
||||
})
|
||||
},
|
||||
scope: ctx => catalogChildScopes.get(ctx) as Agent,
|
||||
note:
|
||||
'Registered per continuable in-process child rather than globally, so this schema is visible only '
|
||||
+ 'inside such a child and survives its global `toolFilter`. The parent-facing `send_message` tool '
|
||||
+ 'is installed independently.',
|
||||
},
|
||||
{
|
||||
pkg: '@deepseek-ai/dsh-tool-tasks',
|
||||
dir: 'tool-tasks',
|
||||
@@ -442,7 +515,7 @@ const TOOL_PACKAGES: ToolPackage[] = [
|
||||
/** One package's contribution to the catalog: its schemas plus attribution. */
|
||||
interface CatalogPackage {
|
||||
pkg: string
|
||||
source: string
|
||||
sources: Readonly<Record<string, string>>
|
||||
requires: string[]
|
||||
writes: string[]
|
||||
shippedNames?: string[]
|
||||
@@ -494,10 +567,13 @@ export async function collectToolCatalog(packages: ToolPackage[] = TOOL_PACKAGES
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRegistry, entry.toolsConfig ?? {})
|
||||
await entry.mount(ctx)
|
||||
const schemas = ctx.tools.schemas().sort((a, b) => a.name.localeCompare(b.name))
|
||||
const schemas = ctx.tools.schemas(entry.scope?.(ctx)).sort((a, b) => a.name.localeCompare(b.name))
|
||||
catalog.push({
|
||||
pkg: entry.pkg,
|
||||
source: entry.source,
|
||||
sources: Object.fromEntries(schemas.map(schema => [
|
||||
schema.name,
|
||||
toolSource(entry, schema.name),
|
||||
])),
|
||||
requires: entry.requires,
|
||||
writes: entry.writes,
|
||||
schemas,
|
||||
@@ -511,6 +587,18 @@ export async function collectToolCatalog(packages: ToolPackage[] = TOOL_PACKAGES
|
||||
return catalog
|
||||
}
|
||||
|
||||
/** Resolve one harvested tool to the plugin source that registered it. */
|
||||
function toolSource(entry: ToolPackage, toolName: string): string {
|
||||
if (typeof entry.source === 'string') return entry.source
|
||||
const source = entry.source[toolName]
|
||||
if (source === undefined) {
|
||||
throw new Error(
|
||||
`gen-tool-catalog: ${entry.pkg} has no source mapping for harvested tool ${toolName}`,
|
||||
)
|
||||
}
|
||||
return source
|
||||
}
|
||||
|
||||
/** Render one tool's entry: name, description, JSON-Schema parameters, source. */
|
||||
function renderTool(schema: ToolSchema, source: string): string[] {
|
||||
const out = [`### \`${schema.name}\``, '']
|
||||
@@ -553,7 +641,11 @@ export function render(catalog: ToolCatalog): string {
|
||||
]
|
||||
for (const entry of catalog) {
|
||||
lines.push(`## \`${entry.pkg}\``, '')
|
||||
for (const schema of entry.schemas) lines.push(...renderTool(schema, entry.source))
|
||||
for (const schema of entry.schemas) {
|
||||
// Collection validated that every harvested schema has a source.
|
||||
const source = entry.sources[schema.name] as string
|
||||
lines.push(...renderTool(schema, source))
|
||||
}
|
||||
if (entry.note) lines.push(entry.note, '')
|
||||
}
|
||||
return lines.join('\n')
|
||||
|
||||
@@ -7,23 +7,34 @@
|
||||
# ~/.dsh/source/master), adds a per-install staging worktree at
|
||||
# ~/.dsh/source/staging-<timestamp> on branch dsh-staging/<timestamp>, checks
|
||||
# host dependencies (git, Node, pnpm) and offers to install a missing pnpm, runs
|
||||
# `pnpm install` (no build — the `bin/dsh` launcher runs the TypeScript source
|
||||
# through the repo's own tsx), points the stable `~/.dsh/source/current` symlink
|
||||
# `pnpm install`, points the stable `~/.dsh/source/current` symlink
|
||||
# at that staging worktree and symlinks `dsh` onto PATH at `current/bin/dsh`,
|
||||
# records your API credentials in the Harness home (`~/.dsh`) dsh reads at boot,
|
||||
# and drops you into `dsh`. Keeping every checkout under ~/.dsh/source keeps
|
||||
# successive upgrades in one place instead of scattered sibling clones, and lets
|
||||
# staging worktrees share the master clone's object store. The PATH symlink
|
||||
# resolves through `current`, so an upgrade repoints one stable symlink instead
|
||||
# of relinking PATH: the `dsh` on PATH never moves and can never dangle.
|
||||
# and lets you launch the Web UI or TUI. The Web choice builds the repository
|
||||
# artifacts first; the TUI runs directly from TypeScript source through the
|
||||
# repo's own tsx. Keeping every checkout under ~/.dsh/source keeps successive
|
||||
# upgrades in one place instead of scattered sibling clones, and lets staging
|
||||
# worktrees share the master clone's object store. The PATH symlink resolves through
|
||||
# `current`, so an upgrade repoints one stable symlink instead of relinking PATH:
|
||||
# the `dsh` on PATH never moves and can never dangle.
|
||||
#
|
||||
# When run from inside an existing checkout (e.g. `sh scripts/install.sh` rather
|
||||
# than `curl ... | sh`) it reuses that checkout in place and skips the
|
||||
# clone/worktree setup, leaving the working tree untouched and linking `dsh`
|
||||
# straight at that checkout's `bin/dsh` (no `current` indirection — the checkout
|
||||
# is not a managed staging worktree under the source container); DSH_REF is
|
||||
# ignored in that mode. Setting DSH_SOURCE to a different directory opts back
|
||||
# into the normal clone/worktree path.
|
||||
# than `curl ... | sh`) it never clones and never touches that working tree;
|
||||
# DSH_REF is ignored. Instead it *adopts* the checkout: `git rev-parse
|
||||
# --git-common-dir` resolves the repository behind it (for a linked worktree that
|
||||
# is the real clone, not the worktree), and a fresh staging worktree branched
|
||||
# from the checkout's HEAD lands in the source container beside `current`. The
|
||||
# container owns staging worktrees and `current`; the clone is discovered, not
|
||||
# owned, so an arbitrary clone (~/src/dsh) and a managed one converge on one
|
||||
# layout and stay upgradable. Adoption carries committed work only: the staging
|
||||
# worktree branches from HEAD, so uncommitted changes stay in the checkout.
|
||||
# Setting DSH_SOURCE to a different directory opts back into the normal
|
||||
# clone/worktree path.
|
||||
#
|
||||
# Adopting an arbitrary clone leaves the container not self-contained: its
|
||||
# staging worktrees hold an absolute gitdir pointer into that clone, so deleting
|
||||
# it breaks them. `git worktree list` in that clone is the record of which
|
||||
# worktrees depend on it.
|
||||
#
|
||||
# When run through `curl | sh` the script text arrives on stdin, so every
|
||||
# prompt and the final launch read the controlling terminal (/dev/tty) directly;
|
||||
@@ -43,16 +54,16 @@ set -eu
|
||||
|
||||
DSH_REF=${DSH_REF:-master}
|
||||
DSH_REPO=${DSH_REPO:-https://github.com/deepseek-harness/deepseek-harness.git}
|
||||
# DSH_SOURCE is the container directory that holds the master clone and every
|
||||
# staging worktree; DSH_MASTER is the one real clone inside it. Remember whether
|
||||
# the caller pinned the source container before defaulting it, so in-repo
|
||||
# detection only repoints an unset DSH_SOURCE.
|
||||
# DSH_SOURCE is the staging-worktree container and the default home of `current`.
|
||||
# DSH_MASTER names the main clone: clone mode defaults it inside DSH_SOURCE,
|
||||
# while adoption discovers an existing clone anywhere on disk. Remember whether
|
||||
# DSH_SOURCE was explicit so a different path selects clone mode.
|
||||
if [ -n "${DSH_SOURCE:-}" ]; then DSH_SOURCE_EXPLICIT=1; else DSH_SOURCE_EXPLICIT=0; fi
|
||||
DSH_SOURCE=${DSH_SOURCE:-$HOME/.dsh/source}
|
||||
DSH_MASTER=${DSH_MASTER:-$DSH_SOURCE/master}
|
||||
# The stable symlink the PATH launcher resolves through: PATH -> current/bin/dsh
|
||||
# -> <staging>/bin/dsh. Fresh installs and upgrades repoint this one symlink; the
|
||||
# PATH launcher itself is written once and never moves. In-repo reuse ignores it.
|
||||
# The stable symlink the PATH launcher resolves through: PATH/dsh ->
|
||||
# current/bin/dsh -> <staging>/bin/dsh. Installs and upgrades repoint `current`;
|
||||
# the PATH target remains current/bin/dsh.
|
||||
DSH_CURRENT=${DSH_CURRENT:-$DSH_SOURCE/current}
|
||||
DSH_BIN_DIR=${DSH_BIN_DIR:-$HOME/.local/bin}
|
||||
# One UTC basic timestamp names this install's staging branch and worktree.
|
||||
@@ -60,26 +71,44 @@ DSH_STAMP=$(date -u +%Y%m%dT%H%M%SZ)
|
||||
DSH_STAGING_BRANCH=dsh-staging/$DSH_STAMP
|
||||
DSH_STAGING=$DSH_SOURCE/staging-$DSH_STAMP
|
||||
|
||||
# --- path helpers ---------------------------------------------------------------
|
||||
# Every path comparison below runs on physical paths. Git always reports resolved
|
||||
# paths, so comparing one against an unresolved path disagrees whenever a symlink
|
||||
# sits anywhere above the checkout — a symlinked home directory is enough, and
|
||||
# macOS reaches every mktemp path that way through /var -> private/var. The
|
||||
# mismatch silently misclassifies an existing managed install as a foreign clone
|
||||
# and builds a second container beside the real one.
|
||||
# `git rev-parse --path-format=absolute` would do this, but it needs git 2.31+.
|
||||
#
|
||||
# A not-yet-created directory (the container on a fresh install) has no physical
|
||||
# path. Falling back here rather than at each call site keeps every caller a
|
||||
# plain assignment, so no site can compare against an empty path by forgetting
|
||||
# its own fallback.
|
||||
resolve_dir() { CDPATH= cd -- "$1" 2>/dev/null && pwd -P || printf '%s\n' "$1"; }
|
||||
|
||||
# --- in-repo detection ---------------------------------------------------------
|
||||
# Under `curl ... | sh` the script text arrives on stdin, so $0 is the shell
|
||||
# name and no file path resolves; running a checked-out copy (`sh
|
||||
# scripts/install.sh`) makes $0 the script file. When $0 is a readable file whose
|
||||
# parent is a scripts/ dir inside a real dsh checkout (bin/dsh launcher present),
|
||||
# reuse that checkout in place — link `dsh` straight at it and skip the
|
||||
# clone/worktree setup. An explicit DSH_SOURCE pointing elsewhere opts back into
|
||||
# the clone/worktree path.
|
||||
# this is in-repo mode: never clone, never touch that working tree. An explicit
|
||||
# DSH_SOURCE pointing elsewhere opts back into the clone/worktree path.
|
||||
IN_REPO=0
|
||||
DSH_CHECKOUT=''
|
||||
if [ -f "$0" ]; then
|
||||
_self_dir=$(CDPATH= cd -- "$(dirname -- "$0")" 2>/dev/null && pwd -P) || _self_dir=''
|
||||
_self_dir=$(resolve_dir "$(dirname -- "$0")")
|
||||
if [ -n "$_self_dir" ]; then
|
||||
# Physical without its own resolve_dir: dirname is textual, so trimming a
|
||||
# resolved path leaves one. The comparison below depends on that.
|
||||
_repo_root=$(dirname -- "$_self_dir")
|
||||
if [ "$(basename -- "$_self_dir")" = scripts ] \
|
||||
&& [ -x "$_repo_root/bin/dsh" ] && [ -f "$_repo_root/scripts/install.sh" ]; then
|
||||
if [ "$DSH_SOURCE_EXPLICIT" = 0 ] || [ "$DSH_SOURCE" = "$_repo_root" ]; then
|
||||
# Compare the explicit DSH_SOURCE physically: an unresolved but equivalent
|
||||
# path must still count as "the caller meant this checkout".
|
||||
_src_resolved=$(resolve_dir "$DSH_SOURCE")
|
||||
if [ "$DSH_SOURCE_EXPLICIT" = 0 ] || [ "$_src_resolved" = "$_repo_root" ]; then
|
||||
IN_REPO=1
|
||||
# In-repo reuse links `dsh` at this checkout as-is; the master/staging
|
||||
# split applies only to fresh clone installs.
|
||||
DSH_STAGING=$_repo_root
|
||||
DSH_CHECKOUT=$_repo_root
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
@@ -148,7 +177,7 @@ confirm() {
|
||||
|
||||
printf '%s\n' "${B}DeepSeek Harness — dsh installer${RST}"
|
||||
if [ "$IN_REPO" = 1 ]; then
|
||||
printf '%ssource %s (in-repo reuse) @ %s%s\n' "$DIM" "$DSH_STAGING" "$DSH_REF" "$RST"
|
||||
printf '%scheckout %s%s\n' "$DIM" "$DSH_CHECKOUT" "$RST"
|
||||
else
|
||||
printf '%smaster %s @ %s%s\n' "$DIM" "$DSH_MASTER" "$DSH_REF" "$RST"
|
||||
printf '%sstaging %s%s\n' "$DIM" "$DSH_STAGING" "$RST"
|
||||
@@ -186,7 +215,7 @@ fi
|
||||
|
||||
# pnpm is the only dependency we offer to install for you.
|
||||
if command -v pnpm >/dev/null 2>&1; then
|
||||
info "pnpm $(pnpm --version 2>/dev/null) ... ok"
|
||||
info "pnpm $(pnpm --version) ... ok"
|
||||
else
|
||||
warn "pnpm is not installed."
|
||||
if confirm "Install pnpm now?" Y; then
|
||||
@@ -203,42 +232,84 @@ else
|
||||
fi
|
||||
fi
|
||||
|
||||
# --- 2. clone the master and lay out the staging worktree ---------------------
|
||||
# Fresh installs keep one real clone at $DSH_MASTER and check the running code
|
||||
# out as a git worktree at $DSH_STAGING, so every checkout lives under
|
||||
# $DSH_SOURCE and shares one object store. In-repo reuse links `dsh` at the
|
||||
# existing checkout untouched.
|
||||
# --- 2. resolve the repository and lay out the staging worktree ---------------
|
||||
# The source container owns staging worktrees and `current`; the repository is
|
||||
# *discovered*, not owned. A curl install discovers it by cloning to $DSH_MASTER;
|
||||
# in-repo adoption discovers it from the checkout. Both then run one shared
|
||||
# worktree/exclude/lock path, so an arbitrary clone and a managed install
|
||||
# converge on the same layout.
|
||||
#
|
||||
# REPO_COMMON is the shared git directory every worktree of the repository
|
||||
# points at; REPO_ROOT is the working tree that owns it (the master clone).
|
||||
REPO_COMMON=''
|
||||
REPO_ROOT=''
|
||||
|
||||
if [ "$IN_REPO" = 1 ]; then
|
||||
step "Using existing checkout at $DSH_STAGING"
|
||||
info "running from inside the repo — skipping clone (DSH_REF ignored, working tree left untouched)"
|
||||
step "Using existing checkout at $DSH_CHECKOUT"
|
||||
info "running from inside the repo — never cloning, and DSH_REF is ignored"
|
||||
|
||||
# Resolve the repository behind the checkout. --git-common-dir returns the
|
||||
# SHARED git dir, so a linked worktree resolves to the real clone rather than
|
||||
# itself; it is relative for a plain clone, so anchor it before resolving.
|
||||
# Require the resolved git dir to exist: resolve_dir echoes its argument back
|
||||
# for a missing path, so test the directory rather than the returned string.
|
||||
if _common=$(git -C "$DSH_CHECKOUT" rev-parse --git-common-dir 2>/dev/null) && [ -n "$_common" ]; then
|
||||
case "$_common" in /*) ;; *) _common=$DSH_CHECKOUT/$_common ;; esac
|
||||
[ -d "$_common" ] && REPO_COMMON=$(resolve_dir "$_common")
|
||||
fi
|
||||
[ -n "$REPO_COMMON" ] || die "$DSH_CHECKOUT is not a git repository — cannot adopt it."
|
||||
REPO_ROOT=$(dirname -- "$REPO_COMMON")
|
||||
|
||||
# Reuse the container when the repository already lives inside it (the normal
|
||||
# managed install re-running its own script); otherwise treat that clone as
|
||||
# its own master and keep worktrees in the default container.
|
||||
_src_resolved=$(resolve_dir "$DSH_SOURCE")
|
||||
case "$REPO_ROOT/" in
|
||||
"$_src_resolved"/*) info "repository $REPO_ROOT is already inside $DSH_SOURCE" ;;
|
||||
*) info "adopting clone $REPO_ROOT as its own master" ;;
|
||||
esac
|
||||
DSH_MASTER=$REPO_ROOT
|
||||
else
|
||||
step "Fetching source into $DSH_MASTER"
|
||||
if [ -d "$DSH_MASTER/.git" ]; then
|
||||
info "existing master clone found — updating"
|
||||
git -C "$DSH_MASTER" fetch origin "$DSH_REF"
|
||||
# Reset the master checkout to the freshly fetched tip. FETCH_HEAD (not
|
||||
# origin/<ref>) so this resolves for a tag as well as a branch, and -B makes
|
||||
# the re-run idempotent whether or not DSH_REF changed since the last install.
|
||||
git -C "$DSH_MASTER" checkout -q -B "$DSH_REF" FETCH_HEAD
|
||||
else
|
||||
mkdir -p "$DSH_SOURCE"
|
||||
git clone --branch "$DSH_REF" "$DSH_REPO" "$DSH_MASTER"
|
||||
step "Fetching source into $DSH_MASTER"
|
||||
if [ -d "$DSH_MASTER/.git" ]; then
|
||||
info "existing master clone found — updating"
|
||||
git -C "$DSH_MASTER" fetch origin "$DSH_REF"
|
||||
# Reset the master checkout to the freshly fetched tip. FETCH_HEAD (not
|
||||
# origin/<ref>) so this resolves for a tag as well as a branch, and -B makes
|
||||
# the re-run idempotent whether or not DSH_REF changed since the last install.
|
||||
git -C "$DSH_MASTER" checkout -q -B "$DSH_REF" FETCH_HEAD
|
||||
else
|
||||
mkdir -p "$DSH_SOURCE"
|
||||
git clone --branch "$DSH_REF" "$DSH_REPO" "$DSH_MASTER"
|
||||
fi
|
||||
# Physical on both branches: REPO_ROOT is compared against resolved paths
|
||||
# below, and REPO_COMMON stays symmetric with it so neither can be read as
|
||||
# carrying a different kind of path.
|
||||
REPO_COMMON=$(resolve_dir "$DSH_MASTER/.git")
|
||||
REPO_ROOT=$(resolve_dir "$DSH_MASTER")
|
||||
fi
|
||||
|
||||
step "Adding staging worktree at $DSH_STAGING"
|
||||
[ -e "$DSH_STAGING" ] && die "staging path $DSH_STAGING already exists — remove it or set DSH_SOURCE elsewhere, then re-run."
|
||||
# The staging worktree owns the branch dsh runs from; the master clone stays on
|
||||
# $DSH_REF as the fetch/upgrade base. Exclude the per-worktree merge lock in the
|
||||
# master clone's info/exclude, which every linked worktree inherits.
|
||||
git -C "$DSH_MASTER" worktree add -b "$DSH_STAGING_BRANCH" "$DSH_STAGING" FETCH_HEAD 2>/dev/null \
|
||||
|| git -C "$DSH_MASTER" worktree add -b "$DSH_STAGING_BRANCH" "$DSH_STAGING" HEAD
|
||||
_exclude="$DSH_MASTER/.git/info/exclude"
|
||||
mkdir -p "$DSH_SOURCE"
|
||||
# The staging worktree owns the branch dsh runs from; the repository stays as
|
||||
# the fetch/upgrade base and is never a launcher target. A clone install
|
||||
# branches from the ref it just fetched; adoption branches from the checkout's
|
||||
# HEAD so the contributor's committed work is what runs.
|
||||
if [ "$IN_REPO" = 1 ]; then
|
||||
git -C "$DSH_CHECKOUT" worktree add -b "$DSH_STAGING_BRANCH" "$DSH_STAGING" HEAD
|
||||
else
|
||||
git -C "$DSH_MASTER" worktree add -b "$DSH_STAGING_BRANCH" "$DSH_STAGING" FETCH_HEAD 2>/dev/null \
|
||||
|| git -C "$DSH_MASTER" worktree add -b "$DSH_STAGING_BRANCH" "$DSH_STAGING" HEAD
|
||||
fi
|
||||
# Exclude the per-worktree merge lock in the shared git dir's info/exclude,
|
||||
# which every linked worktree inherits.
|
||||
_exclude="$REPO_COMMON/info/exclude"
|
||||
if [ -f "$_exclude" ] && ! grep -qxF '.agents/merge.lock' "$_exclude" 2>/dev/null; then
|
||||
printf '.agents/merge.lock\n' >>"$_exclude"
|
||||
fi
|
||||
mkdir -p "$DSH_STAGING/.agents"
|
||||
: >"$DSH_STAGING/.agents/merge.lock"
|
||||
fi
|
||||
|
||||
# --- 3. install dependencies (no build; the launcher runs from source) --------
|
||||
step "Installing dependencies with pnpm (this can take a while)"
|
||||
@@ -247,29 +318,29 @@ step "Installing dependencies with pnpm (this can take a while)"
|
||||
[ -x "$DSH_STAGING/bin/dsh" ] || die "launcher $DSH_STAGING/bin/dsh missing after install — is DSH_REF a branch that ships apps/cli?"
|
||||
|
||||
# --- 4. put `dsh` on PATH ------------------------------------------------------
|
||||
# Clone installs go through a stable `current` symlink so an upgrade repoints
|
||||
# Every install goes through a stable `current` symlink so an upgrade repoints
|
||||
# one symlink (current -> new worktree) and the PATH launcher never moves:
|
||||
# PATH/dsh -> current/bin/dsh -> <staging>/bin/dsh. In-repo reuse links PATH
|
||||
# straight at the checkout, since that checkout is not a managed worktree.
|
||||
# PATH/dsh -> current/bin/dsh -> <staging>/bin/dsh.
|
||||
step "Linking dsh into $DSH_BIN_DIR"
|
||||
mkdir -p "$DSH_BIN_DIR"
|
||||
if [ "$IN_REPO" = 1 ]; then
|
||||
DSH_LAUNCH_TARGET=$DSH_STAGING/bin/dsh
|
||||
ln -sf "$DSH_LAUNCH_TARGET" "$DSH_BIN_DIR/dsh"
|
||||
info "linked $DSH_BIN_DIR/dsh -> $DSH_LAUNCH_TARGET"
|
||||
else
|
||||
# Point `current` at this staging worktree with `ln -sfn`: -f replaces an
|
||||
# existing `current` (re-run or upgrade) and -n stops `ln` from dereferencing
|
||||
# an existing symlink-to-directory and dropping the new link *inside* the old
|
||||
# worktree. `mv` is unusable here — BSD/macOS `mv` follows the existing dir
|
||||
# symlink the same way. The swap is one unlink+symlink pair on a local fs; the
|
||||
# installer holds no other process racing this path.
|
||||
ln -sfn "$DSH_STAGING" "$DSH_CURRENT"
|
||||
info "pointed $DSH_CURRENT -> $DSH_STAGING"
|
||||
DSH_LAUNCH_TARGET=$DSH_CURRENT/bin/dsh
|
||||
ln -sf "$DSH_LAUNCH_TARGET" "$DSH_BIN_DIR/dsh"
|
||||
info "linked $DSH_BIN_DIR/dsh -> $DSH_LAUNCH_TARGET"
|
||||
fi
|
||||
# The launcher must resolve to a staging worktree, never to the repository
|
||||
# itself: an upgrade repoints `current`, so aliasing it onto the master clone
|
||||
# would make every upgrade rewrite the fetch/upgrade base. Compare physical
|
||||
# paths — a symlinked or unresolved path would slip past a string compare.
|
||||
_staging_resolved=$(resolve_dir "$DSH_STAGING")
|
||||
[ "$_staging_resolved" = "$REPO_ROOT" ] \
|
||||
&& die "refusing to point $DSH_CURRENT at the repository $REPO_ROOT — the launcher must resolve to a staging worktree."
|
||||
# Point `current` at this staging worktree with `ln -sfn`: -f replaces an
|
||||
# existing `current` (re-run or upgrade) and -n stops `ln` from dereferencing
|
||||
# an existing symlink-to-directory and dropping the new link *inside* the old
|
||||
# worktree. `mv` is unusable here — BSD/macOS `mv` follows the existing dir
|
||||
# symlink the same way. The swap is one unlink+symlink pair on a local fs; the
|
||||
# installer holds no other process racing this path.
|
||||
ln -sfn "$DSH_STAGING" "$DSH_CURRENT"
|
||||
info "pointed $DSH_CURRENT -> $DSH_STAGING"
|
||||
DSH_LAUNCH_TARGET=$DSH_CURRENT/bin/dsh
|
||||
ln -sf "$DSH_LAUNCH_TARGET" "$DSH_BIN_DIR/dsh"
|
||||
info "linked $DSH_BIN_DIR/dsh -> $DSH_LAUNCH_TARGET"
|
||||
|
||||
case ":$PATH:" in
|
||||
*":$DSH_BIN_DIR:"*) ON_PATH=1 ;;
|
||||
@@ -343,12 +414,33 @@ if [ "${SKIP_CREDS:-0}" != 1 ]; then
|
||||
fi
|
||||
fi
|
||||
|
||||
# --- 6. launch -----------------------------------------------------------------
|
||||
# --- 6. choose and launch an interface -----------------------------------------
|
||||
step "Done"
|
||||
if [ "$HAS_TTY" = 1 ]; then
|
||||
info "launching dsh — run 'dsh' anytime to start again"
|
||||
exec "$DSH_BIN_DIR/dsh" </dev/tty
|
||||
printf ' 1) Web UI (recommended)\n'
|
||||
printf ' 2) TUI\n'
|
||||
while :; do
|
||||
LAUNCH_INTERFACE=$(ask "Choose an interface [1/2]:" 1)
|
||||
case "$LAUNCH_INTERFACE" in
|
||||
1|web|Web|WEB)
|
||||
step "Building DeepSeek Harness for Web UI"
|
||||
( cd "$DSH_STAGING" && pnpm run build )
|
||||
info "launching Web UI — run 'dsh web' anytime to start again"
|
||||
exec "$DSH_BIN_DIR/dsh" web </dev/tty
|
||||
;;
|
||||
2|tui|Tui|TUI)
|
||||
info "launching TUI — run 'dsh' anytime to start again"
|
||||
exec "$DSH_BIN_DIR/dsh" </dev/tty
|
||||
;;
|
||||
*)
|
||||
warn "choose 1 for Web UI or 2 for TUI"
|
||||
;;
|
||||
esac
|
||||
done
|
||||
else
|
||||
info "install complete. Start it with:"
|
||||
info "install complete. Build and start the Web UI with:"
|
||||
printf ' (cd %s && pnpm run build)\n' "$DSH_STAGING"
|
||||
printf ' %s web\n' "$DSH_BIN_DIR/dsh"
|
||||
info "or start the TUI with:"
|
||||
printf ' %s\n' "$DSH_BIN_DIR/dsh"
|
||||
fi
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -1104,6 +1104,51 @@
|
||||
"symbol": "SubagentStartRequest",
|
||||
"source": "packages/subagent/subagent/src/types.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/core-data-structures/subagent.md",
|
||||
"symbol": "ResolvedSubagentStartRequest",
|
||||
"source": "packages/subagent/subagent/src/types.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/core-data-structures/subagent.md",
|
||||
"symbol": "CoordinatorMessageSource",
|
||||
"source": "packages/subagent/subagent/src/continuation.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/core-data-structures/subagent.md",
|
||||
"symbol": "SubagentReportMessageSource",
|
||||
"source": "packages/subagent/subagent/src/continuation.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/core-data-structures/subagent.md",
|
||||
"symbol": "SubagentReportDelivery",
|
||||
"source": "packages/subagent/subagent/src/continuation.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/core-data-structures/subagent.md",
|
||||
"symbol": "SubagentReportOptions",
|
||||
"source": "packages/subagent/subagent/src/continuation.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/core-data-structures/subagent.md",
|
||||
"symbol": "SubagentFollowupOptions",
|
||||
"source": "packages/subagent/subagent/src/continuation.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/core-data-structures/subagent.md",
|
||||
"symbol": "ContinuableStart",
|
||||
"source": "packages/subagent/subagent/src/continuation.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/core-data-structures/subagent.md",
|
||||
"symbol": "ContinuableCreateRequest",
|
||||
"source": "packages/subagent/subagent/src/types.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/core-data-structures/subagent.md",
|
||||
"symbol": "ContinuableCreateSpec",
|
||||
"source": "packages/subagent/subagent/src/types.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/core-data-structures/subagent.md",
|
||||
"symbol": "SubagentResult",
|
||||
|
||||
72
scripts/verify-vendored-links.ts
Normal file
72
scripts/verify-vendored-links.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
/**
|
||||
* Verify that pnpm-lock.yaml resolves every vendored package name to its
|
||||
* workspace `link:` — never a registry copy. `linkWorkspacePackages: true`
|
||||
* (pnpm-workspace.yaml) makes matching upstream semver ranges resolve to the
|
||||
* pinned vendored sources; a registry copy of the same name coexisting with
|
||||
* the vendored one silently forks the framework layer (vendor/README.md).
|
||||
*/
|
||||
import { readdir, readFile } from 'node:fs/promises'
|
||||
import { join, resolve } from 'node:path'
|
||||
import * as yaml from 'js-yaml'
|
||||
|
||||
const root = resolve(import.meta.dirname, '..')
|
||||
|
||||
async function vendoredNames(): Promise<Set<string>> {
|
||||
const names = new Set<string>()
|
||||
for (const entry of await readdir(join(root, 'vendor'), { withFileTypes: true })) {
|
||||
if (!entry.isDirectory()) continue
|
||||
let manifest: { name?: string }
|
||||
try {
|
||||
manifest = JSON.parse(await readFile(join(root, 'vendor', entry.name, 'package.json'), 'utf8')) as { name?: string }
|
||||
} catch {
|
||||
continue // not a package directory (e.g. vendor/README.md siblings)
|
||||
}
|
||||
if (manifest.name !== undefined) names.add(manifest.name)
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
||||
interface Lockfile {
|
||||
importers?: Record<string, Record<string, unknown>>
|
||||
packages?: Record<string, unknown>
|
||||
snapshots?: Record<string, unknown>
|
||||
}
|
||||
|
||||
const names = await vendoredNames()
|
||||
if (names.size === 0) throw new Error('verify-vendored-links: no vendored package manifests found under vendor/')
|
||||
const lockfile = yaml.load(await readFile(join(root, 'pnpm-lock.yaml'), 'utf8')) as Lockfile
|
||||
|
||||
const violations: string[] = []
|
||||
|
||||
// Importer resolutions: every dependency entry naming a vendored package must
|
||||
// resolve to a link:, or the build silently uses a registry copy.
|
||||
for (const [importer, sections] of Object.entries(lockfile.importers ?? {})) {
|
||||
for (const [section, dependencies] of Object.entries(sections)) {
|
||||
if (typeof dependencies !== 'object' || dependencies === null) continue
|
||||
for (const [dependency, entry] of Object.entries(dependencies as Record<string, { version?: string }>)) {
|
||||
if (!names.has(dependency)) continue
|
||||
const version = entry.version ?? ''
|
||||
if (!version.startsWith('link:')) {
|
||||
violations.push(`${importer} ${section}.${dependency} resolves to ${JSON.stringify(version)} (expected link:)`)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Package/snapshot keys: a registry copy materializes as a `<name>@<version>`
|
||||
// key; vendored names must never appear there at all.
|
||||
for (const section of ['packages', 'snapshots'] as const) {
|
||||
for (const key of Object.keys(lockfile[section] ?? {})) {
|
||||
const atIndex = key.lastIndexOf('@')
|
||||
if (atIndex <= 0) continue
|
||||
const packageName = key.slice(0, atIndex)
|
||||
if (names.has(packageName)) violations.push(`${section} entry ${key} is a registry copy of a vendored package`)
|
||||
}
|
||||
}
|
||||
|
||||
if (violations.length > 0) {
|
||||
console.error(`verify-vendored-links: ${String(violations.length)} lockfile resolution(s) bypass the vendored workspaces:`)
|
||||
for (const violation of violations) console.error(` - ${violation}`)
|
||||
process.exit(1)
|
||||
}
|
||||
console.log(`verify-vendored-links: all ${String(names.size)} vendored package names resolve to workspace links.`)
|
||||
Reference in New Issue
Block a user