pi-orchestrator-config 4.3.10 → 4.3.12

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.
Files changed (28) hide show
  1. package/README.md +13 -2
  2. package/extensions/coms/coms-p2p.ts +128 -17
  3. package/extensions/coms/queue-recovery.ts +196 -0
  4. package/extensions/orchestrator/async-agents.ts +78 -19
  5. package/extensions/orchestrator/async-result-format.ts +58 -0
  6. package/extensions/orchestrator/async-runner.ts +20 -4
  7. package/extensions/orchestrator/reviewer-output-archive.ts +69 -0
  8. package/extensions/orchestrator/utils.ts +1 -1
  9. package/extensions/pidash/activity-state.ts +58 -0
  10. package/extensions/pidash/pidash-ui/dist/assets/index-BoNVhD5K.js +23 -0
  11. package/extensions/pidash/pidash-ui/dist/assets/index-j_XBAdtq.css +2 -0
  12. package/extensions/pidash/pidash-ui/dist/index.html +2 -2
  13. package/extensions/pidash/pidash-ui/src/components/SessionSidebar.tsx +8 -2
  14. package/extensions/pidash/pidash-ui/src/components/SessionSwitcher.tsx +5 -5
  15. package/extensions/pidash/pidash-ui/src/hooks/useMessageHandler.ts +5 -0
  16. package/extensions/pidash/pidash-ui/src/lib/activity-display.ts +34 -0
  17. package/extensions/pidash/pidash-ui/src/lib/create-logger.ts +35 -0
  18. package/extensions/pidash/pidash.ts +28 -1
  19. package/extensions/pidiff/pidiff-ui/package.json +1 -1
  20. package/extensions/pitasks/task-tools.ts +2 -2
  21. package/extensions/shared/create-runtime-provider.ts +1 -1
  22. package/extensions/shared/types.ts +7 -0
  23. package/package.json +1 -1
  24. package/scripts/build-extension-uis.sh +6 -6
  25. package/scripts/daemon-shared.ts +4 -4
  26. package/scripts/pidash-server.ts +50 -67
  27. package/extensions/pidash/pidash-ui/dist/assets/index-BHH9Jo-Z.css +0 -2
  28. package/extensions/pidash/pidash-ui/dist/assets/index-DnwxYm06.js +0 -23
package/README.md CHANGED
@@ -104,6 +104,17 @@ Two systems for Pi agents to communicate with each other, activated on-demand vi
104
104
  | `*_send` | Send a prompt to a peer agent |
105
105
  | `*_get` | Non-blocking poll for a response |
106
106
  | `*_await` | Block until response arrives |
107
+ | `coms_queue_inspect` | Preview owned queued P2P messages before recovery |
108
+ | `coms_queue_clear` | Irreversibly clear only an inspect preview |
109
+
110
+ Queue recovery is explicit. Run `coms_queue_inspect` first and review its
111
+ body-free preview. Then pass its `preview_id` to `coms_queue_clear`.
112
+ Reconnects and timeouts never clear queues. This recovers the local P2P coms
113
+ queue. Pi RPC hosts may separately offer `clear_queue` for their steering and
114
+ follow-up queue. It is a distinct queue and must also be previewed and cleared
115
+ explicitly. RPC preview tokens expire after five minutes, retain at most 20
116
+ entries per provider, and require a host-side atomic snapshot comparison before
117
+ clearing.
107
118
 
