Add Droid slash-command integration (#787)

* feat: add Droid slash-command integration

* fix: restore Droid command launcher behavior

* chore: remove archive command from homepage

* fix(droid): tighten last-message session resolution
This commit is contained in:
Michael Ramos
2026-05-24 09:29:13 -07:00
committed by GitHub
parent 127f6c2ccc
commit 4de62e83e1
20 changed files with 651 additions and 33 deletions
+14
View File
@@ -0,0 +1,14 @@
{
"name": "plannotator",
"description": "Plannotator slash commands for Droid.",
"owner": {
"name": "backnotprop"
},
"plugins": [
{
"name": "plannotator",
"source": "./apps/droid-plugin",
"description": "Interactive code review and annotation slash commands for Droid. No plan-mode integration."
}
]
}
+40 -1
View File
@@ -4,7 +4,7 @@
# Plannotator
Interactive Plan & Code Review for AI Coding Agents. Mark up and refine your plans or code diffs using a visual UI, share for team collaboration, and seamlessly integrate with **Claude Code**, **Copilot CLI**, **Gemini CLI**, **OpenCode**, **Pi**, and **Codex**.
Interactive Plan & Code Review for AI Coding Agents. Mark up and refine your plans or code diffs using a visual UI, share for team collaboration, and seamlessly integrate with **Claude Code**, **Copilot CLI**, **Gemini CLI**, **OpenCode**, **Pi**, **Codex**, and **Droid**.
**Plan Mode Demos:**
<table>
@@ -64,6 +64,7 @@ Plannotator lets you privately share plans, annotations, and feedback with colle
- [OpenCode](#install-for-opencode)
- [Pi](#install-for-pi)
- [Codex](#install-for-codex)
- [Droid](#install-for-droid)
## Install for Claude Code
@@ -249,6 +250,44 @@ See [apps/codex/README.md](apps/codex/README.md) for details.
---
## Install for Droid
**Install the `plannotator` command:**
**macOS / Linux / WSL:**
```bash
curl -fsSL https://plannotator.ai/install.sh | bash
```
**Windows PowerShell:**
```powershell
irm https://plannotator.ai/install.ps1 | iex
```
**Then in Droid:**
```bash
droid plugin marketplace add https://github.com/backnotprop/plannotator
droid plugin install plannotator@plannotator
```
This Droid plugin is commands-only. It adds:
```text
/plannotator-review
/plannotator-annotate <file|folder|url>
/plannotator-last
/plannotator-archive
```
It does not currently intercept Droid's planning flow.
See [apps/droid-plugin/README.md](apps/droid-plugin/README.md) for details.
---
## How It Works
When your AI agent finishes planning, Plannotator:
@@ -0,0 +1,11 @@
{
"name": "plannotator",
"description": "Interactive code review and annotation slash commands for Droid. No plan-mode integration.",
"version": "0.19.21",
"author": {
"name": "backnotprop"
},
"homepage": "https://github.com/backnotprop/plannotator",
"repository": "https://github.com/backnotprop/plannotator",
"license": "MIT OR Apache-2.0"
}
+39
View File
@@ -0,0 +1,39 @@
# Plannotator for Droid
Plannotator's Droid plugin ships the manual slash-command workflow only:
- `/plannotator-review`
- `/plannotator-annotate <file|folder|url>`
- `/plannotator-last`
- `/plannotator-archive`
It does not attempt plan-mode interception or host-level planning integration.
## Install
Install the `plannotator` CLI first:
```bash
curl -fsSL https://plannotator.ai/install.sh | bash
```
Then add the marketplace and install the plugin:
```bash
droid plugin marketplace add https://github.com/backnotprop/plannotator
droid plugin install plannotator@plannotator
```
For local development:
```bash
cd /path/to/plannotator
droid plugin marketplace add "$PWD"
droid plugin install plannotator@plannotator
```
## Notes
- The plugin expects `plannotator` on `PATH`.
- Review and annotate flows still open the Plannotator browser UI and return the result to the Droid session.
- The command wrappers set `PLANNOTATOR_ORIGIN=droid` so the UI can label the host correctly.
+11
View File
@@ -0,0 +1,11 @@
#!/usr/bin/env node
const { emitAnnotateDecision, exitWithFailure, runPlannotator } = require("../lib/run-plannotator");
const result = runPlannotator(["annotate", ...process.argv.slice(2), "--json"]);
if (result.error || result.status !== 0) {
exitWithFailure(result, "plannotator annotate");
}
emitAnnotateDecision(result.stdout, "Markdown Annotations");
+11
View File
@@ -0,0 +1,11 @@
#!/usr/bin/env node
const { exitWithFailure, runPlannotator } = require("../lib/run-plannotator");
const result = runPlannotator(["archive", ...process.argv.slice(2)]);
if (result.error || result.status !== 0) {
exitWithFailure(result, "plannotator archive");
}
process.stdout.write("Archive browsing finished.\n");
+11
View File
@@ -0,0 +1,11 @@
#!/usr/bin/env node
const { emitAnnotateDecision, exitWithFailure, runPlannotator } = require("../lib/run-plannotator");
const result = runPlannotator(["annotate-last", ...process.argv.slice(2), "--json"]);
if (result.error || result.status !== 0) {
exitWithFailure(result, "plannotator annotate-last");
}
emitAnnotateDecision(result.stdout, "Message Annotations");
+12
View File
@@ -0,0 +1,12 @@
#!/usr/bin/env node
const { exitWithFailure, runPlannotator } = require("../lib/run-plannotator");
const result = runPlannotator(["review", ...process.argv.slice(2)]);
if (result.error || result.status !== 0) {
exitWithFailure(result, "plannotator review");
}
const output = result.stdout.trim();
process.stdout.write(output ? `${output}\n` : "Review session closed without feedback.\n");
+125
View File
@@ -0,0 +1,125 @@
#!/usr/bin/env node
const childProcess = require("node:child_process");
const fs = require("node:fs");
const path = require("node:path");
const bundledRepoBin = path.resolve(__dirname, "..", "..", "..", "bin", "plannotator.js");
function findRepoBin(startDir) {
let dir = path.resolve(startDir);
while (true) {
const packageJsonPath = path.join(dir, "package.json");
const candidateBin = path.join(dir, "bin", "plannotator.js");
if (fs.existsSync(packageJsonPath) && fs.existsSync(candidateBin)) {
try {
const pkg = JSON.parse(fs.readFileSync(packageJsonPath, "utf8"));
if (pkg && pkg.name === "plannotator") {
return candidateBin;
}
} catch {
// Ignore malformed package.json while walking up.
}
}
const parent = path.dirname(dir);
if (parent === dir) break;
dir = parent;
}
return fs.existsSync(bundledRepoBin) ? bundledRepoBin : null;
}
function writeIfPresent(stream, text) {
if (!text) return;
stream.write(text.endsWith("\n") ? text : `${text}\n`);
}
function runPlannotator(args) {
const repoBin = findRepoBin(process.cwd());
const env = {
...process.env,
PLANNOTATOR_CWD: process.cwd(),
PLANNOTATOR_ORIGIN: "droid",
};
let result = childProcess.spawnSync("plannotator", args, {
encoding: "utf8",
env,
});
if (result.error && result.error.code === "ENOENT" && repoBin) {
result = childProcess.spawnSync(process.execPath, [repoBin, ...args], {
encoding: "utf8",
env,
});
}
return result;
}
function exitWithFailure(result, invocation) {
writeIfPresent(process.stderr, result.stderr);
writeIfPresent(process.stderr, result.stdout);
if (result.error && result.error.code === "ENOENT") {
writeIfPresent(
process.stderr,
[
`Could not run \`${invocation}\` because the \`plannotator\` CLI is not installed or not on PATH.`,
"Install it first: https://plannotator.ai/docs/getting-started/installation/",
].join("\n"),
);
} else if (result.error) {
writeIfPresent(process.stderr, `${invocation} failed: ${result.error.message}`);
}
process.exit(typeof result.status === "number" ? result.status : 1);
}
function emitAnnotateDecision(rawOutput, heading) {
const output = rawOutput.trim();
if (!output) {
process.stdout.write("Annotation session closed.\n");
return;
}
try {
const parsed = JSON.parse(output);
if (parsed && typeof parsed === "object") {
if (parsed.decision === "approved") {
process.stdout.write("Approved.\n");
return;
}
if (parsed.decision === "dismissed") {
process.stdout.write("Annotation session closed.\n");
return;
}
if (parsed.decision === "annotated") {
const feedback = typeof parsed.feedback === "string" ? parsed.feedback.trim() : "";
if (!feedback) {
process.stdout.write("Annotation session closed.\n");
return;
}
process.stdout.write(
`# ${heading}\n\n${feedback}\n\nPlease address the annotation feedback above.\n`,
);
return;
}
}
} catch {
// Fall back to the raw output below.
}
writeIfPresent(process.stdout, output);
}
module.exports = {
emitAnnotateDecision,
exitWithFailure,
runPlannotator,
};
+39 -3
View File
@@ -1,5 +1,5 @@
/**
* Plannotator CLI for Claude Code, Codex, Gemini CLI, and Copilot CLI
* Plannotator CLI for Claude Code, Droid, Codex, Gemini CLI, and Copilot CLI
*
* Supports nine modes:
*
@@ -105,9 +105,12 @@ import { readImprovementHook } from "@plannotator/shared/improvement-hooks";
import { composeImproveContext } from "@plannotator/shared/pfm-reminder";
import { AGENT_CONFIG, type Origin } from "@plannotator/shared/agents";
import {
findDroidSessionLogsByAncestorWalk,
findDroidSessionLogsForCwd,
findSessionLogsByAncestorWalk,
findSessionLogsForCwd,
getLastRenderedMessage,
resolveDroidSessionLogForCwd,
resolveSessionLogByAncestorPids,
resolveSessionLogByCwdScan,
type RenderedMessage,
@@ -258,6 +261,7 @@ 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)
// > Droid command wrappers (PLANNOTATOR_ORIGIN=droid)
// > Codex (CODEX_THREAD_ID)
// > Copilot CLI (COPILOT_CLI)
// > OpenCode (OPENCODE)
@@ -843,6 +847,7 @@ if (args[0] === "sessions") {
const projectRoot = process.env.PLANNOTATOR_CWD || process.cwd();
const codexThreadId = process.env.CODEX_THREAD_ID;
const isCodex = !!codexThreadId;
const isDroid = detectedOrigin === "droid";
let lastMessage: RenderedMessage | null = null;
@@ -861,6 +866,36 @@ if (args[0] === "sessions") {
lastMessage = { messageId: codexThreadId, text: msg.text, lineNumbers: [] };
}
}
} 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) {
lastMessage = getLastRenderedMessage(droidLog);
}
} else {
// Claude Code path: resolve session log
//
@@ -920,10 +955,11 @@ if (args[0] === "sessions") {
console.error(`[DEBUG] Found message ${lastMessage.messageId} (${lastMessage.text.length} chars)`);
}
const annotatedMessage = lastMessage;
const annotateProject = (await detectProjectName()) ?? "_unknown";
const server = await startAnnotateServer({
markdown: lastMessage.text,
markdown: annotatedMessage.text,
filePath: "last-message",
origin: detectedOrigin,
mode: "annotate-last",
@@ -936,7 +972,7 @@ if (args[0] === "sessions") {
handleAnnotateServerReady(url, isRemote, port);
if (isRemote && sharingEnabled) {
await writeRemoteShareLink(lastMessage.text, shareBaseUrl, "annotate", "message only").catch(() => {});
await writeRemoteShareLink(annotatedMessage.text, shareBaseUrl, "annotate", "message only").catch(() => {});
}
},
});
+171 -1
View File
@@ -13,6 +13,8 @@ import {
isHumanPrompt,
findAnchorIndex,
extractLastRenderedMessage,
findDroidSessionLogsForCwd,
resolveDroidSessionLogForCwd,
projectSlugFromCwd,
findSessionLogsByAncestorWalk,
findSessionLogsForCwd,
@@ -24,7 +26,7 @@ import {
resolveSessionLogByCwdScan,
type SessionLogEntry,
} from "./session-log";
import { mkdirSync, writeFileSync, rmSync } from "node:fs";
import { mkdirSync, writeFileSync, rmSync, utimesSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
@@ -168,6 +170,30 @@ function buildLog(...lines: string[]): string {
return lines.join("\n");
}
function droidMessage(
id: string,
role: "user" | "assistant",
text: string,
opts: { visibility?: string; visibilityPlacement?: "message" | "entry" } = {},
): string {
const message = {
role,
content: [{ type: "text", text }],
...(opts.visibility && opts.visibilityPlacement !== "entry"
? { visibility: opts.visibility }
: {}),
};
return JSON.stringify({
type: "message",
id,
timestamp: new Date().toISOString(),
message,
...(opts.visibility && opts.visibilityPlacement === "entry"
? { visibility: opts.visibility }
: {}),
});
}
// --- Tests ---
describe("projectSlugFromCwd", () => {
@@ -245,6 +271,46 @@ describe("isHumanPrompt", () => {
const entry = JSON.parse(assistantText("msg_1", "hello"));
expect(isHumanPrompt(entry)).toBe(false);
});
test("accepts visible Droid user messages", () => {
const entry = JSON.parse(droidMessage("m_user", "user", "real human prompt"));
expect(isHumanPrompt(entry)).toBe(true);
});
test("accepts Droid messages with visible transcript visibility", () => {
expect(
isHumanPrompt(JSON.parse(droidMessage("m_both", "user", "visible to both", { visibility: "both" })))
).toBe(true);
expect(
isHumanPrompt(JSON.parse(droidMessage("m_user_only", "user", "visible to user", { visibility: "user_only" })))
).toBe(true);
});
test("rejects Droid system reminders and command notifications", () => {
expect(
isHumanPrompt(JSON.parse(droidMessage("m_sys", "user", "<system-reminder>\ninternal")))
).toBe(false);
expect(
isHumanPrompt(JSON.parse(droidMessage("m_note", "user", "<system-notification>\ncommand output")))
).toBe(false);
});
test("rejects hidden Droid user messages", () => {
const entry = JSON.parse(
droidMessage("m_hidden", "user", "hidden", { visibility: "llm_only" })
);
expect(isHumanPrompt(entry)).toBe(false);
});
test("rejects hidden Droid user messages with top-level visibility", () => {
const entry = JSON.parse(
droidMessage("m_hidden_top", "user", "hidden", {
visibility: "llm_only",
visibilityPlacement: "entry",
})
);
expect(isHumanPrompt(entry)).toBe(false);
});
});
describe("findAnchorIndex", () => {
@@ -534,6 +600,36 @@ describe("extractLastRenderedMessage", () => {
expect(result).not.toBeNull();
expect(result!.text).toBe("Response before queue op");
});
test("handles Droid transcript entries and ignores slash-command notifications", () => {
const log = buildLog(
droidMessage("ctx", "user", "<system-reminder>\ncontext", { visibility: "llm_only" }),
droidMessage("u1", "user", "Tell me a story."),
droidMessage("a1", "assistant", "Once upon a time."),
droidMessage("cmd", "user", "<system-notification>\nCommand file: /tmp/plannotator-last.js"),
droidMessage("u2", "user", "ANCHOR")
);
const entries = parseSessionLog(log);
const anchor = findAnchorIndex(entries, "ANCHOR")!;
const result = extractLastRenderedMessage(entries, anchor);
expect(result).not.toBeNull();
expect(result!.messageId).toBe("a1");
expect(result!.text).toBe("Once upon a time.");
});
test("uses top-level Droid message ids when message.id is absent", () => {
const log = buildLog(
droidMessage("u1", "user", "Hi"),
droidMessage("a-top-level-id", "assistant", "Factory answer"),
droidMessage("u2", "user", "ANCHOR")
);
const entries = parseSessionLog(log);
const anchor = findAnchorIndex(entries, "ANCHOR")!;
const result = extractLastRenderedMessage(entries, anchor);
expect(result).not.toBeNull();
expect(result!.messageId).toBe("a-top-level-id");
expect(result!.text).toBe("Factory answer");
});
});
describe("extractLastRenderedMessage — edge cases", () => {
@@ -622,6 +718,80 @@ describe("findSessionLogsByAncestorWalk", () => {
});
});
describe("findDroidSessionLogsForCwd", () => {
test("finds session logs under the Factory sessions directory layout", () => {
const { projectsDir: sessionsDir, cleanup } = makeTempDirs("droid-cwd");
try {
const cwd = "/Users/example/project";
const logPath = writeSessionLog(sessionsDir, cwd, "droid-session-1");
const result = findDroidSessionLogsForCwd(cwd, sessionsDir);
expect(result[0]).toBe(logPath);
} finally {
cleanup();
}
});
});
describe("resolveDroidSessionLogForCwd", () => {
test("returns the newest exact-cwd session candidate", () => {
const { projectsDir: sessionsDir, cleanup } = makeTempDirs("droid-current");
try {
const cwd = "/Users/example/project";
const older = writeSessionLog(
sessionsDir,
cwd,
"older-session",
buildLog(
droidMessage("u1", "user", "old prompt"),
droidMessage("a1", "assistant", "old reply"),
),
);
const newer = writeSessionLog(
sessionsDir,
cwd,
"newer-session",
'{"type":"session_start","id":"newer-session"}\n',
);
const now = Date.now() / 1000;
utimesSync(older, now - 10, now - 10);
utimesSync(newer, now, now);
expect(resolveDroidSessionLogForCwd(cwd, sessionsDir)).toBe(newer);
} finally {
cleanup();
}
});
test("falls back to the newest ancestor session candidate when exact cwd has no logs", () => {
const { projectsDir: sessionsDir, cleanup } = makeTempDirs("droid-ancestor");
try {
const sessionRoot = "/Users/example/project";
const subdir = `${sessionRoot}/src/nested`;
const older = writeSessionLog(
sessionsDir,
sessionRoot,
"older-session",
buildLog(droidMessage("a1", "assistant", "old reply")),
);
const newer = writeSessionLog(
sessionsDir,
sessionRoot,
"newer-session",
'{"type":"session_start","id":"newer-session"}\n',
);
const now = Date.now() / 1000;
utimesSync(older, now - 10, now - 10);
utimesSync(newer, now, now);
expect(resolveDroidSessionLogForCwd(subdir, sessionsDir)).toBe(newer);
} finally {
cleanup();
}
});
});
// --- Resolver Tests (new) ---
describe("getAncestorPids", () => {
+107 -25
View File
@@ -1,17 +1,18 @@
/**
* Session Log Parser
*
* Extracts the last rendered assistant message from a Claude Code session log.
* Extracts the last rendered assistant message from local agent session logs.
* Used by the "annotate-last" feature to let users annotate the most recent
* assistant response in the annotation UI.
*
* Session logs are JSONL files at:
* ~/.claude/projects/{project-slug}/{session-id}.jsonl
* Currently supports:
* - Claude Code: ~/.claude/projects/{project-slug}/{session-id}.jsonl
* - Droid/Factory: ~/.factory/sessions/{project-slug}/{session-id}.jsonl
*
* Each line is a JSON object with a `type` field. Assistant messages may be
* split across multiple lines sharing the same `message.id` (streamed chunks).
* Text content blocks (`type: "text"` inside `message.content`) are what the
* user sees rendered in chat.
* split across multiple lines sharing the same logical message id. Text
* content blocks (`type: "text"` inside `message.content`) are what the user
* sees rendered in chat.
*/
import { readdirSync, statSync, readFileSync } from "node:fs";
@@ -23,6 +24,9 @@ const claudeConfigDir =
process.env.CLAUDE_CONFIG_DIR || join(homedir(), ".claude");
const DEFAULT_SESSIONS_DIR = join(claudeConfigDir, "sessions");
const DEFAULT_PROJECTS_DIR = join(claudeConfigDir, "projects");
const factoryConfigDir =
process.env.FACTORY_CONFIG_DIR || join(homedir(), ".factory");
const DEFAULT_FACTORY_SESSIONS_DIR = join(factoryConfigDir, "sessions");
/**
* Normalize a cwd for comparison. On Windows, filesystems are case-insensitive
@@ -40,9 +44,12 @@ export function normalizeCwdForCompare(cwd: string): string {
export interface SessionLogEntry {
type: string;
id?: string;
visibility?: string;
message?: {
id?: string;
role?: string;
visibility?: string;
content?: string | ContentBlock[];
};
[key: string]: unknown;
@@ -139,6 +146,53 @@ export function findSessionLogsForCwd(cwd: string, projectsDirOverride?: string)
return [];
}
/**
* Find Droid/Factory session log candidates for a given working directory.
* Returns all .jsonl paths sorted by mtime (most recent first).
*/
export function findDroidSessionLogsForCwd(
cwd: string,
sessionsDirOverride?: string,
): string[] {
return findSessionLogsForCwd(cwd, sessionsDirOverride ?? DEFAULT_FACTORY_SESSIONS_DIR);
}
/**
* Walk up the directory tree trying each ancestor against the Droid/Factory
* sessions directory. Useful when the user `cd`'d into a subdirectory after
* the session started.
*/
export function findDroidSessionLogsByAncestorWalk(
cwd: string,
sessionsDirOverride?: string,
): string[] {
return findSessionLogsByAncestorWalk(
cwd,
sessionsDirOverride ?? DEFAULT_FACTORY_SESSIONS_DIR,
);
}
/**
* Best-effort current Droid/Factory session log resolution for a cwd.
*
* Factory does not expose per-process session metadata, so the safest
* available selector is the newest exact-cwd log, falling back to the newest
* log from the first ancestor slug with any sessions. Callers should inspect
* only this selected log and fail cleanly if it contains no assistant reply,
* rather than falling through to older sibling sessions.
*/
export function resolveDroidSessionLogForCwd(
cwd: string,
sessionsDirOverride?: string,
): string | null {
const sessionsDir = sessionsDirOverride ?? DEFAULT_FACTORY_SESSIONS_DIR;
const exactLogs = findDroidSessionLogsForCwd(cwd, sessionsDir);
if (exactLogs.length > 0) return exactLogs[0];
const ancestorLogs = findDroidSessionLogsByAncestorWalk(cwd, sessionsDir);
return ancestorLogs[0] ?? null;
}
// --- Session Metadata Resolution ---
/**
@@ -482,16 +536,49 @@ const SYSTEM_USER_PREFIXES = [
"<command-name>",
"<local-command-stdout>",
"<local-command-stderr>",
"<system-reminder>",
"<system-notification>",
];
function getEntryRole(entry: SessionLogEntry): "user" | "assistant" | null {
if (entry.type === "user" || entry.type === "assistant") return entry.type;
const role = entry.message?.role;
return role === "user" || role === "assistant" ? role : null;
}
function getVisibleTextBlocks(content: string | ContentBlock[] | undefined): string[] {
if (typeof content === "string") {
return content.trim() ? [content] : [];
}
if (!Array.isArray(content)) return [];
return content
.filter((b: ContentBlock) => b.type === "text" && b.text?.trim())
.map((b: ContentBlock) => b.text!);
}
function getEntryVisibility(entry: SessionLogEntry): string | undefined {
return entry.visibility ?? entry.message?.visibility;
}
function isHiddenTranscriptEntry(entry: SessionLogEntry): boolean {
const visibility = getEntryVisibility(entry)?.trim().toLowerCase();
return visibility === "llm_only" || visibility === "assistant_only" || visibility === "hidden";
}
function getEntryMessageId(entry: SessionLogEntry): string | undefined {
return entry.message?.id ?? entry.id;
}
/**
* Check if a session log entry is a human-typed user prompt
* (as opposed to a tool result or system-generated user message).
*/
export function isHumanPrompt(entry: SessionLogEntry): boolean {
if (entry.type !== "user") return false;
if (typeof entry.message?.content !== "string") return false;
const content = entry.message.content;
if (getEntryRole(entry) !== "user") return false;
if (isHiddenTranscriptEntry(entry)) return false;
const blocks = getVisibleTextBlocks(entry.message?.content);
if (blocks.length === 0) return false;
const content = blocks.join("\n");
// Filter out system-generated user messages
for (const prefix of SYSTEM_USER_PREFIXES) {
if (content.startsWith(prefix)) return false;
@@ -503,23 +590,18 @@ export function isHumanPrompt(entry: SessionLogEntry): boolean {
* Check if a session log entry is an assistant message with rendered text.
*/
function hasTextContent(entry: SessionLogEntry): boolean {
if (entry.type !== "assistant") return false;
const content = entry.message?.content;
if (!Array.isArray(content)) return false;
return content.some(
(block: ContentBlock) => block.type === "text" && block.text?.trim()
);
if (getEntryRole(entry) !== "assistant") return false;
if (isHiddenTranscriptEntry(entry)) return false;
return getVisibleTextBlocks(entry.message?.content).length > 0;
}
/**
* Extract text blocks from an assistant message's content array.
*/
function extractTextBlocks(entry: SessionLogEntry): string[] {
const content = entry.message?.content;
if (!Array.isArray(content)) return [];
return content
.filter((b: ContentBlock) => b.type === "text" && b.text?.trim())
.map((b: ContentBlock) => b.text!);
if (getEntryRole(entry) !== "assistant") return [];
if (isHiddenTranscriptEntry(entry)) return [];
return getVisibleTextBlocks(entry.message?.content);
}
/**
@@ -536,7 +618,7 @@ export function findAnchorIndex(
for (let i = end; i >= 0; i--) {
if (!isHumanPrompt(entries[i])) continue;
if (!anchorText) return i;
const content = entries[i].message!.content as string;
const content = getVisibleTextBlocks(entries[i].message?.content).join("\n");
if (content.includes(anchorText)) return i;
}
return -1;
@@ -567,7 +649,7 @@ export function extractLastRenderedMessage(
if (entry.type === "queue-operation") continue;
// Skip non-human user messages (tool results, system-generated)
if (entry.type === "user" && !isHumanPrompt(entry)) continue;
if (getEntryRole(entry) === "user" && !isHumanPrompt(entry)) continue;
// At a human prompt: if we already have text, stop.
// If no text yet, skip and keep looking in earlier turns.
@@ -576,11 +658,11 @@ export function extractLastRenderedMessage(
continue;
}
if (entry.type !== "assistant") continue;
if (getEntryRole(entry) !== "assistant") continue;
// If we already locked onto a message.id, collect earlier chunks of it
if (targetMessageId) {
const msgId = entry.message?.id;
const msgId = getEntryMessageId(entry);
if (msgId !== targetMessageId) break;
const texts = extractTextBlocks(entry);
if (texts.length > 0) {
@@ -591,7 +673,7 @@ export function extractLastRenderedMessage(
// Haven't found target yet — look for assistant with text
if (!hasTextContent(entry)) continue;
const msgId = entry.message?.id;
const msgId = getEntryMessageId(entry);
if (!msgId) continue;
targetMessageId = msgId;
Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

@@ -53,6 +53,15 @@ import AnnoReplace from './AnnoReplace.astro';
<img src="/assets/icon-codex.png" alt="" class="w-4 h-4 rounded-sm" />
<span>Codex</span>
</button>
<button
class="agent-btn"
data-agent="droid"
data-command="curl -fsSL https://plannotator.ai/install.sh | bash"
data-video=""
>
<img src="/assets/icon-droid.png" alt="" class="w-4 h-4 rounded-sm" />
<span>Droid</span>
</button>
<button
class="agent-btn"
data-agent="copilot"
@@ -208,6 +217,21 @@ import AnnoReplace from './AnnoReplace.astro';
],
detail: 'Then use in Codex:'
},
droid: {
steps: [
'droid plugin marketplace add https://github.com/backnotprop/plannotator',
'droid plugin install plannotator@plannotator',
'Open a fresh Droid session to load the plugin',
'/plannotator-review',
'/plannotator-annotate <file|folder|url>',
'/plannotator-last'
],
detail: 'Commands-only integration. Installs browser-based review and annotation slash commands in Droid:',
link: {
text: 'Read the Droid install docs',
url: '/docs/getting-started/installation/#droid'
}
},
opencode: {
steps: [
'"plugin": ["@plannotator/opencode@latest"]',
@@ -18,7 +18,7 @@ import GitHubStars from './GitHubStars.tsx';
<span class="text-border/30 hidden sm:inline">|</span>
<span>One command. Hooks into your agent automatically.</span>
<span>Hooks where supported. Slash commands where they fit.</span>
<span class="text-border/30 hidden sm:inline">|</span>
@@ -1,6 +1,6 @@
---
title: "Installation"
description: "How to install Plannotator for Claude Code, Codex, OpenCode, Pi, and other agent hosts."
description: "How to install Plannotator for Claude Code, Codex, OpenCode, Pi, Droid, and other agent hosts."
sidebar:
order: 1
section: "Getting Started"
@@ -193,3 +193,33 @@ pi -e npm:@plannotator/pi-extension
Start plan mode with `pi --plan`, or toggle mid-session with `/plannotator` or `Ctrl+Alt+P`. The extension provides file-based plan review, code review (`/plannotator-review`), markdown annotation (`/plannotator-annotate`), bash safety gating during planning, and progress tracking during execution.
See [Plannotator Meets Pi](/blog/plannotator-meets-pi) for the full walkthrough.
## Droid
Plannotator's Droid integration is currently commands-only. It does not intercept Droid's planning flow yet.
Install the CLI first:
```bash
curl -fsSL https://plannotator.ai/install.sh | bash
```
Then install the Droid plugin:
```bash
droid plugin marketplace add https://github.com/backnotprop/plannotator
droid plugin install plannotator@plannotator
```
Open a fresh Droid session after installing.
This adds the following slash commands:
```text
/plannotator-review
/plannotator-annotate <file|folder|url>
/plannotator-last
/plannotator-archive
```
Those commands open the browser-based Plannotator review UI and send the result back into the Droid session.
+1 -1
View File
@@ -8,7 +8,7 @@ import AnnoReplace from '../components/landing/AnnoReplace.astro';
---
<Base
title="Plannotator - Plan & Code Review for AI Coding Agents"
description="Free, open-source plugin for Claude Code, OpenCode, Codex, and Pi. Review agent plans before they execute, annotate agent-written code, send structured feedback back — all locally."
description="Free, open-source review tooling for Claude Code, OpenCode, Codex, Pi, and Droid. Review agent plans before they execute, annotate agent-written code, and send structured feedback back locally."
>
<div class="min-h-screen bg-background text-foreground">
<Nav />
+1
View File
@@ -18,6 +18,7 @@ type AgentConfigEntry = {
export const AGENT_CONFIG = {
'claude-code': { name: 'Claude Code', badge: 'bg-orange-500/15 text-orange-400', aiProviderTypes: ['claude-agent-sdk'] },
'droid': { name: 'Droid', badge: 'bg-cyan-500/15 text-cyan-400' },
'opencode': { name: 'OpenCode', badge: 'bg-emerald-500/15 text-emerald-400', aiProviderTypes: ['opencode-sdk'] },
'copilot-cli': { name: 'GitHub Copilot', badge: 'bg-blue-500/15 text-blue-400' },
'pi': { name: 'Pi', badge: 'bg-violet-500/15 text-violet-400', aiProviderTypes: ['pi-sdk'] },
+1
View File
@@ -39,6 +39,7 @@ export type PromptSectionOverrides = Record<string, string | undefined>;
export type PromptRuntime =
| "claude-code"
| "droid"
| "opencode"
| "copilot-cli"
| "pi"
+1
View File
@@ -16,6 +16,7 @@ export function resolveTemplate(
export const PLAN_TOOL_NAMES: Record<PromptRuntime, string> = {
"claude-code": "ExitPlanMode",
droid: "ExitPlanMode",
opencode: "submit_plan",
"copilot-cli": "exit_plan_mode",
pi: "plannotator_submit_plan",