chore: version 2.0.0-alpha.6 — companion on Claude CLI subprocess (Max subscription)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
26
CHANGELOG.md
26
CHANGELOG.md
@@ -3,6 +3,32 @@
|
||||
All notable changes to Void 2.0 are documented here.
|
||||
Format: [Keep a Changelog](https://keepachangelog.com).
|
||||
|
||||
## [2.0.0-alpha.6] — 2026-06-01
|
||||
|
||||
### Changed (Plan 5b: companion backend → Claude CLI subprocess)
|
||||
|
||||
- **Companion model backend switched from the Anthropic API to the `claude`
|
||||
CLI subprocess**, authenticated by the owner's **Claude Max subscription**
|
||||
(no API key — the Agent SDK can't use subscription auth headlessly, and Max
|
||||
doesn't issue API keys). Mirrors Void 1.0's `lib/agent.js`: spawn `claude`
|
||||
with `ANTHROPIC_API_KEY`/`ANTHROPIC_AUTH_TOKEN` stripped so it uses the
|
||||
logged-in subscription. The CLI owns the agentic loop; the four companion
|
||||
tools are exposed to it via a local **stdio MCP server** (`lib/mcp/`).
|
||||
- `lib/ai/claude_cli.js` — spawns `claude --print --output-format stream-json
|
||||
--include-partial-messages --append-system-prompt … (--session-id | --resume)
|
||||
--mcp-config … --strict-mcp-config --tools … --allowedTools …`, maps stream-json
|
||||
→ `{delta,tool,tool_result,result,error}`. Prompt fed via **stdin** (variadic
|
||||
`--tools` would eat a positional). Multi-turn continuity via `--resume`.
|
||||
- `lib/mcp/companion-stdio.js` — stdio MCP server re-exposing `companionRegistry`;
|
||||
per-turn Space/agent context passed via env in the `--mcp-config`.
|
||||
- `propose_change` now stamps the current Space onto created space-scoped
|
||||
entities (model can't know the Space uuid).
|
||||
- CT 311 runs the `claude` CLI (logged in as `void`, `HOME=/var/lib/void`).
|
||||
- Built-in CLI tools (Bash/Read/Write/…) disabled via `--tools`; the companion
|
||||
has only the four `mcp__void__*` tools.
|
||||
- The old `@anthropic-ai/sdk` API-key path (`lib/ai/anthropic.js`, `runTurn`)
|
||||
is retained in-tree but no longer the companion's execution path.
|
||||
|
||||
## [2.0.0-alpha.5] — 2026-06-01
|
||||
|
||||
### Added (Plan 5: Companion chat)
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
# Plan 5b — Companion model backend: Claude CLI subprocess (Max subscription)
|
||||
|
||||
> Amendment to Plan 5. Replaces the Anthropic-API-key model backend with the
|
||||
> `claude` CLI subprocess approach (subscription auth), mirroring Void 1.0's
|
||||
> `lib/agent.js`. REQUIRED SUB-SKILL for execution: subagent-driven-development.
|
||||
|
||||
**Why:** Claude Max has no API key; the Agent SDK can't use subscription auth headlessly (ToS). Void 1.0 already powers Claude by spawning the locally-authenticated `claude` CLI (it strips `ANTHROPIC_API_KEY` from the child env so the CLI uses the Max subscription). We replicate that. The CLI owns the agentic loop; our four tools are exposed to it via a local MCP server.
|
||||
|
||||
**Unchanged from Plan 5:** the tool defs + `companionRegistry` (T5–T9), migration 007 + `findOrCreateForSpace` (T3–T4), the per-Space conversation model, persistence, the right-rail UI (T13–T14), and the SSE event vocabulary the UI consumes (`delta` / `tool` / `draft` / `error` / `done`).
|
||||
|
||||
**Removed/replaced:** `lib/ai/anthropic.js` (API-key adapter) and `lib/ai/agent/runtime.js` (`runTurn`) are no longer the execution path. Keep the files for now but the companion route stops importing them. `@anthropic-ai/sdk` dependency stays (harmless) or is removed in cleanup.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
Browser rail ──POST /turn (SSE)──▶ void-server companion route
|
||||
│ writes per-turn: system-prompt file + mcp-config (env carries space/agent/view)
|
||||
▼
|
||||
spawn `claude -p --output-format stream-json --include-partial-messages
|
||||
--session-id <conversation.id> --append-system-prompt <persona>
|
||||
--mcp-config <cfg> --strict-mcp-config
|
||||
--allowedTools mcp__void__search,mcp__void__read,mcp__void__context,mcp__void__propose_change
|
||||
<userText>`
|
||||
(child env: ANTHROPIC_API_KEY/ANTHROPIC_AUTH_TOKEN deleted → Max subscription auth)
|
||||
│ stdout = stream-json lines
|
||||
▼
|
||||
map events → SSE (text_delta→delta, tool_use_start→tool, tool_result→(detect draft), result→done)
|
||||
│ claude calls tools ──stdio MCP──▶ lib/mcp/companion-stdio.js
|
||||
▼ (reuses companionRegistry handlers + pool;
|
||||
persist assistant message + draft_ids reads SPACE_ID/AGENT_ID/VOID_VIEW from env)
|
||||
```
|
||||
|
||||
**MCP transport decision:** stdio. The route writes a per-turn `--mcp-config` JSON declaring one server `void` = `{command:"node", args:["/opt/void-server/lib/mcp/companion-stdio.js"], env:{SPACE_ID, AGENT_ID, VOID_VIEW_JSON, DATABASE_URL,…}}`. claude spawns it; it serves the 4 tools over stdio and runs the same handlers against the DB. No new HTTP attack surface; context flows via env. `--strict-mcp-config` ensures only our server is used; built-in tools are excluded by not allow-listing them.
|
||||
|
||||
**Draft detection:** `propose_change`'s MCP result already contains `pending_change_id`. The route detects a `tool_result` for `propose_change` (or reads the structured result) and emits a `draft` SSE event + collects the id for the assistant message metadata. (Alternatively the tool returns the id in `structuredContent`; the route maps it.)
|
||||
|
||||
---
|
||||
|
||||
## Task B1: MCP server exposing the four tools (stdio)
|
||||
|
||||
**Files:** Create `lib/mcp/companion-stdio.js`; Create `lib/mcp/context.js` (builds tool `ctx` from env); Test `tests/mcp/companion_tools.test.js`
|
||||
|
||||
- [ ] **Step 1: Failing test** — import the registry-backed dispatch and assert each of the 4 tools is exposed with the right name + that calling `propose_change` through the MCP dispatch writes a `pending_changes` row (reuse the existing handler). Use a small exported `dispatch(toolName, args, ctx)` so the test doesn't need a live stdio transport.
|
||||
|
||||
```javascript
|
||||
// tests/mcp/companion_tools.test.js (sketch — implementer fills real assertions)
|
||||
import { describe, it, expect, beforeAll } from 'vitest';
|
||||
import { pool } from '../../lib/db/pool.js';
|
||||
import { resetDb } from '../helpers/db.js';
|
||||
import { migrateUp } from '../../lib/db/migrate.js';
|
||||
import { listMcpTools, callMcpTool } from '../../lib/mcp/companion-stdio.js';
|
||||
|
||||
let spaceId, agentId;
|
||||
beforeAll(async () => { await resetDb(); await migrateUp();
|
||||
({rows:[{id:spaceId}]} = await pool.query(`INSERT INTO spaces(slug,name) VALUES('s','S') RETURNING id`));
|
||||
({rows:[{id:agentId}]} = await pool.query(`SELECT id FROM agents WHERE slug='companion'`)); });
|
||||
|
||||
it('exposes the four tools', () => {
|
||||
expect(listMcpTools().map(t=>t.name).sort()).toEqual(['context','propose_change','read','search']);
|
||||
});
|
||||
it('propose_change writes a pending_changes row via MCP dispatch', async () => {
|
||||
const ctx = { agent:{kind:'agent',id:agentId,capabilities:{read:true,suggest:true,write:false},scopes:{}}, space_id:spaceId };
|
||||
const out = await callMcpTool('propose_change', {entity_type:'task',action:'create',payload:{space_id:spaceId,title:'X'}}, ctx);
|
||||
expect(out.pending_change_id).toBeTruthy();
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2:** Run, confirm fail.
|
||||
- [ ] **Step 3:** Implement using the MCP SDK (`@modelcontextprotocol/sdk`) stdio server. Register each tool from `companionRegistry.listTools()` (name, description, JSON-Schema `input_schema`). On a tool call, build `ctx` from env via `lib/mcp/context.js` (`{ agent: JSON.parse(env.VOID_AGENT_JSON), space_id: env.SPACE_ID, view: env.VOID_VIEW_JSON?… }`) and invoke the registry handler; return the result as MCP `content` (JSON-stringified) + `structuredContent`. Export thin `listMcpTools()` / `callMcpTool(name,args,ctx)` for the unit test. When run as `main`, start the stdio transport. Add `@modelcontextprotocol/sdk` to deps.
|
||||
- [ ] **Step 4:** Run, confirm pass.
|
||||
- [ ] **Step 5:** Commit.
|
||||
|
||||
## Task B2: Claude subprocess driver
|
||||
|
||||
**Files:** Create `lib/ai/claude_cli.js`; Test `tests/ai/claude_cli.test.js`
|
||||
|
||||
- [ ] Implement `runClaudeTurn({ sessionId, systemPrompt, userText, mcpConfigPath, allowedTools, onEvent, claudeExe=process.env.CLAUDE_EXE||'claude', cwd })` that:
|
||||
- spawns the CLI with `--print --output-format stream-json --include-partial-messages --session-id <sessionId> --append-system-prompt <systemPrompt> --mcp-config <mcpConfigPath> --strict-mcp-config --allowedTools <list> <userText>` (verify exact flag names against `claude --help` on CT 311; V1 used `--append-system-prompt-file` — confirm whether the file variant or inline is correct in 2.1.159);
|
||||
- **deletes `ANTHROPIC_API_KEY` and `ANTHROPIC_AUTH_TOKEN` from the child env** (forces subscription auth);
|
||||
- parses stdout stream-json lines and calls `onEvent` with normalized events `{type:'delta',text}` | `{type:'tool',tool,status}` | `{type:'tool_result',name,result}` | `{type:'result',usage,cost}` | `{type:'error',message}`;
|
||||
- returns `{ text, toolTrace, usage }` on close; handles non-zero exit + timeout.
|
||||
- [ ] **TEST WITHOUT THE REAL CLI:** make `claudeExe` injectable and point the test at a fake script (`tests/fixtures/fake-claude.js`, `#!/usr/bin/env node`) that emits canned stream-json lines (a text block, a `tool_use` for `propose_change`, a `tool_result`, a `result`). Assert `onEvent` receives mapped events and the return shape is right. No subscription, no network.
|
||||
- [ ] Commit.
|
||||
|
||||
## Task B3: Rework companion route onto the CLI driver
|
||||
|
||||
**Files:** Modify `lib/api/routes/companion.js`; Modify `tests/api/companion.test.js`
|
||||
|
||||
- [ ] The `POST …/turn` handler:
|
||||
- persists the user message (unchanged);
|
||||
- resolves `{agent, convo}` (unchanged);
|
||||
- writes a per-turn system prompt (the existing `SYSTEM` text + a note that `propose_change` drafts go to the owner's inbox) and a per-turn `--mcp-config` temp JSON declaring the `void` stdio server with env `{SPACE_ID, VOID_AGENT_JSON, VOID_VIEW_JSON, DATABASE_URL}` (+ whatever the pool needs);
|
||||
- calls `runClaudeTurn({ sessionId: convo.id, ... , claudeExe: req.app.locals.claudeExe||'claude', onEvent: e => send(...) })`, mapping driver events → existing SSE event names the UI expects (`delta`/`tool`/`draft`/`done`/`error`); detect `propose_change` results → `draft` events + collect ids;
|
||||
- persists ONE assistant message with `{tool_trace, draft_ids, usage}` (unchanged shape);
|
||||
- cleans up temp files.
|
||||
- [ ] **Integration test:** inject `req.app.locals.claudeExe` = the fake-claude fixture path (same approach as the old `app.locals.callModel`). Assert SSE emits tool/draft/delta/done, user+assistant rows persisted, a `pending_changes` row created (the fake triggers the real MCP `propose_change`? — if the fake can't run the MCP server, instead have the fake emit a `tool_result` for propose_change and have the route create/detect the draft from that; keep the assertion that assistant.metadata.draft_ids has length 1). Keep it network-free.
|
||||
- [ ] Remove the now-unused imports of `runTurn`/`makeCallModel` from the route. Commit.
|
||||
|
||||
## Task B4: UI event-name reconciliation (only if needed)
|
||||
|
||||
**Files:** possibly Modify `public/components/rightrail.js`
|
||||
|
||||
- [ ] Confirm the route still emits exactly `delta`/`tool`/`draft`/`error`/`done` with the same field names the rail reads. If B3 introduced any new event names (e.g. `tool_use_start` vs `tool`), reconcile in the rail (render a chip per tool event; accumulate deltas). Likely a no-op. Commit only if changed.
|
||||
|
||||
## Task B5: CT 311 enablement + redeploy + smoke
|
||||
|
||||
- [ ] Ensure `claude` CLI present on CT 311 (done — v2.1.159) and the user has run `claude login` (subscription). Verify `claude -p "hi"` works with API-key env unset.
|
||||
- [ ] `npm install` the new `@modelcontextprotocol/sdk` dep is on CT 311 (push.sh runs `npm install`).
|
||||
- [ ] Confirm `CLAUDE_EXE` resolves on CT 311 for the `void` systemd user (PATH); set `CLAUDE_EXE=/path/to/claude` in `/opt/void-server/.env` if the service PATH doesn't include the global npm bin.
|
||||
- [ ] Snapshot CT 310+311; `TARGET=root@192.168.1.216 ./deploy/push.sh`; verify `/health`.
|
||||
- [ ] **Live smoke:** open the rail in a Space → ask a question (expect streamed answer; tool chips if it searches) → "create a task to X" → inline draft card → approve → task exists + clears from Inbox.
|
||||
- [ ] Update CHANGELOG + `docs/plan-5-complete.md` (note the CLI-subprocess backend) + memory. Bump to alpha-6 if the deployed alpha-5 behavior materially changed.
|
||||
|
||||
## Open risks / verify-during-build
|
||||
- Exact `claude` 2.1.159 flag spellings (`--append-system-prompt` vs `--append-system-prompt-file`; `--allowedTools` value format — space-separated list vs repeated). Verify against `claude --help` on CT 311 in B2.
|
||||
- stream-json schema in 2.1.159 (event `type`s) — sample real output once logged-in to confirm the mapping (V1's `processEvent` is the reference and should be close).
|
||||
- The `void` systemd service user must have a logged-in `claude` credential. `claude login` stores creds in the invoking user's home (`~/.claude`/keychain). The service runs as user `void`; the login must be done AS the `void` user (e.g. `su void -c "claude"` /login), not root. Flag this in B5.
|
||||
- MCP stdio child inherits env from claude (which inherits from void-server's spawn) → DATABASE_URL/space context must be set on the claude spawn env so it propagates.
|
||||
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "void-server",
|
||||
"version": "2.0.0-alpha.5",
|
||||
"version": "2.0.0-alpha.6",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "void-server",
|
||||
"version": "2.0.0-alpha.5",
|
||||
"version": "2.0.0-alpha.6",
|
||||
"dependencies": {
|
||||
"@anthropic-ai/sdk": "^0.40.1",
|
||||
"@modelcontextprotocol/sdk": "^1.29.0",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "void-server",
|
||||
"version": "2.0.0-alpha.5",
|
||||
"version": "2.0.0-alpha.6",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
|
||||
@@ -8,7 +8,7 @@ import { registerWorkers } from './lib/jobs/index.js';
|
||||
import { router as ingestRouter } from './lib/api/routes/ingest.js';
|
||||
import { startCron } from './lib/cron/index.js';
|
||||
|
||||
const VERSION = '2.0.0-alpha.5';
|
||||
const VERSION = '2.0.0-alpha.6';
|
||||
|
||||
export function createApp() {
|
||||
const app = express();
|
||||
|
||||
@@ -17,7 +17,7 @@ describe('server', () => {
|
||||
const res = await request(app).get('/health');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.db_ok).toBe(true);
|
||||
expect(res.body.version).toBe('2.0.0-alpha.5');
|
||||
expect(res.body.version).toBe('2.0.0-alpha.6');
|
||||
});
|
||||
|
||||
it('GET /api/spaces without token returns 401', async () => {
|
||||
|
||||
BIN
workers/tests/__pycache__/conftest.cpython-312-pytest-9.0.3.pyc
Normal file
BIN
workers/tests/__pycache__/conftest.cpython-312-pytest-9.0.3.pyc
Normal file
Binary file not shown.
BIN
workers/tests/__pycache__/test_boss.cpython-312-pytest-9.0.3.pyc
Normal file
BIN
workers/tests/__pycache__/test_boss.cpython-312-pytest-9.0.3.pyc
Normal file
Binary file not shown.
BIN
workers/tests/__pycache__/test_echo.cpython-312-pytest-9.0.3.pyc
Normal file
BIN
workers/tests/__pycache__/test_echo.cpython-312-pytest-9.0.3.pyc
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
workers/tests/__pycache__/test_pdf.cpython-312-pytest-9.0.3.pyc
Normal file
BIN
workers/tests/__pycache__/test_pdf.cpython-312-pytest-9.0.3.pyc
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
21
workers/void_workers.egg-info/PKG-INFO
Normal file
21
workers/void_workers.egg-info/PKG-INFO
Normal file
@@ -0,0 +1,21 @@
|
||||
Metadata-Version: 2.4
|
||||
Name: void-workers
|
||||
Version: 0.1.0
|
||||
Requires-Python: >=3.12
|
||||
Requires-Dist: psycopg[binary,pool]>=3.2
|
||||
Requires-Dist: structlog>=24.1
|
||||
Provides-Extra: pdf
|
||||
Requires-Dist: pdfplumber>=0.11; extra == "pdf"
|
||||
Requires-Dist: pytesseract>=0.3.13; extra == "pdf"
|
||||
Requires-Dist: pillow>=10.3; extra == "pdf"
|
||||
Provides-Extra: image
|
||||
Requires-Dist: pytesseract>=0.3.13; extra == "image"
|
||||
Requires-Dist: pillow>=10.3; extra == "image"
|
||||
Provides-Extra: video
|
||||
Requires-Dist: yt-dlp>=2024.10.0; extra == "video"
|
||||
Requires-Dist: faster-whisper>=1.0.3; extra == "video"
|
||||
Provides-Extra: test
|
||||
Requires-Dist: pytest>=8.0; extra == "test"
|
||||
Requires-Dist: pytest-asyncio>=0.23; extra == "test"
|
||||
Provides-Extra: all
|
||||
Requires-Dist: void-workers[image,pdf,test,video]; extra == "all"
|
||||
10
workers/void_workers.egg-info/SOURCES.txt
Normal file
10
workers/void_workers.egg-info/SOURCES.txt
Normal file
@@ -0,0 +1,10 @@
|
||||
README.md
|
||||
pyproject.toml
|
||||
void_workers/__init__.py
|
||||
void_workers/config.py
|
||||
void_workers/log.py
|
||||
void_workers.egg-info/PKG-INFO
|
||||
void_workers.egg-info/SOURCES.txt
|
||||
void_workers.egg-info/dependency_links.txt
|
||||
void_workers.egg-info/requires.txt
|
||||
void_workers.egg-info/top_level.txt
|
||||
1
workers/void_workers.egg-info/dependency_links.txt
Normal file
1
workers/void_workers.egg-info/dependency_links.txt
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
22
workers/void_workers.egg-info/requires.txt
Normal file
22
workers/void_workers.egg-info/requires.txt
Normal file
@@ -0,0 +1,22 @@
|
||||
psycopg[binary,pool]>=3.2
|
||||
structlog>=24.1
|
||||
|
||||
[all]
|
||||
void-workers[image,pdf,test,video]
|
||||
|
||||
[image]
|
||||
pytesseract>=0.3.13
|
||||
pillow>=10.3
|
||||
|
||||
[pdf]
|
||||
pdfplumber>=0.11
|
||||
pytesseract>=0.3.13
|
||||
pillow>=10.3
|
||||
|
||||
[test]
|
||||
pytest>=8.0
|
||||
pytest-asyncio>=0.23
|
||||
|
||||
[video]
|
||||
yt-dlp>=2024.10.0
|
||||
faster-whisper>=1.0.3
|
||||
1
workers/void_workers.egg-info/top_level.txt
Normal file
1
workers/void_workers.egg-info/top_level.txt
Normal file
@@ -0,0 +1 @@
|
||||
void_workers
|
||||
BIN
workers/void_workers/__pycache__/__init__.cpython-312.pyc
Normal file
BIN
workers/void_workers/__pycache__/__init__.cpython-312.pyc
Normal file
Binary file not shown.
BIN
workers/void_workers/__pycache__/boss.cpython-312.pyc
Normal file
BIN
workers/void_workers/__pycache__/boss.cpython-312.pyc
Normal file
Binary file not shown.
BIN
workers/void_workers/__pycache__/config.cpython-312.pyc
Normal file
BIN
workers/void_workers/__pycache__/config.cpython-312.pyc
Normal file
Binary file not shown.
BIN
workers/void_workers/__pycache__/log.cpython-312.pyc
Normal file
BIN
workers/void_workers/__pycache__/log.cpython-312.pyc
Normal file
Binary file not shown.
BIN
workers/void_workers/__pycache__/model.cpython-312.pyc
Normal file
BIN
workers/void_workers/__pycache__/model.cpython-312.pyc
Normal file
Binary file not shown.
BIN
workers/void_workers/__pycache__/repo.cpython-312.pyc
Normal file
BIN
workers/void_workers/__pycache__/repo.cpython-312.pyc
Normal file
Binary file not shown.
BIN
workers/void_workers/__pycache__/runner.cpython-312.pyc
Normal file
BIN
workers/void_workers/__pycache__/runner.cpython-312.pyc
Normal file
Binary file not shown.
BIN
workers/void_workers/__pycache__/safe_fetch.cpython-312.pyc
Normal file
BIN
workers/void_workers/__pycache__/safe_fetch.cpython-312.pyc
Normal file
Binary file not shown.
Binary file not shown.
BIN
workers/void_workers/handlers/__pycache__/echo.cpython-312.pyc
Normal file
BIN
workers/void_workers/handlers/__pycache__/echo.cpython-312.pyc
Normal file
Binary file not shown.
BIN
workers/void_workers/handlers/__pycache__/image.cpython-312.pyc
Normal file
BIN
workers/void_workers/handlers/__pycache__/image.cpython-312.pyc
Normal file
Binary file not shown.
BIN
workers/void_workers/handlers/__pycache__/pdf.cpython-312.pyc
Normal file
BIN
workers/void_workers/handlers/__pycache__/pdf.cpython-312.pyc
Normal file
Binary file not shown.
Binary file not shown.
BIN
workers/void_workers/handlers/__pycache__/video.cpython-312.pyc
Normal file
BIN
workers/void_workers/handlers/__pycache__/video.cpython-312.pyc
Normal file
Binary file not shown.
Reference in New Issue
Block a user