@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.
- package/generated/ai/endpoints.ts +19 -10
- package/generated/ai/session-bridge-pull.ts +12 -1
- package/generated/config.ts +16 -0
- package/generated/inbox/connection.ts +40 -4
- package/package.json +1 -1
- package/plannotator.html +363 -363
- package/review-editor.html +229 -229
|
@@ -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
|
});
|
|
@@ -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
|
|
@@ -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: <
|
|
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
|
-
/**
|
|
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: <
|
|
1145
|
-
* 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
|
|
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.
|
|
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",
|