@north-light/crouter 0.3.234 → 0.3.235

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 (58) hide show
  1. package/dist/api/dto/nodes.d.ts +11 -0
  2. package/dist/clients/attach/viewer.js +200 -200
  3. package/dist/commands/attention.js +1 -1
  4. package/dist/commands/cron.js +5 -5
  5. package/dist/commands/human.js +1 -1
  6. package/dist/commands/memory/__tests__/memory-list-and-lint-ignore.test.d.ts +1 -0
  7. package/dist/commands/memory/__tests__/memory-list-and-lint-ignore.test.js +96 -0
  8. package/dist/commands/memory/delete.js +1 -1
  9. package/dist/commands/memory/edit.js +6 -3
  10. package/dist/commands/memory/list.d.ts +4 -0
  11. package/dist/commands/memory/list.js +20 -14
  12. package/dist/commands/memory/move.js +1 -1
  13. package/dist/commands/memory/shared.d.ts +1 -0
  14. package/dist/commands/memory/shared.js +13 -1
  15. package/dist/commands/memory/write.js +4 -1
  16. package/dist/commands/node/inspect.js +11 -4
  17. package/dist/commands/node/lifecycle.js +1 -0
  18. package/dist/commands/node/message.js +8 -4
  19. package/dist/commands/node/subscription.js +5 -1
  20. package/dist/commands/node/wait.js +5 -1
  21. package/dist/commands/pkg/plugin-manage.js +1 -1
  22. package/dist/commands/pkg.js +1 -0
  23. package/dist/commands/profile/rename.js +1 -1
  24. package/dist/commands/sys/config.js +1 -0
  25. package/dist/commands/sys.js +1 -0
  26. package/dist/core/__tests__/cli-help-contract.test.d.ts +1 -0
  27. package/dist/core/__tests__/cli-help-contract.test.js +150 -0
  28. package/dist/core/__tests__/integration/command-plugins.test.js +21 -0
  29. package/dist/core/__tests__/watchdog-abort-arms-retry.test.js +11 -6
  30. package/dist/core/canvas/canvas.js +11 -1
  31. package/dist/core/canvas/meta-file.js +18 -27
  32. package/dist/core/canvas/remote-canvas-source.d.ts +8 -0
  33. package/dist/core/canvas/remote-canvas-source.js +9 -1
  34. package/dist/core/command-manifests/registry.d.ts +2 -0
  35. package/dist/core/command-plugins/compose.js +4 -2
  36. package/dist/core/command-plugins/discovery.d.ts +1 -0
  37. package/dist/core/command-plugins/discovery.js +1 -1
  38. package/dist/core/command.js +2 -2
  39. package/dist/core/fault-classifier.js +8 -0
  40. package/dist/core/pagination.d.ts +2 -0
  41. package/dist/core/pagination.js +2 -1
  42. package/dist/core/runtime/broker/fault-retry.js +6 -0
  43. package/dist/core/runtime/broker/rebind.js +50 -0
  44. package/dist/core/runtime/launch.d.ts +2 -2
  45. package/dist/core/runtime/launch.js +17 -4
  46. package/dist/core/runtime/stop-guard.js +28 -16
  47. package/dist/core/substrate/frontmatter-validation.d.ts +1 -0
  48. package/dist/core/substrate/frontmatter-validation.js +1 -1
  49. package/dist/daemon/api/__tests__/error-seam.test.js +27 -0
  50. package/dist/daemon/api/handlers/messages.js +12 -12
  51. package/dist/daemon/api/handlers/nodes.js +13 -1
  52. package/dist/daemon/api/handlers/worktree.js +6 -22
  53. package/dist/daemon/api/map.d.ts +4 -2
  54. package/dist/daemon/api/map.js +22 -2
  55. package/dist/daemon/reconcilers/broker-supervision.js +1 -1
  56. package/dist/pi-extensions/canvas-bash-valve.js +4 -3
  57. package/package.json +1 -1
  58. package/runtime.lock.json +2 -2
