refactor(ai): ship Harness as optional companion (#561)

* Revert "feat(ai): add HarnessAgent sidecar foundation (#560)"

This reverts commit 83a5ea1b42.

* Revert "Revert "feat(ai): add HarnessAgent sidecar foundation (#560)""

This reverts commit 0d8c03515888c62dc47186d4a3b0b7b04e78f8af.

* refactor(ai): ship Harness as optional companion

* fix(ai): support Harness companion on Windows

* test(ai): restore navigator after Harness Windows test
This commit is contained in:
Danila Poyarkov 2026-08-19 20:04:35 +03:00 committed by GitHub
parent 83a5ea1b42
commit 62ba072b26
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 63 additions and 70 deletions

View file

@ -53,10 +53,6 @@ jobs:
- uses: ./.github/actions/setup-bun - uses: ./.github/actions/setup-bun
- name: Build Harness sidecar
shell: bash
run: bun run build:harness-sidecar -- ${{ matrix.target }}
- name: Extract release notes - name: Extract release notes
if: startsWith(github.ref, 'refs/tags/v') if: startsWith(github.ref, 'refs/tags/v')
id: changelog id: changelog
@ -110,7 +106,7 @@ jobs:
- name: Build packages for publish - name: Build packages for publish
run: | run: |
publish_packages="packages/scene-graph packages/pen packages/kiwi packages/fig packages/core packages/dom-css packages/cli packages/mcp packages/vue" publish_packages="packages/scene-graph packages/pen packages/kiwi packages/fig packages/core packages/dom-css packages/cli packages/mcp packages/harness packages/vue"
for dir in $publish_packages; do for dir in $publish_packages; do
if [ -f "$dir/package.json" ] && node -p "require('./$dir/package.json').scripts?.build" 2>/dev/null | grep -qv undefined; then if [ -f "$dir/package.json" ] && node -p "require('./$dir/package.json').scripts?.build" 2>/dev/null | grep -qv undefined; then
echo "Building $dir..." echo "Building $dir..."
@ -138,7 +134,7 @@ jobs:
fi fi
} }
root_dir="$PWD" root_dir="$PWD"
publish_packages="packages/scene-graph packages/pen packages/kiwi packages/fig packages/core packages/dom-css packages/cli packages/mcp packages/vue" publish_packages="packages/scene-graph packages/pen packages/kiwi packages/fig packages/core packages/dom-css packages/cli packages/mcp packages/harness packages/vue"
for dir in $publish_packages; do for dir in $publish_packages; do
pack_if_new "$dir" pack_if_new "$dir"
done done
@ -163,7 +159,7 @@ jobs:
npm publish "./.npm-packages/${tarball}" --access public --provenance npm publish "./.npm-packages/${tarball}" --access public --provenance
fi fi
} }
publish_packages="packages/scene-graph packages/pen packages/kiwi packages/fig packages/core packages/dom-css packages/cli packages/mcp packages/vue" publish_packages="packages/scene-graph packages/pen packages/kiwi packages/fig packages/core packages/dom-css packages/cli packages/mcp packages/harness packages/vue"
for dir in $publish_packages; do for dir in $publish_packages; do
publish_if_new "$dir" publish_if_new "$dir"
done done

View file

@ -135,9 +135,6 @@ jobs:
- uses: ./.github/actions/setup-bun - uses: ./.github/actions/setup-bun
- name: Build Harness sidecar
run: bun run build:harness-sidecar -- x86_64-unknown-linux-gnu
- name: Typecheck native interaction tests - name: Typecheck native interaction tests
run: bun run check:native-test run: bun run check:native-test

View file

