Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions docs/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ this package adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm
- `ops_bash` 使用任务说明作为宿主 job 标签,后台及超时转后台的返回值和显示文本同时带上该标签(#322)。内部调用未提供说明时保留命令、脚本路径及默认名称回退,并补充旧 OPS 后台工具迁移到宿主任务控制的说明。
- Keep `ref://` result references usable on Linux hosts whose sandbox runs workers under bwrap (#337). bwrap gives workspace-write children a private tmpfs over `/tmp`, so the PTC worker behind `ops_result` never saw the session cache that the host keeps below `tmpdir()`: every page returned `REFERENCE_EXPIRED`, and the session purge silently left the cache behind. The worker argv now binds exactly that one private (0700, owner-checked) cache directory back in, for page/put and for the session purge. Landlock, Seatbelt, Windows and read-only bwrap are unchanged.
- 修复 Linux 宿主沙箱走 bwrap 时 `ref://` 结果引用不可用(#337):bwrap 给 workspace-write 子进程挂了私有 tmpfs `/tmp`,`ops_result` 背后的 PTC worker 看不到宿主放在 `tmpdir()` 下的会话缓存,于是每次分页都报 `REFERENCE_EXPIRED`,会话清理也悄悄把缓存留在原地。现在 worker 的 argv 只把这一个私有(0700、校验属主)缓存目录重新绑定进去,分页/写入和会话清理都覆盖。Landlock、Seatbelt、Windows 与只读 bwrap 行为不变。
- Classify Native file and `ops_result` outcomes in the OPS TUI sidebar from bounded, replayable presentation metadata rather than compact model text. Preserve PTC child JSON handling and exclude paths, content and error messages from the metadata; cover the producer and consumer in always-on tests and the real dsh-TUI host loop (#339).
- OPS TUI 侧栏通过有界、可重放的展示元数据识别 Native 文件工具和 `ops_result` 的业务结果,不再尝试解析面向模型的精简文本。保留 PTC 子调用的 JSON 处理,元数据不含路径、正文和错误消息;补充 CI 必跑的生产/消费回归及真实 dsh-TUI 宿主闭环测试(#339)。

## [0.4.1] — 2026-10-20 — Update locks stop leaving files behind

Expand Down
2 changes: 1 addition & 1 deletion docs/tui-activity.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ Snapshots are immutable and referentially stable until that session changes. Sub

## Native result diagnostics

For the four native file tools and two PTC tools only, `result.js` parses a single JSON text envelope of at most 64 KiB (serialized UTF-8 bytes). It retains only known outcome categories, safe nonnegative counters, boolean flags and a bounded uppercase error code. Raw text, items, paths and error messages are discarded. Host failure cannot be upgraded to confirmed success. Shell prose and spilled/malformed output remain business-unconfirmed. Explicit unknown effects are distinguished from merely unavailable business status. No result reference is followed and no progress percentage or token saving is inferred.
For OPS file and PTC tools only, `result.js` reads outcome facts from one of two sources. A Native top-level result carries them in the host's replayable presentation metadata (`meta.opsOutcome`, produced by `presentationMeta` in `src/kernel/presentation-outcome.ts`), because Native model text is compact prose rather than JSON. A PTC child result is a single JSON text envelope of at most 64 KiB (serialized UTF-8 bytes). It retains only known outcome categories, safe nonnegative counters, boolean flags and a bounded uppercase error code. Raw text, items, paths and error messages are discarded. Host failure cannot be upgraded to confirmed success. Shell prose and spilled/malformed output remain business-unconfirmed. Explicit unknown effects are distinguished from merely unavailable business status. No result reference is followed and no progress percentage or token saving is inferred.

## Verification

Expand Down
4 changes: 2 additions & 2 deletions docs/tui-panel.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,13 +24,13 @@ The OPS tab does not open itself or change focus. No TUI/React runtime dependenc

- Live native and PTC OPS calls for the selected session, since this component was enabled.
- Calls, in-flight count, transport returns/errors and recent observed durations.
- Refusal, operation errors, preview/plan, partial failure and explicit unknown effects from native file/PTC JSON envelopes; unknown effects warn against blind retry.
- Refusal, operation errors, preview/plan, partial failure and explicit unknown effects, read from the Native tools' presentation metadata and from PTC JSON envelopes; unknown effects warn against blind retry.
- Settled batch counts, omitted details and paging hints, never invented live progress.
- Tracking-limit warnings. A return is neutral, **not proof of business success**; a background launch returning does not mean the job finished.

This is not history replay or persistent accounting. Closing/reopening the tab preserves observation; unloading clears it. No command, argument, pattern, replacement, path, result body or error text is retained. Session changes select a different snapshot. No extra model/worker calls, job-output reads, polling, prompt sections or token-savings estimates are added. There are no destructive actions or send-to-chat controls.

Shell prose and spilled/truncated content cannot reliably establish business status and remain business-unconfirmed, never success. Only a single JSON text of at most 64 KiB is parsed; fixed categories, counters, booleans and a bounded error code are retained, not error messages.
Shell prose and spilled/truncated content cannot reliably establish business status and remain business-unconfirmed, never success. Native file tools and Native `ops_result` attach a bounded `opsOutcome` object to the host's replayable presentation metadata (status, error code and effect state, safe counters and booleans; never paths, items or messages), because their model text is not JSON. PTC children are classified from a single JSON text of at most 64 KiB. Model prose is never parsed into an outcome.

## Read-only filters and local pages

Expand Down
4 changes: 2 additions & 2 deletions docs/tui-panel.zh.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,13 +24,13 @@ config:

- 当前选中会话自该组件启用后观察到的原生与 PTC OPS 调用。
- 调用数、运行中、传输返回/错误与最近观察耗时。
- 原生文件/PTC 结构化返回中的拒绝、业务失败、预览/计划、部分失败和明确的未知副作用;未知副作用会提示先检查、不要盲目重试。
- 拒绝、业务失败、预览/计划、部分失败和明确的未知副作用,来自原生工具的展示元数据与 PTC 的 JSON 返回;未知副作用会提示先检查、不要盲目重试。
- 已结算的批处理完成数量、未展示明细数量及分页提示;不伪造实时进度。
- 达到上限时明确标记统计不完整。「已返回」是中性状态,**不代表业务成功**;后台任务启动调用返回不代表任务已经完成。

这不是历史重放或持久用量统计。关闭再打开面板不重置观测,卸载组件才清空。不保留命令、参数、搜索模式、替换文本、路径、结果正文或错误正文。切换会话只切换快照,不增加模型/worker 调用、job_output 读取、轮询、提示词段落或 token 节省估算。不提供删除、重试、提权或发送给模型按钮。

Shell 输出或被 spill/截断的内容无法可靠恢复业务状态,会保持「业务状态未确认」,不会假报成功。只解析最多 64 KiB 的单条 JSON 文本,且只保留固定类别、计数、布尔值和有界错误码,不保留错误正文。
Shell 输出或被 spill/截断的内容无法可靠恢复业务状态,会保持「业务状态未确认」,不会假报成功。原生文件工具与原生 `ops_result` 给模型的是紧凑文本而非 JSON,因此会在宿主可重放的展示元数据里附一个有界的 `opsOutcome` 对象(状态、错误码与副作用状态、安全计数和布尔值;不含路径、条目或错误正文)。PTC 子调用仍只解析最多 64 KiB 的单条 JSON 文本。模型可见的文本从不被解析成业务结论。

## 只读筛选与本地分页

Expand Down
3 changes: 2 additions & 1 deletion src/file/contract.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { PARAMETERS, DESCRIPTIONS, OUTPUT, fileOutput } from '../capabilities/fi
export { FILE_TOOLS } from '../capabilities/registry.js'
export { PARAMETERS, DESCRIPTIONS, OUTPUT }
import { renderNative } from '../modes/native/render.js'
import { presentationMeta } from '../kernel/presentation-outcome.js'

/** Drop unused empty placeholders strict adapters send for optional fields; never mutate the caller's object. */
export function normalizeArguments(rawName: string, args: unknown): unknown {
Expand Down Expand Up @@ -33,7 +34,7 @@ export function definition(rawName: FileToolName, execute: ToolDefinition['execu
description: DESCRIPTIONS[rawName],
parameters: PARAMETERS[rawName],
...(capability(rawName)?.concurrencySafe ? { isConcurrencySafe: () => true } : {}),
output: { schema: fileOutput(rawName), render: (args, value) => [{ type: 'text', text: renderNative(rawName, args, value) }] },
output: { schema: fileOutput(rawName), render: (args, value) => [{ type: 'text', text: renderNative(rawName, args, value) }], presentationMeta },
execute,
}
}
3 changes: 2 additions & 1 deletion src/file/result.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,13 @@ import { publicToolName } from '../policy.js'
import { OUTPUT } from '../capabilities/ptc.js'
import { invoke, failure } from '../ptc/worker.js'
import { renderNative } from '../modes/native/render.js'
import { presentationMeta } from '../kernel/presentation-outcome.js'
export function resultDefinition(execute: ToolDefinition['execute']): ToolDefinition {
const entry = capability('result')
if (!entry || !('nativeProjection' in entry) || !entry.nativeProjection) throw new Error('Native result projection is missing')
return { name: publicToolName(entry.rawName), description: entry.nativeProjection.description,
parameters: entry.nativeProjection.parameters, execute,
output: { schema: OUTPUT, render: (args, value) => [{ type: 'text', text: renderNative('result', args, value) }] } }
output: { schema: OUTPUT, render: (args, value) => [{ type: 'text', text: renderNative('result', args, value) }], presentationMeta } }
}
type Execution = Parameters<ToolDefinition['execute']>[1]
export async function invokeResult(ctx: Parameters<typeof invoke>[0], file: string, args: unknown,
Expand Down
34 changes: 34 additions & 0 deletions src/kernel/presentation-outcome.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
/** Bounded outcome facts persisted as host presentation metadata: replayable for UIs, never model-facing. */
export const OUTCOME_META = 'opsOutcome'
type Json = string | number | boolean | null | Json[] | { [key: string]: Json }
const COUNTERS = ['done', 'total', 'failed', 'unknown', 'omitted', 'nextOffset'] as const
const FLAGS = ['complete', 'dryRun'] as const
const EFFECTS = new Set(['none', 'applied', 'partial', 'unknown'])
const record = (value: unknown): value is Record<string, unknown> => value !== null && typeof value === 'object' && !Array.isArray(value)

/** Copy only status, the error code/effect and safe counters; paths, messages and payloads never leave the value. */
export function outcomeFacts(value: unknown): { [key: string]: Json } {
const facts: { [key: string]: Json } = {}
if (!record(value)) return facts
if (typeof value.status === 'string' && /^[a-z][a-z-]{0,31}$/.test(value.status)) facts.status = value.status
for (const key of COUNTERS) {
const count = value[key]
if (typeof count === 'number' && Number.isSafeInteger(count) && count >= 0) facts[key] = count
}
for (const key of FLAGS) {
const flag = value[key]
if (typeof flag === 'boolean') facts[key] = flag
}
if (record(value.error)) {
const error: { [key: string]: Json } = {}
if (typeof value.error.code === 'string' && /^[A-Z][A-Z0-9_]{0,63}$/.test(value.error.code)) error.code = value.error.code
if (typeof value.error.effectState === 'string' && EFFECTS.has(value.error.effectState)) error.effectState = value.error.effectState
facts.error = error
}
return facts
}

/** dsh-tools `output.presentationMeta` for Native tools whose model text is not a JSON envelope. */
export function presentationMeta(_args: unknown, value: unknown): Json {
return { [OUTCOME_META]: outcomeFacts(value) }
}
13 changes: 10 additions & 3 deletions src/tui/result.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
/** Read only the six OPS native JSON envelopes, never shell stdout or arbitrary tool prose. */
/** Read only OPS outcome facts (presentation metadata or JSON envelopes), never shell stdout or arbitrary tool prose. */
import { FILE_TOOLS, publicToolName } from '../policy.js'
import { OUTCOME_META } from '../kernel/presentation-outcome.js'
import { PTC_TOOLS } from '../ptc/contract.js'
const JSON_TOOLS = new Set([...FILE_TOOLS, ...PTC_TOOLS].map(publicToolName))
const MAX_JSON_BYTES = 64 * 1024
Expand All @@ -20,14 +21,21 @@ export function record(value: unknown): value is Record<string, unknown> {
export function resultDetails(tool: string, data: unknown, nested: boolean): ResultDetails {
if (!JSON_TOOLS.has(tool) || !record(data)) return UNCONFIRMED
const message = record(data.message) ? data.message : undefined
const hostFailed = data.error !== undefined || (nested ? data.isError === true : message?.isError === true)
// Native results render model text; their outcome travels in the host's replayable presentation metadata.
const meta = !nested && record(data.meta) ? data.meta[OUTCOME_META] : undefined
if (record(meta)) return summarize(meta, hostFailed)
const content = nested ? data.content : message?.content
// Multiple blocks can be a spill preview plus locator, not the original JSON envelope.
if (!Array.isArray(content) || content.length !== 1 || !record(content[0]) || content[0].type !== 'text') return UNCONFIRMED
const text = content[0].text
if (typeof text !== 'string' || text.length > MAX_JSON_BYTES || Buffer.byteLength(text, 'utf8') > MAX_JSON_BYTES) return UNCONFIRMED
let value: unknown
try { value = JSON.parse(text) } catch { return UNCONFIRMED }
if (!record(value)) return UNCONFIRMED
return record(value) ? summarize(value, hostFailed) : UNCONFIRMED
}

function summarize(value: Record<string, unknown>, hostFailed: boolean): ResultDetails {
const summary: ResultSummary = {}
for (const key of ['done', 'total', 'failed', 'unknown', 'omitted', 'nextOffset'] as const) {
if (integer(value[key])) summary[key] = value[key]
Expand All @@ -42,6 +50,5 @@ export function resultDetails(tool: string, data: unknown, nested: boolean): Res
: (summary.failed ?? 0) > 0 || value.status === 'partial' ? 'partial'
: value.status === 'planned' || (value.status === 'completed' && value.dryRun === true) ? 'planned'
: value.status === 'completed' ? 'completed' : 'unconfirmed'
const hostFailed = data.error !== undefined || (nested ? data.isError === true : message?.isError === true)
return Object.freeze({ outcome: hostFailed && (outcome === 'completed' || outcome === 'planned') ? 'unconfirmed' : outcome, ...summary })
}
7 changes: 5 additions & 2 deletions test/tui-native.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -52,14 +52,17 @@ const run = async (agent, name, args, mode = 'danger-full-access') => {
modes.set(agent.id, mode)
scripts.set(agent.id, [[{ id: callId, name, args }], []])
const before = modelCalls
agent.followup(createUserMessage({ content: [{ type: 'text', text: 'Run the scripted fixture.' }] }))
// DSH 0.2.1 loops classify queued input by source; a user turn must say it is one.
agent.followup(createUserMessage({ content: [{ type: 'text', text: 'Run the scripted fixture.' }], source: { kind: 'user' } }))
await agent.whenIdle()
assert.deepEqual(failures, [])
assert.equal(modelCalls, before + 2, 'one scripted tool response and one final response')
assert.equal(scripts.get(agent.id).length, 0)
const result = events.find(event => event.type === 'tool/result' && event.data.message.source.callId === callId)
assert.ok(result, 'the real loop must commit a tool result')
return JSON.parse(result.data.message.content[0].text)
// Native text is for the model; the panel reads the replayable presentation facts instead.
assert.equal(result.data.message.content[0].text.startsWith('{'), false)
return result.data.meta.opsOutcome
}
try {
await root.plugin(Loader, { baseUrl: pathToFileURL(join(composition, 'cordis.yml')).href })
Expand Down
Loading
Loading