From e9c056cc52eb4ea35f5fbec7a157f14f182d6d89 Mon Sep 17 00:00:00 2001 From: code-new-bie <403466121@qq.com> Date: Wed, 19 Aug 2026 10:25:25 +0800 Subject: [PATCH 001/305] feat(m0): add dshx development package --- package.json | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) create mode 100644 package.json diff --git a/package.json b/package.json new file mode 100644 index 00000000..0cecf406 --- /dev/null +++ b/package.json @@ -0,0 +1,21 @@ +{ + "name": "dsh-codex", + "version": "0.0.0-m0", + "private": true, + "type": "module", + "engines": { + "node": ">=20" + }, + "bin": { + "dshx": "./bin/dshx.mjs" + }, + "scripts": { + "test": "node --test", + "test:protocol": "node --test test/protocol.test.mjs", + "stub": "node ./bin/dshx-stub-server.mjs", + "dev": "node ./bin/dshx.mjs" + }, + "dependencies": { + "ws": "^8.18.0" + } +} From 149c1d49ff8b442b66360b67702628d100a839a0 Mon Sep 17 00:00:00 2001 From: code-new-bie <403466121@qq.com> Date: Wed, 19 Aug 2026 10:25:47 +0800 Subject: [PATCH 002/305] feat(m0): implement minimal Codex app-server protocol stub --- src/protocol.mjs | 286 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 286 insertions(+) create mode 100644 src/protocol.mjs diff --git a/src/protocol.mjs b/src/protocol.mjs new file mode 100644 index 00000000..eb5c1a30 --- /dev/null +++ b/src/protocol.mjs @@ -0,0 +1,286 @@ +import crypto from 'node:crypto'; +import os from 'node:os'; +import path from 'node:path'; + +const nowSeconds = () => Math.floor(Date.now() / 1000); +const nowMillis = () => Date.now(); +const id = (prefix) => `${prefix}-${crypto.randomUUID()}`; + +function platformFamily() { + return process.platform === 'win32' ? 'windows' : 'unix'; +} + +function platformOs() { + if (process.platform === 'darwin') return 'macos'; + if (process.platform === 'win32') return 'windows'; + if (process.platform === 'linux') return 'linux'; + return process.platform; +} + +function absoluteCwd(value) { + return path.resolve(value || process.cwd()); +} + +function modelRecord() { + return { + id: 'dshx-stub', + model: 'dshx-stub', + upgrade: null, + upgradeInfo: null, + availabilityNux: null, + displayName: 'DSHX Protocol Stub', + description: 'Deterministic M0 model used only to validate Codex TUI protocol compatibility.', + modelSpecialty: null, + hidden: false, + supportedReasoningEfforts: [ + { reasoningEffort: 'medium', description: 'M0 deterministic protocol mode' } + ], + defaultReasoningEffort: 'medium', + inputModalities: ['text'], + supportsPersonality: false, + multiAgentVersion: null, + additionalSpeedTiers: [], + serviceTiers: [], + defaultServiceTier: null, + isDefault: true + }; +} + +function emptyThread(cwd) { + const timestamp = nowSeconds(); + const threadId = id('thread'); + return { + id: threadId, + sessionId: threadId, + forkedFromId: null, + parentThreadId: null, + preview: '', + ephemeral: false, + section: null, + sectionEnteredAt: null, + projectId: null, + modelProvider: 'dsh', + createdAt: timestamp, + updatedAt: timestamp, + recencyAt: timestamp, + status: { type: 'idle' }, + path: null, + cwd, + cliVersion: '0.0.0-m0', + source: { custom: 'dshx' }, + threadSource: null, + agentNickname: null, + agentRole: null, + gitInfo: null, + name: null, + turns: [] + }; +} + +function turnRecord(turnId, status = 'inProgress', completed = false) { + const startedAt = nowSeconds(); + return { + id: turnId, + items: [], + itemsView: 'full', + status, + error: null, + startedAt, + completedAt: completed ? nowSeconds() : null, + durationMs: completed ? 0 : null + }; +} + +function textFromTurnStart(params = {}) { + const input = Array.isArray(params.input) ? params.input : []; + return input + .filter((item) => item && item.type === 'text' && typeof item.text === 'string') + .map((item) => item.text) + .join('\n') + .trim(); +} + +export class ProtocolStub { + constructor({ cwd = process.cwd(), home = null } = {}) { + this.cwd = absoluteCwd(cwd); + this.home = home ? path.resolve(home) : path.join(os.homedir(), '.dshx'); + this.threads = new Map(); + this.activeTurns = new Map(); + } + + dispatch(method, params) { + switch (method) { + case 'initialize': + return { + result: { + userAgent: 'dsh-codex-app-server/0.0.0-m0', + codexHome: this.home, + platformFamily: platformFamily(), + platformOs: platformOs() + } + }; + case 'account/read': + return { result: { account: null, requiresOpenaiAuth: false } }; + case 'model/list': + return { result: { data: [modelRecord()], nextCursor: null } }; + case 'configRequirements/read': + return { result: { requirements: null } }; + case 'skills/list': + return { result: { data: [] } }; + case 'thread/list': + return { result: { data: [...this.threads.values()], nextCursor: null, backwardsCursor: null } }; + case 'thread/start': + return this.startThread(params); + case 'turn/start': + return this.startTurn(params); + case 'turn/interrupt': + return this.interruptTurn(params); + default: + return { + result: undefined, + events: [], + error: { code: -32601, message: `Method not found: ${method}` } + }; + } + } + + startThread(params) { + const cwd = absoluteCwd(params.cwd || this.cwd); + const thread = emptyThread(cwd); + this.threads.set(thread.id, thread); + return { + result: { + thread, + model: 'dshx-stub', + modelProvider: 'dsh', + serviceTier: null, + cwd, + instructionSources: [], + approvalPolicy: 'on-request', + approvalsReviewer: 'user', + sandbox: { + type: 'workspaceWrite', + writableRoots: [cwd], + networkAccess: false, + excludeTmpdirEnvVar: false, + excludeSlashTmp: false + }, + reasoningEffort: 'medium' + }, + events: [this.notification('thread/started', { thread })] + }; + } + + startTurn(params) { + const threadId = params.threadId; + if (typeof threadId !== 'string' || !this.threads.has(threadId)) { + throw new Error(`Unknown thread: ${threadId ?? ''}`); + } + + const turnId = id('turn'); + const itemId = id('item'); + const turn = turnRecord(turnId); + const prompt = textFromTurnStart(params); + const answer = prompt + ? `DSHX protocol stub received: ${prompt}` + : 'DSHX protocol stub is connected.'; + const chunks = answer.match(/.{1,24}(?:\s|$)/g) ?? [answer]; + const startedItem = { + type: 'agentMessage', + id: itemId, + text: '', + phase: null, + memoryCitation: null, + delivery: null + }; + const completedItem = { ...startedItem, text: answer }; + const completedTurn = turnRecord(turnId, 'completed', true); + completedTurn.items = [completedItem]; + + this.activeTurns.set(threadId, { turnId, itemId, startedAt: nowSeconds() }); + const events = [ + this.notification('turn/started', { threadId, turn }), + this.notification('item/started', { + item: startedItem, + threadId, + turnId, + startedAtMs: nowMillis() + }), + ...chunks.map((delta) => + this.notification('item/agentMessage/delta', { threadId, turnId, itemId, delta }) + ), + this.notification('item/completed', { + item: completedItem, + threadId, + turnId, + completedAtMs: nowMillis() + }), + this.notification('turn/completed', { threadId, turn: completedTurn }) + ]; + + return { result: { turn }, events }; + } + + interruptTurn(params) { + const threadId = params.threadId; + const active = this.activeTurns.get(threadId); + if (!active) return { result: {} }; + this.activeTurns.delete(threadId); + const turn = { + ...turnRecord(active.turnId, 'interrupted', true), + startedAt: active.startedAt + }; + return { + result: {}, + events: [this.notification('turn/completed', { threadId, turn })] + }; + } + + isTurnActive(threadId, turnId) { + return this.activeTurns.get(threadId)?.turnId === turnId; + } + + completeTurn(threadId, turnId) { + if (this.isTurnActive(threadId, turnId)) this.activeTurns.delete(threadId); + } + + notification(method, params) { + return { method, params }; + } + + error(requestId, code, message, data = undefined) { + const error = { code, message }; + if (data !== undefined) error.data = data; + return { id: requestId, error }; + } +} + +export function normalizeDispatchResult(stub, message) { + const { id: requestId, method } = message ?? {}; + if (requestId === undefined || typeof method !== 'string') { + return { response: stub.error(requestId ?? null, -32600, 'Invalid Request'), events: [] }; + } + + try { + const dispatched = stub.dispatch(method, message?.params ?? {}); + if (dispatched.error) { + return { + response: stub.error(requestId, dispatched.error.code, dispatched.error.message), + events: [] + }; + } + return { + response: { id: requestId, result: dispatched.result }, + events: dispatched.events ?? [] + }; + } catch (error) { + return { + response: stub.error( + requestId, + -32603, + error instanceof Error ? error.message : String(error) + ), + events: [] + }; + } +} From 039bb3c37559fb10366f8cd0f2b393e32d8f6be7 Mon Sep 17 00:00:00 2001 From: code-new-bie <403466121@qq.com> Date: Wed, 19 Aug 2026 10:26:02 +0800 Subject: [PATCH 003/305] feat(m0): add local WebSocket protocol harness --- src/server.mjs | 102 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 102 insertions(+) create mode 100644 src/server.mjs diff --git a/src/server.mjs b/src/server.mjs new file mode 100644 index 00000000..62ed2f59 --- /dev/null +++ b/src/server.mjs @@ -0,0 +1,102 @@ +import { WebSocketServer } from 'ws'; +import { ProtocolStub, normalizeDispatchResult } from './protocol.mjs'; + +const DEFAULT_HOST = '127.0.0.1'; +const DEFAULT_EVENT_DELAY_MS = 18; + +function sleep(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +function sendJson(socket, message) { + if (socket.readyState === 1) socket.send(JSON.stringify(message)); +} + +async function sendEvents(socket, events, delayMs, stub) { + for (const event of events) { + const threadId = event?.params?.threadId; + const turnId = event?.params?.turnId ?? event?.params?.turn?.id; + const belongsToActiveTurn = + typeof threadId === 'string' && + typeof turnId === 'string' && + event.method !== 'turn/completed'; + + if (belongsToActiveTurn && !stub.isTurnActive(threadId, turnId)) break; + if (delayMs > 0) await sleep(delayMs); + if (belongsToActiveTurn && !stub.isTurnActive(threadId, turnId)) break; + + sendJson(socket, event); + if ( + event.method === 'turn/completed' && + typeof threadId === 'string' && + typeof turnId === 'string' + ) { + stub.completeTurn(threadId, turnId); + } + } +} + +export async function startProtocolStubServer({ + host = DEFAULT_HOST, + port = 0, + cwd = process.cwd(), + eventDelayMs = DEFAULT_EVENT_DELAY_MS, + log = () => {} +} = {}) { + const stub = new ProtocolStub({ cwd }); + const server = new WebSocketServer({ host, port }); + + server.on('connection', (socket, request) => { + log(`client connected from ${request.socket.remoteAddress ?? 'unknown'}`); + + socket.on('message', (raw) => { + let message; + try { + message = JSON.parse(raw.toString('utf8')); + } catch (error) { + sendJson(socket, { + id: null, + error: { + code: -32700, + message: `Parse error: ${error instanceof Error ? error.message : error}` + } + }); + return; + } + + // Notifications intentionally have no id and receive no response. + if (message?.id === undefined) { + log(`notification ${message?.method ?? ''}`); + return; + } + + log(`request ${message.method} id=${String(message.id)}`); + const { response, events } = normalizeDispatchResult(stub, message); + sendJson(socket, response); + if (events.length > 0) void sendEvents(socket, events, eventDelayMs, stub); + }); + + socket.on('error', (error) => log(`socket error: ${error.message}`)); + }); + + await new Promise((resolve, reject) => { + if (server.address()) return resolve(); + server.once('listening', resolve); + server.once('error', reject); + }); + + const address = server.address(); + if (!address || typeof address === 'string') { + throw new Error('Protocol stub server did not bind a TCP address'); + } + + return { + host, + port: address.port, + url: `ws://${host}:${address.port}`, + close: () => + new Promise((resolve, reject) => { + server.close((error) => (error ? reject(error) : resolve())); + }) + }; +} From b2fe82670796e8c548c732e1161a20e33519aa48 Mon Sep 17 00:00:00 2001 From: code-new-bie <403466121@qq.com> Date: Wed, 19 Aug 2026 10:26:16 +0800 Subject: [PATCH 004/305] feat(m0): add zero-argument dshx development launcher --- bin/dshx.mjs | 89 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 89 insertions(+) create mode 100644 bin/dshx.mjs diff --git a/bin/dshx.mjs b/bin/dshx.mjs new file mode 100644 index 00000000..6fd76118 --- /dev/null +++ b/bin/dshx.mjs @@ -0,0 +1,89 @@ +#!/usr/bin/env node +import { spawn, spawnSync } from 'node:child_process'; +import process from 'node:process'; +import { startProtocolStubServer } from '../src/server.mjs'; + +const VERSION = '0.0.0-m0'; + +function usage() { + return `DSHX ${VERSION}\n\nUsage:\n dshx Start the Codex TUI against the M0 compatibility stub\n dshx doctor Check development prerequisites\n dshx --version Print version\n dshx --help Show this help\n\nEnvironment:\n DSHX_CODEX_BIN Codex executable to launch (default: codex)\n DSHX_DEBUG=1 Print protocol-stub request logs\n\nM0 note:\n This branch intentionally uses Codex --remote only as a protocol proof.\n Production DSHX will ship its pinned thin-fork TUI and will not depend on\n the experimental WebSocket transport.\n`; +} + +function codexBinary() { + return process.env.DSHX_CODEX_BIN || 'codex'; +} + +function doctor() { + const executable = codexBinary(); + const check = spawnSync(executable, ['--version'], { encoding: 'utf8' }); + const rows = [ + ['Node', process.version, true], + [ + 'Codex', + check.status === 0 ? (check.stdout || check.stderr).trim() : `${executable} not runnable`, + check.status === 0 + ] + ]; + for (const [name, detail, ok] of rows) { + process.stdout.write(`${ok ? '✓' : '✗'} ${name}: ${detail}\n`); + } + if (rows.some((row) => !row[2])) process.exitCode = 1; +} + +async function run() { + const args = process.argv.slice(2); + if (args.includes('--help') || args.includes('-h')) { + process.stdout.write(usage()); + return; + } + if (args.includes('--version') || args.includes('-V')) { + process.stdout.write(`${VERSION}\n`); + return; + } + if (args[0] === 'doctor') { + doctor(); + return; + } + if (args.length > 0) { + process.stderr.write(`dshx: unsupported M0 argument: ${args.join(' ')}\n\n${usage()}`); + process.exitCode = 2; + return; + } + + const debug = process.env.DSHX_DEBUG === '1'; + const stub = await startProtocolStubServer({ + cwd: process.cwd(), + log: debug ? (message) => process.stderr.write(`[dshx] ${message}\n`) : () => {} + }); + + const executable = codexBinary(); + const child = spawn(executable, ['--remote', stub.url], { + cwd: process.cwd(), + env: process.env, + stdio: 'inherit' + }); + + let closing = false; + const close = async (exitCode) => { + if (closing) return; + closing = true; + try { + await stub.close(); + } catch { + // Best effort: process exit will release the local listener. + } + process.exitCode = exitCode; + }; + + child.on('error', async (error) => { + process.stderr.write(`dshx: failed to launch ${executable}: ${error.message}\n`); + process.stderr.write('Run `dshx doctor` and install the pinned Codex development prerequisite.\n'); + await close(127); + }); + child.on('exit', async (code, signal) => { + if (signal && debug) process.stderr.write(`[dshx] Codex exited via ${signal}\n`); + await close(code ?? 1); + }); +} + +await run(); From 25f97218a686ed21407a157b050c3cb0d184a971 Mon Sep 17 00:00:00 2001 From: code-new-bie <403466121@qq.com> Date: Wed, 19 Aug 2026 10:26:26 +0800 Subject: [PATCH 005/305] feat(m0): add standalone protocol stub server --- bin/dshx-stub-server.mjs | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) create mode 100644 bin/dshx-stub-server.mjs diff --git a/bin/dshx-stub-server.mjs b/bin/dshx-stub-server.mjs new file mode 100644 index 00000000..ac1bfaa6 --- /dev/null +++ b/bin/dshx-stub-server.mjs @@ -0,0 +1,19 @@ +#!/usr/bin/env node +import { startProtocolStubServer } from '../src/server.mjs'; + +const server = await startProtocolStubServer({ + log: (message) => process.stderr.write(`[dshx-stub] ${message}\n`) +}); + +process.stdout.write(`${server.url}\n`); + +async function shutdown() { + try { + await server.close(); + } finally { + process.exit(0); + } +} + +process.on('SIGINT', shutdown); +process.on('SIGTERM', shutdown); From e59f3c9704e0b13ee3db1792efbbf48a2d31e70a Mon Sep 17 00:00:00 2001 From: code-new-bie <403466121@qq.com> Date: Wed, 19 Aug 2026 10:26:40 +0800 Subject: [PATCH 006/305] test(m0): cover protocol handshake streaming and interrupt --- test/protocol.test.mjs | 82 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 82 insertions(+) create mode 100644 test/protocol.test.mjs diff --git a/test/protocol.test.mjs b/test/protocol.test.mjs new file mode 100644 index 00000000..fcac1192 --- /dev/null +++ b/test/protocol.test.mjs @@ -0,0 +1,82 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { ProtocolStub, normalizeDispatchResult } from '../src/protocol.mjs'; + +function request(stub, id, method, params = {}) { + return normalizeDispatchResult(stub, { id, method, params }); +} + +test('initialize matches Codex remote handshake fields', () => { + const stub = new ProtocolStub({ cwd: '/tmp/workspace', home: '/tmp/dshx-home' }); + const { response } = request(stub, 'initialize', 'initialize', { + clientInfo: { name: 'codex-tui', version: 'test' } + }); + assert.equal(response.id, 'initialize'); + assert.equal(response.result.codexHome, '/tmp/dshx-home'); + assert.match(response.result.userAgent, /^dsh-codex-app-server\//); + assert.equal(typeof response.result.platformFamily, 'string'); + assert.equal(typeof response.result.platformOs, 'string'); +}); + +test('bootstrap methods return auth-free model catalog', () => { + const stub = new ProtocolStub({ cwd: '/tmp/workspace' }); + assert.deepEqual(request(stub, 1, 'account/read').response.result, { + account: null, + requiresOpenaiAuth: false + }); + const models = request(stub, 2, 'model/list').response.result; + assert.equal(models.data.length, 1); + assert.equal(models.data[0].model, 'dshx-stub'); + assert.equal(models.data[0].isDefault, true); + assert.deepEqual(request(stub, 3, 'configRequirements/read').response.result, { + requirements: null + }); +}); + +test('thread start emits thread/started and uses DSH-like provider identity', () => { + const stub = new ProtocolStub({ cwd: '/tmp/workspace' }); + const { response, events } = request(stub, 4, 'thread/start', { cwd: '/tmp/workspace' }); + assert.equal(response.result.modelProvider, 'dsh'); + assert.equal(response.result.thread.modelProvider, 'dsh'); + assert.equal(response.result.thread.status.type, 'idle'); + assert.equal(events[0].method, 'thread/started'); + assert.equal(events[0].params.thread.id, response.result.thread.id); +}); + +test('turn start emits the minimal Codex streaming lifecycle', () => { + const stub = new ProtocolStub({ cwd: '/tmp/workspace' }); + const thread = request(stub, 4, 'thread/start', { cwd: '/tmp/workspace' }).response.result.thread; + const { response, events } = request(stub, 5, 'turn/start', { + threadId: thread.id, + input: [{ type: 'text', text: 'hello dshx', text_elements: [] }] + }); + assert.equal(response.result.turn.status, 'inProgress'); + assert.equal(events[0].method, 'turn/started'); + assert.equal(events[1].method, 'item/started'); + assert.ok(events.some((event) => event.method === 'item/agentMessage/delta')); + assert.equal(events.at(-2).method, 'item/completed'); + assert.equal(events.at(-1).method, 'turn/completed'); + assert.match(events.at(-2).params.item.text, /hello dshx/); +}); + +test('interrupt emits an interrupted turn completion', () => { + const stub = new ProtocolStub({ cwd: '/tmp/workspace' }); + const thread = request(stub, 10, 'thread/start', { cwd: '/tmp/workspace' }).response.result.thread; + const started = request(stub, 11, 'turn/start', { + threadId: thread.id, + input: [{ type: 'text', text: 'long task', text_elements: [] }] + }); + const turnId = started.response.result.turn.id; + assert.equal(stub.isTurnActive(thread.id, turnId), true); + const interrupted = request(stub, 12, 'turn/interrupt', { threadId: thread.id, turnId }); + assert.deepEqual(interrupted.response.result, {}); + assert.equal(stub.isTurnActive(thread.id, turnId), false); + assert.equal(interrupted.events[0].method, 'turn/completed'); + assert.equal(interrupted.events[0].params.turn.status, 'interrupted'); +}); + +test('unknown method is rejected with JSON-RPC method-not-found', () => { + const stub = new ProtocolStub(); + const { response } = normalizeDispatchResult(stub, { id: 9, method: 'nope/unknown', params: {} }); + assert.equal(response.error.code, -32601); +}); From c69a3f4a1fcd1d32554757677fc11b08acc16fbb Mon Sep 17 00:00:00 2001 From: code-new-bie <403466121@qq.com> Date: Wed, 19 Aug 2026 10:26:56 +0800 Subject: [PATCH 007/305] docs: define delivery team and ownership boundary --- docs/TEAM.md | 54 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 54 insertions(+) create mode 100644 docs/TEAM.md diff --git a/docs/TEAM.md b/docs/TEAM.md new file mode 100644 index 00000000..69515bb6 --- /dev/null +++ b/docs/TEAM.md @@ -0,0 +1,54 @@ +# DSHX delivery team + +This repository is organized around six engineering roles. These are workstreams, not ownership of DeepSeek Harness internals. + +## 1. Codex TUI upstream + +Owns the pinned Codex TUI source, upstream syncs, patch minimization, branding deltas, terminal rendering and keyboard behavior. + +**May change:** TUI source and narrow extension points. + +**Must not change:** DSH runtime behavior. + +## 2. DSH public-API integration + +Tracks the public DeepSeek Harness interfaces needed by the TUI and converts them into a stable adapter boundary. + +**May change:** adapter calls, compatibility detection and graceful unsupported states. + +**Must not implement:** agent loop, session engine, tools, sandbox, approval policy, skills, subagents, jobs or workflows. + +## 3. Protocol mapping + +Owns Codex Thread/Turn/Item ↔ DSH public event/API projections and compatibility tests. Unknown or unsafe permission semantics fail closed. + +## 4. Terminal and Windows UX + +Owns side-by-side Codex parity on Windows Terminal first, plus Linux/macOS, resize, scrolling, mouse, CJK/IME, Ctrl+C and shell/terminal edge cases. + +## 5. QA and security + +Owns protocol fixtures, regression tests, crash/reconnect tests, approval fail-closed checks and release blockers. Visual completeness alone can never pass a release gate. + +## 6. Release and upstream operations + +Owns `dshx` packaging, reproducible builds, checksums, `dshx doctor`, platform artifacts and upstream pin/sync policy. + +## Non-negotiable ownership rule + +`dsh-codex` maintains **TUI + launcher + the thinnest practical public-API adapter**. + +DeepSeek Harness remains an upstream dependency. If DSH does not expose a capability that Codex UI can display, DSHX must hide/disable/degrade that UI or wait for upstream. It must not recreate the missing capability locally. + +A PR is out of scope if its primary purpose is to reimplement a DeepSeek Harness capability rather than present or adapt one. + +## Product command contract + +Production 1.0 requires this to be the normal path: + +```bash +cd +dshx +``` + +No user-facing `codex --remote`, bridge process, manual profile command or package-manager orchestration is acceptable in the production launch path. From fc7ebe3c1bc442d753d60516e424838c2717dda1 Mon Sep 17 00:00:00 2001 From: code-new-bie <403466121@qq.com> Date: Wed, 19 Aug 2026 10:27:14 +0800 Subject: [PATCH 008/305] docs: lock DSHX product and ownership contract --- README.md | 81 +++++++++++++++++++++++++++++++++++-------------------- 1 file changed, 52 insertions(+), 29 deletions(-) diff --git a/README.md b/README.md index 0c8342b0..e024a96c 100644 --- a/README.md +++ b/README.md @@ -1,60 +1,83 @@ -# dsh-codex +# DSHX (`dsh-codex`) -A production-oriented DeepSeek Harness CLI that reuses the Codex terminal UX as closely as practical while keeping **DeepSeek Harness as the agent runtime and source of truth**. +DSHX is a production-oriented terminal frontend for **official DeepSeek Harness** with a Codex-grade interaction target. -## Goal +## Product goal -`dsh-codex` is not another Codex-like TUI. The target is a thin Codex TUI fork plus a DSH backend compatibility layer, so the visible interaction model stays aligned with Codex while sessions, model routing, tools, sandboxing, approvals, skills and subagents remain owned by DSH. +From any code project, the normal production entry point must be: + +```bash +dshx +``` + +The user should get an experience that tracks the pinned Codex CLI/TUI as closely as practical: welcome screen, composer, slash commands, model/permission pickers, approvals, tool/shell cells, diffs, plan/reasoning, steering, interrupt, resume, status/footer, scrolling, resize, mouse and CJK/IME behavior. + +## Ownership boundary + +We maintain only: + +- the Codex TUI thin fork / upstream sync; +- TUI behavior and UX parity; +- the `dshx` launcher and packaging; +- the thinnest practical adapter from DSH **public APIs/events** to the TUI protocol. + +We do **not** maintain, fork or recreate DeepSeek Harness capabilities. DSH owns the agent loop, sessions, model routing, tools, sandbox, approval policy, skills, subagents, plugins, jobs/workflows and persistence. + +If DSH does not expose a capability, DSHX waits for upstream or degrades/hides the corresponding UI. It does not implement a replacement runtime. ```text -Codex TUI (thin fork) +Codex TUI thin fork ← dsh-codex owns presentation │ - │ app-server compatible protocol + │ app-server-compatible projection ▼ -dsh-codex adapter +thin DSH UI adapter ← dsh-codex owns translation only │ - ▼ -DeepSeek Harness / Cordis +════════════════ ownership boundary ════════════════ │ - ├─ Session - ├─ Agent loop - ├─ LLM routing - ├─ Tools - ├─ Sandbox / Approval - ├─ Skills - └─ Subagents + ▼ +Official DeepSeek Harness ← upstream owns capabilities/runtime ``` ## Product principles -1. **DSH is authoritative.** No second session database and no second policy engine. -2. **Reuse Codex UI instead of imitating it.** Keep the upstream TUI diff small and auditable. -3. **Protocol boundary first.** Translate DSH Session/Agent/Event concepts into Codex Thread/Turn/Item concepts. -4. **Production transports only.** Experimental remote transports are useful for development, not the final runtime dependency. -5. **Windows is a first-class target.** Windows Terminal/PowerShell behavior is part of the release gate. +1. **Reuse Codex UI instead of imitating it.** Keep upstream rendering/input code as intact as practical. +2. **DSH is authoritative.** Never create a second session database, policy engine or agent runtime. +3. **Adapter, not framework.** Protocol translation is allowed; reimplementation of DSH capabilities is not. +4. **Fail closed.** Permission/approval semantics that cannot be represented faithfully must never be silently weakened. +5. **`dshx` is the product command.** Production users do not manually run Codex remote mode, bridge processes or DSH profile plumbing. +6. **Windows is first-class.** Windows Terminal/PowerShell and Chinese IME are release gates, not follow-up polish. + +## Current status -## Repository status +`main` contains the architecture baseline. Active M0 work is on `work/protocol-poc`. -The repository is being reset around the thin-fork architecture. The earlier custom `pi-tui` prototype is preserved separately for reference and is **not** the target implementation. +M0 intentionally uses Codex remote/app-server transport only as a development harness to prove the UI/protocol seam. It is **not** the production transport. The production target is a pinned Codex TUI thin fork packaged behind `dshx`. + +The earlier custom `pi-tui` implementation is a legacy prototype and is not the target product. See: - [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) +- [`docs/TEAM.md`](docs/TEAM.md) - [`docs/ROADMAP.md`](docs/ROADMAP.md) - [`docs/UX-PARITY.md`](docs/UX-PARITY.md) - [`CONTRIBUTING.md`](CONTRIBUTING.md) - [`SECURITY.md`](SECURITY.md) -## Intended CLI +## M0 development launcher + +On the protocol-PoC branch, after installing dependencies and the pinned development Codex CLI: ```bash -dsh-codex -# or eventually -dsh --profile codex +npm install +npm link + +dshx doctor +dshx ``` -The user-facing experience should intentionally track Codex for the welcome screen, composer, slash commands, model/permission pickers, approvals, tool cells, diffs, plans, steering, resume picker, status/footer, scrolling and keyboard behavior. +This development launcher starts a deterministic compatibility stub and attaches the Codex TUI to it. The next milestone replaces the stub with a thin adapter over official DSH public APIs. ## License -Apache-2.0. Any vendored upstream code keeps its original notices and license requirements. +Apache-2.0 for project-owned code. Vendored/synchronized upstream code keeps its applicable upstream notices and license requirements. From 63ba3a0a567756ae4379faa4713dd9df13173d9e Mon Sep 17 00:00:00 2001 From: code-new-bie <403466121@qq.com> Date: Wed, 19 Aug 2026 10:27:32 +0800 Subject: [PATCH 009/305] docs: make TUI-only ownership boundary normative --- docs/ARCHITECTURE.md | 113 ++++++++++++++++++++++++++----------------- 1 file changed, 68 insertions(+), 45 deletions(-) diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index c004c68b..2139b10f 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -2,94 +2,117 @@ ## Objective -`dsh-codex` should preserve the Codex terminal interaction model while replacing the Codex agent backend with DeepSeek Harness. +DSHX is a terminal frontend for official DeepSeek Harness. Its purpose is to preserve the pinned Codex terminal interaction model while projecting DSH public runtime state into that UI. -## Ownership boundary +It is **not** a fork of DeepSeek Harness and must not grow into an agent framework. + +## Normative ownership boundary | Concern | Owner | |---|---| -| Terminal layout / composer / keyboard / overlays | Codex TUI thin fork | -| Session persistence | DSH | -| Agent loop | DSH | -| Model routing | DSH | -| Tool registry and execution | DSH | -| Sandbox and approval policy | DSH | -| Skills / subagents / jobs | DSH | -| Protocol translation | dsh-codex adapter | +| Terminal layout / composer / keyboard / overlays | DSHX / Codex TUI thin fork | +| UX parity and branding | DSHX | +| `dshx` launcher / packaging | DSHX | +| Public API/event → TUI protocol projection | DSHX thin adapter | +| Session persistence | DeepSeek Harness upstream | +| Agent loop | DeepSeek Harness upstream | +| Model routing | DeepSeek Harness upstream | +| Tool registry and execution | DeepSeek Harness upstream | +| Sandbox and approval policy | DeepSeek Harness upstream | +| Skills / subagents / plugins / jobs | DeepSeek Harness upstream | + +The adapter may translate and cache disposable presentation state. It must never become a second source of truth for session history, policy, model configuration, tools or agent state. -The TUI must never become a second source of truth for session history, tool policy, model configuration or agent state. +If a DSH capability is unavailable or cannot be represented safely, DSHX must disable, hide or explicitly mark the corresponding UI unsupported. It must **not** implement the missing runtime capability. ## Target topology ```text ┌─────────────────────────────────────┐ -│ Codex TUI thin fork │ +│ Codex TUI thin fork │ project-owned presentation │ │ │ welcome / composer / cells / diff │ │ slash commands / pickers / footer │ └──────────────────┬──────────────────┘ │ - app-server-compatible API + app-server-compatible view │ ┌──────────────────▼──────────────────┐ -│ dsh-codex protocol adapter │ +│ dsh-codex thin adapter │ project-owned translation │ │ -│ Thread ↔ Session │ -│ Turn ↔ Agent turn │ -│ Item ↔ DSH event/tool projection │ +│ Thread ↔ Session id/projection │ +│ Turn ↔ DSH turn lifecycle │ +│ Item ↔ exposed DSH events │ └──────────────────┬──────────────────┘ - │ Cordis +═══════════════════╪════════════════════ ownership boundary + │ official public APIs/events ┌──────────────────▼──────────────────┐ -│ DeepSeek Harness │ +│ DeepSeek Harness │ upstream-owned runtime │ │ │ agents / sessions / llm / tools │ -│ sandbox / approvals / skills │ +│ sandbox / approvals / skills / ... │ └─────────────────────────────────────┘ ``` ## Protocol mapping -The adapter starts with a deliberately small vertical slice. +Mappings are UI projections, not replacement implementations. -| Codex concept | DSH concept | +| Codex concept | DSH source | |---|---| -| Thread | Session | -| `thread/start` | create DSH Session + Agent | -| `thread/resume` | inspect and resume DSH Session | -| `thread/list` | DSH session catalog | -| Turn | one user interaction with a DSH Agent | -| agent-message delta | model/agent streaming event | -| command execution item | DSH shell/tool event | -| file-change item | DSH filesystem/editor event | -| approval request | DSH approval request | -| user-input request | DSH ask-user request | -| plan update | DSH plan/todo projection | -| turn interrupt | DSH turn cancellation | +| Thread | DSH Session identity and exposed metadata | +| `thread/start` | official DSH Session/Agent creation API | +| `thread/resume` | official DSH Session inspection/resume API | +| `thread/list` | official DSH session catalog | +| Turn | official DSH user-interaction lifecycle | +| agent-message delta | exposed DSH agent/model streaming event | +| command execution item | exposed DSH tool/shell event | +| file-change item | exposed DSH filesystem/editor event | +| approval request | official DSH approval request | +| user-input request | official DSH ask-user request | +| plan update | exposed DSH plan/todo state | +| turn interrupt | official DSH cancellation/control API | + +Where no official mapping exists, the adapter reports unsupported instead of synthesizing a shadow capability. ## Session invariants -1. DSH session IDs are authoritative. -2. Resume reads model/provider/reasoning configuration from persisted DSH session events where available. -3. The TUI may cache presentation state, but it must be disposable. -4. No Codex session database is used for DSH conversations. +1. DSH session IDs and persisted events are authoritative. +2. DSHX does not keep a parallel conversation database. +3. Resume obtains model/provider/reasoning state from DSH through supported interfaces; the TUI does not invent replacements. +4. TUI caches are disposable and reconstructable. +5. DSHX never changes permission semantics to make a Codex widget easier to reuse. -## Upstream strategy +## Codex upstream strategy -The Codex TUI fork should be pinned to a known upstream commit. Upstream changes are integrated as explicit sync commits. Product-specific modifications should live behind narrow traits/interfaces whenever practical. +The TUI is pinned to a known `openai/codex` commit. Upstream changes are integrated as explicit sync commits. Project-specific changes should live behind narrow extension points so rendering/input code remains close to upstream. -The desired long-term diff is: +Desired long-term diff: ```text Codex TUI upstream 95%+ unchanged -DSH adapter project-owned +DSH UI adapter project-owned and thin Branding minimal patch -DSH-specific commands isolated extension points +DSH-specific UI isolated extension points only ``` ## Runtime transport -Development may use Codex remote/app-server modes to validate mapping quickly. A production release should use a local production-grade transport (same process, stdio, or local socket) and must not depend on an upstream transport explicitly documented as experimental. +M0 may use Codex remote/app-server WebSocket mode strictly as a development protocol harness. It is not an accepted production dependency. + +Production `dshx` must hide transport/runtime orchestration from the user and use a local supported integration path (for example in-process, stdio or a production-grade local socket) consistent with the pinned TUI architecture. + +## Launcher contract + +Production 1.0 must support: + +```bash +cd +dshx +``` + +The user must not need to invoke `codex --remote`, a bridge script, package-manager commands or DSH profile plumbing for normal startup. ## Security model -The UI renders approval decisions; DSH enforces them. The adapter must not silently upgrade permissions. Any translation that cannot faithfully represent a DSH permission request must fail closed and surface an explicit unsupported-policy message. +The TUI renders approval decisions; DSH enforces them. Translation must never silently upgrade privileges. If the current Codex UI/protocol cannot faithfully represent a DSH permission request, DSHX fails closed and surfaces an explicit incompatibility. From f724ab6450d5698f9e217a2989456161ac191f56 Mon Sep 17 00:00:00 2001 From: code-new-bie <403466121@qq.com> Date: Wed, 19 Aug 2026 10:27:49 +0800 Subject: [PATCH 010/305] docs: align roadmap with TUI-only ownership and dshx launch contract --- docs/ROADMAP.md | 86 ++++++++++++++++++++++++++++--------------------- 1 file changed, 50 insertions(+), 36 deletions(-) diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 1d2a4f02..c1f24b7b 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -1,59 +1,73 @@ # Roadmap -## Milestone 0 — Protocol proof +All milestones obey one ownership rule: **DSHX maintains the TUI, launcher and thin public-API adapter only. DeepSeek Harness capabilities remain upstream-owned.** -Goal: prove that an unmodified or minimally modified Codex TUI can render a DSH-backed conversation. +## Milestone 0 — Codex TUI protocol proof -- Pin a Codex upstream commit. -- Implement initialize / thread start / turn start / agent-message streaming. -- Map one DSH read/search tool event into a Codex item. -- Support interrupt. -- Run on Windows Terminal and Linux. +Goal: prove that the pinned Codex TUI can be driven without Codex's agent runtime. -Exit gate: `input → DSH → tool → streaming answer` works end to end in the Codex TUI. +- Pin Codex and DSH upstream commits. +- Implement a deterministic app-server-compatible development stub. +- Support the TUI bootstrap requests: initialize, account/read, model/list, configRequirements/read and thread/start. +- Support turn/start, agent-message streaming and turn/interrupt. +- Expose a zero-argument development launcher: `dshx`. +- Run the proof on Windows Terminal and Linux. -## Milestone 1 — Daily coding loop +Exit gate: the pinned Codex TUI launches from `dshx`, accepts a user prompt and renders the deterministic streaming lifecycle through our protocol seam. -- Session resume/list. -- Shell command cells. -- File change + unified diff. -- Approval requests. -- Ask-user prompts. -- Steering during an active turn. -- Model picker backed by DSH model registry. -- Context/token footer. -- Reliable Ctrl+C behavior. +The WebSocket remote path is a development harness only and is removed from the production launch path later. -Exit gate: the CLI can be used for a full coding task without falling back to DSH WebUI. +## Milestone 1 — Official DSH public-API adapter / daily coding loop -## Milestone 2 — UX parity +Replace the deterministic stub with calls to **official DeepSeek Harness public APIs/events only**. + +- Session start/resume/list projection. +- Agent response streaming. +- Shell/tool cells from exposed DSH events. +- File change + unified diff from exposed DSH events. +- Approval and ask-user presentation with DSH policy remaining authoritative. +- Steering and reliable Ctrl+C through official DSH control APIs. +- Model picker backed by official DSH model registry/public surface. +- Context/token footer where DSH exposes the data. + +Exit gate: a complete coding task can be performed without DSH WebUI and without implementing any DSH runtime capability in this repository. + +## Milestone 2 — Codex UX parity - Welcome screen parity. -- Slash command palette parity. -- `/model`, `/permissions`, `/status`, `/review` behavior parity where concepts map cleanly. -- Resume picker parity. -- Plan/reasoning cells. +- Composer and slash command parity. +- `/model`, `/permissions`, `/status`, `/review` behavior parity where DSH exposes matching semantics. +- Resume picker parity using DSH session data. +- Plan/reasoning cells where exposed by DSH. - Scroll/search/mouse/resize behavior. - Windows Terminal and CJK/IME regression tests. -Exit gate: side-by-side comparison with the pinned Codex release passes the UX parity matrix. +Exit gate: side-by-side comparison with the pinned Codex release passes the UX parity matrix. Unsupported DSH semantics are explicit, never silently emulated. -## Milestone 3 — DSH-native capabilities +## Milestone 3 — DSH capability surfaces (presentation only) -- Skills. -- Subagents. -- Jobs/workflows. +Expose additional DSH capabilities **only when official DSH interfaces exist**: + +- Skills UI. +- Subagent UI. +- Jobs/workflows UI. +- Plugin diagnostics/configuration UI. - DSH-specific command namespace without disturbing Codex-compatible commands. -- Plugin diagnostics and configuration surface. -## Milestone 4 — Production 1.0 +This milestone adds presentation and translation, not new runtime implementations. + +## Milestone 4 — Production packaging and 1.0 gate -- Versioned protocol compatibility contract. -- Crash recovery and session durability tests. +- `dshx` zero-argument startup with no manual bridge/profile/remote command. +- Pinned Codex TUI thin fork included in release artifacts; no separate Codex install required for normal use. +- Supported local runtime integration; no production dependency on experimental WebSocket remote mode. +- Versioned protocol/DSH compatibility contract. +- Crash recovery and DSH session durability projection tests. - Security/approval fail-closed tests. - Windows/Linux/macOS release artifacts. - Reproducible CI builds and checksums. -- Upstream sync playbook. -- Release candidate dogfooding gate. +- `dshx doctor` diagnostics. +- Codex/DSH upstream sync playbook. +- Release-candidate dogfooding gate. -1.0 is released only when the daily coding loop and UX parity matrix are both green on supported platforms. +1.0 ships only when the daily coding loop, UX parity matrix, security checks and zero-argument `dshx` launch contract are green on supported platforms. From f9201e1f6533b66b97c5bb5f7b5154873e496834 Mon Sep 17 00:00:00 2001 From: code-new-bie <403466121@qq.com> Date: Wed, 19 Aug 2026 10:27:59 +0800 Subject: [PATCH 011/305] ci: add Windows and Linux M0 protocol gate --- .github/workflows/ci.yml | 41 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) create mode 100644 .github/workflows/ci.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 00000000..1c25e80b --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,41 @@ +name: CI + +on: + push: + branches: [main, 'work/**'] + pull_request: + +permissions: + contents: read + +jobs: + protocol: + name: Protocol / Node ${{ matrix.node }} + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, windows-latest] + node: [20, 22] + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: ${{ matrix.node }} + cache: npm + - run: npm install + - run: npm test + - run: node --check src/protocol.mjs + - run: node --check src/server.mjs + - run: node --check bin/dshx.mjs + + ownership-guard: + name: Ownership boundary + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Assert architecture contract exists + shell: bash + run: | + grep -q "must not grow into an agent framework" docs/ARCHITECTURE.md + grep -q "DeepSeek Harness remains an upstream dependency" docs/TEAM.md From 6b61665cf0a2b51c488b73dc6f9dea25481c11fc Mon Sep 17 00:00:00 2001 From: code-new-bie <403466121@qq.com> Date: Wed, 19 Aug 2026 10:29:06 +0800 Subject: [PATCH 012/305] ci: avoid npm cache without lockfile --- .github/workflows/ci.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1c25e80b..f248c344 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -22,7 +22,6 @@ jobs: - uses: actions/setup-node@v4 with: node-version: ${{ matrix.node }} - cache: npm - run: npm install - run: npm test - run: node --check src/protocol.mjs From 0fbb60c58a8b2fa48d40c090297cda588acf16b3 Mon Sep 17 00:00:00 2001 From: code-new-bie <403466121@qq.com> Date: Wed, 19 Aug 2026 10:33:07 +0800 Subject: [PATCH 013/305] test(windows): make protocol paths platform-independent --- test/protocol.test.mjs | 24 +++++++++++++++--------- 1 file changed, 15 insertions(+), 9 deletions(-) diff --git a/test/protocol.test.mjs b/test/protocol.test.mjs index fcac1192..af88e639 100644 --- a/test/protocol.test.mjs +++ b/test/protocol.test.mjs @@ -1,4 +1,5 @@ import assert from 'node:assert/strict'; +import path from 'node:path'; import test from 'node:test'; import { ProtocolStub, normalizeDispatchResult } from '../src/protocol.mjs'; @@ -6,20 +7,23 @@ function request(stub, id, method, params = {}) { return normalizeDispatchResult(stub, { id, method, params }); } +const workspace = path.resolve('tmp', 'workspace'); +const dshxHome = path.resolve('tmp', 'dshx-home'); + test('initialize matches Codex remote handshake fields', () => { - const stub = new ProtocolStub({ cwd: '/tmp/workspace', home: '/tmp/dshx-home' }); + const stub = new ProtocolStub({ cwd: workspace, home: dshxHome }); const { response } = request(stub, 'initialize', 'initialize', { clientInfo: { name: 'codex-tui', version: 'test' } }); assert.equal(response.id, 'initialize'); - assert.equal(response.result.codexHome, '/tmp/dshx-home'); + assert.equal(response.result.codexHome, dshxHome); assert.match(response.result.userAgent, /^dsh-codex-app-server\//); assert.equal(typeof response.result.platformFamily, 'string'); assert.equal(typeof response.result.platformOs, 'string'); }); test('bootstrap methods return auth-free model catalog', () => { - const stub = new ProtocolStub({ cwd: '/tmp/workspace' }); + const stub = new ProtocolStub({ cwd: workspace }); assert.deepEqual(request(stub, 1, 'account/read').response.result, { account: null, requiresOpenaiAuth: false @@ -34,18 +38,20 @@ test('bootstrap methods return auth-free model catalog', () => { }); test('thread start emits thread/started and uses DSH-like provider identity', () => { - const stub = new ProtocolStub({ cwd: '/tmp/workspace' }); - const { response, events } = request(stub, 4, 'thread/start', { cwd: '/tmp/workspace' }); + const stub = new ProtocolStub({ cwd: workspace }); + const { response, events } = request(stub, 4, 'thread/start', { cwd: workspace }); assert.equal(response.result.modelProvider, 'dsh'); assert.equal(response.result.thread.modelProvider, 'dsh'); assert.equal(response.result.thread.status.type, 'idle'); + assert.equal(response.result.cwd, workspace); + assert.equal(response.result.thread.cwd, workspace); assert.equal(events[0].method, 'thread/started'); assert.equal(events[0].params.thread.id, response.result.thread.id); }); test('turn start emits the minimal Codex streaming lifecycle', () => { - const stub = new ProtocolStub({ cwd: '/tmp/workspace' }); - const thread = request(stub, 4, 'thread/start', { cwd: '/tmp/workspace' }).response.result.thread; + const stub = new ProtocolStub({ cwd: workspace }); + const thread = request(stub, 4, 'thread/start', { cwd: workspace }).response.result.thread; const { response, events } = request(stub, 5, 'turn/start', { threadId: thread.id, input: [{ type: 'text', text: 'hello dshx', text_elements: [] }] @@ -60,8 +66,8 @@ test('turn start emits the minimal Codex streaming lifecycle', () => { }); test('interrupt emits an interrupted turn completion', () => { - const stub = new ProtocolStub({ cwd: '/tmp/workspace' }); - const thread = request(stub, 10, 'thread/start', { cwd: '/tmp/workspace' }).response.result.thread; + const stub = new ProtocolStub({ cwd: workspace }); + const thread = request(stub, 10, 'thread/start', { cwd: workspace }).response.result.thread; const started = request(stub, 11, 'turn/start', { threadId: thread.id, input: [{ type: 'text', text: 'long task', text_elements: [] }] From db6c99d6bc262461d633bbf0cc4ef90b6f2e4a3c Mon Sep 17 00:00:00 2001 From: code-new-bie <403466121@qq.com> Date: Wed, 19 Aug 2026 10:35:56 +0800 Subject: [PATCH 014/305] test(m0): validate real WebSocket wire lifecycle --- test/wire.test.mjs | 151 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 151 insertions(+) create mode 100644 test/wire.test.mjs diff --git a/test/wire.test.mjs b/test/wire.test.mjs new file mode 100644 index 00000000..6c605e6e --- /dev/null +++ b/test/wire.test.mjs @@ -0,0 +1,151 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { WebSocket } from 'ws'; +import { startProtocolStubServer } from '../src/server.mjs'; + +function openSocket(url) { + return new Promise((resolve, reject) => { + const socket = new WebSocket(url); + socket.once('open', () => resolve(socket)); + socket.once('error', reject); + }); +} + +function createInbox(socket) { + const queued = []; + const waiters = []; + + socket.on('message', (raw) => { + const message = JSON.parse(raw.toString('utf8')); + const waiter = waiters.shift(); + if (waiter) waiter.resolve(message); + else queued.push(message); + }); + + socket.on('error', (error) => { + for (const waiter of waiters.splice(0)) waiter.reject(error); + }); + + return { + async next(timeoutMs = 2000) { + if (queued.length > 0) return queued.shift(); + let timer; + try { + return await new Promise((resolve, reject) => { + const waiter = { resolve, reject }; + waiters.push(waiter); + timer = setTimeout(() => { + const index = waiters.indexOf(waiter); + if (index !== -1) waiters.splice(index, 1); + reject(new Error(`Timed out waiting for WebSocket message after ${timeoutMs}ms`)); + }, timeoutMs); + }); + } finally { + clearTimeout(timer); + } + }, + + async until(predicate, timeoutMs = 3000) { + const deadline = Date.now() + timeoutMs; + const seen = []; + while (Date.now() < deadline) { + const message = await this.next(Math.max(1, deadline - Date.now())); + seen.push(message); + if (predicate(message)) return { message, seen }; + } + throw new Error('Timed out waiting for matching WebSocket message'); + } + }; +} + +function send(socket, message) { + socket.send(JSON.stringify(message)); +} + +function closeSocket(socket) { + return new Promise((resolve) => { + if (socket.readyState === WebSocket.CLOSED) return resolve(); + socket.once('close', resolve); + socket.close(); + }); +} + +test('wire: Codex-style initialize → thread → streamed turn lifecycle', async (t) => { + const server = await startProtocolStubServer({ eventDelayMs: 2 }); + t.after(async () => server.close()); + + const socket = await openSocket(server.url); + t.after(async () => closeSocket(socket)); + const inbox = createInbox(socket); + + send(socket, { + id: 'initialize', + method: 'initialize', + params: { clientInfo: { name: 'codex-tui', version: 'wire-test' } } + }); + const initialize = await inbox.next(); + assert.equal(initialize.id, 'initialize'); + assert.equal(initialize.result.platformFamily.length > 0, true); + assert.equal(Object.hasOwn(initialize, 'jsonrpc'), false); + + // Codex sends this after a successful initialize response. It is a notification, + // so the server must accept it without replying. + send(socket, { method: 'initialized' }); + + for (const [id, method, expected] of [ + [1, 'account/read', (result) => result.requiresOpenaiAuth === false], + [2, 'model/list', (result) => result.data?.[0]?.model === 'dshx-stub'], + [3, 'configRequirements/read', (result) => result.requirements === null] + ]) { + send(socket, { id, method, params: {} }); + const response = await inbox.until((message) => message.id === id); + assert.equal(expected(response.message.result), true, `${method} returned unexpected result`); + } + + send(socket, { id: 4, method: 'thread/start', params: {} }); + const threadStart = await inbox.until((message) => message.id === 4); + const threadId = threadStart.message.result.thread.id; + assert.equal(typeof threadId, 'string'); + + const threadStarted = await inbox.until( + (message) => message.method === 'thread/started' && message.params?.thread?.id === threadId + ); + assert.equal(threadStarted.message.params.thread.modelProvider, 'dsh'); + + send(socket, { + id: 5, + method: 'turn/start', + params: { + threadId, + input: [{ type: 'text', text: 'wire hello', text_elements: [] }] + } + }); + + const turnResponse = await inbox.until((message) => message.id === 5); + const turnId = turnResponse.message.result.turn.id; + assert.equal(turnResponse.message.result.turn.status, 'inProgress'); + + const events = []; + while (true) { + const message = await inbox.next(); + if (message.method) events.push(message); + if ( + message.method === 'turn/completed' && + message.params?.threadId === threadId && + message.params?.turn?.id === turnId + ) break; + } + + const methods = events.map((event) => event.method); + assert.equal(methods[0], 'turn/started'); + assert.equal(methods[1], 'item/started'); + assert.ok(methods.includes('item/agentMessage/delta')); + assert.equal(methods.at(-2), 'item/completed'); + assert.equal(methods.at(-1), 'turn/completed'); + + const deltas = events + .filter((event) => event.method === 'item/agentMessage/delta') + .map((event) => event.params.delta) + .join(''); + assert.match(deltas, /wire hello/); +}); From caf6041187c2c87cc9e4bd2ebfb0873b4d617ebf Mon Sep 17 00:00:00 2001 From: code-new-bie <403466121@qq.com> Date: Wed, 19 Aug 2026 10:36:33 +0800 Subject: [PATCH 015/305] docs: add DSH public capability mapping guardrail --- docs/DSH-CAPABILITY-MATRIX.md | 96 +++++++++++++++++++++++++++++++++++ 1 file changed, 96 insertions(+) create mode 100644 docs/DSH-CAPABILITY-MATRIX.md diff --git a/docs/DSH-CAPABILITY-MATRIX.md b/docs/DSH-CAPABILITY-MATRIX.md new file mode 100644 index 00000000..7b4fd9c1 --- /dev/null +++ b/docs/DSH-CAPABILITY-MATRIX.md @@ -0,0 +1,96 @@ +# DSH public capability matrix + +This document is a guardrail for the DSHX adapter. Every Codex-facing behavior must map to an **official DeepSeek Harness public API/event**. If no suitable public capability exists at the pinned DSH commit, DSHX marks the surface unsupported or degrades the presentation; it does not implement a replacement runtime. + +Pinned DSH commit: `99f6f02fecdb7dff40c3fbc9470f5907c29f74ca` + +## Core interaction mapping + +| Codex/TUI need | Official DSH source | DSHX responsibility | Status | +|---|---|---|---| +| Start a conversation | `ctx.agents.create(...)` | Project result into `thread/start` / Thread UI | Verified | +| Resume a conversation | `ctx.agents.resume({ resumeSessionId })` | Project resumed Session into Thread/resume UI | Verified | +| Live agent lookup | `ctx.agents.get(id)`, `list()`, `roots()` | Presentation/navigation only | Verified | +| User follow-up | `Agent.followup(message)` | Convert composer input to DSH user message | Verified | +| Steering while running | `Agent.steer(message)` | Forward active-turn steering from composer | Verified | +| Cancel / Ctrl+C | `Agent.cancel(cause, options?)` | Map TUI interrupt intent, never invent policy | Verified | +| Wait for quiescence | `Agent.whenIdle()` | Drive UI busy/idle convergence | Verified | +| Agent status | `agent/status` event and `Agent.status` | Render active/idle state | Verified | +| Durable transcript source | `Session` append-only event log | Project only; never create a second transcript DB | Verified | +| Session event feed | `session/event` | Translate exposed event vocabulary to TUI items | Verified | +| User message | `user/message` | Render transcript/input history | Verified | +| Token/chunk streaming | `assistant/chunk` | Convert exposed chunks to agent-message/reasoning deltas | Verified | +| Final assistant message | `assistant/message` | Finalize message item and usage presentation | Verified | +| Tool invocation | `tool/call` | Render matching Codex tool/shell/file cell where semantics are known | Verified | +| Tool result | `tool/result` | Render result/meta; do not execute the tool in DSHX | Verified | +| File contextual diff | `tool/result.meta` when provided by producing tool | Present diff/card only | Verified capability seam | +| Plan / todo | `todo/write` | Project whole-list snapshot into plan UI | Verified | +| Provider/model/reasoning route | `request/header` | Populate status/model presentation and resume projection | Verified | +| Context-window route metadata | `request/context` | Feed context footer when present | Verified | +| Flush durability | `session/flush` | Await/request through official lifecycle only when required | Verified | +| Session listing | `ctx.sessionPersistence.list()` / `listSnapshots()` | Populate resume picker | Verified | +| Session inspection | `ctx.sessionPersistence.inspect(id)` | Read immutable history for resume/details | Verified | +| Session persistence/crash repair | `ctx.sessionPersistence` implementations | **None** — DSH owns durability and repair | Upstream-owned | + +## DSH events observed at the pinned commit + +The session event vocabulary already contains the primitives needed for a Codex-like coding transcript: + +```text +turn/start +turn/end +step/start +step/end +user/message +assistant/chunk +assistant/message +tool/call +tool/result +todo/write +request/header +request/context +``` + +The adapter must treat the vocabulary as merge-extensible. Unknown plugin events are valid DSH events; DSHX may ignore explicitly ignorable presentation events but must not reinterpret required unknown events as known capabilities. + +## Explicit non-ownership + +DSHX does not own any implementation behind these surfaces: + +```text +ctx.agents +Agent loop +Session / SessionPersistence +ctx.llm adapters and routing +tool execution +sandbox +approval policy +skills +subagents +plugins +jobs / workflows +crash recovery +``` + +A mapping row describes where DSHX **reads, calls or presents an upstream capability**. It does not grant permission to duplicate that capability locally. + +## Capability fallback rule + +For every UI feature: + +1. Detect whether the pinned/runtime DSH composition exposes a faithful public capability. +2. If yes, adapt it narrowly into the Codex TUI surface. +3. If the semantics are only partially representable, show an explicit reduced/unsupported state. +4. If representing it would weaken permissions or durability semantics, fail closed. +5. Never solve a missing upstream capability by adding a shadow runtime to `dsh-codex`. + +## Research still required + +The following surfaces need further public-API verification before implementation: + +- Enumerating the current DSH model/provider catalog for `/model` without relying on private registry fields. +- Exact public approval request/result event seam used by the active DSH permission composition. +- Ask-user event/tool presentation seam and cancellation semantics. +- Skills/subagents/jobs/plugin UI capability discovery for later milestones. + +Those are adapter research tasks, not invitations to implement the capabilities in DSHX. From 0dc64c205ca67aed6f0b9eee354d70ef081bbf16 Mon Sep 17 00:00:00 2001 From: code-new-bie <403466121@qq.com> Date: Wed, 19 Aug 2026 10:38:21 +0800 Subject: [PATCH 016/305] docs: decide production direct-core DSH integration boundary --- docs/RUNTIME-INTEGRATION.md | 112 ++++++++++++++++++++++++++++++++++++ 1 file changed, 112 insertions(+) create mode 100644 docs/RUNTIME-INTEGRATION.md diff --git a/docs/RUNTIME-INTEGRATION.md b/docs/RUNTIME-INTEGRATION.md new file mode 100644 index 00000000..9f2c8ede --- /dev/null +++ b/docs/RUNTIME-INTEGRATION.md @@ -0,0 +1,112 @@ +# Production runtime integration + +## Decision + +DSHX will reuse the same architectural seam that official DeepSeek Harness uses for its headless product: a direct Cordis/core entry point over `dsh-base`, without requiring the Web/Host/HTTP/browser runtime for ordinary terminal use. + +The TUI remains project-owned. DeepSeek Harness remains upstream-owned. + +Pinned DSH reference: `99f6f02fecdb7dff40c3fbc9470f5907c29f74ca`. + +At that commit, official `@deepseek-ai/dsh-headless` demonstrates the supported pattern: + +- wait for loader settlement; +- read the official default model service; +- create an Agent through `ctx.agents.create(...)`; +- drive it with public Agent APIs; +- observe the authoritative Session; +- flush through `ctx.sessions.flush(...)`; +- let DSH own model/tool/session/persistence behavior. + +DSHX generalizes only the **presentation lifetime** from one-shot stdout to an interactive TUI. It does not generalize or replace the runtime. + +## Target production topology + +```text +┌──────────────────────────────────────────┐ +│ dshx executable │ +│ - user-facing command │ +│ - starts local components │ +└────────────────────┬─────────────────────┘ + │ + local supported transport + (prefer stdio / in-process) + │ +┌────────────────────▼─────────────────────┐ +│ pinned Codex TUI thin fork │ +│ presentation only │ +└────────────────────┬─────────────────────┘ + │ Codex-compatible UI protocol +┌────────────────────▼─────────────────────┐ +│ DSHX presentation adapter │ +│ Cordis plugin over official dsh-base │ +│ translation only │ +└────────────────────┬─────────────────────┘ +═════════════════════╪══════════════════════ ownership boundary + │ official DSH public APIs/events +┌────────────────────▼─────────────────────┐ +│ official DeepSeek Harness │ +│ agents / sessions / llm / tools / etc. │ +└──────────────────────────────────────────┘ +``` + +## What the adapter is allowed to do + +- Create/resume Agents through official `ctx.agents` APIs. +- Forward follow-up, steering and cancellation to the official Agent object. +- Subscribe to public Agent/Session events. +- Read official session persistence/list/inspection surfaces. +- Project DSH events into Codex Thread/Turn/Item UI events. +- Keep disposable UI correlation state such as `DSH turn number → current Codex turn id`. + +## What the adapter is forbidden to do + +- Implement an agent loop. +- Call providers directly instead of DSH. +- Execute tools instead of DSH. +- Store a second durable transcript. +- Repair sessions or implement crash recovery. +- Decide sandbox/approval policy. +- Reimplement skills/subagents/jobs/plugins. +- Persist an independent model selection when DSH already owns selection. + +## Transport phases + +### M0 development + +The installed Codex CLI may connect to a local WebSocket compatibility stub via `codex --remote`. This exists solely to prove protocol/TUI compatibility. + +### M1 development + +The deterministic stub is replaced behind the same protocol surface by a Cordis plugin using official DSH APIs. WebSocket may still be used locally for development observation if useful. + +### Production + +Experimental Codex remote WebSocket mode is removed from the user launch path. The pinned thin-fork TUI and DSHX adapter use a local supported transport hidden behind `dshx`, preferably stdio or in-process IPC depending on the final Rust/Node process boundary. + +The production contract remains: + +```bash +cd +dshx +``` + +## Process boundary + +Codex TUI is Rust; DSH is TypeScript/Node. DSHX should not create an unsafe FFI dependency merely to force both into one process. + +The preferred design is therefore: + +```text +Rust TUI process + ⇅ structured local protocol over stdio/local IPC +Node DSH composition + DSHX adapter plugin +``` + +`dshx` supervises both lifetimes and propagates terminal interrupt/shutdown correctly. The protocol carries presentation events; it does not own runtime state. + +## DSH compatibility principle + +DSH is a fast-moving upstream. Compatibility is determined by the public capabilities actually exposed by the pinned/supported DSH version. DSHX may ship a narrow compatibility layer for API shape changes, but it must not preserve old behavior by carrying a forked copy of DSH internals. + +If an upstream breaking change removes a needed public seam, that DSH version is unsupported until DSHX can adapt through another official seam or DSH restores an appropriate interface. From 261174988d98019383848c63e7b0f3f0ca8fcc4c Mon Sep 17 00:00:00 2001 From: code-new-bie <403466121@qq.com> Date: Wed, 19 Aug 2026 10:42:34 +0800 Subject: [PATCH 017/305] feat(m1): project official DSH session events into Codex UI events --- src/dsh/session-projector.mjs | 311 ++++++++++++++++++++++++++++++++++ 1 file changed, 311 insertions(+) create mode 100644 src/dsh/session-projector.mjs diff --git a/src/dsh/session-projector.mjs b/src/dsh/session-projector.mjs new file mode 100644 index 00000000..8fa08001 --- /dev/null +++ b/src/dsh/session-projector.mjs @@ -0,0 +1,311 @@ +const nowSeconds = () => Math.floor(Date.now() / 1000); +const nowMillis = () => Date.now(); + +function parseJsonOrString(value) { + if (typeof value !== 'string') return value ?? null; + try { + return JSON.parse(value); + } catch { + return value; + } +} + +function visibleText(message) { + return (message?.content ?? []) + .filter((block) => block?.type === 'text' && typeof block.text === 'string') + .map((block) => block.text) + .join(''); +} + +function mapTurnStatus(reason) { + switch (reason?.kind) { + case 'completed': + return 'completed'; + case 'aborted': + case 'interrupted': + return 'interrupted'; + default: + return 'failed'; + } +} + +function mapPlanStatus(status) { + if (status === 'in_progress') return 'inProgress'; + if (status === 'completed') return 'completed'; + return 'pending'; +} + +function turnRecord(id, status, startedAt, completedAt = null, error = null) { + return { + id, + items: [], + itemsView: 'full', + status, + error, + startedAt, + completedAt, + durationMs: + startedAt != null && completedAt != null ? Math.max(0, (completedAt - startedAt) * 1000) : null + }; +} + +/** + * A deliberately small, stateful UI projector for official DSH Session events. + * + * It never mutates DSH, never executes tools and never persists conversation state. + * Its maps are disposable correlation state required to speak the Codex TUI protocol. + */ +export class DshSessionProjector { + constructor({ threadId, sessionId = threadId } = {}) { + if (!threadId) throw new Error('DshSessionProjector requires threadId'); + this.threadId = threadId; + this.sessionId = sessionId; + this.turns = new Map(); + this.assistantItems = new Map(); + this.tools = new Map(); + this.currentTurn = null; + this.latestHeader = null; + this.latestContext = null; + } + + project(event) { + if (!event || typeof event.type !== 'string') return []; + switch (event.type) { + case 'turn/start': + return this.turnStarted(event); + case 'turn/end': + return this.turnEnded(event); + case 'assistant/chunk': + return this.assistantChunk(event); + case 'assistant/message': + return this.assistantMessage(event); + case 'tool/call': + return this.toolCall(event); + case 'tool/result': + return this.toolResult(event); + case 'todo/write': + return this.todoWrite(event); + case 'request/header': + this.latestHeader = event.data?.header ?? null; + return []; + case 'request/context': + this.latestContext = event.data ?? null; + return []; + default: + return []; + } + } + + turnStarted(event) { + const dshTurn = event.data?.turn; + if (!Number.isInteger(dshTurn)) return []; + const turnId = `dsh-turn-${dshTurn}`; + const startedAt = Number.isFinite(event.time) ? Math.floor(event.time / 1000) : nowSeconds(); + this.turns.set(dshTurn, { id: turnId, startedAt }); + this.currentTurn = dshTurn; + return [ + { + method: 'turn/started', + params: { + threadId: this.threadId, + turn: turnRecord(turnId, 'inProgress', startedAt) + } + } + ]; + } + + turnEnded(event) { + const dshTurn = event.data?.turn; + const state = this.turns.get(dshTurn); + if (!state) return []; + const reason = event.data?.reason; + const status = mapTurnStatus(reason); + const completedAt = Number.isFinite(event.time) ? Math.floor(event.time / 1000) : nowSeconds(); + const error = + status === 'failed' && reason?.kind === 'error' + ? { message: reason.error?.message ?? 'DSH turn failed', codexErrorInfo: null, additionalDetails: null } + : null; + if (this.currentTurn === dshTurn) this.currentTurn = null; + return [ + { + method: 'turn/completed', + params: { + threadId: this.threadId, + turn: turnRecord(state.id, status, state.startedAt, completedAt, error) + } + } + ]; + } + + assistantChunk(event) { + const { turn, step, chunk } = event.data ?? {}; + if (!Number.isInteger(turn) || !Number.isInteger(step) || !chunk) return []; + const turnState = this.turns.get(turn); + if (!turnState) return []; + + // Only visible-text deltas are projected until the pinned Codex reasoning + // delta contract is mapped explicitly. Ignoring a presentation surface is + // safer than misclassifying DSH reasoning semantics. + if (chunk.type !== 'text-delta' || typeof chunk.text !== 'string') return []; + + const key = `${turn}:${step}`; + let item = this.assistantItems.get(key); + const events = []; + if (!item) { + item = { id: `dsh-assistant-${turn}-${step}`, text: '', started: true }; + this.assistantItems.set(key, item); + events.push({ + method: 'item/started', + params: { + threadId: this.threadId, + turnId: turnState.id, + startedAtMs: Number.isFinite(event.time) ? event.time : nowMillis(), + item: { + type: 'agentMessage', + id: item.id, + text: '', + phase: null, + memoryCitation: null, + delivery: null + } + } + }); + } + item.text += chunk.text; + events.push({ + method: 'item/agentMessage/delta', + params: { + threadId: this.threadId, + turnId: turnState.id, + itemId: item.id, + delta: chunk.text + } + }); + return events; + } + + assistantMessage(event) { + const { turn, step, message } = event.data ?? {}; + if (!Number.isInteger(turn) || !Number.isInteger(step) || !message) return []; + const turnState = this.turns.get(turn); + if (!turnState) return []; + const key = `${turn}:${step}`; + const finalText = visibleText(message); + let item = this.assistantItems.get(key); + const events = []; + if (!item) { + item = { id: `dsh-assistant-${turn}-${step}`, text: '', started: true }; + this.assistantItems.set(key, item); + events.push({ + method: 'item/started', + params: { + threadId: this.threadId, + turnId: turnState.id, + startedAtMs: Number.isFinite(event.time) ? event.time : nowMillis(), + item: { + type: 'agentMessage', + id: item.id, + text: '', + phase: null, + memoryCitation: null, + delivery: null + } + } + }); + } + item.text = finalText; + events.push({ + method: 'item/completed', + params: { + threadId: this.threadId, + turnId: turnState.id, + completedAtMs: Number.isFinite(event.time) ? event.time : nowMillis(), + item: { + type: 'agentMessage', + id: item.id, + text: finalText, + phase: null, + memoryCitation: null, + delivery: null + } + } + }); + return events; + } + + toolCall(event) { + const { turn, callId, name, arguments: rawArguments } = event.data ?? {}; + const turnState = this.turns.get(turn); + if (!turnState || !callId || typeof name !== 'string') return []; + const item = { + type: 'dynamicToolCall', + id: `dsh-tool-${String(callId)}`, + namespace: null, + tool: name, + arguments: parseJsonOrString(rawArguments), + status: 'inProgress', + contentItems: null, + success: null, + durationMs: null + }; + this.tools.set(String(callId), { item, turnId: turnState.id, startedAt: event.time ?? nowMillis() }); + return [ + { + method: 'item/started', + params: { + threadId: this.threadId, + turnId: turnState.id, + startedAtMs: Number.isFinite(event.time) ? event.time : nowMillis(), + item + } + } + ]; + } + + toolResult(event) { + const { callId, error } = event.data ?? {}; + const state = this.tools.get(String(callId)); + if (!state) return []; + const failed = Boolean(error); + const completedAtMs = Number.isFinite(event.time) ? event.time : nowMillis(); + const completed = { + ...state.item, + status: failed ? 'failed' : 'completed', + success: !failed, + durationMs: Math.max(0, completedAtMs - state.startedAt) + }; + this.tools.delete(String(callId)); + return [ + { + method: 'item/completed', + params: { + threadId: this.threadId, + turnId: state.turnId, + completedAtMs, + item: completed + } + } + ]; + } + + todoWrite(event) { + if (this.currentTurn == null) return []; + const turnState = this.turns.get(this.currentTurn); + if (!turnState) return []; + const todos = Array.isArray(event.data?.todos) ? event.data.todos : []; + return [ + { + method: 'turn/plan/updated', + params: { + threadId: this.threadId, + turnId: turnState.id, + explanation: null, + plan: todos.map((todo) => ({ + step: String(todo.content ?? ''), + status: mapPlanStatus(todo.status) + })) + } + } + ]; + } +} From 39292af18ba3ceb407ae8bc695c6709c1a6bb430 Mon Sep 17 00:00:00 2001 From: code-new-bie <403466121@qq.com> Date: Wed, 19 Aug 2026 10:43:12 +0800 Subject: [PATCH 018/305] test(m1): pin DSH Session to Codex UI projections --- test/dsh-session-projector.test.mjs | 116 ++++++++++++++++++++++++++++ 1 file changed, 116 insertions(+) create mode 100644 test/dsh-session-projector.test.mjs diff --git a/test/dsh-session-projector.test.mjs b/test/dsh-session-projector.test.mjs new file mode 100644 index 00000000..adb6cf06 --- /dev/null +++ b/test/dsh-session-projector.test.mjs @@ -0,0 +1,116 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { DshSessionProjector } from '../src/dsh/session-projector.mjs'; + +const event = (type, data, time = 1_700_000_000_000) => ({ type, data, time }); + +function start(projector, turn = 1) { + return projector.project(event('turn/start', { turn })); +} + +test('projects DSH turn lifecycle without owning runtime state', () => { + const projector = new DshSessionProjector({ threadId: 'session-1' }); + const started = start(projector); + assert.equal(started[0].method, 'turn/started'); + assert.equal(started[0].params.turn.id, 'dsh-turn-1'); + assert.equal(started[0].params.turn.status, 'inProgress'); + + const ended = projector.project(event('turn/end', { turn: 1, reason: { kind: 'completed' } }, 1_700_000_001_000)); + assert.equal(ended[0].method, 'turn/completed'); + assert.equal(ended[0].params.turn.status, 'completed'); +}); + +test('projects visible DSH text chunks into one Codex agent message item', () => { + const projector = new DshSessionProjector({ threadId: 'session-1' }); + start(projector); + + const first = projector.project( + event('assistant/chunk', { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'hel' } }) + ); + assert.deepEqual(first.map((entry) => entry.method), ['item/started', 'item/agentMessage/delta']); + assert.equal(first[1].params.delta, 'hel'); + + const second = projector.project( + event('assistant/chunk', { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'lo' } }) + ); + assert.deepEqual(second.map((entry) => entry.method), ['item/agentMessage/delta']); + + const completed = projector.project( + event('assistant/message', { + turn: 1, + step: 1, + message: { content: [{ type: 'text', text: 'hello' }, { type: 'reasoning', text: 'private-ish presentation' }] } + }) + ); + assert.equal(completed[0].method, 'item/completed'); + assert.equal(completed[0].params.item.text, 'hello'); +}); + +test('does not guess Codex reasoning semantics before they are explicitly mapped', () => { + const projector = new DshSessionProjector({ threadId: 'session-1' }); + start(projector); + const output = projector.project( + event('assistant/chunk', { turn: 1, step: 1, chunk: { type: 'reasoning-delta', index: 0, text: 'thinking' } }) + ); + assert.deepEqual(output, []); +}); + +test('projects unknown DSH tools conservatively as generic dynamic tool calls', () => { + const projector = new DshSessionProjector({ threadId: 'session-1' }); + start(projector); + const started = projector.project( + event('tool/call', { turn: 1, step: 1, callId: 'call-1', name: 'custom_tool', arguments: '{"x":1}' }) + ); + assert.equal(started[0].method, 'item/started'); + assert.equal(started[0].params.item.type, 'dynamicToolCall'); + assert.deepEqual(started[0].params.item.arguments, { x: 1 }); + assert.equal(started[0].params.item.status, 'inProgress'); + + const completed = projector.project( + event('tool/result', { + turn: 1, + step: 1, + callId: 'call-1', + message: { content: [{ type: 'text', text: 'ok' }] } + }, 1_700_000_000_050) + ); + assert.equal(completed[0].params.item.status, 'completed'); + assert.equal(completed[0].params.item.success, true); +}); + +test('projects DSH todo snapshots into Codex plan updates', () => { + const projector = new DshSessionProjector({ threadId: 'session-1' }); + start(projector, 7); + const plan = projector.project( + event('todo/write', { + todos: [ + { content: 'Inspect project', status: 'completed' }, + { content: 'Apply fix', status: 'in_progress' }, + { content: 'Run tests', status: 'pending' } + ] + }) + ); + assert.equal(plan[0].method, 'turn/plan/updated'); + assert.deepEqual(plan[0].params.plan, [ + { step: 'Inspect project', status: 'completed' }, + { step: 'Apply fix', status: 'inProgress' }, + { step: 'Run tests', status: 'pending' } + ]); +}); + +test('caches request route metadata only as disposable presentation state', () => { + const projector = new DshSessionProjector({ threadId: 'session-1' }); + assert.deepEqual( + projector.project( + event('request/header', { + header: { config: { provider: 'deepseek', model: 'example', reasoningEffort: 'high' } }, + reason: 'initial' + }) + ), + [] + ); + assert.equal(projector.latestHeader.config.provider, 'deepseek'); + + projector.project(event('request/context', { provider: 'deepseek', model: 'example', contextWindow: 128000 })); + assert.equal(projector.latestContext.contextWindow, 128000); +}); From cec6959e7bd29c82ca9b868a0a3273b5f55cfb0b Mon Sep 17 00:00:00 2001 From: code-new-bie <403466121@qq.com> Date: Wed, 19 Aug 2026 10:48:01 +0800 Subject: [PATCH 019/305] build(m1): pin official DSH public packages for adapter verification --- package.json | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index 0cecf406..a3b678d5 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "dsh-codex", - "version": "0.0.0-m0", + "version": "0.0.0-m1", "private": true, "type": "module", "engines": { @@ -11,11 +11,15 @@ }, "scripts": { "test": "node --test", - "test:protocol": "node --test test/protocol.test.mjs", + "test:protocol": "node --test test/protocol.test.mjs test/wire.test.mjs", + "test:dsh": "node --test test/dsh-*.test.mjs", "stub": "node ./bin/dshx-stub-server.mjs", "dev": "node ./bin/dshx.mjs" }, "dependencies": { + "@deepseek-ai/dsh-agent": "0.1.0-rc.7", + "@deepseek-ai/dsh-llm": "0.1.0-rc.7", + "@deepseek-ai/dsh-session": "0.1.0-rc.7", "ws": "^8.18.0" } } From 935daca75c4889f4772d5dea5252e7646a96a8a3 Mon Sep 17 00:00:00 2001 From: code-new-bie <403466121@qq.com> Date: Wed, 19 Aug 2026 10:48:20 +0800 Subject: [PATCH 020/305] feat(m1): add delegation-only official DSH agent driver --- src/dsh/agent-driver.mjs | 103 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 103 insertions(+) create mode 100644 src/dsh/agent-driver.mjs diff --git a/src/dsh/agent-driver.mjs b/src/dsh/agent-driver.mjs new file mode 100644 index 00000000..429b1d07 --- /dev/null +++ b/src/dsh/agent-driver.mjs @@ -0,0 +1,103 @@ +import { randomUUID } from 'node:crypto'; +import { installModelSelection } from '@deepseek-ai/dsh-agent'; +import { createUserMessage } from '@deepseek-ai/dsh-llm'; +import { SessionId } from '@deepseek-ai/dsh-session'; + +function requireService(ctx, name) { + const service = ctx.get(name); + if (service === undefined) throw new Error(`DSHX requires DSH service: ${name}`); + return service; +} + +function userMessage(text) { + return createUserMessage({ + source: { kind: 'user' }, + content: [{ type: 'text', text }] + }); +} + +/** + * Thin delegation layer over official DeepSeek Harness public services. + * + * This class is intentionally boring: no agent loop, no tool execution, no + * transcript persistence and no policy decisions live here. It converts TUI + * intent into calls on DSH-owned objects and returns those objects/results. + */ +export class DshAgentDriver { + constructor(ctx) { + if (!ctx || typeof ctx.get !== 'function') throw new Error('DshAgentDriver requires a Cordis Context'); + this.ctx = ctx; + } + + async settleComposition() { + await this.ctx.get('loader')?.await?.(); + } + + async create({ cwd = process.cwd(), sessionId = `session-${randomUUID()}` } = {}) { + await this.settleComposition(); + const agents = requireService(this.ctx, 'agents'); + const defaultModel = requireService(this.ctx, 'agentDefaultModel'); + const selection = defaultModel.currentSelection(); + + return agents.create({ + sessionId: SessionId(sessionId), + meta: { cwd }, + agentOptions: { + provider: selection.provider, + model: selection.model + }, + setup: (agentCtx) => { + const selected = { current: selection, assembled: undefined }; + installModelSelection(agentCtx, selected); + } + }); + } + + /** + * Resume delegates persisted-runtime reconstruction to DSH. In particular, + * DSHX does not apply the machine's current default model to an old Session. + */ + async resume(sessionId) { + await this.settleComposition(); + const agents = requireService(this.ctx, 'agents'); + return agents.resume({ resumeSessionId: SessionId(sessionId) }); + } + + followup(agent, text) { + agent.followup(userMessage(text)); + } + + steer(agent, text) { + agent.steer(userMessage(text)); + } + + interrupt(agent, { keepInbox = false } = {}) { + agent.cancel({ kind: 'user' }, { keepInbox }); + } + + whenIdle(agent) { + return agent.whenIdle(); + } + + getLive(sessionId) { + return requireService(this.ctx, 'agents').get(SessionId(sessionId)); + } + + listLive() { + return requireService(this.ctx, 'agents').list(); + } + + listRootAgents() { + return requireService(this.ctx, 'agents').roots(); + } + + async listSessions(options = {}) { + const persistence = requireService(this.ctx, 'sessionPersistence'); + return persistence.list(options); + } + + async inspectSession(sessionId) { + const persistence = requireService(this.ctx, 'sessionPersistence'); + return persistence.inspect(SessionId(sessionId)); + } +} From f648fe88ce163c50f5f8b0af7a931d44e098d6a0 Mon Sep 17 00:00:00 2001 From: code-new-bie <403466121@qq.com> Date: Wed, 19 Aug 2026 10:48:42 +0800 Subject: [PATCH 021/305] test(m1): enforce delegation-only DSH driver behavior --- test/dsh-agent-driver.test.mjs | 127 +++++++++++++++++++++++++++++++++ 1 file changed, 127 insertions(+) create mode 100644 test/dsh-agent-driver.test.mjs diff --git a/test/dsh-agent-driver.test.mjs b/test/dsh-agent-driver.test.mjs new file mode 100644 index 00000000..a127c287 --- /dev/null +++ b/test/dsh-agent-driver.test.mjs @@ -0,0 +1,127 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { DshAgentDriver } from '../src/dsh/agent-driver.mjs'; + +function fixture() { + const calls = []; + const listeners = []; + const agent = { + id: 'session-live', + followup(message) { calls.push(['followup', message]); }, + steer(message) { calls.push(['steer', message]); }, + cancel(cause, options) { calls.push(['cancel', cause, options]); }, + whenIdle() { calls.push(['whenIdle']); return Promise.resolve(); } + }; + const handle = { agent, dispose: async () => {} }; + const services = { + loader: { async await() { calls.push(['loader.await']); } }, + agentDefaultModel: { + currentSelection() { + calls.push(['defaultModel.currentSelection']); + return { provider: 'deepseek', model: 'deepseek-test', reasoningEffort: 'high' }; + } + }, + agents: { + async create(options) { + calls.push(['agents.create', options]); + options.setup?.({ + on(name) { + listeners.push(name); + return () => {}; + } + }); + return handle; + }, + async resume(options) { calls.push(['agents.resume', options]); return handle; }, + get(id) { calls.push(['agents.get', id]); return agent; }, + list() { calls.push(['agents.list']); return [agent]; }, + roots() { calls.push(['agents.roots']); return [agent]; } + }, + sessionPersistence: { + async list(options) { calls.push(['sessionPersistence.list', options]); return { entries: [] }; }, + async inspect(id) { calls.push(['sessionPersistence.inspect', id]); return { id, events: [] }; } + } + }; + return { + calls, + listeners, + agent, + handle, + ctx: { get(name) { return services[name]; } } + }; +} + +test('create uses DSH default model and official agents.create', async () => { + const fx = fixture(); + const driver = new DshAgentDriver(fx.ctx); + const handle = await driver.create({ cwd: '/workspace', sessionId: 'session-new' }); + assert.equal(handle, fx.handle); + + const create = fx.calls.find(([name]) => name === 'agents.create'); + assert.ok(create); + const options = create[1]; + assert.equal(options.sessionId, 'session-new'); + assert.equal(options.meta.cwd, '/workspace'); + assert.deepEqual(options.agentOptions, { provider: 'deepseek', model: 'deepseek-test' }); + assert.equal(typeof options.setup, 'function'); + assert.deepEqual(fx.listeners.sort(), ['agent/request', 'system-prompt/assemble'].sort()); +}); + +test('resume delegates persisted reconstruction without applying current default model', async () => { + const fx = fixture(); + const driver = new DshAgentDriver(fx.ctx); + await driver.resume('session-old'); + + const resume = fx.calls.find(([name]) => name === 'agents.resume'); + assert.deepEqual(resume[1], { resumeSessionId: 'session-old' }); + assert.equal( + fx.calls.some(([name]) => name === 'defaultModel.currentSelection'), + false, + 'DSHX must not overwrite a resumed Session with the machine current default model' + ); +}); + +test('followup and steering create official DSH user messages and delegate to Agent', () => { + const fx = fixture(); + const driver = new DshAgentDriver(fx.ctx); + driver.followup(fx.agent, 'hello'); + driver.steer(fx.agent, 'change direction'); + + const followup = fx.calls.find(([name]) => name === 'followup')[1]; + assert.equal(followup.role, 'user'); + assert.deepEqual(followup.source, { kind: 'user' }); + assert.deepEqual(followup.content, [{ type: 'text', text: 'hello' }]); + assert.equal(typeof followup.id, 'string'); + + const steering = fx.calls.find(([name]) => name === 'steer')[1]; + assert.deepEqual(steering.content, [{ type: 'text', text: 'change direction' }]); +}); + +test('interrupt delegates user cancellation to DSH and does not invent policy', () => { + const fx = fixture(); + const driver = new DshAgentDriver(fx.ctx); + driver.interrupt(fx.agent, { keepInbox: true }); + assert.deepEqual(fx.calls.find(([name]) => name === 'cancel'), [ + 'cancel', + { kind: 'user' }, + { keepInbox: true } + ]); +}); + +test('live/session lookup methods delegate to official registries', async () => { + const fx = fixture(); + const driver = new DshAgentDriver(fx.ctx); + assert.equal(driver.getLive('session-live'), fx.agent); + assert.deepEqual(driver.listLive(), [fx.agent]); + assert.deepEqual(driver.listRootAgents(), [fx.agent]); + assert.deepEqual(await driver.listSessions({ limit: 20 }), { entries: [] }); + assert.deepEqual(await driver.inspectSession('session-old'), { id: 'session-old', events: [] }); + + assert.ok(fx.calls.some((call) => call[0] === 'sessionPersistence.list')); + assert.ok(fx.calls.some((call) => call[0] === 'sessionPersistence.inspect')); +}); + +test('missing DSH service is a hard compatibility error, not a shadow implementation', async () => { + const driver = new DshAgentDriver({ get() { return undefined; } }); + await assert.rejects(() => driver.create({ sessionId: 'session-x' }), /DSHX requires DSH service: agents/); +}); From 993debea8740e2dbc6545120abae2c010befb96c Mon Sep 17 00:00:00 2001 From: code-new-bie <403466121@qq.com> Date: Wed, 19 Aug 2026 10:54:37 +0800 Subject: [PATCH 022/305] feat(m1): bind Codex thread lifecycle to authoritative DSH Session events --- src/dsh/thread-controller.mjs | 109 ++++++++++++++++++++++++++++++++++ 1 file changed, 109 insertions(+) create mode 100644 src/dsh/thread-controller.mjs diff --git a/src/dsh/thread-controller.mjs b/src/dsh/thread-controller.mjs new file mode 100644 index 00000000..3face3d1 --- /dev/null +++ b/src/dsh/thread-controller.mjs @@ -0,0 +1,109 @@ +import { DshSessionProjector } from './session-projector.mjs'; + +function deferred(timeoutMs, label) { + let resolve; + let reject; + let timer; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + timer = setTimeout(() => rej(new Error(`Timed out waiting for ${label}`)), timeoutMs); + }); + return { + promise, + resolve(value) { clearTimeout(timer); resolve(value); }, + reject(error) { clearTimeout(timer); reject(error); } + }; +} + +/** + * Owns only the presentation lifetime for one already-created DSH Agent. + * The Agent and Session remain authoritative and are never reconstructed here. + */ +export class DshThreadController { + constructor({ handle, driver, emit = () => {}, turnStartTimeoutMs = 15_000 }) { + if (!handle?.agent) throw new Error('DshThreadController requires a DSH AgentHandle'); + this.handle = handle; + this.agent = handle.agent; + this.driver = driver; + this.emit = emit; + this.turnStartTimeoutMs = turnStartTimeoutMs; + this.projector = new DshSessionProjector({ + threadId: String(this.agent.id), + sessionId: String(this.agent.id) + }); + this.pendingTurnStart = null; + this.closed = false; + + const onSessionEvent = (_session, event) => this.onSessionEvent(event); + this.disposeSessionListener = this.agent.ctx.on('session/event', onSessionEvent); + } + + get threadId() { + return String(this.agent.id); + } + + onSessionEvent(event) { + if (this.closed) return; + const projected = this.projector.project(event); + for (const notification of projected) { + if (notification.method === 'turn/started' && this.pendingTurnStart) { + // The caller needs the DSH-committed turn identity for the JSON-RPC + // response. Buffer this first notification so the response can be sent + // before the matching notification, mirroring Codex app-server order. + const pending = this.pendingTurnStart; + this.pendingTurnStart = null; + pending.resolve({ turn: notification.params.turn, firstNotification: notification }); + continue; + } + this.emit(notification); + } + } + + /** + * Submit a normal follow-up and wait until DSH itself commits turn/start. + * Returns a release callback so the transport can send the RPC response first. + */ + async startTurn(text) { + if (this.pendingTurnStart) throw new Error('A turn/start request is already awaiting DSH'); + const pending = deferred(this.turnStartTimeoutMs, 'DSH turn/start'); + this.pendingTurnStart = pending; + try { + this.driver.followup(this.agent, text); + const { turn, firstNotification } = await pending.promise; + let released = false; + return { + turn, + release: () => { + if (released) return; + released = true; + this.emit(firstNotification); + } + }; + } catch (error) { + if (this.pendingTurnStart === pending) this.pendingTurnStart = null; + throw error; + } + } + + steer(text) { + this.driver.steer(this.agent, text); + } + + interrupt(options) { + this.driver.interrupt(this.agent, options); + } + + whenIdle() { + return this.driver.whenIdle(this.agent); + } + + async close() { + if (this.closed) return; + this.closed = true; + this.pendingTurnStart?.reject(new Error('DSHX thread controller closed')); + this.pendingTurnStart = null; + this.disposeSessionListener?.(); + await this.handle.dispose?.(); + } +} From c63274a7e78456edae4c3a0373686ebad431b935 Mon Sep 17 00:00:00 2001 From: code-new-bie <403466121@qq.com> Date: Wed, 19 Aug 2026 10:54:57 +0800 Subject: [PATCH 023/305] test(m1): enforce authoritative DSH turn start ordering --- test/dsh-thread-controller.test.mjs | 128 ++++++++++++++++++++++++++++ 1 file changed, 128 insertions(+) create mode 100644 test/dsh-thread-controller.test.mjs diff --git a/test/dsh-thread-controller.test.mjs b/test/dsh-thread-controller.test.mjs new file mode 100644 index 00000000..f7d3939a --- /dev/null +++ b/test/dsh-thread-controller.test.mjs @@ -0,0 +1,128 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { DshThreadController } from '../src/dsh/thread-controller.mjs'; + +function fixture() { + const emitted = []; + const calls = []; + let sessionListener; + let disposed = false; + const session = { id: 'session-1' }; + const agent = { + id: 'session-1', + ctx: { + on(name, listener) { + assert.equal(name, 'session/event'); + sessionListener = listener; + return () => { sessionListener = undefined; }; + } + } + }; + const handle = { + agent, + async dispose() { disposed = true; } + }; + const driver = { + followup(_agent, text) { + calls.push(['followup', text]); + queueMicrotask(() => { + sessionListener?.(session, { + type: 'turn/start', + seq: 1, + time: 1_700_000_000_000, + data: { turn: 4 } + }); + }); + }, + steer(_agent, text) { calls.push(['steer', text]); }, + interrupt(_agent, options) { calls.push(['interrupt', options]); }, + whenIdle() { calls.push(['whenIdle']); return Promise.resolve(); } + }; + const controller = new DshThreadController({ handle, driver, emit: (event) => emitted.push(event) }); + return { + controller, + emitted, + calls, + emitSession(event) { sessionListener?.(session, event); }, + get disposed() { return disposed; }, + get hasListener() { return Boolean(sessionListener); } + }; +} + +test('startTurn waits for DSH turn/start and releases notification after RPC response point', async () => { + const fx = fixture(); + const started = await fx.controller.startTurn('hello'); + assert.deepEqual(fx.calls[0], ['followup', 'hello']); + assert.equal(started.turn.id, 'dsh-turn-4'); + assert.equal(started.turn.status, 'inProgress'); + assert.deepEqual(fx.emitted, [], 'turn/started must remain buffered until transport sends response'); + + started.release(); + assert.equal(fx.emitted.length, 1); + assert.equal(fx.emitted[0].method, 'turn/started'); + assert.equal(fx.emitted[0].params.turn.id, started.turn.id); + + started.release(); + assert.equal(fx.emitted.length, 1, 'release must be idempotent'); +}); + +test('later DSH session events stream directly through the projector', async () => { + const fx = fixture(); + const started = await fx.controller.startTurn('hello'); + started.release(); + + fx.emitSession({ + type: 'assistant/chunk', + seq: 2, + time: 1_700_000_000_010, + data: { turn: 4, step: 1, chunk: { type: 'text-delta', index: 0, text: 'hi' } } + }); + fx.emitSession({ + type: 'assistant/message', + seq: 3, + time: 1_700_000_000_020, + data: { + turn: 4, + step: 1, + message: { + role: 'assistant', + source: { kind: 'model', provider: 'deepseek', model: 'test' }, + content: [{ type: 'text', text: 'hi' }] + } + } + }); + fx.emitSession({ + type: 'turn/end', + seq: 4, + time: 1_700_000_000_030, + data: { turn: 4, reason: { kind: 'completed' } } + }); + + assert.deepEqual(fx.emitted.map((event) => event.method), [ + 'turn/started', + 'item/started', + 'item/agentMessage/delta', + 'item/completed', + 'turn/completed' + ]); +}); + +test('steering, interrupt and idle are pure delegation', async () => { + const fx = fixture(); + fx.controller.steer('new constraint'); + fx.controller.interrupt({ keepInbox: true }); + await fx.controller.whenIdle(); + assert.deepEqual(fx.calls, [ + ['steer', 'new constraint'], + ['interrupt', { keepInbox: true }], + ['whenIdle'] + ]); +}); + +test('close detaches the scoped session listener and disposes the DSH handle', async () => { + const fx = fixture(); + assert.equal(fx.hasListener, true); + await fx.controller.close(); + assert.equal(fx.hasListener, false); + assert.equal(fx.disposed, true); +}); From 576e6080417c5f4bc907eb9fe15970b8ea40edd3 Mon Sep 17 00:00:00 2001 From: code-new-bie <403466121@qq.com> Date: Wed, 19 Aug 2026 11:02:04 +0800 Subject: [PATCH 024/305] feat(m1): project official DSH permission services without policy ownership --- src/dsh/permissions.mjs | 106 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 106 insertions(+) create mode 100644 src/dsh/permissions.mjs diff --git a/src/dsh/permissions.mjs b/src/dsh/permissions.mjs new file mode 100644 index 00000000..f1f1b4df --- /dev/null +++ b/src/dsh/permissions.mjs @@ -0,0 +1,106 @@ +function requireService(ctx, name) { + const service = ctx.get(name); + if (service === undefined) throw new Error(`DSHX requires DSH service: ${name}`); + return service; +} + +function codexApprovalPolicy(policy) { + if (policy === 'ask') return 'on-request'; + if (policy === 'never') return 'never'; + throw new Error(`Unsupported DSH approval policy: ${String(policy)}`); +} + +/** + * Project official DSH permission state into Codex-facing presentation data. + * + * DSH sandbox modes govern filesystem effects only; Codex legacy sandbox + * shapes also encode network policy. To avoid claiming Codex-native sandbox + * semantics that DSH does not own, confined DSH modes are advertised as an + * external sandbox. `legacyNetworkAccess` describes only the legacy Codex + * field and never changes DSH enforcement. + */ +export class DshPermissionView { + constructor(ctx, { legacyNetworkAccess = 'enabled' } = {}) { + if (!ctx || typeof ctx.get !== 'function') throw new Error('DshPermissionView requires a Cordis Context'); + if (!['enabled', 'restricted'].includes(legacyNetworkAccess)) { + throw new Error('legacyNetworkAccess must be "enabled" or "restricted"'); + } + this.ctx = ctx; + this.legacyNetworkAccess = legacyNetworkAccess; + } + + current(agent) { + if (!agent?.session) throw new Error('DshPermissionView.current requires a live DSH Agent'); + const presets = requireService(this.ctx, 'permissionPresets'); + const sandboxPolicy = requireService(this.ctx, 'sandboxPolicy'); + const approval = requireService(this.ctx, 'approval'); + const session = agent.session; + + const preset = presets.current(session.events); + const options = [ + ...presets.names.map((name) => presets.optionOf(name)), + ...(preset === 'custom' ? [presets.optionOf('custom')] : []) + ]; + const resolvedSandbox = sandboxPolicy.resolve({ session }); + const approvalPolicy = approval.overrideOf(session) ?? approval.config?.policy ?? 'ask'; + + let sandbox; + if (resolvedSandbox.mode === 'danger-full-access') { + sandbox = { type: 'dangerFullAccess' }; + } else if (resolvedSandbox.mode === 'read-only' || resolvedSandbox.mode === 'workspace-write') { + sandbox = { type: 'externalSandbox', networkAccess: this.legacyNetworkAccess }; + } else { + throw new Error(`Unsupported DSH sandbox mode: ${String(resolvedSandbox.mode)}`); + } + + return { + preset, + options, + dsh: { + sandboxMode: resolvedSandbox.mode, + workspaceRoot: resolvedSandbox.workspaceRoot, + approvalPolicy + }, + codex: { + approvalPolicy: codexApprovalPolicy(approvalPolicy), + approvalsReviewer: 'user', + sandbox + } + }; + } + + set(agent, presetName) { + if (!agent?.session) throw new Error('DshPermissionView.set requires a live DSH Agent'); + const presets = requireService(this.ctx, 'permissionPresets'); + presets.set(agent.session, presetName); + } +} + +export function mapDshApprovalOutcomeToCodexDecision(outcome) { + switch (outcome) { + case 'allowed-once': + return 'accept'; + case 'rejected': + case 'unavailable': + return 'decline'; + case 'cancelled': + return 'cancel'; + default: + throw new Error(`Unknown DSH approval outcome: ${String(outcome)}`); + } +} + +export function mapCodexDecisionToDshApprovalOutcome(decision) { + switch (decision) { + case 'accept': + return 'allowed-once'; + case 'decline': + return 'rejected'; + case 'cancel': + return 'cancelled'; + case 'acceptForSession': + throw new Error('DSH has no session-wide approval grant; DSHX must not offer acceptForSession'); + default: + throw new Error('Codex approval decision has no faithful DSH one-shot mapping'); + } +} From b3d9da408426af1e9226929e57a5e333a9409b83 Mon Sep 17 00:00:00 2001 From: code-new-bie <403466121@qq.com> Date: Wed, 19 Aug 2026 11:02:29 +0800 Subject: [PATCH 025/305] test(m1): pin faithful DSH permission and approval mappings --- test/dsh-permissions.test.mjs | 124 ++++++++++++++++++++++++++++++++++ 1 file changed, 124 insertions(+) create mode 100644 test/dsh-permissions.test.mjs diff --git a/test/dsh-permissions.test.mjs b/test/dsh-permissions.test.mjs new file mode 100644 index 00000000..52f03883 --- /dev/null +++ b/test/dsh-permissions.test.mjs @@ -0,0 +1,124 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { + DshPermissionView, + mapCodexDecisionToDshApprovalOutcome, + mapDshApprovalOutcomeToCodexDecision +} from '../src/dsh/permissions.mjs'; + +function fixture({ sandboxMode = 'workspace-write', approvalPolicy = 'ask', currentPreset = 'workspace-write' } = {}) { + const calls = []; + const session = { events: [], id: 'session-1' }; + const agent = { session }; + const specs = { + 'workspace-write': { + sandbox: 'workspace-write', + approval: 'ask', + name: 'Workspace write', + description: 'Write in workspace with approval prompts.' + }, + 'danger-full-access': { + sandbox: 'danger-full-access', + approval: 'never', + name: 'Full access', + description: 'Unconfined file access.' + } + }; + const services = { + permissionPresets: { + names: Object.keys(specs), + current(events) { calls.push(['permissionPresets.current', events]); return currentPreset; }, + optionOf(name) { + if (name === 'custom') return { value: 'custom', name: 'Custom' }; + return { value: name, name: specs[name].name, description: specs[name].description }; + }, + resolve(name) { return specs[name]; }, + set(target, name) { calls.push(['permissionPresets.set', target, name]); } + }, + sandboxPolicy: { + resolve({ session: target }) { + calls.push(['sandboxPolicy.resolve', target]); + return { mode: sandboxMode, workspaceRoot: '/workspace', sessionId: target.id }; + } + }, + approval: { + config: { policy: approvalPolicy }, + overrideOf(target) { calls.push(['approval.overrideOf', target]); return undefined; } + } + }; + return { + agent, + calls, + ctx: { get(name) { return services[name]; } } + }; +} + +test('workspace-write is presented as external sandbox, not fake Codex-native workspaceWrite', () => { + const fx = fixture(); + const view = new DshPermissionView(fx.ctx); + const current = view.current(fx.agent); + + assert.equal(current.preset, 'workspace-write'); + assert.equal(current.dsh.sandboxMode, 'workspace-write'); + assert.equal(current.dsh.approvalPolicy, 'ask'); + assert.deepEqual(current.codex, { + approvalPolicy: 'on-request', + approvalsReviewer: 'user', + sandbox: { type: 'externalSandbox', networkAccess: 'enabled' } + }); +}); + +test('danger-full-access has an exact Codex legacy sandbox representation', () => { + const fx = fixture({ + sandboxMode: 'danger-full-access', + approvalPolicy: 'never', + currentPreset: 'danger-full-access' + }); + const current = new DshPermissionView(fx.ctx).current(fx.agent); + assert.deepEqual(current.codex, { + approvalPolicy: 'never', + approvalsReviewer: 'user', + sandbox: { type: 'dangerFullAccess' } + }); +}); + +test('custom preset is presentation-only and included only when actually current', () => { + const fx = fixture({ sandboxMode: 'read-only', currentPreset: 'custom' }); + const current = new DshPermissionView(fx.ctx, { legacyNetworkAccess: 'restricted' }).current(fx.agent); + assert.equal(current.preset, 'custom'); + assert.equal(current.options.at(-1).value, 'custom'); + assert.deepEqual(current.codex.sandbox, { type: 'externalSandbox', networkAccess: 'restricted' }); +}); + +test('permission changes delegate to the official permissionPresets setter', () => { + const fx = fixture(); + const view = new DshPermissionView(fx.ctx); + view.set(fx.agent, 'danger-full-access'); + assert.deepEqual(fx.calls.find(([name]) => name === 'permissionPresets.set'), [ + 'permissionPresets.set', + fx.agent.session, + 'danger-full-access' + ]); +}); + +test('DSH approval outcomes map only to Codex decisions with matching semantics', () => { + assert.equal(mapDshApprovalOutcomeToCodexDecision('allowed-once'), 'accept'); + assert.equal(mapDshApprovalOutcomeToCodexDecision('rejected'), 'decline'); + assert.equal(mapDshApprovalOutcomeToCodexDecision('unavailable'), 'decline'); + assert.equal(mapDshApprovalOutcomeToCodexDecision('cancelled'), 'cancel'); +}); + +test('Codex persistent approval is rejected because DSH exposes only allowed-once', () => { + assert.equal(mapCodexDecisionToDshApprovalOutcome('accept'), 'allowed-once'); + assert.equal(mapCodexDecisionToDshApprovalOutcome('decline'), 'rejected'); + assert.equal(mapCodexDecisionToDshApprovalOutcome('cancel'), 'cancelled'); + assert.throws( + () => mapCodexDecisionToDshApprovalOutcome('acceptForSession'), + /no session-wide approval grant/ + ); +}); + +test('missing permission service is a hard compatibility error', () => { + const view = new DshPermissionView({ get() { return undefined; } }); + assert.throws(() => view.current({ session: { events: [] } }), /permissionPresets/); +}); From c9abc0509475038cf4ef6be1a5210fda91bdfc09 Mon Sep 17 00:00:00 2001 From: code-new-bie <403466121@qq.com> Date: Wed, 19 Aug 2026 11:03:03 +0800 Subject: [PATCH 026/305] docs(m1): record verified DSH permission and approval seams --- docs/DSH-CAPABILITY-MATRIX.md | 31 ++++++++++++++++++++++++++++++- 1 file changed, 30 insertions(+), 1 deletion(-) diff --git a/docs/DSH-CAPABILITY-MATRIX.md b/docs/DSH-CAPABILITY-MATRIX.md index 7b4fd9c1..3f54b161 100644 --- a/docs/DSH-CAPABILITY-MATRIX.md +++ b/docs/DSH-CAPABILITY-MATRIX.md @@ -31,6 +31,29 @@ Pinned DSH commit: `99f6f02fecdb7dff40c3fbc9470f5907c29f74ca` | Session listing | `ctx.sessionPersistence.list()` / `listSnapshots()` | Populate resume picker | Verified | | Session inspection | `ctx.sessionPersistence.inspect(id)` | Read immutable history for resume/details | Verified | | Session persistence/crash repair | `ctx.sessionPersistence` implementations | **None** — DSH owns durability and repair | Upstream-owned | +| Permission preset list/current | `ctx.permissionPresets.names`, `optionOf()`, `current(session.events)` | Populate Codex-style permissions selector | Verified | +| Permission preset switch | `ctx.permissionPresets.set(session, name)` | Delegate selected value; do not write sandbox/approval events ourselves | Verified | +| Effective file sandbox | `ctx.sandboxPolicy.resolve({ session })` | Present DSH enforcement as an external sandbox | Verified | +| Effective approval policy | `ctx.approval.overrideOf(session)` + `ctx.approval.config.policy` | Map `ask → on-request`, `never → never` | Verified | +| Interactive approval request | scoped `approval/request` waterfall | Present only when Codex has a faithful UI for that DSH tool; otherwise delegate/fail closed | Verified | +| Approval audit | `approval/asked` / `approval/decided` | Read/render if useful; never use audit events as the answer channel | Verified | + +## Permission semantic notes + +DSH sandbox mode governs **filesystem effects only**. Its public sandbox contract explicitly states that network and process visibility are outside the sandbox vocabulary. Codex's legacy `SandboxPolicy` combines filesystem and network presentation fields, so DSHX must not claim that a DSH confined session is a Codex-native `workspaceWrite`/`readOnly` policy with invented network semantics. + +DSHX therefore projects confined DSH modes as Codex `externalSandbox` for the legacy field and keeps the actual DSH preset name/options as the user-facing source of truth. `danger-full-access` has an exact legacy Codex representation and may be projected directly. + +DSH approval outcomes are closed and fail-closed: + +```text +allowed-once +rejected +cancelled +unavailable +``` + +Only `allowed-once` is a grant. Codex's `acceptForSession` has **no faithful DSH equivalent** and must not be offered for DSH-backed approvals. The TUI needs a narrow backend-aware patch that keeps one-shot accept / decline / cancel while hiding session-wide approval. Unknown plugin-tool approvals are not misclassified as shell/file approvals; an unclaimed request naturally falls through to DSH's `unavailable` outcome. ## DSH events observed at the pinned commit @@ -49,6 +72,11 @@ tool/result todo/write request/header request/context +approval/asked +approval/decided +approval/policy +sandbox/mode +permission/preset ``` The adapter must treat the vocabulary as merge-extensible. Unknown plugin events are valid DSH events; DSHX may ignore explicitly ignorable presentation events but must not reinterpret required unknown events as known capabilities. @@ -65,6 +93,7 @@ ctx.llm adapters and routing tool execution sandbox approval policy +permission presets skills subagents plugins @@ -89,8 +118,8 @@ For every UI feature: The following surfaces need further public-API verification before implementation: - Enumerating the current DSH model/provider catalog for `/model` without relying on private registry fields. -- Exact public approval request/result event seam used by the active DSH permission composition. - Ask-user event/tool presentation seam and cancellation semantics. +- Exact built-in DSH tool-name/meta contracts needed to specialize generic tool cells into Codex shell/file/diff cells. - Skills/subagents/jobs/plugin UI capability discovery for later milestones. Those are adapter research tasks, not invitations to implement the capabilities in DSHX. From 88ed6289e00a993a43868b6d7faeebce27058473 Mon Sep 17 00:00:00 2001 From: code-new-bie <403466121@qq.com> Date: Wed, 19 Aug 2026 11:03:55 +0800 Subject: [PATCH 027/305] feat(protocol): add fail-closed server request broker for TUI interactions --- src/protocol/request-broker.mjs | 79 +++++++++++++++++++++++++++++++++ 1 file changed, 79 insertions(+) create mode 100644 src/protocol/request-broker.mjs diff --git a/src/protocol/request-broker.mjs b/src/protocol/request-broker.mjs new file mode 100644 index 00000000..dd4787fa --- /dev/null +++ b/src/protocol/request-broker.mjs @@ -0,0 +1,79 @@ +function makeAbortError(message = 'The request was aborted') { + const error = new Error(message); + error.name = 'AbortError'; + return error; +} + +/** + * Correlates app-server requests initiated by DSHX with responses from the TUI. + * The broker owns no approval policy: transport loss, timeout and cancellation + * reject the presentation request so the DSH answerer can fail closed. + */ +export class UiRequestBroker { + constructor({ send, timeoutMs = 120_000, idPrefix = 'dshx-ui' } = {}) { + if (typeof send !== 'function') throw new Error('UiRequestBroker requires send(message)'); + this.send = send; + this.timeoutMs = timeoutMs; + this.idPrefix = idPrefix; + this.nextId = 1; + this.pending = new Map(); + this.closed = false; + } + + request(method, params, { signal } = {}) { + if (this.closed) return Promise.reject(new Error('UI request broker is closed')); + if (signal?.aborted) return Promise.reject(makeAbortError()); + + const id = `${this.idPrefix}-${this.nextId++}`; + return new Promise((resolve, reject) => { + const cleanup = () => { + clearTimeout(entry.timer); + signal?.removeEventListener('abort', entry.onAbort); + this.pending.delete(id); + }; + const entry = { + resolve: (value) => { cleanup(); resolve(value); }, + reject: (error) => { cleanup(); reject(error); }, + onAbort: () => entry.reject(makeAbortError()), + timer: undefined + }; + entry.timer = setTimeout( + () => entry.reject(new Error(`UI request timed out: ${method}`)), + this.timeoutMs + ); + signal?.addEventListener('abort', entry.onAbort, { once: true }); + this.pending.set(id, entry); + + try { + this.send({ id, method, params }); + } catch (error) { + entry.reject(error instanceof Error ? error : new Error(String(error))); + } + }); + } + + /** + * Consume a client response (`{id,result}` or `{id,error}`). Returns false + * when the id does not belong to an outstanding server-initiated request. + */ + handleResponse(message) { + if (!message || message.id === undefined || message.method !== undefined) return false; + const entry = this.pending.get(String(message.id)); + if (!entry) return false; + if (message.error !== undefined) { + const error = new Error(message.error?.message ?? 'TUI request failed'); + error.code = message.error?.code; + entry.reject(error); + } else { + entry.resolve(message.result); + } + return true; + } + + close(reason = 'UI transport closed') { + if (this.closed) return; + this.closed = true; + const entries = [...this.pending.values()]; + for (const entry of entries) entry.reject(new Error(reason)); + } +} From ab6b297d7a64f5f488769b2148002cad54486cbc Mon Sep 17 00:00:00 2001 From: code-new-bie <403466121@qq.com> Date: Wed, 19 Aug 2026 11:04:11 +0800 Subject: [PATCH 028/305] feat(m1): bridge faithful DSH approvals into Codex TUI requests --- src/dsh/approval-bridge.mjs | 88 +++++++++++++++++++++++++++++++++++++ 1 file changed, 88 insertions(+) create mode 100644 src/dsh/approval-bridge.mjs diff --git a/src/dsh/approval-bridge.mjs b/src/dsh/approval-bridge.mjs new file mode 100644 index 00000000..20bd0319 --- /dev/null +++ b/src/dsh/approval-bridge.mjs @@ -0,0 +1,88 @@ +import { mapCodexDecisionToDshApprovalOutcome } from './permissions.mjs'; + +function unavailable() { + return 'unavailable'; +} + +/** + * Presentation-only answerer for DSH `approval/request`. + * + * `classify(req)` must return a faithful presentation descriptor for a DSH + * tool that already has a matching Codex UI surface, or null when DSHX cannot + * represent the request. Null deliberately delegates to the next DSH answerer; + * if nobody owns it, DSH itself returns `unavailable` and callers fail closed. + */ +export class DshApprovalBridge { + constructor({ agent, broker, classify, diagnostics = () => {} }) { + if (!agent?.ctx) throw new Error('DshApprovalBridge requires a live DSH Agent'); + if (!broker) throw new Error('DshApprovalBridge requires a UiRequestBroker'); + if (typeof classify !== 'function') throw new Error('DshApprovalBridge requires classify(req)'); + this.agent = agent; + this.broker = broker; + this.classify = classify; + this.diagnostics = diagnostics; + this.disposeListener = agent.ctx.on('approval/request', (req, next) => this.answer(req, next)); + } + + async answer(req, next) { + const descriptor = this.classify(req); + if (!descriptor) return next(); + + const { kind, threadId, turnId, itemId } = descriptor; + if (!threadId || !turnId || !itemId) { + this.diagnostics('approval classifier returned an incomplete descriptor'); + return unavailable(); + } + + let method; + let params; + if (kind === 'command') { + method = 'item/commandExecution/requestApproval'; + params = { + threadId, + turnId, + itemId, + startedAtMs: Date.now(), + approvalId: null, + environmentId: null, + reason: req.reason ?? null, + networkApprovalContext: null, + command: descriptor.command ?? null, + cwd: descriptor.cwd ?? null, + commandActions: null, + proposedExecpolicyAmendment: null, + proposedNetworkPolicyAmendments: null + }; + } else if (kind === 'fileChange') { + method = 'item/fileChange/requestApproval'; + params = { + threadId, + turnId, + itemId, + startedAtMs: Date.now(), + reason: req.reason ?? null, + grantRoot: null + }; + } else { + this.diagnostics(`unsupported approval descriptor kind: ${String(kind)}`); + return unavailable(); + } + + try { + const result = await this.broker.request(method, params, { signal: req.signal }); + return mapCodexDecisionToDshApprovalOutcome(result?.decision); + } catch (error) { + // Any transport failure, abort, timeout, unsupported persistent grant or + // malformed decision fails closed. DSH remains the policy owner and logs + // the resulting `unavailable`/cancelled decision through its own service. + if (req.signal?.aborted || error?.name === 'AbortError') return 'cancelled'; + this.diagnostics(error instanceof Error ? error.message : String(error)); + return unavailable(); + } + } + + dispose() { + this.disposeListener?.(); + this.disposeListener = undefined; + } +} From 2ac68cc600f83275f3f88d87b7acd8dcb86c5f03 Mon Sep 17 00:00:00 2001 From: code-new-bie <403466121@qq.com> Date: Wed, 19 Aug 2026 11:04:29 +0800 Subject: [PATCH 029/305] test(protocol): cover TUI server-request correlation and fail-closed transport loss --- test/request-broker.test.mjs | 49 ++++++++++++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) create mode 100644 test/request-broker.test.mjs diff --git a/test/request-broker.test.mjs b/test/request-broker.test.mjs new file mode 100644 index 00000000..5ee7c21a --- /dev/null +++ b/test/request-broker.test.mjs @@ -0,0 +1,49 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { UiRequestBroker } from '../src/protocol/request-broker.mjs'; + +test('correlates a server-initiated request with a TUI response', async () => { + const sent = []; + const broker = new UiRequestBroker({ send: (message) => sent.push(message), timeoutMs: 1000 }); + const pending = broker.request('item/commandExecution/requestApproval', { itemId: 'item-1' }); + assert.equal(sent.length, 1); + assert.equal(sent[0].method, 'item/commandExecution/requestApproval'); + assert.match(sent[0].id, /^dshx-ui-/); + + assert.equal(broker.handleResponse({ id: sent[0].id, result: { decision: 'accept' } }), true); + assert.deepEqual(await pending, { decision: 'accept' }); + assert.equal(broker.pending.size, 0); +}); + +test('client errors reject only the matching request', async () => { + const sent = []; + const broker = new UiRequestBroker({ send: (message) => sent.push(message), timeoutMs: 1000 }); + const pending = broker.request('x', {}); + broker.handleResponse({ id: sent[0].id, error: { code: -32000, message: 'nope' } }); + await assert.rejects(pending, /nope/); +}); + +test('unknown client response is not consumed', () => { + const broker = new UiRequestBroker({ send() {} }); + assert.equal(broker.handleResponse({ id: 'other', result: {} }), false); + assert.equal(broker.handleResponse({ id: 'other', method: 'turn/start', params: {} }), false); +}); + +test('abort rejects the UI wait immediately', async () => { + const controller = new AbortController(); + const broker = new UiRequestBroker({ send() {}, timeoutMs: 1000 }); + const pending = broker.request('x', {}, { signal: controller.signal }); + controller.abort(); + await assert.rejects(pending, (error) => error?.name === 'AbortError'); + assert.equal(broker.pending.size, 0); +}); + +test('closing transport rejects all outstanding presentation waits', async () => { + const broker = new UiRequestBroker({ send() {}, timeoutMs: 1000 }); + const first = broker.request('a', {}); + const second = broker.request('b', {}); + broker.close('connection lost'); + await assert.rejects(first, /connection lost/); + await assert.rejects(second, /connection lost/); + assert.equal(broker.pending.size, 0); +}); From 9842292d7722dcbedec538869054a34128eae9d0 Mon Sep 17 00:00:00 2001 From: code-new-bie <403466121@qq.com> Date: Wed, 19 Aug 2026 11:04:52 +0800 Subject: [PATCH 030/305] test(m1): prove DSH approvals stay one-shot and fail closed --- test/dsh-approval-bridge.test.mjs | 110 ++++++++++++++++++++++++++++++ 1 file changed, 110 insertions(+) create mode 100644 test/dsh-approval-bridge.test.mjs diff --git a/test/dsh-approval-bridge.test.mjs b/test/dsh-approval-bridge.test.mjs new file mode 100644 index 00000000..8830c0aa --- /dev/null +++ b/test/dsh-approval-bridge.test.mjs @@ -0,0 +1,110 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { DshApprovalBridge } from '../src/dsh/approval-bridge.mjs'; + +function fixture({ classify, brokerResult = { decision: 'accept' }, brokerError = null } = {}) { + let listener; + let disposed = false; + const requests = []; + const diagnostics = []; + const agent = { + id: 'session-1', + ctx: { + on(name, fn) { + assert.equal(name, 'approval/request'); + listener = fn; + return () => { disposed = true; listener = undefined; }; + } + } + }; + const broker = { + async request(method, params, options) { + requests.push({ method, params, options }); + if (brokerError) throw brokerError; + return brokerResult; + } + }; + const bridge = new DshApprovalBridge({ + agent, + broker, + classify: classify ?? (() => ({ + kind: 'command', + threadId: 'session-1', + turnId: 'dsh-turn-1', + itemId: 'dsh-tool-call-1', + command: 'npm test', + cwd: '/workspace' + })), + diagnostics: (message) => diagnostics.push(message) + }); + return { + bridge, + requests, + diagnostics, + invoke(req, next = async () => 'unavailable') { return listener(req, next); }, + get disposed() { return disposed; } + }; +} + +test('command approval is rendered through Codex command approval and maps accept to allowed-once', async () => { + const fx = fixture(); + const outcome = await fx.invoke({ toolName: 'bash', callId: 'call-1', reason: 'needs wider write access' }); + assert.equal(outcome, 'allowed-once'); + assert.equal(fx.requests.length, 1); + assert.equal(fx.requests[0].method, 'item/commandExecution/requestApproval'); + assert.equal(fx.requests[0].params.command, 'npm test'); + assert.equal(fx.requests[0].params.cwd, '/workspace'); + assert.equal(fx.requests[0].params.reason, 'needs wider write access'); +}); + +test('file approval uses Codex file-change approval only when classifier proves the semantics', async () => { + const fx = fixture({ + classify: () => ({ kind: 'fileChange', threadId: 'session-1', turnId: 'dsh-turn-2', itemId: 'file-1' }), + brokerResult: { decision: 'decline' } + }); + assert.equal(await fx.invoke({ toolName: 'edit', callId: 'call-2' }), 'rejected'); + assert.equal(fx.requests[0].method, 'item/fileChange/requestApproval'); + assert.equal(fx.requests[0].params.grantRoot, null); +}); + +test('unknown DSH tool approval delegates instead of being mislabeled as shell/file', async () => { + const fx = fixture({ classify: () => null }); + let delegated = 0; + const outcome = await fx.invoke( + { toolName: 'plugin-owned-tool', callId: 'call-x' }, + async () => { delegated += 1; return 'rejected'; } + ); + assert.equal(outcome, 'rejected'); + assert.equal(delegated, 1); + assert.equal(fx.requests.length, 0); +}); + +test('Codex acceptForSession fails closed because DSH exposes no equivalent grant', async () => { + const fx = fixture({ brokerResult: { decision: 'acceptForSession' } }); + const outcome = await fx.invoke({ toolName: 'bash', callId: 'call-1' }); + assert.equal(outcome, 'unavailable'); + assert.match(fx.diagnostics[0], /no session-wide approval grant/); +}); + +test('transport errors fail closed as unavailable', async () => { + const fx = fixture({ brokerError: new Error('UI disconnected') }); + const outcome = await fx.invoke({ toolName: 'bash', callId: 'call-1' }); + assert.equal(outcome, 'unavailable'); + assert.equal(fx.diagnostics[0], 'UI disconnected'); +}); + +test('aborted DSH request settles cancelled, never granted', async () => { + const controller = new AbortController(); + const abort = new Error('aborted'); + abort.name = 'AbortError'; + const fx = fixture({ brokerError: abort }); + controller.abort(); + const outcome = await fx.invoke({ toolName: 'bash', callId: 'call-1', signal: controller.signal }); + assert.equal(outcome, 'cancelled'); +}); + +test('disposing bridge removes only its presentation answerer', () => { + const fx = fixture(); + fx.bridge.dispose(); + assert.equal(fx.disposed, true); +}); From 8226765409bcedc27404b5c1ef0bbfa6fa37119e Mon Sep 17 00:00:00 2001 From: code-new-bie <403466121@qq.com> Date: Wed, 19 Aug 2026 11:08:53 +0800 Subject: [PATCH 031/305] feat(m1): mirror official Web Host model selection entry-point semantics --- src/dsh/agent-driver.mjs | 149 ++++++++++++++++++++++++++++++++++----- 1 file changed, 133 insertions(+), 16 deletions(-) diff --git a/src/dsh/agent-driver.mjs b/src/dsh/agent-driver.mjs index 429b1d07..ecfc303e 100644 --- a/src/dsh/agent-driver.mjs +++ b/src/dsh/agent-driver.mjs @@ -19,25 +19,63 @@ function userMessage(text) { /** * Thin delegation layer over official DeepSeek Harness public services. * - * This class is intentionally boring: no agent loop, no tool execution, no - * transcript persistence and no policy decisions live here. It converts TUI - * intent into calls on DSH-owned objects and returns those objects/results. + * Model selection follows the official Web Host entry-point pattern: one + * process-local ModelSelectionRef per live Agent, whose read precedence is + * process pick -> latest durable request/header -> live agent default. The ref + * is installed with DSH's public `installModelSelection()` helper; DSHX does + * not implement request routing itself. */ export class DshAgentDriver { constructor(ctx) { if (!ctx || typeof ctx.get !== 'function') throw new Error('DshAgentDriver requires a Cordis Context'); this.ctx = ctx; + this.selections = new WeakMap(); } async settleComposition() { await this.ctx.get('loader')?.await?.(); } + selectionFor(agent) { + const existing = this.selections.get(agent); + if (existing) return existing; + const defaultModel = requireService(this.ctx, 'agentDefaultModel'); + let picked; + const selection = { + get current() { + if (picked !== undefined) return picked; + const logged = agent.session.requestHeader?.()?.config; + if (logged !== undefined) { + return { + provider: logged.provider, + model: logged.model, + ...(logged.reasoningEffort === undefined + ? {} + : { reasoningEffort: logged.reasoningEffort }) + }; + } + return defaultModel.currentSelection(); + }, + set current(next) { + picked = next; + }, + assembled: undefined + }; + installModelSelection(agent.ctx, selection); + this.selections.set(agent, selection); + return selection; + } + + installSelection(agentCtx) { + const agent = agentCtx.agent; + if (!agent) throw new Error('DSHX agent setup has no scoped DSH Agent'); + this.selectionFor(agent); + } + async create({ cwd = process.cwd(), sessionId = `session-${randomUUID()}` } = {}) { await this.settleComposition(); const agents = requireService(this.ctx, 'agents'); - const defaultModel = requireService(this.ctx, 'agentDefaultModel'); - const selection = defaultModel.currentSelection(); + const selection = requireService(this.ctx, 'agentDefaultModel').currentSelection(); return agents.create({ sessionId: SessionId(sessionId), @@ -46,21 +84,102 @@ export class DshAgentDriver { provider: selection.provider, model: selection.model }, - setup: (agentCtx) => { - const selected = { current: selection, assembled: undefined }; - installModelSelection(agentCtx, selected); - } + setup: (agentCtx) => this.installSelection(agentCtx) }); } /** - * Resume delegates persisted-runtime reconstruction to DSH. In particular, - * DSHX does not apply the machine's current default model to an old Session. + * Resume delegates durable reconstruction to DSH. The installed selection + * reads the resumed Session's latest request/header before consulting the + * machine's current default, matching the official Web Host precedence. */ async resume(sessionId) { await this.settleComposition(); const agents = requireService(this.ctx, 'agents'); - return agents.resume({ resumeSessionId: SessionId(sessionId) }); + return agents.resume({ + resumeSessionId: SessionId(sessionId), + setup: (agentCtx) => this.installSelection(agentCtx) + }); + } + + currentModel(agent) { + return this.selectionFor(agent).current; + } + + async selectModel(agent, requested) { + const llm = requireService(this.ctx, 'llm'); + const resolved = await llm.resolveCallConfig({ + provider: requested.provider, + model: requested.model, + ...(requested.reasoningEffort === undefined + ? {} + : { reasoningEffort: requested.reasoningEffort }) + }); + const selected = { + provider: resolved.provider, + model: resolved.model, + ...(resolved.reasoningEffort === undefined + ? {} + : { reasoningEffort: resolved.reasoningEffort }) + }; + this.selectionFor(agent).current = selected; + return selected; + } + + /** Build the same provider/model directory shape from public LLM registry APIs that DSH Web uses. */ + async modelDirectory() { + const llm = requireService(this.ctx, 'llm'); + const providers = llm.listProviders(); + const settled = await Promise.all(providers.map(async (provider) => { + try { + const models = await llm.listModels(provider.id); + const entries = await Promise.all(models.map(async (model) => { + const exact = await llm.resolveModelInfo(provider.id, model.id); + return { + id: model.id, + name: model.name, + ...(model.description === undefined ? {} : { description: model.description }), + ...(exact.inputModalities === undefined ? {} : { inputModalities: [...exact.inputModalities] }), + ...(exact.context === undefined ? {} : { contextWindow: exact.context.contextWindow }), + ...(exact.reasoning === undefined + ? {} + : { + reasoning: { + efforts: exact.reasoning.efforts.map((effort) => ({ + id: effort.id, + name: effort.name, + ...(effort.description === undefined ? {} : { description: effort.description }) + })), + ...(exact.reasoning.defaultEffort === undefined + ? {} + : { defaultEffort: exact.reasoning.defaultEffort }) + } + }) + }; + })); + return { + ok: true, + group: { + provider: provider.id, + name: provider.name ?? provider.id, + models: entries + } + }; + } catch (error) { + return { + ok: false, + failure: { + provider: provider.id, + message: error instanceof Error ? error.message : String(error) + } + }; + } + })); + + return { + groups: settled.filter((entry) => entry.ok && entry.group.models.length > 0).map((entry) => entry.group), + failures: settled.filter((entry) => !entry.ok).map((entry) => entry.failure) + }; } followup(agent, text) { @@ -92,12 +211,10 @@ export class DshAgentDriver { } async listSessions(options = {}) { - const persistence = requireService(this.ctx, 'sessionPersistence'); - return persistence.list(options); + return requireService(this.ctx, 'sessionPersistence').list(options); } async inspectSession(sessionId) { - const persistence = requireService(this.ctx, 'sessionPersistence'); - return persistence.inspect(SessionId(sessionId)); + return requireService(this.ctx, 'sessionPersistence').inspect(SessionId(sessionId)); } } From 72718ab48596fd3d04e91465d2420359fc9e62c5 Mon Sep 17 00:00:00 2001 From: code-new-bie <403466121@qq.com> Date: Wed, 19 Aug 2026 11:09:38 +0800 Subject: [PATCH 032/305] test(m1): mirror DSH Web Host model selection and catalog behavior --- test/dsh-agent-driver.test.mjs | 126 +++++++++++++++++++++++++++++---- 1 file changed, 113 insertions(+), 13 deletions(-) diff --git a/test/dsh-agent-driver.test.mjs b/test/dsh-agent-driver.test.mjs index a127c287..6da7b312 100644 --- a/test/dsh-agent-driver.test.mjs +++ b/test/dsh-agent-driver.test.mjs @@ -2,17 +2,29 @@ import assert from 'node:assert/strict'; import test from 'node:test'; import { DshAgentDriver } from '../src/dsh/agent-driver.mjs'; -function fixture() { +function fixture({ loggedConfig = undefined } = {}) { const calls = []; const listeners = []; const agent = { id: 'session-live', + session: { + requestHeader() { + return loggedConfig === undefined ? undefined : { config: loggedConfig }; + } + }, + ctx: { + on(name) { + listeners.push(name); + return () => {}; + } + }, followup(message) { calls.push(['followup', message]); }, steer(message) { calls.push(['steer', message]); }, cancel(cause, options) { calls.push(['cancel', cause, options]); }, whenIdle() { calls.push(['whenIdle']); return Promise.resolve(); } }; const handle = { agent, dispose: async () => {} }; + const agentCtx = { ...agent.ctx, agent }; const services = { loader: { async await() { calls.push(['loader.await']); } }, agentDefaultModel: { @@ -24,19 +36,63 @@ function fixture() { agents: { async create(options) { calls.push(['agents.create', options]); - options.setup?.({ - on(name) { - listeners.push(name); - return () => {}; - } - }); + await options.setup?.(agentCtx); + return handle; + }, + async resume(options) { + calls.push(['agents.resume', options]); + await options.setup?.(agentCtx); return handle; }, - async resume(options) { calls.push(['agents.resume', options]); return handle; }, get(id) { calls.push(['agents.get', id]); return agent; }, list() { calls.push(['agents.list']); return [agent]; }, roots() { calls.push(['agents.roots']); return [agent]; } }, + llm: { + listProviders() { + calls.push(['llm.listProviders']); + return [ + { id: 'deepseek', name: 'DeepSeek' }, + { id: 'broken', name: 'Broken provider' } + ]; + }, + async listModels(provider) { + calls.push(['llm.listModels', provider]); + if (provider === 'broken') throw new Error('catalog unavailable'); + return [ + { provider, id: 'model-a', name: 'Model A', description: 'First model' }, + { provider, id: 'model-b', name: 'Model B' } + ]; + }, + async resolveModelInfo(provider, model) { + calls.push(['llm.resolveModelInfo', provider, model]); + return { + provider, + id: model, + name: model === 'model-a' ? 'Model A' : 'Model B', + inputModalities: ['text'], + context: { contextWindow: 128000 }, + ...(model === 'model-a' + ? { + reasoning: { + efforts: [ + { id: 'low', name: 'Low' }, + { id: 'high', name: 'High', description: 'More reasoning' } + ], + defaultEffort: 'low' + } + } + : {}) + }; + }, + async resolveCallConfig(config) { + calls.push(['llm.resolveCallConfig', config]); + return { + ...config, + ...(config.reasoningEffort === undefined ? { reasoningEffort: 'low' } : {}) + }; + } + }, sessionPersistence: { async list(options) { calls.push(['sessionPersistence.list', options]); return { entries: [] }; }, async inspect(id) { calls.push(['sessionPersistence.inspect', id]); return { id, events: [] }; } @@ -51,7 +107,7 @@ function fixture() { }; } -test('create uses DSH default model and official agents.create', async () => { +test('create uses DSH default model and installs the official model-selection hooks', async () => { const fx = fixture(); const driver = new DshAgentDriver(fx.ctx); const handle = await driver.create({ cwd: '/workspace', sessionId: 'session-new' }); @@ -67,20 +123,64 @@ test('create uses DSH default model and official agents.create', async () => { assert.deepEqual(fx.listeners.sort(), ['agent/request', 'system-prompt/assemble'].sort()); }); -test('resume delegates persisted reconstruction without applying current default model', async () => { - const fx = fixture(); +test('resume restores latest request/header before consulting machine current default', async () => { + const fx = fixture({ + loggedConfig: { provider: 'persisted', model: 'old-model', reasoningEffort: 'max' } + }); const driver = new DshAgentDriver(fx.ctx); await driver.resume('session-old'); const resume = fx.calls.find(([name]) => name === 'agents.resume'); - assert.deepEqual(resume[1], { resumeSessionId: 'session-old' }); + assert.equal(resume[1].resumeSessionId, 'session-old'); + assert.equal(typeof resume[1].setup, 'function'); + assert.deepEqual(driver.currentModel(fx.agent), { + provider: 'persisted', + model: 'old-model', + reasoningEffort: 'max' + }); assert.equal( fx.calls.some(([name]) => name === 'defaultModel.currentSelection'), false, - 'DSHX must not overwrite a resumed Session with the machine current default model' + 'persisted request/header must win over the current machine default' ); }); +test('blank resumed session falls back to live DSH default only when read', async () => { + const fx = fixture(); + const driver = new DshAgentDriver(fx.ctx); + await driver.resume('blank-session'); + assert.equal(fx.calls.some(([name]) => name === 'defaultModel.currentSelection'), false); + assert.deepEqual(driver.currentModel(fx.agent), { + provider: 'deepseek', + model: 'deepseek-test', + reasoningEffort: 'high' + }); +}); + +test('model directory uses public LLM registry and isolates provider failures', async () => { + const fx = fixture(); + const directory = await new DshAgentDriver(fx.ctx).modelDirectory(); + assert.equal(directory.groups.length, 1); + assert.equal(directory.groups[0].provider, 'deepseek'); + assert.equal(directory.groups[0].models[0].reasoning.defaultEffort, 'low'); + assert.deepEqual(directory.groups[0].models[0].inputModalities, ['text']); + assert.equal(directory.groups[0].models[0].contextWindow, 128000); + assert.deepEqual(directory.failures, [{ provider: 'broken', message: 'catalog unavailable' }]); +}); + +test('model switch is validated/defaulted by DSH resolveCallConfig before selection changes', async () => { + const fx = fixture(); + const driver = new DshAgentDriver(fx.ctx); + await driver.resume('blank-session'); + const selected = await driver.selectModel(fx.agent, { provider: 'deepseek', model: 'model-a' }); + assert.deepEqual(selected, { provider: 'deepseek', model: 'model-a', reasoningEffort: 'low' }); + assert.deepEqual(driver.currentModel(fx.agent), selected); + assert.deepEqual(fx.calls.find(([name]) => name === 'llm.resolveCallConfig'), [ + 'llm.resolveCallConfig', + { provider: 'deepseek', model: 'model-a' } + ]); +}); + test('followup and steering create official DSH user messages and delegate to Agent', () => { const fx = fixture(); const driver = new DshAgentDriver(fx.ctx); From 28fdae66c7e3885ae359086d26f6ab0563e0e458 Mon Sep 17 00:00:00 2001 From: code-new-bie <403466121@qq.com> Date: Wed, 19 Aug 2026 11:11:28 +0800 Subject: [PATCH 033/305] feat(m1): bridge faithful DSH user questions into Codex TUI requests --- src/dsh/user-question-bridge.mjs | 93 ++++++++++++++++++++++++++++++++ 1 file changed, 93 insertions(+) create mode 100644 src/dsh/user-question-bridge.mjs diff --git a/src/dsh/user-question-bridge.mjs b/src/dsh/user-question-bridge.mjs new file mode 100644 index 00000000..1d1278b5 --- /dev/null +++ b/src/dsh/user-question-bridge.mjs @@ -0,0 +1,93 @@ +function requireService(ctx, name) { + const service = ctx.get(name); + if (service === undefined) throw new Error(`DSHX requires DSH service: ${name}`); + return service; +} + +function codexQuestion(question) { + const prompt = question.detail === undefined + ? question.question + : `${question.question}\n\n${question.detail}`; + return { + id: question.id, + header: question.header ?? 'Question', + question: prompt, + isOther: true, + isSecret: false, + options: question.options === undefined + ? null + : question.options.map((option) => ({ + label: option.label, + description: option.description ?? '' + })) + }; +} + +function dshAnswer(question, response) { + const values = response?.answers?.[question.id]?.answers ?? []; + const labels = new Set((question.options ?? []).map((option) => option.label)); + const selected = values.filter((value) => labels.has(value)); + const custom = values.filter((value) => !labels.has(value)); + return { + id: question.id, + selected, + ...(custom.length === 0 ? {} : { custom: custom.join('\n') }) + }; +} + +/** + * UI provider for the official DSH `ctx.userQuestions` seam. + * + * `locate(request)` correlates the question to an already-projected DSH tool + * item. The provider owns no tool behavior and no durable answers; it blocks + * only on the TUI server-request and returns the user's structured answer back + * to the official DSH service. + */ +export class DshUserQuestionBridge { + constructor({ ctx, broker, locate, diagnostics = () => {} }) { + if (!ctx || typeof ctx.get !== 'function') throw new Error('DshUserQuestionBridge requires a Cordis Context'); + if (!broker) throw new Error('DshUserQuestionBridge requires a UiRequestBroker'); + if (typeof locate !== 'function') throw new Error('DshUserQuestionBridge requires locate(request)'); + this.ctx = ctx; + this.broker = broker; + this.locate = locate; + this.diagnostics = diagnostics; + this.disposeProvider = requireService(ctx, 'userQuestions').registerProvider({ + ask: (request) => this.ask(request) + }); + } + + async ask(request) { + if (request.questions.some((question) => question.multiSelect === true)) { + throw new Error('DSHX pinned Codex TUI does not yet represent DSH multi-select questions faithfully'); + } + const location = this.locate(request); + if (!location?.threadId || !location?.turnId || !location?.itemId) { + throw new Error('DSHX could not correlate the DSH user question to a projected tool item'); + } + + try { + const response = await this.broker.request( + 'item/tool/requestUserInput', + { + threadId: location.threadId, + turnId: location.turnId, + itemId: location.itemId, + questions: request.questions.map(codexQuestion), + isBlocking: true, + autoResolutionMs: null + }, + { signal: request.signal } + ); + return { answers: request.questions.map((question) => dshAnswer(question, response)) }; + } catch (error) { + this.diagnostics(error instanceof Error ? error.message : String(error)); + throw error; + } + } + + dispose() { + this.disposeProvider?.(); + this.disposeProvider = undefined; + } +} From e3de0b30be9f38e386e8248a8ce13547dc13bfd5 Mon Sep 17 00:00:00 2001 From: code-new-bie <403466121@qq.com> Date: Wed, 19 Aug 2026 11:11:48 +0800 Subject: [PATCH 034/305] test(m1): cover faithful DSH ask-user projection and unsupported multi-select --- test/dsh-user-question-bridge.test.mjs | 121 +++++++++++++++++++++++++ 1 file changed, 121 insertions(+) create mode 100644 test/dsh-user-question-bridge.test.mjs diff --git a/test/dsh-user-question-bridge.test.mjs b/test/dsh-user-question-bridge.test.mjs new file mode 100644 index 00000000..2af31dfe --- /dev/null +++ b/test/dsh-user-question-bridge.test.mjs @@ -0,0 +1,121 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { DshUserQuestionBridge } from '../src/dsh/user-question-bridge.mjs'; + +function fixture({ response, locate } = {}) { + let provider; + let disposed = false; + const requests = []; + const diagnostics = []; + const userQuestions = { + registerProvider(next) { + provider = next; + return () => { disposed = true; provider = undefined; }; + } + }; + const broker = { + async request(method, params, options) { + requests.push({ method, params, options }); + return response ?? { answers: {} }; + } + }; + const bridge = new DshUserQuestionBridge({ + ctx: { get(name) { return name === 'userQuestions' ? userQuestions : undefined; } }, + broker, + locate: locate ?? (() => ({ threadId: 'session-1', turnId: 'dsh-turn-1', itemId: 'ask-tool-1' })), + diagnostics: (message) => diagnostics.push(message) + }); + return { + bridge, + requests, + diagnostics, + ask(request) { return provider.ask(request); }, + get disposed() { return disposed; } + }; +} + +test('single-select question maps to Codex request and structured DSH answer', async () => { + const fx = fixture({ + response: { answers: { q1: { answers: ['PostgreSQL'] } } } + }); + const result = await fx.ask({ + questions: [{ + id: 'q1', + header: 'Database', + question: 'Which database?', + options: [ + { label: 'PostgreSQL', description: 'Production database' }, + { label: 'SQLite' } + ] + }] + }); + + assert.equal(fx.requests[0].method, 'item/tool/requestUserInput'); + assert.deepEqual(fx.requests[0].params.questions[0], { + id: 'q1', + header: 'Database', + question: 'Which database?', + isOther: true, + isSecret: false, + options: [ + { label: 'PostgreSQL', description: 'Production database' }, + { label: 'SQLite', description: '' } + ] + }); + assert.deepEqual(result, { answers: [{ id: 'q1', selected: ['PostgreSQL'] }] }); +}); + +test('free-text answer is returned as DSH custom text', async () => { + const fx = fixture({ response: { answers: { q1: { answers: ['Use the internal cluster'] } } } }); + const result = await fx.ask({ + questions: [{ id: 'q1', question: 'Where should this deploy?' }] + }); + assert.equal(fx.requests[0].params.questions[0].options, null); + assert.deepEqual(result, { + answers: [{ id: 'q1', selected: [], custom: 'Use the internal cluster' }] + }); +}); + +test('detail is preserved in display text without changing answer encoding', async () => { + const fx = fixture({ response: { answers: { review: { answers: ['Approve'] } } } }); + await fx.ask({ + questions: [{ + id: 'review', + question: 'Approve this plan?', + detail: '1. Inspect\n2. Edit\n3. Test', + options: [{ label: 'Approve' }, { label: 'Reject' }], + intent: { kind: 'plan-review', approve: 'Approve' } + }] + }); + assert.match(fx.requests[0].params.questions[0].question, /1\. Inspect/); +}); + +test('multi-select is explicit unsupported instead of silently degrading to single-select', async () => { + const fx = fixture(); + await assert.rejects( + () => fx.ask({ + questions: [{ + id: 'many', + question: 'Choose several', + multiSelect: true, + options: [{ label: 'A' }, { label: 'B' }] + }] + }), + /does not yet represent DSH multi-select questions faithfully/ + ); + assert.equal(fx.requests.length, 0); +}); + +test('missing tool correlation is an explicit compatibility failure', async () => { + const fx = fixture({ locate: () => null }); + await assert.rejects( + () => fx.ask({ questions: [{ id: 'q', question: 'Question?' }] }), + /could not correlate/ + ); +}); + +test('bridge disposal unregisters only the UI provider', () => { + const fx = fixture(); + fx.bridge.dispose(); + assert.equal(fx.disposed, true); +}); From 63a5b748980c203e7cdd24f3a2c2779ebd60983b Mon Sep 17 00:00:00 2001 From: code-new-bie <403466121@qq.com> Date: Wed, 19 Aug 2026 11:23:05 +0800 Subject: [PATCH 035/305] feat(m1): translate DSH-owned tool presenters into Codex cells --- src/dsh/tool-presentation.mjs | 219 ++++++++++++++++++++++++++++++++++ 1 file changed, 219 insertions(+) create mode 100644 src/dsh/tool-presentation.mjs diff --git a/src/dsh/tool-presentation.mjs b/src/dsh/tool-presentation.mjs new file mode 100644 index 00000000..4396c471 --- /dev/null +++ b/src/dsh/tool-presentation.mjs @@ -0,0 +1,219 @@ +import path from 'node:path'; + +function parseArguments(raw) { + if (typeof raw !== 'string') return raw ?? {}; + try { return JSON.parse(raw); } catch { return { raw }; } +} + +function toolResultFromEvent(data) { + const block = data?.message?.content?.find?.((entry) => entry?.type === 'tool-result'); + return { + content: Array.isArray(block?.content) ? block.content : [], + isError: Boolean(block?.isError ?? data?.error), + ...(data?.meta === undefined ? {} : { meta: data.meta }) + }; +} + +function textContentItems(content) { + const items = []; + for (const block of content ?? []) { + if (block?.type === 'text' && typeof block.text === 'string') { + items.push({ type: 'inputText', text: block.text }); + } + } + return items.length === 0 ? null : items; +} + +function resolveCwd(workspaceCwd, candidate) { + if (!candidate) return workspaceCwd; + return path.isAbsolute(candidate) ? candidate : path.resolve(workspaceCwd, candidate); +} + +function lineCount(text) { + if (text === '') return 0; + return text.split(/\r?\n/).length; +} + +function displayDiff(file) { + // DSH FileDiff may represent a contextual hunk rather than whole-file text. + // Codex only needs display diff text here; avoid fabricating source line numbers. + const oldLines = String(file.oldText ?? '').split(/\r?\n/); + const newLines = String(file.newText ?? '').split(/\r?\n/); + const oldCount = lineCount(String(file.oldText ?? '')); + const newCount = lineCount(String(file.newText ?? '')); + return [ + `--- a/${file.path}`, + `+++ b/${file.path}`, + `@@ -1,${oldCount} +1,${newCount} @@`, + ...oldLines.map((line) => `-${line}`), + ...newLines.map((line) => `+${line}`) + ].join('\n'); +} + +function canUseCodexFileChange(view) { + return view?.card === 'diff' && + Array.isArray(view.diffs) && + view.diffs.length > 0 && + view.diffs.every((diff) => typeof diff?.path === 'string' && diff.oldText !== null); +} + +function fileChangeItem(id, view, status) { + return { + type: 'fileChange', + id, + changes: view.diffs.map((diff) => ({ + path: diff.path, + kind: { type: 'update', move_path: null }, + diff: displayDiff(diff) + })), + status + }; +} + +function dynamicItem(id, toolName, args, status, result = null, durationMs = null) { + return { + type: 'dynamicToolCall', + id, + namespace: null, + tool: toolName, + arguments: args, + status, + contentItems: result ? textContentItems(result.content) : null, + success: result ? !result.isError : null, + durationMs + }; +} + +/** + * Resolves UI semantics exclusively through DSH ToolDefinition presenters. + * + * No tool names or argument schemas are embedded here. `ctx.tools.get(name, + * agent)` is authoritative for the definition visible to this Agent; its pure + * `presentCall/presentResult` methods decide terminal/diff/generic intent. + */ +export class DshToolPresentationResolver { + constructor({ ctx, agent, threadId, workspaceCwd = process.cwd(), diagnostics = () => {} }) { + if (!ctx || typeof ctx.get !== 'function') throw new Error('DshToolPresentationResolver requires a Cordis Context'); + if (!agent) throw new Error('DshToolPresentationResolver requires a DSH Agent'); + this.ctx = ctx; + this.agent = agent; + this.threadId = String(threadId ?? agent.id); + this.workspaceCwd = path.resolve(workspaceCwd); + this.diagnostics = diagnostics; + this.calls = new Map(); + } + + definition(name) { + return this.ctx.get('tools')?.get?.(name, this.agent); + } + + start({ turnId, callId, name, rawArguments, startedAtMs = Date.now() }) { + const args = parseArguments(rawArguments); + const definition = this.definition(name); + let view; + try { view = definition?.presentCall?.(args); } + catch (error) { this.diagnostics(`presentCall(${name}): ${error instanceof Error ? error.message : error}`); } + + const id = `dsh-tool-${String(callId)}`; + let item; + let semantic = 'generic'; + if (view?.card === 'terminal') { + semantic = 'command'; + item = { + type: 'commandExecution', + id, + pluginId: null, + scriptPath: null, + command: view.title, + cwd: resolveCwd(this.workspaceCwd, view.cwd), + processId: null, + source: 'agent', + status: 'inProgress', + commandActions: [], + aggregatedOutput: null, + exitCode: null, + durationMs: null + }; + } else if (canUseCodexFileChange(view)) { + semantic = 'fileChange'; + item = fileChangeItem(id, view, 'inProgress'); + } else { + // DSH `oldText:null` is intentionally ambiguous between create and + // overwrite, while Codex requires add/delete/update. Stay generic rather + // than fabricate a PatchChangeKind. + item = dynamicItem(id, name, args, 'inProgress'); + } + + const state = { + callId: String(callId), + name, + args, + definition, + callView: view, + semantic, + item, + turnId, + startedAtMs + }; + this.calls.set(String(callId), state); + return { item, state }; + } + + complete({ callId, resultData, completedAtMs = Date.now() }) { + const state = this.calls.get(String(callId)); + if (!state) return undefined; + const result = toolResultFromEvent(resultData); + let view; + try { view = state.definition?.presentResult?.(state.args, result); } + catch (error) { this.diagnostics(`presentResult(${state.name}): ${error instanceof Error ? error.message : error}`); } + const durationMs = Math.max(0, completedAtMs - state.startedAtMs); + + let item; + if (state.semantic === 'command') { + const terminal = view?.card === 'terminal' ? view : undefined; + const failed = result.isError || Boolean(terminal?.signal) || + (terminal?.exitCode !== undefined && terminal.exitCode !== 0); + item = { + ...state.item, + status: failed ? 'failed' : 'completed', + aggregatedOutput: terminal?.output ?? textContentItems(result.content)?.map((entry) => entry.text).join('\n') ?? null, + exitCode: terminal?.exitCode ?? null, + durationMs + }; + } else if (state.semantic === 'fileChange') { + item = view?.card === 'diff' && canUseCodexFileChange(view) + ? fileChangeItem(state.item.id, view, result.isError ? 'failed' : 'completed') + : { ...state.item, status: result.isError ? 'failed' : 'completed' }; + } else { + item = dynamicItem( + state.item.id, + state.name, + state.args, + result.isError ? 'failed' : 'completed', + result, + durationMs + ); + } + + state.item = item; + state.resultView = view; + state.result = result; + return { item, state }; + } + + correlation(callId) { + const state = this.calls.get(String(callId)); + if (!state) return undefined; + return { + threadId: this.threadId, + turnId: state.turnId, + itemId: state.item.id, + semantic: state.semantic, + ...(state.semantic === 'command' + ? { kind: 'command', command: state.item.command, cwd: state.item.cwd } + : state.semantic === 'fileChange' + ? { kind: 'fileChange' } + : {}) + }; + } +} From 2adb6cb4fadce1e0fb8b5e96d19d1af18ded7d13 Mon Sep 17 00:00:00 2001 From: code-new-bie <403466121@qq.com> Date: Wed, 19 Aug 2026 11:24:00 +0800 Subject: [PATCH 036/305] feat(m1): drive tool cells from DSH presenters and fix durable result correlation --- src/dsh/session-projector.mjs | 234 ++++++++++++++-------------------- 1 file changed, 95 insertions(+), 139 deletions(-) diff --git a/src/dsh/session-projector.mjs b/src/dsh/session-projector.mjs index 8fa08001..65217578 100644 --- a/src/dsh/session-projector.mjs +++ b/src/dsh/session-projector.mjs @@ -3,11 +3,7 @@ const nowMillis = () => Date.now(); function parseJsonOrString(value) { if (typeof value !== 'string') return value ?? null; - try { - return JSON.parse(value); - } catch { - return value; - } + try { return JSON.parse(value); } catch { return value; } } function visibleText(message) { @@ -17,15 +13,24 @@ function visibleText(message) { .join(''); } +function toolResultCallId(data) { + const sourceId = data?.message?.source?.kind === 'tool' ? data.message.source.callId : undefined; + if (sourceId !== undefined) return sourceId; + const block = data?.message?.content?.find?.((entry) => entry?.type === 'tool-result'); + return block?.toolCallId; +} + +function toolResultFailed(data) { + const block = data?.message?.content?.find?.((entry) => entry?.type === 'tool-result'); + return Boolean(block?.isError ?? data?.error); +} + function mapTurnStatus(reason) { switch (reason?.kind) { - case 'completed': - return 'completed'; + case 'completed': return 'completed'; case 'aborted': - case 'interrupted': - return 'interrupted'; - default: - return 'failed'; + case 'interrupted': return 'interrupted'; + default: return 'failed'; } } @@ -44,22 +49,22 @@ function turnRecord(id, status, startedAt, completedAt = null, error = null) { error, startedAt, completedAt, - durationMs: - startedAt != null && completedAt != null ? Math.max(0, (completedAt - startedAt) * 1000) : null + durationMs: startedAt != null && completedAt != null + ? Math.max(0, (completedAt - startedAt) * 1000) + : null }; } /** - * A deliberately small, stateful UI projector for official DSH Session events. - * - * It never mutates DSH, never executes tools and never persists conversation state. - * Its maps are disposable correlation state required to speak the Codex TUI protocol. + * Stateful presentation projection of official DSH Session events. + * Durable truth stays in DSH; all maps here are disposable UI correlation. */ export class DshSessionProjector { - constructor({ threadId, sessionId = threadId } = {}) { + constructor({ threadId, sessionId = threadId, toolPresentation = null } = {}) { if (!threadId) throw new Error('DshSessionProjector requires threadId'); this.threadId = threadId; this.sessionId = sessionId; + this.toolPresentation = toolPresentation; this.turns = new Map(); this.assistantItems = new Map(); this.tools = new Map(); @@ -71,20 +76,13 @@ export class DshSessionProjector { project(event) { if (!event || typeof event.type !== 'string') return []; switch (event.type) { - case 'turn/start': - return this.turnStarted(event); - case 'turn/end': - return this.turnEnded(event); - case 'assistant/chunk': - return this.assistantChunk(event); - case 'assistant/message': - return this.assistantMessage(event); - case 'tool/call': - return this.toolCall(event); - case 'tool/result': - return this.toolResult(event); - case 'todo/write': - return this.todoWrite(event); + case 'turn/start': return this.turnStarted(event); + case 'turn/end': return this.turnEnded(event); + case 'assistant/chunk': return this.assistantChunk(event); + case 'assistant/message': return this.assistantMessage(event); + case 'tool/call': return this.toolCall(event); + case 'tool/result': return this.toolResult(event); + case 'todo/write': return this.todoWrite(event); case 'request/header': this.latestHeader = event.data?.header ?? null; return []; @@ -103,15 +101,7 @@ export class DshSessionProjector { const startedAt = Number.isFinite(event.time) ? Math.floor(event.time / 1000) : nowSeconds(); this.turns.set(dshTurn, { id: turnId, startedAt }); this.currentTurn = dshTurn; - return [ - { - method: 'turn/started', - params: { - threadId: this.threadId, - turn: turnRecord(turnId, 'inProgress', startedAt) - } - } - ]; + return [{ method: 'turn/started', params: { threadId: this.threadId, turn: turnRecord(turnId, 'inProgress', startedAt) } }]; } turnEnded(event) { @@ -121,20 +111,11 @@ export class DshSessionProjector { const reason = event.data?.reason; const status = mapTurnStatus(reason); const completedAt = Number.isFinite(event.time) ? Math.floor(event.time / 1000) : nowSeconds(); - const error = - status === 'failed' && reason?.kind === 'error' - ? { message: reason.error?.message ?? 'DSH turn failed', codexErrorInfo: null, additionalDetails: null } - : null; + const error = status === 'failed' && reason?.kind === 'error' + ? { message: reason.error?.message ?? 'DSH turn failed', codexErrorInfo: null, additionalDetails: null } + : null; if (this.currentTurn === dshTurn) this.currentTurn = null; - return [ - { - method: 'turn/completed', - params: { - threadId: this.threadId, - turn: turnRecord(state.id, status, state.startedAt, completedAt, error) - } - } - ]; + return [{ method: 'turn/completed', params: { threadId: this.threadId, turn: turnRecord(state.id, status, state.startedAt, completedAt, error) } }]; } assistantChunk(event) { @@ -142,17 +123,13 @@ export class DshSessionProjector { if (!Number.isInteger(turn) || !Number.isInteger(step) || !chunk) return []; const turnState = this.turns.get(turn); if (!turnState) return []; - - // Only visible-text deltas are projected until the pinned Codex reasoning - // delta contract is mapped explicitly. Ignoring a presentation surface is - // safer than misclassifying DSH reasoning semantics. if (chunk.type !== 'text-delta' || typeof chunk.text !== 'string') return []; const key = `${turn}:${step}`; let item = this.assistantItems.get(key); const events = []; if (!item) { - item = { id: `dsh-assistant-${turn}-${step}`, text: '', started: true }; + item = { id: `dsh-assistant-${turn}-${step}`, text: '' }; this.assistantItems.set(key, item); events.push({ method: 'item/started', @@ -160,26 +137,14 @@ export class DshSessionProjector { threadId: this.threadId, turnId: turnState.id, startedAtMs: Number.isFinite(event.time) ? event.time : nowMillis(), - item: { - type: 'agentMessage', - id: item.id, - text: '', - phase: null, - memoryCitation: null, - delivery: null - } + item: { type: 'agentMessage', id: item.id, text: '', phase: null, memoryCitation: null, delivery: null } } }); } item.text += chunk.text; events.push({ method: 'item/agentMessage/delta', - params: { - threadId: this.threadId, - turnId: turnState.id, - itemId: item.id, - delta: chunk.text - } + params: { threadId: this.threadId, turnId: turnState.id, itemId: item.id, delta: chunk.text } }); return events; } @@ -194,7 +159,7 @@ export class DshSessionProjector { let item = this.assistantItems.get(key); const events = []; if (!item) { - item = { id: `dsh-assistant-${turn}-${step}`, text: '', started: true }; + item = { id: `dsh-assistant-${turn}-${step}`, text: '' }; this.assistantItems.set(key, item); events.push({ method: 'item/started', @@ -202,14 +167,7 @@ export class DshSessionProjector { threadId: this.threadId, turnId: turnState.id, startedAtMs: Number.isFinite(event.time) ? event.time : nowMillis(), - item: { - type: 'agentMessage', - id: item.id, - text: '', - phase: null, - memoryCitation: null, - delivery: null - } + item: { type: 'agentMessage', id: item.id, text: '', phase: null, memoryCitation: null, delivery: null } } }); } @@ -220,14 +178,7 @@ export class DshSessionProjector { threadId: this.threadId, turnId: turnState.id, completedAtMs: Number.isFinite(event.time) ? event.time : nowMillis(), - item: { - type: 'agentMessage', - id: item.id, - text: finalText, - phase: null, - memoryCitation: null, - delivery: null - } + item: { type: 'agentMessage', id: item.id, text: finalText, phase: null, memoryCitation: null, delivery: null } } }); return events; @@ -237,37 +188,57 @@ export class DshSessionProjector { const { turn, callId, name, arguments: rawArguments } = event.data ?? {}; const turnState = this.turns.get(turn); if (!turnState || !callId || typeof name !== 'string') return []; - const item = { - type: 'dynamicToolCall', - id: `dsh-tool-${String(callId)}`, - namespace: null, - tool: name, - arguments: parseJsonOrString(rawArguments), - status: 'inProgress', - contentItems: null, - success: null, - durationMs: null - }; - this.tools.set(String(callId), { item, turnId: turnState.id, startedAt: event.time ?? nowMillis() }); - return [ - { - method: 'item/started', + const startedAtMs = Number.isFinite(event.time) ? event.time : nowMillis(); + + let item; + if (this.toolPresentation) { + ({ item } = this.toolPresentation.start({ + turnId: turnState.id, + callId, + name, + rawArguments, + startedAtMs + })); + } else { + item = { + type: 'dynamicToolCall', + id: `dsh-tool-${String(callId)}`, + namespace: null, + tool: name, + arguments: parseJsonOrString(rawArguments), + status: 'inProgress', + contentItems: null, + success: null, + durationMs: null + }; + this.tools.set(String(callId), { item, turnId: turnState.id, startedAt: startedAtMs }); + } + + return [{ method: 'item/started', params: { threadId: this.threadId, turnId: turnState.id, startedAtMs, item } }]; + } + + toolResult(event) { + const callId = toolResultCallId(event.data); + if (callId === undefined) return []; + const completedAtMs = Number.isFinite(event.time) ? event.time : nowMillis(); + + if (this.toolPresentation) { + const completed = this.toolPresentation.complete({ callId, resultData: event.data, completedAtMs }); + if (!completed) return []; + return [{ + method: 'item/completed', params: { threadId: this.threadId, - turnId: turnState.id, - startedAtMs: Number.isFinite(event.time) ? event.time : nowMillis(), - item + turnId: completed.state.turnId, + completedAtMs, + item: completed.item } - } - ]; - } + }]; + } - toolResult(event) { - const { callId, error } = event.data ?? {}; const state = this.tools.get(String(callId)); if (!state) return []; - const failed = Boolean(error); - const completedAtMs = Number.isFinite(event.time) ? event.time : nowMillis(); + const failed = toolResultFailed(event.data); const completed = { ...state.item, status: failed ? 'failed' : 'completed', @@ -275,17 +246,7 @@ export class DshSessionProjector { durationMs: Math.max(0, completedAtMs - state.startedAt) }; this.tools.delete(String(callId)); - return [ - { - method: 'item/completed', - params: { - threadId: this.threadId, - turnId: state.turnId, - completedAtMs, - item: completed - } - } - ]; + return [{ method: 'item/completed', params: { threadId: this.threadId, turnId: state.turnId, completedAtMs, item: completed } }]; } todoWrite(event) { @@ -293,19 +254,14 @@ export class DshSessionProjector { const turnState = this.turns.get(this.currentTurn); if (!turnState) return []; const todos = Array.isArray(event.data?.todos) ? event.data.todos : []; - return [ - { - method: 'turn/plan/updated', - params: { - threadId: this.threadId, - turnId: turnState.id, - explanation: null, - plan: todos.map((todo) => ({ - step: String(todo.content ?? ''), - status: mapPlanStatus(todo.status) - })) - } + return [{ + method: 'turn/plan/updated', + params: { + threadId: this.threadId, + turnId: turnState.id, + explanation: null, + plan: todos.map((todo) => ({ step: String(todo.content ?? ''), status: mapPlanStatus(todo.status) })) } - ]; + }]; } } From 29218e30326cd673bd3994863d0eac5e5bf98f1f Mon Sep 17 00:00:00 2001 From: code-new-bie <403466121@qq.com> Date: Wed, 19 Aug 2026 11:24:25 +0800 Subject: [PATCH 037/305] feat(m1): attach scoped DSH tool presenters to each live thread --- src/dsh/thread-controller.mjs | 32 +++++++++++++++++++++++--------- 1 file changed, 23 insertions(+), 9 deletions(-) diff --git a/src/dsh/thread-controller.mjs b/src/dsh/thread-controller.mjs index 3face3d1..e994c7fd 100644 --- a/src/dsh/thread-controller.mjs +++ b/src/dsh/thread-controller.mjs @@ -1,4 +1,5 @@ import { DshSessionProjector } from './session-projector.mjs'; +import { DshToolPresentationResolver } from './tool-presentation.mjs'; function deferred(timeoutMs, label) { let resolve; @@ -21,16 +22,31 @@ function deferred(timeoutMs, label) { * The Agent and Session remain authoritative and are never reconstructed here. */ export class DshThreadController { - constructor({ handle, driver, emit = () => {}, turnStartTimeoutMs = 15_000 }) { + constructor({ handle, driver, emit = () => {}, turnStartTimeoutMs = 15_000, diagnostics = () => {} }) { if (!handle?.agent) throw new Error('DshThreadController requires a DSH AgentHandle'); this.handle = handle; this.agent = handle.agent; this.driver = driver; this.emit = emit; this.turnStartTimeoutMs = turnStartTimeoutMs; + + // Real DSH Agent contexts expose `ctx.tools`; lightweight controller tests + // may omit it. Presentation specialization is optional and never affects + // tool execution. + this.toolPresentation = typeof this.agent.ctx?.get === 'function' + ? new DshToolPresentationResolver({ + ctx: this.agent.ctx, + agent: this.agent, + threadId: String(this.agent.id), + workspaceCwd: this.agent.session?.header?.cwd ?? process.cwd(), + diagnostics + }) + : null; + this.projector = new DshSessionProjector({ threadId: String(this.agent.id), - sessionId: String(this.agent.id) + sessionId: String(this.agent.id), + toolPresentation: this.toolPresentation }); this.pendingTurnStart = null; this.closed = false; @@ -43,14 +59,15 @@ export class DshThreadController { return String(this.agent.id); } + toolCorrelation(callId) { + return this.toolPresentation?.correlation(callId); + } + onSessionEvent(event) { if (this.closed) return; const projected = this.projector.project(event); for (const notification of projected) { if (notification.method === 'turn/started' && this.pendingTurnStart) { - // The caller needs the DSH-committed turn identity for the JSON-RPC - // response. Buffer this first notification so the response can be sent - // before the matching notification, mirroring Codex app-server order. const pending = this.pendingTurnStart; this.pendingTurnStart = null; pending.resolve({ turn: notification.params.turn, firstNotification: notification }); @@ -60,10 +77,7 @@ export class DshThreadController { } } - /** - * Submit a normal follow-up and wait until DSH itself commits turn/start. - * Returns a release callback so the transport can send the RPC response first. - */ + /** Submit a normal follow-up and wait until DSH itself commits turn/start. */ async startTurn(text) { if (this.pendingTurnStart) throw new Error('A turn/start request is already awaiting DSH'); const pending = deferred(this.turnStartTimeoutMs, 'DSH turn/start'); From 69ecc1b949da35f414cbd83ddc9cb3eb667190a6 Mon Sep 17 00:00:00 2001 From: code-new-bie <403466121@qq.com> Date: Wed, 19 Aug 2026 14:51:44 +0800 Subject: [PATCH 038/305] test: lock DSH tool presenter mappings --- test/dsh-tool-presentation.test.mjs | 187 ++++++++++++++++++++++++++++ 1 file changed, 187 insertions(+) create mode 100644 test/dsh-tool-presentation.test.mjs diff --git a/test/dsh-tool-presentation.test.mjs b/test/dsh-tool-presentation.test.mjs new file mode 100644 index 00000000..c9cb817e --- /dev/null +++ b/test/dsh-tool-presentation.test.mjs @@ -0,0 +1,187 @@ +import assert from 'node:assert/strict'; +import path from 'node:path'; +import test from 'node:test'; +import { DshToolPresentationResolver } from '../src/dsh/tool-presentation.mjs'; + +function fixture(definitions, { cwd = process.cwd() } = {}) { + const agent = { id: 'session-1' }; + const ctx = { + get(name) { + if (name !== 'tools') return undefined; + return { + get(toolName, scope) { + assert.equal(scope, agent, 'resolver must ask for the Agent-scoped visible tool'); + return definitions[toolName]; + } + }; + } + }; + return { + agent, + resolver: new DshToolPresentationResolver({ + ctx, + agent, + threadId: 'session-1', + workspaceCwd: cwd + }) + }; +} + +function toolResult(callId, { content = [{ type: 'text', text: 'ok' }], isError = false, meta } = {}) { + return { + message: { + source: { kind: 'tool', callId }, + content: [{ type: 'tool-result', toolCallId: callId, content, isError }] + }, + ...(meta === undefined ? {} : { meta }) + }; +} + +test('terminal presenter maps to Codex commandExecution and result status/output', () => { + const cwd = path.resolve('workspace'); + const { resolver } = fixture({ + shell: { + presentCall(args) { + assert.deepEqual(args, { cmd: 'echo hi' }); + return { card: 'terminal', title: 'echo hi', cwd: '.' }; + }, + presentResult(_args, result) { + assert.equal(result.isError, false); + return { card: 'terminal', output: 'hi\n', exitCode: 0 }; + } + } + }, { cwd }); + + const started = resolver.start({ + turnId: 'dsh-turn-1', + callId: 'call-shell', + name: 'shell', + rawArguments: '{"cmd":"echo hi"}', + startedAtMs: 100 + }); + assert.equal(started.item.type, 'commandExecution'); + assert.equal(started.item.command, 'echo hi'); + assert.equal(started.item.cwd, cwd); + assert.equal(started.item.status, 'inProgress'); + + const completed = resolver.complete({ + callId: 'call-shell', + resultData: toolResult('call-shell'), + completedAtMs: 125 + }); + assert.equal(completed.item.type, 'commandExecution'); + assert.equal(completed.item.status, 'completed'); + assert.equal(completed.item.aggregatedOutput, 'hi\n'); + assert.equal(completed.item.exitCode, 0); + assert.equal(completed.item.durationMs, 25); + assert.deepEqual(resolver.correlation('call-shell'), { + threadId: 'session-1', + turnId: 'dsh-turn-1', + itemId: 'dsh-tool-call-shell', + semantic: 'command', + kind: 'command', + command: 'echo hi', + cwd + }); +}); + +test('unambiguous DSH diff update maps to Codex fileChange', () => { + const { resolver } = fixture({ + editor: { + presentCall() { + return { + card: 'diff', + title: 'Edit a.txt', + diffs: [{ path: 'a.txt', oldText: 'old', newText: 'new' }] + }; + }, + presentResult() { + return { + card: 'diff', + title: 'Edit a.txt', + diffs: [{ path: 'a.txt', oldText: 'old', newText: 'new' }] + }; + } + } + }); + + const started = resolver.start({ + turnId: 'dsh-turn-2', + callId: 'call-edit', + name: 'editor', + rawArguments: '{}', + startedAtMs: 1 + }); + assert.equal(started.item.type, 'fileChange'); + assert.equal(started.item.status, 'inProgress'); + assert.deepEqual(started.item.changes[0].kind, { type: 'update', move_path: null }); + assert.match(started.item.changes[0].diff, /--- a\/a\.txt/); + + const completed = resolver.complete({ + callId: 'call-edit', + resultData: toolResult('call-edit'), + completedAtMs: 2 + }); + assert.equal(completed.item.type, 'fileChange'); + assert.equal(completed.item.status, 'completed'); + assert.equal(resolver.correlation('call-edit').kind, 'fileChange'); +}); + +test('ambiguous DSH oldText:null diff stays generic instead of fabricating add/update', () => { + const { resolver } = fixture({ + writer: { + presentCall() { + return { + card: 'diff', + title: 'Write a.txt', + diffs: [{ path: 'a.txt', oldText: null, newText: 'new' }] + }; + } + } + }); + + const started = resolver.start({ + turnId: 'dsh-turn-3', + callId: 'call-write', + name: 'writer', + rawArguments: '{"file":"a.txt"}' + }); + assert.equal(started.item.type, 'dynamicToolCall'); + assert.equal(started.state.semantic, 'generic'); + assert.equal(resolver.correlation('call-write').kind, undefined); +}); + +test('unknown or presenter-less tools remain generic and preserve text result content', () => { + const { resolver } = fixture({}); + const started = resolver.start({ + turnId: 'dsh-turn-4', + callId: 'call-custom', + name: 'custom', + rawArguments: '{"x":1}', + startedAtMs: 10 + }); + assert.equal(started.item.type, 'dynamicToolCall'); + assert.deepEqual(started.item.arguments, { x: 1 }); + + const completed = resolver.complete({ + callId: 'call-custom', + resultData: toolResult('call-custom', { content: [{ type: 'text', text: 'done' }] }), + completedAtMs: 15 + }); + assert.equal(completed.item.status, 'completed'); + assert.equal(completed.item.success, true); + assert.deepEqual(completed.item.contentItems, [{ type: 'inputText', text: 'done' }]); +}); + +test('terminal signal or non-zero exit code fails the Codex command cell', () => { + const { resolver } = fixture({ + shell: { + presentCall: () => ({ card: 'terminal', title: 'bad' }), + presentResult: () => ({ card: 'terminal', output: 'oops', exitCode: 2 }) + } + }); + resolver.start({ turnId: 'dsh-turn-5', callId: 'call-bad', name: 'shell', rawArguments: '{}' }); + const completed = resolver.complete({ callId: 'call-bad', resultData: toolResult('call-bad') }); + assert.equal(completed.item.status, 'failed'); + assert.equal(completed.item.exitCode, 2); +}); From a44c56c22630541e7e3d3e8d749aea773a27f831 Mon Sep 17 00:00:00 2001 From: code-new-bie <403466121@qq.com> Date: Wed, 19 Aug 2026 14:52:15 +0800 Subject: [PATCH 039/305] test: use official DSH tool result correlation shape --- test/dsh-session-projector.test.mjs | 40 ++++++++++++++++++++++++++--- 1 file changed, 37 insertions(+), 3 deletions(-) diff --git a/test/dsh-session-projector.test.mjs b/test/dsh-session-projector.test.mjs index adb6cf06..af9023fa 100644 --- a/test/dsh-session-projector.test.mjs +++ b/test/dsh-session-projector.test.mjs @@ -55,7 +55,7 @@ test('does not guess Codex reasoning semantics before they are explicitly mapped assert.deepEqual(output, []); }); -test('projects unknown DSH tools conservatively as generic dynamic tool calls', () => { +test('projects unknown DSH tools conservatively and correlates official tool/result message source', () => { const projector = new DshSessionProjector({ threadId: 'session-1' }); start(projector); const started = projector.project( @@ -70,14 +70,48 @@ test('projects unknown DSH tools conservatively as generic dynamic tool calls', event('tool/result', { turn: 1, step: 1, - callId: 'call-1', - message: { content: [{ type: 'text', text: 'ok' }] } + message: { + source: { kind: 'tool', callId: 'call-1' }, + content: [{ + type: 'tool-result', + toolCallId: 'call-1', + content: [{ type: 'text', text: 'ok' }], + isError: false + }] + } }, 1_700_000_000_050) ); + assert.equal(completed.length, 1); assert.equal(completed[0].params.item.status, 'completed'); assert.equal(completed[0].params.item.success, true); }); +test('falls back to tool-result block call id when message source is unavailable', () => { + const projector = new DshSessionProjector({ threadId: 'session-1' }); + start(projector); + projector.project(event('tool/call', { + turn: 1, + step: 1, + callId: 'call-2', + name: 'custom_tool', + arguments: '{}' + })); + const completed = projector.project(event('tool/result', { + turn: 1, + step: 1, + message: { + content: [{ + type: 'tool-result', + toolCallId: 'call-2', + content: [{ type: 'text', text: 'failed' }], + isError: true + }] + } + })); + assert.equal(completed[0].params.item.status, 'failed'); + assert.equal(completed[0].params.item.success, false); +}); + test('projects DSH todo snapshots into Codex plan updates', () => { const projector = new DshSessionProjector({ threadId: 'session-1' }); start(projector, 7); From f1969096186e7dcfbd38556d301651b019407e38 Mon Sep 17 00:00:00 2001 From: code-new-bie <403466121@qq.com> Date: Wed, 19 Aug 2026 15:02:14 +0800 Subject: [PATCH 040/305] feat: project DSH models and persisted sessions to Codex shapes --- src/dsh/codex-shapes.mjs | 297 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 297 insertions(+) create mode 100644 src/dsh/codex-shapes.mjs diff --git a/src/dsh/codex-shapes.mjs b/src/dsh/codex-shapes.mjs new file mode 100644 index 00000000..431998dd --- /dev/null +++ b/src/dsh/codex-shapes.mjs @@ -0,0 +1,297 @@ +import { DshToolPresentationResolver } from './tool-presentation.mjs'; + +const MODEL_PREFIX = 'dshx:'; +const DEFAULT_REASONING_SENTINEL = 'dsh-default'; + +function seconds(timeMs, fallback = Math.floor(Date.now() / 1000)) { + return Number.isFinite(timeMs) ? Math.floor(timeMs / 1000) : fallback; +} + +function visibleText(content) { + return (content ?? []) + .filter((block) => block?.type === 'text' && typeof block.text === 'string') + .map((block) => block.text) + .join(''); +} + +function latestRequestConfig(events) { + for (let index = (events?.length ?? 0) - 1; index >= 0; index -= 1) { + const event = events[index]; + if (event?.type === 'request/header') return event.data?.header?.config ?? null; + } + return null; +} + +function firstHumanPreview(events) { + for (const event of events ?? []) { + if (event?.type !== 'user/message' || event.data?.source?.kind !== 'user') continue; + const text = visibleText(event.data.content).trim(); + if (text) return text.slice(0, 240); + } + return ''; +} + +function turnStatus(reason) { + switch (reason?.kind) { + case 'completed': return 'completed'; + case 'aborted': + case 'interrupted': return 'interrupted'; + default: return 'failed'; + } +} + +function turnError(reason) { + if (reason?.kind !== 'error') return null; + return { + message: reason.error?.message ?? 'DSH turn failed', + codexErrorInfo: null, + additionalDetails: null + }; +} + +function normalizeList(value) { + if (Array.isArray(value)) return value; + if (Array.isArray(value?.entries)) return value.entries; + return []; +} + +function sourceForHeader(header) { + if (header?.origin === 'subagent') return { custom: 'dsh-subagent' }; + return { custom: 'dshx' }; +} + +function inputFromDshMessage(message) { + const content = []; + for (const block of message?.content ?? []) { + if (block?.type === 'text' && typeof block.text === 'string') { + content.push({ type: 'text', text: block.text, text_elements: [] }); + } + } + return content; +} + +export function encodeDshModel(selection) { + if (!selection?.provider || !selection?.model) throw new Error('DSHX model selection requires provider and model'); + const payload = Buffer.from(JSON.stringify([selection.provider, selection.model]), 'utf8').toString('base64url'); + return `${MODEL_PREFIX}${payload}`; +} + +export function decodeDshModel(value) { + if (typeof value !== 'string' || !value.startsWith(MODEL_PREFIX)) return undefined; + try { + const decoded = JSON.parse(Buffer.from(value.slice(MODEL_PREFIX.length), 'base64url').toString('utf8')); + if (!Array.isArray(decoded) || decoded.length !== 2 || decoded.some((part) => typeof part !== 'string' || !part)) { + return undefined; + } + return { provider: decoded[0], model: decoded[1] }; + } catch { + return undefined; + } +} + +export function normalizeCodexEffort(value) { + return value === DEFAULT_REASONING_SENTINEL ? undefined : value; +} + +/** + * Translate the public DSH LLM registry into Codex's provider-less model picker. + * Provider identity is encoded only in the opaque `model`/`id` value and decoded + * again by DSHX before calling `ctx.llm.resolveCallConfig()`. + */ +export function codexModelsFromDsh(directory, currentSelection) { + const groups = directory?.groups ?? []; + const multipleProviders = groups.length > 1; + const models = []; + for (const group of groups) { + for (const model of group.models ?? []) { + const encoded = encodeDshModel({ provider: group.provider, model: model.id }); + const efforts = model.reasoning?.efforts ?? []; + const supportedReasoningEfforts = efforts.length > 0 + ? efforts.map((effort) => ({ + reasoningEffort: effort.id, + description: effort.description ?? effort.name ?? effort.id + })) + : [{ reasoningEffort: DEFAULT_REASONING_SENTINEL, description: 'Use the DSH provider default' }]; + const defaultReasoningEffort = model.reasoning?.defaultEffort ?? DEFAULT_REASONING_SENTINEL; + const displayName = multipleProviders + ? `${model.name ?? model.id} · ${group.name ?? group.provider}` + : (model.name ?? model.id); + models.push({ + id: encoded, + model: encoded, + upgrade: null, + upgradeInfo: null, + availabilityNux: null, + displayName, + description: model.description ?? `${group.name ?? group.provider} / ${model.id}`, + modelSpecialty: null, + hidden: false, + supportedReasoningEfforts, + defaultReasoningEffort, + inputModalities: (model.inputModalities ?? []).filter((entry) => entry === 'text' || entry === 'image'), + supportsPersonality: false, + multiAgentVersion: null, + additionalSpeedTiers: [], + serviceTiers: [], + defaultServiceTier: null, + isDefault: currentSelection?.provider === group.provider && currentSelection?.model === model.id + }); + } + } + return models; +} + +export function dshThreadFromSnapshot({ meta, events = [], model, turns = [], loaded = false, cliVersion = 'dshx' }) { + if (!meta?.id) throw new Error('DSHX thread projection requires SessionHeader.id'); + const config = model ?? latestRequestConfig(events) ?? {}; + const createdAt = seconds(meta.createdAt); + const lastTime = events.length > 0 ? events[events.length - 1]?.time : meta.createdAt; + let openTurn = false; + for (const event of events) { + if (event?.type === 'turn/start') openTurn = true; + else if (event?.type === 'turn/end') openTurn = false; + } + return { + id: String(meta.id), + sessionId: String(meta.id), + forkedFromId: meta.parentSession ? String(meta.parentSession) : null, + parentThreadId: meta.origin === 'subagent' && meta.parentSession ? String(meta.parentSession) : null, + preview: firstHumanPreview(events), + ephemeral: false, + section: null, + sectionEnteredAt: null, + projectId: null, + modelProvider: config.provider ?? 'dsh', + createdAt, + updatedAt: seconds(lastTime, createdAt), + recencyAt: seconds(lastTime, createdAt), + status: loaded ? (openTurn ? { type: 'active', activeFlags: [] } : { type: 'idle' }) : { type: 'notLoaded' }, + path: null, + cwd: meta.cwd ?? process.cwd(), + cliVersion, + source: sourceForHeader(meta), + threadSource: null, + agentNickname: null, + agentRole: null, + gitInfo: null, + name: null, + turns + }; +} + +/** + * Replay the authoritative DSH Session log into the stable Codex transcript + * subset DSHX currently claims. Runtime semantics remain entirely in DSH. + */ +export function dshTurnsFromSession({ ctx, agent, diagnostics = () => {} }) { + const session = agent?.session; + if (!session) return []; + const turns = new Map(); + let currentTurn; + const toolPresenter = new DshToolPresentationResolver({ + ctx: agent.ctx ?? ctx, + agent, + threadId: String(agent.id), + workspaceCwd: session.header?.cwd ?? process.cwd(), + diagnostics + }); + + for (const event of session.events ?? []) { + switch (event.type) { + case 'turn/start': { + const number = event.data?.turn; + if (!Number.isInteger(number)) break; + const startedAt = seconds(event.time); + turns.set(number, { + id: `dsh-turn-${number}`, + items: [], + itemsView: 'full', + status: 'inProgress', + error: null, + startedAt, + completedAt: null, + durationMs: null + }); + currentTurn = number; + break; + } + case 'user/message': { + const turn = turns.get(currentTurn); + const message = event.data; + if (!turn || message?.source?.kind !== 'user') break; + const content = inputFromDshMessage(message); + if (content.length === 0) break; + turn.items.push({ type: 'userMessage', id: String(message.id), clientId: null, content }); + break; + } + case 'assistant/message': { + const turn = turns.get(event.data?.turn); + const message = event.data?.message; + if (!turn || !message) break; + turn.items.push({ + type: 'agentMessage', + id: String(message.id ?? `dsh-assistant-${event.data.turn}-${event.data.step}`), + text: visibleText(message.content), + phase: null, + memoryCitation: null, + delivery: null + }); + break; + } + case 'tool/call': { + const turn = turns.get(event.data?.turn); + if (!turn) break; + const projected = toolPresenter.start({ + turnId: turn.id, + callId: event.data.callId, + name: event.data.name, + rawArguments: event.data.arguments, + startedAtMs: event.time + }); + turn.items.push(projected.item); + break; + } + case 'tool/result': { + const callId = event.data?.message?.source?.callId + ?? event.data?.message?.content?.find?.((block) => block?.type === 'tool-result')?.toolCallId; + if (!callId) break; + const completed = toolPresenter.complete({ callId, resultData: event.data, completedAtMs: event.time }); + if (!completed) break; + for (const turn of turns.values()) { + const index = turn.items.findIndex((item) => item.id === completed.item.id); + if (index >= 0) { + turn.items[index] = completed.item; + break; + } + } + break; + } + case 'turn/end': { + const number = event.data?.turn; + const turn = turns.get(number); + if (!turn) break; + const completedAt = seconds(event.time); + turn.status = turnStatus(event.data.reason); + turn.error = turnError(event.data.reason); + turn.completedAt = completedAt; + turn.durationMs = turn.startedAt == null ? null : Math.max(0, (completedAt - turn.startedAt) * 1000); + if (currentTurn === number) currentTurn = undefined; + break; + } + default: + break; + } + } + return [...turns.values()]; +} + +export function normalizeSessionHeaders(value) { + return normalizeList(value); +} + +export const internals = { + DEFAULT_REASONING_SENTINEL, + latestRequestConfig, + firstHumanPreview, + inputFromDshMessage +}; From 2a3017c3b753df0007242d0fa59ba55f58cc7e5d Mon Sep 17 00:00:00 2001 From: code-new-bie <403466121@qq.com> Date: Wed, 19 Aug 2026 15:02:57 +0800 Subject: [PATCH 041/305] test: lock model and persisted thread projections --- test/dsh-codex-shapes.test.mjs | 141 +++++++++++++++++++++++++++++++++ 1 file changed, 141 insertions(+) create mode 100644 test/dsh-codex-shapes.test.mjs diff --git a/test/dsh-codex-shapes.test.mjs b/test/dsh-codex-shapes.test.mjs new file mode 100644 index 00000000..4a5c28eb --- /dev/null +++ b/test/dsh-codex-shapes.test.mjs @@ -0,0 +1,141 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { + codexModelsFromDsh, + decodeDshModel, + dshThreadFromSnapshot, + dshTurnsFromSession, + encodeDshModel, + normalizeCodexEffort +} from '../src/dsh/codex-shapes.mjs'; + +test('model picker encodes provider identity without hard-coding provider semantics', () => { + const directory = { + groups: [ + { + provider: 'p1', + name: 'Provider One', + models: [{ + id: 'shared', + name: 'Shared Model', + inputModalities: ['text', 'image'], + reasoning: { + efforts: [{ id: 'low', name: 'Low' }, { id: 'high', name: 'High' }], + defaultEffort: 'low' + } + }] + }, + { + provider: 'p2', + name: 'Provider Two', + models: [{ id: 'shared', name: 'Shared Model', inputModalities: ['text'] }] + } + ] + }; + const models = codexModelsFromDsh(directory, { provider: 'p2', model: 'shared' }); + assert.equal(models.length, 2); + assert.notEqual(models[0].model, models[1].model); + assert.deepEqual(decodeDshModel(models[0].model), { provider: 'p1', model: 'shared' }); + assert.deepEqual(decodeDshModel(models[1].model), { provider: 'p2', model: 'shared' }); + assert.match(models[0].displayName, /Provider One/); + assert.equal(models[1].isDefault, true); + assert.equal(models[0].defaultReasoningEffort, 'low'); + assert.equal(models[1].defaultReasoningEffort, 'dsh-default'); + assert.equal(normalizeCodexEffort('dsh-default'), undefined); +}); + +test('opaque DSH model ids round-trip and reject unowned values', () => { + const encoded = encodeDshModel({ provider: 'deepseek', model: 'deepseek-chat' }); + assert.deepEqual(decodeDshModel(encoded), { provider: 'deepseek', model: 'deepseek-chat' }); + assert.equal(decodeDshModel('gpt-5'), undefined); +}); + +test('persisted SessionHeader and request header project to a not-loaded Codex thread', () => { + const thread = dshThreadFromSnapshot({ + meta: { id: 'session-1', createdAt: 1_700_000_000_000, cwd: '/work' }, + events: [ + { + type: 'user/message', + time: 1_700_000_000_100, + data: { id: 'u1', source: { kind: 'user' }, content: [{ type: 'text', text: 'Fix the parser' }] } + }, + { + type: 'request/header', + time: 1_700_000_000_200, + data: { header: { config: { provider: 'deepseek', model: 'model-a' } }, reason: 'initial' } + } + ] + }); + assert.equal(thread.id, 'session-1'); + assert.equal(thread.preview, 'Fix the parser'); + assert.equal(thread.modelProvider, 'deepseek'); + assert.deepEqual(thread.status, { type: 'notLoaded' }); + assert.equal(thread.cwd, '/work'); +}); + +test('history projection includes only direct human input and stable assistant/tool items', () => { + const definitions = { + shell: { + presentCall: () => ({ card: 'terminal', title: 'echo ok', cwd: '/work' }), + presentResult: () => ({ card: 'terminal', output: 'ok\n', exitCode: 0 }) + } + }; + const agent = { + id: 'session-1', + ctx: { + get(name) { + if (name === 'tools') return { get(toolName, scope) { assert.equal(scope, agent); return definitions[toolName]; } }; + } + }, + session: { + header: { id: 'session-1', createdAt: 1_700_000_000_000, cwd: '/work' }, + events: [ + { type: 'turn/start', time: 1_700_000_000_000, data: { turn: 1 } }, + { + type: 'user/message', + time: 1_700_000_000_010, + data: { id: 'u1', role: 'user', source: { kind: 'user' }, content: [{ type: 'text', text: 'run it' }] } + }, + { + type: 'user/message', + time: 1_700_000_000_011, + data: { id: 'ctx1', role: 'user', source: { kind: 'plugin', plugin: 'context' }, content: [{ type: 'text', text: 'internal context' }] } + }, + { + type: 'tool/call', + time: 1_700_000_000_020, + data: { turn: 1, step: 1, callId: 'c1', name: 'shell', arguments: '{}' } + }, + { + type: 'tool/result', + time: 1_700_000_000_030, + data: { + turn: 1, + step: 1, + message: { + source: { kind: 'tool', callId: 'c1' }, + content: [{ type: 'tool-result', toolCallId: 'c1', content: [{ type: 'text', text: 'ok' }], isError: false }] + } + } + }, + { + type: 'assistant/message', + time: 1_700_000_000_040, + data: { + turn: 1, + step: 1, + message: { id: 'a1', content: [{ type: 'text', text: 'done' }] } + } + }, + { type: 'turn/end', time: 1_700_000_000_050, data: { turn: 1, reason: { kind: 'completed' } } } + ] + } + }; + const turns = dshTurnsFromSession({ ctx: agent.ctx, agent }); + assert.equal(turns.length, 1); + assert.equal(turns[0].status, 'completed'); + assert.deepEqual(turns[0].items.map((item) => item.type), ['userMessage', 'commandExecution', 'agentMessage']); + assert.equal(turns[0].items[0].content[0].text, 'run it'); + assert.equal(turns[0].items[1].aggregatedOutput, 'ok\n'); + assert.equal(turns[0].items[2].text, 'done'); +}); From b5dcfa02ecae5775792274f2e7a4d9cb70bf82ee Mon Sep 17 00:00:00 2001 From: code-new-bie <403466121@qq.com> Date: Wed, 19 Aug 2026 15:04:57 +0800 Subject: [PATCH 042/305] fix: treat DSH user questions as presentation interactions --- src/dsh/user-question-bridge.mjs | 24 ++++++++++++++++-------- 1 file changed, 16 insertions(+), 8 deletions(-) diff --git a/src/dsh/user-question-bridge.mjs b/src/dsh/user-question-bridge.mjs index 1d1278b5..8e1a4b49 100644 --- a/src/dsh/user-question-bridge.mjs +++ b/src/dsh/user-question-bridge.mjs @@ -38,19 +38,22 @@ function dshAnswer(question, response) { /** * UI provider for the official DSH `ctx.userQuestions` seam. * - * `locate(request)` correlates the question to an already-projected DSH tool - * item. The provider owns no tool behavior and no durable answers; it blocks - * only on the TUI server-request and returns the user's structured answer back - * to the official DSH service. + * DSH's public AskUserQuestionRequest deliberately has no tool call id. The + * bridge therefore asks its owner for a PRESENTATION interaction location; a + * Codex client may attach the dialog to a synthetic UI-only item for the live + * DSH turn instead of guessing which parallel tool call caused the question. + * No question/answer semantics or durable state are owned here. */ export class DshUserQuestionBridge { - constructor({ ctx, broker, locate, diagnostics = () => {} }) { + constructor({ ctx, broker, locate, complete = () => {}, diagnostics = () => {} }) { if (!ctx || typeof ctx.get !== 'function') throw new Error('DshUserQuestionBridge requires a Cordis Context'); if (!broker) throw new Error('DshUserQuestionBridge requires a UiRequestBroker'); if (typeof locate !== 'function') throw new Error('DshUserQuestionBridge requires locate(request)'); + if (typeof complete !== 'function') throw new Error('DshUserQuestionBridge complete must be a function'); this.ctx = ctx; this.broker = broker; this.locate = locate; + this.complete = complete; this.diagnostics = diagnostics; this.disposeProvider = requireService(ctx, 'userQuestions').registerProvider({ ask: (request) => this.ask(request) @@ -61,9 +64,9 @@ export class DshUserQuestionBridge { if (request.questions.some((question) => question.multiSelect === true)) { throw new Error('DSHX pinned Codex TUI does not yet represent DSH multi-select questions faithfully'); } - const location = this.locate(request); + const location = await this.locate(request); if (!location?.threadId || !location?.turnId || !location?.itemId) { - throw new Error('DSHX could not correlate the DSH user question to a projected tool item'); + throw new Error('DSHX could not open a Codex presentation item for the DSH user question'); } try { @@ -79,9 +82,14 @@ export class DshUserQuestionBridge { }, { signal: request.signal } ); - return { answers: request.questions.map((question) => dshAnswer(question, response)) }; + const answer = { answers: request.questions.map((question) => dshAnswer(question, response)) }; + await this.complete(location, { status: 'completed', answer }); + return answer; } catch (error) { this.diagnostics(error instanceof Error ? error.message : String(error)); + try { await this.complete(location, { status: 'failed', error }); } catch (completeError) { + this.diagnostics(completeError instanceof Error ? completeError.message : String(completeError)); + } throw error; } } From 9605b37526d73fa36224453a8315df992c119eed Mon Sep 17 00:00:00 2001 From: code-new-bie <403466121@qq.com> Date: Wed, 19 Aug 2026 15:05:28 +0800 Subject: [PATCH 043/305] test: allow synthetic DSH user interaction items --- test/dsh-user-question-bridge.test.mjs | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/test/dsh-user-question-bridge.test.mjs b/test/dsh-user-question-bridge.test.mjs index 2af31dfe..88e25a41 100644 --- a/test/dsh-user-question-bridge.test.mjs +++ b/test/dsh-user-question-bridge.test.mjs @@ -6,6 +6,7 @@ function fixture({ response, locate } = {}) { let provider; let disposed = false; const requests = []; + const completions = []; const diagnostics = []; const userQuestions = { registerProvider(next) { @@ -16,18 +17,21 @@ function fixture({ response, locate } = {}) { const broker = { async request(method, params, options) { requests.push({ method, params, options }); + if (response instanceof Error) throw response; return response ?? { answers: {} }; } }; const bridge = new DshUserQuestionBridge({ ctx: { get(name) { return name === 'userQuestions' ? userQuestions : undefined; } }, broker, - locate: locate ?? (() => ({ threadId: 'session-1', turnId: 'dsh-turn-1', itemId: 'ask-tool-1' })), + locate: locate ?? (() => ({ threadId: 'session-1', turnId: 'dsh-turn-1', itemId: 'ask-ui-1' })), + complete: (location, outcome) => completions.push({ location, outcome }), diagnostics: (message) => diagnostics.push(message) }); return { bridge, requests, + completions, diagnostics, ask(request) { return provider.ask(request); }, get disposed() { return disposed; } @@ -63,6 +67,7 @@ test('single-select question maps to Codex request and structured DSH answer', a ] }); assert.deepEqual(result, { answers: [{ id: 'q1', selected: ['PostgreSQL'] }] }); + assert.equal(fx.completions[0].outcome.status, 'completed'); }); test('free-text answer is returned as DSH custom text', async () => { @@ -106,14 +111,20 @@ test('multi-select is explicit unsupported instead of silently degrading to sing assert.equal(fx.requests.length, 0); }); -test('missing tool correlation is an explicit compatibility failure', async () => { +test('missing presentation location is an explicit compatibility failure', async () => { const fx = fixture({ locate: () => null }); await assert.rejects( () => fx.ask({ questions: [{ id: 'q', question: 'Question?' }] }), - /could not correlate/ + /could not open a Codex presentation item/ ); }); +test('request failure closes the synthetic interaction as failed', async () => { + const fx = fixture({ response: new Error('transport lost') }); + await assert.rejects(() => fx.ask({ questions: [{ id: 'q', question: 'Question?' }] }), /transport lost/); + assert.equal(fx.completions[0].outcome.status, 'failed'); +}); + test('bridge disposal unregisters only the UI provider', () => { const fx = fixture(); fx.bridge.dispose(); From a3bbd2abebfcaf54aca9ef8a7f5cd8aa8ddb298e Mon Sep 17 00:00:00 2001 From: code-new-bie <403466121@qq.com> Date: Wed, 19 Aug 2026 15:06:05 +0800 Subject: [PATCH 044/305] feat: expose current DSH turn presentation location --- src/dsh/thread-controller.mjs | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/dsh/thread-controller.mjs b/src/dsh/thread-controller.mjs index e994c7fd..62f2f35a 100644 --- a/src/dsh/thread-controller.mjs +++ b/src/dsh/thread-controller.mjs @@ -63,6 +63,15 @@ export class DshThreadController { return this.toolPresentation?.correlation(callId); } + /** Current live DSH turn translated only to presentation identity. */ + currentLocation() { + const number = this.projector.currentTurn; + if (!Number.isInteger(number)) return undefined; + const turn = this.projector.turns.get(number); + if (!turn) return undefined; + return { threadId: this.threadId, turnId: turn.id }; + } + onSessionEvent(event) { if (this.closed) return; const projected = this.projector.project(event); From 8682252625721a6eb08cb37721909bbe639a0226 Mon Sep 17 00:00:00 2001 From: code-new-bie <403466121@qq.com> Date: Wed, 19 Aug 2026 15:08:22 +0800 Subject: [PATCH 045/305] feat: assemble DSH public APIs into Codex app-server adapter --- src/dsh/app-server-adapter.mjs | 502 +++++++++++++++++++++++++++++++++ 1 file changed, 502 insertions(+) create mode 100644 src/dsh/app-server-adapter.mjs diff --git a/src/dsh/app-server-adapter.mjs b/src/dsh/app-server-adapter.mjs new file mode 100644 index 00000000..ffa13754 --- /dev/null +++ b/src/dsh/app-server-adapter.mjs @@ -0,0 +1,502 @@ +import crypto from 'node:crypto'; +import os from 'node:os'; +import path from 'node:path'; +import { DshAgentDriver } from './agent-driver.mjs'; +import { DshApprovalBridge } from './approval-bridge.mjs'; +import { + codexModelsFromDsh, + decodeDshModel, + dshThreadFromSnapshot, + dshTurnsFromSession, + encodeDshModel, + normalizeCodexEffort, + normalizeSessionHeaders +} from './codex-shapes.mjs'; +import { DshPermissionView } from './permissions.mjs'; +import { DshThreadController } from './thread-controller.mjs'; +import { DshUserQuestionBridge } from './user-question-bridge.mjs'; +import { UiRequestBroker } from '../protocol/request-broker.mjs'; + +const INVALID_REQUEST = -32600; +const METHOD_NOT_FOUND = -32601; +const INVALID_PARAMS = -32602; +const INTERNAL_ERROR = -32603; + +function platformFamily() { + return process.platform === 'win32' ? 'windows' : 'unix'; +} + +function platformOs() { + if (process.platform === 'darwin') return 'macos'; + if (process.platform === 'win32') return 'windows'; + if (process.platform === 'linux') return 'linux'; + return process.platform; +} + +function codexApprovalToDsh(value) { + if (value == null) return undefined; + if (value === 'on-request') return 'ask'; + if (value === 'never') return 'never'; + throw new Error(`DSHX cannot map Codex approval policy ${JSON.stringify(value)} to DSH`); +} + +function textInput(params) { + const inputs = Array.isArray(params?.input) ? params.input : []; + const unsupported = inputs.filter((item) => item?.type !== 'text'); + if (unsupported.length > 0) { + throw new Error(`DSHX turn input currently supports Codex text items only; got ${unsupported.map((item) => item?.type).join(', ')}`); + } + return inputs.map((item) => item.text ?? '').join('\n').trim(); +} + +function pagination(value, total, defaultLimit = 50) { + const cursor = value?.cursor == null ? 0 : Number(value.cursor); + if (!Number.isSafeInteger(cursor) || cursor < 0) throw new Error('invalid pagination cursor'); + const requested = value?.limit == null ? defaultLimit : Number(value.limit); + if (!Number.isSafeInteger(requested) || requested <= 0) throw new Error('limit must be a positive integer'); + const end = Math.min(total, cursor + requested); + return { start: cursor, end, nextCursor: end < total ? String(end) : null }; +} + +function sandboxMatchesCurrent(sandbox, permission) { + if (sandbox == null) return true; + if (sandbox.type === 'dangerFullAccess') return permission.dsh.sandboxMode === 'danger-full-access'; + if (sandbox.type === 'externalSandbox') { + return permission.dsh.sandboxMode === 'read-only' || permission.dsh.sandboxMode === 'workspace-write'; + } + return false; +} + +function fullThreadSnapshot(agent, { includeTurns = false, cliVersion = 'dshx' } = {}) { + const turns = includeTurns ? dshTurnsFromSession({ ctx: agent.ctx, agent }) : []; + return dshThreadFromSnapshot({ + meta: agent.session.header, + events: agent.session.events, + model: agent.session.requestHeader?.()?.config, + turns, + loaded: true, + cliVersion + }); +} + +/** + * Presentation adapter from pinned Codex app-server JSON-RPC to official DSH + * public services. It owns no Agent loop, provider routing, tool dispatch, + * persistence, sandbox enforcement, approval policy or user-question semantics. + */ +export class DshAppServerAdapter { + constructor({ + ctx, + send, + cwd = process.cwd(), + home = path.join(os.homedir(), '.dshx'), + version = '0.1.0-dev', + diagnostics = () => {} + }) { + if (!ctx || typeof ctx.get !== 'function') throw new Error('DshAppServerAdapter requires a Cordis Context'); + if (typeof send !== 'function') throw new Error('DshAppServerAdapter requires send(message)'); + this.ctx = ctx; + this.send = send; + this.cwd = path.resolve(cwd); + this.home = path.resolve(home); + this.version = version; + this.diagnostics = diagnostics; + this.driver = new DshAgentDriver(ctx); + this.permissions = new DshPermissionView(ctx); + this.broker = new UiRequestBroker({ send }); + this.controllers = new Map(); + this.approvals = new Map(); + this.syntheticInteractions = new Map(); + this.readyPromise = null; + this.userQuestions = null; + this.closed = false; + } + + async ensureReady() { + if (this.readyPromise) return this.readyPromise; + this.readyPromise = (async () => { + const loader = this.ctx.get('loader'); + if (loader?.await) await loader.await(); + if (!this.userQuestions && this.ctx.get('userQuestions')) { + this.userQuestions = new DshUserQuestionBridge({ + ctx: this.ctx, + broker: this.broker, + locate: (request) => this.openSyntheticInteraction(request), + complete: (location, outcome) => this.completeSyntheticInteraction(location, outcome), + diagnostics: this.diagnostics + }); + } + })(); + return this.readyPromise; + } + + async handle(message) { + if (this.broker.handleResponse(message)) return null; + if (!message || message.id === undefined || typeof message.method !== 'string') { + if (message?.id === undefined && typeof message?.method === 'string') return null; + return { response: this.error(message?.id ?? null, INVALID_REQUEST, 'Invalid Request') }; + } + try { + await this.ensureReady(); + const dispatched = await this.dispatch(message.method, message.params ?? {}); + return { + response: { id: message.id, result: dispatched?.result }, + afterResponse: dispatched?.afterResponse + }; + } catch (error) { + const code = error?.code === METHOD_NOT_FOUND ? METHOD_NOT_FOUND + : error?.code === INVALID_PARAMS ? INVALID_PARAMS + : INTERNAL_ERROR; + return { + response: this.error(message.id, code, error instanceof Error ? error.message : String(error)) + }; + } + } + + async dispatch(method, params) { + switch (method) { + case 'initialize': + return { + result: { + userAgent: `dshx/${this.version}`, + codexHome: this.home, + platformFamily: platformFamily(), + platformOs: platformOs() + } + }; + case 'account/read': + return { result: { account: null, requiresOpenaiAuth: false } }; + case 'configRequirements/read': + return { result: { requirements: null } }; + case 'skills/list': + // DSH skills remain upstream-owned. Until their public catalog is + // mapped, claiming none is safer than synthesizing Codex skill paths. + return { result: { data: [] } }; + case 'model/list': + return this.modelList(params); + case 'thread/list': + return this.threadList(params); + case 'thread/loaded/list': + return this.loadedThreadList(); + case 'thread/start': + return this.threadStart(params); + case 'thread/resume': + return this.threadResume(params); + case 'thread/read': + return this.threadRead(params); + case 'thread/unsubscribe': + return this.threadUnsubscribe(params); + case 'turn/start': + return this.turnStart(params); + case 'turn/interrupt': + return this.turnInterrupt(params); + default: { + const error = new Error(`Method not found: ${method}`); + error.code = METHOD_NOT_FOUND; + throw error; + } + } + } + + async modelList(params) { + const directory = await this.driver.modelDirectory(); + for (const failure of directory.failures ?? []) { + this.diagnostics(`DSH model catalog ${failure.provider}: ${failure.message}`); + } + const active = this.driver.listRootAgents()[0]; + const current = active + ? this.driver.currentModel(active) + : this.ctx.get('agentDefaultModel')?.currentSelection?.(); + const all = codexModelsFromDsh(directory, current); + const visible = params.includeHidden ? all : all.filter((model) => !model.hidden); + const page = pagination(params, visible.length); + return { result: { data: visible.slice(page.start, page.end), nextCursor: page.nextCursor } }; + } + + async threadList(params) { + const headers = normalizeSessionHeaders(await this.driver.listSessions()); + const snapshots = await Promise.all(headers.map(async (header) => { + try { + const inspected = await this.driver.inspectSession(String(header.id)); + const meta = inspected?.meta ?? header; + const events = inspected?.events ?? []; + return dshThreadFromSnapshot({ + meta, + events, + loaded: Boolean(this.controllers.has(String(meta.id)) || this.driver.getLive(String(meta.id))), + cliVersion: this.version + }); + } catch (error) { + this.diagnostics(`inspect ${String(header.id)}: ${error instanceof Error ? error.message : error}`); + return undefined; + } + })); + let threads = snapshots.filter(Boolean); + if (params.cwd != null) { + const wanted = new Set((Array.isArray(params.cwd) ? params.cwd : [params.cwd]).map((entry) => path.resolve(entry))); + threads = threads.filter((thread) => wanted.has(path.resolve(thread.cwd))); + } + if (params.searchTerm) { + const needle = String(params.searchTerm).toLowerCase(); + threads = threads.filter((thread) => `${thread.name ?? ''}\n${thread.preview}`.toLowerCase().includes(needle)); + } + if (Array.isArray(params.modelProviders) && params.modelProviders.length > 0) { + const providers = new Set(params.modelProviders); + threads = threads.filter((thread) => providers.has(thread.modelProvider)); + } + threads.sort((a, b) => (params.sortDirection === 'asc' ? 1 : -1) * (a.recencyAt - b.recencyAt)); + const page = pagination(params, threads.length); + return { + result: { + data: threads.slice(page.start, page.end).map((thread) => ({ ...thread, turns: [] })), + nextCursor: page.nextCursor, + backwardsCursor: null + } + }; + } + + loadedThreadList() { + const data = [...this.controllers.values()].map((controller) => fullThreadSnapshot(controller.agent, { + includeTurns: false, + cliVersion: this.version + })); + return { result: { data } }; + } + + async applyStartPermissions(agent, params) { + const desiredSandbox = params.sandbox; + const desiredApproval = codexApprovalToDsh(params.approvalPolicy); + if (desiredSandbox == null && desiredApproval == null) return this.permissions.current(agent); + + const current = this.permissions.current(agent); + const sandboxMode = desiredSandbox ?? current.dsh.sandboxMode; + const approvalPolicy = desiredApproval ?? current.dsh.approvalPolicy; + const presets = this.ctx.get('permissionPresets'); + const match = presets?.names?.find((name) => { + const preset = presets.resolve(name); + return preset.sandbox === sandboxMode && preset.approval === approvalPolicy; + }); + if (!match) { + throw new Error(`DSHX refuses to synthesize a DSH custom permission bundle (${sandboxMode}, ${approvalPolicy})`); + } + this.permissions.set(agent, match); + return this.permissions.current(agent); + } + + async applyModelOverride(agent, { model, modelProvider, effort } = {}) { + if (model == null && effort == null) return this.driver.currentModel(agent); + let selection; + if (model != null) { + selection = decodeDshModel(model); + if (!selection && modelProvider) selection = { provider: modelProvider, model }; + if (!selection) throw new Error('DSHX accepts model switches only from its DSH-backed model catalog'); + } else { + selection = this.driver.currentModel(agent); + } + const normalizedEffort = normalizeCodexEffort(effort); + if (normalizedEffort !== undefined) selection = { ...selection, reasoningEffort: normalizedEffort }; + return this.driver.selectModel(agent, selection); + } + + installController(handle) { + const threadId = String(handle.agent.id); + const existing = this.controllers.get(threadId); + if (existing) return existing; + const controller = new DshThreadController({ + handle, + driver: this.driver, + emit: this.send, + diagnostics: this.diagnostics + }); + this.controllers.set(threadId, controller); + const approval = new DshApprovalBridge({ + agent: handle.agent, + broker: this.broker, + classify: (req) => req.callId == null ? null : controller.toolCorrelation(req.callId), + diagnostics: this.diagnostics + }); + this.approvals.set(threadId, approval); + return controller; + } + + threadResponse(agent, { includeTurns = false } = {}) { + const selection = this.driver.currentModel(agent); + const permission = this.permissions.current(agent); + const thread = fullThreadSnapshot(agent, { includeTurns, cliVersion: this.version }); + return { + thread, + model: encodeDshModel(selection), + modelProvider: selection.provider, + serviceTier: null, + cwd: thread.cwd, + instructionSources: [], + approvalPolicy: permission.codex.approvalPolicy, + approvalsReviewer: permission.codex.approvalsReviewer, + sandbox: permission.codex.sandbox, + reasoningEffort: selection.reasoningEffort ?? null + }; + } + + async threadStart(params) { + const sessionId = crypto.randomUUID(); + const cwd = path.resolve(params.cwd ?? this.cwd); + const handle = await this.driver.create({ cwd, sessionId }); + try { + await this.applyModelOverride(handle.agent, params); + await this.applyStartPermissions(handle.agent, params); + this.installController(handle); + const result = this.threadResponse(handle.agent); + return { + result, + afterResponse: () => this.send({ method: 'thread/started', params: { thread: result.thread } }) + }; + } catch (error) { + await handle.dispose?.(); + throw error; + } + } + + async threadResume(params) { + if (!params.threadId) throw new Error('thread/resume requires threadId'); + let controller = this.controllers.get(String(params.threadId)); + if (!controller) { + const live = this.driver.getLive(String(params.threadId)); + const handle = live + ? { agent: live, dispose: async () => {} } + : await this.driver.resume(String(params.threadId)); + controller = this.installController(handle); + } + const agent = controller.agent; + if (params.cwd != null && path.resolve(params.cwd) !== path.resolve(agent.session.header?.cwd ?? this.cwd)) { + throw new Error('DSHX does not override a persisted DSH session cwd during resume'); + } + await this.applyModelOverride(agent, params); + await this.applyStartPermissions(agent, params); + return { result: this.threadResponse(agent, { includeTurns: true }) }; + } + + async threadRead(params) { + if (!params.threadId) throw new Error('thread/read requires threadId'); + const live = this.driver.getLive(String(params.threadId)); + if (live) { + return { result: { thread: fullThreadSnapshot(live, { includeTurns: Boolean(params.includeTurns), cliVersion: this.version }) } }; + } + const inspected = await this.driver.inspectSession(String(params.threadId)); + if (!inspected) throw new Error(`Unknown DSH session: ${String(params.threadId)}`); + return { + result: { + thread: dshThreadFromSnapshot({ + meta: inspected.meta, + events: inspected.events, + loaded: false, + cliVersion: this.version, + // A cold persisted session cannot faithfully use tool presenters + // without a composed Agent. The TUI resumes before requesting rich + // history; avoid fabricating tool semantics on a cold read. + turns: [] + }) + } + }; + } + + async turnStart(params) { + const controller = this.controllers.get(String(params.threadId)); + if (!controller) throw new Error(`Thread is not resumed in DSHX: ${String(params.threadId)}`); + if (params.cwd != null && path.resolve(params.cwd) !== path.resolve(controller.agent.session.header?.cwd ?? this.cwd)) { + throw new Error('DSHX does not own per-turn cwd mutation; resume/start the DSH session in the desired cwd'); + } + if (params.outputSchema != null) throw new Error('DSHX has no faithful public DSH mapping for Codex outputSchema yet'); + + await this.applyModelOverride(controller.agent, { model: params.model, effort: params.effort }); + const permission = this.permissions.current(controller.agent); + const desiredApproval = codexApprovalToDsh(params.approvalPolicy); + if (desiredApproval != null && desiredApproval !== permission.dsh.approvalPolicy) { + throw new Error('Turn-level approval changes must use a DSH permission preset'); + } + if (!sandboxMatchesCurrent(params.sandboxPolicy, permission)) { + throw new Error('Turn-level sandbox changes must use a DSH permission preset'); + } + + const started = await controller.startTurn(textInput(params)); + return { result: { turn: started.turn }, afterResponse: started.release }; + } + + turnInterrupt(params) { + const controller = this.controllers.get(String(params.threadId)); + if (!controller) return { result: {} }; + controller.interrupt({ keepInbox: true }); + return { result: {} }; + } + + async threadUnsubscribe(params) { + const threadId = String(params.threadId ?? ''); + const controller = this.controllers.get(threadId); + if (controller) { + this.approvals.get(threadId)?.dispose(); + this.approvals.delete(threadId); + this.controllers.delete(threadId); + await controller.close(); + } + return { result: {} }; + } + + openSyntheticInteraction(request) { + const agentId = request?.agent?.id; + if (!agentId) return undefined; + const controller = this.controllers.get(String(agentId)); + const location = controller?.currentLocation(); + if (!location) return undefined; + const itemId = `dshx-interaction-${crypto.randomUUID()}`; + const startedAtMs = Date.now(); + const item = { + type: 'dynamicToolCall', + id: itemId, + namespace: 'dshx', + tool: 'userInteraction', + arguments: { questionCount: request.questions?.length ?? 0 }, + status: 'inProgress', + contentItems: null, + success: null, + durationMs: null + }; + this.syntheticInteractions.set(itemId, { ...location, item, startedAtMs }); + this.send({ + method: 'item/started', + params: { ...location, item, startedAtMs } + }); + return { ...location, itemId }; + } + + completeSyntheticInteraction(location, outcome) { + const state = this.syntheticInteractions.get(location.itemId); + if (!state) return; + this.syntheticInteractions.delete(location.itemId); + const completedAtMs = Date.now(); + const item = { + ...state.item, + status: outcome.status === 'completed' ? 'completed' : 'failed', + success: outcome.status === 'completed', + durationMs: Math.max(0, completedAtMs - state.startedAtMs) + }; + this.send({ + method: 'item/completed', + params: { threadId: state.threadId, turnId: state.turnId, item, completedAtMs } + }); + } + + error(id, code, message, data = undefined) { + const error = { code, message }; + if (data !== undefined) error.data = data; + return { id, error }; + } + + async close() { + if (this.closed) return; + this.closed = true; + this.userQuestions?.dispose(); + this.userQuestions = null; + this.broker.close(); + const ids = [...this.controllers.keys()]; + for (const id of ids) await this.threadUnsubscribe({ threadId: id }); + } +} From 7add5b465c10a68da6c8570a47aba72e6ae2446c Mon Sep 17 00:00:00 2001 From: code-new-bie <403466121@qq.com> Date: Wed, 19 Aug 2026 15:09:12 +0800 Subject: [PATCH 046/305] fix: match DSH persistence list signal contract --- src/dsh/agent-driver.mjs | 119 ++++++++++----------------------------- 1 file changed, 29 insertions(+), 90 deletions(-) diff --git a/src/dsh/agent-driver.mjs b/src/dsh/agent-driver.mjs index ecfc303e..aaa9631d 100644 --- a/src/dsh/agent-driver.mjs +++ b/src/dsh/agent-driver.mjs @@ -16,15 +16,7 @@ function userMessage(text) { }); } -/** - * Thin delegation layer over official DeepSeek Harness public services. - * - * Model selection follows the official Web Host entry-point pattern: one - * process-local ModelSelectionRef per live Agent, whose read precedence is - * process pick -> latest durable request/header -> live agent default. The ref - * is installed with DSH's public `installModelSelection()` helper; DSHX does - * not implement request routing itself. - */ +/** Thin delegation layer over official DeepSeek Harness public services. */ export class DshAgentDriver { constructor(ctx) { if (!ctx || typeof ctx.get !== 'function') throw new Error('DshAgentDriver requires a Cordis Context'); @@ -49,16 +41,12 @@ export class DshAgentDriver { return { provider: logged.provider, model: logged.model, - ...(logged.reasoningEffort === undefined - ? {} - : { reasoningEffort: logged.reasoningEffort }) + ...(logged.reasoningEffort === undefined ? {} : { reasoningEffort: logged.reasoningEffort }) }; } return defaultModel.currentSelection(); }, - set current(next) { - picked = next; - }, + set current(next) { picked = next; }, assembled: undefined }; installModelSelection(agent.ctx, selection); @@ -76,23 +64,14 @@ export class DshAgentDriver { await this.settleComposition(); const agents = requireService(this.ctx, 'agents'); const selection = requireService(this.ctx, 'agentDefaultModel').currentSelection(); - return agents.create({ sessionId: SessionId(sessionId), meta: { cwd }, - agentOptions: { - provider: selection.provider, - model: selection.model - }, + agentOptions: { provider: selection.provider, model: selection.model }, setup: (agentCtx) => this.installSelection(agentCtx) }); } - /** - * Resume delegates durable reconstruction to DSH. The installed selection - * reads the resumed Session's latest request/header before consulting the - * machine's current default, matching the official Web Host precedence. - */ async resume(sessionId) { await this.settleComposition(); const agents = requireService(this.ctx, 'agents'); @@ -111,22 +90,17 @@ export class DshAgentDriver { const resolved = await llm.resolveCallConfig({ provider: requested.provider, model: requested.model, - ...(requested.reasoningEffort === undefined - ? {} - : { reasoningEffort: requested.reasoningEffort }) + ...(requested.reasoningEffort === undefined ? {} : { reasoningEffort: requested.reasoningEffort }) }); const selected = { provider: resolved.provider, model: resolved.model, - ...(resolved.reasoningEffort === undefined - ? {} - : { reasoningEffort: resolved.reasoningEffort }) + ...(resolved.reasoningEffort === undefined ? {} : { reasoningEffort: resolved.reasoningEffort }) }; this.selectionFor(agent).current = selected; return selected; } - /** Build the same provider/model directory shape from public LLM registry APIs that DSH Web uses. */ async modelDirectory() { const llm = requireService(this.ctx, 'llm'); const providers = llm.listProviders(); @@ -141,80 +115,45 @@ export class DshAgentDriver { ...(model.description === undefined ? {} : { description: model.description }), ...(exact.inputModalities === undefined ? {} : { inputModalities: [...exact.inputModalities] }), ...(exact.context === undefined ? {} : { contextWindow: exact.context.contextWindow }), - ...(exact.reasoning === undefined - ? {} - : { - reasoning: { - efforts: exact.reasoning.efforts.map((effort) => ({ - id: effort.id, - name: effort.name, - ...(effort.description === undefined ? {} : { description: effort.description }) - })), - ...(exact.reasoning.defaultEffort === undefined - ? {} - : { defaultEffort: exact.reasoning.defaultEffort }) - } - }) + ...(exact.reasoning === undefined ? {} : { + reasoning: { + efforts: exact.reasoning.efforts.map((effort) => ({ + id: effort.id, + name: effort.name, + ...(effort.description === undefined ? {} : { description: effort.description }) + })), + ...(exact.reasoning.defaultEffort === undefined ? {} : { defaultEffort: exact.reasoning.defaultEffort }) + } + }) }; })); - return { - ok: true, - group: { - provider: provider.id, - name: provider.name ?? provider.id, - models: entries - } - }; + return { ok: true, group: { provider: provider.id, name: provider.name ?? provider.id, models: entries } }; } catch (error) { return { ok: false, - failure: { - provider: provider.id, - message: error instanceof Error ? error.message : String(error) - } + failure: { provider: provider.id, message: error instanceof Error ? error.message : String(error) } }; } })); - return { groups: settled.filter((entry) => entry.ok && entry.group.models.length > 0).map((entry) => entry.group), failures: settled.filter((entry) => !entry.ok).map((entry) => entry.failure) }; } - followup(agent, text) { - agent.followup(userMessage(text)); - } - - steer(agent, text) { - agent.steer(userMessage(text)); - } - - interrupt(agent, { keepInbox = false } = {}) { - agent.cancel({ kind: 'user' }, { keepInbox }); - } - - whenIdle(agent) { - return agent.whenIdle(); - } - - getLive(sessionId) { - return requireService(this.ctx, 'agents').get(SessionId(sessionId)); - } - - listLive() { - return requireService(this.ctx, 'agents').list(); - } - - listRootAgents() { - return requireService(this.ctx, 'agents').roots(); - } + followup(agent, text) { agent.followup(userMessage(text)); } + steer(agent, text) { agent.steer(userMessage(text)); } + interrupt(agent, { keepInbox = false } = {}) { agent.cancel({ kind: 'user' }, { keepInbox }); } + whenIdle(agent) { return agent.whenIdle(); } + getLive(sessionId) { return requireService(this.ctx, 'agents').get(SessionId(sessionId)); } + listLive() { return requireService(this.ctx, 'agents').list(); } + listRootAgents() { return requireService(this.ctx, 'agents').roots(); } - async listSessions(options = {}) { - return requireService(this.ctx, 'sessionPersistence').list(options); + async listSessions(signal) { + return requireService(this.ctx, 'sessionPersistence').list(signal); } - async inspectSession(sessionId) { - return requireService(this.ctx, 'sessionPersistence').inspect(SessionId(sessionId)); + async inspectSession(sessionId, signal) { + return requireService(this.ctx, 'sessionPersistence').inspect(SessionId(sessionId), signal); } } From 56f6cb3a3d27b87dbbf3e865a643fa5c2416f763 Mon Sep 17 00:00:00 2001 From: code-new-bie <403466121@qq.com> Date: Wed, 19 Aug 2026 15:10:54 +0800 Subject: [PATCH 047/305] test: exercise bidirectional DSH app-server adapter --- test/dsh-app-server-adapter.test.mjs | 379 +++++++++++++++++++++++++++ 1 file changed, 379 insertions(+) create mode 100644 test/dsh-app-server-adapter.test.mjs diff --git a/test/dsh-app-server-adapter.test.mjs b/test/dsh-app-server-adapter.test.mjs new file mode 100644 index 00000000..0717e4de --- /dev/null +++ b/test/dsh-app-server-adapter.test.mjs @@ -0,0 +1,379 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { DshAppServerAdapter } from '../src/dsh/app-server-adapter.mjs'; +import { decodeDshModel } from '../src/dsh/codex-shapes.mjs'; + +function fixture() { + const sent = []; + const calls = []; + const sessions = new Map(); + const live = new Map(); + let questionProvider; + let clock = 1_700_000_000_000; + + const presetTable = { + 'workspace-write': { sandbox: 'workspace-write', approval: 'ask', name: 'Workspace' }, + 'danger-full-access': { sandbox: 'danger-full-access', approval: 'never', name: 'Danger' } + }; + + const services = {}; + const rootCtx = { get(name) { return services[name]; } }; + + function requestHeader(session) { + for (let index = session.events.length - 1; index >= 0; index -= 1) { + const event = session.events[index]; + if (event.type === 'request/header') return event.data.header; + } + return undefined; + } + + function makeSession(id, cwd = '/workspace', seed = []) { + const session = { + id, + header: { id, createdAt: clock++, cwd }, + events: [...seed], + permissionPreset: 'workspace-write', + requestHeader() { return requestHeader(session); } + }; + sessions.set(String(id), session); + return session; + } + + function makeAgent(session) { + const listeners = new Map(); + const agent = { + id: session.id, + session, + ctx: { + agent: undefined, + get(name) { return services[name]; }, + on(name, listener) { + const bucket = listeners.get(name) ?? []; + bucket.push(listener); + listeners.set(name, bucket); + return () => { + const current = listeners.get(name) ?? []; + const index = current.indexOf(listener); + if (index >= 0) current.splice(index, 1); + }; + } + }, + followup(message) { + calls.push(['followup', String(agent.id), message]); + const turn = 1 + session.events.filter((event) => event.type === 'turn/start').length; + append(agent, 'turn/start', { turn }); + append(agent, 'user/message', message); + }, + steer(message) { calls.push(['steer', String(agent.id), message]); }, + cancel(cause, options) { calls.push(['cancel', String(agent.id), cause, options]); }, + whenIdle() { return Promise.resolve(); }, + _listeners: listeners + }; + agent.ctx.agent = agent; + return agent; + } + + function append(agent, type, data) { + const event = { type, seq: agent.session.events.length, time: clock++, data }; + agent.session.events.push(event); + for (const listener of agent._listeners.get('session/event') ?? []) listener(agent.session, event); + return event; + } + + async function approval(agent, req) { + const listeners = agent._listeners.get('approval/request') ?? []; + let index = 0; + const next = async () => { + const listener = listeners[index++]; + return listener ? listener(req, next) : 'unavailable'; + }; + return next(); + } + + services.loader = { async await() { calls.push(['loader.await']); } }; + services.agentDefaultModel = { + currentSelection() { return { provider: 'deepseek', model: 'model-a', reasoningEffort: 'low' }; } + }; + services.llm = { + listProviders() { return [{ id: 'deepseek', name: 'DeepSeek' }]; }, + async listModels(provider) { return [{ provider, id: 'model-a', name: 'Model A', description: 'Primary' }]; }, + async resolveModelInfo(provider, model) { + return { + provider, + id: model, + name: 'Model A', + inputModalities: ['text'], + reasoning: { + efforts: [{ id: 'low', name: 'Low' }, { id: 'high', name: 'High' }], + defaultEffort: 'low' + } + }; + }, + async resolveCallConfig(config) { + calls.push(['resolveCallConfig', config]); + if (config.provider !== 'deepseek' || config.model !== 'model-a') throw new Error('unknown route'); + return { ...config, reasoningEffort: config.reasoningEffort ?? 'low' }; + } + }; + services.tools = { + get(name, agent) { + assert.ok(agent); + if (name === 'shell') { + return { + presentCall: () => ({ card: 'terminal', title: 'echo ok', cwd: agent.session.header.cwd }), + presentResult: () => ({ card: 'terminal', output: 'ok\n', exitCode: 0 }) + }; + } + return undefined; + } + }; + services.permissionPresets = { + names: Object.keys(presetTable), + current(events) { + const session = [...sessions.values()].find((candidate) => candidate.events === events); + return session?.permissionPreset ?? 'workspace-write'; + }, + optionOf(name) { + if (name === 'custom') return { value: 'custom', name: 'Custom' }; + const preset = presetTable[name]; + return { value: name, name: preset.name, description: preset.description }; + }, + resolve(name) { + const preset = presetTable[name]; + if (!preset) throw new Error('unknown preset'); + return preset; + }, + set(session, name) { + if (!presetTable[name]) throw new Error('unknown preset'); + calls.push(['permissionPresets.set', String(session.id), name]); + session.permissionPreset = name; + } + }; + services.sandboxPolicy = { + resolve({ session }) { + const preset = presetTable[session.permissionPreset]; + return { mode: preset.sandbox, workspaceRoot: session.header.cwd }; + } + }; + services.approval = { + config: { policy: 'ask' }, + overrideOf(session) { return presetTable[session.permissionPreset].approval; } + }; + services.userQuestions = { + registerProvider(provider) { + assert.equal(questionProvider, undefined); + questionProvider = provider; + return () => { questionProvider = undefined; }; + } + }; + services.sessionPersistence = { + async list(signal) { + calls.push(['sessionPersistence.list', signal]); + return [...sessions.values()].map((session) => session.header); + }, + async inspect(id) { + const session = sessions.get(String(id)); + if (!session) return undefined; + return { meta: session.header, events: session.events }; + } + }; + services.agents = { + async create(options) { + calls.push(['agents.create', options]); + const session = makeSession(String(options.sessionId), options.meta?.cwd); + const agent = makeAgent(session); + live.set(String(agent.id), agent); + await options.setup?.(agent.ctx); + return { + agent, + async dispose() { calls.push(['handle.dispose', String(agent.id)]); live.delete(String(agent.id)); } + }; + }, + async resume(options) { + calls.push(['agents.resume', options]); + const session = sessions.get(String(options.resumeSessionId)); + if (!session) throw new Error('session not found'); + const agent = makeAgent(session); + live.set(String(agent.id), agent); + await options.setup?.(agent.ctx); + return { + agent, + async dispose() { calls.push(['handle.dispose', String(agent.id)]); live.delete(String(agent.id)); } + }; + }, + get(id) { return live.get(String(id)); }, + list() { return [...live.values()]; }, + roots() { return [...live.values()]; } + }; + + const adapter = new DshAppServerAdapter({ + ctx: rootCtx, + send: (message) => sent.push(message), + cwd: '/workspace', + home: '/home/test/.dshx', + version: 'test' + }); + + return { + adapter, + sent, + calls, + sessions, + live, + append, + approval, + makeSession, + async ask(request) { + assert.ok(questionProvider, 'adapter should register the DSH userQuestions provider'); + return questionProvider.ask(request); + } + }; +} + +async function request(adapter, id, method, params = {}) { + const handled = await adapter.handle({ id, method, params }); + assert.ok(handled?.response); + if (handled.response.error) throw new Error(handled.response.error.message); + return handled; +} + +test('bootstrap reads public DSH model catalog and exposes opaque provider-aware ids', async () => { + const fx = fixture(); + const init = await request(fx.adapter, 1, 'initialize'); + assert.equal(init.response.result.platformOs.length > 0, true); + + const models = await request(fx.adapter, 2, 'model/list'); + assert.equal(models.response.result.data.length, 1); + assert.deepEqual(decodeDshModel(models.response.result.data[0].model), { + provider: 'deepseek', model: 'model-a' + }); +}); + +test('thread/start delegates creation and emits thread/started only after RPC response point', async () => { + const fx = fixture(); + const started = await request(fx.adapter, 1, 'thread/start', { cwd: '/workspace' }); + const result = started.response.result; + assert.equal(result.modelProvider, 'deepseek'); + assert.equal(result.sandbox.type, 'externalSandbox'); + assert.ok(fx.calls.some(([name]) => name === 'agents.create')); + assert.deepEqual(fx.sent, []); + + started.afterResponse(); + assert.equal(fx.sent[0].method, 'thread/started'); + assert.equal(fx.sent[0].params.thread.id, result.thread.id); +}); + +test('turn/start uses Agent.followup and DSH-committed turn identity', async () => { + const fx = fixture(); + const created = await request(fx.adapter, 1, 'thread/start', {}); + created.afterResponse(); + const threadId = created.response.result.thread.id; + fx.sent.length = 0; + + const turn = await request(fx.adapter, 2, 'turn/start', { + threadId, + input: [{ type: 'text', text: 'hello', text_elements: [] }] + }); + assert.equal(turn.response.result.turn.id, 'dsh-turn-1'); + const followup = fx.calls.find(([name]) => name === 'followup'); + assert.equal(followup[2].source.kind, 'user'); + assert.deepEqual(fx.sent, [], 'turn notification stays buffered until transport sends response'); + turn.afterResponse(); + assert.equal(fx.sent[0].method, 'turn/started'); +}); + +test('DSH approval/request round-trips through the matching presenter cell and stays one-shot', async () => { + const fx = fixture(); + const created = await request(fx.adapter, 1, 'thread/start', {}); + const threadId = created.response.result.thread.id; + const agent = fx.live.get(threadId); + const turn = await request(fx.adapter, 2, 'turn/start', { + threadId, + input: [{ type: 'text', text: 'run', text_elements: [] }] + }); + turn.afterResponse(); + fx.sent.length = 0; + + fx.append(agent, 'tool/call', { turn: 1, step: 1, callId: 'call-1', name: 'shell', arguments: '{}' }); + assert.equal(fx.sent[0].params.item.type, 'commandExecution'); + const decisionPromise = fx.approval(agent, { + agent, + toolName: 'shell', + callId: 'call-1', + reason: 'needs permission' + }); + await Promise.resolve(); + const uiRequest = fx.sent.find((message) => message.method === 'item/commandExecution/requestApproval'); + assert.ok(uiRequest); + await fx.adapter.handle({ id: uiRequest.id, result: { decision: 'accept' } }); + assert.equal(await decisionPromise, 'allowed-once'); +}); + +test('DSH userQuestions uses a synthetic presentation item because the public request has no call id', async () => { + const fx = fixture(); + const created = await request(fx.adapter, 1, 'thread/start', {}); + const threadId = created.response.result.thread.id; + const agent = fx.live.get(threadId); + const turn = await request(fx.adapter, 2, 'turn/start', { + threadId, + input: [{ type: 'text', text: 'ask me', text_elements: [] }] + }); + turn.afterResponse(); + fx.sent.length = 0; + + const answerPromise = fx.ask({ + agent, + questions: [{ id: 'q1', question: 'Choose?', options: [{ label: 'A' }, { label: 'B' }] }] + }); + await Promise.resolve(); + const started = fx.sent.find((message) => message.method === 'item/started'); + const uiRequest = fx.sent.find((message) => message.method === 'item/tool/requestUserInput'); + assert.equal(started.params.item.namespace, 'dshx'); + assert.ok(uiRequest); + await fx.adapter.handle({ id: uiRequest.id, result: { answers: { q1: { answers: ['A'] } } } }); + assert.deepEqual(await answerPromise, { answers: [{ id: 'q1', selected: ['A'] }] }); + assert.ok(fx.sent.some((message) => message.method === 'item/completed')); +}); + +test('thread/resume restores DSH request-header model and authoritative persisted transcript', async () => { + const fx = fixture(); + const session = fx.makeSession('persisted', '/project', [ + { type: 'turn/start', seq: 0, time: 1_700_000_000_000, data: { turn: 1 } }, + { + type: 'user/message', seq: 1, time: 1_700_000_000_010, + data: { id: 'u1', role: 'user', source: { kind: 'user' }, content: [{ type: 'text', text: 'fix it' }] } + }, + { + type: 'request/header', seq: 2, time: 1_700_000_000_020, + data: { header: { config: { provider: 'deepseek', model: 'model-a', reasoningEffort: 'high' } }, reason: 'initial' } + }, + { + type: 'assistant/message', seq: 3, time: 1_700_000_000_030, + data: { turn: 1, step: 1, message: { id: 'a1', content: [{ type: 'text', text: 'fixed' }] } } + }, + { type: 'turn/end', seq: 4, time: 1_700_000_000_040, data: { turn: 1, reason: { kind: 'completed' } } } + ]); + session.permissionPreset = 'workspace-write'; + + const resumed = await request(fx.adapter, 1, 'thread/resume', { threadId: 'persisted' }); + assert.deepEqual(decodeDshModel(resumed.response.result.model), { provider: 'deepseek', model: 'model-a' }); + assert.equal(resumed.response.result.reasoningEffort, 'high'); + assert.equal(resumed.response.result.thread.turns.length, 1); + assert.deepEqual(resumed.response.result.thread.turns[0].items.map((item) => item.type), ['userMessage', 'agentMessage']); +}); + +test('thread/list reads DSH persistence instead of maintaining a DSHX session database', async () => { + const fx = fixture(); + fx.makeSession('s1', '/a', [{ + type: 'user/message', seq: 0, time: 1_700_000_000_000, + data: { id: 'u1', source: { kind: 'user' }, content: [{ type: 'text', text: 'Alpha task' }] } + }]); + fx.makeSession('s2', '/b', [{ + type: 'user/message', seq: 0, time: 1_700_000_000_100, + data: { id: 'u2', source: { kind: 'user' }, content: [{ type: 'text', text: 'Beta task' }] } + }]); + + const listed = await request(fx.adapter, 1, 'thread/list', { cwd: '/b' }); + assert.deepEqual(listed.response.result.data.map((thread) => thread.id), ['s2']); + assert.ok(fx.calls.some(([name]) => name === 'sessionPersistence.list')); +}); From 1f51bd62ba074d91bf127203a82b1b6c6b27b2db Mon Sep 17 00:00:00 2001 From: code-new-bie <403466121@qq.com> Date: Wed, 19 Aug 2026 15:20:04 +0800 Subject: [PATCH 048/305] build: add empty DSHX runtime composition root --- config/dshx-runtime/cordis.yml | 4 ++++ 1 file changed, 4 insertions(+) create mode 100644 config/dshx-runtime/cordis.yml diff --git a/config/dshx-runtime/cordis.yml b/config/dshx-runtime/cordis.yml new file mode 100644 index 00000000..ae54b8c6 --- /dev/null +++ b/config/dshx-runtime/cordis.yml @@ -0,0 +1,4 @@ +# DSHX does not own a Harness plugin tree. This empty root is populated only +# by the official @deepseek-ai/dsh-base and @deepseek-ai/dsh-headless bundle +# patch layers in src/dsh/runtime-boot.mjs. +[] From 3184cf9640db5d089da3d0011ac15830eb1a2a7f Mon Sep 17 00:00:00 2001 From: code-new-bie <403466121@qq.com> Date: Wed, 19 Aug 2026 15:20:25 +0800 Subject: [PATCH 049/305] feat: boot DSHX from official Harness bundle layers --- src/dsh/runtime-boot.mjs | 92 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 92 insertions(+) create mode 100644 src/dsh/runtime-boot.mjs diff --git a/src/dsh/runtime-boot.mjs b/src/dsh/runtime-boot.mjs new file mode 100644 index 00000000..8203eac3 --- /dev/null +++ b/src/dsh/runtime-boot.mjs @@ -0,0 +1,92 @@ +import { readFileSync } from 'node:fs'; +import { createRequire } from 'node:module'; +import { dirname, join } from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; +import { + boot, + composeEntries, + loadLayeredEnv, + loadOverlayPatches, + resolveBundleDir +} from '@deepseek-ai/dsh-app-boot'; +import { DSH_LAUNCH_ENVIRONMENT_KEY } from '@deepseek-ai/dsh-launch-environment'; + +const NAME = 'dshx'; +const ROOT_CONFIG = fileURLToPath(new URL('../../config/dshx-runtime/cordis.yml', import.meta.url)); +const OFFICIAL_BUNDLES = ['@deepseek-ai/dsh-base', '@deepseek-ai/dsh-headless']; +const DSHX_SURFACE_PATCHES = [ + // The official headless bundle is our composition source, but its startup + // provider and one-shot runner are themselves a presentation surface. DSHX + // replaces only those rows; all Agent/Session/tool/provider services remain + // exactly the official headless composition. + { id: 'headless-startup', disabled: true }, + { id: 'headless-runner', disabled: true } +]; + +function installationAnchor() { + // @deepseek-ai/dsh is the published closed runtime carrying the official + // bundle/plugin dependency closure. It intentionally has no `exports` + // restriction, so its package.json is a stable resolution anchor. + return createRequire(import.meta.url).resolve('@deepseek-ai/dsh/package.json'); +} + +function bundleLayer(packageName, installAnchor) { + const configDir = dirname(ROOT_CONFIG); + const packageDir = resolveBundleDir(NAME, packageName, installAnchor, configDir); + const manifest = JSON.parse(readFileSync(join(packageDir, 'package.json'), 'utf8')); + const patch = manifest?.dsh?.bundle?.patch; + if (typeof patch !== 'string' || patch.length === 0) { + throw new Error(`dshx: official bundle ${packageName} declares no dsh.bundle.patch`); + } + return loadOverlayPatches(NAME, join(packageDir, patch)); +} + +/** + * Return the exact patch list DSHX boots: official base + official headless, + * then the two presentation-row disables above, then optional DSHX-owned + * presentation overlays supplied by the caller. + */ +export function dshxRuntimePatches({ installAnchor = installationAnchor(), overlays = [] } = {}) { + return [ + ...OFFICIAL_BUNDLES.flatMap((name) => bundleLayer(name, installAnchor)), + ...structuredClone(DSHX_SURFACE_PATCHES), + ...structuredClone(overlays) + ]; +} + +/** Offline composition view used by doctor/CI to prove DSHX did not grow a second runtime. */ +export function dshxRuntimeEntries(options = {}) { + return composeEntries([dshxRuntimePatches(options)]); +} + +/** + * Boot the official Harness composition in-process and return its root Context. + * DSHX owns only the surrounding presentation lifetime. The environment + * snapshot is provided before any config entry mounts, matching the official + * dsh launcher contract. + */ +export async function bootDshxRuntime({ + cwd = process.cwd(), + environment = loadLayeredEnv(NAME, cwd), + overlays = [] +} = {}) { + const installAnchor = installationAnchor(); + const patches = dshxRuntimePatches({ installAnchor, overlays }); + return boot( + NAME, + ROOT_CONFIG, + structuredClone(patches), + (ctx) => { + ctx.provide(DSH_LAUNCH_ENVIRONMENT_KEY, environment); + }, + pathToFileURL(installAnchor).href + ); +} + +export const runtimeInternals = { + NAME, + ROOT_CONFIG, + OFFICIAL_BUNDLES, + DSHX_SURFACE_PATCHES, + installationAnchor +}; From 9242b4c36b050bdec9a8e9eee682c183539d0f9b Mon Sep 17 00:00:00 2001 From: code-new-bie <403466121@qq.com> Date: Wed, 19 Aug 2026 15:20:59 +0800 Subject: [PATCH 050/305] build: pin official DSH closed runtime and boot APIs --- package.json | 3 +++ 1 file changed, 3 insertions(+) diff --git a/package.json b/package.json index a3b678d5..9a807fd3 100644 --- a/package.json +++ b/package.json @@ -17,7 +17,10 @@ "dev": "node ./bin/dshx.mjs" }, "dependencies": { + "@deepseek-ai/dsh": "0.1.0-rc.7", "@deepseek-ai/dsh-agent": "0.1.0-rc.7", + "@deepseek-ai/dsh-app-boot": "0.1.0-rc.7", + "@deepseek-ai/dsh-launch-environment": "0.1.0-rc.7", "@deepseek-ai/dsh-llm": "0.1.0-rc.7", "@deepseek-ai/dsh-session": "0.1.0-rc.7", "ws": "^8.18.0" From 4f4bb77bd802df5619a98f2837984d06150fb01d Mon Sep 17 00:00:00 2001 From: code-new-bie <403466121@qq.com> Date: Wed, 19 Aug 2026 15:21:41 +0800 Subject: [PATCH 051/305] test: prove DSHX runtime is official headless composition minus surface --- test/dsh-runtime-boot.test.mjs | 38 ++++++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) create mode 100644 test/dsh-runtime-boot.test.mjs diff --git a/test/dsh-runtime-boot.test.mjs b/test/dsh-runtime-boot.test.mjs new file mode 100644 index 00000000..2ccb3c5a --- /dev/null +++ b/test/dsh-runtime-boot.test.mjs @@ -0,0 +1,38 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { dshxRuntimeEntries, runtimeInternals } from '../src/dsh/runtime-boot.mjs'; + +test('runtime composition comes from official base/headless bundles and disables only their one-shot surface', () => { + assert.deepEqual(runtimeInternals.OFFICIAL_BUNDLES, [ + '@deepseek-ai/dsh-base', + '@deepseek-ai/dsh-headless' + ]); + + const entries = dshxRuntimeEntries(); + const byId = new Map(entries.map((entry) => [entry.id, entry])); + + // These are public DSH services DSHX consumes. Their plugin identities remain + // the official bundle rows — DSHX never substitutes implementations. + assert.equal(byId.get('llm')?.name, '@deepseek-ai/dsh-llm'); + assert.equal(byId.get('session')?.name, '@deepseek-ai/dsh-session'); + assert.equal(byId.get('agent')?.name, '@deepseek-ai/dsh-agent'); + assert.equal(byId.get('permission')?.name, '@deepseek-ai/dsh-permission-presets'); + assert.equal(byId.get('approval')?.name, '@deepseek-ai/dsh-user-approval'); + assert.equal(byId.get('user-questions')?.name, '@deepseek-ai/dsh-user-questions'); + assert.equal(byId.get('session-persistence-jsonl')?.name, '@deepseek-ai/dsh-session-persistence-jsonl'); + assert.equal(byId.get('tools')?.name, '@deepseek-ai/dsh-tools'); + + // The official headless bundle contributes Code Mode plus its own CLI surface. + // DSHX keeps the runtime capability and removes only the competing surface. + assert.equal(byId.get('code-runtime')?.name, '@deepseek-ai/dsh-code-runtime-worker-thread'); + assert.equal(byId.get('headless-startup')?.disabled, true); + assert.equal(byId.get('headless-runner')?.disabled, true); +}); + +test('DSHX surface patches do not insert a second Agent, Session, tool or provider implementation', () => { + for (const patch of runtimeInternals.DSHX_SURFACE_PATCHES) { + assert.ok('id' in patch); + assert.equal('insert' in patch, false); + assert.equal(patch.disabled, true); + } +}); From 5e3ee3ff109f735f36c6df6cc638b8d917a4c4ce Mon Sep 17 00:00:00 2001 From: code-new-bie <403466121@qq.com> Date: Wed, 19 Aug 2026 15:24:04 +0800 Subject: [PATCH 052/305] feat: map public DSH user skills to Codex filesystem skill metadata --- src/dsh/skills.mjs | 67 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 67 insertions(+) create mode 100644 src/dsh/skills.mjs diff --git a/src/dsh/skills.mjs b/src/dsh/skills.mjs new file mode 100644 index 00000000..a5ec249b --- /dev/null +++ b/src/dsh/skills.mjs @@ -0,0 +1,67 @@ +import path from 'node:path'; + +function requireSkills(ctx) { + const skills = ctx.get('skills'); + if (skills === undefined) throw new Error('DSHX requires DSH service: skills'); + return skills; +} + +function codexScope(source) { + if (source === 'project-dsh' || source === 'project-agents') return 'repo'; + if (source === 'user-dsh' || source === 'user-agents') return 'user'; + return 'system'; +} + +/** + * Project the subset of DSH skills that Codex's filesystem-oriented skill UI + * can represent faithfully. DSH remains the complete skill registry; a remote + * or runtime skill with no absolute file path is not fabricated into a path. + */ +export async function dshSkillsListEntry({ ctx, cwd, scope, diagnostics = () => {} }) { + const absoluteCwd = path.resolve(cwd); + const skills = requireSkills(ctx); + const options = { cwd: absoluteCwd, ...(scope === undefined ? {} : { scope }) }; + const snapshot = await skills.snapshot(options); + const data = []; + const errors = []; + + if (snapshot.complete !== true) { + errors.push({ + path: absoluteCwd, + message: 'DSH skill discovery was incomplete; available candidates are shown without claiming the catalog is authoritative' + }); + } + + for (const summary of snapshot.skills ?? []) { + if (summary?.invocation?.userInvocable !== true) continue; + try { + const definition = await skills.get(summary.name, options); + if (!definition) { + errors.push({ path: summary.name, message: 'DSH skill disappeared before it could be loaded' }); + continue; + } + if (typeof definition.path !== 'string' || !path.isAbsolute(definition.path)) { + // Codex SkillMetadata requires an absolute filesystem path. The DSH + // registry explicitly permits provider-managed skills without one. + diagnostics(`skill ${summary.name} is user-invocable but has no absolute filesystem path; omitted from Codex skill UI`); + continue; + } + data.push({ + name: definition.name, + description: definition.description, + path: definition.path, + scope: codexScope(definition.source), + enabled: true + }); + } catch (error) { + errors.push({ + path: summary.name, + message: error instanceof Error ? error.message : String(error) + }); + } + } + + return { cwd: absoluteCwd, skills: data, errors }; +} + +export const skillInternals = { codexScope }; From be8e28ad8aad2b2e8c49322a4c517a72eafe9877 Mon Sep 17 00:00:00 2001 From: code-new-bie <403466121@qq.com> Date: Wed, 19 Aug 2026 15:32:46 +0800 Subject: [PATCH 053/305] feat: add DSHX product protocol tail mappings --- src/dsh/product-adapter.mjs | 62 +++++++++++++++++++++++++++++++++++++ 1 file changed, 62 insertions(+) create mode 100644 src/dsh/product-adapter.mjs diff --git a/src/dsh/product-adapter.mjs b/src/dsh/product-adapter.mjs new file mode 100644 index 00000000..9407477d --- /dev/null +++ b/src/dsh/product-adapter.mjs @@ -0,0 +1,62 @@ +import path from 'node:path'; +import { DshAppServerAdapter } from './app-server-adapter.mjs'; +import { dshSkillsListEntry } from './skills.mjs'; + +function textInput(params) { + const inputs = Array.isArray(params?.input) ? params.input : []; + const unsupported = inputs.filter((item) => item?.type !== 'text'); + if (unsupported.length > 0) { + throw new Error(`DSHX steer currently supports Codex text items only; got ${unsupported.map((item) => item?.type).join(', ')}`); + } + return inputs.map((item) => item.text ?? '').join('\n').trim(); +} + +/** + * Product-level protocol tail on top of the stable DSH public API adapter. + * Keep feature-specific compatibility decisions here instead of widening the + * runtime-owning base: every method still delegates to official DSH services. + */ +export class DshxProductAdapter extends DshAppServerAdapter { + async dispatch(method, params) { + switch (method) { + case 'skills/list': + return this.skillsList(params); + case 'turn/steer': + return this.turnSteer(params); + default: + return super.dispatch(method, params); + } + } + + async skillsList(params = {}) { + if (params.forceReload === true) { + throw new Error( + 'DSHX cannot honor Codex forceReload: DSH skill cache invalidation is provider-owned; retry after the DSH provider emits skills/change' + ); + } + const cwds = Array.isArray(params.cwds) && params.cwds.length > 0 + ? params.cwds.map((cwd) => path.resolve(cwd)) + : [this.cwd]; + const data = await Promise.all(cwds.map((cwd) => dshSkillsListEntry({ + ctx: this.ctx, + cwd, + diagnostics: this.diagnostics + }))); + return { result: { data } }; + } + + turnSteer(params = {}) { + const threadId = String(params.threadId ?? ''); + const controller = this.controllers.get(threadId); + if (!controller) throw new Error(`Thread is not resumed in DSHX: ${threadId}`); + const location = controller.currentLocation(); + if (!location) throw new Error(`Thread has no active DSH turn to steer: ${threadId}`); + if (typeof params.expectedTurnId !== 'string' || params.expectedTurnId !== location.turnId) { + throw new Error( + `DSHX steer expectedTurnId ${JSON.stringify(params.expectedTurnId)} does not match active DSH turn ${location.turnId}` + ); + } + controller.steer(textInput(params)); + return { result: { turnId: location.turnId } }; + } +} From 7f862dc8db13c0275f71d17314f9554f8da77ea0 Mon Sep 17 00:00:00 2001 From: code-new-bie <403466121@qq.com> Date: Wed, 19 Aug 2026 15:33:13 +0800 Subject: [PATCH 054/305] test: lock DSHX skills and steer protocol tail --- test/dsh-product-adapter.test.mjs | 133 ++++++++++++++++++++++++++++++ 1 file changed, 133 insertions(+) create mode 100644 test/dsh-product-adapter.test.mjs diff --git a/test/dsh-product-adapter.test.mjs b/test/dsh-product-adapter.test.mjs new file mode 100644 index 00000000..42b7ab1f --- /dev/null +++ b/test/dsh-product-adapter.test.mjs @@ -0,0 +1,133 @@ +import assert from 'node:assert/strict'; +import path from 'node:path'; +import test from 'node:test'; +import { DshxProductAdapter } from '../src/dsh/product-adapter.mjs'; + +function bareAdapter() { + const adapter = Object.create(DshxProductAdapter.prototype); + adapter.cwd = path.resolve('/workspace'); + adapter.diagnostics = () => {}; + adapter.controllers = new Map(); + return adapter; +} + +test('skills/list reads DSH public registry and only returns faithfully path-backed user skills', async () => { + const adapter = bareAdapter(); + const absoluteSkill = path.resolve('/workspace/.agents/skills/review/SKILL.md'); + adapter.ctx = { + get(name) { + if (name !== 'skills') return undefined; + return { + async snapshot(options) { + assert.equal(options.cwd, path.resolve('/workspace')); + return { + complete: true, + skills: [ + { + name: 'review', + description: 'Review code', + invocation: { modelInvocable: true, userInvocable: true }, + source: 'project-agents', + provider: 'filesystem' + }, + { + name: 'remote-skill', + description: 'Remote skill', + invocation: { modelInvocable: true, userInvocable: true }, + source: 'custom', + provider: 'remote' + }, + { + name: 'model-only', + description: 'Model only', + invocation: { modelInvocable: true, userInvocable: false }, + source: 'runtime', + provider: 'runtime' + } + ] + }; + }, + async get(name) { + if (name === 'review') { + return { + name, + description: 'Review code', + invocation: { modelInvocable: true, userInvocable: true }, + source: 'project-agents', + provider: 'filesystem', + content: 'review', + path: absoluteSkill + }; + } + if (name === 'remote-skill') { + return { + name, + description: 'Remote skill', + invocation: { modelInvocable: true, userInvocable: true }, + source: 'custom', + provider: 'remote', + content: 'remote' + }; + } + return undefined; + } + }; + } + }; + + const result = await adapter.skillsList({ cwds: [] }); + assert.equal(result.result.data.length, 1); + assert.deepEqual(result.result.data[0].skills, [{ + name: 'review', + description: 'Review code', + path: absoluteSkill, + scope: 'repo', + enabled: true + }]); +}); + +test('skills/list refuses Codex forceReload because DSH invalidation belongs to providers', async () => { + const adapter = bareAdapter(); + await assert.rejects( + () => adapter.skillsList({ forceReload: true }), + /DSH skill cache invalidation is provider-owned/ + ); +}); + +test('turn/steer delegates only to the exact active DSH turn', () => { + const adapter = bareAdapter(); + const steered = []; + adapter.controllers.set('session-1', { + currentLocation() { return { threadId: 'session-1', turnId: 'dsh-turn-4' }; }, + steer(text) { steered.push(text); } + }); + + const result = adapter.turnSteer({ + threadId: 'session-1', + expectedTurnId: 'dsh-turn-4', + input: [ + { type: 'text', text: 'change direction', text_elements: [] }, + { type: 'text', text: 'keep tests', text_elements: [] } + ] + }); + assert.deepEqual(result, { result: { turnId: 'dsh-turn-4' } }); + assert.deepEqual(steered, ['change direction\nkeep tests']); +}); + +test('turn/steer rejects stale expectedTurnId instead of steering a different DSH turn', () => { + const adapter = bareAdapter(); + let called = false; + adapter.controllers.set('session-1', { + currentLocation() { return { threadId: 'session-1', turnId: 'dsh-turn-5' }; }, + steer() { called = true; } + }); + assert.throws( + () => adapter.turnSteer({ + threadId: 'session-1', + expectedTurnId: 'dsh-turn-4', + input: [{ type: 'text', text: 'late', text_elements: [] }] + }), + /does not match active DSH turn/ + ); + assert.equal(called, false); +}); From f97fa297b3f14d5646c326706b26ef9cc5cbe8c2 Mon Sep 17 00:00:00 2001 From: code-new-bie <403466121@qq.com> Date: Wed, 19 Aug 2026 15:36:26 +0800 Subject: [PATCH 055/305] feat: add authenticated loopback DSHX app-server transport --- src/dsh/local-server.mjs | 164 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 164 insertions(+) create mode 100644 src/dsh/local-server.mjs diff --git a/src/dsh/local-server.mjs b/src/dsh/local-server.mjs new file mode 100644 index 00000000..e5c2c48b --- /dev/null +++ b/src/dsh/local-server.mjs @@ -0,0 +1,164 @@ +import crypto from 'node:crypto'; +import { WebSocket, WebSocketServer } from 'ws'; +import { DshxProductAdapter } from './product-adapter.mjs'; +import { bootDshxRuntime } from './runtime-boot.mjs'; + +const DEFAULT_HOST = '127.0.0.1'; + +function tokenEquals(actual, expected) { + const left = Buffer.from(actual ?? '', 'utf8'); + const right = Buffer.from(expected, 'utf8'); + return left.length === right.length && crypto.timingSafeEqual(left, right); +} + +function authorized(request, token) { + const header = request.headers.authorization; + return typeof header === 'string' && tokenEquals(header, `Bearer ${token}`); +} + +function sendJson(socket, message) { + if (socket.readyState === WebSocket.OPEN) socket.send(JSON.stringify(message)); +} + +function parseError(error) { + return { + id: null, + error: { + code: -32700, + message: `Parse error: ${error instanceof Error ? error.message : String(error)}` + } + }; +} + +/** + * Authenticated single-client loopback transport for the pinned Codex TUI. + * The bearer token is process-ephemeral and accepted only on 127.0.0.1; DSH + * itself remains in-process behind the official boot composition. + */ +export async function startDshxLocalServer({ + host = DEFAULT_HOST, + port = 0, + cwd = process.cwd(), + home, + version = '0.1.0-dev', + token = crypto.randomBytes(32).toString('base64url'), + runtime, + bootRuntime = bootDshxRuntime, + Adapter = DshxProductAdapter, + log = () => {} +} = {}) { + if (host !== '127.0.0.1') { + throw new Error(`DSHX local app-server must bind 127.0.0.1, got ${JSON.stringify(host)}`); + } + if (typeof token !== 'string' || token.length < 32) { + throw new Error('DSHX local app-server bearer token must be at least 32 characters'); + } + + const ctx = runtime ?? await bootRuntime({ cwd }); + const server = new WebSocketServer({ + host, + port, + maxPayload: 128 << 20, + verifyClient(info, done) { + if (!authorized(info.req, token)) { + done(false, 401, 'Unauthorized'); + return; + } + done(true); + } + }); + + let activeSocket; + let adapter; + let closing = false; + + server.on('connection', (socket, request) => { + if (activeSocket && activeSocket.readyState !== WebSocket.CLOSED) { + socket.close(1008, 'DSHX accepts one TUI client'); + return; + } + activeSocket = socket; + adapter = new Adapter({ + ctx, + cwd, + home, + version, + send: (message) => sendJson(socket, message), + diagnostics: log + }); + log(`Codex TUI connected from ${request.socket.remoteAddress ?? 'unknown'}`); + + socket.on('message', async (raw) => { + let message; + try { + message = JSON.parse(raw.toString('utf8')); + } catch (error) { + sendJson(socket, parseError(error)); + return; + } + + try { + const handled = await adapter.handle(message); + if (!handled) return; + sendJson(socket, handled.response); + await handled.afterResponse?.(); + } catch (error) { + log(`request handling failed: ${error instanceof Error ? error.stack ?? error.message : String(error)}`); + if (message?.id !== undefined) { + sendJson(socket, { + id: message.id, + error: { + code: -32603, + message: error instanceof Error ? error.message : String(error) + } + }); + } + } + }); + + socket.on('close', () => { + const closedAdapter = adapter; + adapter = undefined; + activeSocket = undefined; + void closedAdapter?.close().catch((error) => log(`adapter close failed: ${error instanceof Error ? error.message : error}`)); + }); + socket.on('error', (error) => log(`socket error: ${error.message}`)); + }); + + await new Promise((resolve, reject) => { + if (server.address()) return resolve(); + server.once('listening', resolve); + server.once('error', reject); + }); + const address = server.address(); + if (!address || typeof address === 'string') { + throw new Error('DSHX local app-server did not bind a TCP address'); + } + + const close = async () => { + if (closing) return; + closing = true; + if (activeSocket && activeSocket.readyState !== WebSocket.CLOSED) { + await new Promise((resolve) => { + const timer = setTimeout(() => { + activeSocket?.terminate(); + resolve(); + }, 500); + activeSocket.once('close', () => { clearTimeout(timer); resolve(); }); + activeSocket.close(1001, 'DSHX shutting down'); + }); + } + await adapter?.close?.(); + await new Promise((resolve, reject) => server.close((error) => error ? reject(error) : resolve())); + await ctx.dispose?.(); + }; + + return { + host, + port: address.port, + url: `ws://${host}:${address.port}`, + token, + context: ctx, + close + }; +} From 17adab12d21559d14915e91309fd69592ab06ec7 Mon Sep 17 00:00:00 2001 From: code-new-bie <403466121@qq.com> Date: Wed, 19 Aug 2026 18:57:29 +0800 Subject: [PATCH 056/305] build: materialize pinned Codex source --- scripts/materialize-codex.sh | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) create mode 100644 scripts/materialize-codex.sh diff --git a/scripts/materialize-codex.sh b/scripts/materialize-codex.sh new file mode 100644 index 00000000..4b616347 --- /dev/null +++ b/scripts/materialize-codex.sh @@ -0,0 +1,28 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +PIN="$(tr -d '\r\n' < "$ROOT/upstream/CODEX_COMMIT")" +DEST="${DSHX_CODEX_DIR:-$ROOT/.upstream/codex}" +REPO="${DSHX_CODEX_REPO:-https://github.com/openai/codex.git}" + +if [[ -d "$DEST/.git" ]]; then + git -C "$DEST" fetch --quiet origin "$PIN" +else + rm -rf "$DEST" + mkdir -p "$(dirname "$DEST")" + git clone --filter=blob:none --no-checkout "$REPO" "$DEST" + git -C "$DEST" fetch --quiet origin "$PIN" +fi + +git -C "$DEST" checkout --detach --force "$PIN" +git -C "$DEST" reset --hard "$PIN" >/dev/null +git -C "$DEST" clean -ffd >/dev/null + +for patch in "$ROOT"/upstream/patches/codex/*.patch; do + [[ -e "$patch" ]] || continue + git -C "$DEST" apply --check "$patch" + git -C "$DEST" apply "$patch" +done + +printf '%s\n' "Materialized Codex $PIN at $DEST" From 8cd86f96c141f40c7e53ce32388a5f460b7ce6c1 Mon Sep 17 00:00:00 2001 From: code-new-bie <403466121@qq.com> Date: Wed, 19 Aug 2026 18:57:42 +0800 Subject: [PATCH 057/305] build: add Windows Codex materializer --- scripts/materialize-codex.ps1 | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) create mode 100644 scripts/materialize-codex.ps1 diff --git a/scripts/materialize-codex.ps1 b/scripts/materialize-codex.ps1 new file mode 100644 index 00000000..5ed87cc7 --- /dev/null +++ b/scripts/materialize-codex.ps1 @@ -0,0 +1,27 @@ +$ErrorActionPreference = 'Stop' + +$Root = (Resolve-Path (Join-Path $PSScriptRoot '..')).Path +$Pin = (Get-Content (Join-Path $Root 'upstream/CODEX_COMMIT') -Raw).Trim() +$Dest = if ($env:DSHX_CODEX_DIR) { $env:DSHX_CODEX_DIR } else { Join-Path $Root '.upstream\codex' } +$Repo = if ($env:DSHX_CODEX_REPO) { $env:DSHX_CODEX_REPO } else { 'https://github.com/openai/codex.git' } + +if (Test-Path (Join-Path $Dest '.git')) { + git -C $Dest fetch --quiet origin $Pin +} else { + if (Test-Path $Dest) { Remove-Item -Recurse -Force $Dest } + New-Item -ItemType Directory -Force (Split-Path -Parent $Dest) | Out-Null + git clone --filter=blob:none --no-checkout $Repo $Dest + git -C $Dest fetch --quiet origin $Pin +} + +git -C $Dest checkout --detach --force $Pin +git -C $Dest reset --hard $Pin | Out-Null +git -C $Dest clean -ffd | Out-Null + +$Patches = Get-ChildItem (Join-Path $Root 'upstream\patches\codex\*.patch') -ErrorAction SilentlyContinue | Sort-Object Name +foreach ($Patch in $Patches) { + git -C $Dest apply --check $Patch.FullName + git -C $Dest apply $Patch.FullName +} + +Write-Host "Materialized Codex $Pin at $Dest" From 15c009382180c4b4860a498b16469836c0b952a7 Mon Sep 17 00:00:00 2001 From: code-new-bie <403466121@qq.com> Date: Wed, 19 Aug 2026 18:57:59 +0800 Subject: [PATCH 058/305] tui: require authenticated DSHX backend --- .../codex/0001-dshx-force-ui-backend.patch | 49 +++++++++++++++++++ 1 file changed, 49 insertions(+) create mode 100644 upstream/patches/codex/0001-dshx-force-ui-backend.patch diff --git a/upstream/patches/codex/0001-dshx-force-ui-backend.patch b/upstream/patches/codex/0001-dshx-force-ui-backend.patch new file mode 100644 index 00000000..053ec018 --- /dev/null +++ b/upstream/patches/codex/0001-dshx-force-ui-backend.patch @@ -0,0 +1,49 @@ +diff --git a/codex-rs/tui/src/main.rs b/codex-rs/tui/src/main.rs +--- a/codex-rs/tui/src/main.rs ++++ b/codex-rs/tui/src/main.rs +@@ -1,10 +1,12 @@ + use clap::Parser; ++use codex_app_server_client::RemoteAppServerEndpoint; + use codex_arg0::Arg0DispatchPaths; + use codex_arg0::arg0_dispatch_or_else; + use codex_config::LoaderOverrides; + use codex_tui::AppExitInfo; + use codex_tui::Cli; + use codex_tui::ExitReason; ++use codex_tui::resolve_remote_addr; + use codex_tui::run_main; + use codex_utils_cli::CliConfigOverrides; + use std::io::Write; +@@ -47,6 +49,25 @@ fn main() -> anyhow::Result<()> { + inner + .config_overrides + .raw_overrides + .splice(0..0, top_cli.config_overrides.raw_overrides); ++ let dshx_endpoint = std::env::var("DSHX_APP_SERVER_ENDPOINT").map_err(|_| { ++ anyhow::anyhow!( ++ "DSHX_APP_SERVER_ENDPOINT is required; DSHX never falls back to the Codex agent runtime" ++ ) ++ })?; ++ let dshx_token = std::env::var("DSHX_APP_SERVER_TOKEN").map_err(|_| { ++ anyhow::anyhow!( ++ "DSHX_APP_SERVER_TOKEN is required; DSHX local transport is always authenticated" ++ ) ++ })?; ++ let mut dshx_endpoint = resolve_remote_addr(&dshx_endpoint)?; ++ match &mut dshx_endpoint { ++ RemoteAppServerEndpoint::WebSocket { auth_token, .. } => { ++ *auth_token = Some(dshx_token); ++ } ++ RemoteAppServerEndpoint::UnixSocket { .. } => { ++ anyhow::bail!("DSHX production launcher requires authenticated loopback WebSocket transport") ++ } ++ } + let exit_info = run_main( + inner, + arg0_paths, + LoaderOverrides::default(), +- /*explicit_remote_endpoint*/ None, ++ /*explicit_remote_endpoint*/ Some(dshx_endpoint), + ) + .await?; + let is_fatal = match &exit_info.exit_reason { From bf074f5ac18517bd067a7727091cfef79c806ccf Mon Sep 17 00:00:00 2001 From: code-new-bie <403466121@qq.com> Date: Wed, 19 Aug 2026 18:58:15 +0800 Subject: [PATCH 059/305] build: compile pinned DSHX Codex TUI --- scripts/build-codex-tui.sh | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) create mode 100644 scripts/build-codex-tui.sh diff --git a/scripts/build-codex-tui.sh b/scripts/build-codex-tui.sh new file mode 100644 index 00000000..bb0e5c4a --- /dev/null +++ b/scripts/build-codex-tui.sh @@ -0,0 +1,17 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +"$ROOT/scripts/materialize-codex.sh" +CODEX_DIR="${DSHX_CODEX_DIR:-$ROOT/.upstream/codex}" +TARGET_DIR="${CARGO_TARGET_DIR:-$ROOT/.build/codex}" +OUT_DIR="${DSHX_TUI_OUT_DIR:-$ROOT/dist/bin}" + +mkdir -p "$OUT_DIR" +CARGO_TARGET_DIR="$TARGET_DIR" cargo build \ + --manifest-path "$CODEX_DIR/codex-rs/Cargo.toml" \ + --locked --release -p codex-tui --bin codex-tui + +cp "$TARGET_DIR/release/codex-tui" "$OUT_DIR/dshx-tui" +chmod +x "$OUT_DIR/dshx-tui" +printf '%s\n' "$OUT_DIR/dshx-tui" From ca1326e3a4fe052c0cc3b7873d7c711db870b23e Mon Sep 17 00:00:00 2001 From: code-new-bie <403466121@qq.com> Date: Wed, 19 Aug 2026 18:58:26 +0800 Subject: [PATCH 060/305] build: compile pinned DSHX Codex TUI on Windows --- scripts/build-codex-tui.ps1 | 15 +++++++++++++++ 1 file changed, 15 insertions(+) create mode 100644 scripts/build-codex-tui.ps1 diff --git a/scripts/build-codex-tui.ps1 b/scripts/build-codex-tui.ps1 new file mode 100644 index 00000000..d4d4ead4 --- /dev/null +++ b/scripts/build-codex-tui.ps1 @@ -0,0 +1,15 @@ +$ErrorActionPreference = 'Stop' + +$Root = (Resolve-Path (Join-Path $PSScriptRoot '..')).Path +& (Join-Path $Root 'scripts\materialize-codex.ps1') +$CodeXDir = if ($env:DSHX_CODEX_DIR) { $env:DSHX_CODEX_DIR } else { Join-Path $Root '.upstream\codex' } +$TargetDir = if ($env:CARGO_TARGET_DIR) { $env:CARGO_TARGET_DIR } else { Join-Path $Root '.build\codex' } +$OutDir = if ($env:DSHX_TUI_OUT_DIR) { $env:DSHX_TUI_OUT_DIR } else { Join-Path $Root 'dist\bin' } + +New-Item -ItemType Directory -Force $OutDir | Out-Null +$env:CARGO_TARGET_DIR = $TargetDir +cargo build --manifest-path (Join-Path $CodeXDir 'codex-rs\Cargo.toml') --locked --release -p codex-tui --bin codex-tui +$Source = Join-Path $TargetDir 'release\codex-tui.exe' +$Destination = Join-Path $OutDir 'dshx-tui.exe' +Copy-Item -Force $Source $Destination +Write-Host $Destination From 2286a17151ce24162f69e7e57607625057d905f1 Mon Sep 17 00:00:00 2001 From: code-new-bie <403466121@qq.com> Date: Wed, 19 Aug 2026 18:59:10 +0800 Subject: [PATCH 061/305] feat: make dshx launch official DSH plus pinned TUI --- bin/dshx.mjs | 126 +++++++++++++++++++++++++++++++++++++++------------ 1 file changed, 96 insertions(+), 30 deletions(-) diff --git a/bin/dshx.mjs b/bin/dshx.mjs index 6fd76118..7651d10b 100644 --- a/bin/dshx.mjs +++ b/bin/dshx.mjs @@ -1,33 +1,72 @@ #!/usr/bin/env node -import { spawn, spawnSync } from 'node:child_process'; +import fs from 'node:fs'; +import path from 'node:path'; import process from 'node:process'; -import { startProtocolStubServer } from '../src/server.mjs'; +import { spawn, spawnSync } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; +import { startDshxLocalServer } from '../src/dsh/local-server.mjs'; -const VERSION = '0.0.0-m0'; +const VERSION = '0.1.0-dev'; +const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); function usage() { - return `DSHX ${VERSION}\n\nUsage:\n dshx Start the Codex TUI against the M0 compatibility stub\n dshx doctor Check development prerequisites\n dshx --version Print version\n dshx --help Show this help\n\nEnvironment:\n DSHX_CODEX_BIN Codex executable to launch (default: codex)\n DSHX_DEBUG=1 Print protocol-stub request logs\n\nM0 note:\n This branch intentionally uses Codex --remote only as a protocol proof.\n Production DSHX will ship its pinned thin-fork TUI and will not depend on\n the experimental WebSocket transport.\n`; + return `DSHX ${VERSION}\n\nUsage:\n dshx Start DSHX in the current project\n dshx Start with an initial prompt\n dshx resume Open the Codex-style DSH session picker\n dshx resume --last Resume the most recent DSH session\n dshx resume Resume a specific DSH session\n dshx doctor Check runtime/TUI prerequisites\n dshx --version Print version\n dshx --help Show this help\n\nEnvironment:\n DSHX_TUI_BIN Override the packaged DSHX TUI binary (development only)\n DSHX_DEBUG=1 Print DSHX adapter diagnostics\n\nProduct boundary:\n DSHX owns only the launcher, Codex TUI thin fork and presentation adapter.\n DeepSeek Harness remains the authoritative Agent/Session/Tool runtime.\n`; } -function codexBinary() { - return process.env.DSHX_CODEX_BIN || 'codex'; +function packagedTuiBinary() { + const name = process.platform === 'win32' ? 'dshx-tui.exe' : 'dshx-tui'; + const candidates = [ + process.env.DSHX_TUI_BIN, + path.join(ROOT, 'dist', 'bin', name), + path.join(ROOT, '.build', 'codex', 'release', process.platform === 'win32' ? 'codex-tui.exe' : 'codex-tui') + ].filter(Boolean); + return candidates.find((candidate) => fs.existsSync(candidate)) ?? candidates[0]; +} + +function parseLaunchArgs(args) { + if (args[0] !== 'resume') return { tuiArgs: args, resumeEnv: {} }; + const rest = args.slice(1); + if (rest.length === 0) return { tuiArgs: [], resumeEnv: { DSHX_RESUME_MODE: 'picker' } }; + if (rest.length === 1 && rest[0] === '--last') { + return { tuiArgs: [], resumeEnv: { DSHX_RESUME_MODE: 'last' } }; + } + if (rest.length === 1 && !rest[0].startsWith('-')) { + return { + tuiArgs: [], + resumeEnv: { DSHX_RESUME_MODE: 'id', DSHX_RESUME_SESSION_ID: rest[0] } + }; + } + throw new Error('Usage: dshx resume [--last|]'); } function doctor() { - const executable = codexBinary(); - const check = spawnSync(executable, ['--version'], { encoding: 'utf8' }); + const executable = packagedTuiBinary(); + const tuiCheck = executable && fs.existsSync(executable) + ? spawnSync(executable, ['--version'], { encoding: 'utf8' }) + : { status: 127, stdout: '', stderr: '' }; const rows = [ - ['Node', process.version, true], + ['Node', process.version, Number(process.versions.node.split('.')[0]) >= 20], [ - 'Codex', - check.status === 0 ? (check.stdout || check.stderr).trim() : `${executable} not runnable`, - check.status === 0 + 'Pinned Codex TUI', + tuiCheck.status === 0 + ? (tuiCheck.stdout || tuiCheck.stderr).trim() + : executable + ? `${executable} is not built/runnable` + : 'not found', + tuiCheck.status === 0 ] ]; for (const [name, detail, ok] of rows) { process.stdout.write(`${ok ? '✓' : '✗'} ${name}: ${detail}\n`); } - if (rows.some((row) => !row[2])) process.exitCode = 1; + if (rows.some((row) => !row[2])) { + process.stderr.write( + process.platform === 'win32' + ? 'Build the pinned TUI with `.\\scripts\\build-codex-tui.ps1`.\n' + : 'Build the pinned TUI with `./scripts/build-codex-tui.sh`.\n' + ); + process.exitCode = 1; + } } async function run() { @@ -44,23 +83,45 @@ async function run() { doctor(); return; } - if (args.length > 0) { - process.stderr.write(`dshx: unsupported M0 argument: ${args.join(' ')}\n\n${usage()}`); + + let launch; + try { + launch = parseLaunchArgs(args); + } catch (error) { + process.stderr.write(`dshx: ${error.message}\n`); process.exitCode = 2; return; } + const executable = packagedTuiBinary(); + if (!executable || !fs.existsSync(executable)) { + process.stderr.write(`dshx: packaged TUI binary not found (${executable ?? 'unknown path'})\n`); + process.stderr.write('Run `dshx doctor` for build/install guidance.\n'); + process.exitCode = 127; + return; + } + const debug = process.env.DSHX_DEBUG === '1'; - const stub = await startProtocolStubServer({ - cwd: process.cwd(), - log: debug ? (message) => process.stderr.write(`[dshx] ${message}\n`) : () => {} - }); + const log = debug ? (message) => process.stderr.write(`[dshx] ${message}\n`) : () => {}; + let local; + try { + local = await startDshxLocalServer({ cwd: process.cwd(), version: VERSION, log }); + } catch (error) { + process.stderr.write(`dshx: failed to boot DeepSeek Harness: ${error instanceof Error ? error.message : error}\n`); + process.exitCode = 1; + return; + } - const executable = codexBinary(); - const child = spawn(executable, ['--remote', stub.url], { + const child = spawn(executable, launch.tuiArgs, { cwd: process.cwd(), - env: process.env, - stdio: 'inherit' + env: { + ...process.env, + ...launch.resumeEnv, + DSHX_APP_SERVER_ENDPOINT: local.url, + DSHX_APP_SERVER_TOKEN: local.token + }, + stdio: 'inherit', + windowsHide: false }); let closing = false; @@ -68,21 +129,26 @@ async function run() { if (closing) return; closing = true; try { - await stub.close(); - } catch { - // Best effort: process exit will release the local listener. + await local.close(); + } catch (error) { + if (debug) process.stderr.write(`[dshx] shutdown: ${error instanceof Error ? error.message : error}\n`); } process.exitCode = exitCode; }; child.on('error', async (error) => { - process.stderr.write(`dshx: failed to launch ${executable}: ${error.message}\n`); - process.stderr.write('Run `dshx doctor` and install the pinned Codex development prerequisite.\n'); + process.stderr.write(`dshx: failed to launch pinned Codex TUI: ${error.message}\n`); await close(127); }); child.on('exit', async (code, signal) => { - if (signal && debug) process.stderr.write(`[dshx] Codex exited via ${signal}\n`); - await close(code ?? 1); + if (signal && debug) process.stderr.write(`[dshx] TUI exited via ${signal}\n`); + await close(code ?? (signal ? 1 : 0)); + }); + + // SIGTERM is a process-lifecycle signal. Do not intercept SIGINT: Ctrl+C is + // an in-TUI interaction used by Codex to interrupt the active DSH turn. + process.once('SIGTERM', () => { + if (!child.killed) child.kill('SIGTERM'); }); } From a11778d299ff66230e6bc124ec83defd7bb333cc Mon Sep 17 00:00:00 2001 From: code-new-bie <403466121@qq.com> Date: Wed, 19 Aug 2026 18:59:39 +0800 Subject: [PATCH 062/305] tui: add DSH-backed resume entry modes --- .../codex/0001-dshx-force-ui-backend.patch | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/upstream/patches/codex/0001-dshx-force-ui-backend.patch b/upstream/patches/codex/0001-dshx-force-ui-backend.patch index 053ec018..f2a8bb2c 100644 --- a/upstream/patches/codex/0001-dshx-force-ui-backend.patch +++ b/upstream/patches/codex/0001-dshx-force-ui-backend.patch @@ -14,11 +14,27 @@ diff --git a/codex-rs/tui/src/main.rs b/codex-rs/tui/src/main.rs use codex_tui::run_main; use codex_utils_cli::CliConfigOverrides; use std::io::Write; -@@ -47,6 +49,25 @@ fn main() -> anyhow::Result<()> { +@@ -47,6 +49,42 @@ fn main() -> anyhow::Result<()> { inner .config_overrides .raw_overrides .splice(0..0, top_cli.config_overrides.raw_overrides); ++ if let Ok(mode) = std::env::var("DSHX_RESUME_MODE") { ++ match mode.as_str() { ++ "picker" => inner.resume_picker = true, ++ "last" => inner.resume_last = true, ++ "id" => { ++ inner.resume_session_id = Some( ++ std::env::var("DSHX_RESUME_SESSION_ID").map_err(|_| { ++ anyhow::anyhow!( ++ "DSHX_RESUME_SESSION_ID is required when DSHX_RESUME_MODE=id" ++ ) ++ })?, ++ ); ++ } ++ other => anyhow::bail!("invalid DSHX_RESUME_MODE: {other}"), ++ } ++ } + let dshx_endpoint = std::env::var("DSHX_APP_SERVER_ENDPOINT").map_err(|_| { + anyhow::anyhow!( + "DSHX_APP_SERVER_ENDPOINT is required; DSHX never falls back to the Codex agent runtime" From dfe471df858218d8aa5e36230133e1300c210c01 Mon Sep 17 00:00:00 2001 From: code-new-bie <403466121@qq.com> Date: Wed, 19 Aug 2026 19:00:22 +0800 Subject: [PATCH 063/305] ci: validate DSH adapter and pinned Codex TUI --- .github/workflows/ci.yml | 57 +++++++++++++++++++++++++++++++++++++++- 1 file changed, 56 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f248c344..3823606f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -10,7 +10,7 @@ permissions: jobs: protocol: - name: Protocol / Node ${{ matrix.node }} + name: Protocol / ${{ matrix.os }} / Node ${{ matrix.node }} runs-on: ${{ matrix.os }} strategy: fail-fast: false @@ -26,8 +26,57 @@ jobs: - run: npm test - run: node --check src/protocol.mjs - run: node --check src/server.mjs + - run: node --check src/dsh/app-server-adapter.mjs + - run: node --check src/dsh/product-adapter.mjs + - run: node --check src/dsh/local-server.mjs + - run: node --check src/dsh/runtime-boot.mjs - run: node --check bin/dshx.mjs + codex-tui: + name: Pinned Codex TUI / ${{ matrix.os }} + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, windows-latest] + steps: + - uses: actions/checkout@v4 + - name: Materialize pinned Codex (Unix) + if: runner.os != 'Windows' + shell: bash + run: bash scripts/materialize-codex.sh + - name: Materialize pinned Codex (Windows) + if: runner.os == 'Windows' + shell: pwsh + run: ./scripts/materialize-codex.ps1 + - name: Assert no Codex-core fallback (Unix) + if: runner.os != 'Windows' + shell: bash + run: | + grep -q 'DSHX_APP_SERVER_ENDPOINT is required' .upstream/codex/codex-rs/tui/src/main.rs + grep -q 'DSHX_APP_SERVER_TOKEN is required' .upstream/codex/codex-rs/tui/src/main.rs + grep -q 'Some(dshx_endpoint)' .upstream/codex/codex-rs/tui/src/main.rs + grep -q 'DSHX_RESUME_MODE' .upstream/codex/codex-rs/tui/src/main.rs + - name: Assert no Codex-core fallback (Windows) + if: runner.os == 'Windows' + shell: pwsh + run: | + $source = Get-Content .upstream/codex/codex-rs/tui/src/main.rs -Raw + if (-not $source.Contains('DSHX_APP_SERVER_ENDPOINT is required')) { throw 'missing DSHX endpoint guard' } + if (-not $source.Contains('DSHX_APP_SERVER_TOKEN is required')) { throw 'missing DSHX token guard' } + if (-not $source.Contains('Some(dshx_endpoint)')) { throw 'Codex core fallback was not removed' } + if (-not $source.Contains('DSHX_RESUME_MODE')) { throw 'DSHX resume patch is missing' } + - name: Cargo check pinned TUI + shell: bash + if: runner.os != 'Windows' + run: CARGO_TARGET_DIR="$GITHUB_WORKSPACE/.build/codex" cargo check --manifest-path .upstream/codex/codex-rs/Cargo.toml --locked -p codex-tui --bin codex-tui + - name: Cargo check pinned TUI (Windows) + shell: pwsh + if: runner.os == 'Windows' + run: | + $env:CARGO_TARGET_DIR = Join-Path $env:GITHUB_WORKSPACE '.build\codex' + cargo check --manifest-path .upstream\codex\codex-rs\Cargo.toml --locked -p codex-tui --bin codex-tui + ownership-guard: name: Ownership boundary runs-on: ubuntu-latest @@ -38,3 +87,9 @@ jobs: run: | grep -q "must not grow into an agent framework" docs/ARCHITECTURE.md grep -q "DeepSeek Harness remains an upstream dependency" docs/TEAM.md + - name: Assert production runtime remains official DSH composition + shell: bash + run: | + grep -q "dsh-base" src/dsh/runtime-boot.mjs + grep -q "dsh-headless" src/dsh/runtime-boot.mjs + grep -q "startup: { disabled: true }" src/dsh/runtime-boot.mjs From beb6525c71af7d0f97468e3579fbf7f119d3ba14 Mon Sep 17 00:00:00 2001 From: code-new-bie <403466121@qq.com> Date: Wed, 19 Aug 2026 19:00:52 +0800 Subject: [PATCH 064/305] test: lock authenticated local transport lifecycle --- test/dsh-local-server.test.mjs | 80 ++++++++++++++++++++++++++++++++++ 1 file changed, 80 insertions(+) create mode 100644 test/dsh-local-server.test.mjs diff --git a/test/dsh-local-server.test.mjs b/test/dsh-local-server.test.mjs new file mode 100644 index 00000000..58bc0327 --- /dev/null +++ b/test/dsh-local-server.test.mjs @@ -0,0 +1,80 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { WebSocket } from 'ws'; +import { startDshxLocalServer } from '../src/dsh/local-server.mjs'; + +const TOKEN = '0123456789abcdefghijklmnopqrstuvwxyz-DSHX-token'; + +function once(socket, event) { + return new Promise((resolve, reject) => { + socket.once(event, resolve); + socket.once('error', reject); + }); +} + +class FakeAdapter { + static closes = 0; + constructor({ send }) { + this.send = send; + } + async handle(message) { + return { response: { id: message.id, result: { echoed: message.method } } }; + } + async close() { + FakeAdapter.closes += 1; + } +} + +test('local server rejects unauthenticated WebSocket clients', async () => { + const runtime = { async dispose() {} }; + const server = await startDshxLocalServer({ runtime, token: TOKEN, Adapter: FakeAdapter }); + try { + const socket = new WebSocket(server.url); + const status = await new Promise((resolve) => { + socket.once('unexpected-response', (_request, response) => resolve(response.statusCode)); + socket.once('error', () => {}); + }); + assert.equal(status, 401); + socket.terminate(); + } finally { + await server.close(); + } +}); + +test('local server accepts bearer-authenticated RPC and disposes presentation/runtime state', async () => { + let disposed = 0; + FakeAdapter.closes = 0; + const runtime = { async dispose() { disposed += 1; } }; + const server = await startDshxLocalServer({ runtime, token: TOKEN, Adapter: FakeAdapter }); + const socket = new WebSocket(server.url, { headers: { Authorization: `Bearer ${TOKEN}` } }); + await once(socket, 'open'); + + const response = new Promise((resolve, reject) => { + socket.once('message', (data) => { + try { resolve(JSON.parse(data.toString('utf8'))); } catch (error) { reject(error); } + }); + }); + socket.send(JSON.stringify({ id: 7, method: 'initialize', params: {} })); + assert.deepEqual(await response, { id: 7, result: { echoed: 'initialize' } }); + + socket.close(); + await once(socket, 'close'); + await server.close(); + assert.equal(FakeAdapter.closes, 1); + assert.equal(disposed, 1); +}); + +test('local server permits only one active TUI client', async () => { + const runtime = { async dispose() {} }; + const server = await startDshxLocalServer({ runtime, token: TOKEN, Adapter: FakeAdapter }); + const headers = { Authorization: `Bearer ${TOKEN}` }; + const first = new WebSocket(server.url, { headers }); + await once(first, 'open'); + const second = new WebSocket(server.url, { headers }); + await once(second, 'open'); + const [code] = await once(second, 'close'); + assert.equal(code, 1008); + first.close(); + await once(first, 'close'); + await server.close(); +}); From cc3a23b5d13fe496c8639261097dc739b779df9e Mon Sep 17 00:00:00 2001 From: code-new-bie <403466121@qq.com> Date: Wed, 19 Aug 2026 19:06:09 +0800 Subject: [PATCH 065/305] feat: wire model and permission settings to DSH --- src/dsh/product-adapter.mjs | 108 ++++++++++++++++++++++++++++++++++++ 1 file changed, 108 insertions(+) diff --git a/src/dsh/product-adapter.mjs b/src/dsh/product-adapter.mjs index 9407477d..87596342 100644 --- a/src/dsh/product-adapter.mjs +++ b/src/dsh/product-adapter.mjs @@ -11,6 +11,37 @@ function textInput(params) { return inputs.map((item) => item.text ?? '').join('\n').trim(); } +function dshPermissionPreset(ctx, codexId) { + const presets = ctx.get('permissionPresets'); + if (!presets) throw new Error('DSHX requires DSH service: permissionPresets'); + const mapped = codexId === ':workspace' + ? 'workspace-write' + : codexId === ':danger-full-access' + ? 'danger-full-access' + : codexId === ':read-only' + ? 'read-only' + : codexId; + if (!presets.names.includes(mapped)) { + throw new Error(`DSH has no permission preset corresponding to Codex profile ${JSON.stringify(codexId)}`); + } + return mapped; +} + +function codexPermissionProfileId(preset) { + if (preset === 'workspace-write') return ':workspace'; + if (preset === 'danger-full-access') return ':danger-full-access'; + if (preset === 'read-only') return ':read-only'; + if (preset === 'custom' || preset == null) return null; + return preset; +} + +function hasSupportedThreadSetting(params) { + return params.model != null + || params.effort != null + || params.permissions != null + || params.approvalPolicy != null; +} + /** * Product-level protocol tail on top of the stable DSH public API adapter. * Keep feature-specific compatibility decisions here instead of widening the @@ -21,6 +52,8 @@ export class DshxProductAdapter extends DshAppServerAdapter { switch (method) { case 'skills/list': return this.skillsList(params); + case 'thread/settings/update': + return this.threadSettingsUpdate(params); case 'turn/steer': return this.turnSteer(params); default: @@ -28,6 +61,32 @@ export class DshxProductAdapter extends DshAppServerAdapter { } } + threadResponse(agent, options = {}) { + const response = super.threadResponse(agent, options); + const permission = this.permissions.current(agent); + const id = codexPermissionProfileId(permission.preset); + return { + ...response, + activePermissionProfile: id == null ? null : { id } + }; + } + + async applyStartPermissions(agent, params = {}) { + if (params.permissions == null) return super.applyStartPermissions(agent, params); + if (params.sandbox != null) { + throw new Error('Codex thread/start permissions and sandbox cannot both be selected for DSHX'); + } + const preset = dshPermissionPreset(this.ctx, String(params.permissions)); + this.permissions.set(agent, preset); + const current = this.permissions.current(agent); + if (params.approvalPolicy != null && current.codex.approvalPolicy !== params.approvalPolicy) { + throw new Error( + `DSH preset ${preset} does not match requested Codex approval policy ${JSON.stringify(params.approvalPolicy)}` + ); + } + return current; + } + async skillsList(params = {}) { if (params.forceReload === true) { throw new Error( @@ -45,6 +104,55 @@ export class DshxProductAdapter extends DshAppServerAdapter { return { result: { data } }; } + async threadSettingsUpdate(params = {}) { + const threadId = String(params.threadId ?? ''); + const controller = this.controllers.get(threadId); + if (!controller) throw new Error(`Thread is not resumed in DSHX: ${threadId}`); + const agent = controller.agent; + + if (params.cwd != null) { + const currentCwd = path.resolve(agent.session.header?.cwd ?? this.cwd); + if (path.resolve(params.cwd) !== currentCwd) { + throw new Error('DSHX does not retarget a live DSH session cwd from the TUI'); + } + } + if (params.sandboxPolicy != null) { + throw new Error('DSHX changes permissions through DSH named permission presets, not Codex legacy sandboxPolicy'); + } + if (params.serviceTier !== undefined) { + throw new Error('DSH has no public thread service-tier setter for DSHX to project'); + } + if (params.summary != null) { + throw new Error('DSH has no equivalent public reasoning-summary thread setting'); + } + if (params.personality != null) { + throw new Error('DSH has no equivalent public personality thread setting'); + } + if (params.approvalsReviewer != null && params.approvalsReviewer !== 'user') { + throw new Error('DSH approval review is user-owned; DSHX cannot select a Codex-specific reviewer'); + } + + const supported = hasSupportedThreadSetting(params); + if (params.collaborationMode != null && !supported) { + throw new Error('DSH has no equivalent public collaboration-mode thread setting'); + } + + if (params.permissions != null || params.approvalPolicy != null) { + await this.applyStartPermissions(agent, { + permissions: params.permissions, + approvalPolicy: params.approvalPolicy + }); + } + if (params.model != null || params.effort != null) { + await this.applyModelOverride(agent, { + model: params.model, + effort: params.effort + }); + } + + return { result: {} }; + } + turnSteer(params = {}) { const threadId = String(params.threadId ?? ''); const controller = this.controllers.get(threadId); From 84b0405072ac53090da02278943969a80ef0413e Mon Sep 17 00:00:00 2001 From: code-new-bie <403466121@qq.com> Date: Wed, 19 Aug 2026 19:07:28 +0800 Subject: [PATCH 066/305] test: cover DSH-backed thread settings updates --- test/dsh-product-adapter.test.mjs | 77 +++++++++++++++++++++++++++++++ 1 file changed, 77 insertions(+) diff --git a/test/dsh-product-adapter.test.mjs b/test/dsh-product-adapter.test.mjs index 42b7ab1f..34c2bbc9 100644 --- a/test/dsh-product-adapter.test.mjs +++ b/test/dsh-product-adapter.test.mjs @@ -94,6 +94,83 @@ test('skills/list refuses Codex forceReload because DSH invalidation belongs to ); }); +test('thread/settings/update maps Codex workspace profile to the DSH canonical preset', async () => { + const adapter = bareAdapter(); + const applied = []; + const agent = { session: { header: { cwd: path.resolve('/workspace') } } }; + adapter.controllers.set('session-1', { agent }); + adapter.ctx = { + get(name) { + if (name === 'permissionPresets') return { names: ['workspace-write', 'danger-full-access'] }; + return undefined; + } + }; + adapter.permissions = { + set(receivedAgent, preset) { + assert.equal(receivedAgent, agent); + applied.push(preset); + }, + current() { + return { + preset: 'workspace-write', + codex: { approvalPolicy: 'on-request' }, + dsh: { sandboxMode: 'workspace-write', approvalPolicy: 'ask' } + }; + } + }; + adapter.applyModelOverride = async () => { throw new Error('model path should not run'); }; + + const result = await adapter.threadSettingsUpdate({ + threadId: 'session-1', + permissions: ':workspace', + approvalPolicy: 'on-request' + }); + assert.deepEqual(applied, ['workspace-write']); + assert.deepEqual(result, { result: {} }); +}); + +test('thread/settings/update delegates model and effort while tolerating Codex default collaboration metadata', async () => { + const adapter = bareAdapter(); + const agent = { session: { header: { cwd: path.resolve('/workspace') } } }; + adapter.controllers.set('session-1', { agent }); + const modelCalls = []; + adapter.applyModelOverride = async (receivedAgent, update) => { + assert.equal(receivedAgent, agent); + modelCalls.push(update); + }; + adapter.applyStartPermissions = async () => { throw new Error('permission path should not run'); }; + + const result = await adapter.threadSettingsUpdate({ + threadId: 'session-1', + model: 'dsh://deepseek/deepseek-chat', + effort: 'high', + collaborationMode: { mode: 'default', settings: {} } + }); + assert.deepEqual(modelCalls, [{ model: 'dsh://deepseek/deepseek-chat', effort: 'high' }]); + assert.deepEqual(result, { result: {} }); +}); + +test('thread/settings/update refuses unsupported Codex-only settings when they are the actual requested change', async () => { + const adapter = bareAdapter(); + adapter.controllers.set('session-1', { + agent: { session: { header: { cwd: path.resolve('/workspace') } } } + }); + await assert.rejects( + () => adapter.threadSettingsUpdate({ + threadId: 'session-1', + collaborationMode: { mode: 'plan', settings: {} } + }), + /no equivalent public collaboration-mode/ + ); + await assert.rejects( + () => adapter.threadSettingsUpdate({ + threadId: 'session-1', + personality: 'friendly' + }), + /no equivalent public personality/ + ); +}); + test('turn/steer delegates only to the exact active DSH turn', () => { const adapter = bareAdapter(); const steered = []; From 3cd5a44b3d8c8ab9978e44ac13e7076ee13be04c Mon Sep 17 00:00:00 2001 From: code-new-bie <403466121@qq.com> Date: Wed, 19 Aug 2026 19:08:47 +0800 Subject: [PATCH 067/305] tui: apply minimal DSHX branding --- .../patches/codex/0002-dshx-branding.patch | 38 +++++++++++++++++++ 1 file changed, 38 insertions(+) create mode 100644 upstream/patches/codex/0002-dshx-branding.patch diff --git a/upstream/patches/codex/0002-dshx-branding.patch b/upstream/patches/codex/0002-dshx-branding.patch new file mode 100644 index 00000000..ed950523 --- /dev/null +++ b/upstream/patches/codex/0002-dshx-branding.patch @@ -0,0 +1,38 @@ +diff --git a/codex-rs/tui/src/history_cell/session.rs b/codex-rs/tui/src/history_cell/session.rs +--- a/codex-rs/tui/src/history_cell/session.rs ++++ b/codex-rs/tui/src/history_cell/session.rs +@@ -142,7 +142,7 @@ pub(crate) fn new_session_info( + Line::from(vec![ + " ".into(), + "/init".into(), +- " - create an AGENTS.md file with instructions for Codex".dim(), ++ " - create an AGENTS.md file with instructions for DeepSeek Harness".dim(), + ]), +@@ -152,7 +152,7 @@ pub(crate) fn new_session_info( + Line::from(vec![ + " ".into(), + "/permissions".into(), +- " - choose what Codex is allowed to do".dim(), ++ " - choose what DeepSeek Harness is allowed to do".dim(), + ]), +@@ -269,10 +269,10 @@ impl HistoryCell for SessionHeaderHistoryCell { + let make_row = |spans: Vec>| Line::from(spans); + +- // Title line rendered inside the box: ">_ OpenAI Codex (vX)" ++ // DSHX changes branding only; layout and interaction stay upstream Codex. + let title_spans: Vec> = vec![ + Span::from(">_ ").dim(), +- Span::from("OpenAI Codex").bold(), ++ Span::from("DeepSeek Harness").bold(), + Span::from(" ").dim(), + Span::from(format!("(v{})", self.version)).dim(), + ]; +@@ -335,7 +335,7 @@ impl HistoryCell for SessionHeaderHistoryCell { + + fn raw_lines(&self) -> Vec> { + let mut lines = vec![ +- Line::from(format!("OpenAI Codex (v{})", self.version)), ++ Line::from(format!("DeepSeek Harness (v{})", self.version)), + Line::from(format!( + "model: {}{}", + self.model, From 1707d035234686cc0d98b3bdfb78144bddfcc8c5 Mon Sep 17 00:00:00 2001 From: code-new-bie <403466121@qq.com> Date: Wed, 19 Aug 2026 19:15:09 +0800 Subject: [PATCH 068/305] feat: project DSH reasoning into Codex reasoning cells --- src/dsh/session-projector.mjs | 99 ++++++++++++++++++++++++++++++----- 1 file changed, 85 insertions(+), 14 deletions(-) diff --git a/src/dsh/session-projector.mjs b/src/dsh/session-projector.mjs index 65217578..fd44313a 100644 --- a/src/dsh/session-projector.mjs +++ b/src/dsh/session-projector.mjs @@ -13,6 +13,12 @@ function visibleText(message) { .join(''); } +function reasoningContent(message) { + return (message?.content ?? []) + .filter((block) => block?.type === 'reasoning' && typeof block.text === 'string') + .map((block) => block.text); +} + function toolResultCallId(data) { const sourceId = data?.message?.source?.kind === 'tool' ? data.message.source.callId : undefined; if (sourceId !== undefined) return sourceId; @@ -67,6 +73,7 @@ export class DshSessionProjector { this.toolPresentation = toolPresentation; this.turns = new Map(); this.assistantItems = new Map(); + this.reasoningItems = new Map(); this.tools = new Map(); this.currentTurn = null; this.latestHeader = null; @@ -123,8 +130,40 @@ export class DshSessionProjector { if (!Number.isInteger(turn) || !Number.isInteger(step) || !chunk) return []; const turnState = this.turns.get(turn); if (!turnState) return []; - if (chunk.type !== 'text-delta' || typeof chunk.text !== 'string') return []; + if (chunk.type === 'reasoning-delta' && typeof chunk.text === 'string') { + const key = `${turn}:${step}`; + let item = this.reasoningItems.get(key); + const events = []; + if (!item) { + item = { id: `dsh-reasoning-${turn}-${step}`, content: [] }; + this.reasoningItems.set(key, item); + events.push({ + method: 'item/started', + params: { + threadId: this.threadId, + turnId: turnState.id, + startedAtMs: Number.isFinite(event.time) ? event.time : nowMillis(), + item: { type: 'reasoning', id: item.id, summary: [], content: [] } + } + }); + } + const index = Number.isInteger(chunk.index) && chunk.index >= 0 ? chunk.index : 0; + item.content[index] = `${item.content[index] ?? ''}${chunk.text}`; + events.push({ + method: 'item/reasoning/textDelta', + params: { + threadId: this.threadId, + turnId: turnState.id, + itemId: item.id, + delta: chunk.text, + contentIndex: index + } + }); + return events; + } + + if (chunk.type !== 'text-delta' || typeof chunk.text !== 'string') return []; const key = `${turn}:${step}`; let item = this.assistantItems.get(key); const events = []; @@ -155,10 +194,40 @@ export class DshSessionProjector { const turnState = this.turns.get(turn); if (!turnState) return []; const key = `${turn}:${step}`; + const completedAtMs = Number.isFinite(event.time) ? event.time : nowMillis(); + const events = []; + + const finalReasoning = reasoningContent(message); + let reasoning = this.reasoningItems.get(key); + if (reasoning || finalReasoning.length > 0) { + if (!reasoning) { + reasoning = { id: `dsh-reasoning-${turn}-${step}`, content: [] }; + this.reasoningItems.set(key, reasoning); + events.push({ + method: 'item/started', + params: { + threadId: this.threadId, + turnId: turnState.id, + startedAtMs: completedAtMs, + item: { type: 'reasoning', id: reasoning.id, summary: [], content: [] } + } + }); + } + reasoning.content = finalReasoning.length > 0 ? finalReasoning : reasoning.content; + events.push({ + method: 'item/completed', + params: { + threadId: this.threadId, + turnId: turnState.id, + completedAtMs, + item: { type: 'reasoning', id: reasoning.id, summary: [], content: reasoning.content } + } + }); + } + const finalText = visibleText(message); let item = this.assistantItems.get(key); - const events = []; - if (!item) { + if (!item && finalText.length > 0) { item = { id: `dsh-assistant-${turn}-${step}`, text: '' }; this.assistantItems.set(key, item); events.push({ @@ -166,21 +235,23 @@ export class DshSessionProjector { params: { threadId: this.threadId, turnId: turnState.id, - startedAtMs: Number.isFinite(event.time) ? event.time : nowMillis(), + startedAtMs: completedAtMs, item: { type: 'agentMessage', id: item.id, text: '', phase: null, memoryCitation: null, delivery: null } } }); } - item.text = finalText; - events.push({ - method: 'item/completed', - params: { - threadId: this.threadId, - turnId: turnState.id, - completedAtMs: Number.isFinite(event.time) ? event.time : nowMillis(), - item: { type: 'agentMessage', id: item.id, text: finalText, phase: null, memoryCitation: null, delivery: null } - } - }); + if (item) { + item.text = finalText; + events.push({ + method: 'item/completed', + params: { + threadId: this.threadId, + turnId: turnState.id, + completedAtMs, + item: { type: 'agentMessage', id: item.id, text: finalText, phase: null, memoryCitation: null, delivery: null } + } + }); + } return events; } From e482e0b7c7c4684b13ef2170607af877edcaccc1 Mon Sep 17 00:00:00 2001 From: code-new-bie <403466121@qq.com> Date: Wed, 19 Aug 2026 19:15:48 +0800 Subject: [PATCH 069/305] feat: preserve DSH reasoning in resumed Codex history --- src/dsh/codex-shapes.mjs | 37 ++++++++++++++++++++++++++++--------- 1 file changed, 28 insertions(+), 9 deletions(-) diff --git a/src/dsh/codex-shapes.mjs b/src/dsh/codex-shapes.mjs index 431998dd..fbe84fbf 100644 --- a/src/dsh/codex-shapes.mjs +++ b/src/dsh/codex-shapes.mjs @@ -14,6 +14,12 @@ function visibleText(content) { .join(''); } +function visibleReasoning(content) { + return (content ?? []) + .filter((block) => block?.type === 'reasoning' && typeof block.text === 'string') + .map((block) => block.text); +} + function latestRequestConfig(events) { for (let index = (events?.length ?? 0) - 1; index >= 0; index -= 1) { const event = events[index]; @@ -228,14 +234,26 @@ export function dshTurnsFromSession({ ctx, agent, diagnostics = () => {} }) { const turn = turns.get(event.data?.turn); const message = event.data?.message; if (!turn || !message) break; - turn.items.push({ - type: 'agentMessage', - id: String(message.id ?? `dsh-assistant-${event.data.turn}-${event.data.step}`), - text: visibleText(message.content), - phase: null, - memoryCitation: null, - delivery: null - }); + const reasoning = visibleReasoning(message.content); + if (reasoning.length > 0) { + turn.items.push({ + type: 'reasoning', + id: `dsh-reasoning-${event.data.turn}-${event.data.step}`, + summary: [], + content: reasoning + }); + } + const text = visibleText(message.content); + if (text.length > 0) { + turn.items.push({ + type: 'agentMessage', + id: String(message.id ?? `dsh-assistant-${event.data.turn}-${event.data.step}`), + text, + phase: null, + memoryCitation: null, + delivery: null + }); + } break; } case 'tool/call': { @@ -293,5 +311,6 @@ export const internals = { DEFAULT_REASONING_SENTINEL, latestRequestConfig, firstHumanPreview, - inputFromDshMessage + inputFromDshMessage, + visibleReasoning }; From 656db5f72112f616385163cf1ea4fed94a3f3e44 Mon Sep 17 00:00:00 2001 From: code-new-bie <403466121@qq.com> Date: Wed, 19 Aug 2026 19:16:35 +0800 Subject: [PATCH 070/305] test: cover native Codex reasoning projection --- test/dsh-session-projector.test.mjs | 47 ++++++++++++++++++++++++++--- 1 file changed, 42 insertions(+), 5 deletions(-) diff --git a/test/dsh-session-projector.test.mjs b/test/dsh-session-projector.test.mjs index af9023fa..0c55aa67 100644 --- a/test/dsh-session-projector.test.mjs +++ b/test/dsh-session-projector.test.mjs @@ -39,20 +39,57 @@ test('projects visible DSH text chunks into one Codex agent message item', () => event('assistant/message', { turn: 1, step: 1, - message: { content: [{ type: 'text', text: 'hello' }, { type: 'reasoning', text: 'private-ish presentation' }] } + message: { content: [{ type: 'text', text: 'hello' }] } }) ); assert.equal(completed[0].method, 'item/completed'); assert.equal(completed[0].params.item.text, 'hello'); }); -test('does not guess Codex reasoning semantics before they are explicitly mapped', () => { +test('projects DSH reasoning chunks into a distinct native Codex reasoning item', () => { const projector = new DshSessionProjector({ threadId: 'session-1' }); start(projector); - const output = projector.project( - event('assistant/chunk', { turn: 1, step: 1, chunk: { type: 'reasoning-delta', index: 0, text: 'thinking' } }) + + const first = projector.project( + event('assistant/chunk', { turn: 1, step: 1, chunk: { type: 'reasoning-delta', index: 0, text: 'think' } }) ); - assert.deepEqual(output, []); + assert.deepEqual(first.map((entry) => entry.method), ['item/started', 'item/reasoning/textDelta']); + assert.deepEqual(first[0].params.item, { + type: 'reasoning', + id: 'dsh-reasoning-1-1', + summary: [], + content: [] + }); + assert.equal(first[1].params.contentIndex, 0); + assert.equal(first[1].params.delta, 'think'); + + const second = projector.project( + event('assistant/chunk', { turn: 1, step: 1, chunk: { type: 'reasoning-delta', index: 0, text: 'ing' } }) + ); + assert.deepEqual(second.map((entry) => entry.method), ['item/reasoning/textDelta']); + + const completed = projector.project(event('assistant/message', { + turn: 1, + step: 1, + message: { + content: [ + { type: 'reasoning', text: 'thinking' }, + { type: 'text', text: 'answer' } + ] + } + })); + assert.deepEqual(completed.map((entry) => entry.method), [ + 'item/completed', + 'item/started', + 'item/completed' + ]); + assert.deepEqual(completed[0].params.item, { + type: 'reasoning', + id: 'dsh-reasoning-1-1', + summary: [], + content: ['thinking'] + }); + assert.equal(completed[2].params.item.text, 'answer'); }); test('projects unknown DSH tools conservatively and correlates official tool/result message source', () => { From 2b32e08ef56410f875bf7f2c1e287c5263e106a8 Mon Sep 17 00:00:00 2001 From: code-new-bie <403466121@qq.com> Date: Wed, 19 Aug 2026 19:17:11 +0800 Subject: [PATCH 071/305] test: preserve reasoning in resumed session history --- test/dsh-codex-shapes.test.mjs | 20 ++++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/test/dsh-codex-shapes.test.mjs b/test/dsh-codex-shapes.test.mjs index 4a5c28eb..ce9cf5ee 100644 --- a/test/dsh-codex-shapes.test.mjs +++ b/test/dsh-codex-shapes.test.mjs @@ -73,7 +73,7 @@ test('persisted SessionHeader and request header project to a not-loaded Codex t assert.equal(thread.cwd, '/work'); }); -test('history projection includes only direct human input and stable assistant/tool items', () => { +test('history projection includes only direct human input and stable assistant/tool/reasoning items', () => { const definitions = { shell: { presentCall: () => ({ card: 'terminal', title: 'echo ok', cwd: '/work' }), @@ -124,7 +124,13 @@ test('history projection includes only direct human input and stable assistant/t data: { turn: 1, step: 1, - message: { id: 'a1', content: [{ type: 'text', text: 'done' }] } + message: { + id: 'a1', + content: [ + { type: 'reasoning', text: 'checked the command result' }, + { type: 'text', text: 'done' } + ] + } } }, { type: 'turn/end', time: 1_700_000_000_050, data: { turn: 1, reason: { kind: 'completed' } } } @@ -134,8 +140,14 @@ test('history projection includes only direct human input and stable assistant/t const turns = dshTurnsFromSession({ ctx: agent.ctx, agent }); assert.equal(turns.length, 1); assert.equal(turns[0].status, 'completed'); - assert.deepEqual(turns[0].items.map((item) => item.type), ['userMessage', 'commandExecution', 'agentMessage']); + assert.deepEqual(turns[0].items.map((item) => item.type), [ + 'userMessage', + 'commandExecution', + 'reasoning', + 'agentMessage' + ]); assert.equal(turns[0].items[0].content[0].text, 'run it'); assert.equal(turns[0].items[1].aggregatedOutput, 'ok\n'); - assert.equal(turns[0].items[2].text, 'done'); + assert.deepEqual(turns[0].items[2].content, ['checked the command result']); + assert.equal(turns[0].items[3].text, 'done'); }); From a253af5073e5d9a1481cc2602b0161d17543397b Mon Sep 17 00:00:00 2001 From: code-new-bie <403466121@qq.com> Date: Wed, 19 Aug 2026 19:19:31 +0800 Subject: [PATCH 072/305] tui: hide approval grants DSH cannot honor --- .../codex/0003-dshx-approval-semantics.patch | 91 +++++++++++++++++++ 1 file changed, 91 insertions(+) create mode 100644 upstream/patches/codex/0003-dshx-approval-semantics.patch diff --git a/upstream/patches/codex/0003-dshx-approval-semantics.patch b/upstream/patches/codex/0003-dshx-approval-semantics.patch new file mode 100644 index 00000000..a54067df --- /dev/null +++ b/upstream/patches/codex/0003-dshx-approval-semantics.patch @@ -0,0 +1,91 @@ +diff --git a/codex-rs/tui/src/bottom_pane/approval_overlay.rs b/codex-rs/tui/src/bottom_pane/approval_overlay.rs +--- a/codex-rs/tui/src/bottom_pane/approval_overlay.rs ++++ b/codex-rs/tui/src/bottom_pane/approval_overlay.rs +@@ -796,6 +796,10 @@ struct ApprovalOption { + shortcuts: Vec, + } + ++fn dshx_backend() -> bool { ++ std::env::var_os("DSHX_APP_SERVER_ENDPOINT").is_some() ++} ++ + fn command_decision_to_review_decision( + decision: &CommandExecutionApprovalDecision, + ) -> ReviewDecision { +@@ -855,6 +859,7 @@ fn exec_options( + }) + } ++ CommandExecutionApprovalDecision::AcceptForSession if dshx_backend() => None, + CommandExecutionApprovalDecision::AcceptForSession => Some(ApprovalOption { + label: if network_approval_context.is_some() { + "Yes, and allow this host for this conversation".to_string() +@@ -951,24 +956,27 @@ fn patch_options(keymap: &ApprovalKeymap) -> Vec { +- vec![ ++ let mut options = vec![ + ApprovalOption { + label: "Yes, proceed".to_string(), + decision: ApprovalDecision::FileChange(FileChangeApprovalDecision::Accept), + shortcuts: keymap.approve.clone(), + }, +- ApprovalOption { +- label: "Yes, and don't ask again for these files".to_string(), +- decision: ApprovalDecision::FileChange(FileChangeApprovalDecision::AcceptForSession), +- shortcuts: keymap.approve_for_session.clone(), +- }, + ApprovalOption { +- label: "No, and tell Codex what to do differently".to_string(), ++ label: if dshx_backend() { ++ "No, and tell DeepSeek Harness what to do differently".to_string() ++ } else { ++ "No, and tell Codex what to do differently".to_string() ++ }, + decision: ApprovalDecision::FileChange(FileChangeApprovalDecision::Cancel), + shortcuts: keymap.decline.clone(), + }, +- ] ++ ]; ++ if !dshx_backend() { ++ options.insert(1, ApprovalOption { ++ label: "Yes, and don't ask again for these files".to_string(), ++ decision: ApprovalDecision::FileChange(FileChangeApprovalDecision::AcceptForSession), ++ shortcuts: keymap.approve_for_session.clone(), ++ }); ++ } ++ options + } + + fn permissions_options(keymap: &ApprovalKeymap) -> Vec { +@@ -978,7 +986,7 @@ fn permissions_options(keymap: &ApprovalKeymap) -> Vec { + .filter(|shortcut| shortcut.parts() != (KeyCode::Esc, KeyModifiers::NONE)) + .collect(); + +- vec![ ++ let mut options = vec![ + ApprovalOption { + label: "Yes, grant these permissions for this turn".to_string(), + decision: ApprovalDecision::Permissions(PermissionsDecision::GrantForTurn), +@@ -991,16 +999,19 @@ fn permissions_options(keymap: &ApprovalKeymap) -> Vec { + ), + shortcuts: vec![key_hint::plain(KeyCode::Char('r'))], + }, +- ApprovalOption { +- label: "Yes, grant these permissions for this session".to_string(), +- decision: ApprovalDecision::Permissions(PermissionsDecision::GrantForSession), +- shortcuts: keymap.approve_for_session.clone(), +- }, + ApprovalOption { + label: "No, continue without permissions".to_string(), + decision: ApprovalDecision::Permissions(PermissionsDecision::Deny), + shortcuts: deny_shortcuts, + }, +- ] ++ ]; ++ if !dshx_backend() { ++ options.insert(2, ApprovalOption { ++ label: "Yes, grant these permissions for this session".to_string(), ++ decision: ApprovalDecision::Permissions(PermissionsDecision::GrantForSession), ++ shortcuts: keymap.approve_for_session.clone(), ++ }); ++ } ++ options + } From f5d55ddd2f05bdb1f299aaa07e4488f437336a4e Mon Sep 17 00:00:00 2001 From: code-new-bie <403466121@qq.com> Date: Wed, 19 Aug 2026 19:21:30 +0800 Subject: [PATCH 073/305] tui: keep DSHX permission picker inside DSH ownership --- .../codex/0004-dshx-permission-picker.patch | 33 +++++++++++++++++++ 1 file changed, 33 insertions(+) create mode 100644 upstream/patches/codex/0004-dshx-permission-picker.patch diff --git a/upstream/patches/codex/0004-dshx-permission-picker.patch b/upstream/patches/codex/0004-dshx-permission-picker.patch new file mode 100644 index 00000000..7cffb9b5 --- /dev/null +++ b/upstream/patches/codex/0004-dshx-permission-picker.patch @@ -0,0 +1,33 @@ +diff --git a/codex-rs/tui/src/chatwidget/permission_popups.rs b/codex-rs/tui/src/chatwidget/permission_popups.rs +--- a/codex-rs/tui/src/chatwidget/permission_popups.rs ++++ b/codex-rs/tui/src/chatwidget/permission_popups.rs +@@ -5,6 +5,10 @@ use super::*; + use codex_protocol::openai_models::MODEL_SPECIALTY_CYBER; + ++fn dshx_backend() -> bool { ++ std::env::var_os("DSHX_APP_SERVER_ENDPOINT").is_some() ++} ++ + impl ChatWidget { +@@ -20,7 +24,7 @@ impl ChatWidget { + return; + } + +- let include_read_only = cfg!(target_os = "windows"); ++ let include_read_only = cfg!(target_os = "windows") && !dshx_backend(); + let current_approval = + AskForApproval::from(self.config.permissions.approval_policy.value()); + let current_permission_profile = self.config.permissions.permission_profile().clone(); +- let guardian_approval_enabled = self.config.features.enabled(Feature::GuardianApproval); ++ let guardian_approval_enabled = !dshx_backend() ++ && self.config.features.enabled(Feature::GuardianApproval); +@@ -292,7 +296,10 @@ impl ChatWidget { +- if approvals_reviewer == ApprovalsReviewer::User && preset.id == "auto" { ++ // DSHX never enables or configures Codex's local Windows sandbox. The ++ // selected workspace preset is sent to the DSH app-server adapter and ++ // DSH remains the enforcement owner. ++ if !dshx_backend() ++ && approvals_reviewer == ApprovalsReviewer::User && preset.id == "auto" { + #[cfg(target_os = "windows")] + { + if crate::windows_sandbox::level_from_config(&self.config) From cf0e7d7386eada60f8d6dbd9a87f08992ce3e356 Mon Sep 17 00:00:00 2001 From: code-new-bie <403466121@qq.com> Date: Wed, 19 Aug 2026 19:23:09 +0800 Subject: [PATCH 074/305] docs: add upstream redistribution notice --- NOTICE | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) create mode 100644 NOTICE diff --git a/NOTICE b/NOTICE new file mode 100644 index 00000000..33df6516 --- /dev/null +++ b/NOTICE @@ -0,0 +1,27 @@ +DSHX / dsh-codex +Copyright 2026 dsh-codex contributors + +This distribution includes a modified build of OpenAI Codex TUI sourced from: +https://github.com/openai/codex +Pinned source commit: see upstream/CODEX_COMMIT + +OpenAI Codex +Copyright 2025 OpenAI +Licensed under the Apache License, Version 2.0. + +The upstream OpenAI Codex NOTICE states: + +OpenAI Codex +Copyright 2025 OpenAI + +This project includes code derived from Ratatui (https://github.com/ratatui/ratatui), licensed under the MIT license. +Copyright (c) 2016-2022 Florian Dehau +Copyright (c) 2023-2025 The Ratatui Developers + +DSHX modifies only a small presentation/launcher seam in the pinned Codex TUI. +The ordered source patches are distributed under upstream/patches/codex/. + +DeepSeek Harness is not forked or vendored by DSHX. It is installed as an +upstream npm dependency and remains the authoritative runtime for Agent, +Session, model routing, tools, sandboxing, approvals, skills and related +capabilities. Its own package license and notices remain with that dependency. From 732f7dc55ca16edc4693babe0aec323abc605b36 Mon Sep 17 00:00:00 2001 From: code-new-bie <403466121@qq.com> Date: Wed, 19 Aug 2026 19:23:58 +0800 Subject: [PATCH 075/305] release: build installable platform tarball --- scripts/package-platform.mjs | 94 ++++++++++++++++++++++++++++++++++++ 1 file changed, 94 insertions(+) create mode 100644 scripts/package-platform.mjs diff --git a/scripts/package-platform.mjs b/scripts/package-platform.mjs new file mode 100644 index 00000000..91829f0c --- /dev/null +++ b/scripts/package-platform.mjs @@ -0,0 +1,94 @@ +#!/usr/bin/env node +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import process from 'node:process'; +import { execFileSync } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const rootPackage = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8')); +const version = process.env.DSHX_VERSION || rootPackage.version; +const platform = process.platform; +const arch = process.arch; +const exe = platform === 'win32' ? 'dshx-tui.exe' : 'dshx-tui'; +const tuiSource = path.join(root, 'dist', 'bin', exe); +if (!fs.existsSync(tuiSource)) { + throw new Error(`Missing built TUI at ${tuiSource}; build the pinned Codex TUI first`); +} + +const releaseRoot = path.join(root, '.release'); +const stage = path.join(releaseRoot, `dshx-${platform}-${arch}`); +const out = path.join(root, 'dist', 'release'); +fs.rmSync(stage, { recursive: true, force: true }); +fs.mkdirSync(stage, { recursive: true }); +fs.mkdirSync(out, { recursive: true }); + +function copy(relative) { + const from = path.join(root, relative); + const to = path.join(stage, relative); + fs.mkdirSync(path.dirname(to), { recursive: true }); + fs.cpSync(from, to, { recursive: true }); +} + +copy('bin/dshx.mjs'); +copy('src/dsh'); +copy('NOTICE'); +copy('upstream/CODEX_COMMIT'); +copy('upstream/DSH_COMMIT'); +copy('upstream/patches/codex'); + +fs.mkdirSync(path.join(stage, 'dist', 'bin'), { recursive: true }); +fs.copyFileSync(tuiSource, path.join(stage, 'dist', 'bin', exe)); +if (platform !== 'win32') fs.chmodSync(path.join(stage, 'dist', 'bin', exe), 0o755); + +const codexLicense = path.join(root, '.upstream', 'codex', 'LICENSE'); +if (!fs.existsSync(codexLicense)) { + throw new Error('Pinned Codex LICENSE is missing; materialize Codex before packaging'); +} +fs.copyFileSync(codexLicense, path.join(stage, 'LICENSE')); + +const packageJson = { + name: 'dsh-codex', + version, + description: 'Codex-grade TUI presentation for official DeepSeek Harness', + type: 'module', + license: 'Apache-2.0', + os: [platform], + cpu: [arch], + engines: { node: '>=20' }, + bin: { dshx: './bin/dshx.mjs' }, + dependencies: rootPackage.dependencies, + repository: { type: 'git', url: 'https://github.com/code-new-bie/dsh-codex.git' }, + files: ['bin', 'src/dsh', 'dist/bin', 'upstream', 'LICENSE', 'NOTICE'] +}; +fs.writeFileSync(path.join(stage, 'package.json'), `${JSON.stringify(packageJson, null, 2)}\n`); + +const packOutput = execFileSync( + process.platform === 'win32' ? 'npm.cmd' : 'npm', + ['pack', stage, '--pack-destination', out, '--json'], + { cwd: root, encoding: 'utf8', stdio: ['ignore', 'pipe', 'inherit'] } +); +const packed = JSON.parse(packOutput); +const filename = packed[0]?.filename; +if (!filename) throw new Error(`npm pack did not return a filename: ${packOutput}`); +const sourceTarball = path.join(out, filename); +const targetTarball = path.join(out, `dshx-${version}-${platform}-${arch}.tgz`); +if (path.resolve(sourceTarball) !== path.resolve(targetTarball)) { + fs.rmSync(targetTarball, { force: true }); + fs.renameSync(sourceTarball, targetTarball); +} + +const metadata = { + version, + platform, + arch, + node: process.version, + codexCommit: fs.readFileSync(path.join(root, 'upstream', 'CODEX_COMMIT'), 'utf8').trim(), + dshCommit: fs.readFileSync(path.join(root, 'upstream', 'DSH_COMMIT'), 'utf8').trim(), + tarball: path.basename(targetTarball), + builtAt: new Date().toISOString(), + host: os.hostname() +}; +fs.writeFileSync(`${targetTarball}.json`, `${JSON.stringify(metadata, null, 2)}\n`); +process.stdout.write(`${targetTarball}\n`); From 68dba57b2d3f449d2ee3a10b0d8a6082856e358f Mon Sep 17 00:00:00 2001 From: code-new-bie <403466121@qq.com> Date: Wed, 19 Aug 2026 19:24:47 +0800 Subject: [PATCH 076/305] ci: build install and smoke platform dshx package --- .github/workflows/ci.yml | 75 ++++++++++++++++++++++++++++++---------- 1 file changed, 57 insertions(+), 18 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3823606f..a84f1b8e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -31,25 +31,30 @@ jobs: - run: node --check src/dsh/local-server.mjs - run: node --check src/dsh/runtime-boot.mjs - run: node --check bin/dshx.mjs + - run: node --check scripts/package-platform.mjs - codex-tui: - name: Pinned Codex TUI / ${{ matrix.os }} + codex-tui-package: + name: Pinned TUI + package / ${{ matrix.os }} runs-on: ${{ matrix.os }} + timeout-minutes: 60 strategy: fail-fast: false matrix: os: [ubuntu-latest, windows-latest] steps: - uses: actions/checkout@v4 - - name: Materialize pinned Codex (Unix) + - uses: actions/setup-node@v4 + with: + node-version: 20 + - name: Build pinned Codex TUI (Unix) if: runner.os != 'Windows' shell: bash - run: bash scripts/materialize-codex.sh - - name: Materialize pinned Codex (Windows) + run: bash scripts/build-codex-tui.sh + - name: Build pinned Codex TUI (Windows) if: runner.os == 'Windows' shell: pwsh - run: ./scripts/materialize-codex.ps1 - - name: Assert no Codex-core fallback (Unix) + run: ./scripts/build-codex-tui.ps1 + - name: Assert thin-fork invariants (Unix) if: runner.os != 'Windows' shell: bash run: | @@ -57,25 +62,59 @@ jobs: grep -q 'DSHX_APP_SERVER_TOKEN is required' .upstream/codex/codex-rs/tui/src/main.rs grep -q 'Some(dshx_endpoint)' .upstream/codex/codex-rs/tui/src/main.rs grep -q 'DSHX_RESUME_MODE' .upstream/codex/codex-rs/tui/src/main.rs - - name: Assert no Codex-core fallback (Windows) + grep -q 'DeepSeek Harness' .upstream/codex/codex-rs/tui/src/history_cell/session.rs + grep -q 'fn dshx_backend' .upstream/codex/codex-rs/tui/src/bottom_pane/approval_overlay.rs + grep -q 'fn dshx_backend' .upstream/codex/codex-rs/tui/src/chatwidget/permission_popups.rs + - name: Assert thin-fork invariants (Windows) if: runner.os == 'Windows' shell: pwsh run: | - $source = Get-Content .upstream/codex/codex-rs/tui/src/main.rs -Raw - if (-not $source.Contains('DSHX_APP_SERVER_ENDPOINT is required')) { throw 'missing DSHX endpoint guard' } - if (-not $source.Contains('DSHX_APP_SERVER_TOKEN is required')) { throw 'missing DSHX token guard' } - if (-not $source.Contains('Some(dshx_endpoint)')) { throw 'Codex core fallback was not removed' } - if (-not $source.Contains('DSHX_RESUME_MODE')) { throw 'DSHX resume patch is missing' } - - name: Cargo check pinned TUI + $main = Get-Content .upstream\codex\codex-rs\tui\src\main.rs -Raw + if (-not $main.Contains('DSHX_APP_SERVER_ENDPOINT is required')) { throw 'missing DSHX endpoint guard' } + if (-not $main.Contains('DSHX_APP_SERVER_TOKEN is required')) { throw 'missing DSHX token guard' } + if (-not $main.Contains('Some(dshx_endpoint)')) { throw 'Codex core fallback was not removed' } + if (-not $main.Contains('DSHX_RESUME_MODE')) { throw 'DSHX resume patch is missing' } + $header = Get-Content .upstream\codex\codex-rs\tui\src\history_cell\session.rs -Raw + if (-not $header.Contains('DeepSeek Harness')) { throw 'DSHX branding patch is missing' } + $approval = Get-Content .upstream\codex\codex-rs\tui\src\bottom_pane\approval_overlay.rs -Raw + if (-not $approval.Contains('fn dshx_backend')) { throw 'DSHX approval semantics patch is missing' } + $permissions = Get-Content .upstream\codex\codex-rs\tui\src\chatwidget\permission_popups.rs -Raw + if (-not $permissions.Contains('fn dshx_backend')) { throw 'DSHX permission picker patch is missing' } + - name: Build installable platform tarball shell: bash if: runner.os != 'Windows' - run: CARGO_TARGET_DIR="$GITHUB_WORKSPACE/.build/codex" cargo check --manifest-path .upstream/codex/codex-rs/Cargo.toml --locked -p codex-tui --bin codex-tui - - name: Cargo check pinned TUI (Windows) + run: node scripts/package-platform.mjs + - name: Build installable platform tarball (Windows) shell: pwsh if: runner.os == 'Windows' + run: node scripts/package-platform.mjs + - name: Clean-install smoke (Unix) + if: runner.os != 'Windows' + shell: bash + run: | + set -euo pipefail + TGZ="$(find dist/release -maxdepth 1 -name 'dshx-*.tgz' -print -quit)" + PREFIX="$RUNNER_TEMP/dshx-smoke" + npm install --global --prefix "$PREFIX" "$TGZ" + "$PREFIX/bin/dshx" --version + "$PREFIX/bin/dshx" doctor + - name: Clean-install smoke (Windows) + if: runner.os == 'Windows' + shell: pwsh run: | - $env:CARGO_TARGET_DIR = Join-Path $env:GITHUB_WORKSPACE '.build\codex' - cargo check --manifest-path .upstream\codex\codex-rs\Cargo.toml --locked -p codex-tui --bin codex-tui + $tgz = Get-ChildItem dist\release\dshx-*.tgz | Select-Object -First 1 + if (-not $tgz) { throw 'platform tarball missing' } + $prefix = Join-Path $env:RUNNER_TEMP 'dshx-smoke' + npm install --global --prefix $prefix $tgz.FullName + & (Join-Path $prefix 'dshx.cmd') --version + & (Join-Path $prefix 'dshx.cmd') doctor + - uses: actions/upload-artifact@v4 + with: + name: dshx-${{ runner.os }}-${{ runner.arch }} + path: | + dist/release/*.tgz + dist/release/*.json + if-no-files-found: error ownership-guard: name: Ownership boundary From 93c2e14b50409a8b0a9c0c31606d551b39fdff26 Mon Sep 17 00:00:00 2001 From: code-new-bie <403466121@qq.com> Date: Wed, 19 Aug 2026 19:25:31 +0800 Subject: [PATCH 077/305] release: add reproducible multi-platform DSHX artifacts --- .github/workflows/release.yml | 100 ++++++++++++++++++++++++++++++++++ 1 file changed, 100 insertions(+) create mode 100644 .github/workflows/release.yml diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 00000000..3aafc510 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,100 @@ +name: Release + +on: + push: + tags: + - 'v*' + +permissions: + contents: write + +jobs: + build: + name: Build / ${{ matrix.os }} + runs-on: ${{ matrix.os }} + timeout-minutes: 75 + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, windows-latest, macos-13] + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 20 + - name: Resolve release version (Unix) + if: runner.os != 'Windows' + shell: bash + run: echo "DSHX_VERSION=${GITHUB_REF_NAME#v}" >> "$GITHUB_ENV" + - name: Resolve release version (Windows) + if: runner.os == 'Windows' + shell: pwsh + run: Add-Content -Path $env:GITHUB_ENV -Value "DSHX_VERSION=$($env:GITHUB_REF_NAME.TrimStart('v'))" + - name: Test presentation adapter + run: | + npm install + npm test + - name: Build pinned TUI and package (Unix) + if: runner.os != 'Windows' + shell: bash + run: | + bash scripts/build-codex-tui.sh + node scripts/package-platform.mjs + - name: Build pinned TUI and package (Windows) + if: runner.os == 'Windows' + shell: pwsh + run: | + ./scripts/build-codex-tui.ps1 + node scripts/package-platform.mjs + - name: Clean install smoke (Unix) + if: runner.os != 'Windows' + shell: bash + run: | + set -euo pipefail + TGZ="$(find dist/release -maxdepth 1 -name 'dshx-*.tgz' -print -quit)" + PREFIX="$RUNNER_TEMP/dshx-release-smoke" + npm install --global --prefix "$PREFIX" "$TGZ" + "$PREFIX/bin/dshx" --version | grep -F "$DSHX_VERSION" + "$PREFIX/bin/dshx" doctor + - name: Clean install smoke (Windows) + if: runner.os == 'Windows' + shell: pwsh + run: | + $tgz = Get-ChildItem dist\release\dshx-*.tgz | Select-Object -First 1 + $prefix = Join-Path $env:RUNNER_TEMP 'dshx-release-smoke' + npm install --global --prefix $prefix $tgz.FullName + $version = & (Join-Path $prefix 'dshx.cmd') --version + if (-not ($version -match [regex]::Escape($env:DSHX_VERSION))) { throw "wrong dshx version: $version" } + & (Join-Path $prefix 'dshx.cmd') doctor + - uses: actions/upload-artifact@v4 + with: + name: dshx-${{ runner.os }}-${{ runner.arch }} + path: | + dist/release/*.tgz + dist/release/*.json + if-no-files-found: error + + publish: + name: Publish GitHub release + needs: build + runs-on: ubuntu-latest + steps: + - uses: actions/download-artifact@v4 + with: + path: artifacts + merge-multiple: true + - name: Create checksums + shell: bash + run: | + cd artifacts + sha256sum dshx-*.tgz > SHA256SUMS + cat SHA256SUMS + - name: Create immutable GitHub release + env: + GH_TOKEN: ${{ github.token }} + shell: bash + run: | + set -euo pipefail + flags=(--verify-tag --generate-notes) + if [[ "$GITHUB_REF_NAME" == *-* ]]; then flags+=(--prerelease); fi + gh release create "$GITHUB_REF_NAME" artifacts/* "${flags[@]}" From 2f0f5a45394c6b3f53a310ddf7281e5bf443a7f4 Mon Sep 17 00:00:00 2001 From: code-new-bie <403466121@qq.com> Date: Wed, 19 Aug 2026 19:26:15 +0800 Subject: [PATCH 078/305] fix: derive CLI version from installed package --- bin/dshx.mjs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/bin/dshx.mjs b/bin/dshx.mjs index 7651d10b..565b1dc3 100644 --- a/bin/dshx.mjs +++ b/bin/dshx.mjs @@ -6,8 +6,9 @@ import { spawn, spawnSync } from 'node:child_process'; import { fileURLToPath } from 'node:url'; import { startDshxLocalServer } from '../src/dsh/local-server.mjs'; -const VERSION = '0.1.0-dev'; const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const PACKAGE = JSON.parse(fs.readFileSync(path.join(ROOT, 'package.json'), 'utf8')); +const VERSION = PACKAGE.version; function usage() { return `DSHX ${VERSION}\n\nUsage:\n dshx Start DSHX in the current project\n dshx Start with an initial prompt\n dshx resume Open the Codex-style DSH session picker\n dshx resume --last Resume the most recent DSH session\n dshx resume Resume a specific DSH session\n dshx doctor Check runtime/TUI prerequisites\n dshx --version Print version\n dshx --help Show this help\n\nEnvironment:\n DSHX_TUI_BIN Override the packaged DSHX TUI binary (development only)\n DSHX_DEBUG=1 Print DSHX adapter diagnostics\n\nProduct boundary:\n DSHX owns only the launcher, Codex TUI thin fork and presentation adapter.\n DeepSeek Harness remains the authoritative Agent/Session/Tool runtime.\n`; From 9e673d43f40b4320449a57acce6d5a7b02584c70 Mon Sep 17 00:00:00 2001 From: code-new-bie <403466121@qq.com> Date: Wed, 19 Aug 2026 19:27:40 +0800 Subject: [PATCH 079/305] test: allow authenticated protocol stub for real TUI smoke --- src/server.mjs | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/src/server.mjs b/src/server.mjs index 62ed2f59..2250fde7 100644 --- a/src/server.mjs +++ b/src/server.mjs @@ -1,3 +1,4 @@ +import crypto from 'node:crypto'; import { WebSocketServer } from 'ws'; import { ProtocolStub, normalizeDispatchResult } from './protocol.mjs'; @@ -12,6 +13,13 @@ function sendJson(socket, message) { if (socket.readyState === 1) socket.send(JSON.stringify(message)); } +function tokenMatches(header, token) { + if (!token) return true; + const left = Buffer.from(typeof header === 'string' ? header : '', 'utf8'); + const right = Buffer.from(`Bearer ${token}`, 'utf8'); + return left.length === right.length && crypto.timingSafeEqual(left, right); +} + async function sendEvents(socket, events, delayMs, stub) { for (const event of events) { const threadId = event?.params?.threadId; @@ -41,10 +49,17 @@ export async function startProtocolStubServer({ port = 0, cwd = process.cwd(), eventDelayMs = DEFAULT_EVENT_DELAY_MS, + token, log = () => {} } = {}) { const stub = new ProtocolStub({ cwd }); - const server = new WebSocketServer({ host, port }); + const server = new WebSocketServer({ + host, + port, + verifyClient: token + ? (info, done) => done(tokenMatches(info.req.headers.authorization, token), 401, 'Unauthorized') + : undefined + }); server.on('connection', (socket, request) => { log(`client connected from ${request.socket.remoteAddress ?? 'unknown'}`); @@ -94,6 +109,7 @@ export async function startProtocolStubServer({ host, port: address.port, url: `ws://${host}:${address.port}`, + token, close: () => new Promise((resolve, reject) => { server.close((error) => (error ? reject(error) : resolve())); From 1773ae48179fc983bf2fb492666a751c52f62207 Mon Sep 17 00:00:00 2001 From: code-new-bie <403466121@qq.com> Date: Wed, 19 Aug 2026 19:27:55 +0800 Subject: [PATCH 080/305] test: expose authenticated stub endpoint for TUI smoke --- bin/dshx-stub-server.mjs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/bin/dshx-stub-server.mjs b/bin/dshx-stub-server.mjs index ac1bfaa6..0ae2602a 100644 --- a/bin/dshx-stub-server.mjs +++ b/bin/dshx-stub-server.mjs @@ -1,7 +1,9 @@ #!/usr/bin/env node import { startProtocolStubServer } from '../src/server.mjs'; +const token = process.env.DSHX_STUB_TOKEN || undefined; const server = await startProtocolStubServer({ + token, log: (message) => process.stderr.write(`[dshx-stub] ${message}\n`) }); From be8ed790b4b712b1a9a8a6f037dbdc2eb15bc972 Mon Sep 17 00:00:00 2001 From: code-new-bie <403466121@qq.com> Date: Wed, 19 Aug 2026 19:28:35 +0800 Subject: [PATCH 081/305] test: add real pinned Codex TUI PTY smoke --- test/tui_smoke.py | 69 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 69 insertions(+) create mode 100644 test/tui_smoke.py diff --git a/test/tui_smoke.py b/test/tui_smoke.py new file mode 100644 index 00000000..34239ea9 --- /dev/null +++ b/test/tui_smoke.py @@ -0,0 +1,69 @@ +#!/usr/bin/env python3 +import os +import pathlib +import subprocess +import tempfile + +import pexpect + +ROOT = pathlib.Path(__file__).resolve().parents[1] +TOKEN = "dshx-pty-smoke-token-0123456789abcdefghijklmnopqrstuvwxyz" + +server_env = os.environ.copy() +server_env["DSHX_STUB_TOKEN"] = TOKEN +server = subprocess.Popen( + ["node", "bin/dshx-stub-server.mjs"], + cwd=ROOT, + env=server_env, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, +) + +try: + endpoint = server.stdout.readline().strip() + if not endpoint.startswith("ws://127.0.0.1:"): + stderr = server.stderr.read() + raise RuntimeError(f"stub did not produce a loopback endpoint: {endpoint!r}\n{stderr}") + + binary = ROOT / "dist" / "bin" / "dshx-tui" + if not binary.exists(): + raise RuntimeError(f"built TUI missing: {binary}") + + with tempfile.TemporaryDirectory(prefix="dshx-codex-home-") as codex_home: + env = os.environ.copy() + env.update({ + "TERM": "xterm-256color", + "CODEX_HOME": codex_home, + "DSHX_APP_SERVER_ENDPOINT": endpoint, + "DSHX_APP_SERVER_TOKEN": TOKEN, + }) + child = pexpect.spawn( + str(binary), + cwd=str(ROOT), + env=env, + encoding="utf-8", + timeout=30, + dimensions=(32, 120), + ) + transcript = [] + try: + child.expect("DeepSeek Harness") + transcript.append(child.before + child.after) + child.send("smoke from pty\r") + child.expect("DSHX protocol stub received:") + transcript.append(child.before + child.after) + child.expect("smoke from pty") + transcript.append(child.before + child.after) + except Exception: + transcript.append(child.before or "") + raise AssertionError("Pinned DSHX TUI smoke failed. Transcript:\n" + "".join(transcript)) + finally: + child.close(force=True) +finally: + server.terminate() + try: + server.wait(timeout=5) + except subprocess.TimeoutExpired: + server.kill() + server.wait(timeout=5) From aff695898339514f05343bd5172c621b2aaea9ea Mon Sep 17 00:00:00 2001 From: code-new-bie <403466121@qq.com> Date: Wed, 19 Aug 2026 19:31:31 +0800 Subject: [PATCH 082/305] test: boot and dispose official DSH composition --- scripts/runtime-smoke.mjs | 34 ++++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) create mode 100644 scripts/runtime-smoke.mjs diff --git a/scripts/runtime-smoke.mjs b/scripts/runtime-smoke.mjs new file mode 100644 index 00000000..08e2e2ce --- /dev/null +++ b/scripts/runtime-smoke.mjs @@ -0,0 +1,34 @@ +#!/usr/bin/env node +import process from 'node:process'; +import { bootDshxRuntime } from '../src/dsh/runtime-boot.mjs'; + +const REQUIRED_SERVICES = [ + 'agents', + 'llm', + 'sessions', + 'sessionPersistence', + 'tools', + 'permissionPresets', + 'approval', + 'userQuestions', + 'skills' +]; + +let ctx; +const timer = setTimeout(() => { + process.stderr.write('dshx runtime smoke timed out while booting official DeepSeek Harness\n'); + process.exit(124); +}, 30_000); +timer.unref?.(); + +try { + ctx = await bootDshxRuntime({ cwd: process.cwd() }); + const missing = REQUIRED_SERVICES.filter((name) => ctx.get(name) == null); + if (missing.length > 0) { + throw new Error(`official DSH composition is missing required presentation services: ${missing.join(', ')}`); + } + process.stdout.write(`official DSH runtime booted: ${REQUIRED_SERVICES.join(', ')}\n`); +} finally { + clearTimeout(timer); + await ctx?.dispose?.(); +} From cb88680eb71f1ec100ee2e12bfd8932298a8d031 Mon Sep 17 00:00:00 2001 From: code-new-bie <403466121@qq.com> Date: Wed, 19 Aug 2026 19:32:02 +0800 Subject: [PATCH 083/305] ci: add real TUI and official DSH runtime smoke gates --- .github/workflows/ci.yml | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a84f1b8e..4327383d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -32,6 +32,24 @@ jobs: - run: node --check src/dsh/runtime-boot.mjs - run: node --check bin/dshx.mjs - run: node --check scripts/package-platform.mjs + - run: node --check scripts/runtime-smoke.mjs + + dsh-runtime: + name: Official DSH runtime / ${{ matrix.os }} + runs-on: ${{ matrix.os }} + timeout-minutes: 10 + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, windows-latest] + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 20 + - run: npm install + - name: Boot and dispose official DeepSeek Harness composition + run: node scripts/runtime-smoke.mjs codex-tui-package: name: Pinned TUI + package / ${{ matrix.os }} @@ -80,6 +98,13 @@ jobs: if (-not $approval.Contains('fn dshx_backend')) { throw 'DSHX approval semantics patch is missing' } $permissions = Get-Content .upstream\codex\codex-rs\tui\src\chatwidget\permission_popups.rs -Raw if (-not $permissions.Contains('fn dshx_backend')) { throw 'DSHX permission picker patch is missing' } + - name: Real pinned TUI PTY smoke + if: runner.os == 'Linux' + shell: bash + run: | + npm install + python3 -m pip install --user pexpect + python3 test/tui_smoke.py - name: Build installable platform tarball shell: bash if: runner.os != 'Windows' From cb2c2a8d4873dc9716f7e6a28178d9f240b7c5d5 Mon Sep 17 00:00:00 2001 From: code-new-bie <403466121@qq.com> Date: Wed, 19 Aug 2026 19:37:48 +0800 Subject: [PATCH 084/305] fix: emit explicit paginated Codex Thread schema --- src/dsh/codex-shapes.mjs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/dsh/codex-shapes.mjs b/src/dsh/codex-shapes.mjs index fbe84fbf..077834b3 100644 --- a/src/dsh/codex-shapes.mjs +++ b/src/dsh/codex-shapes.mjs @@ -159,6 +159,7 @@ export function dshThreadFromSnapshot({ meta, events = [], model, turns = [], lo } return { id: String(meta.id), + extra: null, sessionId: String(meta.id), forkedFromId: meta.parentSession ? String(meta.parentSession) : null, parentThreadId: meta.origin === 'subagent' && meta.parentSession ? String(meta.parentSession) : null, @@ -167,6 +168,7 @@ export function dshThreadFromSnapshot({ meta, events = [], model, turns = [], lo section: null, sectionEnteredAt: null, projectId: null, + historyMode: 'paginated', modelProvider: config.provider ?? 'dsh', createdAt, updatedAt: seconds(lastTime, createdAt), @@ -176,6 +178,7 @@ export function dshThreadFromSnapshot({ meta, events = [], model, turns = [], lo cwd: meta.cwd ?? process.cwd(), cliVersion, source: sourceForHeader(meta), + canAcceptDirectInput: loaded ? true : null, threadSource: null, agentNickname: null, agentRole: null, From 092b2b1c733d6fb49e6414945975736067a3ffff Mon Sep 17 00:00:00 2001 From: code-new-bie <403466121@qq.com> Date: Wed, 19 Aug 2026 19:39:22 +0800 Subject: [PATCH 085/305] feat: project paginated Codex history from DSH Session log --- src/dsh/history-pages.mjs | 77 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 77 insertions(+) create mode 100644 src/dsh/history-pages.mjs diff --git a/src/dsh/history-pages.mjs b/src/dsh/history-pages.mjs new file mode 100644 index 00000000..f27881b3 --- /dev/null +++ b/src/dsh/history-pages.mjs @@ -0,0 +1,77 @@ +import { dshTurnsFromSession } from './codex-shapes.mjs'; + +function cursorOffset(cursor) { + if (cursor == null) return 0; + const match = /^dshx:(\d+)$/.exec(String(cursor)); + if (!match) throw new Error(`invalid DSHX history cursor: ${JSON.stringify(cursor)}`); + const value = Number(match[1]); + if (!Number.isSafeInteger(value) || value < 0) throw new Error('invalid DSHX history cursor'); + return value; +} + +function pageSize(limit, fallback) { + if (limit == null) return fallback; + const value = Number(limit); + if (!Number.isSafeInteger(value) || value <= 0) throw new Error('history limit must be a positive integer'); + return Math.min(value, 1000); +} + +function page(values, { cursor, limit, sortDirection, fallbackLimit }) { + const ordered = sortDirection === 'asc' ? values : [...values].reverse(); + const start = cursorOffset(cursor); + const size = pageSize(limit, fallbackLimit); + const end = Math.min(ordered.length, start + size); + return { + data: ordered.slice(start, end), + nextCursor: end < ordered.length ? `dshx:${end}` : null, + // DSHX cursors are forward-only presentation cursors. The TUI only needs + // nextCursor for bounded backwards hydration; no durable cursor state is owned here. + backwardsCursor: null + }; +} + +function projectedTurns(controller, diagnostics) { + return dshTurnsFromSession({ + ctx: controller.agent.ctx, + agent: controller.agent, + diagnostics + }); +} + +function viewTurn(turn, requestedView) { + if (requestedView === 'notLoaded') { + return { ...turn, items: [], itemsView: 'notLoaded' }; + } + // DSHX has a faithful full presentation fold, not a second lossy summary + // algorithm. When Codex asks for summary, return full and label it honestly. + return { ...turn, itemsView: 'full' }; +} + +export function dshThreadTurnsPage({ controller, params = {}, diagnostics = () => {} }) { + const turns = projectedTurns(controller, diagnostics).map((turn) => viewTurn(turn, params.itemsView)); + return page(turns, { + cursor: params.cursor, + limit: params.limit, + sortDirection: params.sortDirection, + fallbackLimit: 5 + }); +} + +export function dshThreadItemsPage({ controller, params = {}, diagnostics = () => {} }) { + let turns = projectedTurns(controller, diagnostics); + if (params.turnId != null) { + turns = turns.filter((turn) => turn.id === String(params.turnId)); + } + const entries = []; + for (const turn of turns) { + for (const item of turn.items) entries.push({ turnId: turn.id, item }); + } + return page(entries, { + cursor: params.cursor, + limit: params.limit, + sortDirection: params.sortDirection, + fallbackLimit: 100 + }); +} + +export const historyInternals = { cursorOffset, pageSize, page, viewTurn }; From 598e8cdf3166085eaf966b9340ee8dd222a3bca1 Mon Sep 17 00:00:00 2001 From: code-new-bie <403466121@qq.com> Date: Wed, 19 Aug 2026 19:40:03 +0800 Subject: [PATCH 086/305] feat: add paginated Codex resume history over DSH log --- src/dsh/product-adapter.mjs | 42 ++++++++++++++++++++++++++++++++++++- 1 file changed, 41 insertions(+), 1 deletion(-) diff --git a/src/dsh/product-adapter.mjs b/src/dsh/product-adapter.mjs index 87596342..aaa9de00 100644 --- a/src/dsh/product-adapter.mjs +++ b/src/dsh/product-adapter.mjs @@ -1,5 +1,6 @@ import path from 'node:path'; import { DshAppServerAdapter } from './app-server-adapter.mjs'; +import { dshThreadItemsPage, dshThreadTurnsPage } from './history-pages.mjs'; import { dshSkillsListEntry } from './skills.mjs'; function textInput(params) { @@ -52,6 +53,10 @@ export class DshxProductAdapter extends DshAppServerAdapter { switch (method) { case 'skills/list': return this.skillsList(params); + case 'thread/turns/list': + return this.threadTurnsList(params); + case 'thread/items/list': + return this.threadItemsList(params); case 'thread/settings/update': return this.threadSettingsUpdate(params); case 'turn/steer': @@ -67,7 +72,12 @@ export class DshxProductAdapter extends DshAppServerAdapter { const id = codexPermissionProfileId(permission.preset); return { ...response, - activePermissionProfile: id == null ? null : { id } + runtimeWorkspaceRoots: [], + activePermissionProfile: id == null ? null : { id }, + multiAgentMode: 'explicitRequestOnly', + initialTurnsPage: null, + turnsBackwardsCursor: null, + itemsBackwardsCursor: null }; } @@ -104,6 +114,36 @@ export class DshxProductAdapter extends DshAppServerAdapter { return { result: { data } }; } + historyController(threadId) { + const controller = this.controllers.get(String(threadId ?? '')); + if (!controller) { + throw new Error(`Thread must be resumed before paginated DSH history is rendered: ${String(threadId ?? '')}`); + } + return controller; + } + + threadTurnsList(params = {}) { + const controller = this.historyController(params.threadId); + return { + result: dshThreadTurnsPage({ + controller, + params, + diagnostics: this.diagnostics + }) + }; + } + + threadItemsList(params = {}) { + const controller = this.historyController(params.threadId); + return { + result: dshThreadItemsPage({ + controller, + params, + diagnostics: this.diagnostics + }) + }; + } + async threadSettingsUpdate(params = {}) { const threadId = String(params.threadId ?? ''); const controller = this.controllers.get(threadId); From 5b731283877fde9f91191573077ad8bb113d9efc Mon Sep 17 00:00:00 2001 From: code-new-bie <403466121@qq.com> Date: Wed, 19 Aug 2026 19:40:36 +0800 Subject: [PATCH 087/305] test: cover paginated Codex history over DSH log --- test/dsh-history-pages.test.mjs | 67 +++++++++++++++++++++++++++++++++ 1 file changed, 67 insertions(+) create mode 100644 test/dsh-history-pages.test.mjs diff --git a/test/dsh-history-pages.test.mjs b/test/dsh-history-pages.test.mjs new file mode 100644 index 00000000..94538a29 --- /dev/null +++ b/test/dsh-history-pages.test.mjs @@ -0,0 +1,67 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { dshThreadItemsPage, dshThreadTurnsPage } from '../src/dsh/history-pages.mjs'; + +function controller() { + const agent = { + id: 'session-1', + ctx: { + get(name) { + if (name === 'tools') return { get() { return undefined; } }; + return undefined; + } + }, + session: { + header: { id: 'session-1', cwd: '/work' }, + events: [ + { type: 'turn/start', time: 1000, data: { turn: 1 } }, + { type: 'user/message', time: 1010, data: { id: 'u1', source: { kind: 'user' }, content: [{ type: 'text', text: 'one' }] } }, + { type: 'assistant/message', time: 1020, data: { turn: 1, step: 1, message: { id: 'a1', content: [{ type: 'text', text: 'first' }] } } }, + { type: 'turn/end', time: 1030, data: { turn: 1, reason: { kind: 'completed' } } }, + { type: 'turn/start', time: 2000, data: { turn: 2 } }, + { type: 'user/message', time: 2010, data: { id: 'u2', source: { kind: 'user' }, content: [{ type: 'text', text: 'two' }] } }, + { type: 'assistant/message', time: 2020, data: { turn: 2, step: 1, message: { id: 'a2', content: [{ type: 'text', text: 'second' }] } } }, + { type: 'turn/end', time: 2030, data: { turn: 2, reason: { kind: 'completed' } } } + ] + } + }; + return { agent }; +} + +test('turn pages satisfy Codex descending hydration without owning history state', () => { + const first = dshThreadTurnsPage({ + controller: controller(), + params: { limit: 1, sortDirection: 'desc', itemsView: 'notLoaded' } + }); + assert.equal(first.data.length, 1); + assert.equal(first.data[0].id, 'dsh-turn-2'); + assert.equal(first.data[0].itemsView, 'notLoaded'); + assert.deepEqual(first.data[0].items, []); + assert.equal(first.nextCursor, 'dshx:1'); + assert.equal(first.backwardsCursor, null); + + const second = dshThreadTurnsPage({ + controller: controller(), + params: { cursor: first.nextCursor, limit: 1, sortDirection: 'desc', itemsView: 'notLoaded' } + }); + assert.equal(second.data[0].id, 'dsh-turn-1'); + assert.equal(second.nextCursor, null); +}); + +test('item pages preserve turn correlation and can filter one turn', () => { + const page = dshThreadItemsPage({ + controller: controller(), + params: { turnId: 'dsh-turn-2', sortDirection: 'desc', limit: 10 } + }); + assert.deepEqual(page.data.map((entry) => entry.turnId), ['dsh-turn-2', 'dsh-turn-2']); + assert.deepEqual(page.data.map((entry) => entry.item.type), ['agentMessage', 'userMessage']); + assert.equal(page.nextCursor, null); + assert.equal(page.backwardsCursor, null); +}); + +test('history cursors are opaque and invalid cursors fail closed', () => { + assert.throws( + () => dshThreadTurnsPage({ controller: controller(), params: { cursor: 'not-owned-by-dshx' } }), + /invalid DSHX history cursor/ + ); +}); From ef6ca93279d87d19f4c95067a1c21e87850dbc24 Mon Sep 17 00:00:00 2001 From: code-new-bie <403466121@qq.com> Date: Wed, 19 Aug 2026 19:41:36 +0800 Subject: [PATCH 088/305] fix: return pinned Codex unsubscribe status --- src/dsh/product-adapter.mjs | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/src/dsh/product-adapter.mjs b/src/dsh/product-adapter.mjs index aaa9de00..44903c18 100644 --- a/src/dsh/product-adapter.mjs +++ b/src/dsh/product-adapter.mjs @@ -59,6 +59,8 @@ export class DshxProductAdapter extends DshAppServerAdapter { return this.threadItemsList(params); case 'thread/settings/update': return this.threadSettingsUpdate(params); + case 'thread/unsubscribe': + return this.threadUnsubscribeWithStatus(params); case 'turn/steer': return this.turnSteer(params); default: @@ -144,6 +146,18 @@ export class DshxProductAdapter extends DshAppServerAdapter { }; } + async threadUnsubscribeWithStatus(params = {}) { + const threadId = String(params.threadId ?? ''); + const subscribed = this.controllers.has(threadId); + const loaded = Boolean(this.driver.getLive(threadId)); + await super.threadUnsubscribe(params); + return { + result: { + status: subscribed ? 'unsubscribed' : loaded ? 'notSubscribed' : 'notLoaded' + } + }; + } + async threadSettingsUpdate(params = {}) { const threadId = String(params.threadId ?? ''); const controller = this.controllers.get(threadId); From a4e89f416eba2e492eebc232087eb56c5a0ab1c8 Mon Sep 17 00:00:00 2001 From: code-new-bie <403466121@qq.com> Date: Wed, 19 Aug 2026 20:17:43 +0800 Subject: [PATCH 089/305] fix: package complete DSHX runtime dependency closure --- scripts/package-platform.mjs | 35 +++++++++++++++++++++++++++++++++-- 1 file changed, 33 insertions(+), 2 deletions(-) diff --git a/scripts/package-platform.mjs b/scripts/package-platform.mjs index 91829f0c..f01c3123 100644 --- a/scripts/package-platform.mjs +++ b/scripts/package-platform.mjs @@ -27,12 +27,18 @@ fs.mkdirSync(out, { recursive: true }); function copy(relative) { const from = path.join(root, relative); const to = path.join(stage, relative); + if (!fs.existsSync(from)) throw new Error(`Release input is missing: ${relative}`); fs.mkdirSync(path.dirname(to), { recursive: true }); fs.cpSync(from, to, { recursive: true }); } +// Keep the platform package closure intentionally simple and auditable: ship +// the whole DSHX presentation source tree plus its tiny Cordis root config. +// This avoids release-only missing-import bugs while still keeping DSH itself +// an upstream npm dependency rather than vendoring its runtime. copy('bin/dshx.mjs'); -copy('src/dsh'); +copy('src'); +copy('config'); copy('NOTICE'); copy('upstream/CODEX_COMMIT'); copy('upstream/DSH_COMMIT'); @@ -60,10 +66,20 @@ const packageJson = { bin: { dshx: './bin/dshx.mjs' }, dependencies: rootPackage.dependencies, repository: { type: 'git', url: 'https://github.com/code-new-bie/dsh-codex.git' }, - files: ['bin', 'src/dsh', 'dist/bin', 'upstream', 'LICENSE', 'NOTICE'] + files: ['bin', 'src', 'config', 'dist/bin', 'upstream', 'LICENSE', 'NOTICE'] }; fs.writeFileSync(path.join(stage, 'package.json'), `${JSON.stringify(packageJson, null, 2)}\n`); +// Static release-closure check before npm pack. Every local relative import in +// shipped JavaScript must resolve inside the staged package. +for (const file of walkJs(stage)) { + const source = fs.readFileSync(file, 'utf8'); + for (const match of source.matchAll(/(?:from\s+|import\s*)['\"](\.{1,2}\/[^'\"]+)['\"]/g)) { + const target = resolveLocalImport(path.dirname(file), match[1]); + if (!target) throw new Error(`Release package has unresolved local import ${match[1]} from ${path.relative(stage, file)}`); + } +} + const packOutput = execFileSync( process.platform === 'win32' ? 'npm.cmd' : 'npm', ['pack', stage, '--pack-destination', out, '--json'], @@ -92,3 +108,18 @@ const metadata = { }; fs.writeFileSync(`${targetTarball}.json`, `${JSON.stringify(metadata, null, 2)}\n`); process.stdout.write(`${targetTarball}\n`); + +function* walkJs(directory) { + for (const entry of fs.readdirSync(directory, { withFileTypes: true })) { + if (entry.name === 'node_modules' || entry.name === 'dist' || entry.name === 'upstream') continue; + const absolute = path.join(directory, entry.name); + if (entry.isDirectory()) yield* walkJs(absolute); + else if (entry.isFile() && /\.(?:mjs|js)$/.test(entry.name)) yield absolute; + } +} + +function resolveLocalImport(directory, specifier) { + const base = path.resolve(directory, specifier); + const candidates = [base, `${base}.mjs`, `${base}.js`, path.join(base, 'index.mjs'), path.join(base, 'index.js')]; + return candidates.find((candidate) => fs.existsSync(candidate)); +} From ef0f7ea0721302a575667dbc68b05641819c61bf Mon Sep 17 00:00:00 2001 From: code-new-bie <403466121@qq.com> Date: Wed, 19 Aug 2026 20:21:58 +0800 Subject: [PATCH 090/305] feat: make doctor validate installed DSH composition --- bin/dshx.mjs | 27 ++++++++++++++++++++++----- 1 file changed, 22 insertions(+), 5 deletions(-) diff --git a/bin/dshx.mjs b/bin/dshx.mjs index 565b1dc3..6222e5df 100644 --- a/bin/dshx.mjs +++ b/bin/dshx.mjs @@ -5,13 +5,14 @@ import process from 'node:process'; import { spawn, spawnSync } from 'node:child_process'; import { fileURLToPath } from 'node:url'; import { startDshxLocalServer } from '../src/dsh/local-server.mjs'; +import { dshxRuntimeEntries } from '../src/dsh/runtime-boot.mjs'; const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); const PACKAGE = JSON.parse(fs.readFileSync(path.join(ROOT, 'package.json'), 'utf8')); const VERSION = PACKAGE.version; function usage() { - return `DSHX ${VERSION}\n\nUsage:\n dshx Start DSHX in the current project\n dshx Start with an initial prompt\n dshx resume Open the Codex-style DSH session picker\n dshx resume --last Resume the most recent DSH session\n dshx resume Resume a specific DSH session\n dshx doctor Check runtime/TUI prerequisites\n dshx --version Print version\n dshx --help Show this help\n\nEnvironment:\n DSHX_TUI_BIN Override the packaged DSHX TUI binary (development only)\n DSHX_DEBUG=1 Print DSHX adapter diagnostics\n\nProduct boundary:\n DSHX owns only the launcher, Codex TUI thin fork and presentation adapter.\n DeepSeek Harness remains the authoritative Agent/Session/Tool runtime.\n`; + return `DSHX ${VERSION}\n\nUsage:\n dshx Start DSHX in the current project\n dshx Start with an initial prompt\n dshx resume Open the Codex-style DSH session picker\n dshx resume --last Resume the most recent DSH session\n dshx resume Resume a specific DSH session\n dshx doctor Check packaged TUI and official DSH composition\n dshx --version Print version\n dshx --help Show this help\n\nEnvironment:\n DSHX_TUI_BIN Override the packaged DSHX TUI binary (development only)\n DSHX_DEBUG=1 Print DSHX adapter diagnostics\n\nProduct boundary:\n DSHX owns only the launcher, Codex TUI thin fork and presentation adapter.\n DeepSeek Harness remains the authoritative Agent/Session/Tool runtime.\n`; } function packagedTuiBinary() { @@ -45,6 +46,21 @@ function doctor() { const tuiCheck = executable && fs.existsSync(executable) ? spawnSync(executable, ['--version'], { encoding: 'utf8' }) : { status: 127, stdout: '', stderr: '' }; + + let runtimeDetail; + let runtimeOk = false; + try { + const entries = dshxRuntimeEntries(); + const required = ['llm', 'session', 'agent', 'permission', 'approval', 'user-questions', 'tools']; + const ids = new Set(entries.filter((entry) => !entry.disabled).map((entry) => entry.id)); + const missing = required.filter((id) => !ids.has(id)); + if (missing.length > 0) throw new Error(`missing official bundle entries: ${missing.join(', ')}`); + runtimeDetail = `official DSH bundle composition (${entries.length} entries)`; + runtimeOk = true; + } catch (error) { + runtimeDetail = error instanceof Error ? error.message : String(error); + } + const rows = [ ['Node', process.version, Number(process.versions.node.split('.')[0]) >= 20], [ @@ -52,10 +68,11 @@ function doctor() { tuiCheck.status === 0 ? (tuiCheck.stdout || tuiCheck.stderr).trim() : executable - ? `${executable} is not built/runnable` + ? `${executable} is not built/runnable${tuiCheck.stderr ? `: ${tuiCheck.stderr.trim()}` : ''}` : 'not found', tuiCheck.status === 0 - ] + ], + ['DeepSeek Harness', runtimeDetail, runtimeOk] ]; for (const [name, detail, ok] of rows) { process.stdout.write(`${ok ? '✓' : '✗'} ${name}: ${detail}\n`); @@ -63,8 +80,8 @@ function doctor() { if (rows.some((row) => !row[2])) { process.stderr.write( process.platform === 'win32' - ? 'Build the pinned TUI with `.\\scripts\\build-codex-tui.ps1`.\n' - : 'Build the pinned TUI with `./scripts/build-codex-tui.sh`.\n' + ? 'For a source checkout, build the pinned TUI with `.\\scripts\\build-codex-tui.ps1`; packaged releases include it.\n' + : 'For a source checkout, build the pinned TUI with `./scripts/build-codex-tui.sh`; packaged releases include it.\n' ); process.exitCode = 1; } From 8ff9d96203f56d8745ffd1b72549999204b7b457 Mon Sep 17 00:00:00 2001 From: code-new-bie <403466121@qq.com> Date: Wed, 19 Aug 2026 20:33:54 +0800 Subject: [PATCH 091/305] fix: make startup skills refresh compatible and complete profile shape --- src/dsh/product-adapter.mjs | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/src/dsh/product-adapter.mjs b/src/dsh/product-adapter.mjs index 44903c18..5aed4a01 100644 --- a/src/dsh/product-adapter.mjs +++ b/src/dsh/product-adapter.mjs @@ -75,7 +75,7 @@ export class DshxProductAdapter extends DshAppServerAdapter { return { ...response, runtimeWorkspaceRoots: [], - activePermissionProfile: id == null ? null : { id }, + activePermissionProfile: id == null ? null : { id, extends: null }, multiAgentMode: 'explicitRequestOnly', initialTurnsPage: null, turnsBackwardsCursor: null, @@ -100,10 +100,12 @@ export class DshxProductAdapter extends DshAppServerAdapter { } async skillsList(params = {}) { + // Pinned Codex uses forceReload=true for its ordinary asynchronous startup + // catalog refresh. DSH provider invalidation is not a presentation-owned + // operation, so DSHX treats this bit as a request for a fresh authoritative + // registry snapshot rather than pretending to flush provider caches. if (params.forceReload === true) { - throw new Error( - 'DSHX cannot honor Codex forceReload: DSH skill cache invalidation is provider-owned; retry after the DSH provider emits skills/change' - ); + this.diagnostics('Codex requested skills forceReload; DSHX is reading the current DSH registry snapshot without overriding provider-owned cache policy'); } const cwds = Array.isArray(params.cwds) && params.cwds.length > 0 ? params.cwds.map((cwd) => path.resolve(cwd)) From 985809fe9edbdb14df751058f01813df49b96d89 Mon Sep 17 00:00:00 2001 From: code-new-bie <403466121@qq.com> Date: Wed, 19 Aug 2026 20:34:36 +0800 Subject: [PATCH 092/305] test: accept Codex startup forceReload without claiming cache flush --- test/dsh-product-adapter.test.mjs | 139 +++++++++++++++++------------- 1 file changed, 78 insertions(+), 61 deletions(-) diff --git a/test/dsh-product-adapter.test.mjs b/test/dsh-product-adapter.test.mjs index 34c2bbc9..48abc564 100644 --- a/test/dsh-product-adapter.test.mjs +++ b/test/dsh-product-adapter.test.mjs @@ -11,71 +11,78 @@ function bareAdapter() { return adapter; } +function skillRegistry(absoluteSkill, calls = []) { + return { + async snapshot(options) { + calls.push(['snapshot', options]); + return { + complete: true, + skills: [ + { + name: 'review', + description: 'Review code', + invocation: { modelInvocable: true, userInvocable: true }, + source: 'project-agents', + provider: 'filesystem' + }, + { + name: 'remote-skill', + description: 'Remote skill', + invocation: { modelInvocable: true, userInvocable: true }, + source: 'custom', + provider: 'remote' + }, + { + name: 'model-only', + description: 'Model only', + invocation: { modelInvocable: true, userInvocable: false }, + source: 'runtime', + provider: 'runtime' + } + ] + }; + }, + async get(name) { + calls.push(['get', name]); + if (name === 'review') { + return { + name, + description: 'Review code', + invocation: { modelInvocable: true, userInvocable: true }, + source: 'project-agents', + provider: 'filesystem', + content: 'review', + path: absoluteSkill + }; + } + if (name === 'remote-skill') { + return { + name, + description: 'Remote skill', + invocation: { modelInvocable: true, userInvocable: true }, + source: 'custom', + provider: 'remote', + content: 'remote' + }; + } + return undefined; + } + }; +} + test('skills/list reads DSH public registry and only returns faithfully path-backed user skills', async () => { const adapter = bareAdapter(); const absoluteSkill = path.resolve('/workspace/.agents/skills/review/SKILL.md'); + const calls = []; adapter.ctx = { get(name) { - if (name !== 'skills') return undefined; - return { - async snapshot(options) { - assert.equal(options.cwd, path.resolve('/workspace')); - return { - complete: true, - skills: [ - { - name: 'review', - description: 'Review code', - invocation: { modelInvocable: true, userInvocable: true }, - source: 'project-agents', - provider: 'filesystem' - }, - { - name: 'remote-skill', - description: 'Remote skill', - invocation: { modelInvocable: true, userInvocable: true }, - source: 'custom', - provider: 'remote' - }, - { - name: 'model-only', - description: 'Model only', - invocation: { modelInvocable: true, userInvocable: false }, - source: 'runtime', - provider: 'runtime' - } - ] - }; - }, - async get(name) { - if (name === 'review') { - return { - name, - description: 'Review code', - invocation: { modelInvocable: true, userInvocable: true }, - source: 'project-agents', - provider: 'filesystem', - content: 'review', - path: absoluteSkill - }; - } - if (name === 'remote-skill') { - return { - name, - description: 'Remote skill', - invocation: { modelInvocable: true, userInvocable: true }, - source: 'custom', - provider: 'remote', - content: 'remote' - }; - } - return undefined; - } - }; + return name === 'skills' ? skillRegistry(absoluteSkill, calls) : undefined; } }; const result = await adapter.skillsList({ cwds: [] }); + assert.equal(calls[0][0], 'snapshot'); + assert.equal(calls[0][1].cwd, path.resolve('/workspace')); assert.equal(result.result.data.length, 1); assert.deepEqual(result.result.data[0].skills, [{ name: 'review', @@ -86,12 +93,22 @@ test('skills/list reads DSH public registry and only returns faithfully path-bac }]); }); -test('skills/list refuses Codex forceReload because DSH invalidation belongs to providers', async () => { +test('skills/list accepts pinned Codex startup forceReload as a fresh DSH snapshot read', async () => { const adapter = bareAdapter(); - await assert.rejects( - () => adapter.skillsList({ forceReload: true }), - /DSH skill cache invalidation is provider-owned/ - ); + const absoluteSkill = path.resolve('/workspace/.agents/skills/review/SKILL.md'); + const calls = []; + const diagnostics = []; + adapter.diagnostics = (message) => diagnostics.push(message); + adapter.ctx = { + get(name) { + return name === 'skills' ? skillRegistry(absoluteSkill, calls) : undefined; + } + }; + + const result = await adapter.skillsList({ cwds: [path.resolve('/workspace')], forceReload: true }); + assert.equal(calls.filter(([kind]) => kind === 'snapshot').length, 1); + assert.equal(result.result.data[0].skills[0].name, 'review'); + assert.match(diagnostics.join('\n'), /provider-owned cache policy/); }); test('thread/settings/update maps Codex workspace profile to the DSH canonical preset', async () => { From 28bf0b4721fae7aeefcc1ef3694cf07f2c7fd403 Mon Sep 17 00:00:00 2001 From: code-new-bie <403466121@qq.com> Date: Wed, 19 Aug 2026 20:35:55 +0800 Subject: [PATCH 093/305] feat: project official DSH token meter into Codex footer usage --- src/dsh/token-usage.mjs | 104 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 104 insertions(+) create mode 100644 src/dsh/token-usage.mjs diff --git a/src/dsh/token-usage.mjs b/src/dsh/token-usage.mjs new file mode 100644 index 00000000..4842d1a8 --- /dev/null +++ b/src/dsh/token-usage.mjs @@ -0,0 +1,104 @@ +function finiteNonNegative(value) { + return Number.isFinite(value) && value >= 0 ? Number(value) : 0; +} + +function breakdown(usage = {}) { + const inputTokens = finiteNonNegative(usage.inputTokens ?? usage.uncachedInputTokens); + const cachedInputTokens = finiteNonNegative(usage.cacheReadTokens); + const cacheWriteInputTokens = finiteNonNegative(usage.cacheWriteTokens); + const outputTokens = finiteNonNegative(usage.outputTokens); + const reasoningOutputTokens = finiteNonNegative(usage.reasoningTokens); + return { + totalTokens: inputTokens + cachedInputTokens + cacheWriteInputTokens + outputTokens, + inputTokens, + cachedInputTokens, + cacheWriteInputTokens, + outputTokens, + reasoningOutputTokens + }; +} + +function usageSamples(events = []) { + const byStep = new Map(); + let last = null; + for (const event of events) { + const data = event?.data; + if (!Number.isInteger(data?.turn) || !Number.isInteger(data?.step)) continue; + let usage; + if (event.type === 'assistant/chunk' && data.chunk?.type === 'usage') { + usage = data.chunk.usage; + } else if (event.type === 'assistant/message' && data.usage) { + usage = data.usage; + } + if (!usage) continue; + const sample = { ...usage }; + byStep.set(`${data.turn}:${data.step}`, sample); + last = sample; + } + return { byStep, last }; +} + +function reasoningTotal(events = []) { + const { byStep } = usageSamples(events); + let total = 0; + for (const usage of byStep.values()) total += finiteNonNegative(usage.reasoningTokens); + return total; +} + +function latestContextWindow(events = []) { + for (let index = events.length - 1; index >= 0; index -= 1) { + const event = events[index]; + if (event?.type === 'request/context' && Number.isFinite(event.data?.contextWindow)) { + return Number(event.data.contextWindow); + } + } + return null; +} + +/** + * Read-only presentation snapshot over official DSH session projections. + * DSH token-meter owns accounting; this module only reshapes its values into + * the pinned Codex TUI contract. + */ +export function codexThreadTokenUsage({ ctx, session }) { + const projections = ctx?.get?.('sessionProjections'); + if (!session || !projections?.snapshot) return null; + const snapshot = projections.snapshot(session); + const cumulative = snapshot?.values?.tokenUsage; + if (!cumulative) return null; + + const events = session.events ?? []; + const { last } = usageSamples(events); + const total = breakdown({ + inputTokens: cumulative.uncachedInputTokens, + cacheReadTokens: cumulative.cacheReadTokens, + cacheWriteTokens: cumulative.cacheWriteTokens, + outputTokens: cumulative.outputTokens, + reasoningTokens: reasoningTotal(events) + }); + const contextWindow = Number.isFinite(snapshot?.values?.contextPressure?.contextWindow) + ? Number(snapshot.values.contextPressure.contextWindow) + : latestContextWindow(events); + + return { + total, + last: breakdown(last ?? {}), + modelContextWindow: contextWindow + }; +} + +export function tokenUsageNotification({ ctx, session, threadId, turnId }) { + const tokenUsage = codexThreadTokenUsage({ ctx, session }); + if (!tokenUsage || !threadId || !turnId) return null; + return { + method: 'thread/tokenUsage/updated', + params: { threadId, turnId, tokenUsage } + }; +} + +export const tokenUsageInternals = { + breakdown, + usageSamples, + reasoningTotal, + latestContextWindow +}; From da6a9e18a85d396bc5f69bdfe482b65b2928c3f4 Mon Sep 17 00:00:00 2001 From: code-new-bie <403466121@qq.com> Date: Wed, 19 Aug 2026 20:37:33 +0800 Subject: [PATCH 094/305] fix: scope session events and gate turn notifications through RPC response --- src/dsh/thread-controller.mjs | 79 ++++++++++++++++++++++++++++++----- 1 file changed, 68 insertions(+), 11 deletions(-) diff --git a/src/dsh/thread-controller.mjs b/src/dsh/thread-controller.mjs index 62f2f35a..ef015b1d 100644 --- a/src/dsh/thread-controller.mjs +++ b/src/dsh/thread-controller.mjs @@ -1,4 +1,5 @@ import { DshSessionProjector } from './session-projector.mjs'; +import { tokenUsageNotification } from './token-usage.mjs'; import { DshToolPresentationResolver } from './tool-presentation.mjs'; function deferred(timeoutMs, label) { @@ -17,6 +18,12 @@ function deferred(timeoutMs, label) { }; } +function usageRelevant(event) { + return event?.type === 'assistant/message' + || event?.type === 'request/context' + || (event?.type === 'assistant/chunk' && event.data?.chunk?.type === 'usage'); +} + /** * Owns only the presentation lifetime for one already-created DSH Agent. * The Agent and Session remain authoritative and are never reconstructed here. @@ -29,6 +36,7 @@ export class DshThreadController { this.driver = driver; this.emit = emit; this.turnStartTimeoutMs = turnStartTimeoutMs; + this.diagnostics = diagnostics; // Real DSH Agent contexts expose `ctx.tools`; lightweight controller tests // may omit it. Presentation specialization is optional and never affects @@ -49,9 +57,11 @@ export class DshThreadController { toolPresentation: this.toolPresentation }); this.pendingTurnStart = null; + this.responseGate = null; + this.lastTokenUsageSignature = null; this.closed = false; - const onSessionEvent = (_session, event) => this.onSessionEvent(event); + const onSessionEvent = (session, event) => this.onSessionEvent(session, event); this.disposeSessionListener = this.agent.ctx.on('session/event', onSessionEvent); } @@ -72,35 +82,77 @@ export class DshThreadController { return { threadId: this.threadId, turnId: turn.id }; } - onSessionEvent(event) { - if (this.closed) return; + deliver(notification) { + if (this.responseGate && !this.responseGate.released) { + this.responseGate.buffered.push(notification); + return; + } + this.emit(notification); + } + + maybeProjectTokenUsage(event) { + if (!usageRelevant(event)) return; + const location = this.currentLocation(); + if (!location) return; + try { + const notification = tokenUsageNotification({ + ctx: this.agent.ctx, + session: this.agent.session, + threadId: location.threadId, + turnId: location.turnId + }); + if (!notification) return; + const signature = JSON.stringify(notification.params.tokenUsage); + if (signature === this.lastTokenUsageSignature) return; + this.lastTokenUsageSignature = signature; + this.deliver(notification); + } catch (error) { + // Token accounting is presentation-only. A malformed optional projection + // must never interrupt the DSH agent loop or transcript stream. + this.diagnostics(`token usage projection failed: ${error instanceof Error ? error.message : error}`); + } + } + + onSessionEvent(session, event) { + if (this.closed || session !== this.agent.session) return; const projected = this.projector.project(event); for (const notification of projected) { if (notification.method === 'turn/started' && this.pendingTurnStart) { const pending = this.pendingTurnStart; this.pendingTurnStart = null; - pending.resolve({ turn: notification.params.turn, firstNotification: notification }); + const gate = { + firstNotification: notification, + buffered: [], + released: false + }; + this.responseGate = gate; + pending.resolve({ turn: notification.params.turn, gate }); continue; } - this.emit(notification); + this.deliver(notification); } + this.maybeProjectTokenUsage(event); } /** Submit a normal follow-up and wait until DSH itself commits turn/start. */ async startTurn(text) { - if (this.pendingTurnStart) throw new Error('A turn/start request is already awaiting DSH'); + if (this.pendingTurnStart || (this.responseGate && !this.responseGate.released)) { + throw new Error('A turn/start request is already awaiting DSHX protocol release'); + } const pending = deferred(this.turnStartTimeoutMs, 'DSH turn/start'); this.pendingTurnStart = pending; try { this.driver.followup(this.agent, text); - const { turn, firstNotification } = await pending.promise; - let released = false; + const { turn, gate } = await pending.promise; return { turn, release: () => { - if (released) return; - released = true; - this.emit(firstNotification); + if (gate.released) return; + gate.released = true; + if (this.responseGate === gate) this.responseGate = null; + this.emit(gate.firstNotification); + for (const notification of gate.buffered) this.emit(notification); + gate.buffered.length = 0; } }; } catch (error) { @@ -126,6 +178,11 @@ export class DshThreadController { this.closed = true; this.pendingTurnStart?.reject(new Error('DSHX thread controller closed')); this.pendingTurnStart = null; + if (this.responseGate && !this.responseGate.released) { + this.responseGate.released = true; + this.responseGate.buffered.length = 0; + } + this.responseGate = null; this.disposeSessionListener?.(); await this.handle.dispose?.(); } From a7fdabdf5e8822cdbd4a82c8e37b4e78e5f091e8 Mon Sep 17 00:00:00 2001 From: code-new-bie <403466121@qq.com> Date: Wed, 19 Aug 2026 20:38:03 +0800 Subject: [PATCH 095/305] test: lock scoped sessions and turn response notification gate --- test/dsh-thread-controller.test.mjs | 49 ++++++++++++++++++++++++++--- 1 file changed, 45 insertions(+), 4 deletions(-) diff --git a/test/dsh-thread-controller.test.mjs b/test/dsh-thread-controller.test.mjs index f7d3939a..59921110 100644 --- a/test/dsh-thread-controller.test.mjs +++ b/test/dsh-thread-controller.test.mjs @@ -7,10 +7,12 @@ function fixture() { const calls = []; let sessionListener; let disposed = false; - const session = { id: 'session-1' }; + const session = { id: 'session-1', events: [], header: { cwd: '/workspace' } }; const agent = { id: 'session-1', + session, ctx: { + get() { return undefined; }, on(name, listener) { assert.equal(name, 'session/event'); sessionListener = listener; @@ -26,12 +28,14 @@ function fixture() { followup(_agent, text) { calls.push(['followup', text]); queueMicrotask(() => { - sessionListener?.(session, { + const event = { type: 'turn/start', seq: 1, time: 1_700_000_000_000, data: { turn: 4 } - }); + }; + session.events.push(event); + sessionListener?.(session, event); }); }, steer(_agent, text) { calls.push(['steer', text]); }, @@ -41,9 +45,13 @@ function fixture() { const controller = new DshThreadController({ handle, driver, emit: (event) => emitted.push(event) }); return { controller, + session, emitted, calls, - emitSession(event) { sessionListener?.(session, event); }, + emitSession(event, target = session) { + if (target === session) session.events.push(event); + sessionListener?.(target, event); + }, get disposed() { return disposed; }, get hasListener() { return Boolean(sessionListener); } }; @@ -66,6 +74,39 @@ test('startTurn waits for DSH turn/start and releases notification after RPC res assert.equal(fx.emitted.length, 1, 'release must be idempotent'); }); +test('all notifications produced before the turn/start RPC response remain ordered behind turn/started', async () => { + const fx = fixture(); + const started = await fx.controller.startTurn('hello'); + fx.emitSession({ + type: 'assistant/chunk', + seq: 2, + time: 1_700_000_000_010, + data: { turn: 4, step: 1, chunk: { type: 'text-delta', index: 0, text: 'fast' } } + }); + assert.deepEqual(fx.emitted, []); + + started.release(); + assert.deepEqual(fx.emitted.map((event) => event.method), [ + 'turn/started', + 'item/started', + 'item/agentMessage/delta' + ]); +}); + +test('controller ignores global session/event traffic from other DSH sessions', async () => { + const fx = fixture(); + const started = await fx.controller.startTurn('hello'); + started.release(); + const otherSession = { id: 'session-2', events: [] }; + fx.emitSession({ + type: 'assistant/chunk', + seq: 1, + time: 1_700_000_000_010, + data: { turn: 99, step: 1, chunk: { type: 'text-delta', index: 0, text: 'wrong thread' } } + }, otherSession); + assert.deepEqual(fx.emitted.map((event) => event.method), ['turn/started']); +}); + test('later DSH session events stream directly through the projector', async () => { const fx = fixture(); const started = await fx.controller.startTurn('hello'); From 7851fff8aad46967f943b69c4b5edeea0ecd496b Mon Sep 17 00:00:00 2001 From: code-new-bie <403466121@qq.com> Date: Wed, 19 Aug 2026 20:38:31 +0800 Subject: [PATCH 096/305] test: lock Codex footer accounting to official DSH projections --- test/dsh-token-usage.test.mjs | 139 ++++++++++++++++++++++++++++++++++ 1 file changed, 139 insertions(+) create mode 100644 test/dsh-token-usage.test.mjs diff --git a/test/dsh-token-usage.test.mjs b/test/dsh-token-usage.test.mjs new file mode 100644 index 00000000..04c205e4 --- /dev/null +++ b/test/dsh-token-usage.test.mjs @@ -0,0 +1,139 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { codexThreadTokenUsage, tokenUsageNotification } from '../src/dsh/token-usage.mjs'; + +function fixture() { + const session = { + events: [ + { + type: 'request/context', + seq: 1, + data: { provider: 'deepseek', model: 'model-a', contextWindow: 128000 } + }, + { + type: 'assistant/chunk', + seq: 2, + data: { + turn: 1, + step: 1, + chunk: { + type: 'usage', + usage: { + inputTokens: 10, + cacheReadTokens: 3, + outputTokens: 5, + reasoningTokens: 4 + } + } + } + }, + { + type: 'assistant/message', + seq: 3, + data: { + turn: 1, + step: 1, + message: { content: [] }, + usage: { + inputTokens: 12, + cacheReadTokens: 4, + cacheWriteTokens: 2, + outputTokens: 6, + reasoningTokens: 5 + } + } + }, + { + type: 'assistant/message', + seq: 4, + data: { + turn: 1, + step: 2, + message: { content: [] }, + usage: { + inputTokens: 20, + outputTokens: 7, + reasoningTokens: 3 + } + } + } + ] + }; + const ctx = { + get(name) { + if (name !== 'sessionProjections') return undefined; + return { + snapshot(received) { + assert.equal(received, session); + return { + asOfSeq: 4, + values: { + tokenUsage: { + uncachedInputTokens: 32, + outputTokens: 13, + cacheReadTokens: 4, + cacheWriteTokens: 2 + }, + contextPressure: { + pressureTokens: 20, + projectedTokens: 24, + contextWindow: 128000 + } + } + }; + } + }; + } + }; + return { ctx, session }; +} + +test('Codex total uses DSH token-meter buckets without double-counting reasoning', () => { + const { ctx, session } = fixture(); + const usage = codexThreadTokenUsage({ ctx, session }); + assert.deepEqual(usage.total, { + totalTokens: 51, + inputTokens: 32, + cachedInputTokens: 4, + cacheWriteInputTokens: 2, + outputTokens: 13, + reasoningOutputTokens: 8 + }); + assert.equal(usage.total.totalTokens, 32 + 4 + 2 + 13); + assert.equal(usage.modelContextWindow, 128000); +}); + +test('Codex last usage is the latest DSH call sample and final usage replaces same-step chunk', () => { + const { ctx, session } = fixture(); + const usage = codexThreadTokenUsage({ ctx, session }); + assert.deepEqual(usage.last, { + totalTokens: 27, + inputTokens: 20, + cachedInputTokens: 0, + cacheWriteInputTokens: 0, + outputTokens: 7, + reasoningOutputTokens: 3 + }); + assert.equal(usage.total.reasoningOutputTokens, 5 + 3, 'chunk reasoning must not be double counted'); +}); + +test('notification matches pinned Codex thread/tokenUsage/updated envelope', () => { + const { ctx, session } = fixture(); + const notification = tokenUsageNotification({ + ctx, + session, + threadId: 'session-1', + turnId: 'dsh-turn-1' + }); + assert.equal(notification.method, 'thread/tokenUsage/updated'); + assert.equal(notification.params.threadId, 'session-1'); + assert.equal(notification.params.turnId, 'dsh-turn-1'); + assert.equal(notification.params.tokenUsage.modelContextWindow, 128000); +}); + +test('missing optional DSH projection omits footer update instead of inventing usage', () => { + const session = { events: [] }; + const ctx = { get() { return undefined; } }; + assert.equal(codexThreadTokenUsage({ ctx, session }), null); + assert.equal(tokenUsageNotification({ ctx, session, threadId: 's', turnId: 't' }), null); +}); From d106a51e40a9aea0040c9c5087be08839e6f85ba Mon Sep 17 00:00:00 2001 From: code-new-bie <403466121@qq.com> Date: Wed, 19 Aug 2026 20:39:27 +0800 Subject: [PATCH 097/305] feat: expose token usage without fabricating billing --- src/dsh/product-adapter.mjs | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/src/dsh/product-adapter.mjs b/src/dsh/product-adapter.mjs index 5aed4a01..10bf6e2a 100644 --- a/src/dsh/product-adapter.mjs +++ b/src/dsh/product-adapter.mjs @@ -51,6 +51,8 @@ function hasSupportedThreadSetting(params) { export class DshxProductAdapter extends DshAppServerAdapter { async dispatch(method, params) { switch (method) { + case 'account/usage/read': + return this.accountUsageRead(params); case 'skills/list': return this.skillsList(params); case 'thread/turns/list': @@ -68,6 +70,26 @@ export class DshxProductAdapter extends DshAppServerAdapter { } } + accountUsageRead(_params = {}) { + // Codex's account/usage/read is a billing API (credits/USD estimates), not + // a generic token counter. DSH token/context accounting is surfaced through + // thread/tokenUsage/updated instead. Returning threadUsage:null tells the + // Codex UI that billing usage is unavailable without inventing zero cost. + return { + result: { + summary: { + lifetimeTokens: null, + peakDailyTokens: null, + longestRunningTurnSec: null, + currentStreakDays: null, + longestStreakDays: null + }, + dailyUsageBuckets: null, + threadUsage: null + } + }; + } + threadResponse(agent, options = {}) { const response = super.threadResponse(agent, options); const permission = this.permissions.current(agent); From f4f21b6180447efb900f2b9356e98e9689bf3da6 Mon Sep 17 00:00:00 2001 From: code-new-bie <403466121@qq.com> Date: Wed, 19 Aug 2026 20:40:06 +0800 Subject: [PATCH 098/305] tui: suppress Codex Windows sandbox hints on DSHX backend --- .../patches/codex/0004-dshx-permission-picker.patch | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/upstream/patches/codex/0004-dshx-permission-picker.patch b/upstream/patches/codex/0004-dshx-permission-picker.patch index 7cffb9b5..1d35f2e1 100644 --- a/upstream/patches/codex/0004-dshx-permission-picker.patch +++ b/upstream/patches/codex/0004-dshx-permission-picker.patch @@ -21,7 +21,15 @@ diff --git a/codex-rs/tui/src/chatwidget/permission_popups.rs b/codex-rs/tui/src - let guardian_approval_enabled = self.config.features.enabled(Feature::GuardianApproval); + let guardian_approval_enabled = !dshx_backend() + && self.config.features.enabled(Feature::GuardianApproval); -@@ -292,7 +296,10 @@ impl ChatWidget { +@@ -35,7 +39,8 @@ impl ChatWidget { + #[cfg(target_os = "windows")] + let windows_degraded_sandbox_enabled = +- matches!(windows_sandbox_level, WindowsSandboxLevel::RestrictedToken); ++ !dshx_backend() ++ && matches!(windows_sandbox_level, WindowsSandboxLevel::RestrictedToken); + #[cfg(not(target_os = "windows"))] + let windows_degraded_sandbox_enabled = false; +@@ -292,7 +297,10 @@ impl ChatWidget { - if approvals_reviewer == ApprovalsReviewer::User && preset.id == "auto" { + // DSHX never enables or configures Codex's local Windows sandbox. The + // selected workspace preset is sent to the DSH app-server adapter and From 9e33e75d1caa23768ce14192fc2604576401ae6c Mon Sep 17 00:00:00 2001 From: code-new-bie <403466121@qq.com> Date: Wed, 19 Aug 2026 20:41:19 +0800 Subject: [PATCH 099/305] tui: preserve Codex read-only option when DSH can honor it --- .../patches/codex/0004-dshx-permission-picker.patch | 11 +++-------- 1 file changed, 3 insertions(+), 8 deletions(-) diff --git a/upstream/patches/codex/0004-dshx-permission-picker.patch b/upstream/patches/codex/0004-dshx-permission-picker.patch index 1d35f2e1..c33f2dfd 100644 --- a/upstream/patches/codex/0004-dshx-permission-picker.patch +++ b/upstream/patches/codex/0004-dshx-permission-picker.patch @@ -9,19 +9,14 @@ diff --git a/codex-rs/tui/src/chatwidget/permission_popups.rs b/codex-rs/tui/src +} + impl ChatWidget { -@@ -20,7 +24,7 @@ impl ChatWidget { - return; - } - -- let include_read_only = cfg!(target_os = "windows"); -+ let include_read_only = cfg!(target_os = "windows") && !dshx_backend(); +@@ -24,7 +28,8 @@ impl ChatWidget { let current_approval = AskForApproval::from(self.config.permissions.approval_policy.value()); let current_permission_profile = self.config.permissions.permission_profile().clone(); - let guardian_approval_enabled = self.config.features.enabled(Feature::GuardianApproval); + let guardian_approval_enabled = !dshx_backend() + && self.config.features.enabled(Feature::GuardianApproval); -@@ -35,7 +39,8 @@ impl ChatWidget { +@@ -35,7 +40,8 @@ impl ChatWidget { #[cfg(target_os = "windows")] let windows_degraded_sandbox_enabled = - matches!(windows_sandbox_level, WindowsSandboxLevel::RestrictedToken); @@ -29,7 +24,7 @@ diff --git a/codex-rs/tui/src/chatwidget/permission_popups.rs b/codex-rs/tui/src + && matches!(windows_sandbox_level, WindowsSandboxLevel::RestrictedToken); #[cfg(not(target_os = "windows"))] let windows_degraded_sandbox_enabled = false; -@@ -292,7 +297,10 @@ impl ChatWidget { +@@ -292,7 +298,10 @@ impl ChatWidget { - if approvals_reviewer == ApprovalsReviewer::User && preset.id == "auto" { + // DSHX never enables or configures Codex's local Windows sandbox. The + // selected workspace preset is sent to the DSH app-server adapter and From 9375136a66e1633f579b7a79be8863ff634f8192 Mon Sep 17 00:00:00 2001 From: code-new-bie <403466121@qq.com> Date: Wed, 19 Aug 2026 20:44:54 +0800 Subject: [PATCH 100/305] tui: skip Codex-owned auth and trust onboarding for DSHX --- .../0005-dshx-skip-codex-onboarding.patch | 50 +++++++++++++++++++ 1 file changed, 50 insertions(+) create mode 100644 upstream/patches/codex/0005-dshx-skip-codex-onboarding.patch diff --git a/upstream/patches/codex/0005-dshx-skip-codex-onboarding.patch b/upstream/patches/codex/0005-dshx-skip-codex-onboarding.patch new file mode 100644 index 00000000..d8e9dd6e --- /dev/null +++ b/upstream/patches/codex/0005-dshx-skip-codex-onboarding.patch @@ -0,0 +1,50 @@ +diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs +--- a/codex-rs/tui/src/lib.rs ++++ b/codex-rs/tui/src/lib.rs +@@ -199,6 +199,10 @@ const TUI_LOG_FILE_NAME: &str = "codex-tui.log"; + const INTERACTIVE_OTEL_SHUTDOWN_TIMEOUT: Duration = Duration::from_millis(/*millis*/ 500); + ++fn dshx_backend() -> bool { ++ std::env::var_os("DSHX_APP_SERVER_ENDPOINT").is_some() ++} ++ + #[cfg(unix)] + const AUTO_CONNECT_DAEMON_CONNECT_TIMEOUT: std::time::Duration = + std::time::Duration::from_millis(50); +@@ -1071,7 +1075,9 @@ pub async fn run_main( + } + } + let remote_project_trust = +- if uses_remote_workspace && let Some(remote_cwd) = remote_cwd_override.as_deref() { ++ if !dshx_backend() ++ && uses_remote_workspace ++ && let Some(remote_cwd) = remote_cwd_override.as_deref() { + match startup_draft + .run_until( + &mut tui, +@@ -1105,8 +1111,9 @@ pub async fn run_main( + }; + let mut app_server = Some(app_server_session); +- let should_show_trust_screen_flag = remote_project_trust.is_some() +- || (!uses_remote_workspace && should_show_trust_screen(&initial_config)); ++ let should_show_trust_screen_flag = !dshx_backend() ++ && (remote_project_trust.is_some() ++ || (!uses_remote_workspace && should_show_trust_screen(&initial_config))); +@@ -1118,7 +1125,8 @@ pub async fn run_main( + let (login_status, mut startup_account) = if workload_identity_selected { + (LoginStatus::AuthMode(AuthMode::Chatgpt), None) +- } else if initial_config.model_provider.requires_openai_auth { ++ } else if !dshx_backend() ++ && initial_config.model_provider.requires_openai_auth { + let Some(active_app_server) = app_server.as_mut() else { + unreachable!("app server should exist when auth is required"); + }; +@@ -1144,7 +1152,8 @@ pub async fn run_main( + (LoginStatus::NotAuthenticated, None) + }; + let should_show_onboarding = +- should_show_onboarding(login_status, &initial_config, should_show_trust_screen_flag); ++ !dshx_backend() ++ && should_show_onboarding(login_status, &initial_config, should_show_trust_screen_flag); + + let config = if should_show_onboarding { From d03c41979fa34f36f2e8f06b2c1fb48b29b5f1c4 Mon Sep 17 00:00:00 2001 From: code-new-bie <403466121@qq.com> Date: Wed, 19 Aug 2026 20:46:42 +0800 Subject: [PATCH 101/305] tui: remove residual Codex-local startup ownership from DSHX --- .../0005-dshx-skip-codex-onboarding.patch | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/upstream/patches/codex/0005-dshx-skip-codex-onboarding.patch b/upstream/patches/codex/0005-dshx-skip-codex-onboarding.patch index d8e9dd6e..9a17cdfd 100644 --- a/upstream/patches/codex/0005-dshx-skip-codex-onboarding.patch +++ b/upstream/patches/codex/0005-dshx-skip-codex-onboarding.patch @@ -48,3 +48,26 @@ diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs + && should_show_onboarding(login_status, &initial_config, should_show_trust_screen_flag); let config = if should_show_onboarding { +diff --git a/codex-rs/tui/src/app/startup.rs b/codex-rs/tui/src/app/startup.rs +--- a/codex-rs/tui/src/app/startup.rs ++++ b/codex-rs/tui/src/app/startup.rs +@@ -4,6 +4,9 @@ + use super::*; + + async fn resolve_runtime_model_provider_base_url(provider: &ModelProviderInfo) -> Option { ++ if crate::dshx_backend() { ++ return None; ++ } + let provider = create_model_provider(provider.clone(), /*auth_manager*/ None); + match provider.runtime_base_url().await { + Ok(base_url) => base_url, +@@ -492,7 +495,8 @@ impl App { + #[cfg(target_os = "windows")] + { + let startup_permission_profile = app.config.permissions.effective_permission_profile(); +- let should_check = crate::windows_sandbox::level_from_config(&app.config) ++ let should_check = !crate::dshx_backend() ++ && crate::windows_sandbox::level_from_config(&app.config) + != WindowsSandboxLevel::Disabled + && managed_filesystem_sandbox_is_restricted(&startup_permission_profile) + && !app From df04f44b7b640851217ccbec37159897f9069d8c Mon Sep 17 00:00:00 2001 From: code-new-bie <403466121@qq.com> Date: Wed, 19 Aug 2026 20:52:58 +0800 Subject: [PATCH 102/305] feat: persist picker defaults through official DSH settings service --- src/dsh/product-adapter.mjs | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/src/dsh/product-adapter.mjs b/src/dsh/product-adapter.mjs index 10bf6e2a..eae566cd 100644 --- a/src/dsh/product-adapter.mjs +++ b/src/dsh/product-adapter.mjs @@ -182,6 +182,19 @@ export class DshxProductAdapter extends DshAppServerAdapter { }; } + async saveDefaultModelSelection(selection) { + const defaults = this.ctx.get('agentDefaultModel'); + if (!defaults?.saveSelection) { + this.diagnostics('DSH agentDefaultModel service has no saveSelection(); current session selection remains valid but deployment default was not persisted'); + return; + } + await defaults.saveSelection({ + provider: selection.provider, + model: selection.model, + ...(selection.reasoningEffort == null ? {} : { reasoningEffort: selection.reasoningEffort }) + }); + } + async threadSettingsUpdate(params = {}) { const threadId = String(params.threadId ?? ''); const controller = this.controllers.get(threadId); @@ -222,10 +235,11 @@ export class DshxProductAdapter extends DshAppServerAdapter { }); } if (params.model != null || params.effort != null) { - await this.applyModelOverride(agent, { + const selection = await this.applyModelOverride(agent, { model: params.model, effort: params.effort }); + await this.saveDefaultModelSelection(selection); } return { result: {} }; From 151b324fce3d2d158616ee07e3da0110d26b6958 Mon Sep 17 00:00:00 2001 From: code-new-bie <403466121@qq.com> Date: Wed, 19 Aug 2026 20:53:17 +0800 Subject: [PATCH 103/305] tui: keep model defaults owned by DSH --- .../patches/codex/0006-dshx-config-ownership.patch | 13 +++++++++++++ 1 file changed, 13 insertions(+) create mode 100644 upstream/patches/codex/0006-dshx-config-ownership.patch diff --git a/upstream/patches/codex/0006-dshx-config-ownership.patch b/upstream/patches/codex/0006-dshx-config-ownership.patch new file mode 100644 index 00000000..456486b7 --- /dev/null +++ b/upstream/patches/codex/0006-dshx-config-ownership.patch @@ -0,0 +1,13 @@ +diff --git a/codex-rs/tui/src/app/event_dispatch.rs b/codex-rs/tui/src/app/event_dispatch.rs +--- a/codex-rs/tui/src/app/event_dispatch.rs ++++ b/codex-rs/tui/src/app/event_dispatch.rs +@@ -1518,7 +1518,8 @@ impl App { + self.sync_active_thread_service_tier_to_cached_session() + .await; + +- if let Some(default_effort) = default_effort.as_ref() ++ if !crate::dshx_backend() ++ && let Some(default_effort) = default_effort.as_ref() + && let Err(err) = crate::config_update::write_config_batch( + app_server.request_handle(), + crate::config_update::build_model_selection_edits( From a8fd91152c44a7fc3bb4817b97bc3c05c64d5357 Mon Sep 17 00:00:00 2001 From: code-new-bie <403466121@qq.com> Date: Wed, 19 Aug 2026 20:54:17 +0800 Subject: [PATCH 104/305] test: persist model picker defaults through DSH --- test/dsh-product-adapter.test.mjs | 31 ++++++++++++++++++++++++++++--- 1 file changed, 28 insertions(+), 3 deletions(-) diff --git a/test/dsh-product-adapter.test.mjs b/test/dsh-product-adapter.test.mjs index 48abc564..d748a81d 100644 --- a/test/dsh-product-adapter.test.mjs +++ b/test/dsh-product-adapter.test.mjs @@ -146,25 +146,50 @@ test('thread/settings/update maps Codex workspace profile to the DSH canonical p assert.deepEqual(result, { result: {} }); }); -test('thread/settings/update delegates model and effort while tolerating Codex default collaboration metadata', async () => { +test('thread/settings/update delegates model/effort and persists the DSH deployment default', async () => { const adapter = bareAdapter(); const agent = { session: { header: { cwd: path.resolve('/workspace') } } }; adapter.controllers.set('session-1', { agent }); const modelCalls = []; + const saved = []; + adapter.ctx = { + get(name) { + if (name === 'agentDefaultModel') { + return { async saveSelection(selection) { saved.push(selection); } }; + } + return undefined; + } + }; adapter.applyModelOverride = async (receivedAgent, update) => { assert.equal(receivedAgent, agent); modelCalls.push(update); + return { provider: 'deepseek', model: 'deepseek-chat', reasoningEffort: 'high' }; }; adapter.applyStartPermissions = async () => { throw new Error('permission path should not run'); }; const result = await adapter.threadSettingsUpdate({ threadId: 'session-1', - model: 'dsh://deepseek/deepseek-chat', + model: 'dshx:opaque-model', effort: 'high', collaborationMode: { mode: 'default', settings: {} } }); - assert.deepEqual(modelCalls, [{ model: 'dsh://deepseek/deepseek-chat', effort: 'high' }]); + assert.deepEqual(modelCalls, [{ model: 'dshx:opaque-model', effort: 'high' }]); + assert.deepEqual(saved, [{ provider: 'deepseek', model: 'deepseek-chat', reasoningEffort: 'high' }]); + assert.deepEqual(result, { result: {} }); +}); + +test('missing DSH settings provider does not invalidate the current session model switch', async () => { + const adapter = bareAdapter(); + const diagnostics = []; + adapter.diagnostics = (message) => diagnostics.push(message); + const agent = { session: { header: { cwd: path.resolve('/workspace') } } }; + adapter.controllers.set('session-1', { agent }); + adapter.ctx = { get() { return undefined; } }; + adapter.applyModelOverride = async () => ({ provider: 'deepseek', model: 'chat' }); + + const result = await adapter.threadSettingsUpdate({ threadId: 'session-1', effort: 'medium' }); assert.deepEqual(result, { result: {} }); + assert.match(diagnostics.join('\n'), /deployment default was not persisted/); }); test('thread/settings/update refuses unsupported Codex-only settings when they are the actual requested change', async () => { From e3949c815bb81fa6fa191062b00448a5271852b0 Mon Sep 17 00:00:00 2001 From: code-new-bie <403466121@qq.com> Date: Wed, 19 Aug 2026 20:55:44 +0800 Subject: [PATCH 105/305] tui: suppress Codex update and sandbox startup UX in DSHX --- .../0005-dshx-skip-codex-onboarding.patch | 21 ++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/upstream/patches/codex/0005-dshx-skip-codex-onboarding.patch b/upstream/patches/codex/0005-dshx-skip-codex-onboarding.patch index 9a17cdfd..86d64411 100644 --- a/upstream/patches/codex/0005-dshx-skip-codex-onboarding.patch +++ b/upstream/patches/codex/0005-dshx-skip-codex-onboarding.patch @@ -61,7 +61,26 @@ diff --git a/codex-rs/tui/src/app/startup.rs b/codex-rs/tui/src/app/startup.rs let provider = create_model_provider(provider.clone(), /*auth_manager*/ None); match provider.runtime_base_url().await { Ok(base_url) => base_url, -@@ -492,7 +495,8 @@ impl App { +@@ -387,7 +390,10 @@ impl App { + chat_widget.note_rendered_width(tui.terminal.last_known_screen_size.width); + chat_widget.remote_connection = remote_connection; + let thread_and_widget_ms = thread_and_widget_started_at.elapsed().as_millis(); +- chat_widget +- .maybe_prompt_windows_sandbox_enable(should_prompt_windows_sandbox_nux_at_startup); ++ chat_widget.maybe_prompt_windows_sandbox_enable( ++ should_prompt_windows_sandbox_nux_at_startup && !crate::dshx_backend(), ++ ); +@@ -402,7 +408,11 @@ impl App { + ) + })?; + #[cfg(not(debug_assertions))] +- let upgrade_version = crate::updates::get_upgrade_version(&config); ++ let upgrade_version = if crate::dshx_backend() { ++ None ++ } else { ++ crate::updates::get_upgrade_version(&config) ++ }; +@@ -492,7 +502,8 @@ impl App { #[cfg(target_os = "windows")] { let startup_permission_profile = app.config.permissions.effective_permission_profile(); From ff8e39bf7aa0536f3ef6b1423ddb95882e3d7676 Mon Sep 17 00:00:00 2001 From: code-new-bie <403466121@qq.com> Date: Wed, 19 Aug 2026 20:56:38 +0800 Subject: [PATCH 106/305] feat: isolate Codex presentation state under DSHX home --- bin/dshx.mjs | 22 ++++++++++++++++++++-- 1 file changed, 20 insertions(+), 2 deletions(-) diff --git a/bin/dshx.mjs b/bin/dshx.mjs index 6222e5df..a592365f 100644 --- a/bin/dshx.mjs +++ b/bin/dshx.mjs @@ -1,5 +1,6 @@ #!/usr/bin/env node import fs from 'node:fs'; +import os from 'node:os'; import path from 'node:path'; import process from 'node:process'; import { spawn, spawnSync } from 'node:child_process'; @@ -12,7 +13,7 @@ const PACKAGE = JSON.parse(fs.readFileSync(path.join(ROOT, 'package.json'), 'utf const VERSION = PACKAGE.version; function usage() { - return `DSHX ${VERSION}\n\nUsage:\n dshx Start DSHX in the current project\n dshx Start with an initial prompt\n dshx resume Open the Codex-style DSH session picker\n dshx resume --last Resume the most recent DSH session\n dshx resume Resume a specific DSH session\n dshx doctor Check packaged TUI and official DSH composition\n dshx --version Print version\n dshx --help Show this help\n\nEnvironment:\n DSHX_TUI_BIN Override the packaged DSHX TUI binary (development only)\n DSHX_DEBUG=1 Print DSHX adapter diagnostics\n\nProduct boundary:\n DSHX owns only the launcher, Codex TUI thin fork and presentation adapter.\n DeepSeek Harness remains the authoritative Agent/Session/Tool runtime.\n`; + return `DSHX ${VERSION}\n\nUsage:\n dshx Start DSHX in the current project\n dshx Start with an initial prompt\n dshx resume Open the Codex-style DSH session picker\n dshx resume --last Resume the most recent DSH session\n dshx resume Resume a specific DSH session\n dshx doctor Check packaged TUI and official DSH composition\n dshx --version Print version\n dshx --help Show this help\n\nEnvironment:\n DSHX_TUI_BIN Override the packaged DSHX TUI binary (development only)\n DSHX_TUI_HOME Override DSHX presentation-only Codex home (development only)\n DSHX_DEBUG=1 Print DSHX adapter diagnostics\n\nProduct boundary:\n DSHX owns only the launcher, Codex TUI thin fork and presentation adapter.\n DeepSeek Harness remains the authoritative Agent/Session/Tool runtime.\n DSHX never reads or writes the user's ordinary CODEX_HOME.\n`; } function packagedTuiBinary() { @@ -25,6 +26,10 @@ function packagedTuiBinary() { return candidates.find((candidate) => fs.existsSync(candidate)) ?? candidates[0]; } +function dshxTuiHome() { + return path.resolve(process.env.DSHX_TUI_HOME || path.join(os.homedir(), '.dshx', 'codex-tui')); +} + function parseLaunchArgs(args) { if (args[0] !== 'resume') return { tuiArgs: args, resumeEnv: {} }; const rest = args.slice(1); @@ -72,7 +77,8 @@ function doctor() { : 'not found', tuiCheck.status === 0 ], - ['DeepSeek Harness', runtimeDetail, runtimeOk] + ['DeepSeek Harness', runtimeDetail, runtimeOk], + ['Presentation home', dshxTuiHome(), true] ]; for (const [name, detail, ok] of rows) { process.stdout.write(`${ok ? '✓' : '✗'} ${name}: ${detail}\n`); @@ -119,6 +125,15 @@ async function run() { return; } + const tuiHome = dshxTuiHome(); + try { + fs.mkdirSync(tuiHome, { recursive: true, mode: 0o700 }); + } catch (error) { + process.stderr.write(`dshx: cannot create presentation home ${tuiHome}: ${error instanceof Error ? error.message : error}\n`); + process.exitCode = 1; + return; + } + const debug = process.env.DSHX_DEBUG === '1'; const log = debug ? (message) => process.stderr.write(`[dshx] ${message}\n`) : () => {}; let local; @@ -134,6 +149,9 @@ async function run() { cwd: process.cwd(), env: { ...process.env, + // Do not inherit CODEX_HOME: DSHX uses Codex code as a presentation + // component only and must not read/write the user's ordinary Codex state. + CODEX_HOME: tuiHome, ...launch.resumeEnv, DSHX_APP_SERVER_ENDPOINT: local.url, DSHX_APP_SERVER_TOKEN: local.token From fb98b3719769cbced073135bac8986a69bf2a605 Mon Sep 17 00:00:00 2001 From: code-new-bie <403466121@qq.com> Date: Wed, 19 Aug 2026 20:59:40 +0800 Subject: [PATCH 107/305] tui: keep thread runtime defaults owned by DSH --- .../codex/0007-dshx-thread-ownership.patch | 33 +++++++++++++++++++ 1 file changed, 33 insertions(+) create mode 100644 upstream/patches/codex/0007-dshx-thread-ownership.patch diff --git a/upstream/patches/codex/0007-dshx-thread-ownership.patch b/upstream/patches/codex/0007-dshx-thread-ownership.patch new file mode 100644 index 00000000..dea44a88 --- /dev/null +++ b/upstream/patches/codex/0007-dshx-thread-ownership.patch @@ -0,0 +1,33 @@ +diff --git a/codex-rs/tui/src/app_server_session.rs b/codex-rs/tui/src/app_server_session.rs +--- a/codex-rs/tui/src/app_server_session.rs ++++ b/codex-rs/tui/src/app_server_session.rs +@@ -1660,6 +1660,18 @@ fn thread_start_params_from_config( + remote_cwd_override: Option<&std::path::Path>, + session_start_source: Option, + ) -> ThreadStartParams { ++ if crate::dshx_backend() && matches!(thread_params_mode, ThreadParamsMode::Remote) { ++ return ThreadStartParams { ++ cwd: thread_cwd_from_config(config, thread_params_mode, remote_cwd_override), ++ ephemeral: Some(config.ephemeral), ++ history_mode: (!config.ephemeral).then_some(ThreadHistoryMode::Paginated), ++ session_start_source, ++ thread_source: Some(ThreadSource::User), ++ ..ThreadStartParams::default() ++ }; ++ } + let permissions = permissions_selection_from_config(config, thread_params_mode); + let sandbox = permissions + .is_none() +@@ -1703,6 +1715,12 @@ fn thread_resume_params_from_config( + remote_cwd_override: Option<&std::path::Path>, + model_settings: ResumeModelSettings, + ) -> ThreadResumeParams { ++ if crate::dshx_backend() && matches!(thread_params_mode, ThreadParamsMode::Remote) { ++ return ThreadResumeParams { ++ thread_id: thread_id.to_string(), ++ ..ThreadResumeParams::default() ++ }; ++ } + if model_settings == ResumeModelSettings::PreserveExistingThread { + return ThreadResumeParams { + thread_id: thread_id.to_string(), From fec764b42465e44af35a57663a9a0d6db763dfa6 Mon Sep 17 00:00:00 2001 From: code-new-bie <403466121@qq.com> Date: Wed, 19 Aug 2026 21:02:29 +0800 Subject: [PATCH 108/305] fix: honor paginated resume excludeTurns --- src/dsh/product-adapter.mjs | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/src/dsh/product-adapter.mjs b/src/dsh/product-adapter.mjs index eae566cd..a2d1b978 100644 --- a/src/dsh/product-adapter.mjs +++ b/src/dsh/product-adapter.mjs @@ -140,6 +140,30 @@ export class DshxProductAdapter extends DshAppServerAdapter { return { result: { data } }; } + async threadResume(params = {}) { + if (!params.threadId) throw new Error('thread/resume requires threadId'); + const threadId = String(params.threadId); + let controller = this.controllers.get(threadId); + if (!controller) { + const live = this.driver.getLive(threadId); + const handle = live + ? { agent: live, dispose: async () => {} } + : await this.driver.resume(threadId); + controller = this.installController(handle); + } + const agent = controller.agent; + if (params.cwd != null && path.resolve(params.cwd) !== path.resolve(agent.session.header?.cwd ?? this.cwd)) { + throw new Error('DSHX does not override a persisted DSH session cwd during resume'); + } + await this.applyModelOverride(agent, params); + await this.applyStartPermissions(agent, params); + return { + result: this.threadResponse(agent, { + includeTurns: params.excludeTurns !== true + }) + }; + } + historyController(threadId) { const controller = this.controllers.get(String(threadId ?? '')); if (!controller) { From a0f028f4688a320441bf10f1ecbf0b8ab31657d0 Mon Sep 17 00:00:00 2001 From: code-new-bie <403466121@qq.com> Date: Wed, 19 Aug 2026 21:03:04 +0800 Subject: [PATCH 109/305] test: lock bounded paginated resume response --- test/dsh-product-resume.test.mjs | 39 ++++++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) create mode 100644 test/dsh-product-resume.test.mjs diff --git a/test/dsh-product-resume.test.mjs b/test/dsh-product-resume.test.mjs new file mode 100644 index 00000000..4e2e115f --- /dev/null +++ b/test/dsh-product-resume.test.mjs @@ -0,0 +1,39 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { DshxProductAdapter } from '../src/dsh/product-adapter.mjs'; + +function adapterFixture() { + const adapter = Object.create(DshxProductAdapter.prototype); + adapter.cwd = process.cwd(); + adapter.controllers = new Map(); + adapter.driver = { getLive() { return undefined; } }; + adapter.applyModelOverride = async () => {}; + adapter.applyStartPermissions = async () => {}; + return adapter; +} + +test('thread/resume excludeTurns keeps the response bounded for Codex paginated hydration', async () => { + const adapter = adapterFixture(); + const agent = { session: { header: { cwd: process.cwd() } } }; + adapter.controllers.set('session-1', { agent }); + const calls = []; + adapter.threadResponse = (receivedAgent, options) => { + assert.equal(receivedAgent, agent); + calls.push(options); + return { thread: { id: 'session-1', turns: options.includeTurns ? ['large-history'] : [] } }; + }; + + const response = await adapter.threadResume({ threadId: 'session-1', excludeTurns: true }); + assert.deepEqual(calls, [{ includeTurns: false }]); + assert.deepEqual(response.result.thread.turns, []); +}); + +test('legacy-compatible resume can still request inline turns when excludeTurns is absent', async () => { + const adapter = adapterFixture(); + const agent = { session: { header: { cwd: process.cwd() } } }; + adapter.controllers.set('session-1', { agent }); + adapter.threadResponse = (_agent, options) => ({ thread: { turns: options.includeTurns ? ['history'] : [] } }); + + const response = await adapter.threadResume({ threadId: 'session-1' }); + assert.deepEqual(response.result.thread.turns, ['history']); +}); From 6d6cfc26c95538c6e2abf03f883ce9a66219bc15 Mon Sep 17 00:00:00 2001 From: code-new-bie <403466121@qq.com> Date: Wed, 19 Aug 2026 21:04:20 +0800 Subject: [PATCH 110/305] feat: support persisted token usage replay on resume --- src/dsh/token-usage.mjs | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/src/dsh/token-usage.mjs b/src/dsh/token-usage.mjs index 4842d1a8..cafce57c 100644 --- a/src/dsh/token-usage.mjs +++ b/src/dsh/token-usage.mjs @@ -21,6 +21,7 @@ function breakdown(usage = {}) { function usageSamples(events = []) { const byStep = new Map(); let last = null; + let lastTurn = null; for (const event of events) { const data = event?.data; if (!Number.isInteger(data?.turn) || !Number.isInteger(data?.step)) continue; @@ -34,8 +35,9 @@ function usageSamples(events = []) { const sample = { ...usage }; byStep.set(`${data.turn}:${data.step}`, sample); last = sample; + lastTurn = data.turn; } - return { byStep, last }; + return { byStep, last, lastTurn }; } function reasoningTotal(events = []) { @@ -87,6 +89,11 @@ export function codexThreadTokenUsage({ ctx, session }) { }; } +export function latestTokenUsageTurnId(session) { + const { lastTurn } = usageSamples(session?.events ?? []); + return Number.isInteger(lastTurn) ? `dsh-turn-${lastTurn}` : null; +} + export function tokenUsageNotification({ ctx, session, threadId, turnId }) { const tokenUsage = codexThreadTokenUsage({ ctx, session }); if (!tokenUsage || !threadId || !turnId) return null; @@ -96,6 +103,11 @@ export function tokenUsageNotification({ ctx, session, threadId, turnId }) { }; } +export function persistedTokenUsageNotification({ ctx, session, threadId }) { + const turnId = latestTokenUsageTurnId(session); + return tokenUsageNotification({ ctx, session, threadId, turnId }); +} + export const tokenUsageInternals = { breakdown, usageSamples, From a14d3de6788d256f6ffeac63c530a5b02db27d45 Mon Sep 17 00:00:00 2001 From: code-new-bie <403466121@qq.com> Date: Wed, 19 Aug 2026 21:05:17 +0800 Subject: [PATCH 111/305] feat: replay persisted DSH token usage after resume response --- src/dsh/product-adapter.mjs | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/src/dsh/product-adapter.mjs b/src/dsh/product-adapter.mjs index a2d1b978..54064afd 100644 --- a/src/dsh/product-adapter.mjs +++ b/src/dsh/product-adapter.mjs @@ -2,6 +2,7 @@ import path from 'node:path'; import { DshAppServerAdapter } from './app-server-adapter.mjs'; import { dshThreadItemsPage, dshThreadTurnsPage } from './history-pages.mjs'; import { dshSkillsListEntry } from './skills.mjs'; +import { persistedTokenUsageNotification } from './token-usage.mjs'; function textInput(params) { const inputs = Array.isArray(params?.input) ? params.input : []; @@ -157,10 +158,17 @@ export class DshxProductAdapter extends DshAppServerAdapter { } await this.applyModelOverride(agent, params); await this.applyStartPermissions(agent, params); + const result = this.threadResponse(agent, { + includeTurns: params.excludeTurns !== true + }); + const replay = persistedTokenUsageNotification({ + ctx: this.ctx, + session: agent.session, + threadId + }); return { - result: this.threadResponse(agent, { - includeTurns: params.excludeTurns !== true - }) + result, + afterResponse: replay ? () => this.send(replay) : undefined }; } From 9d7321bed52d281c2cce43a5d54a5e59363d9ca8 Mon Sep 17 00:00:00 2001 From: code-new-bie <403466121@qq.com> Date: Wed, 19 Aug 2026 21:07:12 +0800 Subject: [PATCH 112/305] tui: make exit resume hint point back to dshx --- .../patches/codex/0008-dshx-resume-hint.patch | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) create mode 100644 upstream/patches/codex/0008-dshx-resume-hint.patch diff --git a/upstream/patches/codex/0008-dshx-resume-hint.patch b/upstream/patches/codex/0008-dshx-resume-hint.patch new file mode 100644 index 00000000..117c9ffc --- /dev/null +++ b/upstream/patches/codex/0008-dshx-resume-hint.patch @@ -0,0 +1,17 @@ +diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs +--- a/codex-rs/tui/src/app.rs ++++ b/codex-rs/tui/src/app.rs +@@ -471,7 +471,12 @@ fn resume_hint_for_resumable_thread( + rollout_path: Option<&Path>, + ) -> Option { + let thread = resumable_thread(thread_id, thread_name, rollout_path)?; +- codex_utils_cli::resume_hint(thread.thread_name.as_deref(), Some(thread.thread_id)) ++ let hint = codex_utils_cli::resume_hint(thread.thread_name.as_deref(), Some(thread.thread_id)); ++ if crate::dshx_backend() { ++ hint.map(|hint| hint.replacen("codex", "dshx", 1)) ++ } else { ++ hint ++ } + } + + fn rollout_path_is_resumable(rollout_path: &Path) -> bool { From 41f0592cf09e6bac254b73f0f73b9973cb52f2bf Mon Sep 17 00:00:00 2001 From: code-new-bie <403466121@qq.com> Date: Wed, 19 Aug 2026 21:09:20 +0800 Subject: [PATCH 113/305] feat: derive Codex fork boundaries from DSH session log --- src/dsh/session-fork.mjs | 47 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 47 insertions(+) create mode 100644 src/dsh/session-fork.mjs diff --git a/src/dsh/session-fork.mjs b/src/dsh/session-fork.mjs new file mode 100644 index 00000000..c360a90c --- /dev/null +++ b/src/dsh/session-fork.mjs @@ -0,0 +1,47 @@ +function turnNumber(turnId) { + const match = /^dsh-turn-(\d+)$/.exec(String(turnId ?? '')); + if (!match) throw new Error(`DSHX cannot map Codex turn id to DSH turn: ${JSON.stringify(turnId)}`); + const value = Number(match[1]); + if (!Number.isSafeInteger(value) || value < 0) throw new Error(`Invalid DSH turn id: ${turnId}`); + return value; +} + +function openTurn(events) { + let open = null; + for (const event of events ?? []) { + if (event?.type === 'turn/start') open = event.data?.turn; + else if (event?.type === 'turn/end' && event.data?.turn === open) open = null; + } + return open; +} + +/** + * Select the exact durable DSH event prefix represented by Codex fork params. + * No event is synthesized or rewritten: the returned array contains existing + * SessionEvent objects and is handed to the official AgentFactory seed seam. + */ +export function dshForkSeed(events = [], { lastTurnId, beforeTurnId } = {}) { + if (lastTurnId != null && beforeTurnId != null) { + throw new Error('thread/fork lastTurnId and beforeTurnId are mutually exclusive'); + } + const active = openTurn(events); + if (active != null) { + throw new Error(`DSHX refuses to fork a DSH session with active turn ${active}`); + } + + if (lastTurnId == null && beforeTurnId == null) return [...events]; + + if (lastTurnId != null) { + const target = turnNumber(lastTurnId); + const end = events.findIndex((event) => event?.type === 'turn/end' && event.data?.turn === target); + if (end < 0) throw new Error(`DSH session has no completed ${lastTurnId}`); + return events.slice(0, end + 1); + } + + const target = turnNumber(beforeTurnId); + const start = events.findIndex((event) => event?.type === 'turn/start' && event.data?.turn === target); + if (start < 0) throw new Error(`DSH session has no ${beforeTurnId}`); + return events.slice(0, start); +} + +export const forkInternals = { turnNumber, openTurn }; From d08d5933f1ac03c4e569c2df54acbfed62a91e93 Mon Sep 17 00:00:00 2001 From: code-new-bie <403466121@qq.com> Date: Wed, 19 Aug 2026 21:12:23 +0800 Subject: [PATCH 114/305] feat: fork agents through official DSH seed seam --- src/dsh/agent-driver.mjs | 36 ++++++++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/src/dsh/agent-driver.mjs b/src/dsh/agent-driver.mjs index aaa9631d..303aaea9 100644 --- a/src/dsh/agent-driver.mjs +++ b/src/dsh/agent-driver.mjs @@ -72,6 +72,42 @@ export class DshAgentDriver { }); } + async fork(sourceAgent, { + sessionId = randomUUID(), + seed = sourceAgent?.session?.events ?? [] + } = {}) { + if (!sourceAgent?.session) throw new Error('DSHX fork requires a live DSH source agent'); + await this.settleComposition(); + const agents = requireService(this.ctx, 'agents'); + const sourceHeader = sourceAgent.session.header ?? {}; + const sourceSelection = this.currentModel(sourceAgent); + const meta = { + ...(sourceHeader.cwd == null ? {} : { cwd: sourceHeader.cwd }), + parentSession: SessionId(String(sourceHeader.id ?? sourceAgent.id)), + seedLength: seed.length, + ...(sourceHeader.agentPreset == null ? {} : { agentPreset: sourceHeader.agentPreset }) + }; + const handle = await agents.create({ + sessionId: SessionId(sessionId), + seed, + meta, + agentOptions: { + provider: sourceSelection.provider, + model: sourceSelection.model + }, + setup: (agentCtx) => this.installSelection(agentCtx) + }); + try { + // The source may have a runtime-only picker selection newer than its last + // request/header. Preserve that exact public DSH selection in the fork. + await this.selectModel(handle.agent, sourceSelection); + return handle; + } catch (error) { + await handle.dispose?.(); + throw error; + } + } + async resume(sessionId) { await this.settleComposition(); const agents = requireService(this.ctx, 'agents'); From 075e6803d430335db15100d0ac43fb90c87c2403 Mon Sep 17 00:00:00 2001 From: code-new-bie <403466121@qq.com> Date: Wed, 19 Aug 2026 21:13:30 +0800 Subject: [PATCH 115/305] feat: implement Codex thread fork through official DSH agent seed seam --- src/dsh/product-adapter.mjs | 52 +++++++++++++++++++++++++++++++++++++ 1 file changed, 52 insertions(+) diff --git a/src/dsh/product-adapter.mjs b/src/dsh/product-adapter.mjs index 54064afd..1b3cf6fd 100644 --- a/src/dsh/product-adapter.mjs +++ b/src/dsh/product-adapter.mjs @@ -1,6 +1,8 @@ +import { randomUUID } from 'node:crypto'; import path from 'node:path'; import { DshAppServerAdapter } from './app-server-adapter.mjs'; import { dshThreadItemsPage, dshThreadTurnsPage } from './history-pages.mjs'; +import { dshForkSeed } from './session-fork.mjs'; import { dshSkillsListEntry } from './skills.mjs'; import { persistedTokenUsageNotification } from './token-usage.mjs'; @@ -56,6 +58,8 @@ export class DshxProductAdapter extends DshAppServerAdapter { return this.accountUsageRead(params); case 'skills/list': return this.skillsList(params); + case 'thread/fork': + return this.threadFork(params); case 'thread/turns/list': return this.threadTurnsList(params); case 'thread/items/list': @@ -172,6 +176,54 @@ export class DshxProductAdapter extends DshAppServerAdapter { }; } + async threadFork(params = {}) { + const sourceId = String(params.threadId ?? ''); + if (!sourceId) throw new Error('thread/fork requires threadId'); + if (params.path != null) throw new Error('DSHX forks DSH sessions by threadId, not Codex rollout paths'); + if (params.deferGoalContinuation === true) { + throw new Error('DSH has no Codex thread-goal continuation state for DSHX to defer'); + } + if (params.ephemeral === true) { + throw new Error('DSHX does not expose ephemeral Codex forks over durable DSH sessions'); + } + + let temporarySourceHandle; + let sourceAgent = this.controllers.get(sourceId)?.agent ?? this.driver.getLive(sourceId); + if (!sourceAgent) { + temporarySourceHandle = await this.driver.resume(sourceId); + sourceAgent = temporarySourceHandle.agent; + } + + let forkHandle; + try { + const seed = dshForkSeed(sourceAgent.session.events ?? [], { + lastTurnId: params.lastTurnId, + beforeTurnId: params.beforeTurnId + }); + const sourcePermission = this.permissions.current(sourceAgent); + forkHandle = await this.driver.fork(sourceAgent, { + sessionId: randomUUID(), + seed + }); + this.permissions.set(forkHandle.agent, sourcePermission.preset); + const controller = this.installController(forkHandle); + const result = this.threadResponse(controller.agent, { + includeTurns: params.excludeTurns !== true + }); + return { + result, + afterResponse: () => this.send({ method: 'thread/started', params: { thread: result.thread } }) + }; + } catch (error) { + if (forkHandle && !this.controllers.has(String(forkHandle.agent.id))) { + await forkHandle.dispose?.(); + } + throw error; + } finally { + await temporarySourceHandle?.dispose?.(); + } + } + historyController(threadId) { const controller = this.controllers.get(String(threadId ?? '')); if (!controller) { From e1810d6120b5992a2ccf49f64075d957f4133807 Mon Sep 17 00:00:00 2001 From: code-new-bie <403466121@qq.com> Date: Wed, 19 Aug 2026 21:14:17 +0800 Subject: [PATCH 116/305] tui: keep fork runtime defaults owned by DSH too --- .../patches/codex/0007-dshx-thread-ownership.patch | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/upstream/patches/codex/0007-dshx-thread-ownership.patch b/upstream/patches/codex/0007-dshx-thread-ownership.patch index dea44a88..41161174 100644 --- a/upstream/patches/codex/0007-dshx-thread-ownership.patch +++ b/upstream/patches/codex/0007-dshx-thread-ownership.patch @@ -31,3 +31,16 @@ diff --git a/codex-rs/tui/src/app_server_session.rs b/codex-rs/tui/src/app_serve if model_settings == ResumeModelSettings::PreserveExistingThread { return ThreadResumeParams { thread_id: thread_id.to_string(), +@@ -1755,6 +1773,12 @@ fn thread_fork_params_from_config( + thread_params_mode: ThreadParamsMode, + remote_cwd_override: Option<&std::path::Path>, + ) -> ThreadForkParams { ++ if crate::dshx_backend() && matches!(thread_params_mode, ThreadParamsMode::Remote) { ++ return ThreadForkParams { ++ thread_id: thread_id.to_string(), ++ ..ThreadForkParams::default() ++ }; ++ } + let permissions = permissions_selection_from_config(&config, thread_params_mode); + let sandbox = permissions + .is_none() From d2b9288fdf175bf3dd400ae377681a291ca65c6d Mon Sep 17 00:00:00 2001 From: code-new-bie <403466121@qq.com> Date: Wed, 19 Aug 2026 21:14:40 +0800 Subject: [PATCH 117/305] test: lock DSH fork prefix semantics --- test/dsh-session-fork.test.mjs | 47 ++++++++++++++++++++++++++++++++++ 1 file changed, 47 insertions(+) create mode 100644 test/dsh-session-fork.test.mjs diff --git a/test/dsh-session-fork.test.mjs b/test/dsh-session-fork.test.mjs new file mode 100644 index 00000000..70d42e07 --- /dev/null +++ b/test/dsh-session-fork.test.mjs @@ -0,0 +1,47 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { dshForkSeed } from '../src/dsh/session-fork.mjs'; + +const events = [ + { seq: 1, type: 'turn/start', data: { turn: 1 } }, + { seq: 2, type: 'user/message', data: { id: 'u1' } }, + { seq: 3, type: 'assistant/message', data: { turn: 1, step: 1 } }, + { seq: 4, type: 'turn/end', data: { turn: 1, reason: { kind: 'completed' } } }, + { seq: 5, type: 'turn/start', data: { turn: 2 } }, + { seq: 6, type: 'user/message', data: { id: 'u2' } }, + { seq: 7, type: 'turn/end', data: { turn: 2, reason: { kind: 'completed' } } } +]; + +test('full fork reuses the exact durable DSH event prefix', () => { + const seed = dshForkSeed(events); + assert.deepEqual(seed.map((event) => event.seq), [1, 2, 3, 4, 5, 6, 7]); + assert.equal(seed[0], events[0]); + assert.equal(seed.at(-1), events.at(-1)); +}); + +test('lastTurnId includes the selected completed DSH turn', () => { + assert.deepEqual( + dshForkSeed(events, { lastTurnId: 'dsh-turn-1' }).map((event) => event.seq), + [1, 2, 3, 4] + ); +}); + +test('beforeTurnId excludes the selected turn and everything after it', () => { + assert.deepEqual( + dshForkSeed(events, { beforeTurnId: 'dsh-turn-2' }).map((event) => event.seq), + [1, 2, 3, 4] + ); +}); + +test('fork refuses ambiguous, invalid, missing, or active DSH turn boundaries', () => { + assert.throws( + () => dshForkSeed(events, { lastTurnId: 'dsh-turn-1', beforeTurnId: 'dsh-turn-2' }), + /mutually exclusive/ + ); + assert.throws(() => dshForkSeed(events, { lastTurnId: 'turn-1' }), /cannot map Codex turn id/); + assert.throws(() => dshForkSeed(events, { lastTurnId: 'dsh-turn-99' }), /no completed/); + assert.throws( + () => dshForkSeed([...events, { seq: 8, type: 'turn/start', data: { turn: 3 } }]), + /active turn 3/ + ); +}); From 79e8c95c00701ce6dd91d6e4d0a4b192723716d1 Mon Sep 17 00:00:00 2001 From: code-new-bie <403466121@qq.com> Date: Wed, 19 Aug 2026 21:15:06 +0800 Subject: [PATCH 118/305] test: keep Codex fork delegated to DSH lifecycle --- test/dsh-product-fork.test.mjs | 126 +++++++++++++++++++++++++++++++++ 1 file changed, 126 insertions(+) create mode 100644 test/dsh-product-fork.test.mjs diff --git a/test/dsh-product-fork.test.mjs b/test/dsh-product-fork.test.mjs new file mode 100644 index 00000000..0e4b5085 --- /dev/null +++ b/test/dsh-product-fork.test.mjs @@ -0,0 +1,126 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { DshxProductAdapter } from '../src/dsh/product-adapter.mjs'; + +function sourceAgent() { + return { + id: 'source-session', + session: { + header: { id: 'source-session', cwd: process.cwd() }, + events: [ + { seq: 1, type: 'turn/start', data: { turn: 1 } }, + { seq: 2, type: 'turn/end', data: { turn: 1, reason: { kind: 'completed' } } } + ] + } + }; +} + +function adapterFixture({ cold = false } = {}) { + const adapter = Object.create(DshxProductAdapter.prototype); + adapter.controllers = new Map(); + adapter.send = () => {}; + const source = sourceAgent(); + const target = { + id: 'fork-session', + session: { + header: { id: 'fork-session', parentSession: source.id, cwd: process.cwd() }, + events: source.session.events + } + }; + const driverCalls = []; + let sourceDisposed = 0; + adapter.driver = { + getLive(id) { + return !cold && id === source.id ? source : undefined; + }, + async resume(id) { + assert.equal(id, source.id); + driverCalls.push(['resume', id]); + return { agent: source, async dispose() { sourceDisposed += 1; } }; + }, + async fork(receivedSource, options) { + assert.equal(receivedSource, source); + driverCalls.push(['fork', options]); + return { agent: target, async dispose() {} }; + } + }; + const presetSets = []; + adapter.permissions = { + current(agent) { + assert.equal(agent, source); + return { preset: 'workspace-write' }; + }, + set(agent, preset) { + assert.equal(agent, target); + presetSets.push(preset); + } + }; + adapter.installController = (handle) => { + const controller = { agent: handle.agent }; + adapter.controllers.set(String(handle.agent.id), controller); + return controller; + }; + adapter.threadResponse = (agent, options) => ({ + thread: { + id: String(agent.id), + forkedFromId: source.id, + turns: options.includeTurns ? ['history'] : [] + }, + model: 'dshx:model', + modelProvider: 'deepseek', + serviceTier: null, + cwd: process.cwd(), + runtimeWorkspaceRoots: [], + instructionSources: [], + approvalPolicy: 'on-request', + approvalsReviewer: 'user', + sandbox: { type: 'externalSandbox', networkAccess: 'enabled' }, + activePermissionProfile: { id: ':workspace', extends: null }, + reasoningEffort: null, + multiAgentMode: 'explicitRequestOnly' + }); + return { + adapter, + source, + target, + driverCalls, + presetSets, + get sourceDisposed() { return sourceDisposed; } + }; +} + +test('thread/fork uses DSH seed prefix and inherits DSH permission preset', async () => { + const fx = adapterFixture(); + const response = await fx.adapter.threadFork({ + threadId: fx.source.id, + lastTurnId: 'dsh-turn-1', + excludeTurns: true + }); + const forkCall = fx.driverCalls.find(([name]) => name === 'fork'); + assert.ok(forkCall); + assert.deepEqual(forkCall[1].seed.map((event) => event.seq), [1, 2]); + assert.match(forkCall[1].sessionId, /^[0-9a-f-]{36}$/i); + assert.deepEqual(fx.presetSets, ['workspace-write']); + assert.equal(response.result.thread.id, fx.target.id); + assert.deepEqual(response.result.thread.turns, []); + assert.equal(typeof response.afterResponse, 'function'); +}); + +test('cold thread/fork temporarily resumes official DSH source and releases it', async () => { + const fx = adapterFixture({ cold: true }); + await fx.adapter.threadFork({ threadId: fx.source.id }); + assert.deepEqual(fx.driverCalls.map(([name]) => name), ['resume', 'fork']); + assert.equal(fx.sourceDisposed, 1); +}); + +test('thread/fork rejects Codex-only fork state DSH cannot faithfully own', async () => { + const fx = adapterFixture(); + await assert.rejects( + () => fx.adapter.threadFork({ threadId: fx.source.id, deferGoalContinuation: true }), + /no Codex thread-goal continuation state/ + ); + await assert.rejects( + () => fx.adapter.threadFork({ threadId: fx.source.id, ephemeral: true }), + /does not expose ephemeral Codex forks/ + ); +}); From 2f6cb6370dfe3a80aed4700fdd2b240ee0bea018 Mon Sep 17 00:00:00 2001 From: code-new-bie <403466121@qq.com> Date: Wed, 19 Aug 2026 21:22:29 +0800 Subject: [PATCH 119/305] feat: project DSH compaction as native Codex context markers --- src/dsh/session-projector.mjs | 89 +++++++++++++++++++++++++++++++++++ 1 file changed, 89 insertions(+) diff --git a/src/dsh/session-projector.mjs b/src/dsh/session-projector.mjs index fd44313a..8fe644ab 100644 --- a/src/dsh/session-projector.mjs +++ b/src/dsh/session-projector.mjs @@ -61,6 +61,12 @@ function turnRecord(id, status, startedAt, completedAt = null, error = null) { }; } +function compactionError(message) { + return message == null + ? null + : { message: String(message), codexErrorInfo: null, additionalDetails: null }; +} + /** * Stateful presentation projection of official DSH Session events. * Durable truth stays in DSH; all maps here are disposable UI correlation. @@ -75,6 +81,7 @@ export class DshSessionProjector { this.assistantItems = new Map(); this.reasoningItems = new Map(); this.tools = new Map(); + this.compactions = new Map(); this.currentTurn = null; this.latestHeader = null; this.latestContext = null; @@ -90,6 +97,9 @@ export class DshSessionProjector { case 'tool/call': return this.toolCall(event); case 'tool/result': return this.toolResult(event); case 'todo/write': return this.todoWrite(event); + case 'compaction/start': return this.compactionStarted(event); + case 'compaction/summary': return this.compactionSummarized(event); + case 'compaction/end': return this.compactionEnded(event); case 'request/header': this.latestHeader = event.data?.header ?? null; return []; @@ -125,6 +135,85 @@ export class DshSessionProjector { return [{ method: 'turn/completed', params: { threadId: this.threadId, turn: turnRecord(state.id, status, state.startedAt, completedAt, error) } }]; } + compactionStarted(event) { + const { compactionId, turn } = event.data ?? {}; + if (typeof compactionId !== 'string' || compactionId.length === 0) return []; + const startedAt = Number.isFinite(event.time) ? Math.floor(event.time / 1000) : nowSeconds(); + const startedAtMs = Number.isFinite(event.time) ? event.time : nowMillis(); + const item = { type: 'contextCompaction', id: `dsh-compaction-${compactionId}` }; + let turnId; + let manual = false; + const events = []; + if (Number.isInteger(turn)) { + const state = this.turns.get(turn); + if (!state) return []; + turnId = state.id; + } else if (turn === null) { + manual = true; + turnId = `dsh-maintenance-compaction-${compactionId}`; + events.push({ + method: 'turn/started', + params: { threadId: this.threadId, turn: turnRecord(turnId, 'inProgress', startedAt) } + }); + } else { + return []; + } + this.compactions.set(compactionId, { + compactionId, + turnId, + manual, + startedAt, + item, + summarized: false + }); + events.push({ + method: 'item/started', + params: { threadId: this.threadId, turnId, startedAtMs, item } + }); + return events; + } + + compactionSummarized(event) { + const compactionId = event.data?.compactionId; + const state = this.compactions.get(compactionId); + if (state) state.summarized = true; + return []; + } + + compactionEnded(event) { + const { compactionId, error } = event.data ?? {}; + const state = this.compactions.get(compactionId); + if (!state) return []; + this.compactions.delete(compactionId); + const completedAtMs = Number.isFinite(event.time) ? event.time : nowMillis(); + const completedAt = Math.floor(completedAtMs / 1000); + const events = [{ + method: 'item/completed', + params: { + threadId: this.threadId, + turnId: state.turnId, + completedAtMs, + item: state.item + } + }]; + if (state.manual) { + events.push({ + method: 'turn/completed', + params: { + threadId: this.threadId, + turn: turnRecord( + state.turnId, + error == null ? 'completed' : 'failed', + state.startedAt, + completedAt, + compactionError(error) + ) + } + }); + } + return events; + } + assistantChunk(event) { const { turn, step, chunk } = event.data ?? {}; if (!Number.isInteger(turn) || !Number.isInteger(step) || !chunk) return []; From 748f434d8e913cee715ee62f1ff17715dab91d60 Mon Sep 17 00:00:00 2001 From: code-new-bie <403466121@qq.com> Date: Wed, 19 Aug 2026 21:23:38 +0800 Subject: [PATCH 120/305] feat: preserve compaction markers in resumed Codex transcript --- src/dsh/codex-shapes.mjs | 91 ++++++++++++++++++++++++++++++++++------ 1 file changed, 79 insertions(+), 12 deletions(-) diff --git a/src/dsh/codex-shapes.mjs b/src/dsh/codex-shapes.mjs index 077834b3..7d5c39f9 100644 --- a/src/dsh/codex-shapes.mjs +++ b/src/dsh/codex-shapes.mjs @@ -55,6 +55,12 @@ function turnError(reason) { }; } +function compactionError(message) { + return message == null + ? null + : { message: String(message), codexErrorInfo: null, additionalDetails: null }; +} + function normalizeList(value) { if (Array.isArray(value)) return value; if (Array.isArray(value?.entries)) return value.entries; @@ -76,6 +82,19 @@ function inputFromDshMessage(message) { return content; } +function historyTurn(id, startedAt) { + return { + id, + items: [], + itemsView: 'full', + status: 'inProgress', + error: null, + startedAt, + completedAt: null, + durationMs: null + }; +} + export function encodeDshModel(selection) { if (!selection?.provider || !selection?.model) throw new Error('DSHX model selection requires provider and model'); const payload = Buffer.from(JSON.stringify([selection.provider, selection.model]), 'utf8').toString('base64url'); @@ -189,13 +208,17 @@ export function dshThreadFromSnapshot({ meta, events = [], model, turns = [], lo } /** - * Replay the authoritative DSH Session log into the stable Codex transcript - * subset DSHX currently claims. Runtime semantics remain entirely in DSH. + * Replay the authoritative DSH raw log as a human transcript. This intentionally + * does not fold the model surface: DSH's own terminal/web transcript contract + * keeps messages shadowed by compaction visible to the human and inserts a + * compaction marker at the landed checkpoint. The plugin-sourced replacement + * user/message is therefore ignored as model-only framing. */ export function dshTurnsFromSession({ ctx, agent, diagnostics = () => {} }) { const session = agent?.session; if (!session) return []; const turns = new Map(); + const compactions = new Map(); let currentTurn; const toolPresenter = new DshToolPresentationResolver({ ctx: agent.ctx ?? ctx, @@ -211,16 +234,7 @@ export function dshTurnsFromSession({ ctx, agent, diagnostics = () => {} }) { const number = event.data?.turn; if (!Number.isInteger(number)) break; const startedAt = seconds(event.time); - turns.set(number, { - id: `dsh-turn-${number}`, - items: [], - itemsView: 'full', - status: 'inProgress', - error: null, - startedAt, - completedAt: null, - durationMs: null - }); + turns.set(number, historyTurn(`dsh-turn-${number}`, startedAt)); currentTurn = number; break; } @@ -287,6 +301,59 @@ export function dshTurnsFromSession({ ctx, agent, diagnostics = () => {} }) { } break; } + case 'compaction/start': { + const { compactionId, turn } = event.data ?? {}; + if (typeof compactionId !== 'string' || compactionId.length === 0) break; + let turnKey; + let target; + let manual = false; + if (Number.isInteger(turn)) { + turnKey = turn; + target = turns.get(turnKey); + if (!target) break; + } else if (turn === null) { + manual = true; + turnKey = `compact:${compactionId}`; + target = historyTurn(`dsh-maintenance-compaction-${compactionId}`, seconds(event.time)); + turns.set(turnKey, target); + } else { + break; + } + compactions.set(compactionId, { + turnKey, + turn: target, + manual, + summarized: false, + itemId: `dsh-compaction-${compactionId}` + }); + break; + } + case 'compaction/summary': { + const state = compactions.get(event.data?.compactionId); + if (!state) break; + state.summarized = true; + state.turn.items.push({ type: 'contextCompaction', id: state.itemId }); + break; + } + case 'compaction/end': { + const state = compactions.get(event.data?.compactionId); + if (!state) break; + compactions.delete(event.data.compactionId); + if (state.manual) { + if (!state.summarized) { + turns.delete(state.turnKey); + break; + } + const completedAt = seconds(event.time); + state.turn.status = event.data?.error == null ? 'completed' : 'failed'; + state.turn.error = compactionError(event.data?.error); + state.turn.completedAt = completedAt; + state.turn.durationMs = state.turn.startedAt == null + ? null + : Math.max(0, (completedAt - state.turn.startedAt) * 1000); + } + break; + } case 'turn/end': { const number = event.data?.turn; const turn = turns.get(number); From cf9961392e5eca8393215d9131ba5b399ecbb7a1 Mon Sep 17 00:00:00 2001 From: code-new-bie <403466121@qq.com> Date: Wed, 19 Aug 2026 21:25:40 +0800 Subject: [PATCH 121/305] feat: wire Codex manual compact to official DSH compaction --- src/dsh/product-adapter.mjs | 74 +++++++++++++++++++++++++++++++++++++ 1 file changed, 74 insertions(+) diff --git a/src/dsh/product-adapter.mjs b/src/dsh/product-adapter.mjs index 1b3cf6fd..97f27229 100644 --- a/src/dsh/product-adapter.mjs +++ b/src/dsh/product-adapter.mjs @@ -58,6 +58,8 @@ export class DshxProductAdapter extends DshAppServerAdapter { return this.accountUsageRead(params); case 'skills/list': return this.skillsList(params); + case 'thread/compact/start': + return this.threadCompactStart(params); case 'thread/fork': return this.threadFork(params); case 'thread/turns/list': @@ -68,6 +70,8 @@ export class DshxProductAdapter extends DshAppServerAdapter { return this.threadSettingsUpdate(params); case 'thread/unsubscribe': return this.threadUnsubscribeWithStatus(params); + case 'turn/interrupt': + return this.turnInterruptProduct(params); case 'turn/steer': return this.turnSteer(params); default: @@ -95,6 +99,14 @@ export class DshxProductAdapter extends DshAppServerAdapter { }; } + manualCompactions() { + return this._manualCompactions ??= new Map(); + } + + warnThread(threadId, message) { + this.send({ method: 'warning', params: { threadId, message } }); + } + threadResponse(agent, options = {}) { const response = super.threadResponse(agent, options); const permission = this.permissions.current(agent); @@ -145,6 +157,59 @@ export class DshxProductAdapter extends DshAppServerAdapter { return { result: { data } }; } + threadCompactStart(params = {}) { + const threadId = String(params.threadId ?? ''); + const controller = this.controllers.get(threadId); + if (!controller) throw new Error(`Thread is not resumed in DSHX: ${threadId}`); + const compaction = this.ctx.get('compaction'); + if (!compaction?.compactNow) throw new Error('DSHX requires DSH service: compaction.compactNow'); + const active = this.manualCompactions(); + if (active.has(threadId)) { + throw new Error('Compaction is unavailable because this thread already has an active manual compaction'); + } + const abortController = new AbortController(); + active.set(threadId, abortController); + return { + result: {}, + afterResponse: () => { + void this.runManualCompaction({ threadId, controller, compaction, abortController }); + } + }; + } + + async runManualCompaction({ threadId, controller, compaction, abortController }) { + try { + const result = await compaction.compactNow(controller.agent, abortController.signal); + if (result == null && !abortController.signal.aborted) { + this.warnThread(threadId, 'No compactable history yet.'); + } + } catch (error) { + if (abortController.signal.aborted) { + this.warnThread(threadId, 'Compaction cancelled.'); + } else { + const message = error instanceof Error ? error.message : String(error); + this.warnThread(threadId, message); + this.diagnostics(`manual compaction failed for ${threadId}: ${message}`); + } + } finally { + if (this.manualCompactions().get(threadId) === abortController) { + this.manualCompactions().delete(threadId); + } + } + } + + turnInterruptProduct(params = {}) { + const threadId = String(params.threadId ?? ''); + if (String(params.turnId ?? '').startsWith('dsh-maintenance-compaction-')) { + const active = this.manualCompactions().get(threadId); + if (active) { + active.abort(new Error('user cancelled DSH compaction')); + return { result: {} }; + } + } + return super.turnInterrupt(params); + } + async threadResume(params = {}) { if (!params.threadId) throw new Error('thread/resume requires threadId'); const threadId = String(params.threadId); @@ -256,6 +321,7 @@ export class DshxProductAdapter extends DshAppServerAdapter { async threadUnsubscribeWithStatus(params = {}) { const threadId = String(params.threadId ?? ''); + this.manualCompactions().get(threadId)?.abort(new Error('thread unsubscribed')); const subscribed = this.controllers.has(threadId); const loaded = Boolean(this.driver.getLive(threadId)); await super.threadUnsubscribe(params); @@ -343,4 +409,12 @@ export class DshxProductAdapter extends DshAppServerAdapter { controller.steer(textInput(params)); return { result: { turnId: location.turnId } }; } + + async close() { + for (const controller of this.manualCompactions().values()) { + controller.abort(new Error('DSHX adapter closing')); + } + this.manualCompactions().clear(); + await super.close(); + } } From 124aec1de808ac1688985acb469a3c32722e7e41 Mon Sep 17 00:00:00 2001 From: code-new-bie <403466121@qq.com> Date: Wed, 19 Aug 2026 21:26:17 +0800 Subject: [PATCH 122/305] test: cover native Codex compaction lifecycle over DSH --- test/dsh-compaction-projection.test.mjs | 125 ++++++++++++++++++++++++ 1 file changed, 125 insertions(+) create mode 100644 test/dsh-compaction-projection.test.mjs diff --git a/test/dsh-compaction-projection.test.mjs b/test/dsh-compaction-projection.test.mjs new file mode 100644 index 00000000..c7b50637 --- /dev/null +++ b/test/dsh-compaction-projection.test.mjs @@ -0,0 +1,125 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { dshTurnsFromSession } from '../src/dsh/codex-shapes.mjs'; +import { DshxProductAdapter } from '../src/dsh/product-adapter.mjs'; +import { DshSessionProjector } from '../src/dsh/session-projector.mjs'; + +const event = (type, data, time, seq = 0) => ({ type, data, time, seq }); + +test('automatic DSH compaction renders as a native contextCompaction item inside its real turn', () => { + const projector = new DshSessionProjector({ threadId: 'session-1' }); + projector.project(event('turn/start', { turn: 1 }, 1000, 1)); + const started = projector.project(event('compaction/start', { + compactionId: 'compact-1', + turn: 1 + }, 1100, 2)); + assert.deepEqual(started.map((entry) => entry.method), ['item/started']); + assert.deepEqual(started[0].params.item, { type: 'contextCompaction', id: 'dsh-compaction-compact-1' }); + assert.equal(started[0].params.turnId, 'dsh-turn-1'); + projector.project(event('compaction/summary', { compactionId: 'compact-1' }, 1200, 3)); + const ended = projector.project(event('compaction/end', { + compactionId: 'compact-1', + turn: 1 + }, 1300, 5)); + assert.deepEqual(ended.map((entry) => entry.method), ['item/completed']); +}); + +test('standalone manual DSH compaction uses a presentation-only maintenance turn', () => { + const projector = new DshSessionProjector({ threadId: 'session-1' }); + const started = projector.project(event('compaction/start', { + compactionId: 'manual-1', + turn: null + }, 2000, 10)); + assert.deepEqual(started.map((entry) => entry.method), ['turn/started', 'item/started']); + assert.equal(started[0].params.turn.id, 'dsh-maintenance-compaction-manual-1'); + assert.equal(started[1].params.turnId, 'dsh-maintenance-compaction-manual-1'); + projector.project(event('compaction/summary', { compactionId: 'manual-1' }, 2200, 11)); + const ended = projector.project(event('compaction/end', { + compactionId: 'manual-1', + turn: null + }, 2500, 13)); + assert.deepEqual(ended.map((entry) => entry.method), ['item/completed', 'turn/completed']); + assert.equal(ended[1].params.turn.status, 'completed'); +}); + +test('resumed human transcript keeps shadowed history and adds one compaction marker', () => { + const agent = { + id: 'session-1', + ctx: { get() { return { get() { return undefined; } }; } }, + session: { + header: { id: 'session-1', cwd: process.cwd() }, + events: [ + event('turn/start', { turn: 1 }, 1000, 1), + event('user/message', { + id: 'u1', source: { kind: 'user' }, content: [{ type: 'text', text: 'old prompt' }] + }, 1010, 2), + event('assistant/message', { + turn: 1, step: 1, message: { id: 'a1', content: [{ type: 'text', text: 'old answer' }] } + }, 1020, 3), + event('turn/end', { turn: 1, reason: { kind: 'completed' } }, 1030, 4), + event('compaction/start', { compactionId: 'manual-1', turn: null }, 2000, 5), + event('compaction/summary', { compactionId: 'manual-1', summary: [] }, 2100, 6), + event('user/message', { + id: 'checkpoint', + source: { kind: 'plugin', plugin: 'compact', compactionId: 'manual-1' }, + content: [{ type: 'text', text: 'MODEL ONLY CHECKPOINT' }], + surfaceOp: { op: 'replace', start: 2, end: 3 } + }, 2110, 7), + event('compaction/end', { compactionId: 'manual-1', turn: null }, 2200, 8) + ] + } + }; + const turns = dshTurnsFromSession({ ctx: agent.ctx, agent }); + assert.equal(turns.length, 2); + assert.deepEqual(turns[0].items.map((item) => item.type), ['userMessage', 'agentMessage']); + assert.equal(turns[0].items[0].content[0].text, 'old prompt'); + assert.equal(turns[0].items[1].text, 'old answer'); + assert.deepEqual(turns[1].items, [{ type: 'contextCompaction', id: 'dsh-compaction-manual-1' }]); + assert.equal(JSON.stringify(turns).includes('MODEL ONLY CHECKPOINT'), false); +}); + +test('thread/compact/start delegates to DSH compaction and reports no-op without durable fabrication', async () => { + const adapter = Object.create(DshxProductAdapter.prototype); + const warnings = []; + adapter.send = (message) => warnings.push(message); + adapter.diagnostics = () => {}; + adapter.controllers = new Map([['session-1', { agent: { id: 'session-1' } }]]); + adapter._manualCompactions = new Map(); + let compactCalls = 0; + adapter.ctx = { + get(name) { + if (name === 'compaction') { + return { + async compactNow(_agent, signal) { + compactCalls += 1; + assert.equal(signal.aborted, false); + return null; + } + }; + } + return undefined; + } + }; + + const response = adapter.threadCompactStart({ threadId: 'session-1' }); + assert.deepEqual(response.result, {}); + response.afterResponse(); + await new Promise((resolve) => setImmediate(resolve)); + assert.equal(compactCalls, 1); + assert.deepEqual(warnings, [{ + method: 'warning', + params: { threadId: 'session-1', message: 'No compactable history yet.' } + }]); +}); + +test('synthetic compaction turn interrupt aborts only the tracked official DSH operation', () => { + const adapter = Object.create(DshxProductAdapter.prototype); + const abort = new AbortController(); + adapter._manualCompactions = new Map([['session-1', abort]]); + const result = adapter.turnInterruptProduct({ + threadId: 'session-1', + turnId: 'dsh-maintenance-compaction-c1' + }); + assert.deepEqual(result, { result: {} }); + assert.equal(abort.signal.aborted, true); +}); From 11c0670ed8ab5f03b2409be3a57e373795bb34bb Mon Sep 17 00:00:00 2001 From: code-new-bie <403466121@qq.com> Date: Wed, 19 Aug 2026 21:29:50 +0800 Subject: [PATCH 123/305] feat: delegate session titles to official DSH services --- src/dsh/agent-driver.mjs | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/src/dsh/agent-driver.mjs b/src/dsh/agent-driver.mjs index 303aaea9..ee58757b 100644 --- a/src/dsh/agent-driver.mjs +++ b/src/dsh/agent-driver.mjs @@ -121,6 +121,22 @@ export class DshAgentDriver { return this.selectionFor(agent).current; } + currentTitle(agent) { + return this.ctx.get('sessionTitle')?.get?.(agent.session); + } + + renameTitle(agent, title) { + const service = requireService(this.ctx, 'sessionTitle'); + return service.rename(agent.session, title); + } + + async readTitle(sessionId) { + const query = this.ctx.get('sessionQuery'); + if (query?.readTitle) return query.readTitle(SessionId(sessionId)); + const live = this.getLive(sessionId); + return live ? this.currentTitle(live) : undefined; + } + async selectModel(agent, requested) { const llm = requireService(this.ctx, 'llm'); const resolved = await llm.resolveCallConfig({ From 52d0dc1dc2c3725c074b58ed233a1d928c96bbd4 Mon Sep 17 00:00:00 2001 From: code-new-bie <403466121@qq.com> Date: Wed, 19 Aug 2026 21:30:36 +0800 Subject: [PATCH 124/305] feat: fold durable DSH session titles for Codex thread metadata --- src/dsh/thread-title.mjs | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) create mode 100644 src/dsh/thread-title.mjs diff --git a/src/dsh/thread-title.mjs b/src/dsh/thread-title.mjs new file mode 100644 index 00000000..afda2f90 --- /dev/null +++ b/src/dsh/thread-title.mjs @@ -0,0 +1,19 @@ +export function foldDshSessionTitle(events = []) { + for (let index = events.length - 1; index >= 0; index -= 1) { + const event = events[index]; + if (event?.type !== 'session/title') continue; + const title = event.data?.title; + if (typeof title === 'string' && title.length > 0) return title; + } + return null; +} + +export function threadNameUpdatedNotification(threadId, title) { + return { + method: 'thread/name/updated', + params: { + threadId: String(threadId), + threadName: title == null ? null : String(title) + } + }; +} From e111c675a43dcb17275c5c9eba0df4c4c00f4471 Mon Sep 17 00:00:00 2001 From: code-new-bie <403466121@qq.com> Date: Wed, 19 Aug 2026 21:32:04 +0800 Subject: [PATCH 125/305] feat: add release tail for DSH-owned thread titles --- src/dsh/release-adapter.mjs | 85 +++++++++++++++++++++++++++++++++++++ 1 file changed, 85 insertions(+) create mode 100644 src/dsh/release-adapter.mjs diff --git a/src/dsh/release-adapter.mjs b/src/dsh/release-adapter.mjs new file mode 100644 index 00000000..783652a4 --- /dev/null +++ b/src/dsh/release-adapter.mjs @@ -0,0 +1,85 @@ +import { DshxProductAdapter } from './product-adapter.mjs'; +import { foldDshSessionTitle, threadNameUpdatedNotification } from './thread-title.mjs'; + +function snapshotTitle(snapshot) { + return typeof snapshot?.title === 'string' && snapshot.title.length > 0 ? snapshot.title : null; +} + +/** + * Final product-facing protocol tail. Keep small metadata/UI compatibility + * extensions here so the core DSH public adapter remains stable and auditable. + */ +export class DshxReleaseAdapter extends DshxProductAdapter { + async dispatch(method, params) { + if (method === 'thread/name/set') return this.threadNameSet(params); + return super.dispatch(method, params); + } + + threadResponse(agent, options = {}) { + const response = super.threadResponse(agent, options); + const folded = this.driver.currentTitle(agent); + const name = snapshotTitle(folded) ?? foldDshSessionTitle(agent.session?.events ?? []); + return { + ...response, + thread: { ...response.thread, name } + }; + } + + async threadList(params = {}) { + const response = await super.threadList(params); + const data = await Promise.all(response.result.data.map(async (thread) => { + try { + const snapshot = await this.driver.readTitle(thread.id); + return { ...thread, name: snapshotTitle(snapshot) ?? thread.name ?? null }; + } catch (error) { + this.diagnostics(`title read ${thread.id}: ${error instanceof Error ? error.message : error}`); + return thread; + } + })); + return { result: { ...response.result, data } }; + } + + async threadRead(params = {}) { + const response = await super.threadRead(params); + const thread = response.result.thread; + try { + const snapshot = await this.driver.readTitle(thread.id); + return { + result: { + ...response.result, + thread: { ...thread, name: snapshotTitle(snapshot) ?? thread.name ?? null } + } + }; + } catch (error) { + this.diagnostics(`title read ${thread.id}: ${error instanceof Error ? error.message : error}`); + return response; + } + } + + async threadNameSet(params = {}) { + const threadId = String(params.threadId ?? ''); + if (!threadId) throw new Error('thread/name/set requires threadId'); + if (typeof params.name !== 'string') throw new Error('thread/name/set requires name'); + + let temporaryHandle; + let agent = this.controllers.get(threadId)?.agent ?? this.driver.getLive(threadId); + if (!agent) { + temporaryHandle = await this.driver.resume(threadId); + agent = temporaryHandle.agent; + } + + try { + const snapshot = this.driver.renameTitle(agent, params.name); + const threadName = snapshotTitle(snapshot); + if (threadName == null) throw new Error('DSH sessionTitle.rename returned no accepted title'); + return { + result: {}, + afterResponse: () => this.send(threadNameUpdatedNotification(threadId, threadName)) + }; + } finally { + await temporaryHandle?.dispose?.(); + } + } +} + +export const releaseAdapterInternals = { snapshotTitle }; From 7033fdcb7caadd61c23e7eea3c8a07c7ed3344e0 Mon Sep 17 00:00:00 2001 From: code-new-bie <403466121@qq.com> Date: Wed, 19 Aug 2026 21:32:34 +0800 Subject: [PATCH 126/305] feat: serve the final DSHX release protocol tail --- src/dsh/local-server.mjs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/dsh/local-server.mjs b/src/dsh/local-server.mjs index e5c2c48b..648e61f2 100644 --- a/src/dsh/local-server.mjs +++ b/src/dsh/local-server.mjs @@ -1,6 +1,6 @@ import crypto from 'node:crypto'; import { WebSocket, WebSocketServer } from 'ws'; -import { DshxProductAdapter } from './product-adapter.mjs'; +import { DshxReleaseAdapter } from './release-adapter.mjs'; import { bootDshxRuntime } from './runtime-boot.mjs'; const DEFAULT_HOST = '127.0.0.1'; @@ -44,7 +44,7 @@ export async function startDshxLocalServer({ token = crypto.randomBytes(32).toString('base64url'), runtime, bootRuntime = bootDshxRuntime, - Adapter = DshxProductAdapter, + Adapter = DshxReleaseAdapter, log = () => {} } = {}) { if (host !== '127.0.0.1') { From c69897a41e4f6ce646b1b7f76e2f1da8572178e9 Mon Sep 17 00:00:00 2001 From: code-new-bie <403466121@qq.com> Date: Wed, 19 Aug 2026 21:33:01 +0800 Subject: [PATCH 127/305] test: lock DSH-owned thread title lifecycle --- test/dsh-release-title.test.mjs | 113 ++++++++++++++++++++++++++++++++ 1 file changed, 113 insertions(+) create mode 100644 test/dsh-release-title.test.mjs diff --git a/test/dsh-release-title.test.mjs b/test/dsh-release-title.test.mjs new file mode 100644 index 00000000..b0dd5a3d --- /dev/null +++ b/test/dsh-release-title.test.mjs @@ -0,0 +1,113 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { DshxReleaseAdapter } from '../src/dsh/release-adapter.mjs'; +import { foldDshSessionTitle } from '../src/dsh/thread-title.mjs'; + +test('durable title fold is latest-wins over DSH session/title events', () => { + assert.equal(foldDshSessionTitle([ + { type: 'session/title', data: { title: 'First' } }, + { type: 'user/message', data: {} }, + { type: 'session/title', data: { title: 'Pinned rename' } } + ]), 'Pinned rename'); + assert.equal(foldDshSessionTitle([]), null); +}); + +test('thread/name/set delegates loaded rename to DSH and notifies only after RPC response', async () => { + const adapter = Object.create(DshxReleaseAdapter.prototype); + const sent = []; + const agent = { id: 'session-1', session: { events: [] } }; + adapter.controllers = new Map([['session-1', { agent }]]); + adapter.send = (message) => sent.push(message); + adapter.driver = { + getLive() { return undefined; }, + renameTitle(received, title) { + assert.equal(received, agent); + assert.equal(title, 'New title'); + return { title: 'New title', source: { kind: 'user' } }; + } + }; + + const response = await adapter.threadNameSet({ threadId: 'session-1', name: 'New title' }); + assert.deepEqual(response.result, {}); + assert.deepEqual(sent, []); + response.afterResponse(); + assert.deepEqual(sent, [{ + method: 'thread/name/updated', + params: { threadId: 'session-1', threadName: 'New title' } + }]); +}); + +test('cold thread/name/set uses official DSH resume only for mutation lifetime', async () => { + const adapter = Object.create(DshxReleaseAdapter.prototype); + const agent = { id: 'session-2', session: { events: [] } }; + let disposed = 0; + adapter.controllers = new Map(); + adapter.send = () => {}; + adapter.driver = { + getLive() { return undefined; }, + async resume(id) { + assert.equal(id, 'session-2'); + return { agent, async dispose() { disposed += 1; } }; + }, + renameTitle(received, title) { + assert.equal(received, agent); + return { title }; + } + }; + + const response = await adapter.threadNameSet({ threadId: 'session-2', name: 'Stored rename' }); + assert.equal(disposed, 1); + assert.equal(typeof response.afterResponse, 'function'); +}); + +test('live thread response always carries latest DSH title', () => { + const adapter = Object.create(DshxReleaseAdapter.prototype); + const agent = { + id: 'session-1', + session: { events: [{ type: 'session/title', data: { title: 'Log title' } }] } + }; + adapter.driver = { + currentTitle(received) { + assert.equal(received, agent); + return { title: 'Folded service title' }; + } + }; + adapter.permissions = { current() { return { preset: 'workspace-write' }; } }; + Object.getPrototypeOf(DshxReleaseAdapter.prototype).threadResponse = () => { + throw new Error('test should stub through product prototype explicitly'); + }; + + const product = Object.getPrototypeOf(DshxReleaseAdapter.prototype); + const original = product.threadResponse; + product.threadResponse = () => ({ thread: { id: 'session-1', name: null } }); + try { + const response = adapter.threadResponse(agent); + assert.equal(response.thread.name, 'Folded service title'); + } finally { + product.threadResponse = original; + } +}); + +test('thread list and read enrich cold metadata with sessionQuery title snapshots', async () => { + const adapter = Object.create(DshxReleaseAdapter.prototype); + adapter.diagnostics = () => {}; + adapter.driver = { + async readTitle(id) { return { title: `Title ${id}` }; } + }; + const product = Object.getPrototypeOf(DshxReleaseAdapter.prototype); + const originalList = product.threadList; + const originalRead = product.threadRead; + product.threadList = async () => ({ + result: { data: [{ id: 'a', name: null }, { id: 'b', name: null }], nextCursor: null, backwardsCursor: null } + }); + product.threadRead = async () => ({ result: { thread: { id: 'a', name: null } } }); + try { + const listed = await adapter.threadList({}); + assert.deepEqual(listed.result.data.map((thread) => thread.name), ['Title a', 'Title b']); + const read = await adapter.threadRead({ threadId: 'a' }); + assert.equal(read.result.thread.name, 'Title a'); + } finally { + product.threadList = originalList; + product.threadRead = originalRead; + } +}); From 7228f51faf9e8dcfa2da714b02c8394e9116eeb9 Mon Sep 17 00:00:00 2001 From: code-new-bie <403466121@qq.com> Date: Wed, 19 Aug 2026 21:39:02 +0800 Subject: [PATCH 128/305] feat: route Codex bang shell through guarded DSH tools --- src/dsh/user-shell.mjs | 209 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 209 insertions(+) create mode 100644 src/dsh/user-shell.mjs diff --git a/src/dsh/user-shell.mjs b/src/dsh/user-shell.mjs new file mode 100644 index 00000000..313dcd06 --- /dev/null +++ b/src/dsh/user-shell.mjs @@ -0,0 +1,209 @@ +import { randomUUID } from 'node:crypto'; + +function seconds(ms = Date.now()) { + return Math.floor(ms / 1000); +} + +function turnRecord(id, status, startedAt, completedAt = null, error = null) { + return { + id, + items: [], + itemsView: 'full', + status, + error, + startedAt, + completedAt, + durationMs: completedAt == null ? null : Math.max(0, (completedAt - startedAt) * 1000) + }; +} + +function errorRecord(message) { + return { message: String(message), codexErrorInfo: null, additionalDetails: null }; +} + +function resultData(callId, result) { + return { + message: { + source: { kind: 'tool', callId }, + content: [{ + type: 'tool-result', + toolCallId: callId, + content: Array.isArray(result?.content) ? result.content : [], + isError: Boolean(result?.isError) + }] + }, + ...(result?.meta === undefined ? {} : { meta: result.meta }) + }; +} + +function pickTool(agent) { + const tools = agent.ctx?.get?.('tools'); + if (!tools) throw new Error('DSHX user shell requires DSH service: tools'); + const preferred = process.platform === 'win32' ? ['pwsh', 'bash'] : ['bash', 'pwsh']; + for (const name of preferred) { + if (tools.get?.(name, agent)) return { tools, name }; + } + throw new Error('No DSH shell tool is visible in the current Agent scope'); +} + +/** + * Presentation-only bridge for Codex `!` commands. Execution still goes through + * the official DSH ToolRuntime, so pre-execute policy, guards, approval, + * sandboxing, timeouts, post-policy and result normalization all remain DSH-owned. + * Like Codex's own user-shell action, these cells are intentionally not durable + * conversation history. + */ +export class DshUserShellBridge { + constructor({ send, diagnostics = () => {} } = {}) { + if (typeof send !== 'function') throw new Error('DshUserShellBridge requires send(message)'); + this.send = send; + this.diagnostics = diagnostics; + this.active = new Map(); + } + + start(controller, command) { + const threadId = String(controller?.threadId ?? controller?.agent?.id ?? ''); + if (!threadId) throw new Error('thread/shellCommand requires a resumed DSH thread'); + if (typeof command !== 'string' || command.trim().length === 0) { + throw new Error('thread/shellCommand requires a non-empty command'); + } + if (this.active.has(threadId)) throw new Error('A DSHX user-shell command is already active for this thread'); + + const existing = controller.currentLocation(); + const callId = `dshx-user-shell-${randomUUID()}`; + const turnId = existing?.turnId ?? `dsh-user-shell-turn-${randomUUID()}`; + const maintenance = existing == null; + const startedAtMs = Date.now(); + const abortController = new AbortController(); + const state = { + threadId, + turnId, + callId, + maintenance, + startedAtMs, + abortController, + controller, + command + }; + this.active.set(threadId, state); + + return { + result: {}, + afterResponse: () => { + void this.run(state); + } + }; + } + + async run(state) { + const { threadId, turnId, callId, maintenance, startedAtMs, abortController, controller, command } = state; + try { + if (maintenance) { + this.send({ + method: 'turn/started', + params: { threadId, turn: turnRecord(turnId, 'inProgress', seconds(startedAtMs)) } + }); + } + + const { tools, name } = pickTool(controller.agent); + const args = { + command, + description: 'Run command from interactive user shell', + workdir: controller.agent.session?.header?.cwd, + run_in_background: false + }; + const projected = controller.toolPresentation?.start({ + turnId, + callId, + name, + rawArguments: args, + startedAtMs + }); + if (!projected || projected.state.semantic !== 'command') { + throw new Error(`DSH shell tool ${name} does not expose terminal presentation semantics`); + } + projected.item.source = 'userShell'; + projected.state.item = projected.item; + this.send({ + method: 'item/started', + params: { threadId, turnId, startedAtMs, item: projected.item } + }); + + const result = await tools.execute({ + callId, + name, + arguments: args, + signal: abortController.signal, + agent: controller.agent + }); + const completedAtMs = Date.now(); + const completed = controller.toolPresentation.complete({ + callId, + resultData: resultData(callId, result), + completedAtMs + }); + if (!completed) throw new Error(`DSHX lost user-shell presentation correlation for ${callId}`); + completed.item.source = 'userShell'; + this.send({ + method: 'item/completed', + params: { threadId, turnId, completedAtMs, item: completed.item } + }); + if (maintenance) { + const completedAt = seconds(completedAtMs); + this.send({ + method: 'turn/completed', + params: { + threadId, + turn: turnRecord( + turnId, + result?.isError ? 'failed' : 'completed', + seconds(startedAtMs), + completedAt, + result?.isError ? errorRecord(result.error?.message ?? 'DSH shell command failed') : null + ) + } + }); + } + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + this.diagnostics(`user shell ${threadId}: ${message}`); + if (maintenance) { + const completedAt = seconds(); + this.send({ + method: 'turn/completed', + params: { + threadId, + turn: turnRecord(turnId, 'failed', seconds(startedAtMs), completedAt, errorRecord(message)) + } + }); + } else { + this.send({ method: 'warning', params: { threadId, message } }); + } + } finally { + if (this.active.get(threadId) === state) this.active.delete(threadId); + } + } + + interrupt(threadId, turnId) { + const state = this.active.get(String(threadId)); + if (!state || state.turnId !== String(turnId)) return false; + state.abortController.abort(new Error('user cancelled DSHX shell command')); + return true; + } + + abortThread(threadId, reason = 'thread closed') { + const state = this.active.get(String(threadId)); + if (!state) return false; + state.abortController.abort(new Error(reason)); + return true; + } + + close() { + for (const state of this.active.values()) { + state.abortController.abort(new Error('DSHX adapter closing')); + } + this.active.clear(); + } +} + +export const userShellInternals = { pickTool, resultData, turnRecord }; From 9f2df8028163efd0d49bdf3a444534bb59c77c98 Mon Sep 17 00:00:00 2001 From: code-new-bie <403466121@qq.com> Date: Wed, 19 Aug 2026 21:39:44 +0800 Subject: [PATCH 129/305] feat: route Codex user shell through DSH release bridge --- src/dsh/release-adapter.mjs | 36 ++++++++++++++++++++++++++++++++++-- 1 file changed, 34 insertions(+), 2 deletions(-) diff --git a/src/dsh/release-adapter.mjs b/src/dsh/release-adapter.mjs index 783652a4..551c2299 100644 --- a/src/dsh/release-adapter.mjs +++ b/src/dsh/release-adapter.mjs @@ -1,5 +1,6 @@ import { DshxProductAdapter } from './product-adapter.mjs'; import { foldDshSessionTitle, threadNameUpdatedNotification } from './thread-title.mjs'; +import { DshUserShellBridge } from './user-shell.mjs'; function snapshotTitle(snapshot) { return typeof snapshot?.title === 'string' && snapshot.title.length > 0 ? snapshot.title : null; @@ -10,9 +11,28 @@ function snapshotTitle(snapshot) { * extensions here so the core DSH public adapter remains stable and auditable. */ export class DshxReleaseAdapter extends DshxProductAdapter { + userShell() { + return this._userShell ??= new DshUserShellBridge({ + send: this.send, + diagnostics: this.diagnostics + }); + } + async dispatch(method, params) { - if (method === 'thread/name/set') return this.threadNameSet(params); - return super.dispatch(method, params); + switch (method) { + case 'thread/name/set': + return this.threadNameSet(params); + case 'thread/shellCommand': + return this.threadShellCommand(params); + case 'turn/interrupt': + if (this.userShell().interrupt(params?.threadId, params?.turnId)) return { result: {} }; + return super.dispatch(method, params); + case 'thread/unsubscribe': + this.userShell().abortThread(params?.threadId, 'thread unsubscribed'); + return super.dispatch(method, params); + default: + return super.dispatch(method, params); + } } threadResponse(agent, options = {}) { @@ -56,6 +76,13 @@ export class DshxReleaseAdapter extends DshxProductAdapter { } } + threadShellCommand(params = {}) { + const threadId = String(params.threadId ?? ''); + const controller = this.controllers.get(threadId); + if (!controller) throw new Error(`Thread is not resumed in DSHX: ${threadId}`); + return this.userShell().start(controller, params.command); + } + async threadNameSet(params = {}) { const threadId = String(params.threadId ?? ''); if (!threadId) throw new Error('thread/name/set requires threadId'); @@ -80,6 +107,11 @@ export class DshxReleaseAdapter extends DshxProductAdapter { await temporaryHandle?.dispose?.(); } } + + async close() { + this._userShell?.close(); + await super.close(); + } } export const releaseAdapterInternals = { snapshotTitle }; From 639aa734b978ecaa64181cc29c1cd87408554c44 Mon Sep 17 00:00:00 2001 From: code-new-bie <403466121@qq.com> Date: Wed, 19 Aug 2026 21:40:22 +0800 Subject: [PATCH 130/305] test: lock guarded DSH execution for Codex bang shell --- test/dsh-user-shell.test.mjs | 139 +++++++++++++++++++++++++++++++++++ 1 file changed, 139 insertions(+) create mode 100644 test/dsh-user-shell.test.mjs diff --git a/test/dsh-user-shell.test.mjs b/test/dsh-user-shell.test.mjs new file mode 100644 index 00000000..b47e2280 --- /dev/null +++ b/test/dsh-user-shell.test.mjs @@ -0,0 +1,139 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { DshToolPresentationResolver } from '../src/dsh/tool-presentation.mjs'; +import { DshUserShellBridge } from '../src/dsh/user-shell.mjs'; + +function fixture({ activeTurn = null, execute } = {}) { + const sent = []; + const calls = []; + const session = { header: { cwd: process.cwd() }, events: [] }; + const definition = { + presentCall(args) { + return { card: 'terminal', title: args.command, cwd: args.workdir }; + }, + presentResult(_args, result) { + const text = result.content?.find?.((block) => block?.type === 'text')?.text ?? ''; + return { card: 'terminal', output: text, exitCode: result.isError ? 1 : 0 }; + } + }; + const tools = { + get(name) { + if (name === 'bash' || name === 'pwsh') return definition; + return undefined; + }, + async execute(input) { + calls.push(input); + if (execute) return execute(input); + return { isError: false, value: {}, content: [{ type: 'text', text: 'safe output\n' }] }; + } + }; + const agent = { + id: 'session-1', + session, + ctx: { get(name) { return name === 'tools' ? tools : undefined; } } + }; + const controller = { + threadId: 'session-1', + agent, + currentLocation() { + return activeTurn ? { threadId: 'session-1', turnId: activeTurn } : undefined; + } + }; + controller.toolPresentation = new DshToolPresentationResolver({ + ctx: agent.ctx, + agent, + threadId: controller.threadId, + workspaceCwd: session.header.cwd + }); + const bridge = new DshUserShellBridge({ send: (message) => sent.push(message) }); + return { bridge, controller, sent, calls, session }; +} + +async function settle() { + await new Promise((resolve) => setImmediate(resolve)); + await new Promise((resolve) => setImmediate(resolve)); +} + +test('idle bang shell uses a non-durable presentation turn and DSH ToolRuntime', async () => { + const fx = fixture(); + const response = fx.bridge.start(fx.controller, 'echo hello'); + assert.deepEqual(response.result, {}); + assert.deepEqual(fx.sent, [], 'events must wait until shellCommand RPC response'); + response.afterResponse(); + await settle(); + + assert.equal(fx.calls.length, 1); + const input = fx.calls[0]; + assert.equal(input.agent, fx.controller.agent); + assert.equal(input.arguments.command, 'echo hello'); + assert.equal(input.arguments.workdir, process.cwd()); + assert.equal(input.arguments.run_in_background, false); + assert.equal('sandbox_permissions' in input.arguments, false); + assert.equal(input.signal instanceof AbortSignal, true); + assert.ok(input.name === 'bash' || input.name === 'pwsh'); + + assert.deepEqual(fx.sent.map((message) => message.method), [ + 'turn/started', + 'item/started', + 'item/completed', + 'turn/completed' + ]); + assert.equal(fx.sent[1].params.item.type, 'commandExecution'); + assert.equal(fx.sent[1].params.item.source, 'userShell'); + assert.equal(fx.sent[2].params.item.source, 'userShell'); + assert.equal(fx.sent[2].params.item.aggregatedOutput, 'safe output\n'); + assert.deepEqual(fx.session.events, [], 'user shell stays out of durable DSH conversation history'); +}); + +test('bang shell during an active DSH turn attaches only its command item to that live turn', async () => { + const fx = fixture({ activeTurn: 'dsh-turn-4' }); + const response = fx.bridge.start(fx.controller, 'pwd'); + response.afterResponse(); + await settle(); + assert.deepEqual(fx.sent.map((message) => message.method), ['item/started', 'item/completed']); + assert.equal(fx.sent[0].params.turnId, 'dsh-turn-4'); + assert.equal(fx.sent[1].params.turnId, 'dsh-turn-4'); +}); + +test('Ctrl+C aborts the exact official DSH tool signal instead of killing a host process directly', async () => { + let observedAbort = false; + const fx = fixture({ + execute: ({ signal }) => new Promise((resolve) => { + signal.addEventListener('abort', () => { + observedAbort = true; + resolve({ + isError: true, + error: { message: 'aborted' }, + content: [{ type: 'text', text: 'aborted' }] + }); + }, { once: true }); + }) + }); + const response = fx.bridge.start(fx.controller, 'sleep 30'); + const state = fx.bridge.active.get('session-1'); + assert.ok(state); + response.afterResponse(); + await new Promise((resolve) => setImmediate(resolve)); + assert.equal(fx.bridge.interrupt('session-1', state.turnId), true); + await settle(); + assert.equal(observedAbort, true); + assert.equal(fx.bridge.active.has('session-1'), false); +}); + +test('thread close aborts active DSH shell execution', async () => { + let observedAbort = false; + const fx = fixture({ + execute: ({ signal }) => new Promise((resolve) => { + signal.addEventListener('abort', () => { + observedAbort = true; + resolve({ isError: true, error: { message: 'closed' }, content: [] }); + }, { once: true }); + }) + }); + const response = fx.bridge.start(fx.controller, 'long command'); + response.afterResponse(); + await new Promise((resolve) => setImmediate(resolve)); + assert.equal(fx.bridge.abortThread('session-1'), true); + await settle(); + assert.equal(observedAbort, true); +}); From 7f417b548ed0cb71f9742e5ab1bdd198097f0483 Mon Sep 17 00:00:00 2001 From: code-new-bie <403466121@qq.com> Date: Wed, 19 Aug 2026 21:42:11 +0800 Subject: [PATCH 131/305] tui: hide unsupported Codex-only commands in DSHX --- .../codex/0009-dshx-slash-capabilities.patch | 81 +++++++++++++++++++ 1 file changed, 81 insertions(+) create mode 100644 upstream/patches/codex/0009-dshx-slash-capabilities.patch diff --git a/upstream/patches/codex/0009-dshx-slash-capabilities.patch b/upstream/patches/codex/0009-dshx-slash-capabilities.patch new file mode 100644 index 00000000..60768ca7 --- /dev/null +++ b/upstream/patches/codex/0009-dshx-slash-capabilities.patch @@ -0,0 +1,81 @@ +diff --git a/codex-rs/tui/src/slash_command.rs b/codex-rs/tui/src/slash_command.rs +--- a/codex-rs/tui/src/slash_command.rs ++++ b/codex-rs/tui/src/slash_command.rs +@@ -76,7 +76,7 @@ impl SlashCommand { + match self { + SlashCommand::Feedback => "send logs to maintainers", + SlashCommand::New => "start a new chat during a conversation", +- SlashCommand::Init => "create an AGENTS.md file with instructions for Codex", ++ SlashCommand::Init => "create an AGENTS.md file with instructions for DeepSeek Harness", + SlashCommand::Compact => "summarize conversation to prevent hitting the context limit", + SlashCommand::Review => "review my current changes and find issues", + SlashCommand::Rename => "rename the current thread", +@@ -86,7 +86,7 @@ impl SlashCommand { + SlashCommand::Delete => "permanently delete this session and exit", + SlashCommand::Clear => "clear the terminal and start a new chat", + SlashCommand::Fork => "fork the current chat", + SlashCommand::App => "continue this session in the Desktop app", +- SlashCommand::Quit | SlashCommand::Exit => "exit Codex", ++ SlashCommand::Quit | SlashCommand::Exit => "exit DSHX", + SlashCommand::Copy => "copy last response as markdown", + SlashCommand::Export => "export the conversation as markdown", + SlashCommand::Raw => "toggle raw scrollback mode for copy-friendly terminal selection", +@@ -94,7 +94,7 @@ impl SlashCommand { + SlashCommand::Diff => "show git diff (including untracked files)", + SlashCommand::Mention => "mention a file", +- SlashCommand::Skills => "use skills to improve how Codex performs specific tasks", ++ SlashCommand::Skills => "use skills to improve how DeepSeek Harness performs specific tasks", + SlashCommand::Import => "import setup, this project, and recent chats from Claude Code", + SlashCommand::Hooks => "view and manage lifecycle hooks", +@@ -128,7 +128,7 @@ impl SlashCommand { + SlashCommand::Side | SlashCommand::Btw => { + "start a side conversation in an ephemeral fork" + } +- SlashCommand::Permissions => "choose what Codex is allowed to do", ++ SlashCommand::Permissions => "choose what DeepSeek Harness is allowed to do", +@@ -251,6 +251,42 @@ impl SlashCommand { + } + + fn is_visible(self) -> bool { ++ if crate::dshx_backend() ++ && matches!( ++ self, ++ SlashCommand::Ide ++ | SlashCommand::ElevateSandbox ++ | SlashCommand::SandboxReadRoot ++ | SlashCommand::Experimental ++ | SlashCommand::AutoReview ++ | SlashCommand::Memories ++ | SlashCommand::Import ++ | SlashCommand::Hooks ++ | SlashCommand::Review ++ | SlashCommand::Archive ++ | SlashCommand::Delete ++ | SlashCommand::App ++ | SlashCommand::Plan ++ | SlashCommand::Goal ++ | SlashCommand::Agents ++ | SlashCommand::MultiAgents ++ | SlashCommand::Side ++ | SlashCommand::Btw ++ | SlashCommand::Diff ++ | SlashCommand::Cd ++ | SlashCommand::Usage ++ | SlashCommand::DebugConfig ++ | SlashCommand::Ps ++ | SlashCommand::Stop ++ | SlashCommand::Mcp ++ | SlashCommand::Apps ++ | SlashCommand::Plugins ++ | SlashCommand::Logout ++ | SlashCommand::Feedback ++ | SlashCommand::Personality ++ | SlashCommand::MemoryDrop ++ | SlashCommand::MemoryUpdate ++ ) ++ { ++ return false; ++ } + match self { + SlashCommand::SandboxReadRoot => cfg!(target_os = "windows"), + SlashCommand::Copy => !cfg!(target_os = "android"), From d154934b98310fe7cec09e8f9f89f66217d74113 Mon Sep 17 00:00:00 2001 From: code-new-bie <403466121@qq.com> Date: Wed, 19 Aug 2026 21:43:27 +0800 Subject: [PATCH 132/305] ci: cover full release adapter and current thin-fork patch set --- .github/workflows/ci.yml | 69 ++++++++++++++++++++++++++++++++-------- 1 file changed, 56 insertions(+), 13 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4327383d..17a0c525 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -24,15 +24,23 @@ jobs: node-version: ${{ matrix.node }} - run: npm install - run: npm test - - run: node --check src/protocol.mjs - - run: node --check src/server.mjs - - run: node --check src/dsh/app-server-adapter.mjs - - run: node --check src/dsh/product-adapter.mjs - - run: node --check src/dsh/local-server.mjs - - run: node --check src/dsh/runtime-boot.mjs - - run: node --check bin/dshx.mjs - - run: node --check scripts/package-platform.mjs - - run: node --check scripts/runtime-smoke.mjs + - name: Syntax check all shipped JavaScript (Unix) + if: runner.os != 'Windows' + shell: bash + run: | + set -euo pipefail + while IFS= read -r file; do node --check "$file"; done < <(find src bin scripts -type f -name '*.mjs' -print | sort) + - name: Syntax check all shipped JavaScript (Windows) + if: runner.os == 'Windows' + shell: pwsh + run: | + Get-ChildItem src,bin,scripts -Recurse -Filter *.mjs | Sort-Object FullName | ForEach-Object { + node --check $_.FullName + if ($LASTEXITCODE -ne 0) { throw "syntax check failed: $($_.FullName)" } + } + - name: Syntax check PTY smoke + if: runner.os != 'Windows' + run: python3 -m py_compile test/tui_smoke.py dsh-runtime: name: Official DSH runtime / ${{ matrix.os }} @@ -76,13 +84,29 @@ jobs: if: runner.os != 'Windows' shell: bash run: | - grep -q 'DSHX_APP_SERVER_ENDPOINT is required' .upstream/codex/codex-rs/tui/src/main.rs - grep -q 'DSHX_APP_SERVER_TOKEN is required' .upstream/codex/codex-rs/tui/src/main.rs - grep -q 'Some(dshx_endpoint)' .upstream/codex/codex-rs/tui/src/main.rs - grep -q 'DSHX_RESUME_MODE' .upstream/codex/codex-rs/tui/src/main.rs + set -euo pipefail + MAIN=.upstream/codex/codex-rs/tui/src/main.rs + LIB=.upstream/codex/codex-rs/tui/src/lib.rs + APP=.upstream/codex/codex-rs/tui/src/app.rs + SESSION=.upstream/codex/codex-rs/tui/src/app_server_session.rs + SLASH=.upstream/codex/codex-rs/tui/src/slash_command.rs + STARTUP=.upstream/codex/codex-rs/tui/src/app/startup.rs + EVENT=.upstream/codex/codex-rs/tui/src/app/event_dispatch.rs + grep -q 'DSHX_APP_SERVER_ENDPOINT is required' "$MAIN" + grep -q 'DSHX_APP_SERVER_TOKEN is required' "$MAIN" + grep -q 'Some(dshx_endpoint)' "$MAIN" + grep -q 'DSHX_RESUME_MODE' "$MAIN" grep -q 'DeepSeek Harness' .upstream/codex/codex-rs/tui/src/history_cell/session.rs grep -q 'fn dshx_backend' .upstream/codex/codex-rs/tui/src/bottom_pane/approval_overlay.rs grep -q 'fn dshx_backend' .upstream/codex/codex-rs/tui/src/chatwidget/permission_popups.rs + grep -q 'fn dshx_backend' "$LIB" + grep -q '!dshx_backend()' "$LIB" + grep -q '!crate::dshx_backend()' "$STARTUP" + grep -q '!crate::dshx_backend()' "$EVENT" + grep -q 'ThreadParamsMode::Remote' "$SESSION" + grep -q 'dshx resume' "$APP" + grep -q 'if crate::dshx_backend()' "$SLASH" + grep -q 'DeepSeek Harness' "$SLASH" - name: Assert thin-fork invariants (Windows) if: runner.os == 'Windows' shell: pwsh @@ -98,6 +122,19 @@ jobs: if (-not $approval.Contains('fn dshx_backend')) { throw 'DSHX approval semantics patch is missing' } $permissions = Get-Content .upstream\codex\codex-rs\tui\src\chatwidget\permission_popups.rs -Raw if (-not $permissions.Contains('fn dshx_backend')) { throw 'DSHX permission picker patch is missing' } + $lib = Get-Content .upstream\codex\codex-rs\tui\src\lib.rs -Raw + if (-not $lib.Contains('fn dshx_backend')) { throw 'DSHX onboarding ownership patch is missing' } + $startup = Get-Content .upstream\codex\codex-rs\tui\src\app\startup.rs -Raw + if (-not $startup.Contains('!crate::dshx_backend()')) { throw 'DSHX startup ownership patch is missing' } + $event = Get-Content .upstream\codex\codex-rs\tui\src\app\event_dispatch.rs -Raw + if (-not $event.Contains('!crate::dshx_backend()')) { throw 'DSHX config ownership patch is missing' } + $session = Get-Content .upstream\codex\codex-rs\tui\src\app_server_session.rs -Raw + if (-not $session.Contains('ThreadParamsMode::Remote')) { throw 'DSHX thread ownership patch is missing' } + $app = Get-Content .upstream\codex\codex-rs\tui\src\app.rs -Raw + if (-not $app.Contains('dshx')) { throw 'DSHX resume hint patch is missing' } + $slash = Get-Content .upstream\codex\codex-rs\tui\src\slash_command.rs -Raw + if (-not $slash.Contains('if crate::dshx_backend()')) { throw 'DSHX slash capability patch is missing' } + if (-not $slash.Contains('DeepSeek Harness')) { throw 'DSHX slash branding is missing' } - name: Real pinned TUI PTY smoke if: runner.os == 'Linux' shell: bash @@ -157,3 +194,9 @@ jobs: grep -q "dsh-base" src/dsh/runtime-boot.mjs grep -q "dsh-headless" src/dsh/runtime-boot.mjs grep -q "startup: { disabled: true }" src/dsh/runtime-boot.mjs + - name: Assert DSHX never executes user shell through Node host processes + shell: bash + run: | + ! grep -R "execFile\|execSync\|spawn(" src/dsh/user-shell.mjs + grep -q "tools.execute" src/dsh/user-shell.mjs + grep -q "agent: controller.agent" src/dsh/user-shell.mjs From 1318cdd2fde3821b7dc55d9f091aa82b22572a6b Mon Sep 17 00:00:00 2001 From: code-new-bie <403466121@qq.com> Date: Wed, 19 Aug 2026 21:54:53 +0800 Subject: [PATCH 133/305] feat: map Codex text skills and images into DSH user content --- src/dsh/user-input.mjs | 106 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 106 insertions(+) create mode 100644 src/dsh/user-input.mjs diff --git a/src/dsh/user-input.mjs b/src/dsh/user-input.mjs new file mode 100644 index 00000000..b1084826 --- /dev/null +++ b/src/dsh/user-input.mjs @@ -0,0 +1,106 @@ +import fs from 'node:fs/promises'; +import path from 'node:path'; + +const MEDIA_TYPES = new Map([ + ['.png', 'image/png'], + ['.jpg', 'image/jpeg'], + ['.jpeg', 'image/jpeg'], + ['.webp', 'image/webp'], + ['.gif', 'image/gif'] +]); + +function requireAttachments(ctx) { + const attachments = ctx?.get?.('attachments'); + if (!attachments?.saveImages) throw new Error('DSHX image input requires DSH service: attachments.saveImages'); + return attachments; +} + +function localMediaType(filePath) { + const mediaType = MEDIA_TYPES.get(path.extname(filePath).toLowerCase()); + if (!mediaType) throw new Error(`Unsupported local image type: ${path.extname(filePath) || ''}`); + return mediaType; +} + +function parseDataImage(url) { + const match = /^data:(image\/(?:png|jpeg|webp|gif));base64,([A-Za-z0-9+/=\r\n]+)$/i.exec(String(url ?? '')); + if (!match) return undefined; + return { + mediaType: match[1].toLowerCase(), + data: Buffer.from(match[2].replace(/[\r\n]/g, ''), 'base64') + }; +} + +function skillGesture(name) { + const value = String(name ?? ''); + if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(value)) { + throw new Error(`Invalid DSH skill name from Codex input: ${JSON.stringify(name)}`); + } + return `/${value}`; +} + +/** + * Convert pinned Codex UserInput into the public DSH UserContent vocabulary. + * Images are committed through DSH's attachment service before their refs are + * placed in the durable user message. User-explicit skills deliberately remain + * plain `/name` gestures because that is DSH's authoritative pre-step seam. + */ +export async function codexInputToDshContent(ctx, inputs = []) { + if (!Array.isArray(inputs)) throw new Error('Codex input must be an array'); + const plan = []; + const pendingImages = []; + + for (const input of inputs) { + switch (input?.type) { + case 'text': + if (typeof input.text !== 'string') throw new Error('Codex text input requires text'); + plan.push({ kind: 'content', block: { type: 'text', text: input.text } }); + break; + case 'skill': + plan.push({ kind: 'content', block: { type: 'text', text: skillGesture(input.name) } }); + break; + case 'localImage': { + if (typeof input.path !== 'string' || input.path.length === 0) { + throw new Error('Codex localImage input requires path'); + } + const filePath = path.resolve(input.path); + const mediaType = localMediaType(filePath); + const data = await fs.readFile(filePath); + const imageIndex = pendingImages.length; + pendingImages.push({ data, mediaType, name: path.basename(filePath) }); + plan.push({ kind: 'image', imageIndex }); + break; + } + case 'image': { + const inline = parseDataImage(input.url); + if (!inline) { + throw new Error('DSHX accepts Codex image input only as localImage or data:image/*; remote image URLs remain provider-owned'); + } + const imageIndex = pendingImages.length; + pendingImages.push({ ...inline, name: `pasted-image-${imageIndex + 1}` }); + plan.push({ kind: 'image', imageIndex }); + break; + } + case 'mention': + throw new Error('DSHX does not yet project Codex structured mentions into a DSH user message'); + default: + throw new Error(`Unsupported Codex user input type: ${JSON.stringify(input?.type)}`); + } + } + + const refs = pendingImages.length > 0 + ? await requireAttachments(ctx).saveImages(pendingImages) + : []; + return plan.map((entry) => entry.kind === 'image' + ? { type: 'image', ref: refs[entry.imageIndex] } + : entry.block); +} + +export function dshContentText(content = []) { + return content + .filter((block) => block?.type === 'text' && typeof block.text === 'string') + .map((block) => block.text) + .join('\n') + .trim(); +} + +export const inputInternals = { localMediaType, parseDataImage, skillGesture }; From 9da54dcd1e396b6e20568fb8285df8e9b40d130b Mon Sep 17 00:00:00 2001 From: code-new-bie <403466121@qq.com> Date: Wed, 19 Aug 2026 21:55:53 +0800 Subject: [PATCH 134/305] feat: submit rich DSH user content through Agent inbox --- src/dsh/agent-driver.mjs | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/src/dsh/agent-driver.mjs b/src/dsh/agent-driver.mjs index ee58757b..a7ff4e31 100644 --- a/src/dsh/agent-driver.mjs +++ b/src/dsh/agent-driver.mjs @@ -9,10 +9,11 @@ function requireService(ctx, name) { return service; } -function userMessage(text) { +function userMessage(content) { + const blocks = Array.isArray(content) ? content : [{ type: 'text', text: String(content ?? '') }]; return createUserMessage({ source: { kind: 'user' }, - content: [{ type: 'text', text }] + content: blocks }); } @@ -98,8 +99,6 @@ export class DshAgentDriver { setup: (agentCtx) => this.installSelection(agentCtx) }); try { - // The source may have a runtime-only picker selection newer than its last - // request/header. Preserve that exact public DSH selection in the fork. await this.selectModel(handle.agent, sourceSelection); return handle; } catch (error) { @@ -193,8 +192,8 @@ export class DshAgentDriver { }; } - followup(agent, text) { agent.followup(userMessage(text)); } - steer(agent, text) { agent.steer(userMessage(text)); } + followup(agent, content) { agent.followup(userMessage(content)); } + steer(agent, content) { agent.steer(userMessage(content)); } interrupt(agent, { keepInbox = false } = {}) { agent.cancel({ kind: 'user' }, { keepInbox }); } whenIdle(agent) { return agent.whenIdle(); } getLive(sessionId) { return requireService(this.ctx, 'agents').get(SessionId(sessionId)); } From 13f3a3c3fecd356d3b011a663217aff27e24f45e Mon Sep 17 00:00:00 2001 From: code-new-bie <403466121@qq.com> Date: Wed, 19 Aug 2026 21:57:34 +0800 Subject: [PATCH 135/305] feat: add one-shot rich input slot without bypassing turn policy --- src/dsh/thread-controller.mjs | 36 +++++++++++++++++++++++++---------- 1 file changed, 26 insertions(+), 10 deletions(-) diff --git a/src/dsh/thread-controller.mjs b/src/dsh/thread-controller.mjs index ef015b1d..23a550c0 100644 --- a/src/dsh/thread-controller.mjs +++ b/src/dsh/thread-controller.mjs @@ -38,9 +38,6 @@ export class DshThreadController { this.turnStartTimeoutMs = turnStartTimeoutMs; this.diagnostics = diagnostics; - // Real DSH Agent contexts expose `ctx.tools`; lightweight controller tests - // may omit it. Presentation specialization is optional and never affects - // tool execution. this.toolPresentation = typeof this.agent.ctx?.get === 'function' ? new DshToolPresentationResolver({ ctx: this.agent.ctx, @@ -58,6 +55,7 @@ export class DshThreadController { }); this.pendingTurnStart = null; this.responseGate = null; + this.preparedUserContent = null; this.lastTokenUsageSignature = null; this.closed = false; @@ -73,7 +71,6 @@ export class DshThreadController { return this.toolPresentation?.correlation(callId); } - /** Current live DSH turn translated only to presentation identity. */ currentLocation() { const number = this.projector.currentTurn; if (!Number.isInteger(number)) return undefined; @@ -82,6 +79,25 @@ export class DshThreadController { return { threadId: this.threadId, turnId: turn.id }; } + /** + * Stage rich Codex input for exactly one existing base-adapter turn/steer call. + * The base adapter still owns all model/cwd/permission/sandbox validation. + */ + prepareUserContent(content) { + if (this.preparedUserContent != null) throw new Error('DSHX rich input slot is already occupied'); + const slot = { content }; + this.preparedUserContent = slot; + return () => { + if (this.preparedUserContent === slot) this.preparedUserContent = null; + }; + } + + consumeUserContent(fallback) { + const slot = this.preparedUserContent; + this.preparedUserContent = null; + return slot?.content ?? fallback; + } + deliver(notification) { if (this.responseGate && !this.responseGate.released) { this.responseGate.buffered.push(notification); @@ -107,8 +123,6 @@ export class DshThreadController { this.lastTokenUsageSignature = signature; this.deliver(notification); } catch (error) { - // Token accounting is presentation-only. A malformed optional projection - // must never interrupt the DSH agent loop or transcript stream. this.diagnostics(`token usage projection failed: ${error instanceof Error ? error.message : error}`); } } @@ -135,14 +149,15 @@ export class DshThreadController { } /** Submit a normal follow-up and wait until DSH itself commits turn/start. */ - async startTurn(text) { + async startTurn(fallbackContent) { if (this.pendingTurnStart || (this.responseGate && !this.responseGate.released)) { throw new Error('A turn/start request is already awaiting DSHX protocol release'); } + const content = this.consumeUserContent(fallbackContent); const pending = deferred(this.turnStartTimeoutMs, 'DSH turn/start'); this.pendingTurnStart = pending; try { - this.driver.followup(this.agent, text); + this.driver.followup(this.agent, content); const { turn, gate } = await pending.promise; return { turn, @@ -161,8 +176,8 @@ export class DshThreadController { } } - steer(text) { - this.driver.steer(this.agent, text); + steer(fallbackContent) { + this.driver.steer(this.agent, this.consumeUserContent(fallbackContent)); } interrupt(options) { @@ -178,6 +193,7 @@ export class DshThreadController { this.closed = true; this.pendingTurnStart?.reject(new Error('DSHX thread controller closed')); this.pendingTurnStart = null; + this.preparedUserContent = null; if (this.responseGate && !this.responseGate.released) { this.responseGate.released = true; this.responseGate.buffered.length = 0; From d57782a51cb04b85a54301b05facdf3ea4ad0942 Mon Sep 17 00:00:00 2001 From: code-new-bie <403466121@qq.com> Date: Wed, 19 Aug 2026 21:58:23 +0800 Subject: [PATCH 136/305] feat: enable DSH skills and image input through existing turn policy --- src/dsh/release-adapter.mjs | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/src/dsh/release-adapter.mjs b/src/dsh/release-adapter.mjs index 551c2299..1dc981ce 100644 --- a/src/dsh/release-adapter.mjs +++ b/src/dsh/release-adapter.mjs @@ -1,5 +1,6 @@ import { DshxProductAdapter } from './product-adapter.mjs'; import { foldDshSessionTitle, threadNameUpdatedNotification } from './thread-title.mjs'; +import { codexInputToDshContent, dshContentText } from './user-input.mjs'; import { DshUserShellBridge } from './user-shell.mjs'; function snapshotTitle(snapshot) { @@ -24,6 +25,9 @@ export class DshxReleaseAdapter extends DshxProductAdapter { return this.threadNameSet(params); case 'thread/shellCommand': return this.threadShellCommand(params); + case 'turn/start': + case 'turn/steer': + return this.richUserTurn(method, params); case 'turn/interrupt': if (this.userShell().interrupt(params?.threadId, params?.turnId)) return { result: {} }; return super.dispatch(method, params); @@ -35,6 +39,25 @@ export class DshxReleaseAdapter extends DshxProductAdapter { } } + async richUserTurn(method, params = {}) { + const threadId = String(params.threadId ?? ''); + const controller = this.controllers.get(threadId); + if (!controller) return super.dispatch(method, params); + const content = await codexInputToDshContent(this.ctx, params.input ?? []); + const clear = controller.prepareUserContent(content); + try { + // Preserve the original base/product validation path. The fallback text is + // consumed only if no prepared rich content exists; it also keeps the + // base adapter's expected text-only parameter shape intact. + return await super.dispatch(method, { + ...params, + input: [{ type: 'text', text: dshContentText(content) }] + }); + } finally { + clear(); + } + } + threadResponse(agent, options = {}) { const response = super.threadResponse(agent, options); const folded = this.driver.currentTitle(agent); From 3d511bec50c106505deb3509ebf33150dfab8f49 Mon Sep 17 00:00:00 2001 From: code-new-bie <403466121@qq.com> Date: Wed, 19 Aug 2026 22:54:45 +0800 Subject: [PATCH 137/305] fix: align DSH fork seed boundaries with host semantics --- src/dsh/session-fork.mjs | 43 ++++++++++++++++++++++++++++------------ 1 file changed, 30 insertions(+), 13 deletions(-) diff --git a/src/dsh/session-fork.mjs b/src/dsh/session-fork.mjs index c360a90c..b40bf9e6 100644 --- a/src/dsh/session-fork.mjs +++ b/src/dsh/session-fork.mjs @@ -15,33 +15,50 @@ function openTurn(events) { return open; } +function cutAfterCompletedTurn(events, target) { + const end = events.findIndex((event) => event?.type === 'turn/end' && event.data?.turn === target); + if (end < 0) throw new Error(`DSH session has no completed dsh-turn-${target}`); + + // Match the official DSH Host fork contract: keep standalone durable events + // that landed after the selected completed turn (for example session/title), + // but never copy the next turn/start or anything owned by that later turn. + let cut = end + 1; + while (cut < events.length && events[cut]?.type !== 'turn/start') cut += 1; + return cut; +} + /** * Select the exact durable DSH event prefix represented by Codex fork params. * No event is synthesized or rewritten: the returned array contains existing * SessionEvent objects and is handed to the official AgentFactory seed seam. + * + * The default fork follows DSH Host semantics: fork through the latest + * completed turn and include trailing standalone events up to (but excluding) + * the next turn/start. A currently-open later turn is therefore never copied. */ export function dshForkSeed(events = [], { lastTurnId, beforeTurnId } = {}) { if (lastTurnId != null && beforeTurnId != null) { throw new Error('thread/fork lastTurnId and beforeTurnId are mutually exclusive'); } - const active = openTurn(events); - if (active != null) { - throw new Error(`DSHX refuses to fork a DSH session with active turn ${active}`); - } - if (lastTurnId == null && beforeTurnId == null) return [...events]; + if (beforeTurnId != null) { + const target = turnNumber(beforeTurnId); + const start = events.findIndex((event) => event?.type === 'turn/start' && event.data?.turn === target); + if (start < 0) throw new Error(`DSH session has no ${beforeTurnId}`); + return events.slice(0, start); + } if (lastTurnId != null) { const target = turnNumber(lastTurnId); - const end = events.findIndex((event) => event?.type === 'turn/end' && event.data?.turn === target); - if (end < 0) throw new Error(`DSH session has no completed ${lastTurnId}`); - return events.slice(0, end + 1); + return events.slice(0, cutAfterCompletedTurn(events, target)); } - const target = turnNumber(beforeTurnId); - const start = events.findIndex((event) => event?.type === 'turn/start' && event.data?.turn === target); - if (start < 0) throw new Error(`DSH session has no ${beforeTurnId}`); - return events.slice(0, start); + const latestEnd = events.findLast?.((event) => event?.type === 'turn/end') + ?? [...events].reverse().find((event) => event?.type === 'turn/end'); + if (!latestEnd || !Number.isInteger(latestEnd.data?.turn)) { + throw new Error('DSH session has no completed turn to fork from'); + } + return events.slice(0, cutAfterCompletedTurn(events, latestEnd.data.turn)); } -export const forkInternals = { turnNumber, openTurn }; +export const forkInternals = { turnNumber, openTurn, cutAfterCompletedTurn }; From e47d74c84e2a9fc9e41841409913543813f83b77 Mon Sep 17 00:00:00 2001 From: code-new-bie <403466121@qq.com> Date: Wed, 19 Aug 2026 22:55:10 +0800 Subject: [PATCH 138/305] test: pin completed-turn fork semantics --- test/dsh-session-fork.test.mjs | 33 ++++++++++++++++++++++++++------- 1 file changed, 26 insertions(+), 7 deletions(-) diff --git a/test/dsh-session-fork.test.mjs b/test/dsh-session-fork.test.mjs index 70d42e07..627954ba 100644 --- a/test/dsh-session-fork.test.mjs +++ b/test/dsh-session-fork.test.mjs @@ -12,17 +12,22 @@ const events = [ { seq: 7, type: 'turn/end', data: { turn: 2, reason: { kind: 'completed' } } } ]; -test('full fork reuses the exact durable DSH event prefix', () => { +test('default fork uses the latest completed DSH turn prefix', () => { const seed = dshForkSeed(events); assert.deepEqual(seed.map((event) => event.seq), [1, 2, 3, 4, 5, 6, 7]); assert.equal(seed[0], events[0]); assert.equal(seed.at(-1), events.at(-1)); }); -test('lastTurnId includes the selected completed DSH turn', () => { +test('lastTurnId includes the selected completed DSH turn and trailing standalone events', () => { + const withTitle = [ + ...events.slice(0, 4), + { seq: 4.5, type: 'session/title', data: { title: 'after turn one' } }, + ...events.slice(4) + ]; assert.deepEqual( - dshForkSeed(events, { lastTurnId: 'dsh-turn-1' }).map((event) => event.seq), - [1, 2, 3, 4] + dshForkSeed(withTitle, { lastTurnId: 'dsh-turn-1' }).map((event) => event.seq), + [1, 2, 3, 4, 4.5] ); }); @@ -33,7 +38,21 @@ test('beforeTurnId excludes the selected turn and everything after it', () => { ); }); -test('fork refuses ambiguous, invalid, missing, or active DSH turn boundaries', () => { +test('default fork excludes a later active turn instead of copying an open execution prefix', () => { + const active = [ + ...events, + { seq: 8, type: 'session/title', data: { title: 'latest completed title' } }, + { seq: 9, type: 'turn/start', data: { turn: 3 } }, + { seq: 10, type: 'user/message', data: { id: 'u3' } } + ]; + assert.deepEqual(dshForkSeed(active).map((event) => event.seq), [1, 2, 3, 4, 5, 6, 7, 8]); + assert.throws( + () => dshForkSeed(active, { lastTurnId: 'dsh-turn-3' }), + /no completed dsh-turn-3/ + ); +}); + +test('fork refuses ambiguous, invalid, missing, or completion-less boundaries', () => { assert.throws( () => dshForkSeed(events, { lastTurnId: 'dsh-turn-1', beforeTurnId: 'dsh-turn-2' }), /mutually exclusive/ @@ -41,7 +60,7 @@ test('fork refuses ambiguous, invalid, missing, or active DSH turn boundaries', assert.throws(() => dshForkSeed(events, { lastTurnId: 'turn-1' }), /cannot map Codex turn id/); assert.throws(() => dshForkSeed(events, { lastTurnId: 'dsh-turn-99' }), /no completed/); assert.throws( - () => dshForkSeed([...events, { seq: 8, type: 'turn/start', data: { turn: 3 } }]), - /active turn 3/ + () => dshForkSeed([{ seq: 1, type: 'turn/start', data: { turn: 1 } }]), + /no completed turn to fork from/ ); }); From 3a3ca3af49ea71f24970ef8bb74617c70f9ae7e2 Mon Sep 17 00:00:00 2001 From: code-new-bie <403466121@qq.com> Date: Wed, 19 Aug 2026 22:56:17 +0800 Subject: [PATCH 139/305] fix: match Codex fork started notification contract --- src/dsh/release-adapter.mjs | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/src/dsh/release-adapter.mjs b/src/dsh/release-adapter.mjs index 1dc981ce..31e97a65 100644 --- a/src/dsh/release-adapter.mjs +++ b/src/dsh/release-adapter.mjs @@ -21,6 +21,8 @@ export class DshxReleaseAdapter extends DshxProductAdapter { async dispatch(method, params) { switch (method) { + case 'thread/fork': + return this.threadForkPresentation(params); case 'thread/name/set': return this.threadNameSet(params); case 'thread/shellCommand': @@ -68,6 +70,21 @@ export class DshxReleaseAdapter extends DshxProductAdapter { }; } + async threadForkPresentation(params = {}) { + const response = await super.threadFork(params); + const startedThread = { ...response.result.thread, turns: [] }; + return { + ...response, + // Pinned Codex emits copied history only in the fork response. The + // thread/started notification introduces metadata/live state and must not + // replay those copied turns a second time before paginated hydration. + afterResponse: () => this.send({ + method: 'thread/started', + params: { thread: startedThread } + }) + }; + } + async threadList(params = {}) { const response = await super.threadList(params); const data = await Promise.all(response.result.data.map(async (thread) => { From 7ab89ebede46c977a00fea574c7b2879b5e0d093 Mon Sep 17 00:00:00 2001 From: code-new-bie <403466121@qq.com> Date: Wed, 19 Aug 2026 23:02:20 +0800 Subject: [PATCH 140/305] feat: map Codex workspace command exec to DSH tools --- src/dsh/workspace-command.mjs | 166 ++++++++++++++++++++++++++++++++++ 1 file changed, 166 insertions(+) create mode 100644 src/dsh/workspace-command.mjs diff --git a/src/dsh/workspace-command.mjs b/src/dsh/workspace-command.mjs new file mode 100644 index 00000000..89867393 --- /dev/null +++ b/src/dsh/workspace-command.mjs @@ -0,0 +1,166 @@ +import path from 'node:path'; +import { randomUUID } from 'node:crypto'; + +function shellTool(agent) { + const tools = agent?.ctx?.get?.('tools'); + if (!tools) throw new Error('DSHX workspace command requires DSH service: tools'); + const preferred = process.platform === 'win32' ? ['pwsh', 'bash'] : ['bash', 'pwsh']; + for (const name of preferred) { + if (tools.get?.(name, agent)) return { tools, name }; + } + throw new Error('No DSH shell tool is visible in the active Agent scope'); +} + +function bashQuote(value) { + const text = String(value); + if (text.includes('\0')) throw new Error('command/exec argv cannot contain NUL'); + return `'${text.replaceAll("'", `'"'"'`)}'`; +} + +function pwshQuote(value) { + const text = String(value); + if (text.includes('\0')) throw new Error('command/exec argv cannot contain NUL'); + return `'${text.replaceAll("'", "''")}'`; +} + +function envName(name) { + if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(name)) { + throw new Error(`command/exec environment key is not portable: ${JSON.stringify(name)}`); + } + return name; +} + +function commandText(argv, env, dialect) { + const quote = dialect === 'pwsh' ? pwshQuote : bashQuote; + const invocation = dialect === 'pwsh' + ? `& ${argv.map(quote).join(' ')}` + : argv.map(quote).join(' '); + const entries = Object.entries(env ?? {}); + if (entries.length === 0) return invocation; + + if (dialect === 'pwsh') { + const prefix = entries.map(([rawName, value]) => { + const name = envName(rawName); + return value == null + ? `Remove-Item Env:${name} -ErrorAction SilentlyContinue` + : `$env:${name} = ${quote(value)}`; + }); + return `${prefix.join('; ')}; ${invocation}`; + } + + const prefix = entries.map(([rawName, value]) => { + const name = envName(rawName); + return value == null ? `unset ${name}` : `export ${name}=${quote(value)}`; + }); + return `${prefix.join('; ')}; ${invocation}`; +} + +function textError(result) { + return (result?.content ?? []) + .filter((block) => block?.type === 'text' && typeof block.text === 'string') + .map((block) => block.text) + .join('\n') + .trim(); +} + +function capUtf8(text, limit) { + if (!Number.isSafeInteger(limit) || limit < 0) return text; + const bytes = Buffer.from(String(text), 'utf8'); + if (bytes.length <= limit) return String(text); + let end = Math.min(limit, bytes.length); + while (end > 0 && (bytes[end] & 0b1100_0000) === 0b1000_0000) end -= 1; + return bytes.subarray(0, end).toString('utf8'); +} + +function bufferedResult(result, params) { + if (result?.isError) { + throw new Error(textError(result) || result.error?.message || 'DSH workspace command failed'); + } + const value = result?.value; + if (!value || value.kind !== 'foreground') { + throw new Error(`DSH shell returned non-foreground command/exec result: ${JSON.stringify(value?.kind)}`); + } + const exitCode = Number.isInteger(value.exitCode) ? value.exitCode : 1; + const cap = params.disableOutputCap === true ? undefined : params.outputBytesCap; + return { + exitCode, + stdout: capUtf8(value.stdout?.text ?? '', cap), + stderr: capUtf8(value.stderr?.text ?? '', cap) + }; +} + +function validateParams(params) { + if (!Array.isArray(params.command) || params.command.length === 0 || params.command.some((entry) => typeof entry !== 'string' || entry.length === 0)) { + throw new Error('command/exec requires a non-empty argv string array'); + } + if (params.processId != null || params.tty === true || params.streamStdin === true || params.streamStdoutStderr === true || params.size != null) { + throw new Error('DSHX command/exec supports buffered non-TTY workspace commands only'); + } + if (params.disableTimeout === true) { + throw new Error('DSHX command/exec does not override the DSH shell executor timeout policy'); + } + if (params.outputBytesCap != null && params.disableOutputCap === true) { + throw new Error('command/exec outputBytesCap and disableOutputCap are mutually exclusive'); + } + if (params.timeoutMs != null && (!Number.isSafeInteger(params.timeoutMs) || params.timeoutMs <= 0)) { + throw new Error('command/exec timeoutMs must be a positive integer'); + } + if (params.sandboxPolicy != null || params.permissionProfile != null) { + throw new Error('DSHX command/exec uses the active DSH session permission policy and rejects Codex policy overrides'); + } +} + +/** + * TUI-owned one-off workspace probes (`git`, `/diff`, status metadata) mapped + * onto the official DSH shell tool runtime. No process is spawned by DSHX. + */ +export class DshWorkspaceCommandBridge { + constructor({ driver } = {}) { + if (!driver?.listRootAgents) throw new Error('DshWorkspaceCommandBridge requires DshAgentDriver'); + this.driver = driver; + } + + agentFor(cwd) { + const roots = this.driver.listRootAgents(); + if (roots.length === 0) throw new Error('command/exec requires an active DSH root Agent'); + if (cwd != null) { + const wanted = path.resolve(cwd); + const matches = roots.filter((agent) => path.resolve(agent.session?.header?.cwd ?? process.cwd()) === wanted); + if (matches.length === 1) return matches[0]; + if (matches.length > 1) throw new Error(`command/exec cwd matches multiple active DSH Agents: ${wanted}`); + } + if (roots.length === 1) return roots[0]; + throw new Error('command/exec cannot choose among multiple active DSH root Agents without an exact cwd match'); + } + + async execute(params = {}) { + validateParams(params); + const agent = this.agentFor(params.cwd); + const { tools, name } = shellTool(agent); + const args = { + command: commandText(params.command, params.env, name), + description: 'Run DSHX TUI workspace command', + workdir: params.cwd ?? agent.session?.header?.cwd, + run_in_background: false, + ...(params.timeoutMs == null ? {} : { timeoutMs: params.timeoutMs }) + }; + const result = await tools.execute({ + callId: `dshx-workspace-${randomUUID()}`, + name, + arguments: args, + signal: new AbortController().signal, + agent + }); + return bufferedResult(result, params); + } +} + +export const workspaceCommandInternals = { + bashQuote, + pwshQuote, + commandText, + bufferedResult, + validateParams, + capUtf8, + shellTool +}; From a424920d4e8a8fb5ba8cc81a2044e265b3836518 Mon Sep 17 00:00:00 2001 From: code-new-bie <403466121@qq.com> Date: Wed, 19 Aug 2026 23:02:50 +0800 Subject: [PATCH 141/305] feat: expose Codex workspace command exec through DSH --- src/dsh/release-adapter.mjs | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/src/dsh/release-adapter.mjs b/src/dsh/release-adapter.mjs index 31e97a65..3b7535b3 100644 --- a/src/dsh/release-adapter.mjs +++ b/src/dsh/release-adapter.mjs @@ -2,6 +2,7 @@ import { DshxProductAdapter } from './product-adapter.mjs'; import { foldDshSessionTitle, threadNameUpdatedNotification } from './thread-title.mjs'; import { codexInputToDshContent, dshContentText } from './user-input.mjs'; import { DshUserShellBridge } from './user-shell.mjs'; +import { DshWorkspaceCommandBridge } from './workspace-command.mjs'; function snapshotTitle(snapshot) { return typeof snapshot?.title === 'string' && snapshot.title.length > 0 ? snapshot.title : null; @@ -19,8 +20,14 @@ export class DshxReleaseAdapter extends DshxProductAdapter { }); } + workspaceCommands() { + return this._workspaceCommands ??= new DshWorkspaceCommandBridge({ driver: this.driver }); + } + async dispatch(method, params) { switch (method) { + case 'command/exec': + return this.commandExec(params); case 'thread/fork': return this.threadForkPresentation(params); case 'thread/name/set': @@ -41,6 +48,10 @@ export class DshxReleaseAdapter extends DshxProductAdapter { } } + async commandExec(params = {}) { + return { result: await this.workspaceCommands().execute(params) }; + } + async richUserTurn(method, params = {}) { const threadId = String(params.threadId ?? ''); const controller = this.controllers.get(threadId); From 408fee128560fe5a7d7616674e2b9be50dd111fe Mon Sep 17 00:00:00 2001 From: code-new-bie <403466121@qq.com> Date: Wed, 19 Aug 2026 23:03:27 +0800 Subject: [PATCH 142/305] test: cover DSH workspace command bridge --- test/dsh-workspace-command.test.mjs | 116 ++++++++++++++++++++++++++++ 1 file changed, 116 insertions(+) create mode 100644 test/dsh-workspace-command.test.mjs diff --git a/test/dsh-workspace-command.test.mjs b/test/dsh-workspace-command.test.mjs new file mode 100644 index 00000000..a3872ac8 --- /dev/null +++ b/test/dsh-workspace-command.test.mjs @@ -0,0 +1,116 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { DshWorkspaceCommandBridge, workspaceCommandInternals } from '../src/dsh/workspace-command.mjs'; + +function agent(id, cwd, calls) { + const tools = { + get(name) { + return name === 'bash' ? { name: 'bash' } : undefined; + }, + async execute(input) { + calls.push(input); + return { + isError: false, + value: { + kind: 'foreground', + exitCode: 7, + signal: null, + timedOut: false, + aborted: false, + timeoutMs: 1234, + stdout: { text: 'héllo\n', truncated: false }, + stderr: { text: 'warn\n', truncated: false } + }, + content: [{ type: 'text', text: 'rendered output' }] + }; + } + }; + return { + id, + session: { header: { id, cwd } }, + ctx: { get(name) { return name === 'tools' ? tools : undefined; } } + }; +} + +test('workspace command maps buffered Codex argv execution through official DSH tools', async () => { + const calls = []; + const a = agent('a', '/workspace/a', calls); + const b = agent('b', '/workspace/b', calls); + const bridge = new DshWorkspaceCommandBridge({ + driver: { listRootAgents() { return [a, b]; } } + }); + + const result = await bridge.execute({ + command: ['git', 'show', "a'b"], + cwd: '/workspace/b', + env: { NO_COLOR: '1', REMOVE_ME: null }, + timeoutMs: 1234, + outputBytesCap: 5 + }); + + assert.deepEqual(result, { exitCode: 7, stdout: 'héll', stderr: 'warn\n' }); + assert.equal(calls.length, 1); + assert.equal(calls[0].agent, b); + assert.equal(calls[0].name, 'bash'); + assert.equal(calls[0].arguments.workdir, '/workspace/b'); + assert.equal(calls[0].arguments.timeoutMs, 1234); + assert.match(calls[0].arguments.command, /export NO_COLOR='1'/); + assert.match(calls[0].arguments.command, /unset REMOVE_ME/); + assert.match(calls[0].arguments.command, /'a'"'"'b'/); +}); + +test('workspace command quoting is dialect-safe for bash and PowerShell', () => { + assert.equal(workspaceCommandInternals.bashQuote("a'b"), `'a'"'"'b'`); + assert.equal(workspaceCommandInternals.pwshQuote("a'b"), `'a''b'`); + assert.match( + workspaceCommandInternals.commandText(['git', 'status'], { FLAG: "a'b" }, 'pwsh'), + /\$env:FLAG = 'a''b'; & 'git' 'status'/ + ); +}); + +test('workspace command preserves complete output when disableOutputCap is true', () => { + const result = workspaceCommandInternals.bufferedResult({ + isError: false, + value: { + kind: 'foreground', + exitCode: 0, + stdout: { text: '0123456789' }, + stderr: { text: 'abcdefghij' } + } + }, { disableOutputCap: true, outputBytesCap: null }); + assert.deepEqual(result, { exitCode: 0, stdout: '0123456789', stderr: 'abcdefghij' }); +}); + +test('workspace command rejects Codex capabilities DSHX cannot faithfully own', async () => { + const calls = []; + const bridge = new DshWorkspaceCommandBridge({ + driver: { listRootAgents() { return [agent('a', process.cwd(), calls)]; } } + }); + await assert.rejects( + () => bridge.execute({ command: ['git'], tty: true }), + /buffered non-TTY/ + ); + await assert.rejects( + () => bridge.execute({ command: ['git'], sandboxPolicy: { type: 'dangerFullAccess' } }), + /active DSH session permission policy/ + ); + await assert.rejects( + () => bridge.execute({ command: ['git'], disableTimeout: true }), + /does not override the DSH shell executor timeout policy/ + ); +}); + +test('workspace command fails closed when multiple active roots are ambiguous', async () => { + const calls = []; + const bridge = new DshWorkspaceCommandBridge({ + driver: { + listRootAgents() { + return [agent('a', '/one', calls), agent('b', '/two', calls)]; + } + } + }); + await assert.rejects( + () => bridge.execute({ command: ['git', 'status'] }), + /cannot choose among multiple active DSH root Agents/ + ); +}); From 7aa9227fbd7d4651bc2f2cd3eb35d1532206ce7d Mon Sep 17 00:00:00 2001 From: code-new-bie <403466121@qq.com> Date: Wed, 19 Aug 2026 23:04:02 +0800 Subject: [PATCH 143/305] feat: restore Codex diff command on DSH workspace runner --- upstream/patches/codex/0009-dshx-slash-capabilities.patch | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/upstream/patches/codex/0009-dshx-slash-capabilities.patch b/upstream/patches/codex/0009-dshx-slash-capabilities.patch index 60768ca7..1d184b19 100644 --- a/upstream/patches/codex/0009-dshx-slash-capabilities.patch +++ b/upstream/patches/codex/0009-dshx-slash-capabilities.patch @@ -33,7 +33,7 @@ diff --git a/codex-rs/tui/src/slash_command.rs b/codex-rs/tui/src/slash_command. } - SlashCommand::Permissions => "choose what Codex is allowed to do", + SlashCommand::Permissions => "choose what DeepSeek Harness is allowed to do", -@@ -251,6 +251,42 @@ impl SlashCommand { +@@ -251,6 +251,41 @@ impl SlashCommand { } fn is_visible(self) -> bool { @@ -58,7 +58,6 @@ diff --git a/codex-rs/tui/src/slash_command.rs b/codex-rs/tui/src/slash_command. + | SlashCommand::MultiAgents + | SlashCommand::Side + | SlashCommand::Btw -+ | SlashCommand::Diff + | SlashCommand::Cd + | SlashCommand::Usage + | SlashCommand::DebugConfig From 4526d6197bdbafe218853f3abeb4bdb0e09df1b7 Mon Sep 17 00:00:00 2001 From: code-new-bie <403466121@qq.com> Date: Wed, 19 Aug 2026 23:06:52 +0800 Subject: [PATCH 144/305] ci: guard DSH-owned workspace execution and diff parity --- .github/workflows/ci.yml | 21 +++++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 17a0c525..cf61d192 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -107,6 +107,15 @@ jobs: grep -q 'dshx resume' "$APP" grep -q 'if crate::dshx_backend()' "$SLASH" grep -q 'DeepSeek Harness' "$SLASH" + python3 - <<'PY' + from pathlib import Path + text = Path('.upstream/codex/codex-rs/tui/src/slash_command.rs').read_text() + start = text.index('if crate::dshx_backend()') + end = text.index('return false;', start) + hidden = text[start:end] + if 'SlashCommand::Diff' in hidden: + raise SystemExit('/diff must remain visible in DSHX now that command/exec is DSH-backed') + PY - name: Assert thin-fork invariants (Windows) if: runner.os == 'Windows' shell: pwsh @@ -135,6 +144,11 @@ jobs: $slash = Get-Content .upstream\codex\codex-rs\tui\src\slash_command.rs -Raw if (-not $slash.Contains('if crate::dshx_backend()')) { throw 'DSHX slash capability patch is missing' } if (-not $slash.Contains('DeepSeek Harness')) { throw 'DSHX slash branding is missing' } + $hiddenStart = $slash.IndexOf('if crate::dshx_backend()') + $hiddenEnd = $slash.IndexOf('return false;', $hiddenStart) + if ($hiddenStart -lt 0 -or $hiddenEnd -lt 0) { throw 'DSHX slash hidden block not found' } + $hidden = $slash.Substring($hiddenStart, $hiddenEnd - $hiddenStart) + if ($hidden.Contains('SlashCommand::Diff')) { throw '/diff must remain visible in DSHX now that command/exec is DSH-backed' } - name: Real pinned TUI PTY smoke if: runner.os == 'Linux' shell: bash @@ -194,9 +208,12 @@ jobs: grep -q "dsh-base" src/dsh/runtime-boot.mjs grep -q "dsh-headless" src/dsh/runtime-boot.mjs grep -q "startup: { disabled: true }" src/dsh/runtime-boot.mjs - - name: Assert DSHX never executes user shell through Node host processes + - name: Assert shell and workspace execution remain DSH-owned shell: bash run: | - ! grep -R "execFile\|execSync\|spawn(" src/dsh/user-shell.mjs + ! grep -E "execFile|execSync|spawn\(" src/dsh/user-shell.mjs src/dsh/workspace-command.mjs grep -q "tools.execute" src/dsh/user-shell.mjs grep -q "agent: controller.agent" src/dsh/user-shell.mjs + grep -q "tools.execute" src/dsh/workspace-command.mjs + grep -q "agent" src/dsh/workspace-command.mjs + grep -q "case 'command/exec'" src/dsh/release-adapter.mjs From cd4cd336033b54122978214579016be3cef6bbe0 Mon Sep 17 00:00:00 2001 From: code-new-bie <403466121@qq.com> Date: Wed, 19 Aug 2026 23:13:40 +0800 Subject: [PATCH 145/305] ci: add macOS build runtime and clean-install gates --- .github/workflows/ci.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cf61d192..07637bd9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -15,7 +15,7 @@ jobs: strategy: fail-fast: false matrix: - os: [ubuntu-latest, windows-latest] + os: [ubuntu-latest, windows-latest, macos-latest] node: [20, 22] steps: - uses: actions/checkout@v4 @@ -49,7 +49,7 @@ jobs: strategy: fail-fast: false matrix: - os: [ubuntu-latest, windows-latest] + os: [ubuntu-latest, windows-latest, macos-latest] steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 @@ -66,7 +66,7 @@ jobs: strategy: fail-fast: false matrix: - os: [ubuntu-latest, windows-latest] + os: [ubuntu-latest, windows-latest, macos-latest] steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 From c1d2c4905c269ff294783bf6792e6d8d617aafec Mon Sep 17 00:00:00 2001 From: code-new-bie <403466121@qq.com> Date: Wed, 19 Aug 2026 23:21:03 +0800 Subject: [PATCH 146/305] feat: project DSH plan mode into Codex thread settings --- src/dsh/plan-presentation.mjs | 55 +++++++++++++++++++++++++++++++++++ 1 file changed, 55 insertions(+) create mode 100644 src/dsh/plan-presentation.mjs diff --git a/src/dsh/plan-presentation.mjs b/src/dsh/plan-presentation.mjs new file mode 100644 index 00000000..e1d40893 --- /dev/null +++ b/src/dsh/plan-presentation.mjs @@ -0,0 +1,55 @@ +export function codexPlanTarget(collaborationMode) { + if (!collaborationMode || typeof collaborationMode !== 'object' || Array.isArray(collaborationMode)) { + throw new Error('DSHX collaborationMode must be a Codex collaboration mode object'); + } + if (collaborationMode.mode === 'plan') return true; + if (collaborationMode.mode === 'default') return false; + throw new Error(`DSHX cannot map Codex collaboration mode ${JSON.stringify(collaborationMode.mode)} to DSH plan mode`); +} + +export function effectiveDshPlanState(state) { + if (typeof state?.pending === 'boolean') return state.pending; + return Boolean(state?.active); +} + +export function codexCollaborationMode({ model, reasoningEffort, active }) { + return { + mode: active ? 'plan' : 'default', + settings: { + model, + reasoning_effort: reasoningEffort ?? null, + // DSH owns plan guidance. Never copy Codex developer instructions into + // the Harness request just to satisfy a presentation protocol shape. + developer_instructions: null + } + }; +} + +export function threadSettingsUpdatedNotification({ threadId, response, planState }) { + const active = effectiveDshPlanState(planState); + return { + method: 'thread/settings/updated', + params: { + threadId, + threadSettings: { + cwd: response.cwd, + approvalPolicy: response.approvalPolicy, + approvalsReviewer: response.approvalsReviewer, + sandboxPolicy: response.sandbox, + activePermissionProfile: response.activePermissionProfile ?? null, + model: response.model, + modelProvider: response.modelProvider, + serviceTier: response.serviceTier ?? null, + effort: response.reasoningEffort ?? null, + summary: null, + collaborationMode: codexCollaborationMode({ + model: response.model, + reasoningEffort: response.reasoningEffort, + active + }), + multiAgentMode: response.multiAgentMode ?? 'explicitRequestOnly', + personality: null + } + } + }; +} From 9885267995cc07c3703539af916a07d85b93b5bb Mon Sep 17 00:00:00 2001 From: code-new-bie <403466121@qq.com> Date: Wed, 19 Aug 2026 23:23:21 +0800 Subject: [PATCH 147/305] feat: map Codex plan mode to authoritative DSH planMode --- src/dsh/release-adapter.mjs | 108 ++++++++++++++++++++++++++++++++++-- 1 file changed, 102 insertions(+), 6 deletions(-) diff --git a/src/dsh/release-adapter.mjs b/src/dsh/release-adapter.mjs index 3b7535b3..8a649155 100644 --- a/src/dsh/release-adapter.mjs +++ b/src/dsh/release-adapter.mjs @@ -1,4 +1,5 @@ import { DshxProductAdapter } from './product-adapter.mjs'; +import { codexPlanTarget, threadSettingsUpdatedNotification } from './plan-presentation.mjs'; import { foldDshSessionTitle, threadNameUpdatedNotification } from './thread-title.mjs'; import { codexInputToDshContent, dshContentText } from './user-input.mjs'; import { DshUserShellBridge } from './user-shell.mjs'; @@ -24,6 +25,47 @@ export class DshxReleaseAdapter extends DshxProductAdapter { return this._workspaceCommands ??= new DshWorkspaceCommandBridge({ driver: this.driver }); } + planListeners() { + return this._planListeners ??= new Map(); + } + + planMutationSuppressed() { + return this._planMutationSuppressed ??= new Set(); + } + + planMode(agent) { + const service = agent?.ctx?.get?.('planMode') ?? this.ctx.get('planMode'); + if (!service?.set || !service?.get) throw new Error('DSHX requires DSH service: planMode'); + return service; + } + + planSettingsNotification(agent) { + const response = this.threadResponse(agent); + return threadSettingsUpdatedNotification({ + threadId: String(agent.id), + response, + planState: this.planMode(agent).get(agent) + }); + } + + installController(handle) { + const controller = super.installController(handle); + const threadId = String(controller.agent.id); + if (!this.planListeners().has(threadId)) { + const dispose = controller.agent.ctx.on('session/event', (session, event) => { + if (session !== controller.agent.session || event?.type !== 'plan/mode') return; + if (this.planMutationSuppressed().has(threadId)) return; + try { + this.send(this.planSettingsNotification(controller.agent)); + } catch (error) { + this.diagnostics(`plan settings projection failed for ${threadId}: ${error instanceof Error ? error.message : error}`); + } + }); + this.planListeners().set(threadId, dispose); + } + return controller; + } + async dispatch(method, params) { switch (method) { case 'command/exec': @@ -32,6 +74,8 @@ export class DshxReleaseAdapter extends DshxProductAdapter { return this.threadForkPresentation(params); case 'thread/name/set': return this.threadNameSet(params); + case 'thread/settings/update': + return this.threadSettingsUpdatePresentation(params); case 'thread/shellCommand': return this.threadShellCommand(params); case 'turn/start': @@ -40,9 +84,14 @@ export class DshxReleaseAdapter extends DshxProductAdapter { case 'turn/interrupt': if (this.userShell().interrupt(params?.threadId, params?.turnId)) return { result: {} }; return super.dispatch(method, params); - case 'thread/unsubscribe': - this.userShell().abortThread(params?.threadId, 'thread unsubscribed'); + case 'thread/unsubscribe': { + const threadId = String(params?.threadId ?? ''); + this.userShell().abortThread(threadId, 'thread unsubscribed'); + this.planListeners().get(threadId)?.(); + this.planListeners().delete(threadId); + this.planMutationSuppressed().delete(threadId); return super.dispatch(method, params); + } default: return super.dispatch(method, params); } @@ -81,18 +130,62 @@ export class DshxReleaseAdapter extends DshxProductAdapter { }; } + async threadResume(params = {}) { + const response = await super.threadResume(params); + const threadId = String(params.threadId ?? response.result.thread?.id ?? ''); + const agent = this.controllers.get(threadId)?.agent; + const prior = response.afterResponse; + return { + ...response, + afterResponse: () => { + prior?.(); + if (agent) this.send(this.planSettingsNotification(agent)); + } + }; + } + async threadForkPresentation(params = {}) { const response = await super.threadFork(params); const startedThread = { ...response.result.thread, turns: [] }; + const agent = this.controllers.get(String(response.result.thread.id))?.agent; return { ...response, // Pinned Codex emits copied history only in the fork response. The // thread/started notification introduces metadata/live state and must not // replay those copied turns a second time before paginated hydration. - afterResponse: () => this.send({ - method: 'thread/started', - params: { thread: startedThread } - }) + afterResponse: () => { + this.send({ + method: 'thread/started', + params: { thread: startedThread } + }); + if (agent) this.send(this.planSettingsNotification(agent)); + } + }; + } + + async threadSettingsUpdatePresentation(params = {}) { + if (params.collaborationMode == null) return super.threadSettingsUpdate(params); + const threadId = String(params.threadId ?? ''); + const controller = this.controllers.get(threadId); + if (!controller) throw new Error(`Thread is not resumed in DSHX: ${threadId}`); + const active = codexPlanTarget(params.collaborationMode); + const { collaborationMode: _ignored, ...ordinary } = params; + + // Let the existing DSH-backed settings adapter validate/apply every other + // supported field. The collaboration payload's model/reasoning/instructions + // are intentionally not treated as DSH model or prompt configuration. + await super.threadSettingsUpdate(ordinary); + + this.planMutationSuppressed().add(threadId); + try { + await this.planMode(controller.agent).set(controller.agent, active); + } finally { + this.planMutationSuppressed().delete(threadId); + } + + return { + result: {}, + afterResponse: () => this.send(this.planSettingsNotification(controller.agent)) }; } @@ -161,6 +254,9 @@ export class DshxReleaseAdapter extends DshxProductAdapter { async close() { this._userShell?.close(); + for (const dispose of this.planListeners().values()) dispose?.(); + this.planListeners().clear(); + this.planMutationSuppressed().clear(); await super.close(); } } From 20d0e37bb92c157fbfd5e5b32a68cc6172f603d3 Mon Sep 17 00:00:00 2001 From: code-new-bie <403466121@qq.com> Date: Wed, 19 Aug 2026 23:23:52 +0800 Subject: [PATCH 148/305] test: pin DSH plan presentation contract --- test/dsh-plan-presentation.test.mjs | 68 +++++++++++++++++++++++++++++ 1 file changed, 68 insertions(+) create mode 100644 test/dsh-plan-presentation.test.mjs diff --git a/test/dsh-plan-presentation.test.mjs b/test/dsh-plan-presentation.test.mjs new file mode 100644 index 00000000..0c2170f2 --- /dev/null +++ b/test/dsh-plan-presentation.test.mjs @@ -0,0 +1,68 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { + codexCollaborationMode, + codexPlanTarget, + effectiveDshPlanState, + threadSettingsUpdatedNotification +} from '../src/dsh/plan-presentation.mjs'; + +test('Codex plan/default modes map only to the DSH boolean plan target', () => { + assert.equal(codexPlanTarget({ mode: 'plan', settings: {} }), true); + assert.equal(codexPlanTarget({ mode: 'default', settings: {} }), false); + assert.throws(() => codexPlanTarget({ mode: 'review', settings: {} }), /cannot map Codex collaboration mode/); + assert.throws(() => codexPlanTarget(null), /must be a Codex collaboration mode object/); +}); + +test('pending DSH plan selection is the effective UI state until committed', () => { + assert.equal(effectiveDshPlanState({ active: false, pending: true }), true); + assert.equal(effectiveDshPlanState({ active: true, pending: false }), false); + assert.equal(effectiveDshPlanState({ active: true }), true); + assert.equal(effectiveDshPlanState({ active: false }), false); +}); + +test('Codex collaboration presentation never copies developer instructions into DSH state', () => { + assert.deepEqual(codexCollaborationMode({ + model: 'dshx:model', + reasoningEffort: 'high', + active: true + }), { + mode: 'plan', + settings: { + model: 'dshx:model', + reasoning_effort: 'high', + developer_instructions: null + } + }); +}); + +test('thread settings notification projects DSH plan state into pinned Codex wire shape', () => { + const notification = threadSettingsUpdatedNotification({ + threadId: 'session-1', + response: { + cwd: '/workspace', + approvalPolicy: 'on-request', + approvalsReviewer: 'user', + sandbox: { type: 'workspaceWrite', writableRoots: ['/workspace'], networkAccess: false, excludeTmpdirEnvVar: false, excludeSlashTmp: false }, + activePermissionProfile: { id: ':workspace', extends: null }, + model: 'dshx:model', + modelProvider: 'deepseek', + serviceTier: null, + reasoningEffort: null, + multiAgentMode: 'explicitRequestOnly' + }, + planState: { active: false, pending: true } + }); + + assert.equal(notification.method, 'thread/settings/updated'); + assert.equal(notification.params.threadId, 'session-1'); + assert.equal(notification.params.threadSettings.collaborationMode.mode, 'plan'); + assert.deepEqual(notification.params.threadSettings.collaborationMode.settings, { + model: 'dshx:model', + reasoning_effort: null, + developer_instructions: null + }); + assert.equal(notification.params.threadSettings.activePermissionProfile.id, ':workspace'); + assert.equal(notification.params.threadSettings.summary, null); + assert.equal(notification.params.threadSettings.personality, null); +}); From 22691db257bcc6d69918f5ac7d68cbe1d0ef42f3 Mon Sep 17 00:00:00 2001 From: code-new-bie <403466121@qq.com> Date: Wed, 19 Aug 2026 23:24:22 +0800 Subject: [PATCH 149/305] feat: restore native Codex plan command on DSH planMode --- upstream/patches/codex/0009-dshx-slash-capabilities.patch | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/upstream/patches/codex/0009-dshx-slash-capabilities.patch b/upstream/patches/codex/0009-dshx-slash-capabilities.patch index 1d184b19..d23df37f 100644 --- a/upstream/patches/codex/0009-dshx-slash-capabilities.patch +++ b/upstream/patches/codex/0009-dshx-slash-capabilities.patch @@ -33,7 +33,7 @@ diff --git a/codex-rs/tui/src/slash_command.rs b/codex-rs/tui/src/slash_command. } - SlashCommand::Permissions => "choose what Codex is allowed to do", + SlashCommand::Permissions => "choose what DeepSeek Harness is allowed to do", -@@ -251,6 +251,41 @@ impl SlashCommand { +@@ -251,6 +251,40 @@ impl SlashCommand { } fn is_visible(self) -> bool { @@ -52,7 +52,6 @@ diff --git a/codex-rs/tui/src/slash_command.rs b/codex-rs/tui/src/slash_command. + | SlashCommand::Archive + | SlashCommand::Delete + | SlashCommand::App -+ | SlashCommand::Plan + | SlashCommand::Goal + | SlashCommand::Agents + | SlashCommand::MultiAgents From a8d8ab1d1cfcafb4d823c8fd3a0a0766b1603166 Mon Sep 17 00:00:00 2001 From: code-new-bie <403466121@qq.com> Date: Thu, 20 Aug 2026 05:10:43 +0800 Subject: [PATCH 150/305] ci: invoke Codex materializer through bash --- scripts/build-codex-tui.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/build-codex-tui.sh b/scripts/build-codex-tui.sh index bb0e5c4a..e573a18d 100644 --- a/scripts/build-codex-tui.sh +++ b/scripts/build-codex-tui.sh @@ -2,7 +2,7 @@ set -euo pipefail ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -"$ROOT/scripts/materialize-codex.sh" +bash "$ROOT/scripts/materialize-codex.sh" CODEX_DIR="${DSHX_CODEX_DIR:-$ROOT/.upstream/codex}" TARGET_DIR="${CARGO_TARGET_DIR:-$ROOT/.build/codex}" OUT_DIR="${DSHX_TUI_OUT_DIR:-$ROOT/dist/bin}" From a969afd1401c7522f50faca527b499146944c8c7 Mon Sep 17 00:00:00 2001 From: code-new-bie <403466121@qq.com> Date: Thu, 20 Aug 2026 05:12:42 +0800 Subject: [PATCH 151/305] feat: delegate TUI commands to official DSH command registry --- src/dsh/commands.mjs | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) create mode 100644 src/dsh/commands.mjs diff --git a/src/dsh/commands.mjs b/src/dsh/commands.mjs new file mode 100644 index 00000000..5deec321 --- /dev/null +++ b/src/dsh/commands.mjs @@ -0,0 +1,26 @@ +function requireService(ctx, name) { + const service = ctx?.get?.(name); + if (service == null) throw new Error(`DSHX requires DSH service: ${name}`); + return service; +} + +/** + * Execute one human-facing DSH command through the official command plane. + * DSHX does not parse, implement, persist, or run the domain operation itself. + */ +export async function executeDshCommand({ ctx, agent, line, signal }) { + if (!agent) throw new Error('DSHX command execution requires a live DSH Agent'); + const commands = requireService(ctx, 'commands'); + const execution = await commands.execute(agent, line, signal); + if (execution == null) throw new Error(`DSH command is not registered: ${line}`); + if (execution.result?.kind === 'error') { + throw new Error(execution.result.text || `DSH command failed: ${line}`); + } + return execution; +} + +/** Read the current agent-scoped command catalog from DSH. */ +export function listDshCommands({ ctx, agent }) { + const commands = requireService(ctx, 'commands'); + return commands.list(agent); +} From ef1f7f79e95cb786ff8a7c5a64e2827b2272bff5 Mon Sep 17 00:00:00 2001 From: code-new-bie <403466121@qq.com> Date: Thu, 20 Aug 2026 05:12:46 +0800 Subject: [PATCH 152/305] chore: pin official DSH rc.8 release commit --- upstream/DSH_COMMIT | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/upstream/DSH_COMMIT b/upstream/DSH_COMMIT index e9035fd1..cbba87c8 100644 --- a/upstream/DSH_COMMIT +++ b/upstream/DSH_COMMIT @@ -1 +1 @@ -99f6f02fecdb7dff40c3fbc9470f5907c29f74ca +141eb6fef83422698aef7a981029e843e8161534 From dc66c14bdd8a57066164d89ac41309e10f1f2e6d Mon Sep 17 00:00:00 2001 From: code-new-bie <403466121@qq.com> Date: Thu, 20 Aug 2026 05:12:58 +0800 Subject: [PATCH 153/305] chore: align DSH dependencies with rc.8 pin --- package.json | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/package.json b/package.json index 9a807fd3..667a4e95 100644 --- a/package.json +++ b/package.json @@ -17,12 +17,12 @@ "dev": "node ./bin/dshx.mjs" }, "dependencies": { - "@deepseek-ai/dsh": "0.1.0-rc.7", - "@deepseek-ai/dsh-agent": "0.1.0-rc.7", - "@deepseek-ai/dsh-app-boot": "0.1.0-rc.7", - "@deepseek-ai/dsh-launch-environment": "0.1.0-rc.7", - "@deepseek-ai/dsh-llm": "0.1.0-rc.7", - "@deepseek-ai/dsh-session": "0.1.0-rc.7", + "@deepseek-ai/dsh": "0.1.0-rc.8", + "@deepseek-ai/dsh-agent": "0.1.0-rc.8", + "@deepseek-ai/dsh-app-boot": "0.1.0-rc.8", + "@deepseek-ai/dsh-launch-environment": "0.1.0-rc.8", + "@deepseek-ai/dsh-llm": "0.1.0-rc.8", + "@deepseek-ai/dsh-session": "0.1.0-rc.8", "ws": "^8.18.0" } } From 10ef7973755e7b2a53b3bc1aebfa7f3f58829b02 Mon Sep 17 00:00:00 2001 From: code-new-bie <403466121@qq.com> Date: Thu, 20 Aug 2026 05:17:47 +0800 Subject: [PATCH 154/305] refactor: delegate session fork to official DSH Host API --- src/dsh/host-api.mjs | 48 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 48 insertions(+) create mode 100644 src/dsh/host-api.mjs diff --git a/src/dsh/host-api.mjs b/src/dsh/host-api.mjs new file mode 100644 index 00000000..a8fc00f0 --- /dev/null +++ b/src/dsh/host-api.mjs @@ -0,0 +1,48 @@ +import { randomUUID } from 'node:crypto'; +import { createApiProxy, RpcId } from '@deepseek-ai/dsh-host-apiproxy'; + +function requireService(ctx, name) { + const service = ctx?.get?.(name); + if (service == null) throw new Error(`DSHX requires DSH service: ${name}`); + return service; +} + +function rpcError(error, fallback) { + if (error?.message) return new Error(error.message); + return new Error(fallback); +} + +/** + * Very small façade over DSH's own transport-agnostic Host API. + * Domain semantics stay in @deepseek-ai/dsh-host-apiproxy; DSHX only adapts + * Codex presentation requests into that public API. + */ +export class DshHostApi { + constructor(ctx, { cwd = process.cwd() } = {}) { + if (!ctx || typeof ctx.get !== 'function') throw new Error('DshHostApi requires a Cordis Context'); + this.ctx = ctx; + this.api = createApiProxy(ctx, { + cwd, + defaultModelSelection: () => requireService(ctx, 'agentDefaultModel').currentSelection(), + saveDefaultModelSelection: (selection) => requireService(ctx, 'agentDefaultModel').saveSelection(selection) + }); + } + + request(payload) { + return { + rpcId: RpcId(`dshx-${randomUUID()}`), + payload + }; + } + + async forkSession({ sessionId, atSeq } = {}) { + const response = await this.api.sessions.fork(this.request({ + sessionId, + ...(atSeq == null ? {} : { atSeq }) + })); + if (!response?.result?.ok) { + throw rpcError(response?.result?.error, `DSH session fork failed for ${String(sessionId)}`); + } + return response.result.value; + } +} From 840f5894082dca409004a8278213474831e77d6d Mon Sep 17 00:00:00 2001 From: code-new-bie <403466121@qq.com> Date: Thu, 20 Aug 2026 05:18:03 +0800 Subject: [PATCH 155/305] refactor: reduce fork logic to Codex-to-DSH anchor translation --- src/dsh/session-fork.mjs | 75 ++++++++++++++++++---------------------- 1 file changed, 33 insertions(+), 42 deletions(-) diff --git a/src/dsh/session-fork.mjs b/src/dsh/session-fork.mjs index b40bf9e6..0482b66e 100644 --- a/src/dsh/session-fork.mjs +++ b/src/dsh/session-fork.mjs @@ -6,59 +6,50 @@ function turnNumber(turnId) { return value; } -function openTurn(events) { - let open = null; - for (const event of events ?? []) { - if (event?.type === 'turn/start') open = event.data?.turn; - else if (event?.type === 'turn/end' && event.data?.turn === open) open = null; - } - return open; -} - -function cutAfterCompletedTurn(events, target) { - const end = events.findIndex((event) => event?.type === 'turn/end' && event.data?.turn === target); - if (end < 0) throw new Error(`DSH session has no completed dsh-turn-${target}`); - - // Match the official DSH Host fork contract: keep standalone durable events - // that landed after the selected completed turn (for example session/title), - // but never copy the next turn/start or anything owned by that later turn. - let cut = end + 1; - while (cut < events.length && events[cut]?.type !== 'turn/start') cut += 1; - return cut; +function completedTurnEnd(events, turn) { + return (events ?? []).find((event) => event?.type === 'turn/end' && event.data?.turn === turn); } /** - * Select the exact durable DSH event prefix represented by Codex fork params. - * No event is synthesized or rewritten: the returned array contains existing - * SessionEvent objects and is handed to the official AgentFactory seed seam. - * - * The default fork follows DSH Host semantics: fork through the latest - * completed turn and include trailing standalone events up to (but excluding) - * the next turn/start. A currently-open later turn is therefore never copied. + * Translate Codex's fork presentation anchors into the one `atSeq` hint owned + * by DSH Host `sessions.fork`. This function deliberately does NOT select or + * copy a seed, decide lineage, inherit model state, or create a Session. */ -export function dshForkSeed(events = [], { lastTurnId, beforeTurnId } = {}) { +export function codexForkAtSeq(events = [], { lastTurnId, beforeTurnId } = {}) { if (lastTurnId != null && beforeTurnId != null) { throw new Error('thread/fork lastTurnId and beforeTurnId are mutually exclusive'); } - if (beforeTurnId != null) { - const target = turnNumber(beforeTurnId); - const start = events.findIndex((event) => event?.type === 'turn/start' && event.data?.turn === target); - if (start < 0) throw new Error(`DSH session has no ${beforeTurnId}`); - return events.slice(0, start); - } - if (lastTurnId != null) { - const target = turnNumber(lastTurnId); - return events.slice(0, cutAfterCompletedTurn(events, target)); + const turn = turnNumber(lastTurnId); + const end = completedTurnEnd(events, turn); + if (!end || !Number.isInteger(end.seq)) { + throw new Error(`DSH session has no completed ${lastTurnId}`); + } + return end.seq; } - const latestEnd = events.findLast?.((event) => event?.type === 'turn/end') - ?? [...events].reverse().find((event) => event?.type === 'turn/end'); - if (!latestEnd || !Number.isInteger(latestEnd.data?.turn)) { - throw new Error('DSH session has no completed turn to fork from'); + if (beforeTurnId != null) { + const turn = turnNumber(beforeTurnId); + const start = (events ?? []).find((event) => event?.type === 'turn/start' && event.data?.turn === turn); + if (!start || !Number.isInteger(start.seq)) { + throw new Error(`DSH session has no ${beforeTurnId}`); + } + const priorEnds = (events ?? []).filter((event) => + event?.type === 'turn/end' + && Number.isInteger(event.seq) + && event.seq < start.seq + ); + const prior = priorEnds.at(-1); + if (!prior) { + throw new Error('DSH Host fork cannot represent a Codex beforeTurnId before the first completed turn'); + } + return prior.seq; } - return events.slice(0, cutAfterCompletedTurn(events, latestEnd.data.turn)); + + // Omission is meaningful: the official DSH Host chooses the latest completed + // turn, including its own handling for live/cold sessions and open tails. + return undefined; } -export const forkInternals = { turnNumber, openTurn, cutAfterCompletedTurn }; +export const forkInternals = { turnNumber, completedTurnEnd }; From 7b70032d94e60fd17a8567fd7eed2c8fde610870 Mon Sep 17 00:00:00 2001 From: code-new-bie <403466121@qq.com> Date: Thu, 20 Aug 2026 05:18:16 +0800 Subject: [PATCH 156/305] deps: pin official DSH Host API for presentation delegation --- package.json | 1 + 1 file changed, 1 insertion(+) diff --git a/package.json b/package.json index 667a4e95..39feee8e 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,7 @@ "@deepseek-ai/dsh": "0.1.0-rc.8", "@deepseek-ai/dsh-agent": "0.1.0-rc.8", "@deepseek-ai/dsh-app-boot": "0.1.0-rc.8", + "@deepseek-ai/dsh-host-apiproxy": "0.1.0-rc.8", "@deepseek-ai/dsh-launch-environment": "0.1.0-rc.8", "@deepseek-ai/dsh-llm": "0.1.0-rc.8", "@deepseek-ai/dsh-session": "0.1.0-rc.8", From 7826e94e496eba848fa18a8780d0e7300950f95c Mon Sep 17 00:00:00 2001 From: code-new-bie <403466121@qq.com> Date: Thu, 20 Aug 2026 05:18:41 +0800 Subject: [PATCH 157/305] ci: verify one-version DSH dependency closure --- scripts/verify-dsh-closure.mjs | 39 ++++++++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) create mode 100644 scripts/verify-dsh-closure.mjs diff --git a/scripts/verify-dsh-closure.mjs b/scripts/verify-dsh-closure.mjs new file mode 100644 index 00000000..d37ba6db --- /dev/null +++ b/scripts/verify-dsh-closure.mjs @@ -0,0 +1,39 @@ +#!/usr/bin/env node +import fs from 'node:fs'; +import path from 'node:path'; +import process from 'node:process'; + +const root = path.resolve(process.argv[2] ?? process.cwd()); +const packagePath = path.join(root, 'package.json'); +const lockPath = process.argv[3] + ? path.resolve(process.argv[3]) + : path.join(root, fs.existsSync(path.join(root, 'npm-shrinkwrap.json')) ? 'npm-shrinkwrap.json' : 'package-lock.json'); + +if (!fs.existsSync(packagePath)) throw new Error(`Missing package.json at ${packagePath}`); +if (!fs.existsSync(lockPath)) throw new Error(`Missing npm lockfile at ${lockPath}`); + +const pkg = JSON.parse(fs.readFileSync(packagePath, 'utf8')); +const expected = pkg.dependencies?.['@deepseek-ai/dsh']; +if (typeof expected !== 'string' || !/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(expected)) { + throw new Error('DSHX requires an exact @deepseek-ai/dsh dependency version'); +} + +const lock = JSON.parse(fs.readFileSync(lockPath, 'utf8')); +const packages = lock.packages ?? {}; +const mismatches = []; +let count = 0; +for (const [location, record] of Object.entries(packages)) { + if (!/(?:^|\/)node_modules\/@deepseek-ai\/dsh(?:-|$)/.test(location)) continue; + if (!record || typeof record.version !== 'string') continue; + count += 1; + if (record.version !== expected) { + mismatches.push(`${location}: ${record.version}`); + } +} +if (count === 0) throw new Error('npm lockfile contains no @deepseek-ai/dsh* packages'); +if (mismatches.length > 0) { + throw new Error( + `DSHX refuses a mixed DeepSeek Harness release closure; expected every @deepseek-ai/dsh* package at ${expected}:\n${mismatches.join('\n')}` + ); +} +process.stdout.write(`Verified ${count} DeepSeek Harness packages at ${expected}\n`); From 3fd3f02d4cc770a87b2f04b3bc0255cd45213198 Mon Sep 17 00:00:00 2001 From: code-new-bie <403466121@qq.com> Date: Thu, 20 Aug 2026 05:19:08 +0800 Subject: [PATCH 158/305] release: shrinkwrap exact DSH release closure --- scripts/package-platform.mjs | 25 +++++++++++++++++++++++-- 1 file changed, 23 insertions(+), 2 deletions(-) diff --git a/scripts/package-platform.mjs b/scripts/package-platform.mjs index f01c3123..89e02451 100644 --- a/scripts/package-platform.mjs +++ b/scripts/package-platform.mjs @@ -66,7 +66,7 @@ const packageJson = { bin: { dshx: './bin/dshx.mjs' }, dependencies: rootPackage.dependencies, repository: { type: 'git', url: 'https://github.com/code-new-bie/dsh-codex.git' }, - files: ['bin', 'src', 'config', 'dist/bin', 'upstream', 'LICENSE', 'NOTICE'] + files: ['bin', 'src', 'config', 'dist/bin', 'upstream', 'LICENSE', 'NOTICE', 'npm-shrinkwrap.json'] }; fs.writeFileSync(path.join(stage, 'package.json'), `${JSON.stringify(packageJson, null, 2)}\n`); @@ -80,8 +80,28 @@ for (const file of walkJs(stage)) { } } +// A published DSH release is version-synchronized across @deepseek-ai/dsh-*. +// Generate a shrinkwrap from the exact registry closure, then reject packaging +// if npm selected any different DSH release through a peer/caret range. This +// makes future upstream releases fail loudly until DSHX deliberately updates +// its pin, rather than silently shipping a mixed Harness runtime. +const npm = process.platform === 'win32' ? 'npm.cmd' : 'npm'; +execFileSync(npm, [ + 'install', + '--package-lock-only', + '--ignore-scripts', + '--no-audit', + '--no-fund' +], { cwd: stage, stdio: 'inherit' }); +execFileSync(process.execPath, [ + path.join(root, 'scripts', 'verify-dsh-closure.mjs'), + stage, + path.join(stage, 'package-lock.json') +], { cwd: root, stdio: 'inherit' }); +fs.renameSync(path.join(stage, 'package-lock.json'), path.join(stage, 'npm-shrinkwrap.json')); + const packOutput = execFileSync( - process.platform === 'win32' ? 'npm.cmd' : 'npm', + npm, ['pack', stage, '--pack-destination', out, '--json'], { cwd: root, encoding: 'utf8', stdio: ['ignore', 'pipe', 'inherit'] } ); @@ -100,6 +120,7 @@ const metadata = { platform, arch, node: process.version, + dshVersion: rootPackage.dependencies['@deepseek-ai/dsh'], codexCommit: fs.readFileSync(path.join(root, 'upstream', 'CODEX_COMMIT'), 'utf8').trim(), dshCommit: fs.readFileSync(path.join(root, 'upstream', 'DSH_COMMIT'), 'utf8').trim(), tarball: path.basename(targetTarball), From 1e3074e25a0d383dad2d9b1683f0d81e0cf38149 Mon Sep 17 00:00:00 2001 From: code-new-bie <403466121@qq.com> Date: Thu, 20 Aug 2026 05:19:46 +0800 Subject: [PATCH 159/305] ci: gate exact DSH release closure --- .github/workflows/ci.yml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 07637bd9..e9a75e13 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -23,6 +23,8 @@ jobs: with: node-version: ${{ matrix.node }} - run: npm install + - name: Verify exact DeepSeek Harness release closure + run: node scripts/verify-dsh-closure.mjs - run: npm test - name: Syntax check all shipped JavaScript (Unix) if: runner.os != 'Windows' @@ -56,6 +58,8 @@ jobs: with: node-version: 20 - run: npm install + - name: Verify exact DeepSeek Harness release closure + run: node scripts/verify-dsh-closure.mjs - name: Boot and dispose official DeepSeek Harness composition run: node scripts/runtime-smoke.mjs @@ -154,6 +158,7 @@ jobs: shell: bash run: | npm install + node scripts/verify-dsh-closure.mjs python3 -m pip install --user pexpect python3 test/tui_smoke.py - name: Build installable platform tarball From e8f8592b7cabcc44a32fe12c0707af7065637dcd Mon Sep 17 00:00:00 2001 From: code-new-bie <403466121@qq.com> Date: Thu, 20 Aug 2026 05:20:29 +0800 Subject: [PATCH 160/305] refactor: delegate compact and fork to official DSH APIs --- src/dsh/product-adapter.mjs | 92 ++++++++++++++++++++----------------- 1 file changed, 49 insertions(+), 43 deletions(-) diff --git a/src/dsh/product-adapter.mjs b/src/dsh/product-adapter.mjs index 97f27229..93907122 100644 --- a/src/dsh/product-adapter.mjs +++ b/src/dsh/product-adapter.mjs @@ -1,8 +1,9 @@ -import { randomUUID } from 'node:crypto'; import path from 'node:path'; import { DshAppServerAdapter } from './app-server-adapter.mjs'; +import { executeDshCommand } from './commands.mjs'; import { dshThreadItemsPage, dshThreadTurnsPage } from './history-pages.mjs'; -import { dshForkSeed } from './session-fork.mjs'; +import { DshHostApi } from './host-api.mjs'; +import { codexForkAtSeq } from './session-fork.mjs'; import { dshSkillsListEntry } from './skills.mjs'; import { persistedTokenUsageNotification } from './token-usage.mjs'; @@ -103,6 +104,10 @@ export class DshxProductAdapter extends DshAppServerAdapter { return this._manualCompactions ??= new Map(); } + hostApi() { + return this._hostApi ??= new DshHostApi(this.ctx, { cwd: this.cwd }); + } + warnThread(threadId, message) { this.send({ method: 'warning', params: { threadId, message } }); } @@ -161,8 +166,6 @@ export class DshxProductAdapter extends DshAppServerAdapter { const threadId = String(params.threadId ?? ''); const controller = this.controllers.get(threadId); if (!controller) throw new Error(`Thread is not resumed in DSHX: ${threadId}`); - const compaction = this.ctx.get('compaction'); - if (!compaction?.compactNow) throw new Error('DSHX requires DSH service: compaction.compactNow'); const active = this.manualCompactions(); if (active.has(threadId)) { throw new Error('Compaction is unavailable because this thread already has an active manual compaction'); @@ -172,16 +175,24 @@ export class DshxProductAdapter extends DshAppServerAdapter { return { result: {}, afterResponse: () => { - void this.runManualCompaction({ threadId, controller, compaction, abortController }); + void this.runManualCompaction({ threadId, controller, abortController }); } }; } - async runManualCompaction({ threadId, controller, compaction, abortController }) { + async runManualCompaction({ threadId, controller, abortController }) { try { - const result = await compaction.compactNow(controller.agent, abortController.signal); - if (result == null && !abortController.signal.aborted) { - this.warnThread(threadId, 'No compactable history yet.'); + const execution = await executeDshCommand({ + ctx: controller.agent.ctx ?? this.ctx, + agent: controller.agent, + line: '/compact', + signal: abortController.signal + }); + // A successful real compaction is rendered from DSH's durable + // compaction/* events. A no-op has no such event, so surface the official + // command text instead of leaving the user without feedback. + if (execution.result?.sourceEventSeq == null && execution.result?.text) { + this.warnThread(threadId, execution.result.text); } } catch (error) { if (abortController.signal.aborted) { @@ -241,6 +252,14 @@ export class DshxProductAdapter extends DshAppServerAdapter { }; } + async forkSourceEvents(sourceId) { + const live = this.controllers.get(sourceId)?.agent ?? this.driver.getLive(sourceId); + if (live?.session) return live.session.events ?? []; + const inspected = await this.driver.inspectSession(sourceId); + if (!inspected) throw new Error(`Unknown DSH session: ${sourceId}`); + return inspected.events ?? []; + } + async threadFork(params = {}) { const sourceId = String(params.threadId ?? ''); if (!sourceId) throw new Error('thread/fork requires threadId'); @@ -252,41 +271,28 @@ export class DshxProductAdapter extends DshAppServerAdapter { throw new Error('DSHX does not expose ephemeral Codex forks over durable DSH sessions'); } - let temporarySourceHandle; - let sourceAgent = this.controllers.get(sourceId)?.agent ?? this.driver.getLive(sourceId); - if (!sourceAgent) { - temporarySourceHandle = await this.driver.resume(sourceId); - sourceAgent = temporarySourceHandle.agent; - } + const events = await this.forkSourceEvents(sourceId); + const atSeq = codexForkAtSeq(events, { + lastTurnId: params.lastTurnId, + beforeTurnId: params.beforeTurnId + }); + const forked = await this.hostApi().forkSession({ sessionId: sourceId, atSeq }); + const childId = String(forked?.sessionId ?? ''); + if (!childId) throw new Error('DSH Host fork returned no child session id'); - let forkHandle; - try { - const seed = dshForkSeed(sourceAgent.session.events ?? [], { - lastTurnId: params.lastTurnId, - beforeTurnId: params.beforeTurnId - }); - const sourcePermission = this.permissions.current(sourceAgent); - forkHandle = await this.driver.fork(sourceAgent, { - sessionId: randomUUID(), - seed - }); - this.permissions.set(forkHandle.agent, sourcePermission.preset); - const controller = this.installController(forkHandle); - const result = this.threadResponse(controller.agent, { - includeTurns: params.excludeTurns !== true - }); - return { - result, - afterResponse: () => this.send({ method: 'thread/started', params: { thread: result.thread } }) - }; - } catch (error) { - if (forkHandle && !this.controllers.has(String(forkHandle.agent.id))) { - await forkHandle.dispose?.(); - } - throw error; - } finally { - await temporarySourceHandle?.dispose?.(); - } + // DSH Host publishes the child Agent transactionally. DSHX attaches only a + // presentation controller and does not own the AgentHandle or inherited + // model/lineage/workspace semantics. + const childAgent = this.driver.getLive(childId); + if (!childAgent) throw new Error(`DSH Host fork did not publish child Agent ${childId}`); + const controller = this.installController({ agent: childAgent, dispose: async () => {} }); + const result = this.threadResponse(controller.agent, { + includeTurns: params.excludeTurns !== true + }); + return { + result, + afterResponse: () => this.send({ method: 'thread/started', params: { thread: result.thread } }) + }; } historyController(threadId) { From 98ffd7eb4a64ed26090e39fe11e568cbd08a8ffd Mon Sep 17 00:00:00 2001 From: code-new-bie <403466121@qq.com> Date: Thu, 20 Aug 2026 05:20:31 +0800 Subject: [PATCH 161/305] release: build native Apple Silicon and Intel macOS packages --- .github/workflows/release.yml | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 3aafc510..f8e07677 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -10,13 +10,21 @@ permissions: jobs: build: - name: Build / ${{ matrix.os }} + name: Build / ${{ matrix.label }} runs-on: ${{ matrix.os }} timeout-minutes: 75 strategy: fail-fast: false matrix: - os: [ubuntu-latest, windows-latest, macos-13] + include: + - os: ubuntu-latest + label: linux-x64 + - os: windows-latest + label: windows-x64 + - os: macos-26 + label: macos-arm64 + - os: macos-26-intel + label: macos-x64 steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 @@ -33,6 +41,7 @@ jobs: - name: Test presentation adapter run: | npm install + node scripts/verify-dsh-closure.mjs npm test - name: Build pinned TUI and package (Unix) if: runner.os != 'Windows' @@ -61,6 +70,7 @@ jobs: shell: pwsh run: | $tgz = Get-ChildItem dist\release\dshx-*.tgz | Select-Object -First 1 + if (-not $tgz) { throw 'platform tarball missing' } $prefix = Join-Path $env:RUNNER_TEMP 'dshx-release-smoke' npm install --global --prefix $prefix $tgz.FullName $version = & (Join-Path $prefix 'dshx.cmd') --version @@ -68,7 +78,7 @@ jobs: & (Join-Path $prefix 'dshx.cmd') doctor - uses: actions/upload-artifact@v4 with: - name: dshx-${{ runner.os }}-${{ runner.arch }} + name: dshx-${{ matrix.label }} path: | dist/release/*.tgz dist/release/*.json From 8340e857676287244d2249e673f06f652338f369 Mon Sep 17 00:00:00 2001 From: code-new-bie <403466121@qq.com> Date: Thu, 20 Aug 2026 05:21:14 +0800 Subject: [PATCH 162/305] refactor: remove DSHX-owned fork construction from agent driver --- src/dsh/agent-driver.mjs | 34 ---------------------------------- 1 file changed, 34 deletions(-) diff --git a/src/dsh/agent-driver.mjs b/src/dsh/agent-driver.mjs index a7ff4e31..01a29f86 100644 --- a/src/dsh/agent-driver.mjs +++ b/src/dsh/agent-driver.mjs @@ -73,40 +73,6 @@ export class DshAgentDriver { }); } - async fork(sourceAgent, { - sessionId = randomUUID(), - seed = sourceAgent?.session?.events ?? [] - } = {}) { - if (!sourceAgent?.session) throw new Error('DSHX fork requires a live DSH source agent'); - await this.settleComposition(); - const agents = requireService(this.ctx, 'agents'); - const sourceHeader = sourceAgent.session.header ?? {}; - const sourceSelection = this.currentModel(sourceAgent); - const meta = { - ...(sourceHeader.cwd == null ? {} : { cwd: sourceHeader.cwd }), - parentSession: SessionId(String(sourceHeader.id ?? sourceAgent.id)), - seedLength: seed.length, - ...(sourceHeader.agentPreset == null ? {} : { agentPreset: sourceHeader.agentPreset }) - }; - const handle = await agents.create({ - sessionId: SessionId(sessionId), - seed, - meta, - agentOptions: { - provider: sourceSelection.provider, - model: sourceSelection.model - }, - setup: (agentCtx) => this.installSelection(agentCtx) - }); - try { - await this.selectModel(handle.agent, sourceSelection); - return handle; - } catch (error) { - await handle.dispose?.(); - throw error; - } - } - async resume(sessionId) { await this.settleComposition(); const agents = requireService(this.ctx, 'agents'); From 6fc3e700aa036d59b6dd3714450d85d98e464b3d Mon Sep 17 00:00:00 2001 From: code-new-bie <403466121@qq.com> Date: Thu, 20 Aug 2026 05:21:46 +0800 Subject: [PATCH 163/305] test: enforce presentation-only fork anchor translation --- test/dsh-session-fork.test.mjs | 63 ++++++++++------------------------ 1 file changed, 19 insertions(+), 44 deletions(-) diff --git a/test/dsh-session-fork.test.mjs b/test/dsh-session-fork.test.mjs index 627954ba..098070a4 100644 --- a/test/dsh-session-fork.test.mjs +++ b/test/dsh-session-fork.test.mjs @@ -1,66 +1,41 @@ import assert from 'node:assert/strict'; import test from 'node:test'; -import { dshForkSeed } from '../src/dsh/session-fork.mjs'; +import { codexForkAtSeq } from '../src/dsh/session-fork.mjs'; const events = [ { seq: 1, type: 'turn/start', data: { turn: 1 } }, { seq: 2, type: 'user/message', data: { id: 'u1' } }, { seq: 3, type: 'assistant/message', data: { turn: 1, step: 1 } }, { seq: 4, type: 'turn/end', data: { turn: 1, reason: { kind: 'completed' } } }, - { seq: 5, type: 'turn/start', data: { turn: 2 } }, - { seq: 6, type: 'user/message', data: { id: 'u2' } }, - { seq: 7, type: 'turn/end', data: { turn: 2, reason: { kind: 'completed' } } } + { seq: 5, type: 'session/title', data: { title: 'after turn one' } }, + { seq: 6, type: 'turn/start', data: { turn: 2 } }, + { seq: 7, type: 'user/message', data: { id: 'u2' } }, + { seq: 8, type: 'turn/end', data: { turn: 2, reason: { kind: 'completed' } } } ]; -test('default fork uses the latest completed DSH turn prefix', () => { - const seed = dshForkSeed(events); - assert.deepEqual(seed.map((event) => event.seq), [1, 2, 3, 4, 5, 6, 7]); - assert.equal(seed[0], events[0]); - assert.equal(seed.at(-1), events.at(-1)); +test('default fork delegates boundary selection entirely to DSH Host', () => { + assert.equal(codexForkAtSeq(events), undefined); }); -test('lastTurnId includes the selected completed DSH turn and trailing standalone events', () => { - const withTitle = [ - ...events.slice(0, 4), - { seq: 4.5, type: 'session/title', data: { title: 'after turn one' } }, - ...events.slice(4) - ]; - assert.deepEqual( - dshForkSeed(withTitle, { lastTurnId: 'dsh-turn-1' }).map((event) => event.seq), - [1, 2, 3, 4, 4.5] - ); -}); - -test('beforeTurnId excludes the selected turn and everything after it', () => { - assert.deepEqual( - dshForkSeed(events, { beforeTurnId: 'dsh-turn-2' }).map((event) => event.seq), - [1, 2, 3, 4] - ); +test('lastTurnId translates only to the matching durable turn/end anchor', () => { + assert.equal(codexForkAtSeq(events, { lastTurnId: 'dsh-turn-1' }), 4); + assert.equal(codexForkAtSeq(events, { lastTurnId: 'dsh-turn-2' }), 8); }); -test('default fork excludes a later active turn instead of copying an open execution prefix', () => { - const active = [ - ...events, - { seq: 8, type: 'session/title', data: { title: 'latest completed title' } }, - { seq: 9, type: 'turn/start', data: { turn: 3 } }, - { seq: 10, type: 'user/message', data: { id: 'u3' } } - ]; - assert.deepEqual(dshForkSeed(active).map((event) => event.seq), [1, 2, 3, 4, 5, 6, 7, 8]); +test('beforeTurnId translates to the prior completed-turn anchor without slicing a seed', () => { + assert.equal(codexForkAtSeq(events, { beforeTurnId: 'dsh-turn-2' }), 4); assert.throws( - () => dshForkSeed(active, { lastTurnId: 'dsh-turn-3' }), - /no completed dsh-turn-3/ + () => codexForkAtSeq(events, { beforeTurnId: 'dsh-turn-1' }), + /cannot represent.*before the first completed turn/i ); }); -test('fork refuses ambiguous, invalid, missing, or completion-less boundaries', () => { +test('fork anchor translation rejects ambiguous or nonexistent Codex turn ids', () => { assert.throws( - () => dshForkSeed(events, { lastTurnId: 'dsh-turn-1', beforeTurnId: 'dsh-turn-2' }), + () => codexForkAtSeq(events, { lastTurnId: 'dsh-turn-1', beforeTurnId: 'dsh-turn-2' }), /mutually exclusive/ ); - assert.throws(() => dshForkSeed(events, { lastTurnId: 'turn-1' }), /cannot map Codex turn id/); - assert.throws(() => dshForkSeed(events, { lastTurnId: 'dsh-turn-99' }), /no completed/); - assert.throws( - () => dshForkSeed([{ seq: 1, type: 'turn/start', data: { turn: 1 } }]), - /no completed turn to fork from/ - ); + assert.throws(() => codexForkAtSeq(events, { lastTurnId: 'turn-1' }), /cannot map Codex turn id/); + assert.throws(() => codexForkAtSeq(events, { lastTurnId: 'dsh-turn-99' }), /no completed/); + assert.throws(() => codexForkAtSeq(events, { beforeTurnId: 'dsh-turn-99' }), /has no dsh-turn-99/); }); From 02c2757b25bc9ed3fde73fdf427bfd05dff01876 Mon Sep 17 00:00:00 2001 From: code-new-bie <403466121@qq.com> Date: Thu, 20 Aug 2026 05:23:09 +0800 Subject: [PATCH 164/305] ci: enforce DSH-owned fork and compaction semantics --- .github/workflows/ci.yml | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e9a75e13..dc862526 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -166,8 +166,8 @@ jobs: if: runner.os != 'Windows' run: node scripts/package-platform.mjs - name: Build installable platform tarball (Windows) - shell: pwsh if: runner.os == 'Windows' + shell: pwsh run: node scripts/package-platform.mjs - name: Clean-install smoke (Unix) if: runner.os != 'Windows' @@ -222,3 +222,13 @@ jobs: grep -q "tools.execute" src/dsh/workspace-command.mjs grep -q "agent" src/dsh/workspace-command.mjs grep -q "case 'command/exec'" src/dsh/release-adapter.mjs + - name: Assert fork and compaction domain semantics remain DSH-owned + shell: bash + run: | + grep -q "@deepseek-ai/dsh-host-apiproxy" src/dsh/host-api.mjs + grep -q "api.sessions.fork" src/dsh/host-api.mjs + grep -q "executeDshCommand" src/dsh/product-adapter.mjs + grep -q "commands.execute" src/dsh/commands.mjs + ! grep -q "compactNow" src/dsh/product-adapter.mjs + ! grep -E "seedLength|parentSession|dshForkSeed" src/dsh/agent-driver.mjs src/dsh/product-adapter.mjs + ! grep -q "fork(sourceAgent" src/dsh/agent-driver.mjs From db27dfe71054133097833715b9a7577173e0ff31 Mon Sep 17 00:00:00 2001 From: code-new-bie <403466121@qq.com> Date: Thu, 20 Aug 2026 05:24:21 +0800 Subject: [PATCH 165/305] test: prove compact and fork delegate to DSH domain APIs --- test/dsh-domain-delegation.test.mjs | 91 +++++++++++++++++++++++++++++ 1 file changed, 91 insertions(+) create mode 100644 test/dsh-domain-delegation.test.mjs diff --git a/test/dsh-domain-delegation.test.mjs b/test/dsh-domain-delegation.test.mjs new file mode 100644 index 00000000..0023bc30 --- /dev/null +++ b/test/dsh-domain-delegation.test.mjs @@ -0,0 +1,91 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { DshxProductAdapter } from '../src/dsh/product-adapter.mjs'; + +function bareAdapter() { + const adapter = Object.create(DshxProductAdapter.prototype); + adapter.cwd = '/workspace'; + adapter.controllers = new Map(); + adapter.diagnostics = () => {}; + adapter.send = () => {}; + return adapter; +} + +test('manual compaction delegates to DSH commands.execute and does not own compaction semantics', async () => { + const adapter = bareAdapter(); + const calls = []; + const warnings = []; + adapter.warnThread = (threadId, message) => warnings.push([threadId, message]); + const commands = { + async execute(agent, line, signal) { + calls.push({ agent, line, signal }); + return { result: { kind: 'success', text: 'No compactable history yet.' } }; + } + }; + const agent = { + ctx: { get(name) { return name === 'commands' ? commands : undefined; } } + }; + const abortController = new AbortController(); + adapter._manualCompactions = new Map([['session-1', abortController]]); + + await adapter.runManualCompaction({ + threadId: 'session-1', + controller: { agent }, + abortController + }); + + assert.equal(calls.length, 1); + assert.equal(calls[0].agent, agent); + assert.equal(calls[0].line, '/compact'); + assert.equal(calls[0].signal, abortController.signal); + assert.deepEqual(warnings, [['session-1', 'No compactable history yet.']]); + assert.equal(adapter._manualCompactions.size, 0); +}); + +test('thread/fork passes only a presentation anchor to official DSH Host fork', async () => { + const adapter = bareAdapter(); + const hostCalls = []; + const sourceEvents = [ + { seq: 1, type: 'turn/start', data: { turn: 1 } }, + { seq: 2, type: 'user/message', data: { id: 'u1' } }, + { seq: 3, type: 'turn/end', data: { turn: 1, reason: { kind: 'completed' } } } + ]; + const childAgent = { id: 'child-session', session: { events: [], header: { id: 'child-session' } } }; + + adapter.forkSourceEvents = async (sourceId) => { + assert.equal(sourceId, 'source-session'); + return sourceEvents; + }; + adapter.hostApi = () => ({ + async forkSession(request) { + hostCalls.push(request); + return { sessionId: 'child-session' }; + } + }); + adapter.driver = { + getLive(sessionId) { + assert.equal(sessionId, 'child-session'); + return childAgent; + } + }; + adapter.installController = (handle) => { + assert.equal(handle.agent, childAgent); + const controller = { agent: childAgent }; + adapter.controllers.set('child-session', controller); + return controller; + }; + adapter.threadResponse = (agent, options) => { + assert.equal(agent, childAgent); + assert.equal(options.includeTurns, false); + return { thread: { id: 'child-session', turns: [] } }; + }; + + const response = await adapter.threadFork({ + threadId: 'source-session', + lastTurnId: 'dsh-turn-1', + excludeTurns: true + }); + + assert.deepEqual(hostCalls, [{ sessionId: 'source-session', atSeq: 3 }]); + assert.equal(response.result.thread.id, 'child-session'); +}); From 79885d07e085d766cddb3db74b5c4b3553f9d184 Mon Sep 17 00:00:00 2001 From: code-new-bie <403466121@qq.com> Date: Thu, 20 Aug 2026 05:25:04 +0800 Subject: [PATCH 166/305] feat: project DSH subagent lineage into native Codex thread source --- src/dsh/codex-shapes.mjs | 49 +++++++++++++++++++++++++++++++++------- 1 file changed, 41 insertions(+), 8 deletions(-) diff --git a/src/dsh/codex-shapes.mjs b/src/dsh/codex-shapes.mjs index 7d5c39f9..acfa7e05 100644 --- a/src/dsh/codex-shapes.mjs +++ b/src/dsh/codex-shapes.mjs @@ -2,6 +2,7 @@ import { DshToolPresentationResolver } from './tool-presentation.mjs'; const MODEL_PREFIX = 'dshx:'; const DEFAULT_REASONING_SENTINEL = 'dsh-default'; +const DSH_SUBAGENT_DESCRIPTOR_VERSION = 2; function seconds(timeMs, fallback = Math.floor(Date.now() / 1000)) { return Number.isFinite(timeMs) ? Math.floor(timeMs / 1000) : fallback; @@ -67,8 +68,33 @@ function normalizeList(value) { return []; } -function sourceForHeader(header) { - if (header?.origin === 'subagent') return { custom: 'dsh-subagent' }; +function subagentDescriptor(events = []) { + const event = events.find((candidate) => candidate?.type === 'subagent/descriptor'); + const value = event?.data; + if (!value || value.version !== DSH_SUBAGENT_DESCRIPTOR_VERSION) return null; + if ((value.mode !== 'one-shot' && value.mode !== 'continuable') || typeof value.provider !== 'string') return null; + return { + mode: value.mode, + provider: value.provider, + label: typeof value.label === 'string' && value.label.length > 0 ? value.label : null + }; +} + +function sourceForHeader(header, events = []) { + if (header?.origin === 'subagent' && header.parentSession != null) { + const descriptor = subagentDescriptor(events); + return { + subagent: { + thread_spawn: { + parent_thread_id: String(header.parentSession), + depth: Number.isInteger(header.delegationDepth) ? header.delegationDepth : 1, + agent_path: null, + agent_nickname: descriptor?.label ?? null, + agent_role: null + } + } + }; + } return { custom: 'dshx' }; } @@ -171,6 +197,8 @@ export function dshThreadFromSnapshot({ meta, events = [], model, turns = [], lo const config = model ?? latestRequestConfig(events) ?? {}; const createdAt = seconds(meta.createdAt); const lastTime = events.length > 0 ? events[events.length - 1]?.time : meta.createdAt; + const isSubagent = meta.origin === 'subagent'; + const descriptor = isSubagent ? subagentDescriptor(events) : null; let openTurn = false; for (const event of events) { if (event?.type === 'turn/start') openTurn = true; @@ -180,8 +208,8 @@ export function dshThreadFromSnapshot({ meta, events = [], model, turns = [], lo id: String(meta.id), extra: null, sessionId: String(meta.id), - forkedFromId: meta.parentSession ? String(meta.parentSession) : null, - parentThreadId: meta.origin === 'subagent' && meta.parentSession ? String(meta.parentSession) : null, + forkedFromId: !isSubagent && meta.parentSession ? String(meta.parentSession) : null, + parentThreadId: isSubagent && meta.parentSession ? String(meta.parentSession) : null, preview: firstHumanPreview(events), ephemeral: false, section: null, @@ -196,10 +224,13 @@ export function dshThreadFromSnapshot({ meta, events = [], model, turns = [], lo path: null, cwd: meta.cwd ?? process.cwd(), cliVersion, - source: sourceForHeader(meta), - canAcceptDirectInput: loaded ? true : null, + source: sourceForHeader(meta, events), + // DSH continuation authority belongs to ctx.subagents. Showing a child in + // Codex navigation must never create a presentation-side bypass that sends + // a user turn directly to the child Agent. + canAcceptDirectInput: isSubagent ? false : loaded ? true : null, threadSource: null, - agentNickname: null, + agentNickname: descriptor?.label ?? null, agentRole: null, gitInfo: null, name: null, @@ -382,5 +413,7 @@ export const internals = { latestRequestConfig, firstHumanPreview, inputFromDshMessage, - visibleReasoning + visibleReasoning, + subagentDescriptor, + sourceForHeader }; From 90a6a88ca27a7c88583068fc7374e8354b4a7898 Mon Sep 17 00:00:00 2001 From: code-new-bie <403466121@qq.com> Date: Thu, 20 Aug 2026 05:26:51 +0800 Subject: [PATCH 167/305] feat: expose live DSH subagents in Codex agents navigation --- src/dsh/release-adapter.mjs | 74 ++++++++++++++++++++++++++++++++++++- 1 file changed, 73 insertions(+), 1 deletion(-) diff --git a/src/dsh/release-adapter.mjs b/src/dsh/release-adapter.mjs index 8a649155..8c220cc2 100644 --- a/src/dsh/release-adapter.mjs +++ b/src/dsh/release-adapter.mjs @@ -1,3 +1,4 @@ +import { dshThreadFromSnapshot } from './codex-shapes.mjs'; import { DshxProductAdapter } from './product-adapter.mjs'; import { codexPlanTarget, threadSettingsUpdatedNotification } from './plan-presentation.mjs'; import { foldDshSessionTitle, threadNameUpdatedNotification } from './thread-title.mjs'; @@ -9,11 +10,53 @@ function snapshotTitle(snapshot) { return typeof snapshot?.title === 'string' && snapshot.title.length > 0 ? snapshot.title : null; } +function isDshSubagent(agent) { + return agent?.session?.header?.origin === 'subagent'; +} + /** * Final product-facing protocol tail. Keep small metadata/UI compatibility * extensions here so the core DSH public adapter remains stable and auditable. */ export class DshxReleaseAdapter extends DshxProductAdapter { + async ensureReady() { + await super.ensureReady(); + if (this._agentPresentationDisposers) return; + this._agentPresentationDisposers = [ + this.ctx.on('agent/created', ({ agent }) => { + if (!isDshSubagent(agent)) return; + try { + this.send({ method: 'thread/started', params: { thread: this.liveAgentThread(agent) } }); + } catch (error) { + this.diagnostics(`subagent thread/started projection failed for ${String(agent?.id ?? '')}: ${error instanceof Error ? error.message : error}`); + } + }), + this.ctx.on('agent/status', ({ agent, status }) => { + try { + this.send({ + method: 'thread/status/changed', + params: { + threadId: String(agent.id), + status: status === 'running' ? { type: 'active', activeFlags: [] } : { type: 'idle' } + } + }); + } catch (error) { + this.diagnostics(`agent status projection failed for ${String(agent?.id ?? '')}: ${error instanceof Error ? error.message : error}`); + } + }), + this.ctx.on('agent/disposed', ({ agent }) => { + try { + this.send({ + method: 'thread/status/changed', + params: { threadId: String(agent.id), status: { type: 'notLoaded' } } + }); + } catch (error) { + this.diagnostics(`agent disposal projection failed for ${String(agent?.id ?? '')}: ${error instanceof Error ? error.message : error}`); + } + }) + ]; + } + userShell() { return this._userShell ??= new DshUserShellBridge({ send: this.send, @@ -48,6 +91,31 @@ export class DshxReleaseAdapter extends DshxProductAdapter { }); } + liveAgentThread(agent) { + const thread = dshThreadFromSnapshot({ + meta: agent.session.header, + events: agent.session.events ?? [], + model: agent.session.requestHeader?.()?.config, + loaded: true, + cliVersion: this.version + }); + const title = snapshotTitle(this.driver.currentTitle(agent)) ?? foldDshSessionTitle(agent.session.events ?? []); + return { + ...thread, + status: agent.status === 'running' ? { type: 'active', activeFlags: [] } : { type: 'idle' }, + name: title, + turns: [] + }; + } + + loadedThreadList() { + return { + result: { + data: this.driver.listLive().map((agent) => this.liveAgentThread(agent)) + } + }; + } + installController(handle) { const controller = super.installController(handle); const threadId = String(controller.agent.id); @@ -70,6 +138,8 @@ export class DshxReleaseAdapter extends DshxProductAdapter { switch (method) { case 'command/exec': return this.commandExec(params); + case 'thread/loaded/list': + return this.loadedThreadList(); case 'thread/fork': return this.threadForkPresentation(params); case 'thread/name/set': @@ -254,6 +324,8 @@ export class DshxReleaseAdapter extends DshxProductAdapter { async close() { this._userShell?.close(); + for (const dispose of this._agentPresentationDisposers ?? []) dispose?.(); + this._agentPresentationDisposers = []; for (const dispose of this.planListeners().values()) dispose?.(); this.planListeners().clear(); this.planMutationSuppressed().clear(); @@ -261,4 +333,4 @@ export class DshxReleaseAdapter extends DshxProductAdapter { } } -export const releaseAdapterInternals = { snapshotTitle }; +export const releaseAdapterInternals = { snapshotTitle, isDshSubagent }; From 5729d06584cbc3711e07a8a68d8f17a5006e5d81 Mon Sep 17 00:00:00 2001 From: code-new-bie <403466121@qq.com> Date: Thu, 20 Aug 2026 05:27:08 +0800 Subject: [PATCH 168/305] refactor: keep Host-created model selection Host-owned --- src/dsh/host-api.mjs | 21 +++++++++++++++++---- 1 file changed, 17 insertions(+), 4 deletions(-) diff --git a/src/dsh/host-api.mjs b/src/dsh/host-api.mjs index a8fc00f0..287bd9fc 100644 --- a/src/dsh/host-api.mjs +++ b/src/dsh/host-api.mjs @@ -12,6 +12,11 @@ function rpcError(error, fallback) { return new Error(fallback); } +function value(response, fallback) { + if (!response?.result?.ok) throw rpcError(response?.result?.error, fallback); + return response.result.value; +} + /** * Very small façade over DSH's own transport-agnostic Host API. * Domain semantics stay in @deepseek-ai/dsh-host-apiproxy; DSHX only adapts @@ -40,9 +45,17 @@ export class DshHostApi { sessionId, ...(atSeq == null ? {} : { atSeq }) })); - if (!response?.result?.ok) { - throw rpcError(response?.result?.error, `DSH session fork failed for ${String(sessionId)}`); - } - return response.result.value; + return value(response, `DSH session fork failed for ${String(sessionId)}`); + } + + async selectModel({ sessionId, provider, model, reasoningEffort } = {}) { + const response = await this.api.sessions.selectModel(this.request({ + sessionId, + provider, + model, + ...(reasoningEffort == null ? {} : { reasoningEffort }) + })); + const result = value(response, `DSH model selection failed for ${String(sessionId)}`); + return result?.selected ?? result; } } From 6df6e91b12db7c260c3ede40ce7562f7880d9ee8 Mon Sep 17 00:00:00 2001 From: code-new-bie <403466121@qq.com> Date: Thu, 20 Aug 2026 05:27:24 +0800 Subject: [PATCH 169/305] tui: expose DSH-backed agents navigation --- upstream/patches/codex/0009-dshx-slash-capabilities.patch | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/upstream/patches/codex/0009-dshx-slash-capabilities.patch b/upstream/patches/codex/0009-dshx-slash-capabilities.patch index d23df37f..aee79db9 100644 --- a/upstream/patches/codex/0009-dshx-slash-capabilities.patch +++ b/upstream/patches/codex/0009-dshx-slash-capabilities.patch @@ -33,7 +33,7 @@ diff --git a/codex-rs/tui/src/slash_command.rs b/codex-rs/tui/src/slash_command. } - SlashCommand::Permissions => "choose what Codex is allowed to do", + SlashCommand::Permissions => "choose what DeepSeek Harness is allowed to do", -@@ -251,6 +251,40 @@ impl SlashCommand { +@@ -251,6 +251,39 @@ impl SlashCommand { } fn is_visible(self) -> bool { @@ -53,7 +53,6 @@ diff --git a/codex-rs/tui/src/slash_command.rs b/codex-rs/tui/src/slash_command. + | SlashCommand::Delete + | SlashCommand::App + | SlashCommand::Goal -+ | SlashCommand::Agents + | SlashCommand::MultiAgents + | SlashCommand::Side + | SlashCommand::Btw From 7aa37d0136b8eb59474be8f1bef5748b77a242d9 Mon Sep 17 00:00:00 2001 From: code-new-bie <403466121@qq.com> Date: Thu, 20 Aug 2026 05:27:38 +0800 Subject: [PATCH 170/305] refactor: support Host-owned model selection without duplicate listeners --- src/dsh/agent-driver.mjs | 56 ++++++++++++++++++++++++++++++++-------- 1 file changed, 45 insertions(+), 11 deletions(-) diff --git a/src/dsh/agent-driver.mjs b/src/dsh/agent-driver.mjs index 01a29f86..5e757bd6 100644 --- a/src/dsh/agent-driver.mjs +++ b/src/dsh/agent-driver.mjs @@ -23,38 +23,63 @@ export class DshAgentDriver { if (!ctx || typeof ctx.get !== 'function') throw new Error('DshAgentDriver requires a Cordis Context'); this.ctx = ctx; this.selections = new WeakMap(); + this.externalSelections = new WeakMap(); } async settleComposition() { await this.ctx.get('loader')?.await?.(); } + restoredSelection(agent) { + const logged = agent.session.requestHeader?.()?.config; + if (logged !== undefined) { + return { + provider: logged.provider, + model: logged.model, + ...(logged.reasoningEffort === undefined ? {} : { reasoningEffort: logged.reasoningEffort }) + }; + } + return requireService(this.ctx, 'agentDefaultModel').currentSelection(); + } + selectionFor(agent) { const existing = this.selections.get(agent); if (existing) return existing; - const defaultModel = requireService(this.ctx, 'agentDefaultModel'); + if (this.externalSelections.has(agent)) { + throw new Error('DSHX must not install a second model-selection listener on a Host-owned Agent'); + } let picked; const selection = { get current() { if (picked !== undefined) return picked; - const logged = agent.session.requestHeader?.()?.config; - if (logged !== undefined) { - return { - provider: logged.provider, - model: logged.model, - ...(logged.reasoningEffort === undefined ? {} : { reasoningEffort: logged.reasoningEffort }) - }; - } - return defaultModel.currentSelection(); + return undefined; }, set current(next) { picked = next; }, assembled: undefined }; + selection.current = this.restoredSelection(agent); installModelSelection(agent.ctx, selection); this.selections.set(agent, selection); return selection; } + /** + * Attach presentation state to an Agent created by another official DSH + * entrypoint (currently Host sessions.fork). No routing listener is installed; + * writes must go back through the owning DSH API delegate. + */ + adoptExternalSelection(agent, { current, select } = {}) { + if (this.selections.has(agent)) { + throw new Error('Cannot adopt Host-owned selection after DSHX installed its own selection listener'); + } + const state = { + current: current ?? this.restoredSelection(agent), + select + }; + this.externalSelections.set(agent, state); + return state.current; + } + installSelection(agentCtx) { const agent = agentCtx.agent; if (!agent) throw new Error('DSHX agent setup has no scoped DSH Agent'); @@ -83,7 +108,7 @@ export class DshAgentDriver { } currentModel(agent) { - return this.selectionFor(agent).current; + return this.externalSelections.get(agent)?.current ?? this.selectionFor(agent).current; } currentTitle(agent) { @@ -114,6 +139,15 @@ export class DshAgentDriver { model: resolved.model, ...(resolved.reasoningEffort === undefined ? {} : { reasoningEffort: resolved.reasoningEffort }) }; + const external = this.externalSelections.get(agent); + if (external) { + if (typeof external.select !== 'function') { + throw new Error('Host-owned DSH Agent has no model-selection delegate'); + } + const committed = await external.select(selected); + external.current = committed ?? selected; + return external.current; + } this.selectionFor(agent).current = selected; return selected; } From 93cc55135553d8c5f92818915b396e78ff7b874b Mon Sep 17 00:00:00 2001 From: code-new-bie <403466121@qq.com> Date: Thu, 20 Aug 2026 05:27:47 +0800 Subject: [PATCH 171/305] test: lock DSH subagent projection into Codex agents UI --- test/dsh-subagent-presentation.test.mjs | 83 +++++++++++++++++++++++++ 1 file changed, 83 insertions(+) create mode 100644 test/dsh-subagent-presentation.test.mjs diff --git a/test/dsh-subagent-presentation.test.mjs b/test/dsh-subagent-presentation.test.mjs new file mode 100644 index 00000000..8b77bfbd --- /dev/null +++ b/test/dsh-subagent-presentation.test.mjs @@ -0,0 +1,83 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { dshThreadFromSnapshot } from '../src/dsh/codex-shapes.mjs'; +import { DshxReleaseAdapter } from '../src/dsh/release-adapter.mjs'; + +function childAgent() { + return { + id: 'child-1', + status: 'running', + session: { + header: { + id: 'child-1', + origin: 'subagent', + parentSession: 'root-1', + delegationDepth: 2, + createdAt: 1_700_000_000_000, + cwd: '/work' + }, + events: [ + { + type: 'subagent/descriptor', + time: 1_700_000_000_010, + data: { version: 2, mode: 'continuable', provider: 'in-process', label: 'Scout' } + } + ], + requestHeader() { return { config: { provider: 'deepseek', model: 'model-a' } }; } + } + }; +} + +test('DSH subagent lineage becomes native Codex thread_spawn metadata without fake fork ancestry', () => { + const agent = childAgent(); + const thread = dshThreadFromSnapshot({ + meta: agent.session.header, + events: agent.session.events, + model: agent.session.requestHeader().config, + loaded: true + }); + + assert.equal(thread.forkedFromId, null); + assert.equal(thread.parentThreadId, 'root-1'); + assert.equal(thread.agentNickname, 'Scout'); + assert.equal(thread.agentRole, null); + assert.equal(thread.canAcceptDirectInput, false); + assert.deepEqual(thread.source, { + subagent: { + thread_spawn: { + parent_thread_id: 'root-1', + depth: 2, + agent_path: null, + agent_nickname: 'Scout', + agent_role: null + } + } + }); +}); + +test('release loaded-thread projection enumerates official DSH live agents including children', () => { + const root = { + id: 'root-1', + status: 'idle', + session: { + header: { id: 'root-1', createdAt: 1_700_000_000_000, cwd: '/work' }, + events: [], + requestHeader() { return { config: { provider: 'deepseek', model: 'model-a' } }; } + } + }; + const child = childAgent(); + const adapter = Object.create(DshxReleaseAdapter.prototype); + adapter.version = 'test'; + adapter.driver = { + listLive() { return [root, child]; }, + currentTitle(agent) { return agent.id === 'child-1' ? { title: 'Scout' } : undefined; } + }; + + const response = adapter.loadedThreadList(); + assert.equal(response.result.data.length, 2); + const projectedChild = response.result.data.find((thread) => thread.id === 'child-1'); + assert.equal(projectedChild.name, 'Scout'); + assert.equal(projectedChild.status.type, 'active'); + assert.equal(projectedChild.canAcceptDirectInput, false); + assert.equal(projectedChild.source.subagent.thread_spawn.parent_thread_id, 'root-1'); +}); From 7968d401cdfa45887c6c96c07648fb15b221c1b0 Mon Sep 17 00:00:00 2001 From: code-new-bie <403466121@qq.com> Date: Thu, 20 Aug 2026 05:28:01 +0800 Subject: [PATCH 172/305] test: lock visible DSH-backed Codex commands --- test/dshx-upstream-patch-policy.test.mjs | 35 ++++++++++++++++++++++++ 1 file changed, 35 insertions(+) create mode 100644 test/dshx-upstream-patch-policy.test.mjs diff --git a/test/dshx-upstream-patch-policy.test.mjs b/test/dshx-upstream-patch-policy.test.mjs new file mode 100644 index 00000000..2abe9bb8 --- /dev/null +++ b/test/dshx-upstream-patch-policy.test.mjs @@ -0,0 +1,35 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import test from 'node:test'; + +const slashPatch = fs.readFileSync('upstream/patches/codex/0009-dshx-slash-capabilities.patch', 'utf8'); + +function hiddenCapabilityBlock() { + const start = slashPatch.indexOf('+ if crate::dshx_backend()'); + const end = slashPatch.indexOf('+ return false;', start); + assert.notEqual(start, -1, 'DSHX slash hidden-capability block must exist'); + assert.notEqual(end, -1, 'DSHX slash hidden-capability block must terminate'); + return slashPatch.slice(start, end); +} + +test('DSH-backed daily commands remain visible in the Codex thin fork', () => { + const hidden = hiddenCapabilityBlock(); + for (const command of ['Diff', 'Fork', 'Compact', 'Agents']) { + assert.equal( + hidden.includes(`SlashCommand::${command}`), + false, + `/${command.toLowerCase()} is DSH-backed and must not be hidden` + ); + } +}); + +test('Codex-only runtime owners stay hidden from DSHX', () => { + const hidden = hiddenCapabilityBlock(); + for (const command of ['AutoReview', 'Mcp', 'Plugins', 'Logout', 'MultiAgents']) { + assert.equal( + hidden.includes(`SlashCommand::${command}`), + true, + `/${command.toLowerCase()} must stay hidden until an equivalent DSH public seam is faithfully projected` + ); + } +}); From 4127f03ab27fa30b14107bc556a3d1cc687bd50f Mon Sep 17 00:00:00 2001 From: code-new-bie <403466121@qq.com> Date: Thu, 20 Aug 2026 05:28:24 +0800 Subject: [PATCH 173/305] fix: preserve Host-owned model selection on forked Agents --- src/dsh/product-adapter.mjs | 17 +++-------------- 1 file changed, 3 insertions(+), 14 deletions(-) diff --git a/src/dsh/product-adapter.mjs b/src/dsh/product-adapter.mjs index 93907122..2f8a089f 100644 --- a/src/dsh/product-adapter.mjs +++ b/src/dsh/product-adapter.mjs @@ -81,10 +81,6 @@ export class DshxProductAdapter extends DshAppServerAdapter { } accountUsageRead(_params = {}) { - // Codex's account/usage/read is a billing API (credits/USD estimates), not - // a generic token counter. DSH token/context accounting is surfaced through - // thread/tokenUsage/updated instead. Returning threadUsage:null tells the - // Codex UI that billing usage is unavailable without inventing zero cost. return { result: { summary: { @@ -144,10 +140,6 @@ export class DshxProductAdapter extends DshAppServerAdapter { } async skillsList(params = {}) { - // Pinned Codex uses forceReload=true for its ordinary asynchronous startup - // catalog refresh. DSH provider invalidation is not a presentation-owned - // operation, so DSHX treats this bit as a request for a fresh authoritative - // registry snapshot rather than pretending to flush provider caches. if (params.forceReload === true) { this.diagnostics('Codex requested skills forceReload; DSHX is reading the current DSH registry snapshot without overriding provider-owned cache policy'); } @@ -188,9 +180,6 @@ export class DshxProductAdapter extends DshAppServerAdapter { line: '/compact', signal: abortController.signal }); - // A successful real compaction is rendered from DSH's durable - // compaction/* events. A no-op has no such event, so surface the official - // command text instead of leaving the user without feedback. if (execution.result?.sourceEventSeq == null && execution.result?.text) { this.warnThread(threadId, execution.result.text); } @@ -280,11 +269,11 @@ export class DshxProductAdapter extends DshAppServerAdapter { const childId = String(forked?.sessionId ?? ''); if (!childId) throw new Error('DSH Host fork returned no child session id'); - // DSH Host publishes the child Agent transactionally. DSHX attaches only a - // presentation controller and does not own the AgentHandle or inherited - // model/lineage/workspace semantics. const childAgent = this.driver.getLive(childId); if (!childAgent) throw new Error(`DSH Host fork did not publish child Agent ${childId}`); + this.driver.adoptExternalSelection(childAgent, { + select: (selection) => this.hostApi().selectModel({ sessionId: childId, ...selection }) + }); const controller = this.installController({ agent: childAgent, dispose: async () => {} }); const result = this.threadResponse(controller.agent, { includeTurns: params.excludeTurns !== true From 7ba0d11b86ef326c9a9bc6f0c07399333746daa0 Mon Sep 17 00:00:00 2001 From: code-new-bie <403466121@qq.com> Date: Thu, 20 Aug 2026 05:28:52 +0800 Subject: [PATCH 174/305] test: assert Host fork child keeps Host-owned model selection --- test/dsh-domain-delegation.test.mjs | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/test/dsh-domain-delegation.test.mjs b/test/dsh-domain-delegation.test.mjs index 0023bc30..2d4d4cac 100644 --- a/test/dsh-domain-delegation.test.mjs +++ b/test/dsh-domain-delegation.test.mjs @@ -42,9 +42,11 @@ test('manual compaction delegates to DSH commands.execute and does not own compa assert.equal(adapter._manualCompactions.size, 0); }); -test('thread/fork passes only a presentation anchor to official DSH Host fork', async () => { +test('thread/fork passes only a presentation anchor to DSH Host and adopts Host-owned model selection', async () => { const adapter = bareAdapter(); const hostCalls = []; + const modelCalls = []; + const adoptions = []; const sourceEvents = [ { seq: 1, type: 'turn/start', data: { turn: 1 } }, { seq: 2, type: 'user/message', data: { id: 'u1' } }, @@ -60,12 +62,19 @@ test('thread/fork passes only a presentation anchor to official DSH Host fork', async forkSession(request) { hostCalls.push(request); return { sessionId: 'child-session' }; + }, + async selectModel(request) { + modelCalls.push(request); + return request; } }); adapter.driver = { getLive(sessionId) { assert.equal(sessionId, 'child-session'); return childAgent; + }, + adoptExternalSelection(agent, options) { + adoptions.push({ agent, options }); } }; adapter.installController = (handle) => { @@ -87,5 +96,14 @@ test('thread/fork passes only a presentation anchor to official DSH Host fork', }); assert.deepEqual(hostCalls, [{ sessionId: 'source-session', atSeq: 3 }]); + assert.equal(adoptions.length, 1); + assert.equal(adoptions[0].agent, childAgent); + await adoptions[0].options.select({ provider: 'deepseek', model: 'reasoner', reasoningEffort: 'high' }); + assert.deepEqual(modelCalls, [{ + sessionId: 'child-session', + provider: 'deepseek', + model: 'reasoner', + reasoningEffort: 'high' + }]); assert.equal(response.result.thread.id, 'child-session'); }); From 6783054d20cfc85c1d108d7d979e009e5352221d Mon Sep 17 00:00:00 2001 From: code-new-bie <403466121@qq.com> Date: Thu, 20 Aug 2026 05:29:41 +0800 Subject: [PATCH 175/305] security: keep child runtime control behind DSH subagent authority --- src/dsh/release-adapter.mjs | 41 ++++++++++++++++++++++++++++++++----- 1 file changed, 36 insertions(+), 5 deletions(-) diff --git a/src/dsh/release-adapter.mjs b/src/dsh/release-adapter.mjs index 8c220cc2..a89c4fcc 100644 --- a/src/dsh/release-adapter.mjs +++ b/src/dsh/release-adapter.mjs @@ -116,6 +116,27 @@ export class DshxReleaseAdapter extends DshxProductAdapter { }; } + directController(threadId, operation) { + const controller = this.controllers.get(String(threadId ?? '')); + if (!controller) throw new Error(`Thread is not resumed in DSHX: ${String(threadId ?? '')}`); + if (isDshSubagent(controller.agent)) { + throw new Error(`DSHX refuses ${operation} on a DSH subagent; child runtime control remains owned by ctx.subagents`); + } + return controller; + } + + async interruptSubagent(controller) { + const parentSessionId = controller.agent.session.header?.parentSession; + if (parentSessionId == null) throw new Error('DSH subagent is missing durable parentSession authority'); + const subagents = this.ctx.get('subagents'); + if (!subagents?.interrupt) throw new Error('DSHX requires DSH service: subagents.interrupt'); + await subagents.interrupt(String(controller.agent.id), { + kind: 'user', + parentSessionId: String(parentSessionId) + }); + return { result: {} }; + } + installController(handle) { const controller = super.installController(handle); const threadId = String(controller.agent.id); @@ -148,12 +169,18 @@ export class DshxReleaseAdapter extends DshxProductAdapter { return this.threadSettingsUpdatePresentation(params); case 'thread/shellCommand': return this.threadShellCommand(params); + case 'thread/compact/start': + this.directController(params?.threadId, 'direct compaction'); + return super.dispatch(method, params); case 'turn/start': case 'turn/steer': return this.richUserTurn(method, params); - case 'turn/interrupt': + case 'turn/interrupt': { if (this.userShell().interrupt(params?.threadId, params?.turnId)) return { result: {} }; + const controller = this.controllers.get(String(params?.threadId ?? '')); + if (controller && isDshSubagent(controller.agent)) return this.interruptSubagent(controller); return super.dispatch(method, params); + } case 'thread/unsubscribe': { const threadId = String(params?.threadId ?? ''); this.userShell().abortThread(threadId, 'thread unsubscribed'); @@ -175,6 +202,9 @@ export class DshxReleaseAdapter extends DshxProductAdapter { const threadId = String(params.threadId ?? ''); const controller = this.controllers.get(threadId); if (!controller) return super.dispatch(method, params); + if (isDshSubagent(controller.agent)) { + throw new Error(`DSHX refuses ${method} on a DSH subagent; send/steer authority remains owned by ctx.subagents`); + } const content = await codexInputToDshContent(this.ctx, params.input ?? []); const clear = controller.prepareUserContent(content); try { @@ -234,9 +264,12 @@ export class DshxReleaseAdapter extends DshxProductAdapter { } async threadSettingsUpdatePresentation(params = {}) { - if (params.collaborationMode == null) return super.threadSettingsUpdate(params); const threadId = String(params.threadId ?? ''); const controller = this.controllers.get(threadId); + if (controller && isDshSubagent(controller.agent)) { + throw new Error('DSHX refuses direct model/permission/plan changes on a DSH subagent; composition remains DSH-owned'); + } + if (params.collaborationMode == null) return super.threadSettingsUpdate(params); if (!controller) throw new Error(`Thread is not resumed in DSHX: ${threadId}`); const active = codexPlanTarget(params.collaborationMode); const { collaborationMode: _ignored, ...ordinary } = params; @@ -291,9 +324,7 @@ export class DshxReleaseAdapter extends DshxProductAdapter { } threadShellCommand(params = {}) { - const threadId = String(params.threadId ?? ''); - const controller = this.controllers.get(threadId); - if (!controller) throw new Error(`Thread is not resumed in DSHX: ${threadId}`); + const controller = this.directController(params.threadId, 'direct shell execution'); return this.userShell().start(controller, params.command); } From 39f22a4105ea3a73e233a3ea19ce33ff23a498cd Mon Sep 17 00:00:00 2001 From: code-new-bie <403466121@qq.com> Date: Thu, 20 Aug 2026 05:29:43 +0800 Subject: [PATCH 176/305] test: gate all DSH services used by shipped TUI surfaces --- scripts/runtime-smoke.mjs | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/scripts/runtime-smoke.mjs b/scripts/runtime-smoke.mjs index 08e2e2ce..6d67e643 100644 --- a/scripts/runtime-smoke.mjs +++ b/scripts/runtime-smoke.mjs @@ -4,14 +4,23 @@ import { bootDshxRuntime } from '../src/dsh/runtime-boot.mjs'; const REQUIRED_SERVICES = [ 'agents', + 'agentDefaultModel', 'llm', 'sessions', 'sessionPersistence', + 'sessionQuery', + 'sessionProjections', + 'sessionTitle', + 'workspaceRegistry', + 'attachments', 'tools', + 'commands', + 'compaction', 'permissionPresets', 'approval', 'userQuestions', - 'skills' + 'skills', + 'planMode' ]; let ctx; @@ -27,7 +36,12 @@ try { if (missing.length > 0) { throw new Error(`official DSH composition is missing required presentation services: ${missing.join(', ')}`); } - process.stdout.write(`official DSH runtime booted: ${REQUIRED_SERVICES.join(', ')}\n`); + const commands = ctx.get('commands'); + const commandNames = new Set((commands?.list?.() ?? []).map((entry) => entry.name)); + if (!commandNames.has('compact')) { + throw new Error('official DSH composition does not register the /compact command required by DSHX'); + } + process.stdout.write(`official DSH runtime booted: ${REQUIRED_SERVICES.join(', ')}; /compact registered\n`); } finally { clearTimeout(timer); await ctx?.dispose?.(); From a49d04cac177b171749c79abdaa44f0eb26eee37 Mon Sep 17 00:00:00 2001 From: code-new-bie <403466121@qq.com> Date: Thu, 20 Aug 2026 05:31:44 +0800 Subject: [PATCH 177/305] fix: match rc.8 DSH command execution signature --- src/dsh/commands.mjs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/dsh/commands.mjs b/src/dsh/commands.mjs index 5deec321..d63bec35 100644 --- a/src/dsh/commands.mjs +++ b/src/dsh/commands.mjs @@ -8,10 +8,10 @@ function requireService(ctx, name) { * Execute one human-facing DSH command through the official command plane. * DSHX does not parse, implement, persist, or run the domain operation itself. */ -export async function executeDshCommand({ ctx, agent, line, signal }) { +export async function executeDshCommand({ ctx, agent, line, images = [], signal }) { if (!agent) throw new Error('DSHX command execution requires a live DSH Agent'); const commands = requireService(ctx, 'commands'); - const execution = await commands.execute(agent, line, signal); + const execution = await commands.execute(agent, line, images, signal); if (execution == null) throw new Error(`DSH command is not registered: ${line}`); if (execution.result?.kind === 'error') { throw new Error(execution.result.text || `DSH command failed: ${line}`); @@ -21,6 +21,7 @@ export async function executeDshCommand({ ctx, agent, line, signal }) { /** Read the current agent-scoped command catalog from DSH. */ export function listDshCommands({ ctx, agent }) { + if (!agent) throw new Error('DSHX command discovery requires a live DSH Agent'); const commands = requireService(ctx, 'commands'); return commands.list(agent); } From 5aa0ed18d3e2dd12b70cbd51748195353f3c14df Mon Sep 17 00:00:00 2001 From: code-new-bie <403466121@qq.com> Date: Thu, 20 Aug 2026 05:31:48 +0800 Subject: [PATCH 178/305] test: keep Codex agents UI behind DSH subagent authority --- test/dsh-subagent-authority.test.mjs | 95 ++++++++++++++++++++++++++++ 1 file changed, 95 insertions(+) create mode 100644 test/dsh-subagent-authority.test.mjs diff --git a/test/dsh-subagent-authority.test.mjs b/test/dsh-subagent-authority.test.mjs new file mode 100644 index 00000000..a48b1538 --- /dev/null +++ b/test/dsh-subagent-authority.test.mjs @@ -0,0 +1,95 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { DshxReleaseAdapter } from '../src/dsh/release-adapter.mjs'; + +function childController() { + let cancelled = 0; + const agent = { + id: 'child-1', + session: { + header: { + id: 'child-1', + origin: 'subagent', + parentSession: 'root-1', + cwd: '/work' + } + }, + cancel() { cancelled += 1; } + }; + return { controller: { agent }, cancelled: () => cancelled }; +} + +function adapterWithChild() { + const { controller, cancelled } = childController(); + const adapter = Object.create(DshxReleaseAdapter.prototype); + adapter.controllers = new Map([['child-1', controller]]); + adapter.diagnostics = () => {}; + return { adapter, controller, cancelled }; +} + +test('Codex direct turns cannot bypass DSH continuation authority for child agents', async () => { + const { adapter } = adapterWithChild(); + await assert.rejects( + () => adapter.richUserTurn('turn/start', { + threadId: 'child-1', + input: [{ type: 'text', text: 'bypass child authority', text_elements: [] }] + }), + /send\/steer authority remains owned by ctx\.subagents/ + ); + await assert.rejects( + () => adapter.richUserTurn('turn/steer', { + threadId: 'child-1', + expectedTurnId: 'dsh-turn-1', + input: [{ type: 'text', text: 'bypass steer', text_elements: [] }] + }), + /ctx\.subagents/ + ); +}); + +test('Codex shell, compaction, and settings are presentation-blocked on child agents', async () => { + const { adapter } = adapterWithChild(); + assert.throws( + () => adapter.threadShellCommand({ threadId: 'child-1', command: 'pwd' }), + /child runtime control remains owned by ctx\.subagents/ + ); + assert.throws( + () => adapter.directController('child-1', 'direct compaction'), + /child runtime control remains owned by ctx\.subagents/ + ); + await assert.rejects( + () => adapter.threadSettingsUpdatePresentation({ threadId: 'child-1', model: 'anything' }), + /composition remains DSH-owned/ + ); +}); + +test('child interrupt delegates only to official ctx.subagents interrupt authority', async () => { + const { adapter, controller, cancelled } = adapterWithChild(); + const calls = []; + adapter.ctx = { + get(name) { + if (name !== 'subagents') return undefined; + return { + async interrupt(childId, authority) { + calls.push({ childId, authority }); + } + }; + } + }; + + assert.deepEqual(await adapter.interruptSubagent(controller), { result: {} }); + assert.deepEqual(calls, [{ + childId: 'child-1', + authority: { kind: 'user', parentSessionId: 'root-1' } + }]); + assert.equal(cancelled(), 0, 'DSHX must not call child Agent.cancel() directly'); +}); + +test('child interrupt fails closed without durable parent authority', async () => { + const { adapter, controller } = adapterWithChild(); + controller.agent.session.header.parentSession = undefined; + adapter.ctx = { get() { throw new Error('subagent service must not be consulted without parent authority'); } }; + await assert.rejects( + () => adapter.interruptSubagent(controller), + /missing durable parentSession authority/ + ); +}); From d5eaf45d2d7de13c3af3d20ee54c80b7d1a40440 Mon Sep 17 00:00:00 2001 From: code-new-bie <403466121@qq.com> Date: Thu, 20 Aug 2026 05:31:56 +0800 Subject: [PATCH 179/305] fix: do not call agent-scoped command discovery without an Agent --- scripts/runtime-smoke.mjs | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/scripts/runtime-smoke.mjs b/scripts/runtime-smoke.mjs index 6d67e643..b74dc089 100644 --- a/scripts/runtime-smoke.mjs +++ b/scripts/runtime-smoke.mjs @@ -36,12 +36,10 @@ try { if (missing.length > 0) { throw new Error(`official DSH composition is missing required presentation services: ${missing.join(', ')}`); } - const commands = ctx.get('commands'); - const commandNames = new Set((commands?.list?.() ?? []).map((entry) => entry.name)); - if (!commandNames.has('compact')) { - throw new Error('official DSH composition does not register the /compact command required by DSHX'); - } - process.stdout.write(`official DSH runtime booted: ${REQUIRED_SERVICES.join(', ')}; /compact registered\n`); + // Command discovery is Agent-scoped in DSH rc.8. Do not invent a fake Agent + // just to inspect /compact here: the product-level delegation test proves the + // command call shape, while this smoke proves the official shipped services compose. + process.stdout.write(`official DSH runtime booted: ${REQUIRED_SERVICES.join(', ')}\n`); } finally { clearTimeout(timer); await ctx?.dispose?.(); From 017ad6a9e2ad79f58c7af0bbf251096bc0ded607 Mon Sep 17 00:00:00 2001 From: code-new-bie <403466121@qq.com> Date: Thu, 20 Aug 2026 05:32:13 +0800 Subject: [PATCH 180/305] test: assert rc.8 command execution argument order --- test/dsh-domain-delegation.test.mjs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/test/dsh-domain-delegation.test.mjs b/test/dsh-domain-delegation.test.mjs index 2d4d4cac..5e1b8902 100644 --- a/test/dsh-domain-delegation.test.mjs +++ b/test/dsh-domain-delegation.test.mjs @@ -17,8 +17,8 @@ test('manual compaction delegates to DSH commands.execute and does not own compa const warnings = []; adapter.warnThread = (threadId, message) => warnings.push([threadId, message]); const commands = { - async execute(agent, line, signal) { - calls.push({ agent, line, signal }); + async execute(agent, line, images, signal) { + calls.push({ agent, line, images, signal }); return { result: { kind: 'success', text: 'No compactable history yet.' } }; } }; @@ -37,6 +37,7 @@ test('manual compaction delegates to DSH commands.execute and does not own compa assert.equal(calls.length, 1); assert.equal(calls[0].agent, agent); assert.equal(calls[0].line, '/compact'); + assert.deepEqual(calls[0].images, []); assert.equal(calls[0].signal, abortController.signal); assert.deepEqual(warnings, [['session-1', 'No compactable history yet.']]); assert.equal(adapter._manualCompactions.size, 0); From c0d3b8a45d458473f6c64490dfd80fcbeafa592d Mon Sep 17 00:00:00 2001 From: code-new-bie <403466121@qq.com> Date: Thu, 20 Aug 2026 05:32:30 +0800 Subject: [PATCH 181/305] test: gate official DSH subagent service for agents UI --- scripts/runtime-smoke.mjs | 1 + 1 file changed, 1 insertion(+) diff --git a/scripts/runtime-smoke.mjs b/scripts/runtime-smoke.mjs index b74dc089..01e904ba 100644 --- a/scripts/runtime-smoke.mjs +++ b/scripts/runtime-smoke.mjs @@ -16,6 +16,7 @@ const REQUIRED_SERVICES = [ 'tools', 'commands', 'compaction', + 'subagents', 'permissionPresets', 'approval', 'userQuestions', From 32f11332107de2b38c42b5f18a7ee70f612b1355 Mon Sep 17 00:00:00 2001 From: code-new-bie <403466121@qq.com> Date: Thu, 20 Aug 2026 05:34:17 +0800 Subject: [PATCH 182/305] ci: cancel stale branch runs during RC convergence --- .github/workflows/ci.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index dc862526..5cbdb7bf 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -8,6 +8,10 @@ on: permissions: contents: read +concurrency: + group: ci-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + jobs: protocol: name: Protocol / ${{ matrix.os }} / Node ${{ matrix.node }} From 05f8e2dcfa5e6e91e0f83b5a33c057001409328d Mon Sep 17 00:00:00 2001 From: code-new-bie <403466121@qq.com> Date: Thu, 20 Aug 2026 05:40:07 +0800 Subject: [PATCH 183/305] runtime: boot the official user DSH profile instead of a fixed bundle --- src/dsh/runtime-boot.mjs | 160 ++++++++++++++++++++++++++++----------- 1 file changed, 116 insertions(+), 44 deletions(-) diff --git a/src/dsh/runtime-boot.mjs b/src/dsh/runtime-boot.mjs index 8203eac3..03eb924d 100644 --- a/src/dsh/runtime-boot.mjs +++ b/src/dsh/runtime-boot.mjs @@ -1,92 +1,164 @@ -import { readFileSync } from 'node:fs'; +import { readFileSync, writeFileSync } from 'node:fs'; import { createRequire } from 'node:module'; import { dirname, join } from 'node:path'; -import { fileURLToPath, pathToFileURL } from 'node:url'; +import { pathToFileURL } from 'node:url'; import { boot, composeEntries, + healProfilesModuleFallback, loadLayeredEnv, - loadOverlayPatches, - resolveBundleDir + loadOptionalPatches, + loadProfile, + PROFILE_PATCH_FILENAME } from '@deepseek-ai/dsh-app-boot'; import { DSH_LAUNCH_ENVIRONMENT_KEY } from '@deepseek-ai/dsh-launch-environment'; const NAME = 'dshx'; -const ROOT_CONFIG = fileURLToPath(new URL('../../config/dshx-runtime/cordis.yml', import.meta.url)); -const OFFICIAL_BUNDLES = ['@deepseek-ai/dsh-base', '@deepseek-ai/dsh-headless']; +const DSH_PROFILE_BIN_NAME = 'dsh'; +const DEFAULT_PROFILE = 'headless'; +const PROFILE_ROOT_FILENAME = 'cordis.yml'; +const PROFILE_ROOT_CONFIG = `# dshx profile root — composition remains owned by DeepSeek Harness.\n[]\n`; const DSHX_SURFACE_PATCHES = [ - // The official headless bundle is our composition source, but its startup - // provider and one-shot runner are themselves a presentation surface. DSHX - // replaces only those rows; all Agent/Session/tool/provider services remain - // exactly the official headless composition. + // The official headless profile is the default because it is the DSH runtime + // without a browser surface. Its one-shot CLI startup/runner are themselves + // presentation rows, so DSHX replaces only those two rows with Codex TUI. { id: 'headless-startup', disabled: true }, { id: 'headless-runner', disabled: true } ]; function installationAnchor() { - // @deepseek-ai/dsh is the published closed runtime carrying the official - // bundle/plugin dependency closure. It intentionally has no `exports` - // restriction, so its package.json is a stable resolution anchor. + // @deepseek-ai/dsh is the published profile owner and carries the official + // dependency closure. No exports restriction blocks package.json resolution. return createRequire(import.meta.url).resolve('@deepseek-ai/dsh/package.json'); } -function bundleLayer(packageName, installAnchor) { - const configDir = dirname(ROOT_CONFIG); - const packageDir = resolveBundleDir(NAME, packageName, installAnchor, configDir); - const manifest = JSON.parse(readFileSync(join(packageDir, 'package.json'), 'utf8')); - const patch = manifest?.dsh?.bundle?.patch; - if (typeof patch !== 'string' || patch.length === 0) { - throw new Error(`dshx: official bundle ${packageName} declares no dsh.bundle.patch`); +function selectedProfile(explicit) { + const value = explicit ?? process.env.DSHX_PROFILE ?? DEFAULT_PROFILE; + if (typeof value !== 'string' || value.trim().length === 0) { + throw new Error('dshx: DSH profile name must be non-empty'); } - return loadOverlayPatches(NAME, join(packageDir, patch)); + return value.trim(); +} + +function profileHome(profile) { + // /profiles/ -> + return dirname(dirname(profile.dir)); +} + +function shippedAgentPresetPatch(entries, installAnchor) { + const agentPresets = entries.find((entry) => entry.id === 'agent-presets'); + if (!agentPresets) return []; + return [{ + id: 'agent-presets', + config: { + ...(agentPresets.config ?? {}), + // This is the same installation-owned preset root used by the official + // dsh profile launcher. User/writable roots remain owned by the roster. + roots: [{ path: join(dirname(installAnchor), 'config', 'agent-presets'), trust: 'system' }] + } + }]; +} + +function telemetryPatch(entries) { + if (!process.env.DSH_TELEMETRY_DISABLED) return []; + if (!entries.some((entry) => entry.id === 'session-telemetry-otel')) return []; + return [{ id: 'session-telemetry-otel', disabled: true }]; } /** - * Return the exact patch list DSHX boots: official base + official headless, - * then the two presentation-row disables above, then optional DSHX-owned - * presentation overlays supplied by the caller. + * Load the exact official DSH profile layers that DSHX presents. + * + * Order remains DSH-owned: profile bundle layers -> profile user patch -> + * $DSH_HOME/cordis.patch.yml. DSHX then adds only installation-equivalent + * launcher patches and its presentation locks. Out-of-tree profile plugins + * resolve from the profile directory through DSH's own healed module fallback. */ -export function dshxRuntimePatches({ installAnchor = installationAnchor(), overlays = [] } = {}) { - return [ - ...OFFICIAL_BUNDLES.flatMap((name) => bundleLayer(name, installAnchor)), - ...structuredClone(DSHX_SURFACE_PATCHES), - ...structuredClone(overlays) +export function dshxRuntimeProfile({ + profile: explicitProfile, + installAnchor = installationAnchor(), + home, + overlays = [] +} = {}) { + const profileName = selectedProfile(explicitProfile); + healProfilesModuleFallback(installAnchor, home); + const profile = loadProfile(DSH_PROFILE_BIN_NAME, profileName, installAnchor, home); + const rootConfig = join(profile.dir, PROFILE_ROOT_FILENAME); + // Loader requires a real root anchored in the profile directory so + // profile-local plugins resolve exactly as they do under `dsh --profile`. + writeFileSync(rootConfig, PROFILE_ROOT_CONFIG); + + const bundlePatches = profile.layers.flatMap((layer) => layer.patches); + const homePatches = loadOptionalPatches( + DSH_PROFILE_BIN_NAME, + join(profileHome(profile), PROFILE_PATCH_FILENAME) + ) ?? []; + const beforeLauncher = composeEntries([[...bundlePatches, ...profile.patches, ...homePatches]]); + const launcherPatches = [ + ...shippedAgentPresetPatch(beforeLauncher, installAnchor), + ...telemetryPatch(beforeLauncher), + ...structuredClone(overlays), + // Last on purpose: a profile may customize DSH capabilities, but it cannot + // re-enable a competing presentation runner inside the DSHX process. + ...structuredClone(DSHX_SURFACE_PATCHES) ]; + + return { + name: profileName, + profile, + rootConfig, + installAnchor, + patches: [ + ...structuredClone(bundlePatches), + ...structuredClone(profile.patches), + ...structuredClone(homePatches), + ...launcherPatches + ] + }; } -/** Offline composition view used by doctor/CI to prove DSHX did not grow a second runtime. */ +/** Exact patch list DSHX boots; exposed for doctor/ownership tests only. */ +export function dshxRuntimePatches(options = {}) { + return dshxRuntimeProfile(options).patches; +} + +/** Offline composition view used by doctor/CI; it includes the user's selected DSH profile. */ export function dshxRuntimeEntries(options = {}) { return composeEntries([dshxRuntimePatches(options)]); } /** - * Boot the official Harness composition in-process and return its root Context. - * DSHX owns only the surrounding presentation lifetime. The environment - * snapshot is provided before any config entry mounts, matching the official - * dsh launcher contract. + * Boot the selected official Harness profile in-process and return its root Context. + * DSHX owns only the surrounding presentation lifetime. Environment discovery + * intentionally uses the official `dsh` namespace so project and DSH-home .env + * behavior stays identical to ordinary Harness launches. */ export async function bootDshxRuntime({ cwd = process.cwd(), - environment = loadLayeredEnv(NAME, cwd), - overlays = [] + profile, + environment = loadLayeredEnv(DSH_PROFILE_BIN_NAME, cwd), + overlays = [], + home } = {}) { - const installAnchor = installationAnchor(); - const patches = dshxRuntimePatches({ installAnchor, overlays }); + const composition = dshxRuntimeProfile({ profile, overlays, home }); return boot( NAME, - ROOT_CONFIG, - structuredClone(patches), + composition.rootConfig, + structuredClone(composition.patches), (ctx) => { ctx.provide(DSH_LAUNCH_ENVIRONMENT_KEY, environment); }, - pathToFileURL(installAnchor).href + pathToFileURL(composition.installAnchor).href ); } export const runtimeInternals = { NAME, - ROOT_CONFIG, - OFFICIAL_BUNDLES, + DSH_PROFILE_BIN_NAME, + DEFAULT_PROFILE, + PROFILE_ROOT_FILENAME, + PROFILE_ROOT_CONFIG, DSHX_SURFACE_PATCHES, - installationAnchor + installationAnchor, + selectedProfile, + profileHome }; From a889d5400be99f7d3dabdc10efdeccaa24da6a7f Mon Sep 17 00:00:00 2001 From: code-new-bie <403466121@qq.com> Date: Thu, 20 Aug 2026 05:40:32 +0800 Subject: [PATCH 184/305] test: prove DSHX boots the user's official headless profile layers --- test/dsh-runtime-boot.test.mjs | 96 ++++++++++++++++++++++++---------- 1 file changed, 68 insertions(+), 28 deletions(-) diff --git a/test/dsh-runtime-boot.test.mjs b/test/dsh-runtime-boot.test.mjs index 2ccb3c5a..0bbf0ce2 100644 --- a/test/dsh-runtime-boot.test.mjs +++ b/test/dsh-runtime-boot.test.mjs @@ -1,35 +1,75 @@ import assert from 'node:assert/strict'; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; import test from 'node:test'; -import { dshxRuntimeEntries, runtimeInternals } from '../src/dsh/runtime-boot.mjs'; - -test('runtime composition comes from official base/headless bundles and disables only their one-shot surface', () => { - assert.deepEqual(runtimeInternals.OFFICIAL_BUNDLES, [ - '@deepseek-ai/dsh-base', - '@deepseek-ai/dsh-headless' - ]); - - const entries = dshxRuntimeEntries(); - const byId = new Map(entries.map((entry) => [entry.id, entry])); - - // These are public DSH services DSHX consumes. Their plugin identities remain - // the official bundle rows — DSHX never substitutes implementations. - assert.equal(byId.get('llm')?.name, '@deepseek-ai/dsh-llm'); - assert.equal(byId.get('session')?.name, '@deepseek-ai/dsh-session'); - assert.equal(byId.get('agent')?.name, '@deepseek-ai/dsh-agent'); - assert.equal(byId.get('permission')?.name, '@deepseek-ai/dsh-permission-presets'); - assert.equal(byId.get('approval')?.name, '@deepseek-ai/dsh-user-approval'); - assert.equal(byId.get('user-questions')?.name, '@deepseek-ai/dsh-user-questions'); - assert.equal(byId.get('session-persistence-jsonl')?.name, '@deepseek-ai/dsh-session-persistence-jsonl'); - assert.equal(byId.get('tools')?.name, '@deepseek-ai/dsh-tools'); - - // The official headless bundle contributes Code Mode plus its own CLI surface. - // DSHX keeps the runtime capability and removes only the competing surface. - assert.equal(byId.get('code-runtime')?.name, '@deepseek-ai/dsh-code-runtime-worker-thread'); - assert.equal(byId.get('headless-startup')?.disabled, true); - assert.equal(byId.get('headless-runner')?.disabled, true); +import { dshxRuntimeEntries, dshxRuntimeProfile, runtimeInternals } from '../src/dsh/runtime-boot.mjs'; + +function temporaryDshHome(run) { + const home = mkdtempSync(join(tmpdir(), 'dshx-profile-test-')); + try { + return run(home); + } finally { + rmSync(home, { recursive: true, force: true }); + } +} + +test('runtime auto-initializes and composes the official DSH headless profile', () => { + temporaryDshHome((home) => { + assert.equal(runtimeInternals.DEFAULT_PROFILE, 'headless'); + const composition = dshxRuntimeProfile({ home }); + assert.equal(composition.name, 'headless'); + + const manifest = JSON.parse(readFileSync(join(home, 'profiles', 'headless', 'package.json'), 'utf8')); + assert.deepEqual(manifest.dsh.profile.bundles, [ + '@deepseek-ai/dsh-base', + '@deepseek-ai/dsh-headless' + ]); + + const entries = dshxRuntimeEntries({ home }); + const byId = new Map(entries.map((entry) => [entry.id, entry])); + + // These are public DSH services DSHX consumes. Their plugin identities remain + // the official profile rows — DSHX never substitutes implementations. + assert.equal(byId.get('llm')?.name, '@deepseek-ai/dsh-llm'); + assert.equal(byId.get('session')?.name, '@deepseek-ai/dsh-session'); + assert.equal(byId.get('agent')?.name, '@deepseek-ai/dsh-agent'); + assert.equal(byId.get('permission')?.name, '@deepseek-ai/dsh-permission-presets'); + assert.equal(byId.get('approval')?.name, '@deepseek-ai/dsh-user-approval'); + assert.equal(byId.get('user-questions')?.name, '@deepseek-ai/dsh-user-questions'); + assert.equal(byId.get('session-persistence-jsonl')?.name, '@deepseek-ai/dsh-session-persistence-jsonl'); + assert.equal(byId.get('tools')?.name, '@deepseek-ai/dsh-tools'); + assert.equal(byId.get('code-runtime')?.name, '@deepseek-ai/dsh-code-runtime-worker-thread'); + + // Only the competing headless presentation surface is locked off. + assert.equal(byId.get('headless-startup')?.disabled, true); + assert.equal(byId.get('headless-runner')?.disabled, true); + }); +}); + +test('profile-local and DSH-home user patch layers remain authoritative for capabilities', () => { + temporaryDshHome((home) => { + // First read creates the official profile using DSH's own template. + dshxRuntimeProfile({ home }); + writeFileSync(join(home, 'profiles', 'headless', 'cordis.patch.yml'), ` +- id: tools + config: + dshxProfileProbe: profile +`); + writeFileSync(join(home, 'cordis.patch.yml'), ` +- id: llm + config: + dshxHomeProbe: home +`); + + const entries = dshxRuntimeEntries({ home }); + const byId = new Map(entries.map((entry) => [entry.id, entry])); + assert.equal(byId.get('tools')?.config?.dshxProfileProbe, 'profile'); + assert.equal(byId.get('llm')?.config?.dshxHomeProbe, 'home'); + }); }); -test('DSHX surface patches do not insert a second Agent, Session, tool or provider implementation', () => { +test('DSHX surface patches cannot insert a second Agent, Session, tool or provider implementation', () => { for (const patch of runtimeInternals.DSHX_SURFACE_PATCHES) { assert.ok('id' in patch); assert.equal('insert' in patch, false); From caccdf1eeeeb0cc8ca41452b31a3b9b2ac144c45 Mon Sep 17 00:00:00 2001 From: code-new-bie <403466121@qq.com> Date: Thu, 20 Aug 2026 05:41:39 +0800 Subject: [PATCH 185/305] ci: enforce profile-owned DSH runtime composition --- .github/workflows/ci.yml | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5cbdb7bf..6ac332e7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -211,12 +211,16 @@ jobs: run: | grep -q "must not grow into an agent framework" docs/ARCHITECTURE.md grep -q "DeepSeek Harness remains an upstream dependency" docs/TEAM.md - - name: Assert production runtime remains official DSH composition + - name: Assert runtime composition remains profile-owned by DSH shell: bash run: | - grep -q "dsh-base" src/dsh/runtime-boot.mjs - grep -q "dsh-headless" src/dsh/runtime-boot.mjs - grep -q "startup: { disabled: true }" src/dsh/runtime-boot.mjs + grep -q "loadProfile" src/dsh/runtime-boot.mjs + grep -q "healProfilesModuleFallback" src/dsh/runtime-boot.mjs + grep -q "loadOptionalPatches" src/dsh/runtime-boot.mjs + grep -q "DEFAULT_PROFILE = 'headless'" src/dsh/runtime-boot.mjs + grep -q "headless-startup" src/dsh/runtime-boot.mjs + grep -q "headless-runner" src/dsh/runtime-boot.mjs + ! grep -q "OFFICIAL_BUNDLES" src/dsh/runtime-boot.mjs - name: Assert shell and workspace execution remain DSH-owned shell: bash run: | From e3181bad51887ea7f1109ad46a081a0fc4196528 Mon Sep 17 00:00:00 2001 From: code-new-bie <403466121@qq.com> Date: Thu, 20 Aug 2026 05:42:11 +0800 Subject: [PATCH 186/305] runtime: remove private DSHX Harness config tree --- config/dshx-runtime/cordis.yml | 4 ---- 1 file changed, 4 deletions(-) delete mode 100644 config/dshx-runtime/cordis.yml diff --git a/config/dshx-runtime/cordis.yml b/config/dshx-runtime/cordis.yml deleted file mode 100644 index ae54b8c6..00000000 --- a/config/dshx-runtime/cordis.yml +++ /dev/null @@ -1,4 +0,0 @@ -# DSHX does not own a Harness plugin tree. This empty root is populated only -# by the official @deepseek-ai/dsh-base and @deepseek-ai/dsh-headless bundle -# patch layers in src/dsh/runtime-boot.mjs. -[] From 3dd031e410407533d8be2b5c70fe78adb087d10a Mon Sep 17 00:00:00 2001 From: code-new-bie <403466121@qq.com> Date: Thu, 20 Aug 2026 05:42:42 +0800 Subject: [PATCH 187/305] release: remove obsolete private DSHX runtime config from package --- scripts/package-platform.mjs | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/scripts/package-platform.mjs b/scripts/package-platform.mjs index 89e02451..77b4d882 100644 --- a/scripts/package-platform.mjs +++ b/scripts/package-platform.mjs @@ -32,13 +32,10 @@ function copy(relative) { fs.cpSync(from, to, { recursive: true }); } -// Keep the platform package closure intentionally simple and auditable: ship -// the whole DSHX presentation source tree plus its tiny Cordis root config. -// This avoids release-only missing-import bugs while still keeping DSH itself -// an upstream npm dependency rather than vendoring its runtime. +// Ship only DSHX presentation code. DeepSeek Harness profile/plugin state lives +// under $DSH_HOME and remains owned by the official @deepseek-ai/dsh runtime. copy('bin/dshx.mjs'); copy('src'); -copy('config'); copy('NOTICE'); copy('upstream/CODEX_COMMIT'); copy('upstream/DSH_COMMIT'); @@ -66,7 +63,7 @@ const packageJson = { bin: { dshx: './bin/dshx.mjs' }, dependencies: rootPackage.dependencies, repository: { type: 'git', url: 'https://github.com/code-new-bie/dsh-codex.git' }, - files: ['bin', 'src', 'config', 'dist/bin', 'upstream', 'LICENSE', 'NOTICE', 'npm-shrinkwrap.json'] + files: ['bin', 'src', 'dist/bin', 'upstream', 'LICENSE', 'NOTICE', 'npm-shrinkwrap.json'] }; fs.writeFileSync(path.join(stage, 'package.json'), `${JSON.stringify(packageJson, null, 2)}\n`); From 109fb11127e0a5e7dff3d44df03aba84935eac7d Mon Sep 17 00:00:00 2001 From: code-new-bie <403466121@qq.com> Date: Thu, 20 Aug 2026 05:43:41 +0800 Subject: [PATCH 188/305] runtime: preserve official DSH profile patch hot reload --- src/dsh/runtime-boot.mjs | 76 ++++++++++++++++++++++++++-------------- 1 file changed, 50 insertions(+), 26 deletions(-) diff --git a/src/dsh/runtime-boot.mjs b/src/dsh/runtime-boot.mjs index 03eb924d..e289b71a 100644 --- a/src/dsh/runtime-boot.mjs +++ b/src/dsh/runtime-boot.mjs @@ -1,4 +1,4 @@ -import { readFileSync, writeFileSync } from 'node:fs'; +import { writeFileSync } from 'node:fs'; import { createRequire } from 'node:module'; import { dirname, join } from 'node:path'; import { pathToFileURL } from 'node:url'; @@ -9,7 +9,8 @@ import { loadLayeredEnv, loadOptionalPatches, loadProfile, - PROFILE_PATCH_FILENAME + PROFILE_PATCH_FILENAME, + watchUserPatches } from '@deepseek-ai/dsh-app-boot'; import { DSH_LAUNCH_ENVIRONMENT_KEY } from '@deepseek-ai/dsh-launch-environment'; @@ -27,8 +28,6 @@ const DSHX_SURFACE_PATCHES = [ ]; function installationAnchor() { - // @deepseek-ai/dsh is the published profile owner and carries the official - // dependency closure. No exports restriction blocks package.json resolution. return createRequire(import.meta.url).resolve('@deepseek-ai/dsh/package.json'); } @@ -41,10 +40,13 @@ function selectedProfile(explicit) { } function profileHome(profile) { - // /profiles/ -> return dirname(dirname(profile.dir)); } +function homePatchPath(profile) { + return join(profileHome(profile), PROFILE_PATCH_FILENAME); +} + function shippedAgentPresetPatch(entries, installAnchor) { const agentPresets = entries.find((entry) => entry.id === 'agent-presets'); if (!agentPresets) return []; @@ -52,8 +54,6 @@ function shippedAgentPresetPatch(entries, installAnchor) { id: 'agent-presets', config: { ...(agentPresets.config ?? {}), - // This is the same installation-owned preset root used by the official - // dsh profile launcher. User/writable roots remain owned by the roster. roots: [{ path: join(dirname(installAnchor), 'config', 'agent-presets'), trust: 'system' }] } }]; @@ -83,22 +83,17 @@ export function dshxRuntimeProfile({ healProfilesModuleFallback(installAnchor, home); const profile = loadProfile(DSH_PROFILE_BIN_NAME, profileName, installAnchor, home); const rootConfig = join(profile.dir, PROFILE_ROOT_FILENAME); - // Loader requires a real root anchored in the profile directory so - // profile-local plugins resolve exactly as they do under `dsh --profile`. writeFileSync(rootConfig, PROFILE_ROOT_CONFIG); const bundlePatches = profile.layers.flatMap((layer) => layer.patches); - const homePatches = loadOptionalPatches( - DSH_PROFILE_BIN_NAME, - join(profileHome(profile), PROFILE_PATCH_FILENAME) - ) ?? []; + const homePatches = loadOptionalPatches(DSH_PROFILE_BIN_NAME, homePatchPath(profile)) ?? []; const beforeLauncher = composeEntries([[...bundlePatches, ...profile.patches, ...homePatches]]); const launcherPatches = [ ...shippedAgentPresetPatch(beforeLauncher, installAnchor), ...telemetryPatch(beforeLauncher), ...structuredClone(overlays), - // Last on purpose: a profile may customize DSH capabilities, but it cannot - // re-enable a competing presentation runner inside the DSHX process. + // Last on purpose: DSH user/profile configuration owns capabilities, but + // cannot re-enable a competing presentation runner in the DSHX process. ...structuredClone(DSHX_SURFACE_PATCHES) ]; @@ -107,6 +102,7 @@ export function dshxRuntimeProfile({ profile, rootConfig, installAnchor, + homePatchPath: homePatchPath(profile), patches: [ ...structuredClone(bundlePatches), ...structuredClone(profile.patches), @@ -116,39 +112,65 @@ export function dshxRuntimeProfile({ }; } -/** Exact patch list DSHX boots; exposed for doctor/ownership tests only. */ export function dshxRuntimePatches(options = {}) { return dshxRuntimeProfile(options).patches; } -/** Offline composition view used by doctor/CI; it includes the user's selected DSH profile. */ export function dshxRuntimeEntries(options = {}) { return composeEntries([dshxRuntimePatches(options)]); } +async function installProfileWatchers(ctx, options) { + if (ctx.get('loader') == null) { + throw new Error('dshx: official DSH profile boot has no Loader for profile hot reload'); + } + if (ctx.get('hmr') == null) { + if (ctx.get('timer') == null) { + await ctx.loader.create({ name: '@deepseek-ai/cordis-plugin-timer' }); + } + await ctx.loader.create({ name: '@deepseek-ai/cordis-plugin-hmr', config: { root: [] } }); + } + + const current = () => dshxRuntimeProfile(options).patches; + const composition = dshxRuntimeProfile(options); + await watchUserPatches(ctx, { + binName: NAME, + filename: composition.profile.patchPath, + compose: () => current() + }); + await watchUserPatches(ctx, { + binName: NAME, + filename: composition.homePatchPath, + compose: () => current() + }); +} + /** * Boot the selected official Harness profile in-process and return its root Context. - * DSHX owns only the surrounding presentation lifetime. Environment discovery - * intentionally uses the official `dsh` namespace so project and DSH-home .env - * behavior stays identical to ordinary Harness launches. + * DSHX owns only presentation lifetime. Project and DSH-home environment and + * live patch behavior intentionally follow the official DSH profile launcher. */ export async function bootDshxRuntime({ cwd = process.cwd(), profile, environment = loadLayeredEnv(DSH_PROFILE_BIN_NAME, cwd), overlays = [], - home + home, + watch = true } = {}) { - const composition = dshxRuntimeProfile({ profile, overlays, home }); - return boot( + const options = { profile, overlays, home }; + const composition = dshxRuntimeProfile(options); + const ctx = await boot( NAME, composition.rootConfig, structuredClone(composition.patches), - (ctx) => { - ctx.provide(DSH_LAUNCH_ENVIRONMENT_KEY, environment); + (hostCtx) => { + hostCtx.provide(DSH_LAUNCH_ENVIRONMENT_KEY, environment); }, pathToFileURL(composition.installAnchor).href ); + if (watch) await installProfileWatchers(ctx, options); + return ctx; } export const runtimeInternals = { @@ -160,5 +182,7 @@ export const runtimeInternals = { DSHX_SURFACE_PATCHES, installationAnchor, selectedProfile, - profileHome + profileHome, + homePatchPath, + installProfileWatchers }; From 8c7d6927944d4355b383b93fd0a69b3d0a021697 Mon Sep 17 00:00:00 2001 From: code-new-bie <403466121@qq.com> Date: Thu, 20 Aug 2026 05:44:37 +0800 Subject: [PATCH 189/305] test: add real Windows ConPTY smoke for pinned DSHX TUI --- test/tui_smoke_windows.mjs | 79 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 79 insertions(+) create mode 100644 test/tui_smoke_windows.mjs diff --git a/test/tui_smoke_windows.mjs b/test/tui_smoke_windows.mjs new file mode 100644 index 00000000..574d0bd1 --- /dev/null +++ b/test/tui_smoke_windows.mjs @@ -0,0 +1,79 @@ +#!/usr/bin/env node +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import process from 'node:process'; +import pty from 'node-pty'; +import { startProtocolStubServer } from '../src/server.mjs'; + +const ROOT = path.resolve(new URL('..', import.meta.url).pathname.replace(/^\/(?:[A-Za-z]:)/, (match) => match.slice(1))); +const TOKEN = 'dshx-conpty-smoke-token-0123456789abcdefghijklmnopqrstuvwxyz'; + +function stripTerminalControl(value) { + return value + .replace(/\x1B\][^\x07]*(?:\x07|\x1B\\)/g, '') + .replace(/\x1B\[[0-?]*[ -\/]*[@-~]/g, '') + .replace(/\r/g, ''); +} + +function stringEnv(extra = {}) { + return Object.fromEntries( + Object.entries({ ...process.env, ...extra }) + .filter(([, value]) => value != null) + .map(([key, value]) => [key, String(value)]) + ); +} + +function waitForOutput(state, needle, timeoutMs = 30_000) { + if (stripTerminalControl(state.output).includes(needle)) return Promise.resolve(); + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + dispose.dispose(); + reject(new Error(`Timed out waiting for ${JSON.stringify(needle)}. Transcript:\n${stripTerminalControl(state.output)}`)); + }, timeoutMs); + const dispose = state.term.onData((chunk) => { + state.output += chunk; + if (!stripTerminalControl(state.output).includes(needle)) return; + clearTimeout(timer); + dispose.dispose(); + resolve(); + }); + }); +} + +if (process.platform !== 'win32') { + throw new Error('tui_smoke_windows.mjs must run on Windows/ConPTY'); +} + +const binary = path.join(process.cwd(), 'dist', 'bin', 'dshx-tui.exe'); +if (!fs.existsSync(binary)) throw new Error(`built DSHX TUI missing: ${binary}`); + +const codexHome = fs.mkdtempSync(path.join(os.tmpdir(), 'dshx-conpty-home-')); +const server = await startProtocolStubServer({ token: TOKEN, eventDelayMs: 8 }); +let term; +try { + term = pty.spawn(binary, [], { + name: 'xterm-256color', + cols: 120, + rows: 32, + cwd: process.cwd(), + env: stringEnv({ + TERM: 'xterm-256color', + CODEX_HOME: codexHome, + DSHX_APP_SERVER_ENDPOINT: server.url, + DSHX_APP_SERVER_TOKEN: TOKEN + }) + }); + const state = { term, output: '' }; + term.onData((chunk) => { state.output += chunk; }); + + await waitForOutput(state, 'DeepSeek Harness'); + term.write('smoke from conpty\r'); + await waitForOutput(state, 'DSHX protocol stub received:'); + await waitForOutput(state, 'smoke from conpty'); + process.stdout.write('Windows ConPTY DSHX TUI smoke passed\n'); +} finally { + try { term?.kill(); } catch {} + await server.close(); + fs.rmSync(codexHome, { recursive: true, force: true }); +} From 6255b23ec6d8014c7181be683d675ecc9195312a Mon Sep 17 00:00:00 2001 From: code-new-bie <403466121@qq.com> Date: Thu, 20 Aug 2026 05:44:56 +0800 Subject: [PATCH 190/305] ci: add dedicated Windows ConPTY TUI gate --- .github/workflows/windows-tui-smoke.yml | 34 +++++++++++++++++++++++++ 1 file changed, 34 insertions(+) create mode 100644 .github/workflows/windows-tui-smoke.yml diff --git a/.github/workflows/windows-tui-smoke.yml b/.github/workflows/windows-tui-smoke.yml new file mode 100644 index 00000000..5facd655 --- /dev/null +++ b/.github/workflows/windows-tui-smoke.yml @@ -0,0 +1,34 @@ +name: Windows TUI Smoke + +on: + push: + branches: [main, 'work/**'] + pull_request: + +permissions: + contents: read + +concurrency: + group: windows-tui-${{ github.ref }} + cancel-in-progress: true + +jobs: + conpty: + name: Windows ConPTY / pinned Codex TUI + runs-on: windows-latest + timeout-minutes: 60 + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 20 + - name: Install DSHX runtime dependencies + run: npm install + - name: Build pinned Codex TUI + shell: pwsh + run: ./scripts/build-codex-tui.ps1 + - name: Install test-only ConPTY bridge + shell: pwsh + run: npm install --no-save --package-lock=false node-pty@1.1.0 + - name: Exercise real TUI through Windows ConPTY + run: node test/tui_smoke_windows.mjs From 3f5266e0b2d45124e355791cfcbeb896a4567687 Mon Sep 17 00:00:00 2001 From: code-new-bie <403466121@qq.com> Date: Thu, 20 Aug 2026 05:46:38 +0800 Subject: [PATCH 191/305] test: make Windows ConPTY smoke output collection deterministic --- test/tui_smoke_windows.mjs | 40 +++++++++++++++++++++++--------------- 1 file changed, 24 insertions(+), 16 deletions(-) diff --git a/test/tui_smoke_windows.mjs b/test/tui_smoke_windows.mjs index 574d0bd1..ba51ad1e 100644 --- a/test/tui_smoke_windows.mjs +++ b/test/tui_smoke_windows.mjs @@ -3,10 +3,9 @@ import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; import process from 'node:process'; -import pty from 'node-pty'; +import * as pty from 'node-pty'; import { startProtocolStubServer } from '../src/server.mjs'; -const ROOT = path.resolve(new URL('..', import.meta.url).pathname.replace(/^\/(?:[A-Za-z]:)/, (match) => match.slice(1))); const TOKEN = 'dshx-conpty-smoke-token-0123456789abcdefghijklmnopqrstuvwxyz'; function stripTerminalControl(value) { @@ -25,22 +24,26 @@ function stringEnv(extra = {}) { } function waitForOutput(state, needle, timeoutMs = 30_000) { - if (stripTerminalControl(state.output).includes(needle)) return Promise.resolve(); + const started = Date.now(); return new Promise((resolve, reject) => { - const timer = setTimeout(() => { - dispose.dispose(); - reject(new Error(`Timed out waiting for ${JSON.stringify(needle)}. Transcript:\n${stripTerminalControl(state.output)}`)); - }, timeoutMs); - const dispose = state.term.onData((chunk) => { - state.output += chunk; - if (!stripTerminalControl(state.output).includes(needle)) return; - clearTimeout(timer); - dispose.dispose(); - resolve(); - }); + const poll = setInterval(() => { + const transcript = stripTerminalControl(state.output); + if (transcript.includes(needle)) { + clearInterval(poll); + resolve(); + return; + } + if (Date.now() - started < timeoutMs) return; + clearInterval(poll); + reject(new Error(`Timed out waiting for ${JSON.stringify(needle)}. Transcript:\n${transcript}`)); + }, 50); }); } +function sleep(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + if (process.platform !== 'win32') { throw new Error('tui_smoke_windows.mjs must run on Windows/ConPTY'); } @@ -51,6 +54,7 @@ if (!fs.existsSync(binary)) throw new Error(`built DSHX TUI missing: ${binary}`) const codexHome = fs.mkdtempSync(path.join(os.tmpdir(), 'dshx-conpty-home-')); const server = await startProtocolStubServer({ token: TOKEN, eventDelayMs: 8 }); let term; +let dataDisposable; try { term = pty.spawn(binary, [], { name: 'xterm-256color', @@ -64,8 +68,8 @@ try { DSHX_APP_SERVER_TOKEN: TOKEN }) }); - const state = { term, output: '' }; - term.onData((chunk) => { state.output += chunk; }); + const state = { output: '' }; + dataDisposable = term.onData((chunk) => { state.output += chunk; }); await waitForOutput(state, 'DeepSeek Harness'); term.write('smoke from conpty\r'); @@ -73,7 +77,11 @@ try { await waitForOutput(state, 'smoke from conpty'); process.stdout.write('Windows ConPTY DSHX TUI smoke passed\n'); } finally { + dataDisposable?.dispose?.(); try { term?.kill(); } catch {} + // Let the killed TUI close its WebSocket before asking ws.Server.close() to + // wait for all clients; this keeps failures bounded on busy hosted runners. + await sleep(100); await server.close(); fs.rmSync(codexHome, { recursive: true, force: true }); } From f552cb1a834032682f4a3cac312324dd041f888e Mon Sep 17 00:00:00 2001 From: code-new-bie <403466121@qq.com> Date: Thu, 20 Aug 2026 05:48:28 +0800 Subject: [PATCH 192/305] feat: finalize production local IPC and cross-platform RC gates --- .github/workflows/ci.yml | 67 +++-- .github/workflows/release.yml | 43 ++- .github/workflows/windows-tui-smoke.yml | 15 +- README.md | 99 ++++--- bin/dshx-stub-local.mjs | 62 +++++ bin/dshx.mjs | 48 +++- docs/DSH-CAPABILITY-MATRIX.md | 164 +++++------- docs/RELEASE-READINESS.md | 61 +++++ docs/ROADMAP.md | 123 +++++---- docs/RUNTIME-INTEGRATION.md | 151 +++++------ docs/UX-PARITY.md | 91 ++++--- package.json | 4 +- scripts/build-codex-tui.ps1 | 13 +- scripts/build-codex-tui.sh | 8 +- scripts/package-platform.mjs | 55 ++-- src/dsh/local-server.mjs | 244 +++++++++++------- test/dsh-local-server.test.mjs | 133 ++++++---- test/tui_smoke.py | 15 +- test/tui_smoke_windows.mjs | 113 +++++--- .../codex/0010-dshx-local-ipc-bridge.patch | 164 ++++++++++++ .../codex/0011-dshx-ipc-self-check.patch | 100 +++++++ 21 files changed, 1228 insertions(+), 545 deletions(-) create mode 100644 bin/dshx-stub-local.mjs create mode 100644 docs/RELEASE-READINESS.md create mode 100644 upstream/patches/codex/0010-dshx-local-ipc-bridge.patch create mode 100644 upstream/patches/codex/0011-dshx-ipc-self-check.patch diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6ac332e7..1657ae37 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,7 +2,7 @@ name: CI on: push: - branches: [main, 'work/**'] + branches: [main, 'work/**', 'agent/**'] pull_request: permissions: @@ -80,11 +80,11 @@ jobs: - uses: actions/setup-node@v4 with: node-version: 20 - - name: Build pinned Codex TUI (Unix) + - name: Build pinned Codex TUI + local IPC bridge (Unix) if: runner.os != 'Windows' shell: bash run: bash scripts/build-codex-tui.sh - - name: Build pinned Codex TUI (Windows) + - name: Build pinned Codex TUI + local IPC bridge (Windows) if: runner.os == 'Windows' shell: pwsh run: ./scripts/build-codex-tui.ps1 @@ -100,10 +100,18 @@ jobs: SLASH=.upstream/codex/codex-rs/tui/src/slash_command.rs STARTUP=.upstream/codex/codex-rs/tui/src/app/startup.rs EVENT=.upstream/codex/codex-rs/tui/src/app/event_dispatch.rs + BRIDGE=.upstream/codex/codex-rs/stdio-to-uds/src/dshx_ipc_bridge.rs grep -q 'DSHX_APP_SERVER_ENDPOINT is required' "$MAIN" - grep -q 'DSHX_APP_SERVER_TOKEN is required' "$MAIN" + grep -q 'DSHX production launcher forbids TCP WebSocket transport' "$MAIN" + ! grep -q 'DSHX_APP_SERVER_TOKEN' "$MAIN" + grep -q 'RemoteAppServerEndpoint::UnixSocket' "$MAIN" grep -q 'Some(dshx_endpoint)' "$MAIN" grep -q 'DSHX_RESUME_MODE' "$MAIN" + grep -q 'UnixListener' "$BRIDGE" + grep -q 'UnixStream' "$BRIDGE" + grep -q 'dshxBridge.*ready' "$BRIDGE" + test -x dist/bin/dshx-ipc-bridge + dist/bin/dshx-ipc-bridge --check grep -q 'DeepSeek Harness' .upstream/codex/codex-rs/tui/src/history_cell/session.rs grep -q 'fn dshx_backend' .upstream/codex/codex-rs/tui/src/bottom_pane/approval_overlay.rs grep -q 'fn dshx_backend' .upstream/codex/codex-rs/tui/src/chatwidget/permission_popups.rs @@ -115,6 +123,14 @@ jobs: grep -q 'dshx resume' "$APP" grep -q 'if crate::dshx_backend()' "$SLASH" grep -q 'DeepSeek Harness' "$SLASH" + ! grep -q 'WebSocketServer' src/dsh/local-server.mjs + grep -q 'unix://' src/dsh/local-server.mjs + grep -q 'local-uds-via-stdio-bridge' scripts/package-platform.mjs + node - <<'NODE' + const pkg = JSON.parse(require('fs').readFileSync('package.json', 'utf8')); + if (pkg.dependencies?.ws) throw new Error('ws must not be a production dependency'); + if (!pkg.devDependencies?.ws) throw new Error('development protocol stub still requires ws'); + NODE python3 - <<'PY' from pathlib import Path text = Path('.upstream/codex/codex-rs/tui/src/slash_command.rs').read_text() @@ -130,9 +146,18 @@ jobs: run: | $main = Get-Content .upstream\codex\codex-rs\tui\src\main.rs -Raw if (-not $main.Contains('DSHX_APP_SERVER_ENDPOINT is required')) { throw 'missing DSHX endpoint guard' } - if (-not $main.Contains('DSHX_APP_SERVER_TOKEN is required')) { throw 'missing DSHX token guard' } + if (-not $main.Contains('DSHX production launcher forbids TCP WebSocket transport')) { throw 'TCP WebSocket production guard missing' } + if ($main.Contains('DSHX_APP_SERVER_TOKEN')) { throw 'legacy TCP bearer-token transport is still present' } + if (-not $main.Contains('RemoteAppServerEndpoint::UnixSocket')) { throw 'local Unix socket transport missing' } if (-not $main.Contains('Some(dshx_endpoint)')) { throw 'Codex core fallback was not removed' } if (-not $main.Contains('DSHX_RESUME_MODE')) { throw 'DSHX resume patch is missing' } + $bridge = Get-Content .upstream\codex\codex-rs\stdio-to-uds\src\dshx_ipc_bridge.rs -Raw + if (-not $bridge.Contains('UnixListener')) { throw 'cross-platform Codex UDS bridge is missing' } + if (-not $bridge.Contains('UnixStream')) { throw 'IPC data-plane self-check is missing' } + if (-not $bridge.Contains('dshxBridge')) { throw 'IPC bridge readiness protocol is missing' } + if (-not (Test-Path dist\bin\dshx-ipc-bridge.exe)) { throw 'packaged IPC bridge is missing' } + & .\dist\bin\dshx-ipc-bridge.exe --check + if ($LASTEXITCODE -ne 0) { throw 'packaged IPC bridge self-check failed' } $header = Get-Content .upstream\codex\codex-rs\tui\src\history_cell\session.rs -Raw if (-not $header.Contains('DeepSeek Harness')) { throw 'DSHX branding patch is missing' } $approval = Get-Content .upstream\codex\codex-rs\tui\src\bottom_pane\approval_overlay.rs -Raw @@ -152,12 +177,20 @@ jobs: $slash = Get-Content .upstream\codex\codex-rs\tui\src\slash_command.rs -Raw if (-not $slash.Contains('if crate::dshx_backend()')) { throw 'DSHX slash capability patch is missing' } if (-not $slash.Contains('DeepSeek Harness')) { throw 'DSHX slash branding is missing' } + $localServer = Get-Content src\dsh\local-server.mjs -Raw + if ($localServer.Contains('WebSocketServer')) { throw 'production adapter still opens a TCP WebSocket server' } + if (-not $localServer.Contains('unix://')) { throw 'production adapter does not publish a local Unix endpoint' } + $packager = Get-Content scripts\package-platform.mjs -Raw + if (-not $packager.Contains('local-uds-via-stdio-bridge')) { throw 'release metadata does not pin local IPC transport' } + $pkg = Get-Content package.json -Raw | ConvertFrom-Json + if ($null -ne $pkg.dependencies.ws) { throw 'ws must not be a production dependency' } + if ($null -eq $pkg.devDependencies.ws) { throw 'development protocol stub still requires ws' } $hiddenStart = $slash.IndexOf('if crate::dshx_backend()') $hiddenEnd = $slash.IndexOf('return false;', $hiddenStart) if ($hiddenStart -lt 0 -or $hiddenEnd -lt 0) { throw 'DSHX slash hidden block not found' } $hidden = $slash.Substring($hiddenStart, $hiddenEnd - $hiddenStart) if ($hidden.Contains('SlashCommand::Diff')) { throw '/diff must remain visible in DSHX now that command/exec is DSH-backed' } - - name: Real pinned TUI PTY smoke + - name: Real pinned TUI local-IPC PTY smoke if: runner.os == 'Linux' shell: bash run: | @@ -165,9 +198,9 @@ jobs: node scripts/verify-dsh-closure.mjs python3 -m pip install --user pexpect python3 test/tui_smoke.py - - name: Build installable platform tarball - shell: bash + - name: Build installable platform tarball (Unix) if: runner.os != 'Windows' + shell: bash run: node scripts/package-platform.mjs - name: Build installable platform tarball (Windows) if: runner.os == 'Windows' @@ -211,16 +244,12 @@ jobs: run: | grep -q "must not grow into an agent framework" docs/ARCHITECTURE.md grep -q "DeepSeek Harness remains an upstream dependency" docs/TEAM.md - - name: Assert runtime composition remains profile-owned by DSH + - name: Assert production runtime remains official DSH composition shell: bash run: | - grep -q "loadProfile" src/dsh/runtime-boot.mjs - grep -q "healProfilesModuleFallback" src/dsh/runtime-boot.mjs - grep -q "loadOptionalPatches" src/dsh/runtime-boot.mjs - grep -q "DEFAULT_PROFILE = 'headless'" src/dsh/runtime-boot.mjs - grep -q "headless-startup" src/dsh/runtime-boot.mjs - grep -q "headless-runner" src/dsh/runtime-boot.mjs - ! grep -q "OFFICIAL_BUNDLES" src/dsh/runtime-boot.mjs + grep -q "dsh-base" src/dsh/runtime-boot.mjs + grep -q "dsh-headless" src/dsh/runtime-boot.mjs + grep -q "startup: { disabled: true }" src/dsh/runtime-boot.mjs - name: Assert shell and workspace execution remain DSH-owned shell: bash run: | @@ -240,3 +269,9 @@ jobs: ! grep -q "compactNow" src/dsh/product-adapter.mjs ! grep -E "seedLength|parentSession|dshForkSeed" src/dsh/agent-driver.mjs src/dsh/product-adapter.mjs ! grep -q "fork(sourceAgent" src/dsh/agent-driver.mjs + - name: Assert production transport is local IPC, not TCP WebSocket + shell: bash + run: | + ! grep -q "WebSocketServer" src/dsh/local-server.mjs + grep -q "unix://" src/dsh/local-server.mjs + grep -q "dshx-ipc-bridge" scripts/build-codex-tui.sh diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f8e07677..b06c8024 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -43,18 +43,47 @@ jobs: npm install node scripts/verify-dsh-closure.mjs npm test - - name: Build pinned TUI and package (Unix) + - name: Boot official DSH runtime + run: node scripts/runtime-smoke.mjs + - name: Build pinned TUI and local IPC bridge (Unix) if: runner.os != 'Windows' shell: bash - run: | - bash scripts/build-codex-tui.sh - node scripts/package-platform.mjs - - name: Build pinned TUI and package (Windows) + run: bash scripts/build-codex-tui.sh + - name: Build pinned TUI and local IPC bridge (Windows) if: runner.os == 'Windows' shell: pwsh + run: ./scripts/build-codex-tui.ps1 + - name: Verify local IPC data plane (Unix) + if: runner.os != 'Windows' + shell: bash + run: dist/bin/dshx-ipc-bridge --check + - name: Verify local IPC data plane (Windows) + if: runner.os == 'Windows' + shell: pwsh + run: | + & .\dist\bin\dshx-ipc-bridge.exe --check + if ($LASTEXITCODE -ne 0) { throw 'local IPC data-plane self-check failed' } + - name: Real pinned TUI local-IPC PTY smoke + if: runner.os == 'Linux' + shell: bash run: | - ./scripts/build-codex-tui.ps1 - node scripts/package-platform.mjs + python3 -m pip install --user pexpect + python3 test/tui_smoke.py + - name: Install test-only Windows ConPTY bridge + if: runner.os == 'Windows' + shell: pwsh + run: npm install --no-save --package-lock=false node-pty@1.1.0 + - name: Real pinned TUI Windows ConPTY + CJK local-IPC smoke + if: runner.os == 'Windows' + run: node test/tui_smoke_windows.mjs + - name: Build installable platform tarball (Unix) + if: runner.os != 'Windows' + shell: bash + run: node scripts/package-platform.mjs + - name: Build installable platform tarball (Windows) + if: runner.os == 'Windows' + shell: pwsh + run: node scripts/package-platform.mjs - name: Clean install smoke (Unix) if: runner.os != 'Windows' shell: bash diff --git a/.github/workflows/windows-tui-smoke.yml b/.github/workflows/windows-tui-smoke.yml index 5facd655..fd1eced6 100644 --- a/.github/workflows/windows-tui-smoke.yml +++ b/.github/workflows/windows-tui-smoke.yml @@ -2,7 +2,7 @@ name: Windows TUI Smoke on: push: - branches: [main, 'work/**'] + branches: [main, 'work/**', 'agent/**'] pull_request: permissions: @@ -14,7 +14,7 @@ concurrency: jobs: conpty: - name: Windows ConPTY / pinned Codex TUI + name: Windows ConPTY / pinned Codex TUI / local IPC runs-on: windows-latest timeout-minutes: 60 steps: @@ -24,11 +24,18 @@ jobs: node-version: 20 - name: Install DSHX runtime dependencies run: npm install - - name: Build pinned Codex TUI + - name: Verify exact DeepSeek Harness release closure + run: node scripts/verify-dsh-closure.mjs + - name: Build pinned Codex TUI + local IPC bridge shell: pwsh run: ./scripts/build-codex-tui.ps1 + - name: Verify Windows local IPC data plane + shell: pwsh + run: | + & .\dist\bin\dshx-ipc-bridge.exe --check + if ($LASTEXITCODE -ne 0) { throw 'local IPC data-plane self-check failed' } - name: Install test-only ConPTY bridge shell: pwsh run: npm install --no-save --package-lock=false node-pty@1.1.0 - - name: Exercise real TUI through Windows ConPTY + - name: Exercise real TUI through Windows ConPTY + CJK text run: node test/tui_smoke_windows.mjs diff --git a/README.md b/README.md index e024a96c..2ae3d21a 100644 --- a/README.md +++ b/README.md @@ -1,83 +1,108 @@ # DSHX (`dsh-codex`) -DSHX is a production-oriented terminal frontend for **official DeepSeek Harness** with a Codex-grade interaction target. +DSHX is a terminal frontend for **official DeepSeek Harness** that reuses a pinned Codex TUI thin fork instead of reimplementing a look-alike terminal UI. -## Product goal +## Product contract -From any code project, the normal production entry point must be: +From a code project, the normal user entry point is: ```bash dshx ``` -The user should get an experience that tracks the pinned Codex CLI/TUI as closely as practical: welcome screen, composer, slash commands, model/permission pickers, approvals, tool/shell cells, diffs, plan/reasoning, steering, interrupt, resume, status/footer, scrolling, resize, mouse and CJK/IME behavior. +No separate Codex installation, remote-mode command, bridge command, DSH WebUI, or manual profile plumbing is part of the production UX. + +The interaction target is the pinned Codex CLI/TUI: welcome screen, composer, slash commands, model and permission pickers, approvals, ask-user flows, tool/shell cells, diffs, plan/reasoning presentation, steering, Ctrl+C interrupt, resume, status/footer, scrolling, resize and mouse behavior. Windows Terminal and CJK/IME are first-class release gates. ## Ownership boundary We maintain only: -- the Codex TUI thin fork / upstream sync; +- the Codex TUI thin fork and upstream sync patches; - TUI behavior and UX parity; -- the `dshx` launcher and packaging; -- the thinnest practical adapter from DSH **public APIs/events** to the TUI protocol. +- the `dshx` launcher, local presentation transport and packaging; +- the thinnest practical projection from DSH **public APIs/events** into the TUI protocol. We do **not** maintain, fork or recreate DeepSeek Harness capabilities. DSH owns the agent loop, sessions, model routing, tools, sandbox, approval policy, skills, subagents, plugins, jobs/workflows and persistence. -If DSH does not expose a capability, DSHX waits for upstream or degrades/hides the corresponding UI. It does not implement a replacement runtime. +If DSH does not expose a capability, DSHX waits for upstream or explicitly hides/degrades the corresponding UI. It does not implement a replacement runtime. ```text -Codex TUI thin fork ← dsh-codex owns presentation +pinned Codex TUI thin fork ← DSHX presentation │ - │ app-server-compatible projection + │ WebSocket framing over private local UDS ▼ -thin DSH UI adapter ← dsh-codex owns translation only +dshx-ipc-bridge ← tiny transport bridge; no runtime state │ -════════════════ ownership boundary ════════════════ + │ JSONL over child stdio + ▼ +DSHX presentation adapter ← translation only │ +════════════════ ownership boundary ════════════════ + │ official DSH public APIs/events ▼ -Official DeepSeek Harness ← upstream owns capabilities/runtime +Official DeepSeek Harness ← Agent / Session / tools / policy / persistence ``` -## Product principles - -1. **Reuse Codex UI instead of imitating it.** Keep upstream rendering/input code as intact as practical. -2. **DSH is authoritative.** Never create a second session database, policy engine or agent runtime. -3. **Adapter, not framework.** Protocol translation is allowed; reimplementation of DSH capabilities is not. -4. **Fail closed.** Permission/approval semantics that cannot be represented faithfully must never be silently weakened. -5. **`dshx` is the product command.** Production users do not manually run Codex remote mode, bridge processes or DSH profile plumbing. -6. **Windows is first-class.** Windows Terminal/PowerShell and Chinese IME are release gates, not follow-up polish. +There is **no TCP listener in the production launch path**. The bridge reuses Codex's own cross-platform `codex_uds` implementation so the Rust TUI and Node DSH composition can stay in separate processes without unsafe FFI. -## Current status +## Current integration status -`main` contains the architecture baseline. Active M0 work is on `work/protocol-poc`. +The active production-integration work is stacked on `work/dsh-public-adapter`, with the local-IPC/packaging gate in Draft PR #8 (`agent/production-ipc`). The implementation now includes: -M0 intentionally uses Codex remote/app-server transport only as a development harness to prove the UI/protocol seam. It is **not** the production transport. The production target is a pinned Codex TUI thin fork packaged behind `dshx`. +- zero-argument `dshx` launch and `dshx resume` flows; +- official DSH runtime composition and exact supported DSH release closure checks; +- DSH-backed session/history, model, permissions, approvals, ask-user, tools/shell/diffs, plan/reasoning, steering/interrupt, fork/compaction and subagent presentation boundaries; +- pinned Codex TUI build plus packaged cross-platform local IPC bridge; +- release artifacts for Linux x64, Windows x64, macOS arm64 and macOS x64; +- clean-install `dshx doctor`, including a real local UDS + WebSocket ping/pong data-plane self-check; +- Linux real-TUI PTY smoke through the production local-IPC topology; +- fail-closed ownership/security tests that keep DSH authoritative. -The earlier custom `pi-tui` implementation is a legacy prototype and is not the target product. +A commit is not called 1.0-ready merely because these paths exist. The final release candidate still must pass the repository CI/release gates and the side-by-side Windows Terminal/CJK/IME acceptance pass described in `docs/UX-PARITY.md`. -See: +## Build from source -- [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md) -- [`docs/TEAM.md`](docs/TEAM.md) -- [`docs/ROADMAP.md`](docs/ROADMAP.md) -- [`docs/UX-PARITY.md`](docs/UX-PARITY.md) -- [`CONTRIBUTING.md`](CONTRIBUTING.md) -- [`SECURITY.md`](SECURITY.md) +Requirements: Node.js 20+, Rust/Cargo, Git and the platform toolchain required by the pinned Codex workspace. -## M0 development launcher +```bash +npm install +``` -On the protocol-PoC branch, after installing dependencies and the pinned development Codex CLI: +Linux/macOS: ```bash -npm install +./scripts/build-codex-tui.sh npm link +dshx doctor +dshx +``` +Windows PowerShell: + +```powershell +.\scripts\build-codex-tui.ps1 +npm link dshx doctor dshx ``` -This development launcher starts a deterministic compatibility stub and attaches the Codex TUI to it. The next milestone replaces the stub with a thin adapter over official DSH public APIs. +`dshx doctor` checks Node, the packaged TUI, the local IPC data plane, the official DSH composition and the isolated DSHX presentation home. + +## Release model + +Tags matching `v*` build platform-specific installable tarballs in GitHub Actions. The release workflow performs adapter/runtime tests, builds the pinned TUI and IPC bridge, clean-installs each artifact, runs `dshx doctor`, and publishes SHA-256 checksums. Release candidates containing `-` in the tag are published as prereleases. + +## Design references + +- `docs/ARCHITECTURE.md` +- `docs/RUNTIME-INTEGRATION.md` +- `docs/DSH-CAPABILITY-MATRIX.md` +- `docs/ROADMAP.md` +- `docs/UX-PARITY.md` +- `docs/RELEASE-READINESS.md` +- `SECURITY.md` ## License -Apache-2.0 for project-owned code. Vendored/synchronized upstream code keeps its applicable upstream notices and license requirements. +Apache-2.0 for project-owned code. Synchronized upstream code keeps its applicable upstream notices and license requirements. diff --git a/bin/dshx-stub-local.mjs b/bin/dshx-stub-local.mjs new file mode 100644 index 00000000..2f470848 --- /dev/null +++ b/bin/dshx-stub-local.mjs @@ -0,0 +1,62 @@ +#!/usr/bin/env node +import { startDshxLocalServer } from '../src/dsh/local-server.mjs'; +import { ProtocolStub, normalizeDispatchResult } from '../src/protocol.mjs'; + +const delayMs = Number(process.env.DSHX_STUB_EVENT_DELAY_MS || 18); +const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); + +class LocalStubAdapter { + constructor({ send, cwd }) { + this.send = send; + this.stub = new ProtocolStub({ cwd }); + } + + async handle(message) { + if (message?.id === undefined) return null; + const { response, events } = normalizeDispatchResult(this.stub, message); + return { + response, + afterResponse: async () => { + for (const event of events) { + const threadId = event?.params?.threadId; + const turnId = event?.params?.turnId ?? event?.params?.turn?.id; + const belongsToActiveTurn = + typeof threadId === 'string' && + typeof turnId === 'string' && + event.method !== 'turn/completed'; + if (belongsToActiveTurn && !this.stub.isTurnActive(threadId, turnId)) break; + if (delayMs > 0) await sleep(delayMs); + if (belongsToActiveTurn && !this.stub.isTurnActive(threadId, turnId)) break; + this.send(event); + if ( + event.method === 'turn/completed' && + typeof threadId === 'string' && + typeof turnId === 'string' + ) { + this.stub.completeTurn(threadId, turnId); + } + } + } + }; + } + + async close() {} +} + +const server = await startDshxLocalServer({ + runtime: { async dispose() {} }, + Adapter: LocalStubAdapter, + log: (message) => process.stderr.write(`[dshx-local-stub] ${message}\n`) +}); +process.stdout.write(`${server.url}\n`); + +async function shutdown() { + try { + await server.close(); + } finally { + process.exit(0); + } +} + +process.on('SIGINT', shutdown); +process.on('SIGTERM', shutdown); diff --git a/bin/dshx.mjs b/bin/dshx.mjs index a592365f..dd13bba3 100644 --- a/bin/dshx.mjs +++ b/bin/dshx.mjs @@ -13,7 +13,7 @@ const PACKAGE = JSON.parse(fs.readFileSync(path.join(ROOT, 'package.json'), 'utf const VERSION = PACKAGE.version; function usage() { - return `DSHX ${VERSION}\n\nUsage:\n dshx Start DSHX in the current project\n dshx Start with an initial prompt\n dshx resume Open the Codex-style DSH session picker\n dshx resume --last Resume the most recent DSH session\n dshx resume Resume a specific DSH session\n dshx doctor Check packaged TUI and official DSH composition\n dshx --version Print version\n dshx --help Show this help\n\nEnvironment:\n DSHX_TUI_BIN Override the packaged DSHX TUI binary (development only)\n DSHX_TUI_HOME Override DSHX presentation-only Codex home (development only)\n DSHX_DEBUG=1 Print DSHX adapter diagnostics\n\nProduct boundary:\n DSHX owns only the launcher, Codex TUI thin fork and presentation adapter.\n DeepSeek Harness remains the authoritative Agent/Session/Tool runtime.\n DSHX never reads or writes the user's ordinary CODEX_HOME.\n`; + return `DSHX ${VERSION}\n\nUsage:\n dshx Start DSHX in the current project\n dshx Start with an initial prompt\n dshx resume Open the Codex-style DSH session picker\n dshx resume --last Resume the most recent DSH session\n dshx resume Resume a specific DSH session\n dshx doctor Check packaged TUI, local IPC and official DSH composition\n dshx --version Print version\n dshx --help Show this help\n\nEnvironment:\n DSHX_TUI_BIN Override the packaged DSHX TUI binary (development only)\n DSHX_IPC_BRIDGE_BIN Override the packaged local IPC bridge (development only)\n DSHX_TUI_HOME Override DSHX presentation-only Codex home (development only)\n DSHX_DEBUG=1 Print DSHX adapter diagnostics\n\nProduct boundary:\n DSHX owns only the launcher, Codex TUI thin fork and presentation adapter.\n DeepSeek Harness remains the authoritative Agent/Session/Tool runtime.\n DSHX never reads or writes the user's ordinary CODEX_HOME.\n`; } function packagedTuiBinary() { @@ -26,6 +26,16 @@ function packagedTuiBinary() { return candidates.find((candidate) => fs.existsSync(candidate)) ?? candidates[0]; } +function packagedIpcBridgeBinary() { + const name = process.platform === 'win32' ? 'dshx-ipc-bridge.exe' : 'dshx-ipc-bridge'; + const candidates = [ + process.env.DSHX_IPC_BRIDGE_BIN, + path.join(ROOT, 'dist', 'bin', name), + path.join(ROOT, '.build', 'codex', 'release', name) + ].filter(Boolean); + return candidates.find((candidate) => fs.existsSync(candidate)) ?? candidates[0]; +} + function dshxTuiHome() { return path.resolve(process.env.DSHX_TUI_HOME || path.join(os.homedir(), '.dshx', 'codex-tui')); } @@ -51,6 +61,10 @@ function doctor() { const tuiCheck = executable && fs.existsSync(executable) ? spawnSync(executable, ['--version'], { encoding: 'utf8' }) : { status: 127, stdout: '', stderr: '' }; + const bridge = packagedIpcBridgeBinary(); + const bridgeCheck = bridge && fs.existsSync(bridge) + ? spawnSync(bridge, ['--check'], { encoding: 'utf8' }) + : { status: 127, stdout: '', stderr: '' }; let runtimeDetail; let runtimeOk = false; @@ -77,6 +91,15 @@ function doctor() { : 'not found', tuiCheck.status === 0 ], + [ + 'Local IPC bridge', + bridgeCheck.status === 0 + ? `${bridge} (cross-platform Codex UDS)` + : bridge + ? `${bridge} is not built/runnable${bridgeCheck.stderr ? `: ${bridgeCheck.stderr.trim()}` : ''}` + : 'not found', + bridgeCheck.status === 0 + ], ['DeepSeek Harness', runtimeDetail, runtimeOk], ['Presentation home', dshxTuiHome(), true] ]; @@ -86,8 +109,8 @@ function doctor() { if (rows.some((row) => !row[2])) { process.stderr.write( process.platform === 'win32' - ? 'For a source checkout, build the pinned TUI with `.\\scripts\\build-codex-tui.ps1`; packaged releases include it.\n' - : 'For a source checkout, build the pinned TUI with `./scripts/build-codex-tui.sh`; packaged releases include it.\n' + ? 'For a source checkout, build the pinned TUI + IPC bridge with `.\\scripts\\build-codex-tui.ps1`; packaged releases include both.\n' + : 'For a source checkout, build the pinned TUI + IPC bridge with `./scripts/build-codex-tui.sh`; packaged releases include both.\n' ); process.exitCode = 1; } @@ -124,6 +147,13 @@ async function run() { process.exitCode = 127; return; } + const bridge = packagedIpcBridgeBinary(); + if (!bridge || !fs.existsSync(bridge)) { + process.stderr.write(`dshx: packaged local IPC bridge not found (${bridge ?? 'unknown path'})\n`); + process.stderr.write('Run `dshx doctor` for build/install guidance.\n'); + process.exitCode = 127; + return; + } const tuiHome = dshxTuiHome(); try { @@ -138,9 +168,14 @@ async function run() { const log = debug ? (message) => process.stderr.write(`[dshx] ${message}\n`) : () => {}; let local; try { - local = await startDshxLocalServer({ cwd: process.cwd(), version: VERSION, log }); + local = await startDshxLocalServer({ + cwd: process.cwd(), + version: VERSION, + bridgeCommand: bridge, + log + }); } catch (error) { - process.stderr.write(`dshx: failed to boot DeepSeek Harness: ${error instanceof Error ? error.message : error}\n`); + process.stderr.write(`dshx: failed to boot DeepSeek Harness/local IPC: ${error instanceof Error ? error.message : error}\n`); process.exitCode = 1; return; } @@ -153,8 +188,7 @@ async function run() { // component only and must not read/write the user's ordinary Codex state. CODEX_HOME: tuiHome, ...launch.resumeEnv, - DSHX_APP_SERVER_ENDPOINT: local.url, - DSHX_APP_SERVER_TOKEN: local.token + DSHX_APP_SERVER_ENDPOINT: local.url }, stdio: 'inherit', windowsHide: false diff --git a/docs/DSH-CAPABILITY-MATRIX.md b/docs/DSH-CAPABILITY-MATRIX.md index 3f54b161..280a15d5 100644 --- a/docs/DSH-CAPABILITY-MATRIX.md +++ b/docs/DSH-CAPABILITY-MATRIX.md @@ -1,125 +1,81 @@ # DSH public capability matrix -This document is a guardrail for the DSHX adapter. Every Codex-facing behavior must map to an **official DeepSeek Harness public API/event**. If no suitable public capability exists at the pinned DSH commit, DSHX marks the surface unsupported or degrades the presentation; it does not implement a replacement runtime. +This document is a guardrail for the DSHX adapter. Every Codex-facing behavior must map to an **official DeepSeek Harness public API/event**. If no suitable public capability exists at the supported DSH release, DSHX marks the surface unsupported or degrades the presentation; it does not implement a replacement runtime. -Pinned DSH commit: `99f6f02fecdb7dff40c3fbc9470f5907c29f74ca` +Supported DSH package line: `0.1.0-rc.8`. +Pinned DSH source reference: `141eb6fef83422698aef7a981029e843e8161534`. ## Core interaction mapping | Codex/TUI need | Official DSH source | DSHX responsibility | Status | |---|---|---|---| -| Start a conversation | `ctx.agents.create(...)` | Project result into `thread/start` / Thread UI | Verified | -| Resume a conversation | `ctx.agents.resume({ resumeSessionId })` | Project resumed Session into Thread/resume UI | Verified | -| Live agent lookup | `ctx.agents.get(id)`, `list()`, `roots()` | Presentation/navigation only | Verified | -| User follow-up | `Agent.followup(message)` | Convert composer input to DSH user message | Verified | -| Steering while running | `Agent.steer(message)` | Forward active-turn steering from composer | Verified | -| Cancel / Ctrl+C | `Agent.cancel(cause, options?)` | Map TUI interrupt intent, never invent policy | Verified | -| Wait for quiescence | `Agent.whenIdle()` | Drive UI busy/idle convergence | Verified | -| Agent status | `agent/status` event and `Agent.status` | Render active/idle state | Verified | -| Durable transcript source | `Session` append-only event log | Project only; never create a second transcript DB | Verified | -| Session event feed | `session/event` | Translate exposed event vocabulary to TUI items | Verified | -| User message | `user/message` | Render transcript/input history | Verified | -| Token/chunk streaming | `assistant/chunk` | Convert exposed chunks to agent-message/reasoning deltas | Verified | -| Final assistant message | `assistant/message` | Finalize message item and usage presentation | Verified | -| Tool invocation | `tool/call` | Render matching Codex tool/shell/file cell where semantics are known | Verified | -| Tool result | `tool/result` | Render result/meta; do not execute the tool in DSHX | Verified | -| File contextual diff | `tool/result.meta` when provided by producing tool | Present diff/card only | Verified capability seam | -| Plan / todo | `todo/write` | Project whole-list snapshot into plan UI | Verified | -| Provider/model/reasoning route | `request/header` | Populate status/model presentation and resume projection | Verified | -| Context-window route metadata | `request/context` | Feed context footer when present | Verified | -| Flush durability | `session/flush` | Await/request through official lifecycle only when required | Verified | -| Session listing | `ctx.sessionPersistence.list()` / `listSnapshots()` | Populate resume picker | Verified | -| Session inspection | `ctx.sessionPersistence.inspect(id)` | Read immutable history for resume/details | Verified | +| Start a conversation | `ctx.agents.create(...)` | Project result into `thread/start` / Thread UI | Implemented + tested | +| Resume a conversation | `ctx.agents.resume({ resumeSessionId })` | Project resumed Session into Thread/resume UI | Implemented + tested | +| Live agent lookup | `ctx.agents.get(id)`, `list()`, `roots()` | Presentation/navigation only | Implemented + tested | +| User follow-up | `Agent.followup(message)` | Convert composer input to official DSH user content | Implemented + tested | +| Steering while running | `Agent.steer(message)` | Forward active-turn steering from composer | Implemented + tested | +| Cancel / Ctrl+C | `Agent.cancel(cause, options?)` | Map TUI interrupt intent; never invent policy | Implemented + tested | +| Wait for quiescence | `Agent.whenIdle()` | Drive UI busy/idle convergence | Implemented + tested | +| Agent/subagent status | `agent/status`, `agent/created`, `agent/disposed` | Render thread active/idle/not-loaded state | Implemented + tested | +| Durable transcript source | `Session` append-only event log | Project only; never create a second transcript DB | Implemented + guarded | +| Session event feed | `session/event` | Translate exposed event vocabulary to TUI items | Implemented + tested | +| User message | `user/message` | Render transcript/input history | Implemented + tested | +| Token/chunk streaming | `assistant/chunk` | Convert visible/reasoning chunks to Codex deltas | Implemented + tested | +| Final assistant message | `assistant/message` | Finalize message/reasoning items | Implemented + tested | +| Tool invocation/result | `tool/call`, `tool/result` | Render matching Codex tool/shell/file/diff cells; never execute locally | Implemented + tested | +| File contextual diff | producing tool result/meta | Present diff/card only | Implemented where DSH emits faithful metadata | +| Plan / todo | `todo/write`, `planMode` | Project plan list/mode into Codex presentation | Implemented + tested | +| Provider/model catalog | `ctx.llm.listProviders()`, `listModels()`, `resolveModelInfo()` | Populate `/model` using public registry | Implemented + tested | +| Model/reasoning validation | `ctx.llm.resolveCallConfig()` + official model-selection service | Validate/apply picker changes through DSH | Implemented + tested | +| Persisted route metadata | `request/header` | Restore model/reasoning presentation on resume | Implemented + tested | +| Context/token usage | `request/context` + persisted session usage | Feed footer/usage projection when exposed | Implemented + tested | +| Session listing/inspection | `ctx.sessionPersistence.list()`, `inspect()` | Resume picker/history hydration | Implemented + tested | | Session persistence/crash repair | `ctx.sessionPersistence` implementations | **None** — DSH owns durability and repair | Upstream-owned | -| Permission preset list/current | `ctx.permissionPresets.names`, `optionOf()`, `current(session.events)` | Populate Codex-style permissions selector | Verified | -| Permission preset switch | `ctx.permissionPresets.set(session, name)` | Delegate selected value; do not write sandbox/approval events ourselves | Verified | -| Effective file sandbox | `ctx.sandboxPolicy.resolve({ session })` | Present DSH enforcement as an external sandbox | Verified | -| Effective approval policy | `ctx.approval.overrideOf(session)` + `ctx.approval.config.policy` | Map `ask → on-request`, `never → never` | Verified | -| Interactive approval request | scoped `approval/request` waterfall | Present only when Codex has a faithful UI for that DSH tool; otherwise delegate/fail closed | Verified | -| Approval audit | `approval/asked` / `approval/decided` | Read/render if useful; never use audit events as the answer channel | Verified | +| Paginated history | persisted Session events | Convert immutable events into Codex turn/item pages | Implemented + tested | +| Permission preset list/current | `ctx.permissionPresets` | Populate Codex-style permissions selector | Implemented + tested | +| Permission preset switch | `ctx.permissionPresets.set(...)` | Delegate selected value; do not write policy events ourselves | Implemented + tested | +| Effective sandbox/approval state | DSH sandbox/approval services | Present DSH enforcement without inventing Codex semantics | Implemented + tested | +| Interactive approval request | scoped `approval/request` waterfall | Present only faithful one-shot decisions; fail closed otherwise | Implemented + tested | +| Ask user | `ctx.userQuestions.registerProvider(...)` | Present Codex request-user-input overlay and return DSH answer | Implemented for faithful single-select/free-text cases | +| Skills discovery | official DSH skills registry/provider surface | Adapt current registry snapshot to `skills/list` | Implemented + tested | +| Session fork | Host API `sessions.fork` | Select DSH event boundary and delegate durable fork | Implemented + ownership guarded | +| Manual compaction | official DSH `/compact` command | Invoke through DSH command service; render compaction events | Implemented + ownership guarded | +| Shell execution | official DSH `tools.execute` with owning Agent | Map Codex shell request/result to DSH tool execution | Implemented + ownership guarded | +| Workspace diff command | official DSH `tools.execute` with owning Agent | Use DSH execution, then present result | Implemented + ownership guarded | +| Subagent lifecycle | official `subagents` service + agent lifecycle events | Present child threads/status; delegate interrupt authority | Implemented + fail-closed authority tests | ## Permission semantic notes -DSH sandbox mode governs **filesystem effects only**. Its public sandbox contract explicitly states that network and process visibility are outside the sandbox vocabulary. Codex's legacy `SandboxPolicy` combines filesystem and network presentation fields, so DSHX must not claim that a DSH confined session is a Codex-native `workspaceWrite`/`readOnly` policy with invented network semantics. - -DSHX therefore projects confined DSH modes as Codex `externalSandbox` for the legacy field and keeps the actual DSH preset name/options as the user-facing source of truth. `danger-full-access` has an exact legacy Codex representation and may be projected directly. - -DSH approval outcomes are closed and fail-closed: - -```text -allowed-once -rejected -cancelled -unavailable -``` - -Only `allowed-once` is a grant. Codex's `acceptForSession` has **no faithful DSH equivalent** and must not be offered for DSH-backed approvals. The TUI needs a narrow backend-aware patch that keeps one-shot accept / decline / cancel while hiding session-wide approval. Unknown plugin-tool approvals are not misclassified as shell/file approvals; an unclaimed request naturally falls through to DSH's `unavailable` outcome. - -## DSH events observed at the pinned commit - -The session event vocabulary already contains the primitives needed for a Codex-like coding transcript: - -```text -turn/start -turn/end -step/start -step/end -user/message -assistant/chunk -assistant/message -tool/call -tool/result -todo/write -request/header -request/context -approval/asked -approval/decided -approval/policy -sandbox/mode -permission/preset -``` - -The adapter must treat the vocabulary as merge-extensible. Unknown plugin events are valid DSH events; DSHX may ignore explicitly ignorable presentation events but must not reinterpret required unknown events as known capabilities. - -## Explicit non-ownership - -DSHX does not own any implementation behind these surfaces: - -```text -ctx.agents -Agent loop -Session / SessionPersistence -ctx.llm adapters and routing -tool execution -sandbox -approval policy -permission presets -skills -subagents -plugins -jobs / workflows -crash recovery -``` - -A mapping row describes where DSHX **reads, calls or presents an upstream capability**. It does not grant permission to duplicate that capability locally. +DSH sandbox mode governs the semantics exposed by DSH; DSHX must not invent network/process guarantees that are absent from that public contract. Where Codex's legacy sandbox shape is broader than DSH's vocabulary, DSHX presents an external sandbox plus the actual DSH permission preset rather than falsely claiming Codex-native enforcement. + +DSH approval outcomes are closed and fail-closed. Only an explicit DSH grant is projected as approval. Codex's session-wide approval actions are hidden when no faithful DSH equivalent exists. Unknown plugin-tool approvals are not guessed into shell/file semantics; unclaimed requests remain unavailable/denied according to DSH policy. + +## Session durability and restart semantics + +DSHX keeps no durable transcript. Resume calls `ctx.agents.resume(...)`, history is read from DSH persistence, and the latest persisted `request/header` wins over the current machine default when restoring model/reasoning presentation. DSHX does not repair, replay or rewrite Session state after a crash. + +## Known deliberate capability gaps + +These are not shadow-implemented: + +- **DSH multi-select user questions:** the pinned Codex request-user-input presentation does not provide a faithful multi-select contract in the current adapter, so DSHX rejects that request instead of silently changing its semantics. +- **Codex-specific review/apps/plugins/hooks/memory/personality surfaces:** hidden where the supported DSH release has no faithful public equivalent wired into DSHX. +- **Jobs/workflows UI:** DSH may own these capabilities, but a stable terminal presentation contract has not yet been adopted; this is a presentation-only future surface. +- **Ephemeral Codex fork / goal-continuation semantics:** not mapped onto durable DSH Session forks. +- **Codex service-tier and reasoning-summary/personality thread settings:** rejected because there is no equivalent public DSH setting to project. + +A visible Codex command is kept only when its semantics are backed by DSH or are purely local TUI presentation. Unsupported runtime-owned commands are hidden rather than routed to Codex core. ## Capability fallback rule For every UI feature: -1. Detect whether the pinned/runtime DSH composition exposes a faithful public capability. +1. Detect whether the supported DSH composition exposes a faithful public capability. 2. If yes, adapt it narrowly into the Codex TUI surface. -3. If the semantics are only partially representable, show an explicit reduced/unsupported state. -4. If representing it would weaken permissions or durability semantics, fail closed. +3. If semantics are only partially representable, show an explicit reduced/unsupported state. +4. If representing it would weaken permissions, authority or durability semantics, fail closed. 5. Never solve a missing upstream capability by adding a shadow runtime to `dsh-codex`. -## Research still required - -The following surfaces need further public-API verification before implementation: - -- Enumerating the current DSH model/provider catalog for `/model` without relying on private registry fields. -- Ask-user event/tool presentation seam and cancellation semantics. -- Exact built-in DSH tool-name/meta contracts needed to specialize generic tool cells into Codex shell/file/diff cells. -- Skills/subagents/jobs/plugin UI capability discovery for later milestones. +## Release evidence -Those are adapter research tasks, not invitations to implement the capabilities in DSHX. +This matrix describes implementation/ownership, not release-candidate pass status. The exact candidate must still satisfy `docs/RELEASE-READINESS.md`, including CI, packaged local-IPC checks and manual Windows Terminal/CJK/IME parity acceptance. diff --git a/docs/RELEASE-READINESS.md b/docs/RELEASE-READINESS.md new file mode 100644 index 00000000..271f2e36 --- /dev/null +++ b/docs/RELEASE-READINESS.md @@ -0,0 +1,61 @@ +# 1.0 release readiness + +This file defines the evidence required before a DSHX release candidate may be promoted to 1.0. It intentionally distinguishes **implemented checks** from **checks that have actually passed on a release candidate**. + +## Rule + +No item becomes green because code for the check exists. A release candidate is green only when the corresponding GitHub Actions job or documented manual acceptance run has passed for the exact candidate commit/tag. + +## Automated gates + +| Gate | Evidence implemented in repository | Required result for 1.0 | +|---|---|---| +| Zero-argument launch | `bin/dshx.mjs`, clean-install smoke | pass on supported release artifacts | +| Official DSH composition | `scripts/runtime-smoke.mjs` on Linux/Windows/macOS | pass | +| Exact DSH dependency closure | `scripts/verify-dsh-closure.mjs`, release shrinkwrap | pass | +| DSH ownership boundary | adapter/unit tests + CI ownership assertions | pass | +| Approval/permission fail-closed | approval/permission/subagent authority tests | pass | +| Session resume/durability projection | DSH `agents.resume`, persistence projection and resume tests; persisted model/header wins over machine default | pass | +| Local production transport | no production `WebSocketServer`; TUI rejects TCP WebSocket endpoint | pass | +| Cross-platform local IPC data plane | `dshx-ipc-bridge --check` performs real UDS + WebSocket ping/pong | pass on Linux/Windows/macOS | +| Real pinned TUI / Linux | PTY smoke drives pinned TUI through local IPC deterministic adapter | pass | +| Real pinned TUI / Windows | ConPTY smoke drives pinned TUI through the same local-IPC topology | pass | +| Automated CJK transport/render sanity | Windows ConPTY test submits and observes a Chinese prompt | pass | +| Packaging | platform tarball + static local-import closure check + exact DSH shrinkwrap | pass | +| Clean installation | install generated tarball then `dshx --version` + `dshx doctor` | pass on every release platform | +| Release artifacts | Linux x64, Windows x64, macOS arm64, macOS x64 | all built | +| Integrity | release `SHA256SUMS` | published | +| Codex thin-fork invariants | patch application/build plus CI source assertions | pass | + +## Manual UX acceptance gate + +Automation can exercise ConPTY and Chinese text, but it cannot establish real **IME composition-state behavior** or visual parity. For the exact 1.0 release candidate, perform a side-by-side run against the pinned Codex TUI using the same Windows Terminal dimensions/theme and record pass/fail for: + +- Windows Terminal startup, resize, scroll and mouse behavior; +- Chinese IME composition/commit/cancel in the multiline composer; +- wide CJK glyph alignment in transcript/tool/diff cells; +- slash completion and help keyboard behavior; +- model and permission pickers; +- approval and ask-user overlays; +- shell/tool streaming and collapse behavior; +- unified diff review; +- plan/reasoning cells when DSH exposes them; +- steering and Ctrl+C interrupt during an active turn; +- resume picker and restored session/model state; +- status/footer rendering. + +Any difference is either fixed, documented as a deliberate product difference in `docs/UX-PARITY.md`, or blocks 1.0. + +## Promotion procedure + +1. Freeze the candidate commit and ensure the supported Codex/DSH pins are unchanged during validation. +2. Run the full CI workflow and the dedicated Windows ConPTY workflow for that exact commit. +3. Create a prerelease tag (for example `v1.0.0-rc.N`) and require every release-matrix build/clean-install/TUI gate to pass. +4. Install the generated Windows release artifact for the manual Windows Terminal/CJK/IME side-by-side acceptance run; do not validate a source checkout instead. +5. Resolve every failed gate without weakening a DSH ownership or security boundary. +6. Re-run affected automated and manual gates after fixes. +7. Promote only a commit/tag for which all required evidence is green. + +## Current integration note + +`agent/production-ipc` / Draft PR #8 is the production-transport integration branch. The repository contains the automated gates above, but their existence is **not** a statement that a particular candidate has passed GitHub Actions. Consult the Actions/check status for the exact head before merging or tagging. diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index c1f24b7b..99c929fd 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -1,73 +1,88 @@ # Roadmap -All milestones obey one ownership rule: **DSHX maintains the TUI, launcher and thin public-API adapter only. DeepSeek Harness capabilities remain upstream-owned.** +All milestones obey one ownership rule: **DSHX maintains the TUI, launcher, transport and thin public-API adapter only. DeepSeek Harness capabilities remain upstream-owned.** -## Milestone 0 — Codex TUI protocol proof +Milestone status describes implementation progress. A 1.0 release requires the independent evidence in `RELEASE-READINESS.md`. + +## Milestone 0 — Codex TUI protocol proof — complete Goal: prove that the pinned Codex TUI can be driven without Codex's agent runtime. -- Pin Codex and DSH upstream commits. -- Implement a deterministic app-server-compatible development stub. -- Support the TUI bootstrap requests: initialize, account/read, model/list, configRequirements/read and thread/start. -- Support turn/start, agent-message streaming and turn/interrupt. -- Expose a zero-argument development launcher: `dshx`. -- Run the proof on Windows Terminal and Linux. +Delivered: + +- pinned Codex and DSH upstream references; +- deterministic app-server-compatible development stub; +- bootstrap/thread/turn/streaming/interrupt protocol proof; +- zero-argument development launcher; +- thin-fork patches proving the Codex TUI can run with DSHX as its backend. + +The historical TCP/WebSocket path is retained only as a development fixture and is excluded from the production package. + +## Milestone 1 — Official DSH public-API adapter / daily coding loop — implementation complete, RC evidence pending + +Delivered through official DSH public APIs/events: -Exit gate: the pinned Codex TUI launches from `dshx`, accepts a user prompt and renders the deterministic streaming lifecycle through our protocol seam. +- session start/resume/list/history projection; +- response + reasoning streaming; +- specialized shell/tool/file/diff presentation where DSH exposes faithful semantics; +- approval and ask-user presentation with DSH policy authoritative; +- steering and Ctrl+C interrupt; +- model/provider/reasoning picker backed by official DSH registry/resolution; +- context/token/footer projection where DSH exposes the data; +- skills discovery; +- fork, compaction and subagent boundaries delegated to DSH-owned services; +- fail-closed ownership tests preventing local execution/runtime duplication. -The WebSocket remote path is a development harness only and is removed from the production launch path later. +Exit gate: the exact release candidate must pass the automated daily-loop/PTY gates and real dogfooding; implementation alone does not declare the milestone release-green. -## Milestone 1 — Official DSH public-API adapter / daily coding loop +## Milestone 2 — Codex UX parity — thin-fork parity implemented; manual terminal acceptance pending -Replace the deterministic stub with calls to **official DeepSeek Harness public APIs/events only**. +Implemented/inherited from the pinned Codex TUI: -- Session start/resume/list projection. -- Agent response streaming. -- Shell/tool cells from exposed DSH events. -- File change + unified diff from exposed DSH events. -- Approval and ask-user presentation with DSH policy remaining authoritative. -- Steering and reliable Ctrl+C through official DSH control APIs. -- Model picker backed by official DSH model registry/public surface. -- Context/token footer where DSH exposes the data. +- welcome screen, composer and slash-command interaction; +- model and permission pickers; +- resume picker using DSH session data; +- approval and ask-user overlays; +- tool/shell/diff/plan/reasoning cells; +- steering/interrupt behavior; +- Codex-native scroll, mouse and resize behavior; +- status/footer rendering where DSH exposes data. -Exit gate: a complete coding task can be performed without DSH WebUI and without implementing any DSH runtime capability in this repository. +Unsupported Codex runtime semantics are explicitly hidden/rejected rather than silently emulated. See `DSH-CAPABILITY-MATRIX.md`. -## Milestone 2 — Codex UX parity +Exit gate still pending: side-by-side Windows Terminal + Chinese IME/CJK acceptance against the pinned Codex TUI using the exact generated release-candidate artifact. -- Welcome screen parity. -- Composer and slash command parity. -- `/model`, `/permissions`, `/status`, `/review` behavior parity where DSH exposes matching semantics. -- Resume picker parity using DSH session data. -- Plan/reasoning cells where exposed by DSH. -- Scroll/search/mouse/resize behavior. -- Windows Terminal and CJK/IME regression tests. +## Milestone 3 — Additional DSH capability surfaces (presentation only) — partial -Exit gate: side-by-side comparison with the pinned Codex release passes the UX parity matrix. Unsupported DSH semantics are explicit, never silently emulated. +Implemented where official seams are stable: -## Milestone 3 — DSH capability surfaces (presentation only) +- Skills discovery/UI feed. +- Subagent thread/status presentation and DSH-owned interruption authority. -Expose additional DSH capabilities **only when official DSH interfaces exist**: +Future presentation-only candidates: -- Skills UI. -- Subagent UI. - Jobs/workflows UI. -- Plugin diagnostics/configuration UI. -- DSH-specific command namespace without disturbing Codex-compatible commands. - -This milestone adds presentation and translation, not new runtime implementations. - -## Milestone 4 — Production packaging and 1.0 gate - -- `dshx` zero-argument startup with no manual bridge/profile/remote command. -- Pinned Codex TUI thin fork included in release artifacts; no separate Codex install required for normal use. -- Supported local runtime integration; no production dependency on experimental WebSocket remote mode. -- Versioned protocol/DSH compatibility contract. -- Crash recovery and DSH session durability projection tests. -- Security/approval fail-closed tests. -- Windows/Linux/macOS release artifacts. -- Reproducible CI builds and checksums. -- `dshx doctor` diagnostics. -- Codex/DSH upstream sync playbook. -- Release-candidate dogfooding gate. - -1.0 ships only when the daily coding loop, UX parity matrix, security checks and zero-argument `dshx` launch contract are green on supported platforms. +- Plugin diagnostics/configuration UI where DSH exposes a stable product-facing seam. +- Additional DSH-specific commands that do not disturb Codex-compatible commands. + +This milestone is not a blocker for the core 1.0 daily coding loop unless a listed capability is required by the final product acceptance matrix. + +## Milestone 4 — Production packaging and 1.0 gate — implementation complete, external validation pending + +Implemented: + +- `dshx` zero-argument startup with no manual bridge/profile/remote command; +- pinned Codex TUI included in platform release artifacts; +- packaged cross-platform `dshx-ipc-bridge` using Codex `codex_uds`; +- production Unix-socket transport with no TCP listener / bearer-token loopback dependency; +- real local-IPC data-plane self-check in `dshx doctor`; +- exact supported DSH release closure and compatibility gates; +- DSH-owned session durability/resume projection tests; +- approval/permission/subagent fail-closed tests; +- Linux x64, Windows x64, macOS arm64 and macOS x64 release jobs; +- clean-install artifact smoke and SHA-256 checksums; +- Linux real pinned-TUI PTY smoke in CI **and tagged release workflow**; +- Codex/DSH pin + patch-queue sync model; +- explicit RC promotion checklist in `RELEASE-READINESS.md`. + +1.0 may ship only when the exact candidate has green CI/release evidence and the manual Windows Terminal/CJK/IME UX acceptance is recorded as passing. diff --git a/docs/RUNTIME-INTEGRATION.md b/docs/RUNTIME-INTEGRATION.md index 9f2c8ede..f095a383 100644 --- a/docs/RUNTIME-INTEGRATION.md +++ b/docs/RUNTIME-INTEGRATION.md @@ -2,111 +2,118 @@ ## Decision -DSHX will reuse the same architectural seam that official DeepSeek Harness uses for its headless product: a direct Cordis/core entry point over `dsh-base`, without requiring the Web/Host/HTTP/browser runtime for ordinary terminal use. +DSHX reuses the same architectural seam that official DeepSeek Harness uses for its headless product: a direct Cordis/core entry point over the official DSH composition, without requiring the WebUI/browser runtime for normal terminal use. The TUI remains project-owned. DeepSeek Harness remains upstream-owned. -Pinned DSH reference: `99f6f02fecdb7dff40c3fbc9470f5907c29f74ca`. +Supported DSH package line for the current release branch: `0.1.0-rc.8`. +Pinned DSH source reference: `141eb6fef83422698aef7a981029e843e8161534`. -At that commit, official `@deepseek-ai/dsh-headless` demonstrates the supported pattern: +The official runtime owns Agent creation/resume, Session persistence, model selection/routing, tools, approvals, permission policy, skills, subagents and every other capability surface. DSHX generalizes only the **presentation lifetime** from a headless/one-shot consumer into an interactive terminal frontend. -- wait for loader settlement; -- read the official default model service; -- create an Agent through `ctx.agents.create(...)`; -- drive it with public Agent APIs; -- observe the authoritative Session; -- flush through `ctx.sessions.flush(...)`; -- let DSH own model/tool/session/persistence behavior. - -DSHX generalizes only the **presentation lifetime** from one-shot stdout to an interactive TUI. It does not generalize or replace the runtime. - -## Target production topology +## Production topology ```text ┌──────────────────────────────────────────┐ -│ dshx executable │ -│ - user-facing command │ -│ - starts local components │ +│ dshx Node launcher │ +│ - boots official DSH composition │ +│ - owns child-process lifetime only │ └────────────────────┬─────────────────────┘ │ - local supported transport - (prefer stdio / in-process) - │ -┌────────────────────▼─────────────────────┐ -│ pinned Codex TUI thin fork │ -│ presentation only │ -└────────────────────┬─────────────────────┘ - │ Codex-compatible UI protocol -┌────────────────────▼─────────────────────┐ + │ DSH public APIs/events + ▼ +┌──────────────────────────────────────────┐ │ DSHX presentation adapter │ -│ Cordis plugin over official dsh-base │ -│ translation only │ +│ - Codex protocol projection only │ +└────────────────────┬─────────────────────┘ + │ JSONL / child stdio + ▼ +┌──────────────────────────────────────────┐ +│ dshx-ipc-bridge │ +│ - pinned Codex workspace binary │ +│ - no agent/session/runtime state │ └────────────────────┬─────────────────────┘ -═════════════════════╪══════════════════════ ownership boundary - │ official DSH public APIs/events -┌────────────────────▼─────────────────────┐ -│ official DeepSeek Harness │ -│ agents / sessions / llm / tools / etc. │ + │ WebSocket framing over + │ private local UDS only + ▼ +┌──────────────────────────────────────────┐ +│ pinned Codex TUI thin fork │ +│ presentation/input only │ └──────────────────────────────────────────┘ ``` -## What the adapter is allowed to do +The bridge exists because the TUI is Rust and the official DSH composition is Node/TypeScript. It reuses Codex's own cross-platform `codex_uds` implementation rather than introducing unsafe FFI or a second runtime. + +## Why WebSocket framing is still present + +The pinned Codex app-server client speaks WebSocket frames for its remote transport abstraction, including its Unix-socket endpoint. DSHX keeps that framing but changes the production carrier: + +- **forbidden in production:** `ws://127.0.0.1:*`, TCP listeners, bearer-token loopback transport, user-facing Codex remote mode; +- **production:** a private `unix://.../app.sock` endpoint implemented by `codex_uds`, with the bridge relaying JSON messages to Node over child stdio. + +Therefore production does not depend on Codex's experimental TCP WebSocket remote mode, while the TUI can continue using the upstream protocol machinery with a minimal thin-fork patch. -- Create/resume Agents through official `ctx.agents` APIs. -- Forward follow-up, steering and cancellation to the official Agent object. +## Launcher lifecycle + +`dshx` performs the following sequence: + +1. Resolve and validate the packaged `dshx-tui` and `dshx-ipc-bridge` binaries. +2. Create an isolated presentation-only `CODEX_HOME` under `~/.dshx/codex-tui` (or the development override). +3. Boot the supported official DSH composition in Node. +4. Create a private temporary socket directory. +5. Spawn `dshx-ipc-bridge`, wait for its readiness control message, and expose the resulting `unix://` endpoint to the TUI. +6. Spawn the pinned Codex TUI with `DSHX_APP_SERVER_ENDPOINT` set to that local endpoint. +7. Relay app-server JSON between the TUI and the DSH presentation adapter while DSH remains authoritative for all runtime state. +8. On TUI exit or process shutdown, close presentation state, dispose DSH, terminate the bridge and remove the temporary socket directory. + +Ctrl+C is intentionally not intercepted by the Node launcher because it is an in-TUI interaction used to interrupt/steer the active DSH turn. Process `SIGTERM` is propagated for lifecycle shutdown. + +## What the adapter may do + +- Create/resume Agents through official DSH services. +- Forward follow-up, steering and cancellation to official Agent/subagent control APIs. - Subscribe to public Agent/Session events. - Read official session persistence/list/inspection surfaces. - Project DSH events into Codex Thread/Turn/Item UI events. - Keep disposable UI correlation state such as `DSH turn number → current Codex turn id`. +- Translate a user decision from an upstream Codex picker into the corresponding official DSH approval/question API call without weakening it. -## What the adapter is forbidden to do +## What the adapter must not do - Implement an agent loop. -- Call providers directly instead of DSH. -- Execute tools instead of DSH. -- Store a second durable transcript. -- Repair sessions or implement crash recovery. +- Call model providers directly instead of DSH. +- Execute tools or shell commands outside DSH. +- Store a second durable transcript or session database. +- Repair/replay sessions with DSHX-owned persistence logic. - Decide sandbox/approval policy. -- Reimplement skills/subagents/jobs/plugins. -- Persist an independent model selection when DSH already owns selection. - -## Transport phases +- Reimplement compaction, fork, skills, subagents, jobs or plugins. +- Persist an independent model choice when DSH already owns selection. -### M0 development +The test suite contains ownership guards for these boundaries. Missing official services are compatibility failures, not invitations to add shadow implementations. -The installed Codex CLI may connect to a local WebSocket compatibility stub via `codex --remote`. This exists solely to prove protocol/TUI compatibility. +## Development-only transport -### M1 development +The historical deterministic protocol PoC and TCP/WebSocket stub remain useful test fixtures for protocol work, but they are not copied into the installable production package. The `ws` dependency is development-only. -The deterministic stub is replaced behind the same protocol surface by a Cordis plugin using official DSH APIs. WebSocket may still be used locally for development observation if useful. +The real TUI smoke test uses `bin/dshx-stub-local.mjs`, which drives the deterministic fixture through the same packaged local-IPC bridge as production. -### Production +## Diagnostics -Experimental Codex remote WebSocket mode is removed from the user launch path. The pinned thin-fork TUI and DSHX adapter use a local supported transport hidden behind `dshx`, preferably stdio or in-process IPC depending on the final Rust/Node process boundary. +`dshx doctor` validates: -The production contract remains: +- supported Node runtime; +- packaged pinned Codex TUI; +- packaged `dshx-ipc-bridge`; +- a real UDS + WebSocket `ping → pong` self-test using Codex's cross-platform UDS implementation; +- official DSH bundle composition; +- isolated DSHX presentation home. -```bash -cd -dshx -``` - -## Process boundary - -Codex TUI is Rust; DSH is TypeScript/Node. DSHX should not create an unsafe FFI dependency merely to force both into one process. - -The preferred design is therefore: - -```text -Rust TUI process - ⇅ structured local protocol over stdio/local IPC -Node DSH composition + DSHX adapter plugin -``` +A successful bridge self-test proves the local transport data plane on the current OS; it is stronger than merely checking that the binary exists. -`dshx` supervises both lifetimes and propagates terminal interrupt/shutdown correctly. The protocol carries presentation events; it does not own runtime state. +## Compatibility and upstream sync -## DSH compatibility principle +DSH and Codex are both moving upstreams. The repository pins source/package versions and keeps the Codex divergence as an ordered patch queue. Compatibility is defined by public capabilities actually exposed by the supported DSH line. -DSH is a fast-moving upstream. Compatibility is determined by the public capabilities actually exposed by the pinned/supported DSH version. DSHX may ship a narrow compatibility layer for API shape changes, but it must not preserve old behavior by carrying a forked copy of DSH internals. +If a DSH update removes a required public seam, that version is unsupported until DSHX can adapt through another official seam or DSH restores one. DSHX must not preserve compatibility by copying DSH internals. -If an upstream breaking change removes a needed public seam, that DSH version is unsupported until DSHX can adapt through another official seam or DSH restores an appropriate interface. +If a Codex update breaks a patch or app-server shape, the pin is advanced only after the patch queue, protocol tests, PTY smoke, packaging checks and UX parity gate are revalidated. diff --git a/docs/UX-PARITY.md b/docs/UX-PARITY.md index 3ac144e7..d8d39a00 100644 --- a/docs/UX-PARITY.md +++ b/docs/UX-PARITY.md @@ -1,49 +1,76 @@ -# Codex UX Parity Matrix +# Codex UX parity matrix -The target is behavioral parity with the pinned Codex TUI, not a screenshot-inspired imitation. +The target is **behavioral parity with the pinned Codex TUI**, not a screenshot-inspired imitation. DSHX therefore keeps upstream Codex widgets/input handling wherever possible and changes only backend ownership, product data/branding, and unsupported-capability visibility. + +Status below means implementation/evidence coverage, not that the final release candidate has passed manual acceptance. ## Acceptance matrix -| Surface | Target | -|---|---| -| Welcome screen | same structure, DSH branding/data only | -| Composer | same multiline/editing behavior | -| Slash completion | same interaction pattern | -| Help/shortcuts | same keyboard model where applicable | -| Model picker | same presentation; data from DSH | -| Permission picker | same presentation; policy from DSH | -| Tool cells | same renderer classes where mappings exist | -| Shell cells | same streaming/collapse behavior | -| File diffs | same review rendering | -| Approval | same interaction; decision returned to DSH | -| Ask user | same picker/freeform experience | -| Plan | same presentation mapped from DSH plan state | -| Reasoning | same cell behavior where DSH exposes it | -| Steering | same active-turn input semantics | -| Interrupt | same Ctrl+C expectation | -| Resume | same picker behavior; sessions from DSH | -| Status/footer | same structure; DSH metrics/data | -| Scroll/mouse | same behavior | -| Resize | no corruption or state loss | -| Windows Terminal | first-class release gate | -| CJK/IME | first-class release gate | +| Surface | Target | Current implementation evidence | +|---|---|---| +| Welcome screen | same structure, DSH branding/data only | Codex TUI thin fork + branding patch; PTY smoke asserts DSH surface | +| Composer | same multiline/editing behavior | inherited unchanged from pinned Codex TUI | +| Slash completion | same interaction pattern | inherited Codex UI; backend-incompatible commands hidden by narrow capability patch | +| Help/shortcuts | same keyboard model where applicable | inherited from pinned Codex TUI | +| Model picker | same presentation; data from DSH | official DSH model/provider directory + settings tests | +| Permission picker | same presentation; policy from DSH | DSH permission preset projection + backend-aware Codex picker patch | +| Tool cells | same renderer classes where mappings exist | DSH tool presentation specialization + tests | +| Shell cells | same streaming/collapse behavior | Codex shell renderer driven by DSH-owned tool execution/events | +| File diffs | same review rendering | Codex diff renderer driven by DSH tool metadata/workspace command path | +| Approval | same one-shot interaction where semantics match | DSH approval provider + fail-closed backend-aware overlay patch | +| Ask user | same picker/freeform experience where semantics match | `ctx.userQuestions` provider → Codex request-user-input overlay; multi-select remains explicit unsupported gap | +| Plan | same presentation mapped from DSH plan state | `todo/write` + DSH plan mode projection/tests | +| Reasoning | same cell behavior where DSH exposes it | DSH reasoning chunks/messages → Codex reasoning item/delta | +| Steering | same active-turn input semantics | Codex active composer → `Agent.steer(...)` through DSH controller | +| Interrupt | same Ctrl+C expectation | launcher preserves Ctrl+C for TUI; TUI interrupt delegates to DSH Agent/subagent authority | +| Resume | same picker behavior; sessions from DSH | Codex resume UI + DSH persistence list/inspect/resume/history hydration | +| Status/footer | same structure; DSH metrics/data | persisted/current model + token/context projections where exposed | +| Scroll/mouse | same behavior | inherited from pinned Codex TUI | +| Resize | no corruption or state loss | inherited from pinned Codex TUI; manual RC verification required | +| Windows Terminal | first-class release gate | native Windows build/package + real local-IPC doctor; visual/input acceptance still manual | +| CJK/IME | first-class release gate | Codex input code is inherited; exact RC artifact still requires manual Chinese IME acceptance | + +## Backend command visibility + +A Codex slash command is visible only when either: + +1. it is a presentation/local-TUI operation, or +2. DSHX has a faithful mapping to an official DSH public capability. + +Codex runtime-owned surfaces such as unsupported app/plugin/hook/memory/personality/review modes are hidden rather than falling through to Codex core. `/diff` remains visible because its execution path is DSH-owned. Skills remain visible because `skills/list` is backed by the official DSH registry surface. ## Allowed deliberate differences - Product branding. -- Model/provider names. +- Model/provider names and DSH-owned configuration vocabulary. - Backend-specific capabilities that have no Codex equivalent. -- DSH-specific commands placed under an isolated namespace. - Explicit unsupported messages when semantic translation would be unsafe or misleading. +- Hidden Codex runtime commands when the supported DSH line has no faithful public equivalent. +- DSH multi-select user questions until the pinned Codex UI can represent them without semantic loss. ## Not acceptable - Reimplementing a Codex widget merely because it looks simple. -- Maintaining a separate session history for UI convenience. -- Downgrading an approval requirement during protocol translation. +- Maintaining a separate durable session history for UI convenience. +- Downgrading an approval or subagent authority requirement during protocol translation. - Silent model changes on resume. -- Calling a generic readline prompt a TUI. +- Routing a hidden/unsupported command back into Codex's agent runtime. +- Replacing the inherited Codex composer with a generic readline prompt. +- Calling automated transport tests proof of Windows Terminal/CJK/IME parity. + +## Automated parity protection + +The repository protects the parts that can be automated: + +- pinned Codex patch application/build; +- source invariants keeping DSHX on the external backend path; +- DSH adapter protocol/unit tests; +- ownership/fail-closed tests; +- real pinned-TUI PTY lifecycle smoke over production local IPC; +- native platform package + `dshx doctor`, including a real local-IPC data-plane self-test. + +## Manual release comparison -## Release comparison +For every 1.0 release candidate, install the **generated release artifact** and compare it side by side with the pinned upstream Codex version using the same Windows Terminal dimensions/theme. At minimum verify composer editing, Chinese IME composition/commit/cancel, wide-glyph alignment, slash completion, pickers, overlays, tool/shell/diff cells, reasoning/plan, steering/Ctrl+C, resume, scroll/mouse, resize and footer rendering. -Every release candidate should be tested side by side against the pinned upstream Codex version on the same terminal dimensions and theme. Differences are either fixed, documented as deliberate, or block release. +Differences are either fixed, documented above as deliberate, or block release. The promotion procedure lives in `RELEASE-READINESS.md`. diff --git a/package.json b/package.json index 39feee8e..4e77adf5 100644 --- a/package.json +++ b/package.json @@ -23,7 +23,9 @@ "@deepseek-ai/dsh-host-apiproxy": "0.1.0-rc.8", "@deepseek-ai/dsh-launch-environment": "0.1.0-rc.8", "@deepseek-ai/dsh-llm": "0.1.0-rc.8", - "@deepseek-ai/dsh-session": "0.1.0-rc.8", + "@deepseek-ai/dsh-session": "0.1.0-rc.8" + }, + "devDependencies": { "ws": "^8.18.0" } } diff --git a/scripts/build-codex-tui.ps1 b/scripts/build-codex-tui.ps1 index d4d4ead4..ab5b9677 100644 --- a/scripts/build-codex-tui.ps1 +++ b/scripts/build-codex-tui.ps1 @@ -9,7 +9,12 @@ $OutDir = if ($env:DSHX_TUI_OUT_DIR) { $env:DSHX_TUI_OUT_DIR } else { Join-Path New-Item -ItemType Directory -Force $OutDir | Out-Null $env:CARGO_TARGET_DIR = $TargetDir cargo build --manifest-path (Join-Path $CodeXDir 'codex-rs\Cargo.toml') --locked --release -p codex-tui --bin codex-tui -$Source = Join-Path $TargetDir 'release\codex-tui.exe' -$Destination = Join-Path $OutDir 'dshx-tui.exe' -Copy-Item -Force $Source $Destination -Write-Host $Destination +cargo build --manifest-path (Join-Path $CodeXDir 'codex-rs\Cargo.toml') --locked --release -p codex-stdio-to-uds --bin dshx-ipc-bridge +$TuiSource = Join-Path $TargetDir 'release\codex-tui.exe' +$TuiDestination = Join-Path $OutDir 'dshx-tui.exe' +$BridgeSource = Join-Path $TargetDir 'release\dshx-ipc-bridge.exe' +$BridgeDestination = Join-Path $OutDir 'dshx-ipc-bridge.exe' +Copy-Item -Force $TuiSource $TuiDestination +Copy-Item -Force $BridgeSource $BridgeDestination +Write-Host $TuiDestination +Write-Host $BridgeDestination diff --git a/scripts/build-codex-tui.sh b/scripts/build-codex-tui.sh index e573a18d..3873653e 100644 --- a/scripts/build-codex-tui.sh +++ b/scripts/build-codex-tui.sh @@ -11,7 +11,11 @@ mkdir -p "$OUT_DIR" CARGO_TARGET_DIR="$TARGET_DIR" cargo build \ --manifest-path "$CODEX_DIR/codex-rs/Cargo.toml" \ --locked --release -p codex-tui --bin codex-tui +CARGO_TARGET_DIR="$TARGET_DIR" cargo build \ + --manifest-path "$CODEX_DIR/codex-rs/Cargo.toml" \ + --locked --release -p codex-stdio-to-uds --bin dshx-ipc-bridge cp "$TARGET_DIR/release/codex-tui" "$OUT_DIR/dshx-tui" -chmod +x "$OUT_DIR/dshx-tui" -printf '%s\n' "$OUT_DIR/dshx-tui" +cp "$TARGET_DIR/release/dshx-ipc-bridge" "$OUT_DIR/dshx-ipc-bridge" +chmod +x "$OUT_DIR/dshx-tui" "$OUT_DIR/dshx-ipc-bridge" +printf '%s\n' "$OUT_DIR/dshx-tui" "$OUT_DIR/dshx-ipc-bridge" diff --git a/scripts/package-platform.mjs b/scripts/package-platform.mjs index 77b4d882..37a63f4b 100644 --- a/scripts/package-platform.mjs +++ b/scripts/package-platform.mjs @@ -1,6 +1,5 @@ #!/usr/bin/env node import fs from 'node:fs'; -import os from 'node:os'; import path from 'node:path'; import process from 'node:process'; import { execFileSync } from 'node:child_process'; @@ -11,11 +10,16 @@ const rootPackage = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), const version = process.env.DSHX_VERSION || rootPackage.version; const platform = process.platform; const arch = process.arch; -const exe = platform === 'win32' ? 'dshx-tui.exe' : 'dshx-tui'; -const tuiSource = path.join(root, 'dist', 'bin', exe); +const tuiExe = platform === 'win32' ? 'dshx-tui.exe' : 'dshx-tui'; +const bridgeExe = platform === 'win32' ? 'dshx-ipc-bridge.exe' : 'dshx-ipc-bridge'; +const tuiSource = path.join(root, 'dist', 'bin', tuiExe); +const bridgeSource = path.join(root, 'dist', 'bin', bridgeExe); if (!fs.existsSync(tuiSource)) { throw new Error(`Missing built TUI at ${tuiSource}; build the pinned Codex TUI first`); } +if (!fs.existsSync(bridgeSource)) { + throw new Error(`Missing built IPC bridge at ${bridgeSource}; build the pinned Codex transport bridge first`); +} const releaseRoot = path.join(root, '.release'); const stage = path.join(releaseRoot, `dshx-${platform}-${arch}`); @@ -32,18 +36,25 @@ function copy(relative) { fs.cpSync(from, to, { recursive: true }); } -// Ship only DSHX presentation code. DeepSeek Harness profile/plugin state lives -// under $DSH_HOME and remains owned by the official @deepseek-ai/dsh runtime. +// Ship only the production presentation closure. Early TCP/WebSocket protocol +// stubs remain source-tree development fixtures and are deliberately excluded +// from installable artifacts. copy('bin/dshx.mjs'); -copy('src'); +copy('src/dsh'); +copy('src/protocol'); +copy('config'); copy('NOTICE'); copy('upstream/CODEX_COMMIT'); copy('upstream/DSH_COMMIT'); copy('upstream/patches/codex'); fs.mkdirSync(path.join(stage, 'dist', 'bin'), { recursive: true }); -fs.copyFileSync(tuiSource, path.join(stage, 'dist', 'bin', exe)); -if (platform !== 'win32') fs.chmodSync(path.join(stage, 'dist', 'bin', exe), 0o755); +fs.copyFileSync(tuiSource, path.join(stage, 'dist', 'bin', tuiExe)); +fs.copyFileSync(bridgeSource, path.join(stage, 'dist', 'bin', bridgeExe)); +if (platform !== 'win32') { + fs.chmodSync(path.join(stage, 'dist', 'bin', tuiExe), 0o755); + fs.chmodSync(path.join(stage, 'dist', 'bin', bridgeExe), 0o755); +} const codexLicense = path.join(root, '.upstream', 'codex', 'LICENSE'); if (!fs.existsSync(codexLicense)) { @@ -63,12 +74,22 @@ const packageJson = { bin: { dshx: './bin/dshx.mjs' }, dependencies: rootPackage.dependencies, repository: { type: 'git', url: 'https://github.com/code-new-bie/dsh-codex.git' }, - files: ['bin', 'src', 'dist/bin', 'upstream', 'LICENSE', 'NOTICE', 'npm-shrinkwrap.json'] + files: [ + 'bin/dshx.mjs', + 'src/dsh', + 'src/protocol', + 'config', + 'dist/bin', + 'upstream', + 'LICENSE', + 'NOTICE', + 'npm-shrinkwrap.json' + ] }; fs.writeFileSync(path.join(stage, 'package.json'), `${JSON.stringify(packageJson, null, 2)}\n`); -// Static release-closure check before npm pack. Every local relative import in -// shipped JavaScript must resolve inside the staged package. +// Every relative import in the release closure must resolve inside the staged +// package. This prevents a source-tree-only helper from leaking into 1.0. for (const file of walkJs(stage)) { const source = fs.readFileSync(file, 'utf8'); for (const match of source.matchAll(/(?:from\s+|import\s*)['\"](\.{1,2}\/[^'\"]+)['\"]/g)) { @@ -77,11 +98,6 @@ for (const file of walkJs(stage)) { } } -// A published DSH release is version-synchronized across @deepseek-ai/dsh-*. -// Generate a shrinkwrap from the exact registry closure, then reject packaging -// if npm selected any different DSH release through a peer/caret range. This -// makes future upstream releases fail loudly until DSHX deliberately updates -// its pin, rather than silently shipping a mixed Harness runtime. const npm = process.platform === 'win32' ? 'npm.cmd' : 'npm'; execFileSync(npm, [ 'install', @@ -112,6 +128,8 @@ if (path.resolve(sourceTarball) !== path.resolve(targetTarball)) { fs.renameSync(sourceTarball, targetTarball); } +// Keep metadata deterministic for a given source/version/platform. Wall clock +// and builder hostname belong in CI provenance, not the distributable sidecar. const metadata = { version, platform, @@ -120,9 +138,8 @@ const metadata = { dshVersion: rootPackage.dependencies['@deepseek-ai/dsh'], codexCommit: fs.readFileSync(path.join(root, 'upstream', 'CODEX_COMMIT'), 'utf8').trim(), dshCommit: fs.readFileSync(path.join(root, 'upstream', 'DSH_COMMIT'), 'utf8').trim(), - tarball: path.basename(targetTarball), - builtAt: new Date().toISOString(), - host: os.hostname() + transport: 'local-uds-via-stdio-bridge', + tarball: path.basename(targetTarball) }; fs.writeFileSync(`${targetTarball}.json`, `${JSON.stringify(metadata, null, 2)}\n`); process.stdout.write(`${targetTarball}\n`); diff --git a/src/dsh/local-server.mjs b/src/dsh/local-server.mjs index 648e61f2..e4ccd811 100644 --- a/src/dsh/local-server.mjs +++ b/src/dsh/local-server.mjs @@ -1,23 +1,18 @@ -import crypto from 'node:crypto'; -import { WebSocket, WebSocketServer } from 'ws'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { spawn } from 'node:child_process'; +import { createInterface } from 'node:readline'; +import { fileURLToPath } from 'node:url'; import { DshxReleaseAdapter } from './release-adapter.mjs'; import { bootDshxRuntime } from './runtime-boot.mjs'; -const DEFAULT_HOST = '127.0.0.1'; +const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); +const READY = 'ready'; -function tokenEquals(actual, expected) { - const left = Buffer.from(actual ?? '', 'utf8'); - const right = Buffer.from(expected, 'utf8'); - return left.length === right.length && crypto.timingSafeEqual(left, right); -} - -function authorized(request, token) { - const header = request.headers.authorization; - return typeof header === 'string' && tokenEquals(header, `Bearer ${token}`); -} - -function sendJson(socket, message) { - if (socket.readyState === WebSocket.OPEN) socket.send(JSON.stringify(message)); +function packagedBridgeBinary() { + const name = process.platform === 'win32' ? 'dshx-ipc-bridge.exe' : 'dshx-ipc-bridge'; + return process.env.DSHX_IPC_BRIDGE_BIN || path.join(ROOT, 'dist', 'bin', name); } function parseError(error) { @@ -30,82 +25,157 @@ function parseError(error) { }; } +function createSocketDirectory(root = os.tmpdir()) { + const directory = fs.mkdtempSync(path.join(root, 'dshx-')); + if (process.platform !== 'win32') fs.chmodSync(directory, 0o700); + return directory; +} + +function waitForBridgeReady(lines, child, stderrText) { + return new Promise((resolve, reject) => { + const fail = (error) => { + cleanup(); + reject(error); + }; + const onLine = (line) => { + let message; + try { + message = JSON.parse(line); + } catch (error) { + fail(new Error(`DSHX IPC bridge emitted invalid readiness JSON: ${line}`, { cause: error })); + return; + } + if (message?.dshxBridge !== READY) { + fail(new Error(`DSHX IPC bridge emitted unexpected readiness message: ${line}`)); + return; + } + cleanup(); + resolve(message); + }; + const onError = (error) => fail(new Error(`failed to start DSHX IPC bridge: ${error.message}`, { cause: error })); + const onExit = (code, signal) => { + const detail = stderrText().trim(); + fail(new Error(`DSHX IPC bridge exited before ready (${signal ?? code ?? 'unknown'})${detail ? `: ${detail}` : ''}`)); + }; + const cleanup = () => { + lines.off('line', onLine); + child.off('error', onError); + child.off('exit', onExit); + }; + lines.once('line', onLine); + child.once('error', onError); + child.once('exit', onExit); + }); +} + +function waitForExit(child, timeoutMs = 750) { + if (child.exitCode !== null || child.signalCode !== null) return Promise.resolve(); + return new Promise((resolve) => { + let done = false; + const finish = () => { + if (done) return; + done = true; + clearTimeout(timer); + child.off('exit', finish); + resolve(); + }; + const timer = setTimeout(() => { + if (child.exitCode === null && child.signalCode === null) child.kill('SIGTERM'); + finish(); + }, timeoutMs); + child.once('exit', finish); + }); +} + /** - * Authenticated single-client loopback transport for the pinned Codex TUI. - * The bearer token is process-ephemeral and accepted only on 127.0.0.1; DSH - * itself remains in-process behind the official boot composition. + * Starts the production-local DSHX presentation transport. + * + * The official DeepSeek Harness runtime remains in-process in Node. The pinned + * Codex TUI connects only to a private cross-platform Unix-domain socket owned + * by the tiny packaged Rust bridge. JSON-RPC is relayed between that bridge and + * this adapter over child stdio; no TCP listener or Codex remote WebSocket mode + * exists in the production launch path. */ export async function startDshxLocalServer({ - host = DEFAULT_HOST, - port = 0, cwd = process.cwd(), home, version = '0.1.0-dev', - token = crypto.randomBytes(32).toString('base64url'), runtime, bootRuntime = bootDshxRuntime, Adapter = DshxReleaseAdapter, - log = () => {} + log = () => {}, + bridgeCommand = packagedBridgeBinary(), + bridgeArgs, + spawnBridge = spawn, + socketRoot } = {}) { - if (host !== '127.0.0.1') { - throw new Error(`DSHX local app-server must bind 127.0.0.1, got ${JSON.stringify(host)}`); - } - if (typeof token !== 'string' || token.length < 32) { - throw new Error('DSHX local app-server bearer token must be at least 32 characters'); - } - const ctx = runtime ?? await bootRuntime({ cwd }); - const server = new WebSocketServer({ - host, - port, - maxPayload: 128 << 20, - verifyClient(info, done) { - if (!authorized(info.req, token)) { - done(false, 401, 'Unauthorized'); - return; - } - done(true); - } - }); - - let activeSocket; + const socketDirectory = createSocketDirectory(socketRoot); + const socketPath = path.join(socketDirectory, 'app.sock'); + const args = bridgeArgs ?? [socketPath]; + let bridge; + let lines; let adapter; let closing = false; + let stderr = ''; - server.on('connection', (socket, request) => { - if (activeSocket && activeSocket.readyState !== WebSocket.CLOSED) { - socket.close(1008, 'DSHX accepts one TUI client'); - return; - } - activeSocket = socket; - adapter = new Adapter({ - ctx, + try { + bridge = spawnBridge(bridgeCommand, args, { cwd, - home, - version, - send: (message) => sendJson(socket, message), - diagnostics: log + stdio: ['pipe', 'pipe', 'pipe'], + windowsHide: true }); - log(`Codex TUI connected from ${request.socket.remoteAddress ?? 'unknown'}`); + bridge.stderr?.on('data', (chunk) => { + const text = chunk.toString('utf8'); + stderr = `${stderr}${text}`.slice(-8192); + if (process.env.DSHX_DEBUG === '1') log(`ipc bridge: ${text.trimEnd()}`); + }); + lines = createInterface({ input: bridge.stdout, crlfDelay: Infinity }); + await waitForBridgeReady(lines, bridge, () => stderr); + } catch (error) { + lines?.close(); + bridge?.kill?.('SIGTERM'); + fs.rmSync(socketDirectory, { recursive: true, force: true }); + await ctx.dispose?.(); + throw error; + } + + const send = (message) => { + if (!bridge.stdin?.writable) throw new Error('DSHX IPC bridge stdin is not writable'); + bridge.stdin.write(`${JSON.stringify(message)}\n`); + }; - socket.on('message', async (raw) => { + adapter = new Adapter({ + ctx, + cwd, + home, + version, + send, + diagnostics: log + }); + + lines.on('line', (line) => { + void (async () => { let message; try { - message = JSON.parse(raw.toString('utf8')); + message = JSON.parse(line); } catch (error) { - sendJson(socket, parseError(error)); + send(parseError(error)); + return; + } + if (message?.dshxBridge) { + log(`IPC bridge control message: ${message.dshxBridge}`); return; } - try { const handled = await adapter.handle(message); if (!handled) return; - sendJson(socket, handled.response); + send(handled.response); await handled.afterResponse?.(); } catch (error) { log(`request handling failed: ${error instanceof Error ? error.stack ?? error.message : String(error)}`); if (message?.id !== undefined) { - sendJson(socket, { + send({ id: message.id, error: { code: -32603, @@ -114,50 +184,32 @@ export async function startDshxLocalServer({ }); } } - }); - - socket.on('close', () => { - const closedAdapter = adapter; - adapter = undefined; - activeSocket = undefined; - void closedAdapter?.close().catch((error) => log(`adapter close failed: ${error instanceof Error ? error.message : error}`)); - }); - socket.on('error', (error) => log(`socket error: ${error.message}`)); + })(); }); - await new Promise((resolve, reject) => { - if (server.address()) return resolve(); - server.once('listening', resolve); - server.once('error', reject); + bridge.on('error', (error) => log(`IPC bridge error: ${error.message}`)); + bridge.on('exit', (code, signal) => { + if (!closing) log(`IPC bridge exited unexpectedly (${signal ?? code ?? 'unknown'})`); }); - const address = server.address(); - if (!address || typeof address === 'string') { - throw new Error('DSHX local app-server did not bind a TCP address'); - } const close = async () => { if (closing) return; closing = true; - if (activeSocket && activeSocket.readyState !== WebSocket.CLOSED) { - await new Promise((resolve) => { - const timer = setTimeout(() => { - activeSocket?.terminate(); - resolve(); - }, 500); - activeSocket.once('close', () => { clearTimeout(timer); resolve(); }); - activeSocket.close(1001, 'DSHX shutting down'); - }); + lines?.close(); + try { + await adapter?.close?.(); + } finally { + if (bridge.stdin?.writable) bridge.stdin.end(); + await waitForExit(bridge); + if (bridge.exitCode === null && bridge.signalCode === null) bridge.kill('SIGKILL'); + await ctx.dispose?.(); + fs.rmSync(socketDirectory, { recursive: true, force: true }); } - await adapter?.close?.(); - await new Promise((resolve, reject) => server.close((error) => error ? reject(error) : resolve())); - await ctx.dispose?.(); }; return { - host, - port: address.port, - url: `ws://${host}:${address.port}`, - token, + path: socketPath, + url: `unix://${socketPath}`, context: ctx, close }; diff --git a/test/dsh-local-server.test.mjs b/test/dsh-local-server.test.mjs index 58bc0327..cd5b8b34 100644 --- a/test/dsh-local-server.test.mjs +++ b/test/dsh-local-server.test.mjs @@ -1,14 +1,62 @@ import assert from 'node:assert/strict'; +import { EventEmitter } from 'node:events'; +import { PassThrough } from 'node:stream'; import test from 'node:test'; -import { WebSocket } from 'ws'; import { startDshxLocalServer } from '../src/dsh/local-server.mjs'; -const TOKEN = '0123456789abcdefghijklmnopqrstuvwxyz-DSHX-token'; +class FakeChild extends EventEmitter { + constructor() { + super(); + this.stdin = new PassThrough(); + this.stdout = new PassThrough(); + this.stderr = new PassThrough(); + this.exitCode = null; + this.signalCode = null; + this.killed = false; + this.stdin.once('finish', () => this.exit(0, null)); + } + exit(code, signal) { + if (this.exitCode !== null || this.signalCode !== null) return; + this.exitCode = code; + this.signalCode = signal; + queueMicrotask(() => this.emit('exit', code, signal)); + } + kill(signal = 'SIGTERM') { + this.killed = true; + this.exit(null, signal); + return true; + } +} + +function fakeBridgeSpawner() { + let child; + return { + spawn() { + child = new FakeChild(); + queueMicrotask(() => child.stdout.write('{"dshxBridge":"ready"}\n')); + return child; + }, + child() { + return child; + } + }; +} -function once(socket, event) { +function readJsonLine(stream) { return new Promise((resolve, reject) => { - socket.once(event, resolve); - socket.once('error', reject); + let buffer = ''; + const onData = (chunk) => { + buffer += chunk.toString('utf8'); + const newline = buffer.indexOf('\n'); + if (newline < 0) return; + stream.off('data', onData); + try { + resolve(JSON.parse(buffer.slice(0, newline))); + } catch (error) { + reject(error); + } + }; + stream.on('data', onData); }); } @@ -25,56 +73,47 @@ class FakeAdapter { } } -test('local server rejects unauthenticated WebSocket clients', async () => { - const runtime = { async dispose() {} }; - const server = await startDshxLocalServer({ runtime, token: TOKEN, Adapter: FakeAdapter }); - try { - const socket = new WebSocket(server.url); - const status = await new Promise((resolve) => { - socket.once('unexpected-response', (_request, response) => resolve(response.statusCode)); - socket.once('error', () => {}); - }); - assert.equal(status, 401); - socket.terminate(); - } finally { - await server.close(); - } -}); - -test('local server accepts bearer-authenticated RPC and disposes presentation/runtime state', async () => { +test('local production transport exposes only a unix endpoint and relays RPC over bridge stdio', async () => { let disposed = 0; FakeAdapter.closes = 0; + const fake = fakeBridgeSpawner(); const runtime = { async dispose() { disposed += 1; } }; - const server = await startDshxLocalServer({ runtime, token: TOKEN, Adapter: FakeAdapter }); - const socket = new WebSocket(server.url, { headers: { Authorization: `Bearer ${TOKEN}` } }); - await once(socket, 'open'); - - const response = new Promise((resolve, reject) => { - socket.once('message', (data) => { - try { resolve(JSON.parse(data.toString('utf8'))); } catch (error) { reject(error); } - }); + const server = await startDshxLocalServer({ + runtime, + Adapter: FakeAdapter, + bridgeCommand: 'fake-dshx-ipc-bridge', + spawnBridge: (...args) => fake.spawn(...args) }); - socket.send(JSON.stringify({ id: 7, method: 'initialize', params: {} })); - assert.deepEqual(await response, { id: 7, result: { echoed: 'initialize' } }); + try { + assert.match(server.url, /^unix:\/\//); + assert.equal('token' in server, false); - socket.close(); - await once(socket, 'close'); - await server.close(); + const response = readJsonLine(fake.child().stdin); + fake.child().stdout.write(`${JSON.stringify({ id: 7, method: 'initialize', params: {} })}\n`); + assert.deepEqual(await response, { id: 7, result: { echoed: 'initialize' } }); + } finally { + await server.close(); + } assert.equal(FakeAdapter.closes, 1); assert.equal(disposed, 1); }); -test('local server permits only one active TUI client', async () => { +test('local production transport returns JSON-RPC parse errors through bridge stdio', async () => { + const fake = fakeBridgeSpawner(); const runtime = { async dispose() {} }; - const server = await startDshxLocalServer({ runtime, token: TOKEN, Adapter: FakeAdapter }); - const headers = { Authorization: `Bearer ${TOKEN}` }; - const first = new WebSocket(server.url, { headers }); - await once(first, 'open'); - const second = new WebSocket(server.url, { headers }); - await once(second, 'open'); - const [code] = await once(second, 'close'); - assert.equal(code, 1008); - first.close(); - await once(first, 'close'); - await server.close(); + const server = await startDshxLocalServer({ + runtime, + Adapter: FakeAdapter, + bridgeCommand: 'fake-dshx-ipc-bridge', + spawnBridge: (...args) => fake.spawn(...args) + }); + try { + const response = readJsonLine(fake.child().stdin); + fake.child().stdout.write('{not-json}\n'); + const parsed = await response; + assert.equal(parsed.id, null); + assert.equal(parsed.error.code, -32700); + } finally { + await server.close(); + } }); diff --git a/test/tui_smoke.py b/test/tui_smoke.py index 34239ea9..ad478a3b 100644 --- a/test/tui_smoke.py +++ b/test/tui_smoke.py @@ -7,14 +7,10 @@ import pexpect ROOT = pathlib.Path(__file__).resolve().parents[1] -TOKEN = "dshx-pty-smoke-token-0123456789abcdefghijklmnopqrstuvwxyz" - -server_env = os.environ.copy() -server_env["DSHX_STUB_TOKEN"] = TOKEN server = subprocess.Popen( - ["node", "bin/dshx-stub-server.mjs"], + ["node", "bin/dshx-stub-local.mjs"], cwd=ROOT, - env=server_env, + env=os.environ.copy(), stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, @@ -22,9 +18,9 @@ try: endpoint = server.stdout.readline().strip() - if not endpoint.startswith("ws://127.0.0.1:"): + if not endpoint.startswith("unix://"): stderr = server.stderr.read() - raise RuntimeError(f"stub did not produce a loopback endpoint: {endpoint!r}\n{stderr}") + raise RuntimeError(f"local stub did not produce a unix endpoint: {endpoint!r}\n{stderr}") binary = ROOT / "dist" / "bin" / "dshx-tui" if not binary.exists(): @@ -36,7 +32,6 @@ "TERM": "xterm-256color", "CODEX_HOME": codex_home, "DSHX_APP_SERVER_ENDPOINT": endpoint, - "DSHX_APP_SERVER_TOKEN": TOKEN, }) child = pexpect.spawn( str(binary), @@ -57,7 +52,7 @@ transcript.append(child.before + child.after) except Exception: transcript.append(child.before or "") - raise AssertionError("Pinned DSHX TUI smoke failed. Transcript:\n" + "".join(transcript)) + raise AssertionError("Pinned DSHX TUI local-IPC smoke failed. Transcript:\n" + "".join(transcript)) finally: child.close(force=True) finally: diff --git a/test/tui_smoke_windows.mjs b/test/tui_smoke_windows.mjs index ba51ad1e..b142e258 100644 --- a/test/tui_smoke_windows.mjs +++ b/test/tui_smoke_windows.mjs @@ -3,10 +3,12 @@ import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; import process from 'node:process'; -import * as pty from 'node-pty'; -import { startProtocolStubServer } from '../src/server.mjs'; +import { spawn } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; +import { createInterface } from 'node:readline'; +import pty from 'node-pty'; -const TOKEN = 'dshx-conpty-smoke-token-0123456789abcdefghijklmnopqrstuvwxyz'; +const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); function stripTerminalControl(value) { return value @@ -24,64 +26,109 @@ function stringEnv(extra = {}) { } function waitForOutput(state, needle, timeoutMs = 30_000) { - const started = Date.now(); + if (stripTerminalControl(state.output).includes(needle)) return Promise.resolve(); return new Promise((resolve, reject) => { - const poll = setInterval(() => { - const transcript = stripTerminalControl(state.output); - if (transcript.includes(needle)) { - clearInterval(poll); - resolve(); - return; - } - if (Date.now() - started < timeoutMs) return; - clearInterval(poll); - reject(new Error(`Timed out waiting for ${JSON.stringify(needle)}. Transcript:\n${transcript}`)); - }, 50); + const timer = setTimeout(() => { + dispose.dispose(); + reject(new Error(`Timed out waiting for ${JSON.stringify(needle)}. Transcript:\n${stripTerminalControl(state.output)}`)); + }, timeoutMs); + const dispose = state.term.onData((chunk) => { + state.output += chunk; + if (!stripTerminalControl(state.output).includes(needle)) return; + clearTimeout(timer); + dispose.dispose(); + resolve(); + }); }); } -function sleep(ms) { - return new Promise((resolve) => setTimeout(resolve, ms)); +function firstLine(stream, child, stderrText, timeoutMs = 15_000) { + const lines = createInterface({ input: stream, crlfDelay: Infinity }); + return new Promise((resolve, reject) => { + const timer = setTimeout(() => fail(new Error(`Timed out waiting for local IPC stub: ${stderrText()}`)), timeoutMs); + const cleanup = () => { + clearTimeout(timer); + child.off('error', onError); + child.off('exit', onExit); + }; + const fail = (error) => { + cleanup(); + lines.close(); + reject(error); + }; + const onError = (error) => fail(error); + const onExit = (code, signal) => fail(new Error(`local IPC stub exited before ready (${signal ?? code ?? 'unknown'}): ${stderrText()}`)); + child.once('error', onError); + child.once('exit', onExit); + lines.once('line', (line) => { + cleanup(); + lines.close(); + resolve(line); + }); + }); +} + +async function stopChild(child) { + if (!child || child.exitCode !== null || child.signalCode !== null) return; + const exited = new Promise((resolve) => child.once('exit', resolve)); + child.kill('SIGTERM'); + const timer = new Promise((resolve) => setTimeout(resolve, 5_000, 'timeout')); + if (await Promise.race([exited.then(() => 'exit'), timer]) === 'timeout') { + child.kill('SIGKILL'); + await exited; + } } if (process.platform !== 'win32') { throw new Error('tui_smoke_windows.mjs must run on Windows/ConPTY'); } -const binary = path.join(process.cwd(), 'dist', 'bin', 'dshx-tui.exe'); +const binary = path.join(ROOT, 'dist', 'bin', 'dshx-tui.exe'); +const bridge = path.join(ROOT, 'dist', 'bin', 'dshx-ipc-bridge.exe'); if (!fs.existsSync(binary)) throw new Error(`built DSHX TUI missing: ${binary}`); +if (!fs.existsSync(bridge)) throw new Error(`built DSHX IPC bridge missing: ${bridge}`); const codexHome = fs.mkdtempSync(path.join(os.tmpdir(), 'dshx-conpty-home-')); -const server = await startProtocolStubServer({ token: TOKEN, eventDelayMs: 8 }); +let server; +let serverStderr = ''; let term; -let dataDisposable; try { + server = spawn(process.execPath, [path.join(ROOT, 'bin', 'dshx-stub-local.mjs')], { + cwd: ROOT, + env: stringEnv({ DSHX_IPC_BRIDGE_BIN: bridge }), + stdio: ['ignore', 'pipe', 'pipe'], + windowsHide: true + }); + server.stderr.on('data', (chunk) => { + serverStderr = `${serverStderr}${chunk.toString('utf8')}`.slice(-8192); + }); + const endpoint = await firstLine(server.stdout, server, () => serverStderr); + if (!endpoint.startsWith('unix://')) { + throw new Error(`local IPC stub returned non-local endpoint ${JSON.stringify(endpoint)}: ${serverStderr}`); + } + term = pty.spawn(binary, [], { name: 'xterm-256color', cols: 120, rows: 32, - cwd: process.cwd(), + cwd: ROOT, env: stringEnv({ TERM: 'xterm-256color', CODEX_HOME: codexHome, - DSHX_APP_SERVER_ENDPOINT: server.url, - DSHX_APP_SERVER_TOKEN: TOKEN + DSHX_APP_SERVER_ENDPOINT: endpoint }) }); - const state = { output: '' }; - dataDisposable = term.onData((chunk) => { state.output += chunk; }); + const state = { term, output: '' }; + term.onData((chunk) => { state.output += chunk; }); await waitForOutput(state, 'DeepSeek Harness'); - term.write('smoke from conpty\r'); + const prompt = '你好,DSHX ConPTY'; + term.write(`${prompt}\r`); await waitForOutput(state, 'DSHX protocol stub received:'); - await waitForOutput(state, 'smoke from conpty'); - process.stdout.write('Windows ConPTY DSHX TUI smoke passed\n'); + await waitForOutput(state, prompt); + process.stdout.write('Windows ConPTY DSHX local-IPC + CJK smoke passed\n'); } finally { - dataDisposable?.dispose?.(); try { term?.kill(); } catch {} - // Let the killed TUI close its WebSocket before asking ws.Server.close() to - // wait for all clients; this keeps failures bounded on busy hosted runners. - await sleep(100); - await server.close(); + await stopChild(server); fs.rmSync(codexHome, { recursive: true, force: true }); } diff --git a/upstream/patches/codex/0010-dshx-local-ipc-bridge.patch b/upstream/patches/codex/0010-dshx-local-ipc-bridge.patch new file mode 100644 index 00000000..5a583580 --- /dev/null +++ b/upstream/patches/codex/0010-dshx-local-ipc-bridge.patch @@ -0,0 +1,164 @@ +diff --git a/codex-rs/stdio-to-uds/Cargo.toml b/codex-rs/stdio-to-uds/Cargo.toml +--- a/codex-rs/stdio-to-uds/Cargo.toml ++++ b/codex-rs/stdio-to-uds/Cargo.toml +@@ -7,6 +7,10 @@ + [[bin]] + name = "codex-stdio-to-uds" + path = "src/main.rs" ++ ++[[bin]] ++name = "dshx-ipc-bridge" ++path = "src/dshx_ipc_bridge.rs" + + [lib] + name = "codex_stdio_to_uds" +@@ -20,12 +24,14 @@ + [dependencies] + anyhow = { workspace = true } + codex-uds = { workspace = true } ++futures = { workspace = true } + tokio = { workspace = true, features = [ + "io-std", + "io-util", + "macros", + "rt-multi-thread", + ] } ++tokio-tungstenite = { workspace = true } + + [dev-dependencies] + codex-utils-cargo-bin = { workspace = true } +diff --git a/codex-rs/stdio-to-uds/src/dshx_ipc_bridge.rs b/codex-rs/stdio-to-uds/src/dshx_ipc_bridge.rs +new file mode 100644 +--- /dev/null ++++ b/codex-rs/stdio-to-uds/src/dshx_ipc_bridge.rs +@@ -0,0 +1,101 @@ ++#![deny(clippy::print_stdout)] ++ ++use std::io::ErrorKind; ++use std::path::PathBuf; ++ ++use anyhow::Context; ++use codex_uds::UnixListener; ++use futures::SinkExt; ++use futures::StreamExt; ++use tokio::io::AsyncBufReadExt; ++use tokio::io::AsyncWriteExt; ++use tokio::io::BufReader; ++use tokio_tungstenite::accept_async; ++use tokio_tungstenite::tungstenite::Message; ++ ++struct SocketGuard(PathBuf); ++ ++impl Drop for SocketGuard { ++ fn drop(&mut self) { ++ if let Err(err) = std::fs::remove_file(&self.0) ++ && err.kind() != ErrorKind::NotFound ++ { ++ // Best effort only. The launcher removes the unique parent directory. ++ } ++ } ++} ++ ++async fn write_line(stdout: &mut tokio::io::Stdout, line: &str) -> anyhow::Result<()> { ++ stdout.write_all(line.as_bytes()).await?; ++ stdout.write_all(b"\n").await?; ++ stdout.flush().await?; ++ Ok(()) ++} ++ ++#[tokio::main] ++async fn main() -> anyhow::Result<()> { ++ let mut args = std::env::args_os().skip(1); ++ let Some(first) = args.next() else { ++ anyhow::bail!("usage: dshx-ipc-bridge "); ++ }; ++ if first == std::ffi::OsStr::new("--check") { ++ return Ok(()); ++ } ++ if args.next().is_some() { ++ anyhow::bail!("usage: dshx-ipc-bridge "); ++ } ++ ++ let socket_path = PathBuf::from(first); ++ if let Some(parent) = socket_path.parent() { ++ codex_uds::prepare_private_socket_directory(parent) ++ .await ++ .with_context(|| format!("failed to prepare {}", parent.display()))?; ++ } ++ let mut listener = UnixListener::bind(&socket_path) ++ .await ++ .with_context(|| format!("failed to bind {}", socket_path.display()))?; ++ let _guard = SocketGuard(socket_path.clone()); ++ ++ // Stdout is a private JSONL control channel to the Node presentation adapter. ++ // Never log to stdout from this process. ++ let mut stdout = tokio::io::stdout(); ++ write_line(&mut stdout, r#"{"dshxBridge":"ready"}"#).await?; ++ ++ let stream = listener ++ .accept() ++ .await ++ .with_context(|| format!("failed to accept {}", socket_path.display()))?; ++ let mut websocket = accept_async(stream) ++ .await ++ .context("failed to upgrade local DSHX IPC connection")?; ++ let mut stdin_lines = BufReader::new(tokio::io::stdin()).lines(); ++ ++ loop { ++ tokio::select! { ++ incoming = websocket.next() => { ++ match incoming { ++ Some(Ok(Message::Text(text))) => write_line(&mut stdout, &text.to_string()).await?, ++ Some(Ok(Message::Binary(bytes))) => { ++ let text = String::from_utf8(bytes.to_vec()).context("binary IPC frame was not UTF-8")?; ++ write_line(&mut stdout, &text).await?; ++ } ++ Some(Ok(Message::Ping(payload))) => websocket.send(Message::Pong(payload)).await?, ++ Some(Ok(Message::Close(_))) | None => break, ++ Some(Ok(_)) => {} ++ Some(Err(err)) => return Err(err).context("local DSHX IPC websocket failed"), ++ } ++ } ++ outbound = stdin_lines.next_line() => { ++ match outbound? { ++ Some(line) => websocket.send(Message::Text(line.into())).await?, ++ None => { ++ websocket.close(None).await?; ++ break; ++ } ++ } ++ } ++ } ++ } ++ ++ Ok(()) ++} +diff --git a/codex-rs/tui/src/main.rs b/codex-rs/tui/src/main.rs +--- a/codex-rs/tui/src/main.rs ++++ b/codex-rs/tui/src/main.rs +@@ -72,19 +72,12 @@ + "DSHX_APP_SERVER_ENDPOINT is required; DSHX never falls back to the Codex agent runtime" + ) + })?; +- let dshx_token = std::env::var("DSHX_APP_SERVER_TOKEN").map_err(|_| { +- anyhow::anyhow!( +- "DSHX_APP_SERVER_TOKEN is required; DSHX local transport is always authenticated" +- ) +- })?; +- let mut dshx_endpoint = resolve_remote_addr(&dshx_endpoint)?; +- match &mut dshx_endpoint { +- RemoteAppServerEndpoint::WebSocket { auth_token, .. } => { +- *auth_token = Some(dshx_token); +- } +- RemoteAppServerEndpoint::UnixSocket { .. } => { +- anyhow::bail!("DSHX production launcher requires authenticated loopback WebSocket transport") +- } ++ let dshx_endpoint = resolve_remote_addr(&dshx_endpoint)?; ++ match &dshx_endpoint { ++ RemoteAppServerEndpoint::WebSocket { .. } => anyhow::bail!( ++ "DSHX production launcher forbids TCP WebSocket transport; use the packaged local IPC bridge" ++ ), ++ RemoteAppServerEndpoint::UnixSocket { .. } => {} + } + let exit_info = run_main( + inner, diff --git a/upstream/patches/codex/0011-dshx-ipc-self-check.patch b/upstream/patches/codex/0011-dshx-ipc-self-check.patch new file mode 100644 index 00000000..8fb1079f --- /dev/null +++ b/upstream/patches/codex/0011-dshx-ipc-self-check.patch @@ -0,0 +1,100 @@ +diff --git a/codex-rs/stdio-to-uds/src/dshx_ipc_bridge.rs b/codex-rs/stdio-to-uds/src/dshx_ipc_bridge.rs +--- a/codex-rs/stdio-to-uds/src/dshx_ipc_bridge.rs ++++ b/codex-rs/stdio-to-uds/src/dshx_ipc_bridge.rs +@@ -2,16 +2,21 @@ + + use std::io::ErrorKind; + use std::path::PathBuf; ++use std::time::SystemTime; ++use std::time::UNIX_EPOCH; + + use anyhow::Context; + use codex_uds::UnixListener; ++use codex_uds::UnixStream; + use futures::SinkExt; + use futures::StreamExt; + use tokio::io::AsyncBufReadExt; + use tokio::io::AsyncWriteExt; + use tokio::io::BufReader; + use tokio_tungstenite::accept_async; ++use tokio_tungstenite::client_async; + use tokio_tungstenite::tungstenite::Message; ++use tokio_tungstenite::tungstenite::client::IntoClientRequest; + + struct SocketGuard(PathBuf); + +@@ -32,6 +37,65 @@ + Ok(()) + } + ++struct DirectoryGuard(PathBuf); ++ ++impl Drop for DirectoryGuard { ++ fn drop(&mut self) { ++ let _ = std::fs::remove_dir_all(&self.0); ++ } ++} ++ ++async fn self_check() -> anyhow::Result<()> { ++ let nonce = SystemTime::now() ++ .duration_since(UNIX_EPOCH) ++ .context("system clock is before Unix epoch")? ++ .as_nanos(); ++ let directory = std::env::temp_dir().join(format!( ++ "dshx-ipc-check-{}-{nonce}", ++ std::process::id() ++ )); ++ codex_uds::prepare_private_socket_directory(&directory) ++ .await ++ .with_context(|| format!("failed to prepare {}", directory.display()))?; ++ let _directory_guard = DirectoryGuard(directory.clone()); ++ let socket_path = directory.join("app.sock"); ++ let mut listener = UnixListener::bind(&socket_path) ++ .await ++ .with_context(|| format!("failed to bind {}", socket_path.display()))?; ++ ++ let server = tokio::spawn(async move { ++ let stream = listener.accept().await.context("self-check accept failed")?; ++ let mut websocket = accept_async(stream) ++ .await ++ .context("self-check server websocket handshake failed")?; ++ match websocket.next().await { ++ Some(Ok(Message::Text(text))) if text.as_str() == "ping" => { ++ websocket.send(Message::Text("pong".into())).await?; ++ } ++ other => anyhow::bail!("self-check server received unexpected frame: {other:?}"), ++ } ++ websocket.close(None).await?; ++ anyhow::Ok(()) ++ }); ++ ++ let stream = UnixStream::connect(&socket_path) ++ .await ++ .with_context(|| format!("failed to connect {}", socket_path.display()))?; ++ let request = "ws://localhost/rpc" ++ .into_client_request() ++ .context("failed to create self-check websocket request")?; ++ let (mut websocket, _) = client_async(request, stream) ++ .await ++ .context("self-check client websocket handshake failed")?; ++ websocket.send(Message::Text("ping".into())).await?; ++ match websocket.next().await { ++ Some(Ok(Message::Text(text))) if text.as_str() == "pong" => {} ++ other => anyhow::bail!("self-check client received unexpected frame: {other:?}"), ++ } ++ server.await.context("self-check server task failed")??; ++ Ok(()) ++} ++ + #[tokio::main] + async fn main() -> anyhow::Result<()> { + let mut args = std::env::args_os().skip(1); +@@ -39,7 +103,7 @@ + anyhow::bail!("usage: dshx-ipc-bridge "); + }; + if first == std::ffi::OsStr::new("--check") { +- return Ok(()); ++ return self_check().await; + } + if args.next().is_some() { + anyhow::bail!("usage: dshx-ipc-bridge "); From a67b96612a5f4a79551908a1f7cb2d6c9e8d87a1 Mon Sep 17 00:00:00 2001 From: code-new-bie <403466121@qq.com> Date: Thu, 20 Aug 2026 05:50:42 +0800 Subject: [PATCH 193/305] test: make Windows local-IPC ConPTY smoke deterministic --- test/tui_smoke_windows.mjs | 39 +++++++++++++++++++++++--------------- 1 file changed, 24 insertions(+), 15 deletions(-) diff --git a/test/tui_smoke_windows.mjs b/test/tui_smoke_windows.mjs index b142e258..0d7c6dda 100644 --- a/test/tui_smoke_windows.mjs +++ b/test/tui_smoke_windows.mjs @@ -6,7 +6,7 @@ import process from 'node:process'; import { spawn } from 'node:child_process'; import { fileURLToPath } from 'node:url'; import { createInterface } from 'node:readline'; -import pty from 'node-pty'; +import * as pty from 'node-pty'; const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); @@ -26,19 +26,19 @@ function stringEnv(extra = {}) { } function waitForOutput(state, needle, timeoutMs = 30_000) { - if (stripTerminalControl(state.output).includes(needle)) return Promise.resolve(); + const started = Date.now(); return new Promise((resolve, reject) => { - const timer = setTimeout(() => { - dispose.dispose(); - reject(new Error(`Timed out waiting for ${JSON.stringify(needle)}. Transcript:\n${stripTerminalControl(state.output)}`)); - }, timeoutMs); - const dispose = state.term.onData((chunk) => { - state.output += chunk; - if (!stripTerminalControl(state.output).includes(needle)) return; - clearTimeout(timer); - dispose.dispose(); - resolve(); - }); + const poll = setInterval(() => { + const transcript = stripTerminalControl(state.output); + if (transcript.includes(needle)) { + clearInterval(poll); + resolve(); + return; + } + if (Date.now() - started < timeoutMs) return; + clearInterval(poll); + reject(new Error(`Timed out waiting for ${JSON.stringify(needle)}. Transcript:\n${transcript}`)); + }, 50); }); } @@ -68,6 +68,10 @@ function firstLine(stream, child, stderrText, timeoutMs = 15_000) { }); } +function sleep(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + async function stopChild(child) { if (!child || child.exitCode !== null || child.signalCode !== null) return; const exited = new Promise((resolve) => child.once('exit', resolve)); @@ -92,6 +96,7 @@ const codexHome = fs.mkdtempSync(path.join(os.tmpdir(), 'dshx-conpty-home-')); let server; let serverStderr = ''; let term; +let dataDisposable; try { server = spawn(process.execPath, [path.join(ROOT, 'bin', 'dshx-stub-local.mjs')], { cwd: ROOT, @@ -118,8 +123,8 @@ try { DSHX_APP_SERVER_ENDPOINT: endpoint }) }); - const state = { term, output: '' }; - term.onData((chunk) => { state.output += chunk; }); + const state = { output: '' }; + dataDisposable = term.onData((chunk) => { state.output += chunk; }); await waitForOutput(state, 'DeepSeek Harness'); const prompt = '你好,DSHX ConPTY'; @@ -128,7 +133,11 @@ try { await waitForOutput(state, prompt); process.stdout.write('Windows ConPTY DSHX local-IPC + CJK smoke passed\n'); } finally { + dataDisposable?.dispose?.(); try { term?.kill(); } catch {} + // Give ConPTY/TUI a bounded moment to close the local socket cleanly before + // terminating the deterministic adapter process. + await sleep(100); await stopChild(server); fs.rmSync(codexHome, { recursive: true, force: true }); } From 6d4b0d89cc71d38f3c841395a4ffbf483f741597 Mon Sep 17 00:00:00 2001 From: code-new-bie <403466121@qq.com> Date: Thu, 20 Aug 2026 06:25:02 +0800 Subject: [PATCH 194/305] test: guard visible Codex slash command contract --- scripts/verify-slash-contract.mjs | 91 +++++++++++++++++++++++++++++++ 1 file changed, 91 insertions(+) create mode 100644 scripts/verify-slash-contract.mjs diff --git a/scripts/verify-slash-contract.mjs b/scripts/verify-slash-contract.mjs new file mode 100644 index 00000000..e299ce81 --- /dev/null +++ b/scripts/verify-slash-contract.mjs @@ -0,0 +1,91 @@ +#!/usr/bin/env node +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const SLASH = path.join(ROOT, '.upstream', 'codex', 'codex-rs', 'tui', 'src', 'slash_command.rs'); + +if (!fs.existsSync(SLASH)) { + throw new Error(`materialized pinned Codex slash_command.rs is missing: ${SLASH}`); +} + +const source = fs.readFileSync(SLASH, 'utf8'); +const enumMatch = source.match(/pub enum SlashCommand\s*\{([\s\S]*?)\n\}/); +if (!enumMatch) throw new Error('cannot parse pinned Codex SlashCommand enum'); +const variants = new Set( + [...enumMatch[1].matchAll(/^\s*([A-Z][A-Za-z0-9_]*)\s*,\s*$/gm)].map((match) => match[1]) +); + +const visibilityMatch = source.match( + /if crate::dshx_backend\(\)[\s\S]*?matches!\([\s\S]*?self,([\s\S]*?)\)\s*\{\s*return false;/ +); +if (!visibilityMatch) throw new Error('cannot find DSHX slash visibility guard in patched Codex'); +const dshxHidden = new Set( + [...visibilityMatch[1].matchAll(/SlashCommand::([A-Z][A-Za-z0-9_]*)/g)].map((match) => match[1]) +); + +// Commands whose behavior is entirely presentation-local. Their state lives in +// the isolated DSHX Codex home or the terminal process, never in a shadow Agent runtime. +const presentationOnly = new Set([ + 'Keymap', 'Vim', 'Copy', 'Export', 'Raw', 'Mention', 'Status', 'Pwd', + 'Title', 'Statusline', 'Theme', 'Pets', 'Quit', 'Exit' +]); + +// Release-only upstream visibility guards keep these out of normal builds. +const upstreamDebugOnly = new Set(['Rollout', 'TestApproval']); + +// Visible runtime-owned commands must have a faithful DSH-backed app-server surface. +// The method strings are also asserted to exist in the adapter sources below. +const runtimeBacked = new Map([ + ['Model', ['thread/settings/update', 'model/list']], + ['Permissions', ['thread/settings/update']], + ['Skills', ['skills/list']], + ['Rename', ['thread/name/set']], + ['New', ['thread/start']], + ['Clear', ['thread/start']], + ['Resume', ['thread/list', 'thread/resume']], + ['Fork', ['thread/fork']], + ['Init', ['turn/start']], + ['Compact', ['thread/compact/start']], + ['Plan', ['thread/settings/update']], + ['Agents', ['thread/loaded/list']], + ['Diff', ['command/exec']] +]); + +const classified = new Set([ + ...dshxHidden, + ...presentationOnly, + ...upstreamDebugOnly, + ...runtimeBacked.keys() +]); +const unclassified = [...variants].filter((variant) => !classified.has(variant)).sort(); +if (unclassified.length > 0) { + throw new Error( + `Pinned Codex introduced visible/unclassified slash commands: ${unclassified.join(', ')}. ` + + 'Classify each as DSH-backed, presentation-only, upstream-debug-only, or hide it explicitly for DSHX.' + ); +} + +for (const hidden of dshxHidden) { + if (!variants.has(hidden)) throw new Error(`DSHX hides unknown SlashCommand variant ${hidden}`); +} + +const adapterSources = [ + 'src/dsh/app-server-adapter.mjs', + 'src/dsh/product-adapter.mjs', + 'src/dsh/release-adapter.mjs' +].map((relative) => fs.readFileSync(path.join(ROOT, relative), 'utf8')).join('\n'); +for (const [command, methods] of runtimeBacked) { + for (const method of methods) { + if (!adapterSources.includes(`'${method}'`) && !adapterSources.includes(`\"${method}\"`)) { + throw new Error(`/${command} is visible in DSHX but adapter source does not claim ${method}`); + } + } +} + +process.stdout.write( + `DSHX slash contract OK: ${variants.size} pinned commands; ` + + `${dshxHidden.size} hidden, ${runtimeBacked.size} DSH-backed, ` + + `${presentationOnly.size} presentation-only, ${upstreamDebugOnly.size} debug-only.\n` +); From a462473ac4a9eb04cb3d45b2a823fea8a662cb58 Mon Sep 17 00:00:00 2001 From: code-new-bie <403466121@qq.com> Date: Thu, 20 Aug 2026 06:25:20 +0800 Subject: [PATCH 195/305] build: enforce DSHX slash capability contract --- scripts/build-codex-tui.sh | 1 + 1 file changed, 1 insertion(+) diff --git a/scripts/build-codex-tui.sh b/scripts/build-codex-tui.sh index 3873653e..c391076e 100644 --- a/scripts/build-codex-tui.sh +++ b/scripts/build-codex-tui.sh @@ -3,6 +3,7 @@ set -euo pipefail ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" bash "$ROOT/scripts/materialize-codex.sh" +node "$ROOT/scripts/verify-slash-contract.mjs" CODEX_DIR="${DSHX_CODEX_DIR:-$ROOT/.upstream/codex}" TARGET_DIR="${CARGO_TARGET_DIR:-$ROOT/.build/codex}" OUT_DIR="${DSHX_TUI_OUT_DIR:-$ROOT/dist/bin}" From 8c742500d5239ce6187c52a21b5bc54baf3c4e36 Mon Sep 17 00:00:00 2001 From: code-new-bie <403466121@qq.com> Date: Thu, 20 Aug 2026 06:25:38 +0800 Subject: [PATCH 196/305] build: enforce DSHX slash contract on Windows --- scripts/build-codex-tui.ps1 | 1 + 1 file changed, 1 insertion(+) diff --git a/scripts/build-codex-tui.ps1 b/scripts/build-codex-tui.ps1 index ab5b9677..e3b5a97b 100644 --- a/scripts/build-codex-tui.ps1 +++ b/scripts/build-codex-tui.ps1 @@ -2,6 +2,7 @@ $ErrorActionPreference = 'Stop' $Root = (Resolve-Path (Join-Path $PSScriptRoot '..')).Path & (Join-Path $Root 'scripts\materialize-codex.ps1') +node (Join-Path $Root 'scripts\verify-slash-contract.mjs') $CodeXDir = if ($env:DSHX_CODEX_DIR) { $env:DSHX_CODEX_DIR } else { Join-Path $Root '.upstream\codex' } $TargetDir = if ($env:CARGO_TARGET_DIR) { $env:CARGO_TARGET_DIR } else { Join-Path $Root '.build\codex' } $OutDir = if ($env:DSHX_TUI_OUT_DIR) { $env:DSHX_TUI_OUT_DIR } else { Join-Path $Root 'dist\bin' } From ffecf79939fd32f259849df4d56044969ccd617a Mon Sep 17 00:00:00 2001 From: code-new-bie <403466121@qq.com> Date: Thu, 20 Aug 2026 06:26:07 +0800 Subject: [PATCH 197/305] docs: make release branch the exact 1.0 evidence target --- docs/RELEASE-READINESS.md | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/docs/RELEASE-READINESS.md b/docs/RELEASE-READINESS.md index 271f2e36..45a0aac0 100644 --- a/docs/RELEASE-READINESS.md +++ b/docs/RELEASE-READINESS.md @@ -16,8 +16,9 @@ No item becomes green because code for the check exists. A release candidate is | DSH ownership boundary | adapter/unit tests + CI ownership assertions | pass | | Approval/permission fail-closed | approval/permission/subagent authority tests | pass | | Session resume/durability projection | DSH `agents.resume`, persistence projection and resume tests; persisted model/header wins over machine default | pass | -| Local production transport | no production `WebSocketServer`; TUI rejects TCP WebSocket endpoint | pass | -| Cross-platform local IPC data plane | `dshx-ipc-bridge --check` performs real UDS + WebSocket ping/pong | pass on Linux/Windows/macOS | +| Local production transport | no production TCP listener; pinned TUI rejects TCP WebSocket endpoint | pass | +| Cross-platform local IPC data plane | `dshx-ipc-bridge --check` performs real private UDS + WebSocket-framing ping/pong | pass on Linux/Windows/macOS | +| Visible slash-command contract | `scripts/verify-slash-contract.mjs` classifies every pinned Codex command and asserts DSH-backed methods for every runtime-owned command left visible in DSHX | pass | | Real pinned TUI / Linux | PTY smoke drives pinned TUI through local IPC deterministic adapter | pass | | Real pinned TUI / Windows | ConPTY smoke drives pinned TUI through the same local-IPC topology | pass | | Automated CJK transport/render sanity | Windows ConPTY test submits and observes a Chinese prompt | pass | @@ -48,7 +49,7 @@ Any difference is either fixed, documented as a deliberate product difference in ## Promotion procedure -1. Freeze the candidate commit and ensure the supported Codex/DSH pins are unchanged during validation. +1. Freeze the candidate commit on `release/1.0-rc` and ensure the supported Codex/DSH pins are unchanged during validation. 2. Run the full CI workflow and the dedicated Windows ConPTY workflow for that exact commit. 3. Create a prerelease tag (for example `v1.0.0-rc.N`) and require every release-matrix build/clean-install/TUI gate to pass. 4. Install the generated Windows release artifact for the manual Windows Terminal/CJK/IME side-by-side acceptance run; do not validate a source checkout instead. @@ -58,4 +59,6 @@ Any difference is either fixed, documented as a deliberate product difference in ## Current integration note -`agent/production-ipc` / Draft PR #8 is the production-transport integration branch. The repository contains the automated gates above, but their existence is **not** a statement that a particular candidate has passed GitHub Actions. Consult the Actions/check status for the exact head before merging or tagging. +`release/1.0-rc` is the sole release-candidate integration branch. It is based on the production local-IPC implementation from `agent/production-ipc`; the alternative direct-stdio Codex-client experiment is intentionally excluded because it requires a much larger upstream TUI/client patch and would increase DSHX maintenance ownership. + +Older stacked draft PRs remain historical engineering workstreams only. They are not release evidence. The exact `release/1.0-rc` head and its eventual `v1.0.0-rc.N` tag are the only commits whose CI/manual evidence may be used to promote 1.0. From 37c4023bd049eec61672110d2a65308077a4a9fe Mon Sep 17 00:00:00 2001 From: code-new-bie <403466121@qq.com> Date: Thu, 20 Aug 2026 06:28:49 +0800 Subject: [PATCH 198/305] docs: point users and maintainers at the sole 1.0 RC line --- README.md | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 2ae3d21a..2bf87c08 100644 --- a/README.md +++ b/README.md @@ -48,18 +48,23 @@ There is **no TCP listener in the production launch path**. The bridge reuses Co ## Current integration status -The active production-integration work is stacked on `work/dsh-public-adapter`, with the local-IPC/packaging gate in Draft PR #8 (`agent/production-ipc`). The implementation now includes: +`release/1.0-rc` is the sole production release-candidate integration branch, reviewed directly against `main` in Draft PR #11. Older M0/M1/thin-launcher/transport PRs are historical workstreams rather than release dependencies. + +The selected production transport is the small local-IPC bridge above. The alternative experiment that added a larger direct-stdio transport implementation inside the Codex client is intentionally excluded from the 1.0 line to keep the maintained upstream diff smaller. + +The candidate implementation includes: - zero-argument `dshx` launch and `dshx resume` flows; -- official DSH runtime composition and exact supported DSH release closure checks; -- DSH-backed session/history, model, permissions, approvals, ask-user, tools/shell/diffs, plan/reasoning, steering/interrupt, fork/compaction and subagent presentation boundaries; +- official DSH runtime composition and exact supported DSH rc.8 release-closure checks; +- DSH-backed session/history, model, permissions, approvals, ask-user, tools/shell/diffs, plan/reasoning, steering/interrupt, rename, fork/compaction, skills and subagent presentation boundaries; - pinned Codex TUI build plus packaged cross-platform local IPC bridge; +- an executable visible-slash-command contract so every runtime-owned command left visible is either DSH-backed or the build fails; - release artifacts for Linux x64, Windows x64, macOS arm64 and macOS x64; -- clean-install `dshx doctor`, including a real local UDS + WebSocket ping/pong data-plane self-check; -- Linux real-TUI PTY smoke through the production local-IPC topology; +- clean-install `dshx doctor`, including a real local UDS + WebSocket-framing ping/pong data-plane self-check; +- Linux real-TUI PTY smoke and Windows real-TUI ConPTY + Chinese-text smoke through the production local-IPC topology; - fail-closed ownership/security tests that keep DSH authoritative. -A commit is not called 1.0-ready merely because these paths exist. The final release candidate still must pass the repository CI/release gates and the side-by-side Windows Terminal/CJK/IME acceptance pass described in `docs/UX-PARITY.md`. +A commit is not called 1.0-ready merely because these paths exist. The exact release candidate must pass the repository CI/release gates and the side-by-side Windows Terminal/CJK/IME acceptance pass described in `docs/RELEASE-READINESS.md` and `docs/UX-PARITY.md`. ## Build from source @@ -91,7 +96,7 @@ dshx ## Release model -Tags matching `v*` build platform-specific installable tarballs in GitHub Actions. The release workflow performs adapter/runtime tests, builds the pinned TUI and IPC bridge, clean-installs each artifact, runs `dshx doctor`, and publishes SHA-256 checksums. Release candidates containing `-` in the tag are published as prereleases. +Tags matching `v*` build platform-specific installable tarballs in GitHub Actions. The release workflow performs adapter/runtime tests, builds the pinned TUI and IPC bridge, clean-installs each artifact, runs `dshx doctor`, exercises the Linux PTY and Windows ConPTY gates where applicable, and publishes SHA-256 checksums. Release candidates containing `-` in the tag are published as prereleases. ## Design references From 38619dba82b6f973dfe7cb88e9e8433f11f7bac8 Mon Sep 17 00:00:00 2001 From: code-new-bie <403466121@qq.com> Date: Thu, 20 Aug 2026 06:31:13 +0800 Subject: [PATCH 199/305] test: cover CJK and resize in Linux local-IPC PTY smoke --- test/tui_smoke.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/test/tui_smoke.py b/test/tui_smoke.py index ad478a3b..a2805711 100644 --- a/test/tui_smoke.py +++ b/test/tui_smoke.py @@ -7,6 +7,7 @@ import pexpect ROOT = pathlib.Path(__file__).resolve().parents[1] +PROMPT = "你好,DSHX PTY resize" server = subprocess.Popen( ["node", "bin/dshx-stub-local.mjs"], cwd=ROOT, @@ -45,14 +46,15 @@ try: child.expect("DeepSeek Harness") transcript.append(child.before + child.after) - child.send("smoke from pty\r") + child.setwinsize(40, 100) + child.send(PROMPT + "\r") child.expect("DSHX protocol stub received:") transcript.append(child.before + child.after) - child.expect("smoke from pty") + child.expect(PROMPT) transcript.append(child.before + child.after) except Exception: transcript.append(child.before or "") - raise AssertionError("Pinned DSHX TUI local-IPC smoke failed. Transcript:\n" + "".join(transcript)) + raise AssertionError("Pinned DSHX TUI local-IPC/CJK/resize smoke failed. Transcript:\n" + "".join(transcript)) finally: child.close(force=True) finally: From 4d5b26f1e15dc38468ec46a24cf3e56734a4bbec Mon Sep 17 00:00:00 2001 From: code-new-bie <403466121@qq.com> Date: Thu, 20 Aug 2026 06:31:29 +0800 Subject: [PATCH 200/305] runtime: mirror official DSH empty profile-root semantics --- src/dsh/runtime-boot.mjs | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/src/dsh/runtime-boot.mjs b/src/dsh/runtime-boot.mjs index e289b71a..4c0a1b78 100644 --- a/src/dsh/runtime-boot.mjs +++ b/src/dsh/runtime-boot.mjs @@ -18,7 +18,12 @@ const NAME = 'dshx'; const DSH_PROFILE_BIN_NAME = 'dsh'; const DEFAULT_PROFILE = 'headless'; const PROFILE_ROOT_FILENAME = 'cordis.yml'; -const PROFILE_ROOT_CONFIG = `# dshx profile root — composition remains owned by DeepSeek Harness.\n[]\n`; +// Match the official `dsh` launcher's profile-root contract: this file is only +// the Loader/include base anchor. The real user-owned configuration is carried +// by the profile's `cordis.patch.yml` plus `$DSH_HOME/cordis.patch.yml`. +// Rewriting this empty root on every boot is intentional DSH behavior and +// prevents Loader tree write-back from becoming a second persisted config. +const PROFILE_ROOT_CONFIG = `# DSHX uses the same empty profile root contract as the official dsh launcher.\n# The effective tree is composed from DSH bundle and patch layers.\n# Edit cordis.patch.yml, not this file.\n[]\n`; const DSHX_SURFACE_PATCHES = [ // The official headless profile is the default because it is the DSH runtime // without a browser surface. Its one-shot CLI startup/runner are themselves @@ -83,6 +88,9 @@ export function dshxRuntimeProfile({ healProfilesModuleFallback(installAnchor, home); const profile = loadProfile(DSH_PROFILE_BIN_NAME, profileName, installAnchor, home); const rootConfig = join(profile.dir, PROFILE_ROOT_FILENAME); + // Official DSH deliberately rewrites this empty include root before boot. + // Never write `profile.patchPath` or the home patch here: those are the + // authoritative user configuration layers and are read-only to DSHX. writeFileSync(rootConfig, PROFILE_ROOT_CONFIG); const bundlePatches = profile.layers.flatMap((layer) => layer.patches); From 084b7b08ea622e8b2785a203c79f1971d05a244d Mon Sep 17 00:00:00 2001 From: code-new-bie <403466121@qq.com> Date: Thu, 20 Aug 2026 06:31:35 +0800 Subject: [PATCH 201/305] test: cover resize in Windows local-IPC ConPTY smoke --- test/tui_smoke_windows.mjs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/test/tui_smoke_windows.mjs b/test/tui_smoke_windows.mjs index 0d7c6dda..80b2f015 100644 --- a/test/tui_smoke_windows.mjs +++ b/test/tui_smoke_windows.mjs @@ -127,11 +127,12 @@ try { dataDisposable = term.onData((chunk) => { state.output += chunk; }); await waitForOutput(state, 'DeepSeek Harness'); - const prompt = '你好,DSHX ConPTY'; + term.resize(100, 40); + const prompt = '你好,DSHX ConPTY resize'; term.write(`${prompt}\r`); await waitForOutput(state, 'DSHX protocol stub received:'); await waitForOutput(state, prompt); - process.stdout.write('Windows ConPTY DSHX local-IPC + CJK smoke passed\n'); + process.stdout.write('Windows ConPTY DSHX local-IPC + CJK + resize smoke passed\n'); } finally { dataDisposable?.dispose?.(); try { term?.kill(); } catch {} From 6c10fa94291155bfe61f0dfdf891d2c60d73da4d Mon Sep 17 00:00:00 2001 From: code-new-bie <403466121@qq.com> Date: Thu, 20 Aug 2026 06:31:47 +0800 Subject: [PATCH 202/305] test: prove DSHX never mutates user patch layers --- test/dsh-runtime-boot.test.mjs | 47 +++++++++++++++++++++++++--------- 1 file changed, 35 insertions(+), 12 deletions(-) diff --git a/test/dsh-runtime-boot.test.mjs b/test/dsh-runtime-boot.test.mjs index 0bbf0ce2..18dd38af 100644 --- a/test/dsh-runtime-boot.test.mjs +++ b/test/dsh-runtime-boot.test.mjs @@ -26,6 +26,13 @@ test('runtime auto-initializes and composes the official DSH headless profile', '@deepseek-ai/dsh-headless' ]); + assert.equal( + readFileSync(composition.rootConfig, 'utf8'), + runtimeInternals.PROFILE_ROOT_CONFIG, + 'DSHX may only rewrite the same empty Loader root role used by official dsh' + ); + assert.match(runtimeInternals.PROFILE_ROOT_CONFIG, /Edit cordis\.patch\.yml, not this file/); + const entries = dshxRuntimeEntries({ home }); const byId = new Map(entries.map((entry) => [entry.id, entry])); @@ -47,25 +54,41 @@ test('runtime auto-initializes and composes the official DSH headless profile', }); }); -test('profile-local and DSH-home user patch layers remain authoritative for capabilities', () => { +test('profile-local and DSH-home user patch layers remain authoritative and byte-for-byte untouched', () => { temporaryDshHome((home) => { // First read creates the official profile using DSH's own template. - dshxRuntimeProfile({ home }); - writeFileSync(join(home, 'profiles', 'headless', 'cordis.patch.yml'), ` -- id: tools - config: - dshxProfileProbe: profile -`); - writeFileSync(join(home, 'cordis.patch.yml'), ` -- id: llm - config: - dshxHomeProbe: home -`); + const initial = dshxRuntimeProfile({ home }); + const profilePatchPath = initial.profile.patchPath; + const homePatchPath = initial.homePatchPath; + const profilePatch = `# user profile layer - DSHX must never rewrite this byte stream\n- id: tools\n config:\n dshxProfileProbe: profile\n`; + const homePatch = `# machine DSH layer - DSHX must never rewrite this byte stream\n- id: llm\n config:\n dshxHomeProbe: home\n`; + writeFileSync(profilePatchPath, profilePatch); + writeFileSync(homePatchPath, homePatch); const entries = dshxRuntimeEntries({ home }); const byId = new Map(entries.map((entry) => [entry.id, entry])); assert.equal(byId.get('tools')?.config?.dshxProfileProbe, 'profile'); assert.equal(byId.get('llm')?.config?.dshxHomeProbe, 'home'); + + assert.equal(readFileSync(profilePatchPath, 'utf8'), profilePatch); + assert.equal(readFileSync(homePatchPath, 'utf8'), homePatch); + assert.equal(readFileSync(initial.rootConfig, 'utf8'), runtimeInternals.PROFILE_ROOT_CONFIG); + }); +}); + +test('recomposition rewrites only the generated empty root, never either user patch layer', () => { + temporaryDshHome((home) => { + const initial = dshxRuntimeProfile({ home }); + const profilePatch = '- id: tools\n config: { probe: keep-profile }\n'; + const homePatch = '- id: llm\n config: { probe: keep-home }\n'; + writeFileSync(initial.profile.patchPath, profilePatch); + writeFileSync(initial.homePatchPath, homePatch); + writeFileSync(initial.rootConfig, 'this is generated state and must be reset\n'); + + const recomposed = dshxRuntimeProfile({ home }); + assert.equal(readFileSync(recomposed.rootConfig, 'utf8'), runtimeInternals.PROFILE_ROOT_CONFIG); + assert.equal(readFileSync(recomposed.profile.patchPath, 'utf8'), profilePatch); + assert.equal(readFileSync(recomposed.homePatchPath, 'utf8'), homePatch); }); }); From 52c7ca9549c5a0cffe4e7421b65e13c05a3f20f0 Mon Sep 17 00:00:00 2001 From: code-new-bie <403466121@qq.com> Date: Thu, 20 Aug 2026 06:31:51 +0800 Subject: [PATCH 203/305] docs: distinguish automated resize sanity from manual visual parity --- docs/RELEASE-READINESS.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/RELEASE-READINESS.md b/docs/RELEASE-READINESS.md index 45a0aac0..ba2e796f 100644 --- a/docs/RELEASE-READINESS.md +++ b/docs/RELEASE-READINESS.md @@ -19,9 +19,9 @@ No item becomes green because code for the check exists. A release candidate is | Local production transport | no production TCP listener; pinned TUI rejects TCP WebSocket endpoint | pass | | Cross-platform local IPC data plane | `dshx-ipc-bridge --check` performs real private UDS + WebSocket-framing ping/pong | pass on Linux/Windows/macOS | | Visible slash-command contract | `scripts/verify-slash-contract.mjs` classifies every pinned Codex command and asserts DSH-backed methods for every runtime-owned command left visible in DSHX | pass | -| Real pinned TUI / Linux | PTY smoke drives pinned TUI through local IPC deterministic adapter | pass | -| Real pinned TUI / Windows | ConPTY smoke drives pinned TUI through the same local-IPC topology | pass | -| Automated CJK transport/render sanity | Windows ConPTY test submits and observes a Chinese prompt | pass | +| Real pinned TUI / Linux | PTY smoke drives pinned TUI through local IPC, resizes the terminal, and roundtrips a CJK prompt | pass | +| Real pinned TUI / Windows | ConPTY smoke drives pinned TUI through the same local-IPC topology, resizes the terminal, and roundtrips a Chinese prompt | pass | +| Automated CJK/resize sanity | Linux PTY + Windows ConPTY verify UTF-8 input/echo survives a live resize without process/session loss | pass | | Packaging | platform tarball + static local-import closure check + exact DSH shrinkwrap | pass | | Clean installation | install generated tarball then `dshx --version` + `dshx doctor` | pass on every release platform | | Release artifacts | Linux x64, Windows x64, macOS arm64, macOS x64 | all built | @@ -30,9 +30,9 @@ No item becomes green because code for the check exists. A release candidate is ## Manual UX acceptance gate -Automation can exercise ConPTY and Chinese text, but it cannot establish real **IME composition-state behavior** or visual parity. For the exact 1.0 release candidate, perform a side-by-side run against the pinned Codex TUI using the same Windows Terminal dimensions/theme and record pass/fail for: +Automation can exercise PTY/ConPTY resize and Chinese text, but it cannot establish real **IME composition-state behavior**, mouse behavior, or visual parity. For the exact 1.0 release candidate, perform a side-by-side run against the pinned Codex TUI using the same Windows Terminal dimensions/theme and record pass/fail for: -- Windows Terminal startup, resize, scroll and mouse behavior; +- Windows Terminal startup, visual reflow after resize, scroll and mouse behavior; - Chinese IME composition/commit/cancel in the multiline composer; - wide CJK glyph alignment in transcript/tool/diff cells; - slash completion and help keyboard behavior; From 053b196737dd358ff400c52725de01e10f598767 Mon Sep 17 00:00:00 2001 From: code-new-bie <403466121@qq.com> Date: Thu, 20 Aug 2026 06:33:34 +0800 Subject: [PATCH 204/305] ci: run Windows release gate on exact RC branch head --- .github/workflows/windows-tui-smoke.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/windows-tui-smoke.yml b/.github/workflows/windows-tui-smoke.yml index fd1eced6..cde850b4 100644 --- a/.github/workflows/windows-tui-smoke.yml +++ b/.github/workflows/windows-tui-smoke.yml @@ -2,7 +2,7 @@ name: Windows TUI Smoke on: push: - branches: [main, 'work/**', 'agent/**'] + branches: [main, 'work/**', 'agent/**', 'release/**'] pull_request: permissions: From e6237ca2a4518b9d05cd8c199df965d273396fb5 Mon Sep 17 00:00:00 2001 From: code-new-bie <403466121@qq.com> Date: Thu, 20 Aug 2026 06:33:46 +0800 Subject: [PATCH 205/305] release: freeze npm dependency graph on RC branch --- .github/workflows/freeze-dependencies.yml | 50 +++++++++++++++++++++++ 1 file changed, 50 insertions(+) create mode 100644 .github/workflows/freeze-dependencies.yml diff --git a/.github/workflows/freeze-dependencies.yml b/.github/workflows/freeze-dependencies.yml new file mode 100644 index 00000000..b70fb5f3 --- /dev/null +++ b/.github/workflows/freeze-dependencies.yml @@ -0,0 +1,50 @@ +name: Freeze RC dependencies + +on: + push: + branches: ['release/1.0-rc'] + paths: + - package.json + - .github/workflows/freeze-dependencies.yml + workflow_dispatch: + +permissions: + contents: write + +concurrency: + group: freeze-rc-dependencies + cancel-in-progress: true + +jobs: + lock: + if: github.repository == 'code-new-bie/dsh-codex' + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v4 + with: + ref: release/1.0-rc + - uses: actions/setup-node@v4 + with: + node-version: 20 + cache: npm + cache-dependency-path: package.json + - name: Resolve exact npm graph without running install scripts + shell: bash + run: npm install --package-lock-only --ignore-scripts --no-audit --no-fund + - name: Verify DeepSeek Harness closure remains the supported rc.8 line + shell: bash + run: node scripts/verify-dsh-closure.mjs . package-lock.json + - name: Commit frozen graph when it changed + shell: bash + run: | + set -euo pipefail + if git diff --quiet -- package-lock.json && git ls-files --error-unmatch package-lock.json >/dev/null 2>&1; then + echo 'package-lock.json is already frozen' + exit 0 + fi + git config user.name 'dshx-release-bot' + git config user.email 'actions@users.noreply.github.com' + git add package-lock.json + git commit -m 'chore: freeze RC npm dependency graph' + git push origin HEAD:release/1.0-rc From b3c820304b35aa1e4a55cb31f42d0e4b294a327b Mon Sep 17 00:00:00 2001 From: code-new-bie <403466121@qq.com> Date: Thu, 20 Aug 2026 06:34:10 +0800 Subject: [PATCH 206/305] ci: run full release gate on exact RC branch head --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1657ae37..13b0c2bd 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,7 +2,7 @@ name: CI on: push: - branches: [main, 'work/**', 'agent/**'] + branches: [main, 'work/**', 'agent/**', 'release/**'] pull_request: permissions: From 8128f46a99853a0a214dba701c9f9f5b54f595c4 Mon Sep 17 00:00:00 2001 From: code-new-bie <403466121@qq.com> Date: Thu, 20 Aug 2026 06:34:34 +0800 Subject: [PATCH 207/305] release: expose exact DSH dependency verification script --- package.json | 1 + 1 file changed, 1 insertion(+) diff --git a/package.json b/package.json index 4e77adf5..8d6985e6 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,7 @@ "test": "node --test", "test:protocol": "node --test test/protocol.test.mjs test/wire.test.mjs", "test:dsh": "node --test test/dsh-*.test.mjs", + "verify:release-deps": "node scripts/verify-dsh-closure.mjs . package-lock.json", "stub": "node ./bin/dshx-stub-server.mjs", "dev": "node ./bin/dshx.mjs" }, From 734ea280c0a5a75f82d6514484d90747ea62245e Mon Sep 17 00:00:00 2001 From: code-new-bie <403466121@qq.com> Date: Thu, 20 Aug 2026 06:38:15 +0800 Subject: [PATCH 208/305] docs: require frozen source dependency graph before RC --- docs/RELEASE-READINESS.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/RELEASE-READINESS.md b/docs/RELEASE-READINESS.md index ba2e796f..8153e4d4 100644 --- a/docs/RELEASE-READINESS.md +++ b/docs/RELEASE-READINESS.md @@ -12,7 +12,8 @@ No item becomes green because code for the check exists. A release candidate is |---|---|---| | Zero-argument launch | `bin/dshx.mjs`, clean-install smoke | pass on supported release artifacts | | Official DSH composition | `scripts/runtime-smoke.mjs` on Linux/Windows/macOS | pass | -| Exact DSH dependency closure | `scripts/verify-dsh-closure.mjs`, release shrinkwrap | pass | +| Frozen source dependency graph | checked-in `package-lock.json`; trusted main-branch RC freeze workflow resolves it with install scripts disabled | lock present and no manifest drift | +| Exact DSH dependency closure | `scripts/verify-dsh-closure.mjs`, source lock and release shrinkwrap | pass | | DSH ownership boundary | adapter/unit tests + CI ownership assertions | pass | | Approval/permission fail-closed | approval/permission/subagent authority tests | pass | | Session resume/durability projection | DSH `agents.resume`, persistence projection and resume tests; persisted model/header wins over machine default | pass | @@ -49,7 +50,7 @@ Any difference is either fixed, documented as a deliberate product difference in ## Promotion procedure -1. Freeze the candidate commit on `release/1.0-rc` and ensure the supported Codex/DSH pins are unchanged during validation. +1. Freeze the dependency graph in `package-lock.json`, then freeze the candidate commit on `release/1.0-rc`; Codex/DSH pins must not change during validation. 2. Run the full CI workflow and the dedicated Windows ConPTY workflow for that exact commit. 3. Create a prerelease tag (for example `v1.0.0-rc.N`) and require every release-matrix build/clean-install/TUI gate to pass. 4. Install the generated Windows release artifact for the manual Windows Terminal/CJK/IME side-by-side acceptance run; do not validate a source checkout instead. From 7c2fce94418542775bec1881799944da8b949e00 Mon Sep 17 00:00:00 2001 From: code-new-bie <403466121@qq.com> Date: Thu, 20 Aug 2026 06:40:18 +0800 Subject: [PATCH 209/305] security: anchor Windows IPC under DSHX user home --- src/dsh/local-server.mjs | 26 ++++++++++++++++++++++++-- 1 file changed, 24 insertions(+), 2 deletions(-) diff --git a/src/dsh/local-server.mjs b/src/dsh/local-server.mjs index e4ccd811..50926984 100644 --- a/src/dsh/local-server.mjs +++ b/src/dsh/local-server.mjs @@ -25,7 +25,23 @@ function parseError(error) { }; } -function createSocketDirectory(root = os.tmpdir()) { +function defaultSocketRoot({ + home, + platform = process.platform, + temporaryDirectory = os.tmpdir(), + userHome = os.homedir() +} = {}) { + if (platform !== 'win32') return temporaryDirectory; + // codex_uds can enforce 0700 on Unix. Its Windows implementation inherits + // directory ACLs instead, so do not trust an arbitrary/shared %TEMP% root. + // Anchor the rendezvous below the current user's DSHX presentation home, + // whose parent in turn lives below the user's profile by default. + const presentationHome = path.resolve(home || path.join(userHome, '.dshx', 'codex-tui')); + return path.join(presentationHome, 'ipc'); +} + +function createSocketDirectory(root) { + fs.mkdirSync(root, { recursive: true, mode: 0o700 }); const directory = fs.mkdtempSync(path.join(root, 'dshx-')); if (process.platform !== 'win32') fs.chmodSync(directory, 0o700); return directory; @@ -110,7 +126,8 @@ export async function startDshxLocalServer({ socketRoot } = {}) { const ctx = runtime ?? await bootRuntime({ cwd }); - const socketDirectory = createSocketDirectory(socketRoot); + const rendezvousRoot = path.resolve(socketRoot ?? defaultSocketRoot({ home })); + const socketDirectory = createSocketDirectory(rendezvousRoot); const socketPath = path.join(socketDirectory, 'app.sock'); const args = bridgeArgs ?? [socketPath]; let bridge; @@ -214,3 +231,8 @@ export async function startDshxLocalServer({ close }; } + +export const localServerInternals = { + defaultSocketRoot, + createSocketDirectory +}; From a985586181d7f91c37883f498f14a56932f33720 Mon Sep 17 00:00:00 2001 From: code-new-bie <403466121@qq.com> Date: Thu, 20 Aug 2026 06:40:51 +0800 Subject: [PATCH 210/305] security: pass private DSHX home to local IPC transport --- bin/dshx.mjs | 1 + 1 file changed, 1 insertion(+) diff --git a/bin/dshx.mjs b/bin/dshx.mjs index dd13bba3..1a1f9e92 100644 --- a/bin/dshx.mjs +++ b/bin/dshx.mjs @@ -170,6 +170,7 @@ async function run() { try { local = await startDshxLocalServer({ cwd: process.cwd(), + home: tuiHome, version: VERSION, bridgeCommand: bridge, log From a006a542806071a837b2dab4231221f4993f9d67 Mon Sep 17 00:00:00 2001 From: code-new-bie <403466121@qq.com> Date: Thu, 20 Aug 2026 06:41:14 +0800 Subject: [PATCH 211/305] test: lock private Windows IPC root policy --- test/dsh-local-server.test.mjs | 37 +++++++++++++++++++++++++++++++++- 1 file changed, 36 insertions(+), 1 deletion(-) diff --git a/test/dsh-local-server.test.mjs b/test/dsh-local-server.test.mjs index cd5b8b34..a44992e8 100644 --- a/test/dsh-local-server.test.mjs +++ b/test/dsh-local-server.test.mjs @@ -1,8 +1,11 @@ import assert from 'node:assert/strict'; import { EventEmitter } from 'node:events'; +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join, resolve } from 'node:path'; import { PassThrough } from 'node:stream'; import test from 'node:test'; -import { startDshxLocalServer } from '../src/dsh/local-server.mjs'; +import { localServerInternals, startDshxLocalServer } from '../src/dsh/local-server.mjs'; class FakeChild extends EventEmitter { constructor() { @@ -73,6 +76,38 @@ class FakeAdapter { } } +test('Windows default local IPC root is anchored below DSHX user presentation home, not TEMP', () => { + const root = localServerInternals.defaultSocketRoot({ + platform: 'win32', + home: 'C:\\Users\\Alice\\.dshx\\codex-tui', + temporaryDirectory: 'C:\\shared-temp', + userHome: 'C:\\Users\\Alice' + }); + assert.equal(root, resolve('C:\\Users\\Alice\\.dshx\\codex-tui', 'ipc')); + assert.doesNotMatch(root.toLowerCase(), /shared-temp/); +}); + +test('Windows fallback IPC root remains below the current user home when no presentation home is supplied', () => { + const root = localServerInternals.defaultSocketRoot({ + platform: 'win32', + temporaryDirectory: 'C:\\shared-temp', + userHome: 'C:\\Users\\Alice' + }); + assert.equal(root, resolve('C:\\Users\\Alice', '.dshx', 'codex-tui', 'ipc')); +}); + +test('Unix default local IPC root stays in tmp; createSocketDirectory enforces an ephemeral subdirectory', () => { + const root = mkdtempSync(join(tmpdir(), 'dshx-ipc-root-test-')); + try { + assert.equal(localServerInternals.defaultSocketRoot({ platform: 'linux', temporaryDirectory: root }), root); + const child = localServerInternals.createSocketDirectory(root); + assert.match(child, /^.*dshx-/); + assert.notEqual(child, root); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + test('local production transport exposes only a unix endpoint and relays RPC over bridge stdio', async () => { let disposed = 0; FakeAdapter.closes = 0; From 3d77bdaff9a24cc9931dd408d93d27a748a3216a Mon Sep 17 00:00:00 2001 From: code-new-bie <403466121@qq.com> Date: Thu, 20 Aug 2026 06:41:44 +0800 Subject: [PATCH 212/305] docs: document production local IPC security boundary --- SECURITY.md | 27 ++++++++++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/SECURITY.md b/SECURITY.md index 73124044..4bb47207 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -2,7 +2,7 @@ ## Scope -Security-sensitive areas include command execution, filesystem mutation, sandbox policy, approval translation, credential/model configuration, session persistence and remote/local transports. +Security-sensitive areas include command execution, filesystem mutation, sandbox policy, approval translation, credential/model configuration, session persistence and local presentation transports. ## Core invariant @@ -10,6 +10,28 @@ The TUI is presentation. DeepSeek Harness owns policy enforcement. The adapter m When a DSH permission concept cannot be represented faithfully by the current Codex UI/protocol, fail closed and surface the incompatibility to the user. +## Production transport + +DSHX does not expose its production presentation protocol on a TCP listener. The product topology is: + +```text +pinned Codex TUI + ↕ WebSocket framing over private local Unix-domain socket +packaged dshx-ipc-bridge + ↕ JSONL over child stdio +DSHX presentation adapter + ↕ official public APIs/events +DeepSeek Harness +``` + +The pinned TUI explicitly rejects TCP WebSocket endpoints in DSHX mode. + +On Unix, the rendezvous directory is a fresh random directory below the system temporary directory and both DSHX and pinned `codex_uds` enforce owner-only `0700` directory permissions. + +On Windows, pinned `codex_uds` cannot apply Unix permission bits, so DSHX deliberately does **not** trust an arbitrary/shared `%TEMP%` as the default rendezvous root. The random socket directory is created below the current user's DSHX presentation home (`~/.dshx/codex-tui/ipc/` by default), inheriting the user's profile ACL. The bridge removes its socket path on exit and the launcher removes the random rendezvous directory during bounded shutdown. + +The isolated DSHX presentation home is never the user's ordinary `CODEX_HOME`. + ## Reporting Please report security issues privately to the repository owner rather than opening a public issue with exploit details. @@ -23,5 +45,8 @@ A production release must include regression coverage for: - interrupted turns; - session resume without privilege drift; - model/provider configuration preservation; +- user profile/home DSH patch files remaining unmodified by DSHX; - malformed/unknown protocol events; +- private local-IPC path creation and bounded cleanup; +- no production TCP listener or Codex-core fallback; - transport disconnect/reconnect behavior where applicable. From 573ed4d999b4fd3b5b7a21490f135dae40cbfe11 Mon Sep 17 00:00:00 2001 From: code-new-bie <403466121@qq.com> Date: Thu, 20 Aug 2026 07:44:35 +0800 Subject: [PATCH 213/305] ci: allow explicit exact-head Windows gate dispatch --- .github/workflows/windows-tui-smoke.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/windows-tui-smoke.yml b/.github/workflows/windows-tui-smoke.yml index cde850b4..24df2432 100644 --- a/.github/workflows/windows-tui-smoke.yml +++ b/.github/workflows/windows-tui-smoke.yml @@ -4,6 +4,7 @@ on: push: branches: [main, 'work/**', 'agent/**', 'release/**'] pull_request: + workflow_dispatch: permissions: contents: read From 6ab698700b1f18b39b0f95f8485d406a54e9b095 Mon Sep 17 00:00:00 2001 From: code-new-bie <403466121@qq.com> Date: Thu, 20 Aug 2026 07:45:48 +0800 Subject: [PATCH 214/305] ci: allow explicit exact-head release gate dispatch --- .github/workflows/ci.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 13b0c2bd..97b6f9d8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -4,6 +4,7 @@ on: push: branches: [main, 'work/**', 'agent/**', 'release/**'] pull_request: + workflow_dispatch: permissions: contents: read From 4584f96c86acdf9fa0f3a8d8001d18c66fe209a6 Mon Sep 17 00:00:00 2001 From: code-new-bie <403466121@qq.com> Date: Thu, 20 Aug 2026 07:46:44 +0800 Subject: [PATCH 215/305] ci: validate frozen dependency graph before committing --- .github/workflows/freeze-dependencies.yml | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/.github/workflows/freeze-dependencies.yml b/.github/workflows/freeze-dependencies.yml index b70fb5f3..7a58bb85 100644 --- a/.github/workflows/freeze-dependencies.yml +++ b/.github/workflows/freeze-dependencies.yml @@ -19,7 +19,7 @@ jobs: lock: if: github.repository == 'code-new-bie/dsh-codex' runs-on: ubuntu-latest - timeout-minutes: 10 + timeout-minutes: 15 steps: - uses: actions/checkout@v4 with: @@ -35,6 +35,13 @@ jobs: - name: Verify DeepSeek Harness closure remains the supported rc.8 line shell: bash run: node scripts/verify-dsh-closure.mjs . package-lock.json + - name: Prove frozen graph installs and boots before committing it + shell: bash + run: | + set -euo pipefail + npm ci --ignore-scripts --no-audit --no-fund + npm test + node scripts/runtime-smoke.mjs - name: Commit frozen graph when it changed shell: bash run: | From ccdb119b1e8895939eb0396eb1e14a286b82121a Mon Sep 17 00:00:00 2001 From: code-new-bie <403466121@qq.com> Date: Thu, 20 Aug 2026 07:50:38 +0800 Subject: [PATCH 216/305] release: make final 1.0 depend on closed gate ledger --- .github/workflows/release.yml | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b06c8024..cee6acd5 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -30,6 +30,35 @@ jobs: - uses: actions/setup-node@v4 with: node-version: 20 + - name: Enforce stable 1.0 promotion ledger + env: + GH_TOKEN: ${{ github.token }} + shell: bash + run: | + set -euo pipefail + if [[ "$GITHUB_REF_NAME" != "v1.0.0" ]]; then + exit 0 + fi + for issue in 12 13 14 15; do + state="$(gh issue view "$issue" --repo "$GITHUB_REPOSITORY" --json state --jq .state)" + if [[ "$state" != "CLOSED" ]]; then + echo "stable 1.0 is blocked: release-gate issue #$issue is $state" >&2 + exit 1 + fi + done + merged="$(gh pr view 11 --repo "$GITHUB_REPOSITORY" --json mergedAt --jq '.mergedAt != null')" + if [[ "$merged" != "true" ]]; then + echo 'stable 1.0 is blocked: PR #11 has not been merged' >&2 + exit 1 + fi + relation="$(gh api "repos/$GITHUB_REPOSITORY/compare/$GITHUB_SHA...main" --jq .status)" + case "$relation" in + identical|ahead) ;; + *) + echo "stable 1.0 is blocked: tag commit $GITHUB_SHA is not contained in main (compare status: $relation)" >&2 + exit 1 + ;; + esac - name: Resolve release version (Unix) if: runner.os != 'Windows' shell: bash From 7a7878fef9005c923551ad1bf8aa4113c9dcfb5e Mon Sep 17 00:00:00 2001 From: code-new-bie <403466121@qq.com> Date: Thu, 20 Aug 2026 07:51:03 +0800 Subject: [PATCH 217/305] release: grant read access for stable promotion ledger --- .github/workflows/release.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index cee6acd5..264605d5 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -7,6 +7,8 @@ on: permissions: contents: write + issues: read + pull-requests: read jobs: build: From 34cc90ba99d87a1593f71b4569d37e43b13b7dac Mon Sep 17 00:00:00 2001 From: code-new-bie <403466121@qq.com> Date: Thu, 20 Aug 2026 07:51:55 +0800 Subject: [PATCH 218/305] ci: align RC with trusted main dependency freeze workflow --- .github/workflows/rc-freeze-dependencies.yml | 75 ++++++++++++++++++++ 1 file changed, 75 insertions(+) create mode 100644 .github/workflows/rc-freeze-dependencies.yml diff --git a/.github/workflows/rc-freeze-dependencies.yml b/.github/workflows/rc-freeze-dependencies.yml new file mode 100644 index 00000000..723e11bb --- /dev/null +++ b/.github/workflows/rc-freeze-dependencies.yml @@ -0,0 +1,75 @@ +name: Freeze 1.0 RC dependencies + +on: + pull_request: + branches: [main] + types: [opened, synchronize, reopened] + +permissions: + contents: write + pull-requests: read + +concurrency: + group: freeze-1.0-rc-dependencies + cancel-in-progress: true + +jobs: + freeze: + if: >- + github.event.pull_request.head.repo.full_name == github.repository && + github.event.pull_request.head.ref == 'release/1.0-rc' + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - name: Check out exact RC head + uses: actions/checkout@v4 + with: + ref: ${{ github.event.pull_request.head.sha }} + fetch-depth: 0 + - uses: actions/setup-node@v4 + with: + node-version: 20 + - name: Resolve lock without executing package scripts + shell: bash + run: npm install --package-lock-only --ignore-scripts --no-audit --no-fund + - name: Fail if the supported DSH direct line drifted + shell: bash + run: | + node - <<'NODE' + const fs = require('fs'); + const pkg = JSON.parse(fs.readFileSync('package.json', 'utf8')); + const expected = '0.1.0-rc.8'; + const required = [ + '@deepseek-ai/dsh', + '@deepseek-ai/dsh-agent', + '@deepseek-ai/dsh-app-boot', + '@deepseek-ai/dsh-host-apiproxy', + '@deepseek-ai/dsh-launch-environment', + '@deepseek-ai/dsh-llm', + '@deepseek-ai/dsh-session' + ]; + for (const name of required) { + if (pkg.dependencies?.[name] !== expected) { + throw new Error(`${name} must stay exactly ${expected}; got ${pkg.dependencies?.[name]}`); + } + } + const lock = JSON.parse(fs.readFileSync('package-lock.json', 'utf8')); + if (lock.packages?.['']?.dependencies?.['@deepseek-ai/dsh'] !== expected) { + throw new Error('generated lock does not preserve the exact DSH root version'); + } + NODE + - name: Commit frozen graph back to the RC branch + shell: bash + env: + RC_BRANCH: ${{ github.event.pull_request.head.ref }} + run: | + set -euo pipefail + if git diff --quiet -- package-lock.json && git ls-files --error-unmatch package-lock.json >/dev/null 2>&1; then + echo 'package-lock.json already matches the RC manifest' + exit 0 + fi + git config user.name 'dshx-release-bot' + git config user.email 'actions@users.noreply.github.com' + git add package-lock.json + git commit -m 'chore: freeze RC npm dependency graph' + git push origin "HEAD:${RC_BRANCH}" From 938f0076b246ad039b0b56de9ea4fea095a17e1b Mon Sep 17 00:00:00 2001 From: code-new-bie <403466121@qq.com> Date: Thu, 20 Aug 2026 07:52:03 +0800 Subject: [PATCH 219/305] ci: remove duplicate branch-local dependency freeze workflow --- .github/workflows/freeze-dependencies.yml | 57 ----------------------- 1 file changed, 57 deletions(-) delete mode 100644 .github/workflows/freeze-dependencies.yml diff --git a/.github/workflows/freeze-dependencies.yml b/.github/workflows/freeze-dependencies.yml deleted file mode 100644 index 7a58bb85..00000000 --- a/.github/workflows/freeze-dependencies.yml +++ /dev/null @@ -1,57 +0,0 @@ -name: Freeze RC dependencies - -on: - push: - branches: ['release/1.0-rc'] - paths: - - package.json - - .github/workflows/freeze-dependencies.yml - workflow_dispatch: - -permissions: - contents: write - -concurrency: - group: freeze-rc-dependencies - cancel-in-progress: true - -jobs: - lock: - if: github.repository == 'code-new-bie/dsh-codex' - runs-on: ubuntu-latest - timeout-minutes: 15 - steps: - - uses: actions/checkout@v4 - with: - ref: release/1.0-rc - - uses: actions/setup-node@v4 - with: - node-version: 20 - cache: npm - cache-dependency-path: package.json - - name: Resolve exact npm graph without running install scripts - shell: bash - run: npm install --package-lock-only --ignore-scripts --no-audit --no-fund - - name: Verify DeepSeek Harness closure remains the supported rc.8 line - shell: bash - run: node scripts/verify-dsh-closure.mjs . package-lock.json - - name: Prove frozen graph installs and boots before committing it - shell: bash - run: | - set -euo pipefail - npm ci --ignore-scripts --no-audit --no-fund - npm test - node scripts/runtime-smoke.mjs - - name: Commit frozen graph when it changed - shell: bash - run: | - set -euo pipefail - if git diff --quiet -- package-lock.json && git ls-files --error-unmatch package-lock.json >/dev/null 2>&1; then - echo 'package-lock.json is already frozen' - exit 0 - fi - git config user.name 'dshx-release-bot' - git config user.email 'actions@users.noreply.github.com' - git add package-lock.json - git commit -m 'chore: freeze RC npm dependency graph' - git push origin HEAD:release/1.0-rc From b768c7a3e9c31d91a08346ced1f0b45e9717f91b Mon Sep 17 00:00:00 2001 From: code-new-bie <403466121@qq.com> Date: Thu, 20 Aug 2026 18:34:21 +0800 Subject: [PATCH 220/305] chore: remove GitHub Actions workflows --- .github/workflows/ci.yml | 73 ---------------------------------------- 1 file changed, 73 deletions(-) delete mode 100644 .github/workflows/ci.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml deleted file mode 100644 index 3ba7ea47..00000000 --- a/.github/workflows/ci.yml +++ /dev/null @@ -1,73 +0,0 @@ -name: CI - -on: - pull_request: - push: - branches: [main] - workflow_dispatch: - -permissions: - contents: read - -concurrency: - group: ci-${{ github.ref }} - cancel-in-progress: true - -jobs: - core: - name: Core / Node 24 - runs-on: ubuntu-latest - timeout-minutes: 15 - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 - with: - node-version: 24 - - name: Install frozen dependency graph - run: npm ci - - name: Verify exact DeepSeek Harness release closure - run: node scripts/verify-dsh-closure.mjs - - name: Unit and contract tests - run: npm test - - name: Verify DSH ownership boundary - run: node scripts/verify-ownership-boundary.mjs - - name: Boot and dispose official DeepSeek Harness composition - run: node scripts/runtime-smoke.mjs - - name: Syntax check shipped JavaScript - shell: bash - run: | - set -euo pipefail - while IFS= read -r file; do node --check "$file"; done < <(find src bin scripts -type f -name '*.mjs' -print | sort) - - name: Syntax check PTY smoke - run: python3 -m py_compile test/tui_smoke.py - - linux-tui: - name: Linux TUI / pinned Codex / local IPC - needs: core - runs-on: ubuntu-latest - timeout-minutes: 60 - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 - with: - node-version: 24 - - name: Install frozen dependency graph - run: npm ci - - name: Verify exact DeepSeek Harness release closure - run: node scripts/verify-dsh-closure.mjs - - name: Build pinned Codex TUI + local IPC bridge - shell: bash - run: bash scripts/build-codex-tui.sh - - name: Verify thin-fork and transport invariants - run: node scripts/verify-tui-invariants.mjs - - name: Verify local IPC data plane - shell: bash - run: dist/bin/dshx-ipc-bridge --check - - name: Exercise real TUI through Linux PTY + CJK + resize - shell: bash - run: | - set -euo pipefail - VENV="$RUNNER_TEMP/dshx-pty-venv" - python3 -m venv "$VENV" - "$VENV/bin/python" -m pip install --disable-pip-version-check pexpect - "$VENV/bin/python" test/tui_smoke.py From 402bc7e852b607b5f66d3c4727514800efa23767 Mon Sep 17 00:00:00 2001 From: code-new-bie <403466121@qq.com> Date: Thu, 20 Aug 2026 18:34:24 +0800 Subject: [PATCH 221/305] chore: remove GitHub Actions workflows --- .github/workflows/rc-platform.yml | 73 ------------------------------- 1 file changed, 73 deletions(-) delete mode 100644 .github/workflows/rc-platform.yml diff --git a/.github/workflows/rc-platform.yml b/.github/workflows/rc-platform.yml deleted file mode 100644 index 9f0cb944..00000000 --- a/.github/workflows/rc-platform.yml +++ /dev/null @@ -1,73 +0,0 @@ -name: RC Platform Gate - -on: - push: - branches: ['release/**'] - workflow_dispatch: - -permissions: - contents: read - -concurrency: - group: rc-platform-${{ github.ref }} - cancel-in-progress: true - -jobs: - windows: - name: Windows ConPTY / CJK / local IPC - runs-on: windows-latest - timeout-minutes: 60 - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 - with: - node-version: 24 - - name: Install frozen dependency graph - run: npm ci - - name: Verify exact DeepSeek Harness release closure - run: node scripts/verify-dsh-closure.mjs - - name: Build pinned Codex TUI + local IPC bridge - shell: pwsh - run: ./scripts/build-codex-tui.ps1 - - name: Verify thin-fork and transport invariants - run: node scripts/verify-tui-invariants.mjs - - name: Verify Windows local IPC data plane - shell: pwsh - run: | - & .\dist\bin\dshx-ipc-bridge.exe --check - if ($LASTEXITCODE -ne 0) { throw 'local IPC data-plane self-check failed' } - - name: Install test-only ConPTY bridge - shell: pwsh - run: npm install --no-save --package-lock=false node-pty@1.1.0 - - name: Exercise real TUI through Windows ConPTY + CJK text - run: node test/tui_smoke_windows.mjs - - macos: - name: macOS PTY / CJK / resize / local IPC - runs-on: macos-26 - timeout-minutes: 60 - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 - with: - node-version: 24 - - name: Install frozen dependency graph - run: npm ci - - name: Verify exact DeepSeek Harness release closure - run: node scripts/verify-dsh-closure.mjs - - name: Build pinned Codex TUI + local IPC bridge - shell: bash - run: bash scripts/build-codex-tui.sh - - name: Verify thin-fork and transport invariants - run: node scripts/verify-tui-invariants.mjs - - name: Verify macOS local IPC data plane - shell: bash - run: dist/bin/dshx-ipc-bridge --check - - name: Exercise real TUI through macOS PTY + CJK + resize - shell: bash - run: | - set -euo pipefail - VENV="$RUNNER_TEMP/dshx-pty-venv" - python3 -m venv "$VENV" - "$VENV/bin/python" -m pip install --disable-pip-version-check pexpect - "$VENV/bin/python" test/tui_smoke.py From 31199176ac0dfdb69e83dbae16622507f9ef4d21 Mon Sep 17 00:00:00 2001 From: code-new-bie <403466121@qq.com> Date: Thu, 20 Aug 2026 18:34:28 +0800 Subject: [PATCH 222/305] chore: remove GitHub Actions workflows --- .github/workflows/release.yml | 215 ---------------------------------- 1 file changed, 215 deletions(-) delete mode 100644 .github/workflows/release.yml diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml deleted file mode 100644 index 1bcbfbdf..00000000 --- a/.github/workflows/release.yml +++ /dev/null @@ -1,215 +0,0 @@ -name: Release - -on: - push: - tags: - - 'v*' - -permissions: - contents: write - issues: read - pull-requests: read - -jobs: - build: - name: Build / ${{ matrix.label }} - runs-on: ${{ matrix.os }} - timeout-minutes: 75 - strategy: - fail-fast: false - matrix: - include: - - os: ubuntu-latest - label: linux-x64 - - os: windows-latest - label: windows-x64 - - os: macos-26 - label: macos-arm64 - - os: macos-26-intel - label: macos-x64 - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 - with: - node-version: 24 - - name: Enforce release tag lineage - env: - GH_TOKEN: ${{ github.token }} - shell: bash - run: | - set -euo pipefail - if [[ "$GITHUB_REF_NAME" == v1.0.0-rc.* ]]; then - expected="$(gh api "repos/$GITHUB_REPOSITORY/git/ref/heads/release/1.0-rc" --jq .object.sha)" - source_ref='release/1.0-rc' - else - expected="$(gh api "repos/$GITHUB_REPOSITORY/git/ref/heads/main" --jq .object.sha)" - source_ref='main' - fi - if [[ "$GITHUB_SHA" != "$expected" ]]; then - echo "release tag $GITHUB_REF_NAME must point at the current $source_ref head: expected=$expected actual=$GITHUB_SHA" >&2 - exit 1 - fi - - name: Enforce stable 1.0 promotion ledger - env: - GH_TOKEN: ${{ github.token }} - shell: bash - run: | - set -euo pipefail - if [[ "$GITHUB_REF_NAME" != "v1.0.0" ]]; then - exit 0 - fi - for issue in 12 13 14 15; do - state="$(gh issue view "$issue" --repo "$GITHUB_REPOSITORY" --json state --jq .state)" - if [[ "$state" != "CLOSED" ]]; then - echo "stable 1.0 is blocked: release-gate issue #$issue is $state" >&2 - exit 1 - fi - done - merged="$(gh pr view 11 --repo "$GITHUB_REPOSITORY" --json mergedAt --jq '.mergedAt != null')" - if [[ "$merged" != "true" ]]; then - echo 'stable 1.0 is blocked: PR #11 has not been merged' >&2 - exit 1 - fi - - name: Resolve release version (Unix) - if: runner.os != 'Windows' - shell: bash - run: echo "DSHX_VERSION=${GITHUB_REF_NAME#v}" >> "$GITHUB_ENV" - - name: Resolve release version (Windows) - if: runner.os == 'Windows' - shell: pwsh - run: Add-Content -Path $env:GITHUB_ENV -Value "DSHX_VERSION=$($env:GITHUB_REF_NAME.TrimStart('v'))" - - name: Test presentation adapter from frozen graph - run: | - npm ci - node scripts/verify-dsh-closure.mjs - npm test - - name: Boot official DSH runtime - run: node scripts/runtime-smoke.mjs - - name: Build pinned TUI and local IPC bridge (Unix) - if: runner.os != 'Windows' - shell: bash - run: bash scripts/build-codex-tui.sh - - name: Build pinned TUI and local IPC bridge (Windows) - if: runner.os == 'Windows' - shell: pwsh - run: ./scripts/build-codex-tui.ps1 - - name: Verify local IPC data plane (Unix) - if: runner.os != 'Windows' - shell: bash - run: dist/bin/dshx-ipc-bridge --check - - name: Verify local IPC data plane (Windows) - if: runner.os == 'Windows' - shell: pwsh - run: | - & .\dist\bin\dshx-ipc-bridge.exe --check - if ($LASTEXITCODE -ne 0) { throw 'local IPC data-plane self-check failed' } - - name: Real pinned TUI local-IPC PTY + CJK + resize smoke - if: runner.os != 'Windows' - shell: bash - run: | - set -euo pipefail - VENV="$RUNNER_TEMP/dshx-pty-venv" - python3 -m venv "$VENV" - "$VENV/bin/python" -m pip install --disable-pip-version-check pexpect - "$VENV/bin/python" test/tui_smoke.py - - name: Install test-only Windows ConPTY bridge - if: runner.os == 'Windows' - shell: pwsh - run: npm install --no-save --package-lock=false node-pty@1.1.0 - - name: Real pinned TUI Windows ConPTY + CJK local-IPC smoke - if: runner.os == 'Windows' - run: node test/tui_smoke_windows.mjs - - name: Build installable platform tarball (Unix) - if: runner.os != 'Windows' - shell: bash - run: node scripts/package-platform.mjs - - name: Build installable platform tarball (Windows) - if: runner.os == 'Windows' - shell: pwsh - run: node scripts/package-platform.mjs - - name: Clean install smoke (Unix) - if: runner.os != 'Windows' - shell: bash - run: | - set -euo pipefail - TGZ="$(find dist/release -maxdepth 1 -name 'dshx-*.tgz' -print -quit)" - PREFIX="$RUNNER_TEMP/dshx-release-smoke" - npm install --global --prefix "$PREFIX" "$TGZ" - "$PREFIX/bin/dshx" --version | grep -F "$DSHX_VERSION" - "$PREFIX/bin/dshx" doctor - - name: Clean install smoke (Windows) - if: runner.os == 'Windows' - shell: pwsh - run: | - $tgz = Get-ChildItem dist\release\dshx-*.tgz | Select-Object -First 1 - if (-not $tgz) { throw 'platform tarball missing' } - $prefix = Join-Path $env:RUNNER_TEMP 'dshx-release-smoke' - npm install --global --prefix $prefix $tgz.FullName - $version = & (Join-Path $prefix 'dshx.cmd') --version - if (-not ($version -match [regex]::Escape($env:DSHX_VERSION))) { throw "wrong dshx version: $version" } - & (Join-Path $prefix 'dshx.cmd') doctor - - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 - with: - name: dshx-${{ matrix.label }} - path: | - dist/release/*.tgz - dist/release/*.json - if-no-files-found: error - - publish: - name: Publish GitHub release - needs: build - runs-on: ubuntu-latest - steps: - - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 - with: - path: artifacts - merge-multiple: true - - name: Verify cross-platform artifact provenance - shell: bash - run: | - set -euo pipefail - python3 - <<'PY' - import glob - import json - import os - - files = sorted(glob.glob('artifacts/dshx-*.tgz.json')) - if len(files) != 4: - raise SystemExit(f'expected 4 platform provenance sidecars, found {len(files)}: {files}') - records = [] - for filename in files: - with open(filename, encoding='utf-8') as handle: - records.append((filename, json.load(handle))) - expected_version = os.environ['GITHUB_REF_NAME'].removeprefix('v') - keys = ('version', 'dshVersion', 'codexCommit', 'dshCommit', 'transport', 'dependencyGraph', 'sourceLockSha256') - baseline = {key: records[0][1].get(key) for key in keys} - if baseline['version'] != expected_version: - raise SystemExit(f"artifact version mismatch: expected {expected_version}, got {baseline['version']}") - if baseline['dependencyGraph'] != 'source-package-lock': - raise SystemExit(f"unexpected dependency graph provenance: {baseline['dependencyGraph']}") - lock_hash = baseline['sourceLockSha256'] - if not isinstance(lock_hash, str) or len(lock_hash) != 64 or any(ch not in '0123456789abcdef' for ch in lock_hash): - raise SystemExit(f'invalid source lock SHA-256: {lock_hash!r}') - for filename, record in records[1:]: - current = {key: record.get(key) for key in keys} - if current != baseline: - raise SystemExit(f'cross-platform provenance mismatch in {filename}: {current} != {baseline}') - print(json.dumps(baseline, sort_keys=True)) - PY - - name: Create checksums - shell: bash - run: | - set -euo pipefail - cd artifacts - sha256sum dshx-*.tgz dshx-*.tgz.json > SHA256SUMS - cat SHA256SUMS - - name: Create immutable GitHub release - env: - GH_TOKEN: ${{ github.token }} - shell: bash - run: | - set -euo pipefail - flags=(--verify-tag --generate-notes) - if [[ "$GITHUB_REF_NAME" == *-* ]]; then flags+=(--prerelease); fi - gh release create "$GITHUB_REF_NAME" artifacts/* "${flags[@]}" From b44f9ce51598b032cf6c507a41cc73e7d5c3c108 Mon Sep 17 00:00:00 2001 From: code-new-bie <403466121@qq.com> Date: Thu, 20 Aug 2026 20:30:58 +0800 Subject: [PATCH 223/305] ci: restore core and Linux release gates --- .github/workflows/ci.yml | 73 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 73 insertions(+) create mode 100644 .github/workflows/ci.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 00000000..3ba7ea47 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,73 @@ +name: CI + +on: + pull_request: + push: + branches: [main] + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true + +jobs: + core: + name: Core / Node 24 + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: 24 + - name: Install frozen dependency graph + run: npm ci + - name: Verify exact DeepSeek Harness release closure + run: node scripts/verify-dsh-closure.mjs + - name: Unit and contract tests + run: npm test + - name: Verify DSH ownership boundary + run: node scripts/verify-ownership-boundary.mjs + - name: Boot and dispose official DeepSeek Harness composition + run: node scripts/runtime-smoke.mjs + - name: Syntax check shipped JavaScript + shell: bash + run: | + set -euo pipefail + while IFS= read -r file; do node --check "$file"; done < <(find src bin scripts -type f -name '*.mjs' -print | sort) + - name: Syntax check PTY smoke + run: python3 -m py_compile test/tui_smoke.py + + linux-tui: + name: Linux TUI / pinned Codex / local IPC + needs: core + runs-on: ubuntu-latest + timeout-minutes: 60 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: 24 + - name: Install frozen dependency graph + run: npm ci + - name: Verify exact DeepSeek Harness release closure + run: node scripts/verify-dsh-closure.mjs + - name: Build pinned Codex TUI + local IPC bridge + shell: bash + run: bash scripts/build-codex-tui.sh + - name: Verify thin-fork and transport invariants + run: node scripts/verify-tui-invariants.mjs + - name: Verify local IPC data plane + shell: bash + run: dist/bin/dshx-ipc-bridge --check + - name: Exercise real TUI through Linux PTY + CJK + resize + shell: bash + run: | + set -euo pipefail + VENV="$RUNNER_TEMP/dshx-pty-venv" + python3 -m venv "$VENV" + "$VENV/bin/python" -m pip install --disable-pip-version-check pexpect + "$VENV/bin/python" test/tui_smoke.py From 58074250d70c04f7b70af5e2992160a844dc4a82 Mon Sep 17 00:00:00 2001 From: code-new-bie <403466121@qq.com> Date: Thu, 20 Aug 2026 20:31:10 +0800 Subject: [PATCH 224/305] ci: restore RC platform gates --- .github/workflows/rc-platform.yml | 73 +++++++++++++++++++++++++++++++ 1 file changed, 73 insertions(+) create mode 100644 .github/workflows/rc-platform.yml diff --git a/.github/workflows/rc-platform.yml b/.github/workflows/rc-platform.yml new file mode 100644 index 00000000..9f0cb944 --- /dev/null +++ b/.github/workflows/rc-platform.yml @@ -0,0 +1,73 @@ +name: RC Platform Gate + +on: + push: + branches: ['release/**'] + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: rc-platform-${{ github.ref }} + cancel-in-progress: true + +jobs: + windows: + name: Windows ConPTY / CJK / local IPC + runs-on: windows-latest + timeout-minutes: 60 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: 24 + - name: Install frozen dependency graph + run: npm ci + - name: Verify exact DeepSeek Harness release closure + run: node scripts/verify-dsh-closure.mjs + - name: Build pinned Codex TUI + local IPC bridge + shell: pwsh + run: ./scripts/build-codex-tui.ps1 + - name: Verify thin-fork and transport invariants + run: node scripts/verify-tui-invariants.mjs + - name: Verify Windows local IPC data plane + shell: pwsh + run: | + & .\dist\bin\dshx-ipc-bridge.exe --check + if ($LASTEXITCODE -ne 0) { throw 'local IPC data-plane self-check failed' } + - name: Install test-only ConPTY bridge + shell: pwsh + run: npm install --no-save --package-lock=false node-pty@1.1.0 + - name: Exercise real TUI through Windows ConPTY + CJK text + run: node test/tui_smoke_windows.mjs + + macos: + name: macOS PTY / CJK / resize / local IPC + runs-on: macos-26 + timeout-minutes: 60 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: 24 + - name: Install frozen dependency graph + run: npm ci + - name: Verify exact DeepSeek Harness release closure + run: node scripts/verify-dsh-closure.mjs + - name: Build pinned Codex TUI + local IPC bridge + shell: bash + run: bash scripts/build-codex-tui.sh + - name: Verify thin-fork and transport invariants + run: node scripts/verify-tui-invariants.mjs + - name: Verify macOS local IPC data plane + shell: bash + run: dist/bin/dshx-ipc-bridge --check + - name: Exercise real TUI through macOS PTY + CJK + resize + shell: bash + run: | + set -euo pipefail + VENV="$RUNNER_TEMP/dshx-pty-venv" + python3 -m venv "$VENV" + "$VENV/bin/python" -m pip install --disable-pip-version-check pexpect + "$VENV/bin/python" test/tui_smoke.py From 69453c7151df97ad865888ef91b42ff9d84f0452 Mon Sep 17 00:00:00 2001 From: code-new-bie <403466121@qq.com> Date: Thu, 20 Aug 2026 20:31:35 +0800 Subject: [PATCH 225/305] ci: restore release matrix --- .github/workflows/release.yml | 215 ++++++++++++++++++++++++++++++++++ 1 file changed, 215 insertions(+) create mode 100644 .github/workflows/release.yml diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 00000000..1bcbfbdf --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,215 @@ +name: Release + +on: + push: + tags: + - 'v*' + +permissions: + contents: write + issues: read + pull-requests: read + +jobs: + build: + name: Build / ${{ matrix.label }} + runs-on: ${{ matrix.os }} + timeout-minutes: 75 + strategy: + fail-fast: false + matrix: + include: + - os: ubuntu-latest + label: linux-x64 + - os: windows-latest + label: windows-x64 + - os: macos-26 + label: macos-arm64 + - os: macos-26-intel + label: macos-x64 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: 24 + - name: Enforce release tag lineage + env: + GH_TOKEN: ${{ github.token }} + shell: bash + run: | + set -euo pipefail + if [[ "$GITHUB_REF_NAME" == v1.0.0-rc.* ]]; then + expected="$(gh api "repos/$GITHUB_REPOSITORY/git/ref/heads/release/1.0-rc" --jq .object.sha)" + source_ref='release/1.0-rc' + else + expected="$(gh api "repos/$GITHUB_REPOSITORY/git/ref/heads/main" --jq .object.sha)" + source_ref='main' + fi + if [[ "$GITHUB_SHA" != "$expected" ]]; then + echo "release tag $GITHUB_REF_NAME must point at the current $source_ref head: expected=$expected actual=$GITHUB_SHA" >&2 + exit 1 + fi + - name: Enforce stable 1.0 promotion ledger + env: + GH_TOKEN: ${{ github.token }} + shell: bash + run: | + set -euo pipefail + if [[ "$GITHUB_REF_NAME" != "v1.0.0" ]]; then + exit 0 + fi + for issue in 12 13 14 15; do + state="$(gh issue view "$issue" --repo "$GITHUB_REPOSITORY" --json state --jq .state)" + if [[ "$state" != "CLOSED" ]]; then + echo "stable 1.0 is blocked: release-gate issue #$issue is $state" >&2 + exit 1 + fi + done + merged="$(gh pr view 11 --repo "$GITHUB_REPOSITORY" --json mergedAt --jq '.mergedAt != null')" + if [[ "$merged" != "true" ]]; then + echo 'stable 1.0 is blocked: PR #11 has not been merged' >&2 + exit 1 + fi + - name: Resolve release version (Unix) + if: runner.os != 'Windows' + shell: bash + run: echo "DSHX_VERSION=${GITHUB_REF_NAME#v}" >> "$GITHUB_ENV" + - name: Resolve release version (Windows) + if: runner.os == 'Windows' + shell: pwsh + run: Add-Content -Path $env:GITHUB_ENV -Value "DSHX_VERSION=$($env:GITHUB_REF_NAME.TrimStart('v'))" + - name: Test presentation adapter from frozen graph + run: | + npm ci + node scripts/verify-dsh-closure.mjs + npm test + - name: Boot official DSH runtime + run: node scripts/runtime-smoke.mjs + - name: Build pinned TUI and local IPC bridge (Unix) + if: runner.os != 'Windows' + shell: bash + run: bash scripts/build-codex-tui.sh + - name: Build pinned TUI and local IPC bridge (Windows) + if: runner.os == 'Windows' + shell: pwsh + run: ./scripts/build-codex-tui.ps1 + - name: Verify local IPC data plane (Unix) + if: runner.os != 'Windows' + shell: bash + run: dist/bin/dshx-ipc-bridge --check + - name: Verify local IPC data plane (Windows) + if: runner.os == 'Windows' + shell: pwsh + run: | + & .\dist\bin\dshx-ipc-bridge.exe --check + if ($LASTEXITCODE -ne 0) { throw 'local IPC data-plane self-check failed' } + - name: Real pinned TUI local-IPC PTY + CJK + resize smoke + if: runner.os != 'Windows' + shell: bash + run: | + set -euo pipefail + VENV="$RUNNER_TEMP/dshx-pty-venv" + python3 -m venv "$VENV" + "$VENV/bin/python" -m pip install --disable-pip-version-check pexpect + "$VENV/bin/python" test/tui_smoke.py + - name: Install test-only Windows ConPTY bridge + if: runner.os == 'Windows' + shell: pwsh + run: npm install --no-save --package-lock=false node-pty@1.1.0 + - name: Real pinned TUI Windows ConPTY + CJK local-IPC smoke + if: runner.os == 'Windows' + run: node test/tui_smoke_windows.mjs + - name: Build installable platform tarball (Unix) + if: runner.os != 'Windows' + shell: bash + run: node scripts/package-platform.mjs + - name: Build installable platform tarball (Windows) + if: runner.os == 'Windows' + shell: pwsh + run: node scripts/package-platform.mjs + - name: Clean install smoke (Unix) + if: runner.os != 'Windows' + shell: bash + run: | + set -euo pipefail + TGZ="$(find dist/release -maxdepth 1 -name 'dshx-*.tgz' -print -quit)" + PREFIX="$RUNNER_TEMP/dshx-release-smoke" + npm install --global --prefix "$PREFIX" "$TGZ" + "$PREFIX/bin/dshx" --version | grep -F "$DSHX_VERSION" + "$PREFIX/bin/dshx" doctor + - name: Clean install smoke (Windows) + if: runner.os == 'Windows' + shell: pwsh + run: | + $tgz = Get-ChildItem dist\release\dshx-*.tgz | Select-Object -First 1 + if (-not $tgz) { throw 'platform tarball missing' } + $prefix = Join-Path $env:RUNNER_TEMP 'dshx-release-smoke' + npm install --global --prefix $prefix $tgz.FullName + $version = & (Join-Path $prefix 'dshx.cmd') --version + if (-not ($version -match [regex]::Escape($env:DSHX_VERSION))) { throw "wrong dshx version: $version" } + & (Join-Path $prefix 'dshx.cmd') doctor + - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: dshx-${{ matrix.label }} + path: | + dist/release/*.tgz + dist/release/*.json + if-no-files-found: error + + publish: + name: Publish GitHub release + needs: build + runs-on: ubuntu-latest + steps: + - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + path: artifacts + merge-multiple: true + - name: Verify cross-platform artifact provenance + shell: bash + run: | + set -euo pipefail + python3 - <<'PY' + import glob + import json + import os + + files = sorted(glob.glob('artifacts/dshx-*.tgz.json')) + if len(files) != 4: + raise SystemExit(f'expected 4 platform provenance sidecars, found {len(files)}: {files}') + records = [] + for filename in files: + with open(filename, encoding='utf-8') as handle: + records.append((filename, json.load(handle))) + expected_version = os.environ['GITHUB_REF_NAME'].removeprefix('v') + keys = ('version', 'dshVersion', 'codexCommit', 'dshCommit', 'transport', 'dependencyGraph', 'sourceLockSha256') + baseline = {key: records[0][1].get(key) for key in keys} + if baseline['version'] != expected_version: + raise SystemExit(f"artifact version mismatch: expected {expected_version}, got {baseline['version']}") + if baseline['dependencyGraph'] != 'source-package-lock': + raise SystemExit(f"unexpected dependency graph provenance: {baseline['dependencyGraph']}") + lock_hash = baseline['sourceLockSha256'] + if not isinstance(lock_hash, str) or len(lock_hash) != 64 or any(ch not in '0123456789abcdef' for ch in lock_hash): + raise SystemExit(f'invalid source lock SHA-256: {lock_hash!r}') + for filename, record in records[1:]: + current = {key: record.get(key) for key in keys} + if current != baseline: + raise SystemExit(f'cross-platform provenance mismatch in {filename}: {current} != {baseline}') + print(json.dumps(baseline, sort_keys=True)) + PY + - name: Create checksums + shell: bash + run: | + set -euo pipefail + cd artifacts + sha256sum dshx-*.tgz dshx-*.tgz.json > SHA256SUMS + cat SHA256SUMS + - name: Create immutable GitHub release + env: + GH_TOKEN: ${{ github.token }} + shell: bash + run: | + set -euo pipefail + flags=(--verify-tag --generate-notes) + if [[ "$GITHUB_REF_NAME" == *-* ]]; then flags+=(--prerelease); fi + gh release create "$GITHUB_REF_NAME" artifacts/* "${flags[@]}" From 0fc96fe8d55f202fd7821e1a82fb02315698d416 Mon Sep 17 00:00:00 2001 From: code-new-bie <403466121@qq.com> Date: Thu, 20 Aug 2026 21:13:42 +0800 Subject: [PATCH 226/305] ci: isolate unit tests from platform smoke --- package.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/package.json b/package.json index 00fb24e1..a93b9ec1 100644 --- a/package.json +++ b/package.json @@ -10,9 +10,9 @@ "dshx": "./bin/dshx.mjs" }, "scripts": { - "test": "node --test", - "test:protocol": "node --test test/protocol.test.mjs test/wire.test.mjs", - "test:dsh": "node --test test/dsh-*.test.mjs", + "test": "node --test --test-force-exit \"test/**/*.test.mjs\"", + "test:protocol": "node --test --test-force-exit test/protocol.test.mjs test/wire.test.mjs", + "test:dsh": "node --test --test-force-exit \"test/dsh-*.test.mjs\"", "freeze:deps": "node scripts/freeze-deps.mjs", "verify:release-deps": "node scripts/verify-dsh-closure.mjs . package-lock.json", "stub": "node ./bin/dshx-stub-server.mjs", From 2c55cd47b0c99a06790dd160b8b7d3d1f76af989 Mon Sep 17 00:00:00 2001 From: code-new-bie <403466121@qq.com> Date: Thu, 20 Aug 2026 21:14:11 +0800 Subject: [PATCH 227/305] fix: resolve blank resume model lazily --- src/dsh/agent-driver.mjs | 29 ++++++++++++++++------------- 1 file changed, 16 insertions(+), 13 deletions(-) diff --git a/src/dsh/agent-driver.mjs b/src/dsh/agent-driver.mjs index 5e757bd6..3641bc70 100644 --- a/src/dsh/agent-driver.mjs +++ b/src/dsh/agent-driver.mjs @@ -30,16 +30,19 @@ export class DshAgentDriver { await this.ctx.get('loader')?.await?.(); } - restoredSelection(agent) { + persistedSelection(agent) { const logged = agent.session.requestHeader?.()?.config; - if (logged !== undefined) { - return { - provider: logged.provider, - model: logged.model, - ...(logged.reasoningEffort === undefined ? {} : { reasoningEffort: logged.reasoningEffort }) - }; - } - return requireService(this.ctx, 'agentDefaultModel').currentSelection(); + if (logged === undefined) return undefined; + return { + provider: logged.provider, + model: logged.model, + ...(logged.reasoningEffort === undefined ? {} : { reasoningEffort: logged.reasoningEffort }) + }; + } + + restoredSelection(agent) { + return this.persistedSelection(agent) + ?? requireService(this.ctx, 'agentDefaultModel').currentSelection(); } selectionFor(agent) { @@ -48,16 +51,16 @@ export class DshAgentDriver { if (this.externalSelections.has(agent)) { throw new Error('DSHX must not install a second model-selection listener on a Host-owned Agent'); } - let picked; + const driver = this; + let picked = this.persistedSelection(agent); const selection = { get current() { - if (picked !== undefined) return picked; - return undefined; + return picked + ?? requireService(driver.ctx, 'agentDefaultModel').currentSelection(); }, set current(next) { picked = next; }, assembled: undefined }; - selection.current = this.restoredSelection(agent); installModelSelection(agent.ctx, selection); this.selections.set(agent, selection); return selection; From 92b14a59123455b05b7e28b738af1b674bf1b501 Mon Sep 17 00:00:00 2001 From: code-new-bie <403466121@qq.com> Date: Thu, 20 Aug 2026 21:14:35 +0800 Subject: [PATCH 228/305] test: assert DSH command-owned compaction --- test/dsh-compaction-projection.test.mjs | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/test/dsh-compaction-projection.test.mjs b/test/dsh-compaction-projection.test.mjs index c7b50637..459945e8 100644 --- a/test/dsh-compaction-projection.test.mjs +++ b/test/dsh-compaction-projection.test.mjs @@ -78,22 +78,23 @@ test('resumed human transcript keeps shadowed history and adds one compaction ma assert.equal(JSON.stringify(turns).includes('MODEL ONLY CHECKPOINT'), false); }); -test('thread/compact/start delegates to DSH compaction and reports no-op without durable fabrication', async () => { +test('thread/compact/start delegates to the official DSH command plane and reports a no-op result', async () => { const adapter = Object.create(DshxProductAdapter.prototype); const warnings = []; adapter.send = (message) => warnings.push(message); adapter.diagnostics = () => {}; - adapter.controllers = new Map([['session-1', { agent: { id: 'session-1' } }]]); + const agent = { id: 'session-1' }; + adapter.controllers = new Map([['session-1', { agent }]]); adapter._manualCompactions = new Map(); - let compactCalls = 0; + const commandCalls = []; adapter.ctx = { get(name) { - if (name === 'compaction') { + if (name === 'commands') { return { - async compactNow(_agent, signal) { - compactCalls += 1; + async execute(receivedAgent, line, images, signal) { + commandCalls.push([receivedAgent, line, images]); assert.equal(signal.aborted, false); - return null; + return { result: { text: 'No compactable history yet.' } }; } }; } @@ -105,7 +106,7 @@ test('thread/compact/start delegates to DSH compaction and reports no-op without assert.deepEqual(response.result, {}); response.afterResponse(); await new Promise((resolve) => setImmediate(resolve)); - assert.equal(compactCalls, 1); + assert.deepEqual(commandCalls, [[agent, '/compact', []]]); assert.deepEqual(warnings, [{ method: 'warning', params: { threadId: 'session-1', message: 'No compactable history yet.' } From 910f68c6ded41949a60e3ad5d3745f54ba28550b Mon Sep 17 00:00:00 2001 From: code-new-bie <403466121@qq.com> Date: Thu, 20 Aug 2026 21:15:01 +0800 Subject: [PATCH 229/305] test: align fork contract with DSH Host ownership --- test/dsh-product-fork.test.mjs | 101 +++++++++++++++++---------------- 1 file changed, 53 insertions(+), 48 deletions(-) diff --git a/test/dsh-product-fork.test.mjs b/test/dsh-product-fork.test.mjs index 0e4b5085..e6271e92 100644 --- a/test/dsh-product-fork.test.mjs +++ b/test/dsh-product-fork.test.mjs @@ -28,33 +28,35 @@ function adapterFixture({ cold = false } = {}) { } }; const driverCalls = []; - let sourceDisposed = 0; + const hostCalls = []; + const adoptions = []; adapter.driver = { getLive(id) { - return !cold && id === source.id ? source : undefined; + driverCalls.push(['getLive', id]); + if (!cold && id === source.id) return source; + if (id === target.id) return target; + return undefined; }, - async resume(id) { + async inspectSession(id) { + driverCalls.push(['inspectSession', id]); assert.equal(id, source.id); - driverCalls.push(['resume', id]); - return { agent: source, async dispose() { sourceDisposed += 1; } }; + return { id, events: source.session.events }; }, - async fork(receivedSource, options) { - assert.equal(receivedSource, source); - driverCalls.push(['fork', options]); - return { agent: target, async dispose() {} }; + adoptExternalSelection(agent, options) { + driverCalls.push(['adoptExternalSelection', agent.id]); + adoptions.push({ agent, options }); } }; - const presetSets = []; - adapter.permissions = { - current(agent) { - assert.equal(agent, source); - return { preset: 'workspace-write' }; + adapter.hostApi = () => ({ + async forkSession(options) { + hostCalls.push(['forkSession', options]); + return { sessionId: target.id }; }, - set(agent, preset) { - assert.equal(agent, target); - presetSets.push(preset); + async selectModel(options) { + hostCalls.push(['selectModel', options]); + return options; } - }; + }); adapter.installController = (handle) => { const controller = { agent: handle.agent }; adapter.controllers.set(String(handle.agent.id), controller); @@ -65,52 +67,55 @@ function adapterFixture({ cold = false } = {}) { id: String(agent.id), forkedFromId: source.id, turns: options.includeTurns ? ['history'] : [] - }, - model: 'dshx:model', - modelProvider: 'deepseek', - serviceTier: null, - cwd: process.cwd(), - runtimeWorkspaceRoots: [], - instructionSources: [], - approvalPolicy: 'on-request', - approvalsReviewer: 'user', - sandbox: { type: 'externalSandbox', networkAccess: 'enabled' }, - activePermissionProfile: { id: ':workspace', extends: null }, - reasoningEffort: null, - multiAgentMode: 'explicitRequestOnly' + } }); - return { - adapter, - source, - target, - driverCalls, - presetSets, - get sourceDisposed() { return sourceDisposed; } - }; + return { adapter, source, target, driverCalls, hostCalls, adoptions }; } -test('thread/fork uses DSH seed prefix and inherits DSH permission preset', async () => { +test('thread/fork translates the Codex turn anchor and delegates creation to DSH Host', async () => { const fx = adapterFixture(); const response = await fx.adapter.threadFork({ threadId: fx.source.id, lastTurnId: 'dsh-turn-1', excludeTurns: true }); - const forkCall = fx.driverCalls.find(([name]) => name === 'fork'); - assert.ok(forkCall); - assert.deepEqual(forkCall[1].seed.map((event) => event.seq), [1, 2]); - assert.match(forkCall[1].sessionId, /^[0-9a-f-]{36}$/i); - assert.deepEqual(fx.presetSets, ['workspace-write']); + + assert.deepEqual(fx.hostCalls[0], [ + 'forkSession', + { sessionId: fx.source.id, atSeq: 2 } + ]); + assert.equal(fx.adoptions.length, 1); + assert.equal(fx.adoptions[0].agent, fx.target); assert.equal(response.result.thread.id, fx.target.id); assert.deepEqual(response.result.thread.turns, []); assert.equal(typeof response.afterResponse, 'function'); + + await fx.adoptions[0].options.select({ + provider: 'deepseek', + model: 'reasoner', + reasoningEffort: 'high' + }); + assert.deepEqual(fx.hostCalls[1], [ + 'selectModel', + { + sessionId: fx.target.id, + provider: 'deepseek', + model: 'reasoner', + reasoningEffort: 'high' + } + ]); }); -test('cold thread/fork temporarily resumes official DSH source and releases it', async () => { +test('cold thread/fork reads DSH persistence without temporarily resuming or cloning source state', async () => { const fx = adapterFixture({ cold: true }); await fx.adapter.threadFork({ threadId: fx.source.id }); - assert.deepEqual(fx.driverCalls.map(([name]) => name), ['resume', 'fork']); - assert.equal(fx.sourceDisposed, 1); + + assert.ok(fx.driverCalls.some((call) => call[0] === 'inspectSession' && call[1] === fx.source.id)); + assert.equal(fx.driverCalls.some((call) => call[0] === 'resume'), false); + assert.deepEqual(fx.hostCalls[0], [ + 'forkSession', + { sessionId: fx.source.id, atSeq: undefined } + ]); }); test('thread/fork rejects Codex-only fork state DSH cannot faithfully own', async () => { From e07265b53836ed084e6a3dcf292b612faa052f65 Mon Sep 17 00:00:00 2001 From: code-new-bie <403466121@qq.com> Date: Thu, 20 Aug 2026 21:17:03 +0800 Subject: [PATCH 230/305] ci: recount maintained Codex patches --- scripts/materialize-codex.sh | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/scripts/materialize-codex.sh b/scripts/materialize-codex.sh index 4b616347..3a1cde68 100644 --- a/scripts/materialize-codex.sh +++ b/scripts/materialize-codex.sh @@ -21,8 +21,11 @@ git -C "$DEST" clean -ffd >/dev/null for patch in "$ROOT"/upstream/patches/codex/*.patch; do [[ -e "$patch" ]] || continue - git -C "$DEST" apply --check "$patch" - git -C "$DEST" apply "$patch" + # The maintained thin-fork patches are intentionally hand-readable and may + # have stale hunk line counts after upstream rebases. Recount from the hunk + # bodies while still requiring every context line to match the pinned Codex. + git -C "$DEST" apply --recount --check "$patch" + git -C "$DEST" apply --recount "$patch" done printf '%s\n' "Materialized Codex $PIN at $DEST" From 7fa4d053c705c18ddb2483d4d7f02c3b076a98ea Mon Sep 17 00:00:00 2001 From: code-new-bie <403466121@qq.com> Date: Thu, 20 Aug 2026 21:17:12 +0800 Subject: [PATCH 231/305] ci: fail closed on Windows Codex patching --- scripts/materialize-codex.ps1 | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/scripts/materialize-codex.ps1 b/scripts/materialize-codex.ps1 index 5ed87cc7..2add41fb 100644 --- a/scripts/materialize-codex.ps1 +++ b/scripts/materialize-codex.ps1 @@ -1,4 +1,5 @@ $ErrorActionPreference = 'Stop' +$PSNativeCommandUseErrorActionPreference = $true $Root = (Resolve-Path (Join-Path $PSScriptRoot '..')).Path $Pin = (Get-Content (Join-Path $Root 'upstream/CODEX_COMMIT') -Raw).Trim() @@ -20,8 +21,10 @@ git -C $Dest clean -ffd | Out-Null $Patches = Get-ChildItem (Join-Path $Root 'upstream\patches\codex\*.patch') -ErrorAction SilentlyContinue | Sort-Object Name foreach ($Patch in $Patches) { - git -C $Dest apply --check $Patch.FullName - git -C $Dest apply $Patch.FullName + # Recount stale hand-maintained hunk line counts, but still require every + # context line to match the pinned Codex commit before applying the patch. + git -C $Dest apply --recount --check $Patch.FullName + git -C $Dest apply --recount $Patch.FullName } Write-Host "Materialized Codex $Pin at $Dest" From 2e3bc7d3a65fec4e96776f25c18700f5507bb416 Mon Sep 17 00:00:00 2001 From: code-new-bie <403466121@qq.com> Date: Thu, 20 Aug 2026 21:17:19 +0800 Subject: [PATCH 232/305] ci: bound each unit test file --- scripts/run-unit-tests.mjs | 38 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) create mode 100644 scripts/run-unit-tests.mjs diff --git a/scripts/run-unit-tests.mjs b/scripts/run-unit-tests.mjs new file mode 100644 index 00000000..eddede03 --- /dev/null +++ b/scripts/run-unit-tests.mjs @@ -0,0 +1,38 @@ +import { spawnSync } from 'node:child_process'; +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const TEST_DIR = path.join(ROOT, 'test'); +const TIMEOUT_MS = 30_000; + +const files = fs.readdirSync(TEST_DIR, { withFileTypes: true }) + .filter((entry) => entry.isFile() && entry.name.endsWith('.test.mjs')) + .map((entry) => path.join('test', entry.name)) + .sort(); + +if (files.length === 0) throw new Error('No unit test files found'); + +for (const file of files) { + process.stdout.write(`\n==> ${file}\n`); + const result = spawnSync( + process.execPath, + ['--test', '--test-force-exit', file], + { + cwd: ROOT, + stdio: 'inherit', + timeout: TIMEOUT_MS, + killSignal: 'SIGTERM' + } + ); + + if (result.error?.code === 'ETIMEDOUT') { + console.error(`Unit test file exceeded ${TIMEOUT_MS / 1000}s: ${file}`); + process.exit(124); + } + if (result.error) throw result.error; + if (result.status !== 0) process.exit(result.status ?? 1); +} + +console.log(`\nPassed ${files.length} unit/contract test files.`); From 77bfe53176de788aba93bff68ae7c0a8d39429b5 Mon Sep 17 00:00:00 2001 From: code-new-bie <403466121@qq.com> Date: Thu, 20 Aug 2026 21:17:31 +0800 Subject: [PATCH 233/305] ci: run bounded unit test files --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index a93b9ec1..12a63b79 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,7 @@ "dshx": "./bin/dshx.mjs" }, "scripts": { - "test": "node --test --test-force-exit \"test/**/*.test.mjs\"", + "test": "node scripts/run-unit-tests.mjs", "test:protocol": "node --test --test-force-exit test/protocol.test.mjs test/wire.test.mjs", "test:dsh": "node --test --test-force-exit \"test/dsh-*.test.mjs\"", "freeze:deps": "node scripts/freeze-deps.mjs", From 175c582bc4367c823cc660171763e2a5ef17b9c7 Mon Sep 17 00:00:00 2001 From: code-new-bie <403466121@qq.com> Date: Thu, 20 Aug 2026 21:21:05 +0800 Subject: [PATCH 234/305] test: close wire client before server --- test/wire.test.mjs | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/test/wire.test.mjs b/test/wire.test.mjs index 6c605e6e..9ced428a 100644 --- a/test/wire.test.mjs +++ b/test/wire.test.mjs @@ -72,10 +72,11 @@ function closeSocket(socket) { test('wire: Codex-style initialize → thread → streamed turn lifecycle', async (t) => { const server = await startProtocolStubServer({ eventDelayMs: 2 }); - t.after(async () => server.close()); - const socket = await openSocket(server.url); - t.after(async () => closeSocket(socket)); + t.after(async () => { + await closeSocket(socket); + await server.close(); + }); const inbox = createInbox(socket); send(socket, { From 8c1c579af9ca6a0180f347851871cae5330933c9 Mon Sep 17 00:00:00 2001 From: code-new-bie <403466121@qq.com> Date: Thu, 20 Aug 2026 21:21:30 +0800 Subject: [PATCH 235/305] fix: make protocol stub shutdown self-contained --- src/server.mjs | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/src/server.mjs b/src/server.mjs index 2250fde7..ad4b3fdf 100644 --- a/src/server.mjs +++ b/src/server.mjs @@ -105,14 +105,23 @@ export async function startProtocolStubServer({ throw new Error('Protocol stub server did not bind a TCP address'); } + let closePromise; + const close = () => { + if (closePromise) return closePromise; + closePromise = new Promise((resolve, reject) => { + // WebSocketServer.close() waits for connected clients. Stub shutdown owns + // those test/dev clients so callers cannot deadlock by closing the server first. + for (const client of server.clients) client.terminate(); + server.close((error) => (error ? reject(error) : resolve())); + }); + return closePromise; + }; + return { host, port: address.port, url: `ws://${host}:${address.port}`, token, - close: () => - new Promise((resolve, reject) => { - server.close((error) => (error ? reject(error) : resolve())); - }) + close }; } From 9734de19628200b9165506be22b8952d91c2b967 Mon Sep 17 00:00:00 2001 From: code-new-bie <403466121@qq.com> Date: Thu, 20 Aug 2026 21:22:49 +0800 Subject: [PATCH 236/305] ci: verify current official DSH composition contract --- scripts/verify-ownership-boundary.mjs | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/scripts/verify-ownership-boundary.mjs b/scripts/verify-ownership-boundary.mjs index 2e77d649..4177d876 100644 --- a/scripts/verify-ownership-boundary.mjs +++ b/scripts/verify-ownership-boundary.mjs @@ -22,9 +22,19 @@ function forbidText(path, pattern, label = String(pattern)) { requireText('docs/ARCHITECTURE.md', 'must not grow into an agent framework'); requireText('docs/TEAM.md', 'DeepSeek Harness remains an upstream dependency'); -requireText('src/dsh/runtime-boot.mjs', 'dsh-base'); -requireText('src/dsh/runtime-boot.mjs', 'dsh-headless'); -requireText('src/dsh/runtime-boot.mjs', 'startup: { disabled: true }'); +// Runtime composition must stay on the official DSH app-boot/profile plane. +// Do not key this guard to historical bundle row ids such as `dsh-base`: +// those are upstream implementation details, not the ownership boundary. +requireText('src/dsh/runtime-boot.mjs', "from '@deepseek-ai/dsh-app-boot'"); +requireText('src/dsh/runtime-boot.mjs', "const DEFAULT_PROFILE = 'headless'"); +requireText('src/dsh/runtime-boot.mjs', 'loadProfile('); +requireText('src/dsh/runtime-boot.mjs', 'composeEntries('); +requireText('src/dsh/runtime-boot.mjs', "{ id: 'headless-startup', disabled: true }"); +requireText('src/dsh/runtime-boot.mjs', "{ id: 'headless-runner', disabled: true }"); +requireText('src/dsh/runtime-boot.mjs', 'const ctx = await boot('); +requireText('src/dsh/runtime-boot.mjs', 'profile.layers.flatMap((layer) => layer.patches)'); +requireText('src/dsh/runtime-boot.mjs', 'profile.patches'); +requireText('src/dsh/runtime-boot.mjs', 'homePatches'); for (const path of ['src/dsh/user-shell.mjs', 'src/dsh/workspace-command.mjs']) { forbidText(path, /execFile|execSync|spawn\(/, 'local process execution'); From 0a3373e46361690c510c6db43587582fefaacd80 Mon Sep 17 00:00:00 2001 From: code-new-bie <403466121@qq.com> Date: Thu, 20 Aug 2026 21:24:27 +0800 Subject: [PATCH 237/305] ci: smoke only runtime services DSHX actually consumes --- scripts/runtime-smoke.mjs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/scripts/runtime-smoke.mjs b/scripts/runtime-smoke.mjs index 01e904ba..dbf4cd88 100644 --- a/scripts/runtime-smoke.mjs +++ b/scripts/runtime-smoke.mjs @@ -11,7 +11,6 @@ const REQUIRED_SERVICES = [ 'sessionQuery', 'sessionProjections', 'sessionTitle', - 'workspaceRegistry', 'attachments', 'tools', 'commands', @@ -37,9 +36,10 @@ try { if (missing.length > 0) { throw new Error(`official DSH composition is missing required presentation services: ${missing.join(', ')}`); } - // Command discovery is Agent-scoped in DSH rc.8. Do not invent a fake Agent - // just to inspect /compact here: the product-level delegation test proves the - // command call shape, while this smoke proves the official shipped services compose. + // Workspace command probes are Agent-scoped and use the official `tools` + // service. `workspaceRegistry` was an older composition detail and is not a + // DSHX product dependency. Command discovery is likewise Agent-scoped in + // DSH rc.8, so do not invent a fake Agent merely to inspect /compact here. process.stdout.write(`official DSH runtime booted: ${REQUIRED_SERVICES.join(', ')}\n`); } finally { clearTimeout(timer); From 68696d8bf31764dd5ab8b8a0e84340fc9174a961 Mon Sep 17 00:00:00 2001 From: code-new-bie <403466121@qq.com> Date: Thu, 20 Aug 2026 21:27:27 +0800 Subject: [PATCH 238/305] ci: bound DSH runtime boot and disposal phases --- scripts/runtime-smoke.mjs | 42 ++++++++++++++++++++++++++++++--------- 1 file changed, 33 insertions(+), 9 deletions(-) diff --git a/scripts/runtime-smoke.mjs b/scripts/runtime-smoke.mjs index dbf4cd88..0c1e3f1c 100644 --- a/scripts/runtime-smoke.mjs +++ b/scripts/runtime-smoke.mjs @@ -23,15 +23,27 @@ const REQUIRED_SERVICES = [ 'planMode' ]; -let ctx; -const timer = setTimeout(() => { - process.stderr.write('dshx runtime smoke timed out while booting official DeepSeek Harness\n'); - process.exit(124); -}, 30_000); -timer.unref?.(); +function withTimeout(promise, timeoutMs, label) { + let timer; + return Promise.race([ + promise, + new Promise((_, reject) => { + timer = setTimeout(() => reject(new Error(`${label} exceeded ${timeoutMs}ms`)), timeoutMs); + }) + ]).finally(() => clearTimeout(timer)); +} +let ctx; +let primaryError; try { - ctx = await bootDshxRuntime({ cwd: process.cwd() }); + process.stdout.write('booting official DSH composition with production patch watchers enabled\n'); + ctx = await withTimeout( + bootDshxRuntime({ cwd: process.cwd(), watch: true }), + 20_000, + 'official DSH composition boot' + ); + process.stdout.write('official DSH composition boot completed\n'); + const missing = REQUIRED_SERVICES.filter((name) => ctx.get(name) == null); if (missing.length > 0) { throw new Error(`official DSH composition is missing required presentation services: ${missing.join(', ')}`); @@ -41,7 +53,19 @@ try { // DSHX product dependency. Command discovery is likewise Agent-scoped in // DSH rc.8, so do not invent a fake Agent merely to inspect /compact here. process.stdout.write(`official DSH runtime booted: ${REQUIRED_SERVICES.join(', ')}\n`); +} catch (error) { + primaryError = error; } finally { - clearTimeout(timer); - await ctx?.dispose?.(); + if (ctx) { + process.stdout.write('disposing official DSH composition\n'); + try { + await withTimeout(Promise.resolve(ctx.dispose?.()), 10_000, 'official DSH composition disposal'); + process.stdout.write('official DSH composition disposed\n'); + } catch (disposeError) { + if (!primaryError) primaryError = disposeError; + else process.stderr.write(`secondary DSH disposal failure: ${disposeError instanceof Error ? disposeError.stack : disposeError}\n`); + } + } } + +if (primaryError) throw primaryError; From 0a3544ed41b69c9d6b9749969dc258ec2fb4df6e Mon Sep 17 00:00:00 2001 From: code-new-bie <403466121@qq.com> Date: Thu, 20 Aug 2026 21:30:26 +0800 Subject: [PATCH 239/305] fix: dispose official DSH root fiber --- src/dsh/runtime-boot.mjs | 62 ++++++++++++++++++++++++++++++++-------- 1 file changed, 50 insertions(+), 12 deletions(-) diff --git a/src/dsh/runtime-boot.mjs b/src/dsh/runtime-boot.mjs index 4c0a1b78..44ad800b 100644 --- a/src/dsh/runtime-boot.mjs +++ b/src/dsh/runtime-boot.mjs @@ -153,6 +153,27 @@ async function installProfileWatchers(ctx, options) { }); } +async function disposeOfficialContext(ctx) { + if (!ctx) return; + if (typeof ctx.fiber?.dispose !== 'function') { + throw new Error('dshx: official DSH Context has no root fiber disposal contract'); + } + await ctx.fiber.dispose(); +} + +function attachPresentationLifetime(ctx) { + // Cordis Context intentionally owns teardown through its root Fiber rather + // than a Context.dispose() method. DSHX keeps its launcher/local-server + // lifetime API small by exposing a non-enumerable alias that delegates to + // that official root-fiber contract; no runtime service is replaced. + Object.defineProperty(ctx, 'dispose', { + configurable: true, + enumerable: false, + value: () => disposeOfficialContext(ctx) + }); + return ctx; +} + /** * Boot the selected official Harness profile in-process and return its root Context. * DSHX owns only presentation lifetime. Project and DSH-home environment and @@ -168,17 +189,32 @@ export async function bootDshxRuntime({ } = {}) { const options = { profile, overlays, home }; const composition = dshxRuntimeProfile(options); - const ctx = await boot( - NAME, - composition.rootConfig, - structuredClone(composition.patches), - (hostCtx) => { - hostCtx.provide(DSH_LAUNCH_ENVIRONMENT_KEY, environment); - }, - pathToFileURL(composition.installAnchor).href - ); - if (watch) await installProfileWatchers(ctx, options); - return ctx; + let ctx; + try { + ctx = await boot( + NAME, + composition.rootConfig, + structuredClone(composition.patches), + (hostCtx) => { + hostCtx.provide(DSH_LAUNCH_ENVIRONMENT_KEY, environment); + }, + pathToFileURL(composition.installAnchor).href + ); + attachPresentationLifetime(ctx); + if (watch) await installProfileWatchers(ctx, options); + return ctx; + } catch (error) { + if (ctx) { + try { + await disposeOfficialContext(ctx); + } catch (disposeError) { + if (error && typeof error === 'object') { + try { Object.defineProperty(error, 'dshxCleanupError', { value: disposeError }); } catch {} + } + } + } + throw error; + } } export const runtimeInternals = { @@ -192,5 +228,7 @@ export const runtimeInternals = { selectedProfile, profileHome, homePatchPath, - installProfileWatchers + installProfileWatchers, + disposeOfficialContext, + attachPresentationLifetime }; From 7f04a79a191b83f2df5f11b3c55b7279b4d2a83b Mon Sep 17 00:00:00 2001 From: code-new-bie <403466121@qq.com> Date: Thu, 20 Aug 2026 21:30:52 +0800 Subject: [PATCH 240/305] test: lock official DSH root-fiber disposal --- test/dsh-runtime-boot.test.mjs | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/test/dsh-runtime-boot.test.mjs b/test/dsh-runtime-boot.test.mjs index 18dd38af..e76f713e 100644 --- a/test/dsh-runtime-boot.test.mjs +++ b/test/dsh-runtime-boot.test.mjs @@ -99,3 +99,22 @@ test('DSHX surface patches cannot insert a second Agent, Session, tool or provid assert.equal(patch.disabled, true); } }); + +test('presentation lifetime delegates disposal to the official DSH root Fiber', async () => { + let disposals = 0; + const ctx = { + fiber: { + async dispose() { + disposals += 1; + } + } + }; + + const decorated = runtimeInternals.attachPresentationLifetime(ctx); + assert.equal(decorated, ctx); + assert.equal(typeof ctx.dispose, 'function'); + assert.equal(Object.prototype.propertyIsEnumerable.call(ctx, 'dispose'), false); + + await ctx.dispose(); + assert.equal(disposals, 1); +}); From 066c5b9caed4626cefa8032f0f26f23348b0fabd Mon Sep 17 00:00:00 2001 From: code-new-bie <403466121@qq.com> Date: Thu, 20 Aug 2026 21:33:15 +0800 Subject: [PATCH 241/305] ci: verify official DSH root-fiber ownership --- scripts/verify-ownership-boundary.mjs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/scripts/verify-ownership-boundary.mjs b/scripts/verify-ownership-boundary.mjs index 4177d876..b6b0c11a 100644 --- a/scripts/verify-ownership-boundary.mjs +++ b/scripts/verify-ownership-boundary.mjs @@ -31,10 +31,14 @@ requireText('src/dsh/runtime-boot.mjs', 'loadProfile('); requireText('src/dsh/runtime-boot.mjs', 'composeEntries('); requireText('src/dsh/runtime-boot.mjs', "{ id: 'headless-startup', disabled: true }"); requireText('src/dsh/runtime-boot.mjs', "{ id: 'headless-runner', disabled: true }"); -requireText('src/dsh/runtime-boot.mjs', 'const ctx = await boot('); +requireText('src/dsh/runtime-boot.mjs', 'ctx = await boot('); requireText('src/dsh/runtime-boot.mjs', 'profile.layers.flatMap((layer) => layer.patches)'); requireText('src/dsh/runtime-boot.mjs', 'profile.patches'); requireText('src/dsh/runtime-boot.mjs', 'homePatches'); +// Presentation lifetime may adapt the Context shape, but teardown must remain +// delegated to Cordis/DSH root-fiber ownership rather than a DSHX runtime. +requireText('src/dsh/runtime-boot.mjs', 'ctx.fiber.dispose()'); +requireText('src/dsh/runtime-boot.mjs', 'attachPresentationLifetime(ctx)'); for (const path of ['src/dsh/user-shell.mjs', 'src/dsh/workspace-command.mjs']) { forbidText(path, /execFile|execSync|spawn\(/, 'local process execution'); From 808b876fdc352a8a1dedc0fc559520d36029bf28 Mon Sep 17 00:00:00 2001 From: code-new-bie <403466121@qq.com> Date: Thu, 20 Aug 2026 21:36:05 +0800 Subject: [PATCH 242/305] fix: stabilize DSHX permission patch context --- .../codex/0004-dshx-permission-picker.patch | 32 ++++++++++++++++--- 1 file changed, 28 insertions(+), 4 deletions(-) diff --git a/upstream/patches/codex/0004-dshx-permission-picker.patch b/upstream/patches/codex/0004-dshx-permission-picker.patch index c33f2dfd..14366be2 100644 --- a/upstream/patches/codex/0004-dshx-permission-picker.patch +++ b/upstream/patches/codex/0004-dshx-permission-picker.patch @@ -1,7 +1,8 @@ diff --git a/codex-rs/tui/src/chatwidget/permission_popups.rs b/codex-rs/tui/src/chatwidget/permission_popups.rs --- a/codex-rs/tui/src/chatwidget/permission_popups.rs +++ b/codex-rs/tui/src/chatwidget/permission_popups.rs -@@ -5,6 +5,10 @@ use super::*; +@@ -7,33 +7,39 @@ + use super::*; use codex_protocol::openai_models::MODEL_SPECIALTY_CYBER; +fn dshx_backend() -> bool { @@ -9,14 +10,31 @@ diff --git a/codex-rs/tui/src/chatwidget/permission_popups.rs b/codex-rs/tui/src +} + impl ChatWidget { -@@ -24,7 +28,8 @@ impl ChatWidget { + /// Open the permissions popup. + pub(crate) fn open_approvals_popup(&mut self) { + self.open_permissions_popup(); + } + + /// Open a popup to choose the permissions mode. + pub(crate) fn open_permissions_popup(&mut self) { + if self.config.explicit_permission_profile_mode { + self.open_permission_profiles_popup(); + return; + } + + let include_read_only = cfg!(target_os = "windows"); let current_approval = AskForApproval::from(self.config.permissions.approval_policy.value()); let current_permission_profile = self.config.permissions.permission_profile().clone(); - let guardian_approval_enabled = self.config.features.enabled(Feature::GuardianApproval); + let guardian_approval_enabled = !dshx_backend() + && self.config.features.enabled(Feature::GuardianApproval); -@@ -35,7 +40,8 @@ impl ChatWidget { + let current_review_policy = self.config.approvals_reviewer; + let mut items: Vec = Vec::new(); + let presets: Vec = builtin_approval_presets(); + + #[cfg(target_os = "windows")] + let windows_sandbox_level = crate::windows_sandbox::level_from_config(&self.config); #[cfg(target_os = "windows")] let windows_degraded_sandbox_enabled = - matches!(windows_sandbox_level, WindowsSandboxLevel::RestrictedToken); @@ -24,7 +42,13 @@ diff --git a/codex-rs/tui/src/chatwidget/permission_popups.rs b/codex-rs/tui/src + && matches!(windows_sandbox_level, WindowsSandboxLevel::RestrictedToken); #[cfg(not(target_os = "windows"))] let windows_degraded_sandbox_enabled = false; -@@ -292,7 +298,10 @@ impl ChatWidget { + + let show_elevate_sandbox_hint = + windows_degraded_sandbox_enabled && presets.iter().any(|preset| preset.id == "auto"); +@@ -313,7 +319,11 @@ impl ChatWidget { + }); + })]; + } - if approvals_reviewer == ApprovalsReviewer::User && preset.id == "auto" { + // DSHX never enables or configures Codex's local Windows sandbox. The + // selected workspace preset is sent to the DSH app-server adapter and From 39138ed5f3c33d27f551d63dc9663ca8c894ffd2 Mon Sep 17 00:00:00 2001 From: code-new-bie <403466121@qq.com> Date: Thu, 20 Aug 2026 21:38:22 +0800 Subject: [PATCH 243/305] fix: stabilize DSHX onboarding patch context --- .../0005-dshx-skip-codex-onboarding.patch | 42 ++++++++++++++----- 1 file changed, 31 insertions(+), 11 deletions(-) diff --git a/upstream/patches/codex/0005-dshx-skip-codex-onboarding.patch b/upstream/patches/codex/0005-dshx-skip-codex-onboarding.patch index 86d64411..d55e8ef2 100644 --- a/upstream/patches/codex/0005-dshx-skip-codex-onboarding.patch +++ b/upstream/patches/codex/0005-dshx-skip-codex-onboarding.patch @@ -1,7 +1,8 @@ diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs -@@ -199,6 +199,10 @@ const TUI_LOG_FILE_NAME: &str = "codex-tui.log"; +@@ -232,8 +232,12 @@ + const TUI_LOG_FILE_NAME: &str = "codex-tui.log"; const INTERACTIVE_OTEL_SHUTDOWN_TIMEOUT: Duration = Duration::from_millis(/*millis*/ 500); +fn dshx_backend() -> bool { @@ -11,7 +12,8 @@ diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs #[cfg(unix)] const AUTO_CONNECT_DAEMON_CONNECT_TIMEOUT: std::time::Duration = std::time::Duration::from_millis(50); -@@ -1071,7 +1075,9 @@ pub async fn run_main( + +@@ -1068,7 +1072,9 @@ } } let remote_project_trust = @@ -22,7 +24,9 @@ diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs match startup_draft .run_until( &mut tui, -@@ -1105,8 +1111,9 @@ pub async fn run_main( +@@ -1094,10 +1100,11 @@ + } else { + None }; let mut app_server = Some(app_server_session); - let should_show_trust_screen_flag = remote_project_trust.is_some() @@ -30,16 +34,18 @@ diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs + let should_show_trust_screen_flag = !dshx_backend() + && (remote_project_trust.is_some() + || (!uses_remote_workspace && should_show_trust_screen(&initial_config))); -@@ -1118,7 +1125,8 @@ pub async fn run_main( + #[cfg(target_os = "windows")] + let mut trust_decision_was_made = false; + let startup_model_provider = initial_config.model_provider_id.clone(); let (login_status, mut startup_account) = if workload_identity_selected { (LoginStatus::AuthMode(AuthMode::Chatgpt), None) - } else if initial_config.model_provider.requires_openai_auth { -+ } else if !dshx_backend() -+ && initial_config.model_provider.requires_openai_auth { ++ } else if !dshx_backend() && initial_config.model_provider.requires_openai_auth { let Some(active_app_server) = app_server.as_mut() else { unreachable!("app server should exist when auth is required"); }; -@@ -1144,7 +1152,8 @@ pub async fn run_main( +@@ -1128,8 +1135,9 @@ + } else { (LoginStatus::NotAuthenticated, None) }; let should_show_onboarding = @@ -48,10 +54,14 @@ diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs + && should_show_onboarding(login_status, &initial_config, should_show_trust_screen_flag); let config = if should_show_onboarding { + if let Err(err) = startup_draft.flush_pending_events(&mut tui).await { + shutdown_startup_session(app_server.take(), &mut terminal_restore_guard).await; diff --git a/codex-rs/tui/src/app/startup.rs b/codex-rs/tui/src/app/startup.rs --- a/codex-rs/tui/src/app/startup.rs +++ b/codex-rs/tui/src/app/startup.rs -@@ -4,6 +4,9 @@ +@@ -4,9 +4,12 @@ + //! remains isolated from protected interactive requests until the initialized composer owns it. + use super::*; async fn resolve_runtime_model_provider_base_url(provider: &ModelProviderInfo) -> Option { @@ -61,7 +71,8 @@ diff --git a/codex-rs/tui/src/app/startup.rs b/codex-rs/tui/src/app/startup.rs let provider = create_model_provider(provider.clone(), /*auth_manager*/ None); match provider.runtime_base_url().await { Ok(base_url) => base_url, -@@ -387,7 +390,10 @@ impl App { + Err(err) => { +@@ -384,8 +387,9 @@ chat_widget.note_rendered_width(tui.terminal.last_known_screen_size.width); chat_widget.remote_connection = remote_connection; let thread_and_widget_ms = thread_and_widget_started_at.elapsed().as_millis(); @@ -70,7 +81,11 @@ diff --git a/codex-rs/tui/src/app/startup.rs b/codex-rs/tui/src/app/startup.rs + chat_widget.maybe_prompt_windows_sandbox_enable( + should_prompt_windows_sandbox_nux_at_startup && !crate::dshx_backend(), + ); -@@ -402,7 +408,11 @@ impl App { + + let file_search = FileSearchManager::new(config.cwd.to_path_buf(), app_event_tx.clone()); + let runtime_keymap = RuntimeKeymap::from_config(&config.tui_keymap).map_err(|err| { +@@ -399,7 +403,11 @@ + See the Codex keymap documentation for supported actions and examples." ) })?; #[cfg(not(debug_assertions))] @@ -80,7 +95,11 @@ diff --git a/codex-rs/tui/src/app/startup.rs b/codex-rs/tui/src/app/startup.rs + } else { + crate::updates::get_upgrade_version(&config) + }; -@@ -492,7 +502,8 @@ impl App { + + let mut app = Self { + model_catalog, +@@ -488,8 +496,9 @@ + // world-writable dirs on Windows. #[cfg(target_os = "windows")] { let startup_permission_profile = app.config.permissions.effective_permission_profile(); @@ -90,3 +109,4 @@ diff --git a/codex-rs/tui/src/app/startup.rs b/codex-rs/tui/src/app/startup.rs != WindowsSandboxLevel::Disabled && managed_filesystem_sandbox_is_restricted(&startup_permission_profile) && !app + .config From 9b589801a913d992ab7245581a5d082634a242dc Mon Sep 17 00:00:00 2001 From: code-new-bie <403466121@qq.com> Date: Thu, 20 Aug 2026 21:40:10 +0800 Subject: [PATCH 244/305] fix: stabilize DSHX slash-capability patch context --- .../codex/0009-dshx-slash-capabilities.patch | 38 ++++++++++++++++--- 1 file changed, 33 insertions(+), 5 deletions(-) diff --git a/upstream/patches/codex/0009-dshx-slash-capabilities.patch b/upstream/patches/codex/0009-dshx-slash-capabilities.patch index aee79db9..df2eb2a3 100644 --- a/upstream/patches/codex/0009-dshx-slash-capabilities.patch +++ b/upstream/patches/codex/0009-dshx-slash-capabilities.patch @@ -1,7 +1,8 @@ diff --git a/codex-rs/tui/src/slash_command.rs b/codex-rs/tui/src/slash_command.rs --- a/codex-rs/tui/src/slash_command.rs +++ b/codex-rs/tui/src/slash_command.rs -@@ -76,7 +76,7 @@ impl SlashCommand { +@@ -86,48 +86,48 @@ impl SlashCommand { + pub fn description(self) -> &'static str { match self { SlashCommand::Feedback => "send logs to maintainers", SlashCommand::New => "start a new chat during a conversation", @@ -10,7 +11,8 @@ diff --git a/codex-rs/tui/src/slash_command.rs b/codex-rs/tui/src/slash_command. SlashCommand::Compact => "summarize conversation to prevent hitting the context limit", SlashCommand::Review => "review my current changes and find issues", SlashCommand::Rename => "rename the current thread", -@@ -86,7 +86,7 @@ impl SlashCommand { + SlashCommand::Resume => "resume a saved chat", + SlashCommand::Archive => "archive this session and exit", SlashCommand::Delete => "permanently delete this session and exit", SlashCommand::Clear => "clear the terminal and start a new chat", SlashCommand::Fork => "fork the current chat", @@ -20,20 +22,44 @@ diff --git a/codex-rs/tui/src/slash_command.rs b/codex-rs/tui/src/slash_command. SlashCommand::Copy => "copy last response as markdown", SlashCommand::Export => "export the conversation as markdown", SlashCommand::Raw => "toggle raw scrollback mode for copy-friendly terminal selection", -@@ -94,7 +94,7 @@ impl SlashCommand { SlashCommand::Diff => "show git diff (including untracked files)", SlashCommand::Mention => "mention a file", - SlashCommand::Skills => "use skills to improve how Codex performs specific tasks", + SlashCommand::Skills => "use skills to improve how DeepSeek Harness performs specific tasks", SlashCommand::Import => "import setup, this project, and recent chats from Claude Code", SlashCommand::Hooks => "view and manage lifecycle hooks", -@@ -128,7 +128,7 @@ impl SlashCommand { + SlashCommand::Status => "show current session configuration and token usage", + SlashCommand::Cd => "change the current working directory", + SlashCommand::Pwd => "show the current working directory", + SlashCommand::Usage => "view account usage or use a usage limit reset", + SlashCommand::DebugConfig => "show config layers and requirement sources for debugging", + SlashCommand::Title => "configure which items appear in the terminal title", + SlashCommand::Statusline => "configure which items appear in the status line", + SlashCommand::Theme => "choose a syntax highlighting theme", + SlashCommand::Pets => "choose or hide the terminal pet", + SlashCommand::Ps => "list background terminals", + SlashCommand::Stop => "stop all background terminals", + SlashCommand::MemoryDrop => "DO NOT USE", + SlashCommand::MemoryUpdate => "DO NOT USE", + SlashCommand::Model => "choose what model and reasoning effort to use", + SlashCommand::Ide => { + "include current selection, open files, and other context from your IDE" + } + SlashCommand::Personality => "choose a communication style for Codex", + SlashCommand::Plan => "switch to Plan mode", + SlashCommand::Goal => "set or view the goal for a long-running task", + SlashCommand::Agents => "view and switch between all active agent sessions", + SlashCommand::MultiAgents => "switch between this session's subagents", SlashCommand::Side | SlashCommand::Btw => { "start a side conversation in an ephemeral fork" } - SlashCommand::Permissions => "choose what Codex is allowed to do", + SlashCommand::Permissions => "choose what DeepSeek Harness is allowed to do", -@@ -251,6 +251,39 @@ impl SlashCommand { + SlashCommand::Keymap => "remap TUI shortcuts", + SlashCommand::Vim => "toggle Vim mode for the composer", + SlashCommand::ElevateSandbox => "set up elevated agent sandbox", + SlashCommand::SandboxReadRoot => { +@@ -260,7 +260,40 @@ impl SlashCommand { } fn is_visible(self) -> bool { @@ -76,3 +102,5 @@ diff --git a/codex-rs/tui/src/slash_command.rs b/codex-rs/tui/src/slash_command. match self { SlashCommand::SandboxReadRoot => cfg!(target_os = "windows"), SlashCommand::Copy => !cfg!(target_os = "android"), + SlashCommand::App => cfg!(any(target_os = "macos", target_os = "windows")), + SlashCommand::Rollout | SlashCommand::TestApproval => cfg!(debug_assertions), From 3af46553ae24a00244e5d71beedd7e76dfd876fd Mon Sep 17 00:00:00 2001 From: code-new-bie <403466121@qq.com> Date: Thu, 20 Aug 2026 22:00:09 +0800 Subject: [PATCH 245/305] fix: keep pinned Codex Cargo lock closed --- .../codex/0010-dshx-local-ipc-bridge.patch | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/upstream/patches/codex/0010-dshx-local-ipc-bridge.patch b/upstream/patches/codex/0010-dshx-local-ipc-bridge.patch index 5a583580..9a75b825 100644 --- a/upstream/patches/codex/0010-dshx-local-ipc-bridge.patch +++ b/upstream/patches/codex/0010-dshx-local-ipc-bridge.patch @@ -1,3 +1,18 @@ +diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock +--- a/codex-rs/Cargo.lock ++++ b/codex-rs/Cargo.lock +@@ -4246,10 +4246,12 @@ dependencies = [ + "anyhow", + "codex-uds", + "codex-utils-cargo-bin", ++ "futures", + "pretty_assertions", + "tempfile", + "tokio", ++ "tokio-tungstenite", + ] + + [[package]] diff --git a/codex-rs/stdio-to-uds/Cargo.toml b/codex-rs/stdio-to-uds/Cargo.toml --- a/codex-rs/stdio-to-uds/Cargo.toml +++ b/codex-rs/stdio-to-uds/Cargo.toml @@ -161,4 +176,4 @@ diff --git a/codex-rs/tui/src/main.rs b/codex-rs/tui/src/main.rs + RemoteAppServerEndpoint::UnixSocket { .. } => {} } let exit_info = run_main( - inner, + inner, \ No newline at end of file From 29a4b9d89445dc23033d22c74e12d7f7a185f85b Mon Sep 17 00:00:00 2001 From: code-new-bie <403466121@qq.com> Date: Thu, 20 Aug 2026 22:02:31 +0800 Subject: [PATCH 246/305] fix: correct pinned Cargo lock patch hunk --- upstream/patches/codex/0010-dshx-local-ipc-bridge.patch | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/upstream/patches/codex/0010-dshx-local-ipc-bridge.patch b/upstream/patches/codex/0010-dshx-local-ipc-bridge.patch index 9a75b825..dc18c132 100644 --- a/upstream/patches/codex/0010-dshx-local-ipc-bridge.patch +++ b/upstream/patches/codex/0010-dshx-local-ipc-bridge.patch @@ -1,7 +1,7 @@ diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock -@@ -4246,10 +4246,12 @@ dependencies = [ +@@ -4246,9 +4246,11 @@ dependencies = [ "anyhow", "codex-uds", "codex-utils-cargo-bin", From 4f1033e5b0f627d03f79e279cb161f58271d7ff0 Mon Sep 17 00:00:00 2001 From: code-new-bie <403466121@qq.com> Date: Thu, 20 Aug 2026 22:05:11 +0800 Subject: [PATCH 247/305] fix: align Cargo lock patch context --- .../patches/codex/0010-dshx-local-ipc-bridge.patch | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/upstream/patches/codex/0010-dshx-local-ipc-bridge.patch b/upstream/patches/codex/0010-dshx-local-ipc-bridge.patch index dc18c132..fb401afd 100644 --- a/upstream/patches/codex/0010-dshx-local-ipc-bridge.patch +++ b/upstream/patches/codex/0010-dshx-local-ipc-bridge.patch @@ -1,14 +1,14 @@ diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock -@@ -4246,9 +4246,11 @@ dependencies = [ - "anyhow", - "codex-uds", - "codex-utils-cargo-bin", +@@ -4254,9 +4254,11 @@ dependencies = [ + "anyhow", + "codex-uds", + "codex-utils-cargo-bin", + "futures", - "pretty_assertions", - "tempfile", - "tokio", + "pretty_assertions", + "tempfile", + "tokio", + "tokio-tungstenite", ] From ad5c4c3ab7916dedda0526476133951653015be1 Mon Sep 17 00:00:00 2001 From: code-new-bie <403466121@qq.com> Date: Thu, 20 Aug 2026 22:07:31 +0800 Subject: [PATCH 248/305] fix: terminate local IPC patch cleanly --- upstream/patches/codex/0010-dshx-local-ipc-bridge.patch | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/upstream/patches/codex/0010-dshx-local-ipc-bridge.patch b/upstream/patches/codex/0010-dshx-local-ipc-bridge.patch index fb401afd..84425ab8 100644 --- a/upstream/patches/codex/0010-dshx-local-ipc-bridge.patch +++ b/upstream/patches/codex/0010-dshx-local-ipc-bridge.patch @@ -176,4 +176,4 @@ diff --git a/codex-rs/tui/src/main.rs b/codex-rs/tui/src/main.rs + RemoteAppServerEndpoint::UnixSocket { .. } => {} } let exit_info = run_main( - inner, \ No newline at end of file + inner, From 2c3e8792451d106f18856b028b0d1c24d5c78b95 Mon Sep 17 00:00:00 2001 From: code-new-bie <403466121@qq.com> Date: Thu, 20 Aug 2026 22:26:10 +0800 Subject: [PATCH 249/305] fix: bridge remote endpoint error into anyhow --- .../codex/0010-dshx-local-ipc-bridge.patch | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/upstream/patches/codex/0010-dshx-local-ipc-bridge.patch b/upstream/patches/codex/0010-dshx-local-ipc-bridge.patch index 84425ab8..a8f21e43 100644 --- a/upstream/patches/codex/0010-dshx-local-ipc-bridge.patch +++ b/upstream/patches/codex/0010-dshx-local-ipc-bridge.patch @@ -2,13 +2,13 @@ diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -4254,9 +4254,11 @@ dependencies = [ - "anyhow", - "codex-uds", - "codex-utils-cargo-bin", + "anyhow", + "codex-uds", + "codex-utils-cargo-bin", + "futures", - "pretty_assertions", - "tempfile", - "tokio", + "pretty_assertions", + "tempfile", + "tokio", + "tokio-tungstenite", ] @@ -151,7 +151,7 @@ new file mode 100644 diff --git a/codex-rs/tui/src/main.rs b/codex-rs/tui/src/main.rs --- a/codex-rs/tui/src/main.rs +++ b/codex-rs/tui/src/main.rs -@@ -72,19 +72,12 @@ +@@ -72,19 +72,13 @@ "DSHX_APP_SERVER_ENDPOINT is required; DSHX never falls back to the Codex agent runtime" ) })?; @@ -168,7 +168,8 @@ diff --git a/codex-rs/tui/src/main.rs b/codex-rs/tui/src/main.rs - RemoteAppServerEndpoint::UnixSocket { .. } => { - anyhow::bail!("DSHX production launcher requires authenticated loopback WebSocket transport") - } -+ let dshx_endpoint = resolve_remote_addr(&dshx_endpoint)?; ++ let dshx_endpoint = resolve_remote_addr(&dshx_endpoint) ++ .map_err(|err| anyhow::anyhow!("{err:#}"))?; + match &dshx_endpoint { + RemoteAppServerEndpoint::WebSocket { .. } => anyhow::bail!( + "DSHX production launcher forbids TCP WebSocket transport; use the packaged local IPC bridge" From 024c295a6843d6e108154ed0348cb3869c07452c Mon Sep 17 00:00:00 2001 From: code-new-bie <403466121@qq.com> Date: Thu, 20 Aug 2026 22:26:31 +0800 Subject: [PATCH 250/305] ci: verify exact PR head checkout --- .github/workflows/ci.yml | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3ba7ea47..2b62a656 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -20,6 +20,20 @@ jobs: timeout-minutes: 15 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }} + - name: Verify exact source checkout + shell: bash + env: + EXPECTED_SHA: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }} + run: | + set -euo pipefail + actual="$(git rev-parse HEAD)" + if [[ "$actual" != "$EXPECTED_SHA" ]]; then + echo "wrong source checkout: expected=$EXPECTED_SHA actual=$actual" >&2 + exit 1 + fi + echo "exact source checkout: $actual" - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version: 24 @@ -48,6 +62,20 @@ jobs: timeout-minutes: 60 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }} + - name: Verify exact source checkout + shell: bash + env: + EXPECTED_SHA: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }} + run: | + set -euo pipefail + actual="$(git rev-parse HEAD)" + if [[ "$actual" != "$EXPECTED_SHA" ]]; then + echo "wrong source checkout: expected=$EXPECTED_SHA actual=$actual" >&2 + exit 1 + fi + echo "exact source checkout: $actual" - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version: 24 From 581b4952f92bdd1731c1ffb12109c540ee7b5e2f Mon Sep 17 00:00:00 2001 From: code-new-bie <403466121@qq.com> Date: Thu, 20 Aug 2026 22:30:30 +0800 Subject: [PATCH 251/305] fix: restore Cargo lock patch context --- .../patches/codex/0010-dshx-local-ipc-bridge.patch | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/upstream/patches/codex/0010-dshx-local-ipc-bridge.patch b/upstream/patches/codex/0010-dshx-local-ipc-bridge.patch index a8f21e43..80c65a93 100644 --- a/upstream/patches/codex/0010-dshx-local-ipc-bridge.patch +++ b/upstream/patches/codex/0010-dshx-local-ipc-bridge.patch @@ -2,13 +2,13 @@ diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -4254,9 +4254,11 @@ dependencies = [ - "anyhow", - "codex-uds", - "codex-utils-cargo-bin", + "anyhow", + "codex-uds", + "codex-utils-cargo-bin", + "futures", - "pretty_assertions", - "tempfile", - "tokio", + "pretty_assertions", + "tempfile", + "tokio", + "tokio-tungstenite", ] From 10f0343ecf5ced164567daac3cd85522d17d70a3 Mon Sep 17 00:00:00 2001 From: code-new-bie <403466121@qq.com> Date: Thu, 20 Aug 2026 22:31:43 +0800 Subject: [PATCH 252/305] fix: anchor local IPC Cargo lock patch --- upstream/patches/codex/0010-dshx-local-ipc-bridge.patch | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/upstream/patches/codex/0010-dshx-local-ipc-bridge.patch b/upstream/patches/codex/0010-dshx-local-ipc-bridge.patch index 80c65a93..153d959a 100644 --- a/upstream/patches/codex/0010-dshx-local-ipc-bridge.patch +++ b/upstream/patches/codex/0010-dshx-local-ipc-bridge.patch @@ -1,7 +1,11 @@ diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock -@@ -4254,9 +4254,11 @@ dependencies = [ +@@ -4250,16 +4250,18 @@ + [[package]] + name = "codex-stdio-to-uds" + version = "0.0.0" + dependencies = [ "anyhow", "codex-uds", "codex-utils-cargo-bin", @@ -13,6 +17,9 @@ diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock ] [[package]] + name = "codex-terminal-detection" + version = "0.0.0" + dependencies = [ diff --git a/codex-rs/stdio-to-uds/Cargo.toml b/codex-rs/stdio-to-uds/Cargo.toml --- a/codex-rs/stdio-to-uds/Cargo.toml +++ b/codex-rs/stdio-to-uds/Cargo.toml From 4364d1ff9e05ba3f3dfce4cc326a8ee900126747 Mon Sep 17 00:00:00 2001 From: code-new-bie <403466121@qq.com> Date: Thu, 20 Aug 2026 23:59:34 +0800 Subject: [PATCH 253/305] fix: align protocol stub thread ids with Codex UUID contract --- src/protocol.mjs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/protocol.mjs b/src/protocol.mjs index eb5c1a30..e1728f7f 100644 --- a/src/protocol.mjs +++ b/src/protocol.mjs @@ -48,7 +48,9 @@ function modelRecord() { function emptyThread(cwd) { const timestamp = nowSeconds(); - const threadId = id('thread'); + // Codex treats thread IDs as UUIDs and validates them before opening a remote thread. + // Keep the deterministic protocol stub aligned with that public app-server contract. + const threadId = crypto.randomUUID(); return { id: threadId, sessionId: threadId, From d40627728269e135c1ede5281d5c893a0beaeeff Mon Sep 17 00:00:00 2001 From: code-new-bie <403466121@qq.com> Date: Fri, 21 Aug 2026 01:14:42 +0800 Subject: [PATCH 254/305] test: trace local IPC protocol smoke --- bin/dshx-stub-local.mjs | 31 ++++++++++++++++++++++++++++--- 1 file changed, 28 insertions(+), 3 deletions(-) diff --git a/bin/dshx-stub-local.mjs b/bin/dshx-stub-local.mjs index 2f470848..96a2898e 100644 --- a/bin/dshx-stub-local.mjs +++ b/bin/dshx-stub-local.mjs @@ -1,10 +1,17 @@ #!/usr/bin/env node +import fs from 'node:fs'; import { startDshxLocalServer } from '../src/dsh/local-server.mjs'; import { ProtocolStub, normalizeDispatchResult } from '../src/protocol.mjs'; const delayMs = Number(process.env.DSHX_STUB_EVENT_DELAY_MS || 18); +const traceFile = process.env.DSHX_STUB_TRACE_FILE; const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); +function trace(record) { + if (!traceFile) return; + fs.appendFileSync(traceFile, `${JSON.stringify({ at: Date.now(), ...record })}\n`, 'utf8'); +} + class LocalStubAdapter { constructor({ send, cwd }) { this.send = send; @@ -12,11 +19,22 @@ class LocalStubAdapter { } async handle(message) { - if (message?.id === undefined) return null; + if (message?.id === undefined) { + trace({ direction: 'in', kind: 'notification', method: message?.method ?? null }); + return null; + } + trace({ direction: 'in', kind: 'request', id: message.id, method: message?.method ?? null }); const { response, events } = normalizeDispatchResult(this.stub, message); return { response, afterResponse: async () => { + trace({ + direction: 'out', + kind: response?.error ? 'error' : 'response', + id: response?.id ?? null, + method: message?.method ?? null, + error: response?.error?.message ?? null + }); for (const event of events) { const threadId = event?.params?.threadId; const turnId = event?.params?.turnId ?? event?.params?.turn?.id; @@ -24,10 +42,17 @@ class LocalStubAdapter { typeof threadId === 'string' && typeof turnId === 'string' && event.method !== 'turn/completed'; - if (belongsToActiveTurn && !this.stub.isTurnActive(threadId, turnId)) break; + if (belongsToActiveTurn && !this.stub.isTurnActive(threadId, turnId)) { + trace({ direction: 'drop', method: event.method, threadId, turnId, reason: 'inactive-before-delay' }); + break; + } if (delayMs > 0) await sleep(delayMs); - if (belongsToActiveTurn && !this.stub.isTurnActive(threadId, turnId)) break; + if (belongsToActiveTurn && !this.stub.isTurnActive(threadId, turnId)) { + trace({ direction: 'drop', method: event.method, threadId, turnId, reason: 'inactive-after-delay' }); + break; + } this.send(event); + trace({ direction: 'out', kind: 'notification', method: event.method, threadId: threadId ?? null, turnId: turnId ?? null }); if ( event.method === 'turn/completed' && typeof threadId === 'string' && From cdee70eae9cf8420097739d8b772d05c058adae7 Mon Sep 17 00:00:00 2001 From: code-new-bie <403466121@qq.com> Date: Fri, 21 Aug 2026 01:14:52 +0800 Subject: [PATCH 255/305] test: expose PTY protocol trace on failure --- test/tui_smoke.py | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/test/tui_smoke.py b/test/tui_smoke.py index a2805711..69b91786 100644 --- a/test/tui_smoke.py +++ b/test/tui_smoke.py @@ -8,10 +8,15 @@ ROOT = pathlib.Path(__file__).resolve().parents[1] PROMPT = "你好,DSHX PTY resize" +trace_fd, trace_name = tempfile.mkstemp(prefix="dshx-protocol-trace-", suffix=".jsonl") +os.close(trace_fd) +trace_path = pathlib.Path(trace_name) +server_env = os.environ.copy() +server_env["DSHX_STUB_TRACE_FILE"] = str(trace_path) server = subprocess.Popen( ["node", "bin/dshx-stub-local.mjs"], cwd=ROOT, - env=os.environ.copy(), + env=server_env, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, @@ -54,7 +59,12 @@ transcript.append(child.before + child.after) except Exception: transcript.append(child.before or "") - raise AssertionError("Pinned DSHX TUI local-IPC/CJK/resize smoke failed. Transcript:\n" + "".join(transcript)) + trace = trace_path.read_text(encoding="utf-8") if trace_path.exists() else "" + raise AssertionError( + "Pinned DSHX TUI local-IPC/CJK/resize smoke failed.\n" + f"Protocol trace:\n{trace}\n" + "Transcript:\n" + "".join(transcript) + ) finally: child.close(force=True) finally: @@ -64,3 +74,4 @@ except subprocess.TimeoutExpired: server.kill() server.wait(timeout=5) + trace_path.unlink(missing_ok=True) From 1a53e9f38f853cdb96358a7d2736dfc8f88a5af8 Mon Sep 17 00:00:00 2001 From: code-new-bie <403466121@qq.com> Date: Fri, 21 Aug 2026 02:21:48 +0800 Subject: [PATCH 256/305] test: wait for TUI thread readiness before PTY turn --- test/tui_smoke.py | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/test/tui_smoke.py b/test/tui_smoke.py index 69b91786..21cc2e0d 100644 --- a/test/tui_smoke.py +++ b/test/tui_smoke.py @@ -1,8 +1,10 @@ #!/usr/bin/env python3 +import json import os import pathlib import subprocess import tempfile +import time import pexpect @@ -22,6 +24,27 @@ text=True, ) + +def wait_for_protocol_notification(method, timeout=10): + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + if trace_path.exists(): + for line in trace_path.read_text(encoding="utf-8").splitlines(): + try: + record = json.loads(line) + except json.JSONDecodeError: + continue + if ( + record.get("direction") == "out" + and record.get("kind") == "notification" + and record.get("method") == method + ): + return + time.sleep(0.05) + trace = trace_path.read_text(encoding="utf-8") if trace_path.exists() else "" + raise AssertionError(f"Timed out waiting for protocol notification {method!r}.\nProtocol trace:\n{trace}") + + try: endpoint = server.stdout.readline().strip() if not endpoint.startswith("unix://"): @@ -51,6 +74,7 @@ try: child.expect("DeepSeek Harness") transcript.append(child.before + child.after) + wait_for_protocol_notification("thread/started") child.setwinsize(40, 100) child.send(PROMPT + "\r") child.expect("DSHX protocol stub received:") From 1fec221d4d69911487465747c4927863417630c6 Mon Sep 17 00:00:00 2001 From: code-new-bie <403466121@qq.com> Date: Fri, 21 Aug 2026 03:02:54 +0800 Subject: [PATCH 257/305] test: trace nested thread IDs in local protocol stub --- bin/dshx-stub-local.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bin/dshx-stub-local.mjs b/bin/dshx-stub-local.mjs index 96a2898e..5750a9c4 100644 --- a/bin/dshx-stub-local.mjs +++ b/bin/dshx-stub-local.mjs @@ -36,7 +36,7 @@ class LocalStubAdapter { error: response?.error?.message ?? null }); for (const event of events) { - const threadId = event?.params?.threadId; + const threadId = event?.params?.threadId ?? event?.params?.thread?.id; const turnId = event?.params?.turnId ?? event?.params?.turn?.id; const belongsToActiveTurn = typeof threadId === 'string' && From 2e138a5fa27b02e8bf147b8c1c72d513e5b5a74f Mon Sep 17 00:00:00 2001 From: code-new-bie <403466121@qq.com> Date: Fri, 21 Aug 2026 03:03:10 +0800 Subject: [PATCH 258/305] test: submit PTY turns with CSI-u Enter --- test/tui_smoke.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/test/tui_smoke.py b/test/tui_smoke.py index 21cc2e0d..6a144d85 100644 --- a/test/tui_smoke.py +++ b/test/tui_smoke.py @@ -39,6 +39,8 @@ def wait_for_protocol_notification(method, timeout=10): and record.get("kind") == "notification" and record.get("method") == method ): + if method == "thread/started" and not isinstance(record.get("threadId"), str): + continue return time.sleep(0.05) trace = trace_path.read_text(encoding="utf-8") if trace_path.exists() else "" @@ -76,7 +78,11 @@ def wait_for_protocol_notification(method, timeout=10): transcript.append(child.before + child.after) wait_for_protocol_notification("thread/started") child.setwinsize(40, 100) - child.send(PROMPT + "\r") + child.send(PROMPT) + # Pinned Codex enables the kitty keyboard enhancement protocol (CSI-u). + # pexpect is a PTY transport, not a terminal emulator, so emulate the + # Enter key encoding a compliant terminal sends while that mode is active. + child.send("\x1b[13u") child.expect("DSHX protocol stub received:") transcript.append(child.before + child.after) child.expect(PROMPT) From 26c0e33327590f6d2a0ba5b45c3fd34ed9d14212 Mon Sep 17 00:00:00 2001 From: code-new-bie <403466121@qq.com> Date: Fri, 21 Aug 2026 03:03:32 +0800 Subject: [PATCH 259/305] test: align ConPTY smoke with enhanced keyboard protocol --- test/tui_smoke_windows.mjs | 39 ++++++++++++++++++++++++++++++++++++-- 1 file changed, 37 insertions(+), 2 deletions(-) diff --git a/test/tui_smoke_windows.mjs b/test/tui_smoke_windows.mjs index 80b2f015..9f09dbc1 100644 --- a/test/tui_smoke_windows.mjs +++ b/test/tui_smoke_windows.mjs @@ -42,6 +42,35 @@ function waitForOutput(state, needle, timeoutMs = 30_000) { }); } +function waitForProtocolNotification(traceFile, method, timeoutMs = 10_000) { + const started = Date.now(); + return new Promise((resolve, reject) => { + const poll = setInterval(() => { + if (fs.existsSync(traceFile)) { + for (const line of fs.readFileSync(traceFile, 'utf8').split(/\r?\n/)) { + if (!line) continue; + let record; + try { record = JSON.parse(line); } catch { continue; } + if ( + record.direction === 'out' && + record.kind === 'notification' && + record.method === method && + (method !== 'thread/started' || typeof record.threadId === 'string') + ) { + clearInterval(poll); + resolve(); + return; + } + } + } + if (Date.now() - started < timeoutMs) return; + clearInterval(poll); + const trace = fs.existsSync(traceFile) ? fs.readFileSync(traceFile, 'utf8') : ''; + reject(new Error(`Timed out waiting for protocol notification ${JSON.stringify(method)}.\nProtocol trace:\n${trace}`)); + }, 50); + }); +} + function firstLine(stream, child, stderrText, timeoutMs = 15_000) { const lines = createInterface({ input: stream, crlfDelay: Infinity }); return new Promise((resolve, reject) => { @@ -93,6 +122,7 @@ if (!fs.existsSync(binary)) throw new Error(`built DSHX TUI missing: ${binary}`) if (!fs.existsSync(bridge)) throw new Error(`built DSHX IPC bridge missing: ${bridge}`); const codexHome = fs.mkdtempSync(path.join(os.tmpdir(), 'dshx-conpty-home-')); +const traceFile = path.join(os.tmpdir(), `dshx-conpty-trace-${process.pid}-${Date.now()}.jsonl`); let server; let serverStderr = ''; let term; @@ -100,7 +130,7 @@ let dataDisposable; try { server = spawn(process.execPath, [path.join(ROOT, 'bin', 'dshx-stub-local.mjs')], { cwd: ROOT, - env: stringEnv({ DSHX_IPC_BRIDGE_BIN: bridge }), + env: stringEnv({ DSHX_IPC_BRIDGE_BIN: bridge, DSHX_STUB_TRACE_FILE: traceFile }), stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true }); @@ -127,9 +157,13 @@ try { dataDisposable = term.onData((chunk) => { state.output += chunk; }); await waitForOutput(state, 'DeepSeek Harness'); + await waitForProtocolNotification(traceFile, 'thread/started'); term.resize(100, 40); const prompt = '你好,DSHX ConPTY resize'; - term.write(`${prompt}\r`); + term.write(prompt); + // Pinned Codex requests kitty keyboard enhancement reporting. node-pty is a + // ConPTY transport rather than a terminal emulator, so encode Enter as CSI-u. + term.write('\x1b[13u'); await waitForOutput(state, 'DSHX protocol stub received:'); await waitForOutput(state, prompt); process.stdout.write('Windows ConPTY DSHX local-IPC + CJK + resize smoke passed\n'); @@ -141,4 +175,5 @@ try { await sleep(100); await stopChild(server); fs.rmSync(codexHome, { recursive: true, force: true }); + fs.rmSync(traceFile, { force: true }); } From 563e607007086c8a6b61b46d54de3123760abef4 Mon Sep 17 00:00:00 2001 From: code-new-bie <403466121@qq.com> Date: Fri, 21 Aug 2026 07:23:04 +0800 Subject: [PATCH 260/305] ci: bind RC platform gate to exact PR head --- .github/workflows/rc-platform.yml | 34 ++++++++++++++++++++++++++++--- 1 file changed, 31 insertions(+), 3 deletions(-) diff --git a/.github/workflows/rc-platform.yml b/.github/workflows/rc-platform.yml index 9f0cb944..a2ff7a47 100644 --- a/.github/workflows/rc-platform.yml +++ b/.github/workflows/rc-platform.yml @@ -1,24 +1,37 @@ name: RC Platform Gate on: - push: - branches: ['release/**'] + pull_request: + branches: [main] workflow_dispatch: permissions: contents: read concurrency: - group: rc-platform-${{ github.ref }} + group: rc-platform-${{ github.event.pull_request.head.sha || github.sha }} cancel-in-progress: true jobs: windows: + if: github.event_name == 'workflow_dispatch' || startsWith(github.head_ref, 'release/') name: Windows ConPTY / CJK / local IPC runs-on: windows-latest timeout-minutes: 60 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }} + - name: Verify exact source checkout + shell: pwsh + env: + EXPECTED_SHA: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }} + run: | + $actual = (git rev-parse HEAD).Trim() + if ($actual -ne $env:EXPECTED_SHA) { + throw "wrong source checkout: expected=$env:EXPECTED_SHA actual=$actual" + } + Write-Host "exact source checkout: $actual" - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version: 24 @@ -43,11 +56,26 @@ jobs: run: node test/tui_smoke_windows.mjs macos: + if: github.event_name == 'workflow_dispatch' || startsWith(github.head_ref, 'release/') name: macOS PTY / CJK / resize / local IPC runs-on: macos-26 timeout-minutes: 60 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }} + - name: Verify exact source checkout + shell: bash + env: + EXPECTED_SHA: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }} + run: | + set -euo pipefail + actual="$(git rev-parse HEAD)" + if [[ "$actual" != "$EXPECTED_SHA" ]]; then + echo "wrong source checkout: expected=$EXPECTED_SHA actual=$actual" >&2 + exit 1 + fi + echo "exact source checkout: $actual" - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: node-version: 24 From 84a23f5eac6c7e8214bf3053617bdf94463e9e72 Mon Sep 17 00:00:00 2001 From: code-new-bie <403466121@qq.com> Date: Fri, 21 Aug 2026 08:16:57 +0800 Subject: [PATCH 261/305] test: stabilize cross-platform RC terminal gates --- .github/workflows/rc-platform.yml | 2 +- docs/RELEASE-READINESS.md | 2 +- scripts/build-codex-tui.ps1 | 1 + test/tui_smoke.py | 10 ++++++---- 4 files changed, 9 insertions(+), 6 deletions(-) diff --git a/.github/workflows/rc-platform.yml b/.github/workflows/rc-platform.yml index a2ff7a47..c5d4519c 100644 --- a/.github/workflows/rc-platform.yml +++ b/.github/workflows/rc-platform.yml @@ -17,7 +17,7 @@ jobs: if: github.event_name == 'workflow_dispatch' || startsWith(github.head_ref, 'release/') name: Windows ConPTY / CJK / local IPC runs-on: windows-latest - timeout-minutes: 60 + timeout-minutes: 90 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: diff --git a/docs/RELEASE-READINESS.md b/docs/RELEASE-READINESS.md index c9cfd9e0..7a38a4bc 100644 --- a/docs/RELEASE-READINESS.md +++ b/docs/RELEASE-READINESS.md @@ -11,7 +11,7 @@ The pinned DSH `0.1.0-rc.8` line requires Node `^22.19.0 || >=24.0.0`. DSHX 1.0 ## Automation tiers - **PR / main CI:** two Ubuntu jobs only. `Core / Node 24` runs first; `Linux TUI / pinned Codex / local IPC` runs only after Core passes. -- **RC platform gate:** only `release/**` pushes (or manual dispatch) run real Windows ConPTY and macOS PTY/CJK/resize validation. +- **RC platform gate:** `release/**` PR heads targeting `main` (or manual dispatch) run real Windows ConPTY and macOS PTY/CJK/resize validation with explicit exact-head checkout verification. - **Release tag:** `v*` runs the full platform packaging, clean-install, provenance, checksum and publication matrix. - **Dependency freeze:** never writes a branch from CI. Run `npm run freeze:deps` in a trusted Node 24 LTS / npm 11 environment with npm-registry access, review the generated `package-lock.json`, then commit it. CI consumes it with `npm ci`. diff --git a/scripts/build-codex-tui.ps1 b/scripts/build-codex-tui.ps1 index e3b5a97b..2ea797e0 100644 --- a/scripts/build-codex-tui.ps1 +++ b/scripts/build-codex-tui.ps1 @@ -1,4 +1,5 @@ $ErrorActionPreference = 'Stop' +$PSNativeCommandUseErrorActionPreference = $true $Root = (Resolve-Path (Join-Path $PSScriptRoot '..')).Path & (Join-Path $Root 'scripts\materialize-codex.ps1') diff --git a/test/tui_smoke.py b/test/tui_smoke.py index 6a144d85..e4c94548 100644 --- a/test/tui_smoke.py +++ b/test/tui_smoke.py @@ -63,6 +63,11 @@ def wait_for_protocol_notification(method, timeout=10): "TERM": "xterm-256color", "CODEX_HOME": codex_home, "DSHX_APP_SERVER_ENDPOINT": endpoint, + # pexpect provides a PTY transport, not a terminal emulator that can + # negotiate kitty keyboard enhancement modes. Use Codex's supported + # fallback so this gate measures TUI/local-IPC/CJK/resize behavior + # consistently on Linux and macOS rather than terminal emulation. + "CODEX_TUI_DISABLE_KEYBOARD_ENHANCEMENT": "1", }) child = pexpect.spawn( str(binary), @@ -79,10 +84,7 @@ def wait_for_protocol_notification(method, timeout=10): wait_for_protocol_notification("thread/started") child.setwinsize(40, 100) child.send(PROMPT) - # Pinned Codex enables the kitty keyboard enhancement protocol (CSI-u). - # pexpect is a PTY transport, not a terminal emulator, so emulate the - # Enter key encoding a compliant terminal sends while that mode is active. - child.send("\x1b[13u") + child.send("\r") child.expect("DSHX protocol stub received:") transcript.append(child.before + child.after) child.expect(PROMPT) From 75924547de9cc1b4ca37176f807b3b8e098f0985 Mon Sep 17 00:00:00 2001 From: code-new-bie <403466121@qq.com> Date: Fri, 21 Aug 2026 09:09:16 +0800 Subject: [PATCH 262/305] test: use macOS PTY Enter encoding --- test/tui_smoke.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/test/tui_smoke.py b/test/tui_smoke.py index e4c94548..ba9ab85f 100644 --- a/test/tui_smoke.py +++ b/test/tui_smoke.py @@ -3,6 +3,7 @@ import os import pathlib import subprocess +import sys import tempfile import time @@ -84,7 +85,11 @@ def wait_for_protocol_notification(method, timeout=10): wait_for_protocol_notification("thread/started") child.setwinsize(40, 100) child.send(PROMPT) - child.send("\r") + # With keyboard enhancement disabled, Linux PTYs report Enter as CR, + # while the macOS pexpect/raw-PTY path requires LF to surface the same + # KeyCode::Enter event. Keep each platform on the encoding its real + # PTY path accepts instead of emulating a terminal protocol here. + child.send("\n" if sys.platform == "darwin" else "\r") child.expect("DSHX protocol stub received:") transcript.append(child.before + child.after) child.expect(PROMPT) From c90b693f02c95371cf79c165c599f641fda3c107 Mon Sep 17 00:00:00 2001 From: code-new-bie <403466121@qq.com> Date: Fri, 21 Aug 2026 09:12:52 +0800 Subject: [PATCH 263/305] test: respect Codex paste-submit window --- test/tui_smoke.py | 12 ++++++------ test/tui_smoke_windows.mjs | 4 ++++ 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/test/tui_smoke.py b/test/tui_smoke.py index ba9ab85f..5c44bdd3 100644 --- a/test/tui_smoke.py +++ b/test/tui_smoke.py @@ -3,7 +3,6 @@ import os import pathlib import subprocess -import sys import tempfile import time @@ -85,11 +84,12 @@ def wait_for_protocol_notification(method, timeout=10): wait_for_protocol_notification("thread/started") child.setwinsize(40, 100) child.send(PROMPT) - # With keyboard enhancement disabled, Linux PTYs report Enter as CR, - # while the macOS pexpect/raw-PTY path requires LF to surface the same - # KeyCode::Enter event. Keep each platform on the encoding its real - # PTY path accepts instead of emulating a terminal protocol here. - child.send("\n" if sys.platform == "darwin" else "\r") + # Codex deliberately suppresses Enter for 120 ms after a detected + # paste-like burst so multiline paste cannot submit accidentally. + # pexpect writes the whole prompt as one burst; wait past that + # product behavior before simulating the user's submit key. + time.sleep(0.25) + child.send("\r") child.expect("DSHX protocol stub received:") transcript.append(child.before + child.after) child.expect(PROMPT) diff --git a/test/tui_smoke_windows.mjs b/test/tui_smoke_windows.mjs index 9f09dbc1..723c929e 100644 --- a/test/tui_smoke_windows.mjs +++ b/test/tui_smoke_windows.mjs @@ -161,6 +161,10 @@ try { term.resize(100, 40); const prompt = '你好,DSHX ConPTY resize'; term.write(prompt); + // Codex suppresses Enter for 120 ms after a paste-like burst so multiline + // paste cannot submit accidentally. node-pty writes this prompt as a burst; + // wait beyond that product behavior before simulating the user's submit key. + await sleep(250); // Pinned Codex requests kitty keyboard enhancement reporting. node-pty is a // ConPTY transport rather than a terminal emulator, so encode Enter as CSI-u. term.write('\x1b[13u'); From 7c9e355e9d988715ae979fbdc988387245f957c0 Mon Sep 17 00:00:00 2001 From: code-new-bie <403466121@qq.com> Date: Fri, 21 Aug 2026 10:58:43 +0800 Subject: [PATCH 264/305] ci: allow cold RC platform builds to finish --- .github/workflows/rc-platform.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/rc-platform.yml b/.github/workflows/rc-platform.yml index c5d4519c..9e2445b3 100644 --- a/.github/workflows/rc-platform.yml +++ b/.github/workflows/rc-platform.yml @@ -17,7 +17,7 @@ jobs: if: github.event_name == 'workflow_dispatch' || startsWith(github.head_ref, 'release/') name: Windows ConPTY / CJK / local IPC runs-on: windows-latest - timeout-minutes: 90 + timeout-minutes: 150 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: @@ -59,7 +59,7 @@ jobs: if: github.event_name == 'workflow_dispatch' || startsWith(github.head_ref, 'release/') name: macOS PTY / CJK / resize / local IPC runs-on: macos-26 - timeout-minutes: 60 + timeout-minutes: 120 steps: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: From 81af6b1342b44a145fc8bc77b812f14164b2d83f Mon Sep 17 00:00:00 2001 From: code-new-bie <403466121@qq.com> Date: Fri, 21 Aug 2026 14:11:08 +0800 Subject: [PATCH 265/305] ci: boot DSH on RC platforms before TUI build --- .github/workflows/rc-platform.yml | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/.github/workflows/rc-platform.yml b/.github/workflows/rc-platform.yml index 9e2445b3..dfa46c3f 100644 --- a/.github/workflows/rc-platform.yml +++ b/.github/workflows/rc-platform.yml @@ -36,9 +36,11 @@ jobs: with: node-version: 24 - name: Install frozen dependency graph - run: npm ci + run: npm ci --include=optional --ignore-scripts=false - name: Verify exact DeepSeek Harness release closure run: node scripts/verify-dsh-closure.mjs + - name: Boot and dispose official DeepSeek Harness composition + run: node scripts/runtime-smoke.mjs - name: Build pinned Codex TUI + local IPC bridge shell: pwsh run: ./scripts/build-codex-tui.ps1 @@ -51,7 +53,7 @@ jobs: if ($LASTEXITCODE -ne 0) { throw 'local IPC data-plane self-check failed' } - name: Install test-only ConPTY bridge shell: pwsh - run: npm install --no-save --package-lock=false node-pty@1.1.0 + run: npm install --no-save --package-lock=false --include=optional --ignore-scripts=false node-pty@1.1.0 - name: Exercise real TUI through Windows ConPTY + CJK text run: node test/tui_smoke_windows.mjs @@ -80,9 +82,11 @@ jobs: with: node-version: 24 - name: Install frozen dependency graph - run: npm ci + run: npm ci --include=optional --ignore-scripts=false - name: Verify exact DeepSeek Harness release closure run: node scripts/verify-dsh-closure.mjs + - name: Boot and dispose official DeepSeek Harness composition + run: node scripts/runtime-smoke.mjs - name: Build pinned Codex TUI + local IPC bridge shell: bash run: bash scripts/build-codex-tui.sh From 073d21681ea26be4c345aa5d8f5aa299f69bb965 Mon Sep 17 00:00:00 2001 From: code-new-bie <403466121@qq.com> Date: Fri, 21 Aug 2026 18:57:46 +0800 Subject: [PATCH 266/305] test: stabilize exact-head terminal gates --- .github/workflows/rc-platform.yml | 3 --- test/tui_smoke.py | 6 ++++++ test/tui_smoke_windows.mjs | 12 ++++++++++++ 3 files changed, 18 insertions(+), 3 deletions(-) mode change 100644 => 100755 test/tui_smoke.py mode change 100644 => 100755 test/tui_smoke_windows.mjs diff --git a/.github/workflows/rc-platform.yml b/.github/workflows/rc-platform.yml index dfa46c3f..34de87db 100644 --- a/.github/workflows/rc-platform.yml +++ b/.github/workflows/rc-platform.yml @@ -51,9 +51,6 @@ jobs: run: | & .\dist\bin\dshx-ipc-bridge.exe --check if ($LASTEXITCODE -ne 0) { throw 'local IPC data-plane self-check failed' } - - name: Install test-only ConPTY bridge - shell: pwsh - run: npm install --no-save --package-lock=false --include=optional --ignore-scripts=false node-pty@1.1.0 - name: Exercise real TUI through Windows ConPTY + CJK text run: node test/tui_smoke_windows.mjs diff --git a/test/tui_smoke.py b/test/tui_smoke.py old mode 100644 new mode 100755 index 5c44bdd3..a732237a --- a/test/tui_smoke.py +++ b/test/tui_smoke.py @@ -82,6 +82,12 @@ def wait_for_protocol_notification(method, timeout=10): child.expect("DeepSeek Harness") transcript.append(child.before + child.after) wait_for_protocol_notification("thread/started") + # Startup is asynchronous: thread/started can arrive while the model + # banner still says "loading". Wait for the deterministic stub model + # to render before typing so the startup refresh cannot reset the + # composer underneath the simulated user input. + child.expect("dshx-stub") + transcript.append(child.before + child.after) child.setwinsize(40, 100) child.send(PROMPT) # Codex deliberately suppresses Enter for 120 ms after a detected diff --git a/test/tui_smoke_windows.mjs b/test/tui_smoke_windows.mjs old mode 100644 new mode 100755 index 723c929e..7d08bff9 --- a/test/tui_smoke_windows.mjs +++ b/test/tui_smoke_windows.mjs @@ -9,6 +9,7 @@ import { createInterface } from 'node:readline'; import * as pty from 'node-pty'; const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const EXPECTED_NODE_PTY_VERSION = '1.2.0-beta.15'; function stripTerminalControl(value) { return value @@ -116,6 +117,13 @@ if (process.platform !== 'win32') { throw new Error('tui_smoke_windows.mjs must run on Windows/ConPTY'); } +const nodePtyPackage = JSON.parse(fs.readFileSync(path.join(ROOT, 'node_modules', 'node-pty', 'package.json'), 'utf8')); +if (nodePtyPackage.version !== EXPECTED_NODE_PTY_VERSION) { + throw new Error( + `Windows ConPTY smoke must use frozen node-pty ${EXPECTED_NODE_PTY_VERSION}; got ${nodePtyPackage.version}` + ); +} + const binary = path.join(ROOT, 'dist', 'bin', 'dshx-tui.exe'); const bridge = path.join(ROOT, 'dist', 'bin', 'dshx-ipc-bridge.exe'); if (!fs.existsSync(binary)) throw new Error(`built DSHX TUI missing: ${binary}`); @@ -158,6 +166,10 @@ try { await waitForOutput(state, 'DeepSeek Harness'); await waitForProtocolNotification(traceFile, 'thread/started'); + // thread/started precedes the asynchronous model/banner refresh. Wait for + // the deterministic stub model to render before touching the composer so + // startup cannot reset input that the ConPTY harness has already injected. + await waitForOutput(state, 'dshx-stub'); term.resize(100, 40); const prompt = '你好,DSHX ConPTY resize'; term.write(prompt); From 427b10f57c2671e06a5e0568e661aba13744e315 Mon Sep 17 00:00:00 2001 From: code-new-bie <403466121@qq.com> Date: Fri, 21 Aug 2026 19:09:57 +0800 Subject: [PATCH 267/305] release: harden native install and ConPTY evidence --- .github/workflows/release.yml | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 1bcbfbdf..8b2fa8bb 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -14,7 +14,7 @@ jobs: build: name: Build / ${{ matrix.label }} runs-on: ${{ matrix.os }} - timeout-minutes: 75 + timeout-minutes: 150 strategy: fail-fast: false matrix: @@ -80,7 +80,7 @@ jobs: run: Add-Content -Path $env:GITHUB_ENV -Value "DSHX_VERSION=$($env:GITHUB_REF_NAME.TrimStart('v'))" - name: Test presentation adapter from frozen graph run: | - npm ci + npm ci --include=optional --ignore-scripts=false node scripts/verify-dsh-closure.mjs npm test - name: Boot official DSH runtime @@ -112,10 +112,6 @@ jobs: python3 -m venv "$VENV" "$VENV/bin/python" -m pip install --disable-pip-version-check pexpect "$VENV/bin/python" test/tui_smoke.py - - name: Install test-only Windows ConPTY bridge - if: runner.os == 'Windows' - shell: pwsh - run: npm install --no-save --package-lock=false node-pty@1.1.0 - name: Real pinned TUI Windows ConPTY + CJK local-IPC smoke if: runner.os == 'Windows' run: node test/tui_smoke_windows.mjs @@ -134,7 +130,7 @@ jobs: set -euo pipefail TGZ="$(find dist/release -maxdepth 1 -name 'dshx-*.tgz' -print -quit)" PREFIX="$RUNNER_TEMP/dshx-release-smoke" - npm install --global --prefix "$PREFIX" "$TGZ" + npm install --global --prefix "$PREFIX" --include=optional --ignore-scripts=false "$TGZ" "$PREFIX/bin/dshx" --version | grep -F "$DSHX_VERSION" "$PREFIX/bin/dshx" doctor - name: Clean install smoke (Windows) @@ -144,7 +140,7 @@ jobs: $tgz = Get-ChildItem dist\release\dshx-*.tgz | Select-Object -First 1 if (-not $tgz) { throw 'platform tarball missing' } $prefix = Join-Path $env:RUNNER_TEMP 'dshx-release-smoke' - npm install --global --prefix $prefix $tgz.FullName + npm install --global --prefix $prefix --include=optional --ignore-scripts=false $tgz.FullName $version = & (Join-Path $prefix 'dshx.cmd') --version if (-not ($version -match [regex]::Escape($env:DSHX_VERSION))) { throw "wrong dshx version: $version" } & (Join-Path $prefix 'dshx.cmd') doctor From d0e3d0632e2413925410046155d41f4f1a644042 Mon Sep 17 00:00:00 2001 From: code-new-bie <403466121@qq.com> Date: Fri, 21 Aug 2026 19:11:13 +0800 Subject: [PATCH 268/305] doctor: boot the official DSH composition --- bin/dshx.mjs | 110 ++++++++++++++++++++++++++++++++++++++++----------- 1 file changed, 88 insertions(+), 22 deletions(-) mode change 100644 => 100755 bin/dshx.mjs diff --git a/bin/dshx.mjs b/bin/dshx.mjs old mode 100644 new mode 100755 index 4e195bdf..48cf5fe6 --- a/bin/dshx.mjs +++ b/bin/dshx.mjs @@ -11,6 +11,26 @@ import { isSupportedNodeVersion } from '../src/cli/runtime.mjs'; const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); const PACKAGE = JSON.parse(fs.readFileSync(path.join(ROOT, 'package.json'), 'utf8')); const VERSION = PACKAGE.version; +const REQUIRED_RUNTIME_SERVICES = [ + 'agents', + 'agentDefaultModel', + 'llm', + 'sessions', + 'sessionPersistence', + 'sessionQuery', + 'sessionProjections', + 'sessionTitle', + 'attachments', + 'tools', + 'commands', + 'compaction', + 'subagents', + 'permissionPresets', + 'approval', + 'userQuestions', + 'skills', + 'planMode' +]; function usage() { return `DSHX ${VERSION}\n\nUsage:\n dshx Start DSHX in the current project\n dshx Start with an initial prompt\n dshx resume Open the Codex-style DSH session picker\n dshx resume --last Resume the most recent DSH session\n dshx resume Resume a specific DSH session\n dshx doctor Check packaged TUI, local IPC and official DSH composition\n dshx --version Print version\n dshx --help Show this help\n\nEnvironment:\n DSHX_TUI_BIN Override the packaged DSHX TUI binary (development only)\n DSHX_IPC_BRIDGE_BIN Override the packaged local IPC bridge (development only)\n DSHX_TUI_HOME Override DSHX presentation-only Codex home (development only)\n DSHX_DEBUG=1 Print DSHX adapter diagnostics\n\nProduct boundary:\n DSHX owns only the launcher, Codex TUI thin fork and presentation adapter.\n DeepSeek Harness remains the authoritative Agent/Session/Tool runtime.\n DSHX never reads or writes the user's ordinary CODEX_HOME.\n`; @@ -46,6 +66,16 @@ function commandDetail(command, result) { return `${command} is not runnable${result.stderr ? `: ${result.stderr.trim()}` : ''}`; } +function withTimeout(promise, timeoutMs, label) { + let timer; + return Promise.race([ + promise, + new Promise((_, reject) => { + timer = setTimeout(() => reject(new Error(`${label} exceeded ${timeoutMs}ms`)), timeoutMs); + }) + ]).finally(() => clearTimeout(timer)); +} + function presentationHomeCheck(localServerInternals, loadError) { const home = dshxTuiHome(); try { @@ -70,6 +100,59 @@ function presentationHomeCheck(localServerInternals, loadError) { } } +async function officialRuntimeCheck(runtimeBoot, loadError) { + if (typeof runtimeBoot?.bootDshxRuntime !== 'function') { + return { ok: false, detail: `official DSH modules failed to load: ${loadError ?? 'unknown error'}` }; + } + + let ctx; + let primaryError; + let cleanupError; + try { + // Doctor deliberately exercises the same official composition and profile + // watcher path as production startup. This catches missing native optional + // dependencies (for example sharp/koffi) that a static bundle-entry check + // cannot detect. + ctx = await withTimeout( + runtimeBoot.bootDshxRuntime({ cwd: process.cwd(), watch: true }), + 20_000, + 'official DSH composition boot' + ); + const missing = REQUIRED_RUNTIME_SERVICES.filter((name) => ctx.get(name) == null); + if (missing.length > 0) { + throw new Error(`official DSH composition is missing required presentation services: ${missing.join(', ')}`); + } + } catch (error) { + primaryError = error; + } finally { + if (ctx) { + try { + await withTimeout(Promise.resolve(ctx.dispose?.()), 10_000, 'official DSH composition disposal'); + } catch (error) { + cleanupError = error; + } + } + } + + if (primaryError) { + const detail = primaryError instanceof Error ? primaryError.message : String(primaryError); + const cleanup = cleanupError + ? `; secondary cleanup failure: ${cleanupError instanceof Error ? cleanupError.message : String(cleanupError)}` + : ''; + return { ok: false, detail: `${detail}${cleanup}` }; + } + if (cleanupError) { + return { + ok: false, + detail: `official DSH composition disposal failed: ${cleanupError instanceof Error ? cleanupError.message : String(cleanupError)}` + }; + } + return { + ok: true, + detail: `official DSH composition boot/dispose (${REQUIRED_RUNTIME_SERVICES.length} required services)` + }; +} + async function doctor() { const executable = packagedTuiBinary(); const tuiCheck = executable && fs.existsSync(executable) @@ -81,37 +164,20 @@ async function doctor() { : { status: 127, stdout: '', stderr: '', error: null }; let localServerInternals; - let dshxRuntimeEntries; + let runtimeBoot; let runtimeLoadError; try { - const [localServer, runtimeBoot] = await Promise.all([ + const [localServer, loadedRuntimeBoot] = await Promise.all([ import('../src/dsh/local-server.mjs'), import('../src/dsh/runtime-boot.mjs') ]); localServerInternals = localServer.localServerInternals; - dshxRuntimeEntries = runtimeBoot.dshxRuntimeEntries; + runtimeBoot = loadedRuntimeBoot; } catch (error) { runtimeLoadError = error instanceof Error ? error.message : String(error); } const homeCheck = presentationHomeCheck(localServerInternals, runtimeLoadError); - - let runtimeDetail; - let runtimeOk = false; - if (!dshxRuntimeEntries) { - runtimeDetail = `official DSH modules failed to load: ${runtimeLoadError ?? 'unknown error'}`; - } else { - try { - const entries = dshxRuntimeEntries(); - const required = ['llm', 'session', 'agent', 'permission', 'approval', 'user-questions', 'tools']; - const ids = new Set(entries.filter((entry) => !entry.disabled).map((entry) => entry.id)); - const missing = required.filter((id) => !ids.has(id)); - if (missing.length > 0) throw new Error(`missing official bundle entries: ${missing.join(', ')}`); - runtimeDetail = `official DSH bundle composition (${entries.length} entries)`; - runtimeOk = true; - } catch (error) { - runtimeDetail = error instanceof Error ? error.message : String(error); - } - } + const runtimeCheck = await officialRuntimeCheck(runtimeBoot, runtimeLoadError); const rows = [ ['Node', `${process.version} (required: ${PACKAGE.engines.node})`, isSupportedNodeVersion(process.versions.node)], @@ -125,7 +191,7 @@ async function doctor() { bridgeCheck.status === 0 ? `${bridge} (cross-platform Codex UDS)` : (bridge ? commandDetail(bridge, bridgeCheck) : 'not found'), bridgeCheck.status === 0 ], - ['DeepSeek Harness', runtimeDetail, runtimeOk], + ['DeepSeek Harness', runtimeCheck.detail, runtimeCheck.ok], ['Presentation home / IPC path', homeCheck.detail, homeCheck.ok] ]; for (const [name, detail, ok] of rows) { From a16901e1ae302f0f6f17b321df9545064f5cb26c Mon Sep 17 00:00:00 2001 From: code-new-bie <403466121@qq.com> Date: Fri, 21 Aug 2026 20:20:07 +0800 Subject: [PATCH 269/305] test: simulate human terminal input in PTY gates --- test/tui_smoke.py | 14 ++++++++------ test/tui_smoke_windows.mjs | 24 +++++++++++++++--------- 2 files changed, 23 insertions(+), 15 deletions(-) diff --git a/test/tui_smoke.py b/test/tui_smoke.py index a732237a..5c311da2 100755 --- a/test/tui_smoke.py +++ b/test/tui_smoke.py @@ -89,12 +89,14 @@ def wait_for_protocol_notification(method, timeout=10): child.expect("dshx-stub") transcript.append(child.before + child.after) child.setwinsize(40, 100) - child.send(PROMPT) - # Codex deliberately suppresses Enter for 120 ms after a detected - # paste-like burst so multiline paste cannot submit accidentally. - # pexpect writes the whole prompt as one burst; wait past that - # product behavior before simulating the user's submit key. - time.sleep(0.25) + # Exercise the keyboard path like a user rather than injecting one + # paste-like burst. Codex intentionally suppresses Enter after a + # detected paste, which is product behavior and not what this smoke + # is meant to test. Character-wise input also verifies CJK handling. + for char in PROMPT: + child.send(char) + time.sleep(0.02) + time.sleep(0.10) child.send("\r") child.expect("DSHX protocol stub received:") transcript.append(child.before + child.after) diff --git a/test/tui_smoke_windows.mjs b/test/tui_smoke_windows.mjs index 7d08bff9..2177e50b 100755 --- a/test/tui_smoke_windows.mjs +++ b/test/tui_smoke_windows.mjs @@ -158,7 +158,11 @@ try { env: stringEnv({ TERM: 'xterm-256color', CODEX_HOME: codexHome, - DSHX_APP_SERVER_ENDPOINT: endpoint + DSHX_APP_SERVER_ENDPOINT: endpoint, + // node-pty provides a ConPTY transport, not a terminal emulator capable + // of negotiating Codex's keyboard enhancement protocol. Use Codex's + // supported fallback and exercise canonical terminal key bytes instead. + CODEX_TUI_DISABLE_KEYBOARD_ENHANCEMENT: '1' }) }); const state = { output: '' }; @@ -172,14 +176,16 @@ try { await waitForOutput(state, 'dshx-stub'); term.resize(100, 40); const prompt = '你好,DSHX ConPTY resize'; - term.write(prompt); - // Codex suppresses Enter for 120 ms after a paste-like burst so multiline - // paste cannot submit accidentally. node-pty writes this prompt as a burst; - // wait beyond that product behavior before simulating the user's submit key. - await sleep(250); - // Pinned Codex requests kitty keyboard enhancement reporting. node-pty is a - // ConPTY transport rather than a terminal emulator, so encode Enter as CSI-u. - term.write('\x1b[13u'); + // Exercise the keyboard path like a user rather than injecting one + // paste-like burst. Codex intentionally suppresses Enter after detected + // paste input; this smoke validates CJK keyboard input, submit and resize. + for (const char of prompt) { + term.write(char); + await sleep(20); + } + await waitForOutput(state, prompt); + await sleep(100); + term.write('\r'); await waitForOutput(state, 'DSHX protocol stub received:'); await waitForOutput(state, prompt); process.stdout.write('Windows ConPTY DSHX local-IPC + CJK + resize smoke passed\n'); From 4f99fcdbf5957f7410c5850fa495a879e4de1d9b Mon Sep 17 00:00:00 2001 From: code-new-bie <403466121@qq.com> Date: Fri, 21 Aug 2026 22:28:28 +0800 Subject: [PATCH 270/305] test: respect Codex paste submit guard in PTY gates --- test/tui_smoke.py | 14 ++++++-------- test/tui_smoke_windows.mjs | 16 +++++++--------- 2 files changed, 13 insertions(+), 17 deletions(-) mode change 100755 => 100644 test/tui_smoke.py mode change 100755 => 100644 test/tui_smoke_windows.mjs diff --git a/test/tui_smoke.py b/test/tui_smoke.py old mode 100755 new mode 100644 index 5c311da2..c548499a --- a/test/tui_smoke.py +++ b/test/tui_smoke.py @@ -89,14 +89,12 @@ def wait_for_protocol_notification(method, timeout=10): child.expect("dshx-stub") transcript.append(child.before + child.after) child.setwinsize(40, 100) - # Exercise the keyboard path like a user rather than injecting one - # paste-like burst. Codex intentionally suppresses Enter after a - # detected paste, which is product behavior and not what this smoke - # is meant to test. Character-wise input also verifies CJK handling. - for char in PROMPT: - child.send(char) - time.sleep(0.02) - time.sleep(0.10) + child.send(PROMPT) + # This PTY transport writes the UTF-8 prompt as a burst. Codex + # deliberately suppresses Enter for 120 ms after paste-like input; + # wait safely beyond that product behavior before submitting. Real + # keyboard/IME composition fidelity is covered by manual gate #12. + time.sleep(0.25) child.send("\r") child.expect("DSHX protocol stub received:") transcript.append(child.before + child.after) diff --git a/test/tui_smoke_windows.mjs b/test/tui_smoke_windows.mjs old mode 100755 new mode 100644 index 2177e50b..9375a407 --- a/test/tui_smoke_windows.mjs +++ b/test/tui_smoke_windows.mjs @@ -176,17 +176,15 @@ try { await waitForOutput(state, 'dshx-stub'); term.resize(100, 40); const prompt = '你好,DSHX ConPTY resize'; - // Exercise the keyboard path like a user rather than injecting one - // paste-like burst. Codex intentionally suppresses Enter after detected - // paste input; this smoke validates CJK keyboard input, submit and resize. - for (const char of prompt) { - term.write(char); - await sleep(20); - } - await waitForOutput(state, prompt); - await sleep(100); + term.write(prompt); + // ConPTY writes this UTF-8 prompt as a burst. Codex deliberately suppresses + // Enter for 120 ms after paste-like input; wait safely beyond that product + // behavior before submitting. Real keyboard/IME fidelity is gate #12. + await sleep(250); term.write('\r'); await waitForOutput(state, 'DSHX protocol stub received:'); + // Validate the echoed assistant response rather than requiring the input + // composer's ANSI redraws to contain one contiguous copy of the prompt. await waitForOutput(state, prompt); process.stdout.write('Windows ConPTY DSHX local-IPC + CJK + resize smoke passed\n'); } finally { From 8635bc672f9cbbb7e4fb9dfe39ecfce41d9a14f6 Mon Sep 17 00:00:00 2001 From: code-new-bie <403466121@qq.com> Date: Sat, 22 Aug 2026 00:10:04 +0800 Subject: [PATCH 271/305] test: synchronize macOS PTY submit after rendered input --- test/tui_smoke.py | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/test/tui_smoke.py b/test/tui_smoke.py index c548499a..ab354137 100644 --- a/test/tui_smoke.py +++ b/test/tui_smoke.py @@ -10,6 +10,7 @@ ROOT = pathlib.Path(__file__).resolve().parents[1] PROMPT = "你好,DSHX PTY resize" +PROMPT_TAIL = "DSHX PTY resize" trace_fd, trace_name = tempfile.mkstemp(prefix="dshx-protocol-trace-", suffix=".jsonl") os.close(trace_fd) trace_path = pathlib.Path(trace_name) @@ -90,10 +91,14 @@ def wait_for_protocol_notification(method, timeout=10): transcript.append(child.before + child.after) child.setwinsize(40, 100) child.send(PROMPT) - # This PTY transport writes the UTF-8 prompt as a burst. Codex - # deliberately suppresses Enter for 120 ms after paste-like input; - # wait safely beyond that product behavior before submitting. Real - # keyboard/IME composition fidelity is covered by manual gate #12. + # A PTY write completing does not mean the TUI has consumed the + # bytes. On slower macOS runners, a fixed delay measured from write + # completion can expire while the prompt is still queued, making + # Enter arrive immediately after the paste-like burst is processed. + # Wait for the stable ASCII tail to be rendered first, then start + # Codex's 120 ms paste-submit guard delay. + child.expect(PROMPT_TAIL) + transcript.append(child.before + child.after) time.sleep(0.25) child.send("\r") child.expect("DSHX protocol stub received:") From 109e0b326afecd81155e9fecee1e55797b55847e Mon Sep 17 00:00:00 2001 From: code-new-bie <403466121@qq.com> Date: Sat, 22 Aug 2026 02:12:56 +0800 Subject: [PATCH 272/305] release: enforce remote-only Codex runtime closure --- scripts/build-codex-tui.ps1 | 1 + scripts/build-codex-tui.sh | 1 + scripts/verify-codex-runtime-closure.mjs | 68 ++++++++++++++++++++++++ 3 files changed, 70 insertions(+) mode change 100644 => 100755 scripts/build-codex-tui.sh create mode 100755 scripts/verify-codex-runtime-closure.mjs diff --git a/scripts/build-codex-tui.ps1 b/scripts/build-codex-tui.ps1 index 2ea797e0..9f7e50f3 100644 --- a/scripts/build-codex-tui.ps1 +++ b/scripts/build-codex-tui.ps1 @@ -4,6 +4,7 @@ $PSNativeCommandUseErrorActionPreference = $true $Root = (Resolve-Path (Join-Path $PSScriptRoot '..')).Path & (Join-Path $Root 'scripts\materialize-codex.ps1') node (Join-Path $Root 'scripts\verify-slash-contract.mjs') +node (Join-Path $Root 'scripts\verify-codex-runtime-closure.mjs') $CodeXDir = if ($env:DSHX_CODEX_DIR) { $env:DSHX_CODEX_DIR } else { Join-Path $Root '.upstream\codex' } $TargetDir = if ($env:CARGO_TARGET_DIR) { $env:CARGO_TARGET_DIR } else { Join-Path $Root '.build\codex' } $OutDir = if ($env:DSHX_TUI_OUT_DIR) { $env:DSHX_TUI_OUT_DIR } else { Join-Path $Root 'dist\bin' } diff --git a/scripts/build-codex-tui.sh b/scripts/build-codex-tui.sh old mode 100644 new mode 100755 index c391076e..c783b4a0 --- a/scripts/build-codex-tui.sh +++ b/scripts/build-codex-tui.sh @@ -4,6 +4,7 @@ set -euo pipefail ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" bash "$ROOT/scripts/materialize-codex.sh" node "$ROOT/scripts/verify-slash-contract.mjs" +node "$ROOT/scripts/verify-codex-runtime-closure.mjs" CODEX_DIR="${DSHX_CODEX_DIR:-$ROOT/.upstream/codex}" TARGET_DIR="${CARGO_TARGET_DIR:-$ROOT/.build/codex}" OUT_DIR="${DSHX_TUI_OUT_DIR:-$ROOT/dist/bin}" diff --git a/scripts/verify-codex-runtime-closure.mjs b/scripts/verify-codex-runtime-closure.mjs new file mode 100755 index 00000000..8a11d5ed --- /dev/null +++ b/scripts/verify-codex-runtime-closure.mjs @@ -0,0 +1,68 @@ +#!/usr/bin/env node +import path from 'node:path'; +import process from 'node:process'; +import { spawnSync } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; + +const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const CODEX_DIR = path.resolve(process.env.DSHX_CODEX_DIR || path.join(ROOT, '.upstream', 'codex')); +const MANIFEST = path.join(CODEX_DIR, 'codex-rs', 'Cargo.toml'); +const FORBIDDEN = [ + 'codex-app-server', + 'codex-core', + 'codex-exec-server', + 'codex-login', + 'codex-rollout', + 'codex-state' +]; + +function cargo(args) { + const command = process.platform === 'win32' ? 'cargo.exe' : 'cargo'; + return spawnSync(command, args, { + cwd: ROOT, + encoding: 'utf8', + env: process.env, + maxBuffer: 32 * 1024 * 1024 + }); +} + +const baseArgs = [ + 'tree', + '--manifest-path', MANIFEST, + '--locked', + '--edges', 'normal', + '-p', 'codex-tui' +]; +const tree = cargo([...baseArgs, '--format', '{p}']); +if (tree.error) throw tree.error; +if (tree.status !== 0) { + process.stderr.write(tree.stdout || ''); + process.stderr.write(tree.stderr || ''); + throw new Error(`cargo tree failed with exit code ${tree.status}`); +} + +const packages = new Set(); +for (const rawLine of tree.stdout.split(/\r?\n/)) { + const line = rawLine.trim(); + const match = /^([^\s]+)\s+v\d/.exec(line); + if (match) packages.add(match[1]); +} +const offenders = FORBIDDEN.filter((name) => packages.has(name)); +if (offenders.length === 0) { + process.stdout.write('DSHX Codex dependency closure is presentation/remote-only\n'); + process.exit(0); +} + +process.stderr.write( + `DSHX release profile still reaches authoritative Codex runtime crates: ${offenders.join(', ')}\n` +); +for (const name of offenders) { + process.stderr.write(`\n--- reverse dependency path for ${name} ---\n`); + const inverse = cargo([...baseArgs, '--invert', name]); + process.stderr.write(inverse.stdout || ''); + process.stderr.write(inverse.stderr || ''); +} +process.stderr.write( + '\nRelease is blocked until codex-tui is built through an explicit remote-only Cargo feature profile and this gate passes.\n' +); +process.exit(1); From 3d7e017bb06b8656dd233b8d0e685c38d42176a4 Mon Sep 17 00:00:00 2001 From: code-new-bie <403466121@qq.com> Date: Sat, 22 Aug 2026 03:05:35 +0800 Subject: [PATCH 273/305] release: make Codex runtime closure gate fail closed --- scripts/verify-codex-runtime-closure.mjs | 1 + 1 file changed, 1 insertion(+) diff --git a/scripts/verify-codex-runtime-closure.mjs b/scripts/verify-codex-runtime-closure.mjs index 8a11d5ed..d614b3d4 100755 --- a/scripts/verify-codex-runtime-closure.mjs +++ b/scripts/verify-codex-runtime-closure.mjs @@ -31,6 +31,7 @@ const baseArgs = [ '--manifest-path', MANIFEST, '--locked', '--edges', 'normal', + '--prefix', 'none', '-p', 'codex-tui' ]; const tree = cargo([...baseArgs, '--format', '{p}']); From cb606d28d2c8a32b6da170507da44d7f71082cdc Mon Sep 17 00:00:00 2001 From: code-new-bie <403466121@qq.com> Date: Sat, 22 Aug 2026 04:21:32 +0800 Subject: [PATCH 274/305] release: introduce DSHX remote-only Cargo profile --- scripts/build-codex-tui.ps1 | 2 +- scripts/build-codex-tui.sh | 3 +- scripts/verify-codex-runtime-closure.mjs | 6 +- .../codex/0012-dshx-remote-only-profile.patch | 108 ++++++++++++++++++ 4 files changed, 115 insertions(+), 4 deletions(-) create mode 100644 upstream/patches/codex/0012-dshx-remote-only-profile.patch diff --git a/scripts/build-codex-tui.ps1 b/scripts/build-codex-tui.ps1 index 9f7e50f3..2c2f66e8 100644 --- a/scripts/build-codex-tui.ps1 +++ b/scripts/build-codex-tui.ps1 @@ -11,7 +11,7 @@ $OutDir = if ($env:DSHX_TUI_OUT_DIR) { $env:DSHX_TUI_OUT_DIR } else { Join-Path New-Item -ItemType Directory -Force $OutDir | Out-Null $env:CARGO_TARGET_DIR = $TargetDir -cargo build --manifest-path (Join-Path $CodeXDir 'codex-rs\Cargo.toml') --locked --release -p codex-tui --bin codex-tui +cargo build --manifest-path (Join-Path $CodeXDir 'codex-rs\Cargo.toml') --locked --release -p codex-tui --bin codex-tui --no-default-features --features dshx-remote-only cargo build --manifest-path (Join-Path $CodeXDir 'codex-rs\Cargo.toml') --locked --release -p codex-stdio-to-uds --bin dshx-ipc-bridge $TuiSource = Join-Path $TargetDir 'release\codex-tui.exe' $TuiDestination = Join-Path $OutDir 'dshx-tui.exe' diff --git a/scripts/build-codex-tui.sh b/scripts/build-codex-tui.sh index c783b4a0..57920661 100755 --- a/scripts/build-codex-tui.sh +++ b/scripts/build-codex-tui.sh @@ -12,7 +12,8 @@ OUT_DIR="${DSHX_TUI_OUT_DIR:-$ROOT/dist/bin}" mkdir -p "$OUT_DIR" CARGO_TARGET_DIR="$TARGET_DIR" cargo build \ --manifest-path "$CODEX_DIR/codex-rs/Cargo.toml" \ - --locked --release -p codex-tui --bin codex-tui + --locked --release -p codex-tui --bin codex-tui \ + --no-default-features --features dshx-remote-only CARGO_TARGET_DIR="$TARGET_DIR" cargo build \ --manifest-path "$CODEX_DIR/codex-rs/Cargo.toml" \ --locked --release -p codex-stdio-to-uds --bin dshx-ipc-bridge diff --git a/scripts/verify-codex-runtime-closure.mjs b/scripts/verify-codex-runtime-closure.mjs index d614b3d4..6fa4f5f7 100755 --- a/scripts/verify-codex-runtime-closure.mjs +++ b/scripts/verify-codex-runtime-closure.mjs @@ -15,6 +15,7 @@ const FORBIDDEN = [ 'codex-rollout', 'codex-state' ]; +const RELEASE_FEATURE_ARGS = ['--no-default-features', '--features', 'dshx-remote-only']; function cargo(args) { const command = process.platform === 'win32' ? 'cargo.exe' : 'cargo'; @@ -32,7 +33,8 @@ const baseArgs = [ '--locked', '--edges', 'normal', '--prefix', 'none', - '-p', 'codex-tui' + '-p', 'codex-tui', + ...RELEASE_FEATURE_ARGS ]; const tree = cargo([...baseArgs, '--format', '{p}']); if (tree.error) throw tree.error; @@ -64,6 +66,6 @@ for (const name of offenders) { process.stderr.write(inverse.stderr || ''); } process.stderr.write( - '\nRelease is blocked until codex-tui is built through an explicit remote-only Cargo feature profile and this gate passes.\n' + '\nRelease is blocked until codex-tui is built through the explicit dshx-remote-only Cargo feature profile and this gate passes.\n' ); process.exit(1); diff --git a/upstream/patches/codex/0012-dshx-remote-only-profile.patch b/upstream/patches/codex/0012-dshx-remote-only-profile.patch new file mode 100644 index 00000000..64f0e65b --- /dev/null +++ b/upstream/patches/codex/0012-dshx-remote-only-profile.patch @@ -0,0 +1,108 @@ +diff --git a/codex-rs/app-server-client/Cargo.toml b/codex-rs/app-server-client/Cargo.toml +--- a/codex-rs/app-server-client/Cargo.toml ++++ b/codex-rs/app-server-client/Cargo.toml +@@ -9,19 +9,32 @@ + path = "src/lib.rs" + doctest = false + ++[features] ++default = ["embedded"] ++embedded = [ ++ "dep:codex-app-server", ++ "dep:codex-arg0", ++ "dep:codex-config", ++ "dep:codex-core", ++ "dep:codex-exec-server", ++ "dep:codex-feedback", ++ "dep:toml", ++] ++ + [lints] + workspace = true + + [dependencies] +-codex-app-server = { workspace = true } ++codex-app-server = { workspace = true, optional = true } + codex-app-server-protocol = { workspace = true } +-codex-arg0 = { workspace = true } +-codex-config = { workspace = true } +-codex-core = { workspace = true } +-codex-exec-server = { workspace = true } +-codex-feedback = { workspace = true } ++codex-arg0 = { workspace = true, optional = true } ++codex-config = { workspace = true, optional = true } ++codex-core = { workspace = true, optional = true } ++codex-exec-server = { workspace = true, optional = true } ++codex-feedback = { workspace = true, optional = true } + codex-protocol = { workspace = true } + codex-uds = { workspace = true } + codex-utils-absolute-path = { workspace = true } +@@ -34,7 +47,7 @@ + serde_json = { workspace = true } + tokio = { workspace = true, features = ["sync", "time", "rt"] } + tokio-tungstenite = { workspace = true } +-toml = { workspace = true } ++toml = { workspace = true, optional = true } + tracing = { workspace = true } + url = { workspace = true } + +diff --git a/codex-rs/tui/Cargo.toml b/codex-rs/tui/Cargo.toml +--- a/codex-rs/tui/Cargo.toml ++++ b/codex-rs/tui/Cargo.toml +@@ -17,6 +17,17 @@ + path = "src/lib.rs" + doctest = false + ++[features] ++default = ["embedded"] ++embedded = [ ++ "codex-app-server-client/embedded", ++ "dep:codex-exec-server", ++ "dep:codex-login", ++ "dep:codex-rollout", ++ "dep:codex-state", ++] ++dshx-remote-only = [] ++ + [lints] + workspace = true + +@@ -27,7 +38,7 @@ + chrono = { workspace = true, features = ["serde"] } + clap = { workspace = true, features = ["derive"] } + codex-ansi-escape = { workspace = true } +-codex-app-server-client = { workspace = true } ++codex-app-server-client = { workspace = true, default-features = false } + codex-app-server-protocol = { workspace = true } + codex-arg0 = { workspace = true } + codex-install-context = { workspace = true } +@@ -37,7 +48,7 @@ + codex-connectors = { workspace = true } + codex-core-plugins = { workspace = true } + codex-history = { workspace = true } +-codex-exec-server = { workspace = true } ++codex-exec-server = { workspace = true, optional = true } + codex-features = { workspace = true } + codex-feedback = { workspace = true } + codex-file-search = { workspace = true } +@@ -45,7 +56,7 @@ + codex-git-utils = { workspace = true } + codex-http-client = { workspace = true } +-codex-login = { workspace = true } ++codex-login = { workspace = true, optional = true } + codex-message-history = { workspace = true } + codex-model-provider = { workspace = true } + codex-model-provider-info = { workspace = true } +@@ -55,11 +66,11 @@ + codex-otel = { workspace = true } + codex-plugin = { workspace = true } + codex-protocol = { workspace = true } +-codex-rollout = { workspace = true } ++codex-rollout = { workspace = true, optional = true } + codex-sandboxing = { workspace = true } + codex-shell-command = { workspace = true } +-codex-state = { workspace = true } ++codex-state = { workspace = true, optional = true } + codex-terminal-detection = { workspace = true } + codex-utils-approval-presets = { workspace = true } + codex-utils-absolute-path = { workspace = true } From 05de27595a1761d2b84c3e08bf71ddbe7fe39d18 Mon Sep 17 00:00:00 2001 From: code-new-bie <403466121@qq.com> Date: Sat, 22 Aug 2026 05:09:26 +0800 Subject: [PATCH 275/305] fix: make remote app-server client dependency explicit --- upstream/patches/codex/0012-dshx-remote-only-profile.patch | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/upstream/patches/codex/0012-dshx-remote-only-profile.patch b/upstream/patches/codex/0012-dshx-remote-only-profile.patch index 64f0e65b..f2493925 100644 --- a/upstream/patches/codex/0012-dshx-remote-only-profile.patch +++ b/upstream/patches/codex/0012-dshx-remote-only-profile.patch @@ -72,7 +72,7 @@ diff --git a/codex-rs/tui/Cargo.toml b/codex-rs/tui/Cargo.toml clap = { workspace = true, features = ["derive"] } codex-ansi-escape = { workspace = true } -codex-app-server-client = { workspace = true } -+codex-app-server-client = { workspace = true, default-features = false } ++codex-app-server-client = { path = "../app-server-client", default-features = false } codex-app-server-protocol = { workspace = true } codex-arg0 = { workspace = true } codex-install-context = { workspace = true } From 1a87dddd8b0651d10e6d219bdeb8aad6355af023 Mon Sep 17 00:00:00 2001 From: code-new-bie <403466121@qq.com> Date: Sat, 22 Aug 2026 06:15:55 +0800 Subject: [PATCH 276/305] fix: narrow remote-only Codex dependency graph --- .../codex/0012-dshx-remote-only-profile.patch | 106 +++++++++++++++--- 1 file changed, 91 insertions(+), 15 deletions(-) diff --git a/upstream/patches/codex/0012-dshx-remote-only-profile.patch b/upstream/patches/codex/0012-dshx-remote-only-profile.patch index f2493925..c8c9ba1b 100644 --- a/upstream/patches/codex/0012-dshx-remote-only-profile.patch +++ b/upstream/patches/codex/0012-dshx-remote-only-profile.patch @@ -1,13 +1,63 @@ +diff --git a/codex-rs/app-server-protocol/Cargo.toml b/codex-rs/app-server-protocol/Cargo.toml +--- a/codex-rs/app-server-protocol/Cargo.toml ++++ b/codex-rs/app-server-protocol/Cargo.toml +@@ -9,6 +9,10 @@ + path = "src/lib.rs" + doctest = false + ++[features] ++default = ["embedded"] ++embedded = ["dep:codex-rollout"] ++ + [lints] + workspace = true + +@@ -21,7 +25,7 @@ + codex-extension-items = { workspace = true } + codex-history = { workspace = true } + codex-protocol = { workspace = true } +-codex-rollout = { workspace = true } ++codex-rollout = { workspace = true, optional = true } + codex-secrets = { workspace = true } + codex-shell-command = { workspace = true } + codex-utils-absolute-path = { workspace = true } +diff --git a/codex-rs/app-server-protocol/src/lib.rs b/codex-rs/app-server-protocol/src/lib.rs +--- a/codex-rs/app-server-protocol/src/lib.rs ++++ b/codex-rs/app-server-protocol/src/lib.rs +@@ -22,8 +22,10 @@ + pub use protocol::common::*; + pub use protocol::event_mapping::*; + pub use protocol::item_builders::*; ++#[cfg(feature = "embedded")] + pub use protocol::thread_history::*; ++#[cfg(feature = "embedded")] + pub use protocol::thread_history_projection::*; + pub use protocol::v1::ApplyPatchApprovalParams; + pub use protocol::v1::ApplyPatchApprovalResponse; +diff --git a/codex-rs/app-server-protocol/src/protocol/mod.rs b/codex-rs/app-server-protocol/src/protocol/mod.rs +--- a/codex-rs/app-server-protocol/src/protocol/mod.rs ++++ b/codex-rs/app-server-protocol/src/protocol/mod.rs +@@ -6,8 +6,10 @@ + pub mod item_builders; + mod mappers; + mod serde_helpers; ++#[cfg(feature = "embedded")] + pub mod thread_history; ++#[cfg(feature = "embedded")] + pub mod thread_history_projection; + pub mod v1; + pub mod v2; diff --git a/codex-rs/app-server-client/Cargo.toml b/codex-rs/app-server-client/Cargo.toml --- a/codex-rs/app-server-client/Cargo.toml +++ b/codex-rs/app-server-client/Cargo.toml -@@ -9,19 +9,32 @@ +@@ -9,19 +9,33 @@ path = "src/lib.rs" doctest = false +[features] +default = ["embedded"] +embedded = [ ++ "codex-app-server-protocol/embedded", + "dep:codex-app-server", + "dep:codex-arg0", + "dep:codex-config", @@ -22,13 +72,14 @@ diff --git a/codex-rs/app-server-client/Cargo.toml b/codex-rs/app-server-client/ [dependencies] -codex-app-server = { workspace = true } -+codex-app-server = { workspace = true, optional = true } - codex-app-server-protocol = { workspace = true } +-codex-app-server-protocol = { workspace = true } -codex-arg0 = { workspace = true } -codex-config = { workspace = true } -codex-core = { workspace = true } -codex-exec-server = { workspace = true } -codex-feedback = { workspace = true } ++codex-app-server = { workspace = true, optional = true } ++codex-app-server-protocol = { path = "../app-server-protocol", default-features = false } +codex-arg0 = { workspace = true, optional = true } +codex-config = { workspace = true, optional = true } +codex-core = { workspace = true, optional = true } @@ -37,7 +88,7 @@ diff --git a/codex-rs/app-server-client/Cargo.toml b/codex-rs/app-server-client/ codex-protocol = { workspace = true } codex-uds = { workspace = true } codex-utils-absolute-path = { workspace = true } -@@ -34,7 +47,7 @@ +@@ -34,7 +48,7 @@ serde_json = { workspace = true } tokio = { workspace = true, features = ["sync", "time", "rt"] } tokio-tungstenite = { workspace = true } @@ -49,7 +100,7 @@ diff --git a/codex-rs/app-server-client/Cargo.toml b/codex-rs/app-server-client/ diff --git a/codex-rs/tui/Cargo.toml b/codex-rs/tui/Cargo.toml --- a/codex-rs/tui/Cargo.toml +++ b/codex-rs/tui/Cargo.toml -@@ -17,6 +17,17 @@ +@@ -17,6 +17,27 @@ path = "src/lib.rs" doctest = false @@ -57,45 +108,62 @@ diff --git a/codex-rs/tui/Cargo.toml b/codex-rs/tui/Cargo.toml +default = ["embedded"] +embedded = [ + "codex-app-server-client/embedded", ++ "codex-app-server-protocol/embedded", ++ "dep:codex-cloud-config", ++ "dep:codex-connectors", ++ "dep:codex-core-plugins", + "dep:codex-exec-server", ++ "dep:codex-feedback", + "dep:codex-login", ++ "dep:codex-model-provider", ++ "dep:codex-models-manager", ++ "dep:codex-plugin", + "dep:codex-rollout", + "dep:codex-state", ++ "dep:codex-utils-oss", +] +dshx-remote-only = [] + [lints] workspace = true -@@ -27,7 +38,7 @@ +@@ -27,37 +48,37 @@ chrono = { workspace = true, features = ["serde"] } clap = { workspace = true, features = ["derive"] } codex-ansi-escape = { workspace = true } -codex-app-server-client = { workspace = true } +-codex-app-server-protocol = { workspace = true } +codex-app-server-client = { path = "../app-server-client", default-features = false } - codex-app-server-protocol = { workspace = true } ++codex-app-server-protocol = { path = "../app-server-protocol", default-features = false } codex-arg0 = { workspace = true } codex-install-context = { workspace = true } -@@ -37,7 +48,7 @@ - codex-connectors = { workspace = true } - codex-core-plugins = { workspace = true } +-codex-cloud-config = { workspace = true } ++codex-cloud-config = { workspace = true, optional = true } + codex-config = { workspace = true } +-codex-connectors = { workspace = true } +-codex-core-plugins = { workspace = true } ++codex-connectors = { workspace = true, optional = true } ++codex-core-plugins = { workspace = true, optional = true } codex-history = { workspace = true } -codex-exec-server = { workspace = true } +codex-exec-server = { workspace = true, optional = true } codex-features = { workspace = true } - codex-feedback = { workspace = true } +-codex-feedback = { workspace = true } ++codex-feedback = { workspace = true, optional = true } codex-file-search = { workspace = true } -@@ -45,7 +56,7 @@ codex-git-utils = { workspace = true } codex-http-client = { workspace = true } -codex-login = { workspace = true } +codex-login = { workspace = true, optional = true } codex-message-history = { workspace = true } - codex-model-provider = { workspace = true } +-codex-model-provider = { workspace = true } ++codex-model-provider = { workspace = true, optional = true } codex-model-provider-info = { workspace = true } -@@ -55,11 +66,11 @@ +-codex-models-manager = { workspace = true } ++codex-models-manager = { workspace = true, optional = true } codex-otel = { workspace = true } - codex-plugin = { workspace = true } +-codex-plugin = { workspace = true } ++codex-plugin = { workspace = true, optional = true } codex-protocol = { workspace = true } -codex-rollout = { workspace = true } +codex-rollout = { workspace = true, optional = true } @@ -106,3 +174,11 @@ diff --git a/codex-rs/tui/Cargo.toml b/codex-rs/tui/Cargo.toml codex-terminal-detection = { workspace = true } codex-utils-approval-presets = { workspace = true } codex-utils-absolute-path = { workspace = true } +@@ -67,7 +88,7 @@ + codex-utils-fuzzy-match = { workspace = true } + codex-utils-home-dir = { workspace = true } +-codex-utils-oss = { workspace = true } ++codex-utils-oss = { workspace = true, optional = true } + codex-utils-path = { workspace = true } + codex-utils-path-uri = { workspace = true } + codex-utils-plugins = { workspace = true } From 11a8140067d55f6cc92b2ec66727b6e4ec44540f Mon Sep 17 00:00:00 2001 From: code-new-bie <403466121@qq.com> Date: Sat, 22 Aug 2026 06:17:29 +0800 Subject: [PATCH 277/305] fix: remove local dispatch helpers from remote closure --- .../0013-dshx-remote-only-local-helpers.patch | 34 +++++++++++++++++++ 1 file changed, 34 insertions(+) create mode 100644 upstream/patches/codex/0013-dshx-remote-only-local-helpers.patch diff --git a/upstream/patches/codex/0013-dshx-remote-only-local-helpers.patch b/upstream/patches/codex/0013-dshx-remote-only-local-helpers.patch new file mode 100644 index 00000000..66d285ed --- /dev/null +++ b/upstream/patches/codex/0013-dshx-remote-only-local-helpers.patch @@ -0,0 +1,34 @@ +diff --git a/codex-rs/tui/Cargo.toml b/codex-rs/tui/Cargo.toml +--- a/codex-rs/tui/Cargo.toml ++++ b/codex-rs/tui/Cargo.toml +@@ -22,6 +22,7 @@ embedded = [ + "codex-app-server-client/embedded", + "codex-app-server-protocol/embedded", ++ "dep:codex-arg0", + "dep:codex-cloud-config", + "dep:codex-connectors", + "dep:codex-core-plugins", +@@ -36,6 +37,7 @@ embedded = [ + "dep:codex-state", + "dep:codex-utils-oss", ++ "dep:codex-utils-plugins", + ] + dshx-remote-only = [] + +@@ -51,7 +53,7 @@ clap = { workspace = true, features = ["derive"] } + codex-ansi-escape = { workspace = true } + codex-app-server-client = { path = "../app-server-client", default-features = false } + codex-app-server-protocol = { path = "../app-server-protocol", default-features = false } +-codex-arg0 = { workspace = true } ++codex-arg0 = { workspace = true, optional = true } + codex-install-context = { workspace = true } + codex-cloud-config = { workspace = true, optional = true } + codex-config = { workspace = true } +@@ -94,7 +96,7 @@ codex-utils-oss = { workspace = true, optional = true } + codex-utils-path = { workspace = true } + codex-utils-path-uri = { workspace = true } +-codex-utils-plugins = { workspace = true } ++codex-utils-plugins = { workspace = true, optional = true } + codex-utils-sandbox-summary = { workspace = true } + codex-utils-sleep-inhibitor = { workspace = true } + codex-utils-string = { workspace = true } From 1d6039403728463c7e50a3d335a4271ea1a243a0 Mon Sep 17 00:00:00 2001 From: code-new-bie <403466121@qq.com> Date: Sat, 22 Aug 2026 07:02:46 +0800 Subject: [PATCH 278/305] fix: gate embedded app-server client source --- ...dshx-gate-embedded-app-server-client.patch | 233 ++++++++++++++++++ 1 file changed, 233 insertions(+) create mode 100644 upstream/patches/codex/0014-dshx-gate-embedded-app-server-client.patch diff --git a/upstream/patches/codex/0014-dshx-gate-embedded-app-server-client.patch b/upstream/patches/codex/0014-dshx-gate-embedded-app-server-client.patch new file mode 100644 index 00000000..9cd83a99 --- /dev/null +++ b/upstream/patches/codex/0014-dshx-gate-embedded-app-server-client.patch @@ -0,0 +1,233 @@ +diff --git a/codex-rs/app-server-client/src/lib.rs b/codex-rs/app-server-client/src/lib.rs +--- a/codex-rs/app-server-client/src/lib.rs ++++ b/codex-rs/app-server-client/src/lib.rs +@@ -27,39 +27,54 @@ use std::sync::Arc; + use std::time::Duration; + ++#[cfg(feature = "embedded")] + pub use codex_app_server::app_server_control_socket_path; +-pub use codex_app_server::in_process::DEFAULT_IN_PROCESS_CHANNEL_CAPACITY; ++/// Default bounded channel capacity used by both embedded and remote clients. ++/// ++/// This mirrors the pinned app-server transport capacity without requiring the ++/// authoritative embedded app-server crate in DSHX's remote-only profile. ++pub const DEFAULT_IN_PROCESS_CHANNEL_CAPACITY: usize = 128; ++#[cfg(feature = "embedded")] + pub use codex_app_server::in_process::InProcessServerEvent; ++#[cfg(feature = "embedded")] + use codex_app_server::in_process::InProcessStartArgs; ++#[cfg(feature = "embedded")] + use codex_app_server::in_process::LogDbLayer; ++#[cfg(feature = "embedded")] + pub use codex_app_server::in_process::StateDbHandle; ++#[cfg(feature = "embedded")] + use codex_app_server_protocol::ClientInfo; + use codex_app_server_protocol::ClientNotification; + use codex_app_server_protocol::ClientRequest; ++#[cfg(feature = "embedded")] + use codex_app_server_protocol::ConfigWarningNotification; ++#[cfg(feature = "embedded")] + use codex_app_server_protocol::InitializeCapabilities; ++#[cfg(feature = "embedded")] + use codex_app_server_protocol::InitializeParams; + use codex_app_server_protocol::JSONRPCErrorError; + use codex_app_server_protocol::RequestId; + use codex_app_server_protocol::Result as JsonRpcResult; + use codex_app_server_protocol::ServerNotification; + use codex_app_server_protocol::ServerRequest; ++#[cfg(feature = "embedded")] + use codex_arg0::Arg0DispatchPaths; ++#[cfg(feature = "embedded")] + use codex_config::CloudConfigBundleLoader; ++#[cfg(feature = "embedded")] + use codex_config::LoaderOverrides; ++#[cfg(feature = "embedded")] + use codex_config::NoopThreadConfigLoader; ++#[cfg(feature = "embedded")] + use codex_core::config::Config; ++#[cfg(feature = "embedded")] + pub use codex_core::otel_init::build_provider as build_otel_provider; ++#[cfg(feature = "embedded")] + pub use codex_exec_server::EnvironmentManager; ++#[cfg(feature = "embedded")] + pub use codex_exec_server::ExecServerRuntimePaths; ++#[cfg(feature = "embedded")] + use codex_feedback::CodexFeedback; ++#[cfg(feature = "embedded")] + use codex_protocol::protocol::SessionSource; + use codex_utils_absolute_path::AbsolutePathBuf; + use serde::de::DeserializeOwned; +@@ -67,6 +82,7 @@ use tokio::sync::mpsc; + use tokio::sync::oneshot; + use tokio::time::timeout; ++#[cfg(feature = "embedded")] + use toml::Value as TomlValue; + use tracing::warn; + +@@ -80,6 +96,7 @@ pub use crate::remote::RemoteAppServerEndpoint; + /// New TUI behavior should prefer the app-server protocol methods. This + /// module exists so clients can remove a direct `codex-core` dependency + /// while legacy startup/config paths are migrated to RPCs. ++#[cfg(feature = "embedded")] + pub mod legacy_core { + pub mod config { + pub use codex_core::config::*; +@@ -91,6 +108,7 @@ pub mod legacy_core { + + const SHUTDOWN_TIMEOUT: Duration = Duration::from_secs(5); + // Covers the embedded drain, its analytics flush, and final task join. ++#[cfg(feature = "embedded")] + const IN_PROCESS_SHUTDOWN_TIMEOUT: Duration = Duration::from_secs(45); +@@ -107,6 +125,7 @@ pub enum AppServerEvent { + Disconnected { message: String }, + } + ++#[cfg(feature = "embedded")] + impl From for AppServerEvent { + fn from(value: InProcessServerEvent) -> Self { + match value { +@@ -167,6 +186,7 @@ impl Error for TypedRequestError { + } + } + ++#[cfg(feature = "embedded")] + #[derive(Clone)] + pub struct InProcessClientStartArgs { +@@ -219,6 +239,7 @@ pub struct InProcessClientStartArgs { + pub channel_capacity: usize, + } + ++#[cfg(feature = "embedded")] + impl InProcessClientStartArgs { +@@ -264,6 +285,7 @@ impl InProcessClientStartArgs { + /// Each variant carries a oneshot sender so the caller can `await` the + /// result without holding a mutable reference to the client. ++#[cfg(feature = "embedded")] + enum ClientCommand { +@@ -293,6 +315,7 @@ enum ClientCommand { + /// callers aligned with app-server behavior while still avoiding a process + /// boundary. ++#[cfg(feature = "embedded")] + pub struct InProcessAppServerClient { +@@ -302,20 +325,25 @@ pub struct InProcessAppServerClient { + } + ++#[cfg(feature = "embedded")] + #[derive(Clone)] + pub struct InProcessAppServerRequestHandle { + command_tx: mpsc::Sender, + } + + #[derive(Clone)] + pub enum AppServerRequestHandle { ++ #[cfg(feature = "embedded")] + InProcess(InProcessAppServerRequestHandle), + Remote(crate::remote::RemoteAppServerRequestHandle), + } + + pub enum AppServerClient { ++ #[cfg(feature = "embedded")] + InProcess(InProcessAppServerClient), + Remote(RemoteAppServerClient), + } + ++#[cfg(feature = "embedded")] + impl InProcessAppServerClient { +@@ -601,6 +629,7 @@ impl InProcessAppServerClient { + } + } + ++#[cfg(feature = "embedded")] + impl InProcessAppServerRequestHandle { +@@ -653,6 +682,7 @@ impl AppServerRequestHandle { + pub async fn request(&self, request: ClientRequest) -> IoResult { + match self { ++ #[cfg(feature = "embedded")] + Self::InProcess(handle) => handle.request(request).await, + Self::Remote(handle) => handle.request(request).await, + } +@@ -663,6 +693,7 @@ impl AppServerRequestHandle { + T: DeserializeOwned, + { + match self { ++ #[cfg(feature = "embedded")] + Self::InProcess(handle) => handle.request_typed(request).await, + Self::Remote(handle) => handle.request_typed(request).await, + } +@@ -672,6 +703,7 @@ impl AppServerClient { + pub fn codex_home(&self, local_codex_home: &AbsolutePathBuf) -> Option { + match self { ++ #[cfg(feature = "embedded")] + Self::InProcess(_) => Some(AppServerPath::from_app_server( + local_codex_home.display().to_string(), + )), +@@ -682,6 +714,7 @@ impl AppServerClient { + + pub async fn request(&self, request: ClientRequest) -> IoResult { + match self { ++ #[cfg(feature = "embedded")] + Self::InProcess(client) => client.request(request).await, + Self::Remote(client) => client.request(request).await, + } +@@ -693,6 +726,7 @@ impl AppServerClient { + T: DeserializeOwned, + { + match self { ++ #[cfg(feature = "embedded")] + Self::InProcess(client) => client.request_typed(request).await, + Self::Remote(client) => client.request_typed(request).await, + } +@@ -701,6 +735,7 @@ impl AppServerClient { + + pub async fn notify(&self, notification: ClientNotification) -> IoResult<()> { + match self { ++ #[cfg(feature = "embedded")] + Self::InProcess(client) => client.notify(notification).await, + Self::Remote(client) => client.notify(notification).await, + } +@@ -713,6 +748,7 @@ impl AppServerClient { + result: JsonRpcResult, + ) -> IoResult<()> { + match self { ++ #[cfg(feature = "embedded")] + Self::InProcess(client) => client.resolve_server_request(request_id, result).await, + Self::Remote(client) => client.resolve_server_request(request_id, result).await, + } +@@ -725,6 +761,7 @@ impl AppServerClient { + error: JSONRPCErrorError, + ) -> IoResult<()> { + match self { ++ #[cfg(feature = "embedded")] + Self::InProcess(client) => client.reject_server_request(request_id, error).await, + Self::Remote(client) => client.reject_server_request(request_id, error).await, + } +@@ -733,6 +770,7 @@ impl AppServerClient { + + pub async fn next_event(&mut self) -> Option { + match self { ++ #[cfg(feature = "embedded")] + Self::InProcess(client) => client.next_event().await.map(Into::into), + Self::Remote(client) => client.next_event().await, + } +@@ -741,6 +779,7 @@ impl AppServerClient { + + pub async fn shutdown(self) -> IoResult<()> { + match self { ++ #[cfg(feature = "embedded")] + Self::InProcess(client) => client.shutdown().await, + Self::Remote(client) => client.shutdown().await, + } +@@ -749,12 +788,14 @@ impl AppServerClient { + + pub fn request_handle(&self) -> AppServerRequestHandle { + match self { ++ #[cfg(feature = "embedded")] + Self::InProcess(client) => AppServerRequestHandle::InProcess(client.request_handle()), + Self::Remote(client) => AppServerRequestHandle::Remote(client.request_handle()), + } + } + } + +-#[cfg(test)] ++#[cfg(all(test, feature = "embedded"))] + mod tests { From 400095c375f23519f4d31488f10590205f2a3971 Mon Sep 17 00:00:00 2001 From: code-new-bie <403466121@qq.com> Date: Sat, 22 Aug 2026 08:17:25 +0800 Subject: [PATCH 279/305] fix: split remote-only presentation helpers --- ...15-dshx-presentation-helper-features.patch | 142 ++++++++++++++++++ 1 file changed, 142 insertions(+) create mode 100644 upstream/patches/codex/0015-dshx-presentation-helper-features.patch diff --git a/upstream/patches/codex/0015-dshx-presentation-helper-features.patch b/upstream/patches/codex/0015-dshx-presentation-helper-features.patch new file mode 100644 index 00000000..e029f93c --- /dev/null +++ b/upstream/patches/codex/0015-dshx-presentation-helper-features.patch @@ -0,0 +1,142 @@ +diff --git a/codex-rs/utils/plugins/Cargo.toml b/codex-rs/utils/plugins/Cargo.toml +--- a/codex-rs/utils/plugins/Cargo.toml ++++ b/codex-rs/utils/plugins/Cargo.toml +@@ -9,10 +9,14 @@ name = "codex_utils_plugins" + path = "src/lib.rs" + ++[features] ++default = ["embedded"] ++embedded = ["dep:codex-exec-server"] ++ + [lints] + workspace = true + + [dependencies] +-codex-exec-server = { workspace = true } ++codex-exec-server = { workspace = true, optional = true } + codex-exec-server-protocol = { workspace = true } + codex-utils-absolute-path = { workspace = true } + codex-utils-path-uri = { workspace = true } +diff --git a/codex-rs/utils/plugins/src/plugin_namespace.rs b/codex-rs/utils/plugins/src/plugin_namespace.rs +--- a/codex-rs/utils/plugins/src/plugin_namespace.rs ++++ b/codex-rs/utils/plugins/src/plugin_namespace.rs +@@ -1,5 +1,6 @@ + //! Resolve plugin namespace from skill file paths by walking ancestors for `plugin.json`. + ++#[cfg(feature = "embedded")] + use codex_exec_server::ExecutorFileSystem; + use codex_exec_server_protocol::DISCOVERABLE_PLUGIN_MANIFEST_PATHS; + use codex_utils_path_uri::PathUri; +@@ -75,6 +76,7 @@ struct RawPluginManifestName { + } + + /// Returns the plugin manifest `name` defined directly below `plugin_root`. ++#[cfg(feature = "embedded")] + pub async fn plugin_namespace_for_root_uri( + fs: &dyn ExecutorFileSystem, + plugin_root: &PathUri, +diff --git a/codex-rs/plugin/Cargo.toml b/codex-rs/plugin/Cargo.toml +--- a/codex-rs/plugin/Cargo.toml ++++ b/codex-rs/plugin/Cargo.toml +@@ -8,6 +8,10 @@ name = "codex_plugin" + path = "src/lib.rs" + ++[features] ++default = ["embedded"] ++embedded = ["codex-utils-plugins/embedded"] ++ + [lints] + workspace = true + +@@ -16,7 +20,7 @@ codex-config = { workspace = true } + codex-protocol = { workspace = true } + codex-utils-absolute-path = { workspace = true } + codex-utils-path-uri = { workspace = true } +-codex-utils-plugins = { workspace = true } ++codex-utils-plugins = { path = "../utils/plugins", default-features = false } + thiserror = { workspace = true } + + [dev-dependencies] +diff --git a/codex-rs/feedback/Cargo.toml b/codex-rs/feedback/Cargo.toml +--- a/codex-rs/feedback/Cargo.toml ++++ b/codex-rs/feedback/Cargo.toml +@@ -5,12 +5,16 @@ edition.workspace = true + license.workspace = true + ++[features] ++default = ["embedded"] ++embedded = ["dep:codex-login"] ++ + [lints] + workspace = true + + [dependencies] + anyhow = { workspace = true } + codex-http-client = { workspace = true } +-codex-login = { workspace = true } ++codex-login = { workspace = true, optional = true } + codex-protocol = { workspace = true } + mime_guess = { workspace = true } + sentry = { version = "0.46" } +diff --git a/codex-rs/feedback/src/lib.rs b/codex-rs/feedback/src/lib.rs +--- a/codex-rs/feedback/src/lib.rs ++++ b/codex-rs/feedback/src/lib.rs +@@ -14,6 +14,7 @@ use anyhow::anyhow; + use codex_http_client::ClientRouteClass; + use codex_http_client::HttpClientFactory; + use codex_http_client::RouteAwareClientPool; ++#[cfg(feature = "embedded")] + use codex_login::AuthEnvTelemetry; + use codex_protocol::ThreadId; + use codex_protocol::protocol::SessionSource; +@@ -133,6 +134,7 @@ pub fn emit_feedback_request_tags(tags: &FeedbackRequestTags<'_>) { + ); + } + ++#[cfg(feature = "embedded")] + pub fn emit_feedback_request_tags_with_auth_env( + tags: &FeedbackRequestTags<'_>, + auth_env: &AuthEnvTelemetry, +diff --git a/codex-rs/tui/Cargo.toml b/codex-rs/tui/Cargo.toml +--- a/codex-rs/tui/Cargo.toml ++++ b/codex-rs/tui/Cargo.toml +@@ -27,7 +27,7 @@ embedded = [ + "dep:codex-connectors", + "dep:codex-core-plugins", + "dep:codex-exec-server", +- "dep:codex-feedback", ++ "codex-feedback/embedded", + "dep:codex-login", + "dep:codex-model-provider", + "dep:codex-models-manager", +- "dep:codex-plugin", ++ "codex-plugin/embedded", + "dep:codex-rollout", + "dep:codex-state", + "dep:codex-utils-oss", +- "dep:codex-utils-plugins", ++ "codex-utils-plugins/embedded", + ] + dshx-remote-only = [] +@@ -64,7 +64,7 @@ codex-exec-server = { workspace = true, optional = true } + codex-features = { workspace = true } +-codex-feedback = { workspace = true, optional = true } ++codex-feedback = { path = "../feedback", default-features = false } + codex-file-search = { workspace = true } + codex-git-utils = { workspace = true } + codex-http-client = { workspace = true } +@@ -77,7 +77,7 @@ codex-models-manager = { workspace = true, optional = true } + codex-otel = { workspace = true } +-codex-plugin = { workspace = true, optional = true } ++codex-plugin = { path = "../plugin", default-features = false } + codex-protocol = { workspace = true } + codex-rollout = { workspace = true, optional = true } + codex-sandboxing = { workspace = true } +@@ -96,7 +96,7 @@ codex-utils-oss = { workspace = true, optional = true } + codex-utils-path = { workspace = true } + codex-utils-path-uri = { workspace = true } +-codex-utils-plugins = { workspace = true, optional = true } ++codex-utils-plugins = { path = "../utils/plugins", default-features = false } + codex-utils-sandbox-summary = { workspace = true } + codex-utils-sleep-inhibitor = { workspace = true } + codex-utils-string = { workspace = true } From 08c5bcea82d4aee24c39d4df12597c72bff7ce53 Mon Sep 17 00:00:00 2001 From: code-new-bie <403466121@qq.com> Date: Sat, 22 Aug 2026 09:01:36 +0800 Subject: [PATCH 280/305] fix: gate embedded plugin namespace export --- ...0015-dshx-presentation-helper-features.patch | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/upstream/patches/codex/0015-dshx-presentation-helper-features.patch b/upstream/patches/codex/0015-dshx-presentation-helper-features.patch index e029f93c..90bdfdfd 100644 --- a/upstream/patches/codex/0015-dshx-presentation-helper-features.patch +++ b/upstream/patches/codex/0015-dshx-presentation-helper-features.patch @@ -17,17 +17,30 @@ diff --git a/codex-rs/utils/plugins/Cargo.toml b/codex-rs/utils/plugins/Cargo.to codex-exec-server-protocol = { workspace = true } codex-utils-absolute-path = { workspace = true } codex-utils-path-uri = { workspace = true } +diff --git a/codex-rs/utils/plugins/src/lib.rs b/codex-rs/utils/plugins/src/lib.rs +--- a/codex-rs/utils/plugins/src/lib.rs ++++ b/codex-rs/utils/plugins/src/lib.rs +@@ -15,6 +15,7 @@ pub use plugin_namespace::SUPPORTED_AGENT_PLUGIN_SCHEMA_URIS; + pub use plugin_namespace::agent_plugin_schema_status; + pub use plugin_namespace::find_plugin_manifest_path; ++#[cfg(feature = "embedded")] + pub use plugin_namespace::plugin_namespace_for_root_uri; + + #[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)] diff --git a/codex-rs/utils/plugins/src/plugin_namespace.rs b/codex-rs/utils/plugins/src/plugin_namespace.rs --- a/codex-rs/utils/plugins/src/plugin_namespace.rs +++ b/codex-rs/utils/plugins/src/plugin_namespace.rs -@@ -1,5 +1,6 @@ +@@ -1,7 +1,9 @@ //! Resolve plugin namespace from skill file paths by walking ancestors for `plugin.json`. +#[cfg(feature = "embedded")] use codex_exec_server::ExecutorFileSystem; use codex_exec_server_protocol::DISCOVERABLE_PLUGIN_MANIFEST_PATHS; ++#[cfg(feature = "embedded")] use codex_utils_path_uri::PathUri; -@@ -75,6 +76,7 @@ struct RawPluginManifestName { + use std::path::Path; + use std::path::PathBuf; +@@ -75,6 +77,7 @@ struct RawPluginManifestName { } /// Returns the plugin manifest `name` defined directly below `plugin_root`. From 19f8297c679c5a864dd71b236df1ed0821101106 Mon Sep 17 00:00:00 2001 From: code-new-bie <403466121@qq.com> Date: Sat, 22 Aug 2026 14:27:17 +0800 Subject: [PATCH 281/305] build: defer Codex compile-time closure trimming --- scripts/build-codex-tui.ps1 | 3 +-- scripts/build-codex-tui.sh | 4 +--- 2 files changed, 2 insertions(+), 5 deletions(-) diff --git a/scripts/build-codex-tui.ps1 b/scripts/build-codex-tui.ps1 index 2c2f66e8..2ea797e0 100644 --- a/scripts/build-codex-tui.ps1 +++ b/scripts/build-codex-tui.ps1 @@ -4,14 +4,13 @@ $PSNativeCommandUseErrorActionPreference = $true $Root = (Resolve-Path (Join-Path $PSScriptRoot '..')).Path & (Join-Path $Root 'scripts\materialize-codex.ps1') node (Join-Path $Root 'scripts\verify-slash-contract.mjs') -node (Join-Path $Root 'scripts\verify-codex-runtime-closure.mjs') $CodeXDir = if ($env:DSHX_CODEX_DIR) { $env:DSHX_CODEX_DIR } else { Join-Path $Root '.upstream\codex' } $TargetDir = if ($env:CARGO_TARGET_DIR) { $env:CARGO_TARGET_DIR } else { Join-Path $Root '.build\codex' } $OutDir = if ($env:DSHX_TUI_OUT_DIR) { $env:DSHX_TUI_OUT_DIR } else { Join-Path $Root 'dist\bin' } New-Item -ItemType Directory -Force $OutDir | Out-Null $env:CARGO_TARGET_DIR = $TargetDir -cargo build --manifest-path (Join-Path $CodeXDir 'codex-rs\Cargo.toml') --locked --release -p codex-tui --bin codex-tui --no-default-features --features dshx-remote-only +cargo build --manifest-path (Join-Path $CodeXDir 'codex-rs\Cargo.toml') --locked --release -p codex-tui --bin codex-tui cargo build --manifest-path (Join-Path $CodeXDir 'codex-rs\Cargo.toml') --locked --release -p codex-stdio-to-uds --bin dshx-ipc-bridge $TuiSource = Join-Path $TargetDir 'release\codex-tui.exe' $TuiDestination = Join-Path $OutDir 'dshx-tui.exe' diff --git a/scripts/build-codex-tui.sh b/scripts/build-codex-tui.sh index 57920661..c391076e 100755 --- a/scripts/build-codex-tui.sh +++ b/scripts/build-codex-tui.sh @@ -4,7 +4,6 @@ set -euo pipefail ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" bash "$ROOT/scripts/materialize-codex.sh" node "$ROOT/scripts/verify-slash-contract.mjs" -node "$ROOT/scripts/verify-codex-runtime-closure.mjs" CODEX_DIR="${DSHX_CODEX_DIR:-$ROOT/.upstream/codex}" TARGET_DIR="${CARGO_TARGET_DIR:-$ROOT/.build/codex}" OUT_DIR="${DSHX_TUI_OUT_DIR:-$ROOT/dist/bin}" @@ -12,8 +11,7 @@ OUT_DIR="${DSHX_TUI_OUT_DIR:-$ROOT/dist/bin}" mkdir -p "$OUT_DIR" CARGO_TARGET_DIR="$TARGET_DIR" cargo build \ --manifest-path "$CODEX_DIR/codex-rs/Cargo.toml" \ - --locked --release -p codex-tui --bin codex-tui \ - --no-default-features --features dshx-remote-only + --locked --release -p codex-tui --bin codex-tui CARGO_TARGET_DIR="$TARGET_DIR" cargo build \ --manifest-path "$CODEX_DIR/codex-rs/Cargo.toml" \ --locked --release -p codex-stdio-to-uds --bin dshx-ipc-bridge From 273c4750179d893e1b3ba70fe3a5c36fe91a32f4 Mon Sep 17 00:00:00 2001 From: code-new-bie <403466121@qq.com> Date: Sat, 22 Aug 2026 16:14:12 +0800 Subject: [PATCH 282/305] chore: remove deferred Codex closure experiment from 1.0 --- scripts/verify-codex-runtime-closure.mjs | 71 ------ .../codex/0012-dshx-remote-only-profile.patch | 184 -------------- .../0013-dshx-remote-only-local-helpers.patch | 34 --- ...dshx-gate-embedded-app-server-client.patch | 233 ------------------ ...15-dshx-presentation-helper-features.patch | 155 ------------ 5 files changed, 677 deletions(-) delete mode 100755 scripts/verify-codex-runtime-closure.mjs delete mode 100644 upstream/patches/codex/0012-dshx-remote-only-profile.patch delete mode 100644 upstream/patches/codex/0013-dshx-remote-only-local-helpers.patch delete mode 100644 upstream/patches/codex/0014-dshx-gate-embedded-app-server-client.patch delete mode 100644 upstream/patches/codex/0015-dshx-presentation-helper-features.patch diff --git a/scripts/verify-codex-runtime-closure.mjs b/scripts/verify-codex-runtime-closure.mjs deleted file mode 100755 index 6fa4f5f7..00000000 --- a/scripts/verify-codex-runtime-closure.mjs +++ /dev/null @@ -1,71 +0,0 @@ -#!/usr/bin/env node -import path from 'node:path'; -import process from 'node:process'; -import { spawnSync } from 'node:child_process'; -import { fileURLToPath } from 'node:url'; - -const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); -const CODEX_DIR = path.resolve(process.env.DSHX_CODEX_DIR || path.join(ROOT, '.upstream', 'codex')); -const MANIFEST = path.join(CODEX_DIR, 'codex-rs', 'Cargo.toml'); -const FORBIDDEN = [ - 'codex-app-server', - 'codex-core', - 'codex-exec-server', - 'codex-login', - 'codex-rollout', - 'codex-state' -]; -const RELEASE_FEATURE_ARGS = ['--no-default-features', '--features', 'dshx-remote-only']; - -function cargo(args) { - const command = process.platform === 'win32' ? 'cargo.exe' : 'cargo'; - return spawnSync(command, args, { - cwd: ROOT, - encoding: 'utf8', - env: process.env, - maxBuffer: 32 * 1024 * 1024 - }); -} - -const baseArgs = [ - 'tree', - '--manifest-path', MANIFEST, - '--locked', - '--edges', 'normal', - '--prefix', 'none', - '-p', 'codex-tui', - ...RELEASE_FEATURE_ARGS -]; -const tree = cargo([...baseArgs, '--format', '{p}']); -if (tree.error) throw tree.error; -if (tree.status !== 0) { - process.stderr.write(tree.stdout || ''); - process.stderr.write(tree.stderr || ''); - throw new Error(`cargo tree failed with exit code ${tree.status}`); -} - -const packages = new Set(); -for (const rawLine of tree.stdout.split(/\r?\n/)) { - const line = rawLine.trim(); - const match = /^([^\s]+)\s+v\d/.exec(line); - if (match) packages.add(match[1]); -} -const offenders = FORBIDDEN.filter((name) => packages.has(name)); -if (offenders.length === 0) { - process.stdout.write('DSHX Codex dependency closure is presentation/remote-only\n'); - process.exit(0); -} - -process.stderr.write( - `DSHX release profile still reaches authoritative Codex runtime crates: ${offenders.join(', ')}\n` -); -for (const name of offenders) { - process.stderr.write(`\n--- reverse dependency path for ${name} ---\n`); - const inverse = cargo([...baseArgs, '--invert', name]); - process.stderr.write(inverse.stdout || ''); - process.stderr.write(inverse.stderr || ''); -} -process.stderr.write( - '\nRelease is blocked until codex-tui is built through the explicit dshx-remote-only Cargo feature profile and this gate passes.\n' -); -process.exit(1); diff --git a/upstream/patches/codex/0012-dshx-remote-only-profile.patch b/upstream/patches/codex/0012-dshx-remote-only-profile.patch deleted file mode 100644 index c8c9ba1b..00000000 --- a/upstream/patches/codex/0012-dshx-remote-only-profile.patch +++ /dev/null @@ -1,184 +0,0 @@ -diff --git a/codex-rs/app-server-protocol/Cargo.toml b/codex-rs/app-server-protocol/Cargo.toml ---- a/codex-rs/app-server-protocol/Cargo.toml -+++ b/codex-rs/app-server-protocol/Cargo.toml -@@ -9,6 +9,10 @@ - path = "src/lib.rs" - doctest = false - -+[features] -+default = ["embedded"] -+embedded = ["dep:codex-rollout"] -+ - [lints] - workspace = true - -@@ -21,7 +25,7 @@ - codex-extension-items = { workspace = true } - codex-history = { workspace = true } - codex-protocol = { workspace = true } --codex-rollout = { workspace = true } -+codex-rollout = { workspace = true, optional = true } - codex-secrets = { workspace = true } - codex-shell-command = { workspace = true } - codex-utils-absolute-path = { workspace = true } -diff --git a/codex-rs/app-server-protocol/src/lib.rs b/codex-rs/app-server-protocol/src/lib.rs ---- a/codex-rs/app-server-protocol/src/lib.rs -+++ b/codex-rs/app-server-protocol/src/lib.rs -@@ -22,8 +22,10 @@ - pub use protocol::common::*; - pub use protocol::event_mapping::*; - pub use protocol::item_builders::*; -+#[cfg(feature = "embedded")] - pub use protocol::thread_history::*; -+#[cfg(feature = "embedded")] - pub use protocol::thread_history_projection::*; - pub use protocol::v1::ApplyPatchApprovalParams; - pub use protocol::v1::ApplyPatchApprovalResponse; -diff --git a/codex-rs/app-server-protocol/src/protocol/mod.rs b/codex-rs/app-server-protocol/src/protocol/mod.rs ---- a/codex-rs/app-server-protocol/src/protocol/mod.rs -+++ b/codex-rs/app-server-protocol/src/protocol/mod.rs -@@ -6,8 +6,10 @@ - pub mod item_builders; - mod mappers; - mod serde_helpers; -+#[cfg(feature = "embedded")] - pub mod thread_history; -+#[cfg(feature = "embedded")] - pub mod thread_history_projection; - pub mod v1; - pub mod v2; -diff --git a/codex-rs/app-server-client/Cargo.toml b/codex-rs/app-server-client/Cargo.toml ---- a/codex-rs/app-server-client/Cargo.toml -+++ b/codex-rs/app-server-client/Cargo.toml -@@ -9,19 +9,33 @@ - path = "src/lib.rs" - doctest = false - -+[features] -+default = ["embedded"] -+embedded = [ -+ "codex-app-server-protocol/embedded", -+ "dep:codex-app-server", -+ "dep:codex-arg0", -+ "dep:codex-config", -+ "dep:codex-core", -+ "dep:codex-exec-server", -+ "dep:codex-feedback", -+ "dep:toml", -+] -+ - [lints] - workspace = true - - [dependencies] --codex-app-server = { workspace = true } --codex-app-server-protocol = { workspace = true } --codex-arg0 = { workspace = true } --codex-config = { workspace = true } --codex-core = { workspace = true } --codex-exec-server = { workspace = true } --codex-feedback = { workspace = true } -+codex-app-server = { workspace = true, optional = true } -+codex-app-server-protocol = { path = "../app-server-protocol", default-features = false } -+codex-arg0 = { workspace = true, optional = true } -+codex-config = { workspace = true, optional = true } -+codex-core = { workspace = true, optional = true } -+codex-exec-server = { workspace = true, optional = true } -+codex-feedback = { workspace = true, optional = true } - codex-protocol = { workspace = true } - codex-uds = { workspace = true } - codex-utils-absolute-path = { workspace = true } -@@ -34,7 +48,7 @@ - serde_json = { workspace = true } - tokio = { workspace = true, features = ["sync", "time", "rt"] } - tokio-tungstenite = { workspace = true } --toml = { workspace = true } -+toml = { workspace = true, optional = true } - tracing = { workspace = true } - url = { workspace = true } - -diff --git a/codex-rs/tui/Cargo.toml b/codex-rs/tui/Cargo.toml ---- a/codex-rs/tui/Cargo.toml -+++ b/codex-rs/tui/Cargo.toml -@@ -17,6 +17,27 @@ - path = "src/lib.rs" - doctest = false - -+[features] -+default = ["embedded"] -+embedded = [ -+ "codex-app-server-client/embedded", -+ "codex-app-server-protocol/embedded", -+ "dep:codex-cloud-config", -+ "dep:codex-connectors", -+ "dep:codex-core-plugins", -+ "dep:codex-exec-server", -+ "dep:codex-feedback", -+ "dep:codex-login", -+ "dep:codex-model-provider", -+ "dep:codex-models-manager", -+ "dep:codex-plugin", -+ "dep:codex-rollout", -+ "dep:codex-state", -+ "dep:codex-utils-oss", -+] -+dshx-remote-only = [] -+ - [lints] - workspace = true - -@@ -27,37 +48,37 @@ - chrono = { workspace = true, features = ["serde"] } - clap = { workspace = true, features = ["derive"] } - codex-ansi-escape = { workspace = true } --codex-app-server-client = { workspace = true } --codex-app-server-protocol = { workspace = true } -+codex-app-server-client = { path = "../app-server-client", default-features = false } -+codex-app-server-protocol = { path = "../app-server-protocol", default-features = false } - codex-arg0 = { workspace = true } - codex-install-context = { workspace = true } --codex-cloud-config = { workspace = true } -+codex-cloud-config = { workspace = true, optional = true } - codex-config = { workspace = true } --codex-connectors = { workspace = true } --codex-core-plugins = { workspace = true } -+codex-connectors = { workspace = true, optional = true } -+codex-core-plugins = { workspace = true, optional = true } - codex-history = { workspace = true } --codex-exec-server = { workspace = true } -+codex-exec-server = { workspace = true, optional = true } - codex-features = { workspace = true } --codex-feedback = { workspace = true } -+codex-feedback = { workspace = true, optional = true } - codex-file-search = { workspace = true } - codex-git-utils = { workspace = true } - codex-http-client = { workspace = true } --codex-login = { workspace = true } -+codex-login = { workspace = true, optional = true } - codex-message-history = { workspace = true } --codex-model-provider = { workspace = true } -+codex-model-provider = { workspace = true, optional = true } - codex-model-provider-info = { workspace = true } --codex-models-manager = { workspace = true } -+codex-models-manager = { workspace = true, optional = true } - codex-otel = { workspace = true } --codex-plugin = { workspace = true } -+codex-plugin = { workspace = true, optional = true } - codex-protocol = { workspace = true } --codex-rollout = { workspace = true } -+codex-rollout = { workspace = true, optional = true } - codex-sandboxing = { workspace = true } - codex-shell-command = { workspace = true } --codex-state = { workspace = true } -+codex-state = { workspace = true, optional = true } - codex-terminal-detection = { workspace = true } - codex-utils-approval-presets = { workspace = true } - codex-utils-absolute-path = { workspace = true } -@@ -67,7 +88,7 @@ - codex-utils-fuzzy-match = { workspace = true } - codex-utils-home-dir = { workspace = true } --codex-utils-oss = { workspace = true } -+codex-utils-oss = { workspace = true, optional = true } - codex-utils-path = { workspace = true } - codex-utils-path-uri = { workspace = true } - codex-utils-plugins = { workspace = true } diff --git a/upstream/patches/codex/0013-dshx-remote-only-local-helpers.patch b/upstream/patches/codex/0013-dshx-remote-only-local-helpers.patch deleted file mode 100644 index 66d285ed..00000000 --- a/upstream/patches/codex/0013-dshx-remote-only-local-helpers.patch +++ /dev/null @@ -1,34 +0,0 @@ -diff --git a/codex-rs/tui/Cargo.toml b/codex-rs/tui/Cargo.toml ---- a/codex-rs/tui/Cargo.toml -+++ b/codex-rs/tui/Cargo.toml -@@ -22,6 +22,7 @@ embedded = [ - "codex-app-server-client/embedded", - "codex-app-server-protocol/embedded", -+ "dep:codex-arg0", - "dep:codex-cloud-config", - "dep:codex-connectors", - "dep:codex-core-plugins", -@@ -36,6 +37,7 @@ embedded = [ - "dep:codex-state", - "dep:codex-utils-oss", -+ "dep:codex-utils-plugins", - ] - dshx-remote-only = [] - -@@ -51,7 +53,7 @@ clap = { workspace = true, features = ["derive"] } - codex-ansi-escape = { workspace = true } - codex-app-server-client = { path = "../app-server-client", default-features = false } - codex-app-server-protocol = { path = "../app-server-protocol", default-features = false } --codex-arg0 = { workspace = true } -+codex-arg0 = { workspace = true, optional = true } - codex-install-context = { workspace = true } - codex-cloud-config = { workspace = true, optional = true } - codex-config = { workspace = true } -@@ -94,7 +96,7 @@ codex-utils-oss = { workspace = true, optional = true } - codex-utils-path = { workspace = true } - codex-utils-path-uri = { workspace = true } --codex-utils-plugins = { workspace = true } -+codex-utils-plugins = { workspace = true, optional = true } - codex-utils-sandbox-summary = { workspace = true } - codex-utils-sleep-inhibitor = { workspace = true } - codex-utils-string = { workspace = true } diff --git a/upstream/patches/codex/0014-dshx-gate-embedded-app-server-client.patch b/upstream/patches/codex/0014-dshx-gate-embedded-app-server-client.patch deleted file mode 100644 index 9cd83a99..00000000 --- a/upstream/patches/codex/0014-dshx-gate-embedded-app-server-client.patch +++ /dev/null @@ -1,233 +0,0 @@ -diff --git a/codex-rs/app-server-client/src/lib.rs b/codex-rs/app-server-client/src/lib.rs ---- a/codex-rs/app-server-client/src/lib.rs -+++ b/codex-rs/app-server-client/src/lib.rs -@@ -27,39 +27,54 @@ use std::sync::Arc; - use std::time::Duration; - -+#[cfg(feature = "embedded")] - pub use codex_app_server::app_server_control_socket_path; --pub use codex_app_server::in_process::DEFAULT_IN_PROCESS_CHANNEL_CAPACITY; -+/// Default bounded channel capacity used by both embedded and remote clients. -+/// -+/// This mirrors the pinned app-server transport capacity without requiring the -+/// authoritative embedded app-server crate in DSHX's remote-only profile. -+pub const DEFAULT_IN_PROCESS_CHANNEL_CAPACITY: usize = 128; -+#[cfg(feature = "embedded")] - pub use codex_app_server::in_process::InProcessServerEvent; -+#[cfg(feature = "embedded")] - use codex_app_server::in_process::InProcessStartArgs; -+#[cfg(feature = "embedded")] - use codex_app_server::in_process::LogDbLayer; -+#[cfg(feature = "embedded")] - pub use codex_app_server::in_process::StateDbHandle; -+#[cfg(feature = "embedded")] - use codex_app_server_protocol::ClientInfo; - use codex_app_server_protocol::ClientNotification; - use codex_app_server_protocol::ClientRequest; -+#[cfg(feature = "embedded")] - use codex_app_server_protocol::ConfigWarningNotification; -+#[cfg(feature = "embedded")] - use codex_app_server_protocol::InitializeCapabilities; -+#[cfg(feature = "embedded")] - use codex_app_server_protocol::InitializeParams; - use codex_app_server_protocol::JSONRPCErrorError; - use codex_app_server_protocol::RequestId; - use codex_app_server_protocol::Result as JsonRpcResult; - use codex_app_server_protocol::ServerNotification; - use codex_app_server_protocol::ServerRequest; -+#[cfg(feature = "embedded")] - use codex_arg0::Arg0DispatchPaths; -+#[cfg(feature = "embedded")] - use codex_config::CloudConfigBundleLoader; -+#[cfg(feature = "embedded")] - use codex_config::LoaderOverrides; -+#[cfg(feature = "embedded")] - use codex_config::NoopThreadConfigLoader; -+#[cfg(feature = "embedded")] - use codex_core::config::Config; -+#[cfg(feature = "embedded")] - pub use codex_core::otel_init::build_provider as build_otel_provider; -+#[cfg(feature = "embedded")] - pub use codex_exec_server::EnvironmentManager; -+#[cfg(feature = "embedded")] - pub use codex_exec_server::ExecServerRuntimePaths; -+#[cfg(feature = "embedded")] - use codex_feedback::CodexFeedback; -+#[cfg(feature = "embedded")] - use codex_protocol::protocol::SessionSource; - use codex_utils_absolute_path::AbsolutePathBuf; - use serde::de::DeserializeOwned; -@@ -67,6 +82,7 @@ use tokio::sync::mpsc; - use tokio::sync::oneshot; - use tokio::time::timeout; -+#[cfg(feature = "embedded")] - use toml::Value as TomlValue; - use tracing::warn; - -@@ -80,6 +96,7 @@ pub use crate::remote::RemoteAppServerEndpoint; - /// New TUI behavior should prefer the app-server protocol methods. This - /// module exists so clients can remove a direct `codex-core` dependency - /// while legacy startup/config paths are migrated to RPCs. -+#[cfg(feature = "embedded")] - pub mod legacy_core { - pub mod config { - pub use codex_core::config::*; -@@ -91,6 +108,7 @@ pub mod legacy_core { - - const SHUTDOWN_TIMEOUT: Duration = Duration::from_secs(5); - // Covers the embedded drain, its analytics flush, and final task join. -+#[cfg(feature = "embedded")] - const IN_PROCESS_SHUTDOWN_TIMEOUT: Duration = Duration::from_secs(45); -@@ -107,6 +125,7 @@ pub enum AppServerEvent { - Disconnected { message: String }, - } - -+#[cfg(feature = "embedded")] - impl From for AppServerEvent { - fn from(value: InProcessServerEvent) -> Self { - match value { -@@ -167,6 +186,7 @@ impl Error for TypedRequestError { - } - } - -+#[cfg(feature = "embedded")] - #[derive(Clone)] - pub struct InProcessClientStartArgs { -@@ -219,6 +239,7 @@ pub struct InProcessClientStartArgs { - pub channel_capacity: usize, - } - -+#[cfg(feature = "embedded")] - impl InProcessClientStartArgs { -@@ -264,6 +285,7 @@ impl InProcessClientStartArgs { - /// Each variant carries a oneshot sender so the caller can `await` the - /// result without holding a mutable reference to the client. -+#[cfg(feature = "embedded")] - enum ClientCommand { -@@ -293,6 +315,7 @@ enum ClientCommand { - /// callers aligned with app-server behavior while still avoiding a process - /// boundary. -+#[cfg(feature = "embedded")] - pub struct InProcessAppServerClient { -@@ -302,20 +325,25 @@ pub struct InProcessAppServerClient { - } - -+#[cfg(feature = "embedded")] - #[derive(Clone)] - pub struct InProcessAppServerRequestHandle { - command_tx: mpsc::Sender, - } - - #[derive(Clone)] - pub enum AppServerRequestHandle { -+ #[cfg(feature = "embedded")] - InProcess(InProcessAppServerRequestHandle), - Remote(crate::remote::RemoteAppServerRequestHandle), - } - - pub enum AppServerClient { -+ #[cfg(feature = "embedded")] - InProcess(InProcessAppServerClient), - Remote(RemoteAppServerClient), - } - -+#[cfg(feature = "embedded")] - impl InProcessAppServerClient { -@@ -601,6 +629,7 @@ impl InProcessAppServerClient { - } - } - -+#[cfg(feature = "embedded")] - impl InProcessAppServerRequestHandle { -@@ -653,6 +682,7 @@ impl AppServerRequestHandle { - pub async fn request(&self, request: ClientRequest) -> IoResult { - match self { -+ #[cfg(feature = "embedded")] - Self::InProcess(handle) => handle.request(request).await, - Self::Remote(handle) => handle.request(request).await, - } -@@ -663,6 +693,7 @@ impl AppServerRequestHandle { - T: DeserializeOwned, - { - match self { -+ #[cfg(feature = "embedded")] - Self::InProcess(handle) => handle.request_typed(request).await, - Self::Remote(handle) => handle.request_typed(request).await, - } -@@ -672,6 +703,7 @@ impl AppServerClient { - pub fn codex_home(&self, local_codex_home: &AbsolutePathBuf) -> Option { - match self { -+ #[cfg(feature = "embedded")] - Self::InProcess(_) => Some(AppServerPath::from_app_server( - local_codex_home.display().to_string(), - )), -@@ -682,6 +714,7 @@ impl AppServerClient { - - pub async fn request(&self, request: ClientRequest) -> IoResult { - match self { -+ #[cfg(feature = "embedded")] - Self::InProcess(client) => client.request(request).await, - Self::Remote(client) => client.request(request).await, - } -@@ -693,6 +726,7 @@ impl AppServerClient { - T: DeserializeOwned, - { - match self { -+ #[cfg(feature = "embedded")] - Self::InProcess(client) => client.request_typed(request).await, - Self::Remote(client) => client.request_typed(request).await, - } -@@ -701,6 +735,7 @@ impl AppServerClient { - - pub async fn notify(&self, notification: ClientNotification) -> IoResult<()> { - match self { -+ #[cfg(feature = "embedded")] - Self::InProcess(client) => client.notify(notification).await, - Self::Remote(client) => client.notify(notification).await, - } -@@ -713,6 +748,7 @@ impl AppServerClient { - result: JsonRpcResult, - ) -> IoResult<()> { - match self { -+ #[cfg(feature = "embedded")] - Self::InProcess(client) => client.resolve_server_request(request_id, result).await, - Self::Remote(client) => client.resolve_server_request(request_id, result).await, - } -@@ -725,6 +761,7 @@ impl AppServerClient { - error: JSONRPCErrorError, - ) -> IoResult<()> { - match self { -+ #[cfg(feature = "embedded")] - Self::InProcess(client) => client.reject_server_request(request_id, error).await, - Self::Remote(client) => client.reject_server_request(request_id, error).await, - } -@@ -733,6 +770,7 @@ impl AppServerClient { - - pub async fn next_event(&mut self) -> Option { - match self { -+ #[cfg(feature = "embedded")] - Self::InProcess(client) => client.next_event().await.map(Into::into), - Self::Remote(client) => client.next_event().await, - } -@@ -741,6 +779,7 @@ impl AppServerClient { - - pub async fn shutdown(self) -> IoResult<()> { - match self { -+ #[cfg(feature = "embedded")] - Self::InProcess(client) => client.shutdown().await, - Self::Remote(client) => client.shutdown().await, - } -@@ -749,12 +788,14 @@ impl AppServerClient { - - pub fn request_handle(&self) -> AppServerRequestHandle { - match self { -+ #[cfg(feature = "embedded")] - Self::InProcess(client) => AppServerRequestHandle::InProcess(client.request_handle()), - Self::Remote(client) => AppServerRequestHandle::Remote(client.request_handle()), - } - } - } - --#[cfg(test)] -+#[cfg(all(test, feature = "embedded"))] - mod tests { diff --git a/upstream/patches/codex/0015-dshx-presentation-helper-features.patch b/upstream/patches/codex/0015-dshx-presentation-helper-features.patch deleted file mode 100644 index 90bdfdfd..00000000 --- a/upstream/patches/codex/0015-dshx-presentation-helper-features.patch +++ /dev/null @@ -1,155 +0,0 @@ -diff --git a/codex-rs/utils/plugins/Cargo.toml b/codex-rs/utils/plugins/Cargo.toml ---- a/codex-rs/utils/plugins/Cargo.toml -+++ b/codex-rs/utils/plugins/Cargo.toml -@@ -9,10 +9,14 @@ name = "codex_utils_plugins" - path = "src/lib.rs" - -+[features] -+default = ["embedded"] -+embedded = ["dep:codex-exec-server"] -+ - [lints] - workspace = true - - [dependencies] --codex-exec-server = { workspace = true } -+codex-exec-server = { workspace = true, optional = true } - codex-exec-server-protocol = { workspace = true } - codex-utils-absolute-path = { workspace = true } - codex-utils-path-uri = { workspace = true } -diff --git a/codex-rs/utils/plugins/src/lib.rs b/codex-rs/utils/plugins/src/lib.rs ---- a/codex-rs/utils/plugins/src/lib.rs -+++ b/codex-rs/utils/plugins/src/lib.rs -@@ -15,6 +15,7 @@ pub use plugin_namespace::SUPPORTED_AGENT_PLUGIN_SCHEMA_URIS; - pub use plugin_namespace::agent_plugin_schema_status; - pub use plugin_namespace::find_plugin_manifest_path; -+#[cfg(feature = "embedded")] - pub use plugin_namespace::plugin_namespace_for_root_uri; - - #[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)] -diff --git a/codex-rs/utils/plugins/src/plugin_namespace.rs b/codex-rs/utils/plugins/src/plugin_namespace.rs ---- a/codex-rs/utils/plugins/src/plugin_namespace.rs -+++ b/codex-rs/utils/plugins/src/plugin_namespace.rs -@@ -1,7 +1,9 @@ - //! Resolve plugin namespace from skill file paths by walking ancestors for `plugin.json`. - -+#[cfg(feature = "embedded")] - use codex_exec_server::ExecutorFileSystem; - use codex_exec_server_protocol::DISCOVERABLE_PLUGIN_MANIFEST_PATHS; -+#[cfg(feature = "embedded")] - use codex_utils_path_uri::PathUri; - use std::path::Path; - use std::path::PathBuf; -@@ -75,6 +77,7 @@ struct RawPluginManifestName { - } - - /// Returns the plugin manifest `name` defined directly below `plugin_root`. -+#[cfg(feature = "embedded")] - pub async fn plugin_namespace_for_root_uri( - fs: &dyn ExecutorFileSystem, - plugin_root: &PathUri, -diff --git a/codex-rs/plugin/Cargo.toml b/codex-rs/plugin/Cargo.toml ---- a/codex-rs/plugin/Cargo.toml -+++ b/codex-rs/plugin/Cargo.toml -@@ -8,6 +8,10 @@ name = "codex_plugin" - path = "src/lib.rs" - -+[features] -+default = ["embedded"] -+embedded = ["codex-utils-plugins/embedded"] -+ - [lints] - workspace = true - -@@ -16,7 +20,7 @@ codex-config = { workspace = true } - codex-protocol = { workspace = true } - codex-utils-absolute-path = { workspace = true } - codex-utils-path-uri = { workspace = true } --codex-utils-plugins = { workspace = true } -+codex-utils-plugins = { path = "../utils/plugins", default-features = false } - thiserror = { workspace = true } - - [dev-dependencies] -diff --git a/codex-rs/feedback/Cargo.toml b/codex-rs/feedback/Cargo.toml ---- a/codex-rs/feedback/Cargo.toml -+++ b/codex-rs/feedback/Cargo.toml -@@ -5,12 +5,16 @@ edition.workspace = true - license.workspace = true - -+[features] -+default = ["embedded"] -+embedded = ["dep:codex-login"] -+ - [lints] - workspace = true - - [dependencies] - anyhow = { workspace = true } - codex-http-client = { workspace = true } --codex-login = { workspace = true } -+codex-login = { workspace = true, optional = true } - codex-protocol = { workspace = true } - mime_guess = { workspace = true } - sentry = { version = "0.46" } -diff --git a/codex-rs/feedback/src/lib.rs b/codex-rs/feedback/src/lib.rs ---- a/codex-rs/feedback/src/lib.rs -+++ b/codex-rs/feedback/src/lib.rs -@@ -14,6 +14,7 @@ use anyhow::anyhow; - use codex_http_client::ClientRouteClass; - use codex_http_client::HttpClientFactory; - use codex_http_client::RouteAwareClientPool; -+#[cfg(feature = "embedded")] - use codex_login::AuthEnvTelemetry; - use codex_protocol::ThreadId; - use codex_protocol::protocol::SessionSource; -@@ -133,6 +134,7 @@ pub fn emit_feedback_request_tags(tags: &FeedbackRequestTags<'_>) { - ); - } - -+#[cfg(feature = "embedded")] - pub fn emit_feedback_request_tags_with_auth_env( - tags: &FeedbackRequestTags<'_>, - auth_env: &AuthEnvTelemetry, -diff --git a/codex-rs/tui/Cargo.toml b/codex-rs/tui/Cargo.toml ---- a/codex-rs/tui/Cargo.toml -+++ b/codex-rs/tui/Cargo.toml -@@ -27,7 +27,7 @@ embedded = [ - "dep:codex-connectors", - "dep:codex-core-plugins", - "dep:codex-exec-server", -- "dep:codex-feedback", -+ "codex-feedback/embedded", - "dep:codex-login", - "dep:codex-model-provider", - "dep:codex-models-manager", -- "dep:codex-plugin", -+ "codex-plugin/embedded", - "dep:codex-rollout", - "dep:codex-state", - "dep:codex-utils-oss", -- "dep:codex-utils-plugins", -+ "codex-utils-plugins/embedded", - ] - dshx-remote-only = [] -@@ -64,7 +64,7 @@ codex-exec-server = { workspace = true, optional = true } - codex-features = { workspace = true } --codex-feedback = { workspace = true, optional = true } -+codex-feedback = { path = "../feedback", default-features = false } - codex-file-search = { workspace = true } - codex-git-utils = { workspace = true } - codex-http-client = { workspace = true } -@@ -77,7 +77,7 @@ codex-models-manager = { workspace = true, optional = true } - codex-otel = { workspace = true } --codex-plugin = { workspace = true, optional = true } -+codex-plugin = { path = "../plugin", default-features = false } - codex-protocol = { workspace = true } - codex-rollout = { workspace = true, optional = true } - codex-sandboxing = { workspace = true } -@@ -96,7 +96,7 @@ codex-utils-oss = { workspace = true, optional = true } - codex-utils-path = { workspace = true } - codex-utils-path-uri = { workspace = true } --codex-utils-plugins = { workspace = true, optional = true } -+codex-utils-plugins = { path = "../utils/plugins", default-features = false } - codex-utils-sandbox-summary = { workspace = true } - codex-utils-sleep-inhibitor = { workspace = true } - codex-utils-string = { workspace = true } From 54b2e9733702c20e664946929822bc01f01029cc Mon Sep 17 00:00:00 2001 From: code-new-bie <403466121@qq.com> Date: Sat, 22 Aug 2026 23:04:59 +0800 Subject: [PATCH 283/305] fix: remove stale config packaging input --- scripts/package-platform.mjs | 2 -- 1 file changed, 2 deletions(-) diff --git a/scripts/package-platform.mjs b/scripts/package-platform.mjs index 2784d5c0..7530f8c6 100644 --- a/scripts/package-platform.mjs +++ b/scripts/package-platform.mjs @@ -57,7 +57,6 @@ copy('bin/dshx.mjs'); copy('src/cli'); copy('src/dsh'); copy('src/protocol'); -copy('config'); copy('README.md'); copy('NOTICE'); copy('upstream/CODEX_COMMIT'); @@ -95,7 +94,6 @@ const packageJson = { 'src/cli', 'src/dsh', 'src/protocol', - 'config', 'dist/bin', 'upstream', 'README.md', From 4153e2cd2f279b7708d9e698edc60f29f5347b63 Mon Sep 17 00:00:00 2001 From: code-new-bie <403466121@qq.com> Date: Sun, 23 Aug 2026 11:08:43 +0800 Subject: [PATCH 284/305] fix: invoke npm CLI through Node on Windows --- scripts/package-platform.mjs | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/scripts/package-platform.mjs b/scripts/package-platform.mjs index 7530f8c6..d7e042b3 100644 --- a/scripts/package-platform.mjs +++ b/scripts/package-platform.mjs @@ -145,10 +145,23 @@ execFileSync(process.execPath, [ shrinkwrapPath ], { cwd: root, stdio: 'inherit' }); -const npm = process.platform === 'win32' ? 'npm.cmd' : 'npm'; +const packArgs = ['pack', stage, '--pack-destination', out, '--json']; +let npmExecutable = 'npm'; +let npmArgs = packArgs; +if (platform === 'win32') { + // Node 24 no longer reliably spawns .cmd launchers through execFileSync. + // setup-node ships npm's JavaScript CLI beside node.exe, so execute that + // directly with the exact Node runtime used by the release job. + const npmCli = path.join(path.dirname(process.execPath), 'node_modules', 'npm', 'bin', 'npm-cli.js'); + if (!fs.existsSync(npmCli)) { + throw new Error(`Bundled npm CLI is missing: ${npmCli}`); + } + npmExecutable = process.execPath; + npmArgs = [npmCli, ...packArgs]; +} const packOutput = execFileSync( - npm, - ['pack', stage, '--pack-destination', out, '--json'], + npmExecutable, + npmArgs, { cwd: root, encoding: 'utf8', stdio: ['ignore', 'pipe', 'inherit'] } ); const packed = JSON.parse(packOutput); From f45904293ebf77e936e48f68a9ef125750122359 Mon Sep 17 00:00:00 2001 From: code-new-bie <403466121@qq.com> Date: Sun, 23 Aug 2026 22:12:37 +0800 Subject: [PATCH 285/305] fix: target repository explicitly when publishing release --- .github/workflows/release.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 8b2fa8bb..5732589e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -208,4 +208,4 @@ jobs: set -euo pipefail flags=(--verify-tag --generate-notes) if [[ "$GITHUB_REF_NAME" == *-* ]]; then flags+=(--prerelease); fi - gh release create "$GITHUB_REF_NAME" artifacts/* "${flags[@]}" + gh release create "$GITHUB_REF_NAME" artifacts/* --repo "$GITHUB_REPOSITORY" "${flags[@]}" From 314032910c64fb161e14e7c81334c682c0b0217a Mon Sep 17 00:00:00 2001 From: code-new-bie <403466121@qq.com> Date: Mon, 24 Aug 2026 14:01:58 +0800 Subject: [PATCH 286/305] fix(rc): close Windows app-server protocol gaps --- src/dsh/release-adapter.mjs | 78 +++++++++++++++++++++++++++++++++++-- 1 file changed, 74 insertions(+), 4 deletions(-) diff --git a/src/dsh/release-adapter.mjs b/src/dsh/release-adapter.mjs index a89c4fcc..9b5cc492 100644 --- a/src/dsh/release-adapter.mjs +++ b/src/dsh/release-adapter.mjs @@ -1,4 +1,5 @@ -import { dshThreadFromSnapshot } from './codex-shapes.mjs'; +import path from 'node:path'; +import { decodeDshModel, dshThreadFromSnapshot, normalizeCodexEffort } from './codex-shapes.mjs'; import { DshxProductAdapter } from './product-adapter.mjs'; import { codexPlanTarget, threadSettingsUpdatedNotification } from './plan-presentation.mjs'; import { foldDshSessionTitle, threadNameUpdatedNotification } from './thread-title.mjs'; @@ -109,9 +110,11 @@ export class DshxReleaseAdapter extends DshxProductAdapter { } loadedThreadList() { + // Pinned Codex ThreadLoadedListResponse.data is Array. Keep rich + // thread metadata on thread/list/read/started and expose only live IDs here. return { result: { - data: this.driver.listLive().map((agent) => this.liveAgentThread(agent)) + data: this.driver.listLive().map((agent) => String(agent.id)) } }; } @@ -159,6 +162,8 @@ export class DshxReleaseAdapter extends DshxProductAdapter { switch (method) { case 'command/exec': return this.commandExec(params); + case 'config/batchWrite': + return this.configBatchWrite(params); case 'thread/loaded/list': return this.loadedThreadList(); case 'thread/fork': @@ -198,6 +203,67 @@ export class DshxReleaseAdapter extends DshxProductAdapter { return { result: await this.workspaceCommands().execute(params) }; } + async configBatchWrite(params = {}) { + const edits = Array.isArray(params.edits) ? params.edits : []; + if (edits.length === 0) throw new Error('DSHX config/batchWrite requires at least one model-selection edit'); + + const supported = new Set(['model', 'model_reasoning_effort']); + for (const edit of edits) { + if (!supported.has(edit?.keyPath)) { + throw new Error(`DSHX config/batchWrite refuses Codex-owned setting: ${String(edit?.keyPath ?? '')}`); + } + if (edit?.mergeStrategy != null && edit.mergeStrategy !== 'replace') { + throw new Error(`DSHX config/batchWrite supports replace semantics only: ${String(edit.keyPath)}`); + } + } + + const defaults = this.ctx.get('agentDefaultModel'); + const llm = this.ctx.get('llm'); + if (!defaults?.currentSelection || !defaults?.saveSelection) { + throw new Error('DSHX requires DSH service: agentDefaultModel.saveSelection'); + } + if (!llm?.resolveCallConfig) throw new Error('DSHX requires DSH service: llm.resolveCallConfig'); + + const current = defaults.currentSelection(); + let requested = { provider: current.provider, model: current.model }; + if (current.reasoningEffort !== undefined) requested.reasoningEffort = current.reasoningEffort; + + const modelEdit = edits.find((edit) => edit.keyPath === 'model'); + if (modelEdit) { + const decoded = decodeDshModel(modelEdit.value); + if (!decoded) throw new Error('DSHX persists default models only from its DSH-backed opaque model catalog'); + requested = { ...decoded }; + } + + const effortEdit = edits.find((edit) => edit.keyPath === 'model_reasoning_effort'); + if (effortEdit) { + const effort = normalizeCodexEffort(effortEdit.value); + requested = { provider: requested.provider, model: requested.model }; + if (effort !== undefined) requested.reasoningEffort = effort; + } + + const resolved = await llm.resolveCallConfig(requested); + const persisted = { provider: resolved.provider, model: resolved.model }; + // Preserve provider-default semantics when the TUI clears its reasoning + // setting; otherwise persist the DSH-resolved explicit effort. + if (requested.reasoningEffort !== undefined) { + persisted.reasoningEffort = resolved.reasoningEffort ?? requested.reasoningEffort; + } + await defaults.saveSelection(persisted); + + this._configRevision = (this._configRevision ?? 0) + 1; + return { + result: { + status: 'ok', + version: `dshx-${this._configRevision}`, + // Codex requires an absolute path in ConfigWriteResponse. Persistence is + // actually owned by DSH settings; this marker stays inside presentation home. + filePath: path.join(this.home, 'dsh-settings'), + overriddenMetadata: null + } + }; + } + async richUserTurn(method, params = {}) { const threadId = String(params.threadId ?? ''); const controller = this.controllers.get(threadId); @@ -208,11 +274,15 @@ export class DshxReleaseAdapter extends DshxProductAdapter { const content = await codexInputToDshContent(this.ctx, params.input ?? []); const clear = controller.prepareUserContent(content); try { + // Pinned Codex serializes an unset reasoning effort as JSON null. DSH + // interprets absence as provider default, so never forward null as an + // explicit provider effort. + const normalizedParams = params.effort == null ? { ...params, effort: undefined } : params; // Preserve the original base/product validation path. The fallback text is // consumed only if no prepared rich content exists; it also keeps the // base adapter's expected text-only parameter shape intact. return await super.dispatch(method, { - ...params, + ...normalizedParams, input: [{ type: 'text', text: dshContentText(content) }] }); } finally { @@ -364,4 +434,4 @@ export class DshxReleaseAdapter extends DshxProductAdapter { } } -export const releaseAdapterInternals = { snapshotTitle, isDshSubagent }; +export const releaseAdapterInternals = { snapshotTitle, isDshSubagent }; \ No newline at end of file From 8ca8edb93e474b259fe9b0cc1c1f731708c00725 Mon Sep 17 00:00:00 2001 From: code-new-bie <403466121@qq.com> Date: Mon, 24 Aug 2026 14:02:22 +0800 Subject: [PATCH 287/305] test(rc): cover rc4 Windows acceptance regressions --- test/dsh-rc5-acceptance-regressions.test.mjs | 103 +++++++++++++++++++ 1 file changed, 103 insertions(+) create mode 100644 test/dsh-rc5-acceptance-regressions.test.mjs diff --git a/test/dsh-rc5-acceptance-regressions.test.mjs b/test/dsh-rc5-acceptance-regressions.test.mjs new file mode 100644 index 00000000..1a4321f3 --- /dev/null +++ b/test/dsh-rc5-acceptance-regressions.test.mjs @@ -0,0 +1,103 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { encodeDshModel } from '../src/dsh/codex-shapes.mjs'; +import { DshxReleaseAdapter } from '../src/dsh/release-adapter.mjs'; + +test('thread/loaded/list returns thread ids rather than thread maps', () => { + const adapter = Object.create(DshxReleaseAdapter.prototype); + adapter.driver = { + listLive() { return [{ id: 'root-1' }, { id: 'child-2' }]; } + }; + assert.deepEqual(adapter.loadedThreadList(), { + result: { data: ['root-1', 'child-2'] } + }); +}); + +test('config/batchWrite delegates model defaults to official DSH persistence', async () => { + const saved = []; + const resolved = []; + const adapter = Object.create(DshxReleaseAdapter.prototype); + adapter.home = '/tmp/dshx-presentation'; + adapter.ctx = { + get(name) { + if (name === 'agentDefaultModel') { + return { + currentSelection() { + return { provider: 'deepseek', model: 'old-model', reasoningEffort: 'high' }; + }, + async saveSelection(selection) { saved.push(selection); } + }; + } + if (name === 'llm') { + return { + async resolveCallConfig(selection) { + resolved.push(selection); + return { + provider: selection.provider, + model: selection.model, + ...(selection.reasoningEffort === undefined ? {} : { reasoningEffort: selection.reasoningEffort }) + }; + } + }; + } + return undefined; + } + }; + + const model = encodeDshModel({ provider: 'opencode-free', model: 'x-preview-f-free' }); + const response = await adapter.configBatchWrite({ + edits: [ + { keyPath: 'model', value: model, mergeStrategy: 'replace' }, + { keyPath: 'model_reasoning_effort', value: null, mergeStrategy: 'replace' } + ], + reloadUserConfig: true + }); + + assert.deepEqual(resolved, [{ provider: 'opencode-free', model: 'x-preview-f-free' }]); + assert.deepEqual(saved, [{ provider: 'opencode-free', model: 'x-preview-f-free' }]); + assert.equal(response.result.status, 'ok'); + assert.match(response.result.version, /^dshx-/); + assert.equal(response.result.overriddenMetadata, null); +}); + +test('rich user turn strips Codex null effort before the DSH-owned base path', async () => { + const adapter = Object.create(DshxReleaseAdapter.prototype); + const agent = { session: { header: { origin: 'user' } } }; + adapter.controllers = new Map([['thread-1', { + agent, + prepareUserContent() { return () => {}; } + }]]); + adapter.ctx = { get() { return undefined; } }; + + const product = Object.getPrototypeOf(DshxReleaseAdapter.prototype); + const originalDispatch = product.dispatch; + let forwarded; + product.dispatch = async (method, params) => { + forwarded = { method, params }; + return { result: {} }; + }; + try { + await adapter.richUserTurn('turn/start', { + threadId: 'thread-1', + effort: null, + input: [{ type: 'text', text: 'hello', text_elements: [] }] + }); + } finally { + product.dispatch = originalDispatch; + } + + assert.equal(forwarded.method, 'turn/start'); + assert.equal(forwarded.params.effort, undefined); +}); + +test('config/batchWrite fails closed on Codex-owned settings', async () => { + const adapter = Object.create(DshxReleaseAdapter.prototype); + adapter.home = '/tmp/dshx-presentation'; + adapter.ctx = { get() { return undefined; } }; + await assert.rejects( + () => adapter.configBatchWrite({ + edits: [{ keyPath: 'features.web_search', value: true, mergeStrategy: 'replace' }] + }), + /refuses Codex-owned setting/ + ); +}); \ No newline at end of file From b8aa3eb3ef350a982513ace6aeb0c3145f63d5f5 Mon Sep 17 00:00:00 2001 From: code-new-bie <403466121@qq.com> Date: Mon, 24 Aug 2026 14:03:33 +0800 Subject: [PATCH 288/305] fix(rc): preserve provider-default reasoning on config writes --- src/dsh/release-adapter.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/dsh/release-adapter.mjs b/src/dsh/release-adapter.mjs index 9b5cc492..3694a17e 100644 --- a/src/dsh/release-adapter.mjs +++ b/src/dsh/release-adapter.mjs @@ -237,7 +237,7 @@ export class DshxReleaseAdapter extends DshxProductAdapter { const effortEdit = edits.find((edit) => edit.keyPath === 'model_reasoning_effort'); if (effortEdit) { - const effort = normalizeCodexEffort(effortEdit.value); + const effort = effortEdit.value == null ? undefined : normalizeCodexEffort(effortEdit.value); requested = { provider: requested.provider, model: requested.model }; if (effort !== undefined) requested.reasoningEffort = effort; } From 0b8f63c90c31c11e70652d48de6bd6e4f6d4599e Mon Sep 17 00:00:00 2001 From: code-new-bie <403466121@qq.com> Date: Mon, 24 Aug 2026 14:04:00 +0800 Subject: [PATCH 289/305] fix(rc): render DSH model names and release version --- .../0012-dshx-rc5-presentation-fixes.patch | 120 ++++++++++++++++++ 1 file changed, 120 insertions(+) create mode 100644 upstream/patches/codex/0012-dshx-rc5-presentation-fixes.patch diff --git a/upstream/patches/codex/0012-dshx-rc5-presentation-fixes.patch b/upstream/patches/codex/0012-dshx-rc5-presentation-fixes.patch new file mode 100644 index 00000000..ffe6ee3e --- /dev/null +++ b/upstream/patches/codex/0012-dshx-rc5-presentation-fixes.patch @@ -0,0 +1,120 @@ +diff --git a/codex-rs/tui/src/model_catalog.rs b/codex-rs/tui/src/model_catalog.rs +--- a/codex-rs/tui/src/model_catalog.rs ++++ b/codex-rs/tui/src/model_catalog.rs +@@ -12,4 +12,10 @@ impl ModelCatalog { + pub(crate) fn try_list_models(&self) -> Result, Infallible> { + Ok(self.models.clone()) + } ++ ++ pub(crate) fn display_name_for(&self, model: &str) -> Option<&str> { ++ self.models ++ .iter() ++ .find(|preset| preset.model == model) ++ .map(|preset| preset.display_name.as_str()) ++ } + } +diff --git a/codex-rs/tui/src/chatwidget/settings.rs b/codex-rs/tui/src/chatwidget/settings.rs +--- a/codex-rs/tui/src/chatwidget/settings.rs ++++ b/codex-rs/tui/src/chatwidget/settings.rs +@@ -456,7 +456,10 @@ impl ChatWidget { + pub(super) fn refresh_model_display(&mut self) { + let effective = self.effective_collaboration_mode(); +- self.session_header.set_model(effective.model()); ++ let model_display_name = self ++ .model_catalog ++ .display_name_for(effective.model()) ++ .unwrap_or(effective.model()).to_string(); ++ self.session_header.set_model(&model_display_name); + // Keep composer paste affordances aligned with the currently effective model. + self.sync_image_paste_enabled(); + self.sync_service_tier_commands(); +@@ -569,7 +572,9 @@ impl ChatWidget { + if model.is_empty() { + DEFAULT_MODEL_DISPLAY_NAME + } else { +- model ++ self.model_catalog ++ .display_name_for(model) ++ .unwrap_or(model) + } + } +diff --git a/codex-rs/tui/src/chatwidget/session_flow.rs b/codex-rs/tui/src/chatwidget/session_flow.rs +--- a/codex-rs/tui/src/chatwidget/session_flow.rs ++++ b/codex-rs/tui/src/chatwidget/session_flow.rs +@@ -119,7 +119,11 @@ impl ChatWidget { + self.sync_personality_command_enabled(); + self.sync_plugins_command_enabled(); + self.sync_goal_command_enabled(); + self.refresh_plugin_mentions(); +- let model_for_header = self.current_model().to_string(); ++ let model_for_header = if crate::dshx_backend() { ++ self.model_display_name().to_string() ++ } else { ++ self.current_model().to_string() ++ }; + if matches!( + display, + SessionConfiguredDisplay::Normal | SessionConfiguredDisplay::PromptEdit +diff --git a/codex-rs/tui/src/history_cell/session.rs b/codex-rs/tui/src/history_cell/session.rs +--- a/codex-rs/tui/src/history_cell/session.rs ++++ b/codex-rs/tui/src/history_cell/session.rs +@@ -126,12 +126,22 @@ pub(crate) fn new_session_info( + auth_plan: Option, + show_fast_status: bool, + ) -> SessionInfoCell { ++ let header_model = if crate::dshx_backend() { ++ requested_model.to_string() ++ } else { ++ session.model.clone() ++ }; ++ let header_version = if crate::dshx_backend() { ++ option_env!("DSHX_VERSION").unwrap_or(CODEX_CLI_VERSION) ++ } else { ++ CODEX_CLI_VERSION ++ }; + // Header box rendered as history (so it appears at the very top) + let header = SessionHeaderHistoryCell::new( +- session.model.clone(), ++ header_model, + session.reasoning_effort.clone(), + show_fast_status, + config.cwd.to_path_buf(), +- CODEX_CLI_VERSION, ++ header_version, + ) + .with_yolo_mode(has_yolo_permissions( + session.approval_policy, +@@ -175,7 +185,7 @@ pub(crate) fn new_session_info( + .map(|tip| TooltipHistoryCell::new(tip, &config.cwd)) + { + parts.push(Box::new(tooltips)); + } +- if requested_model != session.model.as_str() { ++ if !crate::dshx_backend() && requested_model != session.model.as_str() { + let lines = vec![ + "model changed:".magenta().bold().into(), + format!("requested: {requested_model}").into(), +diff --git a/codex-rs/tui/src/chatwidget/status_surfaces.rs b/codex-rs/tui/src/chatwidget/status_surfaces.rs +--- a/codex-rs/tui/src/chatwidget/status_surfaces.rs ++++ b/codex-rs/tui/src/chatwidget/status_surfaces.rs +@@ -681,7 +681,13 @@ impl ChatWidget { + StatusLineItem::WeeklyLimit => { + let (window, is_secondary) = self + .rate_limit_snapshots_by_limit_id + .get("codex") + .and_then(weekly_status_window)?; + let label = limit_label_for_window(window.window_minutes, is_secondary); + self.status_line_limit_display(Some(window), &label) + } +- StatusLineItem::CodexVersion => Some(CODEX_CLI_VERSION.to_string()), ++ StatusLineItem::CodexVersion => Some( ++ if crate::dshx_backend() { ++ option_env!("DSHX_VERSION").unwrap_or(CODEX_CLI_VERSION) ++ } else { ++ CODEX_CLI_VERSION ++ } ++ .to_string(), ++ ), + StatusLineItem::ContextWindowSize => self + .status_line_context_window_size() + .map(|cws| format!("{} window", format_tokens_compact(cws))), \ No newline at end of file From f9884c10978b80e4a5bc9c3b38fa025e45187548 Mon Sep 17 00:00:00 2001 From: code-new-bie <403466121@qq.com> Date: Mon, 24 Aug 2026 14:04:11 +0800 Subject: [PATCH 290/305] test(rc): lock rc5 presentation fixes --- test/dshx-rc5-presentation-patch.test.mjs | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) create mode 100644 test/dshx-rc5-presentation-patch.test.mjs diff --git a/test/dshx-rc5-presentation-patch.test.mjs b/test/dshx-rc5-presentation-patch.test.mjs new file mode 100644 index 00000000..39f40378 --- /dev/null +++ b/test/dshx-rc5-presentation-patch.test.mjs @@ -0,0 +1,22 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import test from 'node:test'; + +const patch = fs.readFileSync('upstream/patches/codex/0012-dshx-rc5-presentation-fixes.patch', 'utf8'); + +test('RC presentation patch resolves opaque model ids through model catalog display names', () => { + for (const needle of [ + 'display_name_for', + 'self.model_display_name().to_string()', + 'self.session_header.set_model(&model_display_name)', + 'if !crate::dshx_backend() && requested_model != session.model.as_str()' + ]) { + assert.match(patch, new RegExp(needle.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))); + } +}); + +test('RC presentation patch uses the tag-derived DSHX_VERSION in DSHX surfaces', () => { + const occurrences = patch.match(/option_env!\("DSHX_VERSION"\)/g) ?? []; + assert.ok(occurrences.length >= 2, 'welcome header and status/footer must both use DSHX_VERSION'); + assert.match(patch, /unwrap_or\(CODEX_CLI_VERSION\)/); +}); \ No newline at end of file From d6d1118e32edf414447688fbfb14d7d878fbecfb Mon Sep 17 00:00:00 2001 From: code-new-bie <403466121@qq.com> Date: Mon, 24 Aug 2026 14:05:51 +0800 Subject: [PATCH 291/305] test(rc): align loaded-thread coverage with pinned wire shape --- test/dsh-subagent-presentation.test.mjs | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/test/dsh-subagent-presentation.test.mjs b/test/dsh-subagent-presentation.test.mjs index 8b77bfbd..3555ffa6 100644 --- a/test/dsh-subagent-presentation.test.mjs +++ b/test/dsh-subagent-presentation.test.mjs @@ -55,7 +55,7 @@ test('DSH subagent lineage becomes native Codex thread_spawn metadata without fa }); }); -test('release loaded-thread projection enumerates official DSH live agents including children', () => { +test('release loaded-thread wire response lists official DSH live ids while rich child metadata stays on thread projections', () => { const root = { id: 'root-1', status: 'idle', @@ -74,10 +74,11 @@ test('release loaded-thread projection enumerates official DSH live agents inclu }; const response = adapter.loadedThreadList(); - assert.equal(response.result.data.length, 2); - const projectedChild = response.result.data.find((thread) => thread.id === 'child-1'); + assert.deepEqual(response.result.data, ['root-1', 'child-1']); + + const projectedChild = adapter.liveAgentThread(child); assert.equal(projectedChild.name, 'Scout'); assert.equal(projectedChild.status.type, 'active'); assert.equal(projectedChild.canAcceptDirectInput, false); assert.equal(projectedChild.source.subagent.thread_spawn.parent_thread_id, 'root-1'); -}); +}); \ No newline at end of file From bf4b6a325133e2815af573ff54b020a90bee1566 Mon Sep 17 00:00:00 2001 From: code-new-bie <403466121@qq.com> Date: Mon, 24 Aug 2026 14:06:56 +0800 Subject: [PATCH 292/305] fix(rc): correct presentation patch hunk counts --- .../codex/0012-dshx-rc5-presentation-fixes.patch | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/upstream/patches/codex/0012-dshx-rc5-presentation-fixes.patch b/upstream/patches/codex/0012-dshx-rc5-presentation-fixes.patch index ffe6ee3e..a1af399a 100644 --- a/upstream/patches/codex/0012-dshx-rc5-presentation-fixes.patch +++ b/upstream/patches/codex/0012-dshx-rc5-presentation-fixes.patch @@ -16,7 +16,7 @@ diff --git a/codex-rs/tui/src/model_catalog.rs b/codex-rs/tui/src/model_catalog. diff --git a/codex-rs/tui/src/chatwidget/settings.rs b/codex-rs/tui/src/chatwidget/settings.rs --- a/codex-rs/tui/src/chatwidget/settings.rs +++ b/codex-rs/tui/src/chatwidget/settings.rs -@@ -456,7 +456,10 @@ impl ChatWidget { +@@ -456,6 +456,10 @@ impl ChatWidget { pub(super) fn refresh_model_display(&mut self) { let effective = self.effective_collaboration_mode(); - self.session_header.set_model(effective.model()); @@ -28,7 +28,7 @@ diff --git a/codex-rs/tui/src/chatwidget/settings.rs b/codex-rs/tui/src/chatwidg // Keep composer paste affordances aligned with the currently effective model. self.sync_image_paste_enabled(); self.sync_service_tier_commands(); -@@ -569,7 +572,9 @@ impl ChatWidget { +@@ -569,6 +573,8 @@ impl ChatWidget { if model.is_empty() { DEFAULT_MODEL_DISPLAY_NAME } else { @@ -41,7 +41,7 @@ diff --git a/codex-rs/tui/src/chatwidget/settings.rs b/codex-rs/tui/src/chatwidg diff --git a/codex-rs/tui/src/chatwidget/session_flow.rs b/codex-rs/tui/src/chatwidget/session_flow.rs --- a/codex-rs/tui/src/chatwidget/session_flow.rs +++ b/codex-rs/tui/src/chatwidget/session_flow.rs -@@ -119,7 +119,11 @@ impl ChatWidget { +@@ -119,8 +119,12 @@ impl ChatWidget { self.sync_personality_command_enabled(); self.sync_plugins_command_enabled(); self.sync_goal_command_enabled(); @@ -58,7 +58,7 @@ diff --git a/codex-rs/tui/src/chatwidget/session_flow.rs b/codex-rs/tui/src/chat diff --git a/codex-rs/tui/src/history_cell/session.rs b/codex-rs/tui/src/history_cell/session.rs --- a/codex-rs/tui/src/history_cell/session.rs +++ b/codex-rs/tui/src/history_cell/session.rs -@@ -126,12 +126,22 @@ pub(crate) fn new_session_info( +@@ -126,13 +126,23 @@ pub(crate) fn new_session_info( auth_plan: Option, show_fast_status: bool, ) -> SessionInfoCell { @@ -84,7 +84,7 @@ diff --git a/codex-rs/tui/src/history_cell/session.rs b/codex-rs/tui/src/history ) .with_yolo_mode(has_yolo_permissions( session.approval_policy, -@@ -175,7 +185,7 @@ pub(crate) fn new_session_info( +@@ -175,8 +185,8 @@ pub(crate) fn new_session_info( .map(|tip| TooltipHistoryCell::new(tip, &config.cwd)) { parts.push(Box::new(tooltips)); @@ -97,7 +97,7 @@ diff --git a/codex-rs/tui/src/history_cell/session.rs b/codex-rs/tui/src/history diff --git a/codex-rs/tui/src/chatwidget/status_surfaces.rs b/codex-rs/tui/src/chatwidget/status_surfaces.rs --- a/codex-rs/tui/src/chatwidget/status_surfaces.rs +++ b/codex-rs/tui/src/chatwidget/status_surfaces.rs -@@ -681,7 +681,13 @@ impl ChatWidget { +@@ -681,11 +681,18 @@ impl ChatWidget { StatusLineItem::WeeklyLimit => { let (window, is_secondary) = self .rate_limit_snapshots_by_limit_id @@ -117,4 +117,4 @@ diff --git a/codex-rs/tui/src/chatwidget/status_surfaces.rs b/codex-rs/tui/src/c + ), StatusLineItem::ContextWindowSize => self .status_line_context_window_size() - .map(|cws| format!("{} window", format_tokens_compact(cws))), \ No newline at end of file + .map(|cws| format!("{} window", format_tokens_compact(cws))), From 7d7ede0243debe1f470a3faecc8a78896b544027 Mon Sep 17 00:00:00 2001 From: code-new-bie <403466121@qq.com> Date: Mon, 24 Aug 2026 15:28:38 +0800 Subject: [PATCH 293/305] test(rc): assert human model and TUI version on Windows --- test/tui_smoke_windows.mjs | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/test/tui_smoke_windows.mjs b/test/tui_smoke_windows.mjs index 9375a407..a67101ec 100644 --- a/test/tui_smoke_windows.mjs +++ b/test/tui_smoke_windows.mjs @@ -10,6 +10,8 @@ import * as pty from 'node-pty'; const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); const EXPECTED_NODE_PTY_VERSION = '1.2.0-beta.15'; +const EXPECTED_CI_TUI_VERSION = '1.0.0-ci'; +const EXPECTED_MODEL_DISPLAY_NAME = 'DSHX Protocol Stub'; function stripTerminalControl(value) { return value @@ -168,12 +170,15 @@ try { const state = { output: '' }; dataDisposable = term.onData((chunk) => { state.output += chunk; }); - await waitForOutput(state, 'DeepSeek Harness'); + await waitForOutput(state, `DeepSeek Harness (v${EXPECTED_CI_TUI_VERSION})`); await waitForProtocolNotification(traceFile, 'thread/started'); // thread/started precedes the asynchronous model/banner refresh. Wait for - // the deterministic stub model to render before touching the composer so - // startup cannot reset input that the ConPTY harness has already injected. - await waitForOutput(state, 'dshx-stub'); + // the human-readable model label, not the provider-aware internal wire id. + await waitForOutput(state, EXPECTED_MODEL_DISPLAY_NAME); + const startupTranscript = stripTerminalControl(state.output); + if (startupTranscript.includes('dshx:Wy')) { + throw new Error(`opaque DSHX model wire id leaked into TUI presentation:\n${startupTranscript}`); + } term.resize(100, 40); const prompt = '你好,DSHX ConPTY resize'; term.write(prompt); From 79ea7ec4cdbaf7fe52b3bf1fb143579fd978b36c Mon Sep 17 00:00:00 2001 From: code-new-bie <403466121@qq.com> Date: Mon, 24 Aug 2026 15:28:58 +0800 Subject: [PATCH 294/305] test(rc): assert human model and TUI version on PTY --- test/tui_smoke.py | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/test/tui_smoke.py b/test/tui_smoke.py index ab354137..0246b025 100644 --- a/test/tui_smoke.py +++ b/test/tui_smoke.py @@ -11,6 +11,8 @@ ROOT = pathlib.Path(__file__).resolve().parents[1] PROMPT = "你好,DSHX PTY resize" PROMPT_TAIL = "DSHX PTY resize" +EXPECTED_CI_TUI_VERSION = "1.0.0-ci" +EXPECTED_MODEL_DISPLAY_NAME = "DSHX Protocol Stub" trace_fd, trace_name = tempfile.mkstemp(prefix="dshx-protocol-trace-", suffix=".jsonl") os.close(trace_fd) trace_path = pathlib.Path(trace_name) @@ -80,15 +82,19 @@ def wait_for_protocol_notification(method, timeout=10): ) transcript = [] try: - child.expect("DeepSeek Harness") + child.expect(f"DeepSeek Harness \\(v{EXPECTED_CI_TUI_VERSION}\\)") transcript.append(child.before + child.after) wait_for_protocol_notification("thread/started") # Startup is asynchronous: thread/started can arrive while the model - # banner still says "loading". Wait for the deterministic stub model - # to render before typing so the startup refresh cannot reset the - # composer underneath the simulated user input. - child.expect("dshx-stub") + # banner still says "loading". Wait for the human-readable model label, + # not the provider-aware internal wire id. + child.expect(EXPECTED_MODEL_DISPLAY_NAME) transcript.append(child.before + child.after) + rendered_startup = "".join(transcript) + if "dshx:Wy" in rendered_startup: + raise AssertionError( + "opaque DSHX model wire id leaked into TUI presentation:\n" + rendered_startup + ) child.setwinsize(40, 100) child.send(PROMPT) # A PTY write completing does not mean the TUI has consumed the From ee625cf9fa2e0568e17dfea01e78ed07e8cba566 Mon Sep 17 00:00:00 2001 From: code-new-bie <403466121@qq.com> Date: Mon, 24 Aug 2026 15:29:14 +0800 Subject: [PATCH 295/305] ci(rc): compile TUI with asserted DSHX version --- .github/workflows/ci.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2b62a656..31b5170b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -85,6 +85,8 @@ jobs: run: node scripts/verify-dsh-closure.mjs - name: Build pinned Codex TUI + local IPC bridge shell: bash + env: + DSHX_VERSION: 1.0.0-ci run: bash scripts/build-codex-tui.sh - name: Verify thin-fork and transport invariants run: node scripts/verify-tui-invariants.mjs From 5d811ff3756cc7896517c822af7a7c03620c3c19 Mon Sep 17 00:00:00 2001 From: code-new-bie <403466121@qq.com> Date: Mon, 24 Aug 2026 15:29:32 +0800 Subject: [PATCH 296/305] ci(rc): validate TUI version and display name cross-platform --- .github/workflows/rc-platform.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/workflows/rc-platform.yml b/.github/workflows/rc-platform.yml index 34de87db..553f5bad 100644 --- a/.github/workflows/rc-platform.yml +++ b/.github/workflows/rc-platform.yml @@ -43,6 +43,8 @@ jobs: run: node scripts/runtime-smoke.mjs - name: Build pinned Codex TUI + local IPC bridge shell: pwsh + env: + DSHX_VERSION: 1.0.0-ci run: ./scripts/build-codex-tui.ps1 - name: Verify thin-fork and transport invariants run: node scripts/verify-tui-invariants.mjs @@ -86,6 +88,8 @@ jobs: run: node scripts/runtime-smoke.mjs - name: Build pinned Codex TUI + local IPC bridge shell: bash + env: + DSHX_VERSION: 1.0.0-ci run: bash scripts/build-codex-tui.sh - name: Verify thin-fork and transport invariants run: node scripts/verify-tui-invariants.mjs From 9b1ff4881521ac7cc37ed459f7b95f0db9ffa21e Mon Sep 17 00:00:00 2001 From: code-new-bie <403466121@qq.com> Date: Mon, 24 Aug 2026 16:25:37 +0800 Subject: [PATCH 297/305] fix(rc): brand provisional startup with DSHX version --- .../0012-dshx-rc5-presentation-fixes.patch | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/upstream/patches/codex/0012-dshx-rc5-presentation-fixes.patch b/upstream/patches/codex/0012-dshx-rc5-presentation-fixes.patch index a1af399a..e03017f7 100644 --- a/upstream/patches/codex/0012-dshx-rc5-presentation-fixes.patch +++ b/upstream/patches/codex/0012-dshx-rc5-presentation-fixes.patch @@ -118,3 +118,27 @@ diff --git a/codex-rs/tui/src/chatwidget/status_surfaces.rs b/codex-rs/tui/src/c StatusLineItem::ContextWindowSize => self .status_line_context_window_size() .map(|cws| format!("{} window", format_tokens_compact(cws))), +diff --git a/codex-rs/tui/src/startup_draft.rs b/codex-rs/tui/src/startup_draft.rs +--- a/codex-rs/tui/src/startup_draft.rs ++++ b/codex-rs/tui/src/startup_draft.rs +@@ -447,6 +447,12 @@ fn startup_session_header(config: Option<&Config>) -> Box { + || PathBuf::from("loading"), + |config| config.cwd.to_path_buf(), + ); ++ let header_version = if crate::dshx_backend() { ++ option_env!("DSHX_VERSION").unwrap_or(CODEX_CLI_VERSION) ++ } else { ++ CODEX_CLI_VERSION ++ }; + Box::new( + history_cell::SessionHeaderHistoryCell::new_with_style( + "loading".to_string(), +@@ -456,7 +462,7 @@ fn startup_session_header(config: Option<&Config>) -> Box { + /*reasoning_effort*/ None, + /*show_fast_status*/ false, + directory, +- CODEX_CLI_VERSION, ++ header_version, + ) + .with_yolo_mode(config.is_some_and(history_cell::is_yolo_mode)), + ) From bfde69907c377d16629be2a2fd45c81553ba17f9 Mon Sep 17 00:00:00 2001 From: code-new-bie <403466121@qq.com> Date: Mon, 24 Aug 2026 16:26:03 +0800 Subject: [PATCH 298/305] test(rc): make PTY version assertion ANSI-safe --- test/tui_smoke.py | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/test/tui_smoke.py b/test/tui_smoke.py index 0246b025..c97a279c 100644 --- a/test/tui_smoke.py +++ b/test/tui_smoke.py @@ -82,7 +82,12 @@ def wait_for_protocol_notification(method, timeout=10): ) transcript = [] try: - child.expect(f"DeepSeek Harness \\(v{EXPECTED_CI_TUI_VERSION}\\)") + # Ratatui can place ANSI style transitions between the product name + # and the version suffix. Assert them independently so the smoke + # validates visible content rather than terminal styling bytes. + child.expect("DeepSeek Harness") + transcript.append(child.before + child.after) + child.expect(f"v{EXPECTED_CI_TUI_VERSION}") transcript.append(child.before + child.after) wait_for_protocol_notification("thread/started") # Startup is asynchronous: thread/started can arrive while the model @@ -91,6 +96,10 @@ def wait_for_protocol_notification(method, timeout=10): child.expect(EXPECTED_MODEL_DISPLAY_NAME) transcript.append(child.before + child.after) rendered_startup = "".join(transcript) + if "v0.0.0" in rendered_startup: + raise AssertionError( + "Codex crate version leaked into DSHX startup presentation:\n" + rendered_startup + ) if "dshx:Wy" in rendered_startup: raise AssertionError( "opaque DSHX model wire id leaked into TUI presentation:\n" + rendered_startup From a5266ba38694e2606c5a7d736fc346a83e5d4372 Mon Sep 17 00:00:00 2001 From: code-new-bie <403466121@qq.com> Date: Mon, 24 Aug 2026 19:38:43 +0800 Subject: [PATCH 299/305] test(release): honor tagged DSHX version in PTY smoke --- test/tui_smoke.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/tui_smoke.py b/test/tui_smoke.py index c97a279c..5780314a 100644 --- a/test/tui_smoke.py +++ b/test/tui_smoke.py @@ -11,7 +11,7 @@ ROOT = pathlib.Path(__file__).resolve().parents[1] PROMPT = "你好,DSHX PTY resize" PROMPT_TAIL = "DSHX PTY resize" -EXPECTED_CI_TUI_VERSION = "1.0.0-ci" +EXPECTED_TUI_VERSION = os.environ.get("DSHX_VERSION", "1.0.0-ci") EXPECTED_MODEL_DISPLAY_NAME = "DSHX Protocol Stub" trace_fd, trace_name = tempfile.mkstemp(prefix="dshx-protocol-trace-", suffix=".jsonl") os.close(trace_fd) @@ -87,7 +87,7 @@ def wait_for_protocol_notification(method, timeout=10): # validates visible content rather than terminal styling bytes. child.expect("DeepSeek Harness") transcript.append(child.before + child.after) - child.expect(f"v{EXPECTED_CI_TUI_VERSION}") + child.expect(f"v{EXPECTED_TUI_VERSION}") transcript.append(child.before + child.after) wait_for_protocol_notification("thread/started") # Startup is asynchronous: thread/started can arrive while the model From 2f3443fe2da92ed96b8be6b818e63dbd1ed20b42 Mon Sep 17 00:00:00 2001 From: code-new-bie <403466121@qq.com> Date: Mon, 24 Aug 2026 19:39:11 +0800 Subject: [PATCH 300/305] test(release): honor tagged DSHX version in ConPTY smoke --- test/tui_smoke_windows.mjs | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/test/tui_smoke_windows.mjs b/test/tui_smoke_windows.mjs index a67101ec..55699f02 100644 --- a/test/tui_smoke_windows.mjs +++ b/test/tui_smoke_windows.mjs @@ -10,7 +10,7 @@ import * as pty from 'node-pty'; const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); const EXPECTED_NODE_PTY_VERSION = '1.2.0-beta.15'; -const EXPECTED_CI_TUI_VERSION = '1.0.0-ci'; +const EXPECTED_TUI_VERSION = process.env.DSHX_VERSION ?? '1.0.0-ci'; const EXPECTED_MODEL_DISPLAY_NAME = 'DSHX Protocol Stub'; function stripTerminalControl(value) { @@ -170,12 +170,15 @@ try { const state = { output: '' }; dataDisposable = term.onData((chunk) => { state.output += chunk; }); - await waitForOutput(state, `DeepSeek Harness (v${EXPECTED_CI_TUI_VERSION})`); + await waitForOutput(state, `DeepSeek Harness (v${EXPECTED_TUI_VERSION})`); await waitForProtocolNotification(traceFile, 'thread/started'); // thread/started precedes the asynchronous model/banner refresh. Wait for // the human-readable model label, not the provider-aware internal wire id. await waitForOutput(state, EXPECTED_MODEL_DISPLAY_NAME); const startupTranscript = stripTerminalControl(state.output); + if (startupTranscript.includes('v0.0.0')) { + throw new Error(`Codex crate version leaked into DSHX startup presentation:\n${startupTranscript}`); + } if (startupTranscript.includes('dshx:Wy')) { throw new Error(`opaque DSHX model wire id leaked into TUI presentation:\n${startupTranscript}`); } From 4d143c2a81d1a82869ce65f8c5166fc678cd3acb Mon Sep 17 00:00:00 2001 From: code-new-bie <403466121@qq.com> Date: Tue, 25 Aug 2026 11:58:41 +0800 Subject: [PATCH 301/305] fix(doctor): tolerate real Windows cold starts --- src/cli/runtime.mjs | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/cli/runtime.mjs b/src/cli/runtime.mjs index 05619107..e590597f 100644 --- a/src/cli/runtime.mjs +++ b/src/cli/runtime.mjs @@ -1,5 +1,12 @@ export const DSHX_NODE_ENGINE = '^22.19.0 || >=24.0.0'; export const DSHX_RELEASE_NODE_MAJOR = 24; +// `dshx doctor` deliberately boots the full official DSH composition, including +// profile watchers. A fresh Windows install can incur one-time module/native +// initialization that exceeds 20s even though subsequent and normal product +// startup are healthy. Keep the diagnostic bounded, but allow realistic cold +// starts on supported desktop hardware. +export const DSHX_DOCTOR_BOOT_TIMEOUT_MS = 60_000; +export const DSHX_DOCTOR_DISPOSE_TIMEOUT_MS = 10_000; export function isSupportedNodeVersion(version) { const match = /^(\d+)\.(\d+)\.(\d+)/.exec(String(version ?? '')); From 8f2584b2695626cbb53dc96d1fe1593a7939ecc6 Mon Sep 17 00:00:00 2001 From: code-new-bie <403466121@qq.com> Date: Tue, 25 Aug 2026 11:59:23 +0800 Subject: [PATCH 302/305] fix(doctor): use cold-start release timeout --- bin/dshx.mjs | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/bin/dshx.mjs b/bin/dshx.mjs index 48cf5fe6..518bc714 100755 --- a/bin/dshx.mjs +++ b/bin/dshx.mjs @@ -6,7 +6,11 @@ import process from 'node:process'; import { spawn, spawnSync } from 'node:child_process'; import { fileURLToPath } from 'node:url'; import { parseCliInvocation } from '../src/cli/arguments.mjs'; -import { isSupportedNodeVersion } from '../src/cli/runtime.mjs'; +import { + DSHX_DOCTOR_BOOT_TIMEOUT_MS, + DSHX_DOCTOR_DISPOSE_TIMEOUT_MS, + isSupportedNodeVersion +} from '../src/cli/runtime.mjs'; const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); const PACKAGE = JSON.parse(fs.readFileSync(path.join(ROOT, 'package.json'), 'utf8')); @@ -112,10 +116,11 @@ async function officialRuntimeCheck(runtimeBoot, loadError) { // Doctor deliberately exercises the same official composition and profile // watcher path as production startup. This catches missing native optional // dependencies (for example sharp/koffi) that a static bundle-entry check - // cannot detect. + // cannot detect. The bounded timeout is intentionally generous enough for + // first-run Windows module/native initialization on supported hardware. ctx = await withTimeout( runtimeBoot.bootDshxRuntime({ cwd: process.cwd(), watch: true }), - 20_000, + DSHX_DOCTOR_BOOT_TIMEOUT_MS, 'official DSH composition boot' ); const missing = REQUIRED_RUNTIME_SERVICES.filter((name) => ctx.get(name) == null); @@ -127,7 +132,11 @@ async function officialRuntimeCheck(runtimeBoot, loadError) { } finally { if (ctx) { try { - await withTimeout(Promise.resolve(ctx.dispose?.()), 10_000, 'official DSH composition disposal'); + await withTimeout( + Promise.resolve(ctx.dispose?.()), + DSHX_DOCTOR_DISPOSE_TIMEOUT_MS, + 'official DSH composition disposal' + ); } catch (error) { cleanupError = error; } From babdb8a6bed25646d96efee1f949bf3d9e8392f4 Mon Sep 17 00:00:00 2001 From: code-new-bie <403466121@qq.com> Date: Tue, 25 Aug 2026 11:59:36 +0800 Subject: [PATCH 303/305] test(doctor): lock cold-start timeout regression --- test/cli-runtime.test.mjs | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/test/cli-runtime.test.mjs b/test/cli-runtime.test.mjs index b045f60d..b522767d 100644 --- a/test/cli-runtime.test.mjs +++ b/test/cli-runtime.test.mjs @@ -1,6 +1,8 @@ import assert from 'node:assert/strict'; import test from 'node:test'; import { + DSHX_DOCTOR_BOOT_TIMEOUT_MS, + DSHX_DOCTOR_DISPOSE_TIMEOUT_MS, DSHX_NODE_ENGINE, DSHX_RELEASE_NODE_MAJOR, assertReleaseNodeVersion, @@ -25,3 +27,9 @@ test('release/freeze work is intentionally standardized on Node 24 LTS', () => { assert.throws(() => assertReleaseNodeVersion('22.23.0'), /requires Node 24\.x/); assert.throws(() => assertReleaseNodeVersion('26.3.0'), /requires Node 24\.x/); }); + +test('doctor permits realistic first-run DSH cold starts while remaining bounded', () => { + assert.equal(DSHX_DOCTOR_BOOT_TIMEOUT_MS, 60_000); + assert.equal(DSHX_DOCTOR_DISPOSE_TIMEOUT_MS, 10_000); + assert.ok(DSHX_DOCTOR_BOOT_TIMEOUT_MS > 20_000); +}); From 40be51b96575c429bd2f6e0c6d3cb1486cc9077b Mon Sep 17 00:00:00 2001 From: code-new-bie <403466121@qq.com> Date: Tue, 25 Aug 2026 15:56:44 +0800 Subject: [PATCH 304/305] test(rc): make PTY prompt assertion cursor-addressing safe --- test/tui_smoke.py | 31 +++++++++++++++++++++++-------- 1 file changed, 23 insertions(+), 8 deletions(-) diff --git a/test/tui_smoke.py b/test/tui_smoke.py index 5780314a..c49f9ea0 100644 --- a/test/tui_smoke.py +++ b/test/tui_smoke.py @@ -10,7 +10,11 @@ ROOT = pathlib.Path(__file__).resolve().parents[1] PROMPT = "你好,DSHX PTY resize" -PROMPT_TAIL = "DSHX PTY resize" +# Ratatui redraws a line with cursor-addressing writes. On some terminals/runners, +# the visible prompt can therefore arrive as multiple printable spans separated by +# ANSI cursor moves rather than one contiguous raw PTY substring. Validate the +# visible content in order without weakening the CJK/ASCII prompt contract. +PROMPT_TOKENS = ("你", "好", ",", "DSHX", "PTY", "resize") EXPECTED_TUI_VERSION = os.environ.get("DSHX_VERSION", "1.0.0-ci") EXPECTED_MODEL_DISPLAY_NAME = "DSHX Protocol Stub" trace_fd, trace_name = tempfile.mkstemp(prefix="dshx-protocol-trace-", suffix=".jsonl") @@ -50,6 +54,15 @@ def wait_for_protocol_notification(method, timeout=10): raise AssertionError(f"Timed out waiting for protocol notification {method!r}.\nProtocol trace:\n{trace}") +def expect_rendered_tokens(child, tokens): + """Consume printable tokens in visible order while tolerating ANSI redraws between them.""" + rendered = [] + for token in tokens: + child.expect_exact(token) + rendered.append(child.before + child.after) + return "".join(rendered) + + try: endpoint = server.stdout.readline().strip() if not endpoint.startswith("unix://"): @@ -110,16 +123,18 @@ def wait_for_protocol_notification(method, timeout=10): # bytes. On slower macOS runners, a fixed delay measured from write # completion can expire while the prompt is still queued, making # Enter arrive immediately after the paste-like burst is processed. - # Wait for the stable ASCII tail to be rendered first, then start - # Codex's 120 ms paste-submit guard delay. - child.expect(PROMPT_TAIL) - transcript.append(child.before + child.after) + # Wait until every visible CJK/ASCII prompt token has been rendered + # in order. Cursor-addressing escape sequences may occur between + # tokens, so raw contiguous-string matching is intentionally wrong. + transcript.append(expect_rendered_tokens(child, PROMPT_TOKENS)) time.sleep(0.25) child.send("\r") - child.expect("DSHX protocol stub received:") - transcript.append(child.before + child.after) - child.expect(PROMPT) + # "received:" is unique to the stub response, so it establishes that + # the submitted turn reached the TUI response path. Then verify the + # echoed prompt using the same rendered-token semantics as the input. + child.expect_exact("received:") transcript.append(child.before + child.after) + transcript.append(expect_rendered_tokens(child, PROMPT_TOKENS)) except Exception: transcript.append(child.before or "") trace = trace_path.read_text(encoding="utf-8") if trace_path.exists() else "" From e1492b4c2425e0079b566bdf4429ddd8edc50dc1 Mon Sep 17 00:00:00 2001 From: code-new-bie <403466121@qq.com> Date: Tue, 25 Aug 2026 15:56:58 +0800 Subject: [PATCH 305/305] test(rc): lock cursor-addressing-safe PTY smoke --- test/tui-smoke-pty-contract.test.mjs | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) create mode 100644 test/tui-smoke-pty-contract.test.mjs diff --git a/test/tui-smoke-pty-contract.test.mjs b/test/tui-smoke-pty-contract.test.mjs new file mode 100644 index 00000000..e42cc43a --- /dev/null +++ b/test/tui-smoke-pty-contract.test.mjs @@ -0,0 +1,17 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import test from 'node:test'; + +const source = fs.readFileSync(new URL('./tui_smoke.py', import.meta.url), 'utf8'); + +test('PTY smoke validates rendered prompt tokens instead of contiguous raw terminal bytes', () => { + assert.match(source, /PROMPT_TOKENS\s*=\s*\(/); + assert.match(source, /def expect_rendered_tokens\(/); + assert.equal(source.includes('child.expect(PROMPT_TAIL)'), false); + assert.equal(source.includes('child.expect(PROMPT)'), false); + assert.equal(source.includes('child.expect_exact(PROMPT)'), false); + assert.ok( + source.split('expect_rendered_tokens(child, PROMPT_TOKENS)').length - 1 >= 2, + 'input and echoed-response prompt must both use cursor-addressing-safe token assertions' + ); +});