@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.
- package/dist/api/dto/nodes.d.ts +11 -0
- package/dist/clients/attach/viewer.js +200 -200
- package/dist/commands/attention.js +1 -1
- package/dist/commands/cron.js +5 -5
- package/dist/commands/human.js +1 -1
- package/dist/commands/memory/__tests__/memory-list-and-lint-ignore.test.d.ts +1 -0
- package/dist/commands/memory/__tests__/memory-list-and-lint-ignore.test.js +96 -0
- package/dist/commands/memory/delete.js +1 -1
- package/dist/commands/memory/edit.js +6 -3
- package/dist/commands/memory/list.d.ts +4 -0
- package/dist/commands/memory/list.js +20 -14
- package/dist/commands/memory/move.js +1 -1
- package/dist/commands/memory/shared.d.ts +1 -0
- package/dist/commands/memory/shared.js +13 -1
- package/dist/commands/memory/write.js +4 -1
- package/dist/commands/node/inspect.js +11 -4
- package/dist/commands/node/lifecycle.js +1 -0
- package/dist/commands/node/message.js +8 -4
- package/dist/commands/node/subscription.js +5 -1
- package/dist/commands/node/wait.js +5 -1
- package/dist/commands/pkg/plugin-manage.js +1 -1
- package/dist/commands/pkg.js +1 -0
- package/dist/commands/profile/rename.js +1 -1
- package/dist/commands/sys/config.js +1 -0
- package/dist/commands/sys.js +1 -0
- package/dist/core/__tests__/cli-help-contract.test.d.ts +1 -0
- package/dist/core/__tests__/cli-help-contract.test.js +150 -0
- package/dist/core/__tests__/integration/command-plugins.test.js +21 -0
- package/dist/core/__tests__/watchdog-abort-arms-retry.test.js +11 -6
- package/dist/core/canvas/canvas.js +11 -1
- package/dist/core/canvas/meta-file.js +18 -27
- package/dist/core/canvas/remote-canvas-source.d.ts +8 -0
- package/dist/core/canvas/remote-canvas-source.js +9 -1
- package/dist/core/command-manifests/registry.d.ts +2 -0
- package/dist/core/command-plugins/compose.js +4 -2
- package/dist/core/command-plugins/discovery.d.ts +1 -0
- package/dist/core/command-plugins/discovery.js +1 -1
- package/dist/core/command.js +2 -2
- package/dist/core/fault-classifier.js +8 -0
- package/dist/core/pagination.d.ts +2 -0
- package/dist/core/pagination.js +2 -1
- package/dist/core/runtime/broker/fault-retry.js +6 -0
- package/dist/core/runtime/broker/rebind.js +50 -0
- package/dist/core/runtime/launch.d.ts +2 -2
- package/dist/core/runtime/launch.js +17 -4
- package/dist/core/runtime/stop-guard.js +28 -16
- package/dist/core/substrate/frontmatter-validation.d.ts +1 -0
- package/dist/core/substrate/frontmatter-validation.js +1 -1
- package/dist/daemon/api/__tests__/error-seam.test.js +27 -0
- package/dist/daemon/api/handlers/messages.js +12 -12
- package/dist/daemon/api/handlers/nodes.js +13 -1
- package/dist/daemon/api/handlers/worktree.js +6 -22
- package/dist/daemon/api/map.d.ts +4 -2
- package/dist/daemon/api/map.js +22 -2
- package/dist/daemon/reconcilers/broker-supervision.js +1 -1
- package/dist/pi-extensions/canvas-bash-valve.js +4 -3
- package/package.json +1 -1
- 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.
|
|
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
|
-
|
|
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, '
|
|
63
|
-
assert.
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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
|
-
|
|
67
|
-
|
|
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
|
-
|
|
70
|
-
|
|
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:
|
|
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:
|
|
29
|
+
params: httpLeaf.params, rest: httpLeaf.rest, output: httpLeaf.output, commandPath: manifestCommandPath,
|
|
28
30
|
}, input, context),
|
|
29
31
|
};
|
|
30
32
|
},
|
|
@@ -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));
|
package/dist/core/command.js
CHANGED
|
@@ -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
|
}
|
package/dist/core/pagination.js
CHANGED
|
@@ -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) >
|
|
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`, …)
|
|
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
|
|
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`, …)
|
|
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
|
}
|