@north-light/crouter 0.3.263 → 0.3.269

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 (116) hide show
  1. package/dist/api/client.d.ts +5 -1
  2. package/dist/api/client.js +6 -0
  3. package/dist/api/dto/canvas.d.ts +53 -2
  4. package/dist/api/dto/nodes.d.ts +6 -2
  5. package/dist/api/routes.d.ts +1 -0
  6. package/dist/api/routes.js +1 -0
  7. package/dist/builtin-memory/05-kinds/advisor/advice-contract.md +2 -2
  8. package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/__tests__/integration/provider-rotation.test.ts +50 -0
  9. package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/memory-slash-commands.ts +46 -6
  10. package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/provider-rotation.js +6 -2
  11. package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/provider-rotation.ts +7 -2
  12. package/dist/clients/attach/session/whip-achievements.d.ts +4 -0
  13. package/dist/clients/attach/session/whip-achievements.js +145 -0
  14. package/dist/clients/attach/slash/dispatch.js +5 -3
  15. package/dist/clients/attach/viewer.js +607 -606
  16. package/dist/commands/canvas-history/grep.js +3 -22
  17. package/dist/commands/canvas-history/read.js +5 -3
  18. package/dist/commands/canvas-history/search.js +3 -22
  19. package/dist/commands/canvas-history/shared.d.ts +8 -0
  20. package/dist/commands/canvas-history/shared.js +64 -1
  21. package/dist/commands/canvas-history/stats.d.ts +1 -0
  22. package/dist/commands/canvas-history/stats.js +68 -0
  23. package/dist/commands/canvas-history.js +4 -3
  24. package/dist/commands/node/create.js +2 -2
  25. package/dist/commands/sys/config.js +19 -2
  26. package/dist/commands/sys/panels/models-panel.js +3 -1
  27. package/dist/core/__tests__/integration/revive.test.js +4 -2
  28. package/dist/core/__tests__/model-pin-durability.test.js +25 -1
  29. package/dist/core/__tests__/relaunch-root.test.js +17 -0
  30. package/dist/core/__tests__/seam/broker-provider-retry.test.js +19 -4
  31. package/dist/core/__tests__/seam/memory-slash-node-relative-inventory.test.js +19 -2
  32. package/dist/core/broker-client/__tests__/transport-relay.test.js +3 -1
  33. package/dist/core/broker-client/transport-relay.js +5 -1
  34. package/dist/core/canvas/__tests__/history-transcript.test.d.ts +1 -0
  35. package/dist/core/canvas/__tests__/history-transcript.test.js +102 -0
  36. package/dist/core/canvas/browse/app.d.ts +1 -1
  37. package/dist/core/canvas/browse/app.js +13 -10
  38. package/dist/core/canvas/browse/model.js +16 -1
  39. package/dist/core/canvas/browse/render.d.ts +23 -3
  40. package/dist/core/canvas/browse/render.js +429 -64
  41. package/dist/core/canvas/canvas.js +12 -8
  42. package/dist/core/canvas/extensions.d.ts +1 -1
  43. package/dist/core/canvas/extensions.js +7 -1
  44. package/dist/core/canvas/history.d.ts +36 -1
  45. package/dist/core/canvas/history.js +328 -7
  46. package/dist/core/canvas/labels.d.ts +2 -2
  47. package/dist/core/canvas/migrations.js +32 -0
  48. package/dist/core/canvas/node-recap.d.ts +23 -0
  49. package/dist/core/canvas/node-recap.js +67 -0
  50. package/dist/core/canvas/render-source.d.ts +33 -1
  51. package/dist/core/canvas/render-source.js +80 -22
  52. package/dist/core/canvas/types.d.ts +9 -0
  53. package/dist/core/config.js +13 -5
  54. package/dist/core/keybindings/catalog.d.ts +2 -2
  55. package/dist/core/keybindings/catalog.js +2 -1
  56. package/dist/core/model-routes.js +1 -1
  57. package/dist/core/runtime/broker/auth-reload.js +4 -1
  58. package/dist/core/runtime/broker/fault-retry.js +7 -2
  59. package/dist/core/runtime/broker/frame-client.js +4 -0
  60. package/dist/core/runtime/broker/frame-dispatch.js +7 -0
  61. package/dist/core/runtime/broker/inbox.d.ts +12 -2
  62. package/dist/core/runtime/broker/inbox.js +68 -38
  63. package/dist/core/runtime/broker-protocol.d.ts +6 -0
  64. package/dist/core/runtime/broker.js +14 -0
  65. package/dist/core/runtime/canvas-extensions.d.ts +1 -0
  66. package/dist/core/runtime/canvas-extensions.js +2 -0
  67. package/dist/core/runtime/interactive-deliver.d.ts +8 -2
  68. package/dist/core/runtime/interactive-deliver.js +12 -3
  69. package/dist/core/runtime/launch-target.js +1 -1
  70. package/dist/core/runtime/launch.d.ts +12 -6
  71. package/dist/core/runtime/launch.js +19 -17
  72. package/dist/core/runtime/model-swap.d.ts +5 -4
  73. package/dist/core/runtime/model-swap.js +7 -5
  74. package/dist/core/runtime/promote.js +2 -1
  75. package/dist/core/runtime/reset.js +4 -2
  76. package/dist/core/runtime/revive.js +22 -0
  77. package/dist/core/runtime/spawn.d.ts +3 -3
  78. package/dist/core/runtime/spawn.js +20 -3
  79. package/dist/core/runtime/stamp/channel.d.ts +18 -0
  80. package/dist/core/runtime/stamp/channel.js +74 -0
  81. package/dist/core/runtime/stamp/protocol.d.ts +86 -0
  82. package/dist/core/runtime/stamp/protocol.js +113 -0
  83. package/dist/core/runtime/tmux-driver.d.ts +11 -1
  84. package/dist/core/runtime/tmux-driver.js +27 -11
  85. package/dist/core/self-update.js +2 -5
  86. package/dist/core/tui/draw.d.ts +1 -0
  87. package/dist/core/tui/draw.js +5 -1
  88. package/dist/core/tui/markdown.d.ts +17 -0
  89. package/dist/core/tui/markdown.js +407 -0
  90. package/dist/core/user-settings.d.ts +4 -0
  91. package/dist/core/user-settings.js +1 -0
  92. package/dist/daemon/api/__tests__/node-model-validation.test.js +21 -0
  93. package/dist/daemon/api/bridge.d.ts +3 -2
  94. package/dist/daemon/api/bridge.js +34 -9
  95. package/dist/daemon/api/handlers/canvas.js +62 -16
  96. package/dist/daemon/api/handlers/messages.js +12 -1
  97. package/dist/daemon/api/handlers/nodes.js +10 -3
  98. package/dist/daemon/api/map.js +2 -0
  99. package/dist/daemon/messaging/node-message.js +15 -1
  100. package/dist/daemon/reconcilers/broker-supervision.js +26 -9
  101. package/dist/daemon/reconcilers/live-obligation.d.ts +8 -1
  102. package/dist/daemon/reconcilers/live-obligation.js +17 -0
  103. package/dist/pi-extensions/canvas-inbox-watcher.js +27 -2
  104. package/dist/pi-extensions/canvas-recap.js +4 -0
  105. package/dist/pi-extensions/canvas-stamp.d.ts +14 -0
  106. package/dist/pi-extensions/canvas-stamp.js +77 -0
  107. package/dist/pi-extensions/canvas-stophook.js +3 -0
  108. package/dist/shared/__tests__/env-boundary.test.js +4 -2
  109. package/dist/shared/crtr-version.d.ts +2 -0
  110. package/dist/shared/crtr-version.js +18 -0
  111. package/dist/shared/env.d.ts +10 -0
  112. package/dist/shared/env.js +19 -4
  113. package/dist/types.d.ts +10 -0
  114. package/dist/types.js +4 -0
  115. package/package.json +1 -1
  116. package/runtime.lock.json +2 -2
