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
2 changes: 2 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,8 @@ claude --plugin-dir ./apps/hook
| `PLANNOTATOR_PASTE_URL` | Base URL of the paste service API for short URL sharing. Default: `https://plannotator-paste.plannotator.workers.dev`. |
| `PLANNOTATOR_JINA` | Set to `0` / `false` to disable Jina Reader for URL annotation, or `1` / `true` to enable. Default: enabled. Can also be set via `~/.plannotator/config.json` (`{ "jina": false }`) or per-invocation via `--no-jina`. |
| `JINA_API_KEY` | Optional Jina Reader API key for higher rate limits (500 RPM vs 20 RPM unauthenticated). Free keys include 10M tokens. |
| `PLANNOTATOR_PLAN_SAVE` | Set to `0` / `false` to disable all plan saves (arrival + approve/deny snapshots), or `1` / `true` to enable. Default: enabled. Overrides `~/.plannotator/config.json` (`{ "planSave": { "enabled": false } }`) and the client session override — cannot be re-enabled by the UI when set to disabled. |
| `PLANNOTATOR_PLAN_SAVE_ON_ARRIVAL` | Set to `0` / `false` to skip the plain `{slug}.md` write on server startup (decision snapshots still happen on approve/deny). Default: enabled. Overrides `~/.plannotator/config.json` (`{ "planSave": { "saveOnArrival": false } }`). |
| `PLANNOTATOR_VERIFY_ATTESTATION` | **Read by the install scripts only**, not by the runtime binary. Set to `1` / `true` to have `scripts/install.sh` / `install.ps1` / `install.cmd` run `gh attestation verify` on every install. Off by default. Can also be set persistently via `~/.plannotator/config.json` (`{ "verifyAttestation": true }`) or per-invocation via `--verify-attestation`. Requires `gh` installed and authenticated. |

**Legacy:** `SSH_TTY` and `SSH_CONNECTION` are still detected when `PLANNOTATOR_REMOTE` is unset. Set `PLANNOTATOR_REMOTE=1` / `true` to force remote mode or `0` / `false` to force local mode.
Expand Down
11 changes: 9 additions & 2 deletions apps/pi-extension/server/serverAnnotate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { createServer } from "node:http";
import { dirname, resolve as resolvePath } from "node:path";

import { contentHash, deleteDraft } from "../generated/draft.js";
import { saveConfig, detectGitUser, getServerConfig } from "../generated/config.js";
import { saveConfig, detectGitUser, getServerConfig, isSafeCustomPath } from "../generated/config.js";

