feat: add economy/maximum presets, tool-lab and subagent-cursor extensions
Some checks failed
CI / windows node 24 / wine blocking (push) Has been skipped
CI / node 22.19 (push) Has been skipped
CI / node 26 (push) Has been skipped
CI / python 3.10 / keyless SDK (push) Has been skipped
CI / python runtime / release-shaped Linux x64 (push) Has been skipped
CI / wine apt cache (push) Successful in 7s
CI / serial / linux (push) Has been skipped
Deploy documentation / build (push) Failing after 1m25s
Deploy documentation / deploy (push) Has been skipped
Landlock Run / Matrix (push) Successful in 5s
Release (vendor) / Pack npm tarballs (push) Failing after 2m47s
Release (dsh) / Pack npm tarballs (push) Failing after 1m56s
Sandbox / sandbox e2e (landlock, ubuntu-24.04) (push) Failing after 1m57s
Sandbox / sandbox e2e (bwrap, ubuntu-latest) (push) Failing after 1m19s
Release (vendor) / Publish to npm (push) Has been skipped
Release (dsh) / Publish to npm (push) Has been skipped
CI / serial / windows (self-hosted standby) (push) Has been cancelled
CI / larger-runner-benchmark (16, linux, dsh-ubuntu-24-04-16core, typecheck) (push) Has been cancelled
Landlock Run / darwin (no platform package — degradation proof) (push) Has been cancelled
Landlock Run / ${{ matrix.platform }} (push) Has been cancelled
CI / node 24 / static (push) Has been cancelled
CI / node 24 / coverage (push) Has been cancelled
CI / node 24 / snapshots and artifacts (push) Has been cancelled
CI / windows node 24 / native complete (push) Has been cancelled
CI / serial / linux (self-hosted standby) (push) Has been cancelled
CI / serial / macos (push) Has been cancelled
CI / larger-runner-benchmark (16, windows, dsh-windows-2025-16core, production-site) (push) Has been cancelled
CI / larger-runner-benchmark (32, linux, dsh-ubuntu-24-04-32core, typecheck) (push) Has been cancelled
CI / larger-runner-benchmark (32, windows, dsh-windows-2025-32core, production-site) (push) Has been cancelled
CI / larger-runner-benchmark (4, linux, dsh-ubuntu-24-04-4core, typecheck) (push) Has been cancelled
CI / larger-runner-benchmark (4, windows, dsh-windows-2025-4core, production-site) (push) Has been cancelled
CI / larger-runner-benchmark (64, linux, dsh-ubuntu-24-04-64core, typecheck) (push) Has been cancelled
CI / larger-runner-benchmark (64, windows, dsh-windows-2025-64core, production-site) (push) Has been cancelled
CI / larger-runner-benchmark (8, windows, dsh-windows-2025-8core, production-site) (push) Has been cancelled
CI / larger-runner-benchmark (96, linux, dsh-ubuntu-24-04-96core, typecheck) (push) Has been cancelled
CI / larger-runner-benchmark (96, windows, dsh-windows-2025-96core, production-site) (push) Has been cancelled
CI / consolidated-runner-benchmark (16, linux, dsh-ubuntu-24-04-16core, 16) (push) Has been cancelled
CI / consolidated-runner-benchmark (16, windows, dsh-windows-2025-16core, 2) (push) Has been cancelled
CI / consolidated-runner-benchmark (32, linux, dsh-ubuntu-24-04-32core, 32) (push) Has been cancelled
CI / consolidated-runner-benchmark (32, windows, dsh-windows-2025-32core, 2) (push) Has been cancelled
CI / consolidated-runner-benchmark (4, linux, dsh-ubuntu-24-04-4core, 4) (push) Has been cancelled
CI / consolidated-runner-benchmark (4, windows, dsh-windows-2025-4core, 2) (push) Has been cancelled
CI / consolidated-runner-benchmark (64, linux, dsh-ubuntu-24-04-64core, 32) (push) Has been cancelled
CI / consolidated-runner-benchmark (64, windows, dsh-windows-2025-64core, 2) (push) Has been cancelled
CI / consolidated-runner-benchmark (8, linux, dsh-ubuntu-24-04-8core, 8) (push) Has been cancelled
CI / consolidated-runner-benchmark (8, windows, dsh-windows-2025-8core, 2) (push) Has been cancelled
CI / consolidated-runner-benchmark (96, linux, dsh-ubuntu-24-04-96core, 32) (push) Has been cancelled
CI / consolidated-runner-benchmark (96, windows, dsh-windows-2025-96core, 2) (push) Has been cancelled
CI / all checks passed (push) Has been cancelled
Sandbox / sandbox e2e (seatbelt, macos-latest) (push) Has been cancelled
CI / larger-runner-benchmark (8, linux, dsh-ubuntu-24-04-8core, typecheck) (push) Has been cancelled
Sandbox / sandbox e2e (landlock, ubuntu-24.04-arm) (push) Has been cancelled
E2E (real DeepSeek API) / e2e (push) Failing after 1m24s

- new economy and maximum agent presets with three-role pipeline skill
- new packages/extensions/tool-lab (home-lab ComfyUI/Docling/Whishper tools)
- new packages/subagent/subagent-cursor provider
- openrouter balance UI with on-demand refresh
- session projection context-seed boundary fold
- regenerate docs catalogs; keep local searxng benchmark scripts
This commit is contained in:
2026-08-23 22:12:30 +07:00
parent e818e37f0a
commit 8719fa84f0
112 changed files with 5686 additions and 169 deletions

View File

@@ -302,6 +302,15 @@
config:
providerName: fork
- id: subagent-codex
name: '@deepseek-ai/dsh-subagent-codex'
- id: subagent-claude-code
name: '@deepseek-ai/dsh-subagent-claude-code'
- id: subagent-cursor
name: '@deepseek-ai/dsh-subagent-cursor'
# Continuable background children are selected per delegation tool. The
# separately loaded follow-up tool registers the one global `send_message`.
- id: tool-subagent-control

View File

@@ -64,6 +64,8 @@
"@deepseek-ai/dsh-llm-deepseek": "workspace:^",
"@deepseek-ai/dsh-llm-pi-ai": "workspace:^",
"@deepseek-ai/dsh-llm-retry": "workspace:^",
"@deepseek-ai/dsh-lsp": "workspace:^",
"@deepseek-ai/dsh-lsp-stdio": "workspace:^",
"@deepseek-ai/dsh-permission-presets": "workspace:^",
"@deepseek-ai/dsh-plan-mode": "workspace:^",
"@deepseek-ai/dsh-pwsh-sandbox": "workspace:^",
@@ -85,6 +87,9 @@
"@deepseek-ai/dsh-spill-local": "workspace:^",
"@deepseek-ai/dsh-spill-policy": "workspace:^",
"@deepseek-ai/dsh-subagent": "workspace:^",
"@deepseek-ai/dsh-subagent-claude-code": "workspace:^",
"@deepseek-ai/dsh-subagent-codex": "workspace:^",
"@deepseek-ai/dsh-subagent-cursor": "workspace:^",
"@deepseek-ai/dsh-subagent-fork-in-process": "workspace:^",
"@deepseek-ai/dsh-subagent-spawn-in-process": "workspace:^",
"@deepseek-ai/dsh-subprocess-local": "workspace:^",
@@ -104,6 +109,7 @@
"@deepseek-ai/dsh-tool-subagent-control": "workspace:^",
"@deepseek-ai/dsh-tool-subagent-report": "workspace:^",
"@deepseek-ai/dsh-tool-jobs": "workspace:^",
"@deepseek-ai/dsh-tool-lsp": "workspace:^",
"@deepseek-ai/dsh-tool-todo": "workspace:^",
"@deepseek-ai/dsh-tool-web": "workspace:^",
"@deepseek-ai/dsh-tool-workflow": "workspace:^",

View File

