@blackbelt-technology/pi-agent-dashboard 0.5.0 → 0.5.1
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 +19 -7
- package/package.json +13 -13
- package/packages/extension/package.json +11 -3
- package/packages/extension/src/__tests__/ask-user-tool.test.ts +1 -1
- package/packages/extension/src/__tests__/command-handler.test.ts +68 -0
- package/packages/extension/src/__tests__/enrich-model-metadata.test.ts +1 -1
- package/packages/extension/src/__tests__/no-tui-multiselect-arm-regression.test.ts +1 -1
- package/packages/extension/src/__tests__/provider-register-reload.test.ts +74 -0
- package/packages/extension/src/__tests__/retry-tracker.test.ts +147 -0
- package/packages/extension/src/__tests__/session-sync.test.ts +72 -0
- package/packages/extension/src/__tests__/usage-limit-orderer.test.ts +105 -0
- package/packages/extension/src/ask-user-tool.ts +1 -1
- package/packages/extension/src/bridge-context.ts +1 -1
- package/packages/extension/src/bridge.ts +59 -3
- package/packages/extension/src/command-handler.ts +59 -2
- package/packages/extension/src/flow-event-wiring.ts +1 -1
- package/packages/extension/src/multiselect-list.ts +1 -1
- package/packages/extension/src/pi-env.d.ts +16 -9
- package/packages/extension/src/provider-register.ts +16 -9
- package/packages/extension/src/retry-tracker.ts +123 -0
- package/packages/extension/src/session-sync.ts +10 -1
- package/packages/extension/src/usage-limit-orderer.ts +76 -0
- package/packages/server/package.json +6 -6
- package/packages/server/src/__tests__/changelog-fs.test.ts +171 -0
- package/packages/server/src/__tests__/changelog-parser.test.ts +220 -0
- package/packages/server/src/__tests__/changelog-remote.test.ts +193 -0
- package/packages/server/src/__tests__/cli-parse.test.ts +22 -4
- package/packages/server/src/__tests__/directory-service-refresh-force.test.ts +1 -1
- package/packages/server/src/__tests__/directory-service-specs-mtime.test.ts +1 -1
- package/packages/server/src/__tests__/directory-service-toctou.test.ts +1 -1
- package/packages/server/src/__tests__/directory-service.test.ts +1 -1
- package/packages/server/src/__tests__/event-wiring-providers-list.test.ts +68 -1
- package/packages/server/src/__tests__/fixtures/pi-changelog-slice.md +180 -0
- package/packages/server/src/__tests__/fork-empty-session-preflight.test.ts +268 -0
- package/packages/server/src/__tests__/headless-pid-registry.test.ts +83 -0
- package/packages/server/src/__tests__/is-pi-process.test.ts +1 -1
- package/packages/server/src/__tests__/package-manager-wrapper-resolve.test.ts +4 -4
- package/packages/server/src/__tests__/package-routes.test.ts +1 -1
- package/packages/server/src/__tests__/pending-fork-registry.test.ts +48 -24
- package/packages/server/src/__tests__/pi-changelog-integration.test.ts +165 -0
- package/packages/server/src/__tests__/pi-changelog-routes.test.ts +409 -0
- package/packages/server/src/__tests__/pi-core-checker.test.ts +155 -13
- package/packages/server/src/__tests__/pi-core-updater-managed-path.test.ts +62 -3
- package/packages/server/src/__tests__/pi-core-updater.test.ts +1 -1
- package/packages/server/src/__tests__/pi-dev-version-check.test.ts +184 -0
- package/packages/server/src/__tests__/pi-version-skew.test.ts +4 -4
- package/packages/server/src/__tests__/provider-auth-routes.test.ts +12 -4
- package/packages/server/src/__tests__/provider-catalogue-cache.test.ts +13 -23
- package/packages/server/src/__tests__/recommended-routes.test.ts +1 -1
- package/packages/server/src/__tests__/spawn-correlation-token-integration.test.ts +91 -0
- package/packages/server/src/__tests__/spawn-register-watchdog.test.ts +84 -0
- package/packages/server/src/__tests__/spawn-token.test.ts +57 -0
- package/packages/server/src/browser-gateway.ts +12 -3
- package/packages/server/src/browser-handlers/handler-context.ts +9 -0
- package/packages/server/src/browser-handlers/session-action-handler.ts +100 -17
- package/packages/server/src/changelog-fs.ts +167 -0
- package/packages/server/src/changelog-parser.ts +321 -0
- package/packages/server/src/changelog-remote.ts +134 -0
- package/packages/server/src/cli.ts +2 -2
- package/packages/server/src/event-wiring.ts +59 -5
- package/packages/server/src/headless-pid-registry.ts +54 -5
- package/packages/server/src/pending-client-correlations.ts +73 -0
- package/packages/server/src/pending-fork-registry.ts +24 -12
- package/packages/server/src/pi-core-checker.ts +77 -17
- package/packages/server/src/pi-core-updater.ts +16 -6
- package/packages/server/src/pi-dev-version-check.ts +145 -0
- package/packages/server/src/pi-gateway.ts +4 -0
- package/packages/server/src/pi-version-skew.ts +12 -4
- package/packages/server/src/process-manager.ts +54 -11
- package/packages/server/src/provider-catalogue-cache.ts +24 -18
- package/packages/server/src/routes/pi-changelog-routes.ts +194 -0
- package/packages/server/src/routes/pi-core-routes.ts +1 -1
- package/packages/server/src/routes/provider-auth-routes.ts +5 -1
- package/packages/server/src/routes/provider-routes.ts +4 -4
- package/packages/server/src/server.ts +77 -59
- package/packages/server/src/session-api.ts +54 -3
- package/packages/server/src/session-discovery.ts +1 -1
- package/packages/server/src/session-file-reader.ts +1 -1
- package/packages/server/src/spawn-register-watchdog.ts +62 -7
- package/packages/server/src/spawn-token.ts +20 -0
- package/packages/shared/package.json +1 -1
- package/packages/shared/src/__tests__/bootstrap/__snapshots__/cube.test.ts.snap +24 -17
- package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/a-electron.test.ts.snap +5 -4
- package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/b-npm-global.test.ts.snap +6 -5
- package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/c-dev-monorepo.test.ts.snap +1 -0
- package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/e-stale-partial.test.ts.snap +5 -4
- package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/f-cwd-variants.test.ts.snap +2 -1
- package/packages/shared/src/__tests__/bootstrap/families/__snapshots__/g-windows-specifics.test.ts.snap +5 -3
- package/packages/shared/src/__tests__/bootstrap/fixtures/dev-monorepo.ts +1 -1
- package/packages/shared/src/__tests__/changelog-types.test.ts +78 -0
- package/packages/shared/src/__tests__/node-spawn-jiti-contract.test.ts +56 -20
- package/packages/shared/src/__tests__/resolve-jiti.test.ts +140 -9
- package/packages/shared/src/__tests__/tool-registry-definitions.test.ts +1 -1
- package/packages/shared/src/bootstrap-install.ts +1 -1
- package/packages/shared/src/browser-protocol.ts +43 -0
- package/packages/shared/src/changelog-types.ts +111 -0
- package/packages/shared/src/platform/node-spawn.ts +29 -21
- package/packages/shared/src/protocol.ts +8 -0
- package/packages/shared/src/resolve-jiti.ts +62 -9
- package/packages/shared/src/skill-block-parser.ts +1 -1
- package/packages/shared/src/tool-registry/definitions.ts +15 -3
- package/packages/shared/src/types.ts +7 -0
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { UsageLimitOrderer, USAGE_LIMIT_PATTERN } from "../usage-limit-orderer.js";
|
|
3
|
+
|
|
4
|
+
describe("UsageLimitOrderer", () => {
|
|
5
|
+
it("returns null when no retry was pending", () => {
|
|
6
|
+
const o = new UsageLimitOrderer();
|
|
7
|
+
const result = o.maybeSynthesize("s1", {
|
|
8
|
+
messages: [{ role: "assistant", stopReason: "error", errorMessage: "usage_limit_reached" }],
|
|
9
|
+
});
|
|
10
|
+
expect(result).toBeNull();
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it("returns null when retry was pending but error is not a usage-limit", () => {
|
|
14
|
+
const o = new UsageLimitOrderer();
|
|
15
|
+
o.noteRetryStart("s1");
|
|
16
|
+
const result = o.maybeSynthesize("s1", {
|
|
17
|
+
messages: [{ role: "assistant", stopReason: "error", errorMessage: "tool execution failed" }],
|
|
18
|
+
});
|
|
19
|
+
expect(result).toBeNull();
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it("returns null on a non-error agent_end even with pending retry", () => {
|
|
23
|
+
const o = new UsageLimitOrderer();
|
|
24
|
+
o.noteRetryStart("s1");
|
|
25
|
+
const result = o.maybeSynthesize("s1", {
|
|
26
|
+
messages: [{ role: "assistant", stopReason: "end_turn" }],
|
|
27
|
+
});
|
|
28
|
+
expect(result).toBeNull();
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("synthesizes auto_retry_end on usage_limit_reached when retry was pending", () => {
|
|
32
|
+
const o = new UsageLimitOrderer();
|
|
33
|
+
o.noteRetryStart("s1");
|
|
34
|
+
const result = o.maybeSynthesize("s1", {
|
|
35
|
+
messages: [{ role: "assistant", stopReason: "error", errorMessage: "usage_limit_reached: 5000 RPM" }],
|
|
36
|
+
});
|
|
37
|
+
expect(result).not.toBeNull();
|
|
38
|
+
expect(result!.eventType).toBe("auto_retry_end");
|
|
39
|
+
expect(result!.data).toEqual({ success: false, attempt: -1, finalError: "usage_limit_reached: 5000 RPM" });
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it.each([
|
|
43
|
+
"usage_limit_reached",
|
|
44
|
+
"usage_not_included",
|
|
45
|
+
"quota_exceeded",
|
|
46
|
+
"monthly limit reached for free tier",
|
|
47
|
+
"hourly limit hit",
|
|
48
|
+
"Your quota will reset after 18h31m10s",
|
|
49
|
+
])("matches usage-limit variant: %s", (msg) => {
|
|
50
|
+
expect(USAGE_LIMIT_PATTERN.test(msg)).toBe(true);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it.each([
|
|
54
|
+
"rate limit exceeded",
|
|
55
|
+
"overloaded_error",
|
|
56
|
+
"tool execution failed",
|
|
57
|
+
"fetch failed",
|
|
58
|
+
"",
|
|
59
|
+
])("does not match non-usage-limit variant: %s", (msg) => {
|
|
60
|
+
expect(USAGE_LIMIT_PATTERN.test(msg)).toBe(false);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("clears pending after agent_end (no double-synthesis on subsequent agent_end)", () => {
|
|
64
|
+
const o = new UsageLimitOrderer();
|
|
65
|
+
o.noteRetryStart("s1");
|
|
66
|
+
const first = o.maybeSynthesize("s1", {
|
|
67
|
+
messages: [{ role: "assistant", stopReason: "error", errorMessage: "usage_limit_reached" }],
|
|
68
|
+
});
|
|
69
|
+
expect(first).not.toBeNull();
|
|
70
|
+
// Same payload again — pending was cleared, so no synthesis.
|
|
71
|
+
const second = o.maybeSynthesize("s1", {
|
|
72
|
+
messages: [{ role: "assistant", stopReason: "error", errorMessage: "usage_limit_reached" }],
|
|
73
|
+
});
|
|
74
|
+
expect(second).toBeNull();
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it("noteRetryEnd clears pending so subsequent agent_end does not synthesize", () => {
|
|
78
|
+
const o = new UsageLimitOrderer();
|
|
79
|
+
o.noteRetryStart("s1");
|
|
80
|
+
o.noteRetryEnd("s1");
|
|
81
|
+
expect(o.hasPending("s1")).toBe(false);
|
|
82
|
+
const result = o.maybeSynthesize("s1", {
|
|
83
|
+
messages: [{ role: "assistant", stopReason: "error", errorMessage: "usage_limit_reached" }],
|
|
84
|
+
});
|
|
85
|
+
expect(result).toBeNull();
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it("scopes pending state per-session", () => {
|
|
89
|
+
const o = new UsageLimitOrderer();
|
|
90
|
+
o.noteRetryStart("s1");
|
|
91
|
+
expect(o.hasPending("s2")).toBe(false);
|
|
92
|
+
const result = o.maybeSynthesize("s2", {
|
|
93
|
+
messages: [{ role: "assistant", stopReason: "error", errorMessage: "usage_limit_reached" }],
|
|
94
|
+
});
|
|
95
|
+
expect(result).toBeNull();
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it("returns null on missing or empty messages array", () => {
|
|
99
|
+
const o = new UsageLimitOrderer();
|
|
100
|
+
o.noteRetryStart("s1");
|
|
101
|
+
expect(o.maybeSynthesize("s1", {})).toBeNull();
|
|
102
|
+
o.noteRetryStart("s1");
|
|
103
|
+
expect(o.maybeSynthesize("s1", { messages: [] })).toBeNull();
|
|
104
|
+
});
|
|
105
|
+
});
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* static tool-name conflicts with other extensions (e.g. pi-flows) that also
|
|
6
6
|
* register ask_user. Runtime registration bypasses detectExtensionConflicts.
|
|
7
7
|
*/
|
|
8
|
-
import type { ExtensionAPI } from "@
|
|
8
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
9
9
|
import { Type } from "typebox";
|
|
10
10
|
import { polyfillMultiselect } from "./multiselect-polyfill.js";
|
|
11
11
|
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* Shared mutable state for bridge modules.
|
|
3
3
|
* Avoids passing 14+ closure variables to every extracted function.
|
|
4
4
|
*/
|
|
5
|
-
import type { ExtensionAPI } from "@
|
|
5
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
6
6
|
import type { ConnectionManager } from "./connection.js";
|
|
7
7
|
|
|
8
8
|
export interface BridgeContext {
|
|
@@ -4,12 +4,14 @@
|
|
|
4
4
|
* Global extension that connects to the dashboard server,
|
|
5
5
|
* forwards all pi events, and relays commands back.
|
|
6
6
|
*/
|
|
7
|
-
import type { ExtensionAPI } from "@
|
|
8
|
-
import { Loader } from "@
|
|
7
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
8
|
+
import { Loader } from "@earendil-works/pi-tui";
|
|
9
9
|
import { ConnectionManager } from "./connection.js";
|
|
10
10
|
import { detectSessionSource } from "./source-detector.js";
|
|
11
11
|
import { mapEventToProtocol } from "./event-forwarder.js";
|
|
12
12
|
import { createCommandHandler } from "./command-handler.js";
|
|
13
|
+
import { RetryTracker } from "./retry-tracker.js";
|
|
14
|
+
import { UsageLimitOrderer } from "./usage-limit-orderer.js";
|
|
13
15
|
import fs from "node:fs";
|
|
14
16
|
import os from "node:os";
|
|
15
17
|
import path from "node:path";
|
|
@@ -191,6 +193,22 @@ function initBridge(pi: ExtensionAPI) {
|
|
|
191
193
|
let hasRegisteredOnce = false; // see change: reattach-move-to-front
|
|
192
194
|
let promptBus: PromptBus | undefined;
|
|
193
195
|
|
|
196
|
+
// Provider-retry synthesis trackers. pi's ExtensionAPI does not expose
|
|
197
|
+
// `auto_retry_*` events, so the bridge synthesizes them from observed
|
|
198
|
+
// `message_end` / `agent_end` events. See change: fix-provider-retry-infinite-loop.
|
|
199
|
+
const retryTracker = new RetryTracker();
|
|
200
|
+
const usageLimitOrderer = new UsageLimitOrderer();
|
|
201
|
+
|
|
202
|
+
/** Forward a synthesized auto_retry_* event using the standard event_forward shape. */
|
|
203
|
+
const sendSyntheticRetryEvent = (eventType: string, data: Record<string, unknown>): void => {
|
|
204
|
+
if (!isActive() || !sessionReady) return;
|
|
205
|
+
connection.send({
|
|
206
|
+
type: "event_forward",
|
|
207
|
+
sessionId,
|
|
208
|
+
event: { eventType, timestamp: Date.now(), data },
|
|
209
|
+
});
|
|
210
|
+
};
|
|
211
|
+
|
|
194
212
|
// ── Per-message entry id tracking (for fix-per-message-fork) ──
|
|
195
213
|
// Pi 0.69+ awaits extension handlers BEFORE sessionManager.appendMessage runs,
|
|
196
214
|
// which means getLeafId() at emit time returns the previous leaf, not the
|
|
@@ -648,6 +666,14 @@ function initBridge(pi: ExtensionAPI) {
|
|
|
648
666
|
if (cachedCtx?.abort) {
|
|
649
667
|
cachedCtx.abort();
|
|
650
668
|
}
|
|
669
|
+
// Clear retry-synthesis trackers — the user-initiated abort path
|
|
670
|
+
// already synthesizes its own auto_retry_end via command-handler.
|
|
671
|
+
// See change: fix-provider-retry-infinite-loop.
|
|
672
|
+
retryTracker.noteAbort(sessionId);
|
|
673
|
+
usageLimitOrderer.noteRetryEnd(sessionId);
|
|
674
|
+
},
|
|
675
|
+
isIdle: () => {
|
|
676
|
+
try { return cachedCtx?.isIdle?.() ?? false; } catch { return false; }
|
|
651
677
|
},
|
|
652
678
|
eventSink: (msg) => connection.send(msg),
|
|
653
679
|
compact: (opts) => {
|
|
@@ -802,7 +828,25 @@ function initBridge(pi: ExtensionAPI) {
|
|
|
802
828
|
if (!sessionReady) return;
|
|
803
829
|
// Track agent streaming state (survives reconnect/reload)
|
|
804
830
|
if (eventType === "agent_start") getBridgeState().isAgentStreaming = true;
|
|
805
|
-
if (eventType === "agent_end")
|
|
831
|
+
if (eventType === "agent_end") {
|
|
832
|
+
getBridgeState().isAgentStreaming = false;
|
|
833
|
+
// Provider-retry synthesis: forward auto_retry_end BEFORE agent_end
|
|
834
|
+
// when retries were in flight, so the dashboard's retry banner
|
|
835
|
+
// clears before the error banner appears. The usage-limit orderer
|
|
836
|
+
// takes precedence (it carries the actual error string); the retry
|
|
837
|
+
// tracker handles the non-usage-limit case. See change:
|
|
838
|
+
// fix-provider-retry-infinite-loop.
|
|
839
|
+
const orderedSynth = usageLimitOrderer.maybeSynthesize(sessionId, (event as any));
|
|
840
|
+
if (orderedSynth) {
|
|
841
|
+
sendSyntheticRetryEvent(orderedSynth.eventType, orderedSynth.data);
|
|
842
|
+
retryTracker.noteAbort(sessionId); // clear tracker; orderer's event is authoritative
|
|
843
|
+
} else {
|
|
844
|
+
const trackerSynth = retryTracker.observeAgentEnd(sessionId, event as any);
|
|
845
|
+
if (trackerSynth) {
|
|
846
|
+
sendSyntheticRetryEvent(trackerSynth.eventType, trackerSynth.data);
|
|
847
|
+
}
|
|
848
|
+
}
|
|
849
|
+
}
|
|
806
850
|
// For model_select, enrich the event data with thinkingLevel
|
|
807
851
|
if (eventType === "model_select") {
|
|
808
852
|
const enriched = { ...event, thinkingLevel: (pi as any).getThinkingLevel?.() };
|
|
@@ -872,6 +916,18 @@ function initBridge(pi: ExtensionAPI) {
|
|
|
872
916
|
const enriched = { ...event, entryId, nonce };
|
|
873
917
|
const protoMsg = mapEventToProtocol(sessionId, enriched);
|
|
874
918
|
connection.send(protoMsg);
|
|
919
|
+
// After forwarding the original message_end, ask the retry tracker
|
|
920
|
+
// whether to synthesize an auto_retry_* event. See change:
|
|
921
|
+
// fix-provider-retry-infinite-loop.
|
|
922
|
+
const synthetic = retryTracker.observeMessageEnd(sessionId, messageRef as any);
|
|
923
|
+
if (synthetic) {
|
|
924
|
+
sendSyntheticRetryEvent(synthetic.eventType, synthetic.data);
|
|
925
|
+
if (synthetic.eventType === "auto_retry_start") {
|
|
926
|
+
usageLimitOrderer.noteRetryStart(sessionId);
|
|
927
|
+
} else {
|
|
928
|
+
usageLimitOrderer.noteRetryEnd(sessionId);
|
|
929
|
+
}
|
|
930
|
+
}
|
|
875
931
|
}, 0);
|
|
876
932
|
return;
|
|
877
933
|
}
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
*/
|
|
4
4
|
import { readdirSync } from "node:fs";
|
|
5
5
|
import { join, relative } from "node:path";
|
|
6
|
-
import type { ExtensionAPI } from "@
|
|
6
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
7
7
|
import type {
|
|
8
8
|
ServerToExtensionMessage,
|
|
9
9
|
ExtensionToServerMessage,
|
|
@@ -145,6 +145,11 @@ export function createCommandHandler(
|
|
|
145
145
|
getThinkingLevel?: () => string | undefined;
|
|
146
146
|
shutdown?: () => void;
|
|
147
147
|
abort?: () => void;
|
|
148
|
+
/**
|
|
149
|
+
* Probe agent idleness for the persistent-abort scheduler.
|
|
150
|
+
* See change: fix-provider-retry-infinite-loop.
|
|
151
|
+
*/
|
|
152
|
+
isIdle?: () => boolean;
|
|
148
153
|
getCwd?: () => string;
|
|
149
154
|
/** Callback to send events (e.g., bash_output, command_feedback) back to server */
|
|
150
155
|
eventSink?: (msg: ExtensionToServerMessage) => void;
|
|
@@ -161,6 +166,33 @@ export function createCommandHandler(
|
|
|
161
166
|
},
|
|
162
167
|
): CommandHandler {
|
|
163
168
|
const getSessionId = typeof sessionIdOrGetter === "function" ? sessionIdOrGetter : () => sessionIdOrGetter;
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Persistent-abort scheduler. Re-invokes `options.abort()` at 200ms
|
|
172
|
+
* intervals for up to 2 seconds, breaking early when `options.isIdle()`
|
|
173
|
+
* returns true. Closes the retry race window in pi-coding-agent.
|
|
174
|
+
* See change: fix-provider-retry-infinite-loop.
|
|
175
|
+
*/
|
|
176
|
+
const PERSISTENT_ABORT_INTERVAL_MS = 200;
|
|
177
|
+
const PERSISTENT_ABORT_MAX_MS = 2000;
|
|
178
|
+
function schedulePersistentAbort(opts: NonNullable<typeof options>): void {
|
|
179
|
+
if (!opts.abort) return;
|
|
180
|
+
const startedAt = Date.now();
|
|
181
|
+
const interval = setInterval(() => {
|
|
182
|
+
if (Date.now() - startedAt >= PERSISTENT_ABORT_MAX_MS) {
|
|
183
|
+
clearInterval(interval);
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
try {
|
|
187
|
+
if (opts.isIdle?.()) {
|
|
188
|
+
clearInterval(interval);
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
} catch { /* probe failure — keep trying */ }
|
|
192
|
+
try { opts.abort?.(); } catch { /* idempotent */ }
|
|
193
|
+
}, PERSISTENT_ABORT_INTERVAL_MS);
|
|
194
|
+
}
|
|
195
|
+
|
|
164
196
|
return {
|
|
165
197
|
async handle(msg: ServerToExtensionMessage): Promise<ExtensionToServerMessage | undefined> {
|
|
166
198
|
const sessionId = getSessionId();
|
|
@@ -292,6 +324,31 @@ export function createCommandHandler(
|
|
|
292
324
|
if (options?.abort) {
|
|
293
325
|
options.abort();
|
|
294
326
|
}
|
|
327
|
+
// Synthesize an immediate auto_retry_end so the dashboard clears
|
|
328
|
+
// any in-flight retry banner without waiting for pi's natural
|
|
329
|
+
// auto_retry_end (which is delayed by the abortable-sleep cancel
|
|
330
|
+
// window AND, on extension API, never reaches us at all — see
|
|
331
|
+
// https://github.com/badlogic/pi-mono/discussions/2073). The
|
|
332
|
+
// reducer no-ops auto_retry_end when retryState is undefined,
|
|
333
|
+
// so this is idempotent against later events.
|
|
334
|
+
if (options?.eventSink) {
|
|
335
|
+
options.eventSink({
|
|
336
|
+
type: "event_forward",
|
|
337
|
+
sessionId,
|
|
338
|
+
event: {
|
|
339
|
+
eventType: "auto_retry_end",
|
|
340
|
+
timestamp: Date.now(),
|
|
341
|
+
data: { success: false, attempt: -1, finalError: "Aborted by user" },
|
|
342
|
+
},
|
|
343
|
+
});
|
|
344
|
+
}
|
|
345
|
+
// Persistent-abort scheduler: pi-coding-agent's _retryAbortController
|
|
346
|
+
// is briefly `undefined` between sleep-end and the next
|
|
347
|
+
// agent.continue() call. An abort that arrives in that window is
|
|
348
|
+
// a no-op against the retry. Re-invoke abort every 200ms for up
|
|
349
|
+
// to 2s, breaking early when the agent is idle.
|
|
350
|
+
// See change: fix-provider-retry-infinite-loop.
|
|
351
|
+
if (options) schedulePersistentAbort(options);
|
|
295
352
|
return undefined;
|
|
296
353
|
|
|
297
354
|
case "request_commands": {
|
|
@@ -394,7 +451,7 @@ export function createCommandHandler(
|
|
|
394
451
|
case "list_sessions": {
|
|
395
452
|
try {
|
|
396
453
|
// Dynamic import to avoid hard dependency at module load
|
|
397
|
-
const { SessionManager } = await import("@
|
|
454
|
+
const { SessionManager } = await import("@earendil-works/pi-coding-agent") as any;
|
|
398
455
|
const cwd = msg.cwd || options?.getCwd?.() || process.cwd();
|
|
399
456
|
const sessionInfos = await SessionManager.list(cwd);
|
|
400
457
|
const sessions: PiSessionInfo[] = (sessionInfos || []).map((s: any) => ({
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* Flow event wiring: registers listeners for pi-flows events
|
|
3
3
|
* and forwards them as protocol messages to the dashboard server.
|
|
4
4
|
*/
|
|
5
|
-
import type { ExtensionAPI } from "@
|
|
5
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
6
6
|
import type { BridgeContext } from "./bridge-context.js";
|
|
7
7
|
import { filterHiddenCommands } from "./bridge-context.js";
|
|
8
8
|
import type { FlowInfo } from "@blackbelt-technology/pi-dashboard-shared/types.js";
|
|
@@ -23,7 +23,7 @@ interface Item {
|
|
|
23
23
|
|
|
24
24
|
/**
|
|
25
25
|
* Minimal shape of pi-tui's `Component` interface — we avoid importing from
|
|
26
|
-
* `@
|
|
26
|
+
* `@earendil-works/pi-tui` directly so this module stays compile-friendly when
|
|
27
27
|
* that peer dep isn't present (e.g. in unit tests running via vitest without
|
|
28
28
|
* the full pi runtime).
|
|
29
29
|
*/
|
|
@@ -1,15 +1,8 @@
|
|
|
1
1
|
// Ambient declarations for pi runtime packages.
|
|
2
|
-
// The actual types are provided by whichever host
|
|
2
|
+
// The actual types are provided by whichever host loads this extension.
|
|
3
3
|
// tsconfig paths handles resolution when one of the packages is installed;
|
|
4
4
|
// these declarations serve as fallback when neither is available (e.g. CI, dev without pi).
|
|
5
|
-
declare module "@
|
|
6
|
-
export type ExtensionAPI = import("@oh-my-pi/pi-coding-agent").ExtensionAPI;
|
|
7
|
-
}
|
|
8
|
-
declare module "@mariozechner/pi-ai" {
|
|
9
|
-
export function StringEnum<T extends readonly string[]>(values: T, schema?: Record<string, unknown>): any;
|
|
10
|
-
}
|
|
11
|
-
|
|
12
|
-
declare module "@oh-my-pi/pi-coding-agent" {
|
|
5
|
+
declare module "@earendil-works/pi-coding-agent" {
|
|
13
6
|
export interface ModelRegistry {
|
|
14
7
|
getAvailable(): Array<{ provider: string; id: string }>;
|
|
15
8
|
refresh(): void;
|
|
@@ -35,3 +28,17 @@ declare module "@oh-my-pi/pi-coding-agent" {
|
|
|
35
28
|
events: EventBus;
|
|
36
29
|
}
|
|
37
30
|
}
|
|
31
|
+
|
|
32
|
+
// Legacy fork — re-exports the same ExtensionAPI shape so existing installs still type-check.
|
|
33
|
+
declare module "@mariozechner/pi-coding-agent" {
|
|
34
|
+
export type ExtensionAPI = import("@earendil-works/pi-coding-agent").ExtensionAPI;
|
|
35
|
+
export type ModelRegistry = import("@earendil-works/pi-coding-agent").ModelRegistry;
|
|
36
|
+
export type EventBus = import("@earendil-works/pi-coding-agent").EventBus;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
declare module "@earendil-works/pi-ai" {
|
|
40
|
+
export function StringEnum<T extends readonly string[]>(values: T, schema?: Record<string, unknown>): any;
|
|
41
|
+
}
|
|
42
|
+
declare module "@mariozechner/pi-ai" {
|
|
43
|
+
export function StringEnum<T extends readonly string[]>(values: T, schema?: Record<string, unknown>): any;
|
|
44
|
+
}
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
* flow:resolve-model / flow:get-available-models
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
|
-
import type { ExtensionAPI } from "@
|
|
14
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
15
15
|
import { existsSync, readFileSync } from "node:fs";
|
|
16
16
|
import { homedir } from "node:os";
|
|
17
17
|
import { join } from "node:path";
|
|
@@ -373,7 +373,7 @@ async function loadPiAi(): Promise<PiAiHelpers> {
|
|
|
373
373
|
if (_piAiLoadAttempted) return {};
|
|
374
374
|
_piAiLoadAttempted = true;
|
|
375
375
|
try {
|
|
376
|
-
const mod: any = await import("@
|
|
376
|
+
const mod: any = await import("@earendil-works/pi-ai");
|
|
377
377
|
_piAiModule = { findEnvKeys: mod.findEnvKeys, getEnvApiKey: mod.getEnvApiKey };
|
|
378
378
|
return _piAiModule;
|
|
379
379
|
} catch {
|
|
@@ -436,6 +436,20 @@ function getModelRegistry(): any {
|
|
|
436
436
|
// -- Provider registration (with auto-discovery) --------------------------
|
|
437
437
|
|
|
438
438
|
async function registerEntry(pi: ExtensionAPI, name: string, entry: ProviderEntry): Promise<number> {
|
|
439
|
+
// Record snapshot SYNCHRONOUSLY before awaiting discovery so the very
|
|
440
|
+
// first providers_list push (typically fired from `session_start`
|
|
441
|
+
// shortly after `activate()` kicked off async registerEntry calls) carries
|
|
442
|
+
// the correct `custom: true` flags. Otherwise a slow / unreachable
|
|
443
|
+
// /v1/models endpoint causes custom providers from
|
|
444
|
+
// `~/.pi/agent/providers.json` to leak into Settings → Provider
|
|
445
|
+
// Authentication → API Keys until the discovery probe resolves.
|
|
446
|
+
// See change: fix-custom-provider-flag-race.
|
|
447
|
+
lastRegistered.set(name, {
|
|
448
|
+
baseUrl: entry.baseUrl,
|
|
449
|
+
apiKey: entry.apiKey,
|
|
450
|
+
api: entry.api ?? "openai-completions",
|
|
451
|
+
});
|
|
452
|
+
|
|
439
453
|
const discovered = await discoverModels(entry.baseUrl, entry.apiKey);
|
|
440
454
|
|
|
441
455
|
// Metadata (contextWindow, maxTokens, reasoning, cost, input) is resolved
|
|
@@ -464,13 +478,6 @@ async function registerEntry(pi: ExtensionAPI, name: string, entry: ProviderEntr
|
|
|
464
478
|
models,
|
|
465
479
|
});
|
|
466
480
|
|
|
467
|
-
// Record snapshot so reloadProviders can detect subsequent changes.
|
|
468
|
-
lastRegistered.set(name, {
|
|
469
|
-
baseUrl: entry.baseUrl,
|
|
470
|
-
apiKey: entry.apiKey,
|
|
471
|
-
api: entry.api ?? "openai-completions",
|
|
472
|
-
});
|
|
473
|
-
|
|
474
481
|
// Notify bridge directly (same package — no cross-package event needed)
|
|
475
482
|
onProvidersChanged?.();
|
|
476
483
|
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RetryTracker — synthesizes `auto_retry_start` / `auto_retry_end` events from
|
|
3
|
+
* observed pi events.
|
|
4
|
+
*
|
|
5
|
+
* Background: pi's ExtensionAPI does NOT expose `auto_retry_*` events to
|
|
6
|
+
* extensions (verified against pi 0.70/0.73 — see
|
|
7
|
+
* https://github.com/badlogic/pi-mono/discussions/2073). They fire only via
|
|
8
|
+
* `AgentSession._emit → _eventListeners` which only the embedded SDK can
|
|
9
|
+
* subscribe to.
|
|
10
|
+
*
|
|
11
|
+
* Workaround: pi-coding-agent's `_handleRetryableError` fires `message_end`
|
|
12
|
+
* for the failed assistant message BEFORE entering its retry sleep. The
|
|
13
|
+
* bridge sees that `message_end` via `pi.on("message_end")`. By matching
|
|
14
|
+
* the same regex pi-coding-agent uses internally, we can detect that a
|
|
15
|
+
* retry is about to happen and emit our own `auto_retry_start` to the
|
|
16
|
+
* dashboard. When the next non-error `message_end` or `agent_end` arrives,
|
|
17
|
+
* we emit `auto_retry_end`.
|
|
18
|
+
*
|
|
19
|
+
* `delayMs` and `maxAttempts` are unknowable from observed events (pi's
|
|
20
|
+
* settings are not exposed); we send sentinel `-1` for both. The
|
|
21
|
+
* RetryBanner renders an indeterminate "retrying…" UI in that case.
|
|
22
|
+
*
|
|
23
|
+
* See change: fix-provider-retry-infinite-loop.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Regex copied verbatim from pi-coding-agent `agent-session.js`
|
|
28
|
+
* `_isRetryableError`. If pi adds new retryable categories, this regex
|
|
29
|
+
* goes stale — but the failure mode is "tracker silently misses some
|
|
30
|
+
* retries", never "tracker breaks". Sync at major pi version bumps.
|
|
31
|
+
*/
|
|
32
|
+
export const RETRYABLE_PATTERN =
|
|
33
|
+
/overloaded|provider.?returned.?error|rate.?limit|too many requests|429|500|502|503|504|service.?unavailable|server.?error|internal.?error|network.?error|connection.?error|connection.?refused|connection.?lost|other side closed|fetch failed|upstream.?connect|reset before headers|socket hang up|ended without|http2 request did not get a response|timed? out|timeout|terminated|retry delay/i;
|
|
34
|
+
|
|
35
|
+
export interface SyntheticRetryEvent {
|
|
36
|
+
eventType: "auto_retry_start" | "auto_retry_end";
|
|
37
|
+
data: Record<string, unknown>;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Minimal shape we pluck from a `message_end` event. */
|
|
41
|
+
export interface ObservedAssistantMessage {
|
|
42
|
+
role?: string;
|
|
43
|
+
stopReason?: string;
|
|
44
|
+
errorMessage?: string;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export class RetryTracker {
|
|
48
|
+
/** sessionId → 1-based attempt counter for the current retry chain. */
|
|
49
|
+
private attempt = new Map<string, number>();
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Process a `message_end` event. Returns a synthetic event the bridge
|
|
53
|
+
* should ALSO forward (after the original message_end), or null.
|
|
54
|
+
*/
|
|
55
|
+
observeMessageEnd(
|
|
56
|
+
sessionId: string,
|
|
57
|
+
message: ObservedAssistantMessage | undefined | null,
|
|
58
|
+
): SyntheticRetryEvent | null {
|
|
59
|
+
if (!message || message.role !== "assistant") return null;
|
|
60
|
+
|
|
61
|
+
if (message.stopReason === "error") {
|
|
62
|
+
const err = typeof message.errorMessage === "string" ? message.errorMessage : "";
|
|
63
|
+
if (!err || !RETRYABLE_PATTERN.test(err)) return null;
|
|
64
|
+
const next = (this.attempt.get(sessionId) ?? 0) + 1;
|
|
65
|
+
this.attempt.set(sessionId, next);
|
|
66
|
+
return {
|
|
67
|
+
eventType: "auto_retry_start",
|
|
68
|
+
data: { attempt: next, maxAttempts: -1, delayMs: -1, errorMessage: err },
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Non-error assistant message — clears any in-flight retry chain.
|
|
73
|
+
if (this.attempt.has(sessionId)) {
|
|
74
|
+
const last = this.attempt.get(sessionId) ?? 0;
|
|
75
|
+
this.attempt.delete(sessionId);
|
|
76
|
+
return { eventType: "auto_retry_end", data: { success: true, attempt: last } };
|
|
77
|
+
}
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Process an `agent_end` event. Returns a synthetic event the bridge
|
|
83
|
+
* should forward BEFORE the original agent_end, or null.
|
|
84
|
+
*
|
|
85
|
+
* Always clears any in-flight retry tracking (terminal turn boundary).
|
|
86
|
+
*/
|
|
87
|
+
observeAgentEnd(
|
|
88
|
+
sessionId: string,
|
|
89
|
+
agentEndData: { messages?: unknown } | undefined | null,
|
|
90
|
+
): SyntheticRetryEvent | null {
|
|
91
|
+
const wasRetrying = this.attempt.has(sessionId);
|
|
92
|
+
const last = this.attempt.get(sessionId) ?? -1;
|
|
93
|
+
this.attempt.delete(sessionId);
|
|
94
|
+
if (!wasRetrying) return null;
|
|
95
|
+
|
|
96
|
+
// Inspect terminal message for error context.
|
|
97
|
+
const messages = agentEndData?.messages;
|
|
98
|
+
const lastMsg =
|
|
99
|
+
Array.isArray(messages) && messages.length > 0
|
|
100
|
+
? (messages[messages.length - 1] as ObservedAssistantMessage)
|
|
101
|
+
: undefined;
|
|
102
|
+
if (lastMsg?.stopReason === "error" && typeof lastMsg.errorMessage === "string") {
|
|
103
|
+
return {
|
|
104
|
+
eventType: "auto_retry_end",
|
|
105
|
+
data: { success: false, attempt: last, finalError: lastMsg.errorMessage },
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
return { eventType: "auto_retry_end", data: { success: true, attempt: last } };
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Notify the tracker of a user abort. Clears in-flight tracking so a
|
|
113
|
+
* subsequent agent_end does not double-emit auto_retry_end.
|
|
114
|
+
*/
|
|
115
|
+
noteAbort(sessionId: string): void {
|
|
116
|
+
this.attempt.delete(sessionId);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Test-only / bridge-coordination: is a retry currently in flight? */
|
|
120
|
+
isRetrying(sessionId: string): boolean {
|
|
121
|
+
return this.attempt.has(sessionId);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
@@ -39,7 +39,15 @@ export function sendStateSync(
|
|
|
39
39
|
// dashboard restart) is a "reattach". Server applies the configured
|
|
40
40
|
// `reattachPlacement` policy on "reattach".
|
|
41
41
|
// See change: reattach-move-to-front.
|
|
42
|
-
const
|
|
42
|
+
const isFirstRegister = !bc.hasRegisteredOnce;
|
|
43
|
+
const registerReason: "spawn" | "reattach" = isFirstRegister ? "spawn" : "reattach";
|
|
44
|
+
|
|
45
|
+
// Include the spawn correlation token (server-minted UUID injected via
|
|
46
|
+
// env var at spawn time) ONLY on the first register. Subsequent
|
|
47
|
+
// registers (reattach after dashboard restart, in-process Ctrl+F fork)
|
|
48
|
+
// omit it because the sessionId is already known to the server.
|
|
49
|
+
// See change: spawn-correlation-token (Decision 3).
|
|
50
|
+
const spawnToken = isFirstRegister ? process.env.PI_DASHBOARD_SPAWN_TOKEN : undefined;
|
|
43
51
|
|
|
44
52
|
bc.connection.send({
|
|
45
53
|
type: "session_register",
|
|
@@ -55,6 +63,7 @@ export function sendStateSync(
|
|
|
55
63
|
eventCount,
|
|
56
64
|
pid: process.pid,
|
|
57
65
|
registerReason,
|
|
66
|
+
...(spawnToken ? { spawnToken } : {}),
|
|
58
67
|
});
|
|
59
68
|
|
|
60
69
|
bc.hasRegisteredOnce = true;
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Usage-limit event orderer.
|
|
3
|
+
*
|
|
4
|
+
* Tracks per-session whether an `auto_retry_start` was forwarded without a
|
|
5
|
+
* matching `auto_retry_end`, and — when an `agent_end` arrives whose terminal
|
|
6
|
+
* assistant message has a usage-limit / quota errorMessage — synthesizes an
|
|
7
|
+
* `auto_retry_end { success: false }` to emit BEFORE the `agent_end`.
|
|
8
|
+
*
|
|
9
|
+
* Pure logic (no I/O). The bridge wires this into its forwarding pipeline.
|
|
10
|
+
*
|
|
11
|
+
* See change: fix-provider-retry-infinite-loop.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
export const USAGE_LIMIT_PATTERN =
|
|
15
|
+
/usage[_ ]limit[_ ]reached|usage_not_included|quota[_ ]exceeded|monthly limit|hourly limit|reset after \d+[hms]/i;
|
|
16
|
+
|
|
17
|
+
export interface SyntheticEventEnvelope {
|
|
18
|
+
eventType: "auto_retry_end";
|
|
19
|
+
data: { success: false; attempt: -1; finalError: string };
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export class UsageLimitOrderer {
|
|
23
|
+
/** sessionId → true while a retry is in flight (no auto_retry_end seen yet). */
|
|
24
|
+
private pending = new Set<string>();
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Notify the orderer of an outbound `auto_retry_start` for sessionId.
|
|
28
|
+
*/
|
|
29
|
+
noteRetryStart(sessionId: string): void {
|
|
30
|
+
this.pending.add(sessionId);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Notify the orderer of an outbound `auto_retry_end` for sessionId.
|
|
35
|
+
* Subsequent `agent_end` events will not synthesize unless a new retry
|
|
36
|
+
* has been started.
|
|
37
|
+
*/
|
|
38
|
+
noteRetryEnd(sessionId: string): void {
|
|
39
|
+
this.pending.delete(sessionId);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Inspect an `agent_end` payload. If the terminal message has a
|
|
44
|
+
* usage-limit error AND we have an unmatched retry-start for this session,
|
|
45
|
+
* return the synthetic event the bridge should forward BEFORE the agent_end.
|
|
46
|
+
*
|
|
47
|
+
* Returns null when no synthesis is needed. Always clears the pending flag
|
|
48
|
+
* after a terminal agent_end (errored or not) so we don't double-synthesize.
|
|
49
|
+
*/
|
|
50
|
+
maybeSynthesize(
|
|
51
|
+
sessionId: string,
|
|
52
|
+
agentEndData: Record<string, unknown> | undefined,
|
|
53
|
+
): SyntheticEventEnvelope | null {
|
|
54
|
+
const wasPending = this.pending.has(sessionId);
|
|
55
|
+
// Always clear on agent_end: a terminal turn ends any retry tracking.
|
|
56
|
+
this.pending.delete(sessionId);
|
|
57
|
+
|
|
58
|
+
if (!wasPending || !agentEndData) return null;
|
|
59
|
+
const messages = agentEndData.messages;
|
|
60
|
+
if (!Array.isArray(messages) || messages.length === 0) return null;
|
|
61
|
+
const last = messages[messages.length - 1] as Record<string, unknown> | undefined;
|
|
62
|
+
if (!last || last.stopReason !== "error") return null;
|
|
63
|
+
const errorMessage = typeof last.errorMessage === "string" ? last.errorMessage : "";
|
|
64
|
+
if (!errorMessage || !USAGE_LIMIT_PATTERN.test(errorMessage)) return null;
|
|
65
|
+
|
|
66
|
+
return {
|
|
67
|
+
eventType: "auto_retry_end",
|
|
68
|
+
data: { success: false, attempt: -1, finalError: errorMessage },
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Test-only: inspect pending state. */
|
|
73
|
+
hasPending(sessionId: string): boolean {
|
|
74
|
+
return this.pending.has(sessionId);
|
|
75
|
+
}
|
|
76
|
+
}
|