@crouton-kit/crouter 0.3.68 → 0.3.78

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 (176) hide show
  1. package/README.md +1 -1
  2. package/dist/builtin-memory/00-runtime-base.md +3 -10
  3. package/dist/builtin-memory/04-base-worker.md +18 -0
  4. package/dist/builtin-memory/04-orchestration-kernel.md +1 -1
  5. package/dist/builtin-pi-packages/pi-crtr-extensions/README.md +13 -34
  6. package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/__tests__/provider-rotation.test.ts +639 -741
  7. package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/provider-rotation.ts +241 -58
  8. package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/statusline.ts +2 -9
  9. package/dist/builtin-pi-packages/pi-crtr-extensions/lib/subscription-state.ts +365 -94
  10. package/dist/builtin-pi-packages/pi-crtr-extensions/package-lock.json +2 -2
  11. package/dist/builtin-pi-packages/pi-mode-switch/README.md +11 -39
  12. package/dist/builtin-pi-packages/pi-mode-switch/extensions/index.ts +20 -15
  13. package/dist/builtin-pi-packages/pi-mode-switch/package.json +1 -1
  14. package/dist/builtin-views/canvas/tui.mjs +8 -9
  15. package/dist/builtin-views/chat/tui.mjs +12 -12
  16. package/dist/builtin-views/git-pr/tui.mjs +7 -8
  17. package/dist/builtin-views/inbox/tui.mjs +27 -41
  18. package/dist/builtin-views/linkedin/tui.mjs +23 -37
  19. package/dist/builtin-views/prompt-review/tui.mjs +11 -11
  20. package/dist/builtin-views/settings/tui.mjs +11 -11
  21. package/dist/builtin-views/workspace-sidebar/tui.mjs +8 -9
  22. package/dist/cli.d.ts +1 -1
  23. package/dist/cli.js +1 -0
  24. package/dist/clients/attach/__tests__/attach-chrome-remote.test.js +11 -7
  25. package/dist/clients/attach/__tests__/crtr-output-render.test.js +1 -1
  26. package/dist/clients/attach/__tests__/mermaid-render.test.d.ts +1 -0
  27. package/dist/clients/attach/__tests__/mermaid-render.test.js +20 -0
  28. package/dist/clients/attach/__tests__/oauth-dialog-lifecycle.test.d.ts +1 -0
  29. package/dist/clients/attach/__tests__/oauth-dialog-lifecycle.test.js +110 -0
  30. package/dist/clients/attach/attach-cmd.d.ts +1 -1
  31. package/dist/clients/attach/attach-cmd.js +733 -730
  32. package/dist/clients/attach/auth-pickers.d.ts +12 -0
  33. package/dist/clients/attach/auth-pickers.js +11 -0
  34. package/dist/clients/attach/canvas-panels.js +2 -3
  35. package/dist/clients/attach/chat-view.d.ts +7 -8
  36. package/dist/clients/attach/chat-view.js +119 -79
  37. package/dist/clients/attach/edit-diff-render.d.ts +6 -0
  38. package/dist/clients/attach/edit-diff-render.js +263 -0
  39. package/dist/clients/attach/input-controller.d.ts +28 -2
  40. package/dist/clients/attach/input-controller.js +38 -5
  41. package/dist/clients/attach/mermaid-render.js +5 -2
  42. package/dist/clients/attach/pickers.d.ts +8 -7
  43. package/dist/clients/attach/pickers.js +13 -17
  44. package/dist/clients/attach/slash-commands.d.ts +9 -0
  45. package/dist/clients/attach/slash-commands.js +128 -7
  46. package/dist/clients/attach/titled-editor.d.ts +12 -1
  47. package/dist/clients/attach/titled-editor.js +103 -8
  48. package/dist/commands/canvas-browse.js +2 -2
  49. package/dist/commands/memory/lint.js +39 -5
  50. package/dist/commands/memory/write.js +1 -0
  51. package/dist/commands/node.js +12 -5
  52. package/dist/commands/profile/new.js +30 -5
  53. package/dist/commands/surface-tmux-spread.js +1 -3
  54. package/dist/commands/sys/__tests__/config-keybindings.test.d.ts +1 -0
  55. package/dist/commands/sys/__tests__/config-keybindings.test.js +55 -0
  56. package/dist/commands/sys/__tests__/config-model-ladders.test.d.ts +1 -0
  57. package/dist/commands/sys/__tests__/config-model-ladders.test.js +121 -0
  58. package/dist/commands/sys/config.js +18 -21
  59. package/dist/commands/sys/setup-core.d.ts +12 -1
  60. package/dist/commands/sys/setup-core.js +25 -3
  61. package/dist/commands/sys/setup.js +166 -20
  62. package/dist/core/__tests__/base-worker-prompt.test.d.ts +1 -0
  63. package/dist/core/__tests__/base-worker-prompt.test.js +24 -0
  64. package/dist/core/__tests__/canvas-inbox-watcher-hold.test.js +232 -1
  65. package/dist/core/__tests__/fault-classifier.test.js +15 -0
  66. package/dist/core/__tests__/full/broker-dialogs.test.js +7 -2
  67. package/dist/core/__tests__/full/broker-pane-resolution.test.js +9 -6
  68. package/dist/core/__tests__/full/cascade-close.test.js +5 -2
  69. package/dist/core/__tests__/full/dead-pane-regression.test.js +6 -3
  70. package/dist/core/__tests__/full/detach-focus.test.js +10 -5
  71. package/dist/core/__tests__/full/human-new-window-regression.test.js +6 -3
  72. package/dist/core/__tests__/full/review-render-pane-regression.test.js +2 -2
  73. package/dist/core/__tests__/helpers/harness.d.ts +1 -0
  74. package/dist/core/__tests__/helpers/harness.js +28 -4
  75. package/dist/core/__tests__/host-teardown-process-group.test.js +15 -4
  76. package/dist/core/__tests__/live-mutation-verbs.test.js +5 -4
  77. package/dist/core/__tests__/review-model-floor.test.js +1 -1
  78. package/dist/core/__tests__/stream-watchdog.test.d.ts +1 -0
  79. package/dist/core/__tests__/stream-watchdog.test.js +70 -0
  80. package/dist/core/__tests__/tmux-surface.test.js +72 -0
  81. package/dist/core/canvas/__tests__/remote-event-stream.test.js +11 -9
  82. package/dist/core/canvas/__tests__/render-remote.test.js +7 -4
  83. package/dist/core/canvas/browse/__tests__/model.test.js +23 -5
  84. package/dist/core/canvas/browse/__tests__/rebuild-coalescer.test.js +30 -13
  85. package/dist/core/canvas/browse/__tests__/render.test.js +1 -0
  86. package/dist/core/canvas/browse/app.js +164 -5
  87. package/dist/core/canvas/browse/model.d.ts +10 -6
  88. package/dist/core/canvas/browse/model.js +20 -12
  89. package/dist/core/canvas/browse/pins.d.ts +4 -0
  90. package/dist/core/canvas/browse/pins.js +29 -0
  91. package/dist/core/canvas/browse/render.d.ts +13 -1
  92. package/dist/core/canvas/browse/render.js +72 -15
  93. package/dist/core/canvas/nav-model.js +5 -11
  94. package/dist/core/canvas/paths.d.ts +5 -0
  95. package/dist/core/canvas/paths.js +7 -0
  96. package/dist/core/canvas/pid.d.ts +14 -10
  97. package/dist/core/canvas/pid.js +14 -10
  98. package/dist/core/config.d.ts +53 -2
  99. package/dist/core/config.js +228 -110
  100. package/dist/core/fault-classifier.js +1 -1
  101. package/dist/core/fs-utils.d.ts +1 -0
  102. package/dist/core/fs-utils.js +15 -1
  103. package/dist/core/keybindings/__tests__/resolve.test.d.ts +1 -0
  104. package/dist/core/keybindings/__tests__/resolve.test.js +224 -0
  105. package/dist/core/keybindings/catalog.d.ts +14 -0
  106. package/dist/core/keybindings/catalog.js +256 -0
  107. package/dist/core/keybindings/index.d.ts +5 -0
  108. package/dist/core/keybindings/index.js +4 -0
  109. package/dist/core/keybindings/match.d.ts +29 -0
  110. package/dist/core/keybindings/match.js +99 -0
  111. package/dist/core/keybindings/persistence.d.ts +14 -0
  112. package/dist/core/keybindings/persistence.js +31 -0
  113. package/dist/core/keybindings/resolve.d.ts +14 -0
  114. package/dist/core/keybindings/resolve.js +305 -0
  115. package/dist/core/keybindings/types.d.ts +37 -0
  116. package/dist/core/keybindings/types.js +1 -0
  117. package/dist/core/memory-resolver.d.ts +1 -1
  118. package/dist/core/memory-resolver.js +10 -3
  119. package/dist/core/predicate.d.ts +5 -3
  120. package/dist/core/predicate.js +5 -3
  121. package/dist/core/runtime/broker-protocol.d.ts +33 -5
  122. package/dist/core/runtime/broker.js +165 -14
  123. package/dist/core/runtime/launch.d.ts +39 -6
  124. package/dist/core/runtime/launch.js +79 -21
  125. package/dist/core/runtime/naming.js +3 -3
  126. package/dist/core/runtime/pi-cli.d.ts +6 -0
  127. package/dist/core/runtime/pi-cli.js +16 -2
  128. package/dist/core/runtime/placement.d.ts +2 -2
  129. package/dist/core/runtime/placement.js +4 -1
  130. package/dist/core/runtime/promote.js +4 -0
  131. package/dist/core/runtime/recap.d.ts +3 -3
  132. package/dist/core/runtime/recap.js +47 -44
  133. package/dist/core/runtime/recycle.js +6 -1
  134. package/dist/core/runtime/reset.js +5 -0
  135. package/dist/core/runtime/revive.js +28 -18
  136. package/dist/core/runtime/session-list-cache.d.ts +23 -0
  137. package/dist/core/runtime/session-list-cache.js +270 -0
  138. package/dist/core/runtime/spawn.js +51 -32
  139. package/dist/core/runtime/stream-watchdog.d.ts +26 -0
  140. package/dist/core/runtime/stream-watchdog.js +75 -0
  141. package/dist/core/runtime/tmux-chrome.d.ts +1 -1
  142. package/dist/core/runtime/tmux-chrome.js +2 -2
  143. package/dist/core/runtime/tmux.d.ts +18 -15
  144. package/dist/core/runtime/tmux.js +216 -110
  145. package/dist/core/tui/__tests__/host-keybindings.test.d.ts +1 -0
  146. package/dist/core/tui/__tests__/host-keybindings.test.js +112 -0
  147. package/dist/core/tui/host.d.ts +30 -1
  148. package/dist/core/tui/host.js +148 -34
  149. package/dist/core/view/__tests__/transport-remote.test.js +9 -6
  150. package/dist/core/view/contract.d.ts +17 -3
  151. package/dist/daemon/crtrd-cli.d.ts +1 -1
  152. package/dist/daemon/crtrd-cli.js +1 -0
  153. package/dist/pi-extensions/__tests__/canvas-stophook-agentend.test.js +41 -0
  154. package/dist/pi-extensions/__tests__/canvas-stophook-context-nudge.test.d.ts +1 -0
  155. package/dist/pi-extensions/__tests__/canvas-stophook-context-nudge.test.js +121 -0
  156. package/dist/pi-extensions/canvas-inbox-watcher.d.ts +1 -1
  157. package/dist/pi-extensions/canvas-inbox-watcher.js +203 -25
  158. package/dist/pi-extensions/canvas-recap.d.ts +4 -0
  159. package/dist/pi-extensions/canvas-recap.js +38 -37
  160. package/dist/pi-extensions/canvas-stophook.d.ts +11 -0
  161. package/dist/pi-extensions/canvas-stophook.js +33 -4
  162. package/dist/suppress-experimental-warnings.d.ts +1 -0
  163. package/dist/suppress-experimental-warnings.js +15 -0
  164. package/dist/types.d.ts +3 -13
  165. package/dist/types.js +12 -36
  166. package/dist/web-client/assets/{index-CnF5r8ky.js → index--md2ylfi.js} +18 -17
  167. package/dist/web-client/assets/index-BUdm9s9s.css +2 -0
  168. package/dist/web-client/index.html +3 -3
  169. package/dist/web-client/sw.js +1 -1
  170. package/package.json +11 -8
  171. package/scripts/postinstall.mjs +1 -1
  172. package/dist/pi-extensions/canvas-nav.d.ts +0 -43
  173. package/dist/pi-extensions/canvas-nav.js +0 -640
  174. package/dist/pi-extensions/widget-order-bus.d.ts +0 -6
  175. package/dist/pi-extensions/widget-order-bus.js +0 -34
  176. package/dist/web-client/assets/index-BnmSLNLa.css +0 -2
