@devspecai/pi-plugin 0.0.0-stage → 0.10.3
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/CHANGELOG.md +899 -0
- package/LICENSE +21 -0
- package/README.md +103 -2
- package/extensions/.gitkeep +0 -0
- package/extensions/core/activation-recovery.ts +135 -0
- package/extensions/core/active-deliveries.ts +171 -0
- package/extensions/core/active-session-plans.ts +374 -0
- package/extensions/core/adapter-overlap.ts +247 -0
- package/extensions/core/assistant-speech.ts +44 -0
- package/extensions/core/attach-context.ts +25 -0
- package/extensions/core/attachment-limits.ts +20 -0
- package/extensions/core/attachments.ts +487 -0
- package/extensions/core/auth-store.ts +91 -0
- package/extensions/core/automation-run-retry.ts +31 -0
- package/extensions/core/background-journal.ts +85 -0
- package/extensions/core/background-observer.ts +279 -0
- package/extensions/core/bond-recovery.ts +65 -0
- package/extensions/core/bond-state.ts +80 -0
- package/extensions/core/clear-conversation.ts +53 -0
- package/extensions/core/client-versions.ts +56 -0
- package/extensions/core/client.ts +704 -0
- package/extensions/core/command-offer.ts +88 -0
- package/extensions/core/command-runner.ts +62 -0
- package/extensions/core/command-turn.ts +88 -0
- package/extensions/core/commit-ledger.ts +195 -0
- package/extensions/core/commit-stamp.ts +98 -0
- package/extensions/core/config.ts +63 -0
- package/extensions/core/connection-context.ts +28 -0
- package/extensions/core/context-budget.ts +175 -0
- package/extensions/core/control-result-outbox.ts +80 -0
- package/extensions/core/control.ts +156 -0
- package/extensions/core/ctx-guard.ts +33 -0
- package/extensions/core/debug.ts +136 -0
- package/extensions/core/delivery-queue.ts +92 -0
- package/extensions/core/dispatch.ts +108 -0
- package/extensions/core/duck.ts +128 -0
- package/extensions/core/git-observer.ts +382 -0
- package/extensions/core/github-identity.ts +26 -0
- package/extensions/core/implementation-contract.ts +773 -0
- package/extensions/core/instructions.ts +40 -0
- package/extensions/core/interaction-events.ts +834 -0
- package/extensions/core/json-schema.ts +16 -0
- package/extensions/core/local-activity.ts +34 -0
- package/extensions/core/mcp-config-guard.ts +117 -0
- package/extensions/core/model-catalog.ts +76 -0
- package/extensions/core/model-surface.ts +125 -0
- package/extensions/core/notice-reply.ts +215 -0
- package/extensions/core/owner-inject.ts +148 -0
- package/extensions/core/post-session-receipt.ts +70 -0
- package/extensions/core/preempt.ts +100 -0
- package/extensions/core/project-choice.ts +145 -0
- package/extensions/core/project-scope.ts +335 -0
- package/extensions/core/project-selection-ui.ts +202 -0
- package/extensions/core/provenance-refs.ts +158 -0
- package/extensions/core/push-channel.ts +281 -0
- package/extensions/core/question-receipt-lifecycle.ts +158 -0
- package/extensions/core/recall.ts +121 -0
- package/extensions/core/remote-payload.ts +1329 -0
- package/extensions/core/remote-session-arg.ts +24 -0
- package/extensions/core/repository-context.ts +35 -0
- package/extensions/core/request-chain.ts +1244 -0
- package/extensions/core/result-projection.ts +939 -0
- package/extensions/core/result.ts +100 -0
- package/extensions/core/review-context.ts +70 -0
- package/extensions/core/review-watch.ts +94 -0
- package/extensions/core/room-access.ts +12 -0
- package/extensions/core/room-context.ts +178 -0
- package/extensions/core/room-reader.ts +308 -0
- package/extensions/core/room-store.ts +381 -0
- package/extensions/core/room-sync.ts +79 -0
- package/extensions/core/room-tools.ts +86 -0
- package/extensions/core/room-unread.ts +173 -0
- package/extensions/core/session-events.ts +62 -0
- package/extensions/core/session-polls.ts +292 -0
- package/extensions/core/settings.ts +39 -0
- package/extensions/core/status-line.ts +432 -0
- package/extensions/core/still-to-discuss.ts +124 -0
- package/extensions/core/subagent-fleet.ts +238 -0
- package/extensions/core/system-notice.ts +227 -0
- package/extensions/core/telemetry.ts +177 -0
- package/extensions/core/tool-selection.ts +72 -0
- package/extensions/core/trail-events.ts +247 -0
- package/extensions/core/trail-projection.ts +373 -0
- package/extensions/core/trail.ts +465 -0
- package/extensions/core/turn-lifecycle.ts +472 -0
- package/extensions/core/version-mismatch.ts +165 -0
- package/extensions/core/work-prompt.ts +44 -0
- package/extensions/core/work-state.ts +179 -0
- package/extensions/devspec-adapter-guard.ts +106 -0
- package/extensions/devspec-auth.ts +191 -0
- package/extensions/devspec-brand.ts +140 -0
- package/extensions/devspec-commit-ledger.ts +477 -0
- package/extensions/devspec-context.ts +146 -0
- package/extensions/devspec-memory.ts +136 -0
- package/extensions/devspec-provenance-assistance.ts +233 -0
- package/extensions/devspec-remote.ts +5152 -0
- package/extensions/devspec-tools.ts +552 -0
- package/extensions/devspec-work.ts +244 -0
- package/extensions/generated/README.md +18 -0
- package/extensions/generated/mcp-catalog.ts +15811 -0
- package/package.json +71 -4
- package/prompts/.gitkeep +0 -0
- package/runtime/commit-stamp.mjs +279 -0
- package/runtime/git-observer-shim.mjs +390 -0
- package/skills/.gitkeep +0 -0
- package/themes/.gitkeep +0 -0
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The provenance-ref guard (item a03ba7ab).
|
|
3
|
+
*
|
|
4
|
+
* Opaque 36-character UUIDs cross the model boundary by transcription, and
|
|
5
|
+
* transcription corrupts them — watched live on 2026-09-20 (session 340ca456):
|
|
6
|
+
* the server delivered `5733cc70-9ca8-4bb5-8261-…` and the model's writes came
|
|
7
|
+
* back carrying `5733cc70-9ca8-4f6d-9f10-…` — same 64-bit prefix, fabricated
|
|
8
|
+
* tail. The server refused correctly; the failure was invisible until it did.
|
|
9
|
+
*
|
|
10
|
+
* This module holds the one set the extension can know with certainty: the refs
|
|
11
|
+
* this process has actually been DEALT (delivery refs from validated ingress
|
|
12
|
+
* commands, work-claim refs observed in claim results). The guard refuses a
|
|
13
|
+
* write whose ref was never dealt here — locally, before the network, naming
|
|
14
|
+
* the current turn's refs and teaching omission, which is the safe default:
|
|
15
|
+
* the server works out who asked from the connection (its work on the item the
|
|
16
|
+
* write is about, its newest command, its only claim — DevSpec migrations 1161,
|
|
17
|
+
* 1216), and refuses rather than guesses when that is ambiguous.
|
|
18
|
+
*
|
|
19
|
+
* Deliberate scope: the rule is NEVER-DEALT, never "not currently valid".
|
|
20
|
+
* Whether a dealt ref is still inside the live turn is the server's call
|
|
21
|
+
* (651's live-turn check) — this guard does not duplicate it. A dealt-but-old
|
|
22
|
+
* ref passes here and the server decides; a fabricated one dies at the door.
|
|
23
|
+
*
|
|
24
|
+
* Never rewrite a ref: attribution is the point of the system.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
28
|
+
|
|
29
|
+
interface DealtRef {
|
|
30
|
+
turnId: string | null;
|
|
31
|
+
seq: number;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
interface RefStore {
|
|
35
|
+
delivery: Map<string, DealtRef>;
|
|
36
|
+
claim: Set<string>;
|
|
37
|
+
seq: number;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// jiti duplicates modules; Symbol.for(globalThis) keeps one store per process
|
|
41
|
+
// (the established pattern from active-deliveries.ts).
|
|
42
|
+
const STORE_KEY = Symbol.for("devspec.pi.provenance-refs.v1");
|
|
43
|
+
const CAP = 512;
|
|
44
|
+
|
|
45
|
+
function store(): RefStore {
|
|
46
|
+
const root = globalThis as typeof globalThis & { [STORE_KEY]?: RefStore };
|
|
47
|
+
if (!root[STORE_KEY]) root[STORE_KEY] = { delivery: new Map(), claim: new Set(), seq: 0 };
|
|
48
|
+
return root[STORE_KEY];
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function cap(map: Map<string, DealtRef>): void {
|
|
52
|
+
while (map.size > CAP) {
|
|
53
|
+
const oldest = map.keys().next();
|
|
54
|
+
if (oldest.done) break;
|
|
55
|
+
map.delete(oldest.value);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function noteDeliveryRef(ref: unknown, turnId: string | null): void {
|
|
60
|
+
if (typeof ref !== "string" || !UUID.test(ref)) return;
|
|
61
|
+
const key = ref.trim().toLowerCase();
|
|
62
|
+
const s = store();
|
|
63
|
+
s.delivery.set(key, { turnId, seq: ++s.seq });
|
|
64
|
+
cap(store().delivery);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** The parsed, intake-validated commands of an accepted delivery. Idempotent. */
|
|
68
|
+
export function noteDeliveredCommandRefs(
|
|
69
|
+
commands: readonly { delivery?: { provenance_ref?: unknown; primary_provenance_ref?: unknown; turn_id?: unknown } }[],
|
|
70
|
+
): void {
|
|
71
|
+
for (const command of commands) {
|
|
72
|
+
const delivery = command.delivery;
|
|
73
|
+
if (!delivery) continue;
|
|
74
|
+
const turnId = typeof delivery.turn_id === "string" ? delivery.turn_id : null;
|
|
75
|
+
noteDeliveryRef(delivery.provenance_ref, turnId);
|
|
76
|
+
noteDeliveryRef(delivery.primary_provenance_ref, turnId);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** work_claim_ref values observed in successful claim results this process. */
|
|
81
|
+
export function noteObservedClaimRef(value: unknown): void {
|
|
82
|
+
if (typeof value !== "string" || !UUID.test(value)) return;
|
|
83
|
+
const claims = store().claim;
|
|
84
|
+
claims.add(value.trim().toLowerCase());
|
|
85
|
+
while (claims.size > CAP) {
|
|
86
|
+
const oldest = claims.values().next();
|
|
87
|
+
if (oldest.done) break;
|
|
88
|
+
claims.delete(oldest.value);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function record(value: unknown): Record<string, unknown> | null {
|
|
93
|
+
return value !== null && typeof value === "object" && !Array.isArray(value)
|
|
94
|
+
? value as Record<string, unknown>
|
|
95
|
+
: null;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Claim refs ride claim results (details.work_claim_ref or top level). */
|
|
99
|
+
export function observeResultClaimRefs(result: unknown): void {
|
|
100
|
+
const root = record(result);
|
|
101
|
+
if (!root) return;
|
|
102
|
+
const details = record(root.details) ?? root;
|
|
103
|
+
noteObservedClaimRef(details.work_claim_ref);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Bond/connection resets clear the dealt set with the turn bindings (6f4136d8's lifecycle). */
|
|
107
|
+
export function clearDealtRefs(): void {
|
|
108
|
+
store().delivery.clear();
|
|
109
|
+
store().claim.clear();
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export type RefGuardVerdict = { ok: true } | { ok: false; message: string };
|
|
113
|
+
|
|
114
|
+
function currentTurnRefs(): string[] {
|
|
115
|
+
const entries = [...store().delivery.entries()];
|
|
116
|
+
if (entries.length === 0) return [];
|
|
117
|
+
const latest = entries.reduce((a, b) => (b[1].seq > a[1].seq ? b : a));
|
|
118
|
+
const turn = latest[1].turnId;
|
|
119
|
+
const refs = entries.filter(([, v]) => v.turnId === turn).map(([k]) => k);
|
|
120
|
+
return refs.length > 0 ? refs : [latest[0]];
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function refuse(kind: "provenance_ref" | "work_claim_ref", value: string): RefGuardVerdict {
|
|
124
|
+
if (kind === "provenance_ref") {
|
|
125
|
+
const known = currentTurnRefs();
|
|
126
|
+
const hint = known.length > 0
|
|
127
|
+
? `This session's current command refs are: ${known.join(", ")}. Unless you mean one of those exactly, omit the parameter: by default the server works out who asked from your connection.`
|
|
128
|
+
: "No command has been delivered to this process, so that value did not come from a delivery here. Omit the parameter: by default the server works out who asked from your connection.";
|
|
129
|
+
return {
|
|
130
|
+
ok: false,
|
|
131
|
+
message: `Unknown provenance_ref "${value}" — never delivered to this process (a mistyped ref fails closed; the server would reject it too). ${hint}`,
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
return {
|
|
135
|
+
ok: false,
|
|
136
|
+
message: `Unknown work_claim_ref "${value}" — no claim result this process has seen carries it. Copy the work_claim_ref from the claim_work_item result verbatim, or omit it and let the server work out who asked from your connection.`,
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Check the model-supplied provenance refs on a tool call before it costs a
|
|
142
|
+
* server round-trip. Omission is always allowed — it is the default. Only a
|
|
143
|
+
* present, non-empty value is checked, and only against what this process has
|
|
144
|
+
* verifiably been dealt.
|
|
145
|
+
*/
|
|
146
|
+
export function guardWriteRefs(args: Record<string, unknown>): RefGuardVerdict {
|
|
147
|
+
const provenance = args.provenance_ref;
|
|
148
|
+
if (typeof provenance === "string" && provenance.trim()) {
|
|
149
|
+
const key = provenance.trim().toLowerCase();
|
|
150
|
+
if (!UUID.test(key) || !store().delivery.has(key)) return refuse("provenance_ref", provenance.trim());
|
|
151
|
+
}
|
|
152
|
+
const claim = args.work_claim_ref;
|
|
153
|
+
if (typeof claim === "string" && claim.trim()) {
|
|
154
|
+
const key = claim.trim().toLowerCase();
|
|
155
|
+
if (!UUID.test(key) || !store().claim.has(key)) return refuse("work_claim_ref", claim.trim());
|
|
156
|
+
}
|
|
157
|
+
return { ok: true };
|
|
158
|
+
}
|
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DevSpec Pi — push channel client (SSE wake stream).
|
|
3
|
+
*
|
|
4
|
+
* Client for GET /api/mcp/connection-stream (devspecv2, item 0026da9f).
|
|
5
|
+
* The stream emits wake/ended/hello frames; the client fetches the packaged
|
|
6
|
+
* turn via poll_connection on wake (wake+fetch — packaging stays server-side
|
|
7
|
+
* in ONE place). Falls back to the held long-poll when the channel is
|
|
8
|
+
* unavailable (older server, proxy that buffers SSE, etc).
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { pluginHeaders } from "./client-versions.ts";
|
|
12
|
+
import { SYSTEM_NOTICE_CHANNEL_VERSION } from "./remote-payload.ts";
|
|
13
|
+
|
|
14
|
+
export interface PushChannelHandlers {
|
|
15
|
+
/** A wake frame arrived — something moved; fetch now. */
|
|
16
|
+
onWake(): void;
|
|
17
|
+
/** The server tore the connection down (carries end_reason). */
|
|
18
|
+
onEnded(endReason: string | null): void;
|
|
19
|
+
/** The stream closed (cleanly or by error) — the pump decides what next. */
|
|
20
|
+
onClose(reason: string): void;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface PushChannelOptions {
|
|
24
|
+
/** The MCP endpoint URL (…/api/mcp); the stream lives under it. */
|
|
25
|
+
mcpUrl: string;
|
|
26
|
+
token: string;
|
|
27
|
+
connectionId: string;
|
|
28
|
+
/** Hidden exact-connection capability minted with this bond. */
|
|
29
|
+
connectionCapability?: string;
|
|
30
|
+
/** Main intake capability only; side polls never inherit this negotiation. */
|
|
31
|
+
systemNoticeVersion?: typeof SYSTEM_NOTICE_CHANNEL_VERSION;
|
|
32
|
+
/** Durable interaction events share this one stream's wake marker. */
|
|
33
|
+
interactionEventVersion?: 1;
|
|
34
|
+
questionDismissalEventVersion?: 1;
|
|
35
|
+
handlers: PushChannelHandlers;
|
|
36
|
+
/** Abort to close deliberately. */
|
|
37
|
+
signal: AbortSignal;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Parse an SSE frame block into {event, data}. Returns null for comments/keepalives. */
|
|
41
|
+
export function parseSseFrame(block: string): { event: string; data: string } | null {
|
|
42
|
+
let event = "message";
|
|
43
|
+
const dataLines: string[] = [];
|
|
44
|
+
for (const line of block.split("\n")) {
|
|
45
|
+
if (line.startsWith(":")) return null; // comment / keepalive
|
|
46
|
+
if (line.startsWith("event:")) event = line.slice(6).trim();
|
|
47
|
+
else if (line.startsWith("data:")) dataLines.push(line.slice(5).trim());
|
|
48
|
+
}
|
|
49
|
+
if (dataLines.length === 0) return null;
|
|
50
|
+
return { event, data: dataLines.join("\n") };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Reconnect backoff for unexpected drops (item 8b0c162b). A native host holds
|
|
55
|
+
* ONE stream; a proxy hiccup or brief network blip should NOT cost the rest of
|
|
56
|
+
* the bond's lifetime on the slow path. Returns the delay before the next
|
|
57
|
+
* reconnect attempt, or null when quick retries are exhausted and the caller
|
|
58
|
+
* should settle into the held long-poll fallback.
|
|
59
|
+
*
|
|
60
|
+
* `attempts` counts consecutive drops of SHORT-LIVED streams: a stream that
|
|
61
|
+
* stayed up past `healthyMs` proved the path works, so its drop restarts the
|
|
62
|
+
* backoff from zero instead of burning one of the few quick retries.
|
|
63
|
+
*/
|
|
64
|
+
export const PUSH_RECONNECT_BACKOFF_MS = [1_000, 4_000, 10_000] as const;
|
|
65
|
+
export const PUSH_HEALTHY_MS = 30_000;
|
|
66
|
+
|
|
67
|
+
/** Monotonic fence: callbacks/timers from a replaced stream cannot open another. */
|
|
68
|
+
export class PushChannelGenerationFence {
|
|
69
|
+
private generation = 0;
|
|
70
|
+
|
|
71
|
+
begin(): number {
|
|
72
|
+
this.generation += 1;
|
|
73
|
+
return this.generation;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
invalidate(): void {
|
|
77
|
+
this.generation += 1;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
isCurrent(generation: number): boolean {
|
|
81
|
+
return generation === this.generation;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export type RuntimeRunKind = "owner" | "interaction" | "system_notice";
|
|
86
|
+
|
|
87
|
+
export interface RuntimeOccupancyRevision {
|
|
88
|
+
revision: number;
|
|
89
|
+
kind: RuntimeRunKind;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* True Pi runtime occupancy, independent from server-visible activity. System
|
|
94
|
+
* notices are activity-neutral but still occupy the model and must retain SSE
|
|
95
|
+
* wakes. A stale settle revision can never clear a newer run.
|
|
96
|
+
*/
|
|
97
|
+
export class RuntimeOccupancyFence {
|
|
98
|
+
private nextRevision = 1;
|
|
99
|
+
private current: RuntimeOccupancyRevision | null = null;
|
|
100
|
+
|
|
101
|
+
begin(kind: RuntimeRunKind): RuntimeOccupancyRevision {
|
|
102
|
+
// Every low-level Pi start receives a newer settle token, while retries and
|
|
103
|
+
// compaction retain the existing run kind. An older awaited settle can no
|
|
104
|
+
// longer finish after a newer start has occurred.
|
|
105
|
+
const current = {
|
|
106
|
+
revision: this.nextRevision++,
|
|
107
|
+
kind: this.current?.kind ?? kind,
|
|
108
|
+
};
|
|
109
|
+
this.current = current;
|
|
110
|
+
return { ...current };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
promote(candidate: RuntimeOccupancyRevision | null, kind: RuntimeRunKind): RuntimeOccupancyRevision | null {
|
|
114
|
+
if (!this.isCurrent(candidate) || !this.current) return null;
|
|
115
|
+
this.current = { revision: this.nextRevision++, kind };
|
|
116
|
+
return { ...this.current };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
capture(): RuntimeOccupancyRevision | null {
|
|
120
|
+
return this.current ? { ...this.current } : null;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
isCurrent(candidate: RuntimeOccupancyRevision | null): boolean {
|
|
124
|
+
return Boolean(candidate && this.current
|
|
125
|
+
&& candidate.revision === this.current.revision
|
|
126
|
+
&& candidate.kind === this.current.kind);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
finish(candidate: RuntimeOccupancyRevision | null): boolean {
|
|
130
|
+
if (!this.isCurrent(candidate)) return false;
|
|
131
|
+
this.current = null;
|
|
132
|
+
return true;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
isBusy(): boolean {
|
|
136
|
+
return this.current !== null;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Monotonic wake gate for the one pump fetch loop. Resolving a waiter consumes
|
|
142
|
+
* that exact generation; a later/burst signal remains one pending generation.
|
|
143
|
+
*/
|
|
144
|
+
export class WakeGenerationGate {
|
|
145
|
+
private produced = 0;
|
|
146
|
+
private consumed = 0;
|
|
147
|
+
private waiter: { resolve: () => void; timer: ReturnType<typeof setTimeout> } | null = null;
|
|
148
|
+
|
|
149
|
+
signal(): void {
|
|
150
|
+
this.produced += 1;
|
|
151
|
+
const waiter = this.waiter;
|
|
152
|
+
if (!waiter) return;
|
|
153
|
+
this.waiter = null;
|
|
154
|
+
clearTimeout(waiter.timer);
|
|
155
|
+
this.consumed = this.produced;
|
|
156
|
+
waiter.resolve();
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
wait(safetyMs: number): Promise<void> {
|
|
160
|
+
if (this.produced > this.consumed) {
|
|
161
|
+
this.consumed = this.produced;
|
|
162
|
+
return Promise.resolve();
|
|
163
|
+
}
|
|
164
|
+
return new Promise((resolve) => {
|
|
165
|
+
const timer = setTimeout(() => {
|
|
166
|
+
if (this.waiter?.resolve === resolve) this.waiter = null;
|
|
167
|
+
resolve();
|
|
168
|
+
}, safetyMs);
|
|
169
|
+
if (typeof timer.unref === "function") timer.unref();
|
|
170
|
+
this.waiter = { resolve, timer };
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
cancel(): void {
|
|
175
|
+
const waiter = this.waiter;
|
|
176
|
+
this.waiter = null;
|
|
177
|
+
if (!waiter) return;
|
|
178
|
+
clearTimeout(waiter.timer);
|
|
179
|
+
waiter.resolve();
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
hasPending(): boolean {
|
|
183
|
+
return this.produced > this.consumed;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** Coalesces wake markers consumed while Pi cannot negotiate idle-only lanes. */
|
|
188
|
+
export class BusyWakeLatch {
|
|
189
|
+
private pending = false;
|
|
190
|
+
|
|
191
|
+
noteWake(busy: boolean): void {
|
|
192
|
+
if (busy) this.pending = true;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
consumeAtIdle(idle: boolean): boolean {
|
|
196
|
+
if (!idle || !this.pending) return false;
|
|
197
|
+
this.pending = false;
|
|
198
|
+
return true;
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
export function nextPushReconnectDelayMs(
|
|
203
|
+
attempts: number,
|
|
204
|
+
openedAt: number | undefined,
|
|
205
|
+
now: number,
|
|
206
|
+
healthyMs: number = PUSH_HEALTHY_MS,
|
|
207
|
+
): number | null {
|
|
208
|
+
const effective = openedAt !== undefined && now - openedAt > healthyMs ? 0 : attempts;
|
|
209
|
+
if (effective >= PUSH_RECONNECT_BACKOFF_MS.length) return null;
|
|
210
|
+
return PUSH_RECONNECT_BACKOFF_MS[effective];
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Open and pump the SSE channel until it closes or the signal aborts.
|
|
215
|
+
* Resolves when the stream ends; rejects only on connect-level failure
|
|
216
|
+
* (non-2xx, network) so the caller can choose fallback BEFORE any frames
|
|
217
|
+
* flowed.
|
|
218
|
+
*/
|
|
219
|
+
export function pushChannelUrl(opts: Pick<PushChannelOptions, "mcpUrl" | "connectionId" | "systemNoticeVersion" | "interactionEventVersion" | "questionDismissalEventVersion">): string {
|
|
220
|
+
const query = new URLSearchParams({ connection_id: opts.connectionId });
|
|
221
|
+
if (opts.systemNoticeVersion === SYSTEM_NOTICE_CHANNEL_VERSION) query.set("system_notice_version", String(SYSTEM_NOTICE_CHANNEL_VERSION));
|
|
222
|
+
if (opts.interactionEventVersion === 1) query.set("interaction_event_version", "1");
|
|
223
|
+
if (opts.questionDismissalEventVersion === 1) {
|
|
224
|
+
if (opts.interactionEventVersion !== 1) throw new Error("Dismissal requires answer negotiation");
|
|
225
|
+
query.set("question_dismissal_event_version", "1");
|
|
226
|
+
}
|
|
227
|
+
return `${opts.mcpUrl.replace(/\/$/, "")}/connection-stream?${query.toString()}`;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
export async function openPushChannel(opts: PushChannelOptions): Promise<void> {
|
|
231
|
+
const url = pushChannelUrl(opts);
|
|
232
|
+
const res = await fetch(url, {
|
|
233
|
+
method: "GET",
|
|
234
|
+
headers: {
|
|
235
|
+
authorization: `Bearer ${opts.token}`,
|
|
236
|
+
accept: "text/event-stream",
|
|
237
|
+
...pluginHeaders(),
|
|
238
|
+
...(opts.connectionCapability ? { "x-devspec-connection-capability": opts.connectionCapability } : {}),
|
|
239
|
+
},
|
|
240
|
+
signal: opts.signal,
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
if (!res.ok) {
|
|
244
|
+
const body = await res.text().catch(() => "");
|
|
245
|
+
throw new Error(`push channel HTTP ${res.status}: ${body.slice(0, 200)}`);
|
|
246
|
+
}
|
|
247
|
+
if (!res.body) throw new Error("push channel: no response body");
|
|
248
|
+
|
|
249
|
+
const reader = res.body.getReader();
|
|
250
|
+
const decoder = new TextDecoder();
|
|
251
|
+
let buffer = "";
|
|
252
|
+
|
|
253
|
+
try {
|
|
254
|
+
for (;;) {
|
|
255
|
+
const { done, value } = await reader.read();
|
|
256
|
+
if (done) break;
|
|
257
|
+
buffer += decoder.decode(value, { stream: true });
|
|
258
|
+
let sep: number;
|
|
259
|
+
while ((sep = buffer.indexOf("\n\n")) !== -1) {
|
|
260
|
+
const block = buffer.slice(0, sep);
|
|
261
|
+
buffer = buffer.slice(sep + 2);
|
|
262
|
+
const frame = parseSseFrame(block);
|
|
263
|
+
if (!frame) continue;
|
|
264
|
+
if (frame.event === "wake") {
|
|
265
|
+
opts.handlers.onWake();
|
|
266
|
+
} else if (frame.event === "ended") {
|
|
267
|
+
let reason: string | null = null;
|
|
268
|
+
try {
|
|
269
|
+
reason = (JSON.parse(frame.data) as { end_reason?: string | null }).end_reason ?? null;
|
|
270
|
+
} catch { /* keep null */ }
|
|
271
|
+
opts.handlers.onEnded(reason);
|
|
272
|
+
}
|
|
273
|
+
// hello frames need no action — the bond already knows its facts.
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
opts.handlers.onClose("stream closed");
|
|
277
|
+
} catch (err) {
|
|
278
|
+
if (opts.signal.aborted) return; // deliberate close — not an error
|
|
279
|
+
opts.handlers.onClose(err instanceof Error ? err.message : String(err));
|
|
280
|
+
}
|
|
281
|
+
}
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Runtime-mechanical lifecycle for a tool batch whose durable customer-visible
|
|
3
|
+
* output is owned by canonical directed-question transcript receipts.
|
|
4
|
+
*
|
|
5
|
+
* The server receipt owns only the otherwise-empty transcript/trail bubble. Pi
|
|
6
|
+
* still executes every tool and always completes the exact activity attempt.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
|
10
|
+
|
|
11
|
+
function record(value: unknown): Record<string, unknown> | null {
|
|
12
|
+
return value != null && typeof value === "object" && !Array.isArray(value)
|
|
13
|
+
? value as Record<string, unknown>
|
|
14
|
+
: null;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Host-only receipt identity. It is control metadata and must never enter prose. */
|
|
18
|
+
export function directedQuestionTranscriptReceiptId(result: unknown): string | null {
|
|
19
|
+
const outer = record(result);
|
|
20
|
+
const details = record(outer?.details) ?? outer;
|
|
21
|
+
const question = record(details?.question);
|
|
22
|
+
const id = question?.transcriptMessageId;
|
|
23
|
+
return typeof id === "string" && UUID.test(id) ? id : null;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface ReceiptToolResult {
|
|
27
|
+
success: boolean;
|
|
28
|
+
terminatingReceipt: boolean;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function isPotentialDirectedQuestionCreate(
|
|
32
|
+
toolName: string,
|
|
33
|
+
args: unknown,
|
|
34
|
+
): boolean {
|
|
35
|
+
return toolName === "manage_directed_question" && record(args)?.action === "create";
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function isReceiptOwningDirectedQuestionCreate(
|
|
39
|
+
toolName: string,
|
|
40
|
+
result: unknown,
|
|
41
|
+
isError: boolean,
|
|
42
|
+
): boolean {
|
|
43
|
+
return toolName === "manage_directed_question" && !isError &&
|
|
44
|
+
(record(result)?.terminate === true) && directedQuestionTranscriptReceiptId(result) !== null;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* One LLM tool batch at a time. A receipt may temporarily defer the 1s trail
|
|
49
|
+
* timer while sibling results are outstanding. Any non-receipt/failed sibling
|
|
50
|
+
* immediately resumes ordinary projection with the accumulated trail intact.
|
|
51
|
+
*/
|
|
52
|
+
export class TrailPostFence {
|
|
53
|
+
private pending = new Set<Promise<void>>();
|
|
54
|
+
private valid = true;
|
|
55
|
+
|
|
56
|
+
private adopt(observed: Promise<void>): void {
|
|
57
|
+
if (!this.valid || this.pending.has(observed)) return;
|
|
58
|
+
this.pending.add(observed);
|
|
59
|
+
void observed.then(() => this.pending.delete(observed));
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
track<T>(post: Promise<T>): Promise<T> {
|
|
63
|
+
// The observer handles rejection even when the caller intentionally fires
|
|
64
|
+
// and forgets. `drain` therefore never creates an unhandled rejection.
|
|
65
|
+
const observed = Promise.resolve(post).then(
|
|
66
|
+
() => undefined,
|
|
67
|
+
() => undefined,
|
|
68
|
+
);
|
|
69
|
+
this.adopt(observed);
|
|
70
|
+
return post;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
async drain(): Promise<void> {
|
|
74
|
+
// Candidate marking stops future timer posts first. Looping also covers a
|
|
75
|
+
// projection synchronously tracked by the same turn just before the mark.
|
|
76
|
+
while (this.pending.size > 0) {
|
|
77
|
+
await Promise.all([...this.pending]);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
invalidate(): void {
|
|
82
|
+
this.valid = false;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Hand authority to a fresh fence without abandoning predecessor posts.
|
|
87
|
+
* Directly adopting the normalized in-flight promises is the flattened form
|
|
88
|
+
* of tracking `oldFence.drain()`: repeated rollovers remain O(in-flight), not
|
|
89
|
+
* an ever-growing promise chain, and each settled promise removes itself.
|
|
90
|
+
*/
|
|
91
|
+
rollover(): TrailPostFence {
|
|
92
|
+
this.invalidate();
|
|
93
|
+
const replacement = new TrailPostFence();
|
|
94
|
+
for (const observed of this.pending) replacement.adopt(observed);
|
|
95
|
+
return replacement;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
get size(): number {
|
|
99
|
+
return this.pending.size;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export class DirectedQuestionReceiptBatch {
|
|
104
|
+
private revision = 0;
|
|
105
|
+
private pending = new Set<string>();
|
|
106
|
+
private receiptCandidates = new Set<string>();
|
|
107
|
+
private nonReceiptStarted = false;
|
|
108
|
+
private resultCount = 0;
|
|
109
|
+
private receiptSeen = false;
|
|
110
|
+
private allResultsAreReceipts = true;
|
|
111
|
+
private substantiveAfterReceipt = false;
|
|
112
|
+
|
|
113
|
+
reset(revision: number): void {
|
|
114
|
+
this.revision = revision;
|
|
115
|
+
this.pending.clear();
|
|
116
|
+
this.receiptCandidates.clear();
|
|
117
|
+
this.nonReceiptStarted = false;
|
|
118
|
+
this.resultCount = 0;
|
|
119
|
+
this.receiptSeen = false;
|
|
120
|
+
this.allResultsAreReceipts = true;
|
|
121
|
+
this.substantiveAfterReceipt = false;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
start(toolCallId: string, revision: number, potentialReceipt = false): void {
|
|
125
|
+
if (revision !== this.revision) this.reset(revision);
|
|
126
|
+
this.pending.add(toolCallId);
|
|
127
|
+
if (potentialReceipt) this.receiptCandidates.add(toolCallId);
|
|
128
|
+
else this.nonReceiptStarted = true;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
finish(toolCallId: string, outcome: ReceiptToolResult, revision: number): void {
|
|
132
|
+
if (revision !== this.revision || !this.pending.has(toolCallId)) return;
|
|
133
|
+
this.pending.delete(toolCallId);
|
|
134
|
+
this.receiptCandidates.delete(toolCallId);
|
|
135
|
+
this.resultCount += 1;
|
|
136
|
+
this.receiptSeen ||= outcome.success && outcome.terminatingReceipt;
|
|
137
|
+
this.allResultsAreReceipts &&= outcome.success && outcome.terminatingReceipt;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
noteAssistantText(text: string, revision: number): void {
|
|
141
|
+
if (revision !== this.revision || !this.receiptSeen) return;
|
|
142
|
+
if (text.trim().length > 0) this.substantiveAfterReceipt = true;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** Stop the timer from opening a second bubble during a receipt-owned batch. */
|
|
146
|
+
deferTrail(revision: number): boolean {
|
|
147
|
+
if (revision !== this.revision || this.substantiveAfterReceipt) return false;
|
|
148
|
+
// Potential create is marked synchronously at tool_execution_start, before
|
|
149
|
+
// its awaited pre-invocation fence. No timer post may overtake takeover.
|
|
150
|
+
if (this.receiptCandidates.size > 0) return true;
|
|
151
|
+
return this.receiptSeen && this.allResultsAreReceipts && !this.nonReceiptStarted;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** Suppress only the empty fallback after every sibling result is known. */
|
|
155
|
+
suppressEmptyFallback(revision: number): boolean {
|
|
156
|
+
return this.deferTrail(revision) && this.pending.size === 0 && this.resultCount > 0;
|
|
157
|
+
}
|
|
158
|
+
}
|