@@ -1,14 +1,14 @@
1
1
  import { defineLeaf } from '../../core/command.js';
2
2
  import { usage } from '../../core/errors.js';
3
3
  import { cliClient, rethrowAsCliError } from '../api-client.js';
4
- import { filterParams, parseFilters } from './shared.js';
4
+ import { applyFilters, filterParams, parseFilters } from './shared.js';
5
5
  export const grepLeaf = defineLeaf({
6
6
  name: 'grep',
7
7
  description: 'exact regex line search across the cwd\'s node history',
8
8
  whenToUse: 'you know the exact string or pattern you\'re looking for — an error message, a literal identifier, a code fragment — and want every matching body LINE across every node that ran in this cwd, not a relevance ranking. The pattern is required (an ECMAScript regex, case-insensitive). Narrow with --type/--kind/--status/--since, scope elsewhere with --cwd/--all-cwds/--under/--node. Want ranked/browse search instead? Use `canvas history search` — a distinct leaf, not a flag here. Use `canvas history read <ref>` to read a hit\'s full body, `node inspect artifacts <node-id>` to list one node\'s artifacts, and `node lifecycle revive <id>` to reopen a node you found.',
9
9
  help: {
10
10
  name: 'canvas history grep',
11
- summary: 'required-pattern regex line search over the per-cwd episodic record (reports + context docs + meta)',
11
+ summary: 'required-pattern regex line search over the per-cwd episodic record (reports + context docs + meta; transcripts and inbox opt-in)',
12
12
  params: [
13
13
  { kind: 'positional', name: 'pattern', required: true, constraint: 'An ECMAScript regex, matched case-insensitively against each body line. One hit per matching line.' },
14
14
  ...filterParams,
@@ -43,26 +43,7 @@ export const grepLeaf = defineLeaf({
43
43
  // closures cannot cross the wire.
44
44
  const f = parseFilters(input);
45
45
  const q = { pattern, limit };
46
- if (f.cwd !== undefined)
47
- q.cwd = f.cwd;
48
- if (f.allCwds)
49
- q.all_cwds = true;
50
- if (f.under !== undefined)
51
- q.under = f.under;
52
- if (f.nodes !== undefined)
53
- q.nodes = f.nodes;
54
- if (f.types !== undefined)
55
- q.types = f.types;
56
- if (f.reportKind !== undefined)
57
- q.report_kind = f.reportKind;
58
- if (f.kinds !== undefined)
59
- q.kinds = f.kinds;
60
- if (f.statuses !== undefined)
61
- q.statuses = f.statuses;
62
- if (f.sinceMs !== undefined)
63
- q.since_ms = f.sinceMs;
64
- if (f.untilMs !== undefined)
65
- q.until_ms = f.untilMs;
46
+ applyFilters(q, f);
66
47
  if (cursor !== undefined)
67
48
  q.cursor = cursor;
68
49
  try {
@@ -9,13 +9,14 @@ export const readLeaf = defineLeaf({
9
9
  name: 'canvas history read',
10
10
  summary: 'resolve a <node-id>:<relpath> ref to its full artifact body',
11
11
  params: [
12
- { kind: 'positional', name: 'ref', required: true, constraint: 'The <node-id>:<relpath> handle from `canvas history search` (e.g. mq186ky0-c754531c:reports/20260607T075536-final.md, or <node-id>:meta for a node\'s identity).' },
12
+ { kind: 'positional', name: 'ref', required: true, constraint: 'The <node-id>:<relpath> handle from `canvas history search` (e.g. mq186ky0-c754531c:reports/20260607T075536-final.md, or <node-id>:meta for a node\'s identity). <node-id>:session addresses a node\'s whole conversation; <node-id>:session/<index> one message in it.' },
13
13
  { kind: 'flag', name: 'frontmatter', type: 'bool', required: false, constraint: 'Include the artifact\'s YAML frontmatter. Stripped by default.' },
14
+ { kind: 'flag', name: 'format', type: 'enum', choices: ['rendered', 'raw'], required: false, default: 'rendered', constraint: 'raw returns the verbatim bytes behind the ref — the session .jsonl for a bare session ref, the single persisted jsonl entry for one message, the unparsed file for a report or doc. Use it when the rendered view may be hiding what you are chasing.' },
14
15
  ],
15
16
  output: [
16
17
  { name: 'ref', type: 'string', required: true, constraint: 'Echo of the resolved ref.' },
17
18
  { name: 'node', type: 'string', required: true, constraint: 'Node name + id.' },
18
- { name: 'source', type: 'string', required: true, constraint: 'report | doc | roadmap | meta (+ report-kind for reports).' },
19
+ { name: 'source', type: 'string', required: true, constraint: 'report | doc | roadmap | meta | inbox | transcript (+ report-kind for reports).' },
19
20
  { name: 'ts', type: 'string', required: true, constraint: 'Artifact timestamp.' },
20
21
  { name: 'content', type: 'string', required: true, constraint: 'Full body. Frontmatter stripped unless --frontmatter.' },
21
22
  ],
@@ -25,6 +26,7 @@ export const readLeaf = defineLeaf({
25
26
  run: async (input) => {
26
27
  const ref = input['ref'].trim();
27
28
  const includeFrontmatter = input['frontmatter'] === true;
29
+ const format = input['format'] ?? 'rendered';
28
30
  // Fast local shape check; ref resolution (+ its usage/not-found errors) runs
29
31
  // server-side in crtrd (`GET /v1/canvas/history/read`).
30
32
  if (!ref.includes(':')) {
@@ -34,7 +36,7 @@ export const readLeaf = defineLeaf({
34
36
  });
35
37
  }
36
38
  try {
37
- const res = await cliClient().historyRead({ ref, frontmatter: includeFrontmatter });
39
+ const res = await cliClient().historyRead({ ref, frontmatter: includeFrontmatter, format });
38
40
  return { ref: res.ref, node: res.node, source: res.source, ts: res.ts, content: res.content };
39
41
  }
40
42
  catch (err) {
@@ -1,6 +1,6 @@
1
1
  import { defineLeaf } from '../../core/command.js';
2
2
  import { cliClient, rethrowAsCliError } from '../api-client.js';
3
- import { filterParams, parseFilters } from './shared.js';
3
+ import { applyFilters, filterParams, parseFilters } from './shared.js';
4
4
  const SORTS = ['relevance', 'recency', 'oldest'];
5
5
  export const searchLeaf = defineLeaf({
6
6
  name: 'search',
@@ -8,7 +8,7 @@ export const searchLeaf = defineLeaf({
8
8
  whenToUse: 'you want to find past work by what it SAYS — a design, a final report, a roadmap, a finding — across every node that ran in this cwd, ranked by relevance. Omit the query to browse the record by recency ("what happened here lately"). Narrow with --type/--kind/--status/--since, scope elsewhere with --cwd/--all-cwds/--under/--node. Need an exact regex over bodies instead of ranking? Use `canvas history grep` — a distinct leaf, not a flag here. Use `canvas history read <ref>` to read a hit in full, `node inspect artifacts <node-id>` to list one node\'s artifacts, and `node lifecycle revive <id>` to reopen a node you found.',
9
9
  help: {
10
10
  name: 'canvas history search',
11
- summary: 'ranked/filtered/sorted content search over the per-cwd episodic record (reports + context docs + meta)',
11
+ summary: 'ranked/filtered/sorted content search over the per-cwd episodic record (reports + context docs + meta; transcripts and inbox opt-in)',
12
12
  params: [
13
13
  { kind: 'positional', name: 'query', required: false, constraint: 'Whitespace-separated terms, matched case-insensitively and weighted across node name/description, report titles, and doc headings (and full bodies with --body); artifacts matching more/stronger fields rank higher. Omit to browse by recency (no relevance ranking; --sort still applies).' },
14
14
  ...filterParams,
@@ -45,26 +45,7 @@ export const searchLeaf = defineLeaf({
45
45
  const q = { snippet_lines: snippetLines, limit, sort: sortMode };
46
46
  if (query !== '')
47
47
  q.query = query;
48
- if (f.cwd !== undefined)
49
- q.cwd = f.cwd;
50
- if (f.allCwds)
51
- q.all_cwds = true;
52
- if (f.under !== undefined)
53
- q.under = f.under;
54
- if (f.nodes !== undefined)
55
- q.nodes = f.nodes;
56
- if (f.types !== undefined)
57
- q.types = f.types;
58
- if (f.reportKind !== undefined)
59
- q.report_kind = f.reportKind;
60
- if (f.kinds !== undefined)
61
- q.kinds = f.kinds;
62
- if (f.statuses !== undefined)
63
- q.statuses = f.statuses;
64
- if (f.sinceMs !== undefined)
65
- q.since_ms = f.sinceMs;
66
- if (f.untilMs !== undefined)
67
- q.until_ms = f.untilMs;
48
+ applyFilters(q, f);
68
49
  if (weighBody)
69
50
  q.weigh_body = true;
70
51
  if (full)
@@ -1,5 +1,7 @@
1
1
  import type { InputParam } from '../../core/help.js';
2
2
  export declare const TYPES: string[];
3
+ export declare const ORIGINS: string[];
4
+ export declare const ROLES: string[];
3
5
  export declare const NODE_KINDS: string[];
4
6
  export declare const STATUSES: string[];
5
7
  /** ISO 8601 (`2026-06-01`) \u2192 epoch ms (absolute); relative (`7d`, `2w`, `1mo`)
@@ -21,7 +23,13 @@ export interface ParsedFilters {
21
23
  statuses: string[] | undefined;
22
24
  sinceMs: number | undefined;
23
25
  untilMs: number | undefined;
26
+ origins: string[] | undefined;
27
+ roles: string[] | undefined;
28
+ from: string | undefined;
24
29
  }
25
30
  /** Parse + validate the shared scope/filter flags from a leaf's raw input.
26
31
  * Pure and client-side; the corpus scan itself runs server-side in crtrd. */
27
32
  export declare function parseFilters(input: Record<string, unknown>): ParsedFilters;
33
+ /** Copy every parsed filter onto a wire query. search, grep, and stats share
34
+ * the whole filter vocabulary, so the mapping lives once. */
35
+ export declare function applyFilters(q: Record<string, unknown>, f: ParsedFilters): void;
@@ -5,7 +5,12 @@
5
5
  // seam. This is internal reuse only: neither leaf's public flag set or output
6
6
  // shape is shared, and nothing here is re-exported as a command alias.
7
7
  import { usage } from '../../core/errors.js';
8
- export const TYPES = ['report', 'doc', 'roadmap', 'meta', 'inbox'];
8
+ export const TYPES = ['report', 'doc', 'roadmap', 'meta', 'inbox', 'transcript'];
9
+ export const ORIGINS = [
10
+ 'human', 'inbox', 'wake', 'revive', 'restart-continuation',
11
+ 'stop-guard', 'park', 'recovery', 'context-nudge', 'kickoff', 'unknown',
12
+ ];
13
+ export const ROLES = ['user', 'assistant', 'toolResult'];
9
14
  export const NODE_KINDS = ['advisor', 'developer', 'spec', 'design', 'review', 'explore', 'plan', 'general'];
10
15
  export const STATUSES = ['active', 'idle', 'done', 'dead', 'canceled'];
11
16
  const REL_UNITS = {
@@ -47,6 +52,9 @@ export const filterParams = [
47
52
  { kind: 'flag', name: 'status', type: 'string', required: false, constraint: `Node lifecycle status; comma-separated for several. One of: ${STATUSES.join(', ')} (e.g. done = completed work only).` },
48
53
  { kind: 'flag', name: 'since', type: 'string', required: false, constraint: 'Lower bound on artifact timestamp. ISO 8601 (2026-06-01) or relative (7d, 2w, 1mo).' },
49
54
  { kind: 'flag', name: 'until', type: 'string', required: false, constraint: 'Upper bound on artifact timestamp. ISO 8601 or relative.' },
55
+ { kind: 'flag', name: 'origin', type: 'string', required: false, constraint: `What drove a message to arrive; comma-separated for several. One of: ${ORIGINS.join(', ')}. Applies to transcript + inbox artifacts, which are the only ones that arrive — passing it therefore excludes every generated turn on its own. \`unknown\` is a message carrying no stamp, and is its own bucket rather than a fallback.` },
56
+ { kind: 'flag', name: 'role', type: 'string', required: false, constraint: `Transcript message role; comma-separated for several. One of: ${ROLES.join(', ')}.` },
57
+ { kind: 'flag', name: 'from', type: 'string', required: false, constraint: 'Case-insensitive substring against sender attribution — the node that sent a delivery. Applies to transcript + inbox artifacts.' },
50
58
  ];
51
59
  /** Parse + validate the shared scope/filter flags from a leaf's raw input.
52
60
  * Pure and client-side; the corpus scan itself runs server-side in crtrd. */
@@ -61,6 +69,28 @@ export function parseFilters(input) {
61
69
  if (bad !== undefined)
62
70
  throw usage(`--type must be one of: ${TYPES.join(', ')}; received: ${bad}`);
63
71
  }
72
+ const origins = splitCsv(input['origin']);
73
+ if (origins !== undefined) {
74
+ const bad = origins.find((o) => !ORIGINS.includes(o));
75
+ if (bad !== undefined)
76
+ throw usage(`--origin must be one of: ${ORIGINS.join(', ')}; received: ${bad}`);
77
+ }
78
+ const roles = splitCsv(input['role']);
79
+ if (roles !== undefined) {
80
+ const bad = roles.find((r) => !ROLES.includes(r));
81
+ if (bad !== undefined)
82
+ throw usage(`--role must be one of: ${ROLES.join(', ')}; received: ${bad}`);
83
+ }
84
+ // Session files are large and there is no persistent index, so the whole
85
+ // conversation corpus is reachable only through a narrowed scan. The guard
86
+ // lives in the failure rather than in a permanently visible constraint:
87
+ // agents who never search transcripts never pay to understand it.
88
+ if (types !== undefined && types.includes('transcript')) {
89
+ const narrowed = input['node'] !== undefined || input['under'] !== undefined || input['since'] !== undefined;
90
+ if (!narrowed) {
91
+ throw usage('--type transcript needs a narrowing flag: pass --node, --under, or --since.', { next: 'Transcripts are the full conversation corpus — scanning every node in a cwd reads every session file. Narrow first.' });
92
+ }
93
+ }
64
94
  return {
65
95
  cwd,
66
96
  allCwds,
@@ -72,5 +102,38 @@ export function parseFilters(input) {
72
102
  statuses: splitCsv(input['status']),
73
103
  sinceMs: input['since'] !== undefined ? parseWhen('since', input['since']) : undefined,
74
104
  untilMs: input['until'] !== undefined ? parseWhen('until', input['until']) : undefined,
105
+ origins,
106
+ roles,
107
+ from: input['from'],
75
108
  };
76
109
  }
110
+ /** Copy every parsed filter onto a wire query. search, grep, and stats share
111
+ * the whole filter vocabulary, so the mapping lives once. */
112
+ export function applyFilters(q, f) {
113
+ if (f.cwd !== undefined)
114
+ q['cwd'] = f.cwd;
115
+ if (f.allCwds)
116
+ q['all_cwds'] = true;
117
+ if (f.under !== undefined)
118
+ q['under'] = f.under;
119
+ if (f.nodes !== undefined)
120
+ q['nodes'] = f.nodes;
121
+ if (f.types !== undefined)
122
+ q['types'] = f.types;
123
+ if (f.reportKind !== undefined)
124
+ q['report_kind'] = f.reportKind;
125
+ if (f.kinds !== undefined)
126
+ q['kinds'] = f.kinds;
127
+ if (f.statuses !== undefined)
128
+ q['statuses'] = f.statuses;
129
+ if (f.sinceMs !== undefined)
130
+ q['since_ms'] = f.sinceMs;
131
+ if (f.untilMs !== undefined)
132
+ q['until_ms'] = f.untilMs;
133
+ if (f.origins !== undefined)
134
+ q['origins'] = f.origins;
135
+ if (f.roles !== undefined)
136
+ q['roles'] = f.roles;
137
+ if (f.from !== undefined)
138
+ q['from'] = f.from;
139
+ }
@@ -0,0 +1 @@
1
+ export declare const statsLeaf: import("../../core/command.js").LeafDef;
@@ -0,0 +1,68 @@
1
+ // `canvas history stats` — the grouped-count projection over the same corpus
2
+ // search and grep scan. A distinct leaf rather than a flag on search because
3
+ // the output shape differs: counts by one dimension, not a hit list. Grouping
4
+ // by origin is what turns the per-message stamps into a census — "what actually
5
+ // drove work here" — without a second command family.
6
+ import { defineLeaf } from '../../core/command.js';
7
+ import { usage } from '../../core/errors.js';
8
+ import { cliClient, rethrowAsCliError } from '../api-client.js';
9
+ import { applyFilters, filterParams, parseFilters } from './shared.js';
10
+ const GROUP_KEYS = ['origin', 'node', 'kind', 'day', 'type', 'role'];
11
+ export const statsLeaf = defineLeaf({
12
+ name: 'stats',
13
+ description: 'grouped counts over the cwd\'s node history — the projection view',
14
+ whenToUse: 'you want the SHAPE of the record rather than individual hits — how much of it came from where, who produced it, or when it happened. Group by origin to census what drove agents to run (cron wakes, inbox deliveries, recovery re-drives, people), by node/kind to see who produced the work, by day for volume over time, by type for corpus composition. Takes every scope and filter `canvas history search` takes, so any query you can search you can also count. Want the matching artifacts themselves? Use `canvas history search` or `canvas history grep`.',
15
+ help: {
16
+ name: 'canvas history stats',
17
+ summary: 'counts by one dimension over the per-cwd episodic record — origin census, per-node volume, activity by day',
18
+ params: [
19
+ { kind: 'flag', name: 'group-by', type: 'enum', choices: GROUP_KEYS, required: false, default: 'type', constraint: 'The dimension to count by. origin = what drove an inbound message (transcript + inbox only); role = transcript message role; node / kind = who produced it; day = artifact date; type = corpus composition.' },
20
+ ...filterParams,
21
+ ],
22
+ output: [
23
+ { name: 'group_by', type: 'string', required: true, constraint: 'The dimension counted.' },
24
+ { name: 'groups', type: 'object[]', required: true, constraint: '{key, count}, descending by count then key.' },
25
+ { name: 'total', type: 'int', required: true, constraint: 'Artifacts that carried the grouped dimension. Lower than `scanned` when grouping by one not every artifact has (origin, role).' },
26
+ { name: 'scanned', type: 'int', required: true, constraint: 'Artifacts matched by the filters before grouping.' },
27
+ ],
28
+ outputKind: 'object',
29
+ effects: ['None. Read-only.'],
30
+ },
31
+ run: async (input) => {
32
+ const by = input['groupBy'] ?? 'type';
33
+ if (!GROUP_KEYS.includes(by)) {
34
+ throw usage(`--group-by must be one of: ${GROUP_KEYS.join(', ')}; received: ${by}`);
35
+ }
36
+ // Filter validation stays client-side (pure); the corpus scan + grouping
37
+ // run server-side in crtrd, where canvas.db and the session files live.
38
+ const f = parseFilters(input);
39
+ const q = { group_by: by };
40
+ applyFilters(q, f);
41
+ try {
42
+ const res = await cliClient().historyStats(q);
43
+ return { group_by: res.group_by, groups: res.groups, total: res.total, scanned: res.scanned };
44
+ }
45
+ catch (err) {
46
+ rethrowAsCliError(err);
47
+ }
48
+ },
49
+ render: (r) => {
50
+ const groups = r['groups'];
51
+ const by = String(r['group_by']);
52
+ const total = r['total'];
53
+ const scanned = r['scanned'];
54
+ if (groups.length === 0) {
55
+ return `0 artifacts carried \`${by}\` (${scanned} scanned).`;
56
+ }
57
+ const width = Math.max(...groups.map((g) => g.key.length));
58
+ const max = groups[0].count;
59
+ const rows = groups
60
+ .map((g) => {
61
+ const bar = '█'.repeat(Math.max(1, Math.round((g.count / max) * 24)));
62
+ return ` ${g.key.padEnd(width)} ${String(g.count).padStart(6)} ${bar}`;
63
+ })
64
+ .join('\n');
65
+ const tail = total === scanned ? `${total} artifacts` : `${total} of ${scanned} scanned carried \`${by}\``;
66
+ return `by ${by}:\n\n${rows}\n\n- ${tail}`;
67
+ },
68
+ });
@@ -10,6 +10,7 @@ import { defineBranch } from '../core/command.js';
10
10
  import { searchLeaf } from './canvas-history/search.js';
11
11
  import { grepLeaf } from './canvas-history/grep.js';
12
12
  import { readLeaf } from './canvas-history/read.js';
13
+ import { statsLeaf } from './canvas-history/stats.js';
13
14
  /** A tiny per-cwd `<corpus>` marker for branch -h — just enough context to
14
15
  * anchor the help text without scanning nodes or files. Uses only the injected
15
16
  * node cwd env (or the process cwd outside a node), so help stays cheap. */
@@ -19,13 +20,13 @@ function corpusBlock() {
19
20
  }
20
21
  export const historyBranch = defineBranch({
21
22
  name: 'history',
22
- description: 'search and recall the cwd-wide content corpus — every node\'s reports and context docs',
23
+ description: 'search and recall the cwd-wide content corpus — every node\'s reports, context docs, and conversations',
23
24
  whenToUse: 'you want to find or re-read what was DONE in a cwd — a past design, a final report, a roadmap, a finding — across every node that ran there, WITHOUT already knowing which node. Use it when picking up prior work ("that caching work from last week"), recovering an artifact, or surveying a project\'s history. Already have the node id and just want everything IT left behind? Use `node inspect artifacts <id>` instead — this branch is the cwd-wide search corpus, not a per-node listing. Distinct from `crtr memory` (curated semantic knowledge, not episodes) and from `canvas dashboard`/`node inspect` (graph topology, not content).',
24
25
  help: {
25
26
  name: 'canvas history',
26
27
  summary: 'search and recall the cwd-wide content corpus — every node\'s reports and context docs in this cwd',
27
- model: 'The accumulated record of every node that ran in a cwd and the artifacts it left — its reports (final/update outcome summaries) and context docs (specs, designs, roadmaps, findings). `search` finds by content (ranked, filtered, sorted; omit the query to browse by recency); `grep` finds by an exact ECMAScript regex over body lines (pattern required, one hit per matching line — use this over `search` when you know the literal string/pattern, not just the topic); `read` pulls one hit\'s full body by its <node-id>:<relpath> ref. This is the episodic record of what was DONE — distinct from `crtr memory`, which is curated semantic knowledge. Already have a node id and want everything IT left behind (not a cwd-wide search)? Use `node inspect artifacts <node-id>` instead — that single-node listing moved out of this branch. A node\'s raw inbox history (`--type inbox`) is a separate, opt-in diagnostic corpus — the full cursor-independent inbox.jsonl for one node, including un-clipped direct-message bodies; reach for it only when the curated reports/docs above don\'t already answer the question. To reopen a node you found, use `node lifecycle revive`; for its place in the graph, `node inspect show`.',
28
+ model: 'The accumulated record of every node that ran in a cwd and the artifacts it left — its reports (final/update outcome summaries), context docs (specs, designs, roadmaps, findings), and the conversations themselves. `search` finds by content (ranked, filtered, sorted; omit the query to browse by recency); `grep` finds by an exact ECMAScript regex over body lines (pattern required, one hit per matching line — use this over `search` when you know the literal string/pattern, not just the topic); `read` pulls one hit\'s full body by its <node-id>:<relpath> ref, `--format raw` for the verbatim bytes; `stats` counts the same filtered corpus by one dimension instead of listing hits. Corpus is a DIMENSION here, not a location: `--type` selects which record you search, and every leaf takes the same scope and filters. This is the episodic record of what was DONE — distinct from `crtr memory`, which is curated semantic knowledge. Already have a node id and want everything IT left behind (not a cwd-wide search)? Use `node inspect artifacts <node-id>` instead. Two corpora are opt-in diagnostics rather than curated work: `--type inbox` is one node\'s full cursor-independent inbox.jsonl including un-clipped message bodies, and `--type transcript` is the conversation itself, message by message, each inbound turn carrying the origin that drove it (`--origin`, `--role`, `--from`, and `stats --group-by origin` for the census). Transcripts require a narrowing flag because there is no index. To reopen a node you found, use `node lifecycle revive`; for its place in the graph, `node inspect show`.',
28
29
  dynamicState: corpusBlock,
29
30
  },
30
- children: [searchLeaf, grepLeaf, readLeaf],
31
+ children: [searchLeaf, grepLeaf, readLeaf, statsLeaf],
31
32
  });
@@ -94,8 +94,8 @@ function nodeNewParams() {
94
94
  whenToUse: 'Use only when the user, task, or governing agent guidance already requires a specific provider, model, capability tier, or family. Omit it for routine delegation: the selected persona kind and profile own normal model selection, and review personas may enforce a quality floor.',
95
95
  value: `Accepted forms: ${MODEL_SPEC_FORMS}. Concrete provider/id requests must be registered. Portable provider/tier, bare-tier, and family-alias requests resolve through the configured model ladders.`,
96
96
  effects: [
97
- 'The resolved override is stored on the node and in its launch recipe, so future revives preserve it.',
98
- 'A concrete provider/id pins that provider. Portable tiers and aliases preserve logical intent for runtime routing; a persona quality floor may raise a weaker request.',
97
+ 'The resolved concrete override is stored on the node and in its launch recipe, so every launch and revive uses that exact provider/model.',
98
+ 'Portable tiers and aliases resolve through the configured ladders; a persona quality floor may raise a weaker request before its resolved model is stored.',
99
99
  ],
100
100
  },
101
101
  },
@@ -39,6 +39,11 @@ function assertUserScopeBrokerThresholds(key, scope) {
39
39
  throw usage('brokerThresholds are available only in user scope');
40
40
  }
41
41
  }
42
+ function assertUserScopeLifecycle(key, scope) {
43
+ if (key.split('.', 1)[0] === 'lifecycle' && scope !== 'user') {
44
+ throw usage('lifecycle settings are available only in user scope');
45
+ }
46
+ }
42
47
  function assertWorkingGerundsScope(key, scope) {
43
48
  if (key === 'working_gerunds' && scope !== 'user') {
44
49
  throw usage('working_gerunds is available only in user scope');
@@ -132,6 +137,16 @@ function setNestedValue(cfg, key, rawValue, value) {
132
137
  cfg.brokerThresholds[parts[1]] = threshold;
133
138
  return;
134
139
  }
140
+ if (topKey === 'lifecycle') {
141
+ if (parts.length !== 2 || parts[1] !== 'unattendedParkMs') {
142
+ throw usage(`lifecycle key must be unattendedParkMs`);
143
+ }
144
+ if (typeof value !== 'number' || !Number.isFinite(value) || value < 1) {
145
+ throw usage(`lifecycle.unattendedParkMs must be an integer >= 1`);
146
+ }
147
+ cfg.lifecycle.unattendedParkMs = Math.floor(value);
148
+ return;
149
+ }
135
150
  throw usage(`unsupported key path for set: ${key}`);
136
151
  }
137
152
  // Leaf definitions
@@ -143,7 +158,7 @@ const configGet = defineLeaf({
143
158
  name: 'sys config get',
144
159
  summary: 'read a config value by dotted key',
145
160
  params: [
146
- { kind: 'positional', name: 'key', type: 'string', required: true, constraint: `Dotted key path. Top-level keys: ${CONFIG_TOP_LEVEL_HELP}. brokerThresholds is user-only; keybindings is readable only as the whole user-scope object and editable only through sys settings.` },
161
+ { kind: 'positional', name: 'key', type: 'string', required: true, constraint: `Dotted key path. Top-level keys: ${CONFIG_TOP_LEVEL_HELP}. brokerThresholds and lifecycle are user-only; keybindings is readable only as the whole user-scope object and editable only through sys settings.` },
147
162
  { kind: 'flag', name: 'scope', type: 'enum', choices: ['user', 'project'], required: false, constraint: 'Scope to read from. Default: user.' },
148
163
  ],
149
164
  output: [
@@ -158,6 +173,7 @@ const configGet = defineLeaf({
158
173
  const key = input['key'];
159
174
  const scope = resolveScope(input['scope']);
160
175
  assertUserScopeBrokerThresholds(key, scope);
176
+ assertUserScopeLifecycle(key, scope);
161
177
  if (key.startsWith('keybindings.') || (key === 'keybindings' && scope !== 'user')) {
162
178
  throw notFound('keybindings are available only as the whole user-scope object');
163
179
  }
@@ -177,7 +193,7 @@ const configSet = defineLeaf({
177
193
  name: 'sys config set',
178
194
  summary: 'write a config value by dotted key',
179
195
  params: [
180
- { kind: 'positional', name: 'key', type: 'string', required: true, constraint: `Dotted key path. User-scope scalar settings: ${SCALAR_SETTING_HELP}. Also supported: working_gerunds and whip_messages (non-empty JSON string arrays), brokerThresholds.warning and brokerThresholds.automaticReviveCap (user scope; integer >= 1), modelLadders.defaultProvider, and modelLadders.<anthropic|openai>.<ultra|strong|medium|light>. Keybindings are edited only through sys settings. humanActions is an object map edited directly in ~/.crouter/config.json or <repo>/.crouter/config.json.` },
196
+ { kind: 'positional', name: 'key', type: 'string', required: true, constraint: `Dotted key path. User-scope scalar settings: ${SCALAR_SETTING_HELP}. Also supported: working_gerunds and whip_messages (non-empty JSON string arrays), brokerThresholds.warning and brokerThresholds.automaticReviveCap (user scope; integer >= 1), lifecycle.unattendedParkMs (user scope; integer ms >= 1), modelLadders.defaultProvider, and modelLadders.<anthropic|openai>.<ultra|strong|medium|light>. Keybindings are edited only through sys settings. humanActions is an object map edited directly in ~/.crouter/config.json or <repo>/.crouter/config.json.` },
181
197
  { kind: 'flag', name: 'value', type: 'string', required: true, constraint: 'value VALUE — string, required. working_gerunds and whip_messages accept JSON arrays; other values are stored as-is if quoted and coerced to number or boolean when unambiguous.' },
182
198
  { kind: 'flag', name: 'scope', type: 'enum', choices: ['user', 'project'], required: false, constraint: 'Scope to write to. Default: user. Project scope is supported only for modelLadders.' },
183
199
  ],
@@ -193,6 +209,7 @@ const configSet = defineLeaf({
193
209
  const rawValue = input['value'];
194
210
  const scope = resolveScope(input['scope']);
195
211
  assertUserScopeBrokerThresholds(key, scope);
212
+ assertUserScopeLifecycle(key, scope);
196
213
  assertWorkingGerundsScope(key, scope);
197
214
  assertWhipMessagesScope(key, scope);
198
215
  assertUserSettingScope(key, scope);
@@ -961,7 +961,9 @@ export class ModelsPanel {
961
961
  const contentBudget = Math.max(1, height);
962
962
  const frame = (lines) => renderRows(lines, width);
963
963
  if (this.mode === "dimension-picker")
964
- return frame(fitTail(this.renderChoicePicker(), contentBudget));
964
+ // Provider ids run to dozens of options, so the picker windows on its
965
+ // selection like the list does — a tail cut would scroll the cursor off.
966
+ return frame(this.fitBody(this.renderChoicePicker(), MODELS_CHROME_LINES + this.choiceIndex, contentBudget));
965
967
  if (this.mode === "model-picker")
966
968
  return frame(fitTail(this.modelSelector?.render(width) ?? [], contentBudget));
967
969
  if (this.mode === "thinking-picker")
@@ -180,7 +180,8 @@ test('a resumed revive replays the persisted LaunchSpec (model, tools, extension
180
180
  pi_session_id: 'uuid-9',
181
181
  pi_session_file: sessionFile,
182
182
  launch: {
183
- model: 'anthropic/sonnet',
183
+ model: 'anthropic/claude-opus-5:high',
184
+ modelExact: true,
184
185
  tools: ['bash', 'read'],
185
186
  extensions: ['/ext/a.js', '/ext/b.js'],
186
187
  systemPrompt: 'You are a developer orchestrator.',
@@ -196,7 +197,8 @@ test('a resumed revive replays the persisted LaunchSpec (model, tools, extension
196
197
  assert.equal(argv[ea - 1], '-e');
197
198
  // Model + tools + system prompt + resume target.
198
199
  const mi = argv.indexOf('--model');
199
- assert.equal(argv[mi + 1], 'anthropic/sonnet');
200
+ assert.equal(argv[mi + 1], 'anthropic/claude-opus-5:high');
201
+ assert.ok(argv.includes('--crtr-model-exact'), 'revive preserves the exact-model contract');
200
202
  const ti = argv.indexOf('--tools');
201
203
  assert.equal(argv[ti + 1], 'bash,read');
202
204
  assert.ok(argv.includes('--append-system-prompt'), 'system prompt replayed');
@@ -20,7 +20,7 @@ import { join } from 'node:path';
20
20
  import { buildLaunchSpec, isProviderPinnedModelToken, normalizeModel } from '../runtime/launch.js';
21
21
  import { defaultModelLaddersConfig } from '../../types.js';
22
22
  import { persistDormantModel } from '../runtime/model-swap.js';
23
- import { promote } from '../runtime/promote.js';
23
+ import { promote, reshapeNode } from '../runtime/promote.js';
24
24
  import { createNode, getNode } from '../canvas/canvas.js';
25
25
  import { closeDb } from '../canvas/db.js';
26
26
  const ladders = defaultModelLaddersConfig();
@@ -86,6 +86,15 @@ test('only concrete provider/id tokens pin; logical family/tier refs remain pref
86
86
  assert.equal(isProviderPinnedModelToken('ultra'), false, 'a bare tier has no `/` and stays unpinned');
87
87
  assert.equal(isProviderPinnedModelToken('opus'), false, 'a bare family alias has no `/` and stays unpinned');
88
88
  });
89
+ test('a new --model override resolves once and freezes portable and concrete requests', () => {
90
+ for (const requested of ['anthropic/opus', 'strong', 'anthropic/claude-fable-5:high']) {
91
+ const launch = buildSpec({ model: requested, modelOverride: true });
92
+ assert.equal(launch.model, normalizeModel(requested, ladders), `${requested} resolves through the configured ladder`);
93
+ assert.equal(launch.modelExact, true, `${requested} is exact after resolution`);
94
+ assert.equal(launch.modelIntent, undefined, `${requested} has no portable route intent`);
95
+ assert.equal(launch.env.CRTR_MODEL_INTENT, undefined, `${requested} cannot re-enable fallback on revive`);
96
+ }
97
+ });
89
98
  // Direction 1: an EXPLICIT concrete pin must SURVIVE a config/lifecycle rebuild
90
99
  // even when the carried override has portablized back to a bare tier.
91
100
  test('an explicit concrete pin survives a config/lifecycle rebuild whose portablized override reads as a bare tier', () => {
@@ -103,6 +112,20 @@ test('an explicit concrete pin survives a config/lifecycle rebuild whose portabl
103
112
  // Direction 2: a deliberately TIER-run (unpinned) node must NOT be wrongly
104
113
  // re-pinned when a subsequent rebuild/persist normalizes its override to the
105
114
  // concrete cell it happens to share bytes with.
115
+ test('reshape resolves and persists a new --model override as the exact revive recipe', () => {
116
+ createNode(node('override-node', {
117
+ model_override: 'strong',
118
+ launch: { extensions: [], env: { CRTR_MODEL_INTENT: 'anthropic/strong' }, model: normalizeModel('strong', ladders), modelIntent: { family: 'anthropic', strength: 'strong' } },
119
+ }));
120
+ reshapeNode('override-node', { model: 'anthropic/opus' });
121
+ const after = getNode('override-node');
122
+ const expected = normalizeModel('anthropic/opus', ladders);
123
+ assert.equal(after?.model_override, expected, 'the raw alias never becomes the durable override');
124
+ assert.equal(after?.launch?.model, expected, 'revive replays the resolved provider/model');
125
+ assert.equal(after?.launch?.modelExact, true, 'retries and revives reject substitutes');
126
+ assert.equal(after?.launch?.modelIntent, undefined);
127
+ assert.equal(after?.launch?.env.CRTR_MODEL_INTENT, undefined);
128
+ });
106
129
  test('promotion preserves the exact-model contract required to revive a private human-work fork', () => {
107
130
  const exactModel = 'openai-codex/gpt-5.6-sol:max';
108
131
  createNode(node('exact-node', {
@@ -134,6 +157,7 @@ test('a deliberately tier-run node is not wrongly re-pinned when a rebuild or pe
134
157
  const after = getNode('tier-node');
135
158
  assert.equal(after?.launch?.model, ULTRA_CELL, 'the concrete spec is persisted as the launch model');
136
159
  assert.equal(after?.model_override, ULTRA_CELL, 'the concrete spec is persisted as the override');
160
+ assert.equal(after?.launch?.modelExact, true, 'an explicit model change remains exact on revive');
137
161
  assert.equal(after?.launch?.modelIntent, undefined, 'an explicit concrete choice clears the prior logical route intent');
138
162
  assert.equal(after?.launch?.env.CRTR_MODEL_INTENT, undefined, 'the revived broker does not inherit the prior logical route intent');
139
163
  assert.equal(after?.launch?.modelProviderPinned, false, 'an explicit bare-tier decision clears a prior pin — the raw-token decision wins over the concrete spec shape');
@@ -18,6 +18,7 @@ import { createNode, getNode, subscribe } from '../canvas/canvas.js';
18
18
  import { closeDb } from '../canvas/db.js';
19
19
  import { apiSocketPath } from '../canvas/paths.js';
20
20
  import { brokerCapReached, resetFleetForTests } from '../runtime/fleet.js';
21
+ import { buildPiArgv } from '../runtime/launch.js';
21
22
  import { relaunchRoot } from '../runtime/reset.js';
22
23
  import { bindTestFleet } from './helpers/fleet.js';
23
24
  let home;
@@ -106,6 +107,22 @@ test('relaunchRoot parks the old root done and mints a fresh active root, reapin
106
107
  assert.equal(getNode('child')?.status, 'canceled', 'descendant child canceled');
107
108
  assert.equal(getNode('grand')?.status, 'canceled', 'descendant grandchild canceled');
108
109
  });
110
+ test('relaunchRoot preserves an explicit model override as an exact replay recipe', async () => {
111
+ const exactModel = 'anthropic/claude-opus-5:high';
112
+ createNode(node('root', {
113
+ parent: null,
114
+ lifecycle: 'resident',
115
+ model_override: exactModel,
116
+ launch: { extensions: [], env: {}, model: exactModel, modelExact: true },
117
+ }));
118
+ const { deps } = makeDeps(4242);
119
+ const result = await relaunchRoot('root', deps);
120
+ assert.ok(result);
121
+ const replacement = getNode(result.newNodeId);
122
+ assert.equal(replacement?.launch?.model, exactModel);
123
+ assert.equal(replacement?.launch?.modelExact, true, 'the replacement cannot route away from the override');
124
+ assert.ok(buildPiArgv(replacement, {}).argv.includes('--crtr-model-exact'), 'broker replay preserves the exact-model contract');
125
+ });
109
126
  test('relaunchRoot on a boot failure leaves the old root + descendants fully intact', async () => {
110
127
  // The mint-and-boot-FIRST ordering: a launch failure (pid=null) must occur
111
128
  // BEFORE the old root is parked or its descendants reaped.
@@ -337,7 +337,13 @@ test("picker and Alt+M choices each clear logical routing, and Alt+M / Shift+Tab
337
337
  const parent = h.spawnRoot('model durability parent');
338
338
  seedPool('anthropic', false);
339
339
  seedPool('openai-codex', false);
340
- const pickerNode = await h.spawnHeadlessChild(parent, 'picker durability child', { model: 'openai/strong' });
340
+ const userConfigDir = join(h.agentHome, '.crouter');
341
+ const userConfigPath = join(userConfigDir, 'config.json');
342
+ mkdirSync(userConfigDir, { recursive: true });
343
+ writeFileSync(userConfigPath, JSON.stringify({
344
+ kinds: { 'test/logical-openai': { whenToUse: 'Logical OpenAI routing test.', model: 'openai/strong' } },
345
+ }));
346
+ const pickerNode = await h.spawnHeadlessChild(parent, 'picker durability child', { kind: 'test/logical-openai' });
341
347
  const pickerBefore = h.node(pickerNode);
342
348
  assert.deepEqual(pickerBefore.launch?.modelIntent, { family: 'openai', strength: 'strong' }, 'picker fixture starts from a logical launch preference');
343
349
  assert.equal(pickerBefore.launch?.env.CRTR_MODEL_INTENT, 'openai/strong');
@@ -358,7 +364,7 @@ test("picker and Alt+M choices each clear logical routing, and Alt+M / Shift+Tab
358
364
  // A separate logical launch reaches Alt+M with BOTH intent representations
359
365
  // still populated. This is the regression path: a user-selected ladder rung
360
366
  // must clear them before a restart can resolve the stale route over it.
361
- const ladderNode = await h.spawnHeadlessChild(parent, 'ladder durability child', { model: 'openai/strong' });
367
+ const ladderNode = await h.spawnHeadlessChild(parent, 'ladder durability child', { kind: 'test/logical-openai' });
362
368
  const ladderBefore = h.node(ladderNode);
363
369
  assert.deepEqual(ladderBefore.launch?.modelIntent, { family: 'openai', strength: 'strong' }, 'Alt+M starts with durable logical modelIntent present');
364
370
  assert.equal(ladderBefore.launch?.env.CRTR_MODEL_INTENT, 'openai/strong', 'Alt+M starts with the launch environment intent present');
@@ -388,6 +394,7 @@ test("picker and Alt+M choices each clear logical routing, and Alt+M / Shift+Tab
388
394
  assert.equal(replay.argv[modelArg + 1], expected, 'the replayed model argument carries both choices');
389
395
  assert.equal(replay.env.CRTR_MODEL_INTENT, undefined, 'the stale logical intent is absent from the restarted broker environment');
390
396
  ladderCtrl.close();
397
+ rmSync(userConfigPath, { force: true });
391
398
  });
392
399
  test('reload_auth restores a newly authenticated personal route before the next idle prompt', { timeout: 30_000 }, async () => {
393
400
  const personal = { provider: 'personal-anthropic', id: 'claude-personal' };
@@ -398,6 +405,7 @@ test('reload_auth restores a newly authenticated personal route before the next
398
405
  const unauthenticatedProvider = join(h.home, 'fake-pi.unauthenticated-provider');
399
406
  mkdirSync(userConfigDir, { recursive: true });
400
407
  writeFileSync(userConfigPath, JSON.stringify({
408
+ kinds: { general: { whenToUse: 'General work.', model: 'anthropic/medium' } },
401
409
  modelRoutes: {
402
410
  personal: { family: 'anthropic', credentialSource: 'user', providerId: personal.provider, models: { ultra: personalSpec, strong: personalSpec, medium: personalSpec, light: personalSpec } },
403
411
  funded: { family: 'anthropic', credentialSource: 'northlight', providerId: funded.provider, models: { ultra: `${funded.provider}/${funded.id}:medium`, strong: `${funded.provider}/${funded.id}:medium`, medium: `${funded.provider}/${funded.id}:medium`, light: `${funded.provider}/${funded.id}:medium` } },
@@ -407,7 +415,7 @@ test('reload_auth restores a newly authenticated personal route before the next
407
415
  writeFileSync(unauthenticatedProvider, personal.provider);
408
416
  try {
409
417
  const parent = h.spawnRoot('auth route reconciliation parent');
410
- const child = await h.spawnHeadlessChild(parent, 'auth route reconciliation child', { model: 'anthropic/medium' });
418
+ const child = await h.spawnHeadlessChild(parent, 'auth route reconciliation child');
411
419
  assert.deepEqual(model(child), funded, 'the logical request starts on the funded route while personal auth is absent');
412
420
  const controller = await kit.connectRaw(child, { read: true });
413
421
  controller.send({ type: 'hello', role: 'controller', client_id: 'auth-route-reconciliation' });
@@ -470,10 +478,16 @@ test('reload_auth restores a newly authenticated personal route before the next
470
478
  // subscription-pool occupancy (broker-sdk-wiring's scope).
471
479
  test('login re-drive → list_models self-heals (0a) and reload_auth resolves a model + fires the deferred kickoff', { timeout: 30_000 }, async () => {
472
480
  const noAuthSentinel = join(h.home, 'fake-pi.no-auth');
481
+ const userConfigDir = join(h.agentHome, '.crouter');
482
+ const userConfigPath = join(userConfigDir, 'config.json');
483
+ mkdirSync(userConfigDir, { recursive: true });
484
+ writeFileSync(userConfigPath, JSON.stringify({
485
+ kinds: { general: { whenToUse: 'General work.', model: 'anthropic/medium' } },
486
+ }));
473
487
  writeFileSync(noAuthSentinel, '1');
474
488
  try {
475
489
  const parent = h.spawnRoot('login re-drive parent');
476
- const child = await h.spawnHeadlessChild(parent, 'login re-drive', { model: 'anthropic/medium' });
490
+ const child = await h.spawnHeadlessChild(parent, 'login re-drive');
477
491
  assert.equal(model(child), null, 'boots model-less under zero-auth');
478
492
  assert.equal(agentStartCount(child), 0, 'kickoff deferred at boot');
479
493
  const c = await attachUntil(child, 'controller', 'redrive-ctrl', (a) => a.welcome.role === 'controller', 'redrive controller admitted');
@@ -498,5 +512,6 @@ test('login re-drive → list_models self-heals (0a) and reload_auth resolves a
498
512
  }
499
513
  finally {
500
514
  rmSync(noAuthSentinel, { force: true });
515
+ rmSync(userConfigPath, { force: true });
501
516
  }
502
517
  });