@@ -1,6 +1,8 @@
1
- import { join } from 'node:path';
2
- import { CONFIG_FILE, STATE_FILE, defaultScopeConfig, defaultScopeState, defaultCanvasNavConfig, defaultModelLaddersConfig, defaultKindsConfig, defaultRemoteCanvasConfig } from '../types.js';
3
- import { readJsonIfExists, writeJson, ensureDir } from './fs-utils.js';
1
+ import { mkdirSync, rmSync } from 'node:fs';
2
+ import { dirname, join } from 'node:path';
3
+ import { isDeepStrictEqual } from 'node:util';
4
+ import { CONFIG_FILE, STATE_FILE, defaultScopeConfig, defaultScopeState, defaultModelLaddersConfig, defaultKindsConfig, defaultRemoteCanvasConfig } from '../types.js';
5
+ import { atomicWriteJson, readJsonIfExists, writeJson, ensureDir } from './fs-utils.js';
4
6
  import { scopeRoot, requireScopeRoot, findProjectScopeRoots } from './scope.js';
5
7
  import { profileRoot } from './profiles/manifest.js';
6
8
  function configPathFor(root) {
@@ -41,10 +43,60 @@ export function readState(scope) {
41
43
  activeCanvas: existing.activeCanvas ?? null,
42
44
  };
43
45
  }
