Skip to content

Commit f6e1a36

Browse files
committed
Add long-running Plannotator daemon runtime
Single daemon process per machine manages session lifecycle, serves browser UIs at /s/<sessionId>, and exposes control APIs. CLI commands auto-start the daemon and create sessions via HTTP. Includes session store, auth tokens, lock files, event broadcasting, and goal-setup daemon integration.
1 parent 36ff9f5 commit f6e1a36

81 files changed

Lines changed: 5855 additions & 2337 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/workflows/release.yml

Lines changed: 23 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -170,22 +170,29 @@ jobs:
170170
local ok=0
171171
172172
for _ in $(seq 1 60); do
173-
if curl -sf "http://127.0.0.1:${port}${endpoint}" -o /dev/null 2>/dev/null; then
174-
ok=1
175-
break
173+
local sessions
174+
sessions="$(curl -sf "http://127.0.0.1:${port}/daemon/sessions" 2>/dev/null || true)"
175+
if [ -n "$sessions" ]; then
176+
local session_url
177+
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")"
178+
if [ -n "$session_url" ] && curl -sf "${session_url}${endpoint}" -o /dev/null 2>/dev/null; then
179+
ok=1
180+
break
181+
fi
176182
fi
177183
sleep 0.5
178184
done
179185
180186
kill "$pid" 2>/dev/null || true
181187
wait "$pid" 2>/dev/null || true
188+
PLANNOTATOR_PORT="$port" "$BINARY" daemon stop >/dev/null 2>&1 || true
182189
183190
if [ "$ok" = "0" ]; then
184-
echo "FAIL: ${label} did not respond on :${port}${endpoint}"
191+
echo "FAIL: ${label} did not expose a daemon-scoped ${endpoint}"
185192
exit 1
186193
fi
187194
188-
echo "OK: ${label} responded on :${port}${endpoint}"
195+
echo "OK: ${label} exposed daemon-scoped ${endpoint}"
189196
}
190197
191198
# 2. review: exercises server startup, bundled HTML, git diff, and HTTP.
@@ -232,9 +239,14 @@ jobs:
232239
try {
233240
for ($i = 0; $i -lt 60; $i++) {
234241
try {
235-
Invoke-WebRequest -Uri "http://127.0.0.1:$Port$Endpoint" -UseBasicParsing -TimeoutSec 1 | Out-Null
236-
$ok = $true
237-
break
242+
$sessionsResponse = Invoke-WebRequest -Uri "http://127.0.0.1:$Port/daemon/sessions" -UseBasicParsing -TimeoutSec 1
243+
$sessionsBody = $sessionsResponse.Content | ConvertFrom-Json
244+
if ($sessionsBody.sessions.Count -gt 0) {
245+
$sessionUrl = $sessionsBody.sessions[0].url
246+
Invoke-WebRequest -Uri "$sessionUrl$Endpoint" -UseBasicParsing -TimeoutSec 1 | Out-Null
247+
$ok = $true
248+
break
249+
}
238250
} catch {
239251
if ($process.HasExited) {
240252
break
@@ -247,6 +259,7 @@ jobs:
247259
Stop-Process -Id $process.Id -Force
248260
Wait-Process -Id $process.Id -ErrorAction SilentlyContinue
249261
}
262+
& $binary daemon stop *> $null
250263
Remove-Item Env:\PLANNOTATOR_PORT -ErrorAction SilentlyContinue
251264
}
252265
@@ -255,10 +268,10 @@ jobs:
255268
Get-Content $stdout -ErrorAction SilentlyContinue
256269
Write-Host "stderr:"
257270
Get-Content $stderr -ErrorAction SilentlyContinue
258-
throw "FAIL: $Label did not respond on :$Port$Endpoint"
271+
throw "FAIL: $Label did not expose a daemon-scoped $Endpoint"
259272
}
260273
261-
Write-Host "OK: $Label responded on :$Port$Endpoint"
274+
Write-Host "OK: $Label exposed daemon-scoped $Endpoint"
262275
}
263276
264277
# 2. review: exercises server startup, bundled HTML, git diff, and HTTP.

.gitignore

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,4 @@ opencode.json
5252
plannotator-local
5353
# Local research/reference docs (not for repo)
5454
/reference/
55-
# Local goal setup packages generated by the setup-goal skill.
56-
/goals/
5755
*.bun-build

AGENTS.md

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ plannotator/
4444
│ │ ├── index.ts # startPlannotatorServer(), handleServerReady()
4545
│ │ ├── review.ts # startReviewServer(), handleReviewServerReady()
4646
│ │ ├── annotate.ts # startAnnotateServer(), handleAnnotateServerReady()
47+
│ │ ├── daemon/ # Long-running daemon runtime, state, client, and session store
4748
│ │ ├── storage.ts # Re-exports from @plannotator/shared/storage
4849
│ │ ├── share-url.ts # Server-side share URL generation for remote sessions
4950
│ │ ├── remote.ts # isRemoteSession(), getServerPort()
@@ -99,6 +100,8 @@ Plannotator has one server implementation:
99100

