@ran-sh/dsh-crew 1.1.1 → 1.2.0-rc.4

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 (83) hide show
  1. package/.claude-plugin/marketplace.json +16 -17
  2. package/.claude-plugin/plugin.json +7 -1
  3. package/README.md +44 -15
  4. package/README.zh.md +37 -12
  5. package/agents/ds-flash.md +16 -18
  6. package/agents/ds-pro.md +16 -24
  7. package/agents/ds-reviewer.md +15 -15
  8. package/agents/ds-worker.md +16 -16
  9. package/bin/dsh-crew.mjs +16 -12
  10. package/codex/AGENTS.md +37 -85
  11. package/codex/agents/ds-flash.toml +18 -14
  12. package/codex/agents/ds-pro.toml +18 -15
  13. package/codex/agents/ds-reviewer.toml +17 -13
  14. package/codex/agents/ds-worker.toml +17 -13
  15. package/codex/prompts/dsh-config.md +17 -1
  16. package/codex/prompts/dsh-status.md +5 -1
  17. package/commands/config.md +16 -4
  18. package/commands/status.md +6 -2
  19. package/docs/installation.md +54 -9
  20. package/docs/readiness-matrix.md +14 -6
  21. package/docs/ui-surfaces.md +32 -17
  22. package/lib/client.js +5434 -4844
  23. package/official-web-bridge/lib/client.js +567 -479
  24. package/official-web-bridge/overlay-entry.mjs +59 -0
  25. package/package.json +6 -3
  26. package/scripts/build-client.mjs +2 -0
  27. package/scripts/setup.mjs +8 -7
  28. package/scripts/verify-crew-ui-polish.mjs +145 -0
  29. package/scripts/verify-history-ui.mjs +97 -0
  30. package/scripts/verify-npm-install.mjs +1 -1
  31. package/src/client/history-panel.tsx +108 -0
  32. package/src/client/host-readiness.mjs +15 -6
  33. package/src/client/index.tsx +86 -64
  34. package/src/client/model-callability-view.mjs +17 -0
  35. package/src/client/panel-chrome.tsx +40 -0
  36. package/src/client/quick-panel.tsx +88 -73
  37. package/src/client/readiness-envelope.mjs +34 -0
  38. package/src/config-readiness.mjs +181 -115
  39. package/src/extension-contract.mjs +44 -26
  40. package/src/history/admission-gate.mjs +51 -0
  41. package/src/history/archive-store.mjs +261 -0
  42. package/src/history/cleanup-plan.mjs +64 -0
  43. package/src/history/http.mjs +32 -0
  44. package/src/history/operation.mjs +86 -0
  45. package/src/history/runner.mjs +51 -0
  46. package/src/history/runtime.mjs +28 -0
  47. package/src/history/service.mjs +137 -0
  48. package/src/history/state.mjs +27 -0
  49. package/src/hub/index.mjs +141 -24
  50. package/src/hub-client.mjs +17 -15
  51. package/src/image-output.mjs +71 -0
  52. package/src/install/install-legacy.mjs +126 -23
  53. package/src/install/npx-lifecycle.mjs +373 -64
  54. package/src/install/official-frontend-assets.mjs +78 -0
  55. package/src/install/payload-content.mjs +88 -0
  56. package/src/install/windows-startup.mjs +107 -19
  57. package/src/install/windows-supervisor-adapter.mjs +443 -0
  58. package/src/install/windows-supervisor-lifecycle.mjs +782 -0
  59. package/src/install/zcode.mjs +53 -14
  60. package/src/job-contracts.mjs +37 -31
  61. package/src/jobs.mjs +2 -2
  62. package/src/mcp-runtime.mjs +74 -9
  63. package/src/model-callability-contract.mjs +79 -0
  64. package/src/multimodal.mjs +0 -0
  65. package/src/official-web-bridge.mjs +0 -2
  66. package/src/provider-health.mjs +130 -124
  67. package/src/readiness-matrix.mjs +20 -15
  68. package/src/runtime-identity-contract.mjs +34 -0
  69. package/src/runtime-identity.mjs +14 -4
  70. package/src/runtime-readiness-snapshot.mjs +285 -87
  71. package/src/server.mjs +86 -76
  72. package/src/standalone-sdk.mjs +23 -0
  73. package/src/supervisor/restart-request.mjs +64 -10
  74. package/src/workflow-runtime.mjs +28 -13
  75. package/windows/start-dsh-crew.cmd +57 -57
  76. package/windows/start-dsh-crew.ps1 +314 -51
  77. package/windows/supervisor-control.ps1 +467 -0
  78. package/zcode/AGENTS.md +4 -1
  79. package/zcode/agents/ds-reviewer.md +31 -36
  80. package/zcode/agents/ds-worker.md +31 -39
  81. package/zcode/commands/dsh-config.md +17 -6
  82. package/zcode/commands/dsh-status.md +5 -4
  83. package/.mcp.json +0 -8
