@plannotator/pi-extension 0.28.38 → 0.28.39

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,149 @@
1
+ /**
2
+ * Pi (Node) mirror of packages/server/agent-jobs-ask-ai-child.test.ts: every
3
+ * agent job the Pi review server starts (code review, Code Tour, Guided
4
+ * Review; Claude, Codex and the marker engines) runs with Plannotator's own
5
+ * integration off: PLANNOTATOR_ASK_AI_CHILD=1 with the Inbox tool and agent
6
+ * tool switches at 0 in its env, the Codex `-c` override that turns the Inbox
7
+ * MCP server off, and the Claude disallow of `mcp__plannotator-inbox`.
8
+ *
9
+ * Each job is built by its real (vendored) command builder and spawned through
10
+ * the real POST /api/agents/jobs handler. Fake `claude`, `codex` and
11
+ * `opencode` on PATH (never a real CLI) record the env and argv they get.
12
+ */
13
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
14
+ import { chmodSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
15
+ import { createServer } from "node:http";
16
+ import { tmpdir } from "node:os";
17
+ import { join } from "node:path";
18
+ import { NO_BROWSER } from "../../../tests/helpers/no-browser.ts";
19
+ import { buildClaudeCommand } from "../generated/claude-review.ts";
20
+ import { buildCodexCommand, generateOutputPath } from "../generated/codex-review.ts";
21
+ import { buildGuideClaudeCommand, buildGuideCodexCommand, generateGuideOutputPath } from "../generated/guide-review.ts";
22
+ import { buildMarkerCommand, MARKER_ENGINES } from "../generated/marker-review.ts";
23
+ import { buildTourClaudeCommand, buildTourCodexCommand, generateTourOutputPath } from "../generated/tour-review.ts";
24
+ import { createAgentJobHandler, type AgentJobHandlerOptions } from "./agent-jobs.ts";
25
+ import { requestUrl } from "./helpers.ts";
26
+
27
+ const SWITCHES = ["PLANNOTATOR_ASK_AI_CHILD", "PLANNOTATOR_INBOX_TOOL", "PLANNOTATOR_AGENT_TOOL"] as const;
28
+ const SANDBOX_KEYS = ["PATH", "HOME", "CODEX_HOME", "PLANNOTATOR_DATA_DIR", "PLANNOTATOR_INBOX_CONNECT", "NO_BROWSER", ...SWITCHES];
29
+
30
+ type Built = Awaited<ReturnType<NonNullable<AgentJobHandlerOptions["buildCommand"]>>>;
31
+ type JobRecord = { env: { [key: string]: string | null }; argv: string[] };
32
+
33
+ let root = "";
34
+ let saved: { [key: string]: string | undefined } = {};
35
+
36
+ beforeEach(() => {
37
+ root = mkdtempSync(join(tmpdir(), "plannotator-pi-job-ask-ai-child-"));
38
+ const bin = join(root, "bin");
39
+ for (const dir of [bin, join(root, "home"), join(root, "codex-home"), join(root, "data"), join(root, "cwd")]) mkdirSync(dir);
40
+ for (const name of ["claude", "codex", "opencode"]) {
41
+ const fake = join(bin, name);
42
+ writeFileSync(fake, [
43
+ `#!${process.execPath}`,
44
+ `const env = Object.fromEntries(${JSON.stringify(SWITCHES)}.map((k) => [k, process.env[k] ?? null]));`,
45
+ `require("node:fs").writeFileSync(${JSON.stringify(join(root, "record.json"))}, JSON.stringify({ env, argv: process.argv.slice(2) }));`,
46
+ ].join("\n"));
47
+ chmodSync(fake, 0o755);
48
+ }
49
+ saved = Object.fromEntries(SANDBOX_KEYS.map((key) => [key, process.env[key]]));
50
+ // Only the fakes and the system tools: no real agent CLI is reachable.
51
+ process.env.PATH = `${bin}:/usr/bin:/bin`;
52
+ process.env.HOME = join(root, "home");
53
+ process.env.CODEX_HOME = join(root, "codex-home");
54
+ process.env.PLANNOTATOR_DATA_DIR = join(root, "data");
55
+ process.env.PLANNOTATOR_INBOX_CONNECT = "0";
56
+ process.env.NO_BROWSER = NO_BROWSER;
57
+ // The person's own switches, which a job must override, not inherit.
58
+ process.env.PLANNOTATOR_INBOX_TOOL = "1";
59
+ process.env.PLANNOTATOR_AGENT_TOOL = "1";
60
+ delete process.env.PLANNOTATOR_ASK_AI_CHILD;
61
+ });
62
+
63
+ afterEach(() => {
64
+ for (const [key, value] of Object.entries(saved)) {
65
+ if (value === undefined) delete process.env[key];
66
+ else process.env[key] = value;
67
+ }
68
+ rmSync(root, { recursive: true, force: true });
69
+ });
70
+
71
+ /** Launch one job through the real handler; resolve with what the fake recorded. */
72
+ async function runJob(provider: string, build: (cwd: string) => Promise<Built>, engine?: string): Promise<JobRecord> {
73
+ let done: (() => void) | undefined;
74
+ const completed = new Promise<void>((resolve) => {
75
+ done = resolve;
76
+ });
77
+ const handler = createAgentJobHandler({
78
+ mode: "review",
79
+ getServerUrl: () => "http://localhost:1234",
80
+ getCwd: () => join(root, "cwd"),
81
+ buildCommand: () => build(join(root, "cwd")),
82
+ async onJobComplete() {
83
+ done?.();
84
+ },
85
+ });
86
+ const server = createServer(async (req, res) => {
87
+ if (!(await handler.handle(req, res, requestUrl(req)))) {
88
+ res.writeHead(404);
89
+ res.end();
90
+ }
91
+ });
92
+ try {
93
+ await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
94
+ const address = server.address();
95
+ if (!address || typeof address === "string") throw new Error("no port");
96
+ const res = await fetch(`http://127.0.0.1:${address.port}/api/agents/jobs`, {
97
+ method: "POST",
98
+ headers: { "content-type": "application/json" },
99
+ body: JSON.stringify({ provider, ...(engine && { engine }) }),
100
+ });
101
+ expect(res.status).toBe(201);
102
+ await completed;
103
+ return JSON.parse(readFileSync(join(root, "record.json"), "utf8")) as JobRecord;
104
+ } finally {
105
+ handler.killAll();
106
+ await new Promise<void>((resolve) => server.close(() => resolve()));
107
+ }
108
+ }
109
+
110
+ const claudeJob = (command: string[], stdinPrompt: string, cwd: string): Built => ({ command, stdinPrompt, cwd, captureStdout: true, engine: "claude" });
111
+
112
+ const JOBS: Array<{ name: string; provider: string; engine?: string; build: (cwd: string) => Promise<Built> }> = [
113
+ { name: "code review, Claude", provider: "claude", build: async (cwd) => { const b = buildClaudeCommand("review"); return claudeJob(b.command, b.stdinPrompt, cwd); } },
114
+ { name: "code review, Codex", provider: "codex", build: async (cwd) => { const outputPath = generateOutputPath(); return { command: await buildCodexCommand({ cwd, outputPath, prompt: "review" }), outputPath, cwd }; } },
115
+ { name: "code review, OpenCode", provider: "opencode", build: async (cwd) => ({ command: buildMarkerCommand(MARKER_ENGINES.opencode, "review", undefined, cwd).command, cwd, captureStdout: true }) },
116
+ { name: "Code Tour, Claude", provider: "tour", engine: "claude", build: async (cwd) => { const b = buildTourClaudeCommand("tour"); return claudeJob(b.command, b.stdinPrompt, cwd); } },
117
+ { name: "Code Tour, Codex", provider: "tour", engine: "codex", build: async (cwd) => { const outputPath = generateTourOutputPath(); return { command: await buildTourCodexCommand({ cwd, outputPath, prompt: "tour" }), outputPath, cwd, engine: "codex" }; } },
118
+ { name: "Guided Review, Claude", provider: "guide", engine: "claude", build: async (cwd) => { const b = buildGuideClaudeCommand("guide"); return claudeJob(b.command, b.stdinPrompt, cwd); } },
119
+ { name: "Guided Review, Codex", provider: "guide", engine: "codex", build: async (cwd) => { const outputPath = generateGuideOutputPath(); return { command: await buildGuideCodexCommand({ cwd, outputPath, prompt: "guide" }), outputPath, cwd, engine: "codex" }; } },
120
+ { name: "Guided Review, OpenCode", provider: "guide", engine: "opencode", build: async (cwd) => ({ command: buildMarkerCommand(MARKER_ENGINES.opencode, "guide", undefined, cwd).command, cwd, captureStdout: true, engine: "opencode" }) },
121
+ ];
122
+
123
+ describe("pi agent jobs never load Plannotator's own integration", () => {
124
+ for (const job of JOBS) {
125
+ test(`${job.name}: the agent gets the marker and both switches off`, async () => {
126
+ if (process.platform === "win32") return; // the fakes rely on a shebang
127
+ const { env, argv } = await runJob(job.provider, job.build, job.engine);
128
+ expect(env).toEqual({ PLANNOTATOR_ASK_AI_CHILD: "1", PLANNOTATOR_INBOX_TOOL: "0", PLANNOTATOR_AGENT_TOOL: "0" });
129
+ // Only the job's env changed: the server's (the person's session) did not.
130
+ expect(process.env.PLANNOTATOR_ASK_AI_CHILD).toBeUndefined();
131
+ expect(process.env.PLANNOTATOR_INBOX_TOOL).toBe("1");
132
+
133
+ if (job.name.endsWith("Codex")) {
134
+ // Codex starts a stdio MCP server with none of this env, so the
135
+ // Inbox server is turned off by a global `-c`, before `exec`.
136
+ const at = argv.findIndex((arg) => arg.startsWith("mcp_servers.plannotator-inbox="));
137
+ expect(at).toBeGreaterThan(0);
138
+ expect(argv[at - 1]).toBe("-c");
139
+ expect(argv[at]).toContain("enabled=false");
140
+ expect(at).toBeLessThan(argv.indexOf("exec"));
141
+ }
142
+ if (job.name.endsWith("Claude")) {
143
+ const disallowed = argv[argv.indexOf("--disallowedTools") + 1]!.split(",");
144
+ expect(disallowed).toContain("mcp__plannotator-inbox");
145
+ expect(argv).toContain("--strict-mcp-config");
146
+ }
147
+ });
148
+ }
149
+ });
@@ -26,6 +26,7 @@ import {
26
26
  } from "../generated/agent-jobs.ts";
27
27
  import { resolveGuideLaunchInstructions } from "../generated/guide-instructions-store.ts";
28
28
  import { gitColorFreeEnvironment } from "../generated/review-core.ts";
29
+ import { askAiChildEnv } from "../generated/ai/providers/ask-ai-env.ts";
29
30
  import { ghAccountEnvForPRUrl } from "../generated/pr-github-account.ts";
30
31
  import type { GuideLaunchReview } from "../generated/guide-format.ts";
31
32
  import { allowedToolsOf, CLAUDE_SHELL_BLOCKED_WARNING, detectClaudeShellBlocked, disallowedToolsOf, formatClaudeLogEvent } from "../generated/claude-review.ts";
@@ -312,7 +313,10 @@ export function createAgentJobHandler(options: AgentJobHandlerOptions) {
312
313
  "pipe",
313
314
  ],
314
315
  env: {
315
- ...process.env,
316
+ // The live env with Plannotator's own integration off (the Inbox MCP,
317
+ // the agent tool, the plan and Stop hooks). No job uses it: each
318
+ // returns its result on stdout or in its output file (ask-ai-env.ts).
319
+ ...askAiChildEnv(),
316
320
  // The agent runs `git diff` / `git log` itself through a pipe; keep a
317
321
  // user's `color.diff = always` from filling its context with ANSI
318
322
  // escapes (#1661).
@@ -252,6 +252,7 @@ import {
252
252
  import type { SemanticDiffAvailability, SemanticDiffResponse } from "../generated/semantic-diff-types.ts";
253
253
  import { CallFlowService } from "../generated/call-flow.ts";
254
254
  import { CallFlowInstallCoordinator, callFlowInstallOriginAllowed } from "../generated/call-flow-install.ts";
255
+ import type { CallFlowInstallCoordinatorOptions } from "../generated/call-flow-install.ts";
255
256
  import { parseCallFlowInstallRequest, resolveCallFlowInstallTargets } from "../generated/call-flow-languages.ts";
256
257
  import type { CallFlowResponse } from "../generated/call-flow-types.ts";
257
258
  import { discoverCuratedSkills, resolveRequestedReviewProfile, listAllSkills, enableReviewSkill } from "../generated/review-skill-loader.ts";
@@ -376,6 +377,8 @@ export async function startReviewServer(options: {
376
377
  repoFilesLazyThreshold?: number;
377
378
  /** Tests only: how often a polled live listing looks (default `REPO_LIVE_POLL_MS`). */
378
379
  repoLivePollMs?: number;
380
+ /** Tests only: the Call flow install and Node preflight, so a test never starts a real install. */
381
+ callFlowInstall?: Pick<CallFlowInstallCoordinatorOptions, "install" | "preflight">;
379
382
  /** Tests: run the hover cards' `gh` / `glab` through this instead of the real ones (ref-cards.ts). */
380
383
  refRunner?: RefRunner;
381
384
  /** "Ask this session": the in-process bridge to the Pi session that opened this review. */
@@ -709,6 +712,7 @@ export async function startReviewServer(options: {
709
712
  // invalidates the service's 30 second runtime probe cache so the very
710
713
  // next capability advert resolves available without a server restart.
711
714
  const callFlowInstall = new CallFlowInstallCoordinator({
715
+ ...options.callFlowInstall,
712
716
  onSettled: (ok) => {
713
717
  if (ok) callFlowService.invalidateRuntimeState();
714
718
  },
@@ -182,7 +182,7 @@ plannotator inbox status [--json] | stop | restart
182
182
 
183
183
  The Plannotator Inbox is one local window per machine where you leave the person messages, `:::question` blocks they answer with a click, files to annotate and guided reviews. Nothing blocks: you send, then keep working or end your turn.
184
184
 
185
- - Use it for a decision only they can make (a `:::question` block, then go on with work that does not depend on it), long work you finished (one short summary, with the files to look at in `attachments`), or when they ask. A decision you sent to the Inbox stays there: when the person writes in the chat, say there what waits (a short summary and the thread's link), but never also ask it in the chat with a blocking question tool (AskUserQuestion or similar). Ask in the chat only about something new they raised there that needs no pictures or files, or when they ask. Be proactive about Inbox updates and about replying to the person through the Inbox when you think they would want you to. Don't withhold important updates or decisions, and don't batch updates to send later: proactivity is better than procrastination. Use a review (plan review, `annotate`, `review`) when you need a decision on this document or diff before you go on.
185
+ - Use it for a decision only they can make (a `:::question` block, then go on with work that does not depend on it), long work you finished (one short summary, with the files to look at in `attachments`), or when they ask. One thread per work stream, unless the person says otherwise. A decision you sent to the Inbox stays there: when the person writes in the chat, say there what waits (a short summary and the thread's link), but never also ask it in the chat with a blocking question tool (AskUserQuestion or similar). Ask in the chat only about something new they raised there that needs no pictures or files, or when they ask. Be proactive about Inbox updates and about replying to the person through the Inbox when you think they would want you to. Don't withhold important updates or decisions, and don't batch updates to send later: proactivity is better than procrastination. Use a review (plan review, `annotate`, `review`) when you need a decision on this document or diff before you go on.
186
186
  - If you have a `plannotator_inbox` tool, use it; otherwise use the stdio MCP server `plannotator inbox mcp`. When send_message says the person's reply arrives in this session by itself, end your turn instead of waiting; otherwise call wait_for_reply when you need the answer.
187
187
  - Run `plannotator inbox --help` once and read it before your first use: it is the full guide (tools and arguments, threads, where replies go, question syntax, replacing questions, connecting other agents).
188
188
  - Never run `plannotator inbox` just to send: calls start a stopped Inbox in the background, and bare `plannotator inbox` opens the window for the person. `--tailscale` is the person's choice; never add it. `plannotator inbox status` only reads; never run `plannotator inbox stop` or `restart` unless the person asks.
@@ -257,8 +257,8 @@ Recommended: Local only, purged after 30 days
257
257
  - Add a screenshot (`![alt](path)`) for anything visual.
258
258
  - Link a file that shows it better (a plan, a mock): `[the mock](mock.html)`. In the Inbox, attach it too; the link opens it beside the thread.
259
259
  - Choices: `- [ ] label`, optionally `- [ ] label — why`. The reviewer can also answer "Other", add a note, or skip.
260
- - `Recommended: <label>` marks yours (`A, B and C` on `:::question-multi`); other text shows as one more answer, tagged Recommended.
261
- - Ask only what you cannot decide alone, about 8 a round at most. Do not ask rhetorical questions or questions the codebase answers.
260
+ - `Recommended: <label>` marks yours (`A, B and C` on `:::question-multi`); other text is one more answer, tagged Recommended. Keep A, B, C order.
261
+ - Ask only what you cannot decide alone, about 8 a round. Do not ask rhetorical questions or questions the codebase answers.
262
262
  - `- [x]` marks a settled choice. On resubmit, check the chosen answer, or drop the block and state the decision in prose.
263
263
  - Leave blank lines between the parts so the block reads well on GitHub.
264
264
  - Each answer comes back under its question (`### Q2. <question> (line N)`) as `Answer: <choice>`, marked `(your recommendation)` when the reviewer took yours, or as `Other: …`, free text in a quote, or `Skipped`, plus any `Note:`. A question you marked `- [x]` is settled and only comes back if the reviewer changed it or added a note.