100101
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/`.
101102

103+
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.
104+
102105
## Installation
103106

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

217220
## Server API
218221

222+
### Daemon Runtime (`packages/server/daemon/`)
223+
224+
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.
225+
226+
| Endpoint | Method | Purpose |
227+
| --------------------- | ------ | ------------------------------------------ |
228+
| `/daemon/capabilities` | GET | Return daemon protocol/capability metadata |
229+
| `/daemon/status` | GET | Return daemon process, endpoint, and session counts |
230+
| `/daemon/sessions` | GET | List active sessions (`?clean=1` also reaps expired sessions before listing) |
231+
| `/daemon/sessions` | POST | Create a plan/review/annotate/archive session from a plugin-protocol request |
232+
| `/daemon/sessions/:id` | GET | Fetch a session summary |
233+
| `/daemon/sessions/:id/result` | GET | Wait for a session decision/result |
234+
| `/daemon/sessions/:id/cancel` | POST | Cancel a session and dispose its resources |
235+
| `/daemon/sessions/:id` | DELETE | Delete a session record |
236+
| `/daemon/shutdown` | POST | Ask the daemon to stop |
237+
| `/s/:id` | GET | Serve the browser HTML for a session |
238+
| `/s/:id/api/...` | Any | Route browser API requests to that session's plan/review/annotate handler |
239+
219240
### Plan Server (`packages/server/index.ts`)
220241

221242
| Endpoint | Method | Purpose |

apps/hook/README.md

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ curl -fsSL https://plannotator.ai/install.cmd -o install.cmd && install.cmd && d
2323

2424
Released binaries ship with SHA256 sidecars and [SLSA build provenance](https://slsa.dev/) attestations from v0.17.2 onwards. See the [installation docs](https://plannotator.ai/docs/getting-started/installation/#verifying-your-install) for version pinning and verification commands.
2525

26-
The released binary owns Plannotator's browser server runtime for Claude Code, OpenCode, and Pi. See [Single Binary Runtime](../../docs/single-binary-runtime.md) for the plugin client boundary and daemon-next design.
26+
The released binary owns Plannotator's browser server runtime for Claude Code, OpenCode, and Pi. See [Single Binary Runtime](../../docs/single-binary-runtime.md) for the plugin client boundary and daemon runtime design.
2727

2828
---
2929

@@ -84,6 +84,19 @@ When Claude Code calls `ExitPlanMode`, this hook intercepts and:
8484
| `PLANNOTATOR_BROWSER` | Custom browser to open plans in. macOS: app name or path. Linux/Windows: executable path. |
8585
| `PLANNOTATOR_SHARE_URL` | Custom share portal URL for self-hosting. Default: `https://share.plannotator.ai`. |
8686

87+
## Daemon Runtime
88+
89+
Plan, review, annotate, and archive sessions are created through one long-running `plannotator` daemon. Normal commands auto-start a compatible daemon when needed.
90+
91+
```bash
92+
plannotator daemon status
93+
plannotator daemon stop
94+
plannotator daemon start
95+
plannotator sessions
96+
```
97+
98+
`daemon status` reports the daemon PID, endpoint, protocol version, and active session count. If the running daemon was started with different remote/port settings, stop it and retry with the desired `PLANNOTATOR_REMOTE` / `PLANNOTATOR_PORT` values.
99+
87100
## Remote / Devcontainer Usage
88101

89102
When running Claude Code in a remote environment (SSH, devcontainer, WSL), set `PLANNOTATOR_REMOTE=1` (or `true`) and these environment variables:

apps/hook/dev-mock-api.ts

Lines changed: 0 additions & 159 deletions
Original file line numberDiff line numberDiff line change
@@ -552,9 +552,6 @@ This change lands in section 3 of the contributor guide alongside the updated re
552552
const USE_DIFF_DEMO =
553553
process.env.VITE_DIFF_DEMO === "1" ||
554554
process.env.VITE_DIFF_DEMO === "true";
555-
const GOAL_SETUP_DEMO = process.env.VITE_GOAL_SETUP_DEMO;
556-
const USE_GOAL_SETUP_DEMO =
557-
GOAL_SETUP_DEMO === "interview" || GOAL_SETUP_DEMO === "facts";
558555

