@danypops/pi-jittor 0.1.0

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 ADDED
@@ -0,0 +1,63 @@
1
+ # @danypops/pi-jittor
2
+
3
+ The Pi extension for Jittor: routing enforcement, the integrated footer, settings TUI, usage/cost graphs, and benchmark panels, all through an authenticated loopback client to the [`@danypops/jittor`](../jittor) daemon. See the [repo root README](../../README.md) for the two-package overview.
4
+
5
+ ## Behavior
6
+
7
+ The extension preflights input and every provider turn, applies model/thinking decisions, records response headers and finalized usage through the daemon, and blocks requests when required telemetry is unsafe. It follows Pi's current authenticated model/provider and synchronizes Pi's available models before every decision, so unavailable catalog routes are never selected. Mutable route state is scoped by Pi session, so concurrent sessions cannot replace each other's active provider or footer budget selection. Each session registers an opaque secret with the daemon at `session_start` (best-effort; a registration failure leaves that session unarmored rather than blocked) and presents it on every router-mutating call for the rest of its lifetime; an unregistered `session_id` continues to mutate exactly as before, so this is additive armor, not a breaking change for other callers of the same API. A configured required budget source still fails closed; a provider with no enforceable budget window continues explicitly monitor-only.
8
+
9
+ Its responsive integrated footer groups repository and model identity with cumulative usage, a color-coded context-window bar, and current-provider budget telemetry. Codex shows the active model's bounded quota as a draining remaining-budget bar with reset and freshness information. OpenRouter uses the same drain semantics when its official key telemetry exposes a configured limit and remaining balance; keys without a limit remain honest text-only spend and never receive a fabricated denominator. Anthropic shows the same drain semantics from its most-restrictive-in-effect token bucket, falling back to the request bucket when no token telemetry has been observed yet. During Pi compaction, the context bar drains from its captured fill against a learned median duration estimated from the last few completed compactions (bounded to the most recent 20 samples, requiring at least 3 before trusting it). It never renders a timer. Until enough evidence exists, the bar does not drain; it blinks in place at its captured fill. Run `jittor compaction estimate [--json]` (via the core CLI) to inspect the current estimate and its confidence directly. Unknown and stale telemetry are marked explicitly. Run `/jittor` for the consolidated Settings TUI (its default action), or `/jittor status` for detailed burn pressure, freshness, route state, and confirmed emergency-halt/override controls.
10
+
11
+ The extension currently registers no model-callable native tools, so Pi's native model `content` versus renderer `details` contract is explicitly not applicable. Daemon JSON, CLI `--json`, human CLI output, command notifications, panels, and the footer remain separate bounded channels. See the core package's [`docs/OUTPUT_CHANNELS.md`](../jittor/docs/OUTPUT_CHANNELS.md) for the conformance matrix and the requirements that apply if a native tool is introduced later.
12
+
13
+ Blocking always has a daemon-independent escape hatch. `/jittor off` immediately enters persisted monitor-only mode and never blocks provider requests. The informational footer is independently controlled with `/jittor footer on` and `/jittor footer off`, so showing status never enables enforcement. `/jittor on` only enables enforcement after telemetry polling and available-route synchronization succeed. Every fail-closed error includes these recovery commands plus the daemon restart command.
14
+
15
+ ### Opt-in Codex settled-turn recovery
16
+
17
+ Transient Codex recovery is securely off by default and controlled through the existing Jittor command surface:
18
+
19
+ ```text
20
+ /jittor recovery status
21
+ /jittor recovery on
22
+ /jittor recovery off
23
+ /jittor recovery cancel
24
+ ```
25
+
26
+ The on/off choice persists privately in `$XDG_CONFIG_HOME/jittor/extension.json` (or `~/.config/jittor/extension.json`). Status reports only enabled state, cooldown, bounded attempt/window counters, and the normalized failure class. `cancel` clears the current cooldown and attempt window without changing the persisted on/off choice.
27
+
28
+ The extension observes finalized Codex assistant errors through Pi's public message lifecycle, classifies only bounded error metadata, and waits for `agent_settled` before acting. That boundary guarantees Pi's built-in retry, compaction retry, and queued follow-up work has finished. A transient concurrency, rate-limit, overload, or transport failure then schedules one hidden follow-up with Retry-After-aware capped jitter. Recovery is limited to three attempts per ten-minute window, never overlaps pending Pi messages, resets after success, and is canceled by human input or session shutdown. Quota, authentication, invalid-request, unknown, and aborted failures remain terminal. Raw provider payloads are never retained or injected.
29
+
30
+ ### Settings
31
+
32
+ `/jittor` is the settings and control command. Bare `/jittor` (or `/jittor settings`) opens one keyboard-navigable TUI covering routing enforcement, the informational footer, Codex recovery, and all four token-budget thresholds, with explicit ON/OFF and configured/not-configured labels, bounded rendering on narrow terminals, and confirmation for weaker enforcement/recovery changes. `/jittor status` shows the routing/pressure panel that used to be the bare command's default. Existing non-TUI subcommands (`benchmarks`, `outcome`, `recovery`, `on`/`off`, `footer on`/`off`, `context`) remain available for automation and are unchanged.
33
+
34
+ ### Usage and cost graphs
35
+
36
+ `/usage` is its own top-level command, separate from `/jittor`. Bare `/usage` opens a colored Unicode cumulative graph with X/Y axes, per-provider/model series, and explicit **Hourly**, **Daily**, **Weekly**, **Monthly**, and **Quarterly** periods; `/usage cost` opens the same graph showing aggregated USD spend instead of tokens, reusing the `cost` metric already recorded content-free on every finalized Pi assistant message (no new instrumentation). Left/Right or Tab/Shift+Tab changes the time frame, `v` toggles between the token and cost views, and `r` refreshes.
37
+
38
+ The graph fetches metrics per distinct provider/model scope (`jittor metrics distinct-scopes`, bounded to 40 scopes, 250 rows each) rather than one flat "most recent rows" query. A flat query lets one heavy, long-running session monopolize the entire row budget with its own most recent activity, silently hiding every other provider from the chart no matter which time frame is selected, since the query would never reach back far enough in time to see anything else. Fetching per scope guarantees every active provider/model gets its own fair share of the query budget instead.
39
+
40
+ Series are colored with a categorical palette chosen to avoid this UI's own status colors ("success"/"warning"/"error" already mean something specific elsewhere in this panel, so reusing them for arbitrary model identity would make a model's bar segment look like a warning or a failure) and instead reuses the theme's syntax-highlighting roles. Once more series are active than there are hues, a series reuses a hue in bold rather than repeating an indistinguishable color. Multiple models active within the same cumulative time frame are rendered as one bar stacked by color, not separate bars.
41
+
42
+ Token-budget thresholds are optional and must be configured by the user; Jittor never infers a token allowance from Codex or another provider's subscription percentage. Configure or clear one period with `/usage budget <hourly|daily|weekly|monthly|quarterly> <positive-tokens|off>`, and inspect all of them with `/usage budget`. A configured budget appears as a horizontal threshold on the cumulative graph with explicit remaining or **OVER BUDGET** state; the cost view does not yet support a budget threshold. These private settings persist in `$XDG_CONFIG_HOME/jittor/extension.json` (or `~/.config/jittor/extension.json`).
43
+
44
+ See [`docs/USAGE_PRIOR_ART.md`](docs/USAGE_PRIOR_ART.md) for the chart design research.
45
+
46
+ ### Cost per Papyrus task
47
+
48
+ The extension observes Papyrus's task-focus lifecycle in real time over a shared Pi extension event bus (`papyrus.task-focus.v1`) -- Papyrus never depends on Jittor, it only broadcasts which task is currently focused. Every token/cost metric already recorded on a finalized Pi assistant message is tagged with the currently focused task's id, and the provider/model/thinking level active at that moment, the instant it is recorded (no time-window estimation, no new instrumentation). A paused or cleared focus stops tagging; spend recorded with nothing focused is reported separately as unattributed, never dropped or folded into an invented task. Run `jittor metrics cost-by-task --since <ms> --until <ms> [--json]` (via the core CLI) for a bounded per-task breakdown of cost and input/output/cache tokens, broken down further by which provider/model/thinking combination each task actually spent on.
49
+
50
+ ### Benchmark evidence panel
51
+
52
+ Run `/jittor benchmarks [coding|general] [research|planning|general]` (either order, either or both omitted) for the responsive recommendation panel over the core daemon's benchmark ranking operation (see `@danypops/jittor`'s README for ingestion sources). Because the released Pi extension API does not expose the exact `/scoped-models` set, the current adapter labels candidates `available-models`; the panel says **ADVISORY** and offers no selection action. Automatic route ordering is allowed only for `exact-session` authority and then narrows/reorders routes already present in the supplied candidate set. `/jittor outcome accepted` or `/jittor outcome rejected` attaches explicit outcome evidence to the latest completed local run; runtime completion alone is not treated as quality success.
53
+
54
+ ### Context pressure
55
+
56
+ Run `/jittor context` for the in-session summary of Papyrus prompt-injection and Pi compaction telemetry; see `@danypops/jittor`'s README for what is recorded and the equivalent CLI command.
57
+
58
+ ## Development
59
+
60
+ ```bash
61
+ bun test
62
+ bun x tsc --noEmit
63
+ ```
@@ -0,0 +1,64 @@
1
+ # Token-usage TUI prior art
2
+
3
+ Research performed before implementing Jittor's `/jittor usage` frontend.
4
+
5
+ ## Local agent implementations
6
+
7
+ ### Claude Code
8
+
9
+ `~/Repositories/claude/src/components/Stats.tsx` contains the strongest terminal chart precedent:
10
+
11
+ - `generateTokenChart()` renders daily model-token series with an eight-row `asciichart` plot.
12
+ - Width adapts to the terminal and is capped near 52 columns.
13
+ - The top three models receive distinct theme colors.
14
+ - The X axis places three or four date labels at even positions.
15
+ - The Y axis abbreviates values as `k` and `M`.
16
+
17
+ `~/Repositories/claude/src/utils/heatmap.ts` adds a GitHub-style activity view using percentile-derived `░▒▓█` intensity. `src/components/design-system/ProgressBar.tsx` uses eighth-cell Unicode blocks (`▏▎▍▌▋▊▉█`) for fractional precision.
18
+
19
+ Useful decisions: adaptive width, short axes, bounded series count, theme colors, Unicode partial cells. The Jittor requirement is a histogram rather than Claude's line chart, so only the layout and scaling ideas are reused.
20
+
21
+ ### OpenCode
22
+
23
+ `~/Repositories/opencode/packages/stats/core/src/domain/home.ts` keeps usage projection in the domain layer. It defines explicit ranges (`1D`, `1W`, `2W`, `1M`, `2M`, `3M`, `YTD`, `ALL`), computes date windows, creates deterministic buckets, and formats range-appropriate labels.
24
+
25
+ Useful decision: range/window/bucket projection is separate from rendering and data access.
26
+
27
+ ### Codex
28
+
29
+ `~/Repositories/codex` exposes turn-level `last` and `total` token usage plus account rate-limit snapshots. Its TUI focuses on compact totals and quota state; no reusable historical token histogram was found.
30
+
31
+ Useful decision: finalized turn usage is the durable accounting point. Jittor already records Pi assistant usage on `message_end`.
32
+
33
+ ### Cline
34
+
35
+ Cline tracks accumulated input/output/cache/cost values and presents context/cost in its status area. Its local CLI sources did not contain a historical terminal histogram comparable to the requested chart.
36
+
37
+ Useful decision: preserve input, output, cache-read, and cache-write categories rather than collapsing accounting at ingestion.
38
+
39
+ ## Pi extensions
40
+
41
+ Registry and package-source review covered:
42
+
43
+ - `@pi-vault/pi-usage@0.6.0`: polished framed/tabbed dashboard, Today/This Week/Last Week/All Time tables, live provider quotas, width-safe theme adapters. It has no historical vertical token histogram.
44
+ - `@sreetej510/pi-usage@0.1.20`: `/usage` provider quota reports, cache, retries, statusline, and 20-cell `█░` quota bars.
45
+ - `@narumitw/pi-codex-usage@0.20.0`: Codex 5-hour/weekly quota bars and compact statusline.
46
+ - `@alexanderfortin/pi-token-usage@0.3.0`: session-file aggregation and table/export overlay, but no chart.
47
+
48
+ Useful decisions: native `registerCommand`, `ctx.ui.custom`, width-bounded rendering, theme-derived colors, keyboard refresh/range navigation, and daemon/cache-backed data rather than synchronous file scans in render.
49
+
50
+ ## OpenRouter visual reference
51
+
52
+ OpenRouter's authenticated web dashboard is not distributed as reusable terminal source. Its relevant visual grammar is a compact time-bucket histogram with colored model/provider series, readable axes, totals, and a legend. Jittor reproduces that grammar with Unicode blocks rather than copying web implementation details.
53
+
54
+ ## Jittor design
55
+
56
+ Jittor combines the best applicable patterns:
57
+
58
+ 1. Pure domain projection in `src/domain/usage.ts`.
59
+ 2. Explicit `24h`, `7d`, `30d`, and `90d` windows.
60
+ 3. Provider/model-preserving series and input/output/cache totals.
61
+ 4. Vertically scaled, colored, stacked Unicode bars with fractional top blocks.
62
+ 5. Width-safe X/Y axes and provider/model legend.
63
+ 6. Native `/jittor usage` panel with Left/Right range switching and refresh.
64
+ 7. Data access only through authenticated daemon `metrics.query`; the extension never opens SQLite or reads provider credentials.
@@ -0,0 +1,113 @@
1
+ import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
2
+ import { matchesKey, truncateToWidth } from "@earendil-works/pi-tui";
3
+ import {
4
+ BENCHMARK_TUI_MAX_CANDIDATES,
5
+ BENCHMARK_TUI_MAX_PROVENANCE_PER_CANDIDATE,
6
+ MODEL_RANKING_DEFAULT_CONTEXT_WEIGHT,
7
+ MODEL_RANKING_DEFAULT_COST_WEIGHT,
8
+ MODEL_RANKING_DEFAULT_LATENCY_WEIGHT,
9
+ MODEL_RANKING_DEFAULT_QUALITY_WEIGHT,
10
+ MODEL_RANKING_DEFAULT_RELIABILITY_WEIGHT,
11
+ type ModelCandidate,
12
+ type ModelRankingResult,
13
+ type ModelTaskDomain,
14
+ type ModelTaskType,
15
+ type RankedModel,
16
+ type UtilityComponentName,
17
+ } from "@danypops/jittor";
18
+ import { sessionSecretField } from "./session-identity.ts";
19
+
20
+ export interface BenchmarkPanelClient {
21
+ call(operation: string, input: unknown): Promise<any>;
22
+ }
23
+
24
+ interface BenchmarkTheme {
25
+ fg(color: string, text: string): string;
26
+ bold(text: string): string;
27
+ }
28
+
29
+ type BenchmarkPanelAction = "refresh" | "close";
30
+
31
+ const COMPONENT_LABELS: Record<UtilityComponentName, string> = { quality: "Q", cost: "$", latency: "L", context: "C", reliability: "R" };
32
+
33
+ function componentText(item: RankedModel): string {
34
+ return item.components.map((component) => `${COMPONENT_LABELS[component.name]} ${component.score === null ? "?" : component.score.toFixed(3)}`).join(" · ");
35
+ }
36
+
37
+ function candidateLines(item: RankedModel, index: number, currentIdentity: string): string[] {
38
+ const current = item.identity.startsWith(`${currentIdentity}:`);
39
+ const localSamples = item.components.find((component) => component.name === "reliability")?.evidenceCount ?? 0;
40
+ const provenance = item.provenance.slice(0, BENCHMARK_TUI_MAX_PROVENANCE_PER_CANDIDATE).map((source) => `${source.sourceId}@${source.revision} ${source.freshness}`).join(" · ");
41
+ return [
42
+ ` ${index + 1}. ${item.identity}${index === 0 ? " recommended" : ""}${current ? " current" : ""}`,
43
+ ` utility ${item.utility === null ? "?" : item.utility.toFixed(3)} · confidence ${(item.confidence * 100).toFixed(0)}% · ${componentText(item)}`,
44
+ ` local n=${localSamples}${provenance ? ` · ${provenance}` : " · no external provenance"}`,
45
+ ];
46
+ }
47
+
48
+ export function renderBenchmarkView(result: ModelRankingResult, currentIdentity: string, width: number, theme: BenchmarkTheme): string[] {
49
+ const safeWidth = Math.max(1, width);
50
+ const shown = result.ranked.slice(0, BENCHMARK_TUI_MAX_CANDIDATES);
51
+ const currentIndex = result.ranked.findIndex((item) => item.identity.startsWith(`${currentIdentity}:`));
52
+ const recommended = result.ranked[0];
53
+ const reason = recommended && currentIndex > 0
54
+ ? `Recommendation differs from current: ${recommended.identity} ranks #1; current ranks #${currentIndex + 1}.`
55
+ : recommended && currentIndex === 0 ? "Current model is the top recommendation." : "Current model is outside the ranked candidates.";
56
+ const lines = [
57
+ theme.fg("borderMuted", "─".repeat(safeWidth)),
58
+ theme.bold("Jittor Benchmark Recommendations"),
59
+ result.scopeAuthority === "exact-session" ? "Scope: exact session" : "Scope: available models · ADVISORY (exact session scope unavailable)",
60
+ `Domain: ${result.domain} · Type: ${result.type} · evidence ${result.completeness}`,
61
+ reason,
62
+ ...shown.flatMap((item, index) => candidateLines(item, index, currentIdentity)),
63
+ ...(result.ranked.length > shown.length ? [` … ${result.ranked.length - shown.length} more candidates omitted`] : []),
64
+ ...(result.scopeWarning ? [result.scopeWarning] : []),
65
+ theme.fg("dim", "r refresh · Esc close"),
66
+ theme.fg("borderMuted", "─".repeat(safeWidth)),
67
+ ];
68
+ return lines.map((line) => truncateToWidth(line, safeWidth, "…"));
69
+ }
70
+
71
+ export async function showBenchmarkPanel(
72
+ ctx: ExtensionCommandContext,
73
+ client: BenchmarkPanelClient,
74
+ candidates: ModelCandidate[],
75
+ currentIdentity: string,
76
+ domain: ModelTaskDomain,
77
+ type: ModelTaskType,
78
+ ): Promise<void> {
79
+ for (;;) {
80
+ const session_id = ctx.sessionManager.getSessionId();
81
+ const result = await client.call("models.rank", {
82
+ candidates,
83
+ session_id,
84
+ ...sessionSecretField(session_id),
85
+ scopeAuthority: "available-models",
86
+ domain,
87
+ type,
88
+ budgetPressure: 0,
89
+ weights: {
90
+ quality: MODEL_RANKING_DEFAULT_QUALITY_WEIGHT,
91
+ cost: MODEL_RANKING_DEFAULT_COST_WEIGHT,
92
+ latency: MODEL_RANKING_DEFAULT_LATENCY_WEIGHT,
93
+ context: MODEL_RANKING_DEFAULT_CONTEXT_WEIGHT,
94
+ reliability: MODEL_RANKING_DEFAULT_RELIABILITY_WEIGHT,
95
+ },
96
+ sourceIds: ["openrouter-models", "lmarena-hf", "artificial-analysis-direct", "openrouter-design-arena"],
97
+ }) as ModelRankingResult;
98
+ if (ctx.mode !== "tui") {
99
+ ctx.ui.notify(renderBenchmarkView(result, currentIdentity, 100, { fg: (_color, text) => text, bold: (text) => text }).join("\n"), "info");
100
+ return;
101
+ }
102
+ const action = await ctx.ui.custom<BenchmarkPanelAction>((_tui, theme, _keybindings, done) => ({
103
+ invalidate() {},
104
+ render(width: number): string[] { return renderBenchmarkView(result, currentIdentity, width, theme); },
105
+ handleInput(data: string): void {
106
+ if (matchesKey(data, "escape") || matchesKey(data, "ctrl+c")) done("close");
107
+ else if (data === "r") done("refresh");
108
+ },
109
+ }));
110
+ if (!action || action === "close") return;
111
+ await client.call("benchmark.refresh", { force: true });
112
+ }
113
+ }
@@ -0,0 +1,130 @@
1
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+ import {
3
+ CODEX_RECOVERY_ATTEMPT_WINDOW_MS,
4
+ CODEX_RECOVERY_BASE_DELAY_MS,
5
+ CODEX_RECOVERY_JITTER_RATIO,
6
+ CODEX_RECOVERY_MAX_ATTEMPTS,
7
+ CODEX_RECOVERY_MAX_DELAY_MS,
8
+ MILLISECONDS_PER_MINUTE,
9
+ MILLISECONDS_PER_SECOND,
10
+ CodexRecoveryPolicy,
11
+ classifyCodexFailure,
12
+ type CodexFailureKind,
13
+ type CodexFailureMetadata,
14
+ } from "@danypops/jittor";
15
+ import type { CodexRecoveryControl } from "../settings.ts";
16
+ import { headerValue } from "./http-headers.ts";
17
+
18
+ export interface CodexRecoveryRuntime {
19
+ now(): number;
20
+ random(): number;
21
+ setTimeout(callback: () => void | Promise<void>, delayMs: number): unknown;
22
+ clearTimeout(handle: unknown): void;
23
+ }
24
+
25
+ export const SYSTEM_RECOVERY_RUNTIME: CodexRecoveryRuntime = {
26
+ now: Date.now,
27
+ random: Math.random,
28
+ setTimeout(callback, delayMs) { return setTimeout(() => { void callback(); }, delayMs); },
29
+ clearTimeout(handle) { clearTimeout(handle as ReturnType<typeof setTimeout>); },
30
+ };
31
+
32
+ /**
33
+ * Codex's settled-turn hidden-retry recovery, as its own capability: tracks the most recent
34
+ * Codex response (status/retry-after) across the current turn, classifies a finalized failure,
35
+ * and schedules at most one bounded, jittered follow-up once Pi's own turn has genuinely
36
+ * settled. Fully self-contained -- the only external dependencies are Pi's own message-send API,
37
+ * the persisted on/off control, and a runtime the tests can fake (timers, randomness, clock).
38
+ */
39
+ export class CodexRecoveryCapability {
40
+ private readonly policy: CodexRecoveryPolicy;
41
+ private lastResponse: CodexFailureMetadata = {};
42
+ private timer: unknown;
43
+ private cooldown: { until: number; attempt: number; failureKind: CodexFailureKind } | undefined;
44
+
45
+ constructor(
46
+ private readonly pi: ExtensionAPI,
47
+ private readonly control: CodexRecoveryControl,
48
+ private readonly runtime: CodexRecoveryRuntime,
49
+ ) {
50
+ this.policy = new CodexRecoveryPolicy({
51
+ baseDelayMs: CODEX_RECOVERY_BASE_DELAY_MS,
52
+ maxDelayMs: CODEX_RECOVERY_MAX_DELAY_MS,
53
+ maxAttempts: CODEX_RECOVERY_MAX_ATTEMPTS,
54
+ attemptWindowMs: CODEX_RECOVERY_ATTEMPT_WINDOW_MS,
55
+ jitterRatio: CODEX_RECOVERY_JITTER_RATIO,
56
+ }, runtime.random);
57
+ }
58
+
59
+ /** Clears the tracked response at the start of every new turn, before any Codex response for it has arrived. */
60
+ resetTurn(): void {
61
+ this.lastResponse = {};
62
+ }
63
+
64
+ notifyResponse(status: number, headers: Record<string, string>): void {
65
+ this.lastResponse = { status, ...(headerValue(headers, "retry-after") ? { retryAfter: headerValue(headers, "retry-after") } : {}) };
66
+ }
67
+
68
+ notifyMessageEnd(stopReason: string, errorMessage: string | undefined): void {
69
+ if (stopReason === "error") {
70
+ const failure = classifyCodexFailure(errorMessage, this.lastResponse);
71
+ if (this.control.isCodexRecoveryEnabled() && failure.transient) this.policy.observeFailure(failure, this.runtime.now());
72
+ else this.cancel(true);
73
+ } else if (stopReason !== "aborted") {
74
+ this.cancel(true);
75
+ }
76
+ this.lastResponse = {};
77
+ }
78
+
79
+ cancel(resetPolicy: boolean): void {
80
+ if (this.timer !== undefined) this.runtime.clearTimeout(this.timer);
81
+ this.timer = undefined;
82
+ this.cooldown = undefined;
83
+ if (resetPolicy) this.policy.cancel();
84
+ }
85
+
86
+ statusText(): string {
87
+ const now = this.runtime.now();
88
+ const state = this.policy.state(now);
89
+ const enabled = this.control.isCodexRecoveryEnabled();
90
+ const attempt = this.cooldown?.attempt ?? (state.pending ? state.attempts + 1 : state.attempts);
91
+ const phase = this.cooldown
92
+ ? `cooldown ${Math.ceil(Math.max(0, this.cooldown.until - now) / MILLISECONDS_PER_SECOND)}s`
93
+ : state.pending ? "pending"
94
+ : state.attempts >= CODEX_RECOVERY_MAX_ATTEMPTS ? "exhausted"
95
+ : state.attempts > 0 ? "waiting" : "idle";
96
+ const failureKind = this.cooldown?.failureKind ?? state.lastFailureKind;
97
+ return [
98
+ `Codex recovery: ${enabled ? "on" : "off"}`,
99
+ phase,
100
+ `attempt ${attempt}/${CODEX_RECOVERY_MAX_ATTEMPTS}`,
101
+ `window ${CODEX_RECOVERY_ATTEMPT_WINDOW_MS / MILLISECONDS_PER_MINUTE}m`,
102
+ ...(failureKind ? [failureKind] : []),
103
+ ].join(" · ");
104
+ }
105
+
106
+ scheduleIfIdle(ctx: ExtensionContext): void {
107
+ if (!this.control.isCodexRecoveryEnabled() || this.timer !== undefined || !ctx.isIdle() || ctx.hasPendingMessages()) return;
108
+ const plan = this.policy.plan(this.runtime.now());
109
+ if (plan.action === "exhausted") {
110
+ this.policy.abandonFailure();
111
+ if (ctx.hasUI) ctx.ui.notify(`Jittor Codex recovery stopped: ${plan.reason}.`, "warning");
112
+ return;
113
+ }
114
+ if (plan.action !== "schedule") return;
115
+ this.cooldown = { until: this.runtime.now() + plan.delayMs, attempt: plan.attempt, failureKind: plan.failureKind };
116
+ this.timer = this.runtime.setTimeout(async () => {
117
+ this.timer = undefined;
118
+ this.cooldown = undefined;
119
+ if (!ctx.isIdle() || ctx.hasPendingMessages()) return;
120
+ const attempt = this.policy.recordAttempt(this.runtime.now());
121
+ if (!attempt) return;
122
+ this.pi.sendMessage({
123
+ customType: "jittor-codex-recovery",
124
+ content: `Retry the previous Codex request after a transient ${attempt.failureKind} failure. Automatic recovery attempt ${attempt.attempt} of ${CODEX_RECOVERY_MAX_ATTEMPTS}.`,
125
+ display: false,
126
+ details: { attempt: attempt.attempt, failureKind: attempt.failureKind },
127
+ }, { triggerTurn: true, deliverAs: "followUp" });
128
+ }, plan.delayMs);
129
+ }
130
+ }
@@ -0,0 +1,5 @@
1
+ /** Case-insensitive header lookup -- Pi's provider-response event headers are a plain Record, not a Headers instance. */
2
+ export function headerValue(headers: Record<string, string>, name: string): string | undefined {
3
+ const expected = name.toLowerCase();
4
+ return Object.entries(headers).find(([key]) => key.toLowerCase() === expected)?.[1];
5
+ }
@@ -0,0 +1,103 @@
1
+ import { classifyTaskFromTools, modelRunMetrics, type MetricObservation, type ModelRunObservation } from "@danypops/jittor";
2
+
3
+ export interface ActiveLocalModelRun {
4
+ runId: string;
5
+ startedAt: number;
6
+ firstTokenAt: number | null;
7
+ providerResponses: number;
8
+ toolNames: string[];
9
+ toolCalls: number;
10
+ toolFailures: number;
11
+ }
12
+
13
+ /**
14
+ * Content-free local model observations derived only from Pi's public lifecycle: TTFT, wall
15
+ * latency, tool-loop counts and failures, and a bounded tool-name list used only to derive
16
+ * domain/type classification -- never prompts, responses, tool arguments/results, or project
17
+ * paths. Owns the one in-flight run plus the most recently completed one (for `/jittor outcome`).
18
+ */
19
+ export class LocalRunTelemetry {
20
+ private active: ActiveLocalModelRun | undefined;
21
+ private lastCompleted: ModelRunObservation | undefined;
22
+ private sequence = 0;
23
+
24
+ beginTurn(timestamp: number): void {
25
+ this.active = {
26
+ runId: `local-${timestamp}-${++this.sequence}`,
27
+ startedAt: timestamp,
28
+ firstTokenAt: null,
29
+ providerResponses: 0,
30
+ toolNames: [],
31
+ toolCalls: 0,
32
+ toolFailures: 0,
33
+ };
34
+ }
35
+
36
+ discardTurn(): void {
37
+ this.active = undefined;
38
+ }
39
+
40
+ onMessageUpdate(assistantMessageEventType: string): void {
41
+ if (!this.active || this.active.firstTokenAt !== null) return;
42
+ if (["text_delta", "thinking_delta", "toolcall_delta"].includes(assistantMessageEventType)) this.active.firstTokenAt = Date.now();
43
+ }
44
+
45
+ onToolExecutionEnd(toolName: string, isError: boolean): void {
46
+ if (!this.active) return;
47
+ this.active.toolCalls += 1;
48
+ if (isError) this.active.toolFailures += 1;
49
+ if (this.active.toolNames.length < 100) this.active.toolNames.push(toolName);
50
+ }
51
+
52
+ onProviderResponse(): void {
53
+ if (this.active) this.active.providerResponses += 1;
54
+ }
55
+
56
+ /** Finalizes the active run against a completed assistant turn_end message, returning metrics to record (empty if the message shape doesn't match a completed assistant turn). */
57
+ completeTurn(message: unknown, thinkingLevel: string): MetricObservation[] {
58
+ const active = this.active;
59
+ this.active = undefined;
60
+ if (!active || typeof message !== "object" || message === null || Array.isArray(message)) return [];
61
+ const value = message as Record<string, unknown>;
62
+ if (value["role"] !== "assistant" || typeof value["provider"] !== "string" || typeof value["model"] !== "string") return [];
63
+ const usage = typeof value["usage"] === "object" && value["usage"] !== null ? value["usage"] as Record<string, unknown> : {};
64
+ const amount = (name: string): number => typeof usage[name] === "number" && Number.isFinite(usage[name]) ? usage[name] as number : 0;
65
+ const cost = typeof usage["cost"] === "object" && usage["cost"] !== null && typeof (usage["cost"] as Record<string, unknown>)["total"] === "number"
66
+ ? (usage["cost"] as Record<string, number>)["total"] ?? 0 : 0;
67
+ const stopReason = ["stop", "length", "toolUse", "error", "aborted"].includes(String(value["stopReason"]))
68
+ ? value["stopReason"] as ModelRunObservation["stopReason"] : "unknown";
69
+ const completedAt = Math.max(Date.now(), active.firstTokenAt ?? active.startedAt, active.startedAt);
70
+ this.lastCompleted = {
71
+ runId: active.runId,
72
+ provider: value["provider"],
73
+ model: value["model"],
74
+ thinking: thinkingLevel,
75
+ ...classifyTaskFromTools(active.toolNames),
76
+ startedAt: active.startedAt,
77
+ firstTokenAt: active.firstTokenAt,
78
+ completedAt,
79
+ inputTokens: amount("input"),
80
+ outputTokens: amount("output"),
81
+ cacheReadTokens: amount("cacheRead"),
82
+ cacheWriteTokens: amount("cacheWrite"),
83
+ costUsd: Number.isFinite(cost) && cost >= 0 ? cost : 0,
84
+ providerResponses: Math.max(1, active.providerResponses),
85
+ toolCalls: active.toolCalls,
86
+ toolFailures: active.toolFailures,
87
+ stopReason,
88
+ explicitOutcome: "unknown",
89
+ };
90
+ return modelRunMetrics(this.lastCompleted);
91
+ }
92
+
93
+ /** Builds the single outcome-accepted/outcome-accepted=0 metric for the most recently completed run, or null if none exists yet. */
94
+ explicitOutcomeMetric(explicitOutcome: "accepted" | "rejected"): MetricObservation | null {
95
+ if (!this.lastCompleted) return null;
96
+ return modelRunMetrics({ ...this.lastCompleted, explicitOutcome }).find((metric) => metric.metric === "outcome-accepted") ?? null;
97
+ }
98
+
99
+ reset(): void {
100
+ this.active = undefined;
101
+ this.lastCompleted = undefined;
102
+ }
103
+ }
@@ -0,0 +1,101 @@
1
+ import {
2
+ classifyGoogleVertexFailure,
3
+ googleVertexFailureMetrics,
4
+ hasAnthropicRateLimitHeaders,
5
+ parseAnthropicRateLimitHeaders,
6
+ parseCodexRateLimitHeaders,
7
+ type GoogleVertexFailureMetadata,
8
+ type MetricObservation,
9
+ } from "@danypops/jittor";
10
+ import { headerValue } from "./http-headers.ts";
11
+
12
+ export interface ProviderTelemetryClient {
13
+ call(operation: string, input: unknown): Promise<any>;
14
+ }
15
+
16
+ async function recordMetrics(client: ProviderTelemetryClient, metrics: MetricObservation[]): Promise<void> {
17
+ if (metrics.length === 0) return;
18
+ await client.call("metrics.record_batch", { observations: metrics });
19
+ }
20
+
21
+ /**
22
+ * Bounded telemetry derived directly from provider HTTP responses and finalized messages, for
23
+ * every provider except Codex (whose response tracking is settled-turn-recovery's own concern --
24
+ * see codex-recovery.ts). Anthropic and anthropic-vertex official rate-limit headers become
25
+ * budget metrics; Google Vertex and anthropic-vertex failures become bounded, content-free
26
+ * failure-count metrics classified from GCP's own `google.rpc.Status` shape. anthropic-vertex is
27
+ * tracked distinctly from both google-vertex (different code path, different quota pool) and
28
+ * direct anthropic (different transport), never conflated with either.
29
+ */
30
+ export class ProviderResponseTelemetry {
31
+ private lastGoogleVertexResponse: GoogleVertexFailureMetadata = {};
32
+ private lastAnthropicVertexResponse: GoogleVertexFailureMetadata = {};
33
+
34
+ resetTurn(): void {
35
+ this.lastGoogleVertexResponse = {};
36
+ this.lastAnthropicVertexResponse = {};
37
+ }
38
+
39
+ async handleProviderResponse(
40
+ client: ProviderTelemetryClient,
41
+ provider: string | undefined,
42
+ status: number,
43
+ headers: Record<string, string>,
44
+ notifySchemaDrift: (message: string) => void,
45
+ ): Promise<void> {
46
+ if (provider === "anthropic") {
47
+ const parsedHeaders = new Headers(headers);
48
+ if (hasAnthropicRateLimitHeaders(parsedHeaders)) {
49
+ try {
50
+ await recordMetrics(client, parseAnthropicRateLimitHeaders(parsedHeaders, Date.now()).metrics);
51
+ } catch {
52
+ notifySchemaDrift("Anthropic telemetry schema drift");
53
+ }
54
+ }
55
+ }
56
+ if (provider === "anthropic-vertex") {
57
+ // Best-effort only: unverified whether this passthrough ever forwards Anthropic's own
58
+ // rate-limit headers. If it doesn't, hasAnthropicRateLimitHeaders is false and nothing is
59
+ // recorded -- the same honest default as every other unconfirmed signal in this module.
60
+ const parsedHeaders = new Headers(headers);
61
+ if (hasAnthropicRateLimitHeaders(parsedHeaders)) {
62
+ try {
63
+ await recordMetrics(client, parseAnthropicRateLimitHeaders(parsedHeaders, Date.now(), "anthropic-vertex").metrics);
64
+ } catch {
65
+ notifySchemaDrift("Anthropic-on-Vertex telemetry schema drift");
66
+ }
67
+ }
68
+ // Well-evidenced regardless of headers: GCP's own quota system fronts this transport, so the
69
+ // same failure classification as google-vertex applies below.
70
+ this.lastAnthropicVertexResponse = { status, ...(headerValue(headers, "retry-after") ? { retryAfter: headerValue(headers, "retry-after") } : {}) };
71
+ }
72
+ if (provider === "google-vertex") {
73
+ this.lastGoogleVertexResponse = { status, ...(headerValue(headers, "retry-after") ? { retryAfter: headerValue(headers, "retry-after") } : {}) };
74
+ }
75
+ if (Object.keys(headers).some((name) => name.toLowerCase().startsWith("x-codex-"))) {
76
+ try {
77
+ const updates = parseCodexRateLimitHeaders(new Headers(headers), Date.now());
78
+ await recordMetrics(client, updates.flatMap((update) => update.metrics));
79
+ } catch {
80
+ notifySchemaDrift("Codex telemetry schema drift");
81
+ }
82
+ }
83
+ }
84
+
85
+ async handleMessageEnd(client: ProviderTelemetryClient, provider: string | undefined, stopReason: string | undefined, errorMessage: string | undefined): Promise<void> {
86
+ if (provider === "google-vertex") {
87
+ if (stopReason === "error") {
88
+ const failure = classifyGoogleVertexFailure(errorMessage, this.lastGoogleVertexResponse);
89
+ await recordMetrics(client, googleVertexFailureMetrics(failure, Date.now())).catch(() => undefined);
90
+ }
91
+ this.lastGoogleVertexResponse = {};
92
+ }
93
+ if (provider === "anthropic-vertex") {
94
+ if (stopReason === "error") {
95
+ const failure = classifyGoogleVertexFailure(errorMessage, this.lastAnthropicVertexResponse);
96
+ await recordMetrics(client, googleVertexFailureMetrics(failure, Date.now(), "anthropic-vertex")).catch(() => undefined);
97
+ }
98
+ this.lastAnthropicVertexResponse = {};
99
+ }
100
+ }
101
+ }