Skip to content

Commit 4de62e8

Browse files
authored
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
1 parent 127f6c2 commit 4de62e8

20 files changed

Lines changed: 651 additions & 33 deletions

File tree

.factory-plugin/marketplace.json

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
{
2+
"name": "plannotator",
3+
"description": "Plannotator slash commands for Droid.",
4+
"owner": {
5+
"name": "backnotprop"
6+
},
7+
"plugins": [
8+
{
9+
"name": "plannotator",
10+
"source": "./apps/droid-plugin",
11+
"description": "Interactive code review and annotation slash commands for Droid. No plan-mode integration."
12+
}
13+
]
14+
}

README.md

Lines changed: 40 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44

55
# Plannotator
66

7-
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**.
7+
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**.
88

99
**Plan Mode Demos:**
1010
<table>
@@ -64,6 +64,7 @@ Plannotator lets you privately share plans, annotations, and feedback with colle
6464
- [OpenCode](#install-for-opencode)
6565
- [Pi](#install-for-pi)
6666
- [Codex](#install-for-codex)
67+
- [Droid](#install-for-droid)
6768

6869
## Install for Claude Code
6970

@@ -249,6 +250,44 @@ See [apps/codex/README.md](apps/codex/README.md) for details.
249250

250251
---
251252

253+
## Install for Droid
254+
255+
**Install the `plannotator` command:**
256+
257+
**macOS / Linux / WSL:**
258+
259+
```bash
260+
curl -fsSL https://plannotator.ai/install.sh | bash
261+
```
262+
263+
**Windows PowerShell:**
264+
265+
```powershell
266+
irm https://plannotator.ai/install.ps1 | iex
267+
```
268+
269+
**Then in Droid:**
270+
271+
```bash
272+
droid plugin marketplace add https://github.com/backnotprop/plannotator
273+
droid plugin install plannotator@plannotator
274+
```
275+
276+
This Droid plugin is commands-only. It adds:
277+
278+
```text
279+
/plannotator-review
280+
/plannotator-annotate <file|folder|url>
281+
/plannotator-last
282+
/plannotator-archive
283+
```
284+
285+
It does not currently intercept Droid's planning flow.
286+
287+
See [apps/droid-plugin/README.md](apps/droid-plugin/README.md) for details.
288+
289+
---
290+
252291
## How It Works
253292

254293
When your AI agent finishes planning, Plannotator:
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
{
2+
"name": "plannotator",
3+
"description": "Interactive code review and annotation slash commands for Droid. No plan-mode integration.",
4+
"version": "0.19.21",
5+
"author": {
6+
"name": "backnotprop"
7+
},
8+
"homepage": "https://github.com/backnotprop/plannotator",
9+
"repository": "https://github.com/backnotprop/plannotator",
10+
"license": "MIT OR Apache-2.0"
11+
}

apps/droid-plugin/README.md

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
# Plannotator for Droid
2+
3+
Plannotator's Droid plugin ships the manual slash-command workflow only:
4+
5+
- `/plannotator-review`
6+
- `/plannotator-annotate <file|folder|url>`
7+
- `/plannotator-last`
8+
- `/plannotator-archive`
9+
10+
It does not attempt plan-mode interception or host-level planning integration.
11+
12+
## Install
13+
14+
Install the `plannotator` CLI first:
15+
16+
```bash
17+
curl -fsSL https://plannotator.ai/install.sh | bash
18+
```
19+
20+
Then add the marketplace and install the plugin:
21+
22+
```bash
23+
droid plugin marketplace add https://github.com/backnotprop/plannotator
24+
droid plugin install plannotator@plannotator
25+
```
26+
27+
For local development:
28+
29+
```bash
30+
cd /path/to/plannotator
31+
droid plugin marketplace add "$PWD"
32+
droid plugin install plannotator@plannotator
33+
```
34+
35+
## Notes
36+
37+
- The plugin expects `plannotator` on `PATH`.
38+
- Review and annotate flows still open the Plannotator browser UI and return the result to the Droid session.
39+
- The command wrappers set `PLANNOTATOR_ORIGIN=droid` so the UI can label the host correctly.
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
#!/usr/bin/env node
2+
3+
const { emitAnnotateDecision, exitWithFailure, runPlannotator } = require("../lib/run-plannotator");
4+
5+
const result = runPlannotator(["annotate", ...process.argv.slice(2), "--json"]);
6+
7+
if (result.error || result.status !== 0) {
8+
exitWithFailure(result, "plannotator annotate");
9+
}
10+
11+
emitAnnotateDecision(result.stdout, "Markdown Annotations");
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
#!/usr/bin/env node
2+
3+
const { exitWithFailure, runPlannotator } = require("../lib/run-plannotator");
4+
5+
const result = runPlannotator(["archive", ...process.argv.slice(2)]);
6+
7+
if (result.error || result.status !== 0) {
8+
exitWithFailure(result, "plannotator archive");
9+
}
10+
11+
process.stdout.write("Archive browsing finished.\n");
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
#!/usr/bin/env node
2+
3+
const { emitAnnotateDecision, exitWithFailure, runPlannotator } = require("../lib/run-plannotator");
4+
5+
const result = runPlannotator(["annotate-last", ...process.argv.slice(2), "--json"]);
6+
7+
if (result.error || result.status !== 0) {
8+
exitWithFailure(result, "plannotator annotate-last");
9+
}
10+
11+
emitAnnotateDecision(result.stdout, "Message Annotations");
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
#!/usr/bin/env node
2+
3+
const { exitWithFailure, runPlannotator } = require("../lib/run-plannotator");
4+
5+
const result = runPlannotator(["review", ...process.argv.slice(2)]);
6+
7+
if (result.error || result.status !== 0) {
8+
exitWithFailure(result, "plannotator review");
9+
}
10+
11+
const output = result.stdout.trim();
12+
process.stdout.write(output ? `${output}\n` : "Review session closed without feedback.\n");
Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
#!/usr/bin/env node
2+
3+
const childProcess = require("node:child_process");
4+
const fs = require("node:fs");
5+
const path = require("node:path");
6+
7+
const bundledRepoBin = path.resolve(__dirname, "..", "..", "..", "bin", "plannotator.js");
8+
9+
function findRepoBin(startDir) {
10+
let dir = path.resolve(startDir);
11+
12+
while (true) {
13+
const packageJsonPath = path.join(dir, "package.json");
14+
const candidateBin = path.join(dir, "bin", "plannotator.js");
15+
16+
if (fs.existsSync(packageJsonPath) && fs.existsSync(candidateBin)) {
17+
try {
18+
const pkg = JSON.parse(fs.readFileSync(packageJsonPath, "utf8"));
19+
if (pkg && pkg.name === "plannotator") {
20+
return candidateBin;
21+
}
22+
} catch {
23+
// Ignore malformed package.json while walking up.
24+
}
25+
}
26+
27+
const parent = path.dirname(dir);
28+
if (parent === dir) break;
29+
dir = parent;
30+
}
31+
32+
return fs.existsSync(bundledRepoBin) ? bundledRepoBin : null;
33+
}
34+
35+
function writeIfPresent(stream, text) {
36+
if (!text) return;
37+
stream.write(text.endsWith("\n") ? text : `${text}\n`);
38+
}
39+
40+
function runPlannotator(args) {
41+
const repoBin = findRepoBin(process.cwd());
42+
const env = {
43+
...process.env,
44+
PLANNOTATOR_CWD: process.cwd(),
45+
PLANNOTATOR_ORIGIN: "droid",
46+
};
47+
48+
let result = childProcess.spawnSync("plannotator", args, {
49+
encoding: "utf8",
50+
env,
51+
});
52+
53+
if (result.error && result.error.code === "ENOENT" && repoBin) {
54+
result = childProcess.spawnSync(process.execPath, [repoBin, ...args], {
55+
encoding: "utf8",
56+
env,
57+
});
58+
}
59+
60+
return result;
61+
}
62+
63+
function exitWithFailure(result, invocation) {
64+
writeIfPresent(process.stderr, result.stderr);
65+
writeIfPresent(process.stderr, result.stdout);
66+
67+
if (result.error && result.error.code === "ENOENT") {
68+
writeIfPresent(
69+
process.stderr,
70+
[
71+
`Could not run \`${invocation}\` because the \`plannotator\` CLI is not installed or not on PATH.`,
72+
"Install it first: https://plannotator.ai/docs/getting-started/installation/",
73+
].join("\n"),
74+
);
75+
} else if (result.error) {
76+
writeIfPresent(process.stderr, `${invocation} failed: ${result.error.message}`);
77+
}
78+
79+
process.exit(typeof result.status === "number" ? result.status : 1);
80+
}
81+
82+
function emitAnnotateDecision(rawOutput, heading) {
83+
const output = rawOutput.trim();
84+
if (!output) {
85+
process.stdout.write("Annotation session closed.\n");
86+
return;
87+
}
88+
89+
try {
90+
const parsed = JSON.parse(output);
91+
if (parsed && typeof parsed === "object") {
92+
if (parsed.decision === "approved") {
93+
process.stdout.write("Approved.\n");
94+
return;
95+
}
96+
97+
if (parsed.decision === "dismissed") {
98+
process.stdout.write("Annotation session closed.\n");
99+
return;
100+
}
101+
102+
if (parsed.decision === "annotated") {
103+
const feedback = typeof parsed.feedback === "string" ? parsed.feedback.trim() : "";
104+
if (!feedback) {
105+
process.stdout.write("Annotation session closed.\n");
106+
return;
107+
}
108+
process.stdout.write(
109+
`# ${heading}\n\n${feedback}\n\nPlease address the annotation feedback above.\n`,
110+
);
111+
return;
112+
}
113+
}
114+
} catch {
115+
// Fall back to the raw output below.
116+
}
117+
118+
writeIfPresent(process.stdout, output);
119+
}
120+
121+
module.exports = {
122+
emitAnnotateDecision,
123+
exitWithFailure,
124+
runPlannotator,
125+
};

apps/hook/server/index.ts

Lines changed: 39 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/**
2-
* Plannotator CLI for Claude Code, Codex, Gemini CLI, and Copilot CLI
2+
* Plannotator CLI for Claude Code, Droid, Codex, Gemini CLI, and Copilot CLI
33
*
44
* Supports nine modes:
55
*
@@ -105,9 +105,12 @@ import { readImprovementHook } from "@plannotator/shared/improvement-hooks";
105105
import { composeImproveContext } from "@plannotator/shared/pfm-reminder";
106106
import { AGENT_CONFIG, type Origin } from "@plannotator/shared/agents";
107107
import {
108+
findDroidSessionLogsByAncestorWalk,
109+
findDroidSessionLogsForCwd,
108110
findSessionLogsByAncestorWalk,
109111
findSessionLogsForCwd,
110112
getLastRenderedMessage,
113+
resolveDroidSessionLogForCwd,
111114
resolveSessionLogByAncestorPids,
112115
resolveSessionLogByCwdScan,
113116
type RenderedMessage,
@@ -258,6 +261,7 @@ const pasteApiUrl = process.env.PLANNOTATOR_PASTE_URL || undefined;
258261
// Detect calling agent from environment variables set by agent runtimes.
259262
// Priority:
260263
// PLANNOTATOR_ORIGIN (explicit override, validated against AGENT_CONFIG)
264+
// > Droid command wrappers (PLANNOTATOR_ORIGIN=droid)
261265
// > Codex (CODEX_THREAD_ID)
262266
// > Copilot CLI (COPILOT_CLI)
263267
// > OpenCode (OPENCODE)
@@ -843,6 +847,7 @@ if (args[0] === "sessions") {
843847
const projectRoot = process.env.PLANNOTATOR_CWD || process.cwd();
844848
const codexThreadId = process.env.CODEX_THREAD_ID;
845849
const isCodex = !!codexThreadId;
850+
const isDroid = detectedOrigin === "droid";
846851

847852
let lastMessage: RenderedMessage | null = null;
848853

@@ -861,6 +866,36 @@ if (args[0] === "sessions") {
861866
lastMessage = { messageId: codexThreadId, text: msg.text, lineNumbers: [] };
862867
}
863868
}
869+
} else if (isDroid) {
870+
// Droid/Factory path: resolve the current repo's session log from
871+
// ~/.factory/sessions/<cwd-slug>/*.jsonl. Factory does not expose the same
872+
// per-process session metadata files as Claude Code, so the best available
873+
// selector is "newest current-session candidate for this cwd", with an
874+
// ancestor walk fallback for users who `cd` into a subdirectory after
875+
// session start.
876+
if (process.env.PLANNOTATOR_DEBUG) {
877+
console.error(`[DEBUG] Droid detected, project root: ${projectRoot}`);
878+
}
879+
880+
const cwdLogs = findDroidSessionLogsForCwd(projectRoot);
881+
const ancestorLogs = cwdLogs.length === 0
882+
? findDroidSessionLogsByAncestorWalk(projectRoot)
883+
: [];
884+
885+
if (process.env.PLANNOTATOR_DEBUG) {
886+
console.error(`[DEBUG] Droid CWD session logs (mtime): ${cwdLogs.length ? cwdLogs.join(", ") : "(none)"}`);
887+
if (cwdLogs.length === 0) {
888+
console.error(`[DEBUG] Droid ancestor walk: ${ancestorLogs.length ? ancestorLogs.join(", ") : "(none)"}`);
889+
}
890+
}
891+
892+
const droidLog = resolveDroidSessionLogForCwd(projectRoot);
893+
if (process.env.PLANNOTATOR_DEBUG) {
894+
console.error(`[DEBUG] Droid selected log: ${droidLog ?? "(none)"}`);
895+
}
896+
if (droidLog) {
897+
lastMessage = getLastRenderedMessage(droidLog);
898+
}
864899
} else {
865900
// Claude Code path: resolve session log
866901
//
@@ -920,10 +955,11 @@ if (args[0] === "sessions") {
920955
console.error(`[DEBUG] Found message ${lastMessage.messageId} (${lastMessage.text.length} chars)`);
921956
}
922957

958+
const annotatedMessage = lastMessage;
923959
const annotateProject = (await detectProjectName()) ?? "_unknown";
924960

925961
const server = await startAnnotateServer({
926-
markdown: lastMessage.text,
962+
markdown: annotatedMessage.text,
927963
filePath: "last-message",
928964
origin: detectedOrigin,
929965
mode: "annotate-last",
@@ -936,7 +972,7 @@ if (args[0] === "sessions") {
936972
handleAnnotateServerReady(url, isRemote, port);
937973

938974
if (isRemote && sharingEnabled) {
939-
await writeRemoteShareLink(lastMessage.text, shareBaseUrl, "annotate", "message only").catch(() => {});
975+
await writeRemoteShareLink(annotatedMessage.text, shareBaseUrl, "annotate", "message only").catch(() => {});
940976
}
941977
},
942978
});

0 commit comments

Comments
 (0)