import {
handleDraftRequest,
Expand Down Expand Up @@ -94,11 +94,18 @@ export async function startAnnotateServer(options: {
});
} else if (url.pathname === "/api/config" && req.method === "POST") {
try {
const body = (await parseBody(req)) as { displayName?: string; diffOptions?: Record<string, unknown>; conventionalComments?: boolean };
const body = (await parseBody(req)) as { displayName?: string; diffOptions?: Record<string, unknown>; conventionalComments?: boolean; planSave?: { enabled?: boolean; customPath?: string | null; saveOnArrival?: boolean } };
const toSave: Record<string, unknown> = {};
if (body.displayName !== undefined) toSave.displayName = body.displayName;
if (body.diffOptions !== undefined) toSave.diffOptions = body.diffOptions;
if (body.conventionalComments !== undefined) toSave.conventionalComments = body.conventionalComments;
if (body.planSave !== undefined) {
if (body.planSave.customPath !== undefined && !isSafeCustomPath(body.planSave.customPath)) {
json(res, { error: "Invalid planSave.customPath" }, 400);
return;
}
toSave.planSave = body.planSave;
}
if (Object.keys(toSave).length > 0) saveConfig(toSave as Parameters<typeof saveConfig>[0]);
json(res, { ok: true });
} catch {
Expand Down
91 changes: 61 additions & 30 deletions apps/pi-extension/server/serverPlan.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
readArchivedPlan,
saveAnnotations,
saveFinalSnapshot,
savePlan,
saveToHistory,
} from "../generated/storage.js";
import { createEditorAnnotationHandler } from "./annotations.js";
Expand All @@ -36,7 +37,7 @@ import {
} from "./integrations.js";
import { listenOnPort } from "./network.js";

import { saveConfig, detectGitUser, getServerConfig } from "../generated/config.js";
import { loadConfig, saveConfig, detectGitUser, getServerConfig, resolvePlanSave, isSafeCustomPath, type PlannotatorConfig } from "../generated/config.js";
import { detectProjectName, getRepoInfo } from "./project.js";
import {
handleDocRequest,
Expand Down Expand Up @@ -112,6 +113,21 @@ export async function startPlanReviewServer(options: {
options.mode !== "archive"
? saveToHistory(project, slug, options.plan)
: { version: 0, path: "", isNew: false };

// Arrival save: write a plain {slug}.md to ~/.plannotator/plans/ (or
// custom path) before the UI opens. Respects user preferences from
// config.json — not cookies — because no HTTP request has arrived yet.
// Wrapped in try/catch so filesystem errors never block server startup.
if (options.mode !== "archive") {
try {
const planSaveCfg = resolvePlanSave(loadConfig());
if (planSaveCfg.enabled && planSaveCfg.saveOnArrival) {
savePlan(slug, options.plan, planSaveCfg.customPath);
}
} catch (e) {
process.stderr.write(`[plannotator] Arrival save failed: ${e instanceof Error ? e.message : String(e)}\n`);
}
}
const previousPlan =
options.mode !== "archive" && historyResult.version > 1
? getPlanVersion(project, slug, historyResult.version - 1)
Expand Down Expand Up @@ -225,11 +241,18 @@ export async function startPlanReviewServer(options: {
}
} else if (url.pathname === "/api/config" && req.method === "POST") {
try {
const body = (await parseBody(req)) as { displayName?: string; diffOptions?: Record<string, unknown>; conventionalComments?: boolean };
const body = (await parseBody(req)) as { displayName?: string; diffOptions?: Record<string, unknown>; conventionalComments?: boolean; planSave?: { enabled?: boolean; customPath?: string | null; saveOnArrival?: boolean } };
const toSave: Record<string, unknown> = {};
if (body.displayName !== undefined) toSave.displayName = body.displayName;
if (body.diffOptions !== undefined) toSave.diffOptions = body.diffOptions;
if (body.conventionalComments !== undefined) toSave.conventionalComments = body.conventionalComments;
if (body.planSave !== undefined) {
if (body.planSave.customPath !== undefined && !isSafeCustomPath(body.planSave.customPath)) {
json(res, { error: "Invalid planSave.customPath" }, 400);
return;
}
toSave.planSave = body.planSave;
}
if (Object.keys(toSave).length > 0) saveConfig(toSave as Parameters<typeof saveConfig>[0]);
json(res, { ok: true });
} catch {
Expand Down Expand Up @@ -342,22 +365,29 @@ export async function startPlanReviewServer(options: {
json(res, { ok: true, duplicate: true });
return;
}
let feedback: string | undefined;
let agentSwitch: string | undefined;
let requestedPermissionMode: string | undefined;
let planSaveEnabled = true;
let planSaveCustomPath: string | undefined;
// Parse body first so body.planSave acts as a config override fed
// into resolvePlanSave — env > body > config.json > defaults. A
// client default payload can't silently re-enable saves when the
// operator set PLANNOTATOR_PLAN_SAVE=false.
let body: Record<string, unknown> = {};
try {
body = await parseBody(req);
} catch { /* empty body */ }

const feedback = body.feedback as string | undefined;
const agentSwitch = body.agentSwitch as string | undefined;
const requestedPermissionMode = body.permissionMode as string | undefined;
const bodyPlanSave = body.planSave as { enabled?: boolean; customPath?: string | null } | undefined;

const approveCfg = loadConfig();
const approveEffectiveCfg: PlannotatorConfig = bodyPlanSave !== undefined
? { ...approveCfg, planSave: { ...approveCfg.planSave, ...bodyPlanSave } }
: approveCfg;
const approveResolved = resolvePlanSave(approveEffectiveCfg);
const planSaveEnabled = approveResolved.enabled;
const planSaveCustomPath: string | undefined = approveResolved.customPath ?? undefined;

try {
const body = await parseBody(req);
if (body.feedback) feedback = body.feedback as string;
if (body.agentSwitch) agentSwitch = body.agentSwitch as string;
if (body.permissionMode)
requestedPermissionMode = body.permissionMode as string;
if (body.planSave !== undefined) {
const ps = body.planSave as { enabled: boolean; customPath?: string };
planSaveEnabled = ps.enabled;
planSaveCustomPath = ps.customPath;
}
// Run note integrations in parallel
const integrationResults: Record<string, IntegrationResult> = {};
const integrationPromises: Promise<void>[] = [];
Expand Down Expand Up @@ -421,20 +451,21 @@ export async function startPlanReviewServer(options: {
json(res, { ok: true, duplicate: true });
return;
}
let feedback = "Plan rejected by user";
let planSaveEnabled = true;
let planSaveCustomPath: string | undefined;
let denyBody: Record<string, unknown> = {};
try {
const body = await parseBody(req);
feedback = (body.feedback as string) || feedback;
if (body.planSave !== undefined) {
const ps = body.planSave as { enabled: boolean; customPath?: string };
planSaveEnabled = ps.enabled;
planSaveCustomPath = ps.customPath;
}
} catch {
/* use default feedback */
}
denyBody = await parseBody(req);
} catch { /* empty body */ }
const feedback = (denyBody.feedback as string) || "Plan rejected by user";
const denyBodyPlanSave = denyBody.planSave as { enabled?: boolean; customPath?: string | null } | undefined;

// See /api/approve for precedence rationale: env > body > config.
const denyCfg = loadConfig();
const denyEffectiveCfg: PlannotatorConfig = denyBodyPlanSave !== undefined
? { ...denyCfg, planSave: { ...denyCfg.planSave, ...denyBodyPlanSave } }
: denyCfg;
const denyResolved = resolvePlanSave(denyEffectiveCfg);
const planSaveEnabled = denyResolved.enabled;
const planSaveCustomPath: string | undefined = denyResolved.customPath ?? undefined;
let savedPath: string | undefined;
if (planSaveEnabled) {
saveAnnotations(slug, feedback, planSaveCustomPath);
Expand Down
11 changes: 9 additions & 2 deletions apps/pi-extension/server/serverReview.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import os from "node:os";
import { Readable } from "node:stream";

import { contentHash, deleteDraft } from "../generated/draft.js";
import { saveConfig, detectGitUser, getServerConfig } from "../generated/config.js";
import { saveConfig, detectGitUser, getServerConfig, isSafeCustomPath } from "../generated/config.js";

export type {
DiffOption,
Expand Down Expand Up @@ -592,11 +592,18 @@ export async function startReviewServer(options: {
json(res, { error: "No file access available" }, 400);
} else if (url.pathname === "/api/config" && req.method === "POST") {
try {
const body = (await parseBody(req)) as { displayName?: string; diffOptions?: Record<string, unknown>; conventionalComments?: boolean };
const body = (await parseBody(req)) as { displayName?: string; diffOptions?: Record<string, unknown>; conventionalComments?: boolean; planSave?: { enabled?: boolean; customPath?: string | null; saveOnArrival?: boolean } };
const toSave: Record<string, unknown> = {};
if (body.displayName !== undefined) toSave.displayName = body.displayName;
if (body.diffOptions !== undefined) toSave.diffOptions = body.diffOptions;
if (body.conventionalComments !== undefined) toSave.conventionalComments = body.conventionalComments;
if (body.planSave !== undefined) {
if (body.planSave.customPath !== undefined && !isSafeCustomPath(body.planSave.customPath)) {
json(res, { error: "Invalid planSave.customPath" }, 400);
return;
}
toSave.planSave = body.planSave;
}
if (Object.keys(toSave).length > 0) saveConfig(toSave as Parameters<typeof saveConfig>[0]);
json(res, { ok: true });
} catch {
Expand Down
33 changes: 23 additions & 10 deletions packages/editor/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ import { getBearSettings } from '@plannotator/ui/utils/bear';
import { getOctarineSettings, isOctarineConfigured } from '@plannotator/ui/utils/octarine';
import { getDefaultNotesApp } from '@plannotator/ui/utils/defaultNotesApp';
import { getAgentSwitchSettings, getEffectiveAgentName } from '@plannotator/ui/utils/agentSwitch';
import { getPlanSaveSettings } from '@plannotator/ui/utils/planSave';
import { getPlanSaveSettings, type ServerPlanSave } from '@plannotator/ui/utils/planSave';
import { getUIPreferences, type UIPreferences, type PlanWidth } from '@plannotator/ui/utils/uiPreferences';
import { getEditorMode, saveEditorMode } from '@plannotator/ui/utils/editorMode';
import { getInputMethod, saveInputMethod } from '@plannotator/ui/utils/inputMethod';
Expand Down Expand Up @@ -130,6 +130,7 @@ const App: React.FC = () => {
const [isApiMode, setIsApiMode] = useState(false);
const [origin, setOrigin] = useState<Origin | null>(null);
const [gitUser, setGitUser] = useState<string | undefined>();
const [serverPlanSave, setServerPlanSave] = useState<ServerPlanSave | undefined>();
const [isWSL, setIsWSL] = useState(false);
const [globalAttachments, setGlobalAttachments] = useState<ImageAttachment[]>([]);
const [annotateMode, setAnnotateMode] = useState(false);
Expand Down Expand Up @@ -227,6 +228,7 @@ const App: React.FC = () => {
const archive = useArchive({
markdown, viewerRef, linkedDocHook,
setMarkdown, setAnnotations, setSelectedAnnotationId, setSubmitted,
serverPlanSave,
});

// Markdown file browser (also handles vault dirs via isVault flag)
Expand Down Expand Up @@ -526,16 +528,21 @@ const App: React.FC = () => {
if (!res.ok) throw new Error('Not in API mode');
return res.json();
})
.then((data: { plan: string; origin?: Origin; mode?: 'annotate' | 'annotate-last' | 'annotate-folder' | 'archive'; filePath?: string; sourceInfo?: string; sharingEnabled?: boolean; shareBaseUrl?: string; pasteApiUrl?: string; repoInfo?: { display: string; branch?: string }; previousPlan?: string | null; versionInfo?: { version: number; totalVersions: number; project: string }; archivePlans?: ArchivedPlan[]; projectRoot?: string; isWSL?: boolean; serverConfig?: { displayName?: string; gitUser?: string } }) => {
.then((data: { plan: string; origin?: Origin; mode?: 'annotate' | 'annotate-last' | 'annotate-folder' | 'archive'; filePath?: string; sourceInfo?: string; sharingEnabled?: boolean; shareBaseUrl?: string; pasteApiUrl?: string; repoInfo?: { display: string; branch?: string }; previousPlan?: string | null; versionInfo?: { version: number; totalVersions: number; project: string }; archivePlans?: ArchivedPlan[]; projectRoot?: string; isWSL?: boolean; serverConfig?: { displayName?: string; gitUser?: string; planSave?: ServerPlanSave } }) => {
// Initialize config store with server-provided values (config file > cookie > default)
configStore.init(data.serverConfig);
// gitUser drives the "Use git name" button in Settings; stays undefined (button hidden) when unavailable
setGitUser(data.serverConfig?.gitUser);
// planSave is authoritative source for plan save preferences (config.json > cookie legacy)
setServerPlanSave(data.serverConfig?.planSave);
if (data.mode === 'archive') {
// Archive mode: show first archived plan or clear demo content
// Archive mode: show first archived plan or clear demo content.
// /api/plan already delivered the archivePlans list and first plan
// content using the server-side customPath; don't call
// archive.fetchPlans() here — it would race with setServerPlanSave
// above and re-fetch against the default directory.
setMarkdown(data.plan || '');
if (data.archivePlans) archive.init(data.archivePlans);
archive.fetchPlans();
setSharingEnabled(false);
sidebar.open('archive');
} else if (data.mode === 'annotate-folder') {
Expand Down Expand Up @@ -761,13 +768,13 @@ const App: React.FC = () => {
const bearSettings = getBearSettings();
const octarineSettings = getOctarineSettings();
const agentSwitchSettings = getAgentSwitchSettings();
const planSaveSettings = getPlanSaveSettings();
const planSaveSettings = getPlanSaveSettings(serverPlanSave);
const autoSaveResults = bearSettings.autoSave && autoSavePromiseRef.current
? await autoSavePromiseRef.current
: autoSaveResultsRef.current;

// Build request body - include integrations if enabled
const body: { obsidian?: object; bear?: object; octarine?: object; feedback?: string; agentSwitch?: string; planSave?: { enabled: boolean; customPath?: string }; permissionMode?: string } = {};
const body: { obsidian?: object; bear?: object; octarine?: object; feedback?: string; agentSwitch?: string; planSave?: { enabled: boolean; customPath: string | null }; permissionMode?: string } = {};

// Include permission mode for Claude Code
if (origin === 'claude-code') {
Expand All @@ -780,10 +787,12 @@ const App: React.FC = () => {
body.agentSwitch = effectiveAgent;
}

// Include plan save settings
// Include plan save settings. Always send customPath explicitly (string
// or null) — the server merges this over config, so omitting would let
// a stale config path survive when the user just cleared it.
body.planSave = {
enabled: planSaveSettings.enabled,
...(planSaveSettings.customPath && { customPath: planSaveSettings.customPath }),
customPath: planSaveSettings.customPath,
};

const effectiveVaultPath = getEffectiveVaultPath(obsidianSettings);
Expand Down Expand Up @@ -837,15 +846,17 @@ const App: React.FC = () => {
const handleDeny = async () => {
setIsSubmitting(true);
try {
const planSaveSettings = getPlanSaveSettings();
const planSaveSettings = getPlanSaveSettings(serverPlanSave);
await fetch('/api/deny', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
feedback: annotationsOutput,
// Always send customPath explicitly (string or null) so a cleared
// path doesn't leave a stale config path winning the server merge.
planSave: {
enabled: planSaveSettings.enabled,
...(planSaveSettings.customPath && { customPath: planSaveSettings.customPath }),
customPath: planSaveSettings.customPath,
},
})
});
Expand Down Expand Up @@ -1400,6 +1411,8 @@ const App: React.FC = () => {
externalOpen={mobileSettingsOpen}
onExternalClose={() => setMobileSettingsOpen(false)}
gitUser={gitUser}
serverPlanSave={serverPlanSave}
onServerPlanSaveChange={setServerPlanSave}
/>
</div>

Expand Down
Loading