typeclaw 0.32.1 → 0.34.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.
Files changed (65) hide show
  1. package/auth.schema.json +66 -0
  2. package/cron.schema.json +26 -2
  3. package/package.json +1 -1
  4. package/secrets.schema.json +66 -0
  5. package/src/agent/index.ts +7 -3
  6. package/src/agent/session-origin.ts +17 -0
  7. package/src/agent/subagent-completion-reminder.ts +14 -1
  8. package/src/agent/subagent-drain.ts +2 -0
  9. package/src/agent/subagents.ts +21 -7
  10. package/src/agent/tools/channel-disengage.ts +66 -0
  11. package/src/agent/tools/channel-log.ts +3 -2
  12. package/src/agent/tools/spawn-subagent.ts +25 -5
  13. package/src/agent/tools/subagent-output.ts +13 -1
  14. package/src/bundled-plugins/guard/policies/managed-config.ts +1 -1
  15. package/src/bundled-plugins/memory/memory-logger.ts +7 -0
  16. package/src/bundled-plugins/researcher/researcher.ts +14 -11
  17. package/src/bundled-plugins/security/policies/outbound-secret-scan.ts +2 -0
  18. package/src/channels/adapters/line-channel-resolver.ts +129 -0
  19. package/src/channels/adapters/line-classify.ts +80 -0
  20. package/src/channels/adapters/line-format.ts +11 -0
  21. package/src/channels/adapters/line.ts +350 -0
  22. package/src/channels/engagement.ts +4 -2
  23. package/src/channels/manager.ts +65 -6
  24. package/src/channels/router.ts +186 -41
  25. package/src/channels/schema.ts +6 -1
  26. package/src/cli/channel.ts +112 -1
  27. package/src/cli/cron.ts +22 -4
  28. package/src/cli/init.ts +267 -82
  29. package/src/cli/model.ts +5 -1
  30. package/src/cli/oauth-callbacks.ts +5 -4
  31. package/src/cli/provider.ts +41 -10
  32. package/src/config/providers.ts +366 -7
  33. package/src/cron/consumer.ts +33 -0
  34. package/src/cron/count-state.ts +208 -0
  35. package/src/cron/index.ts +4 -17
  36. package/src/cron/list.ts +24 -6
  37. package/src/cron/scheduler.ts +84 -9
  38. package/src/cron/schema.ts +100 -13
  39. package/src/doctor/channel-checks.ts +28 -0
  40. package/src/hostd/daemon.ts +14 -6
  41. package/src/hostd/protocol.ts +6 -2
  42. package/src/init/gitignore.ts +1 -1
  43. package/src/init/index.ts +36 -3
  44. package/src/init/line-auth.ts +98 -0
  45. package/src/init/models-dev.ts +3 -0
  46. package/src/init/run-owner-claim.ts +1 -0
  47. package/src/init/validate-api-key.ts +15 -0
  48. package/src/inspect/label.ts +1 -0
  49. package/src/permissions/match-rule.ts +28 -12
  50. package/src/permissions/resolve.ts +8 -1
  51. package/src/role-claim/match-rule.ts +5 -1
  52. package/src/run/index.ts +41 -4
  53. package/src/secrets/line-store.ts +112 -0
  54. package/src/secrets/oauth-xai.ts +342 -0
  55. package/src/secrets/schema.ts +25 -0
  56. package/src/secrets/storage.ts +2 -0
  57. package/src/server/index.ts +17 -4
  58. package/src/shared/protocol.ts +4 -1
  59. package/src/skills/typeclaw-channel-line/SKILL.md +46 -0
  60. package/src/skills/typeclaw-channels/SKILL.md +153 -0
  61. package/src/skills/typeclaw-config/SKILL.md +54 -184
  62. package/src/skills/typeclaw-config/references/dockerfile.md +66 -0
  63. package/src/skills/typeclaw-cron/SKILL.md +68 -14
  64. package/src/skills/typeclaw-permissions/SKILL.md +3 -3
  65. package/typeclaw.schema.json +185 -3
@@ -2,10 +2,16 @@ import { cancel, intro, isCancel, log, password, select } from '@clack/prompts'
2
2
  import { defineCommand } from 'citty'