108
119
  Forked from [disler/pi-vs-claude-code](https://github.com/disler/pi-vs-claude-code) coms extensions. We own these files — FIFO message queue, structured task delegation via coms protocol.
109
120
 
@@ -562,7 +573,7 @@ Pidash is a web-based dashboard that runs alongside the TUI, accessible from any
562
573
  - Event buffering for message replay on refresh
563
574
  - Browser push notifications for background events (configurable per event type)
564
575
  - Async agent live streaming (real-time tool calls and output inline in chat)
565
- - Sidebar session working indicator (pulsing green dot when AI is active)
576
+ - Session activity state distinguishes working, waiting for input, and idle. Prompt wait is not counted or styled as active work; streaming and queued browser prompts remain separate.
566
577
  - `[HH:MM]` timestamps on all messages
567
578
 
568
579
  **Access:**
@@ -831,7 +842,7 @@ See [DEVELOPMENT.md](DEVELOPMENT.md) for tips on testing extensions locally, run
831
842
 
832
843
  ## Prerequisites
833
844
 
834
- - [pi](https://github.com/badlogic/pi-mono) (minimum version: **0.84.0**)
845
+ - [pi](https://github.com/badlogic/pi-mono) (minimum version: **0.84.4**). Pidash uses Pi's `ui_prompt_start` and `ui_prompt_end` events to show prompt wait separately from agent work.
835
846
  - `gh` CLI (for GitHub operations)
836
847
  - `uv` (for Python execution)
837
848
  - `myk-pi-tools` (optional, for `/pr-review` and `/release`)
@@ -32,10 +32,13 @@ import { createLogger } from "../shared/logger.js";
32
32
  import { setLogFilePrefix } from "../shared/file-logger.js";
33
33
  import { probeStaleSocket } from "./probe-socket.js";
34
34
  import { isUserMessageDuringInbound, computeMixedTurn } from "./mixed-turn.js";
35
+ import { buildQueuePreview, clearLocalQueue, type QueueRecoveryItem, type QueueRecoveryPreview } from "./queue-recovery.js";
35
36
 
36
37
  // ━━ Constants ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
37
38
 
38
39
  const log = createLogger("coms");
40
+ const RECOVERY_PREVIEW_TTL_MS = 5 * 60 * 1000;
41
+ const MAX_RECOVERY_PREVIEWS_PER_SESSION = 20;
39
42
  let COMS_DIR = path.join(os.homedir(), ".pi", "coms");
40
43
  let MAX_HOPS = 5;
41
44
 
@@ -89,9 +92,10 @@ interface TaskUpdateEnvelope extends Envelope {
89
92
 
90
93
  interface QueueManageEnvelope extends Envelope {
91
94
  type: "queue_manage";
92
- action: "delete" | "edit" | "clear" | "prioritize";
95
+ action: "inspect" | "delete" | "edit" | "clear" | "prioritize";
93
96
  target_msg_id?: string;
94
97
  new_content?: string;
98
+ preview_id?: string;
95
99
  sender_name: string;
96
100
  }
97
101
 
@@ -157,6 +161,8 @@ interface PendingReply {
157
161
  result?: { response?: any; error?: string | null; queued_msg_ids?: string[] };
158
162
  target_name?: string;
159
163
  created_at: string;
164
+ /** Creation time drives body-free recovery previews. */
165
+ queued_at?: string;
160
166
  }
161
167
 
162
168
  interface InboundContext {
@@ -169,6 +175,7 @@ interface InboundContext {
169
175
  prompt: string;
170
176
  tasks?: Array<{ subject: string; description: string }> | null;
171
177
  response_schema?: object | null;
178
+ queued_at: string;
172
179
  fulfilled: boolean;
173
180
  /** How many times this inbound was re-injected due to mixed-turn conflict */
174
181
  mixedTurnRetries?: number;
@@ -613,6 +620,27 @@ export default function (pi: ExtensionAPI) {
613
620
  /** Track pending (unresponded) outbound msg_ids per target name. */
614
621
  const pendingOutbound: Map<string, Set<string>> = new Map();
615
622
  const inboundQueue: Map<string, InboundContext> = new Map();
623
+ /** Destructive recovery is valid only after an explicit, short-lived inspect preview. */
624
+ const queueRecoveryPreviews = new Map<string, { targetSession: string; itemIds: string[]; expiresAt: number }>();
625
+
626
+ function pruneQueueRecoveryPreviews(now = Date.now()): void {
627
+ let removed = 0;
628
+ for (const [previewId, preview] of queueRecoveryPreviews) {
629
+ if (preview.expiresAt <= now) {
630
+ queueRecoveryPreviews.delete(previewId);
631
+ removed++;
632
+ }
633
+ }
634
+ if (removed) log.debug("recovery_previews_pruned", { removed, remaining: queueRecoveryPreviews.size });
635
+ }
636
+
637
+ function retainQueueRecoveryPreview(previewId: string, targetSession: string, itemIds: string[]): void {
638
+ pruneQueueRecoveryPreviews();
639
+ const sessionPreviews = [...queueRecoveryPreviews.entries()].filter(([, preview]) => preview.targetSession === targetSession);
640
+ for (const [expiredId] of sessionPreviews.slice(0, Math.max(0, sessionPreviews.length - MAX_RECOVERY_PREVIEWS_PER_SESSION + 1))) queueRecoveryPreviews.delete(expiredId);
641
+ queueRecoveryPreviews.set(previewId, { targetSession, itemIds, expiresAt: Date.now() + RECOVERY_PREVIEW_TTL_MS });
642
+ log.debug("recovery_preview_retained", { session: targetSession, count: itemIds.length, active: queueRecoveryPreviews.size });
643
+ }
616
644
  let server: net.Server | null = null;
617
645
  let fsWatcher: fs.FSWatcher | null = null;
618
646
  let pingWorker: Worker | null = null;
@@ -700,9 +728,11 @@ export default function (pi: ExtensionAPI) {
700
728
  prompt: env.prompt,
701
729
  tasks: Array.isArray(env.tasks) ? env.tasks : null,
702
730
  response_schema: env.response_schema ?? null,
731
+ queued_at: nowIso(),
703
732
  fulfilled: false,
704
733
  };
705
734
  inboundQueue.set(env.msg_id, inbound);
735
+ log.debug("recovery_inbound_enqueued", { msgId: env.msg_id, sender: env.sender_name, state: "queued" });
706
736
  maybeRefreshWidget();
707
737
 
708
738
  // 3. If already processing another inbound, just queue — agent_end will drain FIFO.
@@ -954,6 +984,7 @@ export default function (pi: ExtensionAPI) {
954
984
  // ━━ queue_manage handler ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
955
985
  // Only the message OWNER (sender_session match) can manage their messages.
956
986
  function handleQueueManage(socket: net.Socket, env: QueueManageEnvelope): void {
987
+ pruneQueueRecoveryPreviews();
957
988
  const action = env.action;
958
989
  let affected = 0;
959
990
  let error: string | null = null;
@@ -980,19 +1011,54 @@ export default function (pi: ExtensionAPI) {
980
1011
  };
981
1012
 
982
1013
  if (action === "delete" && env.target_msg_id) {
1014
+ const preview = env.preview_id ? queueRecoveryPreviews.get(env.preview_id) : undefined;
983
1015
  const ib = inboundQueue.get(env.target_msg_id);
984
- if (!ib) { error = "not_found"; }
1016
+ if (!env.preview_id) { error = "preview_required"; }
1017
+ else if (!preview || preview.targetSession !== env.sender_session || !preview.itemIds.includes(env.target_msg_id)) { error = "invalid_preview"; }
1018
+ else if (!ib) { error = "not_found"; }
985
1019
  else if (!isOwned(ib)) { error = "not_owned"; }
986
- else { dropInbound(ib); affected++; }
1020
+ else { dropInbound(ib); queueRecoveryPreviews.delete(env.preview_id); affected++; }
987
1021
  } else if (action === "edit" && env.target_msg_id && typeof env.new_content === "string") {
988
1022
  const ib = inboundQueue.get(env.target_msg_id);
989
1023
  if (!ib) { error = "not_found"; }
990
1024
  else if (!isOwned(ib)) { error = "not_owned"; }
991
1025
  else { ib.prompt = env.new_content; affected++; }
992
1026
  } else if (action === "clear") {
993
- for (const ib of [...inboundQueue.values()]) {
994
- if (isOwned(ib)) { dropInbound(ib); affected++; }
1027
+ if (!env.preview_id) {
1028
+ error = "preview_required";
1029
+ } else {
1030
+ const storedPreview = queueRecoveryPreviews.get(env.preview_id);
1031
+ if (!storedPreview || storedPreview.targetSession !== env.sender_session) {
1032
+ error = "invalid_preview";
1033
+ try { socket.write(JSON.stringify({ type: "ack", msg_id: env.msg_id, affected, error }) + "\n"); } catch {}
1034
+ log.warn("recovery_clear_denied", { sender: env.sender_name, reason: error });
1035
+ return;
1036
+ }
1037
+ const items = [...inboundQueue.values()].filter(isOwned).map((ib, index): QueueRecoveryItem => ({
1038
+ id: ib.msg_id, sender: ib.sender_name, target: identity?.name ?? "local", queuedAt: ib.queued_at,
1039
+ position: index + 1, deliveryState: "queued",
1040
+ }));
1041
+ const outcome = clearLocalQueue(items, storedPreview.itemIds);
1042
+ if (outcome.outcome === "partial") {
1043
+ error = "preview_stale";
1044
+ } else {
1045
+ for (const item of outcome.cleared) {
1046
+ const inbound = inboundQueue.get(item.id);
1047
+ if (inbound && isOwned(inbound)) { dropInbound(inbound); affected++; }
1048
+ }
1049
+ }
1050
+ queueRecoveryPreviews.delete(env.preview_id);
995
1051
  }
1052
+ } else if (action === "inspect") {
1053
+ const items = [...inboundQueue.values()].filter(isOwned).map((ib, index): QueueRecoveryItem => ({
1054
+ id: ib.msg_id, sender: ib.sender_name, target: identity?.name ?? "local", queuedAt: ib.queued_at,
1055
+ position: index + 1, deliveryState: "queued",
1056
+ }));
1057
+ const preview = buildQueuePreview("local", items, Date.now(), ulid());
1058
+ retainQueueRecoveryPreview(preview.previewId, env.sender_session, preview.items.map(item => item.id));
1059
+ try { socket.write(JSON.stringify({ type: "ack", msg_id: env.msg_id, affected: 0, error: null, preview }) + "\n"); } catch {}
1060
+ log.info("recovery_preview", { provider: "local", sender: env.sender_name, count: preview.items.length });
1061
+ return;
996
1062
  } else if (action === "prioritize" && env.target_msg_id) {
997
1063
  const ib = inboundQueue.get(env.target_msg_id);
998
1064
  if (!ib) { error = "not_found"; }
@@ -1015,7 +1081,8 @@ export default function (pi: ExtensionAPI) {
1015
1081
  } catch { /* ignore */ }
1016
1082
 
1017
1083
  maybeRefreshWidget();
1018
- log.debug("queue_manage", action, "target", env.target_msg_id, "affected", affected, "error", error);
1084
+ const recoveryLog = error === "preview_required" || error === "invalid_preview" || error === "preview_stale" ? log.warn : error ? log.error : log.info;
1085
+ recoveryLog("recovery_local_result", { action, affected, outcome: error ? "failure" : "success", error });
1019
1086
  }
1020
1087
 
1021
1088
  // ━━ task_manage handler — remote task operations from peers ━━━━━━━━━━━
@@ -1344,7 +1411,9 @@ export default function (pi: ExtensionAPI) {
1344
1411
  - coms_list — see connected peers
1345
1412
  - coms_send — send a message to a peer
1346
1413
  - coms_get — check status of a sent message
1347
- - coms_queue_delete / coms_queue_edit / coms_queue_clear / coms_queue_prioritize — manage queued messages
1414
+ - coms_queue_inspect — preview owned inbound queue items before recovery (no message bodies)
1415
+ - coms_queue_clear — irreversibly clear only a prior inspect preview; never clears automatically
1416
+ - coms_queue_delete / coms_queue_edit / coms_queue_prioritize — manage queued messages
1348
1417
  - coms_tasks_create — create tasks on a peer's task list with auto-report and auto-message
1349
1418
  - coms_task_delete — delete a task you created on a peer's task list
1350
1419
  - coms_task_list — list tasks on a peer's task list without sending a message
@@ -1886,7 +1955,7 @@ Do not respond to this message.`;
1886
1955
  subject: Type.String({ description: "Brief task title" }),
1887
1956
  description: Type.String({ description: "Detailed task description" }),
1888
1957
  }), { description: "Optional structured tasks to create in the peer's task list." })),
1889
- clearPrevious: Type.Optional(Type.Boolean({ description: "If true, clear all your prior pending messages on the receiver before delivering this one." })),
1958
+ clearPrevious: Type.Optional(Type.Boolean({ description: "Deprecated and rejected: inspect with coms_queue_inspect, then explicitly clear using preview_id." })),
1890
1959
  }),
1891
1960
  async execute(_callId, params) {
1892
1961
  if (!identity) {
@@ -1906,7 +1975,7 @@ Do not respond to this message.`;
1906
1975
  }
1907
1976
  // Clear sender's prior pending messages on the receiver before delivering
1908
1977
  if (params.clearPrevious) {
1909
- try { await sendQueueManage(target, "clear"); } catch { /* best-effort */ }
1978
+ throw new Error("coms_send clearPrevious is no longer allowed. Inspect with coms_queue_inspect, then explicitly clear with its preview_id.");
1910
1979
  }
1911
1980
  const hops = currentInbound ? currentInbound.hops + 1 : 0;
1912
1981
  if (hops >= MAX_HOPS) {
@@ -2091,7 +2160,7 @@ Do not respond to this message.`;
2091
2160
  // ━━ coms queue management tools ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
2092
2161
 
2093
2162
  /** Send a queue_manage envelope to a peer. Only affects messages owned by this session. */
2094
- async function sendQueueManage(target: { name: string; endpoint: string }, action: string, targetMsgId?: string, newContent?: string): Promise<{ affected?: number; error?: string | null }> {
2163
+ async function sendQueueManage(target: { name: string; endpoint: string }, action: string, targetMsgId?: string, newContent?: string, previewId?: string): Promise<{ affected?: number; error?: string | null; preview?: unknown }> {
2095
2164
  if (!identity) throw new Error("coms not initialised");
2096
2165
  const ack = await sendEnvelope(target.endpoint, {
2097
2166
  type: "queue_manage",
@@ -2103,23 +2172,60 @@ Do not respond to this message.`;
2103
2172
  action,
2104
2173
  target_msg_id: targetMsgId,
2105
2174
  new_content: newContent,
2175
+ preview_id: previewId,
2106
2176
  sender_name: identity.name,
2107
2177
  });
2108
- return { affected: ack?.affected, error: ack?.error };
2178
+ return { affected: ack?.affected, error: ack?.error, preview: ack?.preview };
2109
2179
  }
2110
2180
 
2181
+ pi.registerTool({
2182
+ name: "coms_queue_inspect",
2183
+ label: "Coms Queue Inspect",
2184
+ description: "Preview your queued inbound messages on a peer before any irreversible recovery. Returns IDs, sender, target, age, FIFO position, and delivery state, never full message bodies. Pass preview_id to coms_queue_clear.",
2185
+ parameters: Type.Object({
2186
+ target: Type.String({ description: "Peer name or session_id." }),
2187
+ }),
2188
+ async execute(_callId, params) {
2189
+ const target = await resolveTarget(params.target);
2190
+ if (!target) throw new Error(`coms: no live agent matching "${params.target}"`);
2191
+ const result = await sendQueueManage(target, "inspect");
2192
+ const preview = result.preview as QueueRecoveryPreview | undefined;
2193
+ if (result.error || !preview || preview.outcome !== "supported" || !preview.previewId || !Array.isArray(preview.items)) {
2194
+ throw new Error(`coms_queue_inspect failed: ${result.error ?? "malformed preview"}`);
2195
+ }
2196
+ log.info("recovery_inspect_requested", { target: target.name, previewId: preview.previewId, count: preview.items.length });
2197
+ const itemLines = preview.items.map(item =>
2198
+ `- id: ${item.id} | sender: ${item.sender} | target: ${item.target} | age_ms: ${item.ageMs} | position: ${item.position} | delivery_state: ${item.deliveryState}`,
2199
+ );
2200
+ return {
2201
+ content: [{
2202
+ type: "text" as const,
2203
+ text: [
2204
+ `coms_queue_inspect → ${target.name}`,
2205
+ "Review this preview before clearing. No message bodies are shown.",
2206
+ `preview_id: ${preview.previewId}`,
2207
+ `items: ${preview.items.length}`,
2208
+ ...itemLines,
2209
+ ].join("\n"),
2210
+ }],
2211
+ details: preview,
2212
+ };
2213
+ },
2214
+ });
2215
+
2111
2216
  pi.registerTool({
2112
2217
  name: "coms_queue_delete",
2113
2218
  label: "Coms Queue Delete",
2114
- description: "Delete your message from a peer's queue. Only works on your own pending messages.",
2219
+ description: "Irreversibly delete one of your messages shown by coms_queue_inspect. Only the owner may delete it.",
2115
2220
  parameters: Type.Object({
2116
2221
  target: Type.String({ description: "Peer name or session_id." }),
2117
- msg_id: Type.String({ description: "The msg_id to delete from the peer's queue." }),
2222
+ msg_id: Type.String({ description: "The inspected msg_id to delete from the peer's queue." }),
2223
+ preview_id: Type.String({ description: "previewId returned by coms_queue_inspect for this peer." }),
2118
2224
  }),
2119
2225
  async execute(_callId, params) {
2120
2226
  const target = await resolveTarget(params.target);
2121
2227
  if (!target) throw new Error(`coms: no live agent matching "${params.target}"`);
2122
- const result = await sendQueueManage(target, "delete", params.msg_id);
2228
+ const result = await sendQueueManage(target, "delete", params.msg_id, undefined, params.preview_id);
2123
2229
  if (result.error) throw new Error(`coms_queue_delete failed: ${result.error}`);
2124
2230
  return { content: [{ type: "text" as const, text: `coms_queue_delete → ${target.name}\nmsg_id: ${params.msg_id}` }] };
2125
2231
  },
@@ -2146,15 +2252,18 @@ Do not respond to this message.`;
2146
2252
  pi.registerTool({
2147
2253
  name: "coms_queue_clear",
2148
2254
  label: "Coms Queue Clear",
2149
- description: "Delete ALL your pending messages from a peer's queue. Only affects your own messages.",
2255
+ description: "Irreversibly clear only the messages shown by a preceding coms_queue_inspect preview. Ownership is enforced by the peer; untouched messages retain FIFO order.",
2150
2256
  parameters: Type.Object({
2151
2257
  target: Type.String({ description: "Peer name or session_id." }),
2258
+ preview_id: Type.String({ description: "previewId returned by coms_queue_inspect for this peer." }),
2152
2259
  }),
2153
2260
  async execute(_callId, params) {
2154
2261
  const target = await resolveTarget(params.target);
2155
2262
  if (!target) throw new Error(`coms: no live agent matching "${params.target}"`);
2156
- await sendQueueManage(target, "clear");
2157
- return { content: [{ type: "text" as const, text: `coms_queue_clear → ${target.name}` }] };
2263
+ const result = await sendQueueManage(target, "clear", undefined, undefined, params.preview_id);
2264
+ if (result.error) throw new Error(`coms_queue_clear failed: ${result.error}`);
2265
+ log.info("recovery_clear_requested", { target: target.name, affected: result.affected ?? 0 });
2266
+ return { content: [{ type: "text" as const, text: `coms_queue_clear → ${target.name}\ncleared: ${result.affected ?? 0}` }], details: { affected: result.affected ?? 0, preview_id: params.preview_id } };
2158
2267
  },
2159
2268
  });
2160
2269
 
@@ -3163,6 +3272,8 @@ Do not respond to this message.`;
3163
3272
  try { removeRegistryEntry(ident.project, ident.coms_session_id); } catch { /* ignore */ }
3164
3273
  log.info("shutdown", ident.coms_session_id);
3165
3274
  }
3275
+ queueRecoveryPreviews.clear();
3276
+ log.debug("recovery_previews_cleared", { reason: "shutdown" });
3166
3277
  if (currentCtx?.hasUI) {
3167
3278
  try { currentCtx.ui.setWidget("coms-pool", undefined); } catch { /* ignore */ }
3168
3279
  try { currentCtx.ui.setStatus("coms", undefined); } catch { /* ignore */ }
@@ -0,0 +1,196 @@
1
+ /**
2
+ * Queue recovery contracts shared by the P2P queue tools and optional RPC hosts.
3
+ * Recovery is intentionally two-step: callers must retain a previewId from inspect
4
+ * and pass it to a destructive operation. Message text never belongs in a preview.
5
+ */
6
+ import { createHash } from "node:crypto";
7
+ import { createLogger } from "../shared/logger.js";
8
+
9
+ const log = createLogger("coms");
10
+ export const RPC_RECOVERY_PREVIEW_TTL_MS = 5 * 60 * 1000;
11
+ export const MAX_RPC_RECOVERY_PREVIEWS_PER_PROVIDER = 20;
12
+
13
+ interface RpcPreviewRecord {
14
+ snapshot: string;
15
+ items: QueueRecoveryItem[];
16
+ expiresAt: number;
17
+ }
18
+
19
+ const rpcPreviews = new WeakMap<RpcQueueRecoveryProvider, Map<string, RpcPreviewRecord>>();
20
+ let rpcPreviewSequence = 0;
21
+
22
+ export type QueueRecoveryOutcome = "supported" | "unavailable" | "malformed" | "partial" | "failure" | "success" | "invalid_preview" | "stale_preview" | "indeterminate";
23
+ export type QueueDeliveryState = "queued" | "processing" | "pending_reply" | "timed_out";
24
+
25
+ export interface QueueRecoveryItem {
26
+ id: string;
27
+ sender: string;
28
+ target: string;
29
+ /** `null` when the queue source does not expose an enqueue timestamp. */
30
+ queuedAt: string | null;
31
+ position: number;
32
+ deliveryState: QueueDeliveryState;
33
+ }
34
+
35
+ export interface QueuePreviewItem extends QueueRecoveryItem {
36
+ /** `null` when `queuedAt` is unknown. */
37
+ ageMs: number | null;
38
+ }
39
+
40
+ export interface QueueRecoveryPreview {
41
+ outcome: "supported" | "unavailable" | "malformed";
42
+ provider: "local" | "rpc";
43
+ previewId: string;
44
+ items: QueuePreviewItem[];
45
+ reason?: string;
46
+ }
47
+
48
+ /** Body-free audit record returned when a host may have cleared before returning malformed data. */
49
+ export interface QueueRecoveryAttempt {
50
+ snapshot: string;
51
+ items: QueueRecoveryItem[];
52
+ }
53
+
54
+ export interface QueueRecoveryResult {
55
+ outcome: Exclude<QueueRecoveryOutcome, "supported">;
56
+ cleared: QueueRecoveryItem[];
57
+ untouched: QueueRecoveryItem[];
58
+ attempted?: QueueRecoveryAttempt;
59
+ reason?: string;
60
+ }
61
+
62
+ export function clearLocalQueue(items: QueueRecoveryItem[], ids: readonly string[]): QueueRecoveryResult {
63
+ const selected = new Set(ids);
64
+ const cleared = items.filter(item => selected.has(item.id));
65
+ const untouched = items.filter(item => !selected.has(item.id));
66
+ const outcome = cleared.length === ids.length ? "success" : "partial";
67
+ log.debug("recovery_local_classified", { requested: ids.length, cleared: cleared.length, outcome });
68
+ return {
69
+ outcome,
70
+ cleared,
71
+ untouched,
72
+ ...(outcome === "success" ? {} : { reason: "Some previewed queue items no longer exist" }),
73
+ };
74
+ }
75
+
76
+ export interface RpcQueueRecoveryProvider {
77
+ /** Read-only host adapter. Pi RPC hosts may expose this alongside conditional clearing. */
78
+ previewQueue?(): Promise<unknown>;
79
+ /**
80
+ * Atomically compare the supplied body-free snapshot with the host queue and clear only on a match.
81
+ * Return `{ outcome: "stale_preview" }` on mismatch; otherwise return the cleared queue response.
82
+ */
83
+ clearQueueIfSnapshot(expectedSnapshot: string): Promise<unknown>;
84
+ }
85
+
86
+ function validRpcQueue(response: unknown): response is { steering: string[]; followUp: string[] } {
87
+ return !!response && typeof response === "object" && Array.isArray((response as any).steering) && Array.isArray((response as any).followUp)
88
+ && (response as any).steering.every((item: unknown) => typeof item === "string")
89
+ && (response as any).followUp.every((item: unknown) => typeof item === "string");
90
+ }
91
+
92
+ function writeFrame(hash: ReturnType<typeof createHash>, value: string | number): void {
93
+ const bytes = Buffer.from(typeof value === "number" ? String(value) : value, "utf8");
94
+ const length = Buffer.allocUnsafe(4);
95
+ length.writeUInt32BE(bytes.length);
96
+ hash.update(length).update(bytes);
97
+ }
98
+
99
+ /** Returns a SHA-256 digest of explicit UTF-8 length-framed queue fields, never message text. */
100
+ export function rpcQueueSnapshot(response: unknown): string | undefined {
101
+ if (!validRpcQueue(response)) {
102
+ log.warn("recovery_rpc_malformed", { operation: "snapshot" });
103
+ return undefined;
104
+ }
105
+ const hash = createHash("sha256");
106
+ writeFrame(hash, "pi-config.rpc-queue.v1");
107
+ for (const [kind, messages] of [["steering", response.steering], ["follow_up", response.followUp]] as const) {
108
+ writeFrame(hash, kind);
109
+ writeFrame(hash, messages.length);
110
+ for (const message of messages) writeFrame(hash, message);
111
+ }
112
+ const snapshot = `sha256:${hash.digest("hex")}`;
113
+ log.debug("recovery_rpc_snapshot", { steering: response.steering.length, followUp: response.followUp.length });
114
+ return snapshot;
115
+ }
116
+
117
+ function pruneRpcPreviews(previews: Map<string, RpcPreviewRecord>, now: number): void {
118
+ for (const [previewId, preview] of previews) if (preview.expiresAt <= now) previews.delete(previewId);
119
+ }
120
+
121
+ function retainRpcPreview(provider: RpcQueueRecoveryProvider, previewId: string, snapshot: string, items: QueueRecoveryItem[], now: number): void {
122
+ const previews = rpcPreviews.get(provider) ?? new Map<string, RpcPreviewRecord>();
123
+ pruneRpcPreviews(previews, now);
124
+ while (previews.size >= MAX_RPC_RECOVERY_PREVIEWS_PER_PROVIDER) previews.delete(previews.keys().next().value!);
125
+ previews.set(previewId, { snapshot, items, expiresAt: now + RPC_RECOVERY_PREVIEW_TTL_MS });
126
+ rpcPreviews.set(provider, previews);
127
+ log.debug("recovery_rpc_preview_retained", { active: previews.size, count: items.length });
128
+ }
129
+
130
+ /** Classifies a host-supplied, read-only RPC queue preview without retaining text in its result. */
131
+ export async function previewRpcQueue(provider: RpcQueueRecoveryProvider | undefined, now = Date.now()): Promise<QueueRecoveryPreview> {
132
+ if (!provider?.previewQueue) {
133
+ log.warn("recovery_rpc_preview_unavailable", { operation: "preview" });
134
+ return { outcome: "unavailable", provider: "rpc", previewId: "", items: [], reason: "RPC queue preview is unavailable" };
135
+ }
136
+ try {
137
+ const response = await provider.previewQueue();
138
+ const snapshot = rpcQueueSnapshot(response);
139
+ if (!snapshot || !validRpcQueue(response)) return { outcome: "malformed", provider: "rpc", previewId: "", items: [], reason: "Malformed RPC queue preview" };
140
+ const queueItems = [...rpcItems("steering", response.steering), ...rpcItems("follow_up", response.followUp)];
141
+ const preview = buildQueuePreview("rpc", queueItems, now, `rpc-queue-preview-${now}-${++rpcPreviewSequence}`);
142
+ retainRpcPreview(provider, preview.previewId, snapshot, queueItems, now);
143
+ log.info("recovery_rpc_preview", { operation: "preview", count: preview.items.length });
144
+ return preview;
145
+ } catch (error) {
146
+ log.error("recovery_rpc_preview_failed", { operation: "preview", reason: error instanceof Error ? error.message : String(error) });
147
+ return { outcome: "unavailable", provider: "rpc", previewId: "", items: [], reason: error instanceof Error ? error.message : String(error) };
148
+ }
149
+ }
150
+
151
+ export function buildQueuePreview(provider: "local" | "rpc", items: QueueRecoveryItem[], now = Date.now(), previewId = `queue-preview-${now}`): QueueRecoveryPreview {
152
+ log.debug("recovery_preview_built", { provider, count: items.length });
153
+ return {
154
+ outcome: "supported",
155
+ provider,
156
+ previewId,
157
+ items: items.map(item => ({ ...item, ageMs: item.queuedAt === null ? null : Math.max(0, now - Date.parse(item.queuedAt)) })),
158
+ };
159
+ }
160
+
161
+ function rpcItems(kind: "steering" | "follow_up", messages: string[]): QueueRecoveryItem[] {
162
+ log.debug("recovery_rpc_items", { kind, count: messages.length, queuedAt: "unknown" });
163
+ return messages.map((_message, index) => ({ id: `${kind}-${index + 1}`, sender: "pi-rpc", target: "local", queuedAt: null, position: index + 1, deliveryState: "queued" }));
164
+ }
165
+
166
+ /** Clears only the exact RPC queue snapshot represented by a live preview token. */
167
+ export async function clearRpcQueue(provider: RpcQueueRecoveryProvider | undefined, previewId: string, now = Date.now()): Promise<QueueRecoveryResult> {
168
+ if (!provider) {
169
+ log.warn("recovery_rpc_clear_unavailable", { operation: "clear" });
170
+ return { outcome: "unavailable", cleared: [], untouched: [], reason: "RPC queue recovery is unavailable" };
171
+ }
172
+ const previews = rpcPreviews.get(provider);
173
+ if (previews) pruneRpcPreviews(previews, now);
174
+ const preview = previewId ? previews?.get(previewId) : undefined;
175
+ if (!preview) {
176
+ log.warn("recovery_rpc_clear_denied", { operation: "clear", reason: "invalid_preview" });
177
+ return { outcome: "invalid_preview", cleared: [], untouched: [], reason: "A valid RPC queue preview is required" };
178
+ }
179
+ previews?.delete(previewId);
180
+ try {
181
+ const response = await provider.clearQueueIfSnapshot(preview.snapshot);
182
+ if (response && typeof response === "object" && (response as any).outcome === "stale_preview") {
183
+ log.warn("recovery_rpc_clear_denied", { operation: "clear", reason: "stale_preview" });
184
+ return { outcome: "stale_preview", cleared: [], untouched: preview.items, reason: "RPC queue changed after preview" };
185
+ }
186
+ if (!validRpcQueue(response)) {
187
+ log.warn("recovery_rpc_clear_indeterminate", { operation: "clear", attempted: preview.items.length });
188
+ return { outcome: "indeterminate", cleared: [], untouched: [], attempted: { snapshot: preview.snapshot, items: preview.items }, reason: "Host clear response was malformed after an attempted clear" };
189
+ }
190
+ log.info("recovery_rpc_clear", { operation: "clear", outcome: "success", cleared: preview.items.length });
191
+ return { outcome: "success", cleared: preview.items, untouched: [] };
192
+ } catch (error) {
193
+ log.error("recovery_rpc_clear_failed", { operation: "clear", reason: error instanceof Error ? error.message : String(error) });
194
+ return { outcome: "failure", cleared: [], untouched: [], reason: error instanceof Error ? error.message : String(error) };
195
+ }
196
+ }