Merge remote-tracking branch 'origin/master' into feat/web-workspace-file-links
# Conflicts: # packages/client/ui-conversation/README.i18n.yaml # packages/client/ui-conversation/src/client/chat/ToolRow.module.css # packages/host/apiproxy/src/native-path-opener.ts
This commit is contained in:
@@ -527,6 +527,28 @@ function detachedProjectionsFor(
|
||||
return registry.restore({}, events, 0).snapshot
|
||||
}
|
||||
|
||||
/**
|
||||
* Best-effort projections for one subagent history page, fail-soft like
|
||||
* {@link listProjectionsFor}: a registered unit throwing on a corrupt payload
|
||||
* never blocks transcript reading — the page is served without the block.
|
||||
* @param ctx - context carrying the logger for the degradation warning.
|
||||
* @param childSessionId - the child whose page is being decorated.
|
||||
* @param compute - the arm-specific fold (live watermark or detached restore).
|
||||
* @returns the projections block, or undefined when the fold failed.
|
||||
*/
|
||||
function subagentHistoryProjections(
|
||||
ctx: Context,
|
||||
childSessionId: SessionId,
|
||||
compute: () => SessionProjectionsBlock | undefined,
|
||||
): SessionProjectionsBlock | undefined {
|
||||
try {
|
||||
return compute()
|
||||
} catch (error) {
|
||||
ctx.logger.warn(`subagent.history: projections for "${childSessionId}" failed (serving the page without them): ${String(error)}`)
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
/** Map continuation admission failures without exposing provider details. */
|
||||
function subagentPromptError(
|
||||
request: RpcRequest<{ childSessionId: SessionId }>,
|
||||
@@ -567,6 +589,15 @@ function subagentPromptError(
|
||||
return err(request, { code: 'internal', message: 'subagent prompt failed', details: {} })
|
||||
}
|
||||
|
||||
/** Stable RPC face of the missing projections capability, shared by every catalog read path. */
|
||||
function projectionsUnavailableError(): RpcError {
|
||||
return {
|
||||
code: 'internal',
|
||||
message: 'subagent catalog is unavailable: this deployment does not mount the sessionProjections registry (load @deepseek-ai/dsh-session-projection)',
|
||||
details: {},
|
||||
}
|
||||
}
|
||||
|
||||
/** Verify one address and mode against the complete direct-child catalog. */
|
||||
async function catalogChild(
|
||||
ctx: Context,
|
||||
@@ -600,19 +631,11 @@ async function catalogChild(
|
||||
}
|
||||
return { entry }
|
||||
} catch (error: unknown) {
|
||||
if (signal?.aborted
|
||||
|| (error instanceof SubagentError && error.code === 'CANCELLED')
|
||||
|| (error instanceof SessionQueryError && error.code === 'SESSION_QUERY_ABORTED')) {
|
||||
if (signal?.aborted || (error instanceof SubagentError && error.code === 'CANCELLED')) {
|
||||
return { error: { code: 'cancelled', message: 'subagent catalog read was cancelled', details: {} } }
|
||||
}
|
||||
if (error instanceof SessionQueryError && error.code === 'SESSION_QUERY_SESSION_NOT_FOUND') {
|
||||
return {
|
||||
error: {
|
||||
code: 'subagent-not-found',
|
||||
message: `parent session "${parentSessionId}" was not found`,
|
||||
details: { parentSessionId, childSessionId },
|
||||
},
|
||||
}
|
||||
if (error instanceof SubagentError && error.code === 'SUBAGENT_CONTROL_PROJECTIONS_UNAVAILABLE') {
|
||||
return { error: projectionsUnavailableError() }
|
||||
}
|
||||
return { error: { code: 'internal', message: 'subagent catalog read failed', details: {} } }
|
||||
}
|
||||
@@ -925,28 +948,16 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
|
||||
})
|
||||
}
|
||||
|
||||
/** Whether the session's own suffix carries the durable subagent discriminator. */
|
||||
function hasSubagentDescriptor(session: Pick<Session, 'events' | 'header'>): boolean {
|
||||
const events = session.events
|
||||
// Indexed scan from the own-suffix start: slicing copies the whole suffix
|
||||
// on every Agent-bound RPC, including each `session.prompt` on long
|
||||
// transcripts.
|
||||
for (let index = session.header.seedLength ?? 0; index < events.length; index += 1) {
|
||||
if (events[index]?.type === 'subagent/descriptor') return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* Generic Host interaction cannot claim a durably classified subagent or an
|
||||
* Agent created through its live parent. The runtime-owner arm also covers
|
||||
* descriptor-less child publication windows and older stored headers.
|
||||
* Generic Host interaction cannot claim a durably classified subagent
|
||||
* (`origin: 'subagent'` in the header) or an Agent runtime-owned by its
|
||||
* live parent.
|
||||
*/
|
||||
function hasSubagentOwner(
|
||||
session: Pick<Session, 'events' | 'header'>,
|
||||
session: Pick<Session, 'header'>,
|
||||
agent: Agent | undefined,
|
||||
): boolean {
|
||||
if (session.header.origin === 'subagent' || hasSubagentDescriptor(session)) return true
|
||||
if (session.header.origin === 'subagent') return true
|
||||
const parentId = session.header.parentSession
|
||||
if (parentId === undefined || agent === undefined) return false
|
||||
const parent = ctx.agents.get(parentId)
|
||||
@@ -1002,7 +1013,7 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
|
||||
resume = (async () => {
|
||||
try {
|
||||
const inspected = await inspectServable(sessionId)
|
||||
if (hasSubagentOwner({ header: inspected.meta, events: inspected.events }, undefined)) {
|
||||
if (hasSubagentOwner({ header: inspected.meta }, undefined)) {
|
||||
throw new SubagentSessionOwnership(sessionId)
|
||||
}
|
||||
const publishedSession = ctx.sessions.get(sessionId)
|
||||
@@ -1121,7 +1132,7 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
|
||||
// Ownership first: explicit-id adoption of a session-backed
|
||||
// subagent must answer `agent-busy` regardless of the requested
|
||||
// cwd (the api/commands.ts contract), not a cwd conflict.
|
||||
if (hasSubagentOwner({ header: inspected.meta, events: inspected.events }, undefined)) {
|
||||
if (hasSubagentOwner({ header: inspected.meta }, undefined)) {
|
||||
throw new SubagentSessionOwnership(sessionId)
|
||||
}
|
||||
if (inspected.meta.cwd !== cwd) {
|
||||
@@ -1903,15 +1914,16 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
|
||||
parentAvailable: ctx.agents.get(request.payload.parentSessionId) !== undefined,
|
||||
})
|
||||
} catch (error: unknown) {
|
||||
if (signal?.aborted
|
||||
|| (error instanceof SubagentError && error.code === 'CANCELLED')
|
||||
|| (error instanceof SessionQueryError && error.code === 'SESSION_QUERY_ABORTED')) {
|
||||
if (signal?.aborted || (error instanceof SubagentError && error.code === 'CANCELLED')) {
|
||||
return err(request, {
|
||||
code: 'cancelled',
|
||||
message: 'subagent catalog read was cancelled',
|
||||
details: {},
|
||||
})
|
||||
}
|
||||
if (error instanceof SubagentError && error.code === 'SUBAGENT_CONTROL_PROJECTIONS_UNAVAILABLE') {
|
||||
return err(request, projectionsUnavailableError())
|
||||
}
|
||||
return err(request, {
|
||||
code: 'internal',
|
||||
message: 'subagent catalog read failed',
|
||||
@@ -1928,44 +1940,65 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
|
||||
parentSessionId, childSessionId, mode,
|
||||
}, signal)
|
||||
if (verified.error !== undefined) return err(request, verified.error)
|
||||
try {
|
||||
const snapshot = await ctx.sessionQuery.readSession(childSessionId)
|
||||
signal?.throwIfAborted()
|
||||
if (snapshot.session.parentSession !== parentSessionId) {
|
||||
return err(request, {
|
||||
code: 'subagent-unauthorized',
|
||||
message: 'subagent parent changed during history read',
|
||||
details: { childSessionId },
|
||||
})
|
||||
}
|
||||
const page = historyPage(ctx, snapshot.events, beforeSeq, maxMessages)
|
||||
const projections = beforeSeq === undefined
|
||||
? detachedProjectionsFor(ctx, snapshot.events)
|
||||
// The generic-history data plane: an attached child serves its
|
||||
// in-memory snapshot and the registry's live watermark projections; a
|
||||
// cold child is one persistence inspection plus a detached fold.
|
||||
let header: SessionHeader
|
||||
let events: SessionEvent[]
|
||||
let projections: SessionProjectionsBlock | undefined
|
||||
const attached = ctx.sessions.get(childSessionId)
|
||||
if (attached !== undefined) {
|
||||
header = attached.header
|
||||
events = [...attached.events]
|
||||
projections = beforeSeq === undefined
|
||||
? subagentHistoryProjections(ctx, childSessionId, () => projectionsFor(ctx, attached))
|
||||
: undefined
|
||||
return ok(request, { ...page, ...projections === undefined ? {} : { projections } })
|
||||
} catch (error: unknown) {
|
||||
if (signal?.aborted
|
||||
|| (error instanceof SessionQueryError && error.code === 'SESSION_QUERY_ABORTED')) {
|
||||
} else {
|
||||
try {
|
||||
const inspected = await inspectServable(childSessionId)
|
||||
header = inspected.meta
|
||||
events = inspected.events
|
||||
projections = beforeSeq === undefined
|
||||
? subagentHistoryProjections(ctx, childSessionId, () => detachedProjectionsFor(ctx, inspected.events))
|
||||
: undefined
|
||||
} catch (error: unknown) {
|
||||
if (signal?.aborted) {
|
||||
return err(request, {
|
||||
code: 'cancelled',
|
||||
message: 'subagent history read was cancelled',
|
||||
details: {},
|
||||
})
|
||||
}
|
||||
if (error instanceof SessionNotFound) {
|
||||
return err(request, {
|
||||
code: 'subagent-not-found',
|
||||
message: 'subagent disappeared during history read',
|
||||
details: { parentSessionId, childSessionId },
|
||||
})
|
||||
}
|
||||
return err(request, {
|
||||
code: 'cancelled',
|
||||
message: 'subagent history read was cancelled',
|
||||
code: 'internal',
|
||||
message: 'subagent history read failed',
|
||||
details: {},
|
||||
})
|
||||
}
|
||||
if (error instanceof SessionQueryError
|
||||
&& error.code === 'SESSION_QUERY_SESSION_NOT_FOUND') {
|
||||
return err(request, {
|
||||
code: 'subagent-not-found',
|
||||
message: 'subagent disappeared during history read',
|
||||
details: { parentSessionId, childSessionId },
|
||||
})
|
||||
}
|
||||
}
|
||||
if (signal?.aborted) {
|
||||
return err(request, {
|
||||
code: 'internal',
|
||||
message: 'subagent history read failed',
|
||||
code: 'cancelled',
|
||||
message: 'subagent history read was cancelled',
|
||||
details: {},
|
||||
})
|
||||
}
|
||||
if (header.parentSession !== parentSessionId) {
|
||||
return err(request, {
|
||||
code: 'subagent-unauthorized',
|
||||
message: 'subagent parent changed during history read',
|
||||
details: { childSessionId },
|
||||
})
|
||||
}
|
||||
const page = historyPage(ctx, events, beforeSeq, maxMessages)
|
||||
return ok(request, { ...page, ...projections === undefined ? {} : { projections } })
|
||||
},
|
||||
|
||||
async prompt(request, signal) {
|
||||
|
||||
@@ -70,7 +70,8 @@ export interface SubagentsApi {
|
||||
): Promise<RpcResponse<SubagentCatalog>>
|
||||
|
||||
/**
|
||||
* Reads one healthy catalog child's persisted raw log with ordinary
|
||||
* Reads one healthy catalog child's transcript — the in-memory snapshot of
|
||||
* a live child, the persisted log of a cold one — with ordinary
|
||||
* message-aligned pagination and render intents, without Agent activation.
|
||||
*/
|
||||
history(
|
||||
|
||||
@@ -2,16 +2,13 @@
|
||||
* Cross-platform native path and text-document openers used by the local GUI
|
||||
* carrier.
|
||||
*
|
||||
* Under the default intent, a document a browser RENDERS is opened with the
|
||||
* user's default browser rather than the default application for its type,
|
||||
* when the platform can name one: a developer who binds `.html` to an editor
|
||||
* would otherwise click a produced page and get source code. The contract is
|
||||
* uniform — prefer the default browser, fall back to the default application —
|
||||
* while how completely a platform can answer "which browser" differs, and
|
||||
* every failure falls back rather than surfacing. The text-editor intent never
|
||||
* consults the browser: it exists to open the document's TEXT.
|
||||
* The default intent prefers the default browser for documents it renders when
|
||||
* the platform can name one, then falls back to the default application. WSL
|
||||
* translates every path for the Windows desktop instead of assuming a Linux
|
||||
* GUI. The text-editor intent never consults the browser.
|
||||
*/
|
||||
|
||||
import { release as osRelease } from 'node:os'
|
||||
import { extname } from 'node:path'
|
||||
import { runNativeCommand, type NativeCommandRunner } from '@deepseek-ai/dsh-native-command'
|
||||
|
||||
@@ -21,9 +18,11 @@ export type PathOpenerRunner = NativeCommandRunner
|
||||
/** Injectable platform facts for deterministic adapter tests. */
|
||||
export interface PathOpenerInternals {
|
||||
platform?: NodeJS.Platform
|
||||
run?: PathOpenerRunner
|
||||
/** Environment the linux browser convention reads; defaults to the process env. */
|
||||
/** Kernel release override used to distinguish WSL from desktop Linux. */
|
||||
osRelease?: string
|
||||
/** Environment used for WSL markers and the desktop Linux browser convention. */
|
||||
env?: NodeJS.ProcessEnv
|
||||
run?: PathOpenerRunner
|
||||
}
|
||||
|
||||
/** Documents a browser renders, as opposed to ones an editor merely edits. */
|
||||
@@ -86,6 +85,36 @@ function powershellLiteral(path: string): string {
|
||||
return `'${path.replace(/'/g, "''")}'`
|
||||
}
|
||||
|
||||
/** Whether one environment marker is set to a non-empty value. */
|
||||
function present(value: string | undefined): boolean {
|
||||
return value !== undefined && value !== ''
|
||||
}
|
||||
|
||||
/** Distinguish WSL from desktop Linux using its process and kernel markers. */
|
||||
function isWsl(internals: PathOpenerInternals): boolean {
|
||||
const env = internals.env ?? process.env
|
||||
if (present(env.WSL_DISTRO_NAME) || present(env.WSL_INTEROP)) return true
|
||||
return (internals.osRelease ?? osRelease()).toLowerCase().includes('microsoft')
|
||||
}
|
||||
|
||||
/** Open one Windows-resolvable path through its registered desktop application. */
|
||||
async function openWindowsPath(path: string, signal: AbortSignal, run: PathOpenerRunner): Promise<void> {
|
||||
await run('powershell.exe', [
|
||||
'-NoProfile',
|
||||
'-Command',
|
||||
`Invoke-Item -LiteralPath ${powershellLiteral(path)}`,
|
||||
], signal)
|
||||
}
|
||||
|
||||
/** Translate a WSL path before handing it to the Windows desktop. */
|
||||
async function openWslPath(path: string, signal: AbortSignal, run: PathOpenerRunner): Promise<void> {
|
||||
const translated = await run('wslpath', ['-w', path], signal)
|
||||
signal.throwIfAborted()
|
||||
const windowsPath = translated.stdout.replace(/[\r\n]+$/, '')
|
||||
if (windowsPath === '') throw new Error('wslpath returned no Windows path')
|
||||
await openWindowsPath(windowsPath, signal, run)
|
||||
}
|
||||
|
||||
/** Dispatch one shell-free platform command for the requested open intent. */
|
||||
async function openNativePathWithIntent(
|
||||
path: string,
|
||||
@@ -96,8 +125,9 @@ async function openNativePathWithIntent(
|
||||
const platform = internals.platform ?? process.platform
|
||||
const run = internals.run ?? runNativeCommand
|
||||
const env = internals.env ?? process.env
|
||||
const wsl = platform === 'linux' && isWsl(internals)
|
||||
|
||||
if (intent === 'default' && BROWSER_DOCUMENTS.has(extname(path).toLowerCase())
|
||||
if (!wsl && intent === 'default' && BROWSER_DOCUMENTS.has(extname(path).toLowerCase())
|
||||
&& await openInBrowser(path, signal, platform, run, env)) return
|
||||
|
||||
if (platform === 'darwin') {
|
||||
@@ -106,15 +136,15 @@ async function openNativePathWithIntent(
|
||||
}
|
||||
|
||||
if (platform === 'win32') {
|
||||
await run('powershell.exe', [
|
||||
'-NoProfile',
|
||||
'-Command',
|
||||
`Invoke-Item -LiteralPath ${powershellLiteral(path)}`,
|
||||
], signal)
|
||||
await openWindowsPath(path, signal, run)
|
||||
return
|
||||
}
|
||||
|
||||
if (platform === 'linux') {
|
||||
if (wsl) {
|
||||
await openWslPath(path, signal, run)
|
||||
return
|
||||
}
|
||||
await run('xdg-open', [path], signal)
|
||||
return
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user