3
3
 
4
4
  import {
5
+ KNOWN_PROVIDER_VENDORS,
5
6
  KNOWN_PROVIDERS,
7
+ listKnownProviderVendorIds,
8
+ providerIdsForVendor,
6
9
  supportsApiKey as providerSupportsApiKey,
7
10
  supportsOAuth as providerSupportsOAuth,
11
+ variantHint,
12
+ variantLabel,
8
13
  type KnownProviderId,
14
+ type KnownProviderVendorId,
9
15
  } from '@/config/providers'
10
16
  import {
11
17
  addProvider,
@@ -270,15 +276,40 @@ function validateKnownProvider(input: string): KnownProviderId {
270
276
 
271
277
  async function resolveProviderForAdd(input: string | undefined): Promise<KnownProviderId> {
272
278
  if (input !== undefined) return validateKnownProvider(input)
273
- const ids = Object.keys(KNOWN_PROVIDERS) as KnownProviderId[]
274
- const choice = await select<KnownProviderId>({
279
+ const vendorId = await pickVendorToAdd()
280
+ return await pickVariantToAdd(vendorId)
281
+ }
282
+
283
+ async function pickVendorToAdd(): Promise<KnownProviderVendorId> {
284
+ const vendorIds = listKnownProviderVendorIds()
285
+ const choice = await select<KnownProviderVendorId>({
275
286
  message: 'Pick a provider to add',
276
- options: ids.map((id) => ({
287
+ options: vendorIds.map((id) => ({
277
288
  value: id,
278
- label: KNOWN_PROVIDERS[id].name,
279
- hint: authHint(id),
289
+ label: KNOWN_PROVIDER_VENDORS[id].name,
290
+ hint: vendorAuthHint(id),
280
291
  })),
281
- initialValue: ids[0],
292
+ initialValue: vendorIds[0],
293
+ })
294
+ if (isCancel(choice)) {
295
+ cancel('Aborted.')
296
+ process.exit(0)
297
+ }
298
+ return choice
299
+ }
300
+
301
+ async function pickVariantToAdd(vendorId: KnownProviderVendorId): Promise<KnownProviderId> {
302
+ const variants = providerIdsForVendor(vendorId)
303
+ if (variants.length === 1) return variants[0]!
304
+ const choice = await select<KnownProviderId>({
305
+ message: `Pick a ${KNOWN_PROVIDER_VENDORS[vendorId].name} option`,
306
+ options: variants.map((id) => {
307
+ const hint = variantHint(vendorId, id)
308
+ return hint !== undefined
309
+ ? { value: id, label: variantLabel(vendorId, id), hint }
310
+ : { value: id, label: variantLabel(vendorId, id) }
311
+ }),
312
+ initialValue: variants[0],
282
313
  })
283
314
  if (isCancel(choice)) {
284
315
  cancel('Aborted.')
@@ -378,10 +409,10 @@ async function runOAuthLogin(cwd: string, providerId: KnownProviderId): Promise<
378
409
  }
379
410
  }
380
411
 
381
- function authHint(id: KnownProviderId): string {
382
- const provider = KNOWN_PROVIDERS[id]
383
- const apiKey = providerSupportsApiKey(provider)
384
- const oauth = providerSupportsOAuth(provider)
412
+ function vendorAuthHint(vendorId: KnownProviderVendorId): string {
413
+ const providers = providerIdsForVendor(vendorId)
414
+ const apiKey = providers.some((id) => providerSupportsApiKey(KNOWN_PROVIDERS[id]))
415
+ const oauth = providers.some((id) => providerSupportsOAuth(KNOWN_PROVIDERS[id]))
385
416
  if (apiKey && oauth) return 'API key or OAuth'
386
417
  if (oauth) return 'OAuth only'
387
418
  return 'API key'
@@ -41,6 +41,17 @@ type KnownProvider = {
41
41
  // e.g. `OPENAI_API_KEY`). For `oauth` providers, `oauthProviderId` MUST match
42
42
  // a pi-ai OAuth provider id exactly, otherwise `authStorage.login()` will
43
43
  // throw "Unknown OAuth provider".
44
+ //
45
+ // Granularity rule (split vs merge): a provider id is the runtime API surface,
46
+ // not the brand. Different API call => different provider id; same API call =>
47
+ // same provider id. "Same API call" means same endpoint + same wire transport.
48
+ // So `anthropic` is ONE id because api-key and oauth hit the same
49
+ // /v1/messages endpoint (only the auth header differs), while `openai` /
50
+ // `openai-codex` and `zai` / `zai-coding` are SEPARATE ids because each pair
51
+ // targets different endpoints (and env vars). The user-facing brand grouping
52
+ // lives in `KNOWN_PROVIDER_VENDORS` below — keep it out of this decision.
53
+ // Renaming an id is a breaking change to secrets.json keys and typeclaw.json
54
+ // model refs; only do it behind a migration in a dedicated major-version PR.
44
55
  export const KNOWN_PROVIDERS = {
45
56
  openai: {
46
57
  id: 'openai',
@@ -119,13 +130,12 @@ export const KNOWN_PROVIDERS = {
119
130
  // these ids work end-to-end as long as the Codex backend itself accepts
120
131
  // them, which it does for ChatGPT Plus/Pro accounts as of 2026-05-10.
121
132
  //
122
- // Position-load-bearing: must stay adjacent to `openai`. The init wizard's
123
- // provider picker, `provider --help`'s `id | id | ...` listing, and the
124
- // generated JSON schema's model-ref enum all derive their ordering from
125
- // Object.keys() iteration on this literal. Alphabetizing the registry
126
- // would scatter `openai-codex` after `fireworks` and re-introduce the
127
- // "OpenAI ... Anthropic ... OpenAI" picker order this comment exists to
128
- // prevent.
133
+ // Position-load-bearing: must stay adjacent to `openai`. `provider --help`'s
134
+ // `id | id | ...` listing and the generated JSON schema's model-ref enum
135
+ // derive their ordering from Object.keys() iteration on this literal, so
136
+ // alphabetizing the registry would scatter `openai-codex` after `fireworks`.
137
+ // (The init wizard's picker no longer depends on this order — it groups by
138
+ // `KNOWN_PROVIDER_VENDORS` below.)
129
139
  'openai-codex': {
130
140
  id: 'openai-codex',
131
141
  name: 'OpenAI Codex (ChatGPT Plus/Pro)',
@@ -453,10 +463,359 @@ export const KNOWN_PROVIDERS = {
453
463
  },
454
464
  },
455
465
  },
466
+ // xAI (Grok). The native developer API at api.x.ai/v1 is OpenAI-compatible
467
+ // (Bearer auth + /chat/completions shape), so models go through pi-ai's
468
+ // `openai-completions` adapter with a custom baseUrl — same trick as
469
+ // Fireworks and Z.AI. This is a DUAL-AUTH provider like `anthropic`:
470
+ // * api-key — XAI_API_KEY (the standard xAI env var), a plain Bearer token.
471
+ // * oauth — Grok subscription login via xAI's OIDC server (auth.x.ai),
472
+ // authorization-code + PKCE. pi-ai ships no built-in xAI OAuth provider,
473
+ // so `oauthProviderId: 'xai'` resolves to the custom provider registered
474
+ // in src/secrets/oauth-xai.ts (registered from createSecretsStoreForAgent
475
+ // so both init-login and runtime-refresh see it). The dual-auth runtime
476
+ // rule in src/agent/auth.ts applies: an OAuth credential on disk wins over
477
+ // XAI_API_KEY in .env — remove it (`typeclaw provider remove xai`) to fall
478
+ // back to the key.
479
+ //
480
+ // Costs and context windows mirror docs.x.ai/developers/models and the raw
481
+ // /v1/models price fields as of 2026-06-08 (xAI quotes prices in cents per
482
+ // 100M tokens; e.g. grok-4.3 prompt 12500 = $1.25/1M). grok-4.3 is the
483
+ // flagship default; grok-build-0.1 is the coding-tuned model. The
484
+ // grok-4.20-0309 snapshots are pinned weights for reproducible runs.
485
+ //
486
+ // The earlier grok-4 / grok-4-fast / grok-code-fast-1 ids were RETIRED on
487
+ // 2026-05-15 — they still resolve but silently redirect (and bill) at the
488
+ // grok-4.3 / grok-build-0.1 rates, so they are intentionally NOT listed.
489
+ //
490
+ // cacheWrite is 0: xAI publishes no cache-write price (caching is implicit,
491
+ // billed only at the cacheRead rate). Models 1-4 also carry a long-context
492
+ // tier (2x rates above a 200k-token request); pi-ai's Model shape can't
493
+ // express tiered pricing, so the standard rate is used and the breakpoint is
494
+ // noted here. When refreshing, rerun `scripts/generate-schema.ts`.
495
+ xai: {
496
+ id: 'xai',
497
+ name: 'xAI (Grok)',
498
+ baseUrl: 'https://api.x.ai/v1',
499
+ auth: ['api-key', 'oauth'],
500
+ apiKeyEnv: 'XAI_API_KEY',
501
+ oauthProviderId: 'xai',
502
+ models: {
503
+ 'grok-4.3': {
504
+ id: 'grok-4.3',
505
+ name: 'Grok 4.3',
506
+ api: 'openai-completions',
507
+ provider: 'xai',
508
+ baseUrl: 'https://api.x.ai/v1',
509
+ reasoning: true,
510
+ input: ['text', 'image'],
511
+ cost: { input: 1.25, output: 2.5, cacheRead: 0.2, cacheWrite: 0 },
512
+ contextWindow: 1000000,
513
+ maxTokens: 64000,
514
+ },
515
+ 'grok-4.20-0309-reasoning': {
516
+ id: 'grok-4.20-0309-reasoning',
517
+ name: 'Grok 4.20 (Reasoning)',
518
+ api: 'openai-completions',
519
+ provider: 'xai',
520
+ baseUrl: 'https://api.x.ai/v1',
521
+ reasoning: true,
522
+ input: ['text', 'image'],
523
+ cost: { input: 1.25, output: 2.5, cacheRead: 0.2, cacheWrite: 0 },
524
+ contextWindow: 1000000,
525
+ maxTokens: 64000,
526
+ },
527
+ 'grok-4.20-0309-non-reasoning': {
528
+ id: 'grok-4.20-0309-non-reasoning',
529
+ name: 'Grok 4.20 (Non-Reasoning)',
530
+ api: 'openai-completions',
531
+ provider: 'xai',
532
+ baseUrl: 'https://api.x.ai/v1',
533
+ reasoning: false,
534
+ input: ['text', 'image'],
535
+ cost: { input: 1.25, output: 2.5, cacheRead: 0.2, cacheWrite: 0 },
536
+ contextWindow: 1000000,
537
+ maxTokens: 64000,
538
+ },
539
+ 'grok-build-0.1': {
540
+ id: 'grok-build-0.1',
541
+ name: 'Grok Build 0.1',
542
+ api: 'openai-completions',
543
+ provider: 'xai',
544
+ baseUrl: 'https://api.x.ai/v1',
545
+ reasoning: true,
546
+ input: ['text', 'image'],
547
+ cost: { input: 1.0, output: 2.0, cacheRead: 0.2, cacheWrite: 0 },
548
+ contextWindow: 256000,
549
+ maxTokens: 64000,
550
+ },
551
+ },
552
+ },
553
+ // MiniMax (minimax.io) pay-as-you-go API. OpenAI-compatible (Bearer auth +
554
+ // /chat/completions shape), so models go through pi-ai's `openai-completions`
555
+ // adapter with a custom baseUrl — same trick as Fireworks and Z.AI.
556
+ //
557
+ // Endpoint choice: the international endpoint (api.minimax.io) is the global
558
+ // surface; the China endpoint (api.minimaxi.com) is a regional alternative on
559
+ // the same protocol. We pin the international one here — operators who need the
560
+ // China gateway can front it with an OpenAI-compatible proxy.
561
+ //
562
+ // Model lineup mirrors the OpenAI-compatible model enum on
563
+ // platform.minimax.io as of 2026-06-08: MiniMax-M3 (flagship, 1M context,
564
+ // image input, controllable reasoning) plus the M2 reasoning series
565
+ // (204,800 context, reasoning always on, text-only). The `-highspeed`
566
+ // billing-tier variants and the native-only `M2-her` / `MiniMax-Text-01` /
567
+ // `abab*` models are intentionally omitted — the first are duplicate weights
568
+ // on a pricier surface, the rest don't serve the OpenAI-compatible
569
+ // /chat/completions route this adapter speaks. Costs are USD per 1M tokens
570
+ // from docs/guides/pricing-paygo (standard tier): M3 reflects the permanent
571
+ // 50%-off ≤512K-input rate (input 0.30 / output 1.20 / cacheRead 0.06; M3 has
572
+ // no separate cache-write rate). M2.7 caches at 0.06 read / 0.375 write; the
573
+ // M2.5/M2.1/M2 series at 0.03 read / 0.375 write.
574
+ //
575
+ // M3 tiered pricing is NOT modeled: this single cost record only encodes the
576
+ // ≤512K-input tier. The >512K tier (input 0.60 / output 2.40 / cacheRead 0.12)
577
+ // is a limited-availability surcharge that pi-ai's flat per-model cost shape
578
+ // can't represent, so long-context M3 sessions above 512K under-report cost.
579
+ minimax: {
580
+ id: 'minimax',
581
+ name: 'MiniMax',
582
+ baseUrl: 'https://api.minimax.io/v1',
583
+ auth: ['api-key'],
584
+ apiKeyEnv: 'MINIMAX_API_KEY',
585
+ oauthProviderId: null,
586
+ models: {
587
+ 'MiniMax-M3': {
588
+ id: 'MiniMax-M3',
589
+ name: 'MiniMax M3',
590
+ api: 'openai-completions',
591
+ provider: 'minimax',
592
+ baseUrl: 'https://api.minimax.io/v1',
593
+ reasoning: true,
594
+ input: ['text', 'image'],
595
+ cost: { input: 0.3, output: 1.2, cacheRead: 0.06, cacheWrite: 0 },
596
+ contextWindow: 1000000,
597
+ maxTokens: 524288,
598
+ },
599
+ 'MiniMax-M2.7': {
600
+ id: 'MiniMax-M2.7',
601
+ name: 'MiniMax M2.7',
602
+ api: 'openai-completions',
603
+ provider: 'minimax',
604
+ baseUrl: 'https://api.minimax.io/v1',
605
+ reasoning: true,
606
+ input: ['text'],
607
+ cost: { input: 0.3, output: 1.2, cacheRead: 0.06, cacheWrite: 0.375 },
608
+ contextWindow: 204800,
609
+ maxTokens: 204800,
610
+ },
611
+ 'MiniMax-M2.5': {
612
+ id: 'MiniMax-M2.5',
613
+ name: 'MiniMax M2.5',
614
+ api: 'openai-completions',
615
+ provider: 'minimax',
616
+ baseUrl: 'https://api.minimax.io/v1',
617
+ reasoning: true,
618
+ input: ['text'],
619
+ cost: { input: 0.3, output: 1.2, cacheRead: 0.03, cacheWrite: 0.375 },
620
+ contextWindow: 204800,
621
+ maxTokens: 204800,
622
+ },
623
+ 'MiniMax-M2.1': {
624
+ id: 'MiniMax-M2.1',
625
+ name: 'MiniMax M2.1',
626
+ api: 'openai-completions',
627
+ provider: 'minimax',
628
+ baseUrl: 'https://api.minimax.io/v1',
629
+ reasoning: true,
630
+ input: ['text'],
631
+ cost: { input: 0.3, output: 1.2, cacheRead: 0.03, cacheWrite: 0.375 },
632
+ contextWindow: 204800,
633
+ maxTokens: 204800,
634
+ },
635
+ 'MiniMax-M2': {
636
+ id: 'MiniMax-M2',
637
+ name: 'MiniMax M2',
638
+ api: 'openai-completions',
639
+ provider: 'minimax',
640
+ baseUrl: 'https://api.minimax.io/v1',
641
+ reasoning: true,
642
+ input: ['text'],
643
+ cost: { input: 0.3, output: 1.2, cacheRead: 0.03, cacheWrite: 0.375 },
644
+ contextWindow: 204800,
645
+ maxTokens: 204800,
646
+ },
647
+ },
648
+ },
649
+ // DeepSeek (api.deepseek.com) pay-as-you-go API. OpenAI-compatible (Bearer
650
+ // auth + /chat/completions shape), so models go through pi-ai's
651
+ // `openai-completions` adapter with a custom baseUrl — same trick as
652
+ // Fireworks, Z.AI, and MiniMax. api-key only; DeepSeek ships no OAuth flow.
653
+ //
654
+ // baseUrl is bare `https://api.deepseek.com` (no `/v1` segment) — the SDK
655
+ // appends `/chat/completions`. This mirrors Anthropic's no-`/v1` convention,
656
+ // not the OpenAI/xAI `/v1` one; `validate-api-key.ts` probes
657
+ // `${baseUrl}/models` accordingly.
658
+ //
659
+ // Model lineup is the V4 generation as listed on api-docs.deepseek.com/quick_start/pricing
660
+ // as of 2026-06-08: deepseek-v4-flash (fast, cheap default) and
661
+ // deepseek-v4-pro (stronger). Both default to thinking/reasoning mode (it is
662
+ // toggleable upstream, but reasoning: true reflects the default). 1M context,
663
+ // 384K max output, text-only — DeepSeek's API exposes no image input. The
664
+ // legacy `deepseek-chat` / `deepseek-reasoner` aliases (deprecated 2026-07-24,
665
+ // they redirect into v4-flash's non-thinking/thinking modes) are intentionally
666
+ // omitted in favor of the canonical v4 ids.
667
+ //
668
+ // Costs are USD per 1M tokens (standard tier). DeepSeek prices input on a
669
+ // cache-miss/cache-hit split: `input` is the cache-miss rate, `cacheRead` is
670
+ // the cache-hit rate. There is no published cache-write surcharge, so
671
+ // cacheWrite is 0.
672
+ deepseek: {
673
+ id: 'deepseek',
674
+ name: 'DeepSeek',
675
+ baseUrl: 'https://api.deepseek.com',
676
+ auth: ['api-key'],
677
+ apiKeyEnv: 'DEEPSEEK_API_KEY',
678
+ oauthProviderId: null,
679
+ models: {
680
+ 'deepseek-v4-flash': {
681
+ id: 'deepseek-v4-flash',
682
+ name: 'DeepSeek V4 Flash',
683
+ api: 'openai-completions',
684
+ provider: 'deepseek',
685
+ baseUrl: 'https://api.deepseek.com',
686
+ reasoning: true,
687
+ input: ['text'],
688
+ cost: { input: 0.14, output: 0.28, cacheRead: 0.0028, cacheWrite: 0 },
689
+ contextWindow: 1000000,
690
+ maxTokens: 384000,
691
+ },
692
+ 'deepseek-v4-pro': {
693
+ id: 'deepseek-v4-pro',
694
+ name: 'DeepSeek V4 Pro',
695
+ api: 'openai-completions',
696
+ provider: 'deepseek',
697
+ baseUrl: 'https://api.deepseek.com',
698
+ reasoning: true,
699
+ input: ['text'],
700
+ cost: { input: 0.435, output: 0.87, cacheRead: 0.003625, cacheWrite: 0 },
701
+ contextWindow: 1000000,
702
+ maxTokens: 384000,
703
+ },
704
+ },
705
+ },
456
706
  } as const satisfies Record<string, KnownProvider>
457
707
 
458
708
  export type KnownProviderId = keyof typeof KNOWN_PROVIDERS
459
709
 
710
+ // UX-only grouping of provider ids under one vendor for the init/`provider
711
+ // add` pickers. Deliberately does NOT touch the runtime contract:
712
+ // `KnownProviderId`, `KnownModelRef`, secrets.json keys, auth resolution, and
713
+ // the generated schema all stay keyed on the flat ids in `KNOWN_PROVIDERS`.
714
+ // The follow-up "variant" prompt resolves a concrete provider id, then
715
+ // `pickAuthMethod` runs as before; it is auto-resolved for single-provider
716
+ // vendors (Fireworks, Anthropic). `variants` copy lets the prompt read as an
717
+ // auth choice for OpenAI but a plan choice for Z.AI (both api-key, different
718
+ // billing surfaces).
719
+ type KnownProviderVendor = {
720
+ id: string
721
+ name: string
722
+ providers: ReadonlyArray<KnownProviderId>
723
+ variants?: Partial<Record<KnownProviderId, { label: string; hint?: string }>>
724
+ }
725
+
726
+ // Ordered by product priority for the picker — independent of the
727
+ // `KNOWN_PROVIDERS` declaration order (which stays load-bearing for the schema
728
+ // enum and `provider --help` listing). Every provider id below MUST appear in
729
+ // exactly one vendor; `providers.test.ts` enforces the partition.
730
+ export const KNOWN_PROVIDER_VENDORS = {
731
+ openai: {
732
+ id: 'openai',
733
+ name: 'OpenAI',
734
+ providers: ['openai', 'openai-codex'],
735
+ variants: {
736
+ openai: { label: 'API key', hint: 'OpenAI API platform' },
737
+ 'openai-codex': { label: 'OAuth (ChatGPT Plus/Pro)', hint: 'ChatGPT subscription' },
738
+ },
739
+ },
740
+ anthropic: {
741
+ id: 'anthropic',
742
+ name: 'Anthropic',
743
+ providers: ['anthropic'],
744
+ },
745
+ fireworks: {
746
+ id: 'fireworks',
747
+ name: 'Fireworks',
748
+ providers: ['fireworks'],
749
+ },
750
+ zai: {
751
+ id: 'zai',
752
+ name: 'Z.AI',
753
+ providers: ['zai', 'zai-coding'],
754
+ variants: {
755
+ zai: { label: 'Pay-as-you-go', hint: 'standard API billing' },
756
+ 'zai-coding': { label: 'Coding Plan', hint: 'GLM Coding Plan subscription' },
757
+ },
758
+ },
759
+ xai: {
760
+ id: 'xai',
761
+ name: 'xAI (Grok)',
762
+ providers: ['xai'],
763
+ },
764
+ // Single-provider vendor: pay-as-you-go and Token Plan are the SAME provider
765
+ // id (same /v1 endpoint, same Bearer transport — only the key prefix differs),
766
+ // so per the granularity rule above MiniMax is one id like Anthropic, not a
767
+ // zai-style split. The picker shows one row and skips the variant prompt; the
768
+ // pay-as-you-go-vs-Token-Plan dashboard hint is handled in the key-entry step.
769
+ minimax: {
770
+ id: 'minimax',
771
+ name: 'MiniMax',
772
+ providers: ['minimax'],
773
+ },
774
+ deepseek: {
775
+ id: 'deepseek',
776
+ name: 'DeepSeek',
777
+ providers: ['deepseek'],
778
+ },
779
+ } as const satisfies Record<string, KnownProviderVendor>
780
+
781
+ export type KnownProviderVendorId = keyof typeof KNOWN_PROVIDER_VENDORS
782
+
783
+ export function listKnownProviderVendorIds(): KnownProviderVendorId[] {
784
+ return Object.keys(KNOWN_PROVIDER_VENDORS) as KnownProviderVendorId[]
785
+ }
786
+
787
+ export function providerIdsForVendor(vendorId: KnownProviderVendorId): ReadonlyArray<KnownProviderId> {
788
+ return KNOWN_PROVIDER_VENDORS[vendorId].providers
789
+ }
790
+
791
+ export function vendorForProviderId(providerId: KnownProviderId): KnownProviderVendorId {
792
+ for (const vendorId of listKnownProviderVendorIds()) {
793
+ if ((KNOWN_PROVIDER_VENDORS[vendorId].providers as ReadonlyArray<KnownProviderId>).includes(providerId)) {
794
+ return vendorId
795
+ }
796
+ }
797
+ throw new Error(`Provider ${providerId} is not assigned to any vendor in KNOWN_PROVIDER_VENDORS`)
798
+ }
799
+
800
+ function variantCopy(
801
+ vendorId: KnownProviderVendorId,
802
+ providerId: KnownProviderId,
803
+ ): { label: string; hint?: string } | undefined {
804
+ const vendor: KnownProviderVendor = KNOWN_PROVIDER_VENDORS[vendorId]
805
+ return vendor.variants?.[providerId]
806
+ }
807
+
808
+ // Falls back to the provider's own name when a vendor supplies no variant copy
809
+ // (single-provider vendors never render this prompt, so the fallback only
810
+ // guards against an incomplete `variants` map on a multi-provider vendor).
811
+ export function variantLabel(vendorId: KnownProviderVendorId, providerId: KnownProviderId): string {
812
+ return variantCopy(vendorId, providerId)?.label ?? KNOWN_PROVIDERS[providerId].name
813
+ }
814
+
815
+ export function variantHint(vendorId: KnownProviderVendorId, providerId: KnownProviderId): string | undefined {
816
+ return variantCopy(vendorId, providerId)?.hint
817
+ }
818
+
460
819
  export type KnownModelRef = {
461
820
  [P in KnownProviderId]: `${P}/${Extract<keyof (typeof KNOWN_PROVIDERS)[P]['models'], string>}`
462
821
  }[KnownProviderId]
@@ -56,9 +56,22 @@ export type CreateCronConsumerOptions = {
56
56
  // `ctx.exec`. Optional so unit-test fakes that never schedule handler jobs
57
57
  // stay one-liners.
58
58
  invokeHandler?: CronHandlerInvoker
59
+ // Authoritative count gate. The consumer — not the scheduler — owns
60
+ // accepted-fire accounting: it re-checks the durable count and increments
61
+ // only for runs that pass coalescing, so a coalesced skip never consumes a
62
+ // count. Optional so test fakes that don't exercise counts stay one-liners.
63
+ countStore?: ConsumerCountStore
64
+ now?: () => number
59
65
  logger?: CronConsumerLogger
60
66
  }
61
67
 
68
+ export type ConsumerCountStore = {
69
+ get: (id: string, job: CronJob) => number
70
+ // Resolves true if the fire was accepted/counted, false if the job is no
71
+ // longer live (so the consumer skips dispatching stale config).
72
+ increment: (id: string, job: CronJob, at: number) => Promise<boolean>
73
+ }
74
+
62
75
  export type CronConsumer = {
63
76
  start: () => void
64
77
  stop: () => void
@@ -76,6 +89,8 @@ export function createCronConsumer({
76
89
  cwd,
77
90
  createSessionForCron,
78
91
  invokeHandler,
92
+ countStore,
93
+ now = Date.now,
79
94
  logger = consoleLogger,
80
95
  }: CreateCronConsumerOptions): CronConsumer {
81
96
  const inFlight = new Set<string>()
@@ -94,8 +109,26 @@ export function createCronConsumer({
94
109
  logger.warn(`[cron] ${job.id}: previous run still in progress, skipping`)
95
110
  return
96
111
  }
112
+ // Reserve before the count gate so two close occurrences can't both
113
+ // pass the `firedCount < count` check before either increment lands.
97
114
  inFlight.add(job.id)
98
115
  try {
116
+ if (job.count !== undefined && countStore !== undefined) {
117
+ if (countStore.get(job.id, job) >= job.count) {
118
+ logger.info(`[cron] ${job.id}: count boundary reached, skipping`)
119
+ return
120
+ }
121
+ // Durably record the accepted fire BEFORE dispatch. A crash here
122
+ // consumes the count without running (at-most-count), which is the
123
+ // correct tradeoff for a reminder versus over-firing on restart.
124
+ // A false result means a reload removed/replaced the job while the
125
+ // write was queued — skip dispatch so we never run stale config.
126
+ const accepted = await countStore.increment(job.id, job, now())
127
+ if (!accepted) {
128
+ logger.info(`[cron] ${job.id}: job no longer live, skipping dispatch`)
129
+ return
130
+ }
131
+ }
99
132
  if (job.kind === 'prompt') {
100
133
  await runPrompt(job, createSessionForCron, stream, logger)
101
134
  } else if (job.kind === 'exec') {