@ -17,7 +17,7 @@ Bun workspace packages:
- `packages/vue``@open-pencil/vue`: headless Vue 3 SDK (Reka UI-style) for building custom OpenPencil-powered editor shells and embedded editing surfaces. Renderless components and composables. The app is one consumer of the SDK. - `packages/vue``@open-pencil/vue`: headless Vue 3 SDK (Reka UI-style) for building custom OpenPencil-powered editor shells and embedded editing surfaces. Renderless components and composables. The app is one consumer of the SDK.
- `packages/cli``@open-pencil/cli`: headless CLI for .fig inspection, export, linting. Uses `citty` + `agentfmt`. - `packages/cli``@open-pencil/cli`: headless CLI for .fig inspection, export, linting. Uses `citty` + `agentfmt`.
- `packages/mcp``@open-pencil/mcp`: MCP server for AI coding tools. Stdio + HTTP (Hono). Reuses core tools. - `packages/mcp``@open-pencil/mcp`: MCP server for AI coding tools. Stdio + HTTP (Hono). Reuses core tools.
- `packages/harness``@open-pencil/harness`: private Node sidecar runtime for backend-neutral coding-agent sessions. Owns HarnessAgent adapters, opaque resume-state persistence, and the JSONL host protocol; app/editor integration remains outside the package. - `packages/harness``@open-pencil/harness`: optional Node companion CLI for backend-neutral coding-agent sessions. Owns HarnessAgent adapters, opaque resume-state persistence, and the JSONL host protocol; the desktop detects and launches the separately installed command instead of bundling a JavaScript runtime.
- `packages/docs``@open-pencil/docs`: published VitePress documentation site. Use `bun run docs:dev` for authoring, `bun run docs:build` for the default fast local render check without minification or generated LLM files, and `bun run docs:build:production` for the complete deployment output. - `packages/docs``@open-pencil/docs`: published VitePress documentation site. Use `bun run docs:dev` for authoring, `bun run docs:build` for the default fast local render check without minification or generated LLM files, and `bun run docs:build:production` for the complete deployment output.
The root app (`src/`) is the Tauri/Vite desktop editor. App-specific editor, document, AI, collaboration, shell, tabs, demo, and automation code lives under `src/app/*`. The app consumes scene graph primitives from `@open-pencil/scene-graph`, editor/rendering services through targeted `@open-pencil/core` subpath exports, and `@open-pencil/vue` through the public Vue SDK entrypoint. The root app (`src/`) is the Tauri/Vite desktop editor. App-specific editor, document, AI, collaboration, shell, tabs, demo, and automation code lives under `src/app/*`. The app consumes scene graph primitives from `@open-pencil/scene-graph`, editor/rendering services through targeted `@open-pencil/core` subpath exports, and `@open-pencil/vue` through the public Vue SDK entrypoint.
@ -153,7 +153,7 @@ Release commits are the exception: keep using `Release v0.x.y`.
Keep this section light; implementation details move often. Keep this section light; implementation details move often.
- Harness-based coding agents live in the private `packages/harness` Node sidecar package rather than the browser app. Keep its session service backend-neutral, persist only opaque non-secret resume state, and expose host integration through its bounded JSONL protocol. Pi may use local `just-bash` for development, but that in-memory sandbox does not provide process-restart recovery. - Harness-based coding agents live in the optional `@open-pencil/harness` Node companion rather than the browser app. Keep its session service backend-neutral, persist only opaque non-secret resume state, and expose host integration through its bounded JSONL protocol. Do not bundle a JavaScript runtime into Tauri; launch the separately installed `openpencil-harness` command. Pi may use local `just-bash`, but that in-memory sandbox does not provide process-restart recovery.
- ACP UI/transport lives under `src/app/ai/acp/**`; provider definitions live in `packages/core/src/constants.ts`; app prompts live under `src/app/ai/**`. Direct model configuration lives under `src/app/ai/models/**`: reusable profiles reference provider connections, roles resolve to profiles, and runtime creation resolves credentials lazily. Keep model profiles, provider connections, and role assignments separate rather than returning to singleton provider/model settings. Public docs: `packages/docs/programmable/ai-chat.md` and `packages/docs/programmable/mcp-server.md`. - ACP UI/transport lives under `src/app/ai/acp/**`; provider definitions live in `packages/core/src/constants.ts`; app prompts live under `src/app/ai/**`. Direct model configuration lives under `src/app/ai/models/**`: reusable profiles reference provider connections, roles resolve to profiles, and runtime creation resolves credentials lazily. Keep model profiles, provider connections, and role assignments separate rather than returning to singleton provider/model settings. Public docs: `packages/docs/programmable/ai-chat.md` and `packages/docs/programmable/mcp-server.md`.
- ACP transport uses Tauri shell permissions, so check `desktop/capabilities/**` when changing agent launch behavior. - ACP transport uses Tauri shell permissions, so check `desktop/capabilities/**` when changing agent launch behavior.
- Collaboration lives under `src/app/collab/**` and is documented in `packages/docs/programmable/collaboration.md`. It uses Trystero + Yjs + awareness; preserve crypto-safe room IDs and peer cleanup semantics when changing it. - Collaboration lives under `src/app/collab/**` and is documented in `packages/docs/programmable/collaboration.md`. It uses Trystero + Yjs + awareness; preserve crypto-safe room IDs and peer cleanup semantics when changing it.

View file

