Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 23 additions & 10 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -170,22 +170,29 @@ jobs:
local ok=0

for _ in $(seq 1 60); do
if curl -sf "http://127.0.0.1:${port}${endpoint}" -o /dev/null 2>/dev/null; then
ok=1
break
local sessions
sessions="$(curl -sf "http://127.0.0.1:${port}/daemon/sessions" 2>/dev/null || true)"
if [ -n "$sessions" ]; then
local session_url
session_url="$(python3 -c 'import json,sys; data=json.load(sys.stdin); sessions=data.get("sessions") or []; print(sessions[0].get("url","") if sessions else "")' <<< "$sessions")"
if [ -n "$session_url" ] && curl -sf "${session_url}${endpoint}" -o /dev/null 2>/dev/null; then
ok=1
break
fi
fi
sleep 0.5
done

kill "$pid" 2>/dev/null || true
wait "$pid" 2>/dev/null || true
PLANNOTATOR_PORT="$port" "$BINARY" daemon stop >/dev/null 2>&1 || true

if [ "$ok" = "0" ]; then
echo "FAIL: ${label} did not respond on :${port}${endpoint}"
echo "FAIL: ${label} did not expose a daemon-scoped ${endpoint}"
exit 1
fi

echo "OK: ${label} responded on :${port}${endpoint}"
echo "OK: ${label} exposed daemon-scoped ${endpoint}"
}

# 2. review: exercises server startup, bundled HTML, git diff, and HTTP.
Expand Down Expand Up @@ -232,9 +239,14 @@ jobs:
try {
for ($i = 0; $i -lt 60; $i++) {
try {
Invoke-WebRequest -Uri "http://127.0.0.1:$Port$Endpoint" -UseBasicParsing -TimeoutSec 1 | Out-Null
$ok = $true
break
$sessionsResponse = Invoke-WebRequest -Uri "http://127.0.0.1:$Port/daemon/sessions" -UseBasicParsing -TimeoutSec 1
$sessionsBody = $sessionsResponse.Content | ConvertFrom-Json
if ($sessionsBody.sessions.Count -gt 0) {
$sessionUrl = $sessionsBody.sessions[0].url
Invoke-WebRequest -Uri "$sessionUrl$Endpoint" -UseBasicParsing -TimeoutSec 1 | Out-Null
$ok = $true
break
}
} catch {
if ($process.HasExited) {
break
Expand All @@ -247,6 +259,7 @@ jobs:
Stop-Process -Id $process.Id -Force
Wait-Process -Id $process.Id -ErrorAction SilentlyContinue
}
& $binary daemon stop *> $null
Remove-Item Env:\PLANNOTATOR_PORT -ErrorAction SilentlyContinue
}

Expand All @@ -255,10 +268,10 @@ jobs:
Get-Content $stdout -ErrorAction SilentlyContinue
Write-Host "stderr:"
Get-Content $stderr -ErrorAction SilentlyContinue
throw "FAIL: $Label did not respond on :$Port$Endpoint"
throw "FAIL: $Label did not expose a daemon-scoped $Endpoint"
}

Write-Host "OK: $Label responded on :$Port$Endpoint"
Write-Host "OK: $Label exposed daemon-scoped $Endpoint"
}

# 2. review: exercises server startup, bundled HTML, git diff, and HTTP.
Expand Down
2 changes: 0 additions & 2 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,4 @@ opencode.json
plannotator-local
# Local research/reference docs (not for repo)
/reference/
# Local goal setup packages generated by the setup-goal skill.
/goals/
*.bun-build
36 changes: 28 additions & 8 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ plannotator/
│ │ ├── index.ts # startPlannotatorServer(), handleServerReady()
│ │ ├── review.ts # startReviewServer(), handleReviewServerReady()
│ │ ├── annotate.ts # startAnnotateServer(), handleAnnotateServerReady()
│ │ ├── daemon/ # Long-running daemon runtime, state, client, and session store
│ │ ├── storage.ts # Re-exports from @plannotator/shared/storage
│ │ ├── share-url.ts # Server-side share URL generation for remote sessions
│ │ ├── remote.ts # isRemoteSession(), getServerPort()
Expand Down Expand Up @@ -99,6 +100,8 @@ Plannotator has one server implementation:

Claude Code runs this server through the released `plannotator` binary entrypoint. OpenCode and Pi do not package their own server implementations; they call the same binary through the plugin protocol in `packages/shared/plugin-protocol.ts`. Runtime-agnostic logic (store, validation, types) lives in `packages/shared/`.

