= z.object({
- workspaceRoot: z.string(),
nativeOpen: z.boolean(),
})
@@ -80,12 +76,10 @@ export class ApiProxyService extends Service implements ApiProxy {
constructor(ctx: Context, config: Config) {
super(ctx, 'apiProxy')
- const cwd = process.cwd()
const api = createApiProxy(ctx, {
defaultModelSelection: () => ctx.agentDefaultModel.currentSelection(),
saveDefaultModelSelection: selection => ctx.agentDefaultModel.saveSelection(selection),
- cwd,
- workspaceRoot: resolve(config.workspaceRoot ?? cwd),
+ cwd: process.cwd(),
...config.nativeOpen === undefined ? {} : { canOpenPath: () => config.nativeOpen as boolean },
})
this.sessions = api.sessions
diff --git a/packages/host/apiproxy/tests/api-proxy-agent-preset.spec.ts b/packages/host/apiproxy/tests/api-proxy-agent-preset.spec.ts
index 444cd5490e..eb707c01f6 100644
--- a/packages/host/apiproxy/tests/api-proxy-agent-preset.spec.ts
+++ b/packages/host/apiproxy/tests/api-proxy-agent-preset.spec.ts
@@ -137,7 +137,6 @@ async function harness(
const api = createApiProxy(ctx, {
defaultModelSelection: () => ({ provider: 'test', model: 'test-model' }),
cwd,
- workspaceRoot: cwd,
...options.defaults,
})
return { api, ctx, cwd }
diff --git a/packages/host/apiproxy/tests/api-proxy-approval.spec.ts b/packages/host/apiproxy/tests/api-proxy-approval.spec.ts
index 2272c7c61d..1cee4cd4e0 100644
--- a/packages/host/apiproxy/tests/api-proxy-approval.spec.ts
+++ b/packages/host/apiproxy/tests/api-proxy-approval.spec.ts
@@ -27,7 +27,7 @@ async function harness(): Promise<{ ctx: Context; api: ApiProxy }> {
await ctx.plugin(UserInteractionService)
await ctx.plugin(AgentRegistry)
await ctx.plugin(ApprovalService)
- const api = createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' })
+ const api = createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' })
return { ctx, api }
}
@@ -217,7 +217,7 @@ describe('approval pending registry', () => {
await ctx.plugin(ApprovalService)
let api!: ApiProxy
const fiber = ctx.plugin(Object.assign((fiberCtx: Context) => {
- api = createApiProxy(fiberCtx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' })
+ api = createApiProxy(fiberCtx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' })
}, { inject: ['sessions', 'agents', 'userInteraction', 'approval'] }))
await fiber.await()
const abort = new AbortController()
diff --git a/packages/host/apiproxy/tests/api-proxy-blank.spec.ts b/packages/host/apiproxy/tests/api-proxy-blank.spec.ts
index ed2eaeefc5..e01282043b 100644
--- a/packages/host/apiproxy/tests/api-proxy-blank.spec.ts
+++ b/packages/host/apiproxy/tests/api-proxy-blank.spec.ts
@@ -35,7 +35,7 @@ async function harness(): Promise<{ ctx: Context; api: ApiProxy; attach: (sessio
await ctx.plugin(AgentRegistry)
return {
ctx,
- api: createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' }),
+ api: createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' }),
attach: (session) => {
ctx.agents.register({ id: session.id, session, status: 'idle', ctx } as Agent)
},
diff --git a/packages/host/apiproxy/tests/api-proxy-cold.spec.ts b/packages/host/apiproxy/tests/api-proxy-cold.spec.ts
index 37e0e14169..78c748861a 100644
--- a/packages/host/apiproxy/tests/api-proxy-cold.spec.ts
+++ b/packages/host/apiproxy/tests/api-proxy-cold.spec.ts
@@ -64,7 +64,7 @@ describe('sessions.list cold merge', () => {
return undefined
},
})
- const api = createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' })
+ const api = createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' })
const response = await api.sessions.list(request({}))
expect(response.result.ok).toBe(true)
@@ -92,7 +92,7 @@ describe('attached updatedAt excludes end-seed', () => {
await ctx.plugin(SessionStore)
await ctx.plugin(UserInteractionService)
await ctx.plugin(AgentRegistry)
- const api = createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' })
+ const api = createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' })
// Old work, resumed just now: the log tail would report the pickup.
const worked = 1_000_000
@@ -150,7 +150,7 @@ describe('cold history recovery view', () => {
inspect: (id: SessionId, signal?: AbortSignal) => coordinator.inspect(id, signal),
locate: () => undefined,
} as never)
- const api = createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' })
+ const api = createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' })
const history = await api.sessions.history(request({ sessionId, beforeSeq: 2, maxMessages: 10 }))
if (!history.result.ok) throw new Error('history failed')
@@ -206,7 +206,7 @@ describe('Remote Agent and Session lookup policy', () => {
})
const defaultAgentLookup = ctx.typert.lookups.get('agent')
const defaultSessionLookup = ctx.typert.lookups.get('session')
- createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' })
+ createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' })
await vi.waitFor(() => {
expect(ctx.typert.lookups.get('agent')).not.toBe(defaultAgentLookup)
expect(ctx.typert.lookups.get('session')).not.toBe(defaultSessionLookup)
@@ -250,7 +250,7 @@ describe('Remote Agent and Session lookup policy', () => {
const resume = vi.spyOn(ctx.agents, 'resume')
const defaultAgentLookup = ctx.typert.lookups.get('agent')
const defaultSessionLookup = ctx.typert.lookups.get('session')
- createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' })
+ createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' })
await vi.waitFor(() => {
expect(ctx.typert.lookups.get('agent')).not.toBe(defaultAgentLookup)
expect(ctx.typert.lookups.get('session')).not.toBe(defaultSessionLookup)
@@ -312,7 +312,7 @@ describe('subagent ownership fence', () => {
locate: () => undefined,
} as never)
const resume = vi.spyOn(ctx.agents, 'resume')
- const api = createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' })
+ const api = createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' })
const history = await api.sessions.history(request({ sessionId }))
expect(history.result.ok).toBe(true)
@@ -371,7 +371,7 @@ describe('subagent ownership fence', () => {
// answering `agent-busy`.
const resume = vi.spyOn(ctx.agents, 'resume')
.mockRejectedValue(new Error('registry unavailable in this bench'))
- const api = createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' })
+ const api = createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' })
const prompt = await api.sessions.prompt(request({
sessionId,
@@ -412,7 +412,7 @@ describe('subagent ownership fence', () => {
})
const startingChild = { id: startingSession.id, session: startingSession, status: 'idle', ctx } as Agent
ctx.agents.enter(startingChild, parent)
- const api = createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' })
+ const api = createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' })
const stopped = await api.sessions.cancel(request({ sessionId: originChild.id }))
expect(stopped.result.ok).toBe(false)
@@ -458,7 +458,7 @@ describe('subagent ownership fence', () => {
const followup = vi.fn()
const agent = { id: session.id, session, status: 'idle', ctx, followup } as unknown as Agent
ctx.agents.register(agent)
- const api = createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' })
+ const api = createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' })
const response = await api.sessions.prompt(request({
sessionId: agent.id,
@@ -476,7 +476,7 @@ describe('degenerate composition (no persistence, no factory)', () => {
await ctx.plugin(SessionStore)
await ctx.plugin(AgentRegistry)
await ctx.plugin(UserInteractionService)
- const api = createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' })
+ const api = createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' })
const listed = await api.sessions.list(request({}))
expect(listed.result.ok).toBe(true)
@@ -501,7 +501,7 @@ describe('degenerate composition (no persistence, no factory)', () => {
list: () => Promise.resolve([]),
inspect,
} as never)
- const api = createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' })
+ const api = createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' })
const response = await api.sessions.history(request({ sessionId: sid('session-missing') }))
expect(response.result.ok).toBe(false)
@@ -527,7 +527,7 @@ describe('sessions.prompt synchronous rejection', () => {
followup: () => { throw new Error('agent "session-throwing" lifecycle disposed') },
steer: () => { throw new Error('agent "session-throwing" lifecycle disposed') },
} as unknown as Agent)
- const api = createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' })
+ const api = createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' })
for (const mode of ['queue', 'steer'] as const) {
const response = await api.sessions.prompt(request({
@@ -571,7 +571,7 @@ describe('sessions.prompt synchronous rejection', () => {
ctx.agents.register(child)
throw new Error('session id already published')
})
- const api = createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' })
+ const api = createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' })
const models = await api.sessions.models(request({ sessionId }))
expect(models.result.ok).toBe(false)
diff --git a/packages/host/apiproxy/tests/api-proxy-commands.spec.ts b/packages/host/apiproxy/tests/api-proxy-commands.spec.ts
index b44701fca0..5f0af7cc5c 100644
--- a/packages/host/apiproxy/tests/api-proxy-commands.spec.ts
+++ b/packages/host/apiproxy/tests/api-proxy-commands.spec.ts
@@ -25,7 +25,7 @@ import type { RpcRequest, RpcResponse } from '../src/api/rpc.ts'
import { RpcId } from '../src/api/rpc.ts'
import { createApiProxy } from '../src/api-proxy.ts'
-const DEFAULTS = { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' }
+const DEFAULTS = { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' }
function request(payload: P): RpcRequest
{
return { rpcId: RpcId(`req-${String(nextRpc++)}`), payload }
diff --git a/packages/host/apiproxy/tests/api-proxy-config.spec.ts b/packages/host/apiproxy/tests/api-proxy-config.spec.ts
index 2b3ef5027e..5f04c80d0b 100644
--- a/packages/host/apiproxy/tests/api-proxy-config.spec.ts
+++ b/packages/host/apiproxy/tests/api-proxy-config.spec.ts
@@ -25,7 +25,7 @@ import { RpcId } from '../src/api/rpc.ts'
import { AGENT_DEFAULT_MODEL_SETTINGS_NAMESPACE } from '@deepseek-ai/dsh-agent-default-model'
import { createApiProxy } from '../src/api-proxy.ts'
-const DEFAULTS = { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' }
+const DEFAULTS = { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' }
let nextRpc = 1
function request
(payload: P): RpcRequest
{
diff --git a/packages/host/apiproxy/tests/api-proxy-fork.spec.ts b/packages/host/apiproxy/tests/api-proxy-fork.spec.ts
index bc1f0a14df..d1bf611fbb 100644
--- a/packages/host/apiproxy/tests/api-proxy-fork.spec.ts
+++ b/packages/host/apiproxy/tests/api-proxy-fork.spec.ts
@@ -84,7 +84,6 @@ function liveAgent(
const api = (ctx: Context) => createApiProxy(ctx, {
defaultModelSelection: () => ({ provider: 'default-provider', model: 'default-model' }),
cwd: '/tmp',
- workspaceRoot: '/tmp',
})
describe('sessions.fork', () => {
diff --git a/packages/host/apiproxy/tests/api-proxy-models.spec.ts b/packages/host/apiproxy/tests/api-proxy-models.spec.ts
index 0e947d116c..bdd21128b4 100644
--- a/packages/host/apiproxy/tests/api-proxy-models.spec.ts
+++ b/packages/host/apiproxy/tests/api-proxy-models.spec.ts
@@ -156,7 +156,6 @@ describe('Web session model selection', () => {
const api = createApiProxy(ctx, {
defaultModelSelection: () => ({ provider: 'deepseek-official', model: 'deepseek-chat' }),
cwd: '/tmp',
- workspaceRoot: '/tmp',
})
const result = await api.sessions.prompt(request({
@@ -203,7 +202,6 @@ describe('Web session model selection', () => {
const api = createApiProxy(ctx, {
defaultModelSelection: () => ({ provider: 'deepseek-official', model: 'deepseek-chat' }),
cwd: '/tmp',
- workspaceRoot: '/tmp',
})
const image = {
type: 'image' as const,
@@ -246,7 +244,6 @@ describe('Web session model selection', () => {
const api = createApiProxy(ctx, {
defaultModelSelection: () => ({ provider: 'deepseek-official', model: 'deepseek-chat' }),
cwd: '/tmp',
- workspaceRoot: '/tmp',
})
agent.session.append('agent/inbox/spliced', {
target: 'next-turn',
@@ -277,7 +274,7 @@ describe('Web session model selection', () => {
model: 'private-preview',
reasoningEffort: ReasoningEffortId('max'),
})
- const api = createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'deepseek-official', model: 'deepseek-chat' }), cwd: '/tmp', workspaceRoot: '/tmp' })
+ const api = createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'deepseek-official', model: 'deepseek-chat' }), cwd: '/tmp' })
const catalog = expectValue(await api.sessions.models(request({ sessionId })))
expect(catalog.current).toEqual({
@@ -312,7 +309,7 @@ describe('Web session model selection', () => {
it('accepts an advisory-unlisted model, rejects an unavailable provider, and switches only after the next assembly', async () => {
const { ctx, agent, sessionId } = await harness()
- const api = createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'deepseek-official', model: 'deepseek-chat' }), cwd: '/tmp', workspaceRoot: '/tmp' })
+ const api = createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'deepseek-official', model: 'deepseek-chat' }), cwd: '/tmp' })
const seed: LlmCallConfig = { provider: 'seed', model: 'seed', temperature: 0.2 }
const signal = new AbortController().signal
@@ -384,7 +381,6 @@ describe('Web session model selection', () => {
const api = createApiProxy(ctx, {
defaultModelSelection: () => stored,
cwd: '/tmp',
- workspaceRoot: '/tmp',
})
expect(expectValue(await api.sessions.models(request({ sessionId }))).current)
@@ -409,7 +405,6 @@ describe('Web session model selection', () => {
const api = createApiProxy(ctx, {
defaultModelSelection: () => stored,
cwd: '/tmp',
- workspaceRoot: '/tmp',
})
stored = { provider: 'duplicate', model: 'same' }
@@ -429,7 +424,6 @@ describe('Web session model selection', () => {
return reject ? Promise.reject(new Error('read-only document')) : Promise.resolve()
},
cwd: '/tmp',
- workspaceRoot: '/tmp',
})
expectValue(await api.sessions.selectModel(request({
@@ -460,7 +454,6 @@ describe('Web session model selection', () => {
const api = createApiProxy(ctx, {
defaultModelSelection: () => ({ provider: 'deleted-gateway', model: 'deleted-model' }),
cwd: '/tmp',
- workspaceRoot: '/tmp',
})
// The client disabling its input is an affordance; this method stays
@@ -493,7 +486,6 @@ describe('Web session model selection', () => {
// names the route the user last picked, and nothing serves it.
defaultModelSelection: () => ({ provider: 'deleted-gateway', model: 'deleted-model' }),
cwd: '/tmp',
- workspaceRoot: '/tmp',
})
const catalog = expectValue(await api.sessions.models(request({ sessionId })))
diff --git a/packages/host/apiproxy/tests/api-proxy-projections.spec.ts b/packages/host/apiproxy/tests/api-proxy-projections.spec.ts
index ddc558fefd..45335e131b 100644
--- a/packages/host/apiproxy/tests/api-proxy-projections.spec.ts
+++ b/packages/host/apiproxy/tests/api-proxy-projections.spec.ts
@@ -68,7 +68,7 @@ function seedMessages(session: Session, count: number): void {
}
}
-const api = (ctx: Context) => createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' })
+const api = (ctx: Context) => createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' })
describe('session.history projections block', () => {
it('serves the unit value on the tail page with asOfSeq = last event seq', async () => {
diff --git a/packages/host/apiproxy/tests/api-proxy-question.spec.ts b/packages/host/apiproxy/tests/api-proxy-question.spec.ts
index 00f7fdfffe..2ca7a31d12 100644
--- a/packages/host/apiproxy/tests/api-proxy-question.spec.ts
+++ b/packages/host/apiproxy/tests/api-proxy-question.spec.ts
@@ -14,7 +14,7 @@ async function harness(): Promise<{ ctx: Context; api: ApiProxy }> {
await ctx.plugin(UserInteractionService)
return {
ctx,
- api: createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' }),
+ api: createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' }),
}
}
diff --git a/packages/host/apiproxy/tests/api-proxy-rename.spec.ts b/packages/host/apiproxy/tests/api-proxy-rename.spec.ts
index 9d5ddb09fd..346ed9f313 100644
--- a/packages/host/apiproxy/tests/api-proxy-rename.spec.ts
+++ b/packages/host/apiproxy/tests/api-proxy-rename.spec.ts
@@ -68,7 +68,7 @@ function liveAgent(ctx: Context, id: string, turns: number): Session {
return session
}
-const api = (ctx: Context) => createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' })
+const api = (ctx: Context) => createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' })
describe('sessions.rename', () => {
it('accepts through the composed title service: normalized user-source event, echoed seq', async () => {
diff --git a/packages/host/apiproxy/tests/api-proxy-search.spec.ts b/packages/host/apiproxy/tests/api-proxy-search.spec.ts
index 8b2e030509..7637399f50 100644
--- a/packages/host/apiproxy/tests/api-proxy-search.spec.ts
+++ b/packages/host/apiproxy/tests/api-proxy-search.spec.ts
@@ -27,7 +27,7 @@ vi.mock('node:fs/promises', async (importOriginal) => {
})
const sid = (value: string): SessionId => value as SessionId
-const defaults = { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' }
+const defaults = { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' }
function request(query: string): RpcRequest<{ query: string }> {
return { rpcId: RpcId(`search-${query}`), payload: { query } }
diff --git a/packages/host/apiproxy/tests/api-proxy-subagents.spec.ts b/packages/host/apiproxy/tests/api-proxy-subagents.spec.ts
index 5e46e4b212..eb39bb6791 100644
--- a/packages/host/apiproxy/tests/api-proxy-subagents.spec.ts
+++ b/packages/host/apiproxy/tests/api-proxy-subagents.spec.ts
@@ -95,7 +95,7 @@ function bench(options: {
ctx.provide('sessionProjections', { snapshot, restore, onChanged: () => () => {} })
ctx.provide('userInteraction', { registerProvider: () => () => {} })
const api = createApiProxy(ctx, {
- defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp',
+ defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp',
})
return { api, getAgent, listChildren, inspect, snapshot, restore, followup, interrupt, parent }
}
diff --git a/packages/host/apiproxy/tests/api-proxy-view.spec.ts b/packages/host/apiproxy/tests/api-proxy-view.spec.ts
index 8b82a99d27..86fb593f4b 100644
--- a/packages/host/apiproxy/tests/api-proxy-view.spec.ts
+++ b/packages/host/apiproxy/tests/api-proxy-view.spec.ts
@@ -105,7 +105,7 @@ async function collect(iterable: AsyncIterable>, count: num
describe('mux live view computation', () => {
it('attaches the three standard card views, omits view without a presenter, soft-falls on throw', async () => {
const { ctx } = await harness()
- const api = createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' })
+ const api = createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' })
const abort = new AbortController()
const stream = api.events.mux({ rpcId: RpcId('t-mux'), payload: {} }, abort.signal)
const collected = collect(stream, 9, abort)
@@ -170,7 +170,7 @@ describe('mux live view computation', () => {
it('serves history entries with call/result views, backscan pairing, and soft-falls', async () => {
const { ctx } = await harness()
- const api = createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' })
+ const api = createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' })
const session = ctx.sessions.create()
// history resolves the agent first; a live structural stub is enough (only
// .session is read on this path).
@@ -238,7 +238,7 @@ describe('mux live view computation', () => {
it('counts only append-origin messages toward maxMessages and keeps each compaction summary with its replacement', async () => {
const { ctx } = await harness()
- const api = createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' })
+ const api = createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' })
const session = ctx.sessions.create()
ctx.agents.register({ id: session.id, session, status: 'idle', ctx } as Agent)
session.append('turn/start', { turn: 1 })
@@ -287,7 +287,7 @@ describe('mux live view computation', () => {
it('drops a disposed session from the live open-call table (result after dispose gets no view)', async () => {
const { ctx } = await harness()
- const api = createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' })
+ const api = createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' })
const abort = new AbortController()
const stream = api.events.mux({ rpcId: RpcId('t-mux3'), payload: {} }, abort.signal)
@@ -308,7 +308,7 @@ describe('mux live view computation', () => {
it('pairs a result after turn/end via the in-memory backscan fallback', async () => {
const { ctx } = await harness()
- const api = createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/tmp' })
+ const api = createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' })
const abort = new AbortController()
const stream = api.events.mux({ rpcId: RpcId('t-mux2'), payload: {} }, abort.signal)
const collected = collect(stream, 4, abort)
diff --git a/packages/host/apiproxy/tests/api-proxy-workspace.spec.ts b/packages/host/apiproxy/tests/api-proxy-workspace.spec.ts
index ced130d7f7..ddb53cf02f 100644
--- a/packages/host/apiproxy/tests/api-proxy-workspace.spec.ts
+++ b/packages/host/apiproxy/tests/api-proxy-workspace.spec.ts
@@ -59,7 +59,7 @@ function stubAgent(session: Session): Agent {
/** Compose the API over real Session, Agent, Storage, Domain, and Workspace services. */
async function harness(
- workspaceRoot = realpathSync.native(mkdtempSync(join(tmpdir(), 'dsh-apiproxy-workspace-'))),
+ root = realpathSync.native(mkdtempSync(join(tmpdir(), 'dsh-apiproxy-workspace-'))),
picker: DirectoryPickerCapability = { kind: 'native', pick: async () => null },
extras: { openPath?: (path: string, signal: AbortSignal) => Promise } = {},
) {
@@ -101,11 +101,17 @@ async function harness(
ctx.provide('directoryPicker', { capability: () => picker } as never)
const api = createApiProxy(ctx, {
defaultModelSelection: () => ({ provider: 'test', model: 'test-model' }),
- cwd: workspaceRoot,
- workspaceRoot,
+ cwd: root,
...extras.openPath === undefined ? {} : { openPath: extras.openPath },
})
- return { api, ctx, storageDomain, workspaceRoot }
+ return { api, ctx, storageDomain, root }
+}
+
+/** Stage one directory under the harness root for path adoption. */
+function stageDir(root: string, name: string): string {
+ const path = join(root, name)
+ mkdirSync(path)
+ return path
}
describe('host.pickDirectory', () => {
@@ -243,31 +249,25 @@ describe('host.openPath', () => {
})
describe('workspace.create', () => {
- it('serializes concurrent names and rejects the duplicate', async () => {
- const { api, workspaceRoot } = await harness()
+ it('serializes concurrent creates of one path into a single registration', async () => {
+ const { api, root } = await harness()
+ const target = stageDir(root, 'alpha')
const responses = await Promise.all([
- api.workspace.create(request({ name: 'alpha' })),
- api.workspace.create(request({ name: 'alpha' })),
+ api.workspace.create(request({ path: target })),
+ api.workspace.create(request({ path: target })),
])
- const created = responses.find(response => response.result.ok)
- const duplicate = responses.find(response => !response.result.ok)
+ const values = responses.map(response => expectOk(response))
+ const created = values.find(value => value.created)
+ const resolved = values.find(value => !value.created)
- expect(created).toBeDefined()
- expect(expectOk(created!)).toMatchObject({
- created: true,
- workspace: { path: join(workspaceRoot, 'alpha'), title: 'alpha' },
- })
- expect(duplicate?.result).toMatchObject({
- ok: false,
- error: { code: 'workspace-name-conflict', details: { name: 'alpha' } },
- })
- expect(existsSync(join(workspaceRoot, 'alpha'))).toBe(true)
+ expect(created).toMatchObject({ workspace: { path: target, title: 'alpha' } })
+ expect(resolved?.workspace.workspaceId).toBe(created?.workspace.workspaceId)
+ expect(expectOk(await api.workspace.list(request({}))).items).toHaveLength(1)
})
- it('adopts only existing directories and rejects unsafe names', async () => {
- const { api, workspaceRoot } = await harness()
- const existing = join(workspaceRoot, 'existing')
- mkdirSync(existing)
+ it('adopts only existing directories', async () => {
+ const { api, root } = await harness()
+ const existing = stageDir(root, 'existing')
const first = expectOk(await api.workspace.create(request({ path: existing })))
const repeated = expectOk(await api.workspace.create(request({ path: existing })))
expect(first).toMatchObject({ created: true, workspace: { path: existing, title: 'existing' } })
@@ -280,21 +280,16 @@ describe('workspace.create', () => {
const reopened = expectOk(await api.workspace.create(request({ path: existing })))
expect(reopened.workspace.title).toBe('renamed-existing')
- const missing = join(workspaceRoot, 'missing')
+ const missing = join(root, 'missing')
const missingResult = await api.workspace.create(request({ path: missing }))
expect(missingResult.result).toMatchObject({ ok: false, error: { code: 'workspace-invalid-path' } })
expect(existsSync(missing)).toBe(false)
-
- for (const name of ['', '.', '..', 'a/b', 'a\\b']) {
- const invalid = await api.workspace.create(request({ name }))
- expect(invalid.result).toMatchObject({ ok: false, error: { code: 'workspace-invalid-path' } })
- }
})
it('adopts different paths that derive the same Workspace title', async () => {
- const { api, workspaceRoot } = await harness()
- const first = join(workspaceRoot, 'one', 'project')
- const second = join(workspaceRoot, 'two', 'project')
+ const { api, root } = await harness()
+ const first = join(root, 'one', 'project')
+ const second = join(root, 'two', 'project')
mkdirSync(first, { recursive: true })
mkdirSync(second, { recursive: true })
const firstResult = expectOk(await api.workspace.create(request({ path: first })))
@@ -315,8 +310,8 @@ describe('workspace.create', () => {
describe('session creation and Workspace membership', () => {
it('attaches a preallocated idempotent session while cwd-only sessions stay ungrouped', async () => {
- const { api, ctx } = await harness()
- const workspace = expectOk(await api.workspace.create(request({ name: 'project' }))).workspace
+ const { api, ctx, root } = await harness()
+ const workspace = expectOk(await api.workspace.create(request({ path: stageDir(root, 'project') }))).workspace
const sessionId = SessionId('session-workspace-preallocated')
expectOk(await api.sessions.create(request({ workspaceId: workspace.workspaceId, sessionId })))
@@ -342,8 +337,8 @@ describe('session creation and Workspace membership', () => {
})
it('retains a published session when attachment fails and repairs it on retry', async () => {
- const { api, ctx } = await harness()
- const created = expectOk(await api.workspace.create(request({ name: 'project' }))).workspace
+ const { api, ctx, root } = await harness()
+ const created = expectOk(await api.workspace.create(request({ path: stageDir(root, 'project') }))).workspace
const workspace = ctx.workspace.list()[0]
if (workspace === undefined) throw new Error('workspace missing from registry')
vi.spyOn(workspace, 'attachSession').mockRejectedValueOnce(new Error('simulated write failure'))
@@ -393,7 +388,7 @@ describe('Host Workspace increments', () => {
})
it('streams committed Workspace and Session increments after empty baselines', async () => {
- const { api } = await harness()
+ const { api, root } = await harness()
expect(expectOk(await api.workspace.list(request({}))).items).toEqual([])
expect(expectOk(await api.sessions.list(request({}))).items).toEqual([])
@@ -401,7 +396,7 @@ describe('Host Workspace increments', () => {
const stream: AsyncIterator> =
api.events.host(request({}), abort.signal)[Symbol.asyncIterator]()
const workspaceIncrement = nextHostFrame(stream)
- const workspace = expectOk(await api.workspace.create(request({ name: 'project' }))).workspace
+ const workspace = expectOk(await api.workspace.create(request({ path: stageDir(root, 'project') }))).workspace
expect(await workspaceIncrement).toMatchObject({
payload: { type: 'host/workspace-changed', workspace: { workspaceId: workspace.workspaceId } },
})
@@ -429,7 +424,7 @@ describe('Host Workspace increments', () => {
})
it('does not publish a Workspace whose registry-order commit fails', async () => {
- const { api, storageDomain } = await harness()
+ const { api, storageDomain, root } = await harness()
const domain = storageDomain.get('workspace')
if (domain === undefined) throw new Error('workspace domain is not open')
vi.spyOn(domain.global, 'set').mockRejectedValueOnce(new Error('simulated registry order failure'))
@@ -438,7 +433,7 @@ describe('Host Workspace increments', () => {
api.events.host(request({}), abort.signal)[Symbol.asyncIterator]()
const next = stream.next()
- const failed = await api.workspace.create(request({ name: 'ghost' }))
+ const failed = await api.workspace.create(request({ path: stageDir(root, 'ghost') }))
expect(failed.result.ok).toBe(false)
expect(expectOk(await api.workspace.list(request({}))).items).toEqual([])
abort.abort()
@@ -446,8 +441,8 @@ describe('Host Workspace increments', () => {
})
it('deletes the registration, keeps its session and folder, and streams one removal', async () => {
- const { api, ctx } = await harness()
- const workspace = expectOk(await api.workspace.create(request({ name: 'delete-me' }))).workspace
+ const { api, ctx, root } = await harness()
+ const workspace = expectOk(await api.workspace.create(request({ path: stageDir(root, 'delete-me') }))).workspace
const sessionId = SessionId('session-kept-after-workspace-delete')
expectOk(await api.sessions.create(request({ workspaceId: workspace.workspaceId, sessionId })))
@@ -479,8 +474,8 @@ describe('Host Workspace increments', () => {
})
it('archives a session into the global set, keeps its accounting, and streams the set once', async () => {
- const { api } = await harness()
- const workspace = expectOk(await api.workspace.create(request({ name: 'archive-home' }))).workspace
+ const { api, root } = await harness()
+ const workspace = expectOk(await api.workspace.create(request({ path: stageDir(root, 'archive-home') }))).workspace
const sessionId = SessionId('session-to-archive')
expectOk(await api.sessions.create(request({ workspaceId: workspace.workspaceId, sessionId })))
expect(expectOk(await api.workspace.list(request({}))).archivedSessionIds).toEqual([])
diff --git a/packages/host/apiproxy/tests/client-handler.spec.ts b/packages/host/apiproxy/tests/client-handler.spec.ts
index 44d3e06b52..2b9249b7f0 100644
--- a/packages/host/apiproxy/tests/client-handler.spec.ts
+++ b/packages/host/apiproxy/tests/client-handler.spec.ts
@@ -432,8 +432,8 @@ describe('workspace domain round trip', () => {
expect(archivedResponse.result).toEqual({ ok: true, value: { archivedSessionIds: ['s-arch'] } })
})
- it('rejects a create payload violating the exactly-one refine at the handler', async () => {
- const response = await client(scriptedApi()).workspace.create({})
+ it('rejects a pathless create payload at the handler schema', async () => {
+ const response = await client(scriptedApi()).workspace.create({} as never)
expect(response.result.ok).toBe(false)
if (!response.result.ok) expect(response.result.error.code).toBe('bad-request')
})
diff --git a/packages/host/apiproxy/tests/rpc-schemas.spec.ts b/packages/host/apiproxy/tests/rpc-schemas.spec.ts
index f398305fa7..3be4bf5715 100644
--- a/packages/host/apiproxy/tests/rpc-schemas.spec.ts
+++ b/packages/host/apiproxy/tests/rpc-schemas.spec.ts
@@ -330,11 +330,11 @@ describe('workspace domain schemas', () => {
expect(() => workspaceArchiveSessionValueSchema.parse({ archivedSessionIds: 's1' })).toThrow()
})
- it('create requires exactly one of path/name (both refine arms)', () => {
+ it('create requires a path', () => {
expect(workspaceCreateRequestSchema.parse({ path: '/p' }).path).toBe('/p')
- expect(workspaceCreateRequestSchema.parse({ name: 'n' }).name).toBe('n')
- expect(() => workspaceCreateRequestSchema.parse({})).toThrow(/exactly one/)
- expect(() => workspaceCreateRequestSchema.parse({ path: '/p', name: 'n' })).toThrow(/exactly one/)
+ expect(() => workspaceCreateRequestSchema.parse({})).toThrow()
+ // The retired create-by-name spelling stays a clean schema rejection.
+ expect(() => workspaceCreateRequestSchema.parse({ name: 'n' })).toThrow()
expect(workspaceCreateValueSchema.parse({ workspace: view, created: false }).created).toBe(false)
})
diff --git a/packages/host/directory-picker-auto/README.i18n.yaml b/packages/host/directory-picker-auto/README.i18n.yaml
index 49b198d446..22a7a47e27 100644
--- a/packages/host/directory-picker-auto/README.i18n.yaml
+++ b/packages/host/directory-picker-auto/README.i18n.yaml
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/host/directory-picker-auto/README.md
-README.md: f1715566c8aff8be90cab381bcedd4732d0b41f6
-README.zh.md: 9fc8e539d40a126b30be6dce02257bd9abe37944
+README.md: b1bbe4f97cdb88d8cf9bfe435c0eb6517554338b
+README.zh.md: dc67456e9b86636522406bf6a57929b24793dade
diff --git a/packages/host/directory-picker-auto/README.md b/packages/host/directory-picker-auto/README.md
index f1715566c8..b1bbe4f97c 100644
--- a/packages/host/directory-picker-auto/README.md
+++ b/packages/host/directory-picker-auto/README.md
@@ -16,6 +16,6 @@ None; this package neither assembles nor sends a provider request.
## Known Limitations and Deferred Work
-- **Detection infers operator location from launch context, which no launch-side signal can prove** — a tmux session detached from its SSH launch loses the `SSH_*` markers; a darwin process outside an Aqua session still counts as displayed; and the `ssh -L` shape (a workstation-local launch later reached through a forwarded port, which arrives from `127.0.0.1`) resolves `native` and opens the chooser on the unattended workstation. A wrong `native` choice degrades to the backend's existing retryable failure dialog, and composing `-browse` directly pins the safe interaction for such deployments.
+- **Detection infers operator location from launch context, which no launch-side signal can prove** — a tmux session detached from its SSH launch loses the `SSH_*` markers; a Darwin process outside an Aqua session still counts as displayed; and a workstation-local launch later reached through `ssh -L` arrives from `127.0.0.1`, resolves `native`, and opens the chooser on the unattended workstation. A wrong `native` choice degrades to the backend's existing retryable failure dialog, and composing `-browse` directly selects the safe interaction for such deployments.
- **The Linux chooser probe reads `PATH` only** — a zenity/kdialog reachable some other way (shell alias, non-PATH install) still resolves `browse`; installing either binary on `PATH` restores `native` eligibility at the next boot.
- **Boot-time only** — one resolution serves every client of the boot; per-connection adaptivity (native for a local browser, browse for a remote one, same server) would need a per-client capability and the wire advertisement the seam deliberately deleted, and waits for a deployment that serves both at once.
diff --git a/packages/host/directory-picker-auto/README.zh.md b/packages/host/directory-picker-auto/README.zh.md
index 9fc8e539d4..dc67456e9b 100644
--- a/packages/host/directory-picker-auto/README.zh.md
+++ b/packages/host/directory-picker-auto/README.zh.md
@@ -16,6 +16,6 @@
## 已知限制与暂缓事项
-- **探测是从启动上下文推断操作者位置,而任何启动侧信号都无法证明这一点**——从 SSH 启动中脱离的 tmux 会话会丢失 `SSH_*` 标记;Aqua 会话之外的 darwin 进程仍被算作有显示;而 `ssh -L` 形态(在工作站本地启动、之后经转发端口访问,从 `127.0.0.1` 到达)会判定 `native`,把选择器弹在无人值守的工作站上。错误的 `native` 选择会退化为后端既有的可重试失败对话框,而对这类部署,直接组合 `-browse` 即固定住安全的交互。
+- **探测是从启动上下文推断操作者位置,而任何启动侧信号都无法证明这一点**——从 SSH 启动中脱离的 tmux 会话会丢失 `SSH_*` 标记;Aqua 会话之外的 Darwin 进程仍被算作有显示;在工作站本地启动、之后经 `ssh -L` 访问时,请求会从 `127.0.0.1` 到达,系统会判定 `native`,并把选择器弹在无人值守的工作站上。错误的 `native` 选择会退化为后端既有的可重试失败对话框,而对这类部署,直接组合 `-browse` 即选择安全的交互。
- **Linux 选择器探查只读 `PATH`**——以其他途径可用的 zenity/kdialog(shell 别名、未装在 PATH 上)仍判定为 `browse`;把任一二进制装到 `PATH` 上,下次启动即恢复 `native` 资格。
- **仅在启动时判定**——一次判定服务本次启动的所有客户端;按连接自适应(同一台服务器,本地浏览器用 native、远程浏览器用 browse)需要按客户端的能力对象以及 seam 有意删除的 wire 广播,等到出现同时服务两种形态的部署再做。
diff --git a/packages/host/directory-picker/README.i18n.yaml b/packages/host/directory-picker/README.i18n.yaml
index aeb36e44d7..5ba3a2bed1 100644
--- a/packages/host/directory-picker/README.i18n.yaml
+++ b/packages/host/directory-picker/README.i18n.yaml
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/host/directory-picker/README.md
-README.md: 3749b238b56578ec68610bc13550760aa084bad6
-README.zh.md: bc77a9c6e1d76e00926774dc518fce42b2860735
+README.md: d90f939aca57b6bc520bb96b56b8a7738b69a522
+README.zh.md: 40d82b3d60ab7d27100133385a73f31d8cb3c26a
diff --git a/packages/host/directory-picker/README.md b/packages/host/directory-picker/README.md
index 3749b238b5..d90f939aca 100644
--- a/packages/host/directory-picker/README.md
+++ b/packages/host/directory-picker/README.md
@@ -2,7 +2,7 @@
English | [中文](README.zh.md)
-The **workspace-directory picking seam** for the web-GUI host: an abstract `DirectoryPicker` service (`ctx.directoryPicker`) whose single contract method `capability()` returns a discriminated capability describing how an operator selects a directory. Backends differ in interaction shape, not just mechanism, so the seam models the shapes explicitly instead of one method set: `{ kind: 'native', pick(signal) }` opens one native OS chooser on the host display ([`-native`](../directory-picker-native/README.md)); `{ kind: 'browse', list(path?), createDirectory(path, name) }` serves listing/creation primitives an in-app browser drives, which works for remote clients no OS chooser can reach ([`-browse`](../directory-picker-browse/README.md)). Consumers switch on `capability().kind`; the union derives from the merge-extensible `DirectoryPickerCapabilities` map (a new backend declaration-merges its shape there), and the documented default for an unknown kind is to hide the picking affordance rather than fail. The capability object must be stable for the service lifetime. The client side mirrors the seam without a wire advertisement: each backend package is dual-face, its browser half registering the matching picking interaction into ui-workspace's directory-flow slots — so one composition row swaps both the host capability and the client flow together. A composition that should not pin an interaction mounts the [`-auto`](../directory-picker-auto/README.md) chooser instead, which resolves the host's situation once at boot and mounts the matching backend row itself.
+The web GUI host's workspace-directory picker is a capability seam. The abstract `DirectoryPicker` service (`ctx.directoryPicker`) is its Service Definition. Its only method, `capability()`, returns a discriminated union describing how an operator selects a directory. Backends differ in user interaction, not just implementation: `{ kind: 'native', pick(signal) }` opens one native OS chooser on the host display ([`-native`](../directory-picker-native/README.md)); `{ kind: 'browse', list(path?), createDirectory(path, name) }` provides listing and creation operations for an in-app browser, which works for remote clients that cannot reach an OS chooser ([`-browse`](../directory-picker-browse/README.md)). Consumers switch on `capability().kind`; the union derives from the merge-extensible `DirectoryPickerCapabilities` map, and a new backend adds its variant there through declaration merging. For an unknown kind, consumers hide directory picking rather than fail. The capability object must be stable for the service lifetime. Each backend package also has a browser entrypoint that registers the matching interaction in ui-workspace's directory-flow slots, so one composition row selects both the host capability and the client flow. A composition that should choose at runtime mounts [`-auto`](../directory-picker-auto/README.md), which inspects the host once at boot and mounts the matching backend row.
Browse primitives fail with the typed `DirectoryPickerError` (`directory-unreadable` / `directory-exists` / `directory-create-failed`, each carrying the subject `path`), which the consuming gateway maps 1:1 onto wire error codes. `DirectoryEntry` rows carry a host-owned `hidden` flag (POSIX dot convention) so display policy stays client-side; `DirectoryListing.crumbs` is the ancestor chain from the filesystem root, every crumb a jump target. Design rationale, the `ctx.fs` separation, and the policy decisions live in [the directory-picker capability seam Agent Note](../../../.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md).
@@ -16,4 +16,4 @@ None; this package neither assembles nor sends a provider request.
## Known Limitations and Deferred Work
-- **No multi-root vocabulary** — the browse contract exposes one ancestry chain per listing; per-deployment root scoping (and Windows drive-root enumeration above a drive) waits for a consumer that needs it, per the seam Agent Note.
+- **No multi-root support** — the browse contract exposes one ancestry chain per listing; per-deployment root scoping (and Windows drive-root enumeration above a drive) waits for a consumer that needs it, per the DirectoryPicker Agent Note.
diff --git a/packages/host/directory-picker/README.zh.md b/packages/host/directory-picker/README.zh.md
index bc77a9c6e1..40d82b3d60 100644
--- a/packages/host/directory-picker/README.zh.md
+++ b/packages/host/directory-picker/README.zh.md
@@ -2,7 +2,7 @@
[English](README.md) | 中文
-web GUI 宿主的**工作区目录选择 seam**:抽象服务 `DirectoryPicker`(`ctx.directoryPicker`),唯一约定方法 `capability()` 返回一个可辨识能力对象,描述操作者以何种方式选择目录。后端之间的差异在交互形态而不只是机制,因此 seam 显式建模形态而非统一方法集:`{ kind: 'native', pick(signal) }` 在宿主屏幕上打开一个原生 OS 选择器([`-native`](../directory-picker-native/README.md));`{ kind: 'browse', list(path?), createDirectory(path, name) }` 提供应用内浏览器驱动的列举/创建原语,也能服务于 OS 对话框无法触及的远程客户端([`-browse`](../directory-picker-browse/README.md))。消费方按 `capability().kind` 分支;联合类型由可合并扩展的 `DirectoryPickerCapabilities` 映射派生(新后端通过声明合并加入自己的形态),未知 kind 的文档化默认行为是隐藏选择入口而非失败。能力对象在服务生命周期内必须保持稳定。client 侧以镜像方式承接该 seam,无需通过 wire 公布能力:每个后端包都是双面包,其 browser half 把匹配的选取交互注册进 ui-workspace 的目录流 slot——因此一项组合配置会同时切换宿主能力与 client 流程。不应固定某种交互的组合改为挂载 [`-auto`](../directory-picker-auto/README.md) 选择器,它在启动时一次性判定宿主处境,并自行挂载匹配的后端行。
+web GUI 宿主的工作区目录选择是一项能力 seam。抽象的 `DirectoryPicker` 服务(`ctx.directoryPicker`)是其 Service Definition。该服务只提供一个方法:`capability()`,它返回一个可辨识联合类型,说明操作者如何选择目录。后端之间的用户交互不同,不只是实现不同:`{ kind: 'native', pick(signal) }` 在宿主屏幕上打开一个原生 OS 选择器([`-native`](../directory-picker-native/README.md));`{ kind: 'browse', list(path?), createDirectory(path, name) }` 提供应用内浏览器使用的列举与创建操作,也能服务于无法访问 OS 对话框的远程客户端([`-browse`](../directory-picker-browse/README.md))。消费方按 `capability().kind` 分支;联合类型由可合并扩展的 `DirectoryPickerCapabilities` 映射派生,新后端通过声明合并在其中加入自己的变体。遇到未知 kind 时,消费方会隐藏目录选择入口,而不是失败。能力对象在服务生命周期内必须保持稳定。每个后端包还提供 browser 入口,在 ui-workspace 的 directory-flow slot 中注册匹配的交互,因此一项组合配置会同时选择宿主能力与 client 流程。需要在运行时选择交互的组合挂载 [`-auto`](../directory-picker-auto/README.md),它在启动时检查一次宿主情况,并挂载匹配的后端行。
浏览原语失败时会抛出带类型的 `DirectoryPickerError`(`directory-unreadable`/`directory-exists`/`directory-create-failed`,各自携带出错对象的 `path`),消费网关将其 1:1 映射为协议错误码。`DirectoryEntry` 行携带宿主判定的 `hidden` 标志(POSIX 点前缀约定),展示策略留在客户端;`DirectoryListing.crumbs` 是从文件系统根开始的祖先链,每个 crumb 都是跳转目标。设计依据、与 `ctx.fs` 的切分、策略裁决见[目录选择能力 seam Agent Note](../../../.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md)。
@@ -16,4 +16,4 @@ web GUI 宿主的**工作区目录选择 seam**:抽象服务 `DirectoryPicker`
## 已知限制与暂缓事项
-- **约定未定义多根目录词汇**——浏览约定每次列举只暴露一条祖先链;按部署限定可浏览根(以及 Windows 盘符之上的根枚举)等到出现需要它的消费方再做,见 seam Agent Note。
+- **不支持多根目录**——浏览约定每次列举只公开一条祖先链;按部署限定可浏览根(以及在盘符根的上一级枚举 Windows 各盘符根目录)等到出现需要它的消费方再做,见 DirectoryPicker Agent Note。
diff --git a/packages/host/webserver/README.i18n.yaml b/packages/host/webserver/README.i18n.yaml
index a9e5d9e48a..ecefc4db11 100644
--- a/packages/host/webserver/README.i18n.yaml
+++ b/packages/host/webserver/README.i18n.yaml
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/host/webserver/README.md
-README.md: 569c3f0c19db2c308beaef35baaf915fd39768cd
-README.zh.md: 3aee06487743764bf2cb837360bb1ac9f0268508
+README.md: c41001fba3a69bfd7c00550d0be602e3fc2e0474
+README.zh.md: 061bed977e456ba6c3cd38f5ad3d30fe0c9354ab
diff --git a/packages/host/webserver/README.md b/packages/host/webserver/README.md
index 569c3f0c19..c41001fba3 100644
--- a/packages/host/webserver/README.md
+++ b/packages/host/webserver/README.md
@@ -2,9 +2,9 @@
English | [中文](README.zh.md)
-Web HTTP and upgrade-route registration plugin (default-exported `HttpServerService`, config `{host, port}`): a `node:http` server that listens on activation and provides `ctx.httpServer`. `register(route)` adds a named `exact`/`prefix` HTTP route; `registerUpgrade(route)` adds an upgrade route for an exact pathname. A duplicate path within either table throws because route patterns are a composition-level contract and a collision is a misconfiguration; both methods return a disposer that removes the registration. `registerFallback(handler)` claims the single fallback seat answering everything no named route matches — one owner only (a second claim throws; the SPA dist server [`dsh-frontend-static`](../frontend-static/README.md) is the shipped owner), 404 while unclaimed. `tapIndex(transform)` adds an index.html transform, and `applyIndexTaps(html)` runs a body through the registered transforms in order — the fallback owner calls it on every index response. `port` reads the listening port (the OS-assigned value when `port` is 0), and `host` reads the configured bind host (composition-time facts other plugins adapt to, e.g. the directory-picker chooser). HTTP match order is fixed: exact over the whole table, then longest prefix, then the fallback seat. Upgrades match exactly and unmatched connections are closed; registration order carries no request-facing semantics.
+Web HTTP and upgrade-route registration plugin (default-exported `HttpServerService`, config `{host, port}`): a `node:http` server that listens on activation and provides `ctx.httpServer`. `register(route)` adds a named `exact`/`prefix` HTTP route; `registerUpgrade(route)` adds an upgrade route for an exact pathname. A duplicate path within either table throws because route patterns are a composition-level contract and a collision is a misconfiguration; both methods return a disposer that removes the registration. `registerFallback(handler)` registers the one handler for requests that match no named route. A second registration throws; the SPA dist server [`dsh-frontend-static`](../frontend-static/README.md) is the shipped owner, and the server returns 404 while none is registered. `tapIndex(transform)` adds an index.html transform, and `applyIndexTaps(html)` runs a body through the registered transforms in order; the fallback handler calls it on every index response. `port` reads the listening port (the OS-assigned value when `port` is 0), and `host` reads the configured bind host (composition-time facts other plugins adapt to, e.g. the directory-picker chooser). HTTP match order is fixed: exact over the whole table, then longest prefix, then the fallback handler. Upgrades match exactly and unmatched connections are closed; registration order carries no request-facing semantics.
-The package knows no harness concepts and serves no files: the `/api` HTTP bridge and downlink WebSockets are routes owned by the connection plugin, plugin bundles and the HMR event stream are routes owned by the modules/hmr plugins, and dist serving belongs to the fallback owner. The upgrade handler owns the protocol handshake and connection contents; the webserver only delivers the raw socket and request. `host` accepts only `127.0.0.1` (default posture) and `0.0.0.0` (deliberate network exposure). Web (browser) shape only — Electron loads dist over `file://` and carries fetch over an IPC bridge, not this server. This package never prints; the URL line belongs to the shell.
+The package knows no harness concepts and serves no files: the `/api` HTTP bridge and downlink WebSockets are routes owned by the connection plugin, plugin bundles and the HMR event stream are routes owned by the modules/hmr plugins, and dist serving belongs to the fallback owner. The upgrade handler owns the protocol handshake and connection contents; the webserver only delivers the raw socket and request. `host` accepts only `127.0.0.1` (default posture) and `0.0.0.0` (deliberate network exposure). This server serves browsers only; Electron loads dist over `file://` and carries fetch over an IPC bridge. This package never prints; the URL line belongs to the shell.
A listen failure (EADDRINUSE…) throws out of activation and rejects Loader composition with the bind diagnostic; the failed candidate fiber is disposed. An HTTP request whose handling throws (a fallback owner's `decodeURIComponent` on a malformed %-escape, a client dropping mid-body) is answered 400 — or the socket destroyed when headers are already out — and logged as a warning; it never exits the process. An upgrade-handler exception or upgraded-socket transport error is logged as a warning and destroys its socket. Disposal starts `close()` and `closeAllConnections()`, destroys every tracked upgraded socket, and returns only after the HTTP server and those sockets have closed.
diff --git a/packages/host/webserver/README.zh.md b/packages/host/webserver/README.zh.md
index 3aee064877..061bed977e 100644
--- a/packages/host/webserver/README.zh.md
+++ b/packages/host/webserver/README.zh.md
@@ -2,9 +2,9 @@
[English](README.md) | 中文
-Web HTTP 与 upgrade route 注册插件(默认导出 `HttpServerService`,配置为 `{host, port}`):一个在激活时开始监听的 `node:http` 服务器,提供 `ctx.httpServer`。`register(route)` 添加具名的 `exact`/`prefix` HTTP route;`registerUpgrade(route)` 添加精确 pathname 的 upgrade route;同一张表内的重复路径会抛错,因为 route 模式是组合层约定,冲突即配置错误;两者返回的 disposer 都会移除注册。`registerFallback(handler)` 认领唯一的回退席位,应答所有未被具名 route 命中的请求:只允许一个持有者(第二次认领会抛错;随附的持有者是 SPA dist 服务器 [`dsh-frontend-static`](../frontend-static/README.md)),席位未被认领时返回 404。`tapIndex(transform)` 添加一个 index.html 转换,`applyIndexTaps(html)` 按注册顺序对一段响应体运行已注册的转换:fallback 持有者在每次 index 响应时调用它。`port` 读取正在监听的端口(当 `port` 为 0 时读取 OS 分配的值),`host` 读取配置的绑定宿主(这些是其他插件据以自适应的组合期事实,例如 directory-picker 选择器)。HTTP 匹配顺序固定不变:先在整张表中匹配精确 route,再匹配最长前缀,最后交给回退席位。upgrade 只做精确匹配,未命中连接直接关闭;注册顺序不承载任何面向请求的语义。
+Web HTTP 与 upgrade route 注册插件(默认导出 `HttpServerService`,配置为 `{host, port}`):一个在激活时开始监听的 `node:http` 服务器,提供 `ctx.httpServer`。`register(route)` 添加具名的 `exact`/`prefix` HTTP route;`registerUpgrade(route)` 添加精确 pathname 的 upgrade route;同一张表内的重复路径会抛错,因为 route 模式是组合层约定,冲突即配置错误;两者返回的 disposer 都会移除注册。`registerFallback(handler)` 注册一个 handler,处理所有未被具名 route 命中的请求。第二次注册会抛错;随附的 SPA dist 服务器 [`dsh-frontend-static`](../frontend-static/README.md) 是该 handler 的所有者,没有注册 handler 时服务器返回 404。`tapIndex(transform)` 添加一个 index.html 转换,`applyIndexTaps(html)` 按注册顺序对一段响应体运行已注册的转换;fallback handler 在每次 index 响应时调用它。`port` 读取正在监听的端口(当 `port` 为 0 时读取 OS 分配的值),`host` 读取配置的绑定宿主(这些是其他插件据以自适应的组合期事实,例如 directory-picker 选择器)。HTTP 匹配顺序固定不变:先在整张表中匹配精确 route,再匹配最长前缀,最后交给 fallback handler。upgrade 只做精确匹配,未命中连接直接关闭;注册顺序不影响请求处理。
-该包不了解任何 harness 概念,也不提供任何文件服务:`/api` HTTP 桥接与下行 WebSocket 是 connection 插件的 route,插件 bundle 与 HMR(热模块替换)事件流是 modules/hmr 插件的 route,dist 服务则属于 fallback 持有者。upgrade handler 拥有协议握手与连接内容;webserver 只交付原始 socket 与 request。`host` 只接受 `127.0.0.1`(默认姿态)和 `0.0.0.0`(有意向网络开放)。该服务器只服务 Web(浏览器)形态;Electron 通过 `file://` 加载 dist,并经 IPC 桥接承载 fetch,而不使用本服务器。该包从不打印内容;URL 行属于 shell。
+该包不了解任何 harness 概念,也不提供任何文件服务:`/api` HTTP 桥接与下行 WebSocket 是 connection 插件的 route,插件 bundle 与 HMR(热模块替换)事件流是 modules/hmr 插件的 route,dist 服务则属于 fallback 持有者。upgrade handler 拥有协议握手与连接内容;webserver 只交付原始 socket 与 request。`host` 只接受 `127.0.0.1`(默认值)和 `0.0.0.0`(有意向网络开放)。该服务器只服务浏览器;Electron 通过 `file://` 加载 dist,并经 IPC 桥接承载 fetch。该包从不打印内容;URL 行属于 shell。
监听失败(EADDRINUSE……)会从激活过程抛出,以 bind 诊断使 Loader 组合 reject;失败的候选 fiber 会被 dispose(资源释放)。处理 HTTP 请求时抛错(例如 fallback 持有者的 `decodeURIComponent` 收到格式错误的百分号转义,或客户端在请求体传输中途断开)时,服务器会响应 400;若响应头已经发出,则销毁 socket,并记录 warning,但绝不会退出进程。upgrade handler 抛错或升级 socket 出现传输错误时,会记录 warning 并销毁对应 socket。资源释放会启动 `close()` 与 `closeAllConnections()`,销毁所有受跟踪的升级 socket,并仅在 HTTP server 与这些 socket 均已关闭后返回。
diff --git a/packages/host/webserver/src/index.ts b/packages/host/webserver/src/index.ts
index fbd275eeed..2ff04379e3 100644
--- a/packages/host/webserver/src/index.ts
+++ b/packages/host/webserver/src/index.ts
@@ -50,12 +50,11 @@ export interface Config {
}
/**
- * The web-shape HTTP carrier service. Activation listens immediately (route
- * registration order carries no request-facing semantics: named routes are
- * composed to be disjoint, and the fallback seat answers anything not yet
- * claimed during the boot window — 404 until its owner registers). A listen
- * failure throws out of init — a FAILED fiber the boot's fail-loud sweep
- * reports.
+ * The browser HTTP carrier service. Activation listens immediately. Route
+ * registration order does not affect requests because configured named routes
+ * must be distinct, and the fallback handler answers anything not yet claimed
+ * during startup with 404 until its owner registers. A listen failure rejects
+ * initialization, and the boot process reports the failed fiber.
*/
export class HttpServerService extends Service {
static Config: z = z.object({
@@ -224,8 +223,8 @@ export class HttpServerService extends Service {
})
})
- // Node does not include upgraded sockets in closeAllConnections(), so the
- // service tracks and destroys them as part of the same ownership boundary.
+ // Node does not include upgraded sockets in closeAllConnections(). The service
+ // owns them with the other connections, so it tracks and destroys them explicitly.
this.ctx.effect(() => async () => {
const serverClosed = new Promise((resolve) => {
this.server.close(() => { resolve() })
diff --git a/packages/interaction/permission/src/invariant.ts b/packages/interaction/permission/src/invariant.ts
index b1290b7307..3bd102645f 100644
--- a/packages/interaction/permission/src/invariant.ts
+++ b/packages/interaction/permission/src/invariant.ts
@@ -11,7 +11,7 @@ export const name = 'permission-invariant'
/** Service required before the companion can reserve package ownership. */
export const inject = ['invariants']
-/** Validate the package-owned event shape and ignore unrelated events. */
+/** Validate the package-owned event fields and ignore unrelated events. */
function validateEvent(ctx: Context, event: SessionEvent, fail: InvariantFailure): void {
if (event.type === 'permission/preset' && !ctx.permission.names.includes(event.data.preset)) {
fail(`permission/preset names unknown preset ${JSON.stringify(event.data.preset)}`)
diff --git a/packages/interaction/user-interaction/README.i18n.yaml b/packages/interaction/user-interaction/README.i18n.yaml
index 4537cfd7da..55b9514b60 100644
--- a/packages/interaction/user-interaction/README.i18n.yaml
+++ b/packages/interaction/user-interaction/README.i18n.yaml
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/interaction/user-interaction/README.md
-README.md: cf000dd59754dfe2f14395c33384bad5bda76910
-README.zh.md: 67167649e29afe99667ab6c863127d27d4bceb48
+README.md: a1fe8e63011b0726e67f8b873b0b67f4af2e890a
+README.zh.md: a6a0750bd91a316ebfeaef7859d5079f7ee8b616
diff --git a/packages/interaction/user-interaction/README.md b/packages/interaction/user-interaction/README.md
index cf000dd597..a1fe8e6301 100644
--- a/packages/interaction/user-interaction/README.md
+++ b/packages/interaction/user-interaction/README.md
@@ -26,7 +26,7 @@ When a request carries an agent, `ask()` authenticates its exact identity throug
### Presentation intent
-`intent` declares that a question IS a decision of a known shape, so a UI that recognises the tag may present it as such — `plan-review` says `detail` is a plan under review, and `dsh-plan-mode` sets it on the `exit_plan_mode` question. An intent shapes presentation only: a UI honouring it answers with the same option labels a generic UI would send, and a UI that does not know the tag renders the generic option list, so callers read one answer shape either way. `approve` names the label that approves rather than relying on option order. `ask()` rejects with `BAD_INTENT` the two assertions no type can carry: an `approve` naming none of that question's own options, and an intent on a question with no `detail` — the thing it declares itself a review of.
+`intent` declares that a question IS a known kind of decision, so a UI that recognises the tag may present it as such — `plan-review` says `detail` is a plan under review, and `dsh-plan-mode` sets it on the `exit_plan_mode` question. An intent changes presentation only: a UI honouring it answers with the same option labels a generic UI would send, and a UI that does not know the tag renders the generic option list, so callers read the same answer fields either way. `approve` names the label that approves rather than relying on option order. `ask()` rejects with `BAD_INTENT` the two assertions no type can carry: an `approve` naming none of that question's own options, and an intent on a question with no `detail` — the thing it declares itself a review of.
## Role
diff --git a/packages/interaction/user-interaction/README.zh.md b/packages/interaction/user-interaction/README.zh.md
index 67167649e2..a6a0750bd9 100644
--- a/packages/interaction/user-interaction/README.zh.md
+++ b/packages/interaction/user-interaction/README.zh.md
@@ -26,7 +26,7 @@
### 呈现意图
-`intent` 声明某个问题本身就是一种已知形态的决策,因此认识该标签的 UI 可以照此呈现——`plan-review` 表示 `detail` 是一份待审阅的计划,`dsh-plan-mode` 会在 `exit_plan_mode` 的问题上设置它。意图只塑造呈现:遵循它的 UI 回答的仍是通用 UI 会发送的那些选项标签,不认识该标签的 UI 渲染通用选项列表,因此调用方两种情况下读到的都是同一种回答形态。`approve` 指名表示批准的标签,而不依赖选项顺序。有两项断言是任何类型都承载不了的,`ask()` 会以 `BAD_INTENT` 拒绝它们:`approve` 未命中该问题自身的任一选项,以及意图落在没有 `detail` 的问题上——而 `detail` 正是它自称在审阅的东西。
+`intent` 声明某个问题本身就是一种已知决策,因此认识该标签的 UI 可以照此呈现——`plan-review` 表示 `detail` 是一份待审阅的计划,`dsh-plan-mode` 会在 `exit_plan_mode` 的问题上设置它。意图只改变呈现:遵循它的 UI 回答的仍是通用 UI 会发送的那些选项标签,不认识该标签的 UI 渲染通用选项列表,因此调用方两种情况下读到的回答字段相同。`approve` 指名表示批准的标签,而不依赖选项顺序。有两项断言是任何类型都承载不了的,`ask()` 会以 `BAD_INTENT` 拒绝它们:`approve` 未命中该问题自身的任一选项,以及意图落在没有 `detail` 的问题上——而 `detail` 正是它自称在审阅的东西。
## 职责
diff --git a/packages/interaction/user-interaction/src/types.ts b/packages/interaction/user-interaction/src/types.ts
index 51edfc6bf7..81be220592 100644
--- a/packages/interaction/user-interaction/src/types.ts
+++ b/packages/interaction/user-interaction/src/types.ts
@@ -1,5 +1,5 @@
/**
- * Wire-safe question/answer shapes, free of cordis/service imports so browser
+ * Wire-safe question and answer types, free of cordis/service imports so browser
* type chains (apiproxy api → client) can consume them without loading this
* package's Context augmentation.
* @module @deepseek-ai/dsh-user-interaction/types
@@ -14,11 +14,11 @@ export interface AskUserQuestionOption {
}
/**
- * A caller-declared presentation intent: the question IS a decision of this
- * shape, so a UI that recognises the tag may present it as such instead of as a
+ * A caller-declared presentation intent: the question IS this kind of
+ * decision, so a UI that recognises the tag may present it as such instead of as a
* generic option list. Tagged so further intents can be added; a UI that does
* not know a tag renders the generic flow, and the answer encoding is identical
- * either way — an intent shapes presentation only, never the protocol.
+ * either way — an intent changes presentation only, never the protocol.
*/
export type AskUserQuestionIntent = {
/** A plan submitted for review: `detail` is the plan markdown `ask()` requires, and the decision approves or declines it. */
diff --git a/packages/llm/llm-pi-ai/README.i18n.yaml b/packages/llm/llm-pi-ai/README.i18n.yaml
index 840a8c2865..4011ff5ba9 100644
--- a/packages/llm/llm-pi-ai/README.i18n.yaml
+++ b/packages/llm/llm-pi-ai/README.i18n.yaml
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/llm/llm-pi-ai/README.md
-README.md: 7151fdf5b63f48e625d00a92dc42aa24b7de2f31
-README.zh.md: 0bfd5c706e01dd4448edb9cf0eec812831f68093
+README.md: f6a1eefe6083d801009a5b788a07b58d6e696a5a
+README.zh.md: f4c5ddd6dbe05ae709145cfac341f17a716bac82
diff --git a/packages/llm/llm-pi-ai/README.md b/packages/llm/llm-pi-ai/README.md
index 7151fdf5b6..f6a1eefe60 100644
--- a/packages/llm/llm-pi-ai/README.md
+++ b/packages/llm/llm-pi-ai/README.md
@@ -173,7 +173,7 @@ Conversion preserves logical request order without adding text, while the select
#### What the model sees
-pi-ai events become harness reasoning, text, tool-call, usage, and finish chunks. Parsed tool arguments cross the harness boundary as raw JSON strings.
+pi-ai events become harness reasoning, text, tool-call, usage, and finish chunks. The adapter passes parsed tool arguments to the harness as raw JSON strings.
#### Token effect
diff --git a/packages/llm/llm-pi-ai/README.zh.md b/packages/llm/llm-pi-ai/README.zh.md
index 0bfd5c706e..f4c5ddd6db 100644
--- a/packages/llm/llm-pi-ai/README.zh.md
+++ b/packages/llm/llm-pi-ai/README.zh.md
@@ -173,7 +173,7 @@ pi-ai 会安装多个提供方 SDK,并延迟加载 catalog 模型所选的 SDK
#### 模型看到的内容
-pi-ai 事件会变为 harness 推理、文本、工具调用、usage 与 finish 分片。已解析工具参数以原始 JSON 字符串形式通过 harness 边界传递。
+pi-ai 事件会变为 harness 推理、文本、工具调用、usage 与 finish 分片。适配器把解析后的工具参数作为原始 JSON 字符串传给 harness。
#### Token 影响
diff --git a/packages/llm/llm-pi-ai/src/catalog.ts b/packages/llm/llm-pi-ai/src/catalog.ts
index 4e0cf3c092..90cf145975 100644
--- a/packages/llm/llm-pi-ai/src/catalog.ts
+++ b/packages/llm/llm-pi-ai/src/catalog.ts
@@ -145,12 +145,12 @@ export type PiAiReasoningEfforts = Partial = z.object({
/**
* Keys are the offered levels, values their wire spellings. A valueless key
* (`off:`) survives validation because schemastery passes nullable data
- * through before any member schema runs — `z.const(null)` only shapes the
- * error for non-null wrong values and what a configuration surface renders.
+ * through before any member schema runs — `z.const(null)` only controls the
+ * error for non-null wrong values and what a configuration UI renders.
* Only resolution decides which levels may leave the value empty, so the
* diagnostic can name the route and model. The assertion narrows
* schemastery's `Dict`, which types every literal key as required; dict
- * validation is per-present-key, so the runtime shape is the partial record.
+ * validation checks only present keys, so the runtime value is a partial record.
*/
const reasoningEfforts = z.dict(
z.union([z.string(), z.const(null)]),
@@ -237,7 +237,7 @@ export function assertServiceable(config: Config): void {
resolveProfiles(config.providers)
}
-/** Reject a pre-release profile shape, naming the replacement. */
+/** Reject removed pre-release profile fields and name their replacements. */
function rejectRemovedFields(provider: string, source: PiAiProviderProfile): void {
const legacy = source as PiAiProviderProfile & {
provider?: unknown
diff --git a/packages/llm/llm/src/index.ts b/packages/llm/llm/src/index.ts
index 8a7dec1265..75d8d6f364 100644
--- a/packages/llm/llm/src/index.ts
+++ b/packages/llm/llm/src/index.ts
@@ -184,8 +184,8 @@ export interface PreparedLlmCall {
/**
* Provider-wire adapter for the harness message and stream vocabulary. Register implementations
* with `ctx.llm.registerAdapter(providers, adapter)`. Every provider HTTP request must include
- * `attributionHeaders()`; prove that at the wire or library header-hook boundary. The direct-fetch
- * DeepSeek and library-backed pi-ai adapters intentionally exercise this contract through different internals.
+ * `attributionHeaders()`; prove the headers are added in the wire request or library header hook. The direct-fetch
+ * DeepSeek and library-backed pi-ai adapters meet this contract through different internals.
*/
export abstract class LlmAdapter {
/**
diff --git a/packages/llm/llm/src/message.ts b/packages/llm/llm/src/message.ts
index 2673072fa0..7863e66d58 100644
--- a/packages/llm/llm/src/message.ts
+++ b/packages/llm/llm/src/message.ts
@@ -30,8 +30,8 @@ export interface ToolMessageSource {
}
/**
- * What SHAPE of information a producer-supplied context carries, declared by
- * the producer beside the source fields it supplied.
+ * The kind of information in producer-supplied context, declared by the
+ * producer beside its provenance.
*
* `MessageSource.kind` answers *who produced this*; `form` answers *what kind
* of thing it is*, and the two axes are deliberately independent — several
@@ -69,10 +69,10 @@ export interface ContextSnapshotSection {
/**
* Producer-declared {@link ContextForm} and the fields that form requires,
- * mixed into the source shapes that carry one.
+ * mixed into the source types that carry one.
*
- * Discriminated by `form` so a producer cannot declare a shape without the
- * facts that shape is presented from: a `notice` must record its one-line
+ * Discriminated by `form` so a producer cannot select a form without the
+ * fields needed to present it: a `notice` must record its one-line
* account, a `snapshot` its sections. Omitting `form` stays valid — an
* undeclared context is the documented default.
*/
diff --git a/packages/llm/llm/src/types.ts b/packages/llm/llm/src/types.ts
index ad7e8f66ba..70528bf53a 100644
--- a/packages/llm/llm/src/types.ts
+++ b/packages/llm/llm/src/types.ts
@@ -1,6 +1,6 @@
/**
* Canonical provider-neutral message and streaming vocabulary for the loop,
- * session log, and plugins. Adapters alone translate provider wire shapes;
+ * session log, and plugins. Adapters alone translate provider wire messages;
* mapped interfaces make the content, source, and finish unions extensible.
*/
@@ -21,13 +21,13 @@ export type {
UserMessage,
} from './message.ts'
-/** Serializable provider-boundary facts; policy decides whether they are retryable. */
+/** Serializable provider or transport failure facts; policy decides whether they are retryable. */
export interface LlmFailure {
/** Human-readable provider or transport failure. */
readonly message: string
/** Stable provider-neutral machine-routing code. */
readonly code: string
- /** HTTP status observed at the provider boundary, when available. */
+ /** HTTP status returned by the provider, when available. */
readonly status?: number
/** Provider-requested delay in milliseconds, when valid and available. */
readonly providerRetryAfterMs?: number
@@ -89,7 +89,7 @@ export interface ContentBlockMap {
'tool-result': ToolResultBlock
}
-/** The block `type` tag vocabulary; widens as plugins merge new shapes into {@link ContentBlockMap}. */
+/** The block `type` tag vocabulary; widens as plugins add entries to {@link ContentBlockMap}. */
export type ContentBlockType = keyof ContentBlockMap
/** Any known content block, derived from {@link ContentBlockMap}; switch on `type` and fall through unknowns (merge-extensible). */
export type ContentBlock = ContentBlockMap[ContentBlockType]
diff --git a/packages/llm/token-meter/src/index.ts b/packages/llm/token-meter/src/index.ts
index d3b024a2ee..a5ac463fb2 100644
--- a/packages/llm/token-meter/src/index.ts
+++ b/packages/llm/token-meter/src/index.ts
@@ -206,7 +206,7 @@ export class TokenMeterService extends Service {
if (state.stepStart === undefined
|| state.stepStart.turn !== event.data.turn
|| state.stepStart.step !== event.data.step) {
- throw new Error(`token meter: step/end at seq ${event.seq} has no matching step/start boundary`)
+ throw new Error(`token meter: step/end at seq ${event.seq} has no matching step/start event`)
}
nextStepStart = undefined
break
@@ -223,7 +223,7 @@ export class TokenMeterService extends Service {
if (stepStart === undefined
|| stepStart.turn !== event.data.turn
|| stepStart.step !== event.data.step) {
- throw new Error(`token meter: assistant/message at seq ${event.seq} has no matching step/start boundary`)
+ throw new Error(`token meter: assistant/message at seq ${event.seq} has no matching step/start event`)
}
// assistant/message is surface-mandatory at every append/seed boundary.
diff --git a/packages/plan/plan-mode/README.i18n.yaml b/packages/plan/plan-mode/README.i18n.yaml
index 2a9323474e..3e0f9559e9 100644
--- a/packages/plan/plan-mode/README.i18n.yaml
+++ b/packages/plan/plan-mode/README.i18n.yaml
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/plan/plan-mode/README.md
-README.md: c404cfa73024804bc9f166cfb84fa5f87f723459
-README.zh.md: 275a87669802f38cd98886236ca63a09ffb3e410
+README.md: d7e19cc473695455df667cfd717703c2c303aafa
+README.zh.md: e89b75df184d2283452ab069a2d559650f15bfef
diff --git a/packages/plan/plan-mode/README.md b/packages/plan/plan-mode/README.md
index c404cfa730..d7e19cc473 100644
--- a/packages/plan/plan-mode/README.md
+++ b/packages/plan/plan-mode/README.md
@@ -2,13 +2,13 @@
English | [中文](README.zh.md)
-Logged, per-agent plan collaboration state with deployment-owned guidance, direct `/plan [message]` entry and `/plan off` exit commands, and the reviewed `exit_plan_mode` exit. Plan mode is soft guidance; sandbox mode and approval policy remain independent enforcement axes.
+Logged, per-agent plan collaboration state with deployment-owned guidance, direct `/plan [message]` entry and `/plan off` exit commands, and the reviewed `exit_plan_mode` exit. Plan mode is soft guidance; sandbox mode and approval policy enforce restrictions independently and do not read or write plan state.
## Durable state
`plan/mode` (`{ active: boolean }`) is a log-only, whole-value-replace `SessionEventMap` member. `foldPlanMode(events)` returns the last logged value or `false`, so resume, fork, and compaction recover plan state directly from the session log. UIs observe committed flips through `session/event`.
-`ctx.planMode.set(agent, active)` commits immediately when the agent is idle — no boundary would arrive until the next prompt, so the standalone `plan/mode` event lands at once — and holds a pending selection for the next accepted in-turn pre-step while the agent is running; it returns which of the two happened (`committed`/`queued`), a `cancelled` reversal, or a `noop`. `get(agent)` returns `{ active, pending? }`, separating the logged state shaping the current step from a user's mid-turn selection. Initial and continuation pre-step boundaries are covered; a same-step request-recovery retry reuses its frozen assembly and leaves the selection pending for the next pre-step. A changed user selection contributes one plugin-sourced `user/message` notice when the last logged request header described the other state (both commit paths).
+`ctx.planMode.set(agent, active)` appends the standalone `plan/mode` event immediately when the agent is idle, because no in-turn pre-step runs before the next prompt. While the agent is running, it holds a pending selection for the next accepted in-turn pre-step. It returns which happened (`committed`/`queued`), a `cancelled` reversal, or a `noop`. `get(agent)` returns `{ active, pending? }`, separating the logged state used to assemble the current step from a user's mid-turn selection. Initial and continuation pre-steps both apply pending selections; a same-step request-recovery retry reuses its frozen assembly and leaves the selection pending for the next pre-step. A changed user selection contributes one plugin-sourced `user/message` notice when the last logged request header described the other state (both commit paths).
## Model and human surfaces
@@ -22,7 +22,7 @@ The Web client consumes the plugin-owned `/plan` command; other entry points may
## Session projection
-When the composition mounts `ctx.sessionProjections` ([`@deepseek-ai/dsh-session-projection`](../../session/session-projection/README.md)), this package registers the `plan` projection unit under an injected child. The unit folds two event kinds: a `command/run` record named `plan` with recorded `args` sets the wanted target (`off` → inactive, anything else → active), and `plan/mode` commits the logged state and clears it; every other event returns the same state reference. `view` derives `{ active, pending }`, where `pending` is true only while an outstanding selection differs from the logged state — a pure replay quantity, so host restarts, other tabs, and cold reads all recover it from the log alone (the `/plan` handler calls `set()` before any failing path, keeping the logged request and the run plane from forking). The key merges into `SessionProjectionMap` from `src/types.ts` (served to host consumers via `./types` and client aggregates via `./client`); the framework drives the unit and carriers serve the value on the history tail page and the `session/projection` push frame. Compositions without the registry are unaffected.
+When the composition mounts `ctx.sessionProjections` ([`@deepseek-ai/dsh-session-projection`](../../session/session-projection/README.md)), this package registers the `plan` projection unit under an injected child. The unit folds two event kinds: a `command/run` record named `plan` with recorded `args` sets the wanted target (`off` → inactive, anything else → active), and `plan/mode` commits the logged state and clears it; every other event returns the same state reference. `view` derives `{ active, pending }`, where `pending` is true only while an outstanding selection differs from the logged state — a pure replay quantity, so host restarts, other tabs, and cold reads all recover it from the log alone (the `/plan` handler calls `set()` before any failing path, so a failed handler cannot leave a recorded command without its plan selection). The key merges into `SessionProjectionMap` from `src/types.ts` (served to host consumers via `./types` and client aggregates via `./client`); the framework drives the unit and carriers serve the value on the history tail page and the `session/projection` push frame. Compositions without the registry are unaffected.
## Configuration
@@ -91,8 +91,8 @@ Mode transitions do not change the tool catalog; plan arguments and review resul
## Known Limitations and Deferred Work
-- Plan mode guides rather than enforces; deployments needing a hard boundary must combine independent sandbox and approval controls.
-- A pending selection made while idle is lost if the process exits before the next boundary, so the UI must reapply it.
+- Plan mode guides rather than enforces; deployments that need enforced restrictions must configure sandbox and approval controls independently.
+- A selection made after the turn's final accepted pre-step is lost if the process exits before another accepted in-turn pre-step, so the UI must reapply it.
- Forked agents inherit logged plan state, while newly spawned agents begin inactive; there is no creation-time plan option.
- A live child owned by another agent cannot open the `exit_plan_mode` review. The failed call tells the child to include the unresolved decision in its final result; durable fork lineage alone does not prevent a session resumed as a runtime root from opening the review.
- Only the Web UI has a specialized `plan-review` renderer; another interaction provider may present the same request through its generic option flow.
diff --git a/packages/plan/plan-mode/README.zh.md b/packages/plan/plan-mode/README.zh.md
index 275a876698..e89b75df18 100644
--- a/packages/plan/plan-mode/README.zh.md
+++ b/packages/plan/plan-mode/README.zh.md
@@ -2,13 +2,13 @@
[English](README.md) | 中文
-按 agent(智能体)分别记录到日志的 plan 协作状态,提供由部署方配置的引导内容、用于直接进入的 `/plan [message]` 命令、用于直接退出的 `/plan off` 命令,以及经用户评审的 `exit_plan_mode` 退出方式。Plan mode 是软引导;沙箱模式和批准策略仍是独立的强制执行维度。
+按 agent(智能体)分别记录到日志的 plan 协作状态,提供由部署方配置的引导内容、用于直接进入的 `/plan [message]` 命令、用于直接退出的 `/plan off` 命令,以及经用户评审的 `exit_plan_mode` 退出方式。Plan mode 是软引导;沙箱模式和批准策略各自强制执行限制,且不读写 plan 状态。
## 持久状态
`plan/mode`(`{ active: boolean }`)是一个仅存在于日志中、每次以完整值替换的 `SessionEventMap` 成员。`foldPlanMode(events)` 返回最后记录的值,如果没有则返回 `false`,因此恢复、fork 和压缩(compaction)都能直接从会话日志恢复 plan 状态。UI 通过 `session/event` 观察已提交的切换。
-`ctx.planMode.set(agent, active)` 在 agent 空闲时立即提交——下一个 prompt 之前不会有任何边界到来,因此独立的 `plan/mode` 事件当场落账——在 agent 运行中则持有待生效选择,并等待下一个被接受的轮内 pre-step;返回值区分 `committed`、`queued`、表示反转的 `cancelled` 和 `noop`。`get(agent)` 返回 `{ active, pending? }`,将塑造当前步骤的日志状态与用户的轮中选择分开。初始与续步 pre-step 边界都在覆盖范围内;同一步骤的请求恢复重试会复用已冻结的 assembly,并将该选择保留到下一个 pre-step。当最后记录的请求头描述了另一状态时,用户选择的变更会贡献一条插件来源的 `user/message` 通知(两条提交路径皆然)。
+`ctx.planMode.set(agent, active)` 会在 agent 空闲时立即追加独立的 `plan/mode` 事件,因为下一个 prompt 之前不会运行轮内 pre-step。agent 运行时,该方法会保留待生效选择,直到下一个被接受的轮内 pre-step。返回值区分 `committed`、`queued`、表示反转的 `cancelled` 和 `noop`。`get(agent)` 返回 `{ active, pending? }`,将用于组装当前步骤的日志状态与用户的轮中选择分开。初始与续步 pre-step 都会应用待生效选择;同一步骤的请求恢复重试会复用已冻结的 assembly,并将该选择保留到下一个被接受的轮内 pre-step。当最后记录的请求头描述了另一状态时,用户选择的变更会贡献一条插件来源的 `user/message` 通知(两条追加路径皆然)。
## 模型与人类交互
@@ -16,13 +16,13 @@
评审问题声明 `plan-review` 呈现意图,并指名 `Approve` 为表示批准的标签,因此有能力的 UI 会把计划呈现为一次决定而非通用问题;两种情况下该工具读到的回答完全相同。放弃审阅 —— 用户关掉请求改用说话 —— 会如实报告给模型,要求它留在 plan mode 中等待那条消息;其余每一种评审失败都保留 seam 自身的消息。
-组合 `ctx.commands` 时,该包会注册 `/plan [message]`,并将参数恰好为 `off` 的情况保留给直接退出。不带参数的 `/plan` 会启用 plan mode;任何其他非空参数都会先启用 plan mode,再通过 `agent.steer()` 提交,因此它会在 plan 引导下成为下一步骤的常规已记录用户消息。`/plan off` 会选择停用状态,不发送模型输入;它还可以在启用 plan mode 的待处理选择到达请求边界之前将其取消。
+组合 `ctx.commands` 时,该包会注册 `/plan [message]`,并将参数恰好为 `off` 的情况保留给直接退出。不带参数的 `/plan` 会启用 plan mode;任何其他非空参数都会先启用 plan mode,再通过 `agent.steer()` 提交,因此它会在 plan 引导下成为下一步骤的常规已记录用户消息。`/plan off` 会选择停用状态,不发送模型输入;它还可以在启用 plan mode 的待处理选择由轮内 pre-step 追加之前将其取消。
Web 客户端使用该插件提供的 `/plan` 命令;其他入口可以直接驱动同一服务,无需定义第二套 mode 词汇。
## 会话投影
-当组合挂载 `ctx.sessionProjections`([`@deepseek-ai/dsh-session-projection`](../../session/session-projection/README.md))时,本包会在一个注入的子插件中注册 `plan` 投影单元。该单元折叠两类事件:名为 `plan` 且携带已记录 `args` 的 `command/run` 记录会设置目标状态(`off` → 未激活,其余 → 激活),`plan/mode` 会提交已记录状态并清除该目标;其他任何事件都返回同一个状态引用。`view` 推导 `{ active, pending }`,其中 `pending` 仅在尚未落实的选择与已记录状态不同时为 true。该值完全由日志回放得出,因此 host 重启、其他标签页和冷读都能仅凭日志恢复它。`/plan` 处理器会在任何可能失败的路径之前调用 `set()`,避免已写入日志的请求与运行面分叉。key 由 `src/types.ts` 通过声明合并加入 `SessionProjectionMap`:host 消费方经 `./types` 获取,client 聚合经 `./client` 获取。框架负责驱动该单元,载体通过历史尾页和 `session/projection` 推送帧提供其值。未挂载注册表的组合不受影响。
+当组合挂载 `ctx.sessionProjections`([`@deepseek-ai/dsh-session-projection`](../../session/session-projection/README.md))时,本包会在一个注入的子插件中注册 `plan` 投影单元。该单元折叠两类事件:名为 `plan` 且携带已记录 `args` 的 `command/run` 记录会设置目标状态(`off` → 未激活,其余 → 激活),`plan/mode` 会提交已记录状态并清除该目标;其他任何事件都返回同一个状态引用。`view` 推导 `{ active, pending }`,其中 `pending` 仅在尚未落实的选择与已记录状态不同时为 true。该值完全由日志回放得出,因此 host 重启、其他标签页和冷读都能仅凭日志恢复它。`/plan` 处理器会在任何可能失败的路径之前调用 `set()`,因此处理器失败时不会留下缺少对应 plan 选择的已记录命令。key 由 `src/types.ts` 通过声明合并加入 `SessionProjectionMap`:host 消费方经 `./types` 获取,client 聚合经 `./client` 获取。框架负责驱动该单元,载体通过历史尾页和 `session/projection` 推送帧提供其值。未挂载注册表的组合不受影响。
## 配置
@@ -91,8 +91,8 @@ mode 转换不改变工具目录;plan 参数与评审结果按常规方式扩
## 已知限制与暂缓事项
-- Plan mode 只进行引导,而不强制执行;需要硬边界的部署必须组合独立的沙箱与批准控制。
-- 如果进程在下一个边界之前退出,空闲时作出的待生效选择会丢失,因此 UI 必须重新应用它。
+- Plan mode 只进行引导,而不强制执行;需要强制限制的部署必须分别配置沙箱与批准控制。
+- 如果进程在另一个被接受的轮内 pre-step 之前退出,某轮最后一个被接受的 pre-step 之后作出的选择会丢失,因此 UI 必须重新应用它。
- Fork 的 agent 会继承已记录的 plan 状态,新 spawn 的 agent 则从未激活状态开始;不存在创建时 plan 选项。
- 由另一个 agent 所有的存活子级无法打开 `exit_plan_mode` 审阅。该调用失败时会提示子级在最终结果中包含尚未解决的决策;仅有持久化 fork 谱系并不会阻止恢复为运行时根的会话打开该审阅。
- 只有 Web UI 具备专用的 `plan-review` 渲染器;其他交互提供方可以通过通用选项流程呈现同一请求。
diff --git a/packages/plan/plan-mode/src/index.ts b/packages/plan/plan-mode/src/index.ts
index 00234424ff..86da4d4935 100644
--- a/packages/plan/plan-mode/src/index.ts
+++ b/packages/plan/plan-mode/src/index.ts
@@ -1,20 +1,21 @@
/**
* Plan mode is logged per-agent collaboration state: while active, a
- * deployment-owned guidance section shapes each model request, and
+ * deployment-owned guidance section is included in each model request, and
* `exit_plan_mode` presents the completed plan for user review, while the
- * `/plan off` command lets a user leave directly. Plan mode is independent of
- * sandbox mode and approval policy; those enforcement axes do not read or
- * write plan state.
+ * `/plan off` command lets a user leave directly. Sandbox mode and approval
+ * policy enforce restrictions independently and do not read or write plan
+ * state.
*
* The state in force is folded from the session log (`plan/mode`, last one
* wins), so resume and fork restore it without a live mirror. User selections
- * are held as pending intent until an in-turn step boundary. The service
- * projects pending intent into the proposed step assembly, then flushes it
+ * remain pending until the next accepted in-turn pre-step. The service includes
+ * the selected state in the proposed step assembly, then appends `plan/mode`
* from `agent/pre-step` only when the step is accepted. Same-step request
* retries reuse their assembly.
*
- * The exit tool remains registered while plan mode is inactive so crossing a
- * boundary changes only the prompt section, not the request tool catalog.
+ * The exit tool remains registered while plan mode is inactive, so entering
+ * or leaving plan mode changes only the prompt section, not the request tool
+ * catalog.
*
* Agent Note:
* - .agents/notes/implemented/simplification/2026-07-22-plan-specific-collaboration-state.md
@@ -97,7 +98,7 @@ function firstHeading(plan: string): string | undefined {
/**
* Validate deployment-owned plan guidance. Missing, blank, non-string, or
- * unknown fields fail at plugin load rather than silently shaping nothing.
+ * unknown fields fail at plugin load rather than being ignored.
*
* @param config Raw plugin config.
* @returns A detached validated config.
@@ -176,7 +177,7 @@ function planModeAtLastHeader(events: readonly SessionEvent[]): boolean | undefi
}
/**
- * `ctx.planMode`: owns logged plan state, boundary application and narration,
+ * `ctx.planMode`: owns logged plan state, applies and narrates selected state at step start,
* the `plan:policy` section, the `/plan` command, and the stable exit tool.
* UIs observe committed flips through `session/event`; there is no live mirror.
*/
@@ -187,7 +188,7 @@ export class PlanModeService extends Service {
private readonly section: string
/**
- * Latest selection per session awaiting an in-turn request-boundary flush.
+ * Latest selection per session awaiting the next accepted in-turn pre-step.
* `narrate` is true for user selections and false for the exit tool, whose
* result already narrates the transition.
*/
@@ -197,10 +198,10 @@ export class PlanModeService extends Service {
super(ctx, 'planMode')
this.section = resolveConfig(config).section
let disposed = false
- // Pre-step is outside Session.append publication, so its log-only mode
- // event can land between turns or inside an open turn without re-entering
- // the session. A failed append remains pending for a later boundary, and
- // policy cannot block the step.
+ // Pre-step is outside Session.append publication, so it can append the
+ // log-only mode event inside an open turn without re-entering the session.
+ // A failed append remains pending for a later accepted in-turn pre-step,
+ // and policy cannot block the step.
ctx.on('agent/pre-step', async (
{ agent, signal },
next,
@@ -212,7 +213,7 @@ export class PlanModeService extends Service {
try {
this.onBoundary(agent.session)
} catch (error) {
- ctx.logger.warn('dsh-plan-mode: boundary flush failed: %o', error)
+ ctx.logger.warn('dsh-plan-mode: failed to append selected plan mode at step start: %o', error)
return decision
}
return !pending.narrate || narration === undefined
@@ -234,8 +235,9 @@ export class PlanModeService extends Service {
// The plan projection unit (session-projection RFC): a pure double-event
// fold serving clients the whole {active, pending} value. `command/run`
// records the user's logged /plan selection (the handler calls `set()`
- // before any failing path, so log and run-plane cannot fork); `plan/mode`
- // is the boundary commit that resolves it. Pending is thereby a pure
+ // before any failing path, so a failed handler cannot leave the recorded
+ // command without its plan selection); `plan/mode` records that selection
+ // and clears it. Pending is thereby a pure
// replay quantity: host restarts, other tabs, and cold reads all recover
// it from the log alone. The unit child activates only when a projection
// registry is composed (headless assemblies stay unaffected).
@@ -280,8 +282,9 @@ export class PlanModeService extends Service {
case 'cancelled':
return { kind: 'success', text: 'Plan mode entry cancelled.' }
case 'noop':
- // Repeat the queued wording while an exit still awaits its
- // boundary; only a truly inactive session reads idempotent.
+ // Repeat the queued wording while an exit still awaits the
+ // next accepted pre-step; only a truly inactive session reads
+ // idempotent.
return foldPlanMode(agent.session.events)
? { kind: 'success', text: 'Leaving plan mode (applies from the next step).' }
: { kind: 'success', text: 'Plan mode is already inactive.' }
@@ -357,8 +360,8 @@ export class PlanModeService extends Service {
}
throw cause
})
- // A review may outlive this plugin fiber. Without boundary listeners,
- // an approved result could never land, so fail and keep planning.
+ // A review may outlive this plugin fiber. Without its pre-step listener,
+ // an approved selection could never be appended, so fail and keep planning.
if (disposed) {
throw new Error('the plan-mode service was reloaded while the plan was under review; present the plan again')
}
@@ -371,7 +374,8 @@ export class PlanModeService extends Service {
: `The user chose to keep planning; their feedback: ${feedback}`)
}
// Keep plan guidance for the rest of this assistant tool batch. The
- // silent intent flushes after the step, before the next assembly.
+ // silent selection is appended at the next accepted in-turn pre-step,
+ // before its request assembly.
this.pendingIntents.set(agent.session, { active: false, narrate: false })
return { approved: true }
},
@@ -390,7 +394,8 @@ export class PlanModeService extends Service {
}
/**
- * Read the logged plan state and any selected state awaiting a boundary.
+ * Read the logged plan state and any selected state awaiting the next
+ * accepted in-turn pre-step.
*
* @param agent The agent to read.
* @returns Current logged state plus a pending selection, when present.
@@ -402,20 +407,20 @@ export class PlanModeService extends Service {
}
/**
- * Select whether plan mode should be active. Between turns the change
- * commits immediately — no request boundary would arrive until the next
- * prompt, so a queued intent would hang (the open-turn fold is the idle
- * signal: agent status stays `running` through post-turn checkpointing,
- * where a boundary equally never comes). During an open turn the
- * selection is held as pending intent for the next in-turn request
- * boundary. Repeated selection of the current or already-pending state is
- * a no-op.
+ * Select whether plan mode should be active. Between turns the method
+ * appends the change immediately because no in-turn pre-step will run until
+ * another prompt starts a turn. The open-turn fold is the idle signal:
+ * agent status stays `running` through post-turn checkpointing, when no
+ * further in-turn pre-step runs. During an open turn the selection remains
+ * pending until the next accepted in-turn pre-step. Repeated selection of
+ * the current or already-pending state is a no-op.
*
* @param agent The agent to switch.
* @param active Whether plan mode should be active.
* @returns what happened: `committed` (logged now), `queued` (awaiting the
- * next boundary), `cancelled` (an opposite pending selection was cleared;
- * the logged state already matches), or `noop` (already in that state).
+ * next accepted in-turn pre-step), `cancelled` (an opposite pending selection
+ * was cleared; the logged state already matches), or `noop` (already in that
+ * state).
*/
set(agent: Agent, active: boolean): 'committed' | 'queued' | 'cancelled' | 'noop' {
const session = agent.session
@@ -439,7 +444,7 @@ export class PlanModeService extends Service {
return 'committed'
}
- /** Flush one pending selection before the next request assembly. */
+ /** Append one pending selection before the next request assembly. */
private onBoundary(session: Session): void {
const pending = this.pendingIntents.get(session)
if (pending === undefined) return
@@ -449,8 +454,8 @@ export class PlanModeService extends Service {
return
}
session.append('plan/mode', { active: target })
- // Delete only after append succeeds so a later boundary can retry a failed
- // durable write.
+ // Delete only after append succeeds so a later accepted in-turn pre-step
+ // can retry a failed durable write.
this.pendingIntents.delete(session)
}
diff --git a/packages/plan/plan-mode/src/types.ts b/packages/plan/plan-mode/src/types.ts
index eafd5f0aff..a3c10d2252 100644
--- a/packages/plan/plan-mode/src/types.ts
+++ b/packages/plan/plan-mode/src/types.ts
@@ -11,8 +11,8 @@
/**
* The plan projection's wire value. `active` is the logged state in force
* (the last `plan/mode`, inactive before the first); `pending` is true while
- * a logged `/plan` selection (`command/run`) awaits its request-boundary
- * `plan/mode` commit and targets a state other than `active`. Capability
+ * a logged `/plan` selection (`command/run`) targets a state other than
+ * `active` and no later `plan/mode` event has recorded that state. Capability
* absence (plan-mode not composed) is the key's absence, never a value.
*/
export interface PlanProjection {
diff --git a/packages/plan/plan-mode/tests/projection.spec.ts b/packages/plan/plan-mode/tests/projection.spec.ts
index c504ff5df6..b1e546d8b7 100644
--- a/packages/plan/plan-mode/tests/projection.spec.ts
+++ b/packages/plan/plan-mode/tests/projection.spec.ts
@@ -69,7 +69,7 @@ describe('plan projection unit', () => {
expect(bench.values()).toEqual({ plan: { active: false, pending: false } })
})
- it('a logged /plan selection reads pending until the boundary commit resolves it', async () => {
+ it('a logged /plan selection reads pending until plan/mode records it', async () => {
const bench = await harness(true)
runPlanCommand(bench.session, '', 0)
expect(bench.values().plan).toEqual({ active: false, pending: true })
diff --git a/packages/sandbox/sandbox-local/README.i18n.yaml b/packages/sandbox/sandbox-local/README.i18n.yaml
index 13fa52ce9d..ba2e21b8e9 100644
--- a/packages/sandbox/sandbox-local/README.i18n.yaml
+++ b/packages/sandbox/sandbox-local/README.i18n.yaml
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/sandbox/sandbox-local/README.md
-README.md: 23d3a32451c105c71c0a7399ed051288b70753f3
-README.zh.md: 1890771faf8cab6b1842f973a999c7a9cf2dbb11
+README.md: 4d9e8275ba3fe0c1f49555b61e319f52194244bc
+README.zh.md: 8a755e6c5b0c266538277bbbcd118fd24ab164f3
diff --git a/packages/sandbox/sandbox-local/README.md b/packages/sandbox/sandbox-local/README.md
index 23d3a32451..4d9e8275ba 100644
--- a/packages/sandbox/sandbox-local/README.md
+++ b/packages/sandbox/sandbox-local/README.md
@@ -35,4 +35,4 @@ No direct invalidation; the named consumer owns any request-prefix changes.
- **Landlock may be partial** — older supported kernel ABIs confine only the access classes they expose, reported as `enforcement: 'partial'` rather than overstated as full.
- **Seatbelt depends on deprecated `sandbox-exec`** — macOS still ships it, but this provider cannot replace or probe that private policy engine if Apple removes it.
- **Runner selection is cached for the provider lifetime** — installing, removing, or repairing a runner requires reloading the plugin before selection changes.
-- **`runnerCommand` is an operator assertion** — a configured custom runner skips functional probes and is assumed to implement the bwrap-shaped profile honestly; if it is itself a Bash script, its interpreter startup runs before that script applies confinement.
+- **`runnerCommand` is an operator assertion** — a configured custom runner skips functional probes and is assumed to implement the bwrap-compatible profile honestly; if it is itself a Bash script, its interpreter startup runs before that script applies confinement.
diff --git a/packages/sandbox/sandbox-local/README.zh.md b/packages/sandbox/sandbox-local/README.zh.md
index 1890771faf..8a755e6c5b 100644
--- a/packages/sandbox/sandbox-local/README.zh.md
+++ b/packages/sandbox/sandbox-local/README.zh.md
@@ -35,4 +35,4 @@ Seatbelt profile 默认允许,但带 `(deny file-write*)` 和写入 allow-list
- **Landlock 可能只实现部分强制执行**:较旧且受支持的内核 ABI 只能限制自身公开的访问类别,因此报告 `enforcement: 'partial'`,不会夸大为完整强制执行。
- **Seatbelt 依赖已弃用的 `sandbox-exec`**:macOS 仍会提供它,但若 Apple 移除该私有策略引擎,该提供方无法替换或探测。
- **runner 选择在提供方生命周期内缓存**:安装、移除或修复 runner 后,必须重载插件才能改变选择。
-- **`runnerCommand` 是操作方断言**:配置的自定义 runner 会跳过功能探测,并假定它诚实实现 bwrap 形式的 profile;如果它本身是 Bash 脚本,其解释器启动发生在该脚本施加约束之前。
+- **`runnerCommand` 是操作方断言**:配置的自定义 runner 会跳过功能探测,并假定它诚实实现与 bwrap 兼容的 profile;如果它本身是 Bash 脚本,其解释器启动发生在该脚本施加约束之前。
diff --git a/packages/sandbox/sandbox-local/src/index.ts b/packages/sandbox/sandbox-local/src/index.ts
index 42a150b855..fc19a8dbea 100644
--- a/packages/sandbox/sandbox-local/src/index.ts
+++ b/packages/sandbox/sandbox-local/src/index.ts
@@ -42,7 +42,7 @@ import { bwrapProfileArgs, landlockProfileArgs, seatbeltProfileArgs } from './pr
/** Plugin config. All optional — `static Config` supplies the defaults. */
export interface Config {
/**
- * Override the runner argv; bwrap-shaped profile arguments are appended. A
+ * Override the runner argv; bwrap-compatible profile arguments are appended. A
* non-empty override asserts full enforcement and skips built-in selection and
* probing. A runner that starts but refuses its profile must be identifiable by
* {@link runnerFailureSignatures}. Consumers classify a spawn rejection only after
diff --git a/packages/sandbox/sandbox-policy/src/invariant.ts b/packages/sandbox/sandbox-policy/src/invariant.ts
index 90b8bf65fd..20fd176af6 100644
--- a/packages/sandbox/sandbox-policy/src/invariant.ts
+++ b/packages/sandbox/sandbox-policy/src/invariant.ts
@@ -13,7 +13,7 @@ export const name = 'sandbox-policy-invariant'
export const inject = ['invariants']
/* jscpd:ignore-start -- package companions share replay and dispatch plumbing */
-/** Validate the package-owned event shape and ignore unrelated events. */
+/** Validate the package-owned event fields and ignore unrelated events. */
function validateEvent(event: SessionEvent, fail: InvariantFailure): void {
if (event.type === 'sandbox/mode' && !SANDBOX_MODES.includes(event.data.mode)) {
fail(`sandbox/mode carries unknown mode ${JSON.stringify(event.data.mode)}`)
diff --git a/packages/scaffold/client/src/api.ts b/packages/scaffold/client/src/api.ts
index 6e76efa417..d615caece5 100644
--- a/packages/scaffold/client/src/api.ts
+++ b/packages/scaffold/client/src/api.ts
@@ -203,7 +203,7 @@ export function normalizeInput(input: string | ContentBlock[]): ContentBlock[] {
return typeof input === 'string' ? [{ type: 'text', text: input }] : input
}
-/** Validate a wire `session.event` envelope to the shape the typed result exposes. */
+/** Validate the fields in a wire `session.event` envelope before returning the typed result. */
function validatedSessionEvent(value: unknown): SessionEvent {
if (!isRecord(value) || typeof value.type !== 'string') {
throw new SdkProtocolError(`session.event carried no event envelope: ${JSON.stringify(value)}`)
diff --git a/packages/scaffold/helper/src/documents/tsconfig-file.ts b/packages/scaffold/helper/src/documents/tsconfig-file.ts
index 368b93eb43..2f61e10951 100644
--- a/packages/scaffold/helper/src/documents/tsconfig-file.ts
+++ b/packages/scaffold/helper/src/documents/tsconfig-file.ts
@@ -70,7 +70,7 @@ export class TsConfigFile extends ProjectFile {
))
}
- /** Validate JSONC and the project-reference shape. */
+ /** Validate JSONC and the project-reference fields. */
override validate(): void {
const value = parseConfig(this.text)
if (value.references === undefined) return
diff --git a/packages/scaffold/helper/src/features/define-feature.ts b/packages/scaffold/helper/src/features/define-feature.ts
index 84b020591c..720f15ecd6 100644
--- a/packages/scaffold/helper/src/features/define-feature.ts
+++ b/packages/scaffold/helper/src/features/define-feature.ts
@@ -114,7 +114,7 @@ function configDiagnostics(
if (!expected || Object.keys(expected).length === 0) return undefined
return config => Object.entries(expected).flatMap(([key, value]) => sameShape(value, config[key])
? []
- : [`${key} has an incompatible value shape`])
+ : [`${key} has fields or value types that do not match the expected config`])
}
function resourcesFromSpec(spec: FeatureResourceSpec): ProjectResource[] {
diff --git a/packages/scaffold/helper/src/features/feature.ts b/packages/scaffold/helper/src/features/feature.ts
index 93d51c5ffe..6fec1e84e3 100644
--- a/packages/scaffold/helper/src/features/feature.ts
+++ b/packages/scaffold/helper/src/features/feature.ts
@@ -236,7 +236,7 @@ export abstract class Feature {
}
/**
- * Inspect current files and reject any partial or ambiguous owned shape.
+ * Inspect current files and reject any partial or ambiguous owned file set.
* @param project - project snapshot to inspect.
* @returns installation state, selection, and diagnostics.
*/
diff --git a/packages/self-modification/repository-plugin/src/index.ts b/packages/self-modification/repository-plugin/src/index.ts
index 1251fe45e2..46a020f40a 100644
--- a/packages/self-modification/repository-plugin/src/index.ts
+++ b/packages/self-modification/repository-plugin/src/index.ts
@@ -92,7 +92,7 @@ async function applyPrepared(ctx: Context, value: PreparedPluginConfig): Promise
process.env,
directory,
// Schemastery call signatures collapse the parameter to `never` under
- // NodeNext; ResolvedMcpServer is shaped for the Config union by design.
+ // NodeNext; ResolvedMcpServer matches the Config union by design.
).map(input => McpClient.Config(input as never))
await ctx.effect(async function* () {
diff --git a/packages/self-modification/tool-cordis/src/api-catalog.ts b/packages/self-modification/tool-cordis/src/api-catalog.ts
index fdb852533d..9e63aa3ea3 100644
--- a/packages/self-modification/tool-cordis/src/api-catalog.ts
+++ b/packages/self-modification/tool-cordis/src/api-catalog.ts
@@ -486,7 +486,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
},
{
key: 'httpServer',
- summary: 'The web-shape HTTP carrier service.',
+ summary: 'The browser HTTP carrier service.',
methods: [
{
signature: 'register(route: WebRoute): () => void',
@@ -602,15 +602,15 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
},
{
key: 'planMode',
- summary: '`ctx.planMode`: owns logged plan state, boundary application and narration, the `plan:policy` section, the `/plan` command, and the stable exit tool.',
+ summary: '`ctx.planMode`: owns logged plan state, applies and narrates selected state at step start, the `plan:policy` section, the `/plan` command, and the stable exit tool.',
methods: [
{
signature: 'get(agent: Agent): { active: boolean; pending?: boolean }',
- jsDoc: '/**\n * Read the logged plan state and any selected state awaiting a boundary.\n *\n * @param agent The agent to read.\n * @returns Current logged state plus a pending selection, when present.\n */',
+ jsDoc: '/**\n * Read the logged plan state and any selected state awaiting the next\n * accepted in-turn pre-step.\n *\n * @param agent The agent to read.\n * @returns Current logged state plus a pending selection, when present.\n */',
},
{
signature: 'set(agent: Agent, active: boolean): \'committed\' | \'queued\' | \'cancelled\' | \'noop\'',
- jsDoc: '/**\n * Select whether plan mode should be active. Between turns the change\n * commits immediately — no request boundary would arrive until the next\n * prompt, so a queued intent would hang (the open-turn fold is the idle\n * signal: agent status stays `running` through post-turn checkpointing,\n * where a boundary equally never comes). During an open turn the\n * selection is held as pending intent for the next in-turn request\n * boundary. Repeated selection of the current or already-pending state is\n * a no-op.\n *\n * @param agent The agent to switch.\n * @param active Whether plan mode should be active.\n * @returns what happened: `committed` (logged now), `queued` (awaiting the\n * next boundary), `cancelled` (an opposite pending selection was cleared;\n * the logged state already matches), or `noop` (already in that state).\n */',
+ jsDoc: '/**\n * Select whether plan mode should be active. Between turns the method\n * appends the change immediately because no in-turn pre-step will run until\n * another prompt starts a turn. The open-turn fold is the idle signal:\n * agent status stays `running` through post-turn checkpointing, when no\n * further in-turn pre-step runs. During an open turn the selection remains\n * pending until the next accepted in-turn pre-step. Repeated selection of\n * the current or already-pending state is a no-op.\n *\n * @param agent The agent to switch.\n * @param active Whether plan mode should be active.\n * @returns what happened: `committed` (logged now), `queued` (awaiting the\n * next accepted in-turn pre-step), `cancelled` (an opposite pending selection\n * was cleared; the logged state already matches), or `noop` (already in that\n * state).\n */',
},
],
},
@@ -746,7 +746,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
methods: [
{
signature: 'register(definition: ProjectionDefinition): () => void',
- jsDoc: '/**\n * Register one domain\'s unit. The registration is an effect on the calling\n * context\'s fiber: disposing the fiber (or calling the returned disposer)\n * removes the key — and the unit\'s cached cells — from subsequent drives\n * and snapshots.\n * @param definition - key, boundary schema, pure unit functions, and stateVersion.\n * @returns the exact disposer that unregisters this unit.\n */',
+ jsDoc: '/**\n * Register one domain\'s unit. The registration is an effect on the calling\n * context\'s fiber: disposing the fiber (or calling the returned disposer)\n * removes the key — and the unit\'s cached cells — from subsequent drives\n * and snapshots.\n * @param definition - key, state schema, pure unit functions, and stateVersion.\n * @returns the exact disposer that unregisters this unit.\n */',
},
{
signature: 'onChanged(listener: ProjectionChangeListener): () => void',
@@ -820,11 +820,11 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
},
{
signature: 'async readSurface(sessionId: SessionId): Promise',
- jsDoc: '/**\n * Read one session\'s complete current model surface from one corpus observation.\n * @param sessionId - live-preferred session id to read.\n * @returns cloned header, current surface, and raw-log capture boundary.\n * @throws when source resolution fails or the session surface is invalid.\n */',
+ jsDoc: '/**\n * Read one session\'s complete current model surface from one corpus observation.\n * @param sessionId - live-preferred session id to read.\n * @returns cloned header, current surface, and the last sequence number included in the raw-log capture.\n * @throws when source resolution fails or the session surface is invalid.\n */',
},
{
signature: 'async traceSession(sessionId: SessionId, signal?: AbortSignal): Promise',
- jsDoc: '/**\n * Trace known ancestry and descendants from one corpus observation.\n * @param sessionId - logical session id to trace.\n * @param signal - optional cancellation for persistence listing.\n * @returns a complete lineage or an explicit unresolved parent boundary.\n * @throws when corpus resolution fails, the target is absent, or its known ancestry cycles.\n */',
+ jsDoc: '/**\n * Trace known ancestry and descendants from one corpus observation.\n * @param sessionId - logical session id to trace.\n * @param signal - optional cancellation for persistence listing.\n * @returns a complete lineage or the first parent that could not be resolved.\n * @throws when corpus resolution fails, the target is absent, or its known ancestry cycles.\n */',
},
{
signature: 'async traceEvent(request: SessionEventTraceRequest, signal?: AbortSignal): Promise',
@@ -1140,17 +1140,17 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
},
{
signature: 'abstract onTaskDone(listener: TaskDoneListener): () => void',
- jsDoc: '/**\n * Register an effect-scoped completion listener. Each listener is contained;\n * returned promises are observed but not awaited. No listener runs after\n * service disposal.\n * @param listener - receives each terminal snapshot and its exact owner.\n * @returns disposer that unregisters the listener.\n */',
+ jsDoc: '/**\n * Register an effect-scoped completion listener. It receives the settlements\n * of the owners its registering context\'s scope covers; each listener is\n * contained; returned promises are observed but not awaited. No listener runs\n * after service disposal.\n * @param listener - receives each terminal snapshot and its exact owner.\n * @returns disposer that unregisters the listener.\n */',
},
{
signature: 'abstract attachSurface(name: string): () => void',
- jsDoc: '/**\n * Attach an effect-scoped surface that can read and stop tasks. {@link start}\n * refuses work while none is attached.\n * @param name - diagnostic label; duplicate names remain independent.\n * @returns disposer that detaches this surface.\n */',
+ jsDoc: '/**\n * Attach an effect-scoped surface that can read and stop tasks. It serves the\n * owners its registering context\'s scope covers, and {@link start} refuses an\n * owner no attached surface serves.\n * @param name - diagnostic label; duplicate names remain independent.\n * @returns disposer that detaches this surface.\n */',
},
],
},
{
key: 'telemetry',
- summary: 'The backend contract in its loadable form: one implementation per context — the cordis `Service` registration under the `telemetry` key throws on a duplicate, cordis\' standard behavior.',
+ summary: 'Loadable form of the backend contract: one implementation per context — the cordis `Service` registration under the `telemetry` key throws on a duplicate, cordis\' standard behavior.',
methods: [
{
signature: 'abstract emit(record: TelemetryRecord): void',
@@ -1308,7 +1308,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
},
{
signature: 'async search(request: WebSearchRequest, signal?: AbortSignal): Promise',
- jsDoc: '/**\n * Run one search through the selected provider. Resolves the provider at call\n * time with the selection rules above; throws {@link WebError} when the\n * capability cannot run. The seam enforces `request.maxResults` on the result:\n * if the provider over-returns, `sources[]` is truncated and `truncated` set.\n * @param request - the query plus result-shaping options.\n * @param signal - optional cancellation signal forwarded to the provider.\n * @returns the provider\'s results, capped to `request.maxResults`.\n */',
+ jsDoc: '/**\n * Run one search through the selected provider. Resolves the provider at call\n * time with the selection rules above; throws {@link WebError} when the\n * capability cannot run. The seam enforces `request.maxResults` on the result:\n * if the provider over-returns, `sources[]` is truncated and `truncated` set.\n * @param request - the query and optional result limit.\n * @param signal - optional cancellation signal forwarded to the provider.\n * @returns the provider\'s results, capped to `request.maxResults`.\n */',
},
{
signature: 'async fetch(request: WebFetchRequest, signal?: AbortSignal): Promise',
@@ -1630,8 +1630,8 @@ export const EVENT_API: readonly EventApiEntry[] = [
name: 'tools/code-dispatch-log',
mode: 'waterfall',
signature: '\'tools/code-dispatch-log\'(this: Scoped, dispatch: CodeDispatchLog, next: () => Promise): Promise',
- jsDoc: '/**\n * Shape the DURABLE LOG COPY of one `run_code` sub-dispatch outcome before\n * the bridge appends its `tool/code-dispatch` event. `next()` keeps the\n * content unchanged; a listener may return replacement blocks (e.g. the\n * spill policy\'s preview + locator for an oversized text result). Only the\n * logged copy is affected — the program already received the complete\n * value, and the model sees neither. A throwing listener is contained:\n * the bridge falls back to logging the unshaped content.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent\'s dispatches.\n * @param dispatch - the parent execution, sub-call identity, and the settled content to log.\n * @mode waterfall\n */',
- summary: 'Shape the DURABLE LOG COPY of one `run_code` sub-dispatch outcome before the bridge appends its `tool/code-dispatch` event.',
+ jsDoc: '/**\n * Allow a listener to replace content in the DURABLE LOG COPY of one\n * `run_code` sub-dispatch outcome before the bridge appends its\n * `tool/code-dispatch` event. `next()` keeps the\n * content unchanged; a listener may return replacement blocks (e.g. the\n * spill policy\'s preview + locator for an oversized text result). Only the\n * logged copy is affected — the program already received the complete\n * value, and the model sees neither. A throwing listener is contained:\n * the bridge falls back to logging the original settled content.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent\'s dispatches.\n * @param dispatch - the parent execution, sub-call identity, and the settled content to log.\n * @mode waterfall\n */',
+ summary: 'Allow a listener to replace content in the DURABLE LOG COPY of one `run_code` sub-dispatch outcome before the bridge appends its `tool/code-dispatch` event.',
},
{
name: 'tools/execute',
diff --git a/packages/self-modification/tool-cordis/src/sandbox.ts b/packages/self-modification/tool-cordis/src/sandbox.ts
index 6b3e20a82c..f093da3e24 100644
--- a/packages/self-modification/tool-cordis/src/sandbox.ts
+++ b/packages/self-modification/tool-cordis/src/sandbox.ts
@@ -56,8 +56,8 @@ const TIMER_REDIRECT
/**
* The callable Node APIs the sandbox deliberately disables, each mapped to the
- * cordis alternative its trap error names. Only FUNCTION-shaped globals are
- * trapped — a data-shaped global like `process` stays `undefined`, because a
+ * cordis alternative its trap error names. Only function-valued globals are
+ * trapped; a data-valued global such as `process` stays `undefined`, because a
* throwing accessor would detonate the common `typeof process` feature probe
* at resolution time.
*/
diff --git a/packages/session-query/session-query/src/index.ts b/packages/session-query/session-query/src/index.ts
index b891f5750d..919bf00c88 100644
--- a/packages/session-query/session-query/src/index.ts
+++ b/packages/session-query/session-query/src/index.ts
@@ -257,7 +257,7 @@ export abstract class SessionQueryService extends Service {
/**
* Read one session's complete current model surface from one corpus observation.
* @param sessionId - live-preferred session id to read.
- * @returns cloned header, current surface, and raw-log capture boundary.
+ * @returns cloned header, current surface, and the last sequence number included in the raw-log capture.
* @throws when source resolution fails or the session surface is invalid.
*/
async readSurface(sessionId: SessionId): Promise {
@@ -273,7 +273,7 @@ export abstract class SessionQueryService extends Service {
* Trace known ancestry and descendants from one corpus observation.
* @param sessionId - logical session id to trace.
* @param signal - optional cancellation for persistence listing.
- * @returns a complete lineage or an explicit unresolved parent boundary.
+ * @returns a complete lineage or the first parent that could not be resolved.
* @throws when corpus resolution fails, the target is absent, or its known ancestry cycles.
*/
async traceSession(sessionId: SessionId, signal?: AbortSignal): Promise {
diff --git a/packages/session/session-persistence-jsonl/src/format.ts b/packages/session/session-persistence-jsonl/src/format.ts
index fd62306b1a..809982f94d 100644
--- a/packages/session/session-persistence-jsonl/src/format.ts
+++ b/packages/session/session-persistence-jsonl/src/format.ts
@@ -211,8 +211,8 @@ export function logPath(
* `packChunks` on, delta-chunk runs pack into `text-chunks` /
* `reasoning-chunks` / `tool-call-chunks` storage rows; off writes one event
* per line, byte-identical to the pre-packing layout. Reading is layout-blind
- * either way ({@link scanLog} always decodes rows), so the switch only shapes
- * NEW bytes.
+ * either way ({@link scanLog} always decodes rows), so the switch changes only
+ * newly written bytes.
* @param events - the batch to serialize, in log order.
* @param packChunks - whether to pack delta runs into storage rows.
* @returns the batch's JSONL text; the writer adds the final newline.
diff --git a/packages/session/session-persistence/README.i18n.yaml b/packages/session/session-persistence/README.i18n.yaml
index 74a0d4c8a9..15808bb5e4 100644
--- a/packages/session/session-persistence/README.i18n.yaml
+++ b/packages/session/session-persistence/README.i18n.yaml
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/session/session-persistence/README.md
-README.md: c64826db1e9c7339f43a64ad7c13f01a2a39638e
-README.zh.md: 651d920404300fa602f77cee63b0f31aec837919
+README.md: 391548b1b896dca14cbe4f4ae55cf4180c4e0ac2
+README.zh.md: 7213e1ee71ba418ffacc3685df371dcba33588a7
diff --git a/packages/session/session-persistence/README.md b/packages/session/session-persistence/README.md
index c64826db1e..391548b1b8 100644
--- a/packages/session/session-persistence/README.md
+++ b/packages/session/session-persistence/README.md
@@ -2,7 +2,7 @@
English | [中文](README.zh.md)
-The durable session-persistence Service Definition (`ctx.sessionPersistence`). Defines WHAT a persistence backend does — durably store, reload, and list sessions — without saying HOW. Mirrors the `dsh-bash` capability-seam template ([capability seams](../../../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md)): an abstract service here, a Service provider in a sibling package, and Consumers that inject the service.
+Session persistence is a capability seam. The abstract `SessionPersistence` service (`ctx.sessionPersistence`) is its Service Definition. It requires a persistence backend to store, reload, and list sessions durably without defining the storage implementation. The seam follows the `dsh-bash` roles ([capability seams](../../../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md)): this package owns the Service Definition, a sibling package owns the Service provider, and Consumers inject the service.
The persisted unit IS the existing `SessionEvent` (event-sourced model — the log is the single source of truth), so there is no parallel "persisted message" type. Metadata that is NOT replayable conversation state (format version, cwd, lineage, seed boundary, origin, delegation depth) travels separately as `SessionHeader`, owned by `dsh-session` and re-exported here.
@@ -14,9 +14,9 @@ The persisted unit IS the existing `SessionEvent` (event-sourced model — the l
| `create(meta): Promise` | Register a new session's metadata. MAY defer the physical write until the first `append` (lazy materialization). |
| `append(id, events): Promise` | Durably persist a batch. Append-only; first event `seq` == stored next-seq after any repair; rejects non-JSON-serializable data naming the offending type. |
| `prepare(id, signal?): Promise` | Reserve the exact unpublished Session used by resume. A coordinator reuses an earlier inspection when available, commits pending recovery, and releases an unpublished reservation back to its bounded cache on disposal. |
-| `load(id): Promise<{ meta; events }>` | Return an immutable balanced logical log after supported same-version shape upgrades and commit cold recovery. A live load first flushes its snapshot and rejects while its turn is open; a cold load preserves an interrupted final turn and durably closes it with synthetic `tool/result`/`step/end?`/`turn/end {interrupted}` events. Only a torn tail fragment is dropped; committed corruption, malformed shapes, and unknown `version` reject. |
+| `load(id): Promise<{ meta; events }>` | Return an immutable balanced logical log after converting supported older records from the same format version and committing cold recovery. A live load first flushes its snapshot and rejects while its turn is open; a cold load preserves an interrupted final turn and durably closes it with synthetic `tool/result`/`step/end?`/`turn/end {interrupted}` events. Only a torn tail fragment is dropped; committed corruption, malformed records, and unknown `version` reject. |
| `inspect(id, signal?): Promise<{ meta; events }>` | Return an upgraded, validated, deeply frozen logical view without committing recovery or publishing a Session. A cold view receives in-memory synthetic recovery closers while its physical torn tail remains untouched; an already-live view is its current immutable snapshot and may contain an open turn. Coordinator-backed implementations retain the exact cold unpublished Session in a bounded LRU for later `prepare`, but discard and reload it when the stored revision changes. Same-id inspections share an in-flight read. |
-| `readFrom(id, fromSeq, signal?): Promise<{ meta; events }>` | The detached physical-suffix primitive: return valid stored events with `seq >= fromSeq` without preparation caching, truncation, closers, or coordinator state. A `fromSeq` at or past the stored end returns an empty event list; a negative or non-safe-integer `fromSeq` rejects. Seek-capable backends (SQLite) read only the suffix unless a supported old shape requires prefix context for normalization; sequential backends (JSONL) parse the whole artifact and skip forward. Intended for checkpoint consumers that fold only the tail past a watermark. |
+| `readFrom(id, fromSeq, signal?): Promise<{ meta; events }>` | Return valid stored events with `seq >= fromSeq` without preparation caching, truncation, closers, or coordinator state. A `fromSeq` at or past the stored end returns an empty event list; a negative or non-safe-integer `fromSeq` rejects. Seek-capable backends (SQLite) read only the suffix unless converting a supported older record requires earlier records; sequential backends (JSONL) parse the whole artifact and skip forward. Intended for checkpoint consumers that apply only events after a stored sequence number. |
| `list(signal?): Promise` | Lightweight listing from metadata, no full-log parse. The optional signal cancels backend listing work. A zero-event lazily-materialized session is absent from `list`. |
| `listSnapshots(signal?): Promise` | Lightweight metadata plus an opaque branded per-log revision, without loading event logs. A revision stays equal while that log and its backing store are unchanged, changes after append or mutating load repair, and cannot collide solely because two stores use the same local counter. The optional signal requests cancellation of backend discovery work; first-party backends settle any started listing work before rejecting so an awaited call is quiescent. |
@@ -35,7 +35,7 @@ Each `session/event` copies its event into the session controller. The first pen
Crash repair is cold-only. For a live id, `load(id)` snapshots the authoritative in-memory log, waits for that snapshot to become durable, and returns it only when balanced; an open live turn rejects instead of receiving synthetic interruption closers. For a cold id, inspection reads, validates, freezes, and constructs one unpublished Session; repeated inspection reuses that object graph only while its source revision remains current. `prepare(id)` performs the same check before repair, reserves the exact Session, commits any pending torn-tail/interrupted-turn repair, and returns it for publication. HMR adoption reads through `loadStored`, applies the coordinator's cwd check, and never closes the active turn.
-Backend reads normalize the exact supported same-version shapes before current-shape validation. Pre-identity messages receive the deterministic id `legacy-message::`; a tool-result content replacement inherits its target's imported id. A pre-react-loop `turn/start` loses its obsolete trigger, a removed `steering/message` becomes the same identified `user/message`, and an older `turn/end` maps its terminal reason without inventing a caller that the old record did not name. The coordinator uses the same normalized view for `load`, `inspect`, `readFrom`, ownerless-state claims, and HMR prefix adoption. Storage remains append-only: reads do not rewrite old records, and later appends use the current shape. These are narrow import exceptions from the [pre-identity message](../../../.agents/notes/implemented/bug-fix/2026-07-28-load-pre-identity-session-messages.md) and [pre-react-loop session](../../../.agents/notes/implemented/bug-fix/2026-08-04-load-pre-react-loop-sessions.md) decisions, not a general v0 migration promise.
+Backend reads convert the exact supported older records from the same format version before validating current records. Pre-identity messages receive the deterministic id `legacy-message::`; a tool-result content replacement inherits its target's imported id. A pre-react-loop `turn/start` loses its obsolete trigger, a removed `steering/message` becomes the same identified `user/message`, and an older `turn/end` maps its terminal reason without inventing a caller that the old record did not name. The coordinator uses the same converted view for `load`, `inspect`, `readFrom`, ownerless-state claims, and HMR prefix adoption. Storage remains append-only: reads do not rewrite old records, and later appends use the current format. These are narrow import exceptions from the [pre-identity message](../../../.agents/notes/implemented/bug-fix/2026-07-28-load-pre-identity-session-messages.md) and [pre-react-loop session](../../../.agents/notes/implemented/bug-fix/2026-08-04-load-pre-react-loop-sessions.md) decisions, not a general v0 migration promise.
When a live session emits `session/disposed`, the coordinator waits for its controller, serializes a final drain, then releases state owned by that exact `Session` object. Failed retirement leaves the controller in the live-session map, so backend teardown can retry it. Backend teardown stops event admission first, flushes every remaining controller, awaits per-id operations, and only then closes the storage handle.
diff --git a/packages/session/session-persistence/README.zh.md b/packages/session/session-persistence/README.zh.md
index 651d920404..7213e1ee71 100644
--- a/packages/session/session-persistence/README.zh.md
+++ b/packages/session/session-persistence/README.zh.md
@@ -2,7 +2,7 @@
[English](README.md) | 中文
-这是用于持久保存会话的 Service Definition(`ctx.sessionPersistence`)。它定义持久化后端做什么:持久存储、重新加载和列出会话,而不规定如何实现。它与 `dsh-bash` 能力 seam 模板一致(见[能力 seam](../../../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md)):本包提供抽象服务,同级包提供 Service provider,Consumer 注入服务。
+会话持久化是一项能力 seam。抽象的 `SessionPersistence` 服务(`ctx.sessionPersistence`)是其 Service Definition。它要求持久化后端持久存储、重新加载和列出会话,但不规定具体存储实现。该 seam 采用与 `dsh-bash` 相同的角色划分(见[能力 seam](../../../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md)):本包负责 Service Definition,同级包负责 Service provider,Consumer 注入该服务。
持久化单元就是现有 `SessionEvent`(事件溯源模型:日志是唯一真源),因此不存在另一套并行的「持久消息」类型。不属于可回放对话状态的元数据(格式版本、cwd、血缘、种子边界、origin、委托深度)作为 `SessionHeader` 单独传输,该类型归 `dsh-session` 所有,并在此重新导出。
@@ -14,9 +14,9 @@
| `create(meta): Promise` | 注册新会话元数据。可以将物理写入延迟到第一次 `append`(延迟实体化)。 |
| `append(id, events): Promise` | 持久保存一个批次。仅追加;任何修复后,第一个事件 `seq` == 已存储 next-seq;非 JSON 可序列化数据会被拒绝,并命名违规类型。 |
| `prepare(id, signal?): Promise` | 预留恢复所使用的那个未发布 Session。协调器会尽可能复用之前的检查结果、提交待处理恢复,并在 dispose 时将未发布 reservation 释放回有界缓存。 |
-| `load(id): Promise<{ meta; events }>` | 在升级受支持的同版本形状后返回不可变、平衡的逻辑日志,并提交冷恢复。实时 load 先 flush 其快照,并在轮次开放时拒绝;冷 load 保留中断的最终轮次,并用合成 `tool/result`/`step/end?`/`turn/end {interrupted}` 事件持久关闭它。只丢弃撕裂尾部碎片;已提交损坏、格式错误的形状和未知 `version` 会被拒绝。 |
+| `load(id): Promise<{ meta; events }>` | 转换同一格式版本中受支持的旧记录后,返回不可变、平衡的逻辑日志,并提交冷恢复。实时 load 先 flush 其快照,并在轮次开放时拒绝;冷 load 保留中断的最终轮次,并用合成 `tool/result`/`step/end?`/`turn/end {interrupted}` 事件持久关闭它。只丢弃撕裂尾部碎片;已提交损坏、格式错误的记录和未知 `version` 会被拒绝。 |
| `inspect(id, signal?): Promise<{ meta; events }>` | 返回已经升级、验证和深度冻结的逻辑视图,但不提交恢复或发布 Session。冷视图会获得仅存在于内存的合成恢复 closer,物理撕裂尾部保持不变;实时状态下的视图则是当前不可变快照,可能包含开放的轮次。基于协调器的实现会在有界 LRU 中保留该冷状态下未发布的 Session 本身,供后续 `prepare` 使用,但已存储修订值变化后会丢弃并重新读取。同 id 检查共享进行中的读取。 |
-| `readFrom(id, fromSeq, signal?): Promise<{ meta; events }>` | 脱离的物理后缀原语:返回 `seq >= fromSeq` 的有效已存储事件,不进入 preparation 缓存、不截断、不合成 closer,也不发布协调器状态。`fromSeq` 达到或超过已存储末尾时返回空事件列表;负数或非安全整数 `fromSeq` 会被拒绝。可寻址后端(SQLite)只读后缀,除非受支持的旧形状需要前缀上下文才能完成规范化;顺序后端(JSONL)解析整个产物并向前跳过。用于只续折水位之后尾部的 checkpoint 消费方。 |
+| `readFrom(id, fromSeq, signal?): Promise<{ meta; events }>` | 返回 `seq >= fromSeq` 的有效已存储事件,不进入 preparation 缓存、不截断、不合成 closer,也不发布协调器状态。`fromSeq` 达到或超过已存储末尾时返回空事件列表;负数或非安全整数 `fromSeq` 会被拒绝。可寻址后端(SQLite)只读后缀,除非转换受支持的旧记录需要读取更早的记录;顺序后端(JSONL)解析整个产物并向前跳过。供 checkpoint 消费方只应用已存序号之后的事件。 |
| `list(signal?): Promise` | 从元数据轻量列出,不解析完整日志。可选信号取消后端列表工作。零事件延迟实体化会话不在 `list` 中。 |
| `listSnapshots(signal?): Promise` | 返回轻量元数据和每份日志一个不透明、带品牌类型的修订值,不加载事件日志。日志及其后端存储不变时,修订保持相等;append 或变更性 load 修复后会改变;不会仅因两个存储使用相同本地计数器而冲突。可选信号请求取消后端发现工作;第一方后端会先等待所有已启动的列出工作结束,再予以拒绝,因此调用返回拒绝时,相关工作已完全停稳。 |
@@ -35,7 +35,7 @@
崩溃修复只适用于冷状态。对于实时 id,`load(id)` 为权威内存日志制作快照,等待该快照持久,并只在平衡时返回;开放实时轮次会被拒绝,而不会收到合成中断 closer。对于冷 id,检查只读取、验证、冻结并构造一次未发布 Session;只有来源修订值仍然是当前值时,重复检查才会复用该对象图。`prepare(id)` 在修复前执行相同校验,预留该 Session 本身,提交任何待处理的撕裂尾部或中断轮次修复,并将其返回用于发布。HMR 接管通过 `loadStored` 读取,应用协调器 cwd 检查,并绝不关闭活动轮次。
-后端读取会在当前形状验证前,规范化明确受支持的同版本形状。消息标识机制引入前的消息会获得确定性的 id `legacy-message::`;工具结果的内容替换会继承其目标导入后的 id。react-loop 引入前的 `turn/start` 会移除过时的 trigger,已移除的 steering(中途引导)事件 `steering/message` 会转换为同一条带标识的 `user/message`;旧版 `turn/end` 会在不虚构旧记录中未命名调用方的前提下映射终止原因。协调器对 `load`、`inspect`、`readFrom`、无所有者状态的认领和 HMR 前缀接管使用同一份规范化视图。存储仍然仅追加:读取不会重写旧记录,此后追加的事件使用当前形状。这些是[消息标识机制引入前的消息](../../../.agents/notes/implemented/bug-fix/2026-07-28-load-pre-identity-session-messages.md)与 [react-loop 引入前会话](../../../.agents/notes/implemented/bug-fix/2026-08-04-load-pre-react-loop-sessions.md)决策所规定的范围受限的导入例外,并不构成通用的 v0 迁移承诺。
+后端读取会在验证当前记录前,转换同一格式版本中明确受支持的旧记录。消息标识机制引入前的消息会获得确定性的 id `legacy-message::`;工具结果的内容替换会继承其目标导入后的 id。react-loop 引入前的 `turn/start` 会移除过时的 trigger,已移除的 steering(中途引导)事件 `steering/message` 会转换为同一条带标识的 `user/message`;旧版 `turn/end` 会映射终止原因,但不会虚构旧记录中没有记载的调用方。协调器对 `load`、`inspect`、`readFrom`、无所有者状态的认领和 HMR 前缀接管使用同一份转换后视图。存储仍然仅追加:读取不会重写旧记录,此后追加的事件使用当前格式。这些是[消息标识机制引入前的消息](../../../.agents/notes/implemented/bug-fix/2026-07-28-load-pre-identity-session-messages.md)与 [react-loop 引入前会话](../../../.agents/notes/implemented/bug-fix/2026-08-04-load-pre-react-loop-sessions.md)决策所规定的范围受限的导入例外,并不构成通用的 v0 迁移承诺。
实时会话发出 `session/disposed` 时,协调器等待其 controller,串行化最终 drain,然后释放该精确 `Session` 对象拥有的状态。失败退役会将 controller 保留在实时会话 map 中,使后端拆卸可重试。后端拆卸先停止事件接纳,flush 每个剩余 controller,等待每 id 操作,最后才关闭存储句柄。
diff --git a/packages/session/session-projection/src/index.ts b/packages/session/session-projection/src/index.ts
index 1d0364d1f2..154a5ca2fa 100644
--- a/packages/session/session-projection/src/index.ts
+++ b/packages/session/session-projection/src/index.ts
@@ -65,7 +65,7 @@ export interface ProjectionDefinition {
*/
view(state: S): SessionProjectionMap[K]
/**
- * Persisted-cache invalidation anchor: bump whenever the state shape or the
+ * Persisted-cache invalidation version: bump whenever the serialized state fields or the
* fold semantics change, so persisted `(sessionId, key, ver, seq, val)`
* rows from an older unit are discarded instead of being forward-applied
* into garbage. Non-negative integer.
@@ -188,7 +188,7 @@ export class SessionProjectionRegistry extends Service {
* context's fiber: disposing the fiber (or calling the returned disposer)
* removes the key — and the unit's cached cells — from subsequent drives
* and snapshots.
- * @param definition - key, boundary schema, pure unit functions, and stateVersion.
+ * @param definition - key, state schema, pure unit functions, and stateVersion.
* @returns the exact disposer that unregisters this unit.
*/
register(definition: ProjectionDefinition): () => void {
diff --git a/packages/session/session-telemetry-otel/package.json b/packages/session/session-telemetry-otel/package.json
index 5d941e3fe1..2af0b5294e 100644
--- a/packages/session/session-telemetry-otel/package.json
+++ b/packages/session/session-telemetry-otel/package.json
@@ -35,23 +35,21 @@
},
"peerDependencies": {
"@deepseek-ai/dsh-command-feedback": "^0.0.1",
- "@deepseek-ai/dsh-brand": "^0.0.1",
"@deepseek-ai/dsh-invariants": "^0.0.1",
"@deepseek-ai/dsh-llm": "^0.0.1",
- "@deepseek-ai/dsh-paths": "^0.0.1",
"@deepseek-ai/dsh-session": "^0.0.1",
"@deepseek-ai/dsh-session-telemetry": "^0.0.1",
+ "@deepseek-ai/dsh-user-id": "^0.0.1",
"cordis": "^4.0.0-rc.7"
},
"devDependencies": {
"@cordisjs/plugin-loader": "workspace:^",
"@deepseek-ai/dsh-command-feedback": "workspace:^",
- "@deepseek-ai/dsh-brand": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
- "@deepseek-ai/dsh-paths": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"@deepseek-ai/dsh-session-telemetry": "workspace:^",
+ "@deepseek-ai/dsh-user-id": "workspace:^",
"cordis": "^4.0.0-rc.7"
}
}
diff --git a/packages/session/session-telemetry-otel/src/index.ts b/packages/session/session-telemetry-otel/src/index.ts
index b128f65978..50776f7d3f 100644
--- a/packages/session/session-telemetry-otel/src/index.ts
+++ b/packages/session/session-telemetry-otel/src/index.ts
@@ -3,9 +3,8 @@
*
* Composes the OTel JS SDK as-is — a `LoggerProvider` with a
* `BatchLogRecordProcessor` and an OTLP/HTTP log exporter — and maps each
- * record handed over by the capture coordinator onto `logger.emit()`. Per the Service Definition's
- * boundary axiom, everything downstream of that call (batching, retry,
- * queueing, loss policy) is the SDK's documented behavior, configured
+ * record handed over by the capture coordinator onto `logger.emit()`. After that call,
+ * batching, retry, queueing, and loss policy use the SDK's documented behavior, configured
* verbatim through the `exporter`/`processor` passthroughs. This package owns
* capture mode and an outer shutdown deadline: the SDK's export timeout does
* not bound its preceding `forceFlush()` wait.
@@ -25,7 +24,7 @@ import {
type TelemetrySeverity,
} from '@deepseek-ai/dsh-session-telemetry'
import { APP_IDENTITY } from '@deepseek-ai/dsh-llm'
-import { getOrCreateAnonymousUserId } from './user-id.ts'
+import { getOrCreateAnonymousUserId } from '@deepseek-ai/dsh-user-id'
import {
BatchLogRecordProcessor,
LoggerProvider,
@@ -73,7 +72,7 @@ function assertNever(value: never): never {
}
/**
- * Plugin configuration: one sharing policy, two verbatim SDK option shapes,
+ * Plugin configuration: one sharing policy, two verbatim SDK option objects,
* and one DSH-owned shutdown bound. Uploading modes validate their endpoint
* and shutdown deadline at plugin load; `DISABLED` reads neither.
*/
@@ -101,11 +100,10 @@ export interface Config {
/**
* Schemastery validator for {@link Config}; cordis runs it before the plugin
- * starts. Shape-level only — load-bearing value checks live in the constructor
- * so their errors name the fields. Both SDK slots are opaque passthroughs:
- * the SDK owns their shapes and validates its own options;
- * re-declaring them field-by-field here would violate the boundary axiom
- * (and silently drop every field not re-declared).
+ * starts. It checks only the top-level fields; value checks live in the constructor
+ * so their errors name the fields. Both SDK option objects pass through unchanged:
+ * the SDK defines and validates their fields. Re-declaring them here would
+ * silently drop every field this plugin did not repeat.
*/
export const Config: z = z.object({
mode: z.union(Object.values(TelemetryMode)).default(DEFAULT_TELEMETRY_MODE),
diff --git a/packages/session/session-telemetry-otel/tests/otel.spec.ts b/packages/session/session-telemetry-otel/tests/otel.spec.ts
index 6139b5c505..511c95c0d8 100644
--- a/packages/session/session-telemetry-otel/tests/otel.spec.ts
+++ b/packages/session/session-telemetry-otel/tests/otel.spec.ts
@@ -13,7 +13,7 @@ import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { gunzipSync } from 'node:zlib'
import { Context } from 'cordis'
-import { getOrCreateAnonymousUserId } from '../src/user-id.ts'
+import { getOrCreateAnonymousUserId } from '@deepseek-ai/dsh-user-id'
import Loader from '@cordisjs/plugin-loader'
import { recordFeedback } from '@deepseek-ai/dsh-command-feedback'
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
diff --git a/packages/session/session-telemetry-otel/tsconfig.json b/packages/session/session-telemetry-otel/tsconfig.json
index 60aee08eda..421742a62d 100644
--- a/packages/session/session-telemetry-otel/tsconfig.json
+++ b/packages/session/session-telemetry-otel/tsconfig.json
@@ -30,10 +30,7 @@
"path": "../session-telemetry"
},
{
- "path": "../../util/brand"
- },
- {
- "path": "../../util/paths"
+ "path": "../user-id"
},
{
"path": "../../support/invariants"
diff --git a/packages/session/session-telemetry/README.i18n.yaml b/packages/session/session-telemetry/README.i18n.yaml
index c6c4b9eff9..3d4650361f 100644
--- a/packages/session/session-telemetry/README.i18n.yaml
+++ b/packages/session/session-telemetry/README.i18n.yaml
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/session/session-telemetry/README.md
-README.md: 506f0f5bcb03a54f09805ed0f866a396e3cf0334
-README.zh.md: b5e47c8832452ece281dcdcfac007d6082813583
+README.md: 827554dd53a81eab5a5fd7f145df3f835db9c173
+README.zh.md: a350ea5935a2143cb0f876eeb1eb0520ffee5c53
diff --git a/packages/session/session-telemetry/README.md b/packages/session/session-telemetry/README.md
index 506f0f5bcb..827554dd53 100644
--- a/packages/session/session-telemetry/README.md
+++ b/packages/session/session-telemetry/README.md
@@ -2,11 +2,11 @@
English | [中文](README.zh.md)
-The telemetry Service Definition and capture coordinator sit behind a backend contract any reporting SDK satisfies with zero bending. Capture can follow live session events or replay a canonical session-log prefix on demand. The boundary axiom that shapes everything here: **this package's aspect ends at `emit()`** — batching, retry, queueing, and loss policy belong to the backend's SDK and are neither specified nor wrapped. Rationale and rejected alternatives: [the revival Agent Note](../../../.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.md), [feedback-gated delivery](../../../.agents/notes/implemented/feature/2026-08-05-feedback-gated-session-telemetry.md), and [buffer-free feedback replay](../../../.agents/notes/implemented/simplification/2026-08-06-buffer-free-feedback-telemetry.md).
+The telemetry Service Definition declares the `TelemetryBackend` contract, and its capture coordinator passes session records to any reporting SDK backend that implements it. Capture can follow live session events or replay a canonical session-log prefix on demand. This package stops after it calls `emit()`: batching, retry, queueing, and loss policy belong to the backend's SDK and are neither specified nor wrapped. Rationale and rejected alternatives: [the revival Agent Note](../../../.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.md), [feedback-gated delivery](../../../.agents/notes/implemented/feature/2026-08-05-feedback-gated-session-telemetry.md), and [buffer-free feedback replay](../../../.agents/notes/implemented/simplification/2026-08-06-buffer-free-feedback-telemetry.md).
## The backend contract
-`TelemetryBackend` is three members: `emit(record)` (MUST be a non-blocking enqueue — it runs synchronously on the `session/event` hot path or during an explicit canonical-log replay), optional `flush()` (a turn-boundary hint, fire-and-forget; most backends leave it unimplemented and let their SDK's batching cadence govern export timing — an implementer owns the interaction between concurrent flushes and `shutdown()`'s drain), and `shutdown()` (the lifecycle forward: drain-and-quiesce, awaited at dispose). `Telemetry` is its service-registered form under the `telemetry` context key — one implementation per context, duplicate load throws. A backend composes `TelemetryCoordinator` with `live` capture or `on-demand` capture and calls `captureSession(session, throughSeq?)` at its owning trigger.
+`TelemetryBackend` has three members: `emit(record)` MUST enqueue without blocking because it runs synchronously during `session/event` or explicit canonical-log replay; optional `flush()` is a fire-and-forget hint after a turn ends, and most backends omit it and use their SDK's normal batching schedule; `shutdown()` drains queued records and resolves when the SDK stops, and disposal awaits it. An implementation that provides `flush()` must order concurrent flushes with the final `shutdown()` drain. `Telemetry` registers this API under the `telemetry` context key; each context accepts one implementation, and a duplicate load throws. A backend constructs `TelemetryCoordinator` with `live` or `on-demand` capture and calls `captureSession(session, throughSeq?)` at its chosen trigger.
## Capture points
diff --git a/packages/session/session-telemetry/README.zh.md b/packages/session/session-telemetry/README.zh.md
index b5e47c8832..a350ea5935 100644
--- a/packages/session/session-telemetry/README.zh.md
+++ b/packages/session/session-telemetry/README.zh.md
@@ -2,11 +2,11 @@
[English](README.md) | 中文
-遥测(telemetry)Service Definition 与捕获协调器位于一个后端约定之后,任何上报 SDK 都无需变形即可满足该约定。捕获侧可跟随实时会话事件,也可按需回放权威会话日志前缀。塑造本包(package)一切设计的边界公理:**本包的职责止于 `emit()`**。批处理、重试、排队与丢失策略都属于后端自身的 SDK,本包既不为其立规,也不做包装。设计依据与被否决的替代方案见[复活 Agent Note(agent 决策记录)](../../../.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.md)、[反馈门控投递](../../../.agents/notes/implemented/feature/2026-08-05-feedback-gated-session-telemetry.md)与[无缓冲反馈回放](../../../.agents/notes/implemented/simplification/2026-08-06-buffer-free-feedback-telemetry.md)。
+遥测(telemetry)Service Definition 声明 `TelemetryBackend` 后端约定,捕获协调器把会话记录传给实现该约定的任意上报 SDK 后端。捕获侧可跟随实时会话事件,也可按需回放权威会话日志前缀。本包调用 `emit()` 后就停止处理:批处理、重试、排队与丢失策略都属于后端自身的 SDK,本包既不规定也不包装。设计依据与被否决的替代方案见[复活 Agent Note](../../../.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.md)、[反馈门控投递](../../../.agents/notes/implemented/feature/2026-08-05-feedback-gated-session-telemetry.md)与[无缓冲反馈回放](../../../.agents/notes/implemented/simplification/2026-08-06-buffer-free-feedback-telemetry.md)。
## 后端约定
-`TelemetryBackend` 只有三个成员:`emit(record)`(必须是非阻塞入队;它在 `session/event` 热路径或显式权威日志回放期间同步执行)、可选的 `flush()`(轮次边界提示,触发后不等待结果;多数后端不实现它,而由其 SDK 的批处理节奏决定导出时机;并发 flush 与 `shutdown()` 的排空之间的交互由实现方自行负责)、以及 `shutdown()`(生命周期转发点:排空并完全停稳,在 dispose(资源释放)时被等待)。`Telemetry` 是它注册在 `telemetry` 上下文键下的服务形态:每个上下文只允许一个实现,重复加载会抛出异常。后端以 `live` 或 `on-demand` 模式组合 `TelemetryCoordinator`,并在自身所属的触发器中调用 `captureSession(session, throughSeq?)`。
+`TelemetryBackend` 有三个成员:`emit(record)` 必须入队且不能阻塞,因为它会在 `session/event` 或显式权威日志回放期间同步执行;可选的 `flush()` 是轮次结束后的提示,调用方不等待结果,多数后端省略它并使用 SDK 的常规批处理计划;`shutdown()` 排空已入队记录,并在 SDK 停止后结束,dispose(资源释放)会等待它。提供 `flush()` 的实现必须安排并发 flush 与 `shutdown()` 最终排空的先后顺序。`Telemetry` 将此 API 注册在 `telemetry` 上下文键下:每个上下文只允许一个实现,重复加载会抛出异常。后端以 `live` 或 `on-demand` 捕获构造 `TelemetryCoordinator`,并在自己选择的触发器中调用 `captureSession(session, throughSeq?)`。
## 捕获点
diff --git a/packages/session/session-telemetry/src/index.ts b/packages/session/session-telemetry/src/index.ts
index 977527aace..7ddd85fe8e 100644
--- a/packages/session/session-telemetry/src/index.ts
+++ b/packages/session/session-telemetry/src/index.ts
@@ -87,9 +87,8 @@ export interface TelemetryRecord {
}
/**
- * The backend contract the coordinator hands records to — the minimum any
- * reporting SDK satisfies with zero bending. {@link Telemetry} is its
- * service-registered form; tests compose the coordinator with a bare
+ * The minimum backend contract the coordinator requires. {@link Telemetry} is
+ * its service-registered form; tests compose the coordinator with a bare
* implementation of this interface.
*/
export interface TelemetryBackend {
@@ -104,8 +103,8 @@ export interface TelemetryBackend {
*/
emit(record: TelemetryRecord): void
/**
- * Optional hint that a natural boundary (turn end) passed — a backend may
- * forward it to its SDK's flush so records land at turn boundaries. Called
+ * Optional hint that a turn ended. A backend may forward it to its SDK's
+ * flush so records are exported after each turn. Called
* fire-and-forget; implementations must not block and must not throw
* meaningfully (the coordinator contains exceptions). Most backends should
* leave this unimplemented and let their SDK's own batching cadence govern
@@ -132,7 +131,7 @@ export interface TelemetryBackend {
}
/**
- * The backend contract in its loadable form: one implementation per context —
+ * Loadable form of the backend contract: one implementation per context —
* the cordis `Service` registration under the `telemetry` key throws on a
* duplicate, cordis' standard behavior. A backend composes a
* {@link TelemetryCoordinator} in its constructor to install the capture side.
diff --git a/packages/session/user-id/README.i18n.yaml b/packages/session/user-id/README.i18n.yaml
new file mode 100644
index 0000000000..5d58bba70e
--- /dev/null
+++ b/packages/session/user-id/README.i18n.yaml
@@ -0,0 +1,6 @@
+# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
+# side as of the last confirmed-consistent state. Both languages carry equal authority;
+# after editing either side, bring the other along and re-record with:
+# pnpm run verify-translation-pairing --write packages/session/user-id/README.md
+README.md: 31a72f5e7b58b90b165b16374c2301389cbe2ca0
+README.zh.md: 013097b3038c43ff740660ef9159ca2b13f7b743
diff --git a/packages/session/user-id/README.md b/packages/session/user-id/README.md
new file mode 100644
index 0000000000..31a72f5e7b
--- /dev/null
+++ b/packages/session/user-id/README.md
@@ -0,0 +1,29 @@
+# @deepseek-ai/dsh-user-id
+
+English | [中文](README.zh.md)
+
+Shared anonymous identity for session telemetry and direct feedback acknowledgement. `getOrCreateAnonymousUserId()` returns a random UUID v4 scoped to one harness home, persisted as the bare line `$DSH_HOME/.userid` (`~/.dsh/.userid` when `DSH_HOME` is unset). The OpenTelemetry backend reports it as Resource `user.id`; `/feedback` includes the same value in its acknowledgement so an operator can correlate a submitted session and user with exported telemetry.
+
+The identity is never derived from the hostname, network address, git remote, or another identifying source. Deleting `.userid` resets the identity on the next process launch. Separate harness homes have separate identities, and the dsh-sdk launcher telemetry intentionally keeps its own unrelated store.
+
+## Storage contract
+
+Reads and writes are synchronous because both boot-time telemetry construction and direct command execution need one API. The result is memoized per resolved file path for the process lifetime. A first writer uses exclusive creation and a concurrent loser adopts the persisted winner; a corrupt file is replaced. Persistence is best-effort, so an unwritable home still receives a process-local UUID rather than blocking telemetry or feedback.
+
+## Composition
+
+This package is a shared library, not a Cordis plugin. Consumers import `getOrCreateAnonymousUserId()` directly. Its invariant companion is intentionally empty because the package owns no event stream or public mutable relation that can be checked without creating the identity as a side effect.
+
+## Model Experience
+
+None, as the identifier is used only in telemetry metadata and a direct human command response; it never enters a model request.
+
+#### KV Cache effect
+
+None; this package never contributes to a model request.
+
+## Known Limitations and Deferred Work
+
+- **No recovery after deletion** — loss mints a new anonymous identity by design; recovery would require stable derivation material that weakens anonymity.
+- **Best-effort concurrency** — a reader landing in the narrow interval between a concurrent process's exclusive create and completed write can use a different in-memory UUID for that run; later launches converge on the persisted value.
+- **No cross-home identity** — different `$DSH_HOME` values cannot be correlated, and this package does not unify the separate dsh-sdk launcher telemetry identity.
diff --git a/packages/session/user-id/README.zh.md b/packages/session/user-id/README.zh.md
new file mode 100644
index 0000000000..013097b303
--- /dev/null
+++ b/packages/session/user-id/README.zh.md
@@ -0,0 +1,29 @@
+# @deepseek-ai/dsh-user-id
+
+[English](README.md) | 中文
+
+会话遥测与直接反馈确认共用的匿名身份。`getOrCreateAnonymousUserId()` 返回一个限定于单个 harness home 的随机 UUID v4,并以裸行形式持久化到 `$DSH_HOME/.userid`(未设置 `DSH_HOME` 时为 `~/.dsh/.userid`)。OpenTelemetry 后端将其作为 Resource 的 `user.id` 上报;`/feedback` 在确认文本中包含同一个值,以便运维人员将所报告的会话和用户与导出的遥测相关联。
+
+该身份绝不从 hostname、网络地址、git remote 或其他可用于识别身份的来源派生。删除 `.userid` 后,下次启动进程时会重置身份。不同 harness home 拥有不同身份,dsh-sdk launcher telemetry 则刻意使用与此无关的独立存储。
+
+## 存储契约
+
+读写采用同步方式,因为启动时构造遥测和直接执行命令都需要使用同一个 API。结果在进程生命周期内按解析后的文件路径缓存。首个写入方采用独占创建;并发竞争中失败的一方会采用已持久化的胜出值。损坏的文件会被替换。持久化采用 best-effort,因此即使 home 不可写,系统仍会返回进程本地 UUID,而不会阻塞遥测或反馈。
+
+## 组合
+
+本包是共享库,并非 Cordis 插件。消费方直接导入 `getOrCreateAnonymousUserId()`。其不变式伴生插件刻意留空,因为本包既不拥有事件流,也不拥有任何可以在不触发创建身份这一副作用的情况下检查的公开可变关系。
+
+## 模型体验
+
+无,因为该标识符只用于遥测元数据和面向用户的直接命令响应;它绝不会进入模型请求。
+
+#### KV Cache 影响
+
+无;本包绝不会向模型请求贡献任何内容。
+
+## 已知限制与暂缓工作
+
+- **删除后无法恢复**:身份丢失后会按设计生成新的匿名身份;若要恢复身份,就需要稳定的派生材料,这会削弱匿名性。
+- **Best-effort 并发**:如果读取方恰好落在并发进程完成独占创建但尚未写完的狭窄时间窗内,本次运行可能使用不同的内存 UUID;后续启动会收敛到已持久化的值。
+- **没有跨 home 身份**:不同 `$DSH_HOME` 值之间无法关联,本包也不会统一 dsh-sdk launcher telemetry 的独立身份。
diff --git a/packages/session/user-id/package.json b/packages/session/user-id/package.json
new file mode 100644
index 0000000000..2a09c73b0e
--- /dev/null
+++ b/packages/session/user-id/package.json
@@ -0,0 +1,39 @@
+{
+ "name": "@deepseek-ai/dsh-user-id",
+ "description": "Shared anonymous user identity for DeepSeek Harness telemetry and feedback correlation",
+ "version": "0.0.1",
+ "private": true,
+ "type": "module",
+ "main": "lib/index.js",
+ "types": "lib/types/index.d.ts",
+ "exports": {
+ ".": {
+ "types": "./lib/types/index.d.ts",
+ "default": "./lib/index.js"
+ },
+ "./invariant": {
+ "types": "./lib/types/invariant.d.ts",
+ "default": "./lib/invariant.js"
+ },
+ "./src/*": "./src/*",
+ "./package.json": "./package.json"
+ },
+ "files": [
+ "lib/index.js",
+ "lib/invariant.js",
+ "lib/types/**/*.d.ts"
+ ],
+ "license": "BSD-3-Clause",
+ "peerDependencies": {
+ "@deepseek-ai/dsh-brand": "^0.0.1",
+ "@deepseek-ai/dsh-invariants": "^0.0.1",
+ "@deepseek-ai/dsh-paths": "^0.0.1",
+ "cordis": "^4.0.0-rc.7"
+ },
+ "devDependencies": {
+ "@deepseek-ai/dsh-brand": "workspace:^",
+ "@deepseek-ai/dsh-invariants": "workspace:^",
+ "@deepseek-ai/dsh-paths": "workspace:^",
+ "cordis": "^4.0.0-rc.7"
+ }
+}
diff --git a/packages/session/session-telemetry-otel/src/user-id.ts b/packages/session/user-id/src/index.ts
similarity index 79%
rename from packages/session/session-telemetry-otel/src/user-id.ts
rename to packages/session/user-id/src/index.ts
index 0a2cf95a6a..ca314e945a 100644
--- a/packages/session/session-telemetry-otel/src/user-id.ts
+++ b/packages/session/user-id/src/index.ts
@@ -1,22 +1,20 @@
/**
- * Per-harness-home anonymous user id for the OTel Resource.
+ * Per-harness-home anonymous user id shared by telemetry and feedback.
*
* The id is a random UUID persisted as a bare line in `.userid` inside the
* harness home resolved by {@link resolveDshHome} (`$DSH_HOME` > `~/.dsh`),
* and never derived from the hostname, network address, git remote, or any
- * other identifying source — a derived id would make "anonymous" a fiction.
- * The id is scoped to the harness home, not the machine: every process
- * sharing one `$DSH_HOME` reports the same id, and deleting the file simply
- * mints a fresh identity on the next launch (loss is accepted by design).
- * This identity belongs to the OTel feed alone; the dsh-sdk launcher
- * telemetry keeps its own separate store.
+ * other identifying source. It is scoped to the harness home, not the
+ * machine: every process sharing one `$DSH_HOME` reports the same id, and
+ * deleting the file mints a fresh identity on the next launch. The dsh-sdk
+ * launcher telemetry keeps its own separate store.
*
- * Reads and writes are synchronous so the backend constructor can call this
- * on its boot path, and the result is memoized per resolved file path: one
- * process touches the disk once, and a file deleted mid-run keeps the
- * process's id until the next launch.
+ * Reads and writes are synchronous so boot-time and command consumers can
+ * use one API. The result is memoized per resolved file path: one process
+ * touches the disk once, and a file deleted mid-run keeps the process's id
+ * until the next launch.
*
- * @module @deepseek-ai/dsh-session-telemetry-otel/user-id
+ * @module @deepseek-ai/dsh-user-id
*/
import { randomUUID } from 'node:crypto'
@@ -64,8 +62,8 @@ function readPersistedId(file: string): AnonymousUserId | undefined {
* narrow create-to-write window can still yield two per-process ids for that
* run; the next launch converges on the persisted one.) Persistence is
* best-effort — a write failure (read-only home) still returns a usable id
- * for the current run so telemetry is never blocked.
- * @param options - Home-location and UUID-generation hooks.
+ * for the current run so feedback and telemetry are never blocked.
+ * @param options - home-location and UUID-generation seams.
* @returns the stable per-harness-home anonymous user id.
*/
export function getOrCreateAnonymousUserId(options: AnonymousUserIdOptions = {}): AnonymousUserId {
diff --git a/packages/session/user-id/src/invariant.ts b/packages/session/user-id/src/invariant.ts
new file mode 100644
index 0000000000..b649e23619
--- /dev/null
+++ b/packages/session/user-id/src/invariant.ts
@@ -0,0 +1,31 @@
+/**
+ * Package-owned invariant companion for `@deepseek-ai/dsh-user-id`.
+ * @module @deepseek-ai/dsh-user-id/invariant
+ */
+
+/* jscpd:ignore-start */
+import type { Context } from 'cordis'
+import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
+
+const PACKAGE_NAME = '@deepseek-ai/dsh-user-id'
+
+/** Cordis companion plugin name. */
+export const name = 'user-id-invariant'
+/** Service required before the companion can reserve package ownership. */
+export const inject = ['invariants']
+
+/**
+ * No runtime invariant: the API owns one private memo and one best-effort
+ * file, with no independent event stream or public mutable relation for a
+ * companion to compare without creating the identity as a side effect.
+ */
+const install: InvariantInstaller = () => {}
+
+/**
+ * Register this package's invariant companion.
+ * @param ctx - Cordis context carrying the invariant service.
+ * @returns the installed registration's disposer after setup succeeds.
+ */
+export const apply = (ctx: Context): Promise<() => void> =>
+ Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
+/* jscpd:ignore-end */
diff --git a/packages/session/user-id/tests/invariant.spec.ts b/packages/session/user-id/tests/invariant.spec.ts
new file mode 100644
index 0000000000..abffc89621
--- /dev/null
+++ b/packages/session/user-id/tests/invariant.spec.ts
@@ -0,0 +1,12 @@
+import { describe, expect, it } from 'vitest'
+import { Context } from 'cordis'
+import InvariantService from '@deepseek-ai/dsh-invariants'
+import * as UserIdInvariant from '@deepseek-ai/dsh-user-id/invariant'
+
+describe('invariant companion', () => {
+ it('registers the package ownership with an empty installer', async () => {
+ const ctx = new Context()
+ await ctx.plugin(InvariantService, { enabled: true })
+ await expect(ctx.plugin(UserIdInvariant).await()).resolves.toBeDefined()
+ })
+})
diff --git a/packages/session/session-telemetry-otel/tests/user-id.spec.ts b/packages/session/user-id/tests/user-id.spec.ts
similarity index 99%
rename from packages/session/session-telemetry-otel/tests/user-id.spec.ts
rename to packages/session/user-id/tests/user-id.spec.ts
index f7abf45f0b..0f21cb8204 100644
--- a/packages/session/session-telemetry-otel/tests/user-id.spec.ts
+++ b/packages/session/user-id/tests/user-id.spec.ts
@@ -5,7 +5,7 @@ import { afterEach, describe, expect, it } from 'vitest'
import {
USER_ID_FILE_NAME,
getOrCreateAnonymousUserId,
-} from '../src/user-id.ts'
+} from '../src/index.ts'
const dirs: string[] = []
diff --git a/packages/session/user-id/tsconfig.json b/packages/session/user-id/tsconfig.json
new file mode 100644
index 0000000000..52e417d5ba
--- /dev/null
+++ b/packages/session/user-id/tsconfig.json
@@ -0,0 +1,21 @@
+{
+ "extends": "../../../tsconfig.base.json",
+ "compilerOptions": {
+ "rootDir": "src",
+ "outDir": "lib/types"
+ },
+ "include": [
+ "src"
+ ],
+ "references": [
+ {
+ "path": "../../util/brand"
+ },
+ {
+ "path": "../../util/paths"
+ },
+ {
+ "path": "../../support/invariants"
+ }
+ ]
+}
diff --git a/packages/settings/settings/README.i18n.yaml b/packages/settings/settings/README.i18n.yaml
index 4452e78dab..fba3913f75 100644
--- a/packages/settings/settings/README.i18n.yaml
+++ b/packages/settings/settings/README.i18n.yaml
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/settings/settings/README.md
-README.md: 5bfbf4623c937c2b66886f71adf27075523f5d28
-README.zh.md: 98808424ba1af2210067bd7f74baa6027decbb06
+README.md: 7917f38017bfb23dc4718ee533c1f9a92b519d41
+README.zh.md: f46cf433b4d2207b17b0f40cc3b9cf70794b512c
diff --git a/packages/settings/settings/README.md b/packages/settings/settings/README.md
index 5bfbf4623c..7917f38017 100644
--- a/packages/settings/settings/README.md
+++ b/packages/settings/settings/README.md
@@ -11,7 +11,7 @@ User-settings Service Definition (`ctx.settings`). One provider holds a raw docu
- `register(ns, schema, { base?, applies? })` — returns the owner `SettingsScope` (`get`/`watch`/`update`). The registration is an effect on the calling plugin's fiber: disposing that fiber removes the namespace and its observers. A stored section the schema rejects fails the registration itself; a duplicate namespace fails loud.
- `describe(options?)` — one descriptor per namespace (`schema.toJSON()` envelope, resolved value, detached `base`/`user` layers, `applies`) for configuration surfaces; a field's presence in `user` is what marks it user-overridden. `describe({ redactSecrets: true })` strips `role('secret')` fields from every layer and adds the `secrets` slot list (`{ path, set }`); every wire surface MUST pass it, and the pure `redactSecrets(schema, value)` walker is exported for other wires.
- `get(ns)` — resolved value, `undefined` while unregistered.
-- `update(ns, patch)` — deep-merges the plain-object patch into the user section only (never the `base`), validates the resolved candidate, persists through the provider, then commits. Patches must be JSON-shaped data: a Date, Map, BigInt, non-finite number, or circular reference rejects with its `$`-rooted path before anything persists (YAML/JSON storage would silently distort such values on reload). Validation failure rejects before anything is persisted; a read-only provider (`writable: false`) rejects every write. Writes to one namespace are serialized in call order.
+- `update(ns, patch)` — deep-merges the plain-object patch into the user section only (never the `base`), validates the resolved candidate, persists through the provider, then commits. Patches may contain only JSON-compatible data: a Date, Map, BigInt, non-finite number, or circular reference rejects with its `$`-rooted path before anything persists (YAML/JSON storage would silently change such values on reload). Validation failure rejects before anything is persisted; a read-only provider (`writable: false`) rejects every write. Writes to one namespace are serialized in call order.
- `replace(ns, section)` — sets the user section wholesale: the deliberate reset (`replace({})` re-inherits `base` and schema defaults).
- `mutate(ns, ops)` — applies ordered `{ op: 'set' | 'unset', path }` edits to the section as it stands when the write reaches the front of the queue. This is the removal path for any caller holding an INCOMPLETE view: a configuration UI reads the redacted descriptor, so rebuilding a section from it and replacing wholesale deletes every secret the wire never returned, while an op names the one field it means.
- Every write takes an optional `expectedRevision`. Each descriptor carries the namespace's `revision`, a monotonic counter over its RAW section; a write whose expectation no longer matches rejects with `SettingsConflictError` (`code: 'SETTINGS_CONFLICT'`, both revisions attached) instead of overwriting the writer that landed first. The write queue orders writes but cannot by itself tell a fresh writer from one holding a stale snapshot.
diff --git a/packages/settings/settings/README.zh.md b/packages/settings/settings/README.zh.md
index 98808424ba..f46cf433b4 100644
--- a/packages/settings/settings/README.zh.md
+++ b/packages/settings/settings/README.zh.md
@@ -11,7 +11,7 @@
- `register(ns, schema, { base?, applies? })` — 返回 owner 的 `SettingsScope`(`get`/`watch`/`update`)。注册是调用方插件 fiber 上的 effect:dispose 该 fiber 即移除 namespace 及其观察者。schema 拒绝的存量分节会使注册本身失败;重复 namespace 立即报错。
- `describe(options?)` — 每个 namespace 一条描述(`schema.toJSON()` 封装、解析值、分离出的 `base`/`user` 层、`applies`),供配置界面使用;字段出现在 `user` 中即标记其被用户覆盖。`describe({ redactSecrets: true })` 从每一层剥离 `role('secret')` 字段,并附加 `secrets` 槽位列表(`{ path, set }`);每个协议接口都必须传入它,纯遍历器 `redactSecrets(schema, value)` 已导出,供其他 wire 使用。
- `get(ns)` — 解析值;未注册时为 `undefined`。
-- `update(ns, patch)` — 把普通对象 patch 深合并进用户分节(绝不合并进 `base`),校验解析候选值,经提供方持久化后提交。patch 必须是 JSON 形状的数据:Date、Map、BigInt、非有限数或循环引用会在任何内容持久化前带着以 `$` 为根的路径拒绝(YAML/JSON 存储在重载时会静默扭曲这类值)。校验失败在持久化前拒绝;只读提供方(`writable: false`)拒绝一切写入。同一 namespace 的写入按调用顺序串行。
+- `update(ns, patch)` — 把普通对象 patch 深合并进用户分节(绝不合并进 `base`),校验解析候选值,经提供方持久化后提交。patch 只能包含与 JSON 兼容的数据:Date、Map、BigInt、非有限数或循环引用会在任何内容持久化前带着以 `$` 为根的路径拒绝(YAML/JSON 存储在重载时会静默改变这类值)。校验失败在持久化前拒绝;只读提供方(`writable: false`)拒绝一切写入。同一 namespace 的写入按调用顺序串行。
- `replace(ns, section)` — 整体替换用户分节:这是刻意的重置(`replace({})` 重新继承 `base` 与 schema 默认值)。
- `mutate(ns, ops)` — 在写入排到队首那一刻的分节上,按序施加 `{ op: 'set' | 'unset', path }` 编辑。这是任何持有**不完整**视图的调用方的删除路径:配置 UI 读到的是脱敏后的 descriptor,据此重建分节再整体替换,会把 wire 从未回传的每个机密都删掉,而一条 op 只点名它真正要改的那个字段。
- 每次写入都可携带可选的 `expectedRevision`。每个 descriptor 都带有该 namespace 的 `revision`——一个针对其**原始**分节的单调计数器;期望值不再匹配的写入会以 `SettingsConflictError`(`code: 'SETTINGS_CONFLICT'`,并附上两个 revision)被拒绝,而不是覆盖先完成写入的写入方。写队列只保证写入的先后次序,它本身分辨不出新的写入方与持有陈旧快照的写入方。
diff --git a/packages/settings/settings/src/index.ts b/packages/settings/settings/src/index.ts
index d0f85a2b1b..37d3ec1d50 100644
--- a/packages/settings/settings/src/index.ts
+++ b/packages/settings/settings/src/index.ts
@@ -120,14 +120,14 @@ export interface SettingsScope {
watch(callback: (next: T, prev: T) => void | Promise