@@ -35,10 +35,18 @@ describe('dsh-base bundle', () => {
expect(rows.find(row => row.id === 'session-telemetry-otel')?.config?.['mode']).toEqual({
__jsExpr: "process.env.DSH_TELEMETRY_MODE || 'DISABLED'",
})
expect(rows.filter(row => row.id === 'subagent-codex')).toHaveLength(0)
expect(rows.filter(row => row.id === 'subagent-claude-code')).toHaveLength(0)
expect(manifest.dependencies).not.toHaveProperty('@deepseek-ai/dsh-subagent-codex')
expect(manifest.dependencies).not.toHaveProperty('@deepseek-ai/dsh-subagent-claude-code')
// The base layer mounts each external product agent exactly once, so a
// Profile enables one by removing `disabled` from its Agent Preset tool
// row rather than mounting a duplicate provider. Loading a provider starts
// no product process; a run still needs that product's own CLI on PATH.
for (const [id, dependency] of [
['subagent-codex', '@deepseek-ai/dsh-subagent-codex'],
['subagent-claude-code', '@deepseek-ai/dsh-subagent-claude-code'],
['subagent-cursor', '@deepseek-ai/dsh-subagent-cursor'],
] as const) {
expect(rows.filter(row => row.id === id)).toHaveLength(1)
expect(manifest.dependencies).toHaveProperty(dependency)
}
})
it('gates each shell stack by platform with a symmetric disabled expression', () => {

View File

@@ -8,6 +8,7 @@ export type AgentPresetSettingsKey =
| 'presetCodeName' | 'presetCodeDescription'
| 'presetMinimalName' | 'presetMinimalDescription'
| 'presetCordisName' | 'presetCordisDescription'
| 'presetMaximumName' | 'presetMaximumDescription'
| 'duplicate' | 'duplicateUnavailable' | 'delete' | 'presetId' | 'presetIdPlaceholder' | 'copyOf'
| 'displayName' | 'displayNamePlaceholder'
| 'inUse' | 'noDescription' | 'builtInGroup' | 'customGroup'
@@ -46,6 +47,9 @@ export const en: Record<AgentPresetSettingsKey, string> = {
presetCordisName: 'Creator mode',
presetCordisDescription:
'Built for creating custom agent presets, with all Standard mode capabilities plus runtime inspection, plugin experiments, and preset-authoring guidance.',
presetMaximumName: 'Maximum mode',
presetMaximumDescription:
'Every capability this deployment can compose — terminals, language servers, session history, MCP, external agents, and Code Mode beside the native tools — delivering each change through an architect, an implementer, and a reviewer.',
duplicate: 'Duplicate',
duplicateUnavailable: 'This deployment has no writable preset directory',
delete: 'Delete',
@@ -106,6 +110,8 @@ export const zh: Record<AgentPresetSettingsKey, string> = {
presetMinimalDescription: '仅提供持久 bash 与 str_replace_editor 的双工具编码 Agent。',
presetCordisName: '创造模式',
presetCordisDescription: '用于创建自定义 Agent preset具备标准模式的全部能力并提供运行时检查、插件实验和 preset 创作指导。',
presetMaximumName: '全能模式',
presetMaximumDescription: '组合本部署能提供的全部能力 —— 终端、语言服务器、会话历史、MCP、外部 Agent以及与原生工具并存的 Code Mode —— 并由架构师、实现者、审查者三个角色接力交付每一次改动。',
duplicate: '复制',
duplicateUnavailable: '此部署未配置可写的预设目录',
delete: '删除',
@@ -171,6 +177,7 @@ const BUILT_IN_PRESET_KEYS: Readonly<Partial<Record<string, PresetLocaleKeys>>>
code: { name: 'presetCodeName', description: 'presetCodeDescription' },
minimal: { name: 'presetMinimalName', description: 'presetMinimalDescription' },
cordis: { name: 'presetCordisName', description: 'presetCordisDescription' },
maximum: { name: 'presetMaximumName', description: 'presetMaximumDescription' },
}
/**

View File

@@ -13,13 +13,21 @@ only once a step actually priced — no zero-cost group. An unknown-pricing step
count surfaces in a tooltip so the figure's coverage stays visible. `CostDock`
therefore owns no store, refresh chain, or event listener.
The figure covers the session plus its subagent subtree, walked over the
session list's `parentId`/`origin` rows; a subtree share names itself in the
label. Each projection value describes only its own session's work — the host
fold excludes a forked child's inherited prefix — so the walk sums rather than
double counts. Ordinary forks are not delegated work and stay out of it.
`BalanceBadge` is account-global, so it is not a session projection: the slot
inject face carries a `snapshot()` callback that polls the host
`ctx.remote.openRouterUsage.snapshot()` Remote on a 60s interval and drives
local state. In the collapsed rail it renders only the balance marker; in the
wide column a labeled pill. Unknown or absent figures render the empty
placeholder. Both entries render nothing before real data exists, so an
assembly without the OpenRouter gateway or key costs no layout.
local state. Clicking the badge calls the `refresh()` callback instead, which
makes the host fetch the account immediately; the badge marks itself
`aria-busy` for the duration and ignores further clicks until it settles, and
a failed refresh keeps the last-known figure. In the collapsed rail it renders
only the balance marker; in the wide column a labeled pill. Unknown or absent
figures render the empty placeholder.
The `/client` exports are the plugin body (`apply`/`inject`), the
`CostDock`/`BalanceBadge` components, and the injected face types.
@@ -42,5 +50,10 @@ account figure; neither is a new model-visible input.
projection holds; figures for already-folded history reflect the pricing
table at fold time (see `dsh-openrouter-usage`).
- **Polled, not pushed** — `BalanceBadge` polls the host snapshot; there is
no `openRouterUsage` forwarded-event channel, so the figure updates on the
poll interval rather than instantly.
no `openRouterUsage` forwarded-event channel, so an account change the user
did not click for surfaces on the poll interval rather than instantly.
- **A cold subagent contributes nothing** — the subtree walk reads the
descendants' projection values the client holds. A subagent session never
opened, and with no version-matching row in the host's cold projection
cache, carries no value and is indistinguishable from a zero-cost child, so
its spend is missing from the total rather than marked absent.

View File

@@ -14,7 +14,12 @@
color: var(--dsw-alias-label-tertiary);
font-size: 12px;
line-height: 28px;
cursor: default;
cursor: pointer;
}
.badge[aria-busy='true'] {
cursor: progress;
opacity: 0.6;
}
.badge:hover {

View File

@@ -2,10 +2,11 @@
// plus the latest snapshot's USD figure in both widths (rail = marker-only,
// wide = label + figure). The value is account-global, so it is not a session
// projection: an injected `snapshot` callback polls the host Remote gateway on
// an interval and drives local state. `wide` arrives from the sidebar's owner
// share; `snapshot` from the register's inject face.
// an interval, a click runs `refresh` for an on-demand read, and both drive
// local state. `wide` arrives from the sidebar's owner share; the two
// callbacks from the register's inject face.
import { useEffect, useState } from 'react'
import { useCallback, useEffect, useRef, useState } from 'react'
import type { OpenRouterBalance } from '@deepseek-ai/dsh-openrouter-usage/client'
import type { PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
import { formatUsd } from './money.ts'
@@ -14,25 +15,34 @@ import css from './BalanceBadge.module.css'
/** Poll interval for the balance figure, ms. */
export const BALANCE_POLL_MS = 60_000
/** Injected face: the balance read callback the badge polls. */
/** Injected face: the balance reads the badge polls and re-runs on demand. */
export interface BalanceBadgeActions {
/** Fetch the latest account snapshot from the host gateway. */
/** Read the host gateway's cached account snapshot. */
snapshot: () => Promise<OpenRouterBalance>
/** Make the host fetch the account NOW and serve the result. */
refresh: () => Promise<OpenRouterBalance>
}
/** Composed props: the sidebar footer-action owner share plus the injected read. */
/** Composed props: the sidebar footer-action owner share plus the injected reads. */
export type BalanceBadgeProps =
PropsRuntime<'sidebar.footer.action'> & BalanceBadgeActions & PropsLocale<'openRouterUsage'>
/**
* Poll `snapshot` on an interval and render the account balance. Unknown or
* absent figures render the empty placeholder so the seat costs no confusion.
* A transient failure keeps the last-known figure and retries next tick.
* @param props - owner state plus the injected snapshot callback.
* Poll `snapshot` on an interval, re-read through `refresh` when the user
* clicks, and render the account balance. Unknown or absent figures render the
* empty placeholder so the seat costs no confusion. A transient failure keeps
* the last-known figure and retries next tick; a click while one refresh is
* still outstanding is ignored rather than queued.
* @param props - owner state plus the injected snapshot/refresh callbacks.
* @returns the balance badge.
*/
export function BalanceBadge({ wide, snapshot, t }: BalanceBadgeProps) {
export function BalanceBadge({ wide, snapshot, refresh, t }: BalanceBadgeProps) {
const [balance, setBalance] = useState<OpenRouterBalance | undefined>(undefined)
const [refreshing, setRefreshing] = useState(false)
// Survives the unmount a disposed registration causes mid-fetch: the
// resolving callback must not set state on a gone component.
const mounted = useRef(true)
useEffect(() => () => { mounted.current = false }, [])
useEffect(() => {
let disposed = false
@@ -53,14 +63,38 @@ export function BalanceBadge({ wide, snapshot, t }: BalanceBadgeProps) {
}
}, [snapshot])
const onClick = useCallback(() => {
if (refreshing) return
setRefreshing(true)
void (async () => {
try {
const next = await refresh()
if (mounted.current) setBalance(next)
} catch (_failedRefresh) {
// The click keeps the last-known figure; the interval poll retries.
} finally {
if (mounted.current) setRefreshing(false)
}
})()
}, [refresh, refreshing])
const usd = balance?.balanceUsd ?? null
const amount = usd === null
? t('balance.empty')
: formatUsd(usd)
const label = t('balance.label', { amount })
const tooltip = balance?.label == null ? undefined : t('balance.tooltip', { label: balance.label })
const tooltip = refreshing
? t('balance.refreshing')
: balance?.label == null ? undefined : t('balance.tooltip', { label: balance.label })
return (
<button type="button" className={css.badge} title={tooltip} aria-label={label}>
<button
type="button"
className={css.badge}
title={tooltip}
aria-label={label}
aria-busy={refreshing}
onClick={onClick}
>
<span className={css.marker} aria-hidden></span>
{wide && <span className={css.text}>{label}</span>}
</button>

View File

@@ -2,9 +2,15 @@
// Reads the durable openRouterCost projection, so paging and compaction
// cannot change the figure; the entry renders nothing until at least one
// step priced (no zero-cost group, mirroring the stats line's billing gate).
//
// The figure covers the session plus its subagent subtree. Each projection
// value describes only its own session's work — the host fold excludes a
// forked child's inherited prefix — so summing the lineage is a sum, not a
// double count.
import { memo } from 'react'
import { memo, useMemo } from 'react'
import type { PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client'
// Type-only: merges openRouterCost into SessionProjectionMap for useProjection.
import type {} from '@deepseek-ai/dsh-openrouter-usage/client'
import type { OpenRouterCost } from '@deepseek-ai/dsh-openrouter-usage/client'
@@ -15,22 +21,75 @@ import css from './CostDock.module.css'
export type CostDockProps =
PropsRuntime<'conversation.composer.dock'> & PropsLocale<'openRouterUsage'>
/** Summed spend over one session's subagent subtree. */
interface SubtreeCost {
totalUsd: number
pricedSteps: number
unknownModelSteps: number
}
/**
* Render the running session cost as `会话费用 $X.XX`, shown only when the
* session actually priced steps. An unknown-pricing step count feeds a
* tooltip so the figure's coverage stays visible without polluting the row.
* session or any of its subagents actually priced steps. An unknown-pricing
* step count feeds a tooltip so the figure's coverage stays visible without
* polluting the row.
* @param props - framework/runtime props.
* @returns the cost group, or null on a zero/absent projection.
*/
export const CostDock = memo(function CostDock({ useProjection, t }: CostDockProps) {
const cost = useProjection('openRouterCost') as OpenRouterCost | undefined
if (cost === undefined || cost.pricedSteps === 0) return null
const label = t('cost.label', { amount: formatUsd(cost.totalUsd) })
export const CostDock = memo(function CostDock({ sessionId, useProjection, useSessions, t }: CostDockProps) {
const cost: OpenRouterCost | undefined = useProjection('openRouterCost')
const sessionById = useSessions(s => s.byId)
const subagentCost = useMemo((): SubtreeCost => {
let totalUsd = 0
let pricedSteps = 0
let unknownModelSteps = 0
const rows = Object.values(sessionById)
// Breadth-first over subagent-origin descendants, one frontier at a time.
// `visited` also closes a parentId cycle a corrupt lineage could present.
const visited = new Set<SessionId>([sessionId])
let frontier: SessionId[] = [sessionId]
while (frontier.length > 0) {
const next: SessionId[] = []
for (const parent of frontier) {
for (const row of rows) {
if (row.parentId !== parent || row.origin !== 'subagent' || visited.has(row.id)) continue
visited.add(row.id)
next.push(row.id)
// A descendant the client holds no projection value for (never
// opened, and no version-matching cold cache row) contributes
// nothing; it cannot be told apart from a zero-cost child here.
const childCost = row.projectionValues?.openRouterCost
if (childCost === undefined) continue
totalUsd += childCost.totalUsd
pricedSteps += childCost.pricedSteps
unknownModelSteps += childCost.unknownModelSteps
}
}
frontier = next
}
return { totalUsd, pricedSteps, unknownModelSteps }
}, [sessionById, sessionId])
const selfPricedSteps = cost?.pricedSteps ?? 0
if (selfPricedSteps === 0 && subagentCost.pricedSteps === 0) return null
const selfUsd = cost?.totalUsd ?? 0
const totalUsd = selfUsd + subagentCost.totalUsd
const unknownModelSteps = (cost?.unknownModelSteps ?? 0) + subagentCost.unknownModelSteps
const label = subagentCost.pricedSteps > 0
? t('cost.labelWithSubagents', {
amount: formatUsd(totalUsd),
subagentAmount: formatUsd(subagentCost.totalUsd),
})
: t('cost.label', { amount: formatUsd(totalUsd) })
return (
<span
className={css.cost}
title={cost.unknownModelSteps > 0
? t('cost.tooltipUnknown', { count: String(cost.unknownModelSteps) })
title={unknownModelSteps > 0
? t('cost.tooltipUnknown', { count: String(unknownModelSteps) })
: undefined}
>
{label}

View File

@@ -63,6 +63,14 @@ export function apply(ctx: ClientContext): void {
return result.value
}
const refresh: BalanceBadgeActions['refresh'] = async () => {
const result = await ctx.remote.openRouterUsage.refresh()
if (!result.ok) {
throw new Error(`openRouterUsage.refresh failed: ${result.error.code}: ${result.error.message}`)
}
return result.value
}
// Account balance is account-global (root scope): the injected face carries
// the snapshot read the badge polls. The seat is declared but unhoused by
// default; this registration names it, so an assembly without ui-sidebar
@@ -72,6 +80,6 @@ export function apply(ctx: ClientContext): void {
id: 'openrouter-balance',
order: 10,
locale: NS,
inject: (): BalanceBadgeActions => ({ snapshot }),
inject: (): BalanceBadgeActions => ({ snapshot, refresh }),
}, BalanceBadge))
}

View File

@@ -3,10 +3,12 @@
/** Simplified Chinese dictionary (the key-set source of truth). */
export const zh = {
'cost.label': '会话费用 {amount}',
'cost.labelWithSubagents': '会话费用 {amount}(含子代理 {subagentAmount}',
'cost.tooltipUnknown': '包含 {count} 个未知定价步骤',
'balance.label': '余额 {amount}',
'balance.tooltip': 'OpenRouter 账户余额({label}',
'balance.tooltip': 'OpenRouter 账户余额({label}— 点击刷新',
'balance.empty': '——',
'balance.refreshing': '正在刷新 OpenRouter 账户余额',
} satisfies Record<string, string>
/** The openrouterUsage namespace key union. */
@@ -15,8 +17,10 @@ export type OpenRouterUsageKey = keyof typeof zh
/** English dictionary, checked complete against the zh key set. */
export const en = {
'cost.label': 'Session cost {amount}',
'cost.labelWithSubagents': 'Session cost {amount} (subagents {subagentAmount})',
'cost.tooltipUnknown': 'Includes {count} unpriced steps',
'balance.label': 'Balance {amount}',
'balance.tooltip': 'OpenRouter account balance ({label})',
'balance.tooltip': 'OpenRouter account balance ({label}) — click to refresh',
'balance.empty': '——',
'balance.refreshing': 'Refreshing the OpenRouter account balance',
} satisfies Record<OpenRouterUsageKey, string>

View File

@@ -1,8 +1,9 @@
// @vitest-environment jsdom
// BalanceBadge presentation: polls an injected snapshot callback and renders
// 余额 in both widths, hiding unknown figures behind the empty placeholder.
// 余额 in both widths, hiding unknown figures behind the empty placeholder,
// and re-reads through the injected refresh callback when the user clicks it.
import { act, cleanup, render, screen } from '@testing-library/react'
import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime'
import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts'
@@ -34,6 +35,7 @@ function makeProps(over: Partial<BalanceBadgeProps> = {}) {
return {
wide: true,
snapshot: vi.fn(async () => makeBalance()),
refresh: vi.fn(async () => makeBalance()),
t,
...over,
} as unknown as Parameters<typeof BalanceBadge>[0]
@@ -67,4 +69,37 @@ describe('BalanceBadge', () => {
render(<BalanceBadge {...makeProps({ snapshot: vi.fn(async () => makeBalance({ balanceUsd: null })) })} />)
expect(await screen.findByLabelText('余额 ——')).toBeTruthy()
})
it('re-reads through refresh on click and renders the fetched figure', async () => {
const refresh = vi.fn(async () => makeBalance({ balanceUsd: 42.25 }))
render(<BalanceBadge {...makeProps({ refresh })} />)
const badge = await screen.findByLabelText('余额 $10.50')
await act(async () => { fireEvent.click(badge) })
expect(refresh).toHaveBeenCalledOnce()
expect(await screen.findByLabelText('余额 $42.25')).toBeTruthy()
})
it('ignores a click while a refresh is still outstanding', async () => {
let release: (value: OpenRouterBalance) => void = () => {}
const refresh = vi.fn(() => new Promise<OpenRouterBalance>((resolve) => { release = resolve }))
render(<BalanceBadge {...makeProps({ refresh })} />)
const badge = await screen.findByLabelText('余额 $10.50')
await act(async () => { fireEvent.click(badge) })
expect(badge.getAttribute('aria-busy')).toBe('true')
await act(async () => { fireEvent.click(badge) })
expect(refresh).toHaveBeenCalledOnce()
await act(async () => { release(makeBalance({ balanceUsd: 7 })) })
expect(badge.getAttribute('aria-busy')).toBe('false')
await act(async () => { fireEvent.click(badge) })
expect(refresh).toHaveBeenCalledTimes(2)
})
it('keeps the last-known figure when a refresh fails', async () => {
const refresh = vi.fn(async () => { throw new Error('boom') })
render(<BalanceBadge {...makeProps({ refresh })} />)
const badge = await screen.findByLabelText('余额 $10.50')
await act(async () => { fireEvent.click(badge) })
expect(await screen.findByLabelText('余额 $10.50')).toBeTruthy()
expect(badge.getAttribute('aria-busy')).toBe('false')
})
})

View File

@@ -37,8 +37,10 @@ async function bench() {
new RemoteService(ctx)
const snapshot = vi.fn<() => Promise<RemoteResult<OpenRouterBalance>>>()
.mockResolvedValue({ ok: true, value: balance() })
ctx.provide('remote.openRouterUsage', { snapshot })
return { ctx, slots: ctx.get('slots') as SlotRegistry, locale, snapshot }
const refresh = vi.fn<() => Promise<RemoteResult<OpenRouterBalance>>>()
.mockResolvedValue({ ok: true, value: balance({ balanceUsd: 9 }) })
ctx.provide('remote.openRouterUsage', { snapshot, refresh })
return { ctx, slots: ctx.get('slots') as SlotRegistry, locale, snapshot, refresh }
}
function declare(slots: SlotRegistry): () => void {
@@ -84,11 +86,14 @@ describe('ui-openrouter-usage browser plugin', () => {
expect(badge.options).toMatchObject({ id: 'openrouter-balance', order: 10 })
expect(badge.locale).toBe(NS)
// Registration reads nothing live; the snapshot rolls at call time.
// Registration reads nothing live; both reads roll at call time.
expect(b.snapshot).not.toHaveBeenCalled()
expect(b.refresh).not.toHaveBeenCalled()
const injected = (badge.inject as unknown as () => BalanceBadgeActions)()
await expect(injected.snapshot()).resolves.toEqual(balance())
expect(b.snapshot).toHaveBeenCalledOnce()
await expect(injected.refresh()).resolves.toEqual(balance({ balanceUsd: 9 }))
expect(b.refresh).toHaveBeenCalledOnce()
await b.ctx.fiber.dispose()
})
@@ -102,6 +107,16 @@ describe('ui-openrouter-usage browser plugin', () => {
await b.ctx.fiber.dispose()
})
it('forwards a Remote failure out of the injected refresh verbatim', async () => {
const b = await bench()
declare(b.slots)
await b.ctx.plugin({ inject: [...inject], apply }).await()
b.refresh.mockResolvedValueOnce({ ok: false, error: { code: 'REMOTE_ERROR', message: 'nope', details: {} } })
const injected = (b.slots.entries('sidebar.footer.action')[0]!.inject as unknown as () => BalanceBadgeActions)()
await expect(injected.refresh()).rejects.toThrow('openRouterUsage.refresh failed: REMOTE_ERROR: nope')
await b.ctx.fiber.dispose()
})
it('follows locale and recovers across late declaration and declarer reload', async () => {
const b = await bench()
const fiber = b.ctx.plugin({ inject: [...inject], apply })

View File

@@ -1,6 +1,7 @@
// @vitest-environment jsdom
// CostDock presentation: renders the running session cost from the
// openRouterCost projection, and nothing at all until a step was priced.
// openRouterCost projection plus the subagent subtree read off the session
// list, and nothing at all until a step was priced.
import { act, cleanup, render, screen } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
@@ -13,9 +14,37 @@ afterEach(cleanup)
const t = makeTranslate(zh, commonZh)
function makeProps(over: Partial<Parameters<typeof CostDock>[0]> = {}) {
const PARENT = 'session-parent'
/** One session-list row as much of it as the dock reads. */
interface Row {
id: string
parentId?: string
origin?: 'subagent'
cost?: { totalUsd: number; pricedSteps: number; unknownModelSteps: number }
}
/** A `useSessions` stub serving `byId` built from the given rows. */
function sessionsHook(rows: readonly Row[]) {
const byId: Record<string, unknown> = {}
for (const row of rows) {
byId[row.id] = {
id: row.id,
...row.parentId === undefined ? {} : { parentId: row.parentId },
...row.origin === undefined ? {} : { origin: row.origin },
...row.cost === undefined
? {}
: { projectionValues: { openRouterCost: { ...row.cost, currency: 'USD' } } },
}
}
return (selector: (state: { byId: Record<string, unknown> }) => unknown) => selector({ byId })
}
function makeProps(over: Partial<Parameters<typeof CostDock>[0]> = {}, rows: readonly Row[] = []) {
return {
sessionId: PARENT,
useProjection: vi.fn(() => undefined),
useSessions: sessionsHook(rows),
t,
...over,
} as unknown as Parameters<typeof CostDock>[0]
@@ -57,4 +86,53 @@ describe('CostDock', () => {
act(() => {})
expect(useProjection).toHaveBeenCalledWith('openRouterCost')
})
it('adds the subagent subtree to the figure and names its share', () => {
render(<CostDock {...makeProps(
{ useProjection: () => ({ totalUsd: 1, pricedSteps: 1, unknownModelSteps: 0, currency: 'USD' }) },
[{ id: 'child', parentId: PARENT, origin: 'subagent', cost: { totalUsd: 0.5, pricedSteps: 1, unknownModelSteps: 1 } }],
)} />)
const row = screen.getByText('会话费用 $1.50(含子代理 $0.50')
expect(row.getAttribute('title')).toBe('包含 1 个未知定价步骤')
})
it('follows a nested subagent chain and ignores non-subagent children', () => {
render(<CostDock {...makeProps(
{ useProjection: () => ({ totalUsd: 1, pricedSteps: 1, unknownModelSteps: 0, currency: 'USD' }) },
[
{ id: 'child', parentId: PARENT, origin: 'subagent', cost: { totalUsd: 0.25, pricedSteps: 1, unknownModelSteps: 0 } },
{ id: 'grandchild', parentId: 'child', origin: 'subagent', cost: { totalUsd: 0.125, pricedSteps: 1, unknownModelSteps: 0 } },
// An ordinary fork of the same parent is not delegated work.
{ id: 'sibling', parentId: PARENT, cost: { totalUsd: 9, pricedSteps: 1, unknownModelSteps: 0 } },
],
)} />)
expect(screen.getByText('会话费用 $1.38(含子代理 $0.38')).toBeTruthy()
})
it('shows the subagent subtree alone when the session itself priced nothing', () => {
render(<CostDock {...makeProps(
{ useProjection: () => undefined },
[{ id: 'child', parentId: PARENT, origin: 'subagent', cost: { totalUsd: 0.75, pricedSteps: 2, unknownModelSteps: 0 } }],
)} />)
expect(screen.getByText('会话费用 $0.75(含子代理 $0.75')).toBeTruthy()
})
it('skips a descendant the client holds no projection value for', () => {
render(<CostDock {...makeProps(
{ useProjection: () => ({ totalUsd: 2, pricedSteps: 1, unknownModelSteps: 0, currency: 'USD' }) },
[{ id: 'cold-child', parentId: PARENT, origin: 'subagent' }],
)} />)
expect(screen.getByText('会话费用 $2.00')).toBeTruthy()
})
it('terminates on a parentId cycle', () => {
render(<CostDock {...makeProps(
{ useProjection: () => ({ totalUsd: 1, pricedSteps: 1, unknownModelSteps: 0, currency: 'USD' }) },
[
{ id: 'a', parentId: PARENT, origin: 'subagent', cost: { totalUsd: 0.5, pricedSteps: 1, unknownModelSteps: 0 } },
{ id: PARENT, parentId: 'a', origin: 'subagent', cost: { totalUsd: 99, pricedSteps: 1, unknownModelSteps: 0 } },
],
)} />)
expect(screen.getByText('会话费用 $1.50(含子代理 $0.50')).toBeTruthy()
})
})

View File

@@ -282,6 +282,10 @@ function CatalogRows({
entry.activity,
now,
)
const costUsd = summary?.projectionValues?.openRouterCost?.totalUsd
const costMetric = (costUsd !== undefined && costUsd > 0)
? `$${costUsd < 0.01 ? '<0.01' : costUsd.toFixed(2)}`
: undefined
const tokenMetric = totalTokens === undefined
? undefined
: `${formatTokens(totalTokens)} tok`
@@ -291,7 +295,7 @@ function CatalogRows({
compact: formatDuration(durationMs, t),
exact: formatExactDuration(durationMs, t),
}
const metrics = [tokenMetric, durationMetric?.exact]
const metrics = [costMetric, tokenMetric, durationMetric?.exact]
.filter(value => value !== undefined)
.join(' · ')

View File

@@ -84,6 +84,7 @@ interface UsageLike {
cacheWriteTokens?: number
outputTokens?: number
reasoningTokens?: number
costUsd?: number
}
function requestUsage(value: unknown): TrajectoryUsage | undefined {
@@ -205,7 +206,9 @@ export function TrajectoryView({
const provider = request?.provenance?.provider ?? node?.provenance?.provider
const model = request?.provenance?.model ?? node?.provenance?.model
const requestConfig = request?.requestConfig ?? node?.requestConfig
const stepCostUsd = stepCosts?.[`${turn}:${step}`]
const directCostUsd = (entry.request?.usage as UsageLike | undefined)?.costUsd
?? (entry.node?.usage as UsageLike | undefined)?.costUsd
const stepCostUsd = directCostUsd ?? stepCosts?.[`${turn}:${step}`]
numbered.push({
seq: entry.seq,
turn,
@@ -232,6 +235,7 @@ export function TrajectoryView({
continue
}
const request = entry.request
const directCostUsd = (request.usage as UsageLike | undefined)?.costUsd
numbered.push({
seq: request.startSeq,
turn: request.turn,
@@ -253,6 +257,7 @@ export function TrajectoryView({
...(request.requestConfig === undefined ? {} : { requestConfig: request.requestConfig }),
...(usage === undefined ? {} : { usage }),
...(cumulativeUsage === undefined ? {} : { cumulativeUsage }),
...(directCostUsd === undefined ? {} : { stepCostUsd: directCostUsd }),
})
}

View File

@@ -48,6 +48,7 @@ interface UsageLike {
cacheWriteTokens?: number
outputTokens?: number
reasoningTokens?: number
costUsd?: number
}
/** Cell plus absolute ms for group wall-span descriptions. */

View File

@@ -18,6 +18,7 @@ interface UsageValue {
readonly cacheReadTokens?: number
readonly cacheWriteTokens?: number
readonly reasoningTokens?: number
readonly costUsd?: number
}
interface RetryValue {
@@ -101,6 +102,9 @@ function addUsage(current: UsageValue | undefined, next: UsageValue): UsageValue
...(current?.reasoningTokens === undefined && next.reasoningTokens === undefined
? {}
: { reasoningTokens: (current?.reasoningTokens ?? 0) + (next.reasoningTokens ?? 0) }),
...(current?.costUsd === undefined && next.costUsd === undefined
? {}
: { costUsd: (current?.costUsd ?? 0) + (next.costUsd ?? 0) }),
}
}

View File

@@ -1226,6 +1226,27 @@ describe('TrajectoryView state', () => {
expect(screen.getByText('$0.0028')).toBeTruthy()
})
it('attaches the per-step cost directly from node usage even when projection is undefined', () => {
const nodesWithCost = [
NODES[0]!,
{
...NODES[1]!,
usage: { inputTokens: 100, outputTokens: 50, costUsd: 0.0045 },
},
NODES[2]!,
NODES[3]!,
]
render(
<TrajectoryView
{...standaloneProps(nodesWithCost)}
{...standaloneHistory(historySnapshot(nodesWithCost))}
{...standaloneDuration()}
useProjection={(() => undefined) as never}
/>,
)
expect(screen.getByRole('button', { name: 'Request #1 · $0.0045' })).toBeTruthy()
})
})
describe('node half', () => {

View File

@@ -0,0 +1,51 @@
{
"name": "@deepseek-ai/dsh-tool-lab",
"description": "Model-facing home-lab AI tools (lab_generate_image via ComfyUI, lab_ocr_pdf via Docling, lab_transcribe_audio via Whishper) over the lab's LAN services",
"version": "0.1.0-rc.7",
"publishConfig": {
"access": "public"
},
"repository": {
"type": "git",
"url": "git+https://github.com/deepseek-ai/deepseek-harness.git",
"directory": "packages/extensions/tool-lab"
},
"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"
],
"license": "MIT",
"peerDependencies": {
"@deepseek-ai/cordis": "workspace:^",
"@deepseek-ai/dsh-tools": "workspace:^",
"@deepseek-ai/dsh-fs": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^"
},
"dependencies": {
"@deepseek-ai/schemastery": "workspace:^"
},
"devDependencies": {
"@deepseek-ai/dsh-agent": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-tools": "workspace:^",
"@deepseek-ai/dsh-fs": "workspace:^",
"@deepseek-ai/cordis": "workspace:^"
}
}

View File

@@ -0,0 +1,129 @@
/**
* ComfyUI image generation tool. Talks to the lab's ComfyUI server (default
* http://192.168.31.240:8188) with a Juggernaut-XL workflow and returns the
* served image URL. @module @deepseek-ai/dsh-tool-lab
*/
import { Deadline, sleep } from './helpers.ts'
/** Root of the resolved lab config. */
export interface ResolvedLabConfig {
comfyBaseUrl: string
doclingBaseUrl: string
whishBaseUrl: string
timeoutMs: number
maxUploadBytes: number
maxOutputChars: number
}
/** Model-backed fields ComfyUI exposes on the workflow we build. */
interface ComfyApiResult {
prompt_id?: string
node_errors?: Record<string, unknown>
}
interface ComfyHistoryEntry {
outputs?: Record<string, { images?: { filename?: string; subfolder?: string; type?: string }[] }>
status?: { completed?: boolean; error?: unknown }
}
/** A single ComfyUI generation request. */
export interface GenerateImageArgs {
prompt: string
negative?: string
width?: number
height?: number
steps?: number
seed?: number
model?: string
upscale?: boolean
upscale_model?: string
filename_prefix?: string
}
/** Compose the fixed 28-step Juggernaut-XL workflow from user-facing arguments. */
export function buildWorkflow(args: GenerateImageArgs): Record<string, unknown> {
const seed = args.seed ?? Math.floor(Math.random() * 1_000_000)
const width = args.width ?? 512
const height = args.height ?? 512
const steps = args.steps ?? 28
const model = args.model ?? 'Juggernaut-XL_v9_RunDiffusionPhoto_v2.safetensors'
const negative = args.negative ?? 'blurry, low quality, distorted, watermark, text, extra limbs'
const upscale = args.upscale !== false
const upscaleModel = args.upscale_model ?? 'RealESRGAN_x4plus.safetensors'
const prefix = args.filename_prefix ?? 'dsh'
const workflow: Record<string, unknown> = {
3: {
class_type: 'KSampler',
inputs: {
seed, steps, cfg: 4.0, sampler_name: 'dpmpp_2m_sde', scheduler: 'karras', denoise: 1.0,
model: ['4', 0], positive: ['6', 0], negative: ['7', 0], latent_image: ['5', 0],
},
},
4: { class_type: 'CheckpointLoaderSimple', inputs: { ckpt_name: model } },
5: { class_type: 'EmptyLatentImage', inputs: { width, height, batch_size: 1 } },
6: { class_type: 'CLIPTextEncode', inputs: { text: args.prompt, clip: ['4', 1] } },
7: { class_type: 'CLIPTextEncode', inputs: { text: negative, clip: ['4', 1] } },
8: { class_type: 'VAEDecode', inputs: { samples: ['3', 0], vae: ['4', 2] } },
9: { class_type: 'SaveImage', inputs: { filename_prefix: prefix, images: upscale ? ['10', 0] : ['8', 0] } },
}
if (upscale) {
workflow['10'] = { class_type: 'ImageUpscaleWithModel', inputs: { upscale_model: ['11', 0], image: ['8', 0] } }
workflow['11'] = { class_type: 'UpscaleModelLoader', inputs: { model_name: upscaleModel } }
}
return workflow
}
/**
* Poll ComfyUI `/history/{prompt_id}` until the image appears or the timeout
* budget elapses. Returns the served image URL.
*/
async function pollImage(
baseUrl: string,
promptId: string,
deadline: Deadline,
timeoutMs: number,
): Promise<string> {
const started = Date.now()
for (;;) {
deadline.check()
const res = await fetch(`${baseUrl}/history/${promptId}`)
if (!res.ok) throw new Error(`ComfyUI history ${res.status}: ${await res.text()}`)
const history = (await res.json()) as Record<string, ComfyHistoryEntry>
const entry = history[promptId]
if (entry?.outputs?.['9']?.images?.length) {
const img = entry.outputs['9'].images[0]
if (!img) throw new Error('ComfyUI history entry missing image 0')
return `${baseUrl}/view?filename=${encodeURIComponent(img.filename ?? '')}&subfolder=${encodeURIComponent(img.subfolder ?? '')}&type=${encodeURIComponent(img.type ?? 'output')}`
}
if (entry?.status?.error) throw new Error(`ComfyUI generation failed: ${JSON.stringify(entry.status.error)}`)
if (Date.now() - started >= timeoutMs) throw new Error(`ComfyUI generation timed out after ${timeoutMs}ms`)
await sleep(1500, deadline.signal)
}
}
/** Run the ComfyUI tool. Reads no files; returns a served image URL string. */
export async function runGenerateImage(
config: ResolvedLabConfig,
args: GenerateImageArgs,
signal: AbortSignal | undefined,
): Promise<string> {
const deadline = new Deadline('generate_image', config.timeoutMs, signal)
try {
const clientId = `dsh-${Math.random().toString(36).slice(2)}`
const workflow = buildWorkflow(args)
const submit = await fetch(`${config.comfyBaseUrl}/prompt`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ prompt: workflow, client_id: clientId }),
})
if (!submit.ok) throw new Error(`ComfyUI /prompt ${submit.status}: ${await submit.text()}`)
const result = (await submit.json()) as ComfyApiResult
if (!result.prompt_id) throw new Error(`ComfyUI /prompt no prompt_id: ${JSON.stringify(result)}`)
const errors = result.node_errors
if (errors && Object.keys(errors).length > 0) throw new Error(`ComfyUI node_errors: ${JSON.stringify(errors)}`)
return await pollImage(config.comfyBaseUrl, result.prompt_id, deadline, config.timeoutMs)
} finally {
deadline.dispose()
}
}

View File

@@ -0,0 +1,85 @@
/**
* Docling OCR tool. Reads a local PDF via `ctx.fs`, uploads it to the lab's
* Docling server, polls the async task, and returns the extracted markdown.
* @module @deepseek-ai/dsh-tool-lab
*/
import type { FileSystem } from '@deepseek-ai/dsh-fs'
import { Deadline, sleep } from './helpers.ts'
import type { ResolvedLabConfig } from './comfy.ts'
interface DoclingPoll {
task_id?: string
task_status?: string
}
interface DoclingResult {
document?: { filename?: string; md_content?: string | null }
status?: string
errors?: unknown[]
}
/** Build FormData upload of a PDF from bytes. */
export function buildPdfUpload(data: Uint8Array, filename: string): FormData {
const fd = new FormData()
fd.append('files', new Blob([data as unknown as BlobPart], { type: 'application/pdf' }), filename)
fd.append('options', JSON.stringify({}))
return fd
}
/** Poll Docling `/v1/status/poll/{task_id}` until success. */
async function pollTask(
baseUrl: string,
taskId: string,
deadline: Deadline,
timeoutMs: number,
): Promise<void> {
const started = Date.now()
for (;;) {
deadline.check()
const res = await fetch(`${baseUrl}/v1/status/poll/${taskId}?wait=2`)
if (!res.ok) throw new Error(`Docling poll ${res.status}: ${await res.text()}`)
const poll = (await res.json()) as DoclingPoll
if (poll.task_status === 'success') return
if (poll.task_status === 'failed' || poll.task_status === 'error') {
throw new Error(`Docling task failed: ${await (await fetch(`${baseUrl}/v1/result/${taskId}`)).text()}`)
}
if (Date.now() - started >= timeoutMs) throw new Error(`Docling OCR timed out after ${timeoutMs}ms`)
await sleep(1200, deadline.signal)
}
}
/** Run the Docling OCR tool: read file, upload, poll, return markdown text. */
export async function runOcrPdf(
fs: FileSystem,
config: ResolvedLabConfig,
filePath: string,
signal: AbortSignal | undefined,
): Promise<string> {
const deadline = new Deadline('ocr_pdf', config.timeoutMs, signal)
try {
const target = await fs.resolve(filePath, signal === undefined ? undefined : { signal })
const stat = await fs.stat(target, signal)
if (!stat) throw new Error(`ocr_pdf: file not found: ${filePath}`)
const data = await fs.readBytes(target, signal, config.maxUploadBytes)
const filename = filePath.split(/[\\/]/).pop() ?? 'document.pdf'
const form = buildPdfUpload(data, filename)
const submit = await fetch(`${config.doclingBaseUrl}/v1/convert/file/async`, {
method: 'POST',
body: form,
})
if (!submit.ok) throw new Error(`Docling /convert ${submit.status}: ${await submit.text()}`)
const poll = (await submit.json()) as DoclingPoll
if (!poll.task_id) throw new Error(`Docling no task_id: ${JSON.stringify(poll)}`)
await pollTask(config.doclingBaseUrl, poll.task_id, deadline, config.timeoutMs)
const res = await fetch(`${config.doclingBaseUrl}/v1/result/${poll.task_id}`)
if (!res.ok) throw new Error(`Docling result ${res.status}: ${await res.text()}`)
const result = (await res.json()) as DoclingResult
const md = result.document?.md_content ?? ''
if (md.length === 0) throw new Error('Docling returned empty markdown content')
if (md.length > config.maxOutputChars) return md.slice(0, config.maxOutputChars)
return md
} finally {
deadline.dispose()
}
}

View File

@@ -0,0 +1,62 @@
/**
* Shared helpers for the home-lab tool package: cooperative deadline enforcement
* and aborted sleep. @module @deepseek-ai/dsh-tool-lab
*/
/** Error thrown when a lab tool exceeds its cooperative timeout budget. */
export class LabToolTimeoutError extends Error {
constructor(tool: string, ms: number) {
super(`lab tool ${tool} timed out after ${ms}ms`)
this.name = 'LabToolTimeoutError'
}
}
/** A deadline that aborts when the caller cancels or the budget expires. */
export class Deadline {
readonly signal: AbortSignal | undefined
private readonly timer: ReturnType<typeof setTimeout> | undefined
private cancelled = false
private elapsed = false
constructor(
private readonly tool: string,
private readonly timeoutMs: number,
signal: AbortSignal | undefined,
) {
this.signal = signal
if (Number.isFinite(timeoutMs) && timeoutMs > 0) {
this.timer = setTimeout(() => this.expire(), timeoutMs)
}
signal?.addEventListener('abort', () => this.expire(), { once: true })
}
private expire(): void {
if (this.cancelled || this.elapsed) return
this.elapsed = true
}
/** Throw if the budget has elapsed or the caller aborted. Cheap in loops. */
check(): void {
if (this.signal?.aborted || this.elapsed) {
throw new LabToolTimeoutError(this.tool, this.timeoutMs)
}
}
/** Cancel the timer when the tool finishes normally. */
dispose(): void {
this.cancelled = true
if (this.timer !== undefined) clearTimeout(this.timer)
}
}
/** Abortable sleep; resolves early or throws on cancellation/expiry. */
export async function sleep(ms: number, signal: AbortSignal | undefined): Promise<void> {
if (signal?.aborted) throw new Error('aborted')
await new Promise<void>((resolve) => {
const t = setTimeout(resolve, ms)
signal?.addEventListener('abort', () => {
clearTimeout(t)
resolve()
}, { once: true })
})
}

View File

@@ -0,0 +1,189 @@
/**
* Model-facing home-lab AI tools. Registers `lab_generate_image` (ComfyUI),
* `lab_ocr_pdf` (Docling), and `lab_transcribe_audio` (Whishper) over the
* lab's LAN services. Tools read local files only via `ctx.fs`; uploads and
* polls happen over HTTP. Image results stay server-hosted as URLs.
* @module @deepseek-ai/dsh-tool-lab
*/
import type { Context } from '@deepseek-ai/cordis'
import z from '@deepseek-ai/schemastery'
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
import { defineTool } from '@deepseek-ai/dsh-tools'
import { runGenerateImage, type ResolvedLabConfig } from './comfy.ts'
import { runOcrPdf } from './docling.ts'
import { runTranscribeAudio } from './whish.ts'
export { runGenerateImage, buildWorkflow, type GenerateImageArgs } from './comfy.ts'
export type { ResolvedLabConfig } from './comfy.ts'
export { runOcrPdf, buildPdfUpload } from './docling.ts'
export { runTranscribeAudio, buildAudioUpload } from './whish.ts'
/** Cordis plugin name used by loader diagnostics. */
export const name = 'tool-lab'
/** Services required by the lab tool suite. */
export const inject = ['tools', 'fs']
/** Default cooperative tool-call timeout budget (ms). */
export const DEFAULT_LAB_TIMEOUT_MS = 120_000
/** Default cap on one upload in bytes and one output in characters. */
export const DEFAULT_MAX_UPLOAD_BYTES = 20 * 1024 * 1024
export const DEFAULT_MAX_OUTPUT_CHARS = 200_000
/** Plugin config: which lab tools to register, the base URLs, and limits. */
export interface Config {
/** Register `lab_generate_image` (ComfyUI). Defaults to true. */
generateImage?: boolean
/** Register `lab_ocr_pdf` (Docling). Defaults to true. */
ocrPdf?: boolean
/** Register `lab_transcribe_audio` (Whishper). Defaults to true. */
transcribeAudio?: boolean
/** ComfyUI base URL. Defaults to http://192.168.31.240:8188 */
comfyBaseUrl?: string
/** Docling base URL. Defaults to http://192.168.31.159:5001 */
doclingBaseUrl?: string
/** Whishper base URL. Defaults to http://192.168.31.159:8082 */
whishBaseUrl?: string
/** Cooperative timeout budget (ms). Defaults to 120000. */
timeoutMs?: number
/** Cap on upload bytes. Defaults to 20971520. */
maxUploadBytes?: number
/** Cap on output characters. Defaults to 200000. */
maxOutputChars?: number
}
export const Config: z<Config> = z.object({
generateImage: z.boolean().default(true),
ocrPdf: z.boolean().default(true),
transcribeAudio: z.boolean().default(true),
comfyBaseUrl: z.string().default('http://192.168.31.240:8188'),
doclingBaseUrl: z.string().default('http://192.168.31.159:5001'),
whishBaseUrl: z.string().default('http://192.168.31.159:8082'),
timeoutMs: z.number().default(DEFAULT_LAB_TIMEOUT_MS),
maxUploadBytes: z.number().default(DEFAULT_MAX_UPLOAD_BYTES),
maxOutputChars: z.number().default(DEFAULT_MAX_OUTPUT_CHARS),
})
/** Complete config after schemastery applies every field default. */
type ResolvedConfig = Required<Config>
/** Configured limits must be positive integers. */
function assertPositiveInteger(field: string, value: number): void {
if (!Number.isInteger(value) || value < 1) {
throw new Error(`tool-lab: ${field} must be a positive integer`)
}
}
/** Render one string value as a single text content block. */
function renderText(_args: unknown, value: string): ContentBlock[] {
return [{ type: 'text', text: String(value) }]
}
/**
* Register the enabled lab tools. Each tool's cooperative timeout budget is
* resolved here and attached as `ToolDefinition.timeoutMs`. Disposers are
* fiber-scoped (effect-based registries clean up on dispose), so no manual
* teardown is needed.
*/
export function apply(ctx: Context, config: Config): void {
const resolved = config as ResolvedConfig
assertPositiveInteger('timeoutMs', resolved.timeoutMs)
assertPositiveInteger('maxUploadBytes', resolved.maxUploadBytes)
assertPositiveInteger('maxOutputChars', resolved.maxOutputChars)
const labConfig: ResolvedLabConfig = {
comfyBaseUrl: resolved.comfyBaseUrl,
doclingBaseUrl: resolved.doclingBaseUrl,
whishBaseUrl: resolved.whishBaseUrl,
timeoutMs: resolved.timeoutMs,
maxUploadBytes: resolved.maxUploadBytes,
maxOutputChars: resolved.maxOutputChars,
}
if (resolved.generateImage) {
ctx.tools.register(defineTool({
name: 'lab_generate_image',
description:
'Generate an image on the home-lab ComfyUI server (Juggernaut-XL V9, 512x512 base, upscaled 4x via RealESRGAN by default). Returns a URL to the generated PNG hosted on that server. Give a descriptive prompt; optionally adjust model, width, height, steps, seed, negative, upscale, upscale_model, or filename_prefix.',
parameters: {
prompt: { type: 'string', description: 'Positive prompt describing the desired image.', required: true },
negative: { type: 'string', description: 'Negative prompt.' },
width: { type: 'integer', description: 'Image width in pixels. Default 512.' },
height: { type: 'integer', description: 'Image height in pixels. Default 512.' },
steps: { type: 'integer', description: 'Sampling steps. Default 28 (Juggernaut).' },
seed: { type: 'integer', description: 'Random seed. Defaults to a random value.' },
model: { type: 'string', description: 'Checkpoint model filename. Default Juggernaut-XL_v9_RunDiffusionPhoto_v2.safetensors.' },
upscale: { type: 'boolean', description: 'Upscale the result 4x with RealESRGAN_x4plus. Default true.' },
upscale_model: { type: 'string', description: 'Upscale model filename. Default RealESRGAN_x4plus.safetensors.' },
filename_prefix: { type: 'string', description: 'Filename prefix for the saved image. Default dsh.' },
},
output: {
schema: { type: 'string' },
render: renderText,
},
timeoutMs: resolved.timeoutMs,
isConcurrencySafe: () => true,
async execute(args, exec) {
const imageArgs = args as {
prompt: string
negative?: string
width?: number
height?: number
steps?: number
seed?: number
model?: string
upscale?: boolean
upscale_model?: string
filename_prefix?: string
}
return runGenerateImage(labConfig, imageArgs, exec.signal)
},
}))
}
if (resolved.ocrPdf) {
ctx.tools.register(defineTool({
name: 'lab_ocr_pdf',
description:
'Extract text from a PDF using the home-lab Docling OCR server. Reads the local PDF and returns the recognized markdown text. Provide the local path to the PDF.',
parameters: {
file_path: { type: 'string', description: 'Local path to the PDF file to OCR.', required: true },
},
output: {
schema: { type: 'string' },
render: renderText,
},
timeoutMs: resolved.timeoutMs,
isConcurrencySafe: () => true,
execute(args, exec) {
const fs = ctx.fs
return runOcrPdf(fs, labConfig, args.file_path, exec.signal)
},
}))
}
if (resolved.transcribeAudio) {
ctx.tools.register(defineTool({
name: 'lab_transcribe_audio',
description:
'Transcribe speech from an audio file using the home-lab Whishper server. Returns the recognized text. Provide a local path to an audio file and optionally a language hint.',
parameters: {
file_path: { type: 'string', description: 'Local path to the audio file (wav, mp3, ogg, or m4a).', required: true },
language: { type: 'string', description: 'Optional language code hint (e.g. ru, en).' },
model_size: { type: 'string', description: 'Optional model size (e.g. small, base).' },
},
output: {
schema: { type: 'string' },
render: renderText,
},
timeoutMs: resolved.timeoutMs,
isConcurrencySafe: () => true,
execute(args, exec) {
const fs = ctx.fs
return runTranscribeAudio(fs, labConfig, args.file_path, args.language, args.model_size, exec.signal)
},
}))
}
}

View File

@@ -0,0 +1,29 @@
/**
* Package-owned invariant companion for `@deepseek-ai/dsh-tool-lab`.
* @module @deepseek-ai/dsh-tool-lab/invariant
*/
/* jscpd:ignore-start */
import type { Context } from '@deepseek-ai/cordis'
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
const PACKAGE_NAME = '@deepseek-ai/dsh-tool-lab'
/** Cordis companion plugin name. */
export const name = 'tool-lab-invariant'
/** Service required before the companion can reserve package ownership. */
export const inject = ['invariants']
/**
* No runtime invariant: this model-facing adapter has no independent lifecycle
* stream; execution relations are owned by the capability seam it calls.
*/
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))

View File

@@ -0,0 +1,84 @@
/**
* Whishper speech-to-text tool. Reads local audio via `ctx.fs`, uploads it to
* the lab's Whishper server, polls the transcription, and returns the text.
* @module @deepseek-ai/dsh-tool-lab
*/
import type { FileSystem } from '@deepseek-ai/dsh-fs'
import { Deadline, sleep } from './helpers.ts'
import type { ResolvedLabConfig } from './comfy.ts'
interface WhishperTranscription {
id?: string
status?: number
result?: { text?: string | null; language?: string | null; duration?: number | null }
}
/** Build multipart upload of an audio file. */
export function buildAudioUpload(data: Uint8Array, filename: string, language?: string): FormData {
const fd = new FormData()
const ext = filename.split('.').pop()?.toLowerCase() ?? 'wav'
const type = ext === 'mp3' ? 'audio/mpeg' : ext === 'ogg' ? 'audio/ogg' : ext === 'm4a' ? 'audio/mp4' : 'audio/wav'
fd.append('files', new Blob([data as unknown as BlobPart], { type }), filename)
if (language) fd.append('language', language)
return fd
}
/** Poll Whishper `/api/transcriptions/{id}` until text is ready or done. */
async function pollTranscription(
baseUrl: string,
id: string,
deadline: Deadline,
timeoutMs: number,
): Promise<WhishperTranscription> {
const started = Date.now()
for (;;) {
deadline.check()
const res = await fetch(`${baseUrl}/api/transcriptions/${id}`)
if (!res.ok) throw new Error(`Whishper status ${res.status}: ${await res.text()}`)
const row = (await res.json()) as WhishperTranscription
const text = row.result?.text
if (typeof text === 'string' && text.length > 0 && row.status !== -1) return row
if (row.status !== -1 && (row.status === 1 || row.status === 2)) return row
if (Date.now() - started >= timeoutMs) throw new Error(`Whishper transcription timed out after ${timeoutMs}ms`)
await sleep(1500, deadline.signal)
}
}
/** Run the Whishper tool: read audio, upload, poll, return transcription text. */
export async function runTranscribeAudio(
fs: FileSystem,
config: ResolvedLabConfig,
filePath: string,
language: string | undefined,
modelSize: string | undefined,
signal: AbortSignal | undefined,
): Promise<string> {
const deadline = new Deadline('transcribe_audio', config.timeoutMs, signal)
try {
const target = await fs.resolve(filePath, signal === undefined ? undefined : { signal })
const statInfo = await fs.stat(target, signal)
if (!statInfo) throw new Error(`transcribe_audio: file not found: ${filePath}`)
const data = await fs.readBytes(target, signal, config.maxUploadBytes)
const filename = filePath.split(/[\\/]/).pop() ?? 'audio.wav'
const form = buildAudioUpload(data, filename, language)
if (modelSize) {
// Whishper server config may restrict model size; pass it as a field if supported.
form.append('modelSize', modelSize)
}
const submit = await fetch(`${config.whishBaseUrl}/api/transcriptions`, {
method: 'POST',
body: form,
})
if (!submit.ok) throw new Error(`Whishper upload ${submit.status}: ${await submit.text()}`)
const row = (await submit.json()) as WhishperTranscription
if (!row.id) throw new Error(`Whishper no id: ${JSON.stringify(row)}`)
const done = await pollTranscription(config.whishBaseUrl, row.id, deadline, config.timeoutMs)
const text = done.result?.text ?? ''
if (text.length === 0) return '(transcription completed with no text)'
if (text.length > config.maxOutputChars) return text.slice(0, config.maxOutputChars)
return text
} finally {
deadline.dispose()
}
}

View File

@@ -0,0 +1,60 @@
/**
* Unit tests for the pure helpers of `@deepseek-ai/dsh-tool-lab`. These
* assertions do not touch the network or the runtime; they check the workflow
* and upload builders only.
* @module @deepseek-ai/dsh-tool-lab/tests
*/
import { describe, expect, it } from 'vitest'
import { buildWorkflow } from '../src/comfy.ts'
import { buildPdfUpload } from '../src/docling.ts'
import { buildAudioUpload } from '../src/whish.ts'
/** One ComfyUI workflow node as seen by the assertions. */
interface WorkflowNode {
class_type: string
inputs: Record<string, unknown>
}
/** Reinterpret the workflow builder's JSON-serializable output for assertions. */
function wf(prompt: string, overrides: Record<string, unknown> = {}): Record<string, WorkflowNode> {
return buildWorkflow({ prompt, ...(overrides as object) }) as Record<string, WorkflowNode>
}
describe('buildWorkflow', () => {
it('renders a 28-step Juggernaut workflow with defaults', () => {
const flow = wf('a red fox')
expect(flow['4'].inputs.ckpt_name).toBe('Juggernaut-XL_v9_RunDiffusionPhoto_v2.safetensors')
expect(flow['9'].class_type).toBe('SaveImage')
expect(flow['9'].inputs.images).toEqual(['10', 0])
expect(flow['10'].class_type).toBe('ImageUpscaleWithModel')
expect(flow['11'].inputs.model_name).toBe('RealESRGAN_x4plus.safetensors')
expect(flow['3'].inputs).toMatchObject({ steps: 28, cfg: 4.0, sampler_name: 'dpmpp_2m_sde', scheduler: 'karras' })
})
it('skips upscaling when upscale is false', () => {
const flow = wf('p', { upscale: false })
expect(flow['9'].inputs.images).toEqual(['8', 0])
expect(flow['10']).toBeUndefined()
expect(flow['11']).toBeUndefined()
})
it('honors overrides and seed', () => {
const flow = wf('p', { width: 768, height: 640, steps: 8, seed: 99 })
expect(flow['5'].inputs).toMatchObject({ width: 768, height: 640 })
expect(flow['3'].inputs).toMatchObject({ seed: 99, steps: 8 })
expect(flow['6'].inputs.text).toBe('p')
})
})
describe('upload builders', () => {
it('builds a PDF upload carrying files and options parts', () => {
const fd = buildPdfUpload(new Uint8Array([1, 2, 3]), 'scan.pdf')
expect(fd).toBeDefined()
})
it('builds an audio upload', () => {
const fd = buildAudioUpload(new Uint8Array([1, 2]), 'clip.mp3')
expect(fd).toBeDefined()
})
})

View File

@@ -0,0 +1,28 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "lib/types"
},
"include": ["src"],
"references": [
{
"path": "../../../vendor/cordis"
},
{
"path": "../../../vendor/schemastery"
},
{
"path": "../../llm/llm"
},
{
"path": "../../core/tools"
},
{
"path": "../../fs/fs"
},
{
"path": "../../runtime-diagnostics/invariants"
}
]
}

View File

@@ -57,7 +57,7 @@ import {
truncateUnicodeCodePoints,
} from './api/session-search.ts'
// Type-only: resolves `ctx.get('sessionProjections')` to the projection registry.
import type {} from '@deepseek-ai/dsh-session-projection'
import { foldContextOf } from '@deepseek-ai/dsh-session-projection'
// Type-only: resolves `ctx.get('tasks')` to the background job registry.
import type {} from '@deepseek-ai/dsh-jobs'
import type { JobSnapshot } from '@deepseek-ai/dsh-jobs'
@@ -838,11 +838,12 @@ function listProjectionsFor(ctx: Context, meta: SessionHeader, session: Session
/** Projection baseline for a detached history tail without Agent activation. */
function detachedProjectionsFor(
ctx: Context,
header: SessionHeader,
events: readonly SessionEvent[],
): SessionProjectionsBlock | undefined {
const registry = ctx.get('sessionProjections')
if (registry === undefined) return undefined
return registry.restore({}, events, 0).snapshot
return registry.restore({}, events, 0, foldContextOf(header)).snapshot
}
/**
@@ -1534,7 +1535,7 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
includeProjections: boolean,
): { events: SessionEvent[]; projections?: SessionProjectionsBlock } {
if (source.kind === 'detached') {
const projections = includeProjections ? detachedProjectionsFor(ctx, source.events) : undefined
const projections = includeProjections ? detachedProjectionsFor(ctx, source.header, source.events) : undefined
return { events: source.events, ...projections === undefined ? {} : { projections } }
}
const events = [...source.session.events]
@@ -2632,7 +2633,7 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
header = inspected.meta
events = inspected.events
projections = beforeSeq === undefined
? subagentHistoryProjections(ctx, childSessionId, () => detachedProjectionsFor(ctx, inspected.events))
? subagentHistoryProjections(ctx, childSessionId, () => detachedProjectionsFor(ctx, inspected.meta, inspected.events))
: undefined
} catch (error: unknown) {
if (signal?.aborted) {

View File

@@ -20,11 +20,17 @@ import { toPiReplayState } from './replay.ts'
* @returns harness counts; cache fields appear only when non-zero (pi-ai reports zeros, not absence).
*/
export function mapUsage(usage: PiUsage): TokenUsage {
const usageRecord = usage as unknown as Record<string, unknown>
const rawCost = usageRecord['cost'] ?? usageRecord['total_cost']
const costUsd = typeof rawCost === 'number' && Number.isFinite(rawCost) && rawCost >= 0
? rawCost
: undefined
return {
inputTokens: usage.input,
outputTokens: usage.output,
...usage.cacheRead > 0 ? { cacheReadTokens: usage.cacheRead } : {},
...usage.cacheWrite > 0 ? { cacheWriteTokens: usage.cacheWrite } : {},
...costUsd !== undefined ? { costUsd } : {},
}
}

View File

@@ -138,6 +138,7 @@ export interface TokenUsage {
cacheReadTokens?: number
cacheWriteTokens?: number
reasoningTokens?: number
costUsd?: number
}
/** Display metadata for one registered provider route. */

View File

@@ -43,7 +43,11 @@ is the available balance from `GET /credits` (`total_credits` minus the spent
the monthly `usageTokens`/`limitTokens` budget from `GET /auth/key`,
`isFreeTier`, and an `updatedAt` epoch. Before any successful fetch it serves
an all-`null`
record; a failed refresh keeps the last-known snapshot and logs. The same key
record; a failed refresh keeps the last-known snapshot and logs. The
`refresh()` method fetches the account NOW and resolves with the resulting
snapshot, for a user-initiated read that must not wait for the next scheduled
tick; concurrent callers share one in-flight fetch, and a failed fetch
resolves with the last-known snapshot rather than rejecting. The same key
also refreshes the model pricing table from `GET /models`
(`pricing.prompt`/`completion` USD per token, plus a flat `request` fee and
optional `input_cache_read`/`input_cache_write` when disclosed).
@@ -54,7 +58,14 @@ step) against the pricing table. Attribution prefers the assembled message's
own `provider`/`model`; a chunk-only (failed) step prices from the newest
`request/context` route. A step on a non-`openrouter` provider is outside the
domain and changes nothing; an OpenRouter step whose model has no pricing
entry counts as an unknown (unpriced) step. The projection's `steps` view
entry counts as an unknown (unpriced) step.
Every value describes only its own session's work. A forked child session's
log opens with a verbatim copy of its parent's up to the header's
`seedLength`, and the fold skips that inherited prefix (reading only its
`request/context` route records, which carry no cost but attribute the
child's first chunk-only step). A session total plus its subagents' totals is
therefore a sum, never a double count. The projection's `steps` view
field maps each priced step to its cost in USD under `${turn}:${step}` keys,
so per-step surfaces can render spend without re-pricing; unpriced steps are
absent from the map.
@@ -95,7 +106,10 @@ usage events, never a new model-visible input.
promotional pricing may differ from the model table.
- **Pricing as of the fold** — the projection prices a cell with the model
table current when that cell folds. Refreshing pricing only affects cells
folded afterward; already-folded history keeps its prior figures.
folded afterward; already-folded history keeps its prior figures. A session
folded while the table was empty (no key yet, or a failed first fetch)
keeps its steps recorded as unpriced until a `stateVersion` bump refolds
them.
- **Per-token approximation** — cache-read/cache-write fall back to the
prompt rate when the API does not disclose separate cache rates, and the
flat request fee is charged once per step. Bills may differ by fractions

View File

@@ -103,6 +103,8 @@ export class OpenRouterUsageGateway extends TypertRemoteService {
private readonly abortController = new AbortController()
/** Authoritative settings thunk; re-pointed when the section (re)mounts. */
private currentSource: () => Config
/** In-flight on-demand balance refresh, shared by concurrent callers. */
private pendingRefresh: Promise<OpenRouterBalance> | undefined
constructor(ctx: Context, config: Config = {}) {
super(ctx, 'openRouterUsage')
@@ -124,7 +126,7 @@ export class OpenRouterUsageGateway extends TypertRemoteService {
// live pricing thunk. The unit child activates only when a projection
// registry is composed (headless assemblies stay unaffected).
ctx.inject(['sessionProjections'], (projectionCtx) => {
projectionCtx.sessionProjections.register(createOpenRouterCostProjection((model) => this.pricing.get(model)))
projectionCtx.sessionProjections.register(createOpenRouterCostProjection(model => this.lookupPricing(model)))
})
ctx.effect(() => () => {
@@ -158,6 +160,44 @@ export class OpenRouterUsageGateway extends TypertRemoteService {
return { ...this.balance }
}
/**
* Fetch the account snapshot NOW and serve the result, for a user-initiated
* read that must not wait for the next scheduled tick. Concurrent callers
* share one in-flight fetch, so repeated clicks cost one request. A failed
* fetch resolves with the last-known snapshot rather than rejecting — the
* caller's own `updatedAt` comparison tells it whether the figure moved.
* @returns the snapshot after the refresh attempt settled.
*/
@Remote('refresh')
async refresh(): Promise<OpenRouterBalance> {
this.pendingRefresh ??= (async () => {
try {
await this.refreshBalance(this.resolve(this.currentSource()))
return { ...this.balance }
} finally {
this.pendingRefresh = undefined
}
})()
return await this.pendingRefresh
}
/**
* Look up pricing for a model ID, with fallback for tagged variants (`:free`, etc.).
* @param model - model identifier.
* @returns matched rate, or undefined if unknown.
*/
lookupPricing(model: string): ModelPricing | undefined {
const direct = this.pricing.get(model)
if (direct !== undefined) return direct
if (model.includes(':')) {
const tagIndex = model.indexOf(':')
const base = model.slice(0, tagIndex)
const basePricing = this.pricing.get(base)
if (basePricing !== undefined) return basePricing
}
return undefined
}
/** Materialize plugin defaults against the validated section. */
private resolve(config: Config): Required<Config> {
return {

View File

@@ -11,6 +11,14 @@
* Refreshing pricing only affects cells folded afterward — the documented
* "as of fold" limitation.
*
* Inherited history: a forked child session opens with a verbatim copy of its
* parent's log up to `header.seedLength`. Those steps are the parent's spend,
* which the parent's own value already reports, so the fold skips them and
* every value describes only the session's OWN work — a parent total plus its
* children's totals is a sum, never a double count. `request/context` records
* are still read from the inherited prefix: they carry no cost and the route
* they establish is what attributes the child's first chunk-only step.
*
* Model attribution: an `assistant/message` carries its own provider/model in
* `message.source`; a chunk-only (failed) step has none, so the fold prices it
* from the newest `request/context` last-wins record. A step on a provider that
@@ -24,7 +32,7 @@
import { z } from 'zod'
import type { TokenUsage } from '@deepseek-ai/dsh-llm'
import type { SessionEvent } from '@deepseek-ai/dsh-session'
import type { ProjectionDefinition } from '@deepseek-ai/dsh-session-projection'
import type { ProjectionDefinition, ProjectionFoldContext } from '@deepseek-ai/dsh-session-projection'
import type { ModelPricing, OpenRouterCost } from './types.ts'
/** The provider route this projection prices; LLM routing must land here. */
@@ -84,7 +92,13 @@ export function createOpenRouterCostProjection(
key: 'openRouterCost',
schema: costSchema as unknown as z.ZodType<OpenRouterCost>,
init: () => ({ totalUsd: 0, pricedSteps: 0, unknownModelSteps: 0, steps: {}, last: null, lastModel: null }),
apply: (state, event: SessionEvent) => {
apply: (state, event: SessionEvent, context: ProjectionFoldContext) => {
// A forked child's log opens with a verbatim copy of its parent's log.
// That spend belongs to the parent — which already reports it — so
// pricing it here would report it twice, once per forked child. The
// route records still apply: attribution carries across the boundary,
// so `request/context` below is read from the inherited prefix too.
if (event.seq < context.seedLength && event.type !== 'request/context') return state
if (event.type === 'request/context') {
const nextModel = { provider: event.data.provider, model: event.data.model }
if (state.lastModel?.provider === nextModel.provider && state.lastModel?.model === nextModel.model) return state
@@ -106,14 +120,21 @@ export function createOpenRouterCostProjection(
} else {
return state
}
// A non-openrouter step is outside this plugin's domain: never counted,
// never recorded (a later, correctly-attributed message for the same
// step must still land fresh).
if (attribution === undefined || attribution.provider !== OPENROUTER_PROVIDER) return state
const directCost = usage.costUsd
const pricing = attribution !== undefined ? pricingOf(attribution.model) : undefined
const isOpenRouterProvider = attribution !== undefined && (
attribution.provider === OPENROUTER_PROVIDER
|| attribution.provider.toLowerCase().includes('openrouter')
)
const pricing = pricingOf(attribution.model)
const priced = pricing !== undefined
const costUsd = priced ? stepCostUsd(usage, pricing) : 0
if (directCost === undefined && !isOpenRouterProvider && pricing === undefined) return state
const costUsd = directCost !== undefined
? directCost
: pricing !== undefined
? stepCostUsd(usage, pricing)
: 0
const priced = directCost !== undefined || pricing !== undefined
const previous = state.last !== null && state.last.turn === turn && state.last.step === step
? state.last
: null
@@ -144,6 +165,6 @@ export function createOpenRouterCostProjection(
steps: state.steps,
currency: 'USD',
}),
stateVersion: 2,
stateVersion: 4,
}
}

View File

@@ -33,11 +33,13 @@ export interface ModelPricing {
}
/**
* Whole-log OpenRouter spend for one session, priced from the logged token
* usage of its steps against the pricing table current at fold time. Every
* field is 0 until its first contributing priced step lands; a session whose
* provider route is not `openrouter`, or whose models have no pricing entry,
* stays all-zero.
* One session's OWN OpenRouter spend, priced from the logged token usage of
* its steps against the pricing table current at fold time. A forked child's
* inherited log prefix (the parent's history up to `header.seedLength`) is
* excluded, so a parent value plus its subagents' values is a sum rather
* than a double count. Every field is 0 until its first contributing priced
* step lands; a session whose provider route is not `openrouter`, or whose
* models have no pricing entry, stays all-zero.
*/
export interface OpenRouterCost {
/** Summed USD over steps priced against a known model entry. */

View File

@@ -38,6 +38,7 @@ function stubOpenRouter() {
const pricing = [
{ id: 'deepseek/deepseek-chat', pricing: { prompt: '0.0000014', completion: '0.0000028', request: '0' } },
]
const credits = { total_credits: 42, total_usage: 1, is_free_tier: false }
const calls: string[] = []
vi.stubGlobal('fetch', vi.fn(async (input: RequestInfo | URL) => {
const url = String(input)
@@ -46,9 +47,7 @@ function stubOpenRouter() {
return new Response(JSON.stringify({ data: pricing }), { status: 200 })
}
if (url.endsWith('/credits')) {
return new Response(JSON.stringify({
data: { total_credits: 42, total_usage: 1, is_free_tier: false },
}), { status: 200 })
return new Response(JSON.stringify({ data: { ...credits } }), { status: 200 })
}
if (url.endsWith('/auth/key')) {
return new Response(JSON.stringify({
@@ -57,7 +56,7 @@ function stubOpenRouter() {
}
return new Response('not found', { status: 404 })
}))
return { calls }
return { calls, credits }
}
async function loadComposition(): Promise<Context> {
@@ -170,4 +169,39 @@ describe('openrouter-usage real composition', () => {
expect(balance.currency).toBe('USD')
expect(balance.updatedAt).not.toBeNull()
})
it('re-fetches the account on demand and serves the moved figure', async () => {
const openRouter = stubOpenRouter()
const loaded = await loadComposition()
await vi.waitFor(() => {
expect(openRouter.calls.some(url => url.endsWith('/credits'))).toBe(true)
}, { timeout: 5000 })
expect(loaded.openRouterUsage.snapshot().balanceUsd).toBe(41)
openRouter.credits.total_usage = 12
await expect(loaded.openRouterUsage.refresh()).resolves.toMatchObject({ balanceUsd: 30 })
expect(loaded.openRouterUsage.snapshot().balanceUsd).toBe(30)
})
it('shares one in-flight fetch across concurrent on-demand refreshes', async () => {
const openRouter = stubOpenRouter()
const loaded = await loadComposition()
await vi.waitFor(() => {
expect(openRouter.calls.some(url => url.endsWith('/credits'))).toBe(true)
}, { timeout: 5000 })
const before = openRouter.calls.filter(url => url.endsWith('/credits')).length
const [first, second] = await Promise.all([
loaded.openRouterUsage.refresh(),
loaded.openRouterUsage.refresh(),
])
expect(first).toEqual(second)
expect(openRouter.calls.filter(url => url.endsWith('/credits')).length).toBe(before + 1)
// The shared promise is released once it settles, so a later click fetches again.
await loaded.openRouterUsage.refresh()
expect(openRouter.calls.filter(url => url.endsWith('/credits')).length).toBe(before + 2)
})
})

View File

@@ -224,6 +224,28 @@ describe('openRouterCost session projection', () => {
expect(projected(ctx, session)).toEqual({ totalUsd: 0, pricedSteps: 0, unknownModelSteps: 1, steps: {}, currency: 'USD' })
})
it('uses explicitly logged costUsd on usage directly even without pricing table entry', async () => {
const { ctx, session } = await harness()
session.append('request/context', { provider: 'openrouter', model: 'unlisted/custom-model' })
startStep(session, 1, 1)
session.append('assistant/chunk', {
turn: 1,
step: 1,
chunk: {
type: 'usage',
usage: { inputTokens: 500, outputTokens: 200, costUsd: 0.0042 },
},
})
session.append('step/end', { turn: 1, step: 1 })
expect(projected(ctx, session)).toEqual({
totalUsd: 0.0042,
pricedSteps: 1,
unknownModelSteps: 0,
steps: { '1:1': 0.0042 },
currency: 'USD',
})
})
it('pushes no change for unrelated events', async () => {
const { ctx, session } = await harness()
recordContext(session)
@@ -235,6 +257,47 @@ describe('openRouterCost session projection', () => {
expect(changed).not.toContain('openRouterCost')
})
it('excludes the inherited prefix of a forked child, so parent + child is a sum', async () => {
const { ctx, session: parent } = await harness()
recordContext(parent)
startStep(parent, 1, 1)
const parentSource = usageChunk(parent, { inputTokens: 1_000, outputTokens: 500 }, 1, 1)
finalUsage(parent, { inputTokens: 1_000, outputTokens: 500 }, 1, 1, [parentSource])
const parentCost = 1_000 * 1.4e-6 + 500 * 2.8e-6
expect(projected(ctx, parent).totalUsd).toBe(parentCost)
// The fork: the child's log opens with a verbatim copy of the parent's.
const seed = [...parent.events]
const child = ctx.sessions.create(undefined, { seed, meta: { seedLength: seed.length } })
startStep(child, 2, 1)
const childSource = usageChunk(child, { inputTokens: 10, outputTokens: 4 }, 2, 1)
finalUsage(child, { inputTokens: 10, outputTokens: 4 }, 2, 1, [childSource])
const childCost = 10 * 1.4e-6 + 4 * 2.8e-6
const childValue = projected(ctx, child)
expect(childValue.totalUsd).toBe(childCost)
expect(childValue.pricedSteps).toBe(1)
expect(childValue.steps).toEqual({ '2:1': childCost })
// The parent's own figure is untouched by the fork.
expect(projected(ctx, parent).totalUsd).toBe(parentCost)
})
it('attributes a chunk-only step of a forked child from a route in the inherited prefix', async () => {
const { ctx, session: parent } = await harness()
// The route record is the LAST thing the parent logs, so only the
// inherited prefix can supply the child's attribution.
recordContext(parent)
const seed = [...parent.events]
const child = ctx.sessions.create(undefined, { seed, meta: { seedLength: seed.length } })
startStep(child, 1, 1)
usageChunk(child, { inputTokens: 100, outputTokens: 20 }, 1, 1)
expect(projected(ctx, child)).toMatchObject({
totalUsd: 100 * 1.4e-6 + 20 * 2.8e-6,
pricedSteps: 1,
unknownModelSteps: 0,
})
})
it('restores from a JSON checkpoint', async () => {
const { ctx, session } = await harness()
recordContext(session)

View File

@@ -7,7 +7,7 @@ import { createMessage, createUserMessage } from '@deepseek-ai/dsh-llm'
import type { ContentBlock, ToolSchema } from '@deepseek-ai/dsh-llm'
import SessionStore from '@deepseek-ai/dsh-session'
import type { Session, SessionEvent } from '@deepseek-ai/dsh-session'
import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
import SessionProjectionRegistry, { foldContextOf } from '@deepseek-ai/dsh-session-projection'
import TokenMeter from '@deepseek-ai/dsh-token-meter'
import type { ContextBreakdownProjection } from '@deepseek-ai/dsh-token-meter/client'
import { CompactionId } from '@deepseek-ai/dsh-compaction'
@@ -184,6 +184,9 @@ describe('contextBreakdown session projection', () => {
it('folds a replacement without a claim at zero and fails on a mismatched claim', () => {
const definition = contextBreakdownProjectionDefinition
// The unit ignores the fold context; this session inherits no fork prefix.
const applyBreakdown = (state: Parameters<typeof definition.apply>[0], event: SessionEvent) =>
definition.apply(state, event, foldContextOf({}))
const replace = (start: number, end: number): SessionEvent => ({
type: 'user/message',
seq: 9,
@@ -206,22 +209,22 @@ describe('contextBreakdown session projection', () => {
data: { shadowedRange: { start, end }, shadowedSeqs: [start, end], shadowedTokenCount: 5 },
} as unknown as SessionEvent)
let state = definition.init()
state = definition.apply(state, append(1))
state = definition.apply(state, append(3))
state = applyBreakdown(state, append(1))
state = applyBreakdown(state, append(3))
// No metering event: the replacement contributes zero instead of throwing.
expect(definition.view(definition.apply(state, replace(1, 3))).messageTokens)
expect(definition.view(applyBreakdown(state, replace(1, 3))).messageTokens)
.toBe(definition.view(state).messageTokens)
// An adjacent claim for another range contradicts the replacement.
const mismatched = definition.apply(state, meter(1, 1, 8))
expect(() => definition.apply(mismatched, replace(1, 3))).toThrow('no adjacent shadow price')
const mismatched = applyBreakdown(state, meter(1, 1, 8))
expect(() => applyBreakdown(mismatched, replace(1, 3))).toThrow('no adjacent shadow price')
// A claim expires after one intervening event, so replacement delta is zero.
let expired = definition.apply(state, meter(1, 3, 8))
expired = definition.apply(expired, { type: 'todo/write', seq: 9, time: 0, data: { todos: [] } } as unknown as SessionEvent)
expect(definition.view(definition.apply(expired, replace(1, 3))).messageTokens)
let expired = applyBreakdown(state, meter(1, 3, 8))
expired = applyBreakdown(expired, { type: 'todo/write', seq: 9, time: 0, data: { todos: [] } } as unknown as SessionEvent)
expect(definition.view(applyBreakdown(expired, replace(1, 3))).messageTokens)
.toBe(definition.view(state).messageTokens)
// The armed claim prices exactly the next event's matching replacement.
const armed = definition.apply(state, meter(1, 3, 8))
expect(definition.view(definition.apply(armed, replace(1, 3))).messageTokens)
const armed = applyBreakdown(state, meter(1, 3, 8))
expect(definition.view(applyBreakdown(armed, replace(1, 3))).messageTokens)
.toBe(definition.view(state).messageTokens - 5 + estimateMessage(
createUserMessage({ content: [{ type: 'text', text: 'x' }], source: { kind: 'user' } }),
))

View File

@@ -19,6 +19,7 @@ import type { Session, SessionEvent, SessionHeader, SessionId } from '@deepseek-
// Empty type import: applies the package's cordis Context merge
// (`ctx.sessionPersistence`), which this service reads on the cold path.
import type {} from '@deepseek-ai/dsh-session-persistence'
import { foldContextOf } from '@deepseek-ai/dsh-session-projection'
import type { ProjectionCheckpoint, ProjectionSnapshot } from '@deepseek-ai/dsh-session-projection'
import type { KvTable } from '@deepseek-ai/dsh-storage-domain'
import { projectionCacheDomainSpec } from './spec.ts'
@@ -183,14 +184,14 @@ export class SessionProjectionCache extends Service {
const related = record === undefined || identityMatches(record.identity, identityOf(tail.meta))
try {
if (!related) throw new Error('unrelated log identity')
restored = this.ctx.sessionProjections.restore(cached, tail.events, floor)
restored = this.ctx.sessionProjections.restore(cached, tail.events, floor, foldContextOf(tail.meta))
} catch {
// The recoverable restore failures: an unrelated record, or a row
// overreaching the stored log end (or predating the floor). Both imply
// floor > 0 (baseSeq-0 restores never throw and an unrelated record
// still carried a usable watermark), so the full log is a fresh read.
const whole = await persistence.readFrom(id, 0, signal)
restored = this.ctx.sessionProjections.restore({}, whole.events, 0)
restored = this.ctx.sessionProjections.restore({}, whole.events, 0, foldContextOf(whole.meta))
}
await this.putSoft(id, identityOf(tail.meta), restored.checkpoint, 'cold-read write-back')
return restored.snapshot

View File

@@ -11,15 +11,18 @@ Session-projection Service Definition and drive registry. It owns `ctx.sessionPr
- `ctx.sessionProjections.register(definition): () => void` Register one domain's unit. Duplicate keys and invalid `stateVersion` throw; the registration is an effect on the calling fiber, so an unloaded domain plugin's key (with its cached cells) disappears from subsequent drives and snapshots — clients read that as capability absence.
- `ctx.sessionProjections.onChanged(listener): () => void` Subscribe to the change feed: one call per unit whose state reference changed, per committed event, carrying the schema-validated view and the causing seq. Effect-tied like `register`.
- `ctx.sessionProjections.snapshot(session): ProjectionSnapshot` One consistent synchronous cut over every registered unit — `{ asOfSeq, values }` with `asOfSeq` = the seq of the last event every value reflects (`-1` for an empty log).
- `foldContextOf(header): ProjectionFoldContext` Derive the per-session fold context from a session header. Every `apply` call receives one; a caller folding a detached log (`restore`) supplies it explicitly.
### Key Types
- `SessionProjectionMap` — the single merge-extensible type table for the whole chain (host unit, wire block, React hook). Values are wire-JSON whole values; rendering belongs to the slot system, never this layer.
- `ProjectionDefinition<K, S>``{ key, schema, init(), apply(state, event), view(state), stateVersion }`: a state-driven computation unit of three pure synchronous functions plus declarations, never an opaque getter.
- `ProjectionDefinition<K, S>``{ key, schema, init(), apply(state, event, context), view(state), stateVersion }`: a state-driven computation unit of three pure synchronous functions plus declarations, never an opaque getter.
- `ProjectionFoldContext` — per-session header facts a unit folds against beyond the event stream: `seedLength`, the durable fork-lineage boundary.
## Contract
- **The framework drives, the domain computes.** The registry subscribes to `session/event` once; every committed event passes every unit's `apply` eagerly. Domains hold no subscriptions. Cells (`{state, observedSeq}` per unit per session, WeakMap-keyed) build lazily — a unit registered after events flowed, or a read of a session predating the registration, folds `init` over the in-memory log on first touch.
- **A unit reads session facts through the fold context, never a Session.** Every `apply` call carries `ProjectionFoldContext`, derived from the session's durable header. Its `seedLength` is the fork-lineage boundary: events with `seq < seedLength` were inherited from the forked parent rather than produced by this session. A unit whose value must describe only this session's OWN work (spend, step counts) skips them, so a parent value plus its children's values is a sum rather than a double count; a unit describing the whole conversation (context pressure, the visible transcript) folds them like any other event. A unit needing neither declares two parameters and ignores the third.
- **Same-reference means no work.** `apply` MUST return the same state reference for events that do not concern the unit; the drive gates the change feed on `Object.is`, so non-matching events cost one call and nothing downstream.
- **Whole-value event rule (load-bearing).** A state-carrying log event MUST carry the complete post-change state, never a bare delta — it keeps every transition trivially cheap and every served value self-describing (last-wins for consumers).
- **Synchronous unit discipline.** `init`/`apply`/`view` MUST be synchronous; carriers read `snapshot()` in the same tick as their page slice, which is what makes `asOfSeq` one consistent cut. An accidentally-async `view` returns a Promise, which fails the boundary `schema.parse` loudly.

View File

@@ -19,7 +19,7 @@
import { Context, Service } from '@deepseek-ai/cordis'
import type { ZodType } from 'zod'
import type { Session, SessionEvent } from '@deepseek-ai/dsh-session'
import type { Session, SessionEvent, SessionHeader } from '@deepseek-ai/dsh-session'
declare module '@deepseek-ai/cordis' {
interface Context {
@@ -31,6 +31,32 @@ import type { SessionProjectionMap } from './types.ts'
export type { SessionProjectionMap } from './types.ts'
/**
* Per-session facts a unit folds against beyond the event stream itself.
* Supplied by the framework on every `apply` call and derived from the
* session's durable header, so a unit never reads a Session.
*/
export interface ProjectionFoldContext {
/**
* The session header's durable fork-lineage boundary: events with
* `seq < seedLength` were inherited from the forked parent rather than
* produced by this session. A unit whose value must describe only this
* session's OWN work skips them; a unit describing the whole conversation
* (context pressure, the visible transcript) folds them like any other
* event. 0 for a session with no inherited prefix.
*/
readonly seedLength: number
}
/**
* Derive the fold context of one session's header.
* @param header - the session's durable header.
* @returns the context every `apply` call for that session receives.
*/
export function foldContextOf(header: Pick<SessionHeader, 'seedLength'>): ProjectionFoldContext {
return { seedLength: header.seedLength ?? 0 }
}
/**
* One domain's state-driven computation unit: three pure synchronous
* functions plus declarations — never an opaque getter. The framework drives
@@ -55,9 +81,10 @@ export interface ProjectionDefinition<K extends keyof SessionProjectionMap, S> {
* unchanged reference (`Object.is`) produces zero downstream work.
* @param state - the state covering all prior events.
* @param event - the next committed session event.
* @param context - per-session header facts; a unit that needs none may declare two parameters.
* @returns the next state (same reference when the event is not the unit's).
*/
apply(state: S, event: SessionEvent): S
apply(state: S, event: SessionEvent, context: ProjectionFoldContext): S
/**
* State → wire payload (the read-side projection).
* @param state - the current state.
@@ -122,7 +149,7 @@ interface ErasedDefinition {
key: string
schema: { parse(value: unknown): unknown }
init(): unknown
apply(state: unknown, event: SessionEvent): unknown
apply(state: unknown, event: SessionEvent, context: ProjectionFoldContext): unknown
view(state: unknown): unknown
stateVersion: number
}
@@ -348,12 +375,17 @@ export class SessionProjectionRegistry extends Service {
* @param checkpoint - persisted rows for one session (possibly stale or empty).
* @param events - the stored events with `seq >= baseSeq`, in seq order.
* @param baseSeq - the seq `events` starts at (its first event's seq when non-empty).
* @param context - the stored header's fold context (see {@link foldContextOf}).
* @returns the snapshot cut at the supplied log end (`asOfSeq` is the last
* supplied event's seq, `baseSeq - 1` for an empty tail) plus the
* refreshed checkpoint rows at that cut, ready for a durable write-back.
*/
restore(checkpoint: ProjectionCheckpoint, events: readonly SessionEvent[], baseSeq: number):
{ snapshot: ProjectionSnapshot; checkpoint: ProjectionCheckpoint } {
restore(
checkpoint: ProjectionCheckpoint,
events: readonly SessionEvent[],
baseSeq: number,
context: ProjectionFoldContext,
): { snapshot: ProjectionSnapshot; checkpoint: ProjectionCheckpoint } {
const endSeq = events.at(-1)?.seq ?? baseSeq - 1
const values: Record<string, unknown> = {}
const refreshed: ProjectionCheckpoint = {}
@@ -373,7 +405,7 @@ export class SessionProjectionRegistry extends Service {
let state = usable ? row.val : def.init()
const from = usable ? row.seq : baseSeq - 1
for (const event of events) {
if (event.seq > from) state = def.apply(state, event)
if (event.seq > from) state = def.apply(state, event, context)
}
values[def.key] = def.schema.parse(def.view(state))
refreshed[def.key] = { ver: def.stateVersion, seq: endSeq, val: state }
@@ -385,9 +417,13 @@ export class SessionProjectionRegistry extends Service {
}
/** Fold one unit from init over `events`, producing a cell watermarked at the last folded event. */
private buildCell(def: ErasedDefinition, events: readonly SessionEvent[]): UnitCell {
private buildCell(
def: ErasedDefinition,
events: readonly SessionEvent[],
context: ProjectionFoldContext,
): UnitCell {
let state = def.init()
for (const event of events) state = def.apply(state, event)
for (const event of events) state = def.apply(state, event, context)
return { state, observedSeq: (events.at(-1)?.seq ?? -1) }
}
@@ -395,7 +431,7 @@ export class SessionProjectionRegistry extends Service {
private cellFor(registration: Registration, session: Session): UnitCell {
let cell = registration.cells.get(session)
if (cell === undefined) {
cell = this.buildCell(registration.def, session.events)
cell = this.buildCell(registration.def, session.events, foldContextOf(session.header))
registration.cells.set(session, cell)
}
return cell
@@ -403,15 +439,16 @@ export class SessionProjectionRegistry extends Service {
/** Eager drive: pass one committed event through every registered unit; notify on changed references. */
private drive(session: Session, event: SessionEvent): void {
const context = foldContextOf(session.header)
for (const registration of this.registrations.values()) {
let cell = registration.cells.get(session)
if (cell === undefined) {
// Late build mid-stream: fold history before this event (seq = log
// index, so the prefix slice is exact), then take the normal gate.
cell = this.buildCell(registration.def, session.events.slice(0, event.seq))
cell = this.buildCell(registration.def, session.events.slice(0, event.seq), context)
registration.cells.set(session, cell)
}
const next = registration.def.apply(cell.state, event)
const next = registration.def.apply(cell.state, event, context)
const changed = !Object.is(next, cell.state)
cell.state = next
cell.observedSeq = event.seq

View File

@@ -12,9 +12,12 @@ import { Context } from '@deepseek-ai/cordis'
import { z } from 'zod'
import SessionStore from '@deepseek-ai/dsh-session'
import type { Session, SessionEvent } from '@deepseek-ai/dsh-session'
import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
import SessionProjectionRegistry, { foldContextOf } from '@deepseek-ai/dsh-session-projection'
import type { ProjectionDefinition } from '@deepseek-ai/dsh-session-projection'
/** The fold context of a session with no inherited fork prefix. */
const NO_SEED = foldContextOf({})
declare module '@deepseek-ai/dsh-session-projection/types' {
interface SessionProjectionMap {
'test/marks': { marks: string[] }
@@ -49,6 +52,19 @@ const countUnit = (): ProjectionDefinition<'test/count', number> => ({
stateVersion: 1,
})
/**
* A unit counting only events the fold context marks as this session's OWN
* work, so its value proves the context reached `apply`.
*/
const ownCountUnit = (): ProjectionDefinition<'test/count', number> => ({
key: 'test/count',
schema: z.number().int().nonnegative(),
init: () => 0,
apply: (state, event, context) => (event.seq < context.seedLength ? state : state + 1),
view: state => state,
stateVersion: 1,
})
async function harness(): Promise<{ ctx: Context; session: Session }> {
const ctx = new Context()
await ctx.plugin(SessionStore)
@@ -263,7 +279,7 @@ describe('SessionProjectionRegistry drive', () => {
expect(() => ctx.sessionProjections.restore({
'test/marks': { ver: 1, seq: 2, val: { marks: ['old'] } },
'test/count': { ver: 99, seq: 2, val: 3 },
}, tail, 3)).toThrow(/re-read from seq 0/)
}, tail, 3, NO_SEED)).toThrow(/re-read from seq 0/)
// The full-log re-read (baseSeq 0) refolds the mismatched key from init.
const full: SessionEvent[] = [
{ type: 'turn/start', seq: 0, time: 0, data: { turn: 1 } },
@@ -274,7 +290,7 @@ describe('SessionProjectionRegistry drive', () => {
const { snapshot, checkpoint } = ctx.sessionProjections.restore({
'test/marks': { ver: 1, seq: 2, val: { marks: ['old', '2'] } },
'test/count': { ver: 99, seq: 2, val: 3 },
}, full, 0)
}, full, 0, NO_SEED)
expect(snapshot.asOfSeq).toBe(4)
expect(snapshot.values['test/marks']).toEqual({ marks: ['new'] })
expect(snapshot.values['test/count']).toBe(5) // refolded from init over all 5 events
@@ -295,7 +311,7 @@ describe('SessionProjectionRegistry drive', () => {
{ type: 'turn/start', seq: 3, time: 3, data: { turn: 2 } },
{ type: 'turn/end', seq: 4, time: 4, data: { turn: 2, reason: { kind: 'completed' } } },
]
const { snapshot } = ctx.sessionProjections.restore(rows, tail, 3)
const { snapshot } = ctx.sessionProjections.restore(rows, tail, 3, NO_SEED)
expect(snapshot.asOfSeq).toBe(4)
// marks already covers the tail (watermark 4): nothing re-applied.
expect(snapshot.values['test/marks']).toEqual({ marks: ['done'] })
@@ -306,7 +322,7 @@ describe('SessionProjectionRegistry drive', () => {
const { snapshot: current } = ctx.sessionProjections.restore({
'test/marks': { ver: 1, seq: 4, val: { marks: ['done'] } },
'test/count': { ver: 1, seq: 4, val: 5 },
}, [], 5)
}, [], 5, NO_SEED)
expect(current.asOfSeq).toBe(4)
expect(current.values['test/count']).toBe(5)
})
@@ -324,6 +340,48 @@ describe('SessionProjectionRegistry drive', () => {
expect(ctx.sessionProjections.viewCheckpoint({})).toEqual({})
})
it('folds every unit against the fold context of the session header', async () => {
const { ctx, session: parent } = await harness()
ctx.sessionProjections.register(ownCountUnit())
mark(parent, ['a'])
mark(parent, ['b'])
const inherited = parent.seq
expect(ctx.sessionProjections.snapshot(parent).values['test/count']).toBe(inherited)
// The forked child inherits the parent's log; only its own appends count.
const seed = [...parent.events]
const child = ctx.sessions.create(undefined, { seed, meta: { seedLength: seed.length } })
mark(child, ['own'])
expect(child.header.seedLength).toBe(seed.length)
expect(ctx.sessionProjections.snapshot(child).values['test/count'])
.toBe(child.seq - seed.length)
})
it('passes the fold context to a cell built lazily after events flowed', async () => {
const { ctx, session: parent } = await harness()
mark(parent, ['a'])
const seed = [...parent.events]
const child = ctx.sessions.create(undefined, { seed, meta: { seedLength: seed.length } })
mark(child, ['own'])
// Registered only now: the lazy full-log build must see the same context.
ctx.sessionProjections.register(ownCountUnit())
expect(ctx.sessionProjections.snapshot(child).values['test/count'])
.toBe(child.seq - seed.length)
})
it('restore folds against the caller-supplied fold context', async () => {
const { ctx } = await harness()
ctx.sessionProjections.register(ownCountUnit())
const events: SessionEvent[] = [
{ type: 'turn/start', seq: 0, time: 0, data: { turn: 1 } },
{ type: 'turn/end', seq: 1, time: 1, data: { turn: 1, reason: { kind: 'completed' } } },
{ type: 'turn/start', seq: 2, time: 2, data: { turn: 2 } },
]
expect(ctx.sessionProjections.restore({}, events, 0, NO_SEED).snapshot.values['test/count']).toBe(3)
expect(ctx.sessionProjections.restore({}, events, 0, foldContextOf({ seedLength: 2 }))
.snapshot.values['test/count']).toBe(1)
})
it('restore rejects a row claiming events past the supplied log end (shrunk log ⇒ re-read)', async () => {
const { ctx } = await harness()
ctx.sessionProjections.register(countUnit())
@@ -334,16 +392,16 @@ describe('SessionProjectionRegistry drive', () => {
expect(floor).toBe(9)
// …an intact log serves the anchor event and the checkpoint stands as-is.
const anchor: SessionEvent = { type: 'turn/end', seq: 9, time: 9, data: { turn: 2, reason: { kind: 'completed' } } }
expect(ctx.sessionProjections.restore(rows, [anchor], 9).snapshot.values['test/count']).toBe(10)
expect(ctx.sessionProjections.restore(rows, [anchor], 9, NO_SEED).snapshot.values['test/count']).toBe(10)
// …while a log crash-repaired down to fewer events returns an empty tail:
// the row overreaches the proven end and a tail read cannot fix this key.
expect(() => ctx.sessionProjections.restore(rows, [], 9)).toThrow(/re-read from seq 0/)
expect(() => ctx.sessionProjections.restore(rows, [], 9, NO_SEED)).toThrow(/re-read from seq 0/)
// The full re-read discards the overreaching row and refolds from init.
const events: SessionEvent[] = [
{ type: 'turn/start', seq: 0, time: 0, data: { turn: 1 } },
{ type: 'turn/end', seq: 1, time: 1, data: { turn: 1, reason: { kind: 'completed' } } },
]
const { snapshot } = ctx.sessionProjections.restore(rows, events, 0)
const { snapshot } = ctx.sessionProjections.restore(rows, events, 0, NO_SEED)
expect(snapshot.asOfSeq).toBe(1)
expect(snapshot.values['test/count']).toBe(2)
})

View File

@@ -15,10 +15,14 @@ import { Context } from '@deepseek-ai/cordis'
import { createMessage } from '@deepseek-ai/dsh-llm'
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
import type { Session, SessionEvent } from '@deepseek-ai/dsh-session'
import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
import SessionProjectionRegistry, { foldContextOf } from '@deepseek-ai/dsh-session-projection'
import * as SessionStatsPlugin from '@deepseek-ai/dsh-session-stats'
import { sessionStatsProjectionDefinition } from '@deepseek-ai/dsh-session-stats/src/projection.ts'
import type { SessionStatsProjection } from '@deepseek-ai/dsh-session-stats/types'
/** The unit under test ignores the fold context; this session inherits no fork prefix. */
const applyStats = (state: Parameters<typeof sessionStatsProjectionDefinition.apply>[0], event: SessionEvent) =>
sessionStatsProjectionDefinition.apply(state, event, foldContextOf({}))
async function harness(withStatsPlugin: boolean): Promise<{ ctx: Context; session: Session }> {
const ctx = new Context()
@@ -155,7 +159,7 @@ function at(time: number, type: string, data: unknown): SessionEvent {
/** Fold a synthetic event list through the definition and view the result. */
function fold(events: readonly SessionEvent[]): SessionStatsProjection {
const state = events.reduce(
(folded, event) => sessionStatsProjectionDefinition.apply(folded, event),
(folded, event) => applyStats(folded, event),
sessionStatsProjectionDefinition.init(),
)
return sessionStatsProjectionDefinition.view(state)
@@ -270,10 +274,10 @@ describe('sessionStats wall-time fold (controlled timestamps)', () => {
// The first message closed the step boundary; a defensive duplicate finds
// no open step and folds to the same reference.
const state = events.reduce(
(folded, event) => sessionStatsProjectionDefinition.apply(folded, event),
(folded, event) => applyStats(folded, event),
sessionStatsProjectionDefinition.init(),
)
expect(sessionStatsProjectionDefinition.apply(
expect(applyStats(
state,
at(2_050, 'assistant/message', { turn: 1, step: 1, message }),
)).toBe(state)
@@ -281,7 +285,7 @@ describe('sessionStats wall-time fold (controlled timestamps)', () => {
it('accrues nothing for unrelated events and clamps negative clock skew to zero', () => {
const state = sessionStatsProjectionDefinition.init()
const untouched = sessionStatsProjectionDefinition.apply(state, at(1, 'user/message', { content: [] }))
const untouched = applyStats(state, at(1, 'user/message', { content: [] }))
expect(untouched).toBe(state)
expect(fold([
at(2_000, 'step/start', { turn: 1, step: 1 }),

View File

@@ -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/subagent/README.md
README.md: a863ed3f5ef864b6eb6eb9a7a0c1ee2f40f247d6
README.zh.md: 1c9bf8ba0814a74c5774d81a34a3266daa9c375b
README.md: a3bbfdf415006e6321c7f60f6678a828e8295013
README.zh.md: accc3a2216b9b0d27bb8e23c391d0206f813ff5f

View File

@@ -13,6 +13,7 @@ This family lets an agent delegate work to child agents. Multiple named provider
| [`subagent-acp/`](subagent-acp/README.md) | Starts an out-of-process child over ACP | registers on `ctx.subagents` |
| [`subagent-codex/`](subagent-codex/README.md) | Starts a real Codex app-server child | registers on `ctx.subagents` |
| [`subagent-claude-code/`](subagent-claude-code/README.md) | Starts a real Claude Code child through the official Claude Agent SDK | registers on `ctx.subagents` |
| [`subagent-cursor/`](subagent-cursor/README.md) | Starts a real Cursor child through the `cursor-agent` print-mode CLI | registers on `ctx.subagents` |
| [`subagent-dsh-sdk/`](subagent-dsh-sdk/README.md) | Starts an out-of-process Harness child through the TypeScript SDK | registers on `ctx.subagents` |
| [`tool-subagent/`](tool-subagent/README.md) | Exposes delegation to the model | registers on `ctx.tools` |
| [`tool-subagent-control/`](tool-subagent-control/README.md) | Exposes child messaging and listing to the model | registers on `ctx.tools` |

View File

@@ -13,6 +13,7 @@
| [`subagent-acp/`](subagent-acp/README.md) | 通过 ACPAgent Client Protocol启动进程外子 agent | 注册到 `ctx.subagents` |
| [`subagent-codex/`](subagent-codex/README.md) | 启动真实的 Codex app-server 子 agent | 注册到 `ctx.subagents` |
| [`subagent-claude-code/`](subagent-claude-code/README.md) | 通过官方 Claude Agent SDK 启动真实的 Claude Code 子 agent | 注册到 `ctx.subagents` |
| [`subagent-cursor/`](subagent-cursor/README.md) | 通过 `cursor-agent` print 模式 CLI 启动真实的 Cursor 子 agent | 注册到 `ctx.subagents` |
| [`subagent-dsh-sdk/`](subagent-dsh-sdk/README.md) | 通过 TypeScript SDK 启动进程外 Harness 子 agent | 注册到 `ctx.subagents` |
| [`tool-subagent/`](tool-subagent/README.md) | 向模型公开委派操作 | 注册到 `ctx.tools` |
| [`tool-subagent-control/`](tool-subagent-control/README.md) | 向模型公开子级消息发送和列举操作 | 注册到 `ctx.tools` |

View File

@@ -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/subagent/subagent-claude-code/README.md
README.md: 1a0d6e32b8610769dcc5d8342a4fe88d0c884085
README.zh.md: 78dab14e5eaddc06ccd07b69dc952a09380e0428
README.md: 9ded255eddefbed524a94216df8142411c91f547
README.zh.md: e24d32578b462946dea27e2b13bdd1341efd52bb

View File

@@ -31,7 +31,7 @@ The provider advertises no optional start-time capabilities and reports `inherit
Production resolves `claude` from the subprocess execution world's credential-scrubbed `PATH`, with explicit `env` entries applied, and passes the resulting path to the SDK as `pathToClaudeCodeExecutable`. On Windows, a resolved `.cmd` or `.bat` path is carried as a quoted, per-spawn environment value that `cmd.exe /v:off` expands once, so valid path metacharacters remain data. The pinned SDK's fixed flags then occupy cmd's command tail and contain no cmd metacharacters; they are not ordinary Windows argv. Native settings and authentication remain authoritative. The plugin does not install another CLI, select a model, create a product home, log in, or probe an account. Credential-shaped ambient variables are removed before the explicit `env` overlay is applied, so an API key or token intended for the child must be supplied there. Non-credential endpoint variables such as `ANTHROPIC_BASE_URL`, along with ordinary ambient values such as `PATH` and `HOME`, remain inherited unless overridden.
Production `dsh` does not install or mount this optional provider. A Profile that opts in must install `@deepseek-ai/dsh-subagent-claude-code` and mount it once on the host plane; loading the provider starts no Claude process until a tool call. Full Agent Presets carry a matching product tool row with `disabled: true`; copy a preset and remove that field to expose `subagent_claude_code` only to agents composed from the copy. Its `one-shot` policy keeps omitted or `false` `run_in_background` calls in the foreground, while explicit `true` returns a parent-owned Job id for `job_output` or `job_kill`. The base host and full presets already provide the generic Job registry and controls.
The `dsh-base` host plane mounts this provider, and the `code`, `cordis`, and `standard` Agent Presets carry an enabled `subagent_claude_code` tool row; the `economy` preset keeps it `disabled: true` because an economy composition should not reach for external paid agents by default. Loading the provider starts no Claude process until a tool call. Its `one-shot` policy keeps omitted or `false` `run_in_background` calls in the foreground, while explicit `true` returns a parent-owned Job id for `job_output` or `job_kill`. The base host and full presets already provide the generic Job registry and controls.
The standalone composition below shows the complete explicit capability. A Profile based on `@deepseek-ai/dsh-base` keeps its existing Job rows, adds the product provider row, and enables the preset tool row instead of mounting duplicate Job services.

View File

@@ -31,7 +31,7 @@ SDK 接收由文本块原样拼接成的任务。提供方会完整迭代 SDK
生产环境从子进程执行世界清除凭证后的 `PATH` 解析 `claude`,再应用显式 `env` 条目,并把所得路径作为 `pathToClaudeCodeExecutable` 交给 SDK。在 Windows 上,解析到的 `.cmd``.bat` 路径会作为带引号、仅供本次 spawn 使用的环境值交给 `cmd.exe /v:off` 展开一次,因此合法路径中的元字符仍只是数据。锁定版本的 SDK 随后把固定命令行选项放在 cmd 的命令尾部;这些选项不含 cmd 元字符,也并不是普通的 Windows argv。原生设置与身份验证继续是权威来源。本插件不安装另一份 CLI、不选择模型、不创建产品主目录、不执行登录也不探测账户。具有凭证特征的环境变量会在显式 `env` 覆盖生效前被清除,因此供子进程使用的 API 密钥或 token 必须在该配置中显式提供。除非被覆盖,`ANTHROPIC_BASE_URL` 等非凭证端点变量以及 `PATH``HOME` 等普通环境变量仍会被继承。
生产 `dsh` 不会安装或挂载这个可选提供方。选择启用它的 Profile 必须安装 `@deepseek-ai/dsh-subagent-claude-code`,并在 host plane宿主平面挂载一次加载提供方本身不会在工具调用前启动 Claude 进程。完整 Agent Preset 携带对应的产品工具行并设置 `disabled: true`;复制一个 preset 后删除该字段,即可只向由该副本组装的 agent 暴露 `subagent_claude_code`。其 `one-shot` 策略会让省略 `run_in_background` 或传入 `false` 的调用继续在前台等待,而显式传入 `true` 会返回由父 agent 拥有的 Job ID`job_output``job_kill` 使用。base host基础宿主与完整 preset 已提供通用作业注册表和控制工具。
`dsh-base` 宿主平面会挂载本提供方,`code``cordis``standard` Agent Preset 均携带已启用的 `subagent_claude_code` 工具行;`economy` preset 保留 `disabled: true`,因为经济模式的组装不应默认动用外部付费 agent。加载提供方本身不会在工具调用前启动 Claude 进程。其 `one-shot` 策略会让省略 `run_in_background` 或传入 `false` 的调用继续在前台等待,而显式传入 `true` 会返回由父 agent 拥有的 Job ID`job_output``job_kill` 使用。base host基础宿主与完整 preset 已提供通用作业注册表和控制工具。
下列独立组装展示完整的显式能力。基于 `@deepseek-ai/dsh-base` 的 Profile 保留已有 Job 行,只新增产品提供方行并启用 preset 工具行,禁止重复挂载 Job 服务。

View File

@@ -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/subagent/subagent-codex/README.md
README.md: 848d170585710b682fa4ce331010fce7080de673
README.zh.md: 34e9105e6a78bc16f16997c7df89d4f6412eb50c
README.md: de64a9136c3f1a5573cc169bcd3bd2ea11cd15e8
README.zh.md: 9490e1084def998be9352111dbc8137642c92263

View File

@@ -27,7 +27,7 @@ The provider advertises no optional start-time capabilities and reports `inherit
Production resolves `codex` from `PATH` and uses the host's native Codex configuration and authentication. The plugin does not install Codex, select a model, create `CODEX_HOME`, log in, or probe a version. Credential-shaped ambient variables are removed by the subprocess seam, so an API key intended for the child must be supplied explicitly in `env`; ordinary ambient values such as `PATH` and `HOME` remain available unless overridden.
Production `dsh` does not install or mount this optional provider. A Profile that opts in must install `@deepseek-ai/dsh-subagent-codex` and mount it once on the host plane; loading the provider starts no Codex process until a tool call. Full Agent Presets carry a matching product tool row with `disabled: true`; copy a preset and remove that field to expose `subagent_codex` only to agents composed from the copy. Its `one-shot` policy keeps omitted or `false` `run_in_background` calls in the foreground, while explicit `true` returns a parent-owned Job id for `job_output` or `job_kill`. The base host and full presets already provide the generic Job registry and controls.
The `dsh-base` host plane mounts this provider, and the `code`, `cordis`, and `standard` Agent Presets carry an enabled `subagent_codex` tool row; the `economy` preset keeps it `disabled: true` because an economy composition should not reach for external paid agents by default. Loading the provider starts no Codex process until a tool call. Its `one-shot` policy keeps omitted or `false` `run_in_background` calls in the foreground, while explicit `true` returns a parent-owned Job id for `job_output` or `job_kill`. The base host and full presets already provide the generic Job registry and controls.
The standalone composition below shows the complete explicit capability. A Profile based on `@deepseek-ai/dsh-base` keeps its existing Job rows, adds the product provider row, and enables the preset tool row instead of mounting duplicate Job services.

View File

@@ -27,7 +27,7 @@
生产环境会从 `PATH` 中解析 `codex`,并使用宿主机原生的 Codex 配置与身份验证。本插件不安装 Codex、不选择模型、不创建 `CODEX_HOME`、不执行登录,也不探测版本。子进程 seam 会移除具有凭证特征的环境变量,因此供子进程使用的 API 密钥必须在 `env` 中显式提供;除非被覆盖,`PATH``HOME` 等普通环境变量值仍然可用。
生产 `dsh` 不会安装或挂载这个可选提供方。选择启用它的 Profile 必须安装 `@deepseek-ai/dsh-subagent-codex`,并在 host plane宿主平面挂载一次加载提供方本身不会在工具调用前启动 Codex 进程。完整 Agent Preset 携带对应的产品工具行并设置 `disabled: true`;复制一个 preset 后删除该字段,即可只向由该副本组装的 agent 暴露 `subagent_codex`。其 `one-shot` 策略会让省略 `run_in_background` 或传入 `false` 的调用继续在前台等待,而显式传入 `true` 会返回由父 agent 拥有的 Job ID`job_output``job_kill` 使用。base host基础宿主与完整 preset 已提供通用作业注册表和控制工具。
`dsh-base` 宿主平面会挂载本提供方,`code``cordis``standard` Agent Preset 均携带已启用的 `subagent_codex` 工具行;`economy` preset 保留 `disabled: true`,因为经济模式的组装不应默认动用外部付费 agent。加载提供方本身不会在工具调用前启动 Codex 进程。其 `one-shot` 策略会让省略 `run_in_background` 或传入 `false` 的调用继续在前台等待,而显式传入 `true` 会返回由父 agent 拥有的 Job ID`job_output``job_kill` 使用。base host基础宿主与完整 preset 已提供通用作业注册表和控制工具。
下列独立组装展示完整的显式能力。基于 `@deepseek-ai/dsh-base` 的 Profile 保留已有 Job 行,只新增产品提供方行并启用 preset 工具行,禁止重复挂载 Job 服务。

View File

@@ -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/subagent/subagent-cursor/README.md
README.md: bbf6a4bd763e24c09a34fdc77fecc6f2c66a1ade
README.zh.md: db15b551cd95a763a3768b09ab4a9527c72afa10

View File

@@ -0,0 +1,106 @@
# @deepseek-ai/dsh-subagent-cursor
English | [中文](README.zh.md)
This package registers the fixed `cursor` subagent provider. Each accepted run starts the official `cursor-agent` CLI in non-interactive print mode in the delegating Session's workspace, submits one self-contained text task, reads the CLI's `stream-json` event stream, and returns only the final answer through the shared [`dsh-subagent`](../subagent/README.md) result contract.
## Start and ownership
`start(request)` accepts only a non-empty sequence of text blocks and derives the child cwd from the parent Session. It resolves `cursor-agent` through [`dsh-subprocess`](../../subprocess/subprocess/README.md), spawns the fixed command, and publishes the run only after the CLI's `system`/`init` event announces its own chat id — the point at which the CLI has started and resolved its credentials and model. A failure or cancellation before that announcement terminates the managed process tree, waits for it to exit, and rejects `start()`.
The task crosses the boundary as a positional command-line argument, because that is the only prompt channel print mode offers: the CLI documents no `--` end-of-options separator and reads no prompt from stdin. Two consequences are enforced rather than papered over. A task whose first character is `-` is rejected at admission, since the CLI would parse it as an option. A resolved Windows `.cmd` or `.bat` shim is rejected as well, because only `cmd.exe` can run it and its command tail would reparse model-authored text as shell syntax; PATHEXT resolution prefers the `cursor-agent.exe` that the native Windows installer provides. Stdin is closed immediately after spawn, so a prompt the CLI still tries to read fails fast instead of stalling an unattended child on an answer nobody can give.
The published `run.result` waits for the authoritative terminal `result` event and accepts only `subtype: "success"` with `is_error: false` and a nonblank `result`. Every other terminal event, malformed stdout line, stream failure, or end of stream without a result maps to `error`; print mode carries no machine-readable failure taxonomy, so the provider produces neither `max-tokens` nor `refusal`. `user`, `tool_call`, and event kinds a newer CLI adds contribute nothing to this contract.
Print mode has no reply channel, so there is no protocol interrupt: cancellation is the run's abort signal, which the subprocess seam turns into its termination escalation while the result settles immediately as `aborted` with the last non-empty assistant message collected so far. `dispose()` is idempotent: it detaches the event stream, invokes the shared process-tree termination escalation, and waits for whole-tree exit. Result failure and independent teardown failure remain separate.
## Capabilities and context
The provider advertises no optional start-time capabilities and reports `inheritsParentContext: false`. Cursor receives the standalone text task and the parent Session cwd, but not the parent conversation, persona, tool filter, depth policy, or structured-output contract. The CLI's own chat id and model stay private to the run and are never persisted in the parent Session.
## Configuration
| Key | Default | Meaning |
|---|---|---|
| `env` | `{}` | Explicit child environment layered over the subprocess seam's credential-scrubbed parent environment. |
| `disposeGraceMs` | `3000` | Positive finite grace in milliseconds, no greater than [`MAX_TIMER_DELAY_MS`](../../util/timeout/README.md), between the shared process-tree owner's termination tiers; disposal then waits for whole-tree exit. |
| `force` | `false` | Pass `--force`, letting the child apply file changes and run commands. Cursor's own print-mode default only PROPOSES changes, so a delegation expected to edit the workspace needs this on. |
| `trust` | `false` | Pass `--trust`, letting the child act in the workspace without Cursor's interactive trust prompt an unattended child cannot answer. |
Production resolves `cursor-agent` from the subprocess execution world's credential-scrubbed `PATH`, with explicit `env` entries applied, and uses the host's native Cursor configuration and authentication. The plugin does not install the CLI, select a model, create a Cursor home, log in, or probe a version. Credential-shaped ambient variables are removed by the subprocess seam, so a `CURSOR_API_KEY` intended for the child must be supplied in `env`; it is never passed as `--api-key`, where a process listing would expose it. Ordinary ambient values such as `PATH` and `HOME` remain available unless overridden.
The `dsh-base` host plane mounts this provider, and the `code`, `cordis`, and `standard` Agent Presets carry an enabled `subagent_cursor` tool row; the `economy` preset keeps it `disabled: true` because an economy composition should not reach for external paid agents by default. Loading the provider starts no Cursor process until a tool call. Its `one-shot` policy keeps omitted or `false` `run_in_background` calls in the foreground, while explicit `true` returns a parent-owned Job id for `job_output` or `job_kill`.
The standalone composition below shows the complete explicit capability. A Profile based on `@deepseek-ai/dsh-base` keeps its existing Job rows and provider row and enables only the preset tool row instead of mounting duplicates.
```yaml
- id: subagent-cursor
name: '@deepseek-ai/dsh-subagent-cursor'
config:
force: true
trust: true
env:
CURSOR_API_KEY: !!js process.env.CURSOR_API_KEY
- id: jobs
name: '@deepseek-ai/dsh-jobs-local'
- id: tool-jobs
name: '@deepseek-ai/dsh-tool-jobs'
- id: tool-subagent-cursor
name: '@deepseek-ai/dsh-tool-subagent'
config:
provider: cursor
toolName: subagent_cursor
backgroundMode: one-shot
maxDepth: provider-managed
```
## Product compatibility and evidence
The provider reads only the print-mode events this one-shot contract needs: `system`/`init` to publish, `assistant` to snapshot partial output, and `result` to settle. Deployments supply `cursor-agent` on `PATH`; the CLI is not an npm dependency of this package, so unit evidence drives the real event stream through the subprocess seam rather than a pinned binary.
The CLI also speaks the Agent Client Protocol as `cursor-agent acp`, which the generic [`dsh-subagent-acp`](../subagent-acp/README.md) provider can drive with configuration alone. That path exists for a deployment that wants ACP's permission auto-answer policy or a long-lived remote session; this package exists for the one-shot delegation contract, its own `subagent_cursor` tool row, and print mode's simpler failure surface.
## Model Experience
### Child request
#### What the model sees
The Cursor child receives the concatenated text task as one positional prompt in a fresh print-mode run. Its workspace is the parent Session cwd, and its model, system instructions, tools, and authentication come from the native Cursor installation and configuration.
#### Token effect
The child pays for an independent Cursor context and turn. Child tokens do not enter the parent's context.
#### KV Cache effect
Independent of the parent request cache. Reuse depends only on Cursor's own model, instructions, tools, and per-run request.
### Parent scheduling and results, indirectly
#### What the model sees
Through `dsh-tool-subagent`, a foreground call gives the parent the terminal Cursor answer or the consumer's exact error for a non-completed result. A background call first returns a Job id; the generic job controls later deliver a completion notice, expose the final answer and status through `job_output`, and let `job_kill` request cancellation. Cursor's intermediate messages, tool activity, stderr, workspace diffs, usage, and product ids are not copied into the parent Session.
#### Token effect
Foreground input grows by the retained final answer or error. Background input also includes the start acknowledgement, completion notice, and any `job_output`, `job_kill`, or later status results; child tokens still do not enter the parent context. This provider adds no parent tool schema by itself.
#### KV Cache effect
Append-only: foreground adds one result after the reusable parent prefix, while background appends the Job acknowledgement, notice, and later control or collection results. Background scheduling can add a notice-driven turn, but none of these messages rewrites the earlier prefix.
## Known Limitations and Deferred Work
- **One fresh process and run per delegation** — there is no continuation, `--resume`, pooling, progress stream, or product-session persistence, even though the CLI itself supports resuming a chat by id.
- **The task cannot begin with `-`** — print mode takes the prompt positionally and documents no `--` separator, so such a task is rejected at admission instead of being mis-parsed as an option.
- **Windows needs the native executable** — a resolved `.cmd` or `.bat` shim is rejected rather than run through `cmd.exe`, whose command tail would reparse the task text.
- **Host-managed product installation and account state** — a missing `cursor-agent`, configuration error, or authentication failure is surfaced as a startup or run error; the plugin provides no installer, login flow, or runtime version gate.
- **No graduated approval path** — `force` and `trust` are whole-run deployment switches; there is no per-call policy, allowlist, or human approval channel through this package.
- **No failure taxonomy** — print mode reports only a terminal subtype and error marker, so a context-window ending is indistinguishable from any other failure and never maps to `max-tokens`.
- **Product payload is final text only** — intermediate messages, tool traffic, usage, stderr, and workspace diffs remain product-local; generic Job ids, notices, and status come from the shared job runtime.
- **No optional shared capabilities** — output schemas, child personas, tool filtering, and harness depth enforcement are rejected by the shared service for this provider.
- **No wall-clock timeout or side-effect rollback** — the caller cancels long work, and files or external systems changed before cancellation are not restored.

View File

@@ -0,0 +1,106 @@
# @deepseek-ai/dsh-subagent-cursor
[English](README.md) | 中文
本包注册固定的 `cursor` subagent 提供方。每次接受运行请求后,它都会在发起委托的会话工作区中以非交互 print 模式启动官方 `cursor-agent` CLI提交一个自包含的文本任务读取该 CLI 的 `stream-json` 事件流,并通过共享的 [`dsh-subagent`](../subagent/README.md) 结果约定仅返回最终答案。
## 启动与所有权
`start(request)` 只接受非空的文本块序列,并根据父会话确定子级 cwd。随后它通过 [`dsh-subprocess`](../../subprocess/subprocess/README.md) 解析 `cursor-agent`spawn 固定命令,且仅在该 CLI 的 `system`/`init` 事件公布其自有会话 ID 之后才发布此次运行——那正是该 CLI 已启动并解析出自身凭证与模型的时刻。若在此公布之前发生失败或取消,它会终止受管进程树并等待其退出,然后拒绝 `start()` 调用。
任务以位置命令行参数的形式跨越进程边界,因为这是 print 模式提供的唯一提示词通道:该 CLI 未记载 `--` 选项终止符,也不从标准输入读取提示词。由此产生的两项后果被强制约束,而非含糊掩盖。首字符为 `-` 的任务在准入阶段即被拒绝,因为该 CLI 会将其解析为选项。解析到 Windows `.cmd``.bat` 包装脚本同样被拒绝,因为只有 `cmd.exe` 能运行它,而其命令尾部会把模型撰写的文本重新解析为 shell 语法PATHEXT 解析会优先选择原生 Windows 安装程序提供的 `cursor-agent.exe`。标准输入在 spawn 后立即关闭,因此该 CLI 若仍尝试读取提示词,会快速失败,而不是让无人值守的子级停滞在无人能给出的答案上。
已发布的 `run.result` 会等待权威的终止 `result` 事件,且只接受 `subtype: "success"``is_error: false` 并带非空白 `result` 的事件。其他任何终止事件、格式错误的标准输出行、流失败,或流结束时仍无结果,都映射为 `error`print 模式不携带可供程序判读的失败分类,因此该提供方既不会产生 `max-tokens` 也不会产生 `refusal``user``tool_call` 以及更新版 CLI 新增的事件类别对本约定没有贡献。
print 模式没有回复通道,因此不存在协议层中断:取消即本次运行的中止信号,子进程 seam 会将其转为逐级终止机制,同时结果立即判为 `aborted`,并携带此前收集到的最后一条非空助手消息。`dispose()`(资源释放)具有幂等性:它会摘除事件流监听、调用共享的进程树逐级终止机制,并等待整棵进程树退出。结果失败与独立的清理失败仍彼此分离。
## 能力与上下文
本提供方不声明任何可选的启动时能力,并报告 `inheritsParentContext: false`。Cursor 会接收独立文本任务和父会话 cwd但不会接收父会话的对话、角色设定、工具筛选器、深度策略或结构化输出约定。该 CLI 自有的会话 ID 与模型仅在此次运行内部可见,绝不会持久化到父会话。
## 配置
| 配置键 | 默认值 | 含义 |
|---|---|---|
| `env` | `{}` | 显式指定的子进程环境,叠加在由子进程 seam 清除凭证后的父环境之上。 |
| `disposeGraceMs` | `3000` | 共享进程树责任方各终止层级之间的宽限期,单位为毫秒且须为正有限值,并不得大于 [`MAX_TIMER_DELAY_MS`](../../util/timeout/README.md);随后资源释放会等待整棵进程树退出。 |
| `force` | `false` | 传入 `--force`允许子级实际应用文件改动并执行命令。Cursor 自身的 print 模式默认只“提出”改动,因此预期要编辑工作区的委托需要开启此项。 |
| `trust` | `false` | 传入 `--trust`,允许子级在不经过 Cursor 交互式信任提示的情况下在工作区中行动——无人值守的子级无法回答该提示。 |
生产环境会从子进程执行环境中已清除凭证的 `PATH` 里解析 `cursor-agent`,并叠加显式 `env` 条目,同时使用宿主机原生的 Cursor 配置与身份验证。本插件不安装该 CLI、不选择模型、不创建 Cursor 主目录、不执行登录,也不探测版本。子进程 seam 会移除具有凭证特征的环境变量,因此供子进程使用的 `CURSOR_API_KEY` 必须在 `env` 中提供;它绝不会作为 `--api-key` 传入——那会让进程列表暴露它。除非被覆盖,`PATH``HOME` 等普通环境变量值仍然可用。
`dsh-base` 宿主平面会挂载本提供方,`code``cordis``standard` Agent Preset 均携带已启用的 `subagent_cursor` 工具行;`economy` preset 保留 `disabled: true`,因为经济模式的组装不应默认动用外部付费 agent。加载提供方本身不会在工具调用前启动 Cursor 进程。其 `one-shot` 策略会让省略 `run_in_background` 或传入 `false` 的调用继续在前台等待,而显式传入 `true` 会返回由父 agent 拥有的 Job ID`job_output``job_kill` 使用。
下面的独立组装展示了完整的显式能力。基于 `@deepseek-ai/dsh-base` 的 Profile 会保留其既有的 Job 行与提供方行,只需启用 preset 中的工具行,而不必挂载重复项。
```yaml
- id: subagent-cursor
name: '@deepseek-ai/dsh-subagent-cursor'
config:
force: true
trust: true
env:
CURSOR_API_KEY: !!js process.env.CURSOR_API_KEY
- id: jobs
name: '@deepseek-ai/dsh-jobs-local'
- id: tool-jobs
name: '@deepseek-ai/dsh-tool-jobs'
- id: tool-subagent-cursor
name: '@deepseek-ai/dsh-tool-subagent'
config:
provider: cursor
toolName: subagent_cursor
backgroundMode: one-shot
maxDepth: provider-managed
```
## 产品兼容性与证据
本提供方只读取这一 one-shot 约定所需的 print 模式事件:`system`/`init` 用于发布运行,`assistant` 用于快照部分输出,`result` 用于结束运行。部署方需在 `PATH` 上提供 `cursor-agent`;该 CLI 不是本包的 npm 依赖,因此单元证据通过子进程 seam 驱动真实事件流,而不依赖固定版本的二进制文件。
该 CLI 还能以 `cursor-agent acp` 讲 Agent Client Protocol通用的 [`dsh-subagent-acp`](../subagent-acp/README.md) 提供方仅凭配置即可驱动它。那条路径适用于需要 ACP 权限自动应答策略或长期远端会话的部署;本包存在的意义在于这一 one-shot 委托约定、自有的 `subagent_cursor` 工具行,以及 print 模式更简单的失败面。
## Model Experience
### 子级请求
#### 模型看到什么
Cursor 子级会以一次全新的 print 模式运行,接收拼接后的文本任务作为单个位置提示词。其工作区是父会话 cwd而模型、系统指令、工具与身份验证均来自宿主机原生的 Cursor 安装与配置。
#### Token 影响
子级为独立的 Cursor 上下文与轮次付费。子级 token 不会进入父级上下文。
#### KV 缓存影响
与父级请求缓存互相独立。复用只取决于 Cursor 自身的模型、指令、工具与本次运行的请求。
### 父级调度与结果(间接)
#### 模型看到什么
通过 `dsh-tool-subagent`,前台调用会把终止事件中的 Cursor 答案交给父级,若结果并非完成状态,则交给消费方的确切错误。后台调用先返回一个 Job ID通用作业控制工具随后送达完成通知通过 `job_output` 暴露最终答案与状态,并允许 `job_kill` 请求取消。Cursor 的中间消息、工具活动、标准错误、工作区差异、用量与产品 ID 都不会复制进父会话。
#### Token 影响
前台输入会因保留的最终答案或错误而增长。后台输入还包含启动确认、完成通知,以及任何 `job_output``job_kill` 或后续状态结果;子级 token 仍不会进入父级上下文。本提供方自身不添加任何父级工具 schema。
#### KV 缓存影响
仅追加:前台在可复用的父级前缀之后追加一条结果,后台则追加 Job 确认、通知以及后续的控制或收集结果。后台调度可能新增一个由通知驱动的轮次,但上述任何消息都不会改写此前的前缀。
## Known Limitations and Deferred Work
- **每次委托对应一个全新进程与一次运行** —— 没有续接、`--resume`、进程池化、进度流或产品会话持久化,尽管该 CLI 本身支持按 ID 恢复对话。
- **任务不能以 `-` 开头** —— print 模式按位置接收提示词且未记载 `--` 终止符,因此这类任务在准入阶段即被拒绝,而不是被误解析为选项。
- **Windows 需要原生可执行文件** —— 解析到 `.cmd``.bat` 包装脚本会被拒绝,而不会通过 `cmd.exe` 运行——其命令尾部会重新解析任务文本。
- **产品安装与账户状态由宿主机管理** —— 缺失 `cursor-agent`、配置错误或身份验证失败会作为启动错误或运行错误上报;本插件不提供安装器、登录流程或运行时版本闸门。
- **没有分级审批路径** —— `force``trust` 是整次运行级别的部署开关;本包不提供按调用的策略、允许清单或人工审批通道。
- **没有失败分类** —— print 模式只报告终止子类型与错误标记,因此上下文窗口耗尽与其他任何失败无法区分,且绝不会映射为 `max-tokens`
- **产品载荷仅为最终文本** —— 中间消息、工具流量、用量、标准错误与工作区差异仍留在产品本地;通用 Job ID、通知与状态来自共享作业运行时。
- **没有可选共享能力** —— 输出 schema、子级角色设定、工具筛选与 harness 深度强制均被共享服务针对本提供方拒绝。
- **没有挂钟超时或副作用回滚** —— 由调用方取消长时间工作,取消前已改动的文件或外部系统不会被还原。

View File

@@ -0,0 +1,59 @@
{
"name": "@deepseek-ai/dsh-subagent-cursor",
"description": "One-shot Cursor subagent provider over the cursor-agent print-mode stream-json protocol",
"version": "0.1.0-rc.7",
"publishConfig": {
"access": "public"
},
"repository": {
"type": "git",
"url": "git+https://github.com/deepseek-ai/deepseek-harness.git",
"directory": "packages/subagent/subagent-cursor"
},
"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"
],
"license": "MIT",
"peerDependencies": {
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"@deepseek-ai/dsh-subagent": "workspace:^",
"@deepseek-ai/dsh-subprocess": "workspace:^",
"@deepseek-ai/dsh-timeout": "workspace:^",
"@deepseek-ai/cordis": "workspace:^"
},
"dependencies": {
"@deepseek-ai/schemastery": "workspace:^"
},
"devDependencies": {
"@deepseek-ai/cordis-plugin-loader": "workspace:^",
"@deepseek-ai/dsh-agent": "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-subagent": "workspace:^",
"@deepseek-ai/dsh-subprocess": "workspace:^",
"@deepseek-ai/dsh-subprocess-local": "workspace:^",
"@deepseek-ai/dsh-timeout": "workspace:^",
"@deepseek-ai/cordis": "workspace:^"
}
}

View File

@@ -0,0 +1,130 @@
/**
* Fixed Cursor one-shot subagent provider. Every accepted run starts a fresh
* non-interactive `cursor-agent --print --output-format stream-json` process
* in the delegating Session's workspace and publishes only after the CLI
* announces its own session.
*
* @module @deepseek-ai/dsh-subagent-cursor
*/
import type { Context } from '@deepseek-ai/cordis'
import z from '@deepseek-ai/schemastery'
import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout'
import {
assertPositiveFinite,
NO_START_CAPABILITIES,
resolveChildCwd,
type ResolvedSubagentStartRequest,
type SubagentCapabilities,
type SubagentProvider,
} from '@deepseek-ai/dsh-subagent'
import {
assertDirectlySpawnable,
DEFAULT_DISPOSE_GRACE_MS,
startCursorRun,
type CursorRunSpec,
} from './run.ts'
export const name = 'subagent-cursor'
export const inject = ['subagents', 'subprocess']
/** Fixed native executable; Cursor's own configuration stays authoritative. */
const CURSOR_EXECUTABLE = 'cursor-agent'
/** Deployment-owned environment, permissions, and process-release bound. */
export interface Config {
/**
* Explicit environment entries layered over the subprocess seam's
* credential-scrubbed parent environment. `CURSOR_API_KEY` belongs here
* rather than on the command line, where a process listing would expose it.
*/
env?: Record<string, string>
/** Grace in milliseconds for `cursor-agent` process-tree termination. */
disposeGraceMs?: number
/**
* Whether the child may apply file changes and run commands (`--force`).
* Cursor's own print-mode default only PROPOSES changes, so a delegation
* expected to edit the workspace needs this on.
*/
force?: boolean
/**
* Whether the child may act in the workspace without Cursor's interactive
* trust prompt (`--trust`). An unattended child cannot answer that prompt.
*/
trust?: boolean
}
export const Config: z<Config> = z.object({
env: z.dict(z.string()).default({}),
disposeGraceMs: z.number().default(DEFAULT_DISPOSE_GRACE_MS),
force: z.boolean().default(false),
trust: z.boolean().default(false),
})
type ResolvedConfig = Required<Config>
class CursorProvider implements SubagentProvider {
readonly name = 'cursor'
readonly capabilities: SubagentCapabilities = NO_START_CAPABILITIES
readonly inheritsParentContext = false
constructor(
private readonly ctx: Context,
private readonly config: ResolvedConfig,
) {}
async start(request: ResolvedSubagentStartRequest) {
const parentCwd = request.parent.session.header.cwd
if (parentCwd === undefined) {
throw new Error(
'subagent-cursor: no working directory for the child — delegate from a parent session that has one',
)
}
const executable = assertDirectlySpawnable(
await this.ctx.subprocess.resolveExecutable(
CURSOR_EXECUTABLE,
this.config.env,
request.signal,
),
)
const spec: CursorRunSpec = {
cwd: resolveChildCwd(
'subagent-cursor',
undefined,
parentCwd,
),
executable,
env: this.config.env,
disposeGraceMs: this.config.disposeGraceMs,
force: this.config.force,
trust: this.config.trust,
spawn: spawnSpec => this.ctx.subprocess.spawn(spawnSpec),
onError: (error, stopReason) => {
this.ctx.logger.warn(
`subagent-cursor: child run failed (${stopReason}): ${error.message}`,
)
},
}
return startCursorRun(request, spec)
}
}
/**
* Register the fixed `cursor` provider.
* @param ctx - context carrying shared subagent and subprocess services.
* @param config - explicit child environment, permissions, and disposal grace.
*/
export function apply(ctx: Context, config: Config): void {
const resolved = config as ResolvedConfig
assertPositiveFinite(
'subagent-cursor',
'disposeGraceMs',
resolved.disposeGraceMs,
)
if (resolved.disposeGraceMs > MAX_TIMER_DELAY_MS) {
throw new Error(
`subagent-cursor: disposeGraceMs must be no greater than ${MAX_TIMER_DELAY_MS}`,
)
}
ctx.subagents.registerProvider(new CursorProvider(ctx, resolved))
}

View File

@@ -0,0 +1,30 @@
/**
* Package-owned invariant companion for `@deepseek-ai/dsh-subagent-cursor`.
* @module @deepseek-ai/dsh-subagent-cursor/invariant
*/
/* jscpd:ignore-start */
import type { Context } from '@deepseek-ai/cordis'
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
const PACKAGE_NAME = '@deepseek-ai/dsh-subagent-cursor'
/** Cordis companion plugin name. */
export const name = 'subagent-cursor-invariant'
/** Service required before the companion can reserve package ownership. */
export const inject = ['invariants']
/**
* No runtime invariant: lifecycle pairing belongs to the shared subagent
* service and process-tree ownership belongs to the subprocess service.
*/
const install: InvariantInstaller = () => {}
/**
* Register this package's invariant companion.
* @param ctx - plugin context carrying the invariant registry.
* @returns the installed registration's disposer.
*/
export const apply = (ctx: Context): Promise<() => void> =>
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
/* jscpd:ignore-end */

View File

@@ -0,0 +1,267 @@
/**
* One-shot Cursor child lifecycle: spawn the real `cursor-agent` print-mode
* command through the subprocess seam, publish only after the CLI announces
* its session, flatten post-publication failures, and dispose to whole-tree
* quiescence.
*
* @module @deepseek-ai/dsh-subagent-cursor/run
*/
import { randomUUID } from 'node:crypto'
import { extname } from 'node:path'
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
import { SessionId } from '@deepseek-ai/dsh-session'
import {
settleRunResult,
subprocessRunHandle,
type SubagentResult,
type SubagentRun,
type SubagentStartRequest,
type SubagentStopReason,
} from '@deepseek-ai/dsh-subagent'
import type { SubprocessHandle, SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess'
import { CursorStreamWire } from './wire.ts'
/** Default POSIX grace between subprocess termination tiers. */
export const DEFAULT_DISPOSE_GRACE_MS = 3_000
/* jscpd:ignore-start -- sibling providers intentionally keep product-private
* run inputs and error normalization instead of adding a shared lifecycle owner. */
/** Fully resolved inputs for one `cursor-agent` print-mode run. */
export interface CursorRunSpec {
/** Parent Session workspace, also passed as the CLI `--workspace`. */
readonly cwd: string
/** Exact native `cursor-agent` executable resolved from the host PATH. */
readonly executable: string
/** Explicit deployment/test environment layered after the shared scrub. */
readonly env: Record<string, string>
/** Subprocess termination grace passed to the shared process-tree owner. */
readonly disposeGraceMs: number
/** Whether the child may apply changes instead of only proposing them. */
readonly force: boolean
/** Whether the child may act in the workspace without a trust prompt. */
readonly trust: boolean
/** Shared subprocess service spawn operation. */
readonly spawn: (spec: SubprocessSpawnSpec) => SubprocessHandle
/** Diagnostic sink for a post-publication error flattened into a result. */
readonly onError?: (error: Error, stopReason: SubagentStopReason) => void
}
function thrown(value: unknown): Error {
/* v8 ignore next -- typed subprocess/wire failures reject with Error. */
return value instanceof Error ? value : new Error(String(value))
}
/* jscpd:ignore-end */
/**
* Validate and preserve the one-shot task before it becomes a command-line
* argument.
*
* `cursor-agent` takes the task as a POSITIONAL argument and documents no
* `--` end-of-options separator, so a task whose first character is `-` would
* be parsed as an option. The seam has no way to escape that, so it is
* rejected here rather than silently mis-parsed by the CLI.
* @param prompt - task content accepted from the shared subagent service.
* @returns the exact concatenated text task.
*/
export function textTask(prompt: readonly ContentBlock[]): string {
if (prompt.length === 0) {
throw new Error('subagent-cursor: the one-shot task must contain only text blocks')
}
const texts: string[] = []
for (const block of prompt) {
if (block.type !== 'text') {
throw new Error('subagent-cursor: the one-shot task must contain only text blocks')
}
texts.push(block.text)
}
if (texts.every(text => text.trim().length === 0)) {
throw new Error('subagent-cursor: the one-shot task must not be empty')
}
const task = texts.join('')
if (task.startsWith('-')) {
throw new Error(
'subagent-cursor: the one-shot task must not begin with "-" — cursor-agent takes it as a positional argument and would parse it as an option',
)
}
return task
}
/**
* Assert the resolved executable can be started without a shell.
*
* The task text is ordinary argv on every direct spawn, but a Windows `.cmd`
* or `.bat` shim can only run through `cmd.exe`, which reparses its command
* tail — model-authored task text would then be shell syntax. PATHEXT
* resolution prefers `cursor-agent.exe`, which the native Windows installer
* provides, so this fails loud instead of opening that boundary.
* @param executable - the resolved absolute executable path.
* @param platform - host platform selecting the batch-shim rejection.
* @returns the executable, validated.
*/
export function assertDirectlySpawnable(
executable: string,
platform: NodeJS.Platform = process.platform,
): string {
const extension = extname(executable).toLowerCase()
if (platform === 'win32' && (extension === '.cmd' || extension === '.bat')) {
throw new Error(
`subagent-cursor: resolved ${executable} is a batch shim that requires a shell; install the native cursor-agent executable so the task text stays ordinary argv`,
)
}
return executable
}
/**
* Build the fixed print-mode command for one run.
* @param spec - resolved executable, workspace, and permission selections.
* @param task - the validated positional task text.
* @returns argv for one non-interactive `cursor-agent` run.
*/
export function cursorAgentArgv(
spec: Pick<CursorRunSpec, 'executable' | 'cwd' | 'force' | 'trust'>,
task: string,
): string[] {
return [
spec.executable,
'--print',
'--output-format',
'stream-json',
'--workspace',
spec.cwd,
...(spec.force ? ['--force'] : []),
...(spec.trust ? ['--trust'] : []),
task,
]
}
/**
* Close the event stream, terminate the managed process tree, and wait for the
* subprocess owner to prove it is gone.
* @param wire - the run's private stdout event decoder.
* @param child - shared-service handle that owns the process tree.
*/
export async function disposeCursorChild(
wire: CursorStreamWire,
child: SubprocessHandle,
): Promise<void> {
wire.close()
if (child.pid <= 0) {
await child.done.catch(() => {})
return
}
child.terminate()
await child.waitForExit()
await child.done
}
/**
* Start one real `cursor-agent` print-mode child and publish its one-shot run.
* @param request - resolved shared subagent request.
* @param spec - executable, workspace, environment, permissions, process
* service, and diagnostic policy.
* @returns the published run after the CLI announces its session.
*/
export async function startCursorRun(
request: SubagentStartRequest,
spec: CursorRunSpec,
): Promise<SubagentRun> {
const task = textTask(request.prompt)
if (request.signal.aborted) {
throw new Error('subagent-cursor: request was aborted before cursor-agent startup')
}
const runAbort = new AbortController()
const child = spec.spawn({
argv: cursorAgentArgv(spec, task),
cwd: spec.cwd,
stdio: { stdin: 'pipe', stdout: 'pipe', stderr: 'inherit' },
graceMs: spec.disposeGraceMs,
// Print mode offers no reply channel, so there is no protocol interrupt to
// send: stopping the child IS the cancellation, and the subprocess seam
// owns that termination escalation.
signal: runAbort.signal,
env: spec.env,
})
const wire = new CursorStreamWire(child.stdout as NonNullable<SubprocessHandle['stdout']>)
const disposeProcess = (): Promise<void> => disposeCursorChild(wire, child)
const processFailure: Promise<never> = child.done.then(
outcome => Promise.reject(new Error(
'subagent-cursor: cursor-agent exited before the run settled '
+ `(code ${String(outcome.exitCode)}, signal ${String(outcome.signal)})`,
)),
(error: unknown) => Promise.reject(thrown(error)),
)
// A normal post-result dispose also closes the process. Keep that expected
// late rejection observed after the result race has already settled.
processFailure.catch(() => {})
const requestCancel = (): void => {
if (runAbort.signal.aborted) return
runAbort.abort(new Error('subagent-cursor: run cancelled locally'))
}
const onAbort = (): void => { requestCancel() }
request.signal.addEventListener('abort', onAbort, { once: true })
// The spawn signal stops the child; this race settles the run. Waiting for
// the termination ladder instead would leave a cancelled result pending for
// the whole grace period, so cancellation is observed here directly.
const cancellation: Promise<never> = new Promise((_resolve, reject) => {
runAbort.signal.addEventListener(
'abort',
() => { reject(new Error('subagent-cursor: run cancelled locally')) },
{ once: true },
)
})
// Both races below observe this rejection, but only until one of them
// settles; keep it observed for the window after that.
cancellation.catch(() => {})
try {
wire.start()
// The task is already on the command line and print mode reads no input.
// Closing stdin now makes any prompt the CLI still tries to read fail fast
// instead of stalling an unattended child on an answer nobody can give.
child.stdin?.end()
await Promise.race([wire.ready(), processFailure, cancellation])
} catch (error: unknown) {
request.signal.removeEventListener('abort', onAbort)
try {
await disposeProcess()
} catch (disposeError: unknown) {
throw new AggregateError(
[thrown(error), thrown(disposeError)],
'subagent-cursor: startup failed and cursor-agent cleanup also failed',
)
}
if (runAbort.signal.aborted) {
throw new Error('subagent-cursor: request was aborted before run publication')
}
throw thrown(error)
}
/* jscpd:ignore-start -- the seam documents settlement and publication as two
* primitives, so every out-of-process provider ends with this same literal
* composition; folding them into a third seam function would trade two named
* steps for one eight-parameter call. */
const result: Promise<SubagentResult> = settleRunResult({
attempt: () => Promise.race([wire.awaitResult(), processFailure, cancellation]),
collectOutput: () => wire.collectOutput(),
cancelled: () => runAbort.signal.aborted,
onError: spec.onError,
signal: request.signal,
onAbort,
})
return subprocessRunHandle({
id: SessionId(randomUUID()),
result,
signal: request.signal,
onAbort,
requestCancel,
teardown: disposeProcess,
})
/* jscpd:ignore-end */
}

View File

@@ -0,0 +1,265 @@
/**
* Decoder for one `cursor-agent --print --output-format stream-json` run. The
* CLI writes newline-delimited JSON events on stdout and never reads a reply,
* so this module owns line framing, event validation at the process boundary,
* the `system`/`init` gate that run publication waits on, assistant-message
* selection, and terminal-answer selection. It sends nothing: cancellation is
* local and process termination belongs to the subprocess seam.
*
* @module @deepseek-ai/dsh-subagent-cursor/wire
*/
import type { Readable } from 'node:stream'
import { StringDecoder } from 'node:string_decoder'
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
import type { SubagentResult } from '@deepseek-ai/dsh-subagent'
type JsonObject = Record<string, unknown>
/**
* The `system`/`init` facts observed once before publication. They prove the
* CLI started and resolved its own credentials and model, which is this
* provider's equivalent of a remote session existing; both values stay
* private to the run and are never persisted in the parent Session.
*/
export interface CursorSessionInfo {
/** Cursor's own chat id for this run. */
readonly sessionId: string
/** Model display name Cursor selected from its native configuration. */
readonly model: string | undefined
}
function object(value: unknown, label: string): JsonObject {
if (value === null || typeof value !== 'object' || Array.isArray(value)) {
throw new Error(`subagent-cursor: cursor-agent emitted an invalid ${label}`)
}
return value as JsonObject
}
/**
* Select the text blocks of one Cursor message. Non-text blocks are dropped
* rather than rejected: the CLI may add block kinds this one-shot contract has
* no representation for, and dropping them cannot corrupt the selected answer.
* @param message - the event's `message` value.
* @param label - event name used in the boundary diagnostic.
* @returns the message's text blocks, possibly empty.
*/
function messageText(message: unknown, label: string): ContentBlock[] {
const content = object(message, label).content
if (!Array.isArray(content)) {
throw new Error(`subagent-cursor: cursor-agent emitted an invalid ${label} content`)
}
const blocks: ContentBlock[] = []
for (const block of content) {
if (
block !== null
&& typeof block === 'object'
&& !Array.isArray(block)
&& (block as JsonObject).type === 'text'
&& typeof (block as JsonObject).text === 'string'
) {
blocks.push({ type: 'text', text: (block as JsonObject).text as string })
}
}
return blocks
}
/**
* Describe why a terminal `result` event cannot complete the run. The CLI
* carries no machine-readable failure taxonomy in print mode, so the reported
* detail is its own `subtype` plus the error marker.
* @param event - the terminal event.
* @returns a stable single-line diagnostic detail.
*/
function resultFailureDetail(event: JsonObject): string {
const subtype = typeof event.subtype === 'string' ? event.subtype : 'unknown'
return subtype === 'success'
? 'success result was marked as an error or contained no answer'
: `terminal result subtype ${subtype}`
}
/**
* One `cursor-agent` print-mode event stream.
*
* The class deliberately exposes no generic event surface. Observing another
* event kind must first become part of the provider contract.
*/
export class CursorStreamWire {
private readonly decoder = new StringDecoder('utf8')
private readonly initialization = Promise.withResolvers<CursorSessionInfo>()
private readonly terminal = Promise.withResolvers<SubagentResult>()
private buffer = ''
private lastAssistantMessage: ContentBlock[] | undefined
private started = false
private closed = false
private initialized = false
private settled = false
/**
* Attach to one run's stdout stream.
* @param input - the child's stdout pipe; the wire owns its listeners only
* between {@link start} and {@link close}.
*/
constructor(private readonly input: Readable) {
// Both gates can reject before their awaiter exists: a startup failure
// rejects `initialization` and abandons `terminal`, and a failure racing
// publication rejects `terminal` before `settleRunResult` adopts it. Keep
// both observed so neither becomes an unhandled rejection.
void this.initialization.promise.catch(() => {})
void this.terminal.promise.catch(() => {})
}
/** Begin reading events. Idempotent. */
start(): void {
if (this.started) return
this.started = true
this.input.on('data', this.onData)
this.input.on('error', this.onError)
this.input.on('end', this.onEnd)
}
/**
* Detach listeners and fail both gates if they are still open. Idempotent,
* and safe before {@link start}.
*/
close(): void {
if (this.closed) return
this.closed = true
this.input.off('data', this.onData)
this.input.off('error', this.onError)
this.input.off('end', this.onEnd)
this.fail(new Error('subagent-cursor: cursor-agent event stream closed'))
}
/**
* Await the `system`/`init` event that gates run publication.
* @returns Cursor's own chat id and selected model for this run.
*/
ready(): Promise<CursorSessionInfo> {
return this.initialization.promise
}
/**
* Await the terminal `result` event.
* @returns the completed result; rejects for every non-success ending,
* malformed event, stream failure, or end of stream without a result.
*/
awaitResult(): Promise<SubagentResult> {
return this.terminal.promise
}
/**
* Snapshot the child's output for a cancelled or failed settlement.
* @returns the last non-empty assistant message, or `[]` when the child
* produced none. Without `--stream-partial-output` each `assistant` event
* is one complete message, so this is the seam's selection rule directly
* rather than a delta accumulation.
*/
collectOutput(): ContentBlock[] {
return this.lastAssistantMessage ?? []
}
private readonly onData = (chunk: Buffer): void => {
try {
// The wire owns stdout between `start` and `close` and never sets an
// encoding on it, so every chunk is bytes that may split a code point.
this.buffer += this.decoder.write(chunk)
for (;;) {
const newline = this.buffer.indexOf('\n')
if (newline < 0) break
const line = this.buffer.slice(0, newline)
this.buffer = this.buffer.slice(newline + 1)
if (line.trim().length > 0) this.handleLine(line)
}
} catch (error: unknown) {
/* v8 ignore next -- handleLine and the decoder throw only Error. */
this.fail(error instanceof Error ? error : new Error(String(error)))
}
}
private readonly onError = (error: Error): void => {
this.fail(error)
}
private readonly onEnd = (): void => {
this.fail(new Error('subagent-cursor: cursor-agent ended without a terminal result'))
}
private handleLine(line: string): void {
let parsed: unknown
try {
parsed = JSON.parse(line)
} catch {
// The CLI's stdout is a process boundary, so a line that is not JSON is
// a protocol failure rather than something to skip: skipping it would
// hide a version whose stream this contract cannot read.
throw new Error('subagent-cursor: cursor-agent emitted a stdout line that is not JSON')
}
const event = object(parsed, 'event')
switch (event.type) {
case 'system':
if (event.subtype === 'init') this.handleInit(event)
return
case 'assistant': {
const blocks = messageText(event.message, 'assistant message')
if (blocks.length > 0) this.lastAssistantMessage = blocks
return
}
case 'result':
this.handleResult(event)
return
default:
// `user`, `tool_call`, and any event kind a newer CLI adds contribute
// nothing to this one-shot contract: publication is gated on `init`
// and the answer comes from `result`.
return
}
}
private handleInit(event: JsonObject): void {
if (typeof event.session_id !== 'string' || event.session_id.length === 0) {
throw new Error('subagent-cursor: cursor-agent emitted an invalid init session id')
}
this.initialized = true
this.initialization.resolve({
sessionId: event.session_id,
model: typeof event.model === 'string' ? event.model : undefined,
})
}
private handleResult(event: JsonObject): void {
const answer = event.result
if (
event.subtype !== 'success'
|| event.is_error === true
|| typeof answer !== 'string'
|| answer.trim().length === 0
) {
this.fail(new Error(`subagent-cursor: cursor-agent failed: ${resultFailureDetail(event)}`))
return
}
this.settled = true
if (!this.initialized) {
// A run that never announced itself cannot be published, so its answer
// has nowhere to go: fail startup instead of resolving a result for a
// run the caller was never handed.
const error = new Error('subagent-cursor: cursor-agent produced a result without announcing a session')
this.initialization.reject(error)
this.terminal.reject(error)
return
}
// The terminal event carries the full assistant answer, so it wins over
// the last streamed message for a completed run.
this.terminal.resolve({
output: [{ type: 'text', text: answer }],
stopReason: 'completed',
})
}
private fail(error: Error): void {
if (this.settled) return
this.settled = true
this.initialization.reject(error)
this.terminal.reject(error)
}
}

View File

@@ -0,0 +1,54 @@
import { join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { describe, expect, it } from 'vitest'
import {
LOADER_SMOKE_TEST_TIMEOUT_MS,
runLoaderSmoke,
} from '@deepseek-ai/dsh-loader-smoke'
const fixtureDir = fileURLToPath(new URL(
'../../../../examples/acp-agent/tests/fixtures/subagent/subagent-cursor/',
import.meta.url,
))
const driver = join(fixtureDir, 'driver.ts')
const configPath = join(fixtureDir, 'cordis.yml')
const repoTsconfig = fileURLToPath(new URL('../../../../tsconfig.json', import.meta.url))
describe('Cursor provider public Loader composition', () => {
it('loads the opt-in package, one-shot task tool, and job controls without starting Cursor', async () => {
const { stdout, stderr } = await runLoaderSmoke({
label: 'subagent-cursor Loader composition',
tempDirPrefix: 'dsh-subagent-cursor-loader-',
binScript: driver,
libBinScript: driver,
configPath,
tsconfigPath: repoTsconfig,
env: {
// Loading the optional package must not probe or start a cursor-agent binary.
PATH: '',
},
})
expect(stderr).toBe('')
expect(JSON.parse(stdout)).toEqual({
providers: ['cursor'],
provider: {
name: 'cursor',
capabilities: {
outputSchema: false,
depthLimit: false,
toolFilter: false,
persona: false,
},
inheritsParentContext: false,
},
tool: {
name: 'subagent_cursor',
parameterNames: ['description', 'prompt', 'run_in_background'],
required: ['description', 'prompt'],
},
jobTools: ['job_kill', 'job_list', 'job_output'],
starts: 0,
})
}, LOADER_SMOKE_TEST_TIMEOUT_MS)
})

View File

@@ -0,0 +1,704 @@
import { PassThrough } from 'node:stream'
import { Context } from '@deepseek-ai/cordis'
import Loader from '@deepseek-ai/cordis-plugin-loader'
import { describe, expect, it, vi } from 'vitest'
import type { Agent } from '@deepseek-ai/dsh-agent'
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
import SubagentRuntime, { type SubagentStopReason } from '@deepseek-ai/dsh-subagent'
import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout'
import type {
SubprocessHandle,
SubprocessOutcome,
} from '@deepseek-ai/dsh-subprocess'
import LocalSubprocessRuntime from '@deepseek-ai/dsh-subprocess-local'
import * as cursor from '../src/index.ts'
import * as invariant from '../src/invariant.ts'
import {
assertDirectlySpawnable,
cursorAgentArgv,
DEFAULT_DISPOSE_GRACE_MS,
disposeCursorChild,
startCursorRun,
textTask,
type CursorRunSpec,
} from '../src/run.ts'
import { CursorStreamWire } from '../src/wire.ts'
type JsonObject = Record<string, unknown>
const EXECUTABLE = '/opt/cursor/cursor-agent'
const fakeParent = {
id: 'parent',
session: { header: { cwd: process.cwd() } },
} as unknown as Agent
function request(
prompt: ContentBlock[] = [{ type: 'text', text: 'do the task' }],
signal = new AbortController().signal,
) {
return { prompt, parent: fakeParent, signal }
}
async function nextTask(): Promise<void> {
await new Promise<void>((resolve) => { setImmediate(resolve) })
}
function initEvent(overrides: JsonObject = {}): JsonObject {
return {
type: 'system',
subtype: 'init',
apiKeySource: 'env',
cwd: process.cwd(),
session_id: 'chat-1',
model: 'cursor-model',
permissionMode: 'default',
...overrides,
}
}
function assistantEvent(content: unknown): JsonObject {
return {
type: 'assistant',
message: { role: 'assistant', content },
session_id: 'chat-1',
}
}
function resultEvent(overrides: JsonObject = {}): JsonObject {
return {
type: 'result',
subtype: 'success',
duration_ms: 12,
duration_api_ms: 10,
is_error: false,
result: 'the final answer',
session_id: 'chat-1',
...overrides,
}
}
/** Writes stream-json events the way the real CLI writes its stdout. */
class StreamPeer {
constructor(private readonly output: PassThrough) {}
send(...events: readonly JsonObject[]): void {
this.output.write(`${events.map(event => JSON.stringify(event)).join('\n')}\n`)
}
raw(text: string): void {
this.output.write(text)
}
}
interface FakeChildOptions {
readonly pid?: number
readonly exitOnTerminate?: boolean
readonly doneError?: Error
}
interface FakeChild {
readonly handle: SubprocessHandle
readonly peer: StreamPeer
readonly fromChild: PassThrough
readonly toChild: PassThrough
readonly settle: (outcome?: SubprocessOutcome) => void
readonly fail: (error: Error) => void
readonly terminate: () => void
readonly waitForExit: (signal?: AbortSignal) => Promise<boolean>
}
function fakeChild(options: FakeChildOptions = {}): FakeChild {
const fromChild = new PassThrough()
const toChild = new PassThrough()
const peer = new StreamPeer(fromChild)
let exited = false
let resolveDone!: (outcome: SubprocessOutcome) => void
let rejectDone!: (error: Error) => void
const done = new Promise<SubprocessOutcome>((resolve, reject) => {
resolveDone = resolve
rejectDone = reject
})
const settle = (
outcome: SubprocessOutcome = { exitCode: 0, signal: null },
): void => {
if (exited) return
exited = true
resolveDone(outcome)
}
const fail = (error: Error): void => {
if (exited) return
exited = true
rejectDone(error)
}
if (options.doneError !== undefined) fail(options.doneError)
const terminate = vi.fn(() => {
if (options.exitOnTerminate !== false) settle()
})
const waitForExit = vi.fn(async (signal?: AbortSignal) => {
if (exited) return true
if (signal === undefined) {
await done.catch(() => {})
return true
}
return await new Promise<boolean>((resolve) => {
const onAbort = (): void => { resolve(false) }
signal.addEventListener('abort', onAbort, { once: true })
void done.then(
() => {
signal.removeEventListener('abort', onAbort)
resolve(true)
},
() => {
signal.removeEventListener('abort', onAbort)
resolve(true)
},
)
})
})
const handle: SubprocessHandle = {
pid: options.pid ?? 4321,
stdin: toChild,
stdout: fromChild,
stderr: undefined,
collected: {},
done,
terminate,
waitForExit,
}
return {
handle,
peer,
fromChild,
toChild,
settle,
fail,
terminate,
waitForExit,
}
}
function runSpec(
child: FakeChild,
overrides: Partial<CursorRunSpec> = {},
): CursorRunSpec {
return {
cwd: process.cwd(),
executable: EXECUTABLE,
env: {},
disposeGraceMs: DEFAULT_DISPOSE_GRACE_MS,
force: false,
trust: false,
spawn: () => child.handle,
...overrides,
}
}
async function publishRun(
child = fakeChild(),
signal = new AbortController().signal,
specOverrides: Partial<CursorRunSpec> = {},
) {
const starting = startCursorRun(request(undefined, signal), runSpec(child, specOverrides))
await nextTask()
child.peer.send(initEvent())
return { child, run: await starting }
}
function startedWire(): { readonly child: FakeChild; readonly wire: CursorStreamWire } {
const child = fakeChild()
const wire = new CursorStreamWire(child.handle.stdout!)
wire.start()
return { child, wire }
}
describe('task admission and command construction', () => {
it('accepts one or more text blocks and rejects empty, non-text, or option-shaped tasks', () => {
expect(textTask([{ type: 'text', text: 'a' }, { type: 'text', text: 'b' }])).toBe('ab')
expect(() => textTask([])).toThrow('must contain only text blocks')
expect(() => textTask([{ type: 'reasoning', text: 'x' }]))
.toThrow('must contain only text blocks')
expect(() => textTask([{ type: 'text', text: ' ' }])).toThrow('must not be empty')
expect(() => textTask([{ type: 'text', text: '--force me' }]))
.toThrow('must not begin with "-"')
})
it('refuses a Windows batch shim so task text never reaches a shell', () => {
expect(assertDirectlySpawnable('C:\\bin\\cursor-agent.exe', 'win32'))
.toBe('C:\\bin\\cursor-agent.exe')
expect(assertDirectlySpawnable('/usr/bin/cursor-agent', 'linux'))
.toBe('/usr/bin/cursor-agent')
// A shim is only unusable where it needs cmd.exe.
expect(assertDirectlySpawnable('/usr/bin/cursor-agent.cmd', 'linux'))
.toBe('/usr/bin/cursor-agent.cmd')
for (const shim of ['C:\\bin\\cursor-agent.cmd', 'C:\\bin\\cursor-agent.BAT']) {
expect(() => assertDirectlySpawnable(shim, 'win32')).toThrow('is a batch shim')
}
// The omitted platform reads the host, so the expectation follows it.
const underHostPlatform = (): string => assertDirectlySpawnable('C:\\bin\\cursor-agent.cmd')
if (process.platform === 'win32') {
expect(underHostPlatform).toThrow('is a batch shim')
} else {
expect(underHostPlatform()).toBe('C:\\bin\\cursor-agent.cmd')
}
})
it('builds the fixed print-mode argv and adds only selected permissions', () => {
const base = { executable: EXECUTABLE, cwd: '/work', force: false, trust: false }
expect(cursorAgentArgv(base, 'ship it')).toEqual([
EXECUTABLE,
'--print',
'--output-format',
'stream-json',
'--workspace',
'/work',
'ship it',
])
expect(cursorAgentArgv({ ...base, force: true, trust: true }, 'ship it')).toEqual([
EXECUTABLE,
'--print',
'--output-format',
'stream-json',
'--workspace',
'/work',
'--force',
'--trust',
'ship it',
])
})
})
describe('package contracts', () => {
it('registers one fixed descriptor, validates config, and unregisters on HMR', async () => {
const ctx = new Context()
await ctx.plugin(SubagentRuntime)
await ctx.plugin(LocalSubprocessRuntime)
const fiber = await ctx.plugin(cursor, {})
expect(ctx.subagents.getProvider('cursor')).toMatchObject({
name: 'cursor',
capabilities: {
outputSchema: false,
depthLimit: false,
toolFilter: false,
persona: false,
},
inheritsParentContext: false,
})
expect(ctx.subagents.list()).toEqual(['cursor'])
await fiber.dispose()
expect(ctx.subagents.list()).toEqual([])
for (const disposeGraceMs of [0, -1, Number.NaN, Number.POSITIVE_INFINITY]) {
await expect(ctx.plugin(cursor, { disposeGraceMs }))
.rejects.toThrow('disposeGraceMs must be a positive finite number')
}
await expect(ctx.plugin(cursor, { disposeGraceMs: MAX_TIMER_DELAY_MS + 1 }))
.rejects.toThrow(`disposeGraceMs must be no greater than ${MAX_TIMER_DELAY_MS}`)
await ctx.fiber.dispose()
})
it('requires a parent session cwd before resolving or spawning anything', async () => {
const ctx = new Context()
await ctx.plugin(SubagentRuntime)
await ctx.plugin(LocalSubprocessRuntime)
const resolveExecutable = vi.spyOn(ctx.subprocess, 'resolveExecutable')
const spawn = vi.spyOn(ctx.subprocess, 'spawn')
await ctx.plugin(cursor, {})
await expect(ctx.subagents.start('cursor', {
prompt: [{ type: 'text', text: 'task' }],
parent: {
id: 'parent-without-cwd',
session: { header: {} },
} as unknown as Agent,
signal: new AbortController().signal,
})).rejects.toThrow(
'subagent-cursor: no working directory for the child — delegate from a parent session that has one',
)
expect(resolveExecutable).not.toHaveBeenCalled()
expect(spawn).not.toHaveBeenCalled()
await ctx.fiber.dispose()
})
it('keeps the namespace export shape and package-owned empty invariant', async () => {
expect('default' in cursor).toBe(false)
expect(cursor.name).toBe('subagent-cursor')
expect(cursor.inject).toEqual(['subagents', 'subprocess'])
const loader = Object.create(Loader.prototype) as Loader
expect(loader.unwrapExports(cursor)).toBe(cursor)
const dispose = vi.fn()
const register = vi.fn((
_packageName: string,
_installer: InvariantInstaller,
) => dispose)
const ctx = { invariants: { register } } as unknown as Context
await expect(invariant.apply(ctx)).resolves.toBe(dispose)
expect(register).toHaveBeenCalledWith(
'@deepseek-ai/dsh-subagent-cursor',
expect.any(Function),
)
const install = register.mock.calls[0]![1]
await install(new Context(), (message) => { throw new Error(message) })
expect(invariant.name).toBe('subagent-cursor-invariant')
expect(invariant.inject).toEqual(['invariants'])
})
})
describe('CursorStreamWire', () => {
it('gates on init, keeps the terminal answer, and ignores unrelated events', async () => {
const { child, wire } = startedWire()
wire.start()
child.peer.send(initEvent())
await expect(wire.ready()).resolves.toEqual({
sessionId: 'chat-1',
model: 'cursor-model',
})
child.peer.send(
{ type: 'user', message: { role: 'user', content: [{ type: 'text', text: 'task' }] } },
{ type: 'tool_call', subtype: 'started', call_id: 'c1', tool_call: {} },
{ type: 'system', subtype: 'usage', tokens: 12 },
assistantEvent([{ type: 'text', text: 'thinking out loud' }]),
{ type: 'newer_cli_event', payload: 1 },
)
await nextTask()
expect(wire.collectOutput()).toEqual([{ type: 'text', text: 'thinking out loud' }])
child.peer.send(resultEvent())
await expect(wire.awaitResult()).resolves.toEqual({
output: [{ type: 'text', text: 'the final answer' }],
stopReason: 'completed',
})
})
it('reads events split across chunks and skips blank lines', async () => {
const { child, wire } = startedWire()
const frame = JSON.stringify(initEvent())
child.peer.raw(`\n \n${frame.slice(0, 10)}`)
await nextTask()
child.peer.raw(`${frame.slice(10)}\n`)
await expect(wire.ready()).resolves.toMatchObject({ sessionId: 'chat-1' })
expect(wire.collectOutput()).toEqual([])
})
it('reports an absent model rather than inventing one', async () => {
const { child, wire } = startedWire()
child.peer.send(initEvent({ model: 42 }))
await expect(wire.ready()).resolves.toEqual({
sessionId: 'chat-1',
model: undefined,
})
})
it('keeps the last non-empty assistant message and drops non-text blocks', async () => {
const { child, wire } = startedWire()
child.peer.send(
initEvent(),
assistantEvent([{ type: 'text', text: 'first' }]),
assistantEvent([{ type: 'image', source: {} }]),
assistantEvent([]),
assistantEvent([{ type: 'text', text: 'second' }, { type: 'image', source: {} }]),
)
await nextTask()
expect(wire.collectOutput()).toEqual([{ type: 'text', text: 'second' }])
})
it('fails closed on every unusable terminal result', async () => {
for (const [overrides, detail] of [
[{ subtype: 'error' }, 'terminal result subtype error'],
[{ subtype: 7 }, 'terminal result subtype unknown'],
[{ is_error: true }, 'success result was marked as an error'],
[{ result: 12 }, 'success result was marked as an error'],
[{ result: ' ' }, 'success result was marked as an error'],
] as const) {
const { child, wire } = startedWire()
child.peer.send(initEvent(), resultEvent(overrides))
await expect(wire.awaitResult()).rejects.toThrow(detail)
}
})
it('refuses a result that arrives without an announced session', async () => {
const { child, wire } = startedWire()
child.peer.send(resultEvent())
await expect(wire.ready()).rejects.toThrow('without announcing a session')
await expect(wire.awaitResult()).rejects.toThrow('without announcing a session')
})
it('treats malformed stdout as a protocol failure', async () => {
for (const [line, detail] of [
['not json at all', 'a stdout line that is not JSON'],
['[1,2]', 'an invalid event'],
['null', 'an invalid event'],
[JSON.stringify({ type: 'system', subtype: 'init', session_id: '' }), 'an invalid init session id'],
[JSON.stringify({ type: 'assistant', message: 'text' }), 'an invalid assistant message'],
[JSON.stringify(assistantEvent('not an array')), 'an invalid assistant message content'],
] as const) {
const { child, wire } = startedWire()
child.peer.raw(`${line}\n`)
await expect(wire.awaitResult()).rejects.toThrow(detail)
}
})
it('fails pending gates on stream error, end of stream, and close', async () => {
const broken = startedWire()
broken.child.fromChild.emit('error', new Error('stdout broke'))
await expect(broken.wire.awaitResult()).rejects.toThrow('stdout broke')
const ended = startedWire()
ended.child.fromChild.end()
await expect(ended.wire.awaitResult()).rejects.toThrow('ended without a terminal result')
const closed = startedWire()
closed.wire.close()
closed.wire.close()
await expect(closed.wire.ready()).rejects.toThrow('event stream closed')
// A completed run keeps its result across teardown and end of stream.
const done = startedWire()
done.child.peer.send(initEvent(), resultEvent())
await expect(done.wire.awaitResult()).resolves.toMatchObject({ stopReason: 'completed' })
done.wire.close()
done.child.fromChild.end()
await expect(done.wire.awaitResult()).resolves.toMatchObject({ stopReason: 'completed' })
})
it('is safe to close before it is started', async () => {
const child = fakeChild()
const wire = new CursorStreamWire(child.handle.stdout!)
wire.close()
await expect(wire.ready()).rejects.toThrow('event stream closed')
})
})
describe('run lifecycle and quiescence', () => {
it('spawns the fixed command, publishes after init, and disposes once', async () => {
const child = fakeChild()
const spawn = vi.fn(() => child.handle)
const starting = startCursorRun(
request(),
runSpec(child, { spawn, force: true, trust: true, disposeGraceMs: 40, env: { CURSOR_API_KEY: 'fake' } }),
)
await nextTask()
expect(spawn).toHaveBeenCalledWith(expect.objectContaining({
argv: [
EXECUTABLE,
'--print',
'--output-format',
'stream-json',
'--workspace',
process.cwd(),
'--force',
'--trust',
'do the task',
],
cwd: process.cwd(),
graceMs: 40,
env: { CURSOR_API_KEY: 'fake' },
}))
child.peer.send(initEvent())
const run = await starting
expect(run.id).toMatch(/^[0-9a-f-]{36}$/)
expect(run.localAgent).toBeUndefined()
child.peer.send(resultEvent())
await expect(run.result).resolves.toEqual({
output: [{ type: 'text', text: 'the final answer' }],
stopReason: 'completed',
})
const first = run.dispose()
expect(run.dispose()).toBe(first)
await first
expect(child.terminate).toHaveBeenCalledTimes(1)
})
it('closes stdin so an unattended prompt cannot stall the child', async () => {
const { child, run } = await publishRun()
expect(child.toChild.writableEnded).toBe(true)
child.peer.send(resultEvent())
await run.result
await run.dispose()
})
it('settles a cancelled run as aborted with the output collected so far', async () => {
const controller = new AbortController()
const child = fakeChild({ exitOnTerminate: false })
const { run } = await publishRun(child, controller.signal)
child.peer.send(assistantEvent([{ type: 'text', text: 'partial work' }]))
await nextTask()
controller.abort(new Error('parent stopped waiting'))
await expect(run.result).resolves.toEqual({
output: [{ type: 'text', text: 'partial work' }],
stopReason: 'aborted',
})
child.settle({ exitCode: null, signal: 'SIGTERM' })
await run.dispose()
})
it('flattens a child exit and a protocol failure after publication', async () => {
const exited = await publishRun()
exited.child.settle({ exitCode: 2, signal: null })
await expect(exited.run.result).resolves.toEqual({
output: [],
stopReason: 'error',
})
await exited.run.dispose()
const onError = vi.fn<(error: Error, stopReason: SubagentStopReason) => void>()
const malformed = await publishRun(fakeChild(), new AbortController().signal, { onError })
malformed.child.peer.raw('garbage\n')
await expect(malformed.run.result).resolves.toMatchObject({ stopReason: 'error' })
expect(onError).toHaveBeenCalledTimes(1)
const [reported, reportedReason] = onError.mock.calls[0]!
expect(reported.message).toContain('not JSON')
expect(reportedReason).toBe('error')
await malformed.run.dispose()
})
it('rejects before spawn when the request is already aborted', async () => {
const controller = new AbortController()
controller.abort()
const child = fakeChild()
const spawn = vi.fn(() => child.handle)
await expect(startCursorRun(request(undefined, controller.signal), runSpec(child, { spawn })))
.rejects.toThrow('aborted before cursor-agent startup')
expect(spawn).not.toHaveBeenCalled()
})
it('rolls the child back when startup fails or is aborted before publication', async () => {
const failed = fakeChild()
const failing = startCursorRun(request(), runSpec(failed))
await nextTask()
failed.peer.raw('not json\n')
await expect(failing).rejects.toThrow('not JSON')
expect(failed.terminate).toHaveBeenCalledTimes(1)
const controller = new AbortController()
const aborted = fakeChild()
const aborting = startCursorRun(request(undefined, controller.signal), runSpec(aborted))
await nextTask()
controller.abort()
await expect(aborting).rejects.toThrow('aborted before run publication')
expect(aborted.terminate).toHaveBeenCalledTimes(1)
const exited = fakeChild()
const exiting = startCursorRun(request(), runSpec(exited))
await nextTask()
exited.settle({ exitCode: 3, signal: null })
await expect(exiting).rejects.toThrow('exited before the run settled')
// A failed spawn reports pid -1, so rollback has no tree to signal.
const broken = fakeChild({ pid: -1, doneError: new Error('spawn observer failed') })
await expect(startCursorRun(request(), runSpec(broken)))
.rejects.toThrow('spawn observer failed')
expect(broken.terminate).not.toHaveBeenCalled()
})
it('reports a startup failure whose cleanup also failed as an aggregate', async () => {
const child = fakeChild()
child.handle.waitForExit = vi.fn(() => Promise.reject(new Error('tree never exited')))
const starting = startCursorRun(request(), runSpec(child))
await nextTask()
child.peer.raw('not json\n')
const failure = await starting.catch((error: unknown) => error)
expect(failure).toBeInstanceOf(AggregateError)
expect((failure as AggregateError).message)
.toContain('startup failed and cursor-agent cleanup also failed')
})
it('keeps overlapping runs isolated', async () => {
const first = await publishRun()
const second = await publishRun()
first.child.peer.send(resultEvent({ result: 'first answer' }))
second.child.peer.send(resultEvent({ subtype: 'error' }))
await expect(first.run.result).resolves.toMatchObject({
output: [{ type: 'text', text: 'first answer' }],
stopReason: 'completed',
})
await expect(second.run.result).resolves.toMatchObject({ stopReason: 'error' })
await first.run.dispose()
await second.run.dispose()
})
it('uses the registered provider config, the resolved executable, and logs flattened errors', async () => {
const ctx = new Context()
await ctx.plugin(SubagentRuntime)
await ctx.plugin(LocalSubprocessRuntime)
const child = fakeChild()
const resolveExecutable = vi.spyOn(ctx.subprocess, 'resolveExecutable')
.mockResolvedValue(EXECUTABLE)
const spawn = vi.spyOn(ctx.subprocess, 'spawn').mockReturnValue(child.handle)
const warnings: string[] = []
ctx.logger.warn = ((message: unknown) => {
warnings.push(String(message))
}) as typeof ctx.logger.warn
await ctx.plugin(cursor, {
env: { CURSOR_API_KEY: 'fake' },
disposeGraceMs: 25,
force: true,
})
const starting = ctx.subagents.start('cursor', {
prompt: [{ type: 'text', text: 'task' }],
parent: fakeParent,
signal: new AbortController().signal,
})
await nextTask()
child.peer.send(initEvent())
const run = await starting
child.settle({ exitCode: 1, signal: null })
await expect(run.result).resolves.toMatchObject({ stopReason: 'error' })
expect(resolveExecutable).toHaveBeenCalledWith(
'cursor-agent',
{ CURSOR_API_KEY: 'fake' },
expect.any(AbortSignal),
)
expect(spawn).toHaveBeenCalledTimes(1)
const spawned = spawn.mock.calls[0]![0]
expect(spawned.argv).toContain('--force')
expect(spawned.env).toEqual({ CURSOR_API_KEY: 'fake' })
expect(spawned.graceMs).toBe(25)
expect(spawned.cwd).toBe(process.cwd())
expect(warnings).toEqual([
expect.stringContaining('subagent-cursor: child run failed (error):'),
])
await run.dispose().catch(() => {})
await ctx.fiber.dispose()
})
})
describe('disposeCursorChild', () => {
it('closes the stream, terminates, and waits for the managed tree', async () => {
const child = fakeChild()
const wire = new CursorStreamWire(child.handle.stdout!)
wire.start()
await disposeCursorChild(wire, child.handle)
expect(child.terminate).toHaveBeenCalledTimes(1)
expect(child.waitForExit).toHaveBeenCalledTimes(1)
})
it('does not finish disposal before the managed tree exits', async () => {
const child = fakeChild({ exitOnTerminate: false })
const wire = new CursorStreamWire(child.handle.stdout!)
let settled = false
const disposing = disposeCursorChild(wire, child.handle).then(() => { settled = true })
await nextTask()
expect(settled).toBe(false)
child.settle()
await disposing
expect(settled).toBe(true)
})
it('skips signalling a failed spawn and contains its observer rejection', async () => {
const child = fakeChild({ pid: -1, doneError: new Error('spawn failed') })
const wire = new CursorStreamWire(child.handle.stdout!)
await expect(disposeCursorChild(wire, child.handle)).resolves.toBeUndefined()
expect(child.terminate).not.toHaveBeenCalled()
})
it('reports a direct-child observer failure from a live tree', async () => {
const child = fakeChild({ exitOnTerminate: false })
const wire = new CursorStreamWire(child.handle.stdout!)
const disposing = disposeCursorChild(wire, child.handle)
child.fail(new Error('observer failed'))
await expect(disposing).rejects.toThrow('observer failed')
})
})

View File

@@ -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/agent"
},
{
"path": "../../llm/llm"
},
{
"path": "../../core/session"
},
{
"path": "../subagent"
},
{
"path": "../../subprocess/subprocess"
},
{
"path": "../../util/timeout"
},
{
"path": "../../runtime-diagnostics/invariants"
}
]
}

View File

@@ -19,6 +19,7 @@
import type { Context } from '@deepseek-ai/cordis'
import type { Session, SessionEvent, SessionHeader, SessionId } from '@deepseek-ai/dsh-session'
import type { SessionPersistence } from '@deepseek-ai/dsh-session-persistence'
import { foldContextOf } from '@deepseek-ai/dsh-session-projection'
import type { SessionProjectionRegistry } from '@deepseek-ai/dsh-session-projection'
import type { SessionProjectionCache } from '@deepseek-ai/dsh-session-projection-cache'
import { SubagentError } from './error.ts'
@@ -395,7 +396,7 @@ async function resolveColdIdentity(
}
let identity: SubagentIdentityProjection | null | undefined
try {
identity = projections.restore({}, inspected.events, 0).snapshot.values.subagent
identity = projections.restore({}, inspected.events, 0, foldContextOf(inspected.meta)).snapshot.values.subagent
} catch {
// The restore folds EVERY registered unit over this child's log, so any
// unit's fold or schema can reject damaged payloads — deterministic data

View File

@@ -2,9 +2,13 @@ import { describe, expect, it } from 'vitest'
import { Context } from '@deepseek-ai/cordis'
import SessionStore from '@deepseek-ai/dsh-session'
import type { SessionEvent } from '@deepseek-ai/dsh-session'
import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection'
import SessionProjectionRegistry, { foldContextOf } from '@deepseek-ai/dsh-session-projection'
import SubagentRuntime from '../src/index.ts'
import { subagentTimingProjectionDefinition } from '../src/projection.ts'
/** The unit under test ignores the fold context; this session inherits no fork prefix. */
const applyTiming = (state: Parameters<typeof subagentTimingProjectionDefinition.apply>[0], event: SessionEvent) =>
subagentTimingProjectionDefinition.apply(state, event, foldContextOf({}))
function event(type: SessionEvent['type'], seq: number, time: number): SessionEvent {
return { type, seq, time, data: {} } as SessionEvent
@@ -12,7 +16,7 @@ function event(type: SessionEvent['type'], seq: number, time: number): SessionEv
function fold(events: SessionEvent[]) {
let state = subagentTimingProjectionDefinition.init()
for (const item of events) state = subagentTimingProjectionDefinition.apply(state, item)
for (const item of events) state = applyTiming(state, item)
return subagentTimingProjectionDefinition.view(state)
}
@@ -59,19 +63,19 @@ describe('subagent timing projection', () => {
it('ignores completed pre-descriptor turns and unrelated events', () => {
const initial = subagentTimingProjectionDefinition.init()
expect(subagentTimingProjectionDefinition.apply(
expect(applyTiming(
initial,
event('assistant/chunk', 0, 1),
)).toBe(initial)
expect(subagentTimingProjectionDefinition.apply(
expect(applyTiming(
initial,
event('turn/end', 1, 2),
)).toBe(initial)
const descriptor = subagentTimingProjectionDefinition.apply(
const descriptor = applyTiming(
initial,
event('subagent/descriptor', 2, 3),
)
expect(subagentTimingProjectionDefinition.apply(
expect(applyTiming(
descriptor,
event('turn/end', 3, 4),
)).toBe(descriptor)