mirror of
https://github.com/backnotprop/plannotator.git
synced 2026-09-14 14:17:26 +08:00
cad745d61a
* fix(codex): resolve annotate-last across multi-rollout threads (#1367) * fix(codex): stop hook takes only the newest existing rollout segment --------- Co-authored-by: Michael Ramos <mdramos8@gmail.com>
2545 lines
95 KiB
TypeScript
2545 lines
95 KiB
TypeScript
/**
|
|
* Plannotator CLI for Claude Code, Droid, Codex, Gemini CLI, and Copilot CLI
|
|
*
|
|
* Supports thirteen modes:
|
|
*
|
|
* 1. Plan Review (default, no args):
|
|
* - Spawned by Claude/Gemini/Codex hook entrypoints
|
|
* - Reads hook event from stdin, extracts plan content
|
|
* - Serves UI, returns approve/deny decision to stdout
|
|
*
|
|
* 2. Code Review (`plannotator review`, `plannotator review --git`, `plannotator review --gitbutler`):
|
|
* - Triggered by /review slash command
|
|
* - Runs git diff, opens review UI
|
|
* - Outputs feedback to stdout (captured by slash command)
|
|
*
|
|
* 3. Annotate (`plannotator annotate <file.md | file.txt>`):
|
|
* - Triggered by /plannotator-annotate slash command
|
|
* - Opens any markdown file in the annotation UI
|
|
* - Outputs structured feedback to stdout
|
|
*
|
|
* 4. Archive (`plannotator archive`):
|
|
* - Opens read-only browser for saved plan decisions
|
|
* - Lists plans from ~/.plannotator/plans/ with status badges
|
|
* - Done button closes the browser
|
|
*
|
|
* 5. Sessions (`plannotator sessions`):
|
|
* - Lists active Plannotator server sessions
|
|
* - `--open [N]` reopens a session in the browser
|
|
* - `--clean` removes stale session files
|
|
*
|
|
* 6. Copilot Plan (`plannotator copilot-plan`):
|
|
* - Spawned by preToolUse hook (Copilot CLI)
|
|
* - Intercepts exit_plan_mode, reads plan.md from session state
|
|
* - Outputs permissionDecision JSON to stdout
|
|
*
|
|
* 7. Copilot Last (`plannotator copilot-last`):
|
|
* - Annotate the last assistant message from a Copilot CLI session
|
|
* - Parses events.jsonl from session state
|
|
*
|
|
* 8. Goal Setup (`plannotator setup-goal interview|facts <bundle.json>`):
|
|
* - Opens the bundled question or facts acceptance UI
|
|
* - Outputs structured JSON for setup-goal workflows
|
|
*
|
|
* 9. OpenCode Plan (`plannotator opencode-plan`):
|
|
* - Internal bridge mode used by the OpenCode plugin CLI fallback
|
|
* - Reads `{ plan, timeoutSeconds, sharingEnabled, agents }` from stdin
|
|
* - Outputs structured JSON for the plugin
|
|
*
|
|
* 10. OpenCode Review (`plannotator opencode-review`):
|
|
* - Internal structured review bridge used by the OpenCode plugin CLI fallback
|
|
*
|
|
* 11. OpenCode Last (`plannotator opencode-annotate-last`):
|
|
* - Internal structured last-message annotation bridge for OpenCode
|
|
*
|
|
* 12. Improve Context (`plannotator improve-context`):
|
|
* - Spawned by PreToolUse hook on EnterPlanMode
|
|
* - Reads improvement hook file from ~/.plannotator/hooks/
|
|
* - Returns additionalContext or silently passes through
|
|
*
|
|
* 13. Uninstall (`plannotator uninstall`):
|
|
* - Removes recognized installer-owned components across supported hosts
|
|
* - Preserves local data by default; `--purge` removes known local data
|
|
*
|
|
* 14. Guide tools (`plannotator guide list|export|share|unshare`):
|
|
* - List saved Guided Reviews; export one (or a snapshot JSON) as a portable
|
|
* HTML file whose viewer loads from guides.show; share one as a link on
|
|
* guides.show (encrypted by default) and remove it again
|
|
*
|
|
* Global flags:
|
|
* --help - Show top-level usage information
|
|
* --version, -v - Print version and exit
|
|
* --browser <name> - Override which browser to open (e.g. "Google Chrome")
|
|
*
|
|
* Environment variables:
|
|
* PLANNOTATOR_REMOTE - Set to "1"/"true" for remote, "0"/"false" for local
|
|
* PLANNOTATOR_PORT - Fixed port to use (default: random locally, 19432 for remote)
|
|
*/
|
|
|
|
import {
|
|
startPlannotatorServer,
|
|
handleServerReady,
|
|
} from "@plannotator/server";
|
|
import {
|
|
startReviewServer,
|
|
handleReviewServerReady,
|
|
} from "@plannotator/server/review";
|
|
import { runGuideCli } from "@plannotator/server/guide-cli";
|
|
import {
|
|
startAnnotateServer,
|
|
handleAnnotateServerReady,
|
|
isRemoteSession,
|
|
} from "@plannotator/server/annotate";
|
|
import {
|
|
startGoalSetupServer,
|
|
handleGoalSetupServerReady,
|
|
} from "@plannotator/server/goal-setup";
|
|
import { type DiffType, detectManagedVcs, prepareLocalReviewDiff, gitRuntime } from "@plannotator/server/vcs";
|
|
import { loadConfig, resolveDefaultDiffType, resolveSharingEnabled } from "@plannotator/shared/config";
|
|
import { parseReviewArgs, type ParsedReviewArgs } from "@plannotator/shared/review-args";
|
|
import { resolveReviewOpenState, type ReviewOpenState } from "@plannotator/shared/review-open-state";
|
|
import { listBranches, type AvailableBranches } from "@plannotator/shared/review-core";
|
|
import {
|
|
normalizeGoalSetupBundle,
|
|
type GoalSetupStage,
|
|
} from "@plannotator/shared/goal-setup";
|
|
import {
|
|
buildAmbiguousAnnotateArgsMessage,
|
|
buildUnresolvedAnnotateArgsMessage,
|
|
probeAnnotateToken,
|
|
selectAnnotateTokenTarget,
|
|
} from "@plannotator/shared/annotate-target";
|
|
import { createWorktreePool, type WorktreePool, type PoolEntry } from "@plannotator/shared/worktree-pool";
|
|
import { parsePRUrl, checkPRAuth, fetchPR, getCliName, getCliInstallUrl, getMRLabel, getMRNumberLabel, getDisplayRepo } from "@plannotator/server/pr";
|
|
import { writeRemoteShareLink } from "@plannotator/server/share-url";
|
|
import { enableTailscaleServe } from "@plannotator/server/tailscale-serve";
|
|
import { writeUrlQr } from "@plannotator/server/qr";
|
|
import { resolveAnnotateTarget } from "./annotate-resolution";
|
|
import { LIVE_APP_REMOTE_MESSAGE } from "@plannotator/shared/live-probe";
|
|
// Bridge sources for live app sessions: the CLI supplies them so
|
|
// @plannotator/server never imports @plannotator/ui (mirrors the existing
|
|
// htmlContent precedent).
|
|
import {
|
|
ANNOTATION_HIGHLIGHT_CSS,
|
|
BRIDGE_SCRIPT,
|
|
LIVE_BRIDGE_BOOTSTRAP,
|
|
} from "@plannotator/ui/components/html-viewer/bridge-script";
|
|
import { rmSync, realpathSync, existsSync } from "fs";
|
|
import { parseRemoteUrl } from "@plannotator/shared/repo";
|
|
import {
|
|
getPlanDeniedPrompt,
|
|
getPlanToolName,
|
|
buildPlanFileRule,
|
|
} from "@plannotator/shared/prompts";
|
|
import { buildReviewOutput, supportsReviewApprovalNotes } from "./review-output";
|
|
import { registerSession, unregisterSession, listSessions } from "@plannotator/server/sessions";
|
|
import { openBrowser } from "@plannotator/server/browser";
|
|
import { inlineHtmlLocalAssets } from "@plannotator/server/html-assets";
|
|
import { installAgentTerminalRuntime } from "@plannotator/server/agent-terminal-runtime";
|
|
import { installCallFlowRuntime } from "@plannotator/shared/call-flow";
|
|
import {
|
|
createDefaultUninstallEnvironment,
|
|
formatPurgeWarning,
|
|
formatUninstallResult,
|
|
runPlannotatorUninstall,
|
|
} from "@plannotator/server/uninstall";
|
|
import { detectProjectName } from "@plannotator/server/project";
|
|
import { hostnameOrFallback } from "@plannotator/shared/project";
|
|
import { readImprovementHook } from "@plannotator/shared/improvement-hooks";
|
|
import { composeImproveContext } from "@plannotator/shared/pfm-reminder";
|
|
import {
|
|
waitForPlanReviewCloseDelay,
|
|
waitForPlanReviewDecision,
|
|
} from "@plannotator/shared/plan-review-lifecycle";
|
|
import { AGENT_CONFIG, type Origin } from "@plannotator/shared/agents";
|
|
import {
|
|
findDroidSessionLogsByAncestorWalk,
|
|
findDroidSessionLogsForCwd,
|
|
findSessionLogsByAncestorWalk,
|
|
findSessionLogsForCwd,
|
|
getRecentRenderedMessages,
|
|
resolveDroidSessionLogForCwd,
|
|
resolveSessionLogByAncestorPids,
|
|
resolveSessionLogByCwdScan,
|
|
type RenderedMessage,
|
|
} from "./session-log";
|
|
import { findCodexRolloutsByThreadId, getLatestCodexPlan, getRecentCodexMessages } from "./codex-session";
|
|
import { findCopilotPlanContent, findCopilotSessionByAncestorPids, findCopilotSessionForCwd, getRecentCopilotMessages } from "./copilot-session";
|
|
import {
|
|
formatInteractiveNoArgClarification,
|
|
formatSubcommandHelp,
|
|
formatTopLevelHelp,
|
|
formatVersion,
|
|
isInteractiveNoArgInvocation,
|
|
isSubcommandHelpInvocation,
|
|
isTopLevelHelpInvocation,
|
|
isVersionInvocation,
|
|
parseStrictAnnotateOptions,
|
|
isUninstallConfirmationAccepted,
|
|
parseUninstallOptions,
|
|
} from "./cli";
|
|
import { exitOnUnknownSubcommand } from "./unknown-subcommand";
|
|
import { completeAnnotateCommand } from "./annotate-command";
|
|
import {
|
|
annotateStartupFailureExitCode,
|
|
isStrictAnnotateInvocation,
|
|
assertResultPathAvailable,
|
|
resolveResultFilePath,
|
|
STRICT_GATE_ERROR_EXIT_CODE,
|
|
} from "./strict-annotate-result";
|
|
import path from "path";
|
|
import { tmpdir } from "os";
|
|
import { createInterface } from "node:readline/promises";
|
|
import { buildLocalWorkspaceReview, type WorkspaceDiffType } from "@plannotator/server/review-workspace";
|
|
import {
|
|
createAnnotateOutcomeEmitter,
|
|
supportsAnnotateApprovalNotes,
|
|
supportsAnnotateClientLease,
|
|
} from "./annotate-output";
|
|
|
|
// Embed the built HTML at compile time
|
|
// @ts-ignore - Bun import attribute for text
|
|
import planHtml from "../dist/index.html" with { type: "text" };
|
|
const planHtmlContent = planHtml as unknown as string;
|
|
|
|
// @ts-ignore - Bun import attribute for text
|
|
import reviewHtml from "../dist/review.html" with { type: "text" };
|
|
const reviewHtmlContent = reviewHtml as unknown as string;
|
|
|
|
// Check for subcommand
|
|
const rawArgs = process.argv.slice(2);
|
|
let parsedStrictAnnotateOptions;
|
|
try {
|
|
parsedStrictAnnotateOptions = parseStrictAnnotateOptions(
|
|
rawArgs,
|
|
);
|
|
} catch (error) {
|
|
console.error(error instanceof Error ? error.message : String(error));
|
|
// Usage error: the gate was misconfigured, not a reviewer decision.
|
|
process.exit(STRICT_GATE_ERROR_EXIT_CODE);
|
|
}
|
|
const args = parsedStrictAnnotateOptions.remainingArgs;
|
|
const requireApprovalFlag =
|
|
parsedStrictAnnotateOptions.requireApproval;
|
|
const resultFile = parsedStrictAnnotateOptions.resultFile
|
|
? resolveResultFilePath(
|
|
parsedStrictAnnotateOptions.resultFile,
|
|
process.env.PLANNOTATOR_CWD || process.cwd(),
|
|
)
|
|
: undefined;
|
|
|
|
// Global flag: --browser <name>
|
|
const browserIdx = args.indexOf("--browser");
|
|
if (browserIdx !== -1 && args[browserIdx + 1]) {
|
|
process.env.PLANNOTATOR_BROWSER = args[browserIdx + 1];
|
|
args.splice(browserIdx, 2);
|
|
}
|
|
|
|
// Transport flag: --tailscale (review / annotate / annotate-last) — publish
|
|
// the session over the user's tailnet via `tailscale serve`. The server stays
|
|
// LOOPBACK-bound: serve provides reachability plus TLS, so remote mode's wide
|
|
// bind is redundant and would only broaden exposure. Forcing local mode here
|
|
// (before any port/bind decision) is the safer resolution of the
|
|
// --tailscale + PLANNOTATOR_REMOTE combination; it also restores the random
|
|
// local port, so simultaneous sessions get distinct serve mappings.
|
|
const TAILSCALE_COMMANDS = new Set(["review", "annotate", "annotate-last", "last"]);
|
|
const tailscaleIdx = args.indexOf("--tailscale");
|
|
const tailscaleFlag = tailscaleIdx !== -1;
|
|
if (tailscaleFlag) {
|
|
args.splice(tailscaleIdx, 1);
|
|
if (!TAILSCALE_COMMANDS.has(args[0] ?? "")) {
|
|
console.error(
|
|
"--tailscale is only supported with: plannotator review, annotate, annotate-last (last)",
|
|
);
|
|
process.exit(1);
|
|
}
|
|
if (isRemoteSession()) {
|
|
process.stderr.write(
|
|
"[plannotator] --tailscale keeps the server loopback-bound behind `tailscale serve`; ignoring remote mode (PLANNOTATOR_REMOTE/SSH detection) for this session.\n",
|
|
);
|
|
}
|
|
process.env.PLANNOTATOR_REMOTE = "0";
|
|
// urlHost is irrelevant here — the advertised URL comes from tailscale
|
|
// serve, and the session is local-bound. An empty-but-set env var also
|
|
// suppresses a config-file urlHost, avoiding the misleading
|
|
// "set PLANNOTATOR_REMOTE=1" local-session warning mid --tailscale run.
|
|
process.env.PLANNOTATOR_URL_HOST = "";
|
|
}
|
|
|
|
/**
|
|
* --tailscale ready path: publish the loopback port over the tailnet, print
|
|
* the HTTPS URL (with a QR for the device hop), and hand the reachable URL to
|
|
* the ready-file side channel. Never opens a local browser. Publishing
|
|
* failures resolve HERE with a clean actionable message and a nonzero exit —
|
|
* under the bang-prefix skill a hanging session blocks the whole Claude Code
|
|
* prompt, so this path must never leave the loopback server waiting. (The
|
|
* server APIs also await ready handlers and stop the server on rejection,
|
|
* which covers any other async onReady user.)
|
|
*
|
|
* A publish failure is a STARTUP failure: no reviewer ever saw the session.
|
|
* Under a strict annotate gate (--require-approval / --result-file) exit 1
|
|
* is reserved for "the reviewer did not approve, decision record published",
|
|
* so this exits through annotateStartupFailureExitCode with the strict flags
|
|
* the invocation parsed — exit 2 for strict gates, the documented exit 1
|
|
* otherwise (review and non-strict annotate; strict flags only parse on the
|
|
* annotate subcommand, so review sessions always take the exit-1 leg).
|
|
*/
|
|
async function handleTailscaleReady(port: number): Promise<void> {
|
|
let url: string;
|
|
try {
|
|
({ url } = enableTailscaleServe(port));
|
|
} catch (error) {
|
|
process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
|
|
process.exit(
|
|
annotateStartupFailureExitCode({
|
|
requireApproval: requireApprovalFlag,
|
|
resultFile,
|
|
}),
|
|
);
|
|
}
|
|
process.stderr.write(`\n Plannotator session ready — served over your tailnet:\n ${url}\n\n`);
|
|
writeUrlQr(url);
|
|
await handleServerReady(url, false, port, { skipBrowserOpen: true });
|
|
}
|
|
|
|
// Global flag: --no-jina (disables Jina Reader for URL annotation)
|
|
const noJinaIdx = args.indexOf("--no-jina");
|
|
const cliNoJina = noJinaIdx !== -1;
|
|
if (cliNoJina) args.splice(noJinaIdx, 1);
|
|
|
|
// Annotate review-gate flags: --gate adds an Approve button, --json
|
|
// switches stdout to structured decision output, --hook emits hook-native
|
|
// JSON that works directly with Claude Code and Codex PostToolUse/Stop
|
|
// hook protocols.
|
|
const gateIdx = args.indexOf("--gate");
|
|
let gateFlag = gateIdx !== -1;
|
|
if (gateFlag) args.splice(gateIdx, 1);
|
|
const jsonIdx = args.indexOf("--json");
|
|
const jsonFlag = jsonIdx !== -1;
|
|
if (jsonFlag) args.splice(jsonIdx, 1);
|
|
const hookIdx = args.indexOf("--hook");
|
|
const hookFlag = hookIdx !== -1;
|
|
if (hookFlag) args.splice(hookIdx, 1);
|
|
if (hookFlag) gateFlag = true;
|
|
const renderHtmlIdx = args.indexOf("--render-html");
|
|
const renderHtmlFlag = renderHtmlIdx !== -1;
|
|
if (renderHtmlFlag) args.splice(renderHtmlIdx, 1);
|
|
const renderMarkdownIdx = args.indexOf("--markdown");
|
|
const renderMarkdownFlag = renderMarkdownIdx !== -1;
|
|
if (renderMarkdownFlag) args.splice(renderMarkdownIdx, 1);
|
|
// Live app annotation flags (annotate, loopback URLs): --app forces live
|
|
// mode, --static forces the classic conversion pipeline. Transport-shape
|
|
// flags: never echoed in the tolerant handoff's re-run flag list.
|
|
const appFlagIdx = args.indexOf("--app");
|
|
const appFlag = appFlagIdx !== -1;
|
|
if (appFlag) args.splice(appFlagIdx, 1);
|
|
const staticFlagIdx = args.indexOf("--static");
|
|
const staticFlag = staticFlagIdx !== -1;
|
|
if (staticFlag) args.splice(staticFlagIdx, 1);
|
|
|
|
// Stdout matrix for annotate / annotate-last / copilot annotate-last.
|
|
//
|
|
// --hook (recommended for hooks):
|
|
// Approve/Close → empty stdout (hook passes, agent proceeds).
|
|
// Annotate → {"decision":"block","reason":"<feedback>"} (hook blocks).
|
|
// Works with both Claude Code and Codex hook protocols.
|
|
//
|
|
// --json (structured decisions for wrapper scripts):
|
|
// Emits {"decision":"approved|dismissed|annotated","feedback":"..."}.
|
|
//
|
|
// Plaintext (default):
|
|
// Close → empty. Approve → "The user approved." Annotate → feedback.
|
|
//
|
|
const emitAnnotateOutcome = createAnnotateOutcomeEmitter({
|
|
hook: hookFlag,
|
|
json: jsonFlag,
|
|
});
|
|
|
|
/**
|
|
* Resolve the `--base` / `--diff-type` open-state seed for a review
|
|
* invocation: probe the requested base ref with git (in `cwd`), validate
|
|
* against the provider/PR/workspace matrix, print notices on stderr, and exit
|
|
* 1 on a fatal error (reviews have no strict-gate mode, so every failure here
|
|
* is exit 1 like the other review startup failures). Returns the seed to
|
|
* thread into `prepareLocalReviewDiff`. A flagless invocation is a no-op.
|
|
*/
|
|
async function resolveCliReviewOpenState(
|
|
reviewArgs: ParsedReviewArgs,
|
|
options: {
|
|
isPRMode: boolean;
|
|
isWorkspace: boolean;
|
|
providerId?: "git" | "gitbutler" | "jj" | "p4";
|
|
resolvedDefaultDiffType: DiffType;
|
|
cwd?: string;
|
|
},
|
|
): Promise<ReviewOpenState> {
|
|
if (reviewArgs.base === undefined && reviewArgs.diffType === undefined) {
|
|
return { notices: [] };
|
|
}
|
|
let baseResolves: boolean | undefined;
|
|
let availableBranches: AvailableBranches | undefined;
|
|
if (
|
|
reviewArgs.base !== undefined &&
|
|
!options.isPRMode &&
|
|
!options.isWorkspace &&
|
|
options.providerId === "git"
|
|
) {
|
|
// The probe is the whole point of CLI-side resolution: without it a
|
|
// typo'd base produces a confidently-mislabelled merge-base→HEAD diff
|
|
// (review-core's since-base degrade). --end-of-options blocks flag
|
|
// injection through hostile ref names.
|
|
const probe = await gitRuntime.runGit(
|
|
["rev-parse", "--verify", "--quiet", "--end-of-options", `${reviewArgs.base}^{commit}`],
|
|
{ cwd: options.cwd },
|
|
);
|
|
baseResolves = probe.exitCode === 0;
|
|
if (!baseResolves) {
|
|
// Near-match suggestions: cheap (one for-each-ref) and the single most
|
|
// useful thing an agent caller can act on.
|
|
availableBranches = await listBranches(gitRuntime, options.cwd);
|
|
}
|
|
}
|
|
const openState = resolveReviewOpenState({
|
|
parsed: reviewArgs,
|
|
isPRMode: options.isPRMode,
|
|
isWorkspace: options.isWorkspace,
|
|
providerId: options.providerId,
|
|
resolvedDefaultDiffType: options.resolvedDefaultDiffType,
|
|
baseResolves,
|
|
availableBranches,
|
|
});
|
|
if (openState.error) {
|
|
console.error(openState.error);
|
|
process.exit(1);
|
|
}
|
|
for (const notice of openState.notices) console.error(notice);
|
|
return openState;
|
|
}
|
|
|
|
async function loadGoalSetupBundle(
|
|
stage: GoalSetupStage,
|
|
bundlePath: string
|
|
) {
|
|
const raw =
|
|
bundlePath === "-"
|
|
? await Bun.stdin.text()
|
|
: await Bun.file(path.resolve(bundlePath)).text();
|
|
return normalizeGoalSetupBundle(JSON.parse(raw), stage);
|
|
}
|
|
|
|
if (isVersionInvocation(args)) {
|
|
console.log(formatVersion());
|
|
process.exit(0);
|
|
}
|
|
|
|
if (isTopLevelHelpInvocation(args)) {
|
|
console.log(formatTopLevelHelp());
|
|
process.exit(0);
|
|
}
|
|
|
|
// Per-subcommand help must be handled before the subcommand branches below —
|
|
// otherwise `plannotator review --help` (commonly run by agents probing the
|
|
// CLI) falls through to local review mode and launches the browser UI,
|
|
// spawning a stray tab whose close injects a bogus "no feedback" signal.
|
|
const helpSubcommand = isSubcommandHelpInvocation(args);
|
|
if (helpSubcommand) {
|
|
console.log(formatSubcommandHelp(helpSubcommand));
|
|
process.exit(0);
|
|
}
|
|
|
|
exitOnUnknownSubcommand(args);
|
|
|
|
if (args[0] === "uninstall") {
|
|
let options: ReturnType<typeof parseUninstallOptions>;
|
|
try {
|
|
options = parseUninstallOptions(rawArgs.slice(1));
|
|
} catch (error) {
|
|
console.error(error instanceof Error ? error.message : String(error));
|
|
console.error("Run 'plannotator uninstall --help' for usage.");
|
|
process.exit(1);
|
|
}
|
|
|
|
const environment = createDefaultUninstallEnvironment();
|
|
|
|
if (!options.dryRun && !options.yes) {
|
|
if (process.stdin.isTTY !== true || process.stdout.isTTY !== true) {
|
|
console.error(
|
|
"Uninstall requires confirmation. Re-run with --yes in a non-interactive shell.",
|
|
);
|
|
process.exit(1);
|
|
}
|
|
|
|
if (options.purge) {
|
|
console.error(formatPurgeWarning(environment.dataDir));
|
|
} else {
|
|
console.error(
|
|
`Local Plannotator data in ${environment.dataDir} will be preserved.`,
|
|
);
|
|
}
|
|
|
|
const prompt = options.purge
|
|
? "Type 'purge' to permanently uninstall and delete local data: "
|
|
: "Remove Plannotator-installed components? [y/N] ";
|
|
const readline = createInterface({
|
|
input: process.stdin,
|
|
output: process.stdout,
|
|
});
|
|
let answer = "";
|
|
try {
|
|
answer = await readline.question(prompt);
|
|
} finally {
|
|
readline.close();
|
|
}
|
|
|
|
if (!isUninstallConfirmationAccepted(answer, options.purge)) {
|
|
console.log("Uninstall cancelled.");
|
|
process.exit(0);
|
|
}
|
|
} else if (options.purge && !options.dryRun) {
|
|
console.error(formatPurgeWarning(environment.dataDir));
|
|
}
|
|
|
|
const result = await runPlannotatorUninstall(
|
|
{
|
|
purge: options.purge,
|
|
dryRun: options.dryRun,
|
|
},
|
|
environment,
|
|
);
|
|
const formatted = formatUninstallResult(result);
|
|
if (formatted) console.log(formatted);
|
|
|
|
if (options.dryRun) {
|
|
console.log("Dry run complete; no changes were made.");
|
|
} else if (options.purge && result.ok) {
|
|
console.log(`Known local Plannotator data was purged from ${result.dataDir}.`);
|
|
} else if (!options.purge) {
|
|
console.log(`Local Plannotator data was preserved in ${result.dataDir}.`);
|
|
}
|
|
|
|
process.exit(result.ok ? 0 : 1);
|
|
}
|
|
|
|
if (args[0] === "install-runtime") {
|
|
const runtime = args[1];
|
|
if (runtime !== "agent-terminal" && runtime !== "call-flow") {
|
|
console.error("Usage: plannotator install-runtime <agent-terminal|call-flow>");
|
|
process.exit(1);
|
|
}
|
|
const result = runtime === "call-flow"
|
|
? await installCallFlowRuntime()
|
|
: await installAgentTerminalRuntime();
|
|
console.log(result.message);
|
|
process.exit(result.ok ? 0 : 1);
|
|
}
|
|
|
|
if (isInteractiveNoArgInvocation(args, process.stdin.isTTY)) {
|
|
console.log(formatInteractiveNoArgClarification());
|
|
process.exit(0);
|
|
}
|
|
|
|
// Ensure session cleanup on exit
|
|
process.on("exit", () => unregisterSession());
|
|
|
|
// Route fatal signals through process.exit() so "exit" handlers run — by
|
|
// default a SIGINT/SIGTERM death skips them, leaking background-warmup
|
|
// children and stale `git worktree` registrations (the --local PR checkout
|
|
// cleanup below is registered on "exit"). `once` keeps a second Ctrl-C as a
|
|
// force-quit escape hatch if cleanup ever hangs. SIGHUP is deliberately NOT
|
|
// routed here: installing any SIGHUP listener overrides the ignored
|
|
// disposition `nohup` depends on, so a plain `nohup plannotator review &`
|
|
// must end up with no listener and survive terminal close. The --tailscale
|
|
// path installs its own SIGHUP→exit handler only once a serve mapping
|
|
// actually exists (enableTailscaleServe in
|
|
// packages/server/tailscale-serve.ts), which is the only case where terminal
|
|
// close would otherwise leak tailnet state.
|
|
process.once("SIGINT", () => process.exit(130));
|
|
process.once("SIGTERM", () => process.exit(143));
|
|
|
|
// Check if URL sharing is enabled (default: true)
|
|
const sharingEnabled = resolveSharingEnabled(loadConfig());
|
|
|
|
// Custom share portal URL for self-hosting
|
|
const shareBaseUrl = process.env.PLANNOTATOR_SHARE_URL || undefined;
|
|
|
|
// Paste service URL for short URL sharing
|
|
const pasteApiUrl = process.env.PLANNOTATOR_PASTE_URL || undefined;
|
|
|
|
// Detect calling agent from environment variables set by agent runtimes.
|
|
// Priority:
|
|
// PLANNOTATOR_ORIGIN (explicit override, validated against AGENT_CONFIG)
|
|
// > Amp plugin wrappers (PLANNOTATOR_ORIGIN=amp)
|
|
// > Droid command wrappers (PLANNOTATOR_ORIGIN=droid)
|
|
// > Codex (CODEX_THREAD_ID)
|
|
// > Copilot CLI (COPILOT_CLI)
|
|
// > OpenCode (OPENCODE)
|
|
// > Gemini CLI (GEMINI_CLI)
|
|
// > oh-my-pi harness (OMPCODE) — checked last because OMP exports OMPCODE
|
|
// into every shell it spawns; runtimes launched from an OMP session must
|
|
// still be detected as themselves. OMPCODE still wins over the terminal
|
|
// fallback below.
|
|
//
|
|
// To add a new agent, also add an entry to AGENT_CONFIG in
|
|
// packages/shared/agents.ts (see header comment there).
|
|
const originOverride = process.env.PLANNOTATOR_ORIGIN as Origin | undefined;
|
|
const detectedOrigin: Origin =
|
|
(originOverride && originOverride in AGENT_CONFIG) ? originOverride :
|
|
process.env.CODEX_THREAD_ID ? "codex" :
|
|
process.env.COPILOT_CLI ? "copilot-cli" :
|
|
process.env.OPENCODE ? "opencode" :
|
|
process.env.GEMINI_CLI ? "gemini-cli" :
|
|
process.env.OMPCODE ? "oh-my-pi" :
|
|
"claude-code";
|
|
|
|
type OpenCodeBridgeAgent = {
|
|
name: string;
|
|
description?: string;
|
|
mode: string;
|
|
hidden?: boolean;
|
|
};
|
|
|
|
type OpenCodeBridgeInput = {
|
|
sharingEnabled?: unknown;
|
|
shareBaseUrl?: unknown;
|
|
pasteApiUrl?: unknown;
|
|
agents?: unknown;
|
|
};
|
|
|
|
function parseOpenCodeBridgeInput<T extends object>(
|
|
mode: string,
|
|
inputJson: string,
|
|
): T & OpenCodeBridgeInput {
|
|
try {
|
|
return JSON.parse(inputJson) as T & OpenCodeBridgeInput;
|
|
} catch (error) {
|
|
console.error(`Failed to parse ${mode} input: ${error instanceof Error ? error.message : String(error)}`);
|
|
process.exit(1);
|
|
}
|
|
}
|
|
|
|
function getBridgeSharingEnabled(input: OpenCodeBridgeInput): boolean {
|
|
return typeof input.sharingEnabled === "boolean" ? input.sharingEnabled : sharingEnabled;
|
|
}
|
|
|
|
function getBridgeShareBaseUrl(input: OpenCodeBridgeInput): string | undefined {
|
|
return typeof input.shareBaseUrl === "string" && input.shareBaseUrl ? input.shareBaseUrl : shareBaseUrl;
|
|
}
|
|
|
|
function getBridgePasteApiUrl(input: OpenCodeBridgeInput): string | undefined {
|
|
return typeof input.pasteApiUrl === "string" && input.pasteApiUrl ? input.pasteApiUrl : pasteApiUrl;
|
|
}
|
|
|
|
function normalizeOpenCodeBridgeAgents(value: unknown): OpenCodeBridgeAgent[] | undefined {
|
|
if (!Array.isArray(value)) return undefined;
|
|
|
|
const agents = value
|
|
.map((agent): OpenCodeBridgeAgent | null => {
|
|
if (!agent || typeof agent !== "object") return null;
|
|
const record = agent as Record<string, unknown>;
|
|
if (typeof record.name !== "string" || !record.name) return null;
|
|
return {
|
|
name: record.name,
|
|
...(typeof record.description === "string" && { description: record.description }),
|
|
mode: typeof record.mode === "string" ? record.mode : "primary",
|
|
...(typeof record.hidden === "boolean" && { hidden: record.hidden }),
|
|
};
|
|
})
|
|
.filter((agent): agent is OpenCodeBridgeAgent => agent !== null);
|
|
|
|
return agents.length > 0 ? agents : undefined;
|
|
}
|
|
|
|
function makeOpenCodeBridgeClient(agents: unknown) {
|
|
const data = normalizeOpenCodeBridgeAgents(agents);
|
|
if (!data) return undefined;
|
|
|
|
return {
|
|
app: {
|
|
agents: async () => ({ data }),
|
|
},
|
|
};
|
|
}
|
|
|
|
function emitOpenCodeAnnotateOutcome(result: {
|
|
feedback: string;
|
|
exit?: boolean;
|
|
approved?: boolean;
|
|
selectedMessageId?: string;
|
|
feedbackScope?: "message" | "messages";
|
|
}): void {
|
|
if (result.approved) {
|
|
console.log(JSON.stringify({
|
|
decision: "approved",
|
|
...(result.feedback ? { feedback: result.feedback } : {}),
|
|
}));
|
|
return;
|
|
}
|
|
if (result.exit) {
|
|
console.log(JSON.stringify({ decision: "dismissed" }));
|
|
return;
|
|
}
|
|
console.log(JSON.stringify({
|
|
decision: "annotated",
|
|
feedback: result.feedback || "",
|
|
...(result.selectedMessageId && { selectedMessageId: result.selectedMessageId }),
|
|
...(result.feedbackScope && { feedbackScope: result.feedbackScope }),
|
|
}));
|
|
}
|
|
|
|
if (args[0] === "sessions") {
|
|
// ============================================
|
|
// SESSION DISCOVERY MODE
|
|
// ============================================
|
|
|
|
if (args.includes("--clean")) {
|
|
// Force cleanup: list sessions (which auto-removes stale entries)
|
|
const sessions = listSessions();
|
|
console.error(`Cleaned up stale sessions. ${sessions.length} active session(s) remain.`);
|
|
process.exit(0);
|
|
}
|
|
|
|
const sessions = listSessions();
|
|
|
|
if (sessions.length === 0) {
|
|
console.error("No active Plannotator sessions.");
|
|
process.exit(0);
|
|
}
|
|
|
|
const openIdx = args.indexOf("--open");
|
|
if (openIdx !== -1) {
|
|
// Open a session in the browser
|
|
const nArg = args[openIdx + 1];
|
|
const n = nArg ? parseInt(nArg, 10) : 1;
|
|
const session = sessions[n - 1];
|
|
if (!session) {
|
|
console.error(`Session #${n} not found. ${sessions.length} active session(s).`);
|
|
process.exit(1);
|
|
}
|
|
await openBrowser(session.url);
|
|
console.error(`Opened ${session.mode} session in browser: ${session.url}`);
|
|
process.exit(0);
|
|
}
|
|
|
|
// List sessions as a table
|
|
console.error("Active Plannotator sessions:\n");
|
|
for (let i = 0; i < sessions.length; i++) {
|
|
const s = sessions[i];
|
|
const age = Math.round((Date.now() - new Date(s.startedAt).getTime()) / 60000);
|
|
const ageStr = age < 60 ? `${age}m` : `${Math.floor(age / 60)}h ${age % 60}m`;
|
|
console.error(` #${i + 1} ${s.mode.padEnd(9)} ${s.project.padEnd(20)} ${s.url.padEnd(28)} ${ageStr} ago`);
|
|
}
|
|
console.error(`\nReopen with: plannotator sessions --open [N]`);
|
|
process.exit(0);
|
|
|
|
} else if (args[0] === "setup-goal") {
|
|
// ============================================
|
|
// GOAL SETUP MODE
|
|
// ============================================
|
|
|
|
const stage = args[1] as GoalSetupStage | undefined;
|
|
const bundlePath = args[2];
|
|
|
|
if ((stage !== "interview" && stage !== "facts") || !bundlePath) {
|
|
console.error(
|
|
"Usage: plannotator setup-goal <interview|facts> <bundle.json | -> [--json]"
|
|
);
|
|
process.exit(1);
|
|
}
|
|
|
|
let bundle: Awaited<ReturnType<typeof loadGoalSetupBundle>>;
|
|
try {
|
|
bundle = await loadGoalSetupBundle(stage, bundlePath);
|
|
} catch (err) {
|
|
console.error(
|
|
`Failed to load goal setup bundle: ${err instanceof Error ? err.message : String(err)}`
|
|
);
|
|
process.exit(1);
|
|
}
|
|
|
|
const goalProject = (await detectProjectName()) ?? "_unknown";
|
|
|
|
const server = await startGoalSetupServer({
|
|
bundle,
|
|
origin: detectedOrigin,
|
|
htmlContent: planHtmlContent,
|
|
onReady: (url, isRemote, port) => {
|
|
handleGoalSetupServerReady(url, isRemote, port);
|
|
},
|
|
});
|
|
|
|
registerSession({
|
|
pid: process.pid,
|
|
port: server.port,
|
|
url: server.url,
|
|
mode: "goal-setup",
|
|
project: goalProject,
|
|
startedAt: new Date().toISOString(),
|
|
label: `goal-setup-${bundle.stage}-${bundle.goalSlug || goalProject}`,
|
|
});
|
|
|
|
const result = await server.waitForDecision();
|
|
await Bun.sleep(800);
|
|
server.stop();
|
|
|
|
if (result.exit) {
|
|
console.log(JSON.stringify({ decision: "dismissed", stage: bundle.stage }));
|
|
} else if (result.result) {
|
|
const output = {
|
|
decision: "submitted",
|
|
stage: result.result.stage,
|
|
result: result.result,
|
|
};
|
|
console.log(jsonFlag ? JSON.stringify(output) : JSON.stringify(output, null, 2));
|
|
}
|
|
process.exit(0);
|
|
|
|
} else if (args[0] === "review") {
|
|
// ============================================
|
|
// CODE REVIEW MODE
|
|
// ============================================
|
|
|
|
const reviewArgs = parseReviewArgs(args.slice(1));
|
|
// Argument-shape failures (unknown/typo'd flags) refuse to start a session:
|
|
// silently dropping them is how `--bse main` used to open a review as if
|
|
// nothing happened. Review has no strict-gate mode, so this is exit 1 like
|
|
// every other review startup failure.
|
|
if (reviewArgs.errors.length > 0) {
|
|
for (const parseError of reviewArgs.errors) console.error(parseError);
|
|
console.error("Run 'plannotator review --help' for usage.");
|
|
process.exit(1);
|
|
}
|
|
const urlArg = reviewArgs.prUrl;
|
|
const isPRMode = urlArg !== undefined;
|
|
const useLocal = isPRMode && reviewArgs.useLocal;
|
|
// Caller-pinned open state: `--base` / `--diff-type` seed this session only
|
|
// (nothing is persisted). Pinned sessions advertise openStatePinned so the
|
|
// client's mount effects don't auto-switch the diff away from the flags.
|
|
const openStatePinned = reviewArgs.base !== undefined || reviewArgs.diffType !== undefined;
|
|
let initialBaseFromFlags: string | undefined;
|
|
|
|
let rawPatch: string;
|
|
let gitRef: string;
|
|
let diffError: string | undefined;
|
|
let initialFingerprint: string | undefined;
|
|
let gitContext: Awaited<ReturnType<typeof prepareLocalReviewDiff>>["gitContext"] | undefined;
|
|
let prMetadata: Awaited<ReturnType<typeof fetchPR>>["metadata"] | undefined;
|
|
let prPatchIncomplete = false;
|
|
let initialDiffType: DiffType | WorkspaceDiffType | undefined;
|
|
let agentCwd: string | undefined;
|
|
let worktreePool: WorktreePool | undefined;
|
|
let worktreeCleanup: (() => void | Promise<void>) | undefined;
|
|
let workspace: Awaited<ReturnType<typeof buildLocalWorkspaceReview>> | undefined;
|
|
|
|
if (isPRMode) {
|
|
// --- PR Review Mode ---
|
|
// The base comes from the pull request — the open-state flags always
|
|
// error here (validated before any auth check or platform fetch).
|
|
await resolveCliReviewOpenState(reviewArgs, {
|
|
isPRMode: true,
|
|
isWorkspace: false,
|
|
resolvedDefaultDiffType: resolveDefaultDiffType(loadConfig()),
|
|
});
|
|
const prRef = parsePRUrl(urlArg);
|
|
if (!prRef) {
|
|
console.error(`Invalid PR/MR URL: ${urlArg}`);
|
|
console.error("Supported formats:");
|
|
console.error(" GitHub: https://github.com/owner/repo/pull/123");
|
|
console.error(" GitLab: https://gitlab.com/group/project/-/merge_requests/42");
|
|
process.exit(1);
|
|
}
|
|
|
|
const cliName = getCliName(prRef);
|
|
const cliUrl = getCliInstallUrl(prRef);
|
|
|
|
try {
|
|
await checkPRAuth(prRef);
|
|
} catch (err) {
|
|
const msg = err instanceof Error ? err.message : String(err);
|
|
if (msg.includes("not found") || msg.includes("ENOENT")) {
|
|
console.error(`${cliName === "gh" ? "GitHub" : "GitLab"} CLI (${cliName}) is not installed.`);
|
|
console.error(`Install it from ${cliUrl}`);
|
|
} else {
|
|
console.error(msg);
|
|
}
|
|
process.exit(1);
|
|
}
|
|
|
|
console.error(`Fetching ${getMRLabel(prRef)} ${getMRNumberLabel(prRef)} from ${getDisplayRepo(prRef)}...`);
|
|
try {
|
|
const pr = await fetchPR(prRef);
|
|
rawPatch = pr.rawPatch;
|
|
gitRef = `${getMRLabel(prRef)} ${getMRNumberLabel(prRef)}`;
|
|
prMetadata = pr.metadata;
|
|
prPatchIncomplete = pr.patchIncomplete ?? false;
|
|
} catch (err) {
|
|
console.error(err instanceof Error ? err.message : "Failed to fetch PR");
|
|
process.exit(1);
|
|
}
|
|
|
|
// --local: create a local checkout with the PR head for full file access.
|
|
// The checkout is built in the BACKGROUND — the platform diff is already
|
|
// in hand, so the review server starts immediately. The pool entry starts
|
|
// ready:false and flips to ready when the warmup completes; consumers that
|
|
// need real files (agent jobs, full-stack diff, code-nav) await it via
|
|
// pool.ensure().
|
|
if (useLocal && prMetadata) {
|
|
// Hoisted so catch block can clean up partially-created directories
|
|
let localPath: string | undefined;
|
|
let sessionDir: string | undefined;
|
|
try {
|
|
const repoDir = process.cwd();
|
|
const identifier = prMetadata.platform === "github"
|
|
? `${prMetadata.owner}-${prMetadata.repo}-${prMetadata.number}`
|
|
: `${prMetadata.projectPath.replace(/\//g, "-")}-${prMetadata.iid}`;
|
|
const suffix = Math.random().toString(36).slice(2, 8);
|
|
// Resolve tmpdir to its real path — on macOS, tmpdir() returns /var/folders/...
|
|
// but processes report /private/var/folders/... which breaks path stripping.
|
|
sessionDir = path.join(realpathSync(tmpdir()), `plannotator-pr-${identifier}-${suffix}`);
|
|
const prNumber = prMetadata.platform === "github" ? prMetadata.number : prMetadata.iid;
|
|
localPath = path.join(sessionDir, "pool", `pr-${prNumber}`);
|
|
const fetchRefStr = prMetadata.platform === "github"
|
|
? `refs/pull/${prMetadata.number}/head`
|
|
: `refs/merge-requests/${prMetadata.iid}/head`;
|
|
|
|
// Validate inputs from platform API to prevent git flag/path injection
|
|
if (prMetadata.baseBranch.includes('..') || prMetadata.baseBranch.startsWith('-')) throw new Error(`Invalid base branch: ${prMetadata.baseBranch}`);
|
|
if (!/^[0-9a-f]{40,64}$/i.test(prMetadata.baseSha)) throw new Error(`Invalid base SHA: ${prMetadata.baseSha}`);
|
|
|
|
// Detect same-repo vs cross-repo (must match both owner/repo AND host)
|
|
let isSameRepo = false;
|
|
try {
|
|
const remoteResult = await gitRuntime.runGit(["remote", "get-url", "origin"]);
|
|
if (remoteResult.exitCode === 0) {
|
|
const remoteUrl = remoteResult.stdout.trim();
|
|
const currentRepo = parseRemoteUrl(remoteUrl);
|
|
const prRepo = prMetadata.platform === "github"
|
|
? `${prMetadata.owner}/${prMetadata.repo}`
|
|
: prMetadata.projectPath;
|
|
const repoMatches = !!currentRepo && currentRepo.toLowerCase() === prRepo.toLowerCase();
|
|
// Extract host from remote URL to avoid cross-instance false positives (GHE)
|
|
const sshHost = remoteUrl.match(/^[^@]+@([^:]+):/)?.[1];
|
|
const httpsHost = (() => { try { return new URL(remoteUrl).hostname; } catch { return null; } })();
|
|
const remoteHost = (sshHost || httpsHost || "").toLowerCase();
|
|
const prHost = prMetadata.host.toLowerCase();
|
|
isSameRepo = repoMatches && remoteHost === prHost;
|
|
}
|
|
} catch { /* not in a git repo — cross-repo path */ }
|
|
|
|
// Capture closure values — the warmup outlives this block.
|
|
const warmupPath = localPath;
|
|
const warmupSessionDir = sessionDir;
|
|
const { baseBranch, baseSha, url: prUrl } = prMetadata;
|
|
const platform = prMetadata.platform;
|
|
const host = prMetadata.host;
|
|
const prRepo = platform === "github"
|
|
? `${prMetadata.owner}/${prMetadata.repo}`
|
|
: prMetadata.projectPath;
|
|
// Validate repo identifier to prevent flag injection via crafted URLs
|
|
if (/^-/.test(prRepo)) throw new Error(`Invalid repository identifier: ${prRepo}`);
|
|
|
|
// Async spawn for background steps — spawnSync would block the event
|
|
// loop and freeze the review server while cloning. Children are
|
|
// tracked so a process exit mid-warmup can kill them instead of
|
|
// letting an orphaned clone/fetch resurrect the removed session dir
|
|
// or register a stale worktree after we're gone.
|
|
const warmupProcs = new Set<ReturnType<typeof Bun.spawn>>();
|
|
const runStep = async (
|
|
cmd: string[],
|
|
opts: { cwd?: string; env?: Record<string, string> } = {},
|
|
): Promise<{ exitCode: number; stderr: string }> => {
|
|
const proc = Bun.spawn(cmd, {
|
|
cwd: opts.cwd,
|
|
env: opts.env,
|
|
stdout: "ignore",
|
|
stderr: "pipe",
|
|
});
|
|
warmupProcs.add(proc);
|
|
try {
|
|
const [stderr, exitCode] = await Promise.all([
|
|
new Response(proc.stderr).text(),
|
|
proc.exited,
|
|
]);
|
|
return { exitCode, stderr };
|
|
} finally {
|
|
warmupProcs.delete(proc);
|
|
}
|
|
};
|
|
|
|
const warmup: Promise<PoolEntry> = isSameRepo
|
|
? (async () => {
|
|
// ── Same-repo: fast worktree path (tracked spawns — see above) ──
|
|
// Fetch base branch so origin/<baseBranch> is current for agent
|
|
// diffs. Ensure baseSha is available (may fetch, which overwrites
|
|
// FETCH_HEAD). Both MUST happen before the PR head fetch since
|
|
// FETCH_HEAD is what worktree add uses — PR head fetch is last.
|
|
const baseFetchRes = await runStep(["git", "fetch", "origin", "--", baseBranch], { cwd: repoDir });
|
|
if (baseFetchRes.exitCode !== 0) throw new Error(`git fetch origin ${baseBranch} failed: ${baseFetchRes.stderr.trim()}`);
|
|
// Best-effort baseSha availability — mirrors ensureObjectAvailable
|
|
const catRes = await runStep(["git", "cat-file", "-t", baseSha], { cwd: repoDir });
|
|
if (catRes.exitCode !== 0) await runStep(["git", "fetch", "origin", "--", baseSha], { cwd: repoDir });
|
|
const headFetchRes = await runStep(["git", "fetch", "origin", "--", fetchRefStr], { cwd: repoDir });
|
|
if (headFetchRes.exitCode !== 0) throw new Error(`git fetch origin ${fetchRefStr} failed: ${headFetchRes.stderr.trim()}`);
|
|
|
|
const addRes = await runStep(["git", "worktree", "add", "--detach", warmupPath, "FETCH_HEAD"], { cwd: repoDir });
|
|
if (addRes.exitCode !== 0) throw new Error(`git worktree add failed: ${addRes.stderr.trim()}`);
|
|
return { path: warmupPath, prUrl, number: prNumber, ready: true };
|
|
})()
|
|
: (async () => {
|
|
// ── Cross-repo: shallow clone + fetch PR head ──
|
|
const cli = platform === "github" ? "gh" : "glab";
|
|
// gh/glab repo clone doesn't accept --hostname; set GH_HOST/GITLAB_HOST env instead
|
|
const isDefaultHost = host === "github.com" || host === "gitlab.com";
|
|
const cloneEnv = isDefaultHost ? undefined : {
|
|
...process.env,
|
|
...(platform === "github" ? { GH_HOST: host } : { GITLAB_HOST: host }),
|
|
} as Record<string, string>;
|
|
|
|
// Step 1: Fast skeleton clone (no checkout, depth 1 — minimal data transfer)
|
|
const cloneResult = await runStep(
|
|
[cli, "repo", "clone", prRepo, warmupPath, "--", "--depth=1", "--no-checkout"],
|
|
{ env: cloneEnv },
|
|
);
|
|
if (cloneResult.exitCode !== 0) {
|
|
throw new Error(`${cli} repo clone failed: ${cloneResult.stderr.trim()}`);
|
|
}
|
|
|
|
// Step 2: Fetch only the PR head ref (targeted, much faster than full fetch)
|
|
const fetchResult = await runStep(
|
|
["git", "fetch", "--depth=200", "origin", fetchRefStr],
|
|
{ cwd: warmupPath },
|
|
);
|
|
if (fetchResult.exitCode !== 0) throw new Error(`Failed to fetch PR head ref: ${fetchResult.stderr.trim()}`);
|
|
|
|
// Step 3: Checkout PR head (critical — if this fails, worktree is empty)
|
|
const checkoutResult = await runStep(["git", "checkout", "FETCH_HEAD"], { cwd: warmupPath });
|
|
if (checkoutResult.exitCode !== 0) {
|
|
throw new Error(`git checkout FETCH_HEAD failed: ${checkoutResult.stderr.trim()}`);
|
|
}
|
|
|
|
// Best-effort: create base refs so `git diff main...HEAD` and `git diff origin/main...HEAD` work
|
|
const baseFetch = await runStep(["git", "fetch", "--depth=200", "origin", baseSha], { cwd: warmupPath });
|
|
if (baseFetch.exitCode !== 0) console.error("Warning: failed to fetch baseSha, agent diffs may be inaccurate");
|
|
await runStep(["git", "branch", "--", baseBranch, baseSha], { cwd: warmupPath });
|
|
await runStep(["git", "update-ref", `refs/remotes/origin/${baseBranch}`, baseSha], { cwd: warmupPath });
|
|
|
|
return { path: warmupPath, prUrl, number: prNumber, ready: true };
|
|
})();
|
|
|
|
// --local only provides a sandbox path for agent processes.
|
|
// Do NOT set gitContext — that would contaminate the diff pipeline.
|
|
agentCwd = localPath;
|
|
|
|
// Pool starts with the initial PR as a not-ready entry; the seeded
|
|
// warmup flips it to ready (or leaves it not-ready on failure).
|
|
worktreePool = createWorktreePool(
|
|
{ sessionDir, repoDir, isSameRepo },
|
|
{ path: localPath, prUrl, number: prNumber, ready: false },
|
|
warmup,
|
|
);
|
|
|
|
worktreeCleanup = async () => {
|
|
if (isSameRepo && worktreePool) await worktreePool.cleanup(gitRuntime);
|
|
try { rmSync(warmupSessionDir, { recursive: true, force: true }); } catch {}
|
|
};
|
|
process.once("exit", () => {
|
|
// Best-effort sync cleanup: kill in-flight warmup children first so
|
|
// an orphaned clone/fetch can't write into the dir we're removing,
|
|
// then remove each pool worktree from git, then rm session dir.
|
|
for (const proc of warmupProcs) { try { proc.kill(); } catch {} }
|
|
if (isSameRepo) {
|
|
try {
|
|
for (const entry of worktreePool?.entries() ?? []) {
|
|
Bun.spawnSync(["git", "worktree", "remove", "--force", entry.path], { cwd: repoDir });
|
|
}
|
|
} catch {}
|
|
// Clear any registration left by a worktree add that completed
|
|
// after the kill (or by a not-ready entry the loop can't see).
|
|
try { Bun.spawnSync(["git", "worktree", "prune"], { cwd: repoDir }); } catch {}
|
|
}
|
|
try { Bun.spawnSync(["rm", "-rf", warmupSessionDir]); } catch {}
|
|
});
|
|
|
|
console.error(isSameRepo
|
|
? "Preparing local worktree in the background..."
|
|
: `Cloning ${prRepo} (shallow) in the background...`);
|
|
warmup.then(
|
|
() => console.error(`Local checkout ready at ${warmupPath}`),
|
|
(err) => {
|
|
console.error("Warning: local checkout failed — features needing local files (agents, full-stack diff) are limited");
|
|
console.error(err instanceof Error ? err.message : String(err));
|
|
try { rmSync(warmupSessionDir, { recursive: true, force: true }); } catch {}
|
|
},
|
|
);
|
|
} catch (err) {
|
|
console.error(`Warning: --local failed, falling back to remote diff`);
|
|
console.error(err instanceof Error ? err.message : String(err));
|
|
if (sessionDir) try { rmSync(sessionDir, { recursive: true, force: true }); } catch {}
|
|
agentCwd = undefined;
|
|
worktreePool = undefined;
|
|
worktreeCleanup = undefined;
|
|
}
|
|
}
|
|
} else {
|
|
// --- Local Review Mode ---
|
|
const config = loadConfig();
|
|
const managedVcs = await detectManagedVcs(process.cwd(), reviewArgs.vcsType);
|
|
const forcedVcs = !!reviewArgs.vcsType && reviewArgs.vcsType !== "auto";
|
|
|
|
if (managedVcs || forcedVcs) {
|
|
const providerId = (managedVcs?.id ?? reviewArgs.vcsType) as
|
|
| "git"
|
|
| "gitbutler"
|
|
| "jj"
|
|
| "p4"
|
|
| undefined;
|
|
const openState = await resolveCliReviewOpenState(reviewArgs, {
|
|
isPRMode: false,
|
|
isWorkspace: false,
|
|
providerId,
|
|
resolvedDefaultDiffType: resolveDefaultDiffType(config),
|
|
});
|
|
const diffResult = await prepareLocalReviewDiff({
|
|
vcsType: reviewArgs.vcsType,
|
|
requestedDiffType: openState.requestedDiffType,
|
|
requestedBase: openState.requestedBase,
|
|
configuredDiffType: resolveDefaultDiffType(config),
|
|
hideWhitespace: config.diffOptions?.hideWhitespace ?? false,
|
|
});
|
|
gitContext = diffResult.gitContext;
|
|
initialDiffType = diffResult.diffType;
|
|
rawPatch = diffResult.rawPatch;
|
|
gitRef = diffResult.gitRef;
|
|
diffError = diffResult.error;
|
|
initialFingerprint = diffResult.fingerprint;
|
|
// Forward the base the patch was actually computed against — without it
|
|
// the server would serve this patch under the detected default: a
|
|
// mixed-base review (wrong file-content fetches, wrong agent prompts).
|
|
if (openState.requestedBase !== undefined) initialBaseFromFlags = diffResult.base;
|
|
} else {
|
|
// Multi-repo workspace review has no base parameter — the open-state
|
|
// flags always error here.
|
|
await resolveCliReviewOpenState(reviewArgs, {
|
|
isPRMode: false,
|
|
isWorkspace: true,
|
|
resolvedDefaultDiffType: resolveDefaultDiffType(config),
|
|
});
|
|
workspace = await buildLocalWorkspaceReview(process.cwd(), {
|
|
configuredDiffType: resolveDefaultDiffType(config),
|
|
hideWhitespace: config.diffOptions?.hideWhitespace ?? false,
|
|
});
|
|
if (workspace.repos.length === 0) {
|
|
console.error("Not in a VCS repo and no nested Git/JJ/GitButler repositories were found.");
|
|
process.exit(1);
|
|
}
|
|
rawPatch = workspace.rawPatch;
|
|
gitRef = workspace.gitRef;
|
|
diffError = workspace.error;
|
|
initialDiffType = workspace.diffType;
|
|
agentCwd = workspace.root;
|
|
}
|
|
}
|
|
|
|
const reviewProject = (await detectProjectName()) ?? "_unknown";
|
|
|
|
// Start review server (even if empty - user can switch diff types in local mode)
|
|
const server = await startReviewServer({
|
|
rawPatch,
|
|
gitRef,
|
|
error: diffError,
|
|
origin: detectedOrigin,
|
|
project: reviewProject,
|
|
diffType: workspace ? (initialDiffType ?? workspace.diffType) : gitContext ? (initialDiffType ?? "unstaged") : undefined,
|
|
gitContext,
|
|
initialBase: initialBaseFromFlags,
|
|
initialBaseExplicit: initialBaseFromFlags !== undefined,
|
|
openStatePinned,
|
|
initialFingerprint,
|
|
prMetadata,
|
|
prPatchIncomplete,
|
|
workspace,
|
|
agentCwd,
|
|
worktreePool,
|
|
sharingEnabled,
|
|
shareBaseUrl,
|
|
// The approved branch below prints result.feedback after the prompt, so
|
|
// this CLI's origins may see approve-carrying menu items (spec §6.4).
|
|
approvalNotesSupported: supportsReviewApprovalNotes(detectedOrigin),
|
|
htmlContent: reviewHtmlContent,
|
|
onCleanup: worktreeCleanup,
|
|
onReady: async (url, isRemote, port) => {
|
|
if (tailscaleFlag) {
|
|
await handleTailscaleReady(port);
|
|
return;
|
|
}
|
|
handleReviewServerReady(url, isRemote, port);
|
|
|
|
if (isRemote && sharingEnabled && rawPatch) {
|
|
await writeRemoteShareLink(rawPatch, shareBaseUrl, "review changes", "diff only").catch(() => {});
|
|
}
|
|
},
|
|
});
|
|
|
|
registerSession({
|
|
pid: process.pid,
|
|
port: server.port,
|
|
url: server.url,
|
|
mode: "review",
|
|
project: reviewProject,
|
|
startedAt: new Date().toISOString(),
|
|
label: isPRMode ? `${getMRLabel(prMetadata!).toLowerCase()}-review-${getDisplayRepo(prMetadata!)}${getMRNumberLabel(prMetadata!)}` : `review-${reviewProject}`,
|
|
});
|
|
|
|
// Wait for user feedback
|
|
const result = await server.waitForDecision();
|
|
|
|
// Give browser time to receive response and update UI
|
|
await Bun.sleep(1500);
|
|
|
|
// Cleanup
|
|
server.stop();
|
|
|
|
// Output feedback (captured by slash command)
|
|
const output = buildReviewOutput(result, detectedOrigin);
|
|
console.log(jsonFlag ? JSON.stringify(output) : output.message);
|
|
process.exit(0);
|
|
|
|
} else if (args[0] === "annotate") {
|
|
// ============================================
|
|
// ANNOTATE MODE
|
|
// ============================================
|
|
|
|
// Startup failures below fire after flag parsing, so under a strict flag they
|
|
// must not exit 1 — that code means "the reviewer requested changes".
|
|
function exitAnnotateStartupFailure(message: string): never {
|
|
console.error(message);
|
|
process.exit(
|
|
annotateStartupFailureExitCode({
|
|
requireApproval: requireApprovalFlag,
|
|
resultFile,
|
|
}),
|
|
);
|
|
}
|
|
|
|
if (appFlag && staticFlag) {
|
|
exitAnnotateStartupFailure("--app and --static are mutually exclusive");
|
|
}
|
|
|
|
const rawFilePath = args[1];
|
|
if (!rawFilePath) {
|
|
exitAnnotateStartupFailure("Usage: plannotator annotate <file.md | file.txt | file.html | https://... | folder/> [--markdown] [--no-jina] [--app] [--static] [--gate] [--json] [--hook] [--require-approval] [--result-file <path>]");
|
|
}
|
|
|
|
// Use PLANNOTATOR_CWD if set (original working directory before script cd'd)
|
|
const projectRoot = process.env.PLANNOTATOR_CWD || process.cwd();
|
|
|
|
if (resultFile) {
|
|
try {
|
|
await assertResultPathAvailable(resultFile);
|
|
} catch (error) {
|
|
console.error(error instanceof Error ? error.message : String(error));
|
|
// Startup validation error: the gate could not start.
|
|
process.exit(STRICT_GATE_ERROR_EXIT_CODE);
|
|
}
|
|
}
|
|
|
|
// Strict invocations keep the exact legacy contract: args[1] is the target,
|
|
// a typo'd path stays a startup failure (exit 2), and stdout carries only
|
|
// the decision record. The tolerant token fallback below never runs. Same
|
|
// predicate as the exit-code path, so the two cannot drift.
|
|
const strictAnnotate = isStrictAnnotateInvocation({
|
|
requireApproval: requireApprovalFlag,
|
|
resultFile,
|
|
});
|
|
|
|
// Tolerant argument handling (#1182): slash-command hosts forward raw user
|
|
// words verbatim, so a non-strict invocation with several tokens probes
|
|
// each one instead of blindly taking args[1]. Exactly one token naming an
|
|
// existing target proceeds with it; several is an error naming every
|
|
// candidate (never guess); two or more unresolvable words become a handoff
|
|
// for the agent reading this output. Single-token invocations run the
|
|
// unchanged pipeline and keep every legacy error (a lone typo'd path stays
|
|
// "File not found" with exit 1), and unrecognized dash-prefixed tokens
|
|
// disable tolerance entirely so a typo'd flag errors the way it always
|
|
// did instead of being silently skipped.
|
|
const targetTokens = args.slice(1);
|
|
const tolerantMultiToken = !strictAnnotate && targetTokens.length > 1;
|
|
// Bare directory names only count as targets when they are the sole
|
|
// argument; in multi-token mode a stray word matching a directory (or `.`)
|
|
// must not hijack the fast path.
|
|
const annotateProbe = (token: string) =>
|
|
probeAnnotateToken(token, projectRoot, { bareDirectories: false });
|
|
|
|
let resolution: Awaited<ReturnType<typeof resolveAnnotateTarget>> | null =
|
|
tolerantMultiToken
|
|
? null
|
|
: await resolveAnnotateTarget({
|
|
rawFilePath,
|
|
projectRoot,
|
|
noJina: cliNoJina,
|
|
renderMarkdown: renderMarkdownFlag,
|
|
forceApp: appFlag,
|
|
forceStatic: staticFlag,
|
|
});
|
|
|
|
if (tolerantMultiToken) {
|
|
const selection = selectAnnotateTokenTarget(targetTokens, annotateProbe);
|
|
if (selection.kind === "single") {
|
|
resolution = await resolveAnnotateTarget({
|
|
rawFilePath: selection.candidate.value,
|
|
projectRoot,
|
|
noJina: cliNoJina,
|
|
renderMarkdown: renderMarkdownFlag,
|
|
forceApp: appFlag,
|
|
forceStatic: staticFlag,
|
|
});
|
|
} else if (selection.kind === "multiple") {
|
|
exitAnnotateStartupFailure(buildAmbiguousAnnotateArgsMessage(selection.candidates));
|
|
} else if (selection.kind === "none" && selection.words.length > 1) {
|
|
// Content flags only: transport flags (--gate/--json/--hook) describe
|
|
// this invocation's plumbing, and suggesting them would tell an agent
|
|
// to start a blocking interactive gate from a plain re-run.
|
|
const handoffFlags = [
|
|
...(renderMarkdownFlag ? ["--markdown"] : []),
|
|
...(cliNoJina ? ["--no-jina"] : []),
|
|
...(renderHtmlFlag ? ["--render-html"] : []),
|
|
];
|
|
const message = buildUnresolvedAnnotateArgsMessage({
|
|
words: selection.words,
|
|
flags: handoffFlags,
|
|
agentHandoff: true,
|
|
});
|
|
if (jsonFlag || hookFlag) {
|
|
// Machine-readable stdout stays reserved for decision records; the
|
|
// droid wrapper forwards stderr on failure.
|
|
exitAnnotateStartupFailure(message);
|
|
}
|
|
// Plain mode: a non-zero exit from Claude Code's bash-substitution
|
|
// skill prefix aborts the prompt before the model runs, so the handoff
|
|
// must land on stdout with exit 0 to reach the agent at all.
|
|
console.log(message);
|
|
process.exit(0);
|
|
}
|
|
// "flagged" (unrecognized dash tokens) or a single unresolvable word:
|
|
// fall through to the unchanged pipeline on args[1] so its legacy
|
|
// failure surfaces verbatim.
|
|
}
|
|
|
|
if (resolution === null) {
|
|
resolution = await resolveAnnotateTarget({
|
|
rawFilePath,
|
|
projectRoot,
|
|
noJina: cliNoJina,
|
|
renderMarkdown: renderMarkdownFlag,
|
|
forceApp: appFlag,
|
|
forceStatic: staticFlag,
|
|
});
|
|
}
|
|
|
|
if (!resolution.ok) {
|
|
exitAnnotateStartupFailure(resolution.message);
|
|
}
|
|
|
|
const {
|
|
markdown,
|
|
rawHtml,
|
|
absolutePath,
|
|
folderPath,
|
|
annotateMode,
|
|
sourceInfo,
|
|
sourceConverted,
|
|
isUrl,
|
|
liveApp: liveAppResolved,
|
|
} = resolution;
|
|
|
|
// Remote hard-off (layer 1 of 3; the server throw and the proxy's
|
|
// unconditional loopback bind are the others). No override env var exists
|
|
// on purpose: a live proxy relays the user's authenticated dev app.
|
|
if (liveAppResolved && isRemoteSession()) {
|
|
exitAnnotateStartupFailure(LIVE_APP_REMOTE_MESSAGE);
|
|
}
|
|
|
|
// --tailscale is the same exposure in different clothes: the annotate
|
|
// server stays loopback-bound but is published across the tailnet through
|
|
// the serve proxy, so a live proxy would relay the user's authenticated
|
|
// dev app to every tailnet peer. Hard-off, matching how the annotate agent
|
|
// terminal treats tailnet publication; the server throw backstops this.
|
|
if (liveAppResolved && tailscaleFlag) {
|
|
exitAnnotateStartupFailure(
|
|
"Live app annotation is unavailable with --tailscale (the session is reachable across your tailnet). Run without --tailscale, or use --static to annotate a converted snapshot of the page.",
|
|
);
|
|
}
|
|
|
|
const annotateProject = (await detectProjectName()) ?? "_unknown";
|
|
|
|
// Start the annotate server (reuses plan editor HTML)
|
|
const server = await startAnnotateServer({
|
|
markdown,
|
|
filePath: absolutePath,
|
|
origin: detectedOrigin,
|
|
mode: liveAppResolved ? "annotate-app" : annotateMode,
|
|
liveApp: liveAppResolved
|
|
? {
|
|
targetUrl: absolutePath,
|
|
bridgeScript: BRIDGE_SCRIPT,
|
|
bridgeBootstrap: LIVE_BRIDGE_BOOTSTRAP,
|
|
annotationCss: ANNOTATION_HIGHLIGHT_CSS,
|
|
}
|
|
: undefined,
|
|
folderPath,
|
|
sourceInfo,
|
|
sourceConverted,
|
|
sharingEnabled,
|
|
shareBaseUrl,
|
|
pasteApiUrl,
|
|
gate: gateFlag,
|
|
approvalNotesSupported: supportsAnnotateApprovalNotes({
|
|
gate: gateFlag,
|
|
json: jsonFlag,
|
|
hook: hookFlag,
|
|
}),
|
|
clientLeaseSupported: supportsAnnotateClientLease({
|
|
gate: gateFlag,
|
|
json: jsonFlag,
|
|
hook: hookFlag,
|
|
isRemote: isRemoteSession(),
|
|
}),
|
|
rawHtml,
|
|
renderHtml: !!rawHtml,
|
|
convertHtml: renderMarkdownFlag,
|
|
agentCwd: projectRoot,
|
|
project: annotateProject,
|
|
htmlContent: planHtmlContent,
|
|
tailnetPublished: tailscaleFlag,
|
|
onReady: async (url, isRemote, port) => {
|
|
if (tailscaleFlag) {
|
|
await handleTailscaleReady(port);
|
|
return;
|
|
}
|
|
handleAnnotateServerReady(url, isRemote, port);
|
|
|
|
if (isRemote && sharingEnabled) {
|
|
if (rawHtml) {
|
|
await writeRemoteShareLink("", shareBaseUrl, "annotate", "HTML document only", {
|
|
rawHtml: inlineHtmlLocalAssets(rawHtml, absolutePath),
|
|
pasteApiUrl,
|
|
}).catch(() => {});
|
|
} else if (markdown) {
|
|
await writeRemoteShareLink(markdown, shareBaseUrl, "annotate", "document only").catch(() => {});
|
|
}
|
|
}
|
|
},
|
|
});
|
|
|
|
registerSession({
|
|
pid: process.pid,
|
|
port: server.port,
|
|
url: server.url,
|
|
mode: "annotate",
|
|
project: annotateProject,
|
|
startedAt: new Date().toISOString(),
|
|
label: folderPath
|
|
? `annotate-${path.basename(folderPath)}`
|
|
: `annotate-${isUrl ? hostnameOrFallback(absolutePath) : path.basename(absolutePath)}`,
|
|
});
|
|
|
|
await completeAnnotateCommand({
|
|
waitForDecision: server.waitForDecision,
|
|
settleAfterDecision: () => Bun.sleep(1500),
|
|
stopServer: server.stop,
|
|
requireApproval: requireApprovalFlag,
|
|
resultFile,
|
|
emitLegacyOutcome: emitAnnotateOutcome,
|
|
});
|
|
|
|
} else if (args[0] === "annotate-last" || args[0] === "last") {
|
|
// ============================================
|
|
// ANNOTATE LAST MESSAGE MODE
|
|
// ============================================
|
|
|
|
const projectRoot = process.env.PLANNOTATOR_CWD || process.cwd();
|
|
const stdinIdx = args.indexOf("--stdin");
|
|
const stdinFlag = stdinIdx !== -1;
|
|
if (stdinFlag) args.splice(stdinIdx, 1);
|
|
const codexThreadId = process.env.CODEX_THREAD_ID;
|
|
const isCodex = !!codexThreadId;
|
|
const isDroid = detectedOrigin === "droid";
|
|
const isCopilot = detectedOrigin === "copilot-cli";
|
|
|
|
// Collect up to N recent assistant messages so the user can pick the right
|
|
// one — defaults to the same selection as the legacy "last message"
|
|
// behavior (index 0). Necessary because the newest transcript entry isn't
|
|
// always the message the user intended to annotate (e.g., after /rewind).
|
|
// 25 covers long conversations worth of rewinds without flooding the
|
|
// picker; the list scrolls past this if more are shown.
|
|
const RECENT_MESSAGES_LIMIT = 25;
|
|
let lastMessage: RenderedMessage | null = null;
|
|
let recentMessages: RenderedMessage[] = [];
|
|
|
|
// Copilot CLI sets no env fingerprint, so detection matches ancestor pids
|
|
// against session-state inuse locks (spawns ps). Only attempted when no
|
|
// earlier branch claims the invocation.
|
|
let copilotLockSessionDir: string | null = null;
|
|
let copilotSessionDir: string | null = null;
|
|
if (!stdinFlag && !isCodex && !isDroid) {
|
|
copilotLockSessionDir = findCopilotSessionByAncestorPids();
|
|
copilotSessionDir = copilotLockSessionDir ??
|
|
(isCopilot ? findCopilotSessionForCwd(projectRoot) : null);
|
|
}
|
|
const copilotDetected = isCopilot || copilotSessionDir !== null;
|
|
|
|
if (stdinFlag) {
|
|
const text = (await Bun.stdin.text()).trim();
|
|
if (text) {
|
|
lastMessage = { messageId: "stdin", text, lineNumbers: [] };
|
|
}
|
|
} else if (codexThreadId) {
|
|
// Codex path: find rollout by thread ID
|
|
if (process.env.PLANNOTATOR_DEBUG) {
|
|
console.error(`[DEBUG] Codex detected, thread ID: ${codexThreadId}`);
|
|
}
|
|
// A thread can span multiple rollout files; the newest segment may be
|
|
// empty or aborted, so fall back until one yields a message (#1367).
|
|
for (const rolloutPath of findCodexRolloutsByThreadId(codexThreadId)) {
|
|
if (process.env.PLANNOTATOR_DEBUG) {
|
|
console.error(`[DEBUG] Rollout: ${rolloutPath}`);
|
|
}
|
|
const recent = getRecentCodexMessages(rolloutPath, RECENT_MESSAGES_LIMIT, { beforeActiveTurn: true })
|
|
.map((m) => ({ messageId: m.messageId, text: m.text, lineNumbers: [], timestamp: m.timestamp }));
|
|
if (recent.length > 0) {
|
|
recentMessages = recent;
|
|
lastMessage = recent[0];
|
|
break;
|
|
}
|
|
}
|
|
} else if (isDroid) {
|
|
// Droid/Factory path: resolve the current repo's session log from
|
|
// ~/.factory/sessions/<cwd-slug>/*.jsonl. Factory does not expose the same
|
|
// per-process session metadata files as Claude Code, so the best available
|
|
// selector is "newest current-session candidate for this cwd", with an
|
|
// ancestor walk fallback for users who `cd` into a subdirectory after
|
|
// session start.
|
|
if (process.env.PLANNOTATOR_DEBUG) {
|
|
console.error(`[DEBUG] Droid detected, project root: ${projectRoot}`);
|
|
}
|
|
|
|
const cwdLogs = findDroidSessionLogsForCwd(projectRoot);
|
|
const ancestorLogs = cwdLogs.length === 0
|
|
? findDroidSessionLogsByAncestorWalk(projectRoot)
|
|
: [];
|
|
|
|
if (process.env.PLANNOTATOR_DEBUG) {
|
|
console.error(`[DEBUG] Droid CWD session logs (mtime): ${cwdLogs.length ? cwdLogs.join(", ") : "(none)"}`);
|
|
if (cwdLogs.length === 0) {
|
|
console.error(`[DEBUG] Droid ancestor walk: ${ancestorLogs.length ? ancestorLogs.join(", ") : "(none)"}`);
|
|
}
|
|
}
|
|
|
|
const droidLog = resolveDroidSessionLogForCwd(projectRoot);
|
|
if (process.env.PLANNOTATOR_DEBUG) {
|
|
console.error(`[DEBUG] Droid selected log: ${droidLog ?? "(none)"}`);
|
|
}
|
|
if (droidLog) {
|
|
recentMessages = getRecentRenderedMessages(droidLog, RECENT_MESSAGES_LIMIT);
|
|
lastMessage = recentMessages[0] ?? null;
|
|
}
|
|
} else if (copilotDetected) {
|
|
// Copilot path: prefer the session whose inuse lock an ancestor copilot
|
|
// process holds; with the origin override and no lock match, fall back
|
|
// to the cwd heuristic.
|
|
if (process.env.PLANNOTATOR_DEBUG) {
|
|
console.error(`[DEBUG] Copilot detected, project root: ${projectRoot}`);
|
|
console.error(`[DEBUG] Copilot ancestor lock session: ${copilotLockSessionDir ?? "(none)"}`);
|
|
console.error(`[DEBUG] Copilot selected session: ${copilotSessionDir ?? "(none)"}`);
|
|
}
|
|
if (copilotSessionDir) {
|
|
recentMessages = getRecentCopilotMessages(copilotSessionDir, RECENT_MESSAGES_LIMIT)
|
|
.map((m) => ({ messageId: m.messageId, text: m.text, lineNumbers: [], timestamp: m.timestamp }));
|
|
lastMessage = recentMessages[0] ?? null;
|
|
}
|
|
} else {
|
|
// Claude Code path: resolve session log
|
|
//
|
|
// Strategy (most precise → least precise):
|
|
// 1. Ancestor-PID session metadata: walk up the process tree checking
|
|
// ~/.claude/sessions/<pid>.json at each hop. When invoked from a slash
|
|
// command's `!` bang, the direct parent is a bash subshell — Claude's
|
|
// session file is a few hops up. Deterministic when it matches.
|
|
// 2. Cwd-scan of session metadata: read every ~/.claude/sessions/*.json,
|
|
// filter by cwd, pick the most recent startedAt. Better than mtime
|
|
// guessing because it uses session-level metadata.
|
|
// 3. CWD slug match (mtime-based): legacy behavior — picks the most
|
|
// recently modified jsonl in the project dir. Fragile when multiple
|
|
// sessions exist for the same project.
|
|
// 4. Ancestor directory walk: handles the case where the user `cd`'d
|
|
// deeper into a subdirectory after session start.
|
|
|
|
if (process.env.PLANNOTATOR_DEBUG) {
|
|
console.error(`[DEBUG] Project root: ${projectRoot}`);
|
|
console.error(`[DEBUG] PPID: ${process.ppid}`);
|
|
}
|
|
|
|
/** Try each log path, return the first that yields a message. */
|
|
function tryLogCandidates(label: string, getPaths: () => string[]): void {
|
|
if (lastMessage) return;
|
|
const paths = getPaths();
|
|
if (process.env.PLANNOTATOR_DEBUG) {
|
|
console.error(`[DEBUG] ${label}: ${paths.length ? paths.join(", ") : "(none)"}`);
|
|
}
|
|
for (const logPath of paths) {
|
|
// Claude Code transcripts are trees: `/rewind` re-parents the next
|
|
// message rather than truncating, so a file-order read returns
|
|
// orphaned messages. Follow the id chain instead.
|
|
const recent = getRecentRenderedMessages(logPath, RECENT_MESSAGES_LIMIT, {
|
|
activeBranchOnly: true,
|
|
});
|
|
if (recent.length > 0) {
|
|
recentMessages = recent;
|
|
lastMessage = recent[0];
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
|
|
// 1. Walk ancestor PIDs for a matching session metadata file
|
|
const ancestorLog = resolveSessionLogByAncestorPids();
|
|
tryLogCandidates("Ancestor PID session metadata", () => ancestorLog ? [ancestorLog] : []);
|
|
|
|
// 2. Scan all session metadata files for one whose cwd matches
|
|
const cwdScanLog = resolveSessionLogByCwdScan({ cwd: projectRoot });
|
|
tryLogCandidates("Cwd-scan session metadata", () => cwdScanLog ? [cwdScanLog] : []);
|
|
|
|
// 3. Fall back to CWD slug match (mtime-based)
|
|
tryLogCandidates("CWD slug match (mtime)", () => findSessionLogsForCwd(projectRoot));
|
|
|
|
// 4. Fall back to ancestor directory walk
|
|
tryLogCandidates("Directory ancestor walk", () => findSessionLogsByAncestorWalk(projectRoot));
|
|
}
|
|
|
|
if (!lastMessage) {
|
|
console.error(stdinFlag
|
|
? "No message content received on stdin."
|
|
: "No rendered assistant message found in session logs.");
|
|
process.exit(1);
|
|
}
|
|
|
|
if (process.env.PLANNOTATOR_DEBUG) {
|
|
console.error(`[DEBUG] Found message ${lastMessage.messageId} (${lastMessage.text.length} chars)`);
|
|
}
|
|
|
|
const annotatedMessage = lastMessage;
|
|
const annotateProject = (await detectProjectName()) ?? "_unknown";
|
|
|
|
// Only ship the picker list when there's a choice to make. The client uses
|
|
// its presence (length > 1) as the signal to render the picker UI.
|
|
const pickerMessages = recentMessages.length > 1
|
|
? recentMessages.map((m) => ({ messageId: m.messageId, text: m.text, timestamp: m.timestamp }))
|
|
: undefined;
|
|
|
|
const server = await startAnnotateServer({
|
|
markdown: annotatedMessage.text,
|
|
filePath: "last-message",
|
|
origin: copilotDetected ? "copilot-cli" : detectedOrigin,
|
|
mode: "annotate-last",
|
|
sharingEnabled,
|
|
shareBaseUrl,
|
|
pasteApiUrl,
|
|
gate: gateFlag,
|
|
approvalNotesSupported: supportsAnnotateApprovalNotes({
|
|
gate: gateFlag,
|
|
json: jsonFlag,
|
|
hook: hookFlag,
|
|
}),
|
|
clientLeaseSupported: supportsAnnotateClientLease({
|
|
gate: gateFlag,
|
|
json: jsonFlag,
|
|
hook: hookFlag,
|
|
isRemote: isRemoteSession(),
|
|
}),
|
|
htmlContent: planHtmlContent,
|
|
recentMessages: pickerMessages,
|
|
tailnetPublished: tailscaleFlag,
|
|
onReady: async (url, isRemote, port) => {
|
|
if (tailscaleFlag) {
|
|
await handleTailscaleReady(port);
|
|
return;
|
|
}
|
|
handleAnnotateServerReady(url, isRemote, port);
|
|
|
|
if (isRemote && sharingEnabled) {
|
|
await writeRemoteShareLink(annotatedMessage.text, shareBaseUrl, "annotate", "message only").catch(() => {});
|
|
}
|
|
},
|
|
});
|
|
|
|
registerSession({
|
|
pid: process.pid,
|
|
port: server.port,
|
|
url: server.url,
|
|
mode: "annotate",
|
|
project: annotateProject,
|
|
startedAt: new Date().toISOString(),
|
|
label: `annotate-last`,
|
|
});
|
|
|
|
const result = await server.waitForDecision();
|
|
|
|
await Bun.sleep(1500);
|
|
|
|
server.stop();
|
|
|
|
emitAnnotateOutcome(result);
|
|
process.exit(0);
|
|
|
|
} else if (args[0] === "guide") {
|
|
// ============================================
|
|
// GUIDE TOOLS: list saved guides, export portable HTML, share links
|
|
// ============================================
|
|
// The guide CLI parses its own flags, and `--json` is one of them; `args`
|
|
// had the annotate gate flags (`--json` included) stripped above, so hand
|
|
// it everything after "guide" from the raw argv instead.
|
|
const result = await runGuideCli(rawArgs.slice(rawArgs.indexOf("guide") + 1), process.env, process.env.PLANNOTATOR_CWD || process.cwd());
|
|
if (result.stdout) process.stdout.write(result.stdout);
|
|
if (result.stderr) process.stderr.write(result.stderr);
|
|
process.exit(result.code);
|
|
|
|
} else if (args[0] === "archive") {
|
|
// ============================================
|
|
// ARCHIVE BROWSER MODE
|
|
// ============================================
|
|
|
|
const archiveProject = (await detectProjectName()) ?? "_unknown";
|
|
|
|
const server = await startPlannotatorServer({
|
|
plan: "",
|
|
origin: detectedOrigin,
|
|
mode: "archive",
|
|
sharingEnabled,
|
|
shareBaseUrl,
|
|
htmlContent: planHtmlContent,
|
|
onReady: (url, isRemote, port) => {
|
|
handleServerReady(url, isRemote, port);
|
|
},
|
|
});
|
|
|
|
registerSession({
|
|
pid: process.pid,
|
|
port: server.port,
|
|
url: server.url,
|
|
mode: "archive",
|
|
project: archiveProject,
|
|
startedAt: new Date().toISOString(),
|
|
label: `archive-${archiveProject}`,
|
|
});
|
|
|
|
await server.waitForDone!();
|
|
|
|
await Bun.sleep(500);
|
|
server.stop();
|
|
process.exit(0);
|
|
|
|
} else if (args[0] === "opencode-plan") {
|
|
// ============================================
|
|
// OPENCODE PLUGIN PLAN REVIEW MODE
|
|
// ============================================
|
|
//
|
|
// Internal CLI bridge used when the OpenCode plugin is running in a host
|
|
// that cannot import Bun-only server modules directly.
|
|
|
|
const inputJson = await Bun.stdin.text();
|
|
const input = parseOpenCodeBridgeInput<{ plan?: unknown; timeoutSeconds?: unknown }>(
|
|
"opencode-plan",
|
|
inputJson,
|
|
);
|
|
|
|
const planContent = typeof input.plan === "string" ? input.plan : "";
|
|
if (!planContent.trim()) {
|
|
console.error("No plan content in opencode-plan input");
|
|
process.exit(1);
|
|
}
|
|
|
|
const timeoutSeconds = input.timeoutSeconds === null
|
|
? null
|
|
: typeof input.timeoutSeconds === "number" && Number.isFinite(input.timeoutSeconds) && input.timeoutSeconds > 0
|
|
? input.timeoutSeconds
|
|
: null;
|
|
|
|
const planProject = (await detectProjectName()) ?? "_unknown";
|
|
const bridgeSharingEnabled = getBridgeSharingEnabled(input);
|
|
const bridgeShareBaseUrl = getBridgeShareBaseUrl(input);
|
|
const bridgePasteApiUrl = getBridgePasteApiUrl(input);
|
|
const server = await startPlannotatorServer({
|
|
plan: planContent,
|
|
origin: "opencode",
|
|
sharingEnabled: bridgeSharingEnabled,
|
|
shareBaseUrl: bridgeShareBaseUrl,
|
|
pasteApiUrl: bridgePasteApiUrl,
|
|
htmlContent: planHtmlContent,
|
|
opencodeClient: makeOpenCodeBridgeClient(input.agents),
|
|
onReady: async (url, isRemote, port) => {
|
|
await handleServerReady(url, isRemote, port);
|
|
|
|
if (isRemote && bridgeSharingEnabled) {
|
|
await writeRemoteShareLink(planContent, bridgeShareBaseUrl, "review the plan", "plan only").catch(() => {});
|
|
}
|
|
},
|
|
});
|
|
|
|
registerSession({
|
|
pid: process.pid,
|
|
port: server.port,
|
|
url: server.url,
|
|
mode: "plan",
|
|
project: planProject,
|
|
startedAt: new Date().toISOString(),
|
|
label: `plan-${planProject}`,
|
|
});
|
|
|
|
let result: Awaited<ReturnType<typeof server.waitForDecision>>;
|
|
try {
|
|
result = await waitForPlanReviewDecision({
|
|
waitForDecision: server.waitForDecision,
|
|
timeoutMs: timeoutSeconds === null ? null : timeoutSeconds * 1000,
|
|
timeoutResult: {
|
|
approved: false,
|
|
feedback: `[Plannotator] No response within ${timeoutSeconds} seconds. Port released automatically. Please call submit_plan again.`,
|
|
},
|
|
});
|
|
await waitForPlanReviewCloseDelay(1500);
|
|
} finally {
|
|
await server.stop();
|
|
}
|
|
|
|
console.log(JSON.stringify({
|
|
approved: result.approved,
|
|
...(result.feedback && { feedback: result.feedback }),
|
|
...(result.savedPath && { savedPath: result.savedPath }),
|
|
...(result.agentSwitch && { agentSwitch: result.agentSwitch }),
|
|
}));
|
|
process.exit(0);
|
|
|
|
} else if (args[0] === "opencode-review") {
|
|
// ============================================
|
|
// OPENCODE PLUGIN CODE REVIEW MODE
|
|
// ============================================
|
|
//
|
|
// Internal structured CLI bridge used when the OpenCode plugin is running
|
|
// in a host that cannot import Bun-only server modules directly.
|
|
|
|
const inputJson = await Bun.stdin.text();
|
|
const input = parseOpenCodeBridgeInput<{ arguments?: unknown; supportsApprovalNotes?: unknown }>(
|
|
"opencode-review",
|
|
inputJson,
|
|
);
|
|
const reviewArgs = parseReviewArgs(typeof input.arguments === "string" ? input.arguments : "");
|
|
// Same refusal as the direct `review` branch. Errors go to stderr so the
|
|
// bridge's machine-readable stdout contract stays untouched.
|
|
if (reviewArgs.errors.length > 0) {
|
|
for (const parseError of reviewArgs.errors) console.error(parseError);
|
|
console.error("Run 'plannotator review --help' for usage.");
|
|
process.exit(1);
|
|
}
|
|
const urlArg = reviewArgs.prUrl;
|
|
const isPRMode = urlArg !== undefined;
|
|
// Caller-pinned open state (--base/--diff-type through the plugin's
|
|
// verbatim rawArgs forward) — session-only seed, mirrors the direct
|
|
// `review` branch.
|
|
const openStatePinned = reviewArgs.base !== undefined || reviewArgs.diffType !== undefined;
|
|
let initialBaseFromFlags: string | undefined;
|
|
|
|
let rawPatch: string;
|
|
let gitRef: string;
|
|
let diffError: string | undefined;
|
|
let initialFingerprint: string | undefined;
|
|
let userDiffType: DiffType | WorkspaceDiffType | undefined;
|
|
let gitContext: Awaited<ReturnType<typeof prepareLocalReviewDiff>>["gitContext"] | undefined;
|
|
let prMetadata: Awaited<ReturnType<typeof fetchPR>>["metadata"] | undefined;
|
|
let prPatchIncomplete = false;
|
|
let workspace: Awaited<ReturnType<typeof buildLocalWorkspaceReview>> | undefined;
|
|
let agentCwd: string | undefined;
|
|
|
|
if (isPRMode) {
|
|
await resolveCliReviewOpenState(reviewArgs, {
|
|
isPRMode: true,
|
|
isWorkspace: false,
|
|
resolvedDefaultDiffType: resolveDefaultDiffType(loadConfig()),
|
|
});
|
|
const prRef = parsePRUrl(urlArg);
|
|
if (!prRef) {
|
|
console.error(`Invalid PR/MR URL: ${urlArg}`);
|
|
process.exit(1);
|
|
}
|
|
|
|
console.error(`Fetching ${getMRLabel(prRef)} ${getMRNumberLabel(prRef)} from ${getDisplayRepo(prRef)}...`);
|
|
|
|
try {
|
|
await checkPRAuth(prRef);
|
|
} catch (err) {
|
|
const cliName = getCliName(prRef);
|
|
console.error(err instanceof Error ? err.message : `${cliName} auth check failed`);
|
|
process.exit(1);
|
|
}
|
|
|
|
try {
|
|
const pr = await fetchPR(prRef);
|
|
rawPatch = pr.rawPatch;
|
|
gitRef = `${getMRLabel(prRef)} ${getMRNumberLabel(prRef)}`;
|
|
prMetadata = pr.metadata;
|
|
prPatchIncomplete = pr.patchIncomplete ?? false;
|
|
} catch (err) {
|
|
console.error(err instanceof Error ? err.message : `Failed to fetch ${getMRLabel(prRef)} ${getMRNumberLabel(prRef)}`);
|
|
process.exit(1);
|
|
}
|
|
} else {
|
|
console.error("Opening code review UI...");
|
|
|
|
const config = loadConfig();
|
|
const cwd = process.env.PLANNOTATOR_CWD || process.cwd();
|
|
const managedVcs = await detectManagedVcs(cwd, reviewArgs.vcsType);
|
|
const forcedVcs = !!reviewArgs.vcsType && reviewArgs.vcsType !== "auto";
|
|
|
|
if (managedVcs || forcedVcs) {
|
|
const providerId = (managedVcs?.id ?? reviewArgs.vcsType) as
|
|
| "git"
|
|
| "gitbutler"
|
|
| "jj"
|
|
| "p4"
|
|
| undefined;
|
|
const openState = await resolveCliReviewOpenState(reviewArgs, {
|
|
isPRMode: false,
|
|
isWorkspace: false,
|
|
providerId,
|
|
resolvedDefaultDiffType: resolveDefaultDiffType(config),
|
|
cwd,
|
|
});
|
|
const diffResult = await prepareLocalReviewDiff({
|
|
cwd,
|
|
vcsType: reviewArgs.vcsType,
|
|
requestedDiffType: openState.requestedDiffType,
|
|
requestedBase: openState.requestedBase,
|
|
configuredDiffType: resolveDefaultDiffType(config),
|
|
hideWhitespace: config.diffOptions?.hideWhitespace ?? false,
|
|
});
|
|
gitContext = diffResult.gitContext;
|
|
userDiffType = diffResult.diffType;
|
|
rawPatch = diffResult.rawPatch;
|
|
gitRef = diffResult.gitRef;
|
|
diffError = diffResult.error;
|
|
initialFingerprint = diffResult.fingerprint;
|
|
if (openState.requestedBase !== undefined) initialBaseFromFlags = diffResult.base;
|
|
} else {
|
|
await resolveCliReviewOpenState(reviewArgs, {
|
|
isPRMode: false,
|
|
isWorkspace: true,
|
|
resolvedDefaultDiffType: resolveDefaultDiffType(config),
|
|
cwd,
|
|
});
|
|
workspace = await buildLocalWorkspaceReview(cwd, {
|
|
configuredDiffType: resolveDefaultDiffType(config),
|
|
hideWhitespace: config.diffOptions?.hideWhitespace ?? false,
|
|
});
|
|
if (workspace.repos.length === 0) {
|
|
console.error("Not in a VCS repo and no nested Git/JJ/GitButler repositories were found.");
|
|
process.exit(1);
|
|
}
|
|
rawPatch = workspace.rawPatch;
|
|
gitRef = workspace.gitRef;
|
|
diffError = workspace.error;
|
|
userDiffType = workspace.diffType;
|
|
agentCwd = workspace.root;
|
|
}
|
|
}
|
|
|
|
const bridgeSharingEnabled = getBridgeSharingEnabled(input);
|
|
const bridgeShareBaseUrl = getBridgeShareBaseUrl(input);
|
|
const reviewProject = (await detectProjectName()) ?? "_unknown";
|
|
|
|
const server = await startReviewServer({
|
|
rawPatch,
|
|
gitRef,
|
|
error: diffError,
|
|
origin: "opencode",
|
|
project: reviewProject,
|
|
diffType: isPRMode ? undefined : userDiffType,
|
|
gitContext,
|
|
initialBase: initialBaseFromFlags,
|
|
initialBaseExplicit: initialBaseFromFlags !== undefined,
|
|
openStatePinned,
|
|
initialFingerprint,
|
|
prMetadata,
|
|
prPatchIncomplete,
|
|
workspace,
|
|
agentCwd,
|
|
sharingEnabled: bridgeSharingEnabled,
|
|
shareBaseUrl: bridgeShareBaseUrl,
|
|
// Fail-closed approval-notes handshake: this branch's JSON record already
|
|
// carries feedback on approve, but DELIVERY to the agent lives in the
|
|
// independently-versioned plugin (buildReviewPromptFromBridgeOutcome,
|
|
// spec §6.3 #3), so the advert requires the plugin's own stdin
|
|
// declaration. An old plugin omits `supportsApprovalNotes`, the advert
|
|
// stays false, and no approve-carrying item renders — a new binary can
|
|
// never trick an old bridge into dropping a reviewer's note.
|
|
approvalNotesSupported:
|
|
supportsReviewApprovalNotes("opencode") && input.supportsApprovalNotes === true,
|
|
htmlContent: reviewHtmlContent,
|
|
opencodeClient: makeOpenCodeBridgeClient(input.agents),
|
|
onReady: (url, isRemote, port) => {
|
|
handleReviewServerReady(url, isRemote, port);
|
|
},
|
|
});
|
|
|
|
registerSession({
|
|
pid: process.pid,
|
|
port: server.port,
|
|
url: server.url,
|
|
mode: "review",
|
|
project: reviewProject,
|
|
startedAt: new Date().toISOString(),
|
|
label: isPRMode && prMetadata
|
|
? `${getMRLabel(prMetadata).toLowerCase()}-review-${getDisplayRepo(prMetadata)}${getMRNumberLabel(prMetadata)}`
|
|
: `review-${reviewProject}`,
|
|
});
|
|
|
|
const result = await server.waitForDecision();
|
|
await Bun.sleep(1500);
|
|
server.stop();
|
|
|
|
console.log(JSON.stringify({
|
|
decision: result.exit
|
|
? "dismissed"
|
|
: result.approved
|
|
? "approved"
|
|
: "annotated",
|
|
approved: result.approved,
|
|
isPRMode,
|
|
...(result.feedback && { feedback: result.feedback }),
|
|
...(result.agentSwitch && { agentSwitch: result.agentSwitch }),
|
|
}));
|
|
process.exit(0);
|
|
|
|
} else if (args[0] === "opencode-annotate-last") {
|
|
// ============================================
|
|
// OPENCODE PLUGIN ANNOTATE LAST MESSAGE MODE
|
|
// ============================================
|
|
|
|
const inputJson = await Bun.stdin.text();
|
|
const input = parseOpenCodeBridgeInput<{
|
|
gate?: unknown;
|
|
recentMessages?: unknown;
|
|
}>("opencode-annotate-last", inputJson);
|
|
|
|
const recentMessages = Array.isArray(input.recentMessages)
|
|
? input.recentMessages
|
|
.map((message): { messageId: string; text: string; timestamp?: string } | null => {
|
|
if (!message || typeof message !== "object") return null;
|
|
const record = message as Record<string, unknown>;
|
|
if (typeof record.text !== "string" || !record.text.trim()) return null;
|
|
return {
|
|
messageId: typeof record.messageId === "string" && record.messageId
|
|
? record.messageId
|
|
: crypto.randomUUID(),
|
|
text: record.text,
|
|
...(typeof record.timestamp === "string" && { timestamp: record.timestamp }),
|
|
};
|
|
})
|
|
.filter((message): message is { messageId: string; text: string; timestamp?: string } => message !== null)
|
|
: [];
|
|
|
|
const lastMessage = recentMessages[0] ?? null;
|
|
if (!lastMessage) {
|
|
console.error("No assistant message found in opencode-annotate-last input.");
|
|
process.exit(1);
|
|
}
|
|
|
|
console.error("Opening annotation UI for last message...");
|
|
|
|
const bridgeSharingEnabled = getBridgeSharingEnabled(input);
|
|
const bridgeShareBaseUrl = getBridgeShareBaseUrl(input);
|
|
const bridgePasteApiUrl = getBridgePasteApiUrl(input);
|
|
const annotateProject = (await detectProjectName()) ?? "_unknown";
|
|
const pickerMessages = recentMessages.length > 1 ? recentMessages : undefined;
|
|
|
|
const server = await startAnnotateServer({
|
|
markdown: lastMessage.text,
|
|
filePath: "last-message",
|
|
origin: "opencode",
|
|
mode: "annotate-last",
|
|
recentMessages: pickerMessages,
|
|
sharingEnabled: bridgeSharingEnabled,
|
|
shareBaseUrl: bridgeShareBaseUrl,
|
|
pasteApiUrl: bridgePasteApiUrl,
|
|
gate: input.gate === true,
|
|
approvalNotesSupported: input.gate === true,
|
|
// Same predicate as the CLI-flag branches, with this transport's inputs
|
|
// mapped onto it: `gate` arrives on stdin JSON (cli-bridge.ts forwards
|
|
// parseAnnotateArgs' `gate`); `json` is unconditionally true because
|
|
// emitOpenCodeAnnotateOutcome is this branch's only output path and always
|
|
// writes a structured decision record the bridge parses back; `hook` is
|
|
// false because no flags are parsed here and no hook decision protocol is
|
|
// emitted. Without this, `/plannotator-last --gate` under OpenCode hangs on
|
|
// waitForDecision() forever once every review tab is abandoned — the exact
|
|
// hang #1143 closed for the other three call sites.
|
|
clientLeaseSupported: supportsAnnotateClientLease({
|
|
gate: input.gate === true,
|
|
json: true,
|
|
hook: false,
|
|
isRemote: isRemoteSession(),
|
|
}),
|
|
htmlContent: planHtmlContent,
|
|
onReady: (url, isRemote, port) => {
|
|
handleAnnotateServerReady(url, isRemote, port);
|
|
},
|
|
});
|
|
|
|
registerSession({
|
|
pid: process.pid,
|
|
port: server.port,
|
|
url: server.url,
|
|
mode: "annotate",
|
|
project: annotateProject,
|
|
startedAt: new Date().toISOString(),
|
|
label: "annotate-last",
|
|
});
|
|
|
|
const result = await server.waitForDecision();
|
|
await Bun.sleep(1500);
|
|
server.stop();
|
|
|
|
emitOpenCodeAnnotateOutcome(result);
|
|
process.exit(0);
|
|
|
|
} else if (args[0] === "copilot-plan") {
|
|
// ============================================
|
|
// COPILOT CLI PLAN INTERCEPTION MODE
|
|
// ============================================
|
|
//
|
|
// Called by preToolUse hook on EVERY tool call in Copilot CLI.
|
|
// Must filter quickly and only activate for exit_plan_mode.
|
|
// No output = allow the tool call to proceed.
|
|
|
|
const eventJson = await Bun.stdin.text();
|
|
let event: { toolName: string; toolArgs: string; cwd: string; timestamp: number; sessionId?: string };
|
|
|
|
try {
|
|
event = JSON.parse(eventJson);
|
|
} catch {
|
|
// Can't parse input — allow the tool call
|
|
process.exit(0);
|
|
}
|
|
|
|
// FILTER: Only intercept exit_plan_mode
|
|
if (event.toolName !== "exit_plan_mode") {
|
|
process.exit(0); // No output = allow
|
|
}
|
|
|
|
// Find plan.md content (sessionId primary, newest plan.md fallback)
|
|
const planContent = findCopilotPlanContent(event.sessionId);
|
|
|
|
if (!planContent) {
|
|
// No plan.md found — allow exit_plan_mode to proceed normally
|
|
process.exit(0);
|
|
}
|
|
|
|
const planProject = (await detectProjectName()) ?? "_unknown";
|
|
|
|
const server = await startPlannotatorServer({
|
|
plan: planContent,
|
|
origin: "copilot-cli",
|
|
sharingEnabled,
|
|
shareBaseUrl,
|
|
pasteApiUrl,
|
|
htmlContent: planHtmlContent,
|
|
onReady: async (url, isRemote, port) => {
|
|
handleServerReady(url, isRemote, port);
|
|
|
|
if (isRemote && sharingEnabled) {
|
|
await writeRemoteShareLink(planContent, shareBaseUrl, "review the plan", "plan only").catch(() => {});
|
|
}
|
|
},
|
|
});
|
|
|
|
registerSession({
|
|
pid: process.pid,
|
|
port: server.port,
|
|
url: server.url,
|
|
mode: "plan",
|
|
project: planProject,
|
|
startedAt: new Date().toISOString(),
|
|
label: `plan-${planProject}`,
|
|
});
|
|
|
|
const result = await server.waitForDecision();
|
|
await Bun.sleep(1500);
|
|
server.stop();
|
|
|
|
// Output Copilot CLI permission decision format
|
|
if (result.approved) {
|
|
console.log(JSON.stringify({
|
|
permissionDecision: "allow",
|
|
}));
|
|
} else {
|
|
const feedback = getPlanDeniedPrompt("copilot-cli", undefined, {
|
|
toolName: getPlanToolName("copilot-cli"),
|
|
planFileRule: "",
|
|
feedback: result.feedback || "Plan changes requested",
|
|
});
|
|
console.log(JSON.stringify({
|
|
permissionDecision: "deny",
|
|
permissionDecisionReason: feedback,
|
|
}));
|
|
}
|
|
|
|
process.exit(0);
|
|
|
|
} else if (args[0] === "copilot-last") {
|
|
// ============================================
|
|
// COPILOT CLI ANNOTATE LAST MESSAGE MODE
|
|
// ============================================
|
|
|
|
const projectRoot = process.env.PLANNOTATOR_CWD || process.cwd();
|
|
|
|
if (process.env.PLANNOTATOR_DEBUG) {
|
|
console.error(`[DEBUG] Copilot CLI detected, project root: ${projectRoot}`);
|
|
}
|
|
|
|
// Prefer the session locked by an ancestor copilot process; the cwd
|
|
// heuristic can pick a stale session when several exist for one repo.
|
|
const lockSessionDir = findCopilotSessionByAncestorPids();
|
|
if (process.env.PLANNOTATOR_DEBUG) {
|
|
console.error(`[DEBUG] Ancestor lock session: ${lockSessionDir ?? "(none)"}`);
|
|
}
|
|
|
|
const sessionDir = lockSessionDir ?? findCopilotSessionForCwd(projectRoot);
|
|
|
|
if (!sessionDir) {
|
|
console.error("No Copilot CLI session found.");
|
|
process.exit(1);
|
|
}
|
|
|
|
if (process.env.PLANNOTATOR_DEBUG) {
|
|
console.error(`[DEBUG] Session dir: ${sessionDir}`);
|
|
}
|
|
|
|
const recent = getRecentCopilotMessages(sessionDir, 25);
|
|
const msg = recent[0] ?? null;
|
|
if (!msg) {
|
|
console.error("No assistant message found in Copilot CLI session.");
|
|
process.exit(1);
|
|
}
|
|
|
|
if (process.env.PLANNOTATOR_DEBUG) {
|
|
console.error(`[DEBUG] Found message (${msg.text.length} chars)`);
|
|
}
|
|
|
|
const annotateProject = (await detectProjectName()) ?? "_unknown";
|
|
const pickerMessages = recent.length > 1 ? recent : undefined;
|
|
|
|
const server = await startAnnotateServer({
|
|
markdown: msg.text,
|
|
filePath: "last-message",
|
|
origin: "copilot-cli",
|
|
mode: "annotate-last",
|
|
recentMessages: pickerMessages,
|
|
sharingEnabled,
|
|
shareBaseUrl,
|
|
gate: gateFlag,
|
|
approvalNotesSupported: supportsAnnotateApprovalNotes({
|
|
gate: gateFlag,
|
|
json: jsonFlag,
|
|
hook: hookFlag,
|
|
}),
|
|
clientLeaseSupported: supportsAnnotateClientLease({
|
|
gate: gateFlag,
|
|
json: jsonFlag,
|
|
hook: hookFlag,
|
|
isRemote: isRemoteSession(),
|
|
}),
|
|
htmlContent: planHtmlContent,
|
|
onReady: async (url, isRemote, port) => {
|
|
handleAnnotateServerReady(url, isRemote, port);
|
|
|
|
if (isRemote && sharingEnabled) {
|
|
await writeRemoteShareLink(msg.text, shareBaseUrl, "annotate", "message only").catch(() => {});
|
|
}
|
|
},
|
|
});
|
|
|
|
registerSession({
|
|
pid: process.pid,
|
|
port: server.port,
|
|
url: server.url,
|
|
mode: "annotate",
|
|
project: annotateProject,
|
|
startedAt: new Date().toISOString(),
|
|
label: `annotate-last`,
|
|
});
|
|
|
|
const result = await server.waitForDecision();
|
|
await Bun.sleep(1500);
|
|
server.stop();
|
|
|
|
emitAnnotateOutcome(result);
|
|
process.exit(0);
|
|
|
|
} else if (args[0] === "improve-context") {
|
|
// ============================================
|
|
// IMPROVEMENT HOOK CONTEXT INJECTION MODE
|
|
// ============================================
|
|
//
|
|
// Called by PreToolUse hook on EnterPlanMode.
|
|
// Composes any enabled context sources (compound improvement hook,
|
|
// PFM reminder) into a single additionalContext payload.
|
|
// Nothing enabled = exit 0 silently (passthrough).
|
|
|
|
await Bun.stdin.text();
|
|
|
|
const hook = readImprovementHook("enterplanmode-improve");
|
|
const pfmEnabled = loadConfig().pfmReminder === true;
|
|
|
|
const context = composeImproveContext({
|
|
pfmEnabled,
|
|
improvementHookContent: hook?.content ?? null,
|
|
});
|
|
|
|
if (context === null) process.exit(0);
|
|
|
|
console.log(JSON.stringify({
|
|
hookSpecificOutput: {
|
|
hookEventName: "PreToolUse",
|
|
additionalContext: context,
|
|
},
|
|
}));
|
|
|
|
process.exit(0);
|
|
|
|
} else {
|
|
// ============================================
|
|
// PLAN REVIEW MODE (default)
|
|
// ============================================
|
|
|
|
// Read hook event from stdin
|
|
const eventJson = await Bun.stdin.text();
|
|
if (!eventJson.trim()) {
|
|
process.exit(0);
|
|
}
|
|
|
|
let event: Record<string, any>;
|
|
try {
|
|
event = JSON.parse(eventJson);
|
|
} catch (e: any) {
|
|
console.error(`Failed to parse hook event from stdin: ${e?.message || e}`);
|
|
process.exit(1);
|
|
}
|
|
|
|
if (event.hook_event_name === "Stop") {
|
|
const transcriptPath = typeof event.transcript_path === "string" && event.transcript_path
|
|
? event.transcript_path
|
|
: null;
|
|
// A thread can span multiple rollout files, but the Stop hook asks a
|
|
// TURN-level question and the current turn can only live in the newest
|
|
// segment. Take the first existing candidate only — never fall back to an
|
|
// older segment: findTurnStartIndex degrades to last-turn-in-file when
|
|
// the turn_id is absent, so a fallback file's plan is stale by
|
|
// construction (older segments routinely end with an already-decided
|
|
// <proposed_plan>) and would deterministically reopen settled plan
|
|
// reviews on every turn end. Contrast the annotate-last leg above, which
|
|
// asks a thread-level question and correctly falls back across segments
|
|
// (#1367).
|
|
const rolloutPaths = transcriptPath
|
|
? [transcriptPath]
|
|
: process.env.CODEX_THREAD_ID
|
|
? findCodexRolloutsByThreadId(process.env.CODEX_THREAD_ID)
|
|
: [];
|
|
const rolloutPath = rolloutPaths.find((path) => existsSync(path)) ?? null;
|
|
|
|
const latestPlan = rolloutPath
|
|
? getLatestCodexPlan(rolloutPath, {
|
|
turnId: typeof event.turn_id === "string" ? event.turn_id : undefined,
|
|
stopHookActive: !!event.stop_hook_active,
|
|
})
|
|
: null;
|
|
|
|
if (!latestPlan?.text) {
|
|
process.exit(0);
|
|
}
|
|
|
|
const planProject = (await detectProjectName()) ?? "_unknown";
|
|
const server = await startPlannotatorServer({
|
|
plan: latestPlan.text,
|
|
origin: "codex",
|
|
sharingEnabled,
|
|
shareBaseUrl,
|
|
pasteApiUrl,
|
|
htmlContent: planHtmlContent,
|
|
onReady: async (url, isRemote, port) => {
|
|
handleServerReady(url, isRemote, port);
|
|
|
|
if (isRemote && sharingEnabled) {
|
|
await writeRemoteShareLink(latestPlan.text, shareBaseUrl, "review the plan", "plan only").catch(() => {});
|
|
}
|
|
},
|
|
});
|
|
|
|
registerSession({
|
|
pid: process.pid,
|
|
port: server.port,
|
|
url: server.url,
|
|
mode: "plan",
|
|
project: planProject,
|
|
startedAt: new Date().toISOString(),
|
|
label: `plan-${planProject}`,
|
|
});
|
|
|
|
const result = await server.waitForDecision();
|
|
await Bun.sleep(1500);
|
|
server.stop();
|
|
|
|
if (result.approved) {
|
|
console.log("{}");
|
|
} else {
|
|
console.log(
|
|
JSON.stringify({
|
|
decision: "block",
|
|
reason: getPlanDeniedPrompt("codex", undefined, {
|
|
toolName: getPlanToolName("codex"),
|
|
planFileRule: "",
|
|
feedback: result.feedback || "Plan changes requested",
|
|
}),
|
|
})
|
|
);
|
|
}
|
|
|
|
process.exit(0);
|
|
}
|
|
|
|
let planContent = "";
|
|
let permissionMode = "default";
|
|
let isGemini = false;
|
|
let planFilename = "";
|
|
|
|
// Detect harness: Gemini sends plan_filename (file on disk), Claude Code sends plan (inline)
|
|
planFilename = event.tool_input?.plan_filename || event.tool_input?.plan_path || "";
|
|
isGemini = !!planFilename;
|
|
|
|
if (isGemini) {
|
|
// Reconstruct full plan path from transcript_path and session_id:
|
|
// transcript_path = <projectTempDir>/chats/session-...json
|
|
// plan lives at = <projectTempDir>/<session_id>/plans/<plan_filename>
|
|
const projectTempDir = path.dirname(path.dirname(event.transcript_path));
|
|
const planFilePath = path.join(projectTempDir, event.session_id, "plans", planFilename);
|
|
planContent = await Bun.file(planFilePath).text();
|
|
} else {
|
|
planContent = event.tool_input?.plan || "";
|
|
}
|
|
|
|
permissionMode = event.permission_mode || "default";
|
|
|
|
if (!planContent) {
|
|
console.error("No plan content in hook event");
|
|
process.exit(1);
|
|
}
|
|
|
|
const planProject = (await detectProjectName()) ?? "_unknown";
|
|
|
|
// Start the plan review server
|
|
const server = await startPlannotatorServer({
|
|
plan: planContent,
|
|
origin: isGemini ? "gemini-cli" : detectedOrigin,
|
|
permissionMode,
|
|
sharingEnabled,
|
|
shareBaseUrl,
|
|
pasteApiUrl,
|
|
htmlContent: planHtmlContent,
|
|
onReady: async (url, isRemote, port) => {
|
|
handleServerReady(url, isRemote, port);
|
|
|
|
if (isRemote && sharingEnabled) {
|
|
await writeRemoteShareLink(planContent, shareBaseUrl, "review the plan", "plan only").catch(() => {});
|
|
}
|
|
},
|
|
});
|
|
|
|
registerSession({
|
|
pid: process.pid,
|
|
port: server.port,
|
|
url: server.url,
|
|
mode: "plan",
|
|
project: planProject,
|
|
startedAt: new Date().toISOString(),
|
|
label: `plan-${planProject}`,
|
|
});
|
|
|
|
// Wait for user decision (blocks until approve/deny)
|
|
const result = await server.waitForDecision();
|
|
|
|
// Give browser time to receive response and update UI
|
|
await Bun.sleep(1500);
|
|
|
|
// Cleanup
|
|
server.stop();
|
|
|
|
// Output decision in the appropriate format for the harness
|
|
if (isGemini) {
|
|
if (result.approved) {
|
|
console.log(result.feedback ? JSON.stringify({ systemMessage: result.feedback }) : "{}");
|
|
} else {
|
|
console.log(
|
|
JSON.stringify({
|
|
decision: "deny",
|
|
reason: getPlanDeniedPrompt("gemini-cli", undefined, {
|
|
toolName: getPlanToolName("gemini-cli"),
|
|
planFileRule: buildPlanFileRule(getPlanToolName("gemini-cli"), planFilename),
|
|
feedback: result.feedback || "Plan changes requested",
|
|
}),
|
|
})
|
|
);
|
|
}
|
|
} else {
|
|
// Claude Code: PermissionRequest hook decision
|
|
if (result.approved) {
|
|
const updatedPermissions = [];
|
|
if (result.permissionMode) {
|
|
updatedPermissions.push({
|
|
type: "setMode",
|
|
mode: result.permissionMode,
|
|
destination: "session",
|
|
});
|
|
}
|
|
|
|
console.log(
|
|
JSON.stringify({
|
|
hookSpecificOutput: {
|
|
hookEventName: "PermissionRequest",
|
|
decision: {
|
|
behavior: "allow",
|
|
// Echo the original tool_input as updatedInput. Claude Code
|
|
// >= 2.1.199 silently drops an allow decision for ExitPlanMode
|
|
// (a tool requiring user interaction) when updatedInput is
|
|
// absent, falling back to the built-in approval dialog.
|
|
updatedInput: event.tool_input,
|
|
...(updatedPermissions.length > 0 && { updatedPermissions }),
|
|
},
|
|
},
|
|
})
|
|
);
|
|
} else {
|
|
console.log(
|
|
JSON.stringify({
|
|
hookSpecificOutput: {
|
|
hookEventName: "PermissionRequest",
|
|
decision: {
|
|
behavior: "deny",
|
|
message: getPlanDeniedPrompt(detectedOrigin, undefined, {
|
|
toolName: getPlanToolName(detectedOrigin),
|
|
planFileRule: "",
|
|
feedback: result.feedback || "Plan changes requested",
|
|
}),
|
|
},
|
|
},
|
|
})
|
|
);
|
|
}
|
|
}
|
|
|
|
process.exit(0);
|
|
}
|