@@ -0,0 +1,28 @@
1
+ import { homedir } from 'node:os';
2
+ import { join, resolve } from 'node:path';
3
+ import { fileURLToPath } from 'node:url';
4
+ import { spawn } from 'node:child_process';
5
+ import { getHubRuntimeIdentity } from '../runtime-identity.mjs';
6
+ import { createHistoryService } from './service.mjs';
7
+ import { registerHistoryHttp } from './http.mjs';
8
+
9
+ export function registerRuntimeHistory(ctx) {
10
+ const crewRoot = join(homedir(), '.config', 'dsh-crew');
11
+ const runtime = getHubRuntimeIdentity();
12
+ const owned = process.platform === 'win32' && runtime.dsh_version === '0.1.2-rc.1'
13
+ && resolve(process.env.DSH_HOME ?? '').toLowerCase() === resolve(join(crewRoot, 'harness')).toLowerCase();
14
+ if (!owned) return () => {};
15
+ return ctx.inject(['webServer', 'sessionPersistence', 'agents'], host => {
16
+ const service = createHistoryService({ crewRoot, agents: host.agents, persistence: host.sessionPersistence,
17
+ runtimeId: runtime.runtime_id, launch(id, recover = false) {
18
+ return new Promise((accept, reject) => {
19
+ const child = spawn(process.execPath, [fileURLToPath(new URL('./runner.mjs', import.meta.url)), id, ...(recover ? ['--recover'] : [])], {
20
+ detached: true, windowsHide: true, stdio: 'ignore', env: { ...process.env },
21
+ });
22
+ child.once('error', reject); child.once('spawn', () => { child.unref(); accept(); });
23
+ });
24
+ } });
25
+ const disposeHttp = registerHistoryHttp(host.webServer, service);
26
+ return () => { disposeHttp?.(); service.dispose(); };
27
+ });
28
+ }
@@ -0,0 +1,137 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { existsSync, readdirSync } from 'node:fs';
3
+ import { relative } from 'node:path';
4
+ import { planHistoryCleanup } from './cleanup-plan.mjs';
5
+ import { installHistoryAdmissionGate } from './admission-gate.mjs';
6
+ import { historyHash, historyPath, readHistoryBytes, decodeWorkspaceStore, readHistoryManifest } from './archive-store.mjs';
7
+ import { readHistoryState, writeHistoryState, historyPending, publicHistoryState } from './state.mjs';
8
+
9
+ export function createHistoryService({ crewRoot, agents, persistence, runtimeId, launch, now = Date.now }) {
10
+ const gate = installHistoryAdmissionGate(agents, () => historyPending(crewRoot));
11
+ const plans = new Map();
12
+ let entering = false;
13
+ async function snapshot(options) {
14
+ if (typeof persistence?.listSnapshots !== 'function' || typeof persistence?.locate !== 'function'
15
+ || persistence.supportsRawArtifacts !== true) throw Error('HISTORY_STORAGE_UNSUPPORTED');
16
+ const bytes = readHistoryBytes(historyPath(crewRoot, 'harness/storages/workspace.json'));
17
+ const store = decodeWorkspaceStore(bytes);
18
+ const listed = await persistence.listSnapshots();
19
+ if (!Array.isArray(listed) || listed.length > 10000) throw Error('HISTORY_INVENTORY_TOO_LARGE');
20
+ let total = 0;
21
+ const sessions = listed.map(({ header, revision }) => {
22
+ if (!header || typeof header.id !== 'string') throw Error('HISTORY_INVENTORY_INVALID');
23
+ const location = persistence.locate(header);
24
+ if (location?.kind !== 'jsonl') throw Error('HISTORY_STORAGE_UNSUPPORTED');
25
+ let path = relative(crewRoot, location.path).replaceAll('\\', '/');
26
+ if (!/^harness\/sessions\/[^/]+\/[^/]+\/session\.jsonl(?:\.zstd)?$/.test(path)
27
+ || path.split('/')[3] !== header.id) throw Error('HISTORY_STORAGE_UNSUPPORTED');
28
+ const plain = path.replace(/\.zstd$/, '');
29
+ const existing = [plain, `${plain}.zstd`].filter(candidate => existsSync(historyPath(crewRoot, candidate)));
30
+ if (existing.length !== 1) throw Error('HISTORY_ARTIFACT_AMBIGUOUS');
31
+ path = existing[0];
32
+ const raw = readHistoryBytes(historyPath(crewRoot, path)); total += raw.length;
33
+ if (total > 512 * 1024 * 1024) throw Error('HISTORY_INVENTORY_TOO_LARGE');
34
+ return { id: header.id, createdAt: header.createdAt, parentSession: header.parentSession,
35
+ revision: JSON.stringify([revision, historyHash(raw)]), artifact: { sessionId: header.id, relativePath: path, sha256: historyHash(raw) } };
36
+ });
37
+ // A retained child keeps its ancestor chain; do not leave a newer fork orphaned.
38
+ const workspaces = Object.entries(store.tables.workspaces).map(([id, row]) => ({ id, ...row }));
39
+ const plan = planHistoryCleanup({ workspaces, sessions, activeSessionIds: gate.idle() ? [] : ['active-agent'] }, options);
40
+ const selected = new Set(plan.sessionIds);
41
+ const byId = new Map(sessions.map(row => [row.id, row]));
42
+ const queue = sessions.filter(row => !selected.has(row.id));
43
+ for (let i = 0; i < queue.length; i++) {
44
+ const parent = byId.get(queue[i].parentSession);
45
+ if (parent && selected.delete(parent.id)) queue.push(parent);
46
+ }
47
+ plan.sessionIds = plan.sessionIds.filter(id => selected.has(id));
48
+ plan.workspaceIds = plan.workspaceIds.filter(id => store.tables.workspaces[id].sessionIds.every(sid => selected.has(sid)));
49
+ plan.counts = { workspaces: plan.workspaceIds.length, sessions: plan.sessionIds.length };
50
+ plan.executable = gate.idle() && plan.counts.workspaces + plan.counts.sessions > 0;
51
+ if (!plan.executable && !plan.blockedReason) plan.blockedReason = 'EMPTY_SELECTION';
52
+ const request = { operation: plan.operation, workspaceHash: historyHash(bytes), workspaceIds: plan.workspaceIds,
53
+ sessionIds: plan.sessionIds, artifacts: sessions.filter(row => selected.has(row.id)).map(row => row.artifact) };
54
+ const revision = historyHash(JSON.stringify([runtimeId, plan.revision, request]));
55
+ return { plan: { ...plan, revision, items: workspaces.filter(row => plan.workspaceIds.includes(row.id)).slice(0, 100).map(row => ({ id: row.id, title: row.title })) }, request };
56
+ }
57
+ async function preview(options = {}) {
58
+ if (historyPending(crewRoot)) throw Error('HISTORY_MAINTENANCE_PENDING');
59
+ const result = await snapshot(options); const planId = randomUUID();
60
+ const expiresAt = now() + 600000;
61
+ plans.set(planId, { ...result, options, expiresAt });
62
+ while (plans.size > 16) plans.delete(plans.keys().next().value);
63
+ return { ...result.plan, planId, expiresAt };
64
+ }
65
+ async function submit(operation, build) {
66
+ if (entering || historyPending(crewRoot)) throw Error('HISTORY_MAINTENANCE_PENDING');
67
+ entering = true;
68
+ let state;
69
+ try {
70
+ state = { schemaVersion: 1, id: randomUUID(), operation, phase: 'QUEUED', lease: randomUUID(), runtimeId };
71
+ writeHistoryState(crewRoot, state); // Fence BEFORE the last idle/snapshot check.
72
+ if (!gate.idle()) throw Error('HISTORY_ACTIVE_SESSIONS');
73
+ Object.assign(state, await build(), { phase: 'QUEUED' });
74
+ writeHistoryState(crewRoot, state);
75
+ await launch(state.id);
76
+ return publicHistoryState(state);
77
+ } catch (error) {
78
+ if (state) writeHistoryState(crewRoot, { ...state, phase: 'FAILED', code: safeHistoryError(error) });
79
+ throw error;
80
+ } finally { entering = false; }
81
+ }
82
+ async function execute({ planId, confirm, acknowledgement } = {}) {
83
+ const saved = plans.get(planId);
84
+ if (!saved || saved.expiresAt < now()) throw Error('HISTORY_PLAN_EXPIRED');
85
+ if (confirm !== true || (saved.plan.operation === 'delete' && acknowledgement !== 'DELETE')) throw Error('HISTORY_CONFIRMATION_REQUIRED');
86
+ return submit(saved.plan.operation, async () => {
87
+ const fresh = await snapshot(saved.options);
88
+ if (fresh.plan.revision !== saved.plan.revision) throw Error('HISTORY_PREVIEW_CHANGED');
89
+ if (!fresh.plan.executable) throw Error('HISTORY_EMPTY_SELECTION');
90
+ plans.delete(planId);
91
+ return { request: fresh.request, options: saved.options, revision: fresh.plan.revision, counts: fresh.plan.counts };
92
+ });
93
+ }
94
+ function archives() {
95
+ const directory = historyPath(crewRoot, 'history/transactions');
96
+ if (!existsSync(directory)) return [];
97
+ const ids = readdirSync(directory).filter(id => /^[a-f0-9-]{36}$/.test(id));
98
+ if (ids.length > 10000) throw Error('HISTORY_ARCHIVE_LIMIT');
99
+ return ids.flatMap(id => {
100
+ try {
101
+ const m = readHistoryManifest(crewRoot, id);
102
+ return m.operation === 'archive' && m.state === 'APPLIED' ? [{ id: m.id, createdAt: m.createdAt, sessions: m.files.length,
103
+ workspaces: m.before.global.workspaceIds.filter(id => !m.after.global.workspaceIds.includes(id)).length }] : [];
104
+ } catch {
105
+ return [{ id, invalid: true, code: 'HISTORY_INVALID_MANIFEST', sessions: 0, workspaces: 0, createdAt: null }];
106
+ }
107
+ }).reverse();
108
+ }
109
+ async function restore({ archiveId, confirm } = {}) {
110
+ if (confirm !== true) throw Error('HISTORY_CONFIRMATION_REQUIRED');
111
+ return submit('restore', async () => {
112
+ const archive = archives().find(a => a.id === archiveId && !a.invalid);
113
+ if (!archive) throw Error('HISTORY_ARCHIVE_NOT_RESTORABLE');
114
+ return { archiveId, counts: { sessions: archive.sessions, workspaces: archive.workspaces } };
115
+ });
116
+ }
117
+ async function fencedCheck() {
118
+ const state = readHistoryState(crewRoot);
119
+ if (!state || !historyPending(crewRoot) || state.runtimeId !== runtimeId || !gate.idle()) throw Error('HISTORY_FENCE_NOT_IDLE');
120
+ if (state.operation !== 'restore') {
121
+ const fresh = await snapshot(state.options);
122
+ if (fresh.plan.revision !== state.revision) throw Error('HISTORY_PREVIEW_CHANGED');
123
+ }
124
+ return true;
125
+ }
126
+ async function recover({ confirm } = {}) {
127
+ const state = readHistoryState(crewRoot);
128
+ if (confirm !== true) throw Error('HISTORY_CONFIRMATION_REQUIRED');
129
+ if (!state || !historyPending(crewRoot)) throw Error('HISTORY_RECOVERY_UNAVAILABLE');
130
+ await launch(state.id, true);
131
+ return publicHistoryState(state);
132
+ }
133
+ return { preview, execute, restore, recover, archives, fencedCheck, status: () => publicHistoryState(readHistoryState(crewRoot)), dispose: () => gate.dispose() };
134
+ }
135
+ export function safeHistoryError(error) {
136
+ return /^HISTORY_[A-Z_]+$/.test(error?.message ?? '') ? error.message : 'HISTORY_OPERATION_FAILED';
137
+ }
@@ -0,0 +1,27 @@
1
+ import { existsSync } from 'node:fs';
2
+ import { historyPath, readHistoryBytes, writeHistoryBytes } from './archive-store.mjs';
3
+
4
+ export const TERMINAL_HISTORY_PHASES = ['DONE', 'FAILED', 'ROLLED_BACK'];
5
+ const phases = ['QUEUED', 'STOPPING', 'APPLYING', 'STARTING', 'VERIFYING', 'RECOVERY_REQUIRED', ...TERMINAL_HISTORY_PHASES];
6
+ export function readHistoryState(root) {
7
+ const file = historyPath(root, 'history/active.json');
8
+ if (!existsSync(file)) return null;
9
+ let value;
10
+ try { value = JSON.parse(readHistoryBytes(file)); } catch { throw Error('HISTORY_STATE_INVALID'); }
11
+ if (value?.schemaVersion !== 1 || !/^[a-f0-9-]{36}$/.test(value.id) || !phases.includes(value.phase)
12
+ || !['archive', 'delete', 'restore'].includes(value.operation) || typeof value.runtimeId !== 'string'
13
+ || !/^[a-f0-9-]{36}$/.test(value.lease)) throw Error('HISTORY_STATE_INVALID');
14
+ return value;
15
+ }
16
+ export function writeHistoryState(root, value) {
17
+ writeHistoryBytes(root, 'history/active.json', JSON.stringify({ ...value, updatedAt: Date.now() }));
18
+ }
19
+ export function historyPending(root) {
20
+ const state = readHistoryState(root);
21
+ return state !== null && !TERMINAL_HISTORY_PHASES.includes(state.phase);
22
+ }
23
+ export function publicHistoryState(state) {
24
+ if (!state) return { phase: 'IDLE' };
25
+ return { id: state.id, phase: state.phase, operation: state.operation, counts: state.counts,
26
+ archiveId: state.archiveId, code: state.code, updatedAt: state.updatedAt };
27
+ }
package/src/hub/index.mjs CHANGED
@@ -30,7 +30,7 @@ import { addContextReferences, buildWorkspaceTask, isSafeBranchName, loadWorkspa
30
30
  import { buildExtensionContract } from '../extension-contract.mjs';
31
31
  import { cleanupIsolatedWorkspace, createIsolatedWorkspace } from '../workspace-isolation.mjs';
32
32
  import { assessWorkspaceReadiness } from '../workspace-readiness.mjs';
33
- import { buildHubExecutionRows } from '../config-readiness.mjs';
33
+ import { buildConfigReadinessMatrix } from '../config-readiness.mjs';
34
34
  import { localRequestCore, originLoopback } from '../local-request-guard.mjs';
35
35
  import { raceWaiters } from '../removable-waiter.mjs';
36
36
  import { buildProviderInventory } from '../provider-inventory.mjs';
@@ -273,6 +273,38 @@ export function buildProviderMigrationDeclarations(profileDeclarations = [], set
273
273
  ];
274
274
  }
