@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.
- package/dist/api/client.d.ts +5 -1
- package/dist/api/client.js +6 -0
- package/dist/api/dto/canvas.d.ts +53 -2
- package/dist/api/dto/nodes.d.ts +6 -2
- package/dist/api/routes.d.ts +1 -0
- package/dist/api/routes.js +1 -0
- package/dist/builtin-memory/05-kinds/advisor/advice-contract.md +2 -2
- package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/__tests__/integration/provider-rotation.test.ts +50 -0
- package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/memory-slash-commands.ts +46 -6
- package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/provider-rotation.js +6 -2
- package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/provider-rotation.ts +7 -2
- package/dist/clients/attach/session/whip-achievements.d.ts +4 -0
- package/dist/clients/attach/session/whip-achievements.js +145 -0
- package/dist/clients/attach/slash/dispatch.js +5 -3
- package/dist/clients/attach/viewer.js +607 -606
- package/dist/commands/canvas-history/grep.js +3 -22
- package/dist/commands/canvas-history/read.js +5 -3
- package/dist/commands/canvas-history/search.js +3 -22
- package/dist/commands/canvas-history/shared.d.ts +8 -0
- package/dist/commands/canvas-history/shared.js +64 -1
- package/dist/commands/canvas-history/stats.d.ts +1 -0
- package/dist/commands/canvas-history/stats.js +68 -0
- package/dist/commands/canvas-history.js +4 -3
- package/dist/commands/node/create.js +2 -2
- package/dist/commands/sys/config.js +19 -2
- package/dist/commands/sys/panels/models-panel.js +3 -1
- package/dist/core/__tests__/integration/revive.test.js +4 -2
- package/dist/core/__tests__/model-pin-durability.test.js +25 -1
- package/dist/core/__tests__/relaunch-root.test.js +17 -0
- package/dist/core/__tests__/seam/broker-provider-retry.test.js +19 -4
- package/dist/core/__tests__/seam/memory-slash-node-relative-inventory.test.js +19 -2
- package/dist/core/broker-client/__tests__/transport-relay.test.js +3 -1
- package/dist/core/broker-client/transport-relay.js +5 -1
- package/dist/core/canvas/__tests__/history-transcript.test.d.ts +1 -0
- package/dist/core/canvas/__tests__/history-transcript.test.js +102 -0
- package/dist/core/canvas/browse/app.d.ts +1 -1
- package/dist/core/canvas/browse/app.js +13 -10
- package/dist/core/canvas/browse/model.js +16 -1
- package/dist/core/canvas/browse/render.d.ts +23 -3
- package/dist/core/canvas/browse/render.js +429 -64
- package/dist/core/canvas/canvas.js +12 -8
- package/dist/core/canvas/extensions.d.ts +1 -1
- package/dist/core/canvas/extensions.js +7 -1
- package/dist/core/canvas/history.d.ts +36 -1
- package/dist/core/canvas/history.js +328 -7
- package/dist/core/canvas/labels.d.ts +2 -2
- package/dist/core/canvas/migrations.js +32 -0
- package/dist/core/canvas/node-recap.d.ts +23 -0
- package/dist/core/canvas/node-recap.js +67 -0
- package/dist/core/canvas/render-source.d.ts +33 -1
- package/dist/core/canvas/render-source.js +80 -22
- package/dist/core/canvas/types.d.ts +9 -0
- package/dist/core/config.js +13 -5
- package/dist/core/keybindings/catalog.d.ts +2 -2
- package/dist/core/keybindings/catalog.js +2 -1
- package/dist/core/model-routes.js +1 -1
- package/dist/core/runtime/broker/auth-reload.js +4 -1
- package/dist/core/runtime/broker/fault-retry.js +7 -2
- package/dist/core/runtime/broker/frame-client.js +4 -0
- package/dist/core/runtime/broker/frame-dispatch.js +7 -0
- package/dist/core/runtime/broker/inbox.d.ts +12 -2
- package/dist/core/runtime/broker/inbox.js +68 -38
- package/dist/core/runtime/broker-protocol.d.ts +6 -0
- package/dist/core/runtime/broker.js +14 -0
- package/dist/core/runtime/canvas-extensions.d.ts +1 -0
- package/dist/core/runtime/canvas-extensions.js +2 -0
- package/dist/core/runtime/interactive-deliver.d.ts +8 -2
- package/dist/core/runtime/interactive-deliver.js +12 -3
- package/dist/core/runtime/launch-target.js +1 -1
- package/dist/core/runtime/launch.d.ts +12 -6
- package/dist/core/runtime/launch.js +19 -17
- package/dist/core/runtime/model-swap.d.ts +5 -4
- package/dist/core/runtime/model-swap.js +7 -5
- package/dist/core/runtime/promote.js +2 -1
- package/dist/core/runtime/reset.js +4 -2
- package/dist/core/runtime/revive.js +22 -0
- package/dist/core/runtime/spawn.d.ts +3 -3
- package/dist/core/runtime/spawn.js +20 -3
- package/dist/core/runtime/stamp/channel.d.ts +18 -0
- package/dist/core/runtime/stamp/channel.js +74 -0
- package/dist/core/runtime/stamp/protocol.d.ts +86 -0
- package/dist/core/runtime/stamp/protocol.js +113 -0
- package/dist/core/runtime/tmux-driver.d.ts +11 -1
- package/dist/core/runtime/tmux-driver.js +27 -11
- package/dist/core/self-update.js +2 -5
- package/dist/core/tui/draw.d.ts +1 -0
- package/dist/core/tui/draw.js +5 -1
- package/dist/core/tui/markdown.d.ts +17 -0
- package/dist/core/tui/markdown.js +407 -0
- package/dist/core/user-settings.d.ts +4 -0
- package/dist/core/user-settings.js +1 -0
- package/dist/daemon/api/__tests__/node-model-validation.test.js +21 -0
- package/dist/daemon/api/bridge.d.ts +3 -2
- package/dist/daemon/api/bridge.js +34 -9
- package/dist/daemon/api/handlers/canvas.js +62 -16
- package/dist/daemon/api/handlers/messages.js +12 -1
- package/dist/daemon/api/handlers/nodes.js +10 -3
- package/dist/daemon/api/map.js +2 -0
- package/dist/daemon/messaging/node-message.js +15 -1
- package/dist/daemon/reconcilers/broker-supervision.js +26 -9
- package/dist/daemon/reconcilers/live-obligation.d.ts +8 -1
- package/dist/daemon/reconcilers/live-obligation.js +17 -0
- package/dist/pi-extensions/canvas-inbox-watcher.js +27 -2
- package/dist/pi-extensions/canvas-recap.js +4 -0
- package/dist/pi-extensions/canvas-stamp.d.ts +14 -0
- package/dist/pi-extensions/canvas-stamp.js +77 -0
- package/dist/pi-extensions/canvas-stophook.js +3 -0
- package/dist/shared/__tests__/env-boundary.test.js +4 -2
- package/dist/shared/crtr-version.d.ts +2 -0
- package/dist/shared/crtr-version.js +18 -0
- package/dist/shared/env.d.ts +10 -0
- package/dist/shared/env.js +19 -4
- package/dist/types.d.ts +10 -0
- package/dist/types.js +4 -0
- package/package.json +1 -1
- 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
|
-
|
|
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
|
-
|
|
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
|
|
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)
|
|
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
|
|
98
|
-
'
|
|
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
|
|
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
|
-
|
|
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/
|
|
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/
|
|
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
|
|
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', {
|
|
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'
|
|
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'
|
|
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
|
});
|