@ -170,6 +170,8 @@ Not every provider works in the browser, and not every model streams tool calls
Use Claude Code, Codex, or Gemini CLI directly in the chat panel. The agent connects to the editor's MCP server and uses all 100+ design tools. Requires the desktop app and the agent CLI installed locally. Use Claude Code, Codex, or Gemini CLI directly in the chat panel. The agent connects to the editor's MCP server and uses all 100+ design tools. Requires the desktop app and the agent CLI installed locally.
Pi is also available as an optional AI SDK Harness provider. Install its companion CLI with `npm install -g @open-pencil/harness`, then add a **Pi** model profile in **Settings → AI & agents**. The companion is installed separately so OpenPencil does not bundle a JavaScript runtime for users who do not enable Harness providers.
**Setup (Claude Code):** **Setup (Claude Code):**
1. Install the ACP adapter: `npm install -g @agentclientprotocol/claude-agent-acp` 1. Install the ACP adapter: `npm install -g @agentclientprotocol/claude-agent-acp`

View file

@ -60,7 +60,7 @@
{ "name": "codex-acp", "cmd": "codex-acp", "args": true }, { "name": "codex-acp", "cmd": "codex-acp", "args": true },
{ "name": "gemini", "cmd": "gemini", "args": true }, { "name": "gemini", "cmd": "gemini", "args": true },
{ "name": "openpencil-mcp-http", "cmd": "openpencil-mcp-http", "args": true }, { "name": "openpencil-mcp-http", "cmd": "openpencil-mcp-http", "args": true },
{ "name": "binaries/openpencil-harness", "sidecar": true, "args": false }, { "name": "openpencil-harness", "cmd": "openpencil-harness", "args": false },
{ "name": "cmd", "cmd": "cmd", "args": true } { "name": "cmd", "cmd": "cmd", "args": true }
] ]
}, },

View file

