pig-claude-bridge 0.1.0 → 0.3.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 CHANGED
@@ -10,9 +10,11 @@ A native Go extension for [PiG](https://github.com/MichaelKinsy/PiG) that integr
10
10
  ## Install
11
11
 
12
12
  ```sh
13
- pig install git:github.com/balcsida/pig-claude-bridge
13
+ pig install npm:pig-claude-bridge
14
14
  ```
15
15
 
16
+ Update it with `pig update npm:pig-claude-bridge`. To follow `main` instead of releases, install `git:github.com/balcsida/pig-claude-bridge`.
17
+
16
18
  Requirements:
17
19
 
18
20
  - PiG 0.5.3 or newer.
@@ -30,7 +32,19 @@ Behind the scenes, PiG's tools are bridged to Claude Code but everything works l
30
32
 
31
33
  The model list is a static table generated from the Anthropic catalog of `@earendil-works/pi-ai` 1.2.0 (`scripts/dev/gen-catalog.mjs`) and registered when the extension loads; it does not follow later catalog updates. Dated snapshot ids are not shown. See [docs/compatibility.md](docs/compatibility.md) (D5).
32
34
 
33
- **1M context:** Fable 5/5.1, Opus 5.5/5/4.8/4.7, and Sonnet 5.5/5 get 1M context. Opus 4.6 gets 1M only on a Max plan or with Extra Usage, and Sonnet 4.6 only with Extra Usage: set `provider.plan` and/or `provider.longContextExtraUsage` as described in [Configuration](#configuration).
35
+ **1M context:** Fable 5/5.1, Opus 5.5/5/4.8/4.7, and Sonnet 5.5/5 get 1M context. Opus 4.6 gets 1M on Max, Ultra, Team (Standard and Premium seats) and Enterprise plans or with Extra Usage, and Sonnet 4.6 only with Extra Usage ([Claude Code docs](https://code.claude.com/docs/en/model-config#extended-context)): the extension detects the plan by itself (see `provider.plan` in [Configuration](#configuration)); set `provider.longContextExtraUsage` for Extra Usage.
36
+
37
+ **Fast mode:** `/claude-fast` turns Claude Code's fast mode on or off for the rest of the session (`/claude-fast on`, `/claude-fast off`, or no argument to toggle); set `provider.fastMode` to `true` to start every session with it on. It takes effect from the next turn, on models that have fast mode (Opus 5.5, for example); other models ignore it. On subscription plans fast mode is billed to usage credits, not to the plan's limits, and on Team and Enterprise an Owner has to enable it for the organization first ([Claude Code docs](https://code.claude.com/docs/en/fast-mode)). While it is on, the footer shows `fast` (see [Footer status](#footer-status)).
38
+
39
+ ### Footer status
40
+
41
+ The bridge keeps one entry in PiG's footer status bar, for example `Claude Max · fast · 5h 6% ↻3h · 7d 76% ↻8h · Fable 99% ↻8h`:
42
+
43
+ - the plan (`Pro`, `Max`, `Ultra`, `Team`, `Enterprise`), when `provider.plan` is set or the plan was detected; nothing when it is unknown.
44
+ - `fast` while fast mode is on; nothing when it is off.
45
+ - the plan's usage limits, once Claude Code reports them (see [Plan usage for footers and quota extensions](#plan-usage-for-footers-and-quota-extensions)).
46
+
47
+ The plan shows from session start, fast mode as soon as `/claude-fast` changes it, and the usage after the first turn.
34
48
 
35
49
  ## AskClaude Tool
36
50
 
@@ -55,6 +69,7 @@ The extension asks PiG for both directories; nothing is hard-coded. A missing or
55
69
  },
56
70
  "provider": {
57
71
  "plan": "max",
72
+ "fastMode": false,
58
73
  "longContextExtraUsage": false,
59
74
  "strictMcpConfig": true,
60
75
  "pathToClaudeCodeExecutable": "/home/you/.nix-profile/bin/claude"
@@ -73,14 +88,15 @@ The extension asks PiG for both directories; nothing is hard-coded. A missing or
73
88
  - `appendSkills`: forward PiG's skills block into the system prompt (default `true`)
74
89
 
75
90
  `provider`:
76
- - `plan` (default `"pro"`): set to `"max"` if you have a Max (or Team Premium/Enterprise) Anthropic plan. This enables Opus with 1M context.
91
+ - `plan`: `"pro"`, `"max"`, `"ultra"`, `"team"` or `"enterprise"`. Every plan except `"pro"` enables Opus 4.6 with 1M context. Unset, the extension asks `claude auth status --json` once at load and uses the subscription it reports; when that fails (no `claude` on `PATH`, not logged in, or an API key login) it falls back to `"pro"`.
92
+ - `fastMode` (default `false`): start every session with Claude Code's fast mode on. `/claude-fast` changes it for the current session.
77
93
  - `longContextExtraUsage`: set to `true` to enable 1M context models even if they cost money through Extra Usage on your plan. It enables Sonnet 4.6 with 1M on every plan and Opus 4.6 with 1M on Pro.
78
94
  - `forceTwoHundredK`: array of model ids to pin to 200K context (bare id, no `[1m]` suffix). Use if the catalog declares a model at 1M but Claude Code won't serve it on your plan.
79
95
  - `strictMcpConfig`: block MCP servers from `~/.claude.json` / `.mcp.json` (default `true`). Cloud MCP (Gmail/Drive via claude.ai OAuth) is always blocked.
80
96
  - `autoMemoryEnabled`: enable Claude Code's auto-memory system (default `false`)
81
97
  - `pathToClaudeCodeExecutable`: path to the `claude` binary, used instead of the one on `PATH`. A path ending in `.js`, `.mjs` or `.ts` is run through `node`, as the Agent SDK does.
82
98
 
83
- **Notices:** the startup notice, shown at the first bridge turn in the TUI only (it stays pending in RPC, JSON and print mode), points out when `provider.plan` is unset and records `startupNoticeShown` (a date) in the global config. A deprecation notice on first use of AskClaude records its display date as `askClaudeDeprecationShown`. The extension rewrites the global file only to add these keys and leaves every other key as it was; an unparseable global file is left alone.
99
+ **Notices:** the startup notice, shown at the first bridge turn in the TUI only (it stays pending in RPC, JSON and print mode), points out when `provider.plan` is unset and was not detected, and records `startupNoticeShown` (a date) in the global config. A deprecation notice on first use of AskClaude records its display date as `askClaudeDeprecationShown`. The extension rewrites the global file only to add these keys and leaves every other key as it was; an unparseable global file is left alone.
84
100
 
85
101
  ## Why Go
86
102
 
@@ -164,7 +180,7 @@ PiG runs compaction and branch summaries natively. The bridge routes their `cach
164
180
 
165
181
  ### Plan usage for footers and quota extensions
166
182
 
167
- Claude Code sends a `rate_limit_event` when a usage window changes. The TypeScript bridge passes it to Pi's `after_provider_response` event as `anthropic-ratelimit-unified-*` headers. PiG's host does not emit that event, so this port writes the headers to the debug log only; the user-visible rate-limit notices stay. This is difference D1 in [docs/compatibility.md](docs/compatibility.md).
183
+ Claude Code sends a `rate_limit_event` when a usage window changes. The TypeScript bridge passes it to Pi's `after_provider_response` event as `anthropic-ratelimit-unified-*` headers. PiG's host does not emit that event, so this port writes the headers to the debug log only; the user-visible rate-limit notices stay. The bridge also shows the plan's limits in PiG's footer status bar, after the plan and fast mode (see [Footer status](#footer-status)), for example `Claude Max · 5h 6% ↻3h · 7d 76% ↻8h · Fable 99% ↻8h · reset available`. On each event it asks Claude Code for its `/usage` rows (`get_usage`), which name per-model limits; when that fails it shows the event's own windows. This is difference D1 in [docs/compatibility.md](docs/compatibility.md).
168
184
 
169
185
  ## Known issues
170
186
 
@@ -4,6 +4,7 @@ import (
4
4
  "context"
5
5
  "fmt"
6
6
  "os"
7
+ "strings"
7
8
  "time"
8
9
 
9
10
  sdk "github.com/MichaelKinsy/PiG/extensions/sdk"
@@ -26,6 +27,18 @@ func (h sdkHost) Notify(message, level string) {
26
27
  h.ctx.Notify(message, level)
27
28
  }
28
29
 
30
+ // SetStatus drops the status if the context is stale, as Notify does.
31
+ func (h sdkHost) SetStatus(key, text string) {
32
+ defer func() { _ = recover() }()
33
+ h.ctx.SetStatus(key, text)
34
+ }
35
+
36
+ // ClearStatus drops the call if the context is stale, as SetStatus does.
37
+ func (h sdkHost) ClearStatus(key string) {
38
+ defer func() { _ = recover() }()
39
+ h.ctx.ClearStatus(key)
40
+ }
41
+
29
42
  func (h sdkHost) SessionID() (string, error) { return h.ctx.SessionManager().GetSessionID() }
30
43
  func (h sdkHost) Cwd() string { return h.ctx.Cwd() }
31
44
 
@@ -118,6 +131,17 @@ func Extension() *sdk.Extension {
118
131
  configDirName, _ := sdk.ConfigDirName()
119
132
  cwd, _ := os.Getwd()
120
133
  cfg := config.Load(agentDir, cwd, configDirName)
134
+ // Detected before the provider registers, since the plan decides which models get 1M.
135
+ // Skipped outside a host, so validate-only installs and tests never spawn claude.
136
+ if agentDir != "" && cfg.Provider.Plan == nil {
137
+ plan, err := bridge.DetectPlan(context.Background(), cfg.Provider)
138
+ if err != nil {
139
+ cfg.Problems = append(cfg.Problems, fmt.Sprintf("claude-bridge: plan detection failed: %v", err))
140
+ }
141
+ if plan != "" {
142
+ cfg.Provider.Plan = &plan
143
+ }
144
+ }
121
145
  b := bridge.New(bridge.Deps{
122
146
  Config: cfg,
123
147
  AgentDir: agentDir,
@@ -144,6 +168,26 @@ func Extension() *sdk.Extension {
144
168
  "streamSimple": sdk.ProviderStreamSimpleFunc(streamSimple),
145
169
  })
146
170
 
171
+ ext.Command("claude-fast", "Toggle Claude Code fast mode for this session (on|off)", func(ctx sdk.Context, args string) error {
172
+ host := sdkHost{ctx}
173
+ switch strings.TrimSpace(args) {
174
+ case "":
175
+ b.SetFastMode(host, !b.FastMode())
176
+ case "on":
177
+ b.SetFastMode(host, true)
178
+ case "off":
179
+ b.SetFastMode(host, false)
180
+ default:
181
+ return fmt.Errorf("usage: /claude-fast [on|off]")
182
+ }
183
+ state := "off"
184
+ if b.FastMode() {
185
+ state = "on (from the next turn, on models that support it)"
186
+ }
187
+ host.Notify("Claude fast mode "+state, "info")
188
+ return nil
189
+ })
190
+
147
191
  if a := cfg.AskClaude; a != nil && a.Enabled != nil && *a.Enabled {
148
192
  ext.RegisterTool(askclaude.Definition(b, cfg, agentDir,
149
193
  func(ctx sdk.Context) bridge.Host { return sdkHost{ctx} }, requestContext))
@@ -117,6 +117,7 @@ func (b *Bridge) PromptAndWait(ctx context.Context, host Host, r AskRequest) (te
117
117
  // the skills block, the only channel. The preset prompt is unconditional: without it
118
118
  // the child lacks the tool and permission guidance the bridge relies on.
119
119
  settings := config.ClaudeCodeSettings(b.deps.Config.Provider)
120
+ b.applyFastMode(settings)
120
121
  settings["claudeMdExcludes"] = claudeMdExcludes
121
122
  debugFile, onStderr := b.cliDebugOptions("askclaude")
122
123
  opts := ccproto.Options{
@@ -121,6 +121,7 @@ func (b *Bridge) hostSessionID(host Host, event string) string {
121
121
  // session is a fresh conversation.
122
122
  func (b *Bridge) OnSessionStart(host Host, reason string) {
123
123
  defer b.recoverHandler("session_start")
124
+ b.showStatus(host)
124
125
  b.rememberSessionCwd(b.hostSessionID(host, "session_start"), host.Cwd())
125
126
  switch reason {
126
127
  case "new", "resume", "fork":
@@ -71,6 +71,12 @@ type Bridge struct {
71
71
  // captures is the prompt-capture registry, guarded by its own mutex.
72
72
  captures *promptcapture.Captures
73
73
  longContext models.LongContextSettings
74
+ // fastMode adds fastMode to Claude Code's --settings; /claude-fast flips it mid-session.
75
+ fastMode atomic.Bool
76
+ // plan is provider.plan as configured or detected, "" when unknown; the footer shows it.
77
+ plan string
78
+ // lastUsage is the usage windows the footer last showed, guarded by mu.
79
+ lastUsage string
74
80
 
75
81
  // queries tracks the goroutines a provider call starts, so a shutdown or a test
76
82
  // can wait for them.
@@ -95,6 +101,12 @@ func New(d Deps) *Bridge {
95
101
  cwds: map[string]string{},
96
102
  longContext: LongContextSettings(d.Config.Provider),
97
103
  }
104
+ if p := d.Config.Provider; p != nil && p.FastMode != nil {
105
+ b.fastMode.Store(*p.FastMode)
106
+ }
107
+ if p := d.Config.Provider; p != nil && p.Plan != nil {
108
+ b.plan = *p.Plan
109
+ }
98
110
  b.captures = promptcapture.NewCaptures(0, b.debugCaptureMiss)
99
111
  b.queueStartupNotices()
100
112
  for _, p := range d.Config.Problems {
@@ -1,9 +1,11 @@
1
1
  package bridge
2
2
 
3
3
  import (
4
+ "context"
4
5
  "fmt"
5
6
  "strings"
6
7
 
8
+ "github.com/balcsida/pig-claude-bridge/extensions/pig-claude-bridge/internal/ccproto"
7
9
  "github.com/balcsida/pig-claude-bridge/extensions/pig-claude-bridge/internal/config"
8
10
  "github.com/balcsida/pig-claude-bridge/extensions/pig-claude-bridge/internal/models"
9
11
  "github.com/balcsida/pig-claude-bridge/extensions/pig-claude-bridge/internal/promptcapture"
@@ -102,6 +104,44 @@ func LongContextSettings(p *config.ProviderConfig) models.LongContextSettings {
102
104
  return s
103
105
  }
104
106
 
107
+ // DetectPlan asks Claude Code for the subscription behind its login, for when provider.plan
108
+ // is unset. A subscription the 1M rules don't know is not returned, so the notice still asks.
109
+ func DetectPlan(ctx context.Context, p *config.ProviderConfig) (string, error) {
110
+ configured := ""
111
+ if p != nil && p.PathToClaudeCodeExecutable != nil {
112
+ configured = *p.PathToClaudeCodeExecutable
113
+ }
114
+ exe, err := ccproto.ResolveExecutable(configured)
115
+ if err != nil {
116
+ return "", err
117
+ }
118
+ sub, err := ccproto.SubscriptionType(ctx, exe)
119
+ switch sub {
120
+ case "pro", "max", "ultra", "team", "enterprise":
121
+ return sub, err
122
+ }
123
+ return "", err
124
+ }
125
+
126
+ // SetFastMode turns Claude Code's fast mode on or off from the next turn on and updates
127
+ // host's footer; each turn spawns a fresh Claude Code, so a live query keeps the mode it
128
+ // started with.
129
+ func (b *Bridge) SetFastMode(host Host, on bool) {
130
+ b.fastMode.Store(on)
131
+ b.showStatus(host)
132
+ }
133
+
134
+ // FastMode reports whether the next turn asks Claude Code for fast mode.
135
+ func (b *Bridge) FastMode() bool { return b.fastMode.Load() }
136
+
137
+ // applyFastMode opts settings into fast mode. Claude Code under the SDK keeps fast mode off
138
+ // (sdk_opt_in_required) unless --settings carries fastMode; a model without fast mode ignores it.
139
+ func (b *Bridge) applyFastMode(settings map[string]any) {
140
+ if b.fastMode.Load() {
141
+ settings["fastMode"] = true
142
+ }
143
+ }
144
+
105
145
  // reasoningToEffort maps pi reasoning levels to Claude Code effort levels.
106
146
  var reasoningToEffort = map[string]string{
107
147
  "minimal": "low", "low": "low", "medium": "medium", "high": "high", "xhigh": "max", "max": "max",
@@ -347,6 +347,7 @@ func (b *Bridge) freshQuery(ctx context.Context, host Host, stream *sdk.ModelEve
347
347
  settings := config.ClaudeCodeSettings(providerSettings)
348
348
  settings["claudeMdExcludes"] = claudeMdExcludes
349
349
  settings["includeGitInstructions"] = false
350
+ b.applyFastMode(settings)
350
351
  debugFile, onStderr := b.cliDebugOptions("provider")
351
352
  opts := ccproto.Options{
352
353
  Executable: exe, Cwd: cwd, Effort: effort, Resume: syncResult.SessionID,
@@ -1,12 +1,17 @@
1
1
  package bridge
2
2
 
3
3
  import (
4
+ "context"
4
5
  "encoding/json"
5
6
  "fmt"
7
+ "maps"
6
8
  "math"
9
+ "slices"
7
10
  "strconv"
8
11
  "strings"
9
12
  "time"
13
+
14
+ "github.com/balcsida/pig-claude-bridge/extensions/pig-claude-bridge/internal/ccproto"
10
15
  )
11
16
 
12
17
  const rateLimitHeaderPrefix = "anthropic-ratelimit-unified"
@@ -199,6 +204,189 @@ func (b *Bridge) handleRateLimitEvent(host Host, q *QueryContext, info RateLimit
199
204
  if notice != "" && host != nil { // the TypeScript's piUI?.notify
200
205
  host.Notify(notice, "warning")
201
206
  }
207
+ q.mu.Lock()
208
+ client := q.activeQuery
209
+ q.mu.Unlock()
210
+ b.showUsage(host, client, info.UnifiedWindows)
211
+ }
212
+
213
+ // showUsage puts the plan's usage in the footer status. The event's windows lack the
214
+ // per-model limits' names, so it asks the live CLI for its /usage rows and falls back
215
+ // to the windows when there is no client or the CLI does not answer. An empty usage
216
+ // keeps the last one shown.
217
+ func (b *Bridge) showUsage(host Host, client ccproto.Client, windows map[string]*UsageWindow) {
218
+ if _, ok := host.(StatusSetter); !ok {
219
+ return
220
+ }
221
+ set := func(usage string) {
222
+ if usage != "" {
223
+ b.mu.Lock()
224
+ b.lastUsage = usage
225
+ b.mu.Unlock()
226
+ b.showStatus(host)
227
+ }
228
+ }
229
+ if client == nil {
230
+ set(usageStatus(windows, b.deps.Now()))
231
+ return
232
+ }
233
+ b.queries.Add(1)
234
+ go func() {
235
+ defer b.queries.Done()
236
+ ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
237
+ defer cancel()
238
+ reply, err := client.Usage(ctx)
239
+ status := planUsageStatus(reply, b.deps.Now())
240
+ if err != nil || status == "" {
241
+ b.debugf("rate limit: get_usage unusable (%v), showing the event's windows", err)
242
+ status = usageStatus(windows, b.deps.Now())
243
+ }
244
+ set(status)
245
+ }()
246
+ }
247
+
248
+ // UsageStatusKey is the footer status entry that shows the plan, fast mode and usage windows.
249
+ const UsageStatusKey = "claude-usage"
250
+
251
+ // StatusSetter is a Host that can show a footer status; hosts without one skip it.
252
+ type StatusSetter interface {
253
+ SetStatus(key, text string)
254
+ ClearStatus(key string)
255
+ }
256
+
257
+ // showStatus renders the footer entry as "Claude Max · fast · 5h 6% ↻3h · 7d 76% ↻8h": the
258
+ // plan when configured or detected, "fast" while fast mode is on, then the last usage
259
+ // windows. With none of the three the entry is removed.
260
+ func (b *Bridge) showStatus(host any) {
261
+ s, ok := host.(StatusSetter)
262
+ if !ok {
263
+ return
264
+ }
265
+ var parts []string
266
+ if b.plan != "" {
267
+ parts = append(parts, strings.ToUpper(b.plan[:1])+b.plan[1:])
268
+ }
269
+ if b.fastMode.Load() {
270
+ parts = append(parts, "fast")
271
+ }
272
+ b.mu.Lock()
273
+ if b.lastUsage != "" {
274
+ parts = append(parts, b.lastUsage)
275
+ }
276
+ b.mu.Unlock()
277
+ if len(parts) == 0 {
278
+ s.ClearStatus(UsageStatusKey)
279
+ return
280
+ }
281
+ s.SetStatus(UsageStatusKey, "Claude "+strings.Join(parts, " · "))
282
+ }
283
+
284
+ // usageStatus renders the unified windows as "5h 14% ↻3h · 7d 1% ↻2d", or ""
285
+ // when no window carries a utilization.
286
+ func usageStatus(windows map[string]*UsageWindow, now time.Time) string {
287
+ var parts []string
288
+ for _, name := range slices.Sorted(maps.Keys(windows)) {
289
+ w := windows[name]
290
+ if w == nil || w.Utilization == nil {
291
+ continue
292
+ }
293
+ label, ok := windowAbbreviations[name]
294
+ if !ok {
295
+ label = name
296
+ }
297
+ var resetsAt time.Time
298
+ if w.ResetsAt != nil {
299
+ resetsAt = time.Unix(int64(*w.ResetsAt), 0)
300
+ }
301
+ parts = append(parts, fmt.Sprintf("%s %d%%", label, int(math.Floor(*w.Utilization*100+0.5)))+resetsIn(resetsAt, now))
302
+ }
303
+ return strings.Join(parts, " · ")
304
+ }
305
+
306
+ // resetsIn is " ↻3h": the time to a window's reset, floored to its largest unit as the
307
+ // Claude app shows it, or "" when the reset is unknown or past.
308
+ func resetsIn(at, now time.Time) string {
309
+ d := at.Sub(now)
310
+ switch {
311
+ case at.IsZero() || d <= 0:
312
+ return ""
313
+ case d < time.Hour:
314
+ return fmt.Sprintf(" ↻%dm", max(1, int(d/time.Minute)))
315
+ case d < 24*time.Hour:
316
+ return fmt.Sprintf(" ↻%dh", int(d/time.Hour))
317
+ }
318
+ return fmt.Sprintf(" ↻%dd", int(d/(24*time.Hour)))
319
+ }
320
+
321
+ // usageWindow is one keyed limit of a get_usage reply; utilization is a percentage.
322
+ type usageWindow struct {
323
+ DisplayName string `json:"display_name"`
324
+ Utilization float64 `json:"utilization"`
325
+ ResetsAt time.Time `json:"resets_at"`
326
+ }
327
+
328
+ // usageReply is the part of a get_usage reply the footer reads. It reads the keyed
329
+ // windows, not the labelled limits[] rows, which the server sometimes leaves out.
330
+ type usageReply struct {
331
+ RateLimits *struct {
332
+ FiveHour *usageWindow `json:"five_hour"`
333
+ SevenDay *usageWindow `json:"seven_day"`
334
+ ModelScoped []usageWindow `json:"model_scoped"`
335
+ CedarEmber any `json:"cedar_ember"`
336
+ } `json:"rate_limits"`
337
+ }
338
+
339
+ // planUsageStatus renders a get_usage reply as
340
+ // "5h 6% ↻3h · 7d 76% ↻8h · Fable 99% ↻8h", plus "reset available" when the
341
+ // account holds an unused rate-limit reset. "" when the reply has no windows.
342
+ func planUsageStatus(raw json.RawMessage, now time.Time) string {
343
+ var r usageReply
344
+ if json.Unmarshal(raw, &r) != nil || r.RateLimits == nil {
345
+ return ""
346
+ }
347
+ var parts []string
348
+ add := func(label string, w *usageWindow) {
349
+ if w != nil && label != "" {
350
+ parts = append(parts, fmt.Sprintf("%s %d%%", label, int(math.Round(w.Utilization)))+resetsIn(w.ResetsAt, now))
351
+ }
352
+ }
353
+ add("5h", r.RateLimits.FiveHour)
354
+ add("7d", r.RateLimits.SevenDay)
355
+ for i := range r.RateLimits.ModelScoped {
356
+ add(r.RateLimits.ModelScoped[i].DisplayName, &r.RateLimits.ModelScoped[i])
357
+ }
358
+ if len(parts) == 0 {
359
+ return ""
360
+ }
361
+ if holdsUnusedReset(r.RateLimits.CedarEmber) {
362
+ parts = append(parts, "reset available")
363
+ }
364
+ return strings.Join(parts, " · ")
365
+ }
366
+
367
+ // holdsUnusedReset reports whether the usage reply's cedar_ember block (the reset
368
+ // grants /reset_rate_limits claims) holds a grant with resets left.
369
+ // shortcut: the nesting of grants in the block is unobserved (it was null on the probed
370
+ // account), so any resets_left above 0 counts; pin it once a live reply carries a grant.
371
+ func holdsUnusedReset(v any) bool {
372
+ switch v := v.(type) {
373
+ case map[string]any:
374
+ if n, ok := v["resets_left"].(float64); ok && n > 0 {
375
+ return true
376
+ }
377
+ for _, child := range v {
378
+ if holdsUnusedReset(child) {
379
+ return true
380
+ }
381
+ }
382
+ case []any:
383
+ for _, child := range v {
384
+ if holdsUnusedReset(child) {
385
+ return true
386
+ }
387
+ }
388
+ }
389
+ return false
202
390
  }
203
391
 
204
392
  func sameFloat(a, b *float64) bool {
@@ -124,6 +124,12 @@ func (p *Process) Interrupt(ctx context.Context) (json.RawMessage, error) {
124
124
  return p.request(ctx, map[string]any{"subtype": "interrupt", "cancel_queued": true})
125
125
  }
126
126
 
127
+ // Usage sends get_usage and returns the raw /usage payload: the plan's labelled limit rows, among others.
128
+ // skip_behaviors skips the CLI's scan of local transcripts, which a usage meter does not need.
129
+ func (p *Process) Usage(ctx context.Context) (json.RawMessage, error) {
130
+ return p.request(ctx, map[string]any{"subtype": "get_usage", "skip_behaviors": true})
131
+ }
132
+
127
133
  func (p *Process) sendSuccess(id string, payload any) {
128
134
  _ = p.writeJSON(map[string]any{"type": "control_response", "response": map[string]any{
129
135
  "subtype": "success", "request_id": id, "response": payload}})
@@ -4,6 +4,8 @@ package ccproto
4
4
 
5
5
  import (
6
6
  "context"
7
+ "encoding/json"
8
+ "errors"
7
9
  "fmt"
8
10
  "os/exec"
9
11
  "path/filepath"
@@ -75,6 +77,31 @@ func Version(ctx context.Context, exe string) (string, error) {
75
77
  return m[1], nil
76
78
  }
77
79
 
80
+ // AuthStatusTimeout bounds `claude auth status`: it runs while the extension loads, which must not hang.
81
+ var AuthStatusTimeout = 5 * time.Second
82
+
83
+ // SubscriptionType runs `<exe> auth status --json` and returns its subscriptionType ("pro", "max",
84
+ // "team", "enterprise"); "" when the login is not a Claude subscription.
85
+ func SubscriptionType(ctx context.Context, exe string) (string, error) {
86
+ probe, cancel := context.WithTimeout(ctx, AuthStatusTimeout)
87
+ defer cancel()
88
+ name, args, err := commandLine(exe, "auth", "status", "--json")
89
+ if err != nil {
90
+ return "", err
91
+ }
92
+ cmd := exec.CommandContext(probe, name, args...)
93
+ cmd.WaitDelay = time.Second
94
+ // Logged out exits non-zero but still prints the JSON, so the output decides.
95
+ out, runErr := cmd.Output()
96
+ var status struct {
97
+ SubscriptionType string `json:"subscriptionType"`
98
+ }
99
+ if err := json.Unmarshal(out, &status); err != nil {
100
+ return "", fmt.Errorf("run %s auth status --json: %v (output %q)", exe, errors.Join(runErr, err), strings.TrimSpace(string(out)))
101
+ }
102
+ return status.SubscriptionType, nil
103
+ }
104
+
78
105
  // OlderThan reports whether version is numerically older than minimum; unparsable input is not older.
79
106
  func OlderThan(version, minimum string) bool {
80
107
  a, okA := parseSemver(version)
@@ -39,6 +39,7 @@ type Client interface {
39
39
  WriteUser(msg UserMessage) error
40
40
  EndInput() error
41
41
  Interrupt(ctx context.Context) (json.RawMessage, error)
42
+ Usage(ctx context.Context) (json.RawMessage, error)
42
43
  Close(ctx context.Context) error
43
44
  Wait() error
44
45
  StderrTail() string
@@ -43,8 +43,11 @@ type ProviderConfig struct {
43
43
  StrictMcpConfig *bool `json:"strictMcpConfig,omitempty"`
44
44
  AutoMemoryEnabled *bool `json:"autoMemoryEnabled,omitempty"`
45
45
  PathToClaudeCodeExecutable *string `json:"pathToClaudeCodeExecutable,omitempty"`
46
- // Plan is the subscription tier ("pro" | "max"). "max" enables Opus 4.6 at 1M context.
46
+ // Plan is the subscription tier ("pro" | "max" | "ultra" | "team" | "enterprise"). Every plan
47
+ // but "pro" enables Opus 4.6 at 1M context. Unset, the extension asks `claude auth status` at load.
47
48
  Plan *string `json:"plan,omitempty"`
49
+ // FastMode starts every session with Claude Code's fast mode on; /claude-fast toggles it.
50
+ FastMode *bool `json:"fastMode,omitempty"`
48
51
  // LongContextExtraUsage opts into metered 1M context usage. Enables Sonnet 4.6 [1m]
49
52
  // on every plan and Opus 4.6 [1m] on Pro.
50
53
  LongContextExtraUsage *bool `json:"longContextExtraUsage,omitempty"`
@@ -18,7 +18,7 @@ const (
18
18
 
19
19
  // LongContextSettings carries the plan facts that decide whether a [1m] id is served.
20
20
  type LongContextSettings struct {
21
- Plan string // "pro" or "max"
21
+ Plan string // "pro", "max", "ultra", "team" or "enterprise"
22
22
  LongContextExtraUsage bool
23
23
  // ForceTwoHundredK lists model ids whose declared 1M context Claude Code
24
24
  // turned out not to serve; they stay on the bare id at 200K.
@@ -58,8 +58,11 @@ var measuredOneM = map[string]bool{
58
58
  // planGatedOneM holds the measured exceptions: pi-ai declares 1M and the [1m]
59
59
  // id works, but only when the plan allows it.
60
60
  var planGatedOneM = map[string]func(LongContextSettings) bool{
61
- // [1m] measured 1M on Max plan / extra usage; 429 on Pro without it.
62
- "claude-opus-4-6": func(s LongContextSettings) bool { return s.Plan == "max" || s.LongContextExtraUsage },
61
+ // [1m] measured 1M on Max plan / extra usage; 429 on Pro without it. Max, Ultra, Team (Standard
62
+ // and Premium) and Enterprise include it, per code.claude.com/docs/en/model-config#extended-context.
63
+ "claude-opus-4-6": func(s LongContextSettings) bool {
64
+ return slices.Contains([]string{"max", "ultra", "team", "enterprise"}, s.Plan) || s.LongContextExtraUsage
65
+ },
63
66
  // [1m] measured 1M with extra usage only.
64
67
  "claude-sonnet-4-6": func(s LongContextSettings) bool { return s.LongContextExtraUsage },
65
68
  }
package/package.json CHANGED
@@ -1 +1 @@
1
- {"name":"pig-claude-bridge","version":"0.1.0","license":"MIT","repository":{"type":"git","url":"git+https://github.com/balcsida/pig-claude-bridge.git"},"files":["extensions","!**/*_test.go","!**/testdata","docs/compatibility.md","UPSTREAM.md","THIRD_PARTY_NOTICES.md","SECURITY.md"],"keywords":["pig-package"],"pi":{"extensions":["extensions/pig-claude-bridge"]}}
1
+ {"name":"pig-claude-bridge","version":"0.3.0","description":"Claude Code in PiG: Claude models as a provider and an AskClaude tool. A native Go port of pi-claude-bridge.","license":"MIT","repository":{"type":"git","url":"git+https://github.com/balcsida/pig-claude-bridge.git"},"files":["extensions","!**/*_test.go","!**/testdata","docs/compatibility.md","UPSTREAM.md","THIRD_PARTY_NOTICES.md","SECURITY.md"],"keywords":["pig-package"],"pi":{"extensions":["extensions/pig-claude-bridge"]}}