@plannotator/pi-extension 0.28.43 → 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
  });
@@ -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
@@ -1488,6 +1493,17 @@ export function resolveInboxPhones(
1488
1493
  return coerceConfigBoolean(config.inboxPhones, false);
1489
1494
  }
1490
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
+
1491
1507
  /**
1492
1508
  * The experimental features this build knows: the name PLANNOTATOR_EXPERIMENTAL
1493
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
@@ -774,7 +774,12 @@ export interface InboxReplyCommand {
774
774
  thread_id: string
775
775
  /** The agent message a reply answers; null for a message. */
776
776
  reply_to: string | null
777
- /** 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
+ */
778
783
  subject: string | null
779
784
  /** The reply or message, markdown, verbatim. */
780
785
  body: string
@@ -982,6 +987,36 @@ function wakeName(value: unknown): string {
982
987
  */
983
988
  export const INBOX_SEND_NOW_POLL_FIELD = 'send_now'
984
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
+
985
1020
  /**
986
1021
  * The turn a connection submits when a reply it put into a running turn
987
1022
  * arrived as that turn ended, so the model never read it: the reply is in
@@ -1141,8 +1176,9 @@ export function inboxWakeClosingLine(threadId: unknown, toolName: string = INBOX
1141
1176
  }
1142
1177
 
1143
1178
  /**
1144
- * The turn a reply or a message becomes: `Plannotator Inbox: <subject> (<id>)`,
1145
- * 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
1146
1182
  * thread the person started; on a redirected reply, the line
1147
1183
  * saying so under it), then the words verbatim, then the closing line
1148
1184
  * (`inboxWakeClosingLine`, naming `toolName`, the tool as the host
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@plannotator/pi-extension",
3
- "version": "0.28.43",
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",