@ -4,9 +4,9 @@
"version": "0.14.0", "version": "0.14.0",
"identifier": "net.dannote.open-pencil", "identifier": "net.dannote.open-pencil",
"build": { "build": {
"beforeDevCommand": "bun run generate:tauri-menu && bun run build:harness-sidecar && bun run dev", "beforeDevCommand": "bun run generate:tauri-menu && bun run dev",
"devUrl": "http://localhost:1420", "devUrl": "http://localhost:1420",
"beforeBuildCommand": "bun run generate:tauri-menu && bun run build:harness-sidecar && bun run build", "beforeBuildCommand": "bun run generate:tauri-menu && bun run build",
"frontendDist": "../dist" "frontendDist": "../dist"
}, },
"app": { "app": {
@ -26,7 +26,6 @@
"active": true, "active": true,
"targets": "all", "targets": "all",
"createUpdaterArtifacts": true, "createUpdaterArtifacts": true,
"externalBin": ["binaries/openpencil-harness"],
"icon": [ "icon": [
"icons/32x32.png", "icons/32x32.png",
"icons/128x128.png", "icons/128x128.png",

View file

@ -25,7 +25,6 @@
"storybook": "storybook dev -p 6006", "storybook": "storybook dev -p 6006",
"build-storybook": "storybook build", "build-storybook": "storybook build",
"tauri": "tauri", "tauri": "tauri",
"build:harness-sidecar": "bun tools/harness-sidecar/src/build.ts",
"build:native-test": "bun run build:packages && bun tauri build --debug --no-bundle --features native-test --config desktop/tauri.native-test.conf.json", "build:native-test": "bun run build:packages && bun tauri build --debug --no-bundle --features native-test --config desktop/tauri.native-test.conf.json",
"test:native": "bun run build:native-test && wdio run wdio.conf.ts", "test:native": "bun run build:native-test && wdio run wdio.conf.ts",
"lint": "bun run lint:structure && oxlint -c oxlint.json --type-aware --type-check src/ packages/scene-graph/src/ packages/core/src/ packages/vue/src/ packages/cli/src/ packages/mcp/src/ packages/harness/src/ packages/dom-css/src/ packages/pen/src/ packages/kiwi/src/ packages/fig/src/", "lint": "bun run lint:structure && oxlint -c oxlint.json --type-aware --type-check src/ packages/scene-graph/src/ packages/core/src/ packages/vue/src/ packages/cli/src/ packages/mcp/src/ packages/harness/src/ packages/dom-css/src/ packages/pen/src/ packages/kiwi/src/ packages/fig/src/",

View file

@ -1,6 +1,6 @@
# @open-pencil/harness # @open-pencil/harness
Private Node runtime for coding-agent harness sessions. It owns the backend-neutral session lifecycle, opaque resume-state persistence, and the JSONL sidecar protocol used by host applications. Optional Node companion runtime for coding-agent harness sessions. It owns the backend-neutral session lifecycle, opaque resume-state persistence, and the JSONL sidecar protocol used by host applications.
The first backend uses AI SDK `HarnessAgent`, Pi, and local `just-bash`. Pi runs in the Node host process; `just-bash` provides an isolated in-memory workspace and shell without requiring cloud infrastructure. The first backend uses AI SDK `HarnessAgent`, Pi, and local `just-bash`. Pi runs in the Node host process; `just-bash` provides an isolated in-memory workspace and shell without requiring cloud infrastructure.
@ -11,7 +11,7 @@ The first backend uses AI SDK `HarnessAgent`, Pi, and local `just-bash`. Pi runs
- JSONL stdio sidecar transport. - JSONL stdio sidecar transport.
- Pi + `just-bash` backend. - Pi + `just-bash` backend.
The package is not wired into the desktop application yet. Credentials are supplied to the sidecar process at runtime and are never written to resume-state storage. The package is installed as an optional companion CLI for the desktop application. It is not bundled into every Tauri build; install `@open-pencil/harness` globally to make the `openpencil-harness` command available. Credentials are supplied to the companion process at runtime and are never written to resume-state storage.
## Local sandbox limitation ## Local sandbox limitation

View file

@ -1,7 +1,9 @@
{ {
"name": "@open-pencil/harness", "name": "@open-pencil/harness",
"version": "0.0.0", "version": "0.14.0",
"private": true, "private": false,
"license": "MIT",
"description": "Optional OpenPencil companion CLI for coding-agent Harness sessions",
"type": "module", "type": "module",
"exports": { "exports": {
".": { ".": {
@ -20,6 +22,16 @@
"test": "bun test tests", "test": "bun test tests",
"typecheck": "bunx tsc --noEmit -p tsconfig.json" "typecheck": "bunx tsc --noEmit -p tsconfig.json"
}, },
"repository": {
"type": "git",
"url": "git+https://github.com/open-pencil/open-pencil.git",
"directory": "packages/harness"
},
"publishConfig": {
"access": "public",
"provenance": true
},
"files": ["dist", "README.md"],
"dependencies": { "dependencies": {
"@ai-sdk/harness": "1.0.74", "@ai-sdk/harness": "1.0.74",
"@ai-sdk/harness-pi": "1.0.76", "@ai-sdk/harness-pi": "1.0.76",

View file

@ -1,5 +1,7 @@
import type { HarnessSidecarMessage } from '@open-pencil/harness' import type { HarnessSidecarMessage } from '@open-pencil/harness'
import { resolvePlatformCommand } from '@/app/tauri/command'
export type HarnessChild = { export type HarnessChild = {
write(data: number[]): Promise<void> write(data: number[]): Promise<void>
kill(): Promise<void> kill(): Promise<void>
@ -16,7 +18,8 @@ export async function spawnHarnessProcess(options: {
onUnexpectedClose: () => void onUnexpectedClose: () => void
}): Promise<HarnessProcess> { }): Promise<HarnessProcess> {
const { Command } = await import('@tauri-apps/plugin-shell') const { Command } = await import('@tauri-apps/plugin-shell')
const command = Command.sidecar('binaries/openpencil-harness', [], { const resolved = resolvePlatformCommand('openpencil-harness')
const command = Command.create(resolved.command, resolved.args, {
encoding: 'raw', encoding: 'raw',
env: options.environment env: options.environment
}) })

View file

@ -9,14 +9,14 @@ afterEach(async () => {
}) })
describe('Harness sidecar process', () => { describe('Harness sidecar process', () => {
test('spawns the bundled sidecar and keeps credentials in process environment', async () => { test('spawns the optional companion and keeps credentials in process environment', async () => {
let onEvent: ((event: unknown) => void) | undefined let onEvent: ((event: unknown) => void) | undefined
const calls: Array<{ cmd: string; args: unknown }> = [] const calls: Array<{ cmd: string; args: unknown }> = []
await mockTauriIPC((cmd, args) => { await mockTauriIPC((cmd, args) => {
calls.push({ cmd, args }) calls.push({ cmd, args })
if (cmd === 'plugin:shell|spawn') { if (cmd === 'plugin:shell|spawn') {
expect(args).toMatchObject({ expect(args).toMatchObject({
program: expect.stringContaining('openpencil-harness'), program: 'openpencil-harness',
args: [], args: [],
options: { options: {
encoding: 'raw', encoding: 'raw',
@ -51,4 +51,33 @@ describe('Harness sidecar process', () => {
JSON.stringify(calls.find((call) => call.cmd === 'plugin:shell|stdin_write')) JSON.stringify(calls.find((call) => call.cmd === 'plugin:shell|stdin_write'))
).not.toContain('secret') ).not.toContain('secret')
}) })
test('routes the npm launcher through cmd on Windows', async () => {
const originalNavigator = Object.getOwnPropertyDescriptor(globalThis, 'navigator')
try {
Object.defineProperty(globalThis, 'navigator', {
configurable: true,
value: { userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)' }
})
await mockTauriIPC((cmd, args) => {
if (cmd === 'plugin:shell|spawn') {
expect(args).toMatchObject({
program: 'cmd',
args: ['/c', 'openpencil-harness'],
options: { encoding: 'raw', env: {} }
})
return 46
}
return null
})
const process = await spawnHarnessProcess({
environment: {},
onUnexpectedClose: () => undefined
})
await process.child.kill()
} finally {
if (originalNavigator) Object.defineProperty(globalThis, 'navigator', originalNavigator)
else Reflect.deleteProperty(globalThis, 'navigator')
}
})
}) })

View file

@ -1,5 +0,0 @@
{
"name": "@open-pencil/harness-sidecar-tools",
"private": true,
"type": "module"
}

View file

@ -1,41 +0,0 @@
import { execFileSync } from 'node:child_process'
import { chmod, mkdir } from 'node:fs/promises'
import { join } from 'node:path'
const hostTarget = (): string => {
if (process.platform === 'darwin')
return process.arch === 'arm64' ? 'aarch64-apple-darwin' : 'x86_64-apple-darwin'
if (process.platform === 'linux' && process.arch === 'x64') return 'x86_64-unknown-linux-gnu'
if (process.platform === 'win32') {
return process.arch === 'arm64' ? 'aarch64-pc-windows-msvc' : 'x86_64-pc-windows-msvc'
}
throw new Error(`Unsupported Harness sidecar host: ${process.platform}/${process.arch}`)
}
const target = process.argv[2] ?? process.env.TAURI_ENV_TARGET_TRIPLE ?? hostTarget()
const bunTargetByRustTarget: Record<string, string> = {
'aarch64-apple-darwin': 'bun-darwin-arm64',
'x86_64-apple-darwin': 'bun-darwin-x64',
'x86_64-unknown-linux-gnu': 'bun-linux-x64',
'x86_64-pc-windows-msvc': 'bun-windows-x64',
'aarch64-pc-windows-msvc': 'bun-windows-arm64'
}
const bunTarget = bunTargetByRustTarget[target]
if (!bunTarget) throw new Error(`Unsupported Harness sidecar target: ${target}`)
const extension = target.includes('windows') ? '.exe' : ''
const output = join('desktop', 'binaries', `openpencil-harness-${target}${extension}`)
await mkdir(join('desktop', 'binaries'), { recursive: true })
execFileSync(
'bun',
[
'build',
'--compile',
`--target=${bunTarget}`,
'packages/harness/src/stdio.ts',
'--outfile',
output
],
{ stdio: 'inherit' }
)
if (!extension) await chmod(output, 0o755)

View file

@ -35,6 +35,7 @@ export const DEFAULT_PACKAGES: PackagePublishConfig[] = [
{ dir: 'packages/dom-css', include: ['dist'], extraFiles: ['README.md'] }, { dir: 'packages/dom-css', include: ['dist'], extraFiles: ['README.md'] },
{ dir: 'packages/cli', include: ['bin', 'dist'], extraFiles: [] }, { dir: 'packages/cli', include: ['bin', 'dist'], extraFiles: [] },
{ dir: 'packages/mcp', include: ['dist'], extraFiles: [] }, { dir: 'packages/mcp', include: ['dist'], extraFiles: [] },
{ dir: 'packages/harness', include: ['dist'], extraFiles: ['README.md'] },
{ dir: 'packages/vue', include: ['dist'], extraFiles: ['README.md'] } { dir: 'packages/vue', include: ['dist'], extraFiles: ['README.md'] }
] ]

View file

@ -63,6 +63,7 @@ describe('DEFAULT_PACKAGES', () => {
'packages/dom-css', 'packages/dom-css',
'packages/cli', 'packages/cli',
'packages/mcp', 'packages/mcp',
'packages/harness',
'packages/vue' 'packages/vue'
]) ])
}) })