@polderlabs/bizar 10.19.2 → 10.19.5

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.
@@ -40,6 +40,7 @@ export const HOOK_PROGRAMS = Object.freeze({
40
40
  'pretooluse-bash': 'pretooluse-bash.mjs',
41
41
  'pretooluse-editwrite': 'pretooluse-editwrite.mjs',
42
42
  'sessionend-recall': 'sessionend-recall.mjs',
43
+ 'sessionstart-model-sync': 'sessionstart-model-sync.mjs',
43
44
  'sessionstart-prime': 'sessionstart-prime.mjs',
44
45
  'simplify-guard': 'simplify-guard.mjs',
45
46
  telemetry: 'telemetry.mjs',
@@ -61,6 +62,7 @@ export const EVENT_CHAINS = Object.freeze({
61
62
  ]),
62
63
  'session-start': Object.freeze([
63
64
  'control-inbox',
65
+ 'sessionstart-model-sync',
64
66
  'sessionstart-prime',
65
67
  'persistent-mode',
66
68
  'telemetry',
@@ -592,6 +592,116 @@ export function applyModelOverrides({ settingsJsonPath, pickedIds, liveIds = []
592
592
  return { wrote: true, syncedIds: synced, skippedStale: skipped, settingsPath: path };
593
593
  }
594
594
 
595
+ /**
596
+ * Derive a human-readable label for a model ID. Used to populate the
597
+ * `modelPicker` array in settings.json so Claude Code's `/model` picker
598
+ * displays picked models with something nicer than the raw `provider/name`
599
+ * string.
600
+ *
601
+ * minimax/MiniMax-M3 → "MiniMax M3"
602
+ * codex/gpt-5.6-sol → "GPT 5.6 Sol"
603
+ * qct/qwen3.8-max-preview → "Qwen3.8 Max Preview"
604
+ * openrouter/nvidia/foo:free → "nvidia/foo:free"
605
+ *
606
+ * The gateway-reported `name` (when available on the picked profile) always
607
+ * wins. Falls back to a title-cased rendering of the model segment.
608
+ *
609
+ * @param {string} modelId
610
+ * @param {object} [profile] Optional profile with `name` or `displayName`
611
+ * @returns {string}
612
+ */
613
+ export function deriveModelLabel(modelId, profile) {
614
+ const name = profile && typeof profile.name === 'string' && profile.name.trim();
615
+ if (name) return name.trim();
616
+ const displayName = profile && typeof profile.displayName === 'string' && profile.displayName.trim();
617
+ if (displayName) return displayName.trim();
618
+ const id = String(modelId || '').trim();
619
+ if (!id) return '';
620
+ // Drop the leading provider segment (`minimax/MiniMax-M3` → `MiniMax-M3`)
621
+ // so the operator sees the model name, not the namespace.
622
+ const slash = id.indexOf('/');
623
+ const tail = slash >= 0 ? id.slice(slash + 1) : id;
624
+ // Split on word boundaries (hyphens / underscores / dots / colons / path
625
+ // separators) and join with spaces. Case is preserved verbatim — `gpt`
626
+ // stays `gpt`, `MiniMax` stays `MiniMax`, `M2.7` stays `M2.7`. Brand
627
+ // casing belongs to the gateway (`name` field), not us.
628
+ return tail
629
+ .replace(/[\\/]+/g, ' ')
630
+ .replace(/[-_]+/g, ' ')
631
+ .replace(/:/g, ' ')
632
+ .replace(/\s+/g, ' ')
633
+ .trim();
634
+ }
635
+
636
+ /**
637
+ * Sync `userSelected.models` into Claude Code's `modelPicker` setting
638
+ * (settings.json). The picker is what populates `/model` — `modelOverrides`
639
+ * alone only silences diagnostics, it does NOT add entries to the picker.
640
+ *
641
+ * Per Claude Code's settings reference: `modelPicker` is an OBJECT with an
642
+ * `options` array. Each option has `{ model, label?, description? }`.
643
+ * Scope is User-or-managed (settings.json is in scope). The `options`
644
+ * array preserves the operator's pick order from `userSelected.models`.
645
+ *
646
+ * Behavior:
647
+ * - Reads settings.json; preserves every other field (env, mcpServers,
648
+ * permissions, hooks, etc.).
649
+ * - Writes `modelPicker = { options: [{ model, label }] }`.
650
+ * - Filters out picks the live gateway rejects (same stale-ID contract as
651
+ * `applyModelOverrides`); the surviving picks fill the picker.
652
+ * - Empty pick list → writes `modelPicker: { options: [] }` so Claude
653
+ * Code falls back to its built-in picker.
654
+ * - Atomic replace via temp-file + rename (matches `applyModels`).
655
+ * - Refuses to overwrite a corrupt settings.json.
656
+ * - When `settingsJsonPath === null`, returns a no-op (tests).
657
+ *
658
+ * @param {{
659
+ * settingsJsonPath?: string|null,
660
+ * pickedIds: string[],
661
+ * profiles?: Record<string, object>,
662
+ * liveIds?: string[],
663
+ * }} opts
664
+ * @returns {{
665
+ * wrote: boolean,
666
+ * options: Array<{model: string, label: string, description?: string}>,
667
+ * skippedStale: string[],
668
+ * settingsPath: string|null,
669
+ * }}
670
+ */
671
+ export function applyModelPicker({ settingsJsonPath, pickedIds, profiles = {}, liveIds = [] }) {
672
+ const path = settingsJsonPath === undefined
673
+ ? join(homedir(), '.claude', 'settings.json')
674
+ : settingsJsonPath;
675
+ if (path === null) {
676
+ return { wrote: false, options: [], skippedStale: [], settingsPath: null };
677
+ }
678
+ const live = new Set(Array.isArray(liveIds) ? liveIds : []);
679
+ const picks = Array.isArray(pickedIds) ? pickedIds.filter((id) => typeof id === 'string' && id.trim()) : [];
680
+ const surviving = live.size === 0 ? picks : picks.filter((id) => live.has(id));
681
+ const skipped = live.size === 0 ? [] : picks.filter((id) => !live.has(id));
682
+ const options = surviving.map((id) => {
683
+ const profile = profiles?.[id];
684
+ const label = deriveModelLabel(id, profile);
685
+ const option = { model: id, label };
686
+ const description = profile && typeof profile.description === 'string' && profile.description.trim();
687
+ if (description) option.description = description.trim();
688
+ return option;
689
+ });
690
+
691
+ let settings = {};
692
+ if (existsSync(path)) {
693
+ try {
694
+ const parsed = JSON.parse(readFileSync(path, 'utf8'));
695
+ if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) settings = parsed;
696
+ } catch {
697
+ return { wrote: false, options: [], skippedStale: skipped, settingsPath: path };
698
+ }
699
+ }
700
+ settings.modelPicker = { options };
701
+ writeAtomic(path, JSON.stringify(settings, null, 2) + '\n');
702
+ return { wrote: true, options, skippedStale: skipped, settingsPath: path };
703
+ }
704
+
595
705
  // ── F-190 / IMP-017 refresh + operator-override preservation ────────────────
596
706
 
597
707
  /**
@@ -1409,8 +1519,9 @@ export async function run(name, args, isHelpRequest) {
1409
1519
  // `[claude-code:unrecognized_model]` for any ID the gateway later
1410
1520
  // rejects; the operator can re-run `bizar models` to drop them.
1411
1521
  const sync = applyModelOverrides({ pickedIds: ids, liveIds: [] });
1522
+ const picker = applyModelPicker({ pickedIds: ids, profiles: block.profiles || {}, liveIds: [] });
1412
1523
  if (wantJson) {
1413
- process.stdout.write(JSON.stringify({ applied: block, sync }, null, 2) + '\n');
1524
+ process.stdout.write(JSON.stringify({ applied: block, sync, picker }, null, 2) + '\n');
1414
1525
  } else {
1415
1526
  console.log(chalk.green(` v ${block.models.length} model(s) saved to userSelected`));
1416
1527
  for (const id of block.models) {
@@ -1504,9 +1615,10 @@ export async function run(name, args, isHelpRequest) {
1504
1615
  const picked = await pickModels({ candidates, current });
1505
1616
  if (picked.length === 0) {
1506
1617
  const block = applyModels({ routerPath, models: [], source: 'live-pick' });
1507
- // Clear Claude Code modelOverrides when the picker is emptied so the
1508
- // session no longer claims to recognise removed IDs.
1618
+ // Clear Claude Code modelOverrides + modelPicker when the picker is
1619
+ // emptied so the session no longer claims to recognise removed IDs.
1509
1620
  applyModelOverrides({ pickedIds: [], liveIds: [] });
1621
+ applyModelPicker({ pickedIds: [], liveIds: [] });
1510
1622
  if (wantJson) {
1511
1623
  process.stdout.write(JSON.stringify({ applied: block, endpoint, endpointSource }, null, 2) + '\n');
1512
1624
  } else {
@@ -1533,8 +1645,11 @@ export async function run(name, args, isHelpRequest) {
1533
1645
  block.staleIds = partition.staleIds;
1534
1646
  }
1535
1647
  const sync = applyModelOverrides({ pickedIds: picked, liveIds });
1648
+ // Sync the /model picker contents (`modelPicker` setting) so the user's
1649
+ // picks drive the picker without relying on gateway discovery.
1650
+ const picker = applyModelPicker({ pickedIds: picked, profiles, liveIds });
1536
1651
  if (wantJson) {
1537
- process.stdout.write(JSON.stringify({ applied: block, endpoint, endpointSource, sync }, null, 2) + '\n');
1652
+ process.stdout.write(JSON.stringify({ applied: block, endpoint, endpointSource, sync, picker }, null, 2) + '\n');
1538
1653
  } else {
1539
1654
  console.log(chalk.green(`\n v Saved ${block.models.length} model(s) to ${routerPath}:`));
1540
1655
  console.log(chalk.dim(` Models.dev profiles: ${Object.keys(block.profiles || {}).length}/${block.models.length}`));
@@ -1545,6 +1660,9 @@ export async function run(name, args, isHelpRequest) {
1545
1660
  if (sync.wrote && sync.skippedStale.length > 0) {
1546
1661
  console.log(chalk.yellow(` Settings sync skipped ${sync.skippedStale.length} stale id(s): ${sync.skippedStale.join(', ')}`));
1547
1662
  }
1663
+ if (picker.wrote) {
1664
+ console.log(chalk.dim(` /model picker populated with ${picker.options.length} entr${picker.options.length === 1 ? 'y' : 'ies'}`));
1665
+ }
1548
1666
  }
1549
1667
  return true;
1550
1668
  }
@@ -0,0 +1,202 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * sessionstart-model-sync.mjs — Claude Code SessionStart hook.
4
+ *
5
+ * Re-applies the operator's `userSelected.models` block (from
6
+ * `~/.config/bizar/config/claude/model-router.json`) into the three
7
+ * Claude Code settings keys that the operator owns:
8
+ *
9
+ * - modelPicker → { options: [{ model, label }] } in user pick order
10
+ * - modelOverrides → { <id>: <id> } self-map for every live pick
11
+ * - model → reset to the first user pick if the current value
12
+ * starts with `claude-` (the dead gateway namespace —
13
+ * the live gateway rejects it with model_not_found)
14
+ *
15
+ * Why this hook exists:
16
+ * Claude Code's `/model` picker rewrites `~/.claude/settings.json` on
17
+ * every user pick. The rewrite preserves most keys but drops `modelPicker`
18
+ * and rewrites `model` to whatever the gateway returned (often the dead
19
+ * `claude-<provider>/<model>[1m]` shape). Without this hook the operator
20
+ * loses their picker list between every Claude Code session.
21
+ *
22
+ * Scope guarantee:
23
+ * This hook ONLY touches `modelPicker`, `modelOverrides`, and `model`.
24
+ * Env, mcpServers, permissions, hooks, and every other operator key is
25
+ * left untouched. The source of truth for picks
26
+ * (`~/.config/bizar/config/claude/model-router.json`) is also untouched.
27
+ *
28
+ * Failure policy:
29
+ * This is an advisory hook. Any failure (missing router, malformed JSON,
30
+ * unwritable settings.json, etc.) is logged to
31
+ * `~/.config/bizar/hook-logs/model-sync-DATE.jsonl` and swallowed. The
32
+ * hook always exits 0 — the operator's existing picks survive whatever
33
+ * Claude Code wrote last, and a broken sync must not block session start.
34
+ *
35
+ * Claude Code SessionStart input:
36
+ * { session_id, transcript_path, cwd, hook_event_name, source }
37
+ *
38
+ * Claude Code SessionStart output:
39
+ * { hookSpecificOutput: { hookEventName: 'SessionStart', additionalContext } }
40
+ */
41
+
42
+ 'use strict';
43
+
44
+ import { appendFileSync, existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
45
+ import { dirname, join } from 'node:path';
46
+ import { homedir } from 'node:os';
47
+
48
+ const HOOK_LOG_DIR = join(homedir(), '.config', 'bizar', 'hook-logs');
49
+
50
+ function readJsonIfObject(path) {
51
+ if (!existsSync(path)) return null;
52
+ try {
53
+ const parsed = JSON.parse(readFileSync(path, 'utf8'));
54
+ return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : null;
55
+ } catch {
56
+ return null;
57
+ }
58
+ }
59
+
60
+ function readRouterPath() {
61
+ const envOverride = process.env.BIZAR_MODEL_ROUTER_CONFIG;
62
+ if (envOverride && typeof envOverride === 'string' && envOverride.trim()) {
63
+ return envOverride;
64
+ }
65
+ return join(homedir(), '.config', 'bizar', 'config', 'claude', 'model-router.json');
66
+ }
67
+
68
+ function readSettingsPath() {
69
+ return join(homedir(), '.claude', 'settings.json');
70
+ }
71
+
72
+ // Mirrors `cli/commands/models.mjs#deriveModelLabel` without the profile
73
+ // override branch. The picker label is meant to be derived from the model
74
+ // id; the profile `name`/`displayName` is used elsewhere in `applyModelPicker`
75
+ // only when present, but for our session-start re-apply the id-derived label
76
+ // is sufficient and matches the helper's behaviour for every live id in the
77
+ // router file.
78
+ function deriveModelLabel(modelId) {
79
+ const id = String(modelId || '').trim();
80
+ if (!id) return '';
81
+ const slash = id.indexOf('/');
82
+ const tail = slash >= 0 ? id.slice(slash + 1) : id;
83
+ return tail
84
+ .replace(/[\\/]+/g, ' ')
85
+ .replace(/[-_]+/g, ' ')
86
+ .replace(/:/g, ' ')
87
+ .replace(/\s+/g, ' ')
88
+ .trim();
89
+ }
90
+
91
+ function atomicWriteJson(path, value) {
92
+ const dir = dirname(path);
93
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
94
+ const tmp = `${path}.tmp-${process.pid}-${Date.now()}`;
95
+ writeFileSync(tmp, JSON.stringify(value, null, 2) + '\n');
96
+ renameSync(tmp, path);
97
+ }
98
+
99
+ function logEvent(entry) {
100
+ try {
101
+ mkdirSync(HOOK_LOG_DIR, { recursive: true });
102
+ const today = new Date().toISOString().slice(0, 10);
103
+ appendFileSync(
104
+ join(HOOK_LOG_DIR, `model-sync-${today}.jsonl`),
105
+ JSON.stringify({ ts: new Date().toISOString(), ...entry }) + '\n',
106
+ );
107
+ } catch {
108
+ /* best-effort */
109
+ }
110
+ }
111
+
112
+ function buildPayload(appliedCount, modelAfter) {
113
+ const note = appliedCount > 0
114
+ ? `Bizar reapplied ${appliedCount} model picker option(s) to ~/.claude/settings.json (model=${modelAfter}).`
115
+ : '';
116
+ return {
117
+ hookSpecificOutput: {
118
+ hookEventName: 'SessionStart',
119
+ additionalContext: note,
120
+ },
121
+ };
122
+ }
123
+
124
+ function syncOnce() {
125
+ const routerPath = readRouterPath();
126
+ const router = readJsonIfObject(routerPath);
127
+ const userSelected = router && typeof router === 'object' ? router.userSelected : null;
128
+ const models = userSelected && Array.isArray(userSelected.models) ? userSelected.models : [];
129
+ const profiles = userSelected && userSelected.profiles && typeof userSelected.profiles === 'object'
130
+ ? userSelected.profiles
131
+ : {};
132
+ const liveIds = models.filter((id) => typeof id === 'string' && id.trim());
133
+ if (liveIds.length === 0) {
134
+ return { applied: 0, modelAfter: null, skipped: 'no-userSelected' };
135
+ }
136
+
137
+ const settingsPath = readSettingsPath();
138
+ const settings = readJsonIfObject(settingsPath) || {};
139
+ const before = {
140
+ model: typeof settings.model === 'string' ? settings.model : null,
141
+ modelPicker: settings.modelPicker || null,
142
+ modelOverrides: settings.modelOverrides || null,
143
+ };
144
+
145
+ const options = liveIds.map((id) => {
146
+ const option = { model: id, label: deriveModelLabel(id) };
147
+ const profile = profiles[id];
148
+ if (profile && typeof profile.description === 'string' && profile.description.trim()) {
149
+ option.description = profile.description.trim();
150
+ }
151
+ return option;
152
+ });
153
+
154
+ settings.modelPicker = { options };
155
+ settings.modelOverrides = Object.fromEntries(liveIds.map((id) => [id, id]));
156
+
157
+ let modelChanged = false;
158
+ if (typeof settings.model === 'string' && settings.model.startsWith('claude-')) {
159
+ settings.model = liveIds[0];
160
+ modelChanged = true;
161
+ }
162
+
163
+ atomicWriteJson(settingsPath, settings);
164
+
165
+ const after = {
166
+ model: typeof settings.model === 'string' ? settings.model : null,
167
+ modelPickerOptions: options.length,
168
+ modelOverridesCount: Object.keys(settings.modelOverrides).length,
169
+ modelChanged,
170
+ };
171
+
172
+ logEvent({ event: 'sessionstart-model-sync', routerPath, before, after });
173
+ return { applied: options.length, modelAfter: after.model, modelChanged };
174
+ }
175
+
176
+ function run() {
177
+ let raw = '';
178
+ process.stdin.setEncoding('utf8');
179
+ process.stdin.on('data', (chunk) => { raw += chunk; });
180
+ process.stdin.on('end', () => {
181
+ let input = {};
182
+ try { input = JSON.parse(raw || '{}'); } catch { input = {}; }
183
+ const sessionId = String(input.session_id || '');
184
+ const source = String(input.source || 'startup');
185
+ try {
186
+ const result = syncOnce();
187
+ const payload = buildPayload(result.applied, result.modelAfter);
188
+ process.stdout.write(JSON.stringify(payload) + '\n');
189
+ } catch (err) {
190
+ logEvent({
191
+ event: 'sessionstart-model-sync-error',
192
+ sessionId: sessionId || null,
193
+ source,
194
+ error: err && err.message ? err.message : String(err),
195
+ });
196
+ // Advisory hook — never block session start on a sync failure.
197
+ process.stdout.write('{}\n');
198
+ }
199
+ });
200
+ }
201
+
202
+ run();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@polderlabs/bizar",
3
- "version": "10.19.2",
3
+ "version": "10.19.5",
4
4
  "description": "Autonomous, human-in-the-loop multi-agent harness for Claude Code with guarded workflows, typed SDK primitives, and MCP tools.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1,5 +1,5 @@
1
1
  /**
2
2
  * SDK version constant. Keep synchronized with the workspace package versions.
3
3
  */
4
- export declare const SDK_VERSION: "10.19.2";
4
+ export declare const SDK_VERSION: "10.19.5";
5
5
  //# sourceMappingURL=version.d.ts.map
@@ -1,5 +1,5 @@
1
1
  /**
2
2
  * SDK version constant. Keep synchronized with the workspace package versions.
3
3
  */
4
- export const SDK_VERSION = "10.19.2";
4
+ export const SDK_VERSION = "10.19.5";
5
5
  //# sourceMappingURL=version.js.map
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@polderlabs/bizar-sdk",
3
- "version": "10.19.2",
3
+ "version": "10.19.5",
4
4
  "type": "module",
5
5
  "main": "./dist/index.js",
6
6
  "types": "./dist/index.d.ts",