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
- name: Build Harness sidecar
shell: bash
run: bun run build:harness-sidecar -- ${{ matrix.target }}
- name: Extract release notes
if: startsWith(github.ref, 'refs/tags/v')
id: changelog
@ -110,7 +106,7 @@ jobs:
- name: Build packages for publish
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
if [ -f "$dir/package.json" ] && node -p "require('./$dir/package.json').scripts?.build" 2>/dev/null | grep -qv undefined; then
echo "Building $dir..."
@ -138,7 +134,7 @@ jobs:
fi
}
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
pack_if_new "$dir"
done
@ -163,7 +159,7 @@ jobs:
npm publish "./.npm-packages/${tarball}" --access public --provenance
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
publish_if_new "$dir"
done

View file

@ -135,9 +135,6 @@ jobs:
- 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
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/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/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.
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.
- 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 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.

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.
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):**
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": "gemini", "cmd": "gemini", "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 }
]
},

View file

@ -4,9 +4,9 @@
"version": "0.14.0",
"identifier": "net.dannote.open-pencil",
"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",
"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"
},
"app": {
@ -26,7 +26,6 @@
"active": true,
"targets": "all",
"createUpdaterArtifacts": true,
"externalBin": ["binaries/openpencil-harness"],
"icon": [
"icons/32x32.png",
"icons/128x128.png",

View file

@ -25,7 +25,6 @@
"storybook": "storybook dev -p 6006",
"build-storybook": "storybook build",
"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",
"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/",

View file

@ -1,6 +1,6 @@
# @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.
@ -11,7 +11,7 @@ The first backend uses AI SDK `HarnessAgent`, Pi, and local `just-bash`. Pi runs
- JSONL stdio sidecar transport.
- 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

View file

@ -1,7 +1,9 @@
{
"name": "@open-pencil/harness",
"version": "0.0.0",
"private": true,
"version": "0.14.0",
"private": false,
"license": "MIT",
"description": "Optional OpenPencil companion CLI for coding-agent Harness sessions",
"type": "module",
"exports": {
".": {
@ -20,6 +22,16 @@
"test": "bun test tests",
"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": {
"@ai-sdk/harness": "1.0.74",
"@ai-sdk/harness-pi": "1.0.76",

View file

@ -1,5 +1,7 @@
import type { HarnessSidecarMessage } from '@open-pencil/harness'
import { resolvePlatformCommand } from '@/app/tauri/command'
export type HarnessChild = {
write(data: number[]): Promise<void>
kill(): Promise<void>
@ -16,7 +18,8 @@ export async function spawnHarnessProcess(options: {
onUnexpectedClose: () => void
}): Promise<HarnessProcess> {
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',
env: options.environment
})

View file

@ -9,14 +9,14 @@ afterEach(async () => {
})
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
const calls: Array<{ cmd: string; args: unknown }> = []
await mockTauriIPC((cmd, args) => {
calls.push({ cmd, args })
if (cmd === 'plugin:shell|spawn') {
expect(args).toMatchObject({
program: expect.stringContaining('openpencil-harness'),
program: 'openpencil-harness',
args: [],
options: {
encoding: 'raw',
@ -51,4 +51,33 @@ describe('Harness sidecar process', () => {
JSON.stringify(calls.find((call) => call.cmd === 'plugin:shell|stdin_write'))
).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/cli', include: ['bin', 'dist'], extraFiles: [] },
{ dir: 'packages/mcp', include: ['dist'], extraFiles: [] },
{ dir: 'packages/harness', 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/cli',
'packages/mcp',
'packages/harness',
'packages/vue'
])
})