275
275
 
276
+ export function projectCatalogHealth(health, { limit = 32 } = {}) {
277
+ const boundedLimit = Number.isSafeInteger(limit) && limit > 0 ? Math.min(limit, 128) : 32;
278
+ const hints = (Array.isArray(health?.hints) ? health.hints : []).map((hint) => ({
279
+ code: typeof hint?.code === 'string' && hint.code.trim() ? hint.code.slice(0, 128) : 'UNKNOWN',
280
+ level: hint?.level === 'warning' ? 'warning' : 'info',
281
+ }));
282
+ const warnings = hints.filter((hint) => hint.level === 'warning');
283
+ const informational = hints.filter((hint) => hint.level !== 'warning');
284
+ return { hints: [...warnings, ...informational].slice(0, boundedLimit) };
285
+ }
286
+
287
+ export function projectCurrentProviderHealth(healthStore, currentSelections = {}, { limit = 128 } = {}) {
288
+ const boundedLimit = Number.isSafeInteger(limit) && limit > 0 ? Math.min(limit, 256) : 128;
289
+ const selected = [];
290
+ const selectedKeys = new Set();
291
+ for (const route of Object.values(currentSelections ?? {})) {
292
+ if (route?.ok !== true || typeof route.provider !== 'string' || typeof route.model !== 'string') continue;
293
+ const key = `${route.provider}\u0000${route.model}`;
294
+ if (selectedKeys.has(key)) continue;
295
+ const observation = healthStore?.get?.(route.provider, route.model);
296
+ if (observation?.fresh !== true) continue;
297
+ selectedKeys.add(key);
298
+ selected.push(observation);
299
+ }
300
+ const listed = healthStore?.list?.();
301
+ const remaining = (Array.isArray(listed) ? listed : []).filter((entry) => {
302
+ const key = `${entry?.provider ?? ''}\u0000${entry?.model ?? ''}`;
303
+ return !selectedKeys.has(key);
304
+ });
305
+ return [...selected, ...remaining].slice(0, boundedLimit);
306
+ }
307
+
276
308
  async function readProviderInventorySnapshot(hub, ctx, config) {
277
309
  let catalog = { providers: [], harness_default: null };
278
310
  let catalogEvidence = { ok: false, code: 'MODEL_CATALOG_UNAVAILABLE' };
@@ -384,10 +416,12 @@ async function readProviderInventorySnapshot(hub, ctx, config) {
384
416
  lifecycleEvidence,
385
417
  defaultEvidence,
386
418
  });