Daemon-backed commands run through one long-running `plannotator` process per user/machine environment. `plannotator daemon start|status|stop` manage that lifecycle, while normal plan/review/annotate/archive commands auto-start a compatible daemon and create session-scoped browser URLs at `/s/<sessionId>`. Browser API calls must use `/s/<sessionId>/api/...`; root `/api/...` routes are not a daemon session boundary.

## Installation

**Via plugin marketplace** (when repo is public):
Expand Down Expand Up @@ -216,6 +219,27 @@ During normal plan review, an Archive sidebar tab provides the same browsing via

## Server API

### Daemon Runtime (`packages/server/daemon/`)

The daemon is the single long-running Bun server used by normal plan/review/annotate/archive commands. It owns a session store and exposes browser sessions at `/s/<sessionId>`. Session browser APIs are scoped under `/s/<sessionId>/api/...`; root `/api/...` is not a valid daemon session API boundary.

| Endpoint | Method | Purpose |
| --------------------- | ------ | ------------------------------------------ |
| `/daemon/capabilities` | GET | Return daemon protocol/capability metadata |
| `/daemon/status` | GET | Return daemon process, endpoint, and session counts |
| `/daemon/sessions` | GET | List active sessions (`?clean=1` also reaps expired sessions before listing) |
| `/daemon/sessions` | POST | Create a plan/review/annotate/archive session from a plugin-protocol request |
| `/daemon/sessions/:id` | GET | Fetch a session summary |
| `/daemon/sessions/:id/result` | GET | Wait for a session decision/result |
| `/daemon/sessions/:id/cancel` | POST | Cancel a session and dispose its resources |
| `/daemon/sessions/:id` | DELETE | Delete a session record |
| `/daemon/shutdown` | POST | Ask the daemon to stop |
| `/daemon/ws` | WebSocket | Multiplex daemon lifecycle events, session-scoped external annotation events, agent job events, and correlated session actions |
| `/s/:id` | GET | Serve the browser HTML for a session |
| `/s/:id/api/...` | Any | Route browser API requests to that session's plan/review/annotate handler |

Runtime live updates for daemon lifecycle events, external annotations, and agent jobs are delivered through `/daemon/ws`. Session-scoped updates subscribe by `{ family, sessionId }`. HTTP endpoints below remain for snapshots, mutations, uploads, and large payloads. AI query token streaming remains on `/api/ai/query`.

### Plan Server (`packages/server/index.ts`)

| Endpoint | Method | Purpose |
Expand All @@ -239,8 +263,7 @@ During normal plan review, an Archive sidebar tab provides the same browsing via
| `/api/draft` | GET/POST/DELETE | Auto-save annotation drafts to survive server crashes |
| `/api/editor-annotations` | GET | List editor annotations (VS Code only) |
| `/api/editor-annotation` | POST/DELETE | Add or remove an editor annotation (VS Code only) |
| `/api/external-annotations/stream` | GET | SSE stream for real-time external annotations |
| `/api/external-annotations` | GET | Snapshot of external annotations (polling fallback, `?since=N` for version gating) |
| `/api/external-annotations` | GET | Snapshot of external annotations (`?since=N` for version gating) |
| `/api/external-annotations` | POST | Add external annotations (single or batch `{ annotations: [...] }`) |
| `/api/external-annotations` | PATCH | Update fields on a single annotation (`?id=`) |
| `/api/external-annotations` | DELETE | Remove by `?id=`, `?source=`, or clear all |
Expand All @@ -265,14 +288,12 @@ During normal plan review, an Archive sidebar tab provides the same browsing via
| `/api/ai/abort` | POST | Abort the current query |
| `/api/ai/permission` | POST | Respond to a permission request |
| `/api/ai/sessions` | GET | List active sessions |
| `/api/external-annotations/stream` | GET | SSE stream for real-time external annotations |
| `/api/external-annotations` | GET | Snapshot of external annotations (polling fallback, `?since=N` for version gating) |
| `/api/external-annotations` | GET | Snapshot of external annotations (`?since=N` for version gating) |
| `/api/external-annotations` | POST | Add external annotations (single or batch `{ annotations: [...] }`) |
| `/api/external-annotations` | PATCH | Update fields on a single annotation (`?id=`) |
| `/api/external-annotations` | DELETE | Remove by `?id=`, `?source=`, or clear all |
| `/api/agents/capabilities` | GET | Check available agent providers (claude, codex, tour) |
| `/api/agents/jobs/stream` | GET | SSE stream for real-time agent job status updates |
| `/api/agents/jobs` | GET | Snapshot of agent jobs (polling fallback, `?since=N` for version gating) |
| `/api/agents/jobs` | GET | Snapshot of agent jobs (`?since=N` for version gating) |
| `/api/agents/jobs` | POST | Launch an agent job (body: `{ provider, command, label }`) |
| `/api/agents/jobs` | DELETE | Kill all running agent jobs |
| `/api/agents/jobs/:id` | DELETE | Kill a specific agent job |
Expand All @@ -297,8 +318,7 @@ During normal plan review, an Archive sidebar tab provides the same browsing via
| `/api/doc` | GET | Serve linked .md/.mdx/.html file or code file (`?path=<path>&base=<dir>`) |
| `/api/doc/exists` | POST | Batch-validate code-file paths (body: `{ paths: string[], base?: string }`) |
| `/api/draft` | GET/POST/DELETE | Auto-save annotation drafts to survive server crashes |
| `/api/external-annotations/stream` | GET | SSE stream for real-time external annotations |
| `/api/external-annotations` | GET | Snapshot of external annotations (polling fallback, `?since=N` for version gating) |
| `/api/external-annotations` | GET | Snapshot of external annotations (`?since=N` for version gating) |
| `/api/external-annotations` | POST | Add external annotations (single or batch `{ annotations: [...] }`) |
| `/api/external-annotations` | PATCH | Update fields on a single annotation (`?id=`) |
| `/api/external-annotations` | DELETE | Remove by `?id=`, `?source=`, or clear all |
Expand Down
4 changes: 4 additions & 0 deletions apps/debug-frontend/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
node_modules
dist
.vitest-attachments
src/**/__screenshots__
2 changes: 2 additions & 0 deletions apps/debug-frontend/.oxfmtignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
dist
src/routeTree.gen.ts
2 changes: 2 additions & 0 deletions apps/debug-frontend/.oxlintignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
dist
src/routeTree.gen.ts
34 changes: 34 additions & 0 deletions apps/debug-frontend/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
# @plannotator/debug-frontend

