@plannotator/pi-extension 0.28.42 → 0.28.43

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.
@@ -57,6 +57,13 @@ const IDLE_TIMEOUT_MS = 10 * 60_000;
57
57
  /** Reject a JSON-RPC request that gets no response in this long (RPC acks are
58
58
  * fast — turn output streams via notifications, not the turn/start response). */
59
59
  const RPC_TIMEOUT_MS = 30_000;
60
+ /**
61
+ * The longest unfinished stdout line, in characters. One line can hold a whole `thread/resume` answer or a command's
62
+ * `aggregatedOutput`, and neither has a size Plannotator knows, so this stops a runaway and not a long message.
63
+ */
64
+ export const CODEX_LINE_MAX = 256 * 1024 * 1024;
65
+ /** How long a Codex gets to stop on SIGTERM before SIGKILL. */
66
+ const CODEX_STOP_GRACE_MS = 2_000;
60
67
  /** Model discovery must not gate the AI panel: cap its handshake + model/list
61
68
  * RPCs much shorter than RPC_TIMEOUT_MS so a codex that's installed but not
62
69
  * logged in (or otherwise unresponsive) falls back to the static model list
@@ -297,10 +304,25 @@ export function mapApprovalRequest(
297
304
  // JSON-RPC over stdio (bidirectional: responses, notifications, server requests)
298
305
  // ---------------------------------------------------------------------------
299
306
 
307
+ /** SIGTERM, then SIGKILL for a Codex that ignores it. On Windows the tree kill is already forced. */
308
+ function stopChild(proc: ChildProcess, now = false): void {
309
+ if (killWindowsProcessTree(proc.pid)) return;
310
+ if (now) {
311
+ proc.kill("SIGKILL");
312
+ return;
313
+ }
314
+ proc.kill();
315
+ const timer = setTimeout(() => {
316
+ if (proc.exitCode === null && proc.signalCode === null) proc.kill("SIGKILL");
317
+ }, CODEX_STOP_GRACE_MS);
318
+ timer.unref();
319
+ proc.once("exit", () => clearTimeout(timer));
320
+ }
321
+
300
322
  type NotificationListener =(notification: { method: string; params: RpcMessage }) => void;
301
323
  type RequestHandler = (method: string, id: string | number, params: RpcMessage) => void;
302
324
 
