mirror of
https://github.com/backnotprop/plannotator.git
synced 2026-09-14 14:17:26 +08:00
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:
@@ -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."
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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");
|
||||
Executable
+125
@@ -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,
|
||||
};
|
||||
@@ -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(() => {});
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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'] },
|
||||
|
||||
@@ -39,6 +39,7 @@ export type PromptSectionOverrides = Record<string, string | undefined>;
|
||||
|
||||
export type PromptRuntime =
|
||||
| "claude-code"
|
||||
| "droid"
|
||||
| "opencode"
|
||||
| "copilot-cli"
|
||||
| "pi"
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user