559556
const PLAN_V1 = USE_DIFF_DEMO ? PLAN_V1_DIFF_TEST : PLAN_V1_DEFAULT;
560557
const PLAN_V2 = USE_DIFF_DEMO ? PLAN_V2_DIFF_TEST : PLAN_V2_DEFAULT;
@@ -629,153 +626,6 @@ export function devMockApi(): Plugin {
629626

630627
if (req.url === '/api/plan') {
631628
res.setHeader('Content-Type', 'application/json');
632-
if (USE_GOAL_SETUP_DEMO) {
633-
res.end(JSON.stringify({
634-
plan: '',
635-
origin: 'claude-code',
636-
mode: 'goal-setup',
637-
sharingEnabled: false,
638-
goalSetup: GOAL_SETUP_DEMO === "facts" ? {
639-
stage: "facts",
640-
title: "Interactive goal setup facts",
641-
goalSlug: "interactive-goal-setup-ui",
642-
facts: [
643-
{
644-
id: "skill-batch",
645-
text: "The setup-goal skill should package all interview questions into one Plannotator UI session.",
646-
accepted: false,
647-
removed: false,
648-
recommendedAutomatedVerification: true,
649-
automatedVerification: true,
650-
},
651-
{
652-
id: "facts-verify",
653-
text: "Each fact can be accepted, edited, removed, commented on, and marked for automated verification.",
654-
accepted: false,
655-
removed: false,
656-
recommendedAutomatedVerification: true,
657-
automatedVerification: true,
658-
},
659-
{
660-
id: "header-submit",
661-
text: "Goal setup submission should use the Plannotator app header action area instead of local form buttons.",
662-
accepted: false,
663-
removed: false,
664-
recommendedAutomatedVerification: false,
665-
automatedVerification: false,
666-
},
667-
{
668-
id: "question-modes",
669-
text: "The interview UI should cover text answers, single-select choices, multi-select choices, and custom option entry.",
670-
accepted: false,
671-
removed: false,
672-
recommendedAutomatedVerification: true,
673-
automatedVerification: true,
674-
},
675-
{
676-
id: "previous",
677-
text: "Previously accepted facts remain visible in the facts review with their accepted state preserved.",
678-
accepted: true,
679-
removed: false,
680-
recommendedAutomatedVerification: false,
681-
automatedVerification: false,
682-
},
683-
{
684-
id: "bulk-accept",
685-
text: "The facts UI provides a single action to accept every visible fact while keeping the review open for final edits.",
686-
accepted: false,
687-
removed: false,
688-
recommendedAutomatedVerification: true,
689-
automatedVerification: true,
690-
},
691-
{
692-
id: "copy-export",
693-
text: "The interview and facts UIs can copy the current state as raw JSON or markdown for provenance and debugging.",
694-
accepted: false,
695-
removed: false,
696-
recommendedAutomatedVerification: false,
697-
automatedVerification: false,
698-
},
699-
],
700-
} : {
701-
stage: "interview",
702-
title: "Interactive goal setup interview",
703-
goalSlug: "interactive-goal-setup-ui",
704-
questions: [
705-
{
706-
id: "objective",
707-
prompt: "What is the primary outcome of this goal?",
708-
description: "One sentence that captures what 'done' looks like.",
709-
answerMode: "text",
710-
recommendedAnswer: "A bundled goal setup UI where agents launch one browser session for interview Q&A and a second for facts acceptance, replacing multi-turn chat prompting.",
711-
},
712-
{
713-
id: "audience",
714-
prompt: "Which inferred audience assumption should change?",
715-
description: "The agent should not need basic confirmation here; only change this if the default is wrong.",
716-
answerMode: "single",
717-
recommendedAnswer: "Developers using Claude Code with Plannotator installed.",
718-
recommendedOptionIds: ["devs-cc"],
719-
options: [
720-
{ id: "devs-cc", label: "Developers on Claude Code" },
721-
{ id: "devs-oc", label: "Developers on OpenCode" },
722-
{ id: "devs-all", label: "All Plannotator users" },
723-
],
724-
},
725-
{
726-
id: "scope",
727-
prompt: "Which inferred scope items should stay or be added?",
728-
description: "Recommended items are based on the code paths the agent can infer. Add only missing nuance.",
729-
answerMode: "multi-custom",
730-
recommendedAnswer: "Skill text, interactive UI, server endpoints, and tests.",
731-
recommendedOptionIds: ["skill", "ui", "server", "tests"],
732-
options: [
733-
{ id: "skill", label: "Skill text" },
734-
{ id: "ui", label: "Interactive UI" },
735-
{ id: "server", label: "Server endpoints" },
736-
{ id: "tests", label: "Tests and fixtures" },
737-
],
738-
},
739-
{
740-
id: "launch",
741-
prompt: "What rollout constraint should override the default?",
742-
description: "Default is the smallest useful launch; choose a broader option only if runtime parity matters immediately.",
743-
answerMode: "single",
744-
recommendedOptionIds: ["claude-only"],
745-
options: [
746-
{ id: "claude-only", label: "Claude Code only" },
747-
{ id: "all-runtimes", label: "All runtimes (Claude Code, OpenCode, Pi)" },
748-
{ id: "prototype", label: "Prototype behind a dev flag" },
749-
],
750-
},
751-
{
752-
id: "risk",
753-
prompt: "Which risks should the plan explicitly address?",
754-
answerMode: "multi",
755-
recommendedOptionIds: ["runtime-parity", "data-loss"],
756-
options: [
757-
{ id: "runtime-parity", label: "Runtime parity", description: "Bun and Pi server endpoints stay mirrored." },
758-
{ id: "data-loss", label: "Answer data loss", description: "Edited answers survive until submission." },
759-
{ id: "header-actions", label: "Header action placement", description: "Submit/close matches existing patterns." },
760-
],
761-
},
762-
{
763-
id: "facts-ux",
764-
prompt: "How should fact review work?",
765-
answerMode: "text",
766-
recommendedAnswer: "Vertical list with per-fact accept, edit, remove, comment, and automated-verification toggle. Accepted facts hidden by default on re-review.",
767-
},
768-
{
769-
id: "out-of-scope",
770-
prompt: "Anything explicitly out of scope?",
771-
answerMode: "custom",
772-
required: false,
773-
},
774-
],
775-
},
776-
}));
777-
return;
778-
}
779629
res.end(JSON.stringify({
780630
plan: undefined, // Editor uses its own DIFF_DEMO_PLAN_CONTENT
781631
origin: 'claude-code',
@@ -786,15 +636,6 @@ export function devMockApi(): Plugin {
786636
return;
787637
}
788638

789-
if (req.url === '/api/goal-setup/submit' && req.method === 'POST') {
790-
req.on('data', () => {});
791-
req.on('end', () => {
792-
res.setHeader('Content-Type', 'application/json');
793-
res.end(JSON.stringify({ ok: true }));
794-
});
795-
return;
796-
}
797-
798639
if (req.url === '/api/plan/versions') {
799640
res.setHeader('Content-Type', 'application/json');
800641
res.end(JSON.stringify({

apps/hook/server/cli.test.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,8 @@ describe("CLI top-level help", () => {
2323
expect(output).toContain("plannotator [--browser <name>]");
2424
expect(output).toContain("plannotator review [--git] [PR_URL]");
2525
expect(output).toContain("plannotator annotate <file.md | file.html | https://... | folder/>");
26-
expect(output).toContain("plannotator setup-goal <interview|facts>");
26+
expect(output).toContain("plannotator daemon start|status|stop");
27+
expect(output).toContain("plannotator plugin capabilities");
2728
expect(output).toContain("running 'plannotator' without arguments is for hook integration");
2829
});
2930
});
@@ -56,8 +57,9 @@ describe("interactive no-arg invocation", () => {
5657
expect(output).toContain("usually launched automatically by Claude Code hooks");
5758
expect(output).toContain("It expects hook JSON on stdin.");
5859
expect(output).toContain("plannotator review");
59-
expect(output).toContain("plannotator setup-goal interview bundle.json --json");
6060
expect(output).toContain("plannotator sessions");
61+
expect(output).toContain("plannotator daemon status");
62+
expect(output).toContain("plannotator plugin capabilities");
6163
expect(output).toContain("Run 'plannotator --help' for top-level usage.");
6264
});
6365
});

apps/hook/server/cli.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,10 +27,11 @@ export function formatTopLevelHelp(): string {
2727
" plannotator [--browser <name>]",
2828
" plannotator review [--git] [PR_URL]",
2929
" plannotator annotate <file.md | file.html | https://... | folder/> [--no-jina] [--gate] [--json] [--hook]",
30-
" plannotator setup-goal <interview|facts> <bundle.json | -> [--json]",
3130
" plannotator last",
3231
" plannotator archive",
32+
" plannotator setup-goal <interview|facts> <bundle.json | -> [--json]",
3333
" plannotator sessions",
34+
" plannotator daemon start|status|stop",
3435
" plannotator improve-context",
3536
" plannotator plugin capabilities",
3637
"",
@@ -47,10 +48,10 @@ export function formatInteractiveNoArgClarification(): string {
4748
"For interactive use, try:",
4849
" plannotator review",
4950
" plannotator annotate <file.md | file.html | https://...>",
50-
" plannotator setup-goal interview bundle.json --json",
5151
" plannotator last",
5252
" plannotator archive",
5353
" plannotator sessions",
54+
" plannotator daemon status",
5455
" plannotator plugin capabilities",
5556
"",
5657
"Run 'plannotator --help' for top-level usage.",

0 commit comments

Comments
 (0)