pi-workbuddy 0.2.2 → 0.2.4

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 CHANGED
@@ -6,15 +6,15 @@ A **WorkBuddy AI** provider for [Pi](https://pi.dev), with browser-based account
6
6
 
7
7
  ## Getting started
8
8
 
9
- Requires Node.js ≥22.19 and Pi with native provider support. Tested with Pi CLI **1.3.0**.
9
+ Requires Node.js ≥22.19 and Pi with native provider support. Tested with Pi CLI **1.3.0** and Pendant 0.41.0's bundled Pi SDK **1.2.0**.
10
10
 
11
- Install the npm package after the `pi-workbuddy` release is published:
11
+ Install the published npm package:
12
12
 
13
13
  ```sh
14
14
  pi install npm:pi-workbuddy
15
15
  ```
16
16
 
17
- Until the npm release is available, or to install directly from GitHub:
17
+ Or install directly from GitHub:
18
18
 
19
19
  ```sh
20
20
  pi install git:github.com/lutfi238/pi-workbuddy
@@ -48,9 +48,10 @@ Use `/reload` after updating a loaded extension. To sign out, run `/logout` and
48
48
  | `/logout` → WorkBuddy AI | Remove Pi's local WorkBuddy credentials |
49
49
  | `/workbuddy status` or `/workbuddy doctor` | Show local configuration, catalog source, active model, and the last sanitized catalog error; no network requests |
50
50
  | `/workbuddy refresh` | Fetch the authenticated catalog, refreshing the token if needed |
51
+ | `/workbuddy-tooltip on/off/refresh/status` | Experimental opt-in credit relay; requires the separately reviewed Pendant host adapter |
51
52
  | `/model` | Refresh model catalogs in the background and select a model |
52
53
 
53
- Before discovery, the provider includes conservative fallback metadata for `default-model`. Models are not listed as available until authentication is configured. If discovery fails, the last known catalog is retained **in memory**; it is not persisted across restarts. Pi may invoke discovery during its refresh lifecycle, but the extension factory does not make network requests when loaded.
54
+ Before discovery or cache restoration, the provider includes fallback metadata for `default-model`. Models are not available until authentication is configured. Successful catalogs are persisted through **Pi's model store** as projected public metadata, scoped by a hash of the account origin/user/domain/tenant (never access or refresh tokens). Cached models are revalidated on restoration, including removal of DeepSeek `-sg` variants. A failed refresh retains the last known catalog. The extension factory itself makes no network requests.
54
55
 
55
56
  ### Automatic model discovery
56
57
 
@@ -58,7 +59,19 @@ There is no hardcoded allowlist of WorkBuddy model IDs. The provider fetches `/v
58
59
 
59
60
  A newly advertised model can therefore appear without changing the extension or adding its ID manually. The model must pass the existing compatibility and security filters: disabled models, models explicitly lacking tool support, and custom endpoints/protocols/API keys are excluded. Starting with 0.2.2, DeepSeek variants ending in `-sg` are intentionally excluded, including after catalog refresh; the regular DeepSeek models remain available. Catalog visibility does not guarantee that your account can run every listed model.
60
61
 
61
- This is refresh-based discovery, not a realtime push subscription or continuous idle polling. Reopen `/model` to check for additions during a long session. Offline mode does not fetch new catalogs. Extension updates may still be necessary if WorkBuddy changes its API, authentication, metadata schema, or wire protocol.
62
+ A `session_start` hook refreshes only WorkBuddy using the registry's default network policy and honors `PI_OFFLINE` (also set by Pi CLI `--offline`). This covers embedded hosts that re-register a provider but do not refetch an already-seen provider ID. Offline provider refreshes can restore a matching cached catalog without network access.
63
+
64
+ This is refresh-based discovery, not a realtime push subscription or continuous idle polling. Reopen `/model` to check for additions during a long session. Extension updates may still be necessary if WorkBuddy changes its API, authentication, metadata schema, or wire protocol.
65
+
66
+ ### Pendant / VS Code stays on WorkBuddy Default (fallback)
67
+
68
+ Version **0.2.3** restores cached catalogs after provider re-registration and refreshes WorkBuddy at session startup. It also replaces an active bootstrap fallback with fresh metadata for the same ID. For resumed sessions, it reads only the latest model-change record; a removed DeepSeek `-sg` selection is restored to its exact non-SG counterpart if available. It does not change another provider, overwrite a newer observed selection, edit global defaults, or send a chat request. `/workbuddy refresh` also repairs stale active-model metadata.
69
+
70
+ After the 0.2.3 release is published, update the package used by the host and reload the session. Restarting an old installed copy will not pick up repository changes. `/workbuddy doctor` reports the loaded extension version and `catalog=fallback`, `cache`, or `remote`; share only this sanitized diagnostic, never credentials. A genuine network/authentication error with no usable cache still requires resolving the error; no fabricated catalog is substituted.
71
+
72
+ ### Package published, but absent from gallery search
73
+
74
+ The [direct package page](https://pi.dev/packages/pi-workbuddy) can work before the searchable gallery includes the package. npm publication and Pi gallery discovery are separate. Confirm the public npm version and the `pi-package` keyword before assuming a publishing failure. See the [reported npm-search discovery issue](https://github.com/earendil-works/pi/issues/7885). Do not repeatedly publish identical releases just to try to trigger gallery indexing.
62
75
 
63
76
  ### Login succeeds, but discovery fails or Codex remains selected
64
77
 
@@ -105,6 +118,40 @@ Smaller explicit output requests remain supported; sampling and payload hooks ca
105
118
 
106
119
  See the current [capability policy](research/capability-policy.md), [vendor references](research/vendor-capability-comparison.md), and historical [context-limit audit](research/context-limit-audit.md). No global Pi compaction settings are changed by the extension.
107
120
 
121
+ ### Read-only credit-reader backend
122
+
123
+ Version **0.2.4** adds `createCreditsReader()` in `src/native-credits.js` for the verified personal-account endpoint `POST /billing/meter/get-user-resource-summary` with body `{}`. The reader itself does not run automatically. An opt-in experimental Pendant relay/adapter is available separately below; no installed host is modified automatically.
124
+
125
+ The reader returns `creditsTotal`, `creditsRemaining`, `creditsUsed`, `packageCount`, and `fetchedAt`; optional `planCode`, `planName`, and `isPaidUser` are included only when supplied with the expected types. Invalid/missing numeric fields stay `null`, not zero. The default client also returns immutable `packageDetails` (`state`, `count`, `items`) from the verified paid/free package read routes. Each resource has a reviewed kind, precise total/remaining/used credits, and its own supplied expiry. Different grants of the same product stay separate. Missing dates stay unknown; offset-less dates retain an explicit server-time label. `packageCount` is the legacy summary-group count, not necessarily the number of individual grants. No subscription renewal date, USD value, or allowance is inferred. The account balance is not a separate allowance for each model.
126
+
127
+ ```js
128
+ import { createCreditsReader } from "./src/native-credits.js";
129
+
130
+ const reader = createCreditsReader();
131
+ // credential is the current WorkBuddy OAuth credential supplied by the caller's Pi auth flow.
132
+ const result = await reader.read(credential, { signal });
133
+ // result.source: "remote" or "cache"; result.stale explicitly marks old/failed-refresh data.
134
+ await reader.read(credential, { signal, force: true });
135
+ await reader.read(credential, { allowNetwork: false });
136
+ reader.clear(); // Required when a future UI/controller logs out or disposes this reader.
137
+ ```
138
+
139
+ The default cache TTL is **60 seconds**, with at most **8 account scopes** and **8 concurrent underlying reads**. Cache keys use hashed account identity/domain, never tokens. Token rotation retains the same account cache. Cache entries are memory-only and are never added to the persistent model catalog. Callers receive independent snapshots; repeated concurrent reads share one bounded read job. A fresh job reads the summary and relevant paid/free package pages, at most ten pages of 200 records per route, under a shared detail deadline. Detail failures are explicitly partial/unavailable and do not make old details look fresh. Cancelling one caller leaves other subscribers running; cancelling the last caller or clearing the reader aborts the request and prevents late cache writes.
140
+
141
+ Transient failures may return last-good data with `stale: true` and a sanitized `error`. Authentication/business denials discard the balance rather than returning stale data. Offline reads and `PI_OFFLINE` never trigger a fetch; without a matching cached value they report unavailable. `force` bypasses freshness, not offline policy. `ttlMs` and `maxEntries` can be configured when constructing the reader.
142
+
143
+ This layer never performs login or token refresh itself: its caller must supply current Pi-owned credentials. It never claims rewards, buys credits, changes subscriptions, or sends chat. The enterprise usage route has not been verified and is deliberately not substituted with a potentially misleading personal balance; enterprise-scoped calls report unsupported for now. See [endpoint verification](research/credits-endpoint-verification.md) and [tooltip findings](research/model-tooltip-findings.md) for integration boundaries.
144
+
145
+ ### Optional Pendant credit tooltip (experimental)
146
+
147
+ The exact native model-hover integration requires a host adapter in Pendant **0.41.0**, not just provider metadata. See [the preparation/review guide](integrations/pendant/README.md). The included `prepare.mjs` validates the inspected host version and hash, checks JavaScript syntax, and produces separate modified renderer/broker copies and a manifest. It **never applies** that copy to your installed VS Code extension. Generated vendor bundles are not shipped in npm or committed to Git.
148
+
149
+ After an explicitly approved adapter installation, `/workbuddy-tooltip on` enables a session-scoped credit relay over Pi's normal status API. The adapter consumes that reserved payload inside the existing native model tooltip instead of displaying an extra status badge. `/workbuddy-tooltip refresh`, `status`, and `off` manage the relay. It is disabled by default; normal CLI behavior is unchanged. Do not enable it in an unpatched host. Persistent opt-in after installing the adapter uses `WORKBUDDY_PENDANT_TOOLTIP=1` in the host process environment.
150
+
151
+ Adapter v2 also fixes Pendant's default `extensionStatus.surface=off` transport gate: it forwards only the reserved WorkBuddy payload while leaving other status visibility and user settings unchanged. It hides the encoded transport from VS Code's separate status bar. Install both prepared host bundles and use **Developer: Reload Window**; Pi `/reload` alone does not reload the host broker. `/workbuddy-tooltip status` reports whether the relay payload is waiting, loading, ready, or unavailable without querying credits.
152
+
153
+ Rows show a remaining-credit summary, then separate base/bonus/top-up grants with `remaining / total left` and each grant's expiry, plus update time, optional plan name, and stale/partial markers. At most four grants are previewed (base/daily first, then earliest known expiry); omitted loaded grants have an explicit count. Without usable details, aggregate used/total and credit-group count remain as a labeled fallback. Fresh remaining credits, including each grant, reuse the native OpenAI color thresholds: green at ≥50%, yellow at 15–<50%, red below 15%. Unknown or stale balances are not painted as current healthy quota; other model/account rows stay neutral. No ChatGPT five-hour/weekly allowances, renewal dates, percentages, or USD values are fabricated. Read events use the private reader's cache and cancellation; hovering does not send a chat or start repeated network requests. Tokens and account identity never cross into the webview. Isolated tests cover the patched renderer and the actual shipped SDK transport. The operator also confirmed the native hover, colors, individual grants, and expiry dates visually in the backed-up local Pendant 0.41.0 preview. That confirmation does not establish compatibility with other host versions.
154
+
108
155
  ### Native configuration
109
156
 
110
157
  ```powershell
@@ -124,7 +171,7 @@ The `.env.example` file is a reference only: Pi and this extension do **not** au
124
171
 
125
172
  - Pi stores credentials in its agent profile (`auth.json`), not in the project. Protect that file; never commit or share it.
126
173
  - The extension does not read the desktop application's credentials, databases, cookies, or sessions.
127
- - Authentication, discovery, and chat requests target only the HTTPS origin `www.workbuddy.ai`; HTTP redirects are rejected. Recognized production WorkBuddy/CodeBuddy account domains are used only as header values, not destination URLs.
174
+ - Authentication, discovery, credit reads, and chat requests target only the HTTPS origin `www.workbuddy.ai`; HTTP redirects are rejected. Recognized production WorkBuddy/CodeBuddy account domains are used only as header values, not destination URLs.
128
175
  - Browser login URLs must use recognized production WorkBuddy/CodeBuddy origins. Login URLs can contain sensitive state; do not share them or screenshots containing them.
129
176
  - Remote configuration cannot import prompts, tools, commands, credentials, or model URLs into this extension. Discovery does not send a list of repositories.
130
177
  - `X-Private-Data: true` is a conservative privacy default, **not a guarantee** of the server's retention policy.
@@ -182,13 +229,23 @@ npm test
182
229
  node scripts/smoke-cli.mjs "C:/path/to/pi-coding-agent/dist/bundle/cli.js"
183
230
  ```
184
231
 
232
+ To additionally test against an installed Pendant bundle (no live credentials or inference):
233
+
234
+ ```powershell
235
+ $env:PI_SDK_TEST_PATH = "C:/path/to/pendant/dist/pi-sdk.cjs"
236
+ npm test
237
+ ```
238
+
239
+ Pendant integration tests instantiate its actual `ModelRuntime`, resource services, session manager, and `session_start` bindings. They cover provider re-registration, offline cache restoration in a fresh runtime, startup fallback repair, and migration of a saved DeepSeek SG selection. Cache tests cover account isolation, secret/endpoint stripping, malformed entries, failed refreshes, and generation rejection.
240
+
185
241
  Host tests cover coordinated login, storage and refresh, tool-call/result round trips, reasoning, Unicode, usage tails, instrumentation, empty and truncated streams, errors, and cancellation. The CLI smoke test uses an offline temporary profile with synthetic credentials and removes it afterward. **None of these automated tests sends a prompt to the live WorkBuddy service.**
186
242
 
187
243
  Local investigation notes:
188
244
  [authentication](research/workbuddy-auth-findings.md) ·
189
245
  [chat protocol](research/workbuddy-protocol-findings.md) ·
190
246
  [error 12403](research/12403-findings.md) ·
191
- [context-limit audit](research/context-limit-audit.md).
247
+ [context-limit audit](research/context-limit-audit.md) ·
248
+ [Pendant recovery and gallery findings](research/pendant-recovery-findings.md).
192
249
 
193
250
  These notes are not included in the npm tarball. Raw third-party snapshots and generated evidence remain local and are excluded from Git; they are preserved verbatim rather than translated so their provenance and hashes remain valid. The authored documentation, code comments, user-facing messages, and test fixtures use English. Emoji in test fixtures intentionally exercise Unicode streaming.
194
251
 
@@ -1,6 +1,8 @@
1
1
  import { openAICompletionsApi } from "@earendil-works/pi-ai/compat";
2
2
  import bridge from "../src/bridge-extension.js";
3
3
  import { createWorkbuddyProvider, registerNativeCommands } from "../src/native-provider.js";
4
+ import { registerNativeLifecycle } from "../src/native-lifecycle.js";
5
+ import { registerPendantTooltip } from "../src/native-tooltip-controller.js";
4
6
 
5
7
  export default async function workbuddy(pi) {
6
8
  const mode = process.env.WORKBUDDY_MODE || "native";
@@ -9,7 +11,12 @@ export default async function workbuddy(pi) {
9
11
  if (process.env.WORKBUDDY_GZIP && !["0", "1"].includes(process.env.WORKBUDDY_GZIP)) {
10
12
  throw new Error("WORKBUDDY_GZIP must be 0 or 1.");
11
13
  }
14
+ if (process.env.WORKBUDDY_PENDANT_TOOLTIP && !["0", "1"].includes(process.env.WORKBUDDY_PENDANT_TOOLTIP)) {
15
+ throw new Error("WORKBUDDY_PENDANT_TOOLTIP must be 0 or 1.");
16
+ }
12
17
  const provider = createWorkbuddyProvider({ api: openAICompletionsApi(), gzip: process.env.WORKBUDDY_GZIP !== "0" });
13
18
  pi.registerProvider(provider);
14
19
  registerNativeCommands(pi, provider);
20
+ registerNativeLifecycle(pi, provider);
21
+ registerPendantTooltip(pi, { enabled: process.env.WORKBUDDY_PENDANT_TOOLTIP === "1" });
15
22
  }
@@ -0,0 +1,174 @@
1
+ # Optional Pendant model-tooltip adapter (experimental)
2
+
3
+ This integration prepares **WorkBuddy credit rows in Pendant's existing native
4
+ model-hover grid**, without disguising credits as OpenAI five-hour/weekly limits.
5
+ It is not an official Pendant API or an upstream-supported integration.
6
+
7
+ ## Important boundary
8
+
9
+ - The Pi extension alone cannot add arbitrary model-hover rows in Pendant 0.41.0.
10
+ - Adapter v2 prepares modified **copies** of the webview and host status-broker bundles. Preparation
11
+ never overwrites the installed host and does not imply that the visible UI has
12
+ changed. Installing that copy is a separate, explicitly approved step.
13
+ - It supports only `cdervis.vscode-pi` **0.41.0** with the exact inspected original
14
+ webview SHA-256:
15
+ `88c02dc6b1474e83fb5b8762ef1f26ae2d07abe04145b1b04f0ba519039bc38c`.
16
+ Its original `dist/extension.js` must also match
17
+ `8c449489dfba9f04fb8e96c2f09640b7ddf69900f6ca0e88ca1a4389b43714d6`.
18
+ - Other versions, changed hashes, duplicate patching, and an output path inside
19
+ the host installation are rejected. Updates require a new review, not disabling
20
+ the checks. Generated vendor bundles are excluded from Git and npm publication.
21
+
22
+ ## Architecture
23
+
24
+ 1. The opt-in WorkBuddy controller resolves credentials through Pi's existing
25
+ `modelRegistry.getApiKeyAndHeaders()` flow, then calls the private in-memory
26
+ credit reader. Pi may refresh expired OAuth credentials through its normal
27
+ auth lock; the controller does not read `auth.json` or manage tokens itself.
28
+ 2. Only a whitelisted credit snapshot and session ID cross Pi's standard
29
+ `setStatus` transport under the reserved key `workbuddy.credits.v1`.
30
+ Credentials, user IDs, emails, and raw errors are never serialized.
31
+ 3. The host broker forwards the reserved entry even when the ordinary extension-
32
+ status surface is off (Pendant's default). Other entries retain their existing
33
+ display policy. It does not enable a panel/status bar or change user settings.
34
+ The reserved entry is excluded from the separate VS Code status-bar badge.
35
+ 4. The webview consumes the reserved entry instead of showing encoded text in the
36
+ ordinary extension-status badge/panel.
37
+ 5. Its renderer appends rows only for an available WorkBuddy model in the exact
38
+ matching active session. Non-WorkBuddy tooltips keep their original rows.
39
+ Session mismatch, missing model/auth availability, duplicate entries, and
40
+ malformed/oversized payloads hide the credit rows rather than guessing.
41
+ 6. Status changes refresh an already-open tooltip. No global frontend balance
42
+ cache, new worker request kind, local HTTP server, or credential-bearing
43
+ webview request is introduced.
44
+
45
+ The controller is **disabled by default**. Normal CLI behavior remains unchanged.
46
+ It refreshes on session start, model selection, and completed agent runs only
47
+ while explicitly enabled and on a WorkBuddy model. The reader's 60-second cache
48
+ and request deduplication apply. There is no hover-triggered network request or
49
+ idle polling; old snapshots are explicitly marked `Cached / stale`. Use the
50
+ refresh command when a newer balance is needed without sending a chat.
51
+
52
+ Observed provider/session changes cancel outdated publication. Moving away from
53
+ WorkBuddy, disabling the relay, or shutting down clears its private reader. The
54
+ host hides balances for models no longer present in its available-model list.
55
+ Account changes must reach the normal Pi auth/session lifecycle or be followed
56
+ by an explicit refresh; this is not a separate account-monitoring service.
57
+
58
+ ## Prepare, without applying
59
+
60
+ From the repository (the output directory must not already exist):
61
+
62
+ ```powershell
63
+ node integrations/pendant/prepare.mjs `
64
+ "C:/Users/YOUR_USER/.vscode/extensions/cdervis.vscode-pi-0.41.0" `
65
+ "integrations/pendant/generated"
66
+ ```
67
+
68
+ The command validates the version/hash and generated JavaScript syntax, then
69
+ writes only:
70
+
71
+ - `generated/webview.js`: the optional prepared renderer copy.
72
+ - `generated/extension.js`: the optional prepared broker copy.
73
+ - `generated/manifest.json`: original/prepared hashes for both and `applied: false`.
74
+
75
+ If the output already exists, preserve/review it or choose a different new
76
+ output directory. Do not use preparation to overwrite an installed bundle.
77
+
78
+ The preparation utility is included in the npm package at
79
+ `integrations/pendant/prepare.mjs`; paths in the command above assume a repository
80
+ checkout. It requires only Node.js and the supplied installed host directory.
81
+
82
+ ## Before any installation
83
+
84
+ Review the manifest and diff. Prefer testing in a disposable VS Code profile.
85
+ Applying v2 requires explicit backups of the original `dist/webview.js` and
86
+ `dist/extension.js`, original-hash checks, and reversible replacements of both. This
87
+ utility deliberately has **no apply command**. No installed Pendant file has
88
+ been modified by implementing or preparing this integration.
89
+
90
+ A local modification is not guaranteed to survive extension updates and may
91
+ interact with host integrity/CSP behavior that isolated tests cannot prove.
92
+ Keep the backup and restore the original before upgrading or troubleshooting
93
+ unrelated host problems. Do not publish modified vendor bundles as this project.
94
+
95
+ ## After an approved adapter installation
96
+
97
+ Load the updated `pi-workbuddy` extension, then in the patched Pendant session:
98
+
99
+ ```text
100
+ /workbuddy-tooltip on
101
+ /workbuddy-tooltip refresh
102
+ /workbuddy-tooltip status
103
+ /workbuddy-tooltip off
104
+ ```
105
+
106
+ The command is a Pi extension command, not a chat prompt. It sends only a
107
+ bounded read-only credit requests when enabled (summary and relevant detail pages); it never invokes inference. Do not enable
108
+ it in an unmodified host: an ordinary status surface might display the encoded
109
+ transport rather than a native tooltip. It refuses the normal TUI mode.
110
+
111
+ For persistent opt-in **after installing the adapter**, launch the host process
112
+ with `WORKBUDDY_PENDANT_TOOLTIP=1`. The `.env.example` is documentation only; it is
113
+ not automatically loaded. If `pendant.extensionStatus.keys` is configured as an
114
+ allowlist, include `workbuddy.credits.v1`; the integration does not bypass that
115
+ user-controlled filter.
116
+
117
+ ## Displayed fields
118
+
119
+ The native model header, provider, context, reasoning and input rows remain.
120
+ Additional credit rows use only verified data:
121
+
122
+ - Plan name, if supplied by the credit summary (no guessed Free/Pro label).
123
+ - Credits remaining.
124
+ - Separate base/daily/bonus/top-up grants, each with `remaining / total left`.
125
+ - Each grant's supplied expiry; never substitute an ordinary renewal/reset date.
126
+ - A maximum of four preview grants (base/daily first, then earliest known expiry),
127
+ with an explicit count of further loaded grants. Partial detail reads are labeled.
128
+ - Aggregate used/total and summary-group count only when details are unavailable.
129
+ - Last successful update.
130
+ - Explicit cached/stale indicator when applicable.
131
+
132
+ `Credits remaining` and each grant's remaining balance reuse Pendant's native OpenAI semantic tones: green at
133
+ 50% or more remaining, yellow from 15% to below 50%, and red below 15% (including
134
+ an explicitly known zero). A positive balance with an unknown/inconsistent total
135
+ stays neutral; stale balances are not shown as current healthy/depleted quota.
136
+ The percentage only selects a tone, not a new displayed allowance. Other rows
137
+ stay neutral; package count is not a banked-reset count. Absolute expiry timestamps
138
+ render in the browser's local timezone. Offset-less dates remain unchanged under
139
+ `Expires (server time)`; unknown dates show `Unknown`, not `Never`. The stale marker and
140
+ unavailable state use the host's warning tone. No custom RGB/CSS palette is added.
141
+
142
+ Missing numbers show `Unknown`, not zero. Loading/unavailable replaces the
143
+ credit values. No expiry, renewal time, percentage, or USD conversion is invented.
144
+ The balance belongs to the account, not independently to each model. Only the
145
+ verified personal-account route is currently supported.
146
+
147
+ ## Validation and remaining verification
148
+
149
+ If the installed host is already adapted, keep its original backup as the test
150
+ fixture instead of weakening the original-hash check:
151
+
152
+ ```powershell
153
+ $env:PI_PENDANT_FIXTURE_PATH = "C:/path/to/backup/pendant-original"
154
+ # This fixture contains original package.json, dist/webview.js and dist/extension.js.
155
+ # PI_SDK_TEST_PATH still points to the real installed dist/pi-sdk.cjs.
156
+ npm test
157
+ ```
158
+
159
+ Tests cover payload redaction and bounds, account/session cancellation, Pi's
160
+ actual bundled SDK auth/status APIs with synthetic credentials, and the patched
161
+ native renderer methods extracted from the exact shipped bundle. Preparation
162
+ also checks that the original file is unchanged and the output parses as ESM.
163
+ These are not a browser screenshot or a test of an applied VS Code installation.
164
+ The operator separately confirmed the native hover, colors, per-grant balances
165
+ and expiry dates visually in the local 0.41.0 deployment. New deployments or host
166
+ versions still need their own visible hover check.
167
+ In particular, the regression suite now checks the default `off` status surface;
168
+ renderer-only v1 omitted that broker gate and could not receive the payload.
169
+ Use **Developer: Reload Window** after installing v2, not only Pi `/reload`.
170
+
171
+ For upstream support, the preferable long-term change is a documented generic
172
+ provider-details API with text rows and session/account scoping. The current
173
+ version-pinned adapter is an explicit compatibility bridge, not a claim that
174
+ Pendant already implements that public API.
@@ -0,0 +1,86 @@
1
+ // Optional, version-pinned adapter preparation. Never writes to the installed host.
2
+ import { readFileSync, writeFileSync, mkdirSync, realpathSync } from "node:fs";
3
+ import { resolve, dirname, relative, isAbsolute, join } from "node:path";
4
+ import { pathToFileURL } from "node:url";
5
+ import { createHash } from "node:crypto";
6
+ import { spawnSync } from "node:child_process";
7
+ import { createTooltipAdapter, TOOLTIP_STATUS_KEY } from "../../src/pendant-tooltip.js";
8
+
9
+ export const PENDANT_VERSION = "0.41.0";
10
+ export const ORIGINAL_SHA256 = "88c02dc6b1474e83fb5b8762ef1f26ae2d07abe04145b1b04f0ba519039bc38c";
11
+ export const HOST_EXTENSION_SHA256 = "8c449489dfba9f04fb8e96c2f09640b7ddf69900f6ca0e88ca1a4389b43714d6";
12
+ const hash = value => createHash("sha256").update(value).digest("hex");
13
+ function replaceOnce(source, before, after) {
14
+ const index = source.indexOf(before);
15
+ if (index < 0 || source.indexOf(before, index + before.length) >= 0) throw new Error("Pendant adapter anchor is absent or ambiguous.");
16
+ return source.slice(0, index) + after + source.slice(index + before.length);
17
+ }
18
+ export function patchWebview(source) {
19
+ if (hash(source) !== ORIGINAL_SHA256) throw new Error("Unsupported Pendant webview hash; refusing to patch.");
20
+ const modelStart = source.indexOf("renderModelTooltipFor(e){"), modelEnd = source.indexOf("prepareModelTooltipFor(e){", modelStart);
21
+ const originalModel = source.slice(modelStart, modelEnd);
22
+ let model = replaceOnce(originalModel, "return s$(xn(this.options.getModels(),e.provider,e.modelId),",
23
+ "let __wbModel=xn(this.options.getModels(),e.provider,e.modelId);let __wbBase=s$(__wbModel,");
24
+ model = replaceOnce(model, ",n.error)}", ",n.error);return __piWorkbuddyTooltip.append(__wbBase,this.__piWorkbuddyEntries??[],{provider:e.provider,sessionId:this.options.getState().sessionId,modelAvailable:!!__wbModel},Date.now())}");
25
+ let output = replaceOnce(source, originalModel, model);
26
+ const statusStart = source.indexOf("setExtensionStatus(e){"), statusEnd = source.indexOf("setExtensionStatusSurface(e){", statusStart);
27
+ const originalStatus = source.slice(statusStart, statusEnd);
28
+ let status = replaceOnce(originalStatus, "setExtensionStatus(e){this.extensionStatus.entries=e.map(",
29
+ "setExtensionStatus(e){this.__piWorkbuddyEntries=e.filter(n=>n.key===" + JSON.stringify(TOOLTIP_STATUS_KEY) +
30
+ ");this.extensionStatus.entries=e.filter(n=>n.key!==" + JSON.stringify(TOOLTIP_STATUS_KEY) + ").map(");
31
+ status = replaceOnce(status, "this.ui.footerTooltipController.refresh(this.options.extensionStatusButton,t)}",
32
+ "this.ui.footerTooltipController.refresh(this.options.extensionStatusButton,t);this.ui.footerTooltipController.refreshActive()}");
33
+ output = replaceOnce(output, originalStatus, status);
34
+ // This is a module-local helper; no global account cache, network API, or credentials enter the webview.
35
+ return "// pi-workbuddy optional Pendant tooltip adapter v1\nconst __piWorkbuddyTooltip=(" + createTooltipAdapter.toString() + ")();\n" + output;
36
+ }
37
+ export function patchHostExtension(source) {
38
+ if (hash(source) !== HOST_EXTENSION_SHA256) throw new Error("Unsupported Pendant broker hash; refusing to patch.");
39
+ const start = source.indexOf("broadcastExtensionStatus(){"), end = source.indexOf("waitForSidebarWebviewReady(", start);
40
+ const original = source.slice(start, end);
41
+ // A hidden status surface must not drop the private tooltip transport. Other entries stay hidden.
42
+ const replacement = "broadcastExtensionStatus(){let visible=this.extensionStatusSurface===\"panel\"||this.extensionStatusSurface===\"both\";let targets=[...this.view?[{source:\"sidebar\"}]:[],...[...this.editorSurfaces.values()].map(n=>({source:\"editor\",surfaceId:n.id}))];for(let target of targets){let entries=this.extensionUiHost.getStatusSnapshot(B(target));if(!visible)entries=entries.filter(entry=>entry.key===" + JSON.stringify(TOOLTIP_STATUS_KEY) + ");this.postMessage({type:\"extension_status\",entries},target)}}";
43
+ let output = replaceOnce(source, original, replacement);
44
+ // Never expose the encoded transport in VS Code's separate status bar either.
45
+ output = replaceOnce(output, "refreshStatusBar(){let e=this.getStatusSnapshot(),n=e.flatMap(",
46
+ "refreshStatusBar(){let e=this.getStatusSnapshot().filter(entry=>entry.key!==" + JSON.stringify(TOOLTIP_STATUS_KEY) + "),n=e.flatMap(");
47
+ return output;
48
+ }
49
+ function within(child, parent) {
50
+ const path = relative(parent, child);
51
+ return path === "" || !path.startsWith("..") && !isAbsolute(path);
52
+ }
53
+ export function prepareAdapter(sourceDirectory, outputDirectory) {
54
+ const sourceRoot = realpathSync(sourceDirectory);
55
+ const parent = realpathSync(dirname(resolve(outputDirectory)));
56
+ const output = join(parent, resolve(outputDirectory).split(/[\\/]/).at(-1));
57
+ if (within(output, sourceRoot)) throw new Error("Output must not be inside the installed Pendant directory.");
58
+ const pkg = JSON.parse(readFileSync(join(sourceRoot, "package.json"), "utf8"));
59
+ if (pkg.publisher !== "cdervis" || pkg.name !== "vscode-pi" || pkg.version !== PENDANT_VERSION) {
60
+ throw new Error("Only cdervis.vscode-pi 0.41.0 is supported by this experimental adapter.");
61
+ }
62
+ const original = readFileSync(join(sourceRoot, "dist", "webview.js"));
63
+ const brokerOriginal = readFileSync(join(sourceRoot, "dist", "extension.js"));
64
+ const patched = patchWebview(original.toString("utf8"));
65
+ const brokerPatched = patchHostExtension(brokerOriginal.toString("utf8"));
66
+ for (const [source, esm] of [[patched, true], [brokerPatched, false]]) {
67
+ const checked = spawnSync(process.execPath, [...(esm ? ["--input-type=module"] : []), "--check"], { input: source, encoding: "utf8", timeout: 15000 });
68
+ if (checked.error || checked.status !== 0) throw new Error("Prepared adapter failed syntax validation; nothing was written.");
69
+ }
70
+ const manifest = { adapter: "pi-workbuddy-tooltip-v2", hostVersion: pkg.version, originalSha256: hash(original),
71
+ preparedSha256: hash(patched), target: "dist/webview.js", brokerOriginalSha256: hash(brokerOriginal),
72
+ brokerPreparedSha256: hash(brokerPatched), brokerTarget: "dist/extension.js", applied: false };
73
+ mkdirSync(output); // Fail if output already exists; never overwrite a previous artifact.
74
+ writeFileSync(join(output, "webview.js"), patched, { flag: "wx" });
75
+ writeFileSync(join(output, "extension.js"), brokerPatched, { flag: "wx" });
76
+ writeFileSync(join(output, "manifest.json"), JSON.stringify(manifest, null, 2) + "\n", { flag: "wx" });
77
+ return manifest;
78
+ }
79
+ if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) {
80
+ try {
81
+ if (process.argv.length !== 4) throw new Error("Usage: node integrations/pendant/prepare.mjs PENDANT_DIRECTORY NEW_OUTPUT_DIRECTORY");
82
+ const manifest = prepareAdapter(process.argv[2], process.argv[3]);
83
+ console.log(JSON.stringify(manifest, null, 2));
84
+ console.log("Prepared only. Installed Pendant files have NOT been changed.");
85
+ } catch (error) { console.error(error.message); process.exitCode = 1; }
86
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-workbuddy",
3
- "version": "0.2.2",
3
+ "version": "0.2.4",
4
4
  "description": "Native WorkBuddy account login and direct model provider for Pi",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -20,6 +20,8 @@
20
20
  "files": [
21
21
  "extensions",
22
22
  "src",
23
+ "integrations/pendant/prepare.mjs",
24
+ "integrations/pendant/README.md",
23
25
  "README.md",
24
26
  "LICENSE"
25
27
  ],
@@ -1,6 +1,6 @@
1
1
  // Compatibility baseline: the operator-supplied WorkBuddy AI 5.8.4 artifact.
2
2
  // This is a protocol/version marker, not an assertion that Pi is the official desktop app.
3
- export const EXTENSION_VERSION = "0.2.2";
3
+ export const EXTENSION_VERSION = "0.2.4";
4
4
  export const WORKBUDDY_PROTOCOL_VERSION = "5.8.4";
5
5
  export const USER_AGENT = "CLI/unknown WorkBuddy/" + WORKBUDDY_PROTOCOL_VERSION +
6
6
  " pi-workbuddy/" + EXTENSION_VERSION;
@@ -0,0 +1,38 @@
1
+ import { createHash } from "node:crypto";
2
+ import { BASE_URL, ORIGIN } from "./native-client.js";
3
+ import { projectModels } from "./native-models.js";
4
+ import { WorkbuddyError } from "./native-errors.js";
5
+
6
+ /** Local cache validator only; never sent as an HTTP ETag. Tokens are not hashed or stored. */
7
+ export function catalogCacheKey(credential) {
8
+ if (credential?.type !== "oauth" || credential.origin !== ORIGIN || !credential.userId) return undefined;
9
+ const parts = [ORIGIN, credential.userId, credential.domain || "www.workbuddy.ai", credential.enterpriseId || ""];
10
+ if (parts.some(value => typeof value !== "string" || value.length > 2048 || /[\r\n\0]/.test(value))) return undefined;
11
+ return "workbuddy-catalog-v1:" + createHash("sha256").update(JSON.stringify(parts)).digest("hex");
12
+ }
13
+
14
+ /** Reproject a public metadata whitelist, never cached auth, endpoints, headers or instructions. */
15
+ export function restoreCatalog(entry, key) {
16
+ if (!key || entry?.etag !== key) return undefined;
17
+ if (!Array.isArray(entry.models) || entry.models.length > 2000) {
18
+ throw new WorkbuddyError("catalog-cache", "Cached WorkBuddy model metadata is invalid.");
19
+ }
20
+ const models = entry.models.filter(model => model?.provider === "workbuddy" && model.api === "openai-completions" &&
21
+ model.baseUrl === BASE_URL).map(model => ({
22
+ id: model.id, name: model.name,
23
+ maxInputTokens: model.contextWindow, maxOutputTokens: model.maxTokens,
24
+ supportsReasoning: model.reasoning === true,
25
+ supportsImages: Array.isArray(model.input) && model.input.includes("image"),
26
+ onlyReasoning: model.thinkingLevelMap?.off === null,
27
+ reasoning: { supportedEfforts: model.thinkingLevelMap && typeof model.thinkingLevelMap === "object" ?
28
+ Object.entries(model.thinkingLevelMap).filter(([level, value]) => level !== "off" && typeof value === "string").map(([level]) => level) : [] },
29
+ temperature: model.samplingParams?.temperature, top_p: model.samplingParams?.top_p,
30
+ }));
31
+ try { return projectModels({ models }); }
32
+ catch { throw new WorkbuddyError("catalog-cache", "Cached WorkBuddy model metadata is invalid."); }
33
+ }
34
+
35
+ export function catalogCacheEntry(models, key) {
36
+ // Models originate from projectModels(), which emits only bounded public metadata.
37
+ return { models, checkedAt: Date.now(), etag: key };
38
+ }
@@ -1,6 +1,7 @@
1
1
  import { setTimeout as sleep } from "node:timers/promises";
2
2
  import { WorkbuddyError } from "./native-errors.js";
3
3
  import { USER_AGENT } from "./client-identity.js";
4
+ import { readCreditPackages } from "./native-credit-packages.js";
4
5
 
5
6
  export const ORIGIN = "https://www.workbuddy.ai";
6
7
  export const BASE_URL = ORIGIN + "/v2";
@@ -88,7 +89,7 @@ export function createNativeClient({
88
89
  fetch: fetchImpl = globalThis.fetch, requestTimeoutMs = 15000,
89
90
  loginTimeoutMs = 300000, pollIntervalMs = 1000, now = Date.now,
90
91
  } = {}) {
91
- async function request(path, { method = "GET", credential, signal, pendingCode, headers = {} } = {}) {
92
+ async function request(path, { method = "GET", credential, signal, pendingCode, headers = {}, body = {} } = {}) {
92
93
  const url = new URL(path, ORIGIN);
93
94
  if (url.origin !== ORIGIN || url.username || url.password) throw new WorkbuddyError("endpoint", "Untrusted WorkBuddy endpoint.");
94
95
  const timed = AbortSignal.timeout(requestTimeoutMs);
@@ -104,7 +105,7 @@ export function createNativeClient({
104
105
  try {
105
106
  response = await fetchImpl(url, {
106
107
  method, redirect: "error", signal: activeSignal, headers: requestHeaders,
107
- ...(method === "POST" ? { body: "{}" } : {}),
108
+ ...(method === "POST" ? { body: JSON.stringify(body) } : {}),
108
109
  });
109
110
  } catch {
110
111
  if (signal?.aborted) throw new WorkbuddyError("cancelled", "WorkBuddy operation cancelled or timed out.");
@@ -179,5 +180,35 @@ export function createNativeClient({
179
180
  async catalog(credential, signal) {
180
181
  return request("/v3/config", { credential, signal });
181
182
  },
183
+ async credits(credential, signal) {
184
+ accountHeaders(credential);
185
+ if (credential.enterpriseId) {
186
+ throw new WorkbuddyError("credits-scope", "The verified credits route supports personal accounts only.");
187
+ }
188
+ return request("/billing/meter/get-user-resource-summary", { method: "POST", credential, signal });
189
+ },
190
+ async creditPackages(credential, signal, summary) {
191
+ accountHeaders(credential);
192
+ if (credential.enterpriseId) throw new WorkbuddyError("credits-scope", "Credit-package details support personal accounts only.");
193
+ const deadline = AbortSignal.timeout(requestTimeoutMs);
194
+ const activeSignal = signal ? AbortSignal.any([signal, deadline]) : deadline;
195
+ try {
196
+ return await readCreditPackages({ summary, signal: activeSignal,
197
+ fetchPage: (group, body) => {
198
+ const today = new Date(now()), pad = value => String(value).padStart(2, "0");
199
+ const day = `${today.getFullYear()}-${pad(today.getMonth() + 1)}-${pad(today.getDate())}`;
200
+ return request("/billing/meter/get-user-resource-" + group + "-packages", {
201
+ method: "POST", credential, signal: activeSignal, headers: { "Accept-Language": "en" },
202
+ body: { ...body, ...(group === "paid" ? { NeedRenewInfo: true } : {
203
+ SlicePeriodStartTime: day + " 00:00:00", SlicePeriodEndTime: day + " 23:59:59",
204
+ }) },
205
+ });
206
+ },
207
+ });
208
+ } catch {
209
+ if (signal?.aborted) throw new WorkbuddyError("cancelled", "WorkBuddy credit read cancelled.");
210
+ return { accounts: [], state: "unavailable" };
211
+ }
212
+ },
182
213
  };
183
214
  }
@@ -0,0 +1,117 @@
1
+ import { WorkbuddyError } from "./native-errors.js";
2
+
3
+ const MAX_PACKAGES = 2000, PAGE_SIZE = 200, MAX_PAGES = 10;
4
+ // Public product codes reviewed in WorkBuddy 5.8.4; unknown summary codes are queried on both read routes.
5
+ const products = {
6
+ TCACA_code_001_PqouKr6QWV: ["free", "daily"],
7
+ TCACA_code_002_AkiJS3ZHF5: ["paid", "base"],
8
+ TCACA_code_005_maRGyrHhw1: ["paid", "base"],
9
+ TCACA_code_003_FAnt7lcmRT: ["paid", "base"],
10
+ TCACA_code_006_DbXS0lrypC: ["free", "bonus"],
11
+ TCACA_code_007_nzdH5h4Nl0: ["free", "bonus"],
12
+ TCACA_code_008_cfWoLwvjU4: ["free", "base"],
13
+ TCACA_code_009_0XmEQc2xOf: ["paid", "topup"],
14
+ TCACA_code_023_4xbGhMrE6q: ["paid", "base"],
15
+ TCACA_code_026_BaESVICNoi: ["paid", "base"],
16
+ TCACA_code_027_0FCGVA6vSa: ["paid", "base"],
17
+ TCACA_code_028_NtpWi0jzXs: ["free", "bonus"],
18
+ TCACA_code_029_6wCGEWquYy: ["free", "bonus"],
19
+ TCACA_code_030_BjSt89qTvr: ["free", "bonus"],
20
+ TCACA_code_035_ArVxJcGDsm: ["free", "base"],
21
+ TCACA_code_036_lupO5WgNdG: ["paid", "topup"],
22
+ TCACA_code_037_WxOD3MpI2o: ["free", "bonus"],
23
+ TCACA_code_038_OhvqZtiPKr: ["paid", "topup"],
24
+ TCACA_code_039_KRcQj7wUat: ["free", "base"],
25
+ TCACA_code_040_mi9rCYg46x: ["free", "base"],
26
+ };
27
+ const codeOf = value => typeof value === "string" && /^[\w-]{1,128}$/.test(value) ? value : undefined;
28
+ const cancelled = () => new WorkbuddyError("cancelled", "WorkBuddy credit read cancelled.");
29
+
30
+ /** Bounded, read-only detail discovery. Never claims rewards or changes renewal/purchases. */
31
+ export async function readCreditPackages({ summary, fetchPage, signal }) {
32
+ if (!Array.isArray(summary?.Packages) || summary.Packages.length > MAX_PACKAGES ||
33
+ summary.Packages.some(item => !item || typeof item !== "object" || Array.isArray(item))) return { accounts: [], state: "unavailable" };
34
+ const codes = [...new Set((summary?.Packages ?? []).map(item => codeOf(item?.PackageCode)).filter(Boolean))];
35
+ if (!codes.length) return { accounts: [], state: summary?.Packages?.length === 0 ? "complete" : "unavailable" };
36
+ const missingCode = summary.Packages.some(item => !codeOf(item?.PackageCode));
37
+ const groups = await Promise.allSettled(["paid", "free"].map(async group => {
38
+ const selected = codes.filter(code => !Object.hasOwn(products, code) || products[code][0] === group);
39
+ if (!selected.length) return { accounts: [], complete: true };
40
+ const accounts = [];
41
+ for (let page = 1; page <= MAX_PAGES; page++) {
42
+ if (signal?.aborted) throw cancelled();
43
+ let data;
44
+ try {
45
+ data = await fetchPage(group, { PageNumber: page, PageSize: PAGE_SIZE, PackageCodes: selected,
46
+ Status: [0, 3], OnlyValidPeriod: true, IsDisplayTotalInfo: true });
47
+ } catch {
48
+ if (signal?.aborted) throw cancelled();
49
+ return { accounts, complete: false };
50
+ }
51
+ if (!Array.isArray(data?.Accounts) || data.Accounts.length > PAGE_SIZE ||
52
+ !Number.isSafeInteger(data.TotalCount) || data.TotalCount < 0 ||
53
+ data.Accounts.some(item => !item || typeof item !== "object" || Array.isArray(item))) {
54
+ return { accounts, complete: false };
55
+ }
56
+ // Some servers return unrelated products; never project them as matching this summary.
57
+ const matching = data.Accounts.filter(item => selected.includes(item.PackageCode));
58
+ accounts.push(...matching);
59
+ if (matching.length !== data.Accounts.length) return { accounts, complete: false };
60
+ if (accounts.length >= data.TotalCount) return { accounts, complete: accounts.length === data.TotalCount };
61
+ if (!data.Accounts.length) return { accounts, complete: false };
62
+ }
63
+ return { accounts, complete: false };
64
+ }));
65
+ if (signal?.aborted) throw cancelled();
66
+ const accounts = [], seen = new Set(); let complete = !missingCode;
67
+ for (const result of groups) {
68
+ if (result.status !== "fulfilled") { complete = false; continue; }
69
+ complete &&= result.value.complete;
70
+ for (const item of result.value.accounts) {
71
+ const id = typeof item.ResourceId === "string" && item.ResourceId || Number.isSafeInteger(item.ResourceId) && String(item.ResourceId);
72
+ if (id && seen.has(id)) continue;
73
+ if (id) seen.add(id);
74
+ if (accounts.length < MAX_PACKAGES) accounts.push(item); else complete = false;
75
+ }
76
+ }
77
+ const returnedCodes = new Set(accounts.map(item => item.PackageCode));
78
+ if (codes.some(code => !returnedCodes.has(code))) complete = false;
79
+ return { accounts, state: complete ? "complete" : accounts.length ? "partial" : "unavailable" };
80
+ }
81
+
82
+ function capacity(value) {
83
+ if (typeof value === "string" && /^\d+(?:\.\d+)?$/.test(value.trim())) value = Number(value);
84
+ return typeof value === "number" && Number.isFinite(value) && value >= 0 && value <= Number.MAX_SAFE_INTEGER ? value : null;
85
+ }
86
+ function date(value) {
87
+ // The verified numeric field is Unix milliseconds; do not guess seconds or a timezone for wall-clock strings.
88
+ if (typeof value === "string" && /^\d+$/.test(value)) value = Number(value);
89
+ if (typeof value === "string" && /^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d(?:\.\d{1,3})?(?:Z|[+-]\d\d:\d\d)$/.test(value) && wallClock(value.slice(0, 19))) value = Date.parse(value);
90
+ return Number.isSafeInteger(value) && value >= Date.UTC(2000, 0, 1) && value <= 8640000000000000 ? value : null;
91
+ }
92
+ function wallClock(value) {
93
+ if (typeof value !== "string" || !/^\d{4}-\d\d-\d\d[ T]\d\d:\d\d:\d\d$/.test(value)) return null;
94
+ const normalized = value.replace(" ", "T"), stamp = Date.parse(normalized + "Z");
95
+ return Number.isFinite(stamp) && new Date(stamp).toISOString().slice(0, 19) === normalized ? normalized.replace("T", " ") : null;
96
+ }
97
+
98
+ /** Whitelist only display metrics and expiry. No account/resource/order IDs or upstream names leave this module. */
99
+ export function normalizeCreditPackages(details, summary) {
100
+ if (!details || !["complete", "partial"].includes(details.state) || !Array.isArray(details.accounts) || details.accounts.length > MAX_PACKAGES ||
101
+ details.accounts.some(item => !item || typeof item !== "object" || Array.isArray(item))) {
102
+ return Object.freeze({ state: "unavailable", count: 0, items: Object.freeze([]) });
103
+ }
104
+ const items = details.accounts.map(item => {
105
+ const code = codeOf(item.PackageCode), kind = code && code === summary.SubscriptionPackageCode ? "base" :
106
+ code && Object.hasOwn(products, code) ? products[code][1] : "other";
107
+ const slice = Number(item.CapacityType) === 4 ? item.SlicePeriodUsageDetails?.[0] : undefined;
108
+ const total = capacity(slice ? slice.SlicePeriodCapacitySizePrecise : item.CycleCapacitySizePrecise);
109
+ const remaining = capacity(slice ? slice.SlicePeriodCapacityRemainPrecise : item.CycleCapacityRemainPrecise);
110
+ const used = total !== null && remaining !== null && total >= remaining ? total - remaining : null;
111
+ const candidates = kind === "daily" ? [item.CycleEndTime] : [item.DeductionEndTime, item.ExpiredTime];
112
+ const expiresAt = candidates.map(date).find(value => value !== null) ?? null;
113
+ const expiresText = expiresAt === null ? candidates.map(wallClock).find(Boolean) ?? null : null;
114
+ return Object.freeze({ kind, total, remaining, used, expiresAt, expiresText });
115
+ });
116
+ return Object.freeze({ state: details.state, count: items.length, items: Object.freeze(items) });
117
+ }
@@ -0,0 +1,175 @@
1
+ import { createNativeClient, accountHeaders } from "./native-client.js";
2
+ import { catalogCacheKey } from "./native-catalog-cache.js";
3
+ import { WorkbuddyError, describeFailure } from "./native-errors.js";
4
+ import { normalizeCreditPackages } from "./native-credit-packages.js";
5
+
6
+ const MAX_PACKAGES = 2000;
7
+ function capacity(value) {
8
+ if (typeof value === "string") {
9
+ if (!/^\d+(?:\.\d+)?$/.test(value.trim())) return null;
10
+ value = Number(value);
11
+ }
12
+ return typeof value === "number" && Number.isFinite(value) && value >= 0 && value <= Number.MAX_SAFE_INTEGER ? value : null;
13
+ }
14
+ function text(value) {
15
+ if (typeof value !== "string") return undefined;
16
+ const result = value.replace(/[\x00-\x1f\x7f]/g, "").trim().slice(0, 128);
17
+ return result || undefined;
18
+ }
19
+
20
+ /** Personal-account credits only; never infer a plan label, expiry, USD cost, or percentage. */
21
+ export function normalizeCredits(data, fetchedAt) {
22
+ if (!Number.isSafeInteger(fetchedAt) || fetchedAt < 0 || !data || !Array.isArray(data.Packages) || data.Packages.length > MAX_PACKAGES ||
23
+ data.Packages.some(item => !item || typeof item !== "object" || Array.isArray(item))) {
24
+ throw new WorkbuddyError("credits-shape", "Unsupported WorkBuddy credit summary.");
25
+ }
26
+ const total = field => {
27
+ let result = 0;
28
+ for (const item of data.Packages) {
29
+ const value = capacity(item[field]);
30
+ if (value === null || result + value > Number.MAX_SAFE_INTEGER) return null;
31
+ result += value;
32
+ }
33
+ return result;
34
+ };
35
+ const planCode = text(data.SubscriptionPackageCode), planName = text(data.SubscriptionPackageName);
36
+ return {
37
+ creditsTotal: total("CycleTotalCapacity"),
38
+ creditsRemaining: total("CycleRemainCapacity"),
39
+ creditsUsed: total("CycleUsedCapacity"),
40
+ packageCount: data.Packages.length, fetchedAt,
41
+ ...(typeof data.IsPaidUser === "boolean" ? { isPaidUser: data.IsPaidUser } : {}),
42
+ ...(planCode ? { planCode } : {}), ...(planName ? { planName } : {}),
43
+ };
44
+ }
45
+
46
+ function cancelled() { return new WorkbuddyError("cancelled", "WorkBuddy credit read cancelled."); }
47
+ function safeError(error) {
48
+ const message = describeFailure(error);
49
+ if (error instanceof WorkbuddyError && !message.startsWith("[unknown]")) {
50
+ const details = {};
51
+ if (Number.isInteger(error.details.status)) details.status = error.details.status;
52
+ if (Number.isSafeInteger(error.details.businessCode)) details.businessCode = error.details.businessCode;
53
+ // Do not expose upstream/custom error text, causes, or arbitrary diagnostic fields.
54
+ return new WorkbuddyError(error.diagnosticCode, message, details);
55
+ }
56
+ return new WorkbuddyError("credits-request", "WorkBuddy credits could not be refreshed.");
57
+ }
58
+ function permitsStale(error) {
59
+ if (["cancelled", "credentials", "account-domain", "metadata", "endpoint", "credits-scope"].includes(error.diagnosticCode)) return false;
60
+ if (error.diagnosticCode !== "http") return true;
61
+ const status = error.details.status;
62
+ return status === 408 || status === 429 || status >= 500 && status <= 599;
63
+ }
64
+
65
+ /** No timers, persistence, automatic login/refresh, or network work until read() is explicitly called. */
66
+ export function createCreditsReader({ client = createNativeClient(), ttlMs = 60000, maxEntries = 8, now = Date.now } = {}) {
67
+ if (!Number.isSafeInteger(ttlMs) || ttlMs < 0 || !Number.isSafeInteger(maxEntries) || maxEntries < 1 || maxEntries > 128 ||
68
+ typeof now !== "function" || typeof client?.credits !== "function") throw new Error("Invalid WorkBuddy credit-reader configuration.");
69
+ const cache = new Map(), pending = new Map();
70
+ const timestamp = () => {
71
+ const value = now();
72
+ if (!Number.isSafeInteger(value) || value < 0) throw new Error("WorkBuddy credit-reader clock must return a nonnegative safe integer.");
73
+ return value;
74
+ };
75
+ function keyFor(credential) {
76
+ accountHeaders(credential);
77
+ if (credential.enterpriseId) throw new WorkbuddyError("credits-scope", "The verified credits route supports personal accounts only.");
78
+ const key = catalogCacheKey(credential); // Reuse only the hashed account scope, never the model store.
79
+ if (!key) throw new WorkbuddyError("credentials", "Invalid WorkBuddy account scope.");
80
+ return key;
81
+ }
82
+ function fresh(snapshot) {
83
+ const age = timestamp() - snapshot.fetchedAt;
84
+ return age >= 0 && age < ttlMs;
85
+ }
86
+ function remember(key, snapshot) {
87
+ cache.delete(key); cache.set(key, snapshot);
88
+ while (cache.size > maxEntries) cache.delete(cache.keys().next().value);
89
+ }
90
+ function start(key, credential) {
91
+ if (pending.size >= maxEntries) throw new WorkbuddyError("credits-busy", "Too many WorkBuddy credit reads.");
92
+ const job = { controller: new AbortController(), users: 0, settled: false };
93
+ // Defer execution until the job has been registered, including synchronous client failures.
94
+ job.promise = Promise.resolve().then(async () => {
95
+ try {
96
+ if (job.controller.signal.aborted) throw cancelled();
97
+ const raw = await client.credits(credential, job.controller.signal);
98
+ if (job.controller.signal.aborted) throw cancelled();
99
+ const metrics = normalizeCredits(raw, timestamp());
100
+ let packageDetails;
101
+ if (typeof client.creditPackages === "function") {
102
+ let details;
103
+ try { details = await client.creditPackages(credential, job.controller.signal, raw); }
104
+ catch { if (job.controller.signal.aborted) throw cancelled(); }
105
+ packageDetails = normalizeCreditPackages(details, raw);
106
+ }
107
+ if (job.controller.signal.aborted) throw cancelled();
108
+ // All fields share the start-of-read timestamp, rather than making older metrics look newer.
109
+ const snapshot = Object.freeze({ ...metrics, ...(packageDetails ? { packageDetails } : {}) });
110
+ remember(key, snapshot);
111
+ return snapshot;
112
+ } catch (error) {
113
+ const failure = job.controller.signal.aborted ? cancelled() : safeError(error);
114
+ if (!permitsStale(failure) && failure.diagnosticCode !== "cancelled") cache.delete(key);
115
+ throw failure;
116
+ }
117
+ }).finally(() => {
118
+ job.settled = true;
119
+ if (pending.get(key) === job) pending.delete(key);
120
+ });
121
+ pending.set(key, job);
122
+ return job;
123
+ }
124
+ function join(key, job, signal) {
125
+ job.users++;
126
+ return new Promise((resolve, reject) => {
127
+ let active = true;
128
+ const leave = () => {
129
+ active = false;
130
+ signal?.removeEventListener("abort", abort);
131
+ job.controller.signal.removeEventListener("abort", abort);
132
+ if (--job.users === 0 && !job.settled) {
133
+ if (pending.get(key) === job) pending.delete(key);
134
+ job.controller.abort();
135
+ }
136
+ };
137
+ const abort = () => { if (active) { leave(); reject(cancelled()); } };
138
+ signal?.addEventListener("abort", abort, { once: true });
139
+ job.controller.signal.addEventListener("abort", abort, { once: true });
140
+ job.promise.then(value => { if (active) { leave(); resolve(value); } }, error => { if (active) { leave(); reject(error); } });
141
+ if (signal?.aborted || job.controller.signal.aborted) abort();
142
+ });
143
+ }
144
+ return {
145
+ async read(credential, { signal, force = false, allowNetwork = true } = {}) {
146
+ if (typeof force !== "boolean" || typeof allowNetwork !== "boolean" || signal !== undefined &&
147
+ (typeof signal?.aborted !== "boolean" || typeof signal.addEventListener !== "function" || typeof signal.removeEventListener !== "function")) {
148
+ throw new Error("Invalid WorkBuddy credit-read options.");
149
+ }
150
+ if (signal?.aborted) throw cancelled();
151
+ const key = keyFor(credential), saved = cache.get(key);
152
+ const network = allowNetwork && process.env.PI_OFFLINE === undefined;
153
+ if (saved && (!network || !force && fresh(saved))) {
154
+ remember(key, saved);
155
+ return { ...saved, source: "cache", stale: !fresh(saved) };
156
+ }
157
+ if (!network) throw new WorkbuddyError("credits-offline", "No cached WorkBuddy credits available offline.");
158
+ try {
159
+ const snapshot = await join(key, pending.get(key) ?? start(key, credential), signal);
160
+ return { ...snapshot, source: "remote", stale: false };
161
+ } catch (error) {
162
+ const failure = safeError(error), previous = cache.get(key);
163
+ if (previous && permitsStale(failure)) return { ...previous, source: "cache", stale: true, error: failure.message };
164
+ throw failure;
165
+ }
166
+ },
167
+ clear(credential) {
168
+ const key = credential === undefined ? undefined : keyFor(credential);
169
+ for (const [id, job] of pending) if (key === undefined || id === key) {
170
+ pending.delete(id); job.controller.abort();
171
+ }
172
+ if (key === undefined) cache.clear(); else cache.delete(key);
173
+ },
174
+ };
175
+ }
@@ -15,6 +15,12 @@ const REASONS = {
15
15
  "envelope": "The JSON response envelope was unexpected.",
16
16
  "catalog-shape": "The response did not contain a supported models array.",
17
17
  "catalog-empty": "The server returned an empty model list.",
18
+ "catalog-cache": "Cached model metadata was rejected; refresh to rebuild the catalog.",
19
+ "credits-shape": "WorkBuddy returned unsupported credit-summary metadata.",
20
+ "credits-request": "WorkBuddy credits could not be refreshed.",
21
+ "credits-offline": "No cached WorkBuddy credits are available while offline.",
22
+ "credits-scope": "The verified credit reader supports personal WorkBuddy accounts only.",
23
+ "credits-busy": "Too many WorkBuddy credit reads are in progress; retry shortly.",
18
24
  "catalog-filtered": "No model passed the provider's compatibility/security filters.",
19
25
  };
20
26
  export class WorkbuddyError extends Error {
@@ -0,0 +1,52 @@
1
+ import { describeFailure } from "./native-errors.js";
2
+
3
+ function definition(model) {
4
+ return JSON.stringify([model?.provider, model?.id, model?.name, model?.contextWindow,
5
+ model?.maxTokens, model?.reasoning, model?.input, model?.thinkingLevelMap]);
6
+ }
7
+
8
+ export async function recoverSelectedModel(pi, provider, ctx, selected = ctx.model) {
9
+ // Never switch away from another provider, race a user selection, or alter a running turn.
10
+ if (!selected || selected.provider !== "workbuddy" || ctx.model !== selected || ctx.isIdle?.() === false) return;
11
+ let previousId = selected.id;
12
+ const placeholder = selected.name === "WorkBuddy Default (fallback)";
13
+ if (placeholder) {
14
+ // On resume Pi can replace a missing selection before session_start; the latest
15
+ // model-change record still identifies it. Never scan past a newer valid choice.
16
+ const previous = ctx.sessionManager?.getBranch?.().findLast(entry => entry.type === "model_change");
17
+ if (previous?.provider === "workbuddy" && typeof previous.modelId === "string") previousId = previous.modelId;
18
+ }
19
+ const id = /^deepseek-/i.test(previousId) && /-sg$/i.test(previousId) ? previousId.slice(0, -3) : previousId;
20
+ const replacement = provider.getModels().find(model => model.id === id) ??
21
+ (placeholder ? provider.getModels().find(model => model.id === selected.id) : undefined);
22
+ if (!replacement || definition(selected) === definition(replacement)) return;
23
+ if (await pi.setModel(replacement) === false) {
24
+ ctx.ui?.notify?.("WorkBuddy catalog is ready; select an available model with /model.", "warning");
25
+ } else if (id !== selected.id && replacement.id === id) {
26
+ ctx.ui?.notify?.("WorkBuddy restored the available model " + replacement.id + ". No chat request sent.", "info");
27
+ }
28
+ }
29
+
30
+ /** Embedded hosts can re-register providers without refreshing an already-seen provider ID. */
31
+ export function registerNativeLifecycle(pi, provider) {
32
+ pi.on("session_start", async (_event, ctx) => {
33
+ const selected = ctx.model;
34
+ try {
35
+ if (!ctx.modelRegistry.hasConfiguredAuth(provider.getModels()[0])) return;
36
+ const result = await ctx.modelRegistry.refresh({
37
+ providers: ["workbuddy"], force: true, signal: ctx.signal,
38
+ // Honor PI_OFFLINE; otherwise inherit the registry's default network policy.
39
+ ...(process.env.PI_OFFLINE !== undefined ? { allowNetwork: false } : {}),
40
+ });
41
+ if (result.aborted) return;
42
+ if (result.errors.has("workbuddy")) {
43
+ ctx.ui?.notify?.("WorkBuddy catalog refresh failed: " + describeFailure(result.errors.get("workbuddy")) +
44
+ " Cached models, if available, were retained. No chat request sent.", "warning");
45
+ }
46
+ await recoverSelectedModel(pi, provider, ctx, selected);
47
+ } catch (error) {
48
+ ctx.ui?.notify?.("WorkBuddy startup recovery failed: " + describeFailure(error) +
49
+ " Use /workbuddy refresh to retry. No chat request sent.", "warning");
50
+ }
51
+ });
52
+ }
@@ -3,12 +3,16 @@ import { fallbackModels, projectModels } from "./native-models.js";
3
3
  import { createChatFetch } from "./native-transport.js";
4
4
  import { describeFailure } from "./native-errors.js";
5
5
  import { resolveOutputLimit, capPayloadOutput } from "./native-limits.js";
6
+ import { catalogCacheKey, catalogCacheEntry, restoreCatalog } from "./native-catalog-cache.js";
7
+ import { recoverSelectedModel } from "./native-lifecycle.js";
8
+ import { EXTENSION_VERSION } from "./client-identity.js";
6
9
 
7
10
  export function createWorkbuddyProvider({ api, client = createNativeClient(), fetch: fetchImpl, gzip = true } = {}) {
8
11
  if (!api?.stream || !api?.streamSimple) throw new Error("Pi OpenAI streaming API is required.");
9
12
  let models = fallbackModels();
10
13
  let catalogSource = "fallback";
11
14
  let lastCatalogError;
15
+ let catalogAccountKey;
12
16
  function prepareRequest(model, options = {}) {
13
17
  let outputLimit = resolveOutputLimit(model, options.maxTokens);
14
18
  const modelSampling = model.samplingParams ? { ...model.samplingParams } : undefined;
@@ -51,13 +55,33 @@ export function createWorkbuddyProvider({ api, client = createNativeClient(), fe
51
55
  getModels: () => models,
52
56
  getCatalogStatus: () => ({ source: catalogSource, lastError: lastCatalogError }),
53
57
  async refreshModels(context) {
58
+ const key = catalogCacheKey(context.credential);
59
+ if (catalogAccountKey && catalogAccountKey !== key) {
60
+ if (await context.publish({ update: () => {
61
+ models = fallbackModels(); catalogSource = "fallback"; catalogAccountKey = undefined; lastCatalogError = undefined;
62
+ } }) === false) return;
63
+ }
64
+ if (catalogSource === "fallback" && context.stored) {
65
+ try {
66
+ const restored = restoreCatalog(context.stored, key);
67
+ if (restored && await context.publish({ update: () => {
68
+ models = restored; catalogSource = "cache"; catalogAccountKey = key; lastCatalogError = undefined;
69
+ } }) === false) return;
70
+ } catch (error) {
71
+ // Invalid or fully filtered caches must not block a fresh authenticated catalog fetch.
72
+ lastCatalogError = describeFailure(error);
73
+ }
74
+ }
54
75
  if (!context.allowNetwork || context.credential?.type !== "oauth") return;
55
76
  try {
56
77
  const next = projectModels(await client.catalog(context.credential, context.signal));
57
- // Generation-checked publication owned by Pi. No auth or remote instructions persisted.
58
- await context.publish({ update: () => {
59
- models = next; catalogSource = "remote"; lastCatalogError = undefined;
60
- } });
78
+ // Pi owns persistence and generation checks. Persist only projected public metadata.
79
+ await context.publish({
80
+ ...(key ? { persist: catalogCacheEntry(next, key) } : {}),
81
+ update: () => {
82
+ models = next; catalogSource = "remote"; catalogAccountKey = key; lastCatalogError = undefined;
83
+ },
84
+ });
61
85
  } catch (error) {
62
86
  lastCatalogError = describeFailure(error);
63
87
  throw error;
@@ -83,11 +107,12 @@ export function registerNativeCommands(pi, provider) {
83
107
  const ready = ctx.modelRegistry.hasConfiguredAuth(provider.getModels()[0]);
84
108
  const catalog = provider.getCatalogStatus?.();
85
109
  const active = ctx.model ? ctx.model.provider + "/" + ctx.model.id : "unknown";
86
- ctx.ui.notify("WorkBuddy native (experimental): " + (ready ? "account configured" : "not logged in; use /login") +
110
+ ctx.ui.notify("WorkBuddy native " + EXTENSION_VERSION + " (experimental): " + (ready ? "account configured" : "not logged in; use /login") +
87
111
  ". " + provider.getModels().length + " known models; catalog=" + (catalog?.source ?? "unknown") +
88
112
  ". Active model: " + active + "." + (catalog?.lastError ? " Last catalog failure: " + catalog.lastError : "") +
89
113
  " No chat request sent.", "info");
90
114
  } else if (action === "refresh") {
115
+ const selected = ctx.model;
91
116
  try {
92
117
  if (!ctx.modelRegistry.hasConfiguredAuth(provider.getModels()[0])) {
93
118
  ctx.ui.notify("Sign in to WorkBuddy using /login first.", "warning"); return;
@@ -102,6 +127,7 @@ export function registerNativeCommands(pi, provider) {
102
127
  " Existing catalog retained. No chat request sent.", "warning");
103
128
  return;
104
129
  }
130
+ await recoverSelectedModel(pi, provider, ctx, selected);
105
131
  ctx.ui.notify("WorkBuddy catalog refreshed: " + provider.getModels().length + " models. Select with /model.", "info");
106
132
  } catch (error) {
107
133
  ctx.ui.notify("WorkBuddy catalog refresh failed: " +
@@ -0,0 +1,90 @@
1
+ import { createCreditsReader } from "./native-credits.js";
2
+ import { ORIGIN } from "./native-client.js";
3
+ import { createTooltipAdapter, TOOLTIP_STATUS_KEY } from "./pendant-tooltip.js";
4
+
5
+ /** Opt-in status transport for the optional Pendant adapter, not a native Pi usage hook. */
6
+ export function registerPendantTooltip(pi, { reader = createCreditsReader(), enabled = false } = {}) {
7
+ const wire = createTooltipAdapter();
8
+ let active = enabled, generation = 0, pending, lastContext, published = false;
9
+ let payloadState = enabled ? "waiting" : "disabled";
10
+ const clear = ctx => {
11
+ try { if (published) ctx?.ui?.setStatus?.(TOOLTIP_STATUS_KEY, undefined); } catch { /* Unsupported UI must not break a turn. */ }
12
+ published = false;
13
+ };
14
+ const session = ctx => ctx.sessionManager?.getSessionId?.();
15
+ function stop(ctx) {
16
+ generation++;
17
+ pending?.controller.abort(); pending = undefined;
18
+ reader.clear(); clear(ctx ?? lastContext);
19
+ payloadState = active ? "waiting" : "disabled";
20
+ }
21
+ async function refresh(ctx, force = false) {
22
+ lastContext = ctx;
23
+ if (!active || ctx.mode !== "rpc" || ctx.model?.provider !== "workbuddy") { stop(ctx); return; }
24
+ const sessionId = session(ctx);
25
+ if (typeof sessionId !== "string" || !/^[\w-]{1,128}$/.test(sessionId)) { stop(ctx); return; }
26
+ const identity = sessionId + "/" + ctx.model.id;
27
+ if (pending?.identity === identity && !force) return pending.promise;
28
+ generation++;
29
+ pending?.controller.abort();
30
+ const ticket = generation, controller = new AbortController();
31
+ const job = { identity, controller };
32
+ pending = job;
33
+ const current = () => active && generation === ticket && !controller.signal.aborted && session(ctx) === sessionId && ctx.model?.provider === "workbuddy";
34
+ const publish = (state, data = {}) => {
35
+ if (current()) {
36
+ try {
37
+ ctx.ui.setStatus(TOOLTIP_STATUS_KEY, wire.encode({ ...data, v: 1, provider: "workbuddy", sessionId, state }));
38
+ published = true; payloadState = state;
39
+ } catch { active = false; stop(ctx); }
40
+ }
41
+ };
42
+ job.promise = Promise.resolve().then(async () => {
43
+ publish("loading"); // Remove old balances while the current auth scope is being resolved.
44
+ if (!current()) { if (pending === job) pending = undefined; return; }
45
+ try {
46
+ const model = ctx.model;
47
+ const auth = await ctx.modelRegistry.getApiKeyAndHeaders(model);
48
+ if (!current()) return;
49
+ if (!auth?.ok) throw new Error("Account unavailable");
50
+ if (auth.baseUrl && new URL(auth.baseUrl).origin !== ORIGIN) throw new Error("Untrusted account origin");
51
+ const headers = new Headers(auth.headers);
52
+ const access = auth.apiKey || headers.get("Authorization")?.replace(/^Bearer\s+/i, "");
53
+ const credential = { type: "oauth", origin: ORIGIN, access, userId: headers.get("X-User-Id"),
54
+ domain: headers.get("X-Domain") || "www.workbuddy.ai",
55
+ ...(headers.get("X-Enterprise-Id") ? { enterpriseId: headers.get("X-Enterprise-Id") } : {}),
56
+ };
57
+ const signal = ctx.signal ? AbortSignal.any([ctx.signal, controller.signal]) : controller.signal;
58
+ const data = await reader.read(credential, { force, signal, allowNetwork: process.env.PI_OFFLINE === undefined });
59
+ publish("ready", data);
60
+ } catch {
61
+ if (current()) { reader.clear(); publish("unavailable"); }
62
+ } finally {
63
+ if (pending === job) pending = undefined;
64
+ }
65
+ });
66
+ return job.promise;
67
+ }
68
+ pi.registerCommand("workbuddy-tooltip", {
69
+ description: "Experimental Pendant tooltip relay: on/off/refresh/status (requires the optional host adapter)",
70
+ async handler(args, ctx) {
71
+ const action = args.trim() || "status";
72
+ if (action === "off") { active = false; stop(ctx); ctx.ui.notify("WorkBuddy tooltip relay disabled.", "info"); return; }
73
+ if (action === "status") {
74
+ ctx.ui.notify("WorkBuddy tooltip relay: " + (active ? "enabled" : "disabled") + "; payload=" + payloadState +
75
+ ". Requires the optional Pendant adapter v2 (renderer + broker). No chat request sent.", "info"); return;
76
+ }
77
+ if (!["on", "refresh"].includes(action)) { ctx.ui.notify("Usage: /workbuddy-tooltip [on|off|refresh|status]", "warning"); return; }
78
+ if (ctx.mode !== "rpc") { ctx.ui.notify("The experimental tooltip relay is for Pendant/RPC, not the CLI footer.", "warning"); return; }
79
+ if (action === "on") {
80
+ active = true;
81
+ ctx.ui.notify("Experimental WorkBuddy tooltip relay enabled. The separate Pendant adapter must already be installed; this command does not patch the host.", "info");
82
+ }
83
+ if (!active) { ctx.ui.notify("Enable the optional relay with /workbuddy-tooltip on first.", "warning"); return; }
84
+ await refresh(ctx, action === "refresh");
85
+ },
86
+ });
87
+ for (const event of ["session_start", "model_select", "agent_end"]) pi.on(event, async (_event, ctx) => { await refresh(ctx); });
88
+ pi.on("session_shutdown", async (_event, ctx) => { active = false; stop(ctx); });
89
+ return { refresh, dispose() { active = false; stop(lastContext); } };
90
+ }
@@ -0,0 +1,105 @@
1
+ export const TOOLTIP_STATUS_KEY = "workbuddy.credits.v1";
2
+
3
+ /** Self-contained so the optional host adapter can embed exactly the same validator/renderer. */
4
+ export function createTooltipAdapter(statusKey = "workbuddy.credits.v1") {
5
+ const number = value => value === null || typeof value === "number" && Number.isFinite(value) && value >= 0 && value <= Number.MAX_SAFE_INTEGER;
6
+ const text = value => typeof value === "string" ? value.replace(/[\x00-\x1f\x7f\u202a-\u202e\u2066-\u2069]/g, "").trim().slice(0, 128) : "";
7
+ const instant = value => value === null || Number.isSafeInteger(value) && value >= 0 && value <= 8640000000000000;
8
+ const wallClock = value => value === null || typeof value === "string" && /^\d{4}-\d\d-\d\d \d\d:\d\d:\d\d$/.test(value) &&
9
+ Number.isFinite(Date.parse(value.replace(" ", "T") + "Z")) && new Date(value.replace(" ", "T") + "Z").toISOString().slice(0, 19) === value.replace(" ", "T");
10
+ const tone = (remaining, total, stale) => {
11
+ if (stale || remaining === null) return undefined;
12
+ if (remaining === 0) return "danger";
13
+ if (total > 0 && remaining <= total) {
14
+ const percent = remaining / total * 100;
15
+ return percent < 15 ? "danger" : percent < 50 ? "warning" : "good";
16
+ }
17
+ };
18
+ function validate(input, maxItems = 4) {
19
+ if (!input || input.v !== 1 || input.provider !== "workbuddy" || typeof input.sessionId !== "string" || !/^[\w-]{1,128}$/.test(input.sessionId) ||
20
+ !["loading", "ready", "unavailable"].includes(input.state)) return undefined;
21
+ const packet = { v: 1, provider: "workbuddy", sessionId: input.sessionId, state: input.state };
22
+ if (input.state !== "ready") return packet;
23
+ if (![input.creditsTotal, input.creditsRemaining, input.creditsUsed].every(number) ||
24
+ !Number.isSafeInteger(input.packageCount) || input.packageCount < 0 || input.packageCount > 2000 ||
25
+ !Number.isSafeInteger(input.fetchedAt) || input.fetchedAt < 0 || input.fetchedAt > 8640000000000000 ||
26
+ !["cache", "remote"].includes(input.source) || typeof input.stale !== "boolean") return undefined;
27
+ Object.assign(packet, { creditsTotal: input.creditsTotal, creditsRemaining: input.creditsRemaining,
28
+ creditsUsed: input.creditsUsed, packageCount: input.packageCount, fetchedAt: input.fetchedAt,
29
+ source: input.source, stale: input.stale });
30
+ if (input.packageDetails !== undefined) {
31
+ const details = input.packageDetails;
32
+ if (!details || !["complete", "partial", "unavailable"].includes(details.state) ||
33
+ !Number.isSafeInteger(details.count) || details.count < 0 || details.count > 2000 || !Array.isArray(details.items) ||
34
+ details.items.length > maxItems || details.items.length > details.count ||
35
+ details.state === "unavailable" && (details.count !== 0 || details.items.length !== 0)) return undefined;
36
+ const items = [];
37
+ for (const item of details.items) {
38
+ if (!item || !["base", "bonus", "topup", "daily", "other"].includes(item.kind) ||
39
+ ![item.total, item.remaining, item.used].every(number) || !instant(item.expiresAt) || !wallClock(item.expiresText) ||
40
+ item.expiresAt !== null && item.expiresText !== null) return undefined;
41
+ items.push({ kind: item.kind, total: item.total, remaining: item.remaining, used: item.used,
42
+ expiresAt: item.expiresAt, expiresText: item.expiresText });
43
+ }
44
+ packet.packageDetails = { state: details.state, count: details.count, items };
45
+ }
46
+ const name = text(input.planName);
47
+ if (name) packet.planName = name;
48
+ return packet;
49
+ }
50
+ return {
51
+ encode(input) {
52
+ const packet = validate(input, 2000);
53
+ if (!packet) throw new Error("Invalid WorkBuddy tooltip payload.");
54
+ if (packet.packageDetails) {
55
+ const priority = item => item.kind === "base" ? 0 : item.kind === "daily" ? 1 : 2;
56
+ packet.packageDetails.items.sort((a, b) => priority(a) - priority(b) || (a.expiresAt ?? Infinity) - (b.expiresAt ?? Infinity));
57
+ packet.packageDetails.items = packet.packageDetails.items.slice(0, 4);
58
+ }
59
+ return JSON.stringify(packet);
60
+ },
61
+ append(base, entries, context, now = Date.now()) {
62
+ if (base?.kind !== "grid" || !Array.isArray(base.rows) || context?.provider !== "workbuddy" ||
63
+ context.modelAvailable !== true || !context.sessionId || !Array.isArray(entries)) return base;
64
+ const candidates = entries.filter(entry => entry?.key === statusKey);
65
+ if (candidates.length !== 1 || typeof candidates[0].text !== "string" || candidates[0].text.length > 4096) return base;
66
+ let packet;
67
+ try { packet = validate(JSON.parse(candidates[0].text)); } catch { return base; }
68
+ if (!packet || packet.sessionId !== context.sessionId) return base;
69
+ const rows = [];
70
+ if (packet.state !== "ready") rows.push({ label: "WorkBuddy credits", value: packet.state === "loading" ? "Loading…" : "Unavailable",
71
+ ...(packet.state === "unavailable" ? { valueTone: "warning" } : {}) });
72
+ else {
73
+ const format = value => value === null ? "Unknown" : value.toLocaleString(undefined, { maximumFractionDigits: 6 });
74
+ const stale = packet.stale || !Number.isFinite(now) || now < packet.fetchedAt || now - packet.fetchedAt >= 60000;
75
+ const remainingTone = tone(packet.creditsRemaining, packet.creditsTotal, stale);
76
+ if (packet.planName) rows.push({ label: "Plan", value: packet.planName });
77
+ rows.push({ label: "Credits remaining", value: format(packet.creditsRemaining), ...(remainingTone ? { valueTone: remainingTone } : {}) });
78
+ const details = packet.packageDetails;
79
+ if (details?.items.length) {
80
+ const counts = {}, names = { base: "Base credits", daily: "Daily credits", bonus: "Bonus", topup: "Top-up", other: "Credit pack" };
81
+ for (const item of details.items) {
82
+ counts[item.kind] = (counts[item.kind] ?? 0) + 1;
83
+ const indexed = !["base", "daily"].includes(item.kind) || details.items.filter(other => other.kind === item.kind).length > 1;
84
+ const valueTone = tone(item.remaining, item.total, stale);
85
+ rows.push({ label: names[item.kind] + (indexed ? " " + counts[item.kind] : ""),
86
+ value: format(item.remaining) + " / " + format(item.total) + " left", ...(valueTone ? { valueTone } : {}) });
87
+ rows.push({ label: item.expiresText ? "Expires (server time)" : "Expires", value: item.expiresAt !== null ?
88
+ new Date(item.expiresAt).toLocaleString() : item.expiresText ?? "Unknown" });
89
+ }
90
+ if (details.count > details.items.length) rows.push({ label: "More packages", value: String(details.count - details.items.length) + " not shown" });
91
+ } else {
92
+ rows.push({ label: "Credits used / total", value: format(packet.creditsUsed) + " / " + format(packet.creditsTotal) });
93
+ rows.push({ label: "Credit groups", value: String(packet.packageCount) });
94
+ }
95
+ if (details && details.state !== "complete") rows.push({ label: "Package details",
96
+ value: details.state === "partial" ? "Partial" : "Unavailable", valueTone: "warning" });
97
+ rows.push({ label: "Updated", value: new Date(packet.fetchedAt).toLocaleString() });
98
+ if (stale) {
99
+ rows.push({ label: "Credit data", value: "Cached / stale", valueTone: "warning" });
100
+ }
101
+ }
102
+ return { ...base, rows: [...base.rows, ...rows] };
103
+ },
104
+ };
105
+ }