feat(ui): add desktop harness workbench

This commit is contained in:
NI0317
2026-07-18 17:05:46 +08:00
parent 4139e093dd
commit 649183aa65
15 changed files with 5256 additions and 0 deletions

View File

@@ -0,0 +1,184 @@
# @deepseek-ai/dsh-desktop
Desktop workbench for developing and studying DeepSeek Harness agents. The app is a first-party Electron client bound to this repository, not a generic workspace picker and not a continuation of the localhost prototype under `/Users/tn.shen/Documents/原型`.
The product loop is: run a task in chat, inspect what happened, modify Harness or a local plugin, restart only the runtime, replay a previous task, compare the new run against the baseline, and repeat.
## Run it
From the repository root:
```bash
pnpm --dir packages/ui/desktop run dev
```
This starts a Vite renderer on `http://127.0.0.1:5174`, opens Electron, and starts the real Harness ACP runtime with:
```bash
node --import tsx packages/examples/acp-demo/src/bin.ts --config examples/acp-agent/cordis.yml
```
The renderer talks only to the Electron preload API. The main process owns the ACP subprocess, session JSONL reads, feedback writes, runtime restart, and diagnostics.
Useful checks:
```bash
node_modules/.bin/vitest run packages/ui/desktop/tests/index.spec.ts packages/ui/desktop/tests/acp-subprocess.spec.ts
node_modules/.bin/tsc --noEmit --ignoreConfig --module NodeNext --moduleResolution NodeNext --target ES2022 --lib ES2022,DOM --strict --allowImportingTsExtensions packages/ui/desktop/src/index.ts packages/ui/desktop/src/app.ts packages/ui/desktop/src/global.d.ts packages/ui/desktop/src/css.d.ts
```
## User journey
The desktop app is for a Harness developer or researcher who wants to understand and improve one repo-bound agent runtime.
1. Install and open the app. The app binds to this repository; there is no workspace picker.
2. Start a session in `Chat` and run one or two tasks to prove the runtime works.
3. Switch the same run to `Trajectory`, `Waterfall`, or `Context` to understand what happened behind one message.
4. Click any interesting message, step, tool call, request, timing bar, or context section to open the `Inspector` with complete facts.
5. Use `Dev` to ask the agent to modify Harness, a plugin, a prompt, a tool, or config. The first version seeds chat with repo paths and context instead of exposing a graphical code editor.
6. Restart only the managed Harness runtime subprocess when code/config changes require it.
7. Replay a previous prompt or turn against the changed runtime.
8. Compare the baseline run and candidate run. Keep the new behavior or ask the agent to revert/change it, then repeat.
## Product shape
The Electron shell owns the desktop lifecycle while the Harness runtime runs as a managed subprocess. The renderer never talks directly to the filesystem or the model. It calls typed preload APIs; the main process starts the runtime, talks to the Agent Client Protocol (ACP) server over JSON-RPC stdio, and reads session logs through trusted query adapters.
The default runtime channel is ACP because `dsh-acp` already owns `session/new`, `session/load`, `session/prompt`, `session/cancel`, streaming `session/update`, permission prompts, and user elicitations. The desktop app may add a query side channel for persisted JSONL or `ctx.sessionQuery`, but live chat should not be implemented by polling JSONL.
The app is repository-bound. On startup it treats the package root as the Harness repository, launches the runtime from that directory, reads `.sessions` from that runtime's persistence config, and marks each run with repository state such as commit, dirty status, runtime config hash, and parent replay metadata.
## Main surfaces
`Sessions/Runs` is the left navigation. It lists new and persisted runs, supports search, opens or resumes a session, pins a baseline, and starts replay from a historical prompt or turn.
`Chat` is the reading and driving surface. It renders user messages, assistant text, and lightweight collapsed `Thinking` and `Tool use` rows. Clicking a message or activity opens the inspector. The composer is always available for the selected live session.
`Trajectory` is the structural surface. It is a mixed navigation view over `session -> turn -> step -> request / assistant / tool / context` with status, duration, token counts, tool names, errors, and short previews. It must not expand full raw payloads, complete system prompts, full tool schemas, or raw chunk streams inline. Clicking any node opens the inspector.
`Waterfall` is the time surface. It answers where latency went across turns, steps, model calls, tool calls, background work, and failures. It contains labels, timings, and critical-path cues only. Clicking a bar opens the inspector.
`Context` is the request-anatomy surface. It answers what the model saw at a selected request boundary: config, system prompt, session prefix, derived conversation surface, injected `context/message` entries, compaction summaries, visible tools, and request-header deltas. It may show section summaries and bounded previews, but the full raw data still belongs in the inspector.
`Inspector` is a right-side drawer, not a permanent column. It appears only after the user selects a message, trajectory node, waterfall bar, or context section. Its tabs are `Input`, `Output`, `Metadata`, and `Feedback`. It is the only place for complete JSON, JSONL, full schemas, complete system prompts, raw event windows, copy actions, and node-targeted feedback.
## Surface behavior contract
| Surface | Primary job | Inline content | Inspector trigger |
|---|---|---|---|
| `Chat` | Drive and read the conversation | User messages, assistant messages, collapsed thinking rows, collapsed tool-use rows | Message or activity click |
| `Trajectory` | Navigate run structure | `session -> turn -> step -> request / assistant / tool / context`, statuses, counts, durations, short previews | Any node click |
| `Waterfall` | Diagnose latency | Spans, critical path, status, start/duration | Bar click |
| `Context` | Explain request anatomy | Config summary, system summary, prefix summary, derived-history summary, context-message summary, tool-schema list summary, deltas | Section click |
| `Compare` | Compare two run artifacts | Baseline/candidate diffs for output, context, tools, events, usage, duration, errors | Diff hunk click |
| `Dev` | Support repo-bound modification loop | Runtime state, dirty state, watched paths, suggested agent prompts, restart-needed flag | Config/plugin/path click |
Only `Chat` owns the composer. The other middle surfaces are inspection modes over the selected run or selected request boundary.
## Information ownership
Middle surfaces locate and explain. The inspector preserves the complete facts.
`Trajectory` and `Context` should not duplicate inspector responsibilities. Trajectory shows where the user is in the run. Context shows which context sources contributed to a request. Inspector shows the selected object's complete input, output, metadata, and feedback.
The same selected object can be entered from multiple surfaces. A `tool/call` selected from Trajectory, a `Tool use` row selected from Chat, or a tool segment selected from Waterfall should resolve to one inspector target. This keeps feedback and copying attached to the event, not to the view that opened it.
### Why Trajectory still needs Inspector
Trajectory answers orientation questions:
- Which turn and step produced this behavior?
- Which request triggered which assistant output and tool calls?
- Did the run fail, cancel, or continue?
- What is the rough shape of this step before I inspect raw data?
Trajectory should not expand full request headers, full tool schemas, full system prompts, or raw chunk streams inline because that turns the navigator into a raw JSON viewer. Instead, every row has a stable target id. Clicking a row opens Inspector, where full `Input`, `Output`, `Metadata`, and `Feedback` are available.
### Why Context still needs Inspector
Context answers request-anatomy questions:
- What did this request include outside normal chat history?
- Did the system prompt, tools, call config, or session prefix change?
- Which `context/message` or `steering/message` entries were model-visible?
- Was history compacted or replaced before this request?
- Which sections are large enough to matter for token cost?
Context should show section summaries and bounded previews. The complete system prompt, complete tool schemas, full derived message list, raw `request/header`, and raw `request/header-delta` belong in Inspector. This makes Context useful as a map while preserving the user's requirement that every fact remains reachable.
## Plugin and development panel
The `Dev` panel is for changing Harness itself. It is not a full IDE and should not start as a graphical Cordis editor.
The first version lists Cordis config entries, local `plugins/*` packages when present, runtime status, repository dirty state, and whether a runtime restart is needed. It provides actions to ask the agent to add, modify, or disable a plugin by seeding the current chat with the relevant paths and config context.
Runtime changes are applied by restarting the managed ACP subprocess, not by restarting Electron. File watching should mark `Restart needed` for changes under `packages/**`, `examples/**`, `plugins/**`, `cordis.yml`, and package manifests. HMR can be explored later, but the reliable path is subprocess restart because ACP stdout is the JSON-RPC protocol channel.
## Replay and compare
Trace is looking at the past. Replay is running a past task against the current runtime. Compare is inspecting two run artifacts.
Replay creates a new run with `parentRunId` or `replayOf` metadata. If the app can only replay the user prompt and cwd rather than reconstructing an exact intermediate state, the UI must label it as prompt replay. It must not imply bit-for-bit session replay unless the runtime can prove it restored the same context boundary.
Compare is not a normal tab inside one session. It is a mode over two runs: baseline and candidate. The first version compares final output, request header, context sections, tool sequence, tool inputs and outputs, event counts, step counts, usage, duration, and errors.
## Backend integration plan
The first implementation uses two backend paths:
- ACP subprocess for live chat and runtime lifecycle. It maps to `session/new`, `session/load`, `session/prompt`, `session/cancel`, and streamed `session/update`. This is the authoritative path for driving the agent.
- Session query/read adapters for exact inspection. They read live-preferred persisted logs through `listSessions()`, `listEvents(sessionId)`, and bounded raw-event windows for Inspector.
The renderer receives normalized view models, not raw filesystem paths. The main process owns:
- Runtime process start, stop, restart, status, stderr diagnostics, and JSON-RPC stdout framing.
- Session/run discovery and run metadata sidecars.
- Trace normalization into chat messages, trajectory nodes, waterfall spans, context sections, compare diffs, and inspector payload refs.
- Feedback writes attached to stable inspector target ids.
## Trace and context data sources
The current session log can provide all core facts needed by the UI:
- `turn/start` and `turn/end` define durable user-visible turn boundaries.
- `step/start` and `step/end` define one model request plus its tool work.
- `request/header` and `request/header-delta` reconstruct model config, system prompt, tools, and `messagePrefix`.
- `user/message`, `assistant/message`, `tool/result`, `context/message`, and `steering/message` define the model-visible surface through `deriveMessages()`.
- `assistant/chunk` preserves token-level streaming fidelity but should usually be folded in Chat and opened in Inspector only when needed.
- `tool/call` and `tool/result` define tool input/output pairs and errors.
- `sourceEventSeqs` and `surfaceOp` explain provenance and compaction/replacement.
## Minimum implementation plan
Start with a desktop package that defines shared UI contracts, then build the Electron shell around them.
1. Add an Electron main process that launches the ACP runtime subprocess from the repository root and owns lifecycle commands: start, stop, restart, status.
2. Add a preload API for sessions, prompts, trace reads, replay, compare, dev status, and feedback. Renderer code must not use Node globals.
3. Build the renderer around the five middle surfaces and the inspector drawer. The renderer should treat all trace/context/compare objects as view models supplied by the main process.
4. Implement session ingestion from ACP live updates and persisted session logs. Normalize selected objects to stable inspector targets.
5. Implement Dev panel actions as agent-seeded chat prompts first. Direct file mutations and graphical Cordis editing are later features.
6. Implement replay by creating a new run from a historical prompt or turn. Attach run metadata and open Compare after the candidate completes.
## Phase-one acceptance criteria
- A developer can open the desktop app from this repository without selecting a workspace.
- A developer can create/load sessions and send prompts through the real ACP runtime.
- The same run can switch between `Chat`, `Trajectory`, `Waterfall`, and `Context`.
- Clicking any middle-surface object opens the Inspector drawer with `Input`, `Output`, `Metadata`, and `Feedback`, with Feedback last.
- Complete system prompts, tool schemas, raw event windows, and JSON/JSONL are reachable from Inspector.
- Feedback defaults the author to `shentuni` and persists against the selected target.
- Runtime restart does not close the Electron shell.
- Replay creates a separate candidate run with lineage metadata.
- Compare operates over two runs, not "inside" one session.
## Known limitations and deferred work
This package now ships a usable Electron/Vite development app and a real ACP subprocess bridge. It is still a v1 workbench rather than a packaged distributable.
The first runtime channel is ACP. Direct in-process embedding would make context queries and restarts richer, but it makes isolation, teardown, and hot reload harder and should wait until the ACP path is working.
The first Dev panel is agent-assisted. A direct graphical plugin/config editor should come after the app can reliably run, replay, compare, and restart the runtime.
The current trace/context surfaces read persisted JSONL after turns complete and use ACP live updates for streaming chat. A richer live raw-event side channel would make Trajectory/Context update at token-time rather than after the persistence flush.
The first Compare view is structural and textual. Semantic evaluation and dataset-level analysis belong to a later evaluation product surface.

