@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,9 +1,10 @@
1
1
  import { defineLeaf } from '../../core/command.js';
2
- import { createProfile, profileRoot } from '../../core/profiles/manifest.js';
2
+ import { usage } from '../../core/errors.js';
3
+ import { createProfile, ensureRootProfile, profileRoot } from '../../core/profiles/manifest.js';
3
4
  export const newLeaf = defineLeaf({
4
5
  name: 'new',
5
6
  description: 'create a new profile',
6
- whenToUse: 'you are establishing a new agent identity — a stable id, a display name, and its own memory store. Pass at most one initial project directory here (the parser has no repeatable-flag support); use `profile project add` afterward for every additional one.',
7
+ whenToUse: 'you are establishing a new agent identity — a stable id, a display name, and its own memory store. Pass at most one initial project directory here (the parser has no repeatable-flag support); use `profile project add` afterward for every additional one. Use `--root` (no name) to provision the canonical stable root profile — the default identity a home falls back to — idempotently, without spawning a node.',
7
8
  help: {
8
9
  name: 'profile new',
9
10
  summary: 'create a profile: `<profiles-root>/<slug>-<id>/` with a manifest + its own `memory/` store',
@@ -12,8 +13,15 @@ export const newLeaf = defineLeaf({
12
13
  kind: 'flag',
13
14
  name: 'name',
14
15
  type: 'string',
15
- required: true,
16
- constraint: 'Display name for the profile. Also slugified into the stable profile id — the id never changes on later rename.',
16
+ required: false,
17
+ constraint: 'Display name for the profile. Also slugified into the stable profile id — the id never changes on later rename. Required unless `--root` is given; mutually exclusive with `--root`.',
18
+ },
19
+ {
20
+ kind: 'flag',
21
+ name: 'root',
22
+ type: 'bool',
23
+ required: false,
24
+ constraint: 'Provision the canonical stable root profile (fixed id `root-00000000`, name `root`) — the fallback default identity for a home — instead of a fresh named one. Idempotent: re-running returns the existing root. Node-free (no `node new --root` needed). Mutually exclusive with `--name`/`--project`.',
17
25
  },
18
26
  {
19
27
  kind: 'flag',
@@ -32,11 +40,28 @@ export const newLeaf = defineLeaf({
32
40
  { name: 'follow_up', type: 'string', required: true, constraint: 'Concrete next commands.' },
33
41
  ],
34
42
  outputKind: 'object',
35
- effects: ['Creates `<profile-dir>/profile.json` and `<profile-dir>/memory/`.'],
43
+ effects: ['Creates `<profile-dir>/profile.json` and `<profile-dir>/memory/` (the fixed root dir when `--root`).'],
36
44
  },
37
45
  run: async (input) => {
38
46
  const name = input['name'];
39
47
  const project = input['project'];
48
+ const root = input['root'] === true;
49
+ if (root) {
50
+ if (name !== undefined || project !== undefined) {
51
+ throw usage('`--root` provisions the canonical stable root profile and takes no `--name`/`--project`.');
52
+ }
53
+ const { profileId, manifest } = ensureRootProfile();
54
+ return {
55
+ profile_id: profileId,
56
+ name: manifest.name,
57
+ path: profileRoot(profileId),
58
+ projects: manifest.projects,
59
+ created_at: manifest.created_at,
60
+ follow_up: `Root profile ensured. Spawn nodes under it with \`--profile ${profileId}\`, or widen its purview with \`crtr profile project add ${profileId} --dir <path>\`.`,
61
+ };
62
+ }
63
+ if (name === undefined)
64
+ throw usage('provide `--name <name>` to create a profile, or `--root` to provision the stable root profile.');
40
65
  const { profileId, manifest } = createProfile(name, project !== undefined ? [project] : []);
41
66
  return {
42
67
  profile_id: profileId,
@@ -2,9 +2,7 @@
2
2
  //
3
3
  // A genuine viewer-placement decision (tile every live viewer in a subtree into
4
4
  // one window), so it lives under `surface` alongside view/attach/workspace/web.
5
- // Also bound to the `/graph` view's `e` key (src/pi-extensions/canvas-nav.ts),
6
- // which shells this leaf directly for the cursor node. It "expands" the graph
7
- // you are watching: the caller's
5
+ // The command expands the graph being watched: the caller's
8
6
  // own viewer pane is broken out into a BRAND-NEW window, then a surface-attach
9
7
  // viewer pane is tiled beside it for every broker-alive node in the local
10
8
  // subtree (ancestry root → self → descendants), so the user lands on a single
@@ -0,0 +1,55 @@
1
+ import { after, before, beforeEach, test } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
4
+ import { tmpdir } from 'node:os';
5
+ import { join } from 'node:path';
6
+ import { configBranch } from '../config.js';
7
+ import { ensureScopeInitialized } from '../../../core/config.js';
8
+ import { resetScopeCache } from '../../../core/scope.js';
9
+ function leaf(name) {
10
+ const found = (configBranch.children ?? []).find((child) => child.name === name);
11
+ if (found === undefined || found.kind !== 'leaf')
12
+ throw new Error(`no leaf '${name}' under sys config`);
13
+ return found;
14
+ }
15
+ let home;
16
+ let realHome;
17
+ before(() => { realHome = process.env['HOME']; });
18
+ beforeEach(() => {
19
+ home = mkdtempSync(join(tmpdir(), 'crtr-config-keybindings-'));
20
+ process.env['HOME'] = home;
21
+ });
22
+ after(() => {
23
+ if (realHome === undefined)
24
+ delete process.env['HOME'];
25
+ else
26
+ process.env['HOME'] = realHome;
27
+ });
28
+ test.afterEach(() => rmSync(home, { recursive: true, force: true }));
29
+ test('a new project scope initializes through the config lock', () => {
30
+ const project = mkdtempSync(join(tmpdir(), 'crtr-config-project-'));
31
+ const previous = process.cwd();
32
+ try {
33
+ process.chdir(project);
34
+ resetScopeCache();
35
+ const root = join(project, '.crouter');
36
+ ensureScopeInitialized('project', root);
37
+ assert.equal(JSON.parse(readFileSync(join(root, 'config.json'), 'utf8')).keybindings, undefined);
38
+ }
39
+ finally {
40
+ process.chdir(previous);
41
+ resetScopeCache();
42
+ rmSync(project, { recursive: true, force: true });
43
+ }
44
+ });
45
+ test('sys config exposes whole user keybindings read-only and routes edits to setup', async () => {
46
+ mkdirSync(join(home, '.crouter'), { recursive: true });
47
+ writeFileSync(join(home, '.crouter', 'config.json'), JSON.stringify({
48
+ keybindings: { 'crtr.graph.down': ['down'] },
49
+ }));
50
+ const result = await leaf('get').run({ key: 'keybindings', scope: 'user' });
51
+ assert.deepEqual(result['value'], { 'crtr.graph.down': ['down'] });
52
+ await assert.rejects(() => leaf('get').run({ key: 'keybindings.crtr.graph.down', scope: 'user' }), /whole user-scope object/);
53
+ await assert.rejects(() => leaf('set').run({ key: 'keybindings', value: '{}' }), /crtr sys setup/);
54
+ assert.doesNotMatch(JSON.stringify(configBranch), /canvasNav/);
55
+ });
@@ -0,0 +1,121 @@
1
+ // config-model-ladders.test.ts — regression coverage for the live Hearth
2
+ // incident where a home's config.json had `openai-codex/gpt-5.6-*` baked into
3
+ // `modelLadders` while the guest's own installed model registry only had
4
+ // `gpt-5.5`. Root cause: both `ensureScopeInitialized` (fresh scope) and
5
+ // `sys config set modelLadders...` (a targeted edit) persisted the FULLY
6
+ // materialized ladder (all 4 strengths x 2 providers) rather than only the
7
+ // genuine override, freezing whatever concrete model ids happened to be
8
+ // compiled into the crouter build that ran at that moment. A later
9
+ // crouter/pi upgrade (or rollback) that changes the registry can then never
10
+ // reach the frozen rungs again.
11
+ import { test, before, after, beforeEach, afterEach } from 'node:test';
12
+ import assert from 'node:assert/strict';
13
+ import { mkdtempSync, rmSync, readFileSync, existsSync } from 'node:fs';
14
+ import { tmpdir } from 'node:os';
15
+ import { join } from 'node:path';
16
+ import { ensureScopeInitialized, readConfig, updateConfig } from '../../../core/config.js';
17
+ import { configBranch } from '../config.js';
18
+ function leaf(name) {
19
+ const found = (configBranch.children ?? []).find((c) => c.name === name);
20
+ if (found === undefined || found.kind !== 'leaf')
21
+ throw new Error(`no leaf '${name}' under sys config`);
22
+ return found;
23
+ }
24
+ let home;
25
+ let realHome;
26
+ before(() => {
27
+ realHome = process.env['HOME'];
28
+ });
29
+ beforeEach(() => {
30
+ home = mkdtempSync(join(tmpdir(), 'crtr-config-ladders-'));
31
+ process.env['HOME'] = home;
32
+ });
33
+ // Every test allocates its own temp HOME in beforeEach, so each must be removed
34
+ // in afterEach -- a single `after` would leak all but the last dir.
35
+ afterEach(() => {
36
+ rmSync(home, { recursive: true, force: true });
37
+ });
38
+ after(() => {
39
+ if (realHome !== undefined)
40
+ process.env['HOME'] = realHome;
41
+ else
42
+ delete process.env['HOME'];
43
+ });
44
+ test('ensureScopeInitialized does NOT bake concrete modelLadders model ids into a fresh config.json', () => {
45
+ const root = join(home, '.crouter');
46
+ ensureScopeInitialized('user', root);
47
+ const cfgPath = join(root, 'config.json');
48
+ assert.equal(existsSync(cfgPath), true);
49
+ const raw = JSON.parse(readFileSync(cfgPath, 'utf8'));
50
+ // The file must exist (so `sys config path` has something to point at) but
51
+ // must NOT freeze a modelLadders snapshot -- that key should be absent so
52
+ // `readConfig` always re-derives it live from whichever build is running.
53
+ assert.equal(raw.modelLadders, undefined);
54
+ // readConfig must still default-fill a full, usable ladder despite the
55
+ // omission -- a config-less scope is never worse off.
56
+ const cfg = readConfig('user');
57
+ assert.equal(typeof cfg.modelLadders.anthropic.strong, 'string');
58
+ assert.equal(typeof cfg.modelLadders.openai.strong, 'string');
59
+ });
60
+ test('sys config set modelLadders.<provider>.<strength> persists ONLY the touched rung, not the whole materialized ladder', async () => {
61
+ const root = join(home, '.crouter');
62
+ ensureScopeInitialized('user', root);
63
+ await leaf('set').run({ key: 'modelLadders.openai.strong', value: 'openai-codex/gpt-5.5:high' });
64
+ const cfgPath = join(root, 'config.json');
65
+ const raw = JSON.parse(readFileSync(cfgPath, 'utf8'));
66
+ assert.equal(raw.modelLadders.openai.strong, 'openai-codex/gpt-5.5:high');
67
+ // The other three openai rungs, and the entire anthropic ladder, must stay
68
+ // UNSET on disk -- baking them in would freeze this scope to today's
69
+ // compiled defaults for rungs the user never touched, the exact staleness
70
+ // bug that produced the live `gpt-5.6` vs. installed-`gpt-5.5` mismatch.
71
+ assert.equal(raw.modelLadders.openai.ultra, undefined);
72
+ assert.equal(raw.modelLadders.openai.medium, undefined);
73
+ assert.equal(raw.modelLadders.openai.light, undefined);
74
+ assert.equal(raw.modelLadders.anthropic, undefined);
75
+ // readConfig still resolves a full ladder: the one override layered over
76
+ // live compiled defaults for everything else.
77
+ const cfg = readConfig('user');
78
+ assert.equal(cfg.modelLadders.openai.strong, 'openai-codex/gpt-5.5:high');
79
+ assert.equal(typeof cfg.modelLadders.openai.ultra, 'string');
80
+ assert.equal(typeof cfg.modelLadders.anthropic.strong, 'string');
81
+ });
82
+ test('an unrelated updateConfig mutation (as fresh bootstrap does) does NOT freeze modelLadders to disk', () => {
83
+ const root = join(home, '.crouter');
84
+ ensureScopeInitialized('user', root);
85
+ // Mimic `ensureOfficialMarketplace`'s bootstrap write: updateConfig mutating an
86
+ // unrelated field on a fresh scope. This must not materialize a full ladder.
87
+ updateConfig('user', (c) => {
88
+ c.marketplaces['crouter-official-marketplace'] = {
89
+ url: 'https://example.invalid/x.git',
90
+ ref: 'main',
91
+ installed_at: '2026-01-01T00:00:00.000Z',
92
+ };
93
+ });
94
+ const cfgPath = join(root, 'config.json');
95
+ const raw = JSON.parse(readFileSync(cfgPath, 'utf8'));
96
+ assert.equal(raw.modelLadders, undefined);
97
+ assert.ok(raw.marketplaces['crouter-official-marketplace']);
98
+ });
99
+ test('a non-ladder sys config set does NOT freeze modelLadders to disk', async () => {
100
+ const root = join(home, '.crouter');
101
+ ensureScopeInitialized('user', root);
102
+ await leaf('set').run({ key: 'max_panes_per_window', value: '5' });
103
+ const cfgPath = join(root, 'config.json');
104
+ const raw = JSON.parse(readFileSync(cfgPath, 'utf8'));
105
+ // The touched key persists; modelLadders stays absent so a later build's
106
+ // registry change still reaches every rung.
107
+ assert.equal(raw.max_panes_per_window, 5);
108
+ assert.equal(raw.modelLadders, undefined);
109
+ });
110
+ test('a second sys config set for a different rung does not clobber an earlier override', async () => {
111
+ const root = join(home, '.crouter');
112
+ ensureScopeInitialized('user', root);
113
+ await leaf('set').run({ key: 'modelLadders.openai.strong', value: 'openai-codex/gpt-5.5:high' });
114
+ await leaf('set').run({ key: 'modelLadders.anthropic.light', value: 'anthropic/claude-haiku-4-5:high' });
115
+ const cfgPath = join(root, 'config.json');
116
+ const raw = JSON.parse(readFileSync(cfgPath, 'utf8'));
117
+ assert.equal(raw.modelLadders.openai.strong, 'openai-codex/gpt-5.5:high');
118
+ assert.equal(raw.modelLadders.anthropic.light, 'anthropic/claude-haiku-4-5:high');
119
+ assert.equal(raw.modelLadders.openai.ultra, undefined);
120
+ assert.equal(raw.modelLadders.anthropic.strong, undefined);
121
+ });
@@ -1,5 +1,5 @@
1
1
  import { defineBranch, defineLeaf } from '../../core/command.js';
2
- import { readConfig, writeConfig, configPath as coreConfigPath } from '../../core/config.js';
2
+ import { readConfig, updateConfig, configPath as coreConfigPath } from '../../core/config.js';
3
3
  import { usage, notFound } from '../../core/errors.js';
4
4
  import { scopeRoot, listScopes } from '../../core/scope.js';
5
5
  import { resolveScope } from './shared.js';
@@ -11,7 +11,6 @@ const TOP_LEVEL_KEYS = new Set([
11
11
  'marketplaces',
12
12
  'plugins',
13
13
  'max_panes_per_window',
14
- 'canvasNav',
15
14
  'modelLadders',
16
15
  ]);
17
16
  const MODEL_PROVIDERS = new Set(['anthropic', 'openai']);
@@ -44,6 +43,9 @@ function parseConfigValue(raw) {
44
43
  function setNestedValue(cfg, key, rawValue, value) {
45
44
  const parts = key.split('.');
46
45
  const topKey = parts[0];
46
+ if (topKey === 'keybindings') {
47
+ throw usage('keybindings are edited only through `crtr sys setup`');
48
+ }
47
49
  if (!TOP_LEVEL_KEYS.has(topKey)) {
48
50
  throw usage(`unknown config key: ${topKey} (expected: ${[...TOP_LEVEL_KEYS].join('|')})`);
49
51
  }
@@ -69,16 +71,6 @@ function setNestedValue(cfg, key, rawValue, value) {
69
71
  cfg.max_panes_per_window = Math.floor(value);
70
72
  return;
71
73
  }
72
- if (topKey === 'canvasNav') {
73
- // Only the scalar prefixKey is settable here; the prefixBinds/graphBinds
74
- // record tables are edited directly in config.json (run `crtr sys config
75
- // path` to locate it).
76
- if (key === 'canvasNav.prefixKey') {
77
- cfg.canvasNav.prefixKey = rawValue;
78
- return;
79
- }
80
- throw usage(`canvasNav.${parts.slice(1).join('.') || '*'} is a record table — edit config.json directly (run \`crtr sys config path\`). Only canvasNav.prefixKey is settable via this command.`);
81
- }
82
74
  if (topKey === 'modelLadders') {
83
75
  if (parts.length === 2 && parts[1] === 'defaultProvider') {
84
76
  if (!isModelProvider(rawValue)) {
@@ -121,12 +113,12 @@ function setNestedValue(cfg, key, rawValue, value) {
121
113
  const configGet = defineLeaf({
122
114
  name: 'get',
123
115
  description: 'read a config value by key',
124
- whenToUse: 'you want to read a single config value by its dotted key the current auto_update policy, max_panes_per_window, the canvasNav prefix key, or model ladders — optionally from a specific scope. Use sys config set instead to change a value, sys config path to locate the file for hand-editing.',
116
+ whenToUse: 'you want to inspect one config value — including the whole user keybindings object — optionally from a specific scope. Use sys setup to edit keybindings and sys config set for supported scalar settings.',
125
117
  help: {
126
118
  name: 'sys config get',
127
119
  summary: 'read a config value by dotted key',
128
120
  params: [
129
- { kind: 'positional', name: 'key', type: 'string', required: true, constraint: 'Dotted key path. Top-level keys: auto_update, marketplaces, plugins, max_panes_per_window, canvasNav, modelLadders (read whole; edit canvasNav.prefixBinds/graphBinds in config.json directly).' },
121
+ { kind: 'positional', name: 'key', type: 'string', required: true, constraint: 'Dotted key path. Top-level keys: auto_update, marketplaces, plugins, max_panes_per_window, modelLadders, or the whole keybindings object. Keybindings are edited only through sys setup.' },
130
122
  { kind: 'flag', name: 'scope', type: 'enum', choices: ['user', 'project'], required: false, constraint: 'Scope to read from. Default: user.' },
131
123
  ],
132
124
  output: [
@@ -140,6 +132,9 @@ const configGet = defineLeaf({
140
132
  run: async (input) => {
141
133
  const key = input['key'];
142
134
  const scope = resolveScope(input['scope']);
135
+ if (key.startsWith('keybindings.') || (key === 'keybindings' && scope !== 'user')) {
136
+ throw notFound('keybindings are available only as the whole user-scope object');
137
+ }
143
138
  const cfg = readConfig(scope);
144
139
  const value = getNestedValue(cfg, key);
145
140
  if (value === undefined) {
@@ -151,12 +146,12 @@ const configGet = defineLeaf({
151
146
  const configSet = defineLeaf({
152
147
  name: 'set',
153
148
  description: 'write a config value by key',
154
- whenToUse: 'you want to change a crtr setting — flip auto_update.crtr or auto_update.content to notify, apply, or off, raise max_panes_per_window, rebind canvasNav.prefixKey, or set model ladders — written to the user or project scope. Use sys config get instead to read a value; the canvasNav record tables are not settable here, so edit config.json directly (sys config path) for those.',
149
+ whenToUse: 'you want to change a supported scalar crtr setting in user or project scope. Use sys setup instead for keybindings, and sys config get to inspect values.',
155
150
  help: {
156
151
  name: 'sys config set',
157
152
  summary: 'write a config value by dotted key',
158
153
  params: [
159
- { kind: 'positional', name: 'key', type: 'string', required: true, constraint: 'Dotted key path. Supported: auto_update.crtr, auto_update.content, auto_update.interval_hours, max_panes_per_window, canvasNav.prefixKey, modelLadders.defaultProvider, modelLadders.<anthropic|openai>.<ultra|strong|medium|light>. The canvasNav.prefixBinds/graphBinds record tables are not settable here — edit config.json directly (`crtr sys config path`).' },
154
+ { kind: 'positional', name: 'key', type: 'string', required: true, constraint: 'Dotted key path. Supported: auto_update.crtr, auto_update.content, auto_update.interval_hours, max_panes_per_window, modelLadders.defaultProvider, modelLadders.<anthropic|openai>.<ultra|strong|medium|light>. Keybindings are edited only through sys setup.' },
160
155
  { kind: 'flag', name: 'value', type: 'string', required: true, constraint: 'value VALUE — string, required. Stored as-is if quoted; coerced to number or boolean when unambiguous.' },
161
156
  { kind: 'flag', name: 'scope', type: 'enum', choices: ['user', 'project'], required: false, constraint: 'Scope to write to. Default: user.' },
162
157
  ],
@@ -174,9 +169,11 @@ const configSet = defineLeaf({
174
169
  const scope = resolveScope(input['scope']);
175
170
  // Flags are stringly-typed; coerce to number or boolean when unambiguous
176
171
  const parsed = parseConfigValue(rawValue);
177
- const cfg = readConfig(scope);
178
- setNestedValue(cfg, key, rawValue, parsed);
179
- writeConfig(scope, cfg);
172
+ // Mutate + persist through the central raw-partial path. `updateConfig`
173
+ // writes only the keys this set actually touched -- a non-ladder set never
174
+ // freezes a materialized `modelLadders`, and a direct
175
+ // `modelLadders.<provider>.<strength>` set persists only the touched rung.
176
+ const cfg = updateConfig(scope, (c) => setNestedValue(c, key, rawValue, parsed));
180
177
  // Read back the written value for echo
181
178
  const written = getNestedValue(cfg, key);
182
179
  return { key, value: written, scope };
@@ -185,7 +182,7 @@ const configSet = defineLeaf({
185
182
  const configPath = defineLeaf({
186
183
  name: 'path',
187
184
  description: 'print path(s) to config.json',
188
- whenToUse: 'you need the absolute path to config.json typically to hand-edit settings sys config set cannot reach, like the canvasNav.prefixBinds and graphBinds record tables.',
185
+ whenToUse: 'you need the absolute path to a user or project config.json for inspection or backup.',
189
186
  help: {
190
187
  name: 'sys config path',
191
188
  summary: 'print absolute path(s) to config.json',
@@ -225,7 +222,7 @@ const configPath = defineLeaf({
225
222
  export const configBranch = defineBranch({
226
223
  name: 'config',
227
224
  description: 'read and write configuration',
228
- whenToUse: 'inspecting or changing crtr settings — read a value with sys config get, change one with sys config set (auto_update policy, max_panes_per_window, canvasNav.prefixKey, model ladders), or locate config.json with sys config path to hand-edit the record tables set cannot reach.',
225
+ whenToUse: 'inspecting crtr settings, changing supported scalar values, or locating config.json. Keybinding changes belong in sys setup.',
229
226
  help: {
230
227
  name: 'sys config',
231
228
  summary: 'read and write crtr configuration, including model ladders',
@@ -31,6 +31,7 @@ export interface SetupStaticInstructions {
31
31
  packageLines: string[];
32
32
  systemLines: string[];
33
33
  exaLine: string;
34
+ composioLine: string;
34
35
  footer: string;
35
36
  }
36
37
  export interface SetupSubmission {
@@ -39,18 +40,23 @@ export interface SetupSubmission {
39
40
  systemDeps: SetupSystemInstallPlan[];
40
41
  exaKey: string;
41
42
  exaKeyPath: string;
43
+ composioKey: string;
44
+ composioKeyPath: string;
42
45
  }
43
46
  export interface SetupExecutionSummary {
44
47
  installed: string[];
45
48
  skipped: string[];
46
49
  exaKeyWritten: boolean;
47
50
  exaKeyPath: string;
51
+ composioKeyWritten: boolean;
52
+ composioKeyPath: string;
48
53
  }
49
54
  export interface SetupInitialState {
50
55
  manifest: SetupPackageManifestEntry[];
51
56
  installedPackageIds: Set<SetupPackageId>;
52
57
  systemTools: SetupSystemToolState[];
53
58
  exaKeyPath: string;
59
+ composioKeyPath: string;
54
60
  }
55
61
  export declare function buildSetupManifest(resolveBuiltinPackageDir?: (name: string) => string): SetupPackageManifestEntry[];
56
62
  export declare function normalizePackageIdentity(source: string, settingsDir: string, homeDir: string): SetupPackageIdentity;
@@ -61,6 +67,7 @@ export declare function buildSystemInstallPlan(tool: SetupTool, packageManager:
61
67
  export declare function buildSystemToolState(tool: SetupTool, packageManager: PackageManager, commandExists?: (command: string) => boolean): SetupSystemToolState;
62
68
  export declare function resolveSettingsPath(homeDir?: string): string;
63
69
  export declare function resolveExaKeyPath(homeDir?: string): string;
70
+ export declare function resolveComposioKeyPath(homeDir?: string): string;
64
71
  export declare function buildSetupInitialState(opts?: {
65
72
  manifest?: SetupPackageManifestEntry[];
66
73
  settingsPath?: string;
@@ -73,9 +80,11 @@ export declare function buildSetupStaticInstructions(opts: {
73
80
  installedPackageIds: Set<SetupPackageId>;
74
81
  systemPlans: SetupSystemInstallPlan[];
75
82
  exaKeyPath: string;
83
+ composioKeyPath: string;
76
84
  }): SetupStaticInstructions;
77
- export declare function renderSetupStaticInstructions(state: Pick<SetupInitialState, 'manifest' | 'installedPackageIds' | 'systemTools' | 'exaKeyPath'>): string;
85
+ export declare function renderSetupStaticInstructions(state: Pick<SetupInitialState, 'manifest' | 'installedPackageIds' | 'systemTools' | 'exaKeyPath' | 'composioKeyPath'>): string;
78
86
  export declare function writeExaKey(exaKeyPath: string, exaKey: string): Promise<void>;
87
+ export declare function writeComposioKey(composioKeyPath: string, composioKey: string): Promise<void>;
79
88
  export declare function summarizeSetupExecution(result: {
80
89
  installedPackages: string[];
81
90
  skippedPackages: string[];
@@ -83,6 +92,8 @@ export declare function summarizeSetupExecution(result: {
83
92
  skippedSystemDeps: string[];
84
93
  exaKeyWritten: boolean;
85
94
  exaKeyPath: string;
95
+ composioKeyWritten: boolean;
96
+ composioKeyPath: string;
86
97
  }): SetupExecutionSummary;
87
98
  export declare function formatSetupExecutionSummary(summary: SetupExecutionSummary): string;
88
99
  export declare function executeSetupSubmission(submission: SetupSubmission): Promise<SetupExecutionSummary>;
@@ -13,8 +13,8 @@ const PACKAGE_COPY = {
13
13
  },
14
14
  'mode-switch': {
15
15
  name: 'pi-mode-switch',
16
- shortDescription: 'Alt+M cycles spec / plan / normal modes; injects mode-specific guidance on change.',
17
- longDescription: 'Press Alt+M to rotate between three operating modes — spec (requirements gathering), plan (design before code), and normal (execution). Each switch appends mode-tailored guidance to keep the agent in the right gear.',
16
+ shortDescription: 'Alt+Shift+P cycles spec / plan / normal modes; injects mode-specific guidance on change.',
17
+ longDescription: 'Press Alt+Shift+P to rotate between three operating modes — spec (requirements gathering), plan (design before code), and normal (execution). Each switch appends mode-tailored guidance to keep the agent in the right gear.',
18
18
  },
19
19
  'oauth-adapter': {
20
20
  name: 'pi-claude-oauth-adapter',
@@ -231,6 +231,9 @@ export function resolveSettingsPath(homeDir = homedir()) {
231
231
  export function resolveExaKeyPath(homeDir = homedir()) {
232
232
  return join(homeDir, '.crouter', 'exa.key');
233
233
  }
234
+ export function resolveComposioKeyPath(homeDir = homedir()) {
235
+ return join(homeDir, '.crouter', 'composio.key');
236
+ }
234
237
  export function buildSetupInitialState(opts = {}) {
235
238
  const homeDir = opts.homeDir ?? homedir();
236
239
  const settingsPath = opts.settingsPath ?? resolveSettingsPath(homeDir);
@@ -240,7 +243,7 @@ export function buildSetupInitialState(opts = {}) {
240
243
  const commandExists = opts.commandExists ?? detectCommandOnPath;
241
244
  const packageManager = opts.packageManager ?? detectPackageManager(process.platform, commandExists);
242
245
  const systemTools = ['tmux', 'nvim'].map((tool) => buildSystemToolState(tool, packageManager, commandExists));
243
- return { manifest, installedPackageIds, systemTools, exaKeyPath: resolveExaKeyPath(homeDir) };
246
+ return { manifest, installedPackageIds, systemTools, exaKeyPath: resolveExaKeyPath(homeDir), composioKeyPath: resolveComposioKeyPath(homeDir) };
244
247
  }
245
248
  export function buildSetupStaticInstructions(opts) {
246
249
  return {
@@ -257,6 +260,7 @@ export function buildSetupStaticInstructions(opts) {
257
260
  return `- ${plan.tool}: ${plan.hint}`;
258
261
  }),
259
262
  exaLine: `- Exa key: write to ${opts.exaKeyPath} or set EXA_API_KEY`,
263
+ composioLine: `- Composio key: write to ${opts.composioKeyPath} or set COMPOSIO_API_KEY`,
260
264
  footer: 'Run `crtr sys setup` in a TTY to open the wizard.',
261
265
  };
262
266
  }
@@ -266,10 +270,13 @@ export function renderSetupStaticInstructions(state) {
266
270
  installedPackageIds: state.installedPackageIds,
267
271
  systemPlans: state.systemTools.map((tool) => tool.plan),
268
272
  exaKeyPath: state.exaKeyPath,
273
+ composioKeyPath: state.composioKeyPath,
269
274
  });
270
275
  return [
271
276
  instructions.headline,
272
277
  '',
278
+ 'Provider login: open the wizard in a TTY and use the Login tab (same flow as /login) to authenticate a model provider.',
279
+ '',
273
280
  'Packages:',
274
281
  ...instructions.packageLines,
275
282
  '',
@@ -277,6 +284,7 @@ export function renderSetupStaticInstructions(state) {
277
284
  ...instructions.systemLines,
278
285
  '',
279
286
  instructions.exaLine,
287
+ instructions.composioLine,
280
288
  instructions.footer,
281
289
  ].join('\n') + '\n';
282
290
  }
@@ -297,12 +305,19 @@ export async function writeExaKey(exaKeyPath, exaKey) {
297
305
  await writeFile(exaKeyPath, `${exaKey}\n`, 'utf8');
298
306
  await chmod(exaKeyPath, 0o600).catch(() => undefined);
299
307
  }
308
+ export async function writeComposioKey(composioKeyPath, composioKey) {
309
+ await mkdir(dirname(composioKeyPath), { recursive: true });
310
+ await writeFile(composioKeyPath, `${composioKey}\n`, 'utf8');
311
+ await chmod(composioKeyPath, 0o600).catch(() => undefined);
312
+ }
300
313
  export function summarizeSetupExecution(result) {
301
314
  return {
302
315
  installed: [...result.installedPackages, ...result.installedSystemDeps],
303
316
  skipped: [...result.skippedPackages, ...result.skippedSystemDeps],
304
317
  exaKeyWritten: result.exaKeyWritten,
305
318
  exaKeyPath: result.exaKeyPath,
319
+ composioKeyWritten: result.composioKeyWritten,
320
+ composioKeyPath: result.composioKeyPath,
306
321
  };
307
322
  }
308
323
  export function formatSetupExecutionSummary(summary) {
@@ -311,6 +326,7 @@ export function formatSetupExecutionSummary(summary) {
311
326
  summary.installed.length > 0 ? `installed: ${summary.installed.join(', ')}` : 'installed: none',
312
327
  summary.skipped.length > 0 ? `skipped: ${summary.skipped.join(', ')}` : 'skipped: none',
313
328
  summary.exaKeyWritten ? `exa key written: ${summary.exaKeyPath}` : 'exa key written: no',
329
+ summary.composioKeyWritten ? `composio key written: ${summary.composioKeyPath}` : 'composio key written: no',
314
330
  ].join('\n') + '\n';
315
331
  }
316
332
  export async function executeSetupSubmission(submission) {
@@ -342,6 +358,10 @@ export async function executeSetupSubmission(submission) {
342
358
  if (exaKeyWritten) {
343
359
  await writeExaKey(submission.exaKeyPath, submission.exaKey);
344
360
  }
361
+ const composioKeyWritten = submission.composioKey.trim() !== '';
362
+ if (composioKeyWritten) {
363
+ await writeComposioKey(submission.composioKeyPath, submission.composioKey);
364
+ }
345
365
  return summarizeSetupExecution({
346
366
  installedPackages,
347
367
  skippedPackages,
@@ -349,5 +369,7 @@ export async function executeSetupSubmission(submission) {
349
369
  skippedSystemDeps,
350
370
  exaKeyWritten,
351
371
  exaKeyPath: submission.exaKeyPath,
372
+ composioKeyWritten,
373
+ composioKeyPath: submission.composioKeyPath,
352
374
  });
353
375
  }