@plannotator/pi-extension 0.28.42 → 0.28.44

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.
@@ -148,6 +148,13 @@ export interface AIEndpointDeps {
148
148
  * `authorizeSessionBridgeRequest` (loopback Host) before the token check.
149
149
  */
150
150
  pullBridge?: { handle(req: Request): Promise<Response> | null };
151
+ /**
152
+ * A dropped `/api/ai/query` stream leaves the question running: the answer
153
+ * keeps draining and the provider decides what to do with it. The Inbox's
154
+ * Ask AI saves it, so a reload or a sleeping phone stops nothing. Default off:
155
+ * a disconnect aborts. `/api/ai/abort` aborts either way.
156
+ */
157
+ keepRunningOnDisconnect?: boolean;
151
158
  }
152
159
 
153
160
  /** Error code for a bridge request refused by `authorizeSessionBridgeRequest`. */
@@ -266,6 +273,7 @@ export function createAIEndpoints(deps: AIEndpointDeps) {
266
273
  beforeProviderSession,
267
274
  authorizeSessionBridgeRequest,
268
275
  pullBridge,
276
+ keepRunningOnDisconnect = false,
269
277
  } = deps;
270
278
 
271
279
  // Streams whose question /api/ai/abort already handled. The browser drops a
@@ -465,31 +473,31 @@ export function createAIEndpoints(deps: AIEndpointDeps) {
465
473
  markers!.delete(marker);
466
474
  if (markers!.size === 0 && liveStreams.get(sessionId) === markers) liveStreams.delete(sessionId);
467
475
  };
476
+ // Set only with `keepRunningOnDisconnect`: the question drains into nothing.
477
+ let draining = false;
468
478
  const stream = new ReadableStream({
469
479
  async start(controller) {
480
+ const send = (text: string) => {
481
+ if (!draining) controller.enqueue(encoder.encode(text));
482
+ };
470
483
  try {
471
484
  const messages = Object.keys(queryOptions).length > 0
472
485
  ? entry.session.query(effectivePrompt, queryOptions)
473
486
  : entry.session.query(effectivePrompt);
474
487
  for await (const message of messages) {
475
- const data = JSON.stringify(message);
476
- controller.enqueue(
477
- encoder.encode(`data: ${data}\n\n`)
478
- );
488
+ send(`data: ${JSON.stringify(message)}\n\n`);
479
489
  }
480
- controller.enqueue(encoder.encode("data: [DONE]\n\n"));
490
+ send("data: [DONE]\n\n");
481
491
  } catch (err) {
482
492
  const errorMsg: AIMessage = {
483
493
  type: "error",
484
494
  error: err instanceof Error ? err.message : String(err),
485
495
  code: "stream_error",
486
496
  };
487
- controller.enqueue(
488
- encoder.encode(`data: ${JSON.stringify(errorMsg)}\n\n`)
489
- );
497
+ send(`data: ${JSON.stringify(errorMsg)}\n\n`);
490
498
  } finally {
491
499
  forget();
492
- controller.close();
500
+ if (!draining) controller.close();
493
501
  }
494
502
  },
495
503
  cancel() {
@@ -497,8 +505,9 @@ export function createAIEndpoints(deps: AIEndpointDeps) {
497
505
  // close, navigation). Stop the in-flight turn so it doesn't keep
498
506
  // running on the now long-lived provider process. Not when an abort
499
507
  // request already handled this question (see liveStreams).
508
+ draining = keepRunningOnDisconnect;
500
509
  forget();
501
- if (marker.handled) return;
510
+ if (marker.handled || draining) return;
502
511
  entry.session.abort();
503
512
  },
504
513
  });
@@ -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) {
@@ -114,11 +114,15 @@ export interface PullSessionBridgeOptions extends PullSessionBridgeConfig {
114
114
  interruptTimeoutMs?: number;
115
115
  /** Default 15s: after a cancel, how long to wait for the host to confirm before freeing the slot. */
116
116
  cancelGraceMs?: number;
117
+ /** Default 1,000,000: the most characters one request may carry. A `done` repeats the whole answer. */
118
+ maxBodyChars?: number;
117
119
  now?: () => number;
118
120
  }
119
121
 
120
122
  export interface PullSessionBridge {
121
123
  readonly bridge: SessionBridge;
124
+ /** The host has polled, so `status()` is what it reported, not a new bridge's `ready`. */
125
+ readonly connected: boolean;
122
126
  /** Handle a request to one of the two bridge paths. `null` for any other path. */
123
127
  handle(req: Request): Promise<Response> | null;
124
128
  /** Server shutdown: answer open polls with `closing`, fail anything still waiting. */
@@ -211,11 +215,14 @@ export function createPullSessionBridge(options: PullSessionBridgeOptions): Pull
211
215
  const resendAfterMs = options.resendAfterMs ?? 5_000;
212
216
  const interruptTimeoutMs = options.interruptTimeoutMs ?? 15_000;
213
217
  const cancelGraceMs = options.cancelGraceMs ?? 15_000;
218
+ const maxBodyChars = options.maxBodyChars ?? MAX_EVENT_BODY_BYTES;
214
219
  const createdAt = now();
215
220
  const modes = { ...options.modes };
216
221
 
217
222
  let hostStatus: SessionBridgeStatus = "ready";
218
223
  let connected = false;
224
+ /** A poll came: an event alone reports no status, however late it is. */
225
+ let polled = false;
219
226
  let lastSeen = createdAt;
220
227
  let openPoll: OpenPoll | null = null;
221
228
  let active: ActiveAsk | null = null;
@@ -404,7 +411,7 @@ export function createPullSessionBridge(options: PullSessionBridgeOptions): Pull
404
411
 
405
412
  const readJson = async (req: Request): Promise<unknown> => {
406
413
  const text = await req.text();
407
- if (text.length > MAX_EVENT_BODY_BYTES) throw new Error("Request body too large.");
414
+ if (text.length > maxBodyChars) throw new Error("Request body too large.");
408
415
  return text ? JSON.parse(text) : {};
409
416
  };
410
417
 
@@ -425,6 +432,7 @@ export function createPullSessionBridge(options: PullSessionBridgeOptions): Pull
425
432
  return json({ error: "Invalid JSON body." }, 400);
426
433
  }
427
434
  noteContact();
435
+ polled = true;
428
436
  if (isRecord(body)) {
429
437
  applyStatus(body.status);
430
438
  if (isRecord(body.modes)) {
@@ -543,6 +551,9 @@ export function createPullSessionBridge(options: PullSessionBridgeOptions): Pull
543
551
 
544
552
  return {
545
553
  bridge,
554
+ get connected() {
555
+ return polled;
556
+ },
546
557
  handle(req) {
547
558
  const path = new URL(req.url).pathname;
548
559
  if (path !== SESSION_BRIDGE_POLL_PATH && path !== SESSION_BRIDGE_EVENT_PATH) return null;
@@ -360,6 +360,11 @@ export interface PlannotatorConfig {
360
360
  * out): on wherever the tool is on.
361
361
  */
362
362
  inboxReminder?: boolean | Partial<Record<InboxToolHost, boolean>>;
363
+ /**
364
+ * The Plannotator Inbox's debug log (`inbox/debug.log` in the data dir), as
365
+ * `PLANNOTATOR_INBOX_DEBUG=1`. Config-only, read when the Inbox starts.
366
+ */
367
+ inboxDebug?: boolean;
363
368
  /**
364
369
  * The Plannotator Inbox's browser notifications, set from the Inbox page
365
370
  * (its one-time ask and its Settings), kept here rather than in the page's
@@ -421,6 +426,13 @@ export interface PlannotatorConfig {
421
426
  * once per machine.
422
427
  */
423
428
  inboxListIntro?: { dueAt?: string; shownAt?: string };
429
+ /**
430
+ * Inbox voice dictation (approval "inbox-voice"), off unless turned on. Written by Settings → Voice and the
431
+ * announcement (`POST /api/inbox/settings` `{ voice }`).
432
+ */
433
+ inboxVoice?: { on?: boolean; phonesOnly?: boolean; engine?: "codex" | "api" };
434
+ /** The voice announcement: the list intro's rule (`dueAt`, or `shownAt` on a fresh install), claimed with `POST /api/inbox/voice/intro`. */
435
+ inboxVoiceIntro?: { dueAt?: string; shownAt?: string };
424
436
  /**
425
437
  * The Board (approval "inbox-board", 2026-10-26 amendment): something the
426
438
  * person turns on, off by default. On: the sidebar's Board row and page,
@@ -1481,6 +1493,17 @@ export function resolveInboxPhones(
1481
1493
  return coerceConfigBoolean(config.inboxPhones, false);
1482
1494
  }
1483
1495
 
1496
+ /**
1497
+ * Whether the Plannotator Inbox offers Ask AI: an environment switch, off by
1498
+ * default, until the owner has used it. Off, the ask routes answer 404, no
1499
+ * question can wait and no agent connects for one. Same words as
1500
+ * PLANNOTATOR_INBOX_PHONES; anything else counts as off.
1501
+ */
1502
+ export function resolveInboxAskAi(env: NodeJS.ProcessEnv = process.env): boolean {
1503
+ const v = env.PLANNOTATOR_INBOX_ASK_AI?.trim().toLowerCase();
1504
+ return v === "1" || v === "true" || v === "on";
1505
+ }
1506
+
1484
1507
  /**
1485
1508
  * The experimental features this build knows: the name PLANNOTATOR_EXPERIMENTAL
1486
1509
  * lists (kebab case) and the key it is saved under in config.json
@@ -361,7 +361,7 @@ export const INBOX_TOOL_LEAD =
361
361
  * OpenCode 2 as a user message, and all three carry the wake's first line.
362
362
  */
363
363
  export const INBOX_TOOL_WAKE_NOTE =
364
- "A reply arrives in this session by itself once it is idle, headed `Plannotator Inbox: <subject> (<reply id>)`: end your turn or keep working; do not call wait_for_reply. To answer it, send_message with reply_to set to that reply id, never one from memory."
364
+ "A reply arrives in this session by itself once it is idle, headed `Plannotator Inbox: <title> (<reply id>)`: end your turn or keep working; do not call wait_for_reply. To answer it, send_message with reply_to set to that reply id, never one from memory."
365
365
 
366
366
  /**
367
367
  * Fixed text, the description's last line: where everything the tool's short
@@ -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
@@ -773,7 +774,12 @@ export interface InboxReplyCommand {
773
774
  thread_id: string
774
775
  /** The agent message a reply answers; null for a message. */
775
776
  reply_to: string | null
776
- /** The thread's subject. */
777
+ /**
778
+ * The title a wake shows, as the Inbox decided it (`InboxStore.agentTitle`):
779
+ * on a plain reply the thread's short name, or null when it has none, which
780
+ * every connection words `a reply`; on everything else the thread's
781
+ * subject. An older Inbox always sends the subject here.
782
+ */
777
783
  subject: string | null
778
784
  /** The reply or message, markdown, verbatim. */
779
785
  body: string
@@ -981,6 +987,36 @@ function wakeName(value: unknown): string {
981
987
  */
982
988
  export const INBOX_SEND_NOW_POLL_FIELD = 'send_now'
983
989
 
990
+ /**
991
+ * The poll field (`true`) by which a connection says it can answer Ask AI
992
+ * live. An older connection never sends it, so a question never waits for it.
993
+ */
994
+ export const INBOX_ASK_POLL_FIELD = 'ask'
995
+
996
+ /**
997
+ * Set (`true`) on every poll answer while a question waits for this session
998
+ * and its Ask AI client has not connected, and a held poll ends at once. The
999
+ * connection then runs the client against `inboxAskBridgeBase(session)`, with
1000
+ * the Inbox's token, until the Inbox says `closing`. It is a state, not an
1001
+ * event: a poll answer that does not read it loses nothing.
1002
+ */
1003
+ export const INBOX_ASK_WAITS_FIELD = 'ask_waits'
1004
+
1005
+ export const INBOX_ASK_BRIDGE_PREFIX = '/api/inbox/ask/'
1006
+
1007
+ /** Where a session's Ask AI client talks to the Inbox: the pull bridge's two paths go after it. */
1008
+ export function inboxAskBridgeBase(session: string): string {
1009
+ return `${INBOX_ASK_BRIDGE_PREFIX}${encodeURIComponent(session)}`
1010
+ }
1011
+
1012
+ export function parseInboxAskWaits(text: string): boolean {
1013
+ try {
1014
+ return (JSON.parse(text) as Record<string, unknown>)[INBOX_ASK_WAITS_FIELD] === true
1015
+ } catch {
1016
+ return false
1017
+ }
1018
+ }
1019
+
984
1020
  /**
985
1021
  * The turn a connection submits when a reply it put into a running turn
986
1022
  * arrived as that turn ended, so the model never read it: the reply is in
@@ -1140,8 +1176,9 @@ export function inboxWakeClosingLine(threadId: unknown, toolName: string = INBOX
1140
1176
  }
1141
1177
 
1142
1178
  /**
1143
- * The turn a reply or a message becomes: `Plannotator Inbox: <subject> (<id>)`,
1144
- * the fixed instruction line for its type (a reply, a New message, or a
1179
+ * The turn a reply or a message becomes: `Plannotator Inbox: <title> (<id>)`
1180
+ * (the title is the command's `subject`, or `a reply` / `a message` without
1181
+ * one), the fixed instruction line for its type (a reply, a New message, or a
1145
1182
  * thread the person started; on a redirected reply, the line
1146
1183
  * saying so under it), then the words verbatim, then the closing line
1147
1184
  * (`inboxWakeClosingLine`, naming `toolName`, the tool as the host
@@ -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.44",
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",