View File

@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>DeepSeek Harness Desktop</title>
</head>
<body>
<main id="app"></main>
<script type="module" src="/src/app.ts"></script>
</body>
</html>

View File

@@ -0,0 +1,31 @@
{
"name": "@deepseek-ai/dsh-desktop",
"description": "Electron desktop workbench plan and UI contracts for DeepSeek Harness development loops",
"version": "0.0.1",
"private": true,
"type": "module",
"main": "src/main.mjs",
"types": "lib/types/index.d.ts",
"scripts": {
"dev": "node scripts/dev.mjs",
"dev:web": "vite --host 127.0.0.1",
"start": "electron src/main.mjs",
"build:ui": "vite build",
"preview": "vite preview --host 127.0.0.1"
},
"exports": {
".": {
"types": "./lib/types/index.d.ts",
"default": "./lib/index.js"
},
"./src/*": "./src/*",
"./package.json": "./package.json"
},
"files": [
"lib/index.js",
"lib/types/**/*.d.ts",
"lib/types/**/*.d.ts.map",
"src"
],
"license": "BSD-3-Clause"
}

View File

@@ -0,0 +1,60 @@
#!/usr/bin/env node
import { spawn } from 'node:child_process'
import { dirname, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
const here = dirname(fileURLToPath(import.meta.url))
const packageRoot = resolve(here, '..')
const repoRoot = resolve(packageRoot, '../../..')
const viteUrl = process.env.DSH_DESKTOP_VITE_URL ?? 'http://127.0.0.1:5174'
const vite = spawn(resolve(repoRoot, 'node_modules/.bin/vite'), [
'--host',
'127.0.0.1',
'--port',
'5174',
], {
cwd: packageRoot,
env: { ...process.env },
stdio: ['ignore', 'pipe', 'pipe'],
})
let electronStarted = false
let electron
const startElectron = () => {
if (electronStarted) return
electronStarted = true
electron = spawn(resolve(repoRoot, 'node_modules/.bin/electron'), ['src/main.mjs'], {
cwd: packageRoot,
env: { ...process.env, VITE_DEV_SERVER_URL: viteUrl },
stdio: 'inherit',
})
electron.on('exit', (code, signal) => {
vite.kill('SIGTERM')
process.exit(code ?? (signal === null ? 0 : 1))
})
}
const pipeVite = (chunk, stream) => {
const text = chunk.toString()
stream.write(text)
if (text.includes('Local:') || text.includes(viteUrl)) startElectron()
}
vite.stdout.on('data', chunk => { pipeVite(chunk, process.stdout) })
vite.stderr.on('data', chunk => { pipeVite(chunk, process.stderr) })
vite.on('exit', (code, signal) => {
if (!electronStarted) process.exit(code ?? (signal === null ? 0 : 1))
})
setTimeout(startElectron, 2500)
process.on('SIGINT', () => {
electron?.kill('SIGINT')
vite.kill('SIGINT')
})
process.on('SIGTERM', () => {
electron?.kill('SIGTERM')
vite.kill('SIGTERM')
})

File diff suppressed because it is too large Load Diff

1
packages/ui/desktop/src/css.d.ts vendored Normal file
View File

@@ -0,0 +1 @@
declare module '*.css'

34
packages/ui/desktop/src/global.d.ts vendored Normal file
View File

@@ -0,0 +1,34 @@
export {}
declare global {
interface Window {
dshDesktop: {
runtime: {
start(): Promise<unknown>
stop(): Promise<unknown>
restart(): Promise<unknown>
status(): Promise<unknown>
onStatus(callback: (payload: unknown) => void): () => void
onStderr(callback: (payload: unknown) => void): () => void
}
sessions: {
list(): Promise<unknown>
create(): Promise<unknown>
load(sessionId: string): Promise<unknown>
prompt(sessionId: string, text: string): Promise<unknown>
cancel(sessionId: string): Promise<unknown>
onUpdate(callback: (payload: unknown) => void): () => void
}
trace: {
read(sessionId: string): Promise<unknown>
}
feedback: {
list(sessionId: string, targetId?: string): Promise<unknown>
add(entry: Record<string, unknown>): Promise<unknown>
}
dev: {
status(): Promise<unknown>
}
}
}
}

View File

@@ -0,0 +1,112 @@
export type Locale = 'zh-CN' | 'en-US'
const messages = {
'zh-CN': {
'app.newChat': '新对话',
'app.sessions': 'Sessions',
'app.sessionsSubtitle': 'Chat + trace in one处',
'app.develop': 'Develop',
'app.developSubtitle': 'Prompt、工具、插件、运行时',
'app.searchPlaceholder': '搜索标题、id、模型',
'app.recentSessions': '最近 sessions',
'app.language': 'EN',
'surface.chat': 'Chat',
'surface.trajectory': 'Trajectory',
'surface.waterfall': 'Waterfall',
'chat.details': '详情',
'chat.thinking': 'Thinking',
'chat.toolUse': 'Tool use',
'chat.toolFailed': 'Tool failed',
'chat.input': 'Input',
'chat.output': 'Output',
'chat.errorOutput': 'Error output',
'chat.openInspector': '在检查器中打开',
'chat.emptyTitle': '还没有消息',
'chat.emptyBody': '从底部输入框发出一条消息。Thinking 和 tool use 会默认折叠,但可以随时展开。',
'chat.newTitle': '新对话',
'chat.newBody': '先输入一句话。发送后才会创建真实 ACP session并在左侧出现记录。',
'chat.startTitle': '开始一个 Deepseek Harness session',
'chat.startBody': '点击 New chat 只会打开草稿;真正发送第一句话后,才会创建后端 session。',
'trace.title': 'Trajectory',
'trace.body': '按 session / turn / step / request / tool 组织。展开节点可以直接看关键 prompt、schema、input/output。',
'trace.systemPrompt': 'System prompt',
'trace.toolSchemas': 'Tool schemas',
'trace.configPrefix': 'Config and message prefix',
'trace.metadata': 'Metadata',
'trace.noSystem': '没有记录 system prompt。',
'waterfall.title': 'Waterfall',
'waterfall.body': '从耗时角度定位慢点、工具等待和错误,再跳回 Trajectory 看细节。',
'waterfall.total': 'total',
'waterfall.turns': 'turns',
'waterfall.steps': 'steps',
'waterfall.tools': 'tools',
'waterfall.slowest': 'slowest',
'waterfall.errors': 'errors',
'empty.traceTitle': '还没有 trace',
'empty.traceBody': '先运行一条 prompt这里会读取真实 JSONL trace。',
'inspector.close': '关闭',
'feedback.empty': '这个对象还没有 feedback。',
'feedback.author': 'Feedback author',
'feedback.placeholder': '给这个对象写 feedback',
'feedback.add': '添加 feedback',
'composer.placeholderDraft': '先输入一句话创建 session',
'composer.placeholderSession': 'Message Deepseek Harness',
},
'en-US': {
'app.newChat': 'New chat',
'app.sessions': 'Sessions',
'app.sessionsSubtitle': 'Chat + trace in one place',
'app.develop': 'Develop',
'app.developSubtitle': 'Prompts, tools, plugins, runtime',
'app.searchPlaceholder': 'Search title, id, model',
'app.recentSessions': 'Recent sessions',
'app.language': '中文',
'surface.chat': 'Chat',
'surface.trajectory': 'Trajectory',
'surface.waterfall': 'Waterfall',
'chat.details': 'Details',
'chat.thinking': 'Thinking',
'chat.toolUse': 'Tool use',
'chat.toolFailed': 'Tool failed',
'chat.input': 'Input',
'chat.output': 'Output',
'chat.errorOutput': 'Error output',
'chat.openInspector': 'Open in inspector',
'chat.emptyTitle': 'No messages yet',
'chat.emptyBody': 'Send a prompt from the bottom composer. Thinking and tool use stay folded, but remain available.',
'chat.newTitle': 'New chat',
'chat.newBody': 'Type a message first. A real ACP session is created only after sending.',
'chat.startTitle': 'Start a Deepseek Harness session',
'chat.startBody': 'New chat opens a draft only. The backend session is created after the first sent message.',
'trace.title': 'Trajectory',
'trace.body': 'Organized by session / turn / step / request / tool. Expand nodes to inspect prompts, schemas, input, and output inline.',
'trace.systemPrompt': 'System prompt',
'trace.toolSchemas': 'Tool schemas',
'trace.configPrefix': 'Config and message prefix',
'trace.metadata': 'Metadata',
'trace.noSystem': 'No system prompt recorded.',
'waterfall.title': 'Waterfall',
'waterfall.body': 'Find slow spans, tool waits, and errors by duration, then jump back to Trajectory for detail.',
'waterfall.total': 'total',
'waterfall.turns': 'turns',
'waterfall.steps': 'steps',
'waterfall.tools': 'tools',
'waterfall.slowest': 'slowest',
'waterfall.errors': 'errors',
'empty.traceTitle': 'No trace yet',
'empty.traceBody': 'Run a prompt first; this surface will read the real JSONL trace.',
'inspector.close': 'Close',
'feedback.empty': 'No feedback for this object yet.',
'feedback.author': 'Feedback author',
'feedback.placeholder': 'Write feedback for this exact object',
'feedback.add': 'Add feedback',
'composer.placeholderDraft': 'Type a message to create a session',
'composer.placeholderSession': 'Message Deepseek Harness',
},
} as const
export type I18nKey = keyof typeof messages['en-US']
export function translate(locale: Locale, key: I18nKey): string {
return messages[locale][key] ?? messages['en-US'][key] ?? key
}

View File

@@ -0,0 +1,338 @@
/**
* Shared contracts for the Deepseek Harness desktop app.
*
* The package starts with view and lifecycle contracts so Electron main,
* preload, and renderer code can evolve without copying product decisions from
* design notes.
*
* @module @deepseek-ai/dsh-desktop
*/
/** Main analysis surfaces in the Deepseek Harness session view. */
export const DESKTOP_SURFACES = ['chat', 'trajectory', 'waterfall', 'context', 'compare', 'dev'] as const
/** Main analysis surfaces in the Deepseek Harness session view. */
export type DesktopSurface = (typeof DESKTOP_SURFACES)[number]
/** Right-side inspector tabs, ordered as rendered. */
export const INSPECTOR_TABS = ['input', 'output', 'metadata', 'feedback'] as const
/** Right-side inspector tabs. */
export type InspectorTab = (typeof INSPECTOR_TABS)[number]
/** What a middle surface is primarily for. */
export type SurfacePurpose = 'drive' | 'navigate' | 'timing' | 'request-anatomy' | 'diff' | 'development'
/** Stable class of selectable objects shared by all surfaces. */
export type InspectorTargetKind =
| 'session'
| 'run'
| 'turn'
| 'step'
| 'request'
| 'message'
| 'assistant-stream'
| 'tool-call'
| 'tool-result'
| 'context-section'
| 'waterfall-span'
| 'dev-object'
/** Stable selector fields used to derive view-independent inspector ids. */
export interface InspectorTargetKey {
readonly sessionId: string
readonly runId?: string
readonly kind: InspectorTargetKind
readonly eventSeq?: number
readonly syntheticId?: string
}
/** A selected object that can open the inspector drawer. */
export interface InspectorTarget {
/** Stable view-independent id, usually derived from session id and event seq. */
readonly id: string
/** The selected object's normalized kind. */
readonly kind: InspectorTargetKind
/** Human-readable title shown in the inspector header. */
readonly title: string
/** Optional compact subtitle, such as `turn 1 step 2` or a duration. */
readonly subtitle?: string
}
/** A visible tab in the inspector for a selected object. */
export interface InspectorTabState {
readonly tab: InspectorTab
readonly available: boolean
readonly summary?: string
readonly fullPayloadRef?: string
readonly canCopy: boolean
}
/** Right drawer state. It is absent until the user selects an object. */
export interface InspectorState {
readonly open: boolean
readonly target?: InspectorTarget
readonly activeTab: InspectorTab
readonly tabs: readonly InspectorTabState[]
}
/** Decides whether the inspector is the right place for complete detail. */
export interface SurfacePolicy {
/** True when a surface is mainly for navigation, explanation, or comparison. */
readonly summaryFirst: boolean
/** True when the surface may show a bounded preview inline. */
readonly inlinePreview: boolean
/** True when full raw payloads belong only in the inspector. */
readonly fullDetailInInspector: boolean
}
/** Static description of one middle surface. */
export interface SurfaceDefinition extends SurfacePolicy {
readonly id: DesktopSurface
readonly label: string
readonly purpose: SurfacePurpose
readonly primaryQuestion: string
readonly ownsComposer: boolean
}
/** Lifecycle state for the managed Harness runtime subprocess. */
export type RuntimeState = 'stopped' | 'starting' | 'running' | 'restart-needed' | 'stopping' | 'error'
/** Metadata attached to a run so replay and compare can explain provenance. */
export interface RunArtifact {
readonly runId: string
readonly sessionId: string
readonly createdAt: number
readonly cwd: string
readonly gitCommit?: string
readonly gitDirty?: boolean
readonly runtimeConfigHash?: string
readonly parentRunId?: string
readonly replayOf?: {
readonly runId: string
readonly turn?: number
readonly mode: 'prompt' | 'session-boundary'
}
}
/** Structural node kinds for the trajectory navigator. */
export type TrajectoryNodeKind =
| 'session'
| 'turn'
| 'step'
| 'request'
| 'assistant'
| 'tool'
| 'context'
| 'error'
/** Status shown on structural and timing views. */
export type NodeStatus = 'pending' | 'running' | 'completed' | 'failed' | 'cancelled' | 'unknown'
/** A bounded structural row. Full payloads stay in the inspector. */
export interface TrajectoryNode {
readonly id: string
readonly kind: TrajectoryNodeKind
readonly title: string
readonly status: NodeStatus
readonly depth: number
readonly target: InspectorTarget
readonly eventSeqs: readonly number[]
readonly preview?: string
readonly badges?: readonly string[]
readonly children?: readonly TrajectoryNode[]
}
/** Context sections reconstructed at a selected request boundary. */
export type ContextSectionKind =
| 'config'
| 'system'
| 'message-prefix'
| 'derived-history'
| 'context-message'
| 'steering-message'
| 'tool-schemas'
| 'compaction'
| 'request-delta'
/** Summary row in the Context surface. Full text/schema/JSON is inspector-owned. */
export interface ContextSection {
readonly id: string
readonly kind: ContextSectionKind
readonly title: string
readonly target: InspectorTarget
readonly eventSeqs: readonly number[]
readonly preview?: string
readonly tokenEstimate?: number
readonly changedSincePreviousRequest?: boolean
}
/** Timing bar shown in Waterfall. */
export interface WaterfallSpan {
readonly id: string
readonly title: string
readonly target: InspectorTarget
readonly startMs: number
readonly durationMs: number
readonly status: NodeStatus
readonly parentId?: string
}
/** A user-authored note attached to a stable inspector target. */
export interface FeedbackEntry {
readonly targetId: string
readonly author: string
readonly body: string
readonly createdAt: number
}
/** Run pair used by Compare. Compare is not scoped to a single session. */
export interface ComparePair {
readonly baseline: RunArtifact
readonly candidate: RunArtifact
}
/** First Dev panel shape: agent-assisted modification plus runtime restart. */
export interface DevPanelStatus {
readonly runtimeState: RuntimeState
readonly repoDirty: boolean
readonly restartNeeded: boolean
readonly watchedPaths: readonly string[]
readonly suggestedPrompt?: string
}
export const DEFAULT_FEEDBACK_AUTHOR = 'shentuni'
/** Default surface definitions for the first Electron implementation. */
export const SURFACE_DEFINITIONS: Record<DesktopSurface, SurfaceDefinition> = {
chat: {
id: 'chat',
label: 'Chat',
purpose: 'drive',
primaryQuestion: 'What did the user and agent say, with thinking and tool use folded into readable activity rows?',
ownsComposer: true,
summaryFirst: true,
inlinePreview: true,
fullDetailInInspector: true,
},
trajectory: {
id: 'trajectory',
label: 'Trajectory',
purpose: 'navigate',
primaryQuestion: 'Where am I in the session, turn, step, request, assistant, tool, and context structure?',
ownsComposer: false,
summaryFirst: true,
inlinePreview: true,
fullDetailInInspector: true,
},
waterfall: {
id: 'waterfall',
label: 'Waterfall',
purpose: 'timing',
primaryQuestion: 'Where did time go across model requests, tool calls, and failures?',
ownsComposer: false,
summaryFirst: true,
inlinePreview: false,
fullDetailInInspector: true,
},
context: {
id: 'context',
label: 'Context',
purpose: 'request-anatomy',
primaryQuestion: 'What exactly contributed to the selected model request boundary?',
ownsComposer: false,
summaryFirst: true,
inlinePreview: true,
fullDetailInInspector: true,
},
compare: {
id: 'compare',
label: 'Compare',
purpose: 'diff',
primaryQuestion: 'How did a candidate replay/run differ from the chosen baseline run?',
ownsComposer: false,
summaryFirst: true,
inlinePreview: true,
fullDetailInInspector: true,
},
dev: {
id: 'dev',
label: 'Dev',
purpose: 'development',
primaryQuestion: 'Which prompt, tool, plugin, and config artifacts compose the repo-bound Harness agent, and what needs reload after editing?',
ownsComposer: false,
summaryFirst: true,
inlinePreview: true,
fullDetailInInspector: false,
},
}
/** Backward-compatible alias for callers that only need policies. */
export const SURFACE_POLICIES: Record<DesktopSurface, SurfacePolicy> = SURFACE_DEFINITIONS
/** Returns whether selecting from a middle surface should open the inspector. */
export function opensInspector(surface: DesktopSurface, target: InspectorTarget | undefined): boolean {
return target !== undefined && SURFACE_POLICIES[surface].fullDetailInInspector
}
/** Returns whether the surface is allowed to show the chat composer. */
export function ownsComposer(surface: DesktopSurface): boolean {
return SURFACE_DEFINITIONS[surface].ownsComposer
}
/** Full detail belongs in the inspector for trace-analysis surfaces, not the Develop artifact browser. */
export function fullDetailBelongsInInspector(surface: DesktopSurface): boolean {
return SURFACE_DEFINITIONS[surface].fullDetailInInspector
}
/** Create a stable, view-independent inspector id. */
export function createInspectorTargetId(key: InspectorTargetKey): string {
const parts = [`session:${key.sessionId}`]
if (key.runId !== undefined) parts.push(`run:${key.runId}`)
parts.push(`kind:${key.kind}`)
if (key.eventSeq !== undefined) parts.push(`seq:${String(key.eventSeq)}`)
if (key.syntheticId !== undefined) parts.push(`synthetic:${key.syntheticId}`)
return parts.join(':')
}
/** Pick a useful starting inspector tab for common target kinds. */
export function defaultInspectorTabForTarget(target: InspectorTarget): InspectorTab {
switch (target.kind) {
case 'assistant-stream':
case 'tool-result':
return 'output'
case 'session':
case 'run':
case 'turn':
case 'step':
case 'waterfall-span':
return 'metadata'
case 'dev-object':
return 'input'
default:
return 'input'
}
}
/** Build closed drawer state when no node is selected. */
export function closedInspectorState(): InspectorState {
return {
open: false,
activeTab: 'input',
tabs: [],
}
}
/** Build an open drawer state for a selected target. */
export function openInspectorState(target: InspectorTarget): InspectorState {
return {
open: true,
target,
activeTab: defaultInspectorTabForTarget(target),
tabs: INSPECTOR_TABS.map((tab) => ({
tab,
available: true,
canCopy: tab !== 'feedback',
})),
}
}

View File

@@ -0,0 +1,558 @@
import { spawn, execFileSync } from 'node:child_process'
import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, appendFileSync } from 'node:fs'
import { dirname, join, relative, resolve } from 'node:path'
import { Readable, Writable } from 'node:stream'
import { fileURLToPath, pathToFileURL } from 'node:url'
import { app, BrowserWindow, dialog, ipcMain } from 'electron'
import {
ClientSideConnection,
PROTOCOL_VERSION,
ndJsonStream,
} from '@agentclientprotocol/sdk'
const here = dirname(fileURLToPath(import.meta.url))
const packageRoot = resolve(here, '..')
const repoRoot = resolve(packageRoot, '../../..')
const sessionsRoot = resolve(repoRoot, '.sessions')
const feedbackRoot = resolve(sessionsRoot, '.desktop-feedback')
const acpConfigPath = resolve(repoRoot, 'examples/acp-agent/cordis.yml')
/** @type {BrowserWindow | undefined} */
let mainWindow
/** @type {'stopped' | 'starting' | 'running' | 'stopping' | 'error'} */
let runtimeState = 'stopped'
/** @type {import('node:child_process').ChildProcessWithoutNullStreams | undefined} */
let runtimeProcess
/** @type {ClientSideConnection | undefined} */
let acpClient
let initializeResult
let stderrTail = ''
/** @type {Map<string, {sessionId: string, loaded: boolean, cwd: string}>} */
const activeSessions = new Map()
function broadcast(channel, payload) {
for (const window of BrowserWindow.getAllWindows()) {
window.webContents.send(channel, payload)
}
}
function setRuntimeState(next, extra = {}) {
runtimeState = next
broadcast('runtime:status-update', runtimeStatus(extra))
}
function runtimeStatus(extra = {}) {
return {
state: runtimeState,
pid: runtimeProcess?.pid,
repoRoot,
sessionsRoot,
configPath: acpConfigPath,
initialized: initializeResult,
stderrTail,
...extra,
}
}
async function ensureRuntime() {
if (acpClient !== undefined && runtimeState === 'running') return acpClient
await startRuntime()
if (acpClient === undefined) throw new Error('ACP runtime did not start')
return acpClient
}
async function startRuntime() {
if (runtimeState === 'running' || runtimeState === 'starting') return runtimeStatus()
setRuntimeState('starting')
stderrTail = ''
activeSessions.clear()
runtimeProcess = spawn('node', [
'--import',
'tsx',
'packages/examples/acp-demo/src/bin.ts',
'--config',
relative(repoRoot, acpConfigPath),
], {
cwd: repoRoot,
env: { ...process.env },
stdio: ['pipe', 'pipe', 'pipe'],
})
runtimeProcess.stderr.setEncoding('utf8')
runtimeProcess.stderr.on('data', chunk => {
stderrTail = `${stderrTail}${chunk}`.slice(-10_000)
broadcast('runtime:stderr', { text: String(chunk), tail: stderrTail })
})
runtimeProcess.on('exit', (code, signal) => {
runtimeProcess = undefined
acpClient = undefined
initializeResult = undefined
activeSessions.clear()
setRuntimeState(code === 0 ? 'stopped' : 'error', { exit: { code, signal } })
})
const stream = ndJsonStream(
Writable.toWeb(runtimeProcess.stdin),
Readable.toWeb(runtimeProcess.stdout),
)
acpClient = new ClientSideConnection(() => ({
sessionUpdate(params) {
broadcast('sessions:update', params)
return Promise.resolve()
},
async requestPermission(params) {
const options = params.options.map(option => `${option.name ?? option.optionId} (${option.kind})`)
const result = await dialog.showMessageBox(mainWindow, {
type: 'question',
buttons: [...options, 'Cancel'],
cancelId: options.length,
defaultId: 0,
title: 'Harness permission request',
message: params.toolCall.title ?? 'Tool permission request',
detail: JSON.stringify(params.toolCall.rawInput ?? params.toolCall, null, 2),
})
const option = params.options[result.response]
if (option === undefined) return { outcome: { outcome: 'cancelled' } }
return { outcome: { outcome: 'selected', optionId: option.optionId } }
},
async unstable_createElicitation(params) {
const result = await dialog.showMessageBox(mainWindow, {
type: 'question',
buttons: ['Accept', 'Cancel'],
cancelId: 1,
defaultId: 0,
title: 'Harness needs input',
message: params.message,
detail: JSON.stringify(params, null, 2),
})
return result.response === 0 ? { action: 'accept', content: {} } : { action: 'cancel' }
},
}), stream)
initializeResult = await acpClient.initialize({
protocolVersion: PROTOCOL_VERSION,
clientCapabilities: {
_meta: { terminal_output: true },
},
})
setRuntimeState('running')
return runtimeStatus()
}
async function stopRuntime() {
if (runtimeProcess === undefined) {
setRuntimeState('stopped')
return runtimeStatus()
}
setRuntimeState('stopping')
const proc = runtimeProcess
await new Promise(resolvePromise => {
proc.once('exit', resolvePromise)
proc.stdin.end()
setTimeout(() => {
if (!proc.killed) proc.kill('SIGTERM')
}, 1000).unref()
})
return runtimeStatus()
}
function walkJsonl(dir, out = []) {
if (!existsSync(dir)) return out
for (const entry of readdirSync(dir, { withFileTypes: true })) {
const full = join(dir, entry.name)
if (entry.isDirectory()) {
if (entry.name !== '.desktop-feedback') walkJsonl(full, out)
} else if (entry.isFile() && entry.name.endsWith('.jsonl')) {
out.push(full)
}
}
return out
}
function readJsonl(file) {
const text = readFileSync(file, 'utf8')
const rows = []
for (const [index, line] of text.split(/\r?\n/).entries()) {
if (!line.trim()) continue
try {
rows.push(JSON.parse(line))
} catch (error) {
rows.push({ type: 'parse/error', seq: index, time: 0, data: { line, error: String(error) } })
}
}
const first = rows[0]
const header = first?.type === 'session'
? { ...first, path: file }
: { type: 'session', version: 0, id: file.split('/').at(-1)?.replace(/\.jsonl$/, ''), createdAt: 0, path: file }
const events = first?.type === 'session' ? rows.slice(1) : rows
return { header, events, rawText: text }
}
function textOfContent(content) {
if (!Array.isArray(content)) return ''
return content.map(block => {
if (block?.type === 'text' || block?.type === 'reasoning') return block.text ?? ''
if (block?.type === 'tool-call') return `[tool-call ${block.name}] ${block.arguments ?? ''}`
return JSON.stringify(block)
}).filter(Boolean).join('\n')
}
function latestHeader(events) {
return events.filter(event => event.type === 'request/header' && event.data?.header).at(-1)?.data?.header ?? {}
}
function summarizeSession(file) {
const { header, events } = readJsonl(file)
const requestHeader = latestHeader(events)
const firstUser = events.find(event => event.type === 'user/message')
const last = events.at(-1)
return {
id: String(header.id),
cwd: header.cwd,
path: file,
relativePath: relative(repoRoot, file),
createdAt: header.createdAt || events[0]?.time || statSync(file).birthtimeMs,
lastActivity: last?.time || statSync(file).mtimeMs,
eventCount: events.length,
turnCount: events.filter(event => event.type === 'turn/start').length,
stepCount: events.filter(event => event.type === 'step/start').length,
toolCallCount: events.filter(event => event.type === 'tool/call').length,
model: requestHeader.config?.model,
title: textOfContent(firstUser?.data?.content).slice(0, 120) || String(header.id),
live: activeSessions.has(String(header.id)),
}
}
function listSessions() {
const persisted = walkJsonl(sessionsRoot).map(summarizeSession)
const persistedIds = new Set(persisted.map(session => session.id))
const liveOnly = [...activeSessions.values()]
.filter(session => !persistedIds.has(session.sessionId))
.map(session => ({
id: session.sessionId,
cwd: session.cwd,
path: undefined,
relativePath: undefined,
createdAt: Date.now(),
lastActivity: Date.now(),
eventCount: 0,
turnCount: 0,
stepCount: 0,
toolCallCount: 0,
model: undefined,
title: 'New live session',
live: true,
}))
return [...liveOnly, ...persisted].sort((a, b) => b.lastActivity - a.lastActivity)
}
function findSessionFile(sessionId) {
return walkJsonl(sessionsRoot).find(file => {
if (file.endsWith(`${sessionId}.jsonl`)) return true
try {
return String(readJsonl(file).header.id) === sessionId
} catch {
return false
}
})
}
function readTrace(sessionId) {
const file = findSessionFile(sessionId)
if (file === undefined) {
return { found: false, sessionId, header: { id: sessionId, cwd: repoRoot }, events: [], rawText: '' }
}
const trace = readJsonl(file)
return {
found: true,
sessionId,
header: trace.header,
events: trace.events,
rawText: trace.rawText,
path: file,
relativePath: relative(repoRoot, file),
feedback: readFeedback(sessionId),
}
}
function feedbackFile(sessionId) {
return join(feedbackRoot, `${encodeURIComponent(sessionId)}.feedback.jsonl`)
}
function readFeedback(sessionId, targetId) {
const file = feedbackFile(sessionId)
if (!existsSync(file)) return []
return readFileSync(file, 'utf8')
.split(/\r?\n/)
.filter(Boolean)
.map((line, index) => {
try {
return JSON.parse(line)
} catch (error) {
return { type: 'feedback/parse-error', seq: index, time: 0, data: { line, error: String(error) } }
}
})
.filter(record => targetId === undefined || record.data?.targetId === targetId)
}
function appendFeedback(entry) {
const sessionId = String(entry.sessionId ?? '')
if (sessionId.length === 0) throw new Error('sessionId is required')
const rows = readFeedback(sessionId)
const record = {
type: 'feedback/add',
seq: rows.length,
time: Date.now(),
data: {
sessionId,
targetId: String(entry.targetId ?? `session:${sessionId}`),
targetTitle: String(entry.targetTitle ?? sessionId),
targetKind: String(entry.targetKind ?? 'session'),
author: String(entry.author ?? 'shentuni').trim() || 'shentuni',
text: String(entry.text ?? '').trim(),
},
}
if (record.data.text.length === 0) throw new Error('feedback text is required')
mkdirSync(feedbackRoot, { recursive: true })
appendFileSync(feedbackFile(sessionId), `${JSON.stringify(record)}\n`)
return record
}
async function ensureSessionLoaded(sessionId) {
if (activeSessions.has(sessionId)) return
const summary = listSessions().find(session => session.id === sessionId)
const client = await ensureRuntime()
await client.loadSession({ sessionId, cwd: summary?.cwd ?? repoRoot, mcpServers: [] })
activeSessions.set(sessionId, { sessionId, loaded: true, cwd: summary?.cwd ?? repoRoot })
}
function devStatus() {
let dirty = false
let branch = 'unknown'
let commit = 'unknown'
const configText = readTextSafe(acpConfigPath)
const recentEvidence = summarizeRecentArtifactEvidence()
try {
dirty = execFileSync('git', ['status', '--porcelain'], { cwd: repoRoot, encoding: 'utf8' }).trim().length > 0
branch = execFileSync('git', ['branch', '--show-current'], { cwd: repoRoot, encoding: 'utf8' }).trim()
commit = execFileSync('git', ['rev-parse', '--short', 'HEAD'], { cwd: repoRoot, encoding: 'utf8' }).trim()
} catch {
// Keep the UI usable if git is unavailable.
}
return {
runtime: runtimeStatus(),
git: { dirty, branch, commit },
watchedPaths: ['packages/**', 'examples/**', 'plugins/**', 'cordis.yml', 'package.json', 'pnpm-lock.yaml'],
restartNeeded: dirty,
recentPromptUses: recentEvidence.promptUses,
recentToolCalls: recentEvidence.toolCalls,
appComposition: {
name: 'Deepseek Harness ACP agent',
entrypoint: 'packages/examples/acp-demo/src/bin.ts',
configPath: relative(repoRoot, acpConfigPath),
configText,
plugins: parseCordisPlugins(configText),
sourceFiles: [
{
label: 'ACP front door',
path: 'packages/examples/acp-demo/src/index.ts',
purpose: 'Loads the agent spine, JSONL persistence, user interaction service, and ACP bridge.',
},
{
label: 'Agent spine',
path: 'packages/examples/agent-spine-demo/src/index.ts',
purpose: 'Composes system prompt, tool registry, skills, agent registry, tasks, invariants, tool plugins, and agent loop.',
},
{
label: 'System prompt service',
path: 'packages/system-prompt/system-prompt/src/index.ts',
purpose: 'Owns persona, tool order, and assembled model-facing prompt sections.',
},
{
label: 'Tool registry',
path: 'packages/tools/tools/src/index.ts',
purpose: 'Owns model-facing tool registration, schema validation, and tool presentation mode.',
},
],
},
}
}
function summarizeRecentArtifactEvidence() {
const promptUses = []
const toolCalls = new Map()
for (const file of walkJsonl(sessionsRoot)) {
let trace
try {
trace = readJsonl(file)
} catch {
continue
}
const sessionId = String(trace.header.id)
for (const event of trace.events) {
if (event.type === 'request/header' && event.data?.header) {
const header = event.data.header
promptUses.push({
sessionId,
relativePath: relative(repoRoot, file),
seq: event.seq,
time: event.time,
systemChars: String(header.system ?? '').length,
tools: Array.isArray(header.tools) ? header.tools.length : 0,
model: header.config?.model,
})
}
if (event.type === 'tool/call') {
const name = String(event.data?.name ?? event.data?.toolName ?? 'tool')
const existing = toolCalls.get(name) ?? {
name,
count: 0,
lastSessionId: sessionId,
lastRelativePath: relative(repoRoot, file),
lastSeq: event.seq,
lastTime: event.time,
}
existing.count += 1
if ((event.time ?? 0) >= (existing.lastTime ?? 0)) {
existing.lastSessionId = sessionId
existing.lastRelativePath = relative(repoRoot, file)
existing.lastSeq = event.seq
existing.lastTime = event.time
}
toolCalls.set(name, existing)
}
}
}
return {
promptUses: promptUses
.sort((a, b) => (b.time ?? 0) - (a.time ?? 0))
.slice(0, 8),
toolCalls: [...toolCalls.values()]
.sort((a, b) => (b.lastTime ?? 0) - (a.lastTime ?? 0)),
}
}
function readTextSafe(file) {
try {
return readFileSync(file, 'utf8')
} catch (error) {
return `# Unable to read ${file}\n${String(error)}`
}
}
function parseCordisPlugins(text) {
const plugins = []
let current
for (const line of text.split(/\r?\n/)) {
const id = line.match(/^\s*-\s+id:\s*(.+?)\s*$/)
if (id) {
if (current !== undefined) plugins.push(current)
current = { id: id[1], name: '', configPreview: '' }
continue
}
if (current === undefined) continue
const name = line.match(/^\s*name:\s*(.+?)\s*$/)
if (name) {
current.name = name[1].replace(/^['"]|['"]$/g, '')
continue
}
if (/^\s{2,}\S/.test(line) && current.configPreview.length < 1600) {
current.configPreview = `${current.configPreview}${line}\n`
}
}
if (current !== undefined) plugins.push(current)
return plugins
}
function registerIpc() {
ipcMain.handle('runtime:start', async () => startRuntime())
ipcMain.handle('runtime:stop', async () => stopRuntime())
ipcMain.handle('runtime:restart', async () => {
await stopRuntime()
return startRuntime()
})
ipcMain.handle('runtime:status', () => runtimeStatus())
ipcMain.handle('sessions:list', () => ({ root: sessionsRoot, sessions: listSessions() }))
ipcMain.handle('sessions:create', async () => {
const client = await ensureRuntime()
const response = await client.newSession({ cwd: repoRoot, mcpServers: [] })
activeSessions.set(response.sessionId, { sessionId: response.sessionId, loaded: true, cwd: repoRoot })
return { ...response, trace: readTrace(response.sessionId) }
})
ipcMain.handle('sessions:load', async (_event, { sessionId }) => {
await ensureSessionLoaded(String(sessionId))
return { sessionId, trace: readTrace(String(sessionId)) }
})
ipcMain.handle('sessions:prompt', async (_event, { sessionId, text }) => {
const id = String(sessionId)
await ensureSessionLoaded(id)
const client = await ensureRuntime()
const response = await client.prompt({
sessionId: id,
prompt: [{ type: 'text', text: String(text) }],
})
return { response, trace: readTrace(id) }
})
ipcMain.handle('sessions:cancel', async (_event, { sessionId }) => {
const client = await ensureRuntime()
await client.cancel({ sessionId: String(sessionId) })
return { ok: true }
})
ipcMain.handle('trace:read', (_event, { sessionId }) => readTrace(String(sessionId)))
ipcMain.handle('feedback:list', (_event, { sessionId, targetId }) => readFeedback(String(sessionId), targetId === undefined ? undefined : String(targetId)))
ipcMain.handle('feedback:add', (_event, entry) => appendFeedback(entry))
ipcMain.handle('dev:status', () => devStatus())
}
async function createWindow() {
mainWindow = new BrowserWindow({
width: 1440,
height: 940,
minWidth: 1080,
minHeight: 720,
title: 'DeepSeek Harness Desktop',
backgroundColor: '#f5f5f7',
webPreferences: {
preload: join(here, 'preload.cjs'),
contextIsolation: true,
nodeIntegration: false,
},
})
if (process.env.VITE_DEV_SERVER_URL !== undefined) {
await mainWindow.loadURL(process.env.VITE_DEV_SERVER_URL)
mainWindow.webContents.openDevTools({ mode: 'detach' })
} else {
const builtIndex = join(packageRoot, 'dist/index.html')
if (!existsSync(builtIndex)) {
await mainWindow.loadURL(`data:text/html,${encodeURIComponent('Run pnpm --dir packages/ui/desktop build:ui before pnpm --dir packages/ui/desktop start.')}`)
} else {
await mainWindow.loadURL(pathToFileURL(builtIndex).toString())
}
}
}
registerIpc()
app.whenReady().then(async () => {
await createWindow()
try {
await startRuntime()
} catch (error) {
setRuntimeState('error', { error: String(error) })
}
})
app.on('window-all-closed', () => {
void stopRuntime().finally(() => {
if (process.platform !== 'darwin') app.quit()
})
})
app.on('activate', () => {
if (BrowserWindow.getAllWindows().length === 0) void createWindow()
})

View File

@@ -0,0 +1,44 @@
const { contextBridge, ipcRenderer } = require('electron')
const api = {
runtime: {
start: () => ipcRenderer.invoke('runtime:start'),
stop: () => ipcRenderer.invoke('runtime:stop'),
restart: () => ipcRenderer.invoke('runtime:restart'),
status: () => ipcRenderer.invoke('runtime:status'),
onStatus: (callback) => {
const listener = (_event, payload) => { callback(payload) }
ipcRenderer.on('runtime:status-update', listener)
return () => { ipcRenderer.removeListener('runtime:status-update', listener) }
},
onStderr: (callback) => {
const listener = (_event, payload) => { callback(payload) }
ipcRenderer.on('runtime:stderr', listener)
return () => { ipcRenderer.removeListener('runtime:stderr', listener) }
},
},
sessions: {
list: () => ipcRenderer.invoke('sessions:list'),
create: () => ipcRenderer.invoke('sessions:create'),
load: (sessionId) => ipcRenderer.invoke('sessions:load', { sessionId }),
prompt: (sessionId, text) => ipcRenderer.invoke('sessions:prompt', { sessionId, text }),
cancel: (sessionId) => ipcRenderer.invoke('sessions:cancel', { sessionId }),
onUpdate: (callback) => {
const listener = (_event, payload) => { callback(payload) }
ipcRenderer.on('sessions:update', listener)
return () => { ipcRenderer.removeListener('sessions:update', listener) }
},
},
trace: {
read: (sessionId) => ipcRenderer.invoke('trace:read', { sessionId }),
},
feedback: {
list: (sessionId, targetId) => ipcRenderer.invoke('feedback:list', { sessionId, targetId }),
add: (entry) => ipcRenderer.invoke('feedback:add', entry),
},
dev: {
status: () => ipcRenderer.invoke('dev:status'),
},
}
contextBridge.exposeInMainWorld('dshDesktop', api)

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,79 @@
import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process'
import { mkdtemp, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { Readable, Writable } from 'node:stream'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import {
ClientSideConnection,
PROTOCOL_VERSION,
ndJsonStream,
type Client,
type SessionNotification,
type Stream,
} from '@agentclientprotocol/sdk'
describe('desktop ACP subprocess bridge', () => {
let storageDir: string
let child: ChildProcessWithoutNullStreams | undefined
beforeEach(async () => {
storageDir = await mkdtemp(join(tmpdir(), 'dsh-desktop-acp-'))
})
afterEach(async () => {
if (child !== undefined) {
child.stdin.end()
child.kill('SIGTERM')
child = undefined
}
await rm(storageDir, { recursive: true, force: true })
})
it('initializes the real ACP runtime and creates a session without a model call', async () => {
child = spawn('node', [
'--import',
'tsx',
'packages/examples/acp-demo/src/bin.ts',
'--config',
'examples/acp-agent/cordis.yml',
], {
cwd: process.cwd(),
env: {
...process.env,
DSH_SNAPSHOT_SESSIONS_ROOT: storageDir,
},
stdio: ['pipe', 'pipe', 'pipe'],
})
let stderr = ''
child.stderr.setEncoding('utf8')
child.stderr.on('data', chunk => { stderr += String(chunk) })
const stream: Stream = ndJsonStream(
Writable.toWeb(child.stdin) as WritableStream<Uint8Array>,
Readable.toWeb(child.stdout) as ReadableStream<Uint8Array>,
)
const updates: SessionNotification[] = []
const client = new ClientSideConnection((): Client => ({
sessionUpdate(params) {
updates.push(params)
return Promise.resolve()
},
requestPermission() {
return Promise.resolve({ outcome: { outcome: 'cancelled' } })
},
unstable_createElicitation() {
return Promise.resolve({ action: 'cancel' })
},
}), stream)
const init = await client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })
expect(init.agentInfo.name).toBe('deepseek-harness-acp')
const session = await client.newSession({ cwd: process.cwd(), mcpServers: [] })
expect(session.sessionId).toBeTruthy()
expect(updates).toHaveLength(0)
expect(stderr).not.toContain('Error:')
}, 20_000)
})

View File

@@ -0,0 +1,103 @@
import { describe, expect, it } from 'vitest'
import {
closedInspectorState,
createInspectorTargetId,
DEFAULT_FEEDBACK_AUTHOR,
DESKTOP_SURFACES,
fullDetailBelongsInInspector,
INSPECTOR_TABS,
openInspectorState,
opensInspector,
ownsComposer,
SURFACE_DEFINITIONS,
SURFACE_POLICIES,
type DesktopSurface,
type InspectorTarget,
} from '../src/index.ts'
const target: InspectorTarget = {
id: 'session:one:event:1',
kind: 'message',
title: 'user/message',
}
describe('desktop surface policies', () => {
it('routes trace-analysis detail through the inspector, but not Develop', () => {
for (const surface of DESKTOP_SURFACES.filter(surface => surface !== 'dev')) {
expect(opensInspector(surface, target)).toBe(true)
expect(fullDetailBelongsInInspector(surface)).toBe(true)
}
expect(opensInspector('dev', target)).toBe(false)
expect(fullDetailBelongsInInspector('dev')).toBe(false)
})
it('keeps empty selections from opening the inspector', () => {
expect(opensInspector('trajectory', undefined)).toBe(false)
})
it('keeps trajectory and context as summary-first surfaces', () => {
expect(SURFACE_POLICIES.trajectory).toMatchObject({
summaryFirst: true,
inlinePreview: true,
fullDetailInInspector: true,
})
expect(SURFACE_POLICIES.context).toMatchObject({
summaryFirst: true,
inlinePreview: true,
fullDetailInInspector: true,
})
})
it('keeps the composer scoped to chat only', () => {
for (const surface of DESKTOP_SURFACES) {
expect(ownsComposer(surface)).toBe(surface === 'chat')
}
})
it('keeps surface definitions aligned with the exported surface list', () => {
expect(Object.keys(SURFACE_DEFINITIONS).sort()).toEqual([...DESKTOP_SURFACES].sort())
expect(SURFACE_DEFINITIONS.trajectory.purpose).toBe('navigate')
expect(SURFACE_DEFINITIONS.context.purpose).toBe('request-anatomy')
expect(SURFACE_DEFINITIONS.compare.primaryQuestion).toContain('baseline')
expect(SURFACE_DEFINITIONS.dev.primaryQuestion).toContain('artifacts compose')
})
})
describe('desktop inspector contracts', () => {
it('uses stable target ids across surfaces', () => {
expect(createInspectorTargetId({
sessionId: 's1',
runId: 'r1',
kind: 'tool-call',
eventSeq: 42,
})).toBe('session:s1:run:r1:kind:tool-call:seq:42')
})
it('opens output by default for produced data and metadata for structural targets', () => {
expect(openInspectorState({
id: 'session:s1:kind:tool-result:seq:9',
kind: 'tool-result',
title: 'tool/result',
}).activeTab).toBe('output')
expect(openInspectorState({
id: 'session:s1:kind:step:seq:3',
kind: 'step',
title: 'step 1',
}).activeTab).toBe('metadata')
})
it('keeps inspector tabs ordered with feedback last', () => {
expect(INSPECTOR_TABS).toEqual(['input', 'output', 'metadata', 'feedback'])
expect(openInspectorState(target).tabs.map((tab) => tab.tab)).toEqual(INSPECTOR_TABS)
expect(closedInspectorState()).toEqual({
open: false,
activeTab: 'input',
tabs: [],
})
})
it('uses shentuni as the default feedback author', () => {
expect(DEFAULT_FEEDBACK_AUTHOR).toBe('shentuni')
})
})

View File

@@ -0,0 +1,10 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "lib/types"
},
"include": [
"src"
]
}