@@ -0,0 +1,150 @@
1
+ // Help-contract gate for the whole command tree.
2
+ // Run with: node --import tsx/esm --test src/core/__tests__/cli-help-contract.test.ts
3
+ //
4
+ // The cli-design rules that hold for every node are enforced here rather than
5
+ // per command, because help text is written one command at a time and drifts
6
+ // the same way every time: a branch ships its selection prose in the
7
+ // description attribute, a rubric restates the description it sits beside, or
8
+ // a constraint copied from a sibling leaf keeps naming a value this leaf's
9
+ // enum rejects. Each rule below is mechanical — a judgment call about whether
10
+ // a rubric actually helps an agent choose belongs to review, not to this file.
11
+ import { test, before, after } from 'node:test';
12
+ import assert from 'node:assert/strict';
13
+ import { mkdtempSync, rmSync } from 'node:fs';
14
+ import { tmpdir } from 'node:os';
15
+ import { join } from 'node:path';
16
+ import { buildRoot } from '../../build-root.js';
17
+ import { resetScopeCache } from '../scope.js';
18
+ // buildRoot() composes external command-plugin branches from the effective
19
+ // enabled plugins. Pin HOME to an isolated no-plugin home: a third party's
20
+ // help text is not this repo's contract to enforce.
21
+ let home;
22
+ let realHome;
23
+ let realProfile;
24
+ before(() => {
25
+ realHome = process.env['HOME'];
26
+ realProfile = process.env['CRTR_PROFILE_ID'];
27
+ home = mkdtempSync(join(tmpdir(), 'crtr-help-contract-'));
28
+ delete process.env['CRTR_PROFILE_ID'];
29
+ process.env['HOME'] = home;
30
+ resetScopeCache();
31
+ });
32
+ after(() => {
33
+ if (realHome !== undefined)
34
+ process.env['HOME'] = realHome;
35
+ else
36
+ delete process.env['HOME'];
37
+ if (realProfile !== undefined)
38
+ process.env['CRTR_PROFILE_ID'] = realProfile;
39
+ rmSync(home, { recursive: true, force: true });
40
+ resetScopeCache();
41
+ });
42
+ async function everyNode() {
43
+ const root = await buildRoot();
44
+ const out = [];
45
+ const visit = (node, path) => {
46
+ out.push({ node, path });
47
+ if (node.kind === 'branch')
48
+ for (const child of node.children)
49
+ visit(child, `${path} ${child.name}`);
50
+ };
51
+ for (const subtree of root.subtrees)
52
+ visit(subtree, subtree.name);
53
+ return out;
54
+ }
55
+ function normalize(text) {
56
+ if (text === undefined)
57
+ return '';
58
+ return text.toLowerCase().replace(/\s+/g, ' ').replace(/[.\s]+$/, '').trim();
59
+ }
60
+ function enumFlags(leaf) {
61
+ return (leaf.help.params ?? []).filter((param) => param.kind === 'flag' && param.type === 'enum' && param.choices !== undefined);
62
+ }
63
+ test('every branch offering a choice carries the model prose that frames it', async () => {
64
+ const offenders = [];
65
+ for (const { node, path } of await everyNode()) {
66
+ if (node.kind !== 'branch')
67
+ continue;
68
+ const choices = node.children.filter((child) => child.tier !== 'hidden');
69
+ if (choices.length < 2)
70
+ continue;
71
+ const model = node.help.model;
72
+ if (model === undefined || model.trim() === '')
73
+ offenders.push(`${path} (${choices.length} children)`);
74
+ }
75
+ assert.deepEqual(offenders, [], `branches with 2+ visible children and no help.model:\n ${offenders.join('\n ')}`);
76
+ });
77
+ test('no rubric restates the description it is read beside', async () => {
78
+ const offenders = [];
79
+ for (const { node, path } of await everyNode()) {
80
+ const description = normalize(node.description ?? '');
81
+ const rubric = normalize(node.whenToUse ?? '');
82
+ if (description === '' || rubric === '')
83
+ continue;
84
+ if (rubric === description || rubric.includes(description))
85
+ offenders.push(`${path}: whenToUse repeats "${node.description}"`);
86
+ }
87
+ assert.deepEqual(offenders, [], `rubrics restating their own description:\n ${offenders.join('\n ')}`);
88
+ });
89
+ // A constraint may name a value its own enum rejects only while saying so —
90
+ // the copy-paste failure this catches states the foreign value as if it were
91
+ // selectable. Vocabulary is per flag NAME, so it only fires where one flag
92
+ // genuinely carries different choices on different leaves.
93
+ const NEGATION = /\b(?:reject\w*|refus\w*|invalid|unsupported|forbid\w*|not\s+accepted)\b/i;
94
+ test('no enum constraint names a value that enum does not accept', async () => {
95
+ const offenders = [];
96
+ for (const { node, path } of await everyNode()) {
97
+ if (node.kind !== 'branch')
98
+ continue;
99
+ const leaves = node.children.filter((child) => child.kind === 'leaf');
100
+ // Vocabulary is pooled across ONE branch's leaves, because that is where a
101
+ // constraint gets copied: two sibling verbs carrying the same flag with
102
+ // different choices. Pooling tree-wide would compare unrelated commands
103
+ // that merely share a flag name and flag ordinary English words that
104
+ // happen to be some distant enum's value.
105
+ const vocabulary = new Map();
106
+ for (const leaf of leaves) {
107
+ for (const flag of enumFlags(leaf)) {
108
+ const known = vocabulary.get(flag.name) ?? new Set();
109
+ for (const choice of flag.choices)
110
+ known.add(choice.toLowerCase());
111
+ vocabulary.set(flag.name, known);
112
+ }
113
+ }
114
+ for (const leaf of leaves) {
115
+ for (const flag of enumFlags(leaf)) {
116
+ const own = new Set(flag.choices.map((choice) => choice.toLowerCase()));
117
+ const foreign = [...(vocabulary.get(flag.name) ?? [])].filter((value) => !own.has(value));
118
+ if (foreign.length === 0)
119
+ continue;
120
+ for (const sentence of flag.constraint.split(/(?<=[.;:])\s+/)) {
121
+ if (NEGATION.test(sentence))
122
+ continue;
123
+ const named = foreign.filter((value) => new RegExp(`\\b${value}\\b`, 'i').test(sentence));
124
+ if (named.length > 0)
125
+ offenders.push(`${path} ${leaf.name} --${flag.name}: names ${named.join(', ')} — accepts only ${flag.choices.join(', ')}`);
126
+ }
127
+ }
128
+ }
129
+ }
130
+ assert.deepEqual(offenders, [], `enum constraints naming values they reject:\n ${offenders.join('\n ')}`);
131
+ });
132
+ test('every leaf declares its effects, and none contradicts a claim of none', async () => {
133
+ const offenders = [];
134
+ for (const { node, path } of await everyNode()) {
135
+ if (node.kind !== 'leaf')
136
+ continue;
137
+ const effects = node.help.effects;
138
+ if (effects.length === 0 || effects.some((effect) => effect.trim() === '')) {
139
+ offenders.push(`${path}: declares no effects`);
140
+ continue;
141
+ }
142
+ // A leaf may describe what read-only means for it — several usefully do.
143
+ // What it may not do is open with "None" and then list effects underneath:
144
+ // an agent reading the first entry stops there.
145
+ if (effects.length > 1 && /^none\b/i.test(effects[0].trim())) {
146
+ offenders.push(`${path}: claims "${effects[0]}" and then lists ${effects.length - 1} more effect(s)`);
147
+ }
148
+ }
149
+ assert.deepEqual(offenders, [], `leaves with a broken effects declaration:\n ${offenders.join('\n ')}`);
150
+ });
@@ -641,6 +641,27 @@ process.stdout.write(JSON.stringify({ protocolVersion: 1, ok: true, result: { ap
641
641
  }
642
642
  assertNotExecuted();
643
643
  });
644
+ test('a colliding exec plugin keeps its manifest path for help and execution', async () => {
645
+ installPlugin(userRoot, 'p-one', { exec: FIXTURE_EXEC });
646
+ installPlugin(userRoot, 'p-two', { exec: FIXTURE_EXEC, sourceMarketplace: 'fixture-market' });
647
+ resetScopeCache();
648
+ const snapshot = discoverCommandContributions(new Set(['sys']), emptyStart);
649
+ const external = composeExternalSubtrees(adaptPluginContributions(snapshot.contributions));
650
+ const mounted = external.find((branch) => branch.name === 'fixture-market:app');
651
+ assert.ok(mounted, 'the marketplace-qualified plugin branch is mounted');
652
+ assert.match(renderBranch(mounted.help), /<command name="fixture-market:app"/);
653
+ const leaf = leafOf(mounted, 'show');
654
+ const log = join(emptyStart, 'collision-request.json');
655
+ process.env['FIXTURE_REQUEST_LOG'] = log;
656
+ try {
657
+ const result = await leaf.run({ appId: 'app_123', mode: 'ok' });
658
+ assert.deepEqual(result, { app_id: 'app_123', status: 'running' });
659
+ assert.deepEqual(JSON.parse(readFileSync(log, 'utf8')).command, ['app', 'show']);
660
+ }
661
+ finally {
662
+ delete process.env['FIXTURE_REQUEST_LOG'];
663
+ }
664
+ });
644
665
  });