Debug/development harness UI for the Plannotator daemon runtime. **Not production code** — this is a
testbed for exercising daemon sessions, verifying event streams, and testing session lifecycle actions.

## Shape

- `src/routes` is only TanStack Router wiring.
- `src/daemon` owns the typed daemon API client and contracts.
- `src/sessions` owns session ids, session state, the dashboard, and mode dispatch.
- `src/plan`, `src/review`, `src/annotate`, `src/archive`, and `src/setup-goal` own product views.
- `src/testing` owns contract fixtures and browser helpers.

The shell talks to session APIs through `/s/:sessionId/api`, never root `/api`.

The build is intentionally single-file HTML for daemon serving. Separate static asset
routes are deferred until the full UI migration needs code splitting or cacheable chunks.

## Commands

```bash
bun run --cwd apps/debug-frontend dev
bun run --cwd apps/debug-frontend build
bun run --cwd apps/debug-frontend check
bun run --cwd apps/debug-frontend test:browser
```

Or from the repo root:

```bash
bun run dev:debug-frontend
bun run build:debug-frontend
bun run check:debug-frontend
```
12 changes: 12 additions & 0 deletions apps/debug-frontend/index.html
Original file line number Diff line number Diff line change
@@ -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>Plannotator</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
44 changes: 44 additions & 0 deletions apps/debug-frontend/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
{
"name": "@plannotator/debug-frontend",
"description": "Debug/development harness UI for the Plannotator daemon runtime. Not production code.",
"private": true,
"version": "0.0.1",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build && bun run scripts/verify-single-file-build.ts",
"typecheck": "tsc --noEmit -p tsconfig.json",
"lint": "oxlint .",
"lint:fix": "oxlint . --fix",
"fmt": "oxfmt --ignore-path .oxfmtignore --write .",
"fmt:check": "oxfmt --ignore-path .oxfmtignore --check .",
"test": "vitest run",
"test:browser": "vitest run --config vitest.browser.config.ts",
"check": "bun run typecheck && bun run lint && bun run fmt:check && bun run test"
},
"dependencies": {
"@plannotator/shared": "workspace:*",
"@plannotator/ui": "workspace:*",
"@tanstack/react-router": "^1.141.0",
"immer": "^10.2.0",
"react": "^19.2.3",
"react-dom": "^19.2.3",
"zustand": "^5.0.13"
},
"devDependencies": {
"@tailwindcss/vite": "^4.1.18",
"@tanstack/router-plugin": "^1.141.0",
"@types/node": "^22.14.0",
"@types/react": "^19.2.0",
"@types/react-dom": "^19.2.0",
"@vitejs/plugin-react": "^5.0.0",
"@vitest/browser-playwright": "^4.0.16",
"oxfmt": "^0.17.0",
"oxlint": "^1.31.0",
"tailwindcss": "^4.1.18",
"typescript": "~5.8.2",
"vite": "^6.2.0",
"vite-plugin-singlefile": "^2.0.3",
"vitest": "^4.0.16"
}
}
56 changes: 56 additions & 0 deletions apps/debug-frontend/scripts/verify-single-file-build.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { existsSync, readdirSync, readFileSync } from "fs";
import { join, relative } from "path";