303
- class CodexAppServerProcess {
325
+ export class CodexAppServerProcess {
304
326
  private proc: ChildProcess | null = null;
305
327
  private listeners: NotificationListener[] = [];
306
328
  private requestHandlers: RequestHandler[] = [];
@@ -318,11 +340,15 @@ class CodexAppServerProcess {
318
340
  /** The initialize response's `userAgent` ("plannotator/<codex version> (…)"). */
319
341
  userAgent: string | undefined;
320
342
 
343
+ /** `lineMax`: past this many characters with no newline, the process is failed and stopped. */
344
+ constructor(private readonly lineMax: number = CODEX_LINE_MAX) {}
345
+
321
346
  /** Spawn + JSON-RPC initialize handshake, once. `initTimeoutMs` bounds the
322
- * initialize wait (model discovery passes a short value so it can't hang). */
323
- start(codexPath: string, cwd: string, initTimeoutMs?: number): Promise<void> {
347
+ * initialize wait (model discovery passes a short value so it can't hang).
348
+ * `env`: the environment Codex runs in and whose config it reads; default this process's. */
349
+ start(codexPath: string, cwd: string, initTimeoutMs?: number, env?: Readonly<Record<string, string | undefined>>): Promise<void> {
324
350
  if (!this.startPromise) {
325
- this.startPromise = this.doStart(codexPath, cwd, initTimeoutMs).catch((err) => {
351
+ this.startPromise = this.doStart(codexPath, cwd, initTimeoutMs, env).catch((err) => {
326
352
  this.startPromise = null;
327
353
  throw err;
328
354
  });
@@ -330,9 +356,9 @@ class CodexAppServerProcess {
330
356
  return this.startPromise;
331
357
  }
332
358
 
333
- private async doStart(codexPath: string, cwd: string, initTimeoutMs?: number): Promise<void> {
359
+ private async doStart(codexPath: string, cwd: string, initTimeoutMs?: number, env: Readonly<Record<string, string | undefined>> = process.env): Promise<void> {
334
360
  const commandPath = resolveWindowsCommandShim(codexPath);
335
- const appServerArgs = [...codexAskAiConfigArgs(readCodexConfigTexts(cwd)), "app-server"];
361
+ const appServerArgs = [...codexAskAiConfigArgs(readCodexConfigTexts(cwd, env)), "app-server"];
336
362
  const command =
337
363
  buildWindowsCommandScriptSpawnCommand(commandPath, appServerArgs) ?? [
338
364
  commandPath,
@@ -346,7 +372,7 @@ class CodexAppServerProcess {
346
372
  // stderr pipe deadlocks the child once its buffer fills. The env turns
347
373
  // Plannotator's integration off in this Codex (ask-ai-env.ts): its hooks
348
374
  // read the marker; the Inbox MCP server is off by the `-c` overrides.
349
- proc = spawn(file, args, { cwd, env: askAiChildEnv(), stdio: ["pipe", "pipe", "ignore"] });
375
+ proc = spawn(file, args, { cwd, env: askAiChildEnv(env), stdio: ["pipe", "pipe", "ignore"] });
350
376
  } catch (err) {
351
377
  const error = err instanceof Error ? err : new Error(String(err));
352
378
  this.handleProcessEnd(error);
@@ -412,7 +438,7 @@ class CodexAppServerProcess {
412
438
  this.handleProcessEnd(error);
413
439
  if (proc) {
414
440
  try {
415
- if (!killWindowsProcessTree(proc.pid)) proc.kill();
441
+ stopChild(proc);
416
442
  } catch {
417
443
  // Already gone.
418
444
  }
@@ -432,18 +458,30 @@ class CodexAppServerProcess {
432
458
 
433
459
  private readStream(): void {
434
460
  if (!this.proc?.stdout) return;
435
- this.proc.stdout.on("data", (chunk: Buffer) => {
436
- this.buffer += this.decoder.decode(chunk, { stream: true });
437
- const lines = this.buffer.split("\n");
438
- this.buffer = lines.pop() ?? "";
439
- for (const line of lines) {
440
- const trimmed = line.replace(/\r$/, "");
441
- if (!trimmed) continue;
442
- try {
443
- this.routeMessage(JSON.parse(trimmed));
444
- } catch {
445
- // Ignore malformed lines.
461
+ const stdout = this.proc.stdout;
462
+ stdout.on("data", (chunk: Buffer) => {
463
+ const text = this.decoder.decode(chunk, { stream: true });
464
+ // Only a chunk with a newline splits: searching the whole buffer on every chunk starves the event loop on a long line.
465
+ const end = text.lastIndexOf("\n");
466
+ if (end >= 0) {
467
+ const lines = `${this.buffer}${text.slice(0, end)}`.split("\n");
468
+ this.buffer = text.slice(end + 1);
469
+ for (const line of lines) {
470
+ const trimmed = line.replace(/\r$/, "");
471
+ if (!trimmed) continue;
472
+ try {
473
+ this.routeMessage(JSON.parse(trimmed));
474
+ } catch {
475
+ // Ignore malformed lines.
476
+ }
446
477
  }
478
+ } else {
479
+ this.buffer += text;
480
+ }
481
+ if (this.buffer.length > this.lineMax) {
482
+ this.buffer = "";
483
+ stdout.destroy();
484
+ this.failProcess(new Error("Codex app-server wrote a line past the limit"));
447
485
  }
448
486
  });
449
487
  }
@@ -549,14 +587,13 @@ class CodexAppServerProcess {
549
587
  return this._alive;
550
588
  }
551
589
 
552
- kill(): void {
590
+ /** `now`: SIGKILL at once, for when this process is exiting and no later timer would run. */
591
+ kill(now = false): void {
553
592
  this._alive = false;
554
593
  this.startPromise = null;
555
594
  const proc = this.proc;
556
595
  this.proc = null;
557
- if (proc) {
558
- if (!killWindowsProcessTree(proc.pid)) proc.kill();
559
- }
596
+ if (proc) stopChild(proc, now);
560
597
  this.listeners.length = 0;
561
598
  this.requestHandlers.length = 0;
562
599
  for (const [, pending] of this.pendingRequests) {
@@ -421,6 +421,13 @@ export interface PlannotatorConfig {
421
421
  * once per machine.
422
422
  */
423
423
  inboxListIntro?: { dueAt?: string; shownAt?: string };
424
+ /**
425
+ * Inbox voice dictation (approval "inbox-voice"), off unless turned on. Written by Settings → Voice and the
426
+ * announcement (`POST /api/inbox/settings` `{ voice }`).
427
+ */
428
+ inboxVoice?: { on?: boolean; phonesOnly?: boolean; engine?: "codex" | "api" };
429
+ /** The voice announcement: the list intro's rule (`dueAt`, or `shownAt` on a fresh install), claimed with `POST /api/inbox/voice/intro`. */
430
+ inboxVoiceIntro?: { dueAt?: string; shownAt?: string };
424
431
  /**
425
432
  * The Board (approval "inbox-board", 2026-10-26 amendment): something the
426
433
  * person turns on, off by default. On: the sidebar's Board row and page,
@@ -415,6 +415,7 @@ export const INBOX_AGENT_FIELD_DESCRIPTIONS: Readonly<Record<string, Readonly<Re
415
415
  '',
416
416
  'Context in one to three plain sentences: what it affects, what you know, why you recommend one. The person switches in cold.',
417
417
  '',
418
+ '- [ ] Nowhere — silent loss, as today',
418
419
  '- [ ] Local only, purged after 30 days — cheap',
419
420
  '- [ ] Server-side per user — needs a retention policy',
420
421
  '',
@@ -422,7 +423,7 @@ export const INBOX_AGENT_FIELD_DESCRIPTIONS: Readonly<Record<string, Readonly<Re
422
423
  'Holds up: sync release',
423
424
  ':::',
424
425
  '',
425
- 'Your recommendation goes on the `Recommended:` line, never in a choice label. `:::question-multi` takes several choices, `:::question-text` free text. The person can always answer Other, add a note or skip, so never add an Other choice yourself. `Stopped: <why>` only when you cannot go on until it is answered (never `Stopped: no`); `Holds up: <work>` names work that waits while you go on. Ask only what you cannot decide alone, about 8 a round at most. Answers come back in the reply under "Answers to your questions".',
426
+ 'Your recommendation goes on the `Recommended:` line, never in a choice label. Keep the choices in their natural order; never move yours first. `:::question-multi` takes several choices, `:::question-text` free text. The person can always answer Other, add a note or skip, so never add an Other choice yourself. `Stopped: <why>` only when you cannot go on until it is answered (never `Stopped: no`); `Holds up: <work>` names work that waits while you go on. Ask only what you cannot decide alone, about 8 a round at most. Answers come back in the reply under "Answers to your questions".',
426
427
  '',
427
428
  'Optional lines after the question; most questions need none of these: `Stakes: <what it affects>` (give one with every Stopped or Holds up), `Shows: ![alt](path)` or `Shows: [name](file)` (the one picture or file it is about), `Why: <why your recommendation>` on its own line, `Reversible: no — <why>` only when it cannot be undone. Visual options can be picture choices, `- [ ] Label — ![alt](path)`: the picture picks it, the label comes back.',
428
429
  '',
@@ -457,14 +458,14 @@ export const INBOX_AGENT_FIELD_DESCRIPTIONS: Readonly<Record<string, Readonly<Re
457
458
  * the send, before anything about the chat, so it reads as about the Inbox.
458
459
  */
459
460
  export const INBOX_REMINDER_TEXT =
460
- `Plannotator Inbox is on: when you finish long work, or need a decision only the person can make, send it with plannotator_inbox send_message and keep going. ${INBOX_FOLLOW_THROUGH_RULE} ${INBOX_THREAD_RULE} Their reply comes back here. A decision in the Inbox stays there: when the person writes here, say what waits and link its thread, but never also ask it in the chat with a blocking question tool (AskUserQuestion or similar). Run \`plannotator inbox --help\` for how it works.`
461
+ `Plannotator Inbox is on: when you finish long work, or need a decision only the person can make, send it with plannotator_inbox send_message (each decision as a \`:::question\` block) and keep going. ${INBOX_FOLLOW_THROUGH_RULE} ${INBOX_THREAD_RULE} Their reply comes back here. A decision in the Inbox stays there: when the person writes here, say what waits and link its thread, but never also ask it in the chat with a blocking question tool (AskUserQuestion or similar). Run \`plannotator inbox --help\` for how it works.`
461
462
 
462
463
  /**
463
464
  * The same line for a host without the wake (OpenCode 1): the reply does not
464
465
  * come back by itself there, so it names wait_for_reply instead.
465
466
  */
466
467
  export const INBOX_REMINDER_TEXT_NO_WAKE =
467
- `Plannotator Inbox is on: when you finish long work, or need a decision only the person can make, send it with plannotator_inbox send_message and keep going. ${INBOX_FOLLOW_THROUGH_RULE} ${INBOX_THREAD_RULE} Call wait_for_reply when you need their answer. A decision in the Inbox stays there: when the person writes here, say what waits and link its thread, but never also ask it in the chat with a blocking question tool (AskUserQuestion or similar). Run \`plannotator inbox --help\` for how it works.`
468
+ `Plannotator Inbox is on: when you finish long work, or need a decision only the person can make, send it with plannotator_inbox send_message (each decision as a \`:::question\` block) and keep going. ${INBOX_FOLLOW_THROUGH_RULE} ${INBOX_THREAD_RULE} Call wait_for_reply when you need their answer. A decision in the Inbox stays there: when the person writes here, say what waits and link its thread, but never also ask it in the chat with a blocking question tool (AskUserQuestion or similar). Run \`plannotator inbox --help\` for how it works.`
468
469
 
469
470
  /**
470
471
  * The reminder naming the tool as the host registered it. Claude Code defers
@@ -1873,11 +1873,11 @@ Ask what only the reviewer can decide as a question block. Answers come back at
1873
1873
  :::question
1874
1874
  Where should we keep the edit that loses a sync conflict?
1875
1875
 
1876
- When two devices edit one note offline, the later save wins and the other edit is lost. Local storage keeps it with no server work.
1876
+ When two devices edit one note offline, the later save wins and the other is lost. Local storage keeps it with no server work.
1877
1877
 
1878
- - [ ] Local only, purged after 30 days — cheap, no server change
1878
+ - [ ] Nowhere — silent loss, as today
1879
+ - [ ] Local only, purged after 30 days — cheap
1879
1880
  - [ ] Server-side per user — survives reinstall, needs a retention policy
1880
- - [ ] Nowhere — accept silent loss for v1
1881
1881
 
1882
1882
  Recommended: Local only, purged after 30 days
1883
1883
  :::
@@ -1889,8 +1889,8 @@ Recommended: Local only, purged after 30 days
1889
1889
  - Be terse: one to three short sentences, plain words, no jargon or internal names.
1890
1890
  - Add a screenshot (\`![alt](path)\`) for anything visual.
1891
1891
  - 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.
1892
- - Choices: \`- [ ] label\`, optionally \`- [ ] label — why\`. The reviewer can also answer "Other", add a note, or skip.
1893
- - \`Recommended: <label>\` marks yours (\`A, B and C\` on \`:::question-multi\`); other text is one more answer, tagged Recommended. Keep A, B, C order.
1892
+ - Choices: \`- [ ] label\`, optionally \`- [ ] label — why\`, in natural order. The reviewer can also answer "Other", add a note, or skip.
1893
+ - \`Recommended: <label>\` marks yours (\`A, B and C\` on \`:::question-multi\`); other text is one more answer, tagged Recommended. Never move yours first.
1894
1894
  - Ask only what you cannot decide alone, about 8 a round. Do not ask rhetorical questions or questions the codebase answers.
1895
1895
  - \`- [x]\` marks a settled choice. On resubmit, check the chosen answer, or drop the block and state the decision in prose.
1896
1896
  - Leave blank lines between the parts so the block reads well on GitHub.
@@ -12,11 +12,13 @@
12
12
  *
13
13
  * Pure parsers live here so both runtimes (Bun server, Pi extension) share
14
14
  * them. The only process-spawning edge is `runTailscale`, which never invokes
15
- * a shell and is injectable for tests.
15
+ * a shell and is injectable for tests; it and the Inbox's async runner
16
+ * (inbox-pairing-checks.ts) share one lookup, `tailscaleAppFallback`.
16
17
  */
17
18
 
18
19
  import { childEnv } from "./child-env.ts";
19
20
  import { spawnSync } from "node:child_process";
21
+ import { existsSync } from "node:fs";
20
22
  import { isValidUrlHost } from "./config.ts";
21
23
 
22
24
  /** Detection commands answer from local state; keep the wait short. */
@@ -79,10 +81,66 @@ export interface TailscaleRunResult {
79
81
 
80
82
  export type TailscaleRunner = (args: string[], timeoutMs: number) => TailscaleRunResult;
81
83
 
82
- /** Run the `tailscale` CLI without a shell. */
83
- export const runTailscale: TailscaleRunner = (args, timeoutMs) => {
84
- const result = spawnSync("tailscale", args, {
85
- env: childEnv(),
84
+ /**
85
+ * The Mac Tailscale app's own program, which is also its CLI when run by
86
+ * this exact path (Tailscale's "Install CLI" writes a `/usr/local/bin/tailscale`
87
+ * script that runs it). Never through a symlink: invoked under another name
88
+ * it crashes on its bundle identifier.
89
+ */
90
+ export const MAC_TAILSCALE_APP_CLI = "/Applications/Tailscale.app/Contents/MacOS/Tailscale";
91
+
92
+ /** What the app fallback reads; each part replaceable so tests never look at the real /Applications. */
93
+ export interface TailscaleAppLookup {
94
+ platform?: NodeJS.Platform;
95
+ exists?: (path: string) => boolean;
96
+ env?: Record<string, string | undefined>;
97
+ }
98
+
99
+ /**
100
+ * The command to try when `tailscale` on PATH was not found (`ENOENT`): on
101
+ * macOS, the Tailscale app's own CLI if the app is installed, so a Mac whose
102
+ * app never had its command-line tool turned on still works. Undefined for
103
+ * any other failure, any other platform, or a test run
104
+ * (`PLANNOTATOR_TEST_SANDBOX`, so no test reaches the real app). PATH always
105
+ * comes first.
106
+ */
107
+ export function tailscaleAppFallback(result: TailscaleRunResult, lookup: TailscaleAppLookup = {}): string | undefined {
108
+ if ((result.error as NodeJS.ErrnoException | undefined)?.code !== "ENOENT") return undefined;
109
+ if ((lookup.env ?? process.env).PLANNOTATOR_TEST_SANDBOX) return undefined;
110
+ if ((lookup.platform ?? process.platform) !== "darwin") return undefined;
111
+ return (lookup.exists ?? existsSync)(MAC_TAILSCALE_APP_CLI) ? MAC_TAILSCALE_APP_CLI : undefined;
112
+ }
113
+
114
+ /**
115
+ * The environment for the app's CLI: `env` with `SHLVL=1` when it has none.
116
+ * The app's program acts as a CLI only when its environment looks like a
117
+ * shell's (SHLVL or a non-empty TERM); with neither it tries to start the
118
+ * GUI instead. Tailscale's own `/usr/local/bin/tailscale` wrapper gets SHLVL
119
+ * from `sh`; an Inbox started without a terminal (the Claude app, launchd)
120
+ * has neither. TERM is left alone.
121
+ */
122
+ export function tailscaleAppEnv(env: Record<string, string | undefined>): Record<string, string | undefined> {
123
+ return env.SHLVL ? env : { ...env, SHLVL: "1" };
124
+ }
125
+
126
+ /** What Tailscale's Mac app prints (exit 0) when run as a CLI without a shell-like environment. */
127
+ const APP_GUI_FAILED = /The Tailscale GUI failed to start/;
128
+
129
+ export const TAILSCALE_APP_NOT_CLI =
130
+ "The Tailscale app did not answer as a command-line tool; turn on Tailscale → Settings → Install CLI, then try again.";
131
+
132
+ /** The app's CLI answer, with "the GUI failed to start" (which exits 0) turned into a failure callers report in plain words. */
133
+ export function checkTailscaleAppAnswer(result: TailscaleRunResult): TailscaleRunResult {
134
+ if (result.error || !APP_GUI_FAILED.test(`${result.stdout}\n${result.stderr}`)) return result;
135
+ return { ...result, error: new Error(TAILSCALE_APP_NOT_CLI) };
136
+ }
137
+
138
+ /** Spawn one `tailscale` command (bare name or absolute path) without a shell, in `env`. */
139
+ export type TailscaleSpawn = (command: string, args: string[], timeoutMs: number, env: Record<string, string | undefined>) => TailscaleRunResult;
140
+
141
+ const spawnTailscale: TailscaleSpawn = (command, args, timeoutMs, env) => {
142
+ const result = spawnSync(command, args, {
143
+ env,
86
144
  encoding: "utf8",
87
145
  windowsHide: true,
88
146
  timeout: timeoutMs,
@@ -95,6 +153,26 @@ export const runTailscale: TailscaleRunner = (args, timeoutMs) => {
95
153
  };
96
154
  };
97
155
 
156
+ /**
157
+ * A runner that tries `tailscale` on PATH, then the app's CLI
158
+ * (`tailscaleAppFallback`) with the same args and timeout, in `tailscaleAppEnv`.
159
+ * `baseEnv` is the child environment (the live `process.env` by default).
160
+ */
161
+ export function createTailscaleRunner(
162
+ spawn: TailscaleSpawn = spawnTailscale,
163
+ lookup: TailscaleAppLookup = {},
164
+ baseEnv: () => Record<string, string | undefined> = childEnv,
165
+ ): TailscaleRunner {
166
+ return (args, timeoutMs) => {
167
+ const onPath = spawn("tailscale", args, timeoutMs, baseEnv());
168
+ const app = tailscaleAppFallback(onPath, lookup);
169
+ return app ? checkTailscaleAppAnswer(spawn(app, args, timeoutMs, tailscaleAppEnv(baseEnv()))) : onPath;
170
+ };
171
+ }
172
+
173
+ /** Run the `tailscale` CLI without a shell (PATH first, then the Mac app's CLI). */
174
+ export const runTailscale: TailscaleRunner = createTailscaleRunner();
175
+
98
176
  /**
99
177
  * A failure whose cause is Tailscale's own state on this computer or tailnet,
100
178
  * in plain words with the next step, from what the CLI printed. Undefined for
@@ -112,6 +190,7 @@ export const runTailscale: TailscaleRunner = (args, timeoutMs) => {
112
190
  export function explainTailscaleSetup(result: TailscaleRunResult): string | undefined {
113
191
  const output = `${result.stdout}\n${result.stderr}`;
114
192
  const links = output.match(/https:\/\/[^\s"'<>]+/g) ?? [];
193
+ if (APP_GUI_FAILED.test(output)) return TAILSCALE_APP_NOT_CLI;
115
194
  const enableLink = links.find((link) => /\/f\/(?:serve|https)\b/.test(link));
116
195
  if (enableLink || /\b(?:serve|https)\b[^\n]*\bnot enabled\b/i.test(output)) {
117
196
  const link = enableLink ?? links.find((candidate) => /login\.tailscale\.com/.test(candidate));
@@ -145,7 +224,7 @@ export function describeTailscaleFailure(result: TailscaleRunResult): string {
145
224
  if (result.error) {
146
225
  const code = (result.error as NodeJS.ErrnoException).code;
147
226
  if (code === "ENOENT") {
148
- return "`tailscale` CLI not found on PATH. Install Tailscale (https://tailscale.com/download) and sign in with `tailscale up`.";
227
+ return "`tailscale` CLI not found on PATH (on a Mac, Plannotator also looks for the app at /Applications/Tailscale.app). With the Tailscale app, turn on its command-line tool (Tailscale → Settings → Install CLI); otherwise install Tailscale from https://tailscale.com/download and sign in.";
149
228
  }
150
229
  if (code === "ETIMEDOUT") {
151
230
  return "Timed out waiting for the `tailscale` CLI.";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@plannotator/pi-extension",
3
- "version": "0.28.42",
3
+ "version": "0.28.43",
4
4
  "type": "module",
5
5
  "description": "Plannotator Pi extension - interactive plan review with annotations, annotate agent messages, and review code/PRs",
6
6
  "author": "backnotprop",