@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.
- package/generated/ai/endpoints.ts +19 -10
- package/generated/ai/providers/codex-app-server.ts +60 -23
- package/generated/ai/session-bridge-pull.ts +12 -1
- package/generated/config.ts +23 -0
- package/generated/inbox/connection.ts +44 -7
- package/generated/question-block.ts +5 -5
- package/generated/tailscale.ts +85 -6
- package/package.json +1 -1
- package/plannotator.html +357 -357
- package/review-editor.html +251 -251
- package/skills/plannotator/SKILL.md +5 -5
|
@@ -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
|
-
|
|
476
|
-
controller.enqueue(
|
|
477
|
-
encoder.encode(`data: ${data}\n\n`)
|
|
478
|
-
);
|
|
488
|
+
send(`data: ${JSON.stringify(message)}\n\n`);
|
|
479
489
|
}
|
|
480
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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) {
|
|
@@ -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 >
|
|
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;
|
package/generated/config.ts
CHANGED
|
@@ -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: <
|
|
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: ` 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
|
|
@@ -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
|
-
/**
|
|
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: <
|
|
1144
|
-
* the
|
|
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
|
|
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.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",
|