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.
- package/README.md +13 -2
- package/extensions/coms/coms-p2p.ts +128 -17
- package/extensions/coms/queue-recovery.ts +196 -0
- package/extensions/orchestrator/async-agents.ts +78 -19
- package/extensions/orchestrator/async-result-format.ts +58 -0
- package/extensions/orchestrator/async-runner.ts +20 -4
- package/extensions/orchestrator/reviewer-output-archive.ts +69 -0
- package/extensions/orchestrator/utils.ts +1 -1
- package/extensions/pidash/activity-state.ts +58 -0
- package/extensions/pidash/pidash-ui/dist/assets/index-BoNVhD5K.js +23 -0
- package/extensions/pidash/pidash-ui/dist/assets/index-j_XBAdtq.css +2 -0
- package/extensions/pidash/pidash-ui/dist/index.html +2 -2
- package/extensions/pidash/pidash-ui/src/components/SessionSidebar.tsx +8 -2
- package/extensions/pidash/pidash-ui/src/components/SessionSwitcher.tsx +5 -5
- package/extensions/pidash/pidash-ui/src/hooks/useMessageHandler.ts +5 -0
- package/extensions/pidash/pidash-ui/src/lib/activity-display.ts +34 -0
- package/extensions/pidash/pidash-ui/src/lib/create-logger.ts +35 -0
- package/extensions/pidash/pidash.ts +28 -1
- package/extensions/pidiff/pidiff-ui/package.json +1 -1
- package/extensions/pitasks/task-tools.ts +2 -2
- package/extensions/shared/create-runtime-provider.ts +1 -1
- package/extensions/shared/types.ts +7 -0
- package/package.json +1 -1
- package/scripts/build-extension-uis.sh +6 -6
- package/scripts/daemon-shared.ts +4 -4
- package/scripts/pidash-server.ts +50 -67
- package/extensions/pidash/pidash-ui/dist/assets/index-BHH9Jo-Z.css +0 -2
- 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
|
-
-
|
|
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.
|
|
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 (!
|
|
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
|
-
|
|
994
|
-
|
|
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
|
-
|
|
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
|
-
-
|
|
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: "
|
|
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
|
-
|
|
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: "
|
|
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: "
|
|
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
|
-
|
|
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
|
+
}
|