@polderlabs/bizar 10.19.3 → 10.19.6

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/cli/bin.mjs CHANGED
@@ -129,7 +129,8 @@ function showHelp() {
129
129
  bizar install
130
130
  bizar audit
131
131
  bizar doctor
132
- bizar update --all --dry-run
132
+ bizar update --dry-run # Preview what would change
133
+ bizar update --force --yes # Non-interactive full re-emit
133
134
 
134
135
  Run \`bizar <command> --help\` for per-command help.
135
136
 
@@ -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',
@@ -3,10 +3,15 @@
3
3
  *
4
4
  * install + update command families.
5
5
  * v4.4.11+ — 'bizar install' and 'bizar update' share the same code path.
6
+ *
7
+ * v10.19.6 — `bizar update` now honors `--dry-run` / `--force` / `--yes`
8
+ * the same way `bizar install` does. The previous implementation called
9
+ * a legacy `runUpdate(args)` that swallowed every flag; this version
10
+ * routes update through `parseFlags` + `runInstaller` + `runRepair` so
11
+ * the documented flags actually do what they claim.
6
12
  */
7
13
  import chalk from 'chalk';
8
14
  import { runInstaller } from '../install.mjs';
9
- import { runUpdate } from '../update.mjs';
10
15
  import { runRepair } from '../repair.mjs';
11
16
  import { parseFlags } from '../provision.mjs';
12
17
 
@@ -61,35 +66,53 @@ export function showInstallHelp() {
61
66
 
62
67
  export function showUpdateHelp() {
63
68
  console.log(`
64
- bizar update — Update @anthropic-ai/claude-code + @polderlabs/bizar
65
- (which bundles the CLI, SDK, agents, skills, hooks, and commands). Detects what's
66
- installed and only touches what's missing or out of date.
69
+ bizar update — Update @polderlabs/bizar (CLI + SDK + agents +
70
+ skills + hooks + commands). Refreshing Claude Code itself is also
71
+ handled when the bundled install.sh runs under --force.
72
+
67
73
  Usage:
68
- bizar update Update all installed Bizar components
69
- bizar update --check Only print current vs. latest; do not update
70
- bizar update --channel=stable|beta Pick the npm dist-tag (default: stable)
74
+ bizar update Refresh every Bizar-managed surface
71
75
  bizar update --dry-run Print what would happen, change nothing
72
- bizar update --force Override .bizar/PRE_PUSH_NOTES.md blockers
73
- bizar update --yes Same as --force, but named for one-line scripts
76
+ bizar update --force | --deep Full clean re-emit: wipe Bizar-managed
77
+ dirs, back up settings env vars,
78
+ re-sync everything from the repo
79
+ bizar update --yes | -y Assume yes for any non-destructive prompt
80
+ bizar update --non-interactive Alias for --yes
74
81
  bizar update --help Show this help
75
- Components updated:
76
- @anthropic-ai/claude-code the Claude Code CLI itself
77
- @polderlabs/bizar CLI + SDK + agents + skills + hooks
78
- Behavior (v4.4.7+):
79
- • Single unified provisioner. 'bizar install' and 'bizar update' are
80
- the same code path with different mode flags. Every step is
81
- idempotent re-running is safe.
82
- Re-runs the provisioner so installed Claude Code surfaces match
83
- the just-upgraded npm version.
84
- Runs 'bizar doctor' after a successful update to catch config
85
- regressions before claude tries to start.
86
- With --check: prints the version matrix and release-notes excerpt
87
- between current and latest, exits non-zero if an update is available.
82
+
83
+ Behavior (v10.19.6+):
84
+ Single unified provisioner the same code path as 'bizar install'
85
+ with mode=update. Every step is idempotent; re-running is safe.
86
+
87
+ 1. Re-emits skills, commands, rules, hooks, agents, and workflows
88
+ from the repo source into ~/.claude/ (or $CLAUDE_CONFIG_DIR),
89
+ overwriting only the Bizar-managed surface and pruning stale
90
+ entries (F-141).
91
+ 2. Writes the install marker so subsequent runs short-circuit when
92
+ nothing has changed.
93
+ 3. With --force, re-runs the F-183 clean-install flow: wipes
94
+ ~/.claude/{agents,skills,commands,hooks,rules,workflows,plugins}/
95
+ and ~/.agents/, stashes the prior settings.json env block into
96
+ BIZAR_SAVED_ENV, then re-emits settings.json with the operator's
97
+ ANTHROPIC_* and BIZAR_* keys union-merged back in (so gateway
98
+ URL, auth token, and BIZAR_HOME are preserved across the wipe).
99
+ 4. Runs 'bizar doctor' after a successful update so config
100
+ regressions surface before the next Claude Code session.
101
+ 5. Repairs stale bin symlinks so the operator picks up the new code
102
+ on the next shell prompt.
103
+
104
+ Idempotency note:
105
+ When the SDK on disk matches the published npm version, every step
106
+ above is a no-op — settings.json is rewritten only if its hash
107
+ drifted, sync targets are skipped when the file set is unchanged,
108
+ and the bin symlink repair short-circuits. A clean re-run is fast.
109
+
88
110
  Examples:
89
- bizar update Full auto-update (recommended)
90
- bizar update --check Show version matrix + notes, do nothing
91
- bizar update --channel=beta Upgrade to latest beta build
92
- bizar update --dry-run Preview what would change
111
+ bizar update Refresh the managed surface
112
+ bizar update --dry-run Preview every step
113
+ bizar update --force Full clean re-emit, preserved env
114
+ bizar update --force --yes Same, no prompts (script-friendly)
115
+
93
116
  Errors:
94
117
  Network failures (registry offline / DNS) and npm permission issues
95
118
  are surfaced with the raw npm output. The provisioner never silently
@@ -97,7 +120,34 @@ export function showUpdateHelp() {
97
120
  `);
98
121
  }
99
122
 
123
+ // ── Shared post-install teardown ────────────────────────────────────────────────
124
+
125
+ /**
126
+ * Run `runRepair({})` after install/update and surface any repointed
127
+ * bin symlinks to the operator. Both `install()` and `update()` share
128
+ * this block; the previous `bizar update` skipped it entirely (audit
129
+ * A6), so a freshly-upgraded Bizar left stale bin symlinks pointing at
130
+ * the prior install until the operator manually re-sourced their shell.
131
+ *
132
+ * @param {object} [opts]
133
+ * @param {(opts: object) => Promise<{ ok: boolean, fixed: string[], notes?: string[] }>} [opts.runRepair]
134
+ * Dependency-injected for tests; defaults to the real `runRepair`.
135
+ */
136
+ async function runPostInstallerRepair({ runRepair: runRepairDep = runRepair } = {}) {
137
+ try {
138
+ const r = await runRepairDep({});
139
+ if (r.fixed.length > 0) {
140
+ console.log(chalk.cyan('\n Repair: repointed stale bin symlinks:'));
141
+ for (const f of r.fixed) console.log(` ${f}`);
142
+ console.log(chalk.dim(' Re-run your shell or `hash -r` to pick up the new path.'));
143
+ }
144
+ } catch (err) {
145
+ console.log(chalk.dim(` Repair skipped: ${err.message}`));
146
+ }
147
+ }
148
+
100
149
  // ── Command runners ────────────────────────────────────────────────────────────
150
+
101
151
  export async function install(args, isHelpRequest) {
102
152
  if (isHelpRequest) {
103
153
  showInstallHelp();
@@ -122,16 +172,10 @@ export async function install(args, isHelpRequest) {
122
172
  }
123
173
  // v4.4.3 — After install, repair any stale bin symlinks so the
124
174
  // user picks up the new code.
125
- try {
126
- const r = await runRepair({});
127
- if (r.fixed.length > 0) {
128
- console.log(chalk.cyan('\n Repair: repointed stale bin symlinks:'));
129
- for (const f of r.fixed) console.log(` ${f}`);
130
- console.log(chalk.dim(' Re-run your shell or `hash -r` to pick up the new path.'));
131
- }
132
- } catch (err) {
133
- console.log(chalk.dim(` Repair skipped: ${err.message}`));
134
- }
175
+ await runPostInstallerRepair();
176
+ // v10.19.6 propagate install failure to the parent shell so
177
+ // `bizar install && bizar doctor` short-circuits on install errors.
178
+ if (!result?.ok) process.exit(1);
135
179
  }
136
180
 
137
181
  export async function update(args, isHelpRequest) {
@@ -139,7 +183,46 @@ export async function update(args, isHelpRequest) {
139
183
  showUpdateHelp();
140
184
  return;
141
185
  }
142
- await runUpdate(args);
186
+ // v10.19.6 — route update through the same flag-parsing + installer
187
+ // pipeline as install so `--dry-run`, `--force`, `--yes`, etc. do
188
+ // what they claim. See `runUpdateWithFlags` for the testable
189
+ // dependency-injected core.
190
+ await runUpdateWithFlags({ args });
191
+ }
192
+
193
+ /**
194
+ * Testable core of `update()`. Default arguments bind to the real
195
+ * `runInstaller` / `parseFlags` / `runRepair` from this module; tests
196
+ * inject stubs to assert the wiring without touching disk.
197
+ *
198
+ * Sequence (mirrors install() so the audit's documented "Runs doctor +
199
+ * runs repair" promises become true):
200
+ * 1. `parseFlags(args)` → `{ mode, dryRun, force, yes }`
201
+ * 2. `runInstaller({ mode, dryRun, force, yes })` — runs the
202
+ * provisioner, then post-install `runDoctor({ silent: true })`.
203
+ * 3. `runRepair({})` — repoint stale bin symlinks.
204
+ * 4. If `runInstaller` returned `{ ok: false }`, exit(1) so scripts
205
+ * that gate on the exit code (`bizar update && bizar doctor`) see
206
+ * the failure.
207
+ *
208
+ * @param {object} [opts]
209
+ * @param {string[]} [opts.args]
210
+ * @param {(opts: { mode: string, dryRun: boolean, force: boolean, yes: boolean }) => Promise<{ ok?: boolean }>} [opts.runInstaller]
211
+ * @param {(argv: string[]) => { mode: string, dryRun: boolean, force: boolean, yes: boolean }} [opts.parseFlags]
212
+ * @param {(opts: { dryRun?: boolean }) => Promise<{ ok: boolean, fixed: string[] }>} [opts.runRepair]
213
+ * @returns {Promise<{ ok?: boolean }>}
214
+ */
215
+ export async function runUpdateWithFlags({
216
+ args = [],
217
+ runInstaller: runInstallerDep = runInstaller,
218
+ parseFlags: parseFlagsDep = parseFlags,
219
+ runRepair: runRepairDep = runRepair,
220
+ } = {}) {
221
+ const { mode, dryRun, force, yes } = parseFlagsDep(args);
222
+ const result = await runInstallerDep({ mode, dryRun, force, yes });
223
+ await runPostInstallerRepair({ runRepair: runRepairDep });
224
+ if (!result?.ok) process.exit(1);
225
+ return result;
143
226
  }
144
227
 
145
228
  // ── run() entry point (used by bin.mjs dispatcher) ──────────────────────────────
@@ -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;
@@ -182,17 +182,14 @@ export async function runTestGate() {
182
182
 
183
183
  export async function run(name, args, isHelpRequest) {
184
184
  switch (name) {
185
- case 'update':
186
- // `bizar update` lives in commands/install.mjs (the install/update
187
- // pair share a code path). Proxy to it.
188
- if (isHelpRequest) {
189
- const { showUpdateHelp } = await import('./install.mjs');
190
- showUpdateHelp();
191
- } else {
192
- const { runUpdate } = await import('./install.mjs');
193
- await runUpdate(args, {});
194
- }
195
- break;
185
+ // NOTE: 'update' is intentionally NOT routed through this dispatcher.
186
+ // `cli/bin.mjs` dispatches 'install' / 'update' directly to
187
+ // `cli/commands/install.mjs`, which owns both commands (they share
188
+ // the same code path). The legacy branch below used to import
189
+ // `runUpdate` from `./install.mjs`, but that module never exported
190
+ // `runUpdate` — the import would throw at runtime. The branch was
191
+ // dead code, deleted in v10.19.6 as part of the `bizar update` audit
192
+ // fix.
196
193
 
197
194
  case 'audit':
198
195
  if (isHelpRequest) showAuditHelp();
package/cli/provision.mjs CHANGED
@@ -1431,10 +1431,11 @@ if (import.meta.url === `file://${process.argv[1]}`) {
1431
1431
  });
1432
1432
  }
1433
1433
 
1434
- // Back-compat alias — `bizar update` historically
1435
- // called `runUpdate(args)`; we collapsed install + update onto
1436
- // `runProvision({ mode: 'update', ... })`. Keep `runUpdate` importable so
1437
- // `cli/commands/install.mjs` (which still uses the historical signature)
1438
- // works without modification.
1434
+ // Back-compat alias — `bizar update` historically called `runUpdate(args)`;
1435
+ // install + update now share `runProvision({ mode: 'update', ... })`.
1436
+ // `runUpdate` is retained as an importable export for external SDK
1437
+ // consumers (re-exported from `cli/update.mjs`); `cli/commands/install.mjs`
1438
+ // no longer imports it — its `update()` command routes through `parseFlags`
1439
+ // → `runInstaller({ mode: 'update', ... })` instead.
1439
1440
  export const runUpdate = (subargs, opts = {}) =>
1440
1441
  runProvision({ ...opts, mode: 'update', subargs });
@@ -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.6",
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.6";
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.6";
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.6",
4
4
  "type": "module",
5
5
  "main": "./dist/index.js",
6
6
  "types": "./dist/index.d.ts",