const distDir = join(import.meta.dirname, "..", "dist");
const indexPath = join(distDir, "index.html");

function listFiles(dir: string): string[] {
if (!existsSync(dir)) return [];

const files: string[] = [];
for (const entry of readdirSync(dir, { withFileTypes: true })) {
const entryPath = join(dir, entry.name);
if (entry.isDirectory()) {
files.push(...listFiles(entryPath));
} else if (entry.isFile()) {
files.push(entryPath);
}
}
return files;
}

if (!existsSync(indexPath)) {
throw new Error("Expected apps/debug-frontend/dist/index.html to exist after build.");
}

const html = readFileSync(indexPath, "utf-8");

const outputFiles = listFiles(distDir)
.map((file) => relative(distDir, file))
.sort();
const extraFiles = outputFiles.filter((file) => file !== "index.html");

if (extraFiles.length > 0) {
throw new Error(
`Frontend daemon shell build must be single-file; found outputs: ${extraFiles.join(", ")}`,
);
}

const htmlWithoutInlineCode = html
.replace(/<script\b[^>]*>[\s\S]*?<\/script>/gi, "<script></script>")
.replace(/<style\b[^>]*>[\s\S]*?<\/style>/gi, "<style></style>");

const externalScriptPattern = /<script\b[^>]*\bsrc=["'][^"']+["']/i;
const externalLinkPatterns = [
/<link\b[^>]*\brel=["'](?:stylesheet|modulepreload|preload)["'][^>]*\bhref=["'][^"']+["']/i,
/<link\b[^>]*\bhref=["'][^"']+["'][^>]*\brel=["'](?:stylesheet|modulepreload|preload)["']/i,
];

if (
externalScriptPattern.test(html) ||
externalLinkPatterns.some((pattern) => pattern.test(htmlWithoutInlineCode))
) {
throw new Error("Frontend daemon shell build must inline scripts and styles.");
}

console.log("Verified single-file frontend shell build.");
21 changes: 21 additions & 0 deletions apps/debug-frontend/src/annotate/AnnotateSessionView.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { apiGroupsForMode, sharedApiGroups } from "../sessions/session-api-groups";
import { ApiGroupList } from "../shared/ui/ApiGroupList";
import { SessionFacts } from "../shared/ui/SessionFacts";
import type { SessionViewComponentProps } from "../sessions/session-view-registry";

export function AnnotateSessionView({ bootstrap }: SessionViewComponentProps) {
return (
<section className="session-panel" aria-label="Annotate session">
<header>
<p className="eyebrow">Annotate</p>
<h2>{bootstrap.session.label}</h2>
<p>
Skeleton for markdown, folder, last-message, raw HTML, URL annotation, review-gate
approval, linked docs, image attachments, drafts, and external annotations.
</p>
</header>
<SessionFacts bootstrap={bootstrap} />
<ApiGroupList groups={[...apiGroupsForMode("annotate"), ...sharedApiGroups()]} />
</section>
);
}
22 changes: 22 additions & 0 deletions apps/debug-frontend/src/app/layout/ShellLayout.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { Link, Outlet } from "@tanstack/react-router";

export function ShellLayout() {
return (
<div className="app-shell">
<header className="app-header">
<div>
<p className="eyebrow">Local runtime shell</p>
<h1>Plannotator</h1>
</div>
<nav aria-label="Primary">
<Link to="/" activeProps={{ "aria-current": "page" }}>
Sessions
</Link>
</nav>
</header>
<main className="app-main">
<Outlet />
</main>
</div>
);
}
23 changes: 23 additions & 0 deletions apps/debug-frontend/src/app/router.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { createRouter } from "@tanstack/react-router";
import { createDaemonApiClient, type DaemonApiClient } from "../daemon/api/client";
import { routeTree } from "../routeTree.gen";

export interface AppRouterContext {
daemonClient: DaemonApiClient;
}

export function createAppRouter(
context: AppRouterContext = { daemonClient: createDaemonApiClient() },
) {
return createRouter({
routeTree,
context,
defaultPreload: "intent",
});
}

declare module "@tanstack/react-router" {
interface Register {
router: ReturnType<typeof createAppRouter>;
}
}
Loading