44
- export function writeConfig(scope, config) {
46
+ const CONFIG_LOCK_TIMEOUT_MS = 5000;
47
+ const CONFIG_LOCK_POLL_MS = 25;
48
+ function sleepSync(ms) {
49
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
50
+ }
51
+ function withConfigPathLock(path, run) {
52
+ ensureDir(dirname(path));
53
+ const lockPath = `${path}.lock`;
54
+ const deadline = Date.now() + CONFIG_LOCK_TIMEOUT_MS;
55
+ for (;;) {
56
+ try {
57
+ mkdirSync(lockPath);
58
+ break;
59
+ }
60
+ catch (error) {
61
+ if (error.code !== 'EEXIST')
62
+ throw error;
63
+ if (Date.now() > deadline)
64
+ throw new Error(`timed out waiting for config lock: ${path}`);
65
+ sleepSync(CONFIG_LOCK_POLL_MS);
66
+ }
67
+ }
68
+ try {
69
+ return run(path);
70
+ }
71
+ finally {
72
+ rmSync(lockPath, { recursive: true, force: true });
73
+ }
74
+ }
75
+ function withConfigLock(scope, run) {
45
76
  const root = requireScopeRoot(scope);
46
- ensureDir(root);
47
- writeJson(configPathFor(root), config);
77
+ return withConfigPathLock(configPathFor(root), run);
78
+ }
79
+ export function updateRawConfigAtomically(scope, mutate) {
80
+ return withConfigLock(scope, (path) => {
81
+ const latest = readJsonIfExists(path) ?? {};
82
+ const next = mutate(latest);
83
+ atomicWriteJson(path, next);
84
+ return next;
85
+ });
86
+ }
87
+ export function writeConfig(scope, config) {
88
+ withConfigLock(scope, (path) => atomicWriteJson(path, config));
89
+ }
90
+ /** Persist a RAW PARTIAL config to a scope's config.json exactly as given.
91
+ * Unlike `writeConfig`, this makes no promise that every field is present --
92
+ * callers pass the sparse partial that should live on disk, so an untouched
93
+ * key stays absent (and `readConfig`/`mergeConfig` re-derive it live from
94
+ * whichever build is running). This is the single central primitive every
95
+ * mutation path funnels through so no writer ever freezes a fully
96
+ * materialized `modelLadders` (concrete model ids that go stale across
97
+ * builds -- the live-Hearth freeze bug) onto disk. */
98
+ export function writeRawConfig(scope, config) {
99
+ withConfigLock(scope, (path) => atomicWriteJson(path, config));
48
100
  }
49
101
  export function writeState(scope, state) {
50
102
  const root = requireScopeRoot(scope);
@@ -53,105 +105,39 @@ export function writeState(scope, state) {
53
105
  }
54
106
  export function ensureScopeInitialized(scope, root) {
55
107
  ensureDir(root);
56
- const cfgPath = configPathFor(root);
57
- if (!readJsonIfExists(cfgPath)) {
58
- writeJson(cfgPath, defaultScopeConfig());
59
- }
60
- }
61
- function prefixBindDesc(desc) {
62
- return typeof desc === 'string' && desc !== '' ? { desc } : {};
63
- }
64
- function normalizePrefixMenuItem(raw) {
65
- if (raw === null || typeof raw !== 'object')
66
- return null;
67
- const r = raw;
68
- const key = typeof r.key === 'string' && r.key !== '' ? r.key : null;
69
- const desc = typeof r.desc === 'string' && r.desc !== '' ? r.desc : null;
70
- const action = normalizePrefixBind(r.action);
71
- if (key === null || desc === null || action === null)
72
- return null;
73
- const out = { key, desc, action };
74
- if (r.confirm === true)
75
- out.confirm = true;
76
- return out;
77
- }
78
- function normalizePrefixBind(raw) {
79
- if (raw === null || typeof raw !== 'object')
80
- return null;
81
- const r = raw;
82
- const desc = prefixBindDesc(r.desc);
83
- const confirm = r.confirm === true ? { confirm: true } : {};
84
- if (typeof r.run === 'string') {
85
- if (r.run === '__graph__')
86
- return { kind: 'keys', keys: '/graph', ...desc };
87
- if (r.run === '__issue__') {
88
- return {
89
- kind: 'menu',
90
- items: [
91
- { key: 'i', desc: 'idea', action: { kind: 'popup', run: 'canvas issue new --type idea' } },
92
- { key: 'b', desc: 'bug', action: { kind: 'popup', run: 'canvas issue new --type bug' } },
93
- ],
94
- ...desc,
95
- };
96
- }
97
- }
98
- if (r.kind === 'keys' && typeof r.keys === 'string')
99
- return { kind: 'keys', keys: r.keys, ...desc };
100
- if (r.kind === 'popup' && typeof r.run === 'string')
101
- return { kind: 'popup', run: r.run, ...desc };
102
- if (r.kind === 'menu' && Array.isArray(r.items)) {
103
- const items = r.items.map(normalizePrefixMenuItem).filter((item) => item !== null);
104
- if (items.length !== r.items.length)
105
- return null;
106
- return { kind: 'menu', items, ...desc };
107
- }
108
- if ((r.kind === undefined || r.kind === 'crtr') && typeof r.run === 'string') {
109
- return { kind: 'crtr', run: r.run, ...confirm, ...desc };
110
- }
111
- if (r.kind === undefined && typeof r.keys === 'string')
112
- return { kind: 'keys', keys: r.keys, ...desc };
113
- return null;
114
- }
115
- function normalizeGraphBind(raw) {
116
- if (raw === null || typeof raw !== 'object')
117
- return null;
118
- const r = raw;
119
- if (typeof r.run !== 'string')
120
- return null;
121
- return {
122
- run: r.run,
123
- ...(r.confirm === true ? { confirm: true } : {}),
124
- ...(typeof r.desc === 'string' && r.desc !== '' ? { desc: r.desc } : {}),
125
- };
126
- }
127
- /** Deep-merge one bind table over its built-in defaults: a user adding a single
128
- * bind must not wipe the rest. Each user entry is normalized before it
129
- * replaces/extends a key. */
130
- function mergeBinds(base, over, normalize) {
131
- const out = { ...base };
132
- if (over !== null && typeof over === 'object') {
133
- for (const [k, v] of Object.entries(over)) {
134
- const normalized = normalize(v);
135
- if (normalized !== null)
136
- out[k] = normalized;
108
+ withConfigPathLock(configPathFor(root), (cfgPath) => {
109
+ if (readJsonIfExists(cfgPath))
110
+ return;
111
+ // Persist no materialized model ladder or keybinding defaults. `modelLadders` holds concrete model ids (e.g.
112
+ // "openai-codex/gpt-5.6-sol:high") that are only valid as long as they match whatever
113
+ // pi model registry this crouter build ships. Baking a materialized snapshot here would
114
+ // freeze this scope's ladder to whatever ids happened to be current the moment it was
115
+ // first initialized -- a later crouter/pi upgrade (or a rollback) that changes the
116
+ // registry can then never reach it: the stale persisted override still wins over the
117
+ // new build's compiled defaults in `mergeConfig`/`mergeModelLadders`, producing a
118
+ // fallback rung `resolveViableFallback` rejects as unregistered while the terminal
119
+ // error misleadingly claims the fallback provider needs authenticating (see live
120
+ // incident: a home's config.json was seeded with `gpt-5.6-*` ladder entries from a
121
+ // build newer than the one actually installed, so its own model registry never had
122
+ // them). Omitting the key here is safe: `readConfig`/`mergeConfig` already default-fill
123
+ // a missing `modelLadders` from whatever build is CURRENTLY running, and an explicit
124
+ // user override (`sys config set modelLadders...`) still persists and still wins.
125
+ // Keybindings are sparse user overrides, so an untouched scope omits that member too.
126
+ const { modelLadders: _omittedModelLadders, keybindings: _omittedKeybindings, ...rest } = defaultScopeConfig();
127
+ atomicWriteJson(cfgPath, rest);
128
+ });
129
+ }
130
+ function normalizeKeybindings(raw) {
131
+ if (raw === null || typeof raw !== 'object' || Array.isArray(raw))
132
+ return {};
133
+ const out = {};
134
+ for (const [id, gestures] of Object.entries(raw)) {
135
+ if (Array.isArray(gestures) && gestures.every((gesture) => typeof gesture === 'string')) {
136
+ out[id] = [...gestures];
137
137
  }
138
138
  }
139
139
  return out;
140
140
  }
141
- /** Validate + deep-merge a user `canvasNav` block over the built-in defaults.
142
- * An absent or malformed block falls back wholesale to defaults. */
143
- function mergeCanvasNav(raw) {
144
- const defaults = defaultCanvasNavConfig();
145
- if (raw === null || typeof raw !== 'object')
146
- return defaults;
147
- const r = raw;
148
- const prefixKey = typeof r.prefixKey === 'string' && r.prefixKey.trim() !== '' ? r.prefixKey : defaults.prefixKey;
149
- return {
150
- prefixKey,
151
- prefixBinds: mergeBinds(defaults.prefixBinds, r.prefixBinds, normalizePrefixBind),
152
- graphBinds: mergeBinds(defaults.graphBinds, r.graphBinds, normalizeGraphBind),
153
- };
154
- }
155
141
  function mergeModelLadders(raw, base = defaultModelLaddersConfig()) {
156
142
  const defaults = base;
157
143
  if (raw === null || typeof raw !== 'object')
@@ -228,18 +214,55 @@ function normalizeKindEntry(raw) {
228
214
  out.availableTo = r.availableTo;
229
215
  return out;
230
216
  }
217
+ /** Validate one raw `kinds.<name>` entry as a sparse PATCH — an entry with no
218
+ * `whenToUse` that overrides individual launch knobs (model/orchestratorModel/
219
+ * tools/extensions/availableTo) of a kind some lower-precedence scope already
220
+ * defines. This is what lets a scope (e.g. a profile) persist ONLY
221
+ * `{ "model": "ultra" }` for a kind instead of freezing a full registry
222
+ * snapshot to disk (the same staleness trap `sparseModelLaddersToPersist`
223
+ * exists to avoid). Returns null when no valid field is present. */
224
+ function normalizeKindPatch(raw) {
225
+ if (raw === null || typeof raw !== 'object')
226
+ return null;
227
+ const r = raw;
228
+ const out = {};
229
+ if (typeof r.model === 'string' && r.model.trim() !== '')
230
+ out.model = r.model;
231
+ if (typeof r.orchestratorModel === 'string' && r.orchestratorModel.trim() !== '')
232
+ out.orchestratorModel = r.orchestratorModel;
233
+ const isStringArray = (v) => Array.isArray(v) && v.every((item) => typeof item === 'string');
234
+ if (isStringArray(r.tools))
235
+ out.tools = r.tools;
236
+ if (isStringArray(r.extensions))
237
+ out.extensions = r.extensions;
238
+ if (isStringArray(r.availableTo))
239
+ out.availableTo = r.availableTo;
240
+ return Object.keys(out).length > 0 ? out : null;
241
+ }
231
242
  /** Merge a raw `kinds` block over `base` (defaulting to the builtin registry):
232
243
  * each valid entry adds or shadows a kind by name; invalid entries are
233
244
  * dropped, never thrown. Mirrors `mergeModelLadders`'s layer-over-defaults
234
245
  * shape so a user/project config.json can add or override a single kind
235
- * without restating the whole registry. */
246
+ * without restating the whole registry. Two entry shapes:
247
+ * - FULL (has `whenToUse`): defines or wholly replaces the kind, as before.
248
+ * - PATCH (no `whenToUse`, ≥1 valid launch field): field-merges over the
249
+ * kind the layers below already define (dropped when they don't) — the
250
+ * sparse-override shape `persistDefaultKindModel` writes. */
236
251
  function mergeKinds(raw, base = defaultKindsConfig()) {
237
252
  const out = { ...base };
238
253
  if (raw !== null && typeof raw === 'object') {
239
254
  for (const [kind, value] of Object.entries(raw)) {
240
255
  const normalized = normalizeKindEntry(value);
241
- if (normalized !== null)
256
+ if (normalized !== null) {
242
257
  out[kind] = normalized;
258
+ continue;
259
+ }
260
+ const existing = out[kind];
261
+ if (existing === undefined)
262
+ continue;
263
+ const patch = normalizeKindPatch(value);
264
+ if (patch !== null)
265
+ out[kind] = { ...existing, ...patch };
243
266
  }
244
267
  }
245
268
  return out;
@@ -307,12 +330,12 @@ function mergeConfig(partial) {
307
330
  const max_panes_per_window = typeof rawMaxPanes === 'number' && Number.isFinite(rawMaxPanes) && rawMaxPanes >= 1
308
331
  ? Math.floor(rawMaxPanes)
309
332
  : defaults.max_panes_per_window;
310
- const canvasNav = mergeCanvasNav(partial.canvasNav);
333
+ const keybindings = normalizeKeybindings(partial.keybindings);
311
334
  const modelLadders = mergeModelLadders(partial.modelLadders);
312
335
  const kinds = mergeKinds(partial.kinds);
313
336
  const remoteCanvas = mergeRemoteCanvas(partial.remoteCanvas);
314
337
  const spawnEnv = mergeSpawnEnv(partial.spawnEnv, defaults.spawnEnv?.allow);
315
- return { schema_version, marketplaces, plugins, auto_update, max_panes_per_window, canvasNav, modelLadders, kinds, remoteCanvas, spawnEnv };
338
+ return { schema_version, marketplaces, plugins, auto_update, max_panes_per_window, keybindings, modelLadders, kinds, remoteCanvas, spawnEnv };
316
339
  }
317
340
  /** Raw (un-defaulted) partial config for one scope, or null if the scope has
318
341
  * no root or no config.json. Used by `readMergedLaunchConfig` to layer
@@ -320,7 +343,7 @@ function mergeConfig(partial) {
320
343
  * lower-precedence scope's real customization (see that function's comment
321
344
  * for why `readConfig(scope)`, which already defaults-fills, is unusable
322
345
  * for cross-scope layering). */
323
- function readRawScopeConfig(scope) {
346
+ export function readRawScopeConfig(scope) {
324
347
  const root = scopeRoot(scope);
325
348
  if (!root)
326
349
  return null;
@@ -440,11 +463,106 @@ export function subKindsAvailableTo(kind) {
440
463
  }
441
464
  return out.sort((a, b) => a.kind.localeCompare(b.kind));
442
465
  }
466
+ /** Every top-level `ScopeConfig` key, diffed one-by-one by `updateConfig` so
467
+ * an untouched key is never rewritten. */
468
+ const CONFIG_TOP_LEVEL_KEYS = [
469
+ 'schema_version', 'marketplaces', 'plugins', 'auto_update',
470
+ 'max_panes_per_window', 'keybindings', 'modelLadders', 'kinds',
471
+ 'remoteCanvas', 'spawnEnv',
472
+ ];
473
+ /** The SPARSE `modelLadders` to persist after a config mutation: the raw
474
+ * on-disk sparse ladder (absent rungs stay absent) plus ONLY the specific
475
+ * rungs / `defaultProvider` the mutation actually changed away from the
476
+ * merged baseline. Untouched default rungs are never materialized -- they
477
+ * hold concrete model ids that go stale across builds (the live-Hearth freeze
478
+ * bug). Returns undefined when nothing ladder-related is on disk and nothing
479
+ * was touched, so the key is omitted from config.json entirely. This is what
480
+ * makes a direct `sys config set modelLadders.<provider>.<strength>` persist
481
+ * only the touched rung through the SAME central write path as every other
482
+ * mutation, rather than a special case. */
483
+ function sparseModelLaddersToPersist(rawLadders, baseline, mutated) {
484
+ const out = { ...(rawLadders ?? {}) };
485
+ if (out.anthropic !== undefined)
486
+ out.anthropic = { ...out.anthropic };
487
+ if (out.openai !== undefined)
488
+ out.openai = { ...out.openai };
489
+ if (mutated.defaultProvider !== baseline.defaultProvider && mutated.defaultProvider !== undefined) {
490
+ out.defaultProvider = mutated.defaultProvider;
491
+ }
492
+ for (const provider of ['anthropic', 'openai']) {
493
+ for (const strength of ['ultra', 'strong', 'medium', 'light']) {
494
+ if (mutated[provider][strength] !== baseline[provider][strength]) {
495
+ out[provider] = { ...(out[provider] ?? {}), [strength]: mutated[provider][strength] };
496
+ }
497
+ }
498
+ }
499
+ const hasContent = out.defaultProvider !== undefined || out.anthropic !== undefined || out.openai !== undefined;
500
+ return hasContent ? out : undefined;
501
+ }
502
+ /** Apply `mutate` to a scope's MERGED config (so callers can read + write any
503
+ * field), then persist only a RAW PARTIAL: every top-level key the mutation
504
+ * left untouched keeps its exact on-disk raw value (absent stays absent, a
505
+ * sparse override stays sparse), and only keys the mutation actually changed
506
+ * are written. `modelLadders` gets sub-field-granular sparse handling via
507
+ * `sparseModelLaddersToPersist`. Result: a fresh bootstrap or any unrelated
508
+ * `sys config set` no longer freezes a full concrete `modelLadders` to disk,
509
+ * and a direct ladder-rung set still persists only the touched rung -- all
510
+ * through one lock-protected atomic update path. */
443
511
  export function updateConfig(scope, mutate) {
444
- const cfg = readConfig(scope);
445
- mutate(cfg);
446
- writeConfig(scope, cfg);
447
- return cfg;
512
+ let result;
513
+ updateRawConfigAtomically(scope, (latest) => {
514
+ const raw = latest;
515
+ const baseline = mergeConfig(raw);
516
+ const cfg = mergeConfig(raw);
517
+ mutate(cfg);
518
+ const partial = { ...raw };
519
+ for (const key of CONFIG_TOP_LEVEL_KEYS) {
520
+ if (key === 'modelLadders')
521
+ continue;
522
+ if (!isDeepStrictEqual(cfg[key], baseline[key])) {
523
+ partial[key] = cfg[key];
524
+ }
525
+ }
526
+ const ladders = sparseModelLaddersToPersist(raw.modelLadders, baseline.modelLadders, cfg.modelLadders);
527
+ if (ladders !== undefined)
528
+ partial.modelLadders = ladders;
529
+ else
530
+ delete partial.modelLadders;
531
+ result = cfg;
532
+ return partial;
533
+ });
534
+ return result;
535
+ }
536
+ /** Persist a model as the DEFAULT for a kind — the `/model`-picker "defaults
537
+ * going forward" write. Writes a SPARSE `kinds.<kind>` patch (just the one
538
+ * model field, merged by `normalizeKindPatch`/`mergeKinds` over whatever the
539
+ * lower scopes define — never a frozen full kind entry) into the PROFILE's
540
+ * config.json when the node runs under a profile, else the user scope's.
541
+ * Mode-aware: an orchestrator's pick lands on `orchestratorModel` (base
542
+ * workers of the same kind keep their own default), a base node's on `model`.
543
+ * Callers pass a model string in any form `normalizeModel` accepts; portable
544
+ * ladder tokens (`ultra`, `anthropic/strong`) are preferred by callers since
545
+ * they never go stale across builds. */
546
+ export function persistDefaultKindModel(opts) {
547
+ const field = opts.orchestrator === true ? 'orchestratorModel' : 'model';
548
+ let root;
549
+ let scope;
550
+ if (opts.profileId !== undefined && opts.profileId !== null && opts.profileId !== '') {
551
+ root = profileRoot(opts.profileId);
552
+ scope = 'profile';
553
+ }
554
+ else {
555
+ root = requireScopeRoot('user');
556
+ scope = 'user';
557
+ }
558
+ ensureDir(root);
559
+ const path = configPathFor(root);
560
+ const raw = readJsonIfExists(path) ?? {};
561
+ const kinds = { ...(raw.kinds ?? {}) };
562
+ kinds[opts.kind] = { ...(kinds[opts.kind] ?? {}), [field]: opts.model };
563
+ raw['kinds'] = kinds;
564
+ writeJson(path, raw);
565
+ return { scope, path, field };
448
566
  }
449
567
  export function updateState(scope, mutate) {
450
568
  const s = readState(scope);
@@ -15,7 +15,7 @@ function classifyProviderText(message) {
15
15
  return 'overloaded';
16
16
  if (/invalid_grant|refresh token|unauthori[sz]ed|invalid.?api.?key|authentication failed|\b401\b|\b403\b/i.test(m))
17
17
  return 'auth';
18
- if (/connection|econnreset|etimedout|enotfound|econnrefused|network|fetch failed|socket hang|timed? out|timeout/i.test(m))
18
+ if (/connection|econnreset|etimedout|enotfound|econnrefused|network|fetch failed|socket hang|timed? out|timeout|\bterminated\b|other side closed|premature close/i.test(m))
19
19
  return 'connection';
20
20
  return 'other';
21
21
  }
@@ -1,5 +1,6 @@
1
1
  export declare function ensureDir(dir: string): void;
2
2
  export declare function writeJson(path: string, data: unknown): void;
3
+ export declare function atomicWriteJson(path: string, data: unknown): void;
3
4
  export declare function readJson<T = unknown>(path: string): T;
4
5
  export declare function readJsonIfExists<T = unknown>(path: string): T | null;
5
6
  export declare function readTextIfExists(path: string): string | null;
@@ -1,4 +1,4 @@
1
- import { existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, symlinkSync, writeFileSync, cpSync, readlinkSync, } from 'node:fs';
1
+ import { existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, symlinkSync, writeFileSync, cpSync, readlinkSync, renameSync, chmodSync, } from 'node:fs';
2
2
  import { dirname, join, relative } from 'node:path';
3
3
  import { platform } from 'node:os';
4
4
  export function ensureDir(dir) {
@@ -8,6 +8,20 @@ export function writeJson(path, data) {
8
8
  ensureDir(dirname(path));
9
9
  writeFileSync(path, JSON.stringify(data, null, 2) + '\n', 'utf8');
10
10
  }
11
+ export function atomicWriteJson(path, data) {
12
+ ensureDir(dirname(path));
13
+ const temp = `${path}.${process.pid}.${Date.now()}.${Math.random().toString(16).slice(2)}.tmp`;
14
+ const mode = existsSync(path) ? statSync(path).mode & 0o777 : 0o600;
15
+ try {
16
+ writeFileSync(temp, JSON.stringify(data, null, 2) + '\n', { encoding: 'utf8', mode });
17
+ chmodSync(temp, mode);
18
+ renameSync(temp, path);
19
+ }
20
+ finally {
21
+ if (existsSync(temp))
22
+ rmSync(temp, { force: true });
23
+ }
24
+ }
11
25
  export function readJson(path) {
12
26
  return JSON.parse(readFileSync(path, 'utf8'));
13
27
  }
@@ -0,0 +1 @@
1
+ export {};