@ran-sh/dsh-crew 2.2.8 → 2.2.10
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/.claude-plugin/plugin.json +1 -1
- package/package.json +1 -1
- package/src/install/install-legacy.mjs +35 -23
- package/src/install/npx-lifecycle.mjs +3 -0
- package/src/log-prune.mjs +44 -12
- package/src/readiness-matrix.mjs +12 -3
- package/src/runtime-identity.mjs +1 -1
- package/src/runtime-readiness-snapshot.mjs +21 -9
package/package.json
CHANGED
|
@@ -1247,26 +1247,35 @@ export function installHudSegment({ home = homedir() } = {}) {
|
|
|
1247
1247
|
return { ok: true, actions: [...(bak ? [`backup: ${bak}`] : []), 'statusLine: claude-hud now runs worker-segment.sh via --extra-cmd'] };
|
|
1248
1248
|
}
|
|
1249
1249
|
|
|
1250
|
-
// Crew
|
|
1251
|
-
//
|
|
1252
|
-
//
|
|
1253
|
-
//
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1250
|
+
// The paths Crew has recorded as its own in this Claude host: the marketplace records
|
|
1251
|
+
// it wrote (under the current and pre-rename names, in settings and in the CLI's own
|
|
1252
|
+
// cache) and the constant an older installer used. Only these are grounds for removing
|
|
1253
|
+
// anything: a directory that merely happens to be named `dsh-crew` belongs to whoever
|
|
1254
|
+
// made it, the module's own checkout is not evidence (an uninstall may run from a
|
|
1255
|
+
// checkout this host never recorded), and a path that is not recorded here is left
|
|
1256
|
+
// alone.
|
|
1257
|
+
function crewOwnedRoots({ home, settings, markets, installed }) {
|
|
1258
|
+
const roots = [];
|
|
1259
|
+
const push = (value) => { if (typeof value === 'string' && value.trim() && !roots.includes(value.trim())) roots.push(value.trim()); };
|
|
1260
|
+
for (const name of CREW_CLAUDE_MARKETPLACES) {
|
|
1261
|
+
push(settings?.extraKnownMarketplaces?.[name]?.source?.path);
|
|
1262
|
+
push(markets?.[name]?.source?.path);
|
|
1263
|
+
push(markets?.[name]?.installLocation);
|
|
1264
|
+
}
|
|
1265
|
+
for (const key of CREW_CLAUDE_PLUGIN_KEYS) {
|
|
1266
|
+
const entries = Array.isArray(installed?.plugins?.[key]) ? installed.plugins[key] : [installed?.plugins?.[key]];
|
|
1267
|
+
for (const entry of entries) push(entry?.installPath);
|
|
1268
|
+
}
|
|
1269
|
+
push(join(home, '.config', 'dsh-crew', 'marketplace'));
|
|
1270
|
+
return roots;
|
|
1260
1271
|
}
|
|
1261
1272
|
|
|
1262
|
-
|
|
1263
|
-
// Crew's own script, in Crew's own directory, in the exact shape the installer
|
|
1264
|
-
// writes (`bash <root>/statusline/statusline.sh`). A command that merely mentions
|
|
1265
|
-
// these words belongs to whoever wrote it.
|
|
1266
|
-
function crewOwnedStatusLine(value) {
|
|
1273
|
+
function crewOwnedStatusLine(value, roots) {
|
|
1267
1274
|
const command = typeof value?.command === 'string' ? value.command.trim() : '';
|
|
1268
|
-
const match = /^bash\s+(.+
|
|
1269
|
-
|
|
1275
|
+
const match = /^bash\s+(.+)$/i.exec(command);
|
|
1276
|
+
if (!match) return false;
|
|
1277
|
+
const path = normalizedPath(match[1].trim());
|
|
1278
|
+
return path !== null && roots.some((root) => normalizedPath(join(root, 'statusline', 'statusline.sh')) === path);
|
|
1270
1279
|
}
|
|
1271
1280
|
|
|
1272
1281
|
// The claude CLI records, per marketplace and plugin, what it materialized, in
|
|
@@ -1304,16 +1313,19 @@ function removeCrewClaudeCacheRecords({ home, actions }) {
|
|
|
1304
1313
|
export function uninstallClaudeCode({ home = homedir() } = {}) {
|
|
1305
1314
|
const actions = [];
|
|
1306
1315
|
const settingsFile = join(home, '.claude', 'settings.json');
|
|
1316
|
+
const cacheDir = join(home, '.claude', 'plugins');
|
|
1307
1317
|
const settings = readJson(settingsFile, null);
|
|
1318
|
+
const markets = readJson(join(cacheDir, 'known_marketplaces.json'), null);
|
|
1319
|
+
const installed = readJson(join(cacheDir, 'installed_plugins.json'), null);
|
|
1320
|
+
const roots = crewOwnedRoots({ home, settings, markets, installed });
|
|
1308
1321
|
if (settings) {
|
|
1309
1322
|
backup(settingsFile);
|
|
1310
|
-
const mpDir = join(home, '.config', 'dsh-crew', 'marketplace');
|
|
1311
1323
|
if (Array.isArray(settings.extraKnownMarketplaces)) {
|
|
1312
|
-
// Legacy array shape:
|
|
1313
|
-
//
|
|
1314
|
-
//
|
|
1324
|
+
// Legacy array shape: an entry is removed only when it IS one of the paths Crew
|
|
1325
|
+
// recorded as its own. What the path is spelled with is not evidence — a
|
|
1326
|
+
// marketplace under someone else's `dsh-crew` directory stays.
|
|
1315
1327
|
settings.extraKnownMarketplaces = settings.extraKnownMarketplaces
|
|
1316
|
-
.filter((entry) => !
|
|
1328
|
+
.filter((entry) => !roots.some((root) => normalizedPath(entry?.path) === normalizedPath(root)));
|
|
1317
1329
|
} else if (settings.extraKnownMarketplaces) {
|
|
1318
1330
|
for (const name of CREW_CLAUDE_MARKETPLACES) delete settings.extraKnownMarketplaces[name];
|
|
1319
1331
|
}
|
|
@@ -1326,7 +1338,7 @@ export function uninstallClaudeCode({ home = homedir() } = {}) {
|
|
|
1326
1338
|
settings.permissions.allow = settings.permissions.allow.filter((rule) => typeof rule !== 'string'
|
|
1327
1339
|
|| !CREW_CLAUDE_PERMISSION_PREFIXES.some((prefix) => rule.startsWith(prefix)));
|
|
1328
1340
|
}
|
|
1329
|
-
if (crewOwnedStatusLine(settings.statusLine)) {
|
|
1341
|
+
if (crewOwnedStatusLine(settings.statusLine, roots)) {
|
|
1330
1342
|
delete settings.statusLine;
|
|
1331
1343
|
actions.push('statusLine: removed (Crew-installed)');
|
|
1332
1344
|
}
|
|
@@ -2785,6 +2785,9 @@ export async function npxLogs({ args = [], log = console.log } = {}) {
|
|
|
2785
2785
|
const keep = Number(args[1]);
|
|
2786
2786
|
const result = pruneCrewTempLogs(Number.isInteger(keep) && keep > 0 ? { keepRuns: keep } : {});
|
|
2787
2787
|
log(`✓ Crew diagnostics pruned: removed ${result.removed.length} file(s); kept ${result.kept.length} run(s)`);
|
|
2788
|
+
if (result.failed?.length) {
|
|
2789
|
+
log(`- ${result.failed.length} file(s) could not be deleted (a running process may hold them); they are counted in neither number and the next prune retries`);
|
|
2790
|
+
}
|
|
2788
2791
|
return { ok: true, ...result };
|
|
2789
2792
|
}
|
|
2790
2793
|
|
package/src/log-prune.mjs
CHANGED
|
@@ -2,9 +2,12 @@
|
|
|
2
2
|
// new dsh-crew-dsh-crew-3210-<stamp>.out/.err.log pair, every desktop launch a
|
|
3
3
|
// dsh-crew-web-*, every official launch a dsh-official-web-*, and the launcher
|
|
4
4
|
// appends to dsh-crew-launcher.log forever. They are the only post-mortem evidence
|
|
5
|
-
// for a crash-before-readiness, so the rule
|
|
6
|
-
//
|
|
7
|
-
//
|
|
5
|
+
// for a crash-before-readiness, so the rule bounds them instead of deleting them all.
|
|
6
|
+
// A run survives when it is BOTH within its family's newest `keep` AND younger than
|
|
7
|
+
// the age bound; exceeding either one removes it, unless it was written within the
|
|
8
|
+
// recency guard (a running process owns its own log files). The newest `keep` runs
|
|
9
|
+
// therefore do not survive unconditionally — only a run that is also inside the age
|
|
10
|
+
// bound does, which is what keeps a burst of starts from filling the directory.
|
|
8
11
|
|
|
9
12
|
import { readdirSync, rmSync, statSync } from 'node:fs';
|
|
10
13
|
import { join } from 'node:path';
|
|
@@ -23,7 +26,14 @@ const LOG_FAMILIES = [
|
|
|
23
26
|
{ prefix: 'dsh-crew-dsh-crew-3210-', suffix: '.out.log', keep: 10 },
|
|
24
27
|
{ prefix: 'dsh-crew-web-', suffix: '.out.log', keep: 20 },
|
|
25
28
|
{ prefix: 'dsh-official-web-', suffix: '.out.log', keep: 20 },
|
|
26
|
-
]
|
|
29
|
+
].map((family) => ({
|
|
30
|
+
...family,
|
|
31
|
+
// An `.err.log` whose `.out.log` sibling is gone (or was never written) is still a
|
|
32
|
+
// Crew diagnostic, so each family recognizes its error files as prunable runs too.
|
|
33
|
+
errStamp: (name) => name.startsWith(family.prefix) && name.endsWith('.err.log')
|
|
34
|
+
? name.slice(family.prefix.length, name.length - '.err.log'.length) || null
|
|
35
|
+
: null,
|
|
36
|
+
}));
|
|
27
37
|
|
|
28
38
|
// `dsh-crew-<profile>-<port>-<stamp>.out.log` -> the stamp identifies one run, and
|
|
29
39
|
// its .err.log sibling must live or die with it.
|
|
@@ -48,14 +58,28 @@ export function pruneCrewTempLogs({ tempDir = tmpdir(), keepRuns = null, maxAgeD
|
|
|
48
58
|
const runs = new Map();
|
|
49
59
|
for (const name of names) {
|
|
50
60
|
const stamp = runStamp(name, family);
|
|
51
|
-
if (
|
|
61
|
+
if (stamp) {
|
|
62
|
+
const file = join(tempDir, name);
|
|
63
|
+
let mtimeMs = 0;
|
|
64
|
+
try { mtimeMs = statSync(file).mtimeMs; } catch { continue; }
|
|
65
|
+
const run = runs.get(stamp) ?? { stamp, files: [], newest: 0 };
|
|
66
|
+
run.files.push(name);
|
|
67
|
+
run.newest = Math.max(run.newest, mtimeMs);
|
|
68
|
+
runs.set(stamp, run);
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
// An orphan `.err.log` (its `.out.log` sibling already pruned or never written)
|
|
72
|
+
// is still a Crew diagnostic: prune it on its own stamp so a half-written run
|
|
73
|
+
// cannot outlive every bound.
|
|
74
|
+
const errStamp = family.errStamp(name);
|
|
75
|
+
if (!errStamp) continue;
|
|
52
76
|
const file = join(tempDir, name);
|
|
53
77
|
let mtimeMs = 0;
|
|
54
78
|
try { mtimeMs = statSync(file).mtimeMs; } catch { continue; }
|
|
55
|
-
const run = runs.get(
|
|
79
|
+
const run = runs.get(errStamp) ?? { stamp: errStamp, files: [], newest: 0 };
|
|
56
80
|
run.files.push(name);
|
|
57
81
|
run.newest = Math.max(run.newest, mtimeMs);
|
|
58
|
-
runs.set(
|
|
82
|
+
runs.set(errStamp, run);
|
|
59
83
|
}
|
|
60
84
|
const ordered = [...runs.values()].sort((left, right) => right.newest - left.newest);
|
|
61
85
|
const keep = keepOverride ?? family.keep;
|
|
@@ -70,15 +94,23 @@ export function pruneCrewTempLogs({ tempDir = tmpdir(), keepRuns = null, maxAgeD
|
|
|
70
94
|
// bound exists to stop. A burst of eleven young runs still keeps ten.
|
|
71
95
|
if ((tooOld || tooMany) && !tooRecentToTouch) {
|
|
72
96
|
for (const name of run.files) removed.push(name);
|
|
73
|
-
const errName = run.files[0].replace(/\.out\.log$/, '.err.log');
|
|
74
|
-
if (names.includes(errName)) removed.push(errName);
|
|
75
97
|
} else {
|
|
76
98
|
kept.push(run.stamp);
|
|
77
99
|
}
|
|
78
100
|
});
|
|
79
101
|
}
|
|
80
|
-
|
|
81
|
-
|
|
102
|
+
// `removed` lists what is actually gone. A locked file (Windows holds a log open
|
|
103
|
+
// while its process runs) is reported in `failed` instead of pretending a deletion
|
|
104
|
+
// happened: the next pass tries again, and the operator is not told a number that
|
|
105
|
+
// is not true.
|
|
106
|
+
const failed = [];
|
|
107
|
+
for (const name of [...new Set(removed)]) {
|
|
108
|
+
try { rmSync(join(tempDir, name), { force: true }); } catch { failed.push(name); }
|
|
82
109
|
}
|
|
83
|
-
return {
|
|
110
|
+
return {
|
|
111
|
+
ok: failed.length === 0 ? true : 'partial',
|
|
112
|
+
removed: removed.filter((name) => !failed.includes(name)).sort(),
|
|
113
|
+
kept: kept.sort(),
|
|
114
|
+
...(failed.length ? { failed: failed.sort() } : {}),
|
|
115
|
+
};
|
|
84
116
|
}
|
package/src/readiness-matrix.mjs
CHANGED
|
@@ -76,8 +76,17 @@ function normalizeEvidence(row, evidence) {
|
|
|
76
76
|
// flip the row is how "this route is unused" would turn into "this route passed".
|
|
77
77
|
// What was reported is kept as metadata rather than thrown away.
|
|
78
78
|
if (row.status === 'NOT_APPLICABLE') {
|
|
79
|
-
|
|
80
|
-
|
|
79
|
+
// The same sanitized fields an applied record keeps, so "the observation is not
|
|
80
|
+
// lost" is a statement the code supports rather than a claim about three of five.
|
|
81
|
+
const source = (value) => (typeof value === 'string' && value.trim() ? value.trim() : null);
|
|
82
|
+
const reported = {
|
|
83
|
+
status: evidence.status,
|
|
84
|
+
...(source(evidence.reason_code) ? { reason_code: source(evidence.reason_code) } : {}),
|
|
85
|
+
...(source(evidence.evidence_source) ? { evidence_source: source(evidence.evidence_source) } : {}),
|
|
86
|
+
...(source(evidence.evidence_ref) ? { evidence_ref: source(evidence.evidence_ref) } : {}),
|
|
87
|
+
...(source(evidence.detail_code) && /^[A-Z][A-Z0-9_]{0,127}$/u.test(evidence.detail_code) ? { detail_code: evidence.detail_code } : {}),
|
|
88
|
+
};
|
|
89
|
+
return { ...row, reported_evidence: reported };
|
|
81
90
|
}
|
|
82
91
|
const reason = typeof evidence.reason_code === 'string' && evidence.reason_code.trim()
|
|
83
92
|
? evidence.reason_code.trim()
|
|
@@ -195,7 +204,7 @@ export function buildReadinessMatrix({
|
|
|
195
204
|
const selectionKnown = typeof workerSelection?.provider === 'string' && workerSelection.provider.length > 0;
|
|
196
205
|
const deepseekRouteSelected = workerProviderMode === 'deepseek-official'
|
|
197
206
|
|| workerSelection?.provider === 'deepseek-official';
|
|
198
|
-
if (workerProviderMode
|
|
207
|
+
if (workerProviderMode === 'follow-dsh' && selectionKnown && !deepseekRouteSelected) {
|
|
199
208
|
for (const id of POLICY_NOT_APPLICABLE_ROWS) {
|
|
200
209
|
rows[id] = baseRow(id, 'real-execution', 'NOT_APPLICABLE', READINESS_REASON_CODES.WORKER_PROVIDER_FOLLOWS_DSH, 'runtime-policy');
|
|
201
210
|
}
|
package/src/runtime-identity.mjs
CHANGED
|
@@ -36,7 +36,7 @@ export {
|
|
|
36
36
|
// included in the identity contract.
|
|
37
37
|
const RUNTIME_ID = randomUUID();
|
|
38
38
|
|
|
39
|
-
export const RUNTIME_VERSION = '2.2.
|
|
39
|
+
export const RUNTIME_VERSION = '2.2.10';
|
|
40
40
|
export const HUB_PROTOCOL_VERSION = 1;
|
|
41
41
|
|
|
42
42
|
export const HUB_CAPABILITIES = Object.freeze([
|
|
@@ -108,12 +108,14 @@ function projectModelRole({ role, selected, health, jobs, runtime, now, executio
|
|
|
108
108
|
if (healthCurrent && matchingHealth.state === 'callable') {
|
|
109
109
|
return { state: 'CALLABLE', reason_code: matchingHealth.reason_code ?? 'PROVIDER_CALLABLE', selected, observed_at: matchingHealth.observed_at ?? null, expires_at: expiresAt, source: 'provider_health', last_success: null };
|
|
110
110
|
}
|
|
111
|
-
//
|
|
112
|
-
//
|
|
113
|
-
//
|
|
114
|
-
//
|
|
115
|
-
//
|
|
116
|
-
|
|
111
|
+
// Callability asks for positive proof that the provider answered, so the ledger
|
|
112
|
+
// fact that proves it is the one that says the execution ended normally: a job the
|
|
113
|
+
// worker recorded as `done` whose `execution_status` is `completed`. The task-level
|
|
114
|
+
// verdict is deliberately NOT part of this — a `partial` task still had a provider
|
|
115
|
+
// response — and a job whose execution failed or was never recorded is not evidence
|
|
116
|
+
// of a response, however its task was later classified. A recovery probe or a real
|
|
117
|
+
// run is what a route without such a job needs.
|
|
118
|
+
const responded = (job) => job?.execution_status === 'completed';
|
|
117
119
|
const completed = (Array.isArray(jobs) ? jobs : [])
|
|
118
120
|
.filter((job) => job?.role === role && job?.provider === selected.provider && job?.model === selected.model
|
|
119
121
|
&& job?.status === 'done' && responded(job)
|
|
@@ -249,7 +251,12 @@ export function reprojectRuntimeModelCallability(snapshot, { enabled_roles = {},
|
|
|
249
251
|
const roles = {};
|
|
250
252
|
for (const role of ['worker', 'reviewer']) {
|
|
251
253
|
if (enabled_roles?.[role] === false) {
|
|
252
|
-
|
|
254
|
+
// The hub may still record a selected route for a role this session disables;
|
|
255
|
+
// keeping that route here (rather than nulling it) is what lets the canonical
|
|
256
|
+
// validation below accept the reprojection. Nulling it manufactured a
|
|
257
|
+
// selection mismatch against the unchanged outer snapshot and read the whole
|
|
258
|
+
// contract as DEGRADED for an intentionally disabled role.
|
|
259
|
+
roles[role] = { state: 'NOT_APPLICABLE', reason_code: 'ROLE_DISABLED', selected: snapshot[role]?.selected ?? null, last_success: null };
|
|
253
260
|
} else if (sourceProjection.enabled_roles?.[role] === true) {
|
|
254
261
|
roles[role] = structuredClone(sourceProjection.roles[role]);
|
|
255
262
|
} else {
|
|
@@ -273,9 +280,14 @@ export function reprojectRuntimeModelCallability(snapshot, { enabled_roles = {},
|
|
|
273
280
|
projection: projected,
|
|
274
281
|
runtime: snapshot.runtime,
|
|
275
282
|
expectedEnabledRoles: projected.enabled_roles,
|
|
283
|
+
// Expected selections are the routes the snapshot RECORDED, for disabled roles
|
|
284
|
+
// too: the canonical rule is "a disabled role keeps its route", and expecting
|
|
285
|
+
// null here would contradict the very projection this function just built — the
|
|
286
|
+
// mismatch then discarded a healthy worker's evidence and read the session as
|
|
287
|
+
// degraded for a role that was intentionally switched off.
|
|
276
288
|
expectedSelections: {
|
|
277
|
-
worker:
|
|
278
|
-
reviewer:
|
|
289
|
+
worker: snapshot.worker?.selected ?? null,
|
|
290
|
+
reviewer: snapshot.reviewer?.selected ?? null,
|
|
279
291
|
},
|
|
280
292
|
now,
|
|
281
293
|
});
|