@polderlabs/bizar 10.19.3 → 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',
@@ -634,23 +634,23 @@ export function deriveModelLabel(modelId, profile) {
634
634
  }
635
635
 
636
636
  /**
637
- * Sync `userSelected.models` into Claude Code's `modelPicker` array
637
+ * Sync `userSelected.models` into Claude Code's `modelPicker` setting
638
638
  * (settings.json). The picker is what populates `/model` — `modelOverrides`
639
639
  * alone only silences diagnostics, it does NOT add entries to the picker.
640
640
  *
641
- * Per Claude Code's settings reference: `modelPicker` is an array of
642
- * `{ id, label }` entries that replaces the gateway-discovered picker
643
- * contents. Scope is User-or-managed (settings.json is in scope). Each
644
- * entry preserves the operator's pick order from `userSelected.models`.
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
645
  *
646
646
  * Behavior:
647
647
  * - Reads settings.json; preserves every other field (env, mcpServers,
648
648
  * permissions, hooks, etc.).
649
- * - Writes `modelPicker` as an array of `{ id, label }`.
649
+ * - Writes `modelPicker = { options: [{ model, label }] }`.
650
650
  * - Filters out picks the live gateway rejects (same stale-ID contract as
651
651
  * `applyModelOverrides`); the surviving picks fill the picker.
652
- * - Empty pick list → writes `modelPicker: []` so the picker falls back
653
- * to whatever Claude Code's defaults surface.
652
+ * - Empty pick list → writes `modelPicker: { options: [] }` so Claude
653
+ * Code falls back to its built-in picker.
654
654
  * - Atomic replace via temp-file + rename (matches `applyModels`).
655
655
  * - Refuses to overwrite a corrupt settings.json.
656
656
  * - When `settingsJsonPath === null`, returns a no-op (tests).
@@ -663,7 +663,7 @@ export function deriveModelLabel(modelId, profile) {
663
663
  * }} opts
664
664
  * @returns {{
665
665
  * wrote: boolean,
666
- * entries: Array<{id: string, label: string}>,
666
+ * options: Array<{model: string, label: string, description?: string}>,
667
667
  * skippedStale: string[],
668
668
  * settingsPath: string|null,
669
669
  * }}
@@ -673,13 +673,20 @@ export function applyModelPicker({ settingsJsonPath, pickedIds, profiles = {}, l
673
673
  ? join(homedir(), '.claude', 'settings.json')
674
674
  : settingsJsonPath;
675
675
  if (path === null) {
676
- return { wrote: false, entries: [], skippedStale: [], settingsPath: null };
676
+ return { wrote: false, options: [], skippedStale: [], settingsPath: null };
677
677
  }
678
678
  const live = new Set(Array.isArray(liveIds) ? liveIds : []);
679
679
  const picks = Array.isArray(pickedIds) ? pickedIds.filter((id) => typeof id === 'string' && id.trim()) : [];
680
680
  const surviving = live.size === 0 ? picks : picks.filter((id) => live.has(id));
681
681
  const skipped = live.size === 0 ? [] : picks.filter((id) => !live.has(id));
682
- const entries = surviving.map((id) => ({ id, label: deriveModelLabel(id, profiles?.[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
+ });
683
690
 
684
691
  let settings = {};
685
692
  if (existsSync(path)) {
@@ -687,12 +694,12 @@ export function applyModelPicker({ settingsJsonPath, pickedIds, profiles = {}, l
687
694
  const parsed = JSON.parse(readFileSync(path, 'utf8'));
688
695
  if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) settings = parsed;
689
696
  } catch {
690
- return { wrote: false, entries: [], skippedStale: skipped, settingsPath: path };
697
+ return { wrote: false, options: [], skippedStale: skipped, settingsPath: path };
691
698
  }
692
699
  }
693
- settings.modelPicker = entries;
700
+ settings.modelPicker = { options };
694
701
  writeAtomic(path, JSON.stringify(settings, null, 2) + '\n');
695
- return { wrote: true, entries, skippedStale: skipped, settingsPath: path };
702
+ return { wrote: true, options, skippedStale: skipped, settingsPath: path };
696
703
  }
697
704
 
698
705
  // ── F-190 / IMP-017 refresh + operator-override preservation ────────────────
@@ -1654,7 +1661,7 @@ export async function run(name, args, isHelpRequest) {
1654
1661
  console.log(chalk.yellow(` Settings sync skipped ${sync.skippedStale.length} stale id(s): ${sync.skippedStale.join(', ')}`));
1655
1662
  }
1656
1663
  if (picker.wrote) {
1657
- console.log(chalk.dim(` /model picker populated with ${picker.entries.length} entr${picker.entries.length === 1 ? 'y' : 'ies'}`));
1664
+ console.log(chalk.dim(` /model picker populated with ${picker.options.length} entr${picker.options.length === 1 ? 'y' : 'ies'}`));
1658
1665
  }
1659
1666
  }
1660
1667
  return true;
@@ -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.3",
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.3";
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.3";
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.3",
3
+ "version": "10.19.5",
4
4
  "type": "module",
5
5
  "main": "./dist/index.js",
6
6
  "types": "./dist/index.d.ts",