645
666
  // Memory-corpus gate across the plugin lifecycle — install, and enable of a
646
667
  // package installed while disabled, both converge the candidate's memory store
@@ -14,7 +14,7 @@ after(() => {
14
14
  rmSync(home, { recursive: true, force: true });
15
15
  });
16
16
  test('only a watchdog-stamped aborted settlement records an automatic connection fault', () => {
17
- const settleAbort = (nodeId, stagedWatchdogAbort, stagedRefreshAbort, stopReason = 'aborted') => {
17
+ const settleAbort = (nodeId, stagedWatchdogAbort, stagedRefreshAbort, stopReason = 'aborted', errorText) => {
18
18
  const session = {
19
19
  sessionManager: { getLeafId: () => 'leaf-entry' },
20
20
  model: undefined,
@@ -27,7 +27,7 @@ test('only a watchdog-stamped aborted settlement records an automatic connection
27
27
  messages: [{
28
28
  role: 'assistant',
29
29
  stopReason,
30
- errorMessage: stopReason === 'error' ? 'This operation was aborted' : 'Request was aborted',
30
+ errorMessage: errorText ?? (stopReason === 'error' ? 'This operation was aborted' : 'Request was aborted'),
31
31
  }],
32
32
  },
33
33
  stagedWatchdogAbort,
@@ -53,14 +53,19 @@ test('only a watchdog-stamped aborted settlement records an automatic connection
53
53
  settleAbort('human-node', false, false);
54
54
  assert.equal(readFault('human-node'), null, 'an otherwise-identical human abort must not arm recovery');
55
55
  // Pi 0.84 currently serializes a host abort from `node yield` as an `error`
56
- // settlement. Only the refresh-abort provenance suppresses that provider-looking error.
56
+ // settlement.
57
57
  const refresh = settleAbort('refresh-node', false, true, 'error');
58
58
  assert.equal(readFault('refresh-node'), null, 'a refresh-aborted error settlement must not enter provider fault recovery');
59
59
  assert.equal(refresh.generation.stagedRefreshAbort, false, 'settlement consumes the one-shot refresh provenance');
60
- settleAbort('provider-node', false, false, 'error');
60
+ // #683 — an abort that lands between requests settles as an error carrying
61
+ // AbortError text. It stopped because something asked it to; a fatal fault
62
+ // here parks the node and fans a false permanent-failure notice.
63
+ settleAbort('aborted-node', false, false, 'error');
64
+ assert.equal(readFault('aborted-node'), null, 'an abort-shaped error settlement must not record a provider fault');
65
+ settleAbort('provider-node', false, false, 'error', 'connection reset by peer');
61
66
  const providerFault = readFault('provider-node');
62
- assert.equal(providerFault?.kind, 'other', 'the same provider error without refresh provenance still classifies');
63
- assert.deepEqual(providerFault?.retry, { disposition: 'fatal' }, 'the same provider error without refresh provenance still records its fault');
67
+ assert.equal(providerFault?.kind, 'connection', 'a real provider error still classifies');
68
+ assert.equal(providerFault?.retry.disposition, 'auto', 'a real provider error still records its fault');
64
69
  });
65
70
  test('a failed retry settlement carries the episode forward instead of restarting it', () => {
66
71
  // The episode marker a daemon retry records before re-prompting: original
@@ -164,8 +164,18 @@ function runtimeFromRow(nodeId) {
164
164
  * row (identity + runtime from the incoming meta). Returns the hydrated view. */
165
165
  export function createNode(meta) {
166
166
  ensureHome();
167
+ const scaffolded = !existsSync(nodeDir(meta.node_id));
167
168
  ensureNodeDirs(meta.node_id);
168
- writeMeta(meta);
169
+ try {
170
+ writeMeta(meta);
171
+ }
172
+ catch (error) {
173
+ // No row exists yet, so a dir left here is invisible to every surface and to
174
+ // ghost-row reaping. Only unwind a dir this call created.
175
+ if (scaffolded)
176
+ rmSync(nodeDir(meta.node_id), { recursive: true, force: true });
177
+ throw error;
178
+ }
169
179
  seedRow(meta);
170
180
  return getNode(meta.node_id);
171
181
  }
@@ -1,7 +1,8 @@
1
1
  // Durable node-meta file primitives shared by normal canvas writes and forward migrations.
2
2
  import { randomUUID } from 'node:crypto';
3
- import { closeSync, existsSync, fsyncSync, mkdirSync, openSync, readFileSync, renameSync, rmSync, writeSync } from 'node:fs';
3
+ import { closeSync, existsSync, fsyncSync, openSync, readFileSync, renameSync, rmSync, writeSync } from 'node:fs';
4
4
  import { dirname } from 'node:path';
5
+ import { withExclusiveDirectoryLock } from '../exclusive-lock.js';
5
6
  function parseObject(path, strict) {
6
7
  if (!existsSync(path))
7
8
  return null;
@@ -36,40 +37,30 @@ export function readMetaObject(path) {
36
37
  * re-entrant — the operation must use {@link writeMetaObjectLocked}, never the
37
38
  * lock-taking {@link writeJsonFileDurably}. */
38
39
  export function withMetaLock(path, operation) {
39
- const lock = `${path}.lock`;
40
- const wait = new Int32Array(new SharedArrayBuffer(4));
41
- const deadline = Date.now() + 5_000;
42
- for (;;) {
43
- try {
44
- mkdirSync(lock);
45
- break;
46
- }
47
- catch (error) {
48
- if (error.code !== 'EEXIST' || Date.now() >= deadline)
49
- throw error;
50
- Atomics.wait(wait, 0, 0, 10);
51
- }
52
- }
53
- try {
54
- return operation();
55
- }
56
- finally {
57
- rmSync(lock, { recursive: true, force: true });
58
- }
40
+ return withExclusiveDirectoryLock(`${path}.lock`, operation, {
41
+ timeoutMs: 5_000,
42
+ timeoutError: () => new Error(`timed out waiting to write node metadata: ${path}`),
43
+ });
59
44
  }
60
45
  /** Collision-free durable write (fsync file + parent dir), assuming the caller
61
46
  * already holds {@link withMetaLock} for `path`. */
62
47
  export function writeMetaObjectLocked(path, value) {
63
48
  const tmp = `${path}.${process.pid}.${randomUUID()}.tmp`;
64
- const fd = openSync(tmp, 'w');
65
49
  try {
66
- writeSync(fd, JSON.stringify(value, null, 2));
67
- fsyncSync(fd);
50
+ const fd = openSync(tmp, 'w');
51
+ try {
52
+ writeSync(fd, JSON.stringify(value, null, 2));
53
+ fsyncSync(fd);
54
+ }
55
+ finally {
56
+ closeSync(fd);
57
+ }
58
+ renameSync(tmp, path);
68
59
  }
69
- finally {
70
- closeSync(fd);
60
+ catch (error) {
61
+ rmSync(tmp, { force: true });
62
+ throw error;
71
63
  }
72
- renameSync(tmp, path);
73
64
  const dirFd = openSync(dirname(path), 'r');
74
65
  try {
75
66
  fsyncSync(dirFd);
@@ -31,6 +31,14 @@ export interface RemoteShowResponse {
31
31
  session_file?: string | null;
32
32
  };
33
33
  follow_up: string;
34
+ fault?: {
35
+ link: string;
36
+ kind: string;
37
+ retry: {
38
+ disposition: string;
39
+ };
40
+ since: string;
41
+ } | null;
34
42
  }
35
43
  export declare class RemoteCanvasSource implements CanvasSource {
36
44
  private readonly previewEndpoint;
@@ -47,6 +47,14 @@ function isValidShowResponse(x) {
47
47
  return false;
48
48
  if (typeof x['follow_up'] !== 'string')
49
49
  return false;
50
+ const fault = x['fault'];
51
+ if (fault !== undefined && fault !== null) {
52
+ if (!isPlainObject(fault) || typeof fault['link'] !== 'string' || typeof fault['kind'] !== 'string' || typeof fault['since'] !== 'string')
53
+ return false;
54
+ const retry = fault['retry'];
55
+ if (!isPlainObject(retry) || typeof retry['disposition'] !== 'string')
56
+ return false;
57
+ }
50
58
  return true;
51
59
  }
52
60
  /** A `reports`/`managers` entry (`RemoteShowResponse`) — validated per-element
@@ -121,7 +129,7 @@ export class RemoteCanvasSource {
121
129
  // node/inspect.ts's remote `show` passthrough render — see only well-formed
122
130
  // neighbor entries, never a malformed one they'd dereference unguarded.
123
131
  const res = isValidShowResponse(raw)
124
- ? { ...raw, reports: raw.reports.filter(isValidNeighborEntry), managers: raw.managers.filter(isValidNeighborEntry) }
132
+ ? { ...raw, fault: raw.fault ?? null, reports: raw.reports.filter(isValidNeighborEntry), managers: raw.managers.filter(isValidNeighborEntry) }
125
133
  : null;
126
134
  this.nodeCache.set(id, { value: res, at: Date.now() });
127
135
  return res;
@@ -24,6 +24,8 @@ export type LeafAdapter = (leaf: DeclLeafBase, commandPath: readonly string[]) =
24
24
  export interface CommandContribution {
25
25
  contributor: CommandContributorRef;
26
26
  node: DeclBranch<DeclLeafBase>;
27
+ /** Root name from the manifest, before collision qualification. */
28
+ manifestName: string;
27
29
  adaptLeaf: LeafAdapter;
28
30
  }
29
31
  /** Per-contributor collision issue. */
@@ -6,15 +6,17 @@ export function adaptPluginContributions(contributions) {
6
6
  contributions: contributions.map((contribution) => ({
7
7
  contributor: { name: contribution.plugin.name, scope: contribution.plugin.scope, sourceMarketplace: contribution.plugin.sourceMarketplace },
8
8
  node: contribution.node,
9
+ manifestName: contribution.manifestName,
9
10
  adaptLeaf: (leaf, commandPath) => {
10
11
  const transport = contribution.transport;
12
+ const manifestCommandPath = [contribution.manifestName, ...commandPath.slice(1)];
11
13
  if (transport.kind === 'exec') {
12
14
  const executable = transport.executable;
13
15
  if (executable === undefined)
14
16
  throw new Error(`exec plugin "${contribution.plugin.name}" has an executable leaf without transport.executable`);
15
17
  return {
16
18
  outputKind: 'object',
17
- run: async (input) => executeExternalLeaf({ plugin: contribution.plugin, executable, commandPath: [...commandPath], output: leaf.output }, input),
19
+ run: async (input) => executeExternalLeaf({ plugin: contribution.plugin, executable, commandPath: manifestCommandPath, output: leaf.output }, input),
18
20
  };
19
21
  }
20
22
  const httpLeaf = leaf;
@@ -24,7 +26,7 @@ export function adaptPluginContributions(contributions) {
24
26
  registration: { name: contribution.plugin.name, endpoint: transport.endpoint, ...(transport.authEnv !== undefined ? { authEnv: transport.authEnv } : {}) },
25
27
  ...(contribution.manifest?.baseUrl !== undefined ? { baseUrl: contribution.manifest.baseUrl } : {}),
26
28
  ...(contribution.manifest?.timeouts !== undefined ? { timeouts: contribution.manifest.timeouts } : {}),
27
- params: httpLeaf.params, rest: httpLeaf.rest, output: httpLeaf.output, commandPath: [...commandPath],
29
+ params: httpLeaf.params, rest: httpLeaf.rest, output: httpLeaf.output, commandPath: manifestCommandPath,
28
30
  }, input, context),
29
31
  };
30
32
  },
@@ -14,6 +14,7 @@ export interface ValidatedContribution {
14
14
  transport: PluginTransport;
15
15
  node: DeclBranchGeneric<DeclLeafGeneric>;
16
16
  manifest: ValidatedCommandManifest;
17
+ manifestName: string;
17
18
  }
18
19
  export interface PluginCommandValidation {
19
20
  plugin: InstalledPlugin;
@@ -130,7 +130,7 @@ export function validatePluginCommands(plugin, reservedNames = new Set(), coreCo
130
130
  : transport;
131
131
  if (resolvedTransport === undefined)
132
132
  return { plugin, manifestPath, transport, contributions: [], issues };
133
- return { plugin, manifestPath, transport: resolvedTransport, manifest: validation.manifest, contributions: validation.manifest.roots.map((node) => ({ plugin, transport: resolvedTransport, node, manifest: validation.manifest })), issues };
133
+ return { plugin, manifestPath, transport: resolvedTransport, manifest: validation.manifest, contributions: validation.manifest.roots.map((node) => ({ plugin, transport: resolvedTransport, node, manifest: validation.manifest, manifestName: node.name })), issues };
134
134
  }
135
135
  export function discoverPluginCommandCandidates(startDir = process.cwd(), profileId) {
136
136
  return effectiveCommandPlugins(startDir, profileId).map((plugin) => validatePluginCommands(plugin));
@@ -138,9 +138,9 @@ export function defineRoot(opts) {
138
138
  /** Validate and return child names for an unknown-path error. */
139
139
  function childNames(node) {
140
140
  if (node.kind === 'root')
141
- return node.subtrees.map((s) => s.name);
141
+ return node.subtrees.filter((s) => s.tier !== 'hidden').map((s) => s.name);
142
142
  if (node.kind === 'branch')
143
- return node.children.map((c) => c.name);
143
+ return node.children.filter((c) => c.tier !== 'hidden').map((c) => c.name);
144
144
  return [];
145
145
  }
146
146
  /** Walk argv tokens to the deepest matched node.
@@ -42,6 +42,10 @@ function classifyProviderText(message) {
42
42
  return 'context-overflow';
43
43
  if (/websocket.*(?:closed.*\b(?:1002|1003|1007|1008|1009|1010)\b|protocol error|policy violation|unsupported data|invalid (?:frame|payload)|message too big|mandatory extension)/i.test(m))
44
44
  return 'protocol';
45
+ // proper-lockfile contention on a credential file: another process holds the
46
+ // mutex right now, so the same request wins once it releases.
47
+ if (/lock file is already being held/i.test(m))
48
+ return 'overloaded';
45
49
  if (/overloaded|\b5\d\d\b|internal server error|api.?error|capacity|server.{0,3}busy|temporarily unavailable/i.test(m))
46
50
  return 'overloaded';
47
51
  if (/connection|econnreset|etimedout|enotfound|econnrefused|network|fetch failed|socket hang|timed? out|timeout|\bterminated\b|other side closed|premature close|websocket\s+(?:was\s+)?closed|websocket stream closed before response\.completed|websocket\s+error|websocket.*interrupt/i.test(m))
@@ -55,6 +59,10 @@ function classifyErrno(raw) {
55
59
  if (/^(ECONNREFUSED|ECONNRESET|ETIMEDOUT|ENOENT|ENOTFOUND|EAI_AGAIN|EPIPE|EHOSTUNREACH|ENETUNREACH)$/i.test(code)) {
56
60
  return 'connection';
57
61
  }
62
+ // A held filesystem mutex, not a broken link: back off on time rather than
63
+ // behind the online probe a connection fault waits on.
64
+ if (/^ELOCKED$/i.test(code))
65
+ return 'overloaded';
58
66
  return null;
59
67
  }
60
68
  function classifyHttp(raw) {
@@ -16,6 +16,8 @@ export interface PaginateOpts<T> {
16
16
  /** Returns the stable sort key for an item. Must match the sort order of the
17
17
  * input array — ascending by this key. */
18
18
  keyOf: (item: T) => string;
19
+ /** Compares sort keys when their order is not raw code-unit order. */
20
+ compareKeys?: (left: string, right: string) => number;
19
21
  /** 'count' → compute and return `total`; 'omit' → return `null`. */
20
22
  total: 'count' | 'omit';
21
23
  }
@@ -67,9 +67,10 @@ export function paginate(allItemsSortedAscByKey, params, opts) {
67
67
  let startIndex = 0;
68
68
  if (params.cursor !== undefined) {
69
69
  const cursorKey = decodeCursor(params.cursor); // throws CrtrError on bad cursor
70
+ const compareKeys = opts.compareKeys ?? ((left, right) => left < right ? -1 : left > right ? 1 : 0);
70
71
  // Find the first item whose key is strictly greater than cursorKey.
71
72
  // Linear scan; callers with large datasets should pre-filter upstream.
72
- startIndex = allItemsSortedAscByKey.findIndex((item) => opts.keyOf(item) > cursorKey);
73
+ startIndex = allItemsSortedAscByKey.findIndex((item) => compareKeys(opts.keyOf(item), cursorKey) > 0);
73
74
  if (startIndex === -1) {
74
75
  // All items are at or before the cursor key — list is exhausted.
75
76
  startIndex = allItemsSortedAscByKey.length;
@@ -9,6 +9,7 @@ import { emitEvent } from '../../events/emit.js';
9
9
  import { CONNECTION_FAULT_RECOVERY_BODY, PROVIDER_FAULT_RECOVERY_BODY, formatCard, formatModelFallbackRecovery } from '../../../shared/generated-context.js';
10
10
  import { markFatalOnExhaust, nextFaultRetry, policyFor } from '../fault-recovery.js';
11
11
  import { parseModelSpec } from '../model-selection.js';
12
+ import { endedByAbort } from '../session-cycles.js';
12
13
  import { resolveProviderCandidates } from '../../subscription-state.js';
13
14
  const MAX_TIMEOUT_MS = 2_147_483_647;
14
15
  /** Owns provider fallback and daemon-driven fault retry state for one broker session. */
@@ -90,6 +91,11 @@ export class FaultRetry {
90
91
  if (refreshAbort)
91
92
  return;
92
93
  const raw = typeof last.errorMessage === 'string' ? last.errorMessage : 'engine error (no errorMessage recorded)';
94
+ // An abort landing between requests reaches pi's catch as a bare AbortError
95
+ // and settles as an error rather than `aborted`. Something asked that turn
96
+ // to stop, so there is no provider fault to record.
97
+ if (endedByAbort({ stopReason: 'error', errorMessage: raw }))
98
+ return;
93
99
  const uncompactedOverflow = isContextOverflow(last, settledSession.model?.contextWindow);
94
100
  const classified = classify('pi→provider', last);
95
101
  const coolingDeadline = extractCoolingDeadline(last);
@@ -8,6 +8,55 @@ import { abortSessionFromExtension } from './extension-abort.js';
8
8
  const MAX_ADMITTED_REPLACEMENTS = 2;
9
9
  const REPLACEMENT_DEADLINE_MS = 30_000;
10
10
  const REPLACEMENT_DEADLINE_EXCEEDED = Symbol('replacement-deadline-exceeded');
11
+ const TRAILING_THINKING_PLACEHOLDER = '(interrupted)';
12
+ const TRAILING_THINKING_GUARD = Symbol.for('crtr.trailing-thinking-guard');
13
+ /** The last block an Anthropic request will actually carry: the serializer
14
+ * drops whitespace-only text and signature-less empty thinking. */
15
+ function lastSerializedBlock(content) {
16
+ for (let i = content.length - 1; i >= 0; i--) {
17
+ const block = content[i];
18
+ if (block === undefined)
19
+ continue;
20
+ if (block.type === 'text') {
21
+ if (block.text.trim().length === 0)
22
+ continue;
23
+ return block;
24
+ }
25
+ if (block.type === 'thinking') {
26
+ const signature = block.thinkingSignature;
27
+ const hasSignature = typeof signature === 'string' && signature.trim().length > 0;
28
+ if (block.redacted !== true && block.thinking.trim().length === 0 && !hasSignature)
29
+ continue;
30
+ return block;
31
+ }
32
+ return block;
33
+ }
34
+ return undefined;
35
+ }
36
+ function guardTrailingThinking(messages) {
37
+ return messages.map((message) => {
38
+ if (message.role !== 'assistant')
39
+ return message;
40
+ if (lastSerializedBlock(message.content)?.type !== 'thinking')
41
+ return message;
42
+ return { ...message, content: [...message.content, { type: 'text', text: TRAILING_THINKING_PLACEHOLDER }] };
43
+ });
44
+ }
45
+ /** Crouter aborts live streams (watchdog, extension abort, viewer esc), which
46
+ * persists assistant turns that end on a thinking block. Anthropic rejects a
47
+ * request whose final assistant block is `thinking`, and pi-ai's serializer
48
+ * has no guard for it, so every later turn re-fails on embedded history. */
49
+ function installTrailingThinkingGuard(agent) {
50
+ const inner = agent.convertToLlm;
51
+ if (inner[TRAILING_THINKING_GUARD] === true)
52
+ return;
53
+ const wrapped = (messages) => {
54
+ const converted = inner(messages);
55
+ return converted instanceof Promise ? converted.then(guardTrailingThinking) : guardTrailingThinking(converted);
56
+ };
57
+ wrapped[TRAILING_THINKING_GUARD] = true;
58
+ agent.convertToLlm = wrapped;
59
+ }
11
60
  /** Owns session subscription replacement, its serialization lane, and replacement admission policy. */
12
61
  export class RebindDriver {
13
62
  deps;
@@ -86,6 +135,7 @@ export class RebindDriver {
86
135
  });
87
136
  agent.steeringMode = 'all';
88
137
  agent.followUpMode = 'all';
138
+ installTrailingThinkingGuard(agent);
89
139
  }
90
140
  if (this.deps.cfg.editorName !== undefined && this.deps.cfg.editorName !== '') {
91
141
  try {
@@ -15,8 +15,8 @@ export type { PortableModelSelection } from './model-selection.js';
15
15
  * stays unpinned. */
16
16
  export declare function isProviderPinnedModelToken(model: string): boolean;
17
17
  /** True when `model` is a PORTABLE ladder token — a bare strength/alias
18
- * (`ultra`, `normal`, `opus`, …) or a `anthropic|openai / strength` pair —
19
- * i.e. a form `normalizeModel` resolves through the ladders rather than
18
+ * (`ultra`, `normal`, `opus`, …), an `anthropic|openai / strength` pair, or
19
+ * an anthropic family alias — i.e. a form `normalizeModel` resolves through the ladders rather than
20
20
  * passing through as a concrete id. Portable tokens are safe to PERSIST as
21
21
  * defaults (`persistDefaultKindModel`): they re-resolve against whatever
22
22
  * ladder the running build ships, so they never freeze a stale concrete
@@ -85,10 +85,14 @@ function findLadderCell(normalized, ladders, providers) {
85
85
  * strong/medium/light, ALWAYS anthropic (ignores the env/config default).
86
86
  * 4. Anything else → pass through unchanged. */
87
87
  function modelTokenStrength(model) {
88
- const rest = model.includes('/') ? model.slice(model.indexOf('/') + 1) : model;
88
+ const slash = model.indexOf('/');
89
+ const provider = slash === -1 ? undefined : model.slice(0, slash);
90
+ const rest = slash === -1 ? model : model.slice(slash + 1);
89
91
  const strength = STRENGTH_ALIASES[rest];
90
92
  if (strength !== undefined)
91
93
  return strength;
94
+ if (provider !== undefined && provider !== 'anthropic')
95
+ return null;
92
96
  if (rest === 'opus')
93
97
  return 'strong';
94
98
  if (rest === 'sonnet')
@@ -133,8 +137,8 @@ export function isProviderPinnedModelToken(model) {
133
137
  return model.includes('/') && !isPortableModelToken(model);
134
138
  }
135
139
  /** True when `model` is a PORTABLE ladder token — a bare strength/alias
136
- * (`ultra`, `normal`, `opus`, …) or a `anthropic|openai / strength` pair —
137
- * i.e. a form `normalizeModel` resolves through the ladders rather than
140
+ * (`ultra`, `normal`, `opus`, …), an `anthropic|openai / strength` pair, or
141
+ * an anthropic family alias — i.e. a form `normalizeModel` resolves through the ladders rather than
138
142
  * passing through as a concrete id. Portable tokens are safe to PERSIST as
139
143
  * defaults (`persistDefaultKindModel`): they re-resolve against whatever
140
144
  * ladder the running build ships, so they never freeze a stale concrete
@@ -144,7 +148,8 @@ export function isPortableModelToken(model) {
144
148
  const slash = model.indexOf('/');
145
149
  const provider = model.slice(0, slash);
146
150
  const rest = model.slice(slash + 1);
147
- return (provider === 'anthropic' || provider === 'openai') && STRENGTH_ALIASES[rest] !== undefined;
151
+ return (provider === 'anthropic' || provider === 'openai') && (STRENGTH_ALIASES[rest] !== undefined
152
+ || (provider === 'anthropic' && (rest === 'opus' || rest === 'sonnet' || rest === 'haiku')));
148
153
  }
149
154
  return STRENGTH_ALIASES[model] !== undefined || model === 'opus' || model === 'sonnet' || model === 'haiku';
150
155
  }
@@ -168,6 +173,14 @@ export function normalizeModel(model, ladders = modelLadders()) {
168
173
  if (strength !== undefined && (provider === 'anthropic' || provider === 'openai')) {
169
174
  return ladderFor(provider, ladders)[strength];
170
175
  }
176
+ if (provider === 'anthropic') {
177
+ if (rest === 'opus')
178
+ return ladders.anthropic.strong;
179
+ if (rest === 'sonnet')
180
+ return ladders.anthropic.medium;
181
+ if (rest === 'haiku')
182
+ return ladders.anthropic.light;
183
+ }
171
184
  // Concrete `provider/id` (optionally `...:thinking`) — pass through.
172
185
  return model;
173
186
  }