419
+ const catalogHealth = projectCatalogHealth(catalog?.health);
387
420
  return {
388
421
  ...inventory,
389
422
  migration,
390
423
  catalog_evidence: catalogEvidence,
424
+ catalog_health: catalogHealth,
391
425
  declaration_evidence: declarationEvidence,
392
426
  default_evidence: defaultEvidence,
393
427
  lifecycle_evidence: lifecycleEvidence.ok ? { ok: true } : { ok: false, code: lifecycleEvidence.code },
@@ -1262,6 +1296,8 @@ export async function apply(ctx) {
1262
1296
  // calls resolveHubSpawnPayload (above) to stamp the effective tier onto
1263
1297
  // the spawn payload.
1264
1298
  const disposers = [];
1299
+ const { registerRuntimeHistory } = await import('../history/runtime.mjs');
1300
+ disposers.push(registerRuntimeHistory(ctx));
1265
1301
  const PROVIDER_DELETE_PLAN_TTL_MS = 10 * 60 * 1000;
1266
1302
  const providerDeletePlans = new Map();
1267
1303
  const providerMigrationPlans = new Map();
@@ -1427,20 +1463,82 @@ export async function apply(ctx) {
1427
1463
  if (!isLoopbackRequest(req)) return sendJson(res, 403, { ok: false, error: 'loopback only' });
1428
1464
  if (req.method !== 'GET') return sendJson(res, 405, { ok: false, error: 'GET only' }, { allow: 'GET' });
1429
1465
  const config = normalizeGlobalConfig(hub.getConfig?.() ?? {});
1430
- let catalogStatus = { id: 'provider_catalog', status: 'NOT_RUN', reason_code: 'PROVIDER_MODE_UNKNOWN' };
1431
- if (normalizeWorkerProviderMode(config.worker_provider_mode) === 'deepseek-official') {
1432
- catalogStatus = { id: 'provider_catalog', status: 'SKIP', reason_code: 'PROVIDER_CATALOG_NOT_REQUIRED' };
1433
- } else {
1434
- try {
1435
- await readHarnessModelCatalog({
1436
- llm: ctx.llm ?? ctx.get('llm'),
1437
- getCurrentSelection: () => ctx.get('agentDefaultModel')?.currentSelection?.(),
1466
+ const workerProviderMode = normalizeWorkerProviderMode(config.worker_provider_mode);
1467
+ const runtime = getHubRuntimeIdentity();
1468
+ // One authoritative snapshot drives both catalog and provider-lifecycle
1469
+ // readiness. Keep only bounded, secret-free projections in the matrix.
1470
+ let providerInventoryBody = { ok: false, code: 'PROVIDER_INVENTORY_UNAVAILABLE' };
1471
+ let providerCatalogBody = { ok: false, code: 'PROVIDER_CATALOG_UNAVAILABLE' };
1472
+ let providerInventorySnapshot = null;
1473
+ try {
1474
+ providerInventorySnapshot = await readProviderInventorySnapshot(hub, ctx, config);
1475
+ const records = Array.isArray(providerInventorySnapshot?.records) ? providerInventorySnapshot.records : [];
1476
+ const recoveryTransactions = Array.isArray(providerInventorySnapshot?.recovery_transactions)
1477
+ ? providerInventorySnapshot.recovery_transactions
1478
+ : [];
1479
+ providerInventoryBody = records.length <= 256 && recoveryTransactions.length <= 256
1480
+ ? {
1481
+ ok: true,
1482
+ records,
1483
+ lifecycle_evidence: providerInventorySnapshot.lifecycle_evidence ?? null,
1484
+ declaration_evidence: providerInventorySnapshot.declaration_evidence ?? null,
1485
+ default_evidence: providerInventorySnapshot.default_evidence ?? null,
1486
+ recovery_transactions: recoveryTransactions.map((transaction) => ({
1487
+ provider_id: transaction?.provider_id ?? null,
1488
+ unresolved: transaction?.unresolved === true,
1489
+ })),
1490
+ }
1491
+ : { ok: false, code: 'PROVIDER_INVENTORY_INCOMPLETE' };
1492
+ providerCatalogBody = providerInventorySnapshot.catalog_evidence?.ok === true
1493
+ ? { ok: true, health: providerInventorySnapshot.catalog_health ?? { hints: [] } }
1494
+ : { ok: false, code: providerInventorySnapshot.catalog_evidence?.code ?? 'PROVIDER_CATALOG_UNAVAILABLE' };
1495
+ } catch {
1496
+ providerInventoryBody = { ok: false, code: 'PROVIDER_INVENTORY_UNAVAILABLE' };
1497
+ }
1498
+ const liveJobs = typeof hub.list === 'function' ? hub.list() : [];
1499
+ const boundedJobs = Array.isArray(liveJobs) ? liveJobs.slice(-128) : [];
1500
+ const currentSelections = {};
1501
+ if (workerProviderMode === 'deepseek-official') {
1502
+ currentSelections.worker = { ok: true, provider: 'deepseek-official', model: 'deepseek-v4-flash', source: 'legacy-strict' };
1503
+ currentSelections.worker_escalation = { ok: true, provider: 'deepseek-official', model: 'deepseek-v4-pro', source: 'legacy-strict' };
1504
+ currentSelections.reviewer = { ok: true, provider: 'deepseek-official', model: 'deepseek-v4-pro', source: 'legacy-strict' };
1505
+ } else if (providerInventorySnapshot) {
1506
+ const routingCatalog = {
1507
+ providers: providerInventorySnapshot.records
1508
+ .filter((record) => record?.lifecycle?.catalogued === true)
1509
+ .map((record) => ({
1510
+ id: record.id,
1511
+ name: record.display_name ?? record.id,
1512
+ models: (Array.isArray(record.models) ? record.models : []).map((id) => ({ id })),
1513
+ })),
1514
+ harness_default: providerInventorySnapshot.harness_default ?? null,
1515
+ };
1516
+ const lifecycleState = readProviderLifecycleState();
1517
+ for (const route of [
1518
+ { key: 'worker', role: 'worker', attempt: 0 },
1519
+ { key: 'worker_escalation', role: 'worker', attempt: 1 },
1520
+ { key: 'reviewer', role: 'reviewer', attempt: 0 },
1521
+ ]) {
1522
+ const policy = resolveModelPolicy(config, route.role, { attempt: route.attempt });
1523
+ currentSelections[route.key] = resolveModel({
1524
+ role: route.role,
1525
+ attempt: route.attempt,
1526
+ policy,
1527
+ catalog: routingCatalog,
1528
+ harnessDefault: routingCatalog.harness_default,
1529
+ healthStore: hub.healthStore,
1530
+ healthGate: policy.health_gate ?? config.health_gate ?? config.worker?.model_policy?.health_gate,
1531
+ allowFallback: config.allow_fallback !== false,
1532
+ tombstones: lifecycleState.tombstones,
1438
1533
  });
1439
- catalogStatus = { id: 'provider_catalog', status: 'PASS', reason_code: 'PROVIDER_CATALOG_RESOLVED' };
1440
- } catch {
1441
- catalogStatus = { id: 'provider_catalog', status: 'FAIL', reason_code: 'PROVIDER_CATALOG_UNAVAILABLE' };
1442
1534
  }
1443
1535
  }
1536
+ let providerHealthBody = { ok: false, code: 'PROVIDER_HEALTH_UNAVAILABLE' };
1537
+ try {
1538
+ providerHealthBody = { ok: true, health: projectCurrentProviderHealth(hub.healthStore, currentSelections) };
1539
+ } catch {
1540
+ providerHealthBody = { ok: false, code: 'PROVIDER_HEALTH_UNAVAILABLE' };
1541
+ }
1444
1542
  const profiles = loadRoleProfiles();
1445
1543
  const requestUrl = new URL(req.url, 'http://localhost');
1446
1544
  const requestedWorkspaceId = requestUrl.searchParams.get('workspace_id');
@@ -1449,20 +1547,39 @@ export async function apply(ctx) {
1449
1547
  const workspaceReadiness = requestedWorkspaceId && !requestedContext
1450
1548
  ? { status: 'UNAVAILABLE', reason_code: 'WORKSPACE_CONTEXT_NOT_FOUND' }
1451
1549
  : await assessWorkspaceReadiness({ cwd: requestedContext?.repo_root ?? null });
1452
- const liveJobs = typeof hub.list === 'function' ? hub.list() : [];
1453
- const executionRows = buildHubExecutionRows(liveJobs);
1454
- const readinessMatrix = { rows: [
1455
- { id: 'hub_compatibility', status: 'PASS', reason_code: 'LIVE_CHECK_PASSED' },
1456
- catalogStatus,
1457
- ...executionRows,
1458
- ] };
1550
+ const readinessMatrix = buildConfigReadinessMatrix({
1551
+ hubCompatibility: {
1552
+ reachable: true,
1553
+ compatible: true,
1554
+ runtime_version: runtime.runtime_version ?? null,
1555
+ protocol_version: runtime.protocol_version ?? null,
1556
+ },
1557
+ workerProviderMode,
1558
+ providerCatalogChecked: true,
1559
+ providerCatalogBody,
1560
+ providerInventoryChecked: true,
1561
+ providerInventoryBody,
1562
+ providerHealthChecked: true,
1563
+ providerHealthBody,
1564
+ currentSelections,
1565
+ hubJobsChecked: true,
1566
+ hubJobsBody: { ok: true, jobs: boundedJobs },
1567
+ });
1459
1568
  const readinessSnapshot = buildRuntimeReadinessSnapshot({
1460
- runtime: getHubRuntimeIdentity(),
1569
+ runtime,
1461
1570
  readinessMatrix,
1462
- selections: { worker: liveJobs.find((job) => job?.role === 'worker') ?? null, reviewer: liveJobs.find((job) => job?.role === 'reviewer') ?? null },
1463
- health: hub.healthStore.list(),
1464
- jobs: liveJobs,
1571
+ selections: {
1572
+ worker: currentSelections.worker ?? null,
1573
+ reviewer: currentSelections.reviewer ?? null,
1574
+ },
1575
+ health: Array.isArray(providerHealthBody?.health) ? providerHealthBody.health.slice(0, 128) : [],
1576
+ health_status: providerHealthBody.ok === true ? 'AVAILABLE' : 'UNAVAILABLE',
1577
+ jobs: boundedJobs,
1465
1578
  workspace: workspaceReadiness,
1579
+ enabled_roles: {
1580
+ worker: config.subagents_enabled !== false && config.worker_state !== 'disabled',
1581
+ reviewer: config.subagents_enabled !== false && config.review_state !== 'disabled',
1582
+ },
1466
1583
  });
1467
1584
  const contract = buildExtensionContract({
1468
1585
  config,
@@ -1470,7 +1587,7 @@ export async function apply(ctx) {
1470
1587
  readinessSnapshot,
1471
1588
  workspace: workspaceReadiness,
1472
1589
  profiles,
1473
- runtime: getHubRuntimeIdentity(),
1590
+ runtime,
1474
1591
  });
1475
1592
  return sendJson(res, 200, { ok: true, extension: contract });
1476
1593
  },
@@ -23,7 +23,7 @@ const EMPTY_STATUS = Object.freeze({
23
23
  code: HUB_COMPATIBILITY_CODES.UNREACHABLE,
24
24
  });
25
25
 
26
- let lastProbe = { at: 0, status: EMPTY_STATUS };
26
+ let lastProbe = { at: 0, base: null, status: EMPTY_STATUS };
27
27
 
28
28
  function withReadiness(status) {
29
29
  return {
@@ -32,14 +32,15 @@ function withReadiness(status) {
32
32
  };
33
33
  }
34
34
 
35
- function cacheStatus(status) {
35
+ function cacheStatus(status, base) {
36
36
  const observed = withReadiness(status);
37
- lastProbe = { at: Date.now(), status: observed };
37
+ lastProbe = { at: Date.now(), base, status: observed };
38
38
  return observed;
39
39
  }
40
40
 
41
- async function fetchJson(path) {
42
- const res = await fetch(`${API}${path}`, { signal: AbortSignal.timeout(800) });
41
+ async function fetchJson(path, base = BASE) {
42
+ const api = `${String(base).replace(/\/$/, '')}/_dsh/dsh-crew`;
43
+ const res = await fetch(`${api}${path}`, { signal: AbortSignal.timeout(800) });
43
44
  let body;
44
45
  try { body = await res.json(); } catch { body = null; }
45
46
  return { res, body };
@@ -53,10 +54,11 @@ async function fetchJson(path) {
53
54
  * lacks /runtime is therefore reachable-but-incompatible instead of looking
54
55
  * offline or being silently treated as current.
55
56
  */
56
- export async function hubStatus({ force = false } = {}) {
57
- if (!force && Date.now() - lastProbe.at < 10_000) return lastProbe.status;
57
+ export async function hubStatus({ force = false, base = BASE } = {}) {
58
+ const normalizedBase = String(base).replace(/\/$/, '');
59
+ if (!force && lastProbe.base === normalizedBase && Date.now() - lastProbe.at < 10_000) return lastProbe.status;
58
60
  try {
59
- const ping = await fetchJson('/ping');
61
+ const ping = await fetchJson('/ping', normalizedBase);
60
62
  if (!ping.res.ok) {
61
63
  return cacheStatus({
62
64
  ...EMPTY_STATUS,
@@ -64,13 +66,13 @@ export async function hubStatus({ force = false } = {}) {
64
66
  code: HUB_COMPATIBILITY_CODES.HTTP_ERROR,
65
67
  http_status: ping.res.status,
66
68
  endpoint: 'ping',
67
- });
69
+ }, normalizedBase);
68
70
  }
69
71
  if (ping.body?.service !== 'dsh-crew-hub') {
70
- return cacheStatus(evaluateHubHandshake(ping.body));
72
+ return cacheStatus(evaluateHubHandshake(ping.body), normalizedBase);
71
73
  }
72
74
 
73
- const runtime = await fetchJson('/runtime');
75
+ const runtime = await fetchJson('/runtime', normalizedBase);
74
76
  if (runtime.res.status === 404) {
75
77
  return cacheStatus({
76
78
  ...EMPTY_STATUS,
@@ -78,7 +80,7 @@ export async function hubStatus({ force = false } = {}) {
78
80
  service: 'dsh-crew-hub',
79
81
  code: HUB_COMPATIBILITY_CODES.PROTOCOL_MISSING,
80
82
  endpoint: 'runtime',
81
- });
83
+ }, normalizedBase);
82
84
  }
83
85
  if (!runtime.res.ok) {
84
86
  return cacheStatus({
@@ -88,11 +90,11 @@ export async function hubStatus({ force = false } = {}) {
88
90
  code: HUB_COMPATIBILITY_CODES.HTTP_ERROR,
89
91
  http_status: runtime.res.status,
90
92
  endpoint: 'runtime',
91
- });
93
+ }, normalizedBase);
92
94
  }
93
- return cacheStatus(evaluateHubHandshake(runtime.body, { strictProduction: true }));
95
+ return cacheStatus(evaluateHubHandshake(runtime.body, { strictProduction: true }), normalizedBase);
94
96
  } catch {
95
- return cacheStatus(EMPTY_STATUS);
97
+ return cacheStatus(EMPTY_STATUS, normalizedBase);
96
98
  }
97
99
  }
98
100
 
@@ -0,0 +1,71 @@
1
+ import { mkdtempSync, mkdirSync, lstatSync, openSync, readSync, closeSync, renameSync, rmSync } from 'node:fs';
2
+ import { basename, dirname, isAbsolute, join } from 'node:path';
3
+
4
+ export async function downloadImageBytes(url, { fetchImpl = globalThis.fetch, timeoutMs = 60_000, maxBytes = 64 * 1024 * 1024 } = {}) {
5
+ if (!['http:', 'https:'].includes(new URL(url).protocol)) throw new Error('unsupported image URL protocol');
6
+ const controller = new AbortController();
7
+ let reader;
8
+ let timer;
9
+ const timeout = new Promise((_, reject) => {
10
+ timer = setTimeout(() => {
11
+ controller.abort();
12
+ reject(new Error('image download timed out'));
13
+ }, timeoutMs);
14
+ });
15
+ const download = async () => {
16
+ const response = await fetchImpl(url, { signal: controller.signal });
17
+ controller.signal.throwIfAborted();
18
+ if (!response.ok) throw new Error(`image download failed: HTTP ${response.status}`);
19
+ if (Number(response.headers.get('content-length')) > maxBytes) throw new Error('image download exceeds size limit');
20
+ if (!response.body) throw new Error('image download has no body');
21
+ reader = response.body.getReader();
22
+ const chunks = [];
23
+ let bytes = 0;
24
+ while (true) {
25
+ const { done, value } = await reader.read();
26
+ controller.signal.throwIfAborted();
27
+ if (done) break;
28
+ bytes += value.byteLength;
29
+ if (bytes > maxBytes) throw new Error('image download exceeds size limit');
30
+ chunks.push(Buffer.from(value));
31
+ }
32
+ return Buffer.concat(chunks, bytes);
33
+ };
34
+ try { return await Promise.race([download(), timeout]); }
35
+ finally {
36
+ clearTimeout(timer);
37
+ controller.abort();
38
+ if (reader) void reader.cancel().catch(() => {});
39
+ }
40
+ }
41
+
42
+ function validateImage(file) {
43
+ const info = lstatSync(file);
44
+ if (!info.isFile() || info.size === 0 || info.size > 64 * 1024 * 1024) throw new Error('generated image has invalid type or size');
45
+ const head = Buffer.alloc(32);
46
+ const fd = openSync(file, 'r');
47
+ let length;
48
+ try { length = readSync(fd, head, 0, head.length, 0); } finally { closeSync(fd); }
49
+ const bytes = head.subarray(0, length);
50
+ const png = bytes.subarray(0, 8).equals(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]));
51
+ const jpeg = bytes.length >= 3 && bytes[0] === 255 && bytes[1] === 216 && bytes[2] === 255;
52
+ const gif = ['GIF87a', 'GIF89a'].includes(bytes.subarray(0, 6).toString('ascii'));
53
+ const webp = bytes.subarray(0, 4).toString('ascii') === 'RIFF' && bytes.subarray(8, 12).toString('ascii') === 'WEBP';
54
+ if (!png && !jpeg && !gif && !webp) throw new Error('generated output does not have a supported image signature');
55
+ }
56
+
57
+ /** Stage each request separately; an existing destination never proves success. */
58
+ export async function withGeneratedImageOutput(outputPath, generate) {
59
+ if (!isAbsolute(outputPath)) throw new Error('output_path must be absolute');
60
+ mkdirSync(dirname(outputPath), { recursive: true });
61
+ const stage = mkdtempSync(join(dirname(outputPath), '.dsh-image-'));
62
+ const stagedFile = join(stage, basename(outputPath));
63
+ try {
64
+ await generate(stagedFile);
65
+ validateImage(stagedFile);
66
+ renameSync(stagedFile, outputPath);
67
+ return outputPath;
68
+ } finally {
69
+ rmSync(stage, { recursive: true, force: true });
70
+ }
71
+ }