@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.
- package/generated/ai/providers/codex-app-server.ts +60 -23
- package/generated/config.ts +7 -0
- package/generated/inbox/connection.ts +4 -3
- package/generated/question-block.ts +5 -5
- package/generated/tailscale.ts +85 -6
- package/package.json +1 -1
- package/plannotator.html +160 -160
- package/review-editor.html +121 -121
- package/skills/plannotator/SKILL.md +5 -5
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
436
|
-
|
|
437
|
-
const
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
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
|
-
|
|
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) {
|
package/generated/config.ts
CHANGED
|
@@ -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: ` 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 — `: 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
|
|
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
|
-
- [ ]
|
|
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 (\`\`) 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
|
|
1893
|
-
- \`Recommended: <label>\` marks yours (\`A, B and C\` on \`:::question-multi\`); other text is one more answer, tagged Recommended.
|
|
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.
|
package/generated/tailscale.ts
CHANGED
|
@@ -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
|
-
/**
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
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
|
|
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.
|
|
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",
|