Skip to content

Commit aa5cd37

Browse files
committed
Fix daemon routing and session lifecycle
1 parent 559e056 commit aa5cd37

11 files changed

Lines changed: 137 additions & 68 deletions

File tree

AGENTS.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,8 @@ Plannotator has one server implementation:
9696

9797
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/`.
9898

99+
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.
100+
99101
## Installation
100102

101103
**Via plugin marketplace** (when repo is public):

apps/hook/server/index.ts

Lines changed: 21 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -383,17 +383,20 @@ function getPluginOrigin(request: Partial<PluginBaseRequest>): PluginClientOrigi
383383
return origin;
384384
}
385385

386+
function getInvocationCwd(): string {
387+
return process.env.PLANNOTATOR_CWD || process.cwd();
388+
}
389+
386390
function resolvePluginCwd(request: Partial<PluginBaseRequest>): string {
387-
if (!request.cwd) return process.cwd();
388-
const cwd = path.resolve(request.cwd);
391+
const cwd = path.resolve(request.cwd || getInvocationCwd());
389392
try {
390393
if (!statSync(cwd).isDirectory()) {
391-
emitPluginError("invalid-cwd", `Invalid cwd: ${request.cwd}`);
394+
emitPluginError("invalid-cwd", `Invalid cwd: ${request.cwd || cwd}`);
392395
}
393396
} catch (err) {
394397
emitPluginError(
395398
"invalid-cwd",
396-
err instanceof Error ? err.message : `Invalid cwd: ${request.cwd}`,
399+
err instanceof Error ? err.message : `Invalid cwd: ${request.cwd || cwd}`,
397400
);
398401
}
399402
return cwd;
@@ -420,7 +423,7 @@ async function ensureDaemonClient(options: { pluginError?: boolean } = {}) {
420423

421424
const command = getDaemonStartCommand();
422425
const child = Bun.spawn(command, {
423-
cwd: process.cwd(),
426+
cwd: getInvocationCwd(),
424427
stdin: "ignore",
425428
stdout: "ignore",
426429
stderr: "ignore",
@@ -476,7 +479,10 @@ async function runDaemonSessionRequest(request: PluginRequest, options: { plugin
476479
fail(completed.error.code, completed.error.message);
477480
}
478481
if (completed.session.status !== "completed") {
479-
fail(completed.session.status, completed.session.status);
482+
fail(
483+
completed.session.status,
484+
completed.session.error ?? `Plannotator session ${completed.session.id} ended with status ${completed.session.status}.`,
485+
);
480486
}
481487

482488
return {
@@ -640,7 +646,7 @@ if (args[0] === "sessions") {
640646
const outcome = await runDaemonSessionRequest({
641647
action: "review",
642648
origin: detectedOrigin,
643-
cwd: process.cwd(),
649+
cwd: getInvocationCwd(),
644650
args: args.slice(1).join(" "),
645651
sharingEnabled,
646652
shareBaseUrl,
@@ -673,7 +679,7 @@ if (args[0] === "sessions") {
673679
const outcome = await runDaemonSessionRequest({
674680
action: "annotate",
675681
origin: detectedOrigin,
676-
cwd: process.cwd(),
682+
cwd: getInvocationCwd(),
677683
args: rawFilePath,
678684
noJina: cliNoJina,
679685
gate: gateFlag,
@@ -690,7 +696,7 @@ if (args[0] === "sessions") {
690696
// ANNOTATE LAST MESSAGE MODE
691697
// ============================================
692698

693-
const projectRoot = process.env.PLANNOTATOR_CWD || process.cwd();
699+
const projectRoot = getInvocationCwd();
694700
const codexThreadId = process.env.CODEX_THREAD_ID;
695701
const isCodex = !!codexThreadId;
696702

@@ -773,7 +779,7 @@ if (args[0] === "sessions") {
773779
const outcome = await runDaemonSessionRequest({
774780
action: "annotate-last",
775781
origin: detectedOrigin,
776-
cwd: process.cwd(),
782+
cwd: projectRoot,
777783
markdown: lastMessage.text,
778784
filePath: "last-message",
779785
mode: "annotate-last",
@@ -794,7 +800,7 @@ if (args[0] === "sessions") {
794800
await runDaemonSessionRequest({
795801
action: "archive",
796802
origin: detectedOrigin,
797-
cwd: process.cwd(),
803+
cwd: getInvocationCwd(),
798804
sharingEnabled,
799805
shareBaseUrl,
800806
pasteApiUrl,
@@ -836,7 +842,7 @@ if (args[0] === "sessions") {
836842
const outcome = await runDaemonSessionRequest({
837843
action: "plan",
838844
origin: "copilot-cli",
839-
cwd: event.cwd || process.cwd(),
845+
cwd: event.cwd || getInvocationCwd(),
840846
plan: planContent,
841847
sharingEnabled,
842848
shareBaseUrl,
@@ -868,7 +874,7 @@ if (args[0] === "sessions") {
868874
// COPILOT CLI ANNOTATE LAST MESSAGE MODE
869875
// ============================================
870876

871-
const projectRoot = process.env.PLANNOTATOR_CWD || process.cwd();
877+
const projectRoot = getInvocationCwd();
872878

873879
if (process.env.PLANNOTATOR_DEBUG) {
874880
console.error(`[DEBUG] Copilot CLI detected, finding session for CWD: ${projectRoot}`);
@@ -984,7 +990,7 @@ if (args[0] === "sessions") {
984990
const outcome = await runDaemonSessionRequest({
985991
action: "plan",
986992
origin: "codex",
987-
cwd: process.cwd(),
993+
cwd: getInvocationCwd(),
988994
plan: latestPlan.text,
989995
sharingEnabled,
990996
shareBaseUrl,
@@ -1040,7 +1046,7 @@ if (args[0] === "sessions") {
10401046
const outcome = await runDaemonSessionRequest({
10411047
action: "plan",
10421048
origin: isGemini ? "gemini-cli" : detectedOrigin,
1043-
cwd: process.cwd(),
1049+
cwd: getInvocationCwd(),
10441050
plan: planContent,
10451051
permissionMode,
10461052
sharingEnabled,

packages/server/daemon/client.ts

Lines changed: 40 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -86,11 +86,50 @@ function stateBaseUrl(state: unknown): string | undefined {
8686
return typeof baseUrl === "string" ? baseUrl : undefined;
8787
}
8888

89+
function statePid(state: unknown): number | undefined {
90+
const pid = (state as { pid?: unknown } | null)?.pid;
91+
return typeof pid === "number" && Number.isInteger(pid) && pid > 0 ? pid : undefined;
92+
}
93+
94+
function isProcessAlive(pid: number, options: DaemonClientOptions): boolean {
95+
const isAlive = options.isAlive ?? ((targetPid: number) => {
96+
try {
97+
process.kill(targetPid, 0);
98+
return true;
99+
} catch {
100+
return false;
101+
}
102+
});
103+
return isAlive(pid);
104+
}
105+
106+
async function waitForProcessExit(pid: number, options: DaemonClientOptions): Promise<void> {
107+
for (let attempt = 0; attempt < 10; attempt++) {
108+
if (!isProcessAlive(pid, options)) return;
109+
await new Promise((resolve) => setTimeout(resolve, 50));
110+
}
111+
}
112+
89113
export async function cleanupDaemonState(state: unknown, options: DaemonClientOptions = {}): Promise<void> {
90114
const fetchImpl = options.fetch ?? fetch;
91115
const baseUrl = stateBaseUrl(state);
116+
let shutdownOk = false;
92117
if (baseUrl) {
93-
await fetchImpl(`${baseUrl}/daemon/shutdown`, { method: "POST" }).catch(() => undefined);
118+
try {
119+
const response = await fetchImpl(`${baseUrl}/daemon/shutdown`, { method: "POST" });
120+
shutdownOk = response.ok;
121+
} catch {
122+
shutdownOk = false;
123+
}
124+
}
125+
const pid = statePid(state);
126+
if (baseUrl && !shutdownOk && pid && isProcessAlive(pid, options)) {
127+
try {
128+
process.kill(pid, "SIGTERM");
129+
await waitForProcessExit(pid, options);
130+
} catch {
131+
// Best effort; removing the stale state lets the caller start or report the real port error.
132+
}
94133
}
95134
removeDaemonFiles(options);
96135
}

packages/server/daemon/server.test.ts

Lines changed: 27 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ function makeHandler() {
2323
url: "http://127.0.0.1:4321/s/s1",
2424
project: "repo",
2525
label: "plan-repo",
26-
htmlContent: "<html><head></head><body>Plan</body></html>",
26+
htmlContent: "<html><script>const literal='</head>';</script><head></head><body>Plan</body></html>",
2727
handleRequest: (_req, url) => Response.json({ path: url.pathname }),
2828
}),
2929
});
@@ -44,6 +44,7 @@ describe("daemon HTTP router", () => {
4444
const { handler } = makeHandler();
4545
await handler(new Request("http://127.0.0.1:4321/daemon/sessions", {
4646
method: "POST",
47+
headers: { "content-type": "application/json" },
4748
body: JSON.stringify({ request: { action: "plan", origin: "opencode", plan: "x" } }),
4849
}));
4950
const res = await handler(new Request("http://127.0.0.1:4321/daemon/status"));
@@ -58,6 +59,7 @@ describe("daemon HTTP router", () => {
5859
const { handler } = makeHandler();
5960
const create = await handler(new Request("http://127.0.0.1:4321/daemon/sessions", {
6061
method: "POST",
62+
headers: { "content-type": "application/json" },
6163
body: JSON.stringify({ request: { action: "plan", origin: "opencode", plan: "x" } }),
6264
}));
6365
expect(create.status).toBe(201);
@@ -74,6 +76,7 @@ describe("daemon HTTP router", () => {
7476
const { handler } = makeHandler();
7577
await handler(new Request("http://127.0.0.1:4321/daemon/sessions", {
7678
method: "POST",
79+
headers: { "content-type": "application/json" },
7780
body: JSON.stringify({ request: { action: "plan", origin: "opencode", plan: "x" } }),
7881
}));
7982
const res = await handler(new Request("http://127.0.0.1:4321/s/s1"));
@@ -82,12 +85,15 @@ describe("daemon HTTP router", () => {
8285
expect(html).toContain("apiBase=\"/s/s1/api\"");
8386
expect(html).toContain("window.fetch");
8487
expect(html).toContain("window.EventSource");
88+
expect(html.indexOf("window.__PLANNOTATOR_API_BASE__")).toBeGreaterThan(html.indexOf("const literal"));
89+
expect(html.indexOf("window.__PLANNOTATOR_API_BASE__")).toBeLessThan(html.indexOf("<body>"));
8590
});
8691

8792
test("routes session-scoped API paths to the owning session", async () => {
8893
const { handler } = makeHandler();
8994
await handler(new Request("http://127.0.0.1:4321/daemon/sessions", {
9095
method: "POST",
96+
headers: { "content-type": "application/json" },
9197
body: JSON.stringify({ request: { action: "plan", origin: "opencode", plan: "x" } }),
9298
}));
9399
const res = await handler(new Request("http://127.0.0.1:4321/s/s1/api/plan"));
@@ -100,6 +106,7 @@ describe("daemon HTTP router", () => {
100106
let timeoutDisabled = 0;
101107
await handler(new Request("http://127.0.0.1:4321/daemon/sessions", {
102108
method: "POST",
109+
headers: { "content-type": "application/json" },
103110
body: JSON.stringify({ request: { action: "plan", origin: "opencode", plan: "x" } }),
104111
}));
105112
const record = store.get("s1");
@@ -118,23 +125,36 @@ describe("daemon HTTP router", () => {
118125
expect(timeoutDisabled).toBe(1);
119126
});
120127

121-
test("routes legacy root API paths by session referer during UI migration", async () => {
128+
test("does not route root API paths by spoofable referer", async () => {
122129
const { handler } = makeHandler();
123130
await handler(new Request("http://127.0.0.1:4321/daemon/sessions", {
124131
method: "POST",
132+
headers: { "content-type": "application/json" },
125133
body: JSON.stringify({ request: { action: "plan", origin: "opencode", plan: "x" } }),
126134
}));
127135
const res = await handler(new Request("http://127.0.0.1:4321/api/plan", {
128136
headers: { referer: "http://127.0.0.1:4321/s/s1" },
129137
}));
138+
expect(res.status).toBe(404);
139+
});
140+
141+
test("rejects non-JSON session creation requests", async () => {
142+
const { handler } = makeHandler();
143+
const res = await handler(new Request("http://127.0.0.1:4321/daemon/sessions", {
144+
method: "POST",
145+
headers: { "content-type": "text/plain" },
146+
body: JSON.stringify({ request: { action: "plan", origin: "opencode", plan: "x" } }),
147+
}));
130148
const body = await res.json();
131-
expect(body.path).toBe("/api/plan");
149+
expect(res.status).toBe(415);
150+
expect(body.error.code).toBe("invalid-request");
132151
});
133152

134153
test("cancels sessions and returns result status", async () => {
135154
const { handler, store } = makeHandler();
136155
await handler(new Request("http://127.0.0.1:4321/daemon/sessions", {
137156
method: "POST",
157+
headers: { "content-type": "application/json" },
138158
body: JSON.stringify({ request: { action: "plan", origin: "opencode", plan: "x" } }),
139159
}));
140160
const cancel = await handler(new Request("http://127.0.0.1:4321/daemon/sessions/s1/cancel", {
@@ -143,7 +163,9 @@ describe("daemon HTTP router", () => {
143163
expect((await cancel.json()).session.status).toBe("cancelled");
144164

145165
const result = await handler(new Request("http://127.0.0.1:4321/daemon/sessions/s1/result"));
146-
expect((await result.json()).session.status).toBe("cancelled");
147-
expect(store.get("s1")).toBeUndefined();
166+
const body = await result.json();
167+
expect(body.session.status).toBe("cancelled");
168+
expect(body.session.error).toBe("Session cancelled.");
169+
expect(store.get("s1")).toBeDefined();
148170
});
149171
});

packages/server/daemon/server.ts

Lines changed: 20 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ import type { DaemonState } from "./state";
99
import { DaemonSessionStore, type DaemonSessionRecord } from "./session-store";
1010
import type { SessionRequestContext } from "../session-handler";
1111

12+
const RESULT_DELETE_GRACE_MS = 2_000;
13+
1214
export interface DaemonServerOptions {
1315
state: DaemonState;
1416
store?: DaemonSessionStore;
@@ -44,15 +46,16 @@ function sessionFromPath(pathname: string): { id: string; rest: string } | null
4446
};
4547
}
4648

47-
function sessionIdFromReferer(req: Request): string | null {
48-
const referer = req.headers.get("referer");
49-
if (!referer) return null;
50-
try {
51-
const url = new URL(referer);
52-
return sessionFromPath(url.pathname)?.id ?? null;
53-
} catch {
54-
return null;
55-
}
49+
function isJsonRequest(req: Request): boolean {
50+
const contentType = req.headers.get("content-type") ?? "";
51+
return contentType.split(";")[0].trim().toLowerCase() === "application/json";
52+
}
53+
54+
function injectApiBase(html: string, apiBaseScript: string): string {
55+
const marker = "</head>";
56+
const index = html.lastIndexOf(marker);
57+
if (index === -1) return `${apiBaseScript}${html}`;
58+
return `${html.slice(0, index)}${apiBaseScript}${html.slice(index)}`;
5659
}
5760

5861
export function createDaemonFetchHandler(options: DaemonServerOptions) {
@@ -66,8 +69,7 @@ export function createDaemonFetchHandler(options: DaemonServerOptions) {
6669

6770
const context: DaemonFetchContext = { endpoint, store };
6871

69-
return Object.assign(
70-
async function daemonFetch(req: Request, requestContext?: SessionRequestContext): Promise<Response> {
72+
return async function daemonFetch(req: Request, requestContext?: SessionRequestContext): Promise<Response> {
7173
const url = new URL(req.url);
7274

7375
if (url.pathname === "/daemon/capabilities" && req.method === "GET") {
@@ -94,6 +96,9 @@ export function createDaemonFetchHandler(options: DaemonServerOptions) {
9496
}
9597

9698
if (url.pathname === "/daemon/sessions" && req.method === "POST") {
99+
if (!isJsonRequest(req)) {
100+
return json(createDaemonErrorResponse("invalid-request", "Daemon session requests must use application/json."), { status: 415 });
101+
}
97102
let body: DaemonCreateSessionRequest;
98103
try {
99104
body = await req.json() as DaemonCreateSessionRequest;
@@ -127,7 +132,8 @@ export function createDaemonFetchHandler(options: DaemonServerOptions) {
127132
if (action === "result" && req.method === "GET") {
128133
const completed = await store.waitForResult(id);
129134
const response = json({ ok: true, session: store.summary(completed), result: completed.result ?? null });
130-
void store.delete(id);
135+
const timer = setTimeout(() => void store.delete(id), RESULT_DELETE_GRACE_MS);
136+
timer.unref?.();
131137
return response;
132138
}
133139

@@ -147,20 +153,6 @@ export function createDaemonFetchHandler(options: DaemonServerOptions) {
147153
return json({ ok: true, shuttingDown: true });
148154
}
149155

150-
if (url.pathname === "/api" || url.pathname.startsWith("/api/")) {
151-
const id = sessionIdFromReferer(req);
152-
if (id) {
153-
const record = store.get(id);
154-
if (!record) {
155-
return new Response("Session not found", { status: 404 });
156-
}
157-
if (!record.handleRequest) {
158-
return new Response("Session has no API handler", { status: 404 });
159-
}
160-
return record.handleRequest(req, url, requestContext);
161-
}
162-
}
163-
164156
const browserSession = sessionFromPath(url.pathname);
165157
if (browserSession) {
166158
const record = store.get(browserSession.id);
@@ -177,14 +169,12 @@ export function createDaemonFetchHandler(options: DaemonServerOptions) {
177169
if (record.htmlContent) {
178170
const apiBase = `/s/${record.id}/api`;
179171
const apiBaseScript = `<script>(()=>{const apiBase=${JSON.stringify(apiBase)};window.__PLANNOTATOR_API_BASE__=apiBase;const rewrite=(input)=>{if(typeof input==="string"&&input.startsWith("/api/"))return apiBase+input.slice(4);if(input instanceof URL&&input.pathname.startsWith("/api/")){const next=new URL(input.toString());next.pathname=apiBase+input.pathname.slice(4);return next;}return input;};const originalFetch=window.fetch?.bind(window);if(originalFetch)window.fetch=(input,init)=>originalFetch(rewrite(input),init);const OriginalEventSource=window.EventSource;if(OriginalEventSource){window.EventSource=function(url,init){return new OriginalEventSource(rewrite(url),init)};window.EventSource.prototype=OriginalEventSource.prototype;}})();</script>`;
180-
return new Response(record.htmlContent.replace("</head>", `${apiBaseScript}</head>`), {
172+
return new Response(injectApiBase(record.htmlContent, apiBaseScript), {
181173
headers: { "Content-Type": "text/html" },
182174
});
183175
}
184176
}
185177

186178
return new Response("Not found", { status: 404 });
187-
},
188-
{ context },
189-
);
179+
};
190180
}

0 commit comments

Comments
 (0)