@ran-sh/dsh-crew 0.5.6 → 1.0.0

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 (47) hide show
  1. package/README.md +20 -7
  2. package/README.zh.md +18 -7
  3. package/docs/installation.md +1 -1
  4. package/docs/ui-surfaces.md +65 -46
  5. package/lib/client.js +565 -28
  6. package/official-web-bridge/lib/client.js +565 -28
  7. package/package.json +1 -1
  8. package/src/client/collapsible-sections.mjs +3 -2
  9. package/src/client/host-readiness.mjs +16 -3
  10. package/src/client/index.tsx +331 -54
  11. package/src/config-readiness.mjs +66 -5
  12. package/src/credential-lifecycle.mjs +73 -0
  13. package/src/credential-purge-adapters.mjs +127 -0
  14. package/src/credential-purge-state.mjs +59 -0
  15. package/src/credential-reference-inventory.mjs +78 -0
  16. package/src/dsh-cli-runtime.mjs +36 -9
  17. package/src/extension-contract.mjs +16 -10
  18. package/src/hub/index.mjs +1004 -438
  19. package/src/hub-client.mjs +25 -5
  20. package/src/hub-compatibility.mjs +23 -7
  21. package/src/install/install.mjs +4 -4
  22. package/src/install/npx-lifecycle.mjs +495 -134
  23. package/src/job-contracts.mjs +27 -6
  24. package/src/jobs.mjs +2 -4
  25. package/src/local-request-guard.mjs +60 -0
  26. package/src/mcp-runtime.mjs +7 -5
  27. package/src/model-routing.mjs +141 -49
  28. package/src/multimodal.mjs +0 -0
  29. package/src/official-web-bridge.mjs +248 -95
  30. package/src/policy.mjs +62 -31
  31. package/src/provider-config-scrub.mjs +71 -0
  32. package/src/provider-delete-adapters.mjs +451 -0
  33. package/src/provider-desired-state.mjs +37 -0
  34. package/src/provider-health.mjs +124 -0
  35. package/src/provider-inventory.mjs +132 -0
  36. package/src/provider-lifecycle-state.mjs +95 -0
  37. package/src/provider-lifecycle.mjs +214 -0
  38. package/src/provider-profile-store.mjs +171 -0
  39. package/src/readiness-matrix.mjs +11 -5
  40. package/src/removable-waiter.mjs +29 -0
  41. package/src/runtime-identity.mjs +66 -21
  42. package/src/runtime-readiness-snapshot.mjs +87 -0
  43. package/src/server.mjs +64 -21
  44. package/src/status-shard.mjs +13 -2
  45. package/src/workflow-runtime.mjs +44 -10
  46. package/windows/start-dsh-crew.cmd +3 -3
  47. package/windows/start-dsh-crew.ps1 +49 -5
package/src/hub/index.mjs CHANGED
@@ -3,8 +3,9 @@
3
3
  // Web UI session list), exposes a loopback jobs API for the CC/Codex MCP shim,
4
4
  // and serves the one-click installer endpoints for the settings page.
5
5
 
6
- import { randomUUID } from 'node:crypto';
7
- import { join, isAbsolute } from 'node:path';
6
+ import { randomUUID } from 'node:crypto';
7
+ import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs';
8
+ import { join, isAbsolute } from 'node:path';
8
9
  import { homedir } from 'node:os';
9
10
  import { createShardWriter, readMergedStatus } from '../status-shard.mjs';
10
11
  import {
@@ -20,16 +21,29 @@ import { buildDirectSelectionTrace, resolveWorkerModel, resolveModel } from '../
20
21
  import { readHarnessModelCatalog } from '../model-catalog.mjs';
21
22
  import { appendDeliveryInstructions, parseDeliveryReport, formatDeliveryMetadata } from '../delivery.mjs';
22
23
  import { captureWorkspaceBaseline, captureWorkspaceDiff, NOT_A_GIT_REPOSITORY } from '../workspace-audit.mjs';
23
- import { applyWorkspaceEvidence, buildOutcome, JOB_PHASES } from '../workflow.mjs';
24
- import { boundedMachineCodeFromError } from '../structured-error-code.mjs';
25
- import { createCanonicalJobEvent, projectWorkflowView } from '../job-contracts.mjs';
26
- import { getHubRuntimeIdentity } from '../runtime-identity.mjs';
27
- import { loadRoleProfiles, resolveRoleProfile, saveRoleProfiles } from '../role-profiles.mjs';
28
- import { addContextReferences, buildWorkspaceTask, isSafeBranchName, loadWorkspaceContexts, resolveWorkspaceContext, saveWorkspaceContexts } from '../workspace-context.mjs';
29
- import { buildExtensionContract } from '../extension-contract.mjs';
30
- import { cleanupIsolatedWorkspace, createIsolatedWorkspace } from '../workspace-isolation.mjs';
31
- import { assessWorkspaceReadiness } from '../workspace-readiness.mjs';
24
+ import { applyWorkspaceEvidence, buildOutcome, JOB_PHASES } from '../workflow.mjs';
25
+ import { boundedMachineCodeFromError } from '../structured-error-code.mjs';
26
+ import { createCanonicalJobEvent, projectWorkflowView } from '../job-contracts.mjs';
27
+ import { getHubRuntimeIdentity } from '../runtime-identity.mjs';
28
+ import { loadRoleProfiles, resolveRoleProfile, saveRoleProfiles } from '../role-profiles.mjs';
29
+ import { addContextReferences, buildWorkspaceTask, isSafeBranchName, loadWorkspaceContexts, resolveWorkspaceContext, saveWorkspaceContexts } from '../workspace-context.mjs';
30
+ import { buildExtensionContract } from '../extension-contract.mjs';
31
+ import { cleanupIsolatedWorkspace, createIsolatedWorkspace } from '../workspace-isolation.mjs';
32
+ import { assessWorkspaceReadiness } from '../workspace-readiness.mjs';
32
33
  import { buildHubExecutionRows } from '../config-readiness.mjs';
34
+ import { localRequestCore, originLoopback } from '../local-request-guard.mjs';
35
+ import { raceWaiters } from '../removable-waiter.mjs';
36
+ import { buildProviderInventory } from '../provider-inventory.mjs';
37
+ import { inspectProviderProfile, readProviderDeclarations } from '../provider-profile-store.mjs';
38
+ import { normalizeProviderLifecycleState } from '../provider-lifecycle-state.mjs';
39
+ import { createProviderHealthStore } from '../provider-health.mjs';
40
+ import { planProviderDelete } from '../provider-lifecycle.mjs';
41
+ import { createProviderDeleteFileHooks } from '../provider-delete-adapters.mjs';
42
+ import { buildCredentialReferenceInventory } from '../credential-reference-inventory.mjs';
43
+ import { executeCredentialPurge, planCredentialPurge } from '../credential-lifecycle.mjs';
44
+ import { createCredentialPurgeFileHooks } from '../credential-purge-adapters.mjs';
45
+ import { normalizeCredentialPurgeState, recordCredentialPurgeOutcome } from '../credential-purge-state.mjs';
46
+ import { buildRuntimeReadinessSnapshot } from '../runtime-readiness-snapshot.mjs';
33
47
 
34
48
  // policy.mjs is pure (no @deepseek-ai imports, no ctx access), so importing it
35
49
  // here is safe for the profile-realm discipline: it never pulls in package
@@ -91,63 +105,221 @@ const LEGACY_TIER_MODELS = { flash: 'deepseek-v4-flash', pro: 'deepseek-v4-pro'
91
105
  // the profile realm): a valid dispatch role set.
92
106
  const ROLES = { worker: true, reviewer: true };
93
107
  const CONFIG_DIR = join(homedir(), '.config', 'dsh-crew');
108
+ const CREDENTIAL_PURGE_STATE_FILE = join(CONFIG_DIR, 'credential-purge-lifecycle.json');
94
109
 
95
- export function hubCanonicalEvents(job = {}) {
96
- if (!job.id) return [];
97
- const role = job.role === 'reviewer' ? 'reviewer' : 'worker';
98
- const atStart = job.startedAt ?? null;
99
- const atEnd = job.endedAt ?? atStart;
110
+ function readProviderLifecycleState() {
111
+ try {
112
+ return normalizeProviderLifecycleState(JSON.parse(readFileSync(join(CONFIG_DIR, 'provider-lifecycle.json'), 'utf8')));
113
+ } catch {
114
+ return normalizeProviderLifecycleState();
115
+ }
116
+ }
117
+
118
+ function readCredentialPurgeState() {
119
+ try { return normalizeCredentialPurgeState(JSON.parse(readFileSync(CREDENTIAL_PURGE_STATE_FILE, 'utf8'))); } catch { return normalizeCredentialPurgeState(); }
120
+ }
121
+
122
+ function writeCredentialPurgeState(state) {
123
+ mkdirSync(CONFIG_DIR, { recursive: true });
124
+ const temp = `${CREDENTIAL_PURGE_STATE_FILE}.${process.pid}.${Date.now()}.tmp`;
125
+ try {
126
+ writeFileSync(temp, JSON.stringify(normalizeCredentialPurgeState(state), null, 2) + '\n', 'utf8');
127
+ renameSync(temp, CREDENTIAL_PURGE_STATE_FILE);
128
+ } catch (error) {
129
+ try { rmSync(temp, { force: true }); } catch {}
130
+ throw error;
131
+ }
132
+ }
133
+
134
+ function providerInventoryPolicy(config) {
135
+ return {
136
+ worker: config?.worker?.model_policy ?? {
137
+ priority: config?.flash_model_priority,
138
+ escalation_priority: config?.pro_model_priority,
139
+ },
140
+ reviewer: config?.review?.model_policy ?? {
141
+ priority: config?.pro_model_priority,
142
+ escalation_priority: [],
143
+ },
144
+ };
145
+ }
146
+
147
+ async function readProviderInventorySnapshot(hub, ctx, config) {
148
+ let catalog = { providers: [], harness_default: null };
149
+ try {
150
+ catalog = await readHarnessModelCatalog({
151
+ llm: ctx.llm ?? ctx.get('llm'),
152
+ getCurrentSelection: () => ctx.get('agentDefaultModel')?.currentSelection?.(),
153
+ });
154
+ } catch {}
155
+ const profileFile = join(CONFIG_DIR, 'harness', 'profiles', 'dsh-crew', 'cordis.patch.yml');
156
+ let declarations = [];
157
+ try {
158
+ if (existsSync(profileFile)) {
159
+ const source = readFileSync(profileFile, 'utf8');
160
+ const parsed = readProviderDeclarations(source, { file: 'harness/profiles/dsh-crew/cordis.patch.yml' });
161
+ if (parsed.ok) declarations = parsed.declarations;
162
+ }
163
+ } catch {}
164
+ const lifecycle = readProviderLifecycleState();
165
+ const credentialPurge = readCredentialPurgeState();
166
+ const inventory = buildProviderInventory({
167
+ catalog,
168
+ declarations,
169
+ policy: providerInventoryPolicy(config),
170
+ tombstones: lifecycle.tombstones,
171
+ activeJobs: hub.list(),
172
+ });
173
+ return {
174
+ ...inventory,
175
+ credential_history_refs: Object.values(lifecycle.transactions ?? {})
176
+ .flatMap((transaction) => Array.isArray(transaction?.credential_refs) ? transaction.credential_refs : []),
177
+ credential_purged_refs: Object.keys(credentialPurge.purged),
178
+ credential_purge_unverified: Object.entries(credentialPurge.unverified).map(([referenceId, record]) => ({
179
+ reference_id: referenceId,
180
+ transaction_id: record.transaction_id,
181
+ state: record.state,
182
+ })),
183
+ lifecycle_transactions: Object.entries(lifecycle.transactions ?? {}).slice(-64).map(([transactionId, transaction]) => ({
184
+ transaction_id: transactionId,
185
+ provider_id: transaction.provider_id,
186
+ state: transaction.state,
187
+ ...(transaction.expected_revision ? { expected_revision: transaction.expected_revision } : {}),
188
+ })),
189
+ };
190
+ }
191
+
192
+ function readProviderProfileRevision() {
193
+ const profileFile = join(CONFIG_DIR, 'harness', 'profiles', 'dsh-crew', 'cordis.patch.yml');
194
+ try {
195
+ if (!existsSync(profileFile)) return { ok: false, code: 'PROVIDER_PROFILE_SCHEMA_UNSUPPORTED' };
196
+ const parsed = inspectProviderProfile(readFileSync(profileFile, 'utf8'));
197
+ return parsed.ok ? { ok: true, revision: parsed.revision } : { ok: false, code: parsed.code };
198
+ } catch {
199
+ return { ok: false, code: 'PROVIDER_PROFILE_SCHEMA_UNSUPPORTED' };
200
+ }
201
+ }
202
+
203
+ export function hubCanonicalEvents(job = {}) {
204
+ if (!job.id) return [];
205
+ const role = job.role === 'reviewer' ? 'reviewer' : 'worker';
206
+ const atStart = job.startedAt ?? null;
207
+ const atEnd = job.endedAt ?? atStart;
100
208
  const definitions = [
101
209
  ['job.created', atStart, { client_job_id: job.client_job_id ?? null }],
102
210
  ['job.started', atStart, {}],
211
+ ['runtime.bound', atStart, {
212
+ execution_plane: job.execution_context?.execution_plane ?? null,
213
+ profile: job.execution_context?.profile ?? null,
214
+ listen_port: Number.isInteger(job.execution_context?.listen_port) ? job.execution_context.listen_port : null,
215
+ runtime_id: job.execution_context?.runtime_id ?? null,
216
+ }],
103
217
  ['model.selected', atStart, { provider: job.provider ?? null, model: job.model ?? null, source: job.selection_source ?? null }],
218
+ ['model.admitted', atStart, {
219
+ provider: job.provider ?? null,
220
+ model: job.model ?? null,
221
+ health_state: job.health_state ?? 'unprobed',
222
+ reason_code: job.health_reason_code ?? null,
223
+ observed_at: Number.isFinite(job.health_observed_at) ? job.health_observed_at : null,
224
+ }],
104
225
  [role === 'reviewer' ? 'review.started' : 'worker.started', atStart, { run_id: job.id }],
226
+ ['agent.created', atStart, { run_id: job.id }],
105
227
  ];
106
- if (job.status !== 'running') {
107
- definitions.push([
108
- role === 'reviewer' ? 'review.completed' : 'worker.completed',
109
- atEnd,
110
- role === 'reviewer'
111
- ? { status: job.status ?? null, verdict: job.review?.verdict ?? null }
112
- : { status: job.status ?? null, summary: job.outcome?.task_status ?? null },
113
- ]);
114
- if (job.status === 'done') definitions.push(['job.completed', atEnd, { result_ref: job.id }]);
115
- else if (job.status === 'cancelled') definitions.push(['job.cancelled', atEnd, { reason: 'cancelled' }]);
116
- else definitions.push(['job.failed', atEnd, { error_code: job.error_code ?? null }]);
117
- }
118
- return definitions.map(([type, at, data], index) => createCanonicalJobEvent({
119
- jobId: job.id, type, sequence: index + 1, at, role, attempt: job.attempt ?? 0, data,
120
- }));
121
- }
228
+ if (job.status !== 'running') {
229
+ definitions.push([
230
+ role === 'reviewer' ? 'review.completed' : 'worker.completed',
231
+ atEnd,
232
+ role === 'reviewer'
233
+ ? { status: job.status ?? null, verdict: job.review?.verdict ?? null }
234
+ : { status: job.status ?? null, summary: job.outcome?.task_status ?? null },
235
+ ]);
236
+ if (job.status === 'done') definitions.push(['job.completed', atEnd, { result_ref: job.id }]);
237
+ else if (job.status === 'cancelled') definitions.push(['job.cancelled', atEnd, { reason: 'cancelled' }]);
238
+ else definitions.push(['job.failed', atEnd, { error_code: job.error_code ?? null }]);
239
+ }
240
+ return definitions.map(([type, at, data], index) => createCanonicalJobEvent({
241
+ jobId: job.id, type, sequence: index + 1, at, role, attempt: job.attempt ?? 0, data,
242
+ }));
243
+ }
244
+
245
+ // ---------- job registry ----------
122
246
 
123
- // ---------- job registry ----------
124
-
125
247
  export function applyHubWorkspaceEvidence({ outcome, workspaceDiff, allowNoChanges = false, isolation = 'shared', role = 'worker' } = {}) {
126
- const changes = workspaceDiff?.changes ?? {};
127
- const hasChanges = ['modified', 'deleted', 'renamed', 'untracked']
128
- .some((key) => Array.isArray(changes[key]) && changes[key].length > 0);
129
- const evidenceAvailable = workspaceDiff?.kind === 'git' && workspaceDiff.dirtyBaseline !== true;
130
- return applyWorkspaceEvidence(outcome, {
131
- evidenceAvailable,
132
- hasChanges,
133
- allowNoChanges: allowNoChanges === true && isolation === 'worktree',
134
- requireNoChangeAuthorization: role === 'worker',
248
+ const changes = workspaceDiff?.changes ?? {};
249
+ const hasChanges = ['modified', 'deleted', 'renamed', 'untracked']
250
+ .some((key) => Array.isArray(changes[key]) && changes[key].length > 0);
251
+ const evidenceAvailable = workspaceDiff?.kind === 'git' && workspaceDiff.dirtyBaseline !== true;
252
+ return applyWorkspaceEvidence(outcome, {
253
+ evidenceAvailable,
254
+ hasChanges,
255
+ allowNoChanges: allowNoChanges === true && isolation === 'worktree',
256
+ requireNoChangeAuthorization: role === 'worker',
135
257
  });
136
258
  }
137
-
138
- // Exported for the unit tests (test/hub-windows.test.mjs); instantiation
259
+
260
+ /**
261
+ * Build the explicit provider callability probe used by the 3210 lifecycle
262
+ * route. It performs one token-bounded Harness stream through the same llm
263
+ * runtime used by WorkerRegistry; no credential values are read or returned.
264
+ */
265
+ export function createProviderProbe(ctx) {
266
+ const llm = ctx?.llm ?? ctx?.get?.('llm');
267
+ if (!llm || typeof llm.stream !== 'function') return null;
268
+ return async ({ provider, model, signal }) => {
269
+ try {
270
+ const stream = llm.stream({
271
+ provider,
272
+ model,
273
+ messages: [userMessage('dsh-crew provider probe')],
274
+ maxTokens: 1,
275
+ temperature: 0,
276
+ signal,
277
+ });
278
+ for await (const chunk of stream) {
279
+ if (chunk?.type !== 'finish') continue;
280
+ const reason = chunk.reason;
281
+ if (reason?.kind === 'error' || reason?.kind === 'aborted') {
282
+ return { ok: false, error: reason.failure ?? { code: `PROVIDER_PROBE_${String(reason.kind).toUpperCase()}` } };
283
+ }
284
+ return { ok: true };
285
+ }
286
+ return { ok: false, error: { code: 'PROVIDER_PROBE_NO_FINISH' } };
287
+ } catch (error) {
288
+ return { ok: false, error };
289
+ }
290
+ };
291
+ }
292
+
293
+ /** Pick the first configured model for a provider, falling back to its live catalog. */
294
+ export function selectProviderProbeModel({ providerId, record, config } = {}) {
295
+ const advertised = new Set(Array.isArray(record?.models) ? record.models.filter((model) => typeof model === 'string') : []);
296
+ if (advertised.size === 0) return null;
297
+ const priorities = [
298
+ ...(Array.isArray(config?.worker?.model_policy?.priority) ? config.worker.model_policy.priority : []),
299
+ ...(Array.isArray(config?.flash_model_priority) ? config.flash_model_priority : []),
300
+ ...(Array.isArray(config?.review?.model_policy?.priority) ? config.review.model_policy.priority : []),
301
+ ...(Array.isArray(config?.pro_model_priority) ? config.pro_model_priority : []),
302
+ ];
303
+ for (const ref of priorities) {
304
+ if (ref?.provider === providerId && advertised.has(ref.model)) return ref.model;
305
+ }
306
+ return [...advertised][0];
307
+ }
308
+
309
+ // Exported for the unit tests (test/hub-windows.test.mjs); instantiation
139
310
  // needs only a duck-typed ctx, so spawn()'s path guard is testable without a
140
311
  // live DSH host.
141
- export class WorkerRegistry { constructor(ctx) {
142
- this.ctx = ctx;
143
- this.jobs = new Map();
144
- this.nextId = 1;
145
- this.shard = createShardWriter('hub');
146
- }
312
+ export class WorkerRegistry { constructor(ctx) {
313
+ this.ctx = ctx;
314
+ this.jobs = new Map();
315
+ this.nextId = 1;
316
+ this.shard = createShardWriter('hub');
317
+ this.healthStore = createProviderHealthStore();
318
+ }
147
319
 
148
320
  view(job, withResult = false) {
149
321
  const v = {
150
- id: job.id, client_job_id: job.client_job_id ?? null, sessionId: job.sessionId, role: job.role ?? 'worker', attempt: job.attempt ?? 0,
322
+ id: job.id, client_job_id: job.client_job_id ?? null, sessionId: job.sessionId, role: job.role ?? 'worker', attempt: job.attempt ?? 0,
151
323
  tier: job.tier, provider: job.provider, model: job.model,
152
324
  selection_source: job.selection_source,
153
325
  selection_trace: job.selection_trace ?? null,
@@ -156,31 +328,32 @@ export class WorkerRegistry { constructor(ctx) {
156
328
  cwd: job.cwd, turn: job.turn, step: job.step, currentTool: job.currentTool,
157
329
  phase: job.phase ?? null,
158
330
  toolCalls: job.toolCalls, tokens: job.tokens, mode: 'hub',
159
- startedAt: job.startedAt, endedAt: job.endedAt,
160
- isolation: job.isolation ?? 'shared', workspace_branch: job.workspace_branch ?? null,
161
- delivery_complete: !!job.delivery_complete,
162
- allow_no_changes: job.allow_no_changes === true,
163
- task_status: job.outcome?.task_status ?? null,
164
- workspace_evidence_ok: job.outcome?.workspace_evidence_ok ?? null,
165
- review_verdict: job.review?.verdict ?? null,
331
+ startedAt: job.startedAt, endedAt: job.endedAt,
332
+ isolation: job.isolation ?? 'shared', workspace_branch: job.workspace_branch ?? null,
333
+ delivery_complete: !!job.delivery_complete,
334
+ allow_no_changes: job.allow_no_changes === true,
335
+ task_status: job.outcome?.task_status ?? null,
336
+ workspace_evidence_ok: job.outcome?.workspace_evidence_ok ?? null,
337
+ review_verdict: job.review?.verdict ?? null,
166
338
  workspace_diff_available: !!job.workspaceDiff && job.workspaceDiff.kind === 'git',
167
339
  workspace_retained: job.workspace_retained === true,
168
340
  cleanup_warning: job.cleanup_warning ?? null,
169
341
  profile_id: job.profile_id ?? null,
170
342
  workspace_context: job.workspace_context ?? null,
343
+ execution_context: job.execution_context ?? null,
171
344
  event_cursor: hubCanonicalEvents(job).at(-1)?.sequence ?? 0,
172
345
  };
173
346
  if (withResult) {
174
347
  v.result = job.result; v.error = job.error; v.stopReason = job.stopReason;
175
348
  v.reasonDetail = job.reasonDetail;
176
349
  v.delivery = job.delivery_metadata ?? null;
177
- v.delivery_missing = job.delivery_missing ?? [];
178
- v.outcome = job.outcome ?? null;
179
- v.review = job.review ?? null;
350
+ v.delivery_missing = job.delivery_missing ?? [];
351
+ v.outcome = job.outcome ?? null;
352
+ v.review = job.review ?? null;
180
353
  v.workspace_diff = job.workspaceDiff ?? null;
181
- v.workspace_baseline_dirty = !!job.workspaceDiff?.dirtyBaseline;
182
- v.canonical_events = hubCanonicalEvents(job);
183
- }
354
+ v.workspace_baseline_dirty = !!job.workspaceDiff?.dirtyBaseline;
355
+ v.canonical_events = hubCanonicalEvents(job);
356
+ }
184
357
  return v;
185
358
  }
186
359
 
@@ -200,7 +373,7 @@ export class WorkerRegistry { constructor(ctx) {
200
373
  * `role` (worker | reviewer) records who does the work; `tier` remains the
201
374
  * legacy model-class slot. Reviewer-role jobs always use the pro slot.
202
375
  */
203
- async spawn({ task, tier = 'flash', role, attempt = 0, effort = 'max', cwd, source = 'api', preset, delivery = 'coding', client_job_id, requested_isolation, workspace_branch, timeout_seconds, profile_id, workspace_context, allow_no_changes }) {
376
+ async spawn({ task, tier = 'flash', role, attempt = 0, effort = 'max', cwd, source = 'api', preset, delivery = 'coding', client_job_id, requested_isolation, workspace_branch, timeout_seconds, profile_id, workspace_context, allow_no_changes, ingress = 'direct-3210' }) {
204
377
  // role is only honored when the caller explicitly names it; a legacy
205
378
  // tier-only spawn (role === undefined) keeps the exact v0.1 resolution.
206
379
  const hasRole = role === 'worker' || role === 'reviewer';
@@ -219,7 +392,9 @@ export class WorkerRegistry { constructor(ctx) {
219
392
  // Provider routing: follow-dsh reads the DSH Models selection live; the
220
393
  // default deepseek-official keeps legacy setups unchanged. Re-resolved on
221
394
  // every spawn so a Models change takes effect on the next worker.
222
- const cfg = normalizeGlobalConfig(this.getConfig?.() ?? {});
395
+ const cfg = normalizeGlobalConfig(this.getConfig?.() ?? {});
396
+ const lifecycleState = readProviderLifecycleState();
397
+ const healthGate = cfg.health_gate ?? cfg.worker?.model_policy?.health_gate;
223
398
  const workerProviderMode = normalizeWorkerProviderMode(cfg.worker_provider_mode);
224
399
  const getCurrentSelection = () => this.ctx.get('agentDefaultModel')?.currentSelection?.();
225
400
  let selection;
@@ -260,22 +435,30 @@ export class WorkerRegistry { constructor(ctx) {
260
435
  // tier-only spawns resolve through the tier resolver exactly as before.
261
436
  if (hasRole) {
262
437
  const policy = resolveModelPolicy(cfg, effRole, { attempt });
263
- selection = resolveModel({
264
- role: effRole,
265
- attempt,
266
- policy,
267
- catalog,
268
- harnessDefault: catalog.harness_default ?? getCurrentSelection(),
269
- });
438
+ selection = resolveModel({
439
+ role: effRole,
440
+ attempt,
441
+ policy,
442
+ catalog,
443
+ harnessDefault: catalog.harness_default ?? getCurrentSelection(),
444
+ healthStore: this.healthStore,
445
+ healthGate: policy.health_gate ?? healthGate,
446
+ allowFallback: cfg.allow_fallback !== false,
447
+ tombstones: lifecycleState.tombstones,
448
+ });
270
449
  } else {
271
450
  selection = resolveWorkerModel({
272
451
  tier: effTier,
273
452
  priority: cfg[`${effTier}_model_priority`],
274
453
  priorityConfigured: cfg[`${effTier}_model_priority_configured`],
275
454
  fallback: cfg[`${effTier}_model_fallback`],
276
- catalog,
277
- harnessDefault: catalog.harness_default ?? getCurrentSelection(),
278
- traceContext: {
455
+ catalog,
456
+ harnessDefault: catalog.harness_default ?? getCurrentSelection(),
457
+ healthStore: this.healthStore,
458
+ healthGate,
459
+ allowFallback: cfg.allow_fallback !== false,
460
+ tombstones: lifecycleState.tombstones,
461
+ traceContext: {
279
462
  role: effRole,
280
463
  logicalAttempt: attempt,
281
464
  modelClassHint: effTier,
@@ -293,53 +476,80 @@ export class WorkerRegistry { constructor(ctx) {
293
476
  const jobRole = hasRole ? role : (delivery === 'review' || role === 'reviewer' ? 'reviewer' : 'worker');
294
477
  const workerPrompt = appendDeliveryInstructions(task, { tier: effTier, role: jobRole, isReview: delivery === 'review' || jobRole === 'reviewer' });
295
478
 
296
- const id = `hub-${this.nextId++}-${Date.now().toString(36)}`;
297
- let executionCwd = cwd;
298
- let isolatedWorkspace = null;
299
- if ((requested_isolation === 'worktree' && jobRole === 'worker') || requested_isolation === 'readonly') {
300
- const created = await createIsolatedWorkspace({ cwd, jobId: id, baseRevision: workspace_branch });
301
- if (!created.ok) throw Object.assign(new Error(created.error ?? created.reason), { code: created.reason });
302
- executionCwd = created.worktreePath;
303
- isolatedWorkspace = { worktreePath: created.worktreePath, repoRoot: created.repoRoot };
304
- }
305
- const sessionId = `session-${randomUUID()}`;
306
- const job = {
307
- id, client_job_id: client_job_id ?? null, sessionId, role: jobRole, attempt, tier: effTier, provider: selection.provider, model: selection.model,
479
+ const id = `hub-${this.nextId++}-${Date.now().toString(36)}`;
480
+
481
+ // Resolve presets before any side effect (registry entry, worktree): a
482
+ // rejected preset must fail spawn() cleanly instead of leaving a ghost
483
+ // "running" job and a leaked worktree behind.
484
+ const presets = this.ctx.get('agentPresets');
485
+ const wanted = preset ?? (tier === 'flash' ? cfg.preset_flash : cfg.preset_pro);
486
+ const presetId = presets === undefined
487
+ ? undefined
488
+ : (await presets.resolve(!wanted || wanted === 'default' ? undefined : wanted)).id;
489
+
490
+ let executionCwd = cwd;
491
+ let isolatedWorkspace = null;
492
+ if ((requested_isolation === 'worktree' && jobRole === 'worker') || requested_isolation === 'readonly') {
493
+ const created = await createIsolatedWorkspace({ cwd, jobId: id, baseRevision: workspace_branch });
494
+ if (!created.ok) throw Object.assign(new Error(created.error ?? created.reason), { code: created.reason });
495
+ executionCwd = created.worktreePath;
496
+ isolatedWorkspace = { worktreePath: created.worktreePath, repoRoot: created.repoRoot };
497
+ }
498
+ const sessionId = `session-${randomUUID()}`;
499
+ const job = {
500
+ id, client_job_id: client_job_id ?? null, sessionId, role: jobRole, attempt, tier: effTier, provider: selection.provider, model: selection.model,
308
501
  selection_source: selection.source, selection_trace: selection.selection_trace ?? null,
309
502
  effort, reasoning_effort: selection.reasoningEffort,
310
503
  task, source, cwd: executionCwd, requested_cwd: cwd,
311
504
  isolation: isolatedWorkspace ? 'worktree' : 'shared',
505
+ execution_context: (() => {
506
+ const runtime = getHubRuntimeIdentity();
507
+ return {
508
+ execution_plane: runtime.execution_plane,
509
+ profile: runtime.profile,
510
+ listen_port: runtime.listen_port,
511
+ runtime_id: runtime.runtime_id,
512
+ ingress: ingress === 'official-3080' ? 'official-3080' : 'direct-3210',
513
+ };
514
+ })(),
312
515
  allow_no_changes: allow_no_changes === true,
313
- workspace_branch: workspace_branch ?? null, isolatedWorkspace,
314
- profile_id: profile_id ?? null, workspace_context: workspace_context ?? null,
516
+ workspace_branch: workspace_branch ?? null, isolatedWorkspace,
517
+ profile_id: profile_id ?? null, workspace_context: workspace_context ?? null,
315
518
  prompt: workerPrompt, delivery: delivery === 'review' ? 'review' : 'coding',
316
519
  phase: JOB_PHASES.RUNNING,
317
520
  status: 'running', turn: 0, step: 0, currentTool: null, toolCalls: 0,
318
521
  tokens: { input: 0, output: 0, reasoning: 0 },
319
522
  startedAt: new Date().toISOString(), endedAt: null,
320
523
  result: null, error: null, stopReason: null, handle: null, waiters: [],
321
- delivery_complete: false, delivery_missing: [], delivery_metadata: null,
322
- outcome: null,
323
- lastAssistantText: null, handle_dispose_promise: null, disposeHandle: null,
324
- };
325
- const disposeJobHandle = async () => {
326
- if (!job.handle) return;
327
- job.handle_dispose_promise ??= Promise.resolve().then(() => job.handle.dispose());
328
- await job.handle_dispose_promise;
329
- };
330
- job.disposeHandle = disposeJobHandle;
524
+ delivery_complete: false, delivery_missing: [], delivery_metadata: null,
525
+ outcome: null,
526
+ lastAssistantText: null, handle_dispose_promise: null, disposeHandle: null,
527
+ // Abort latch: cancel()/timeout set this the moment the job becomes
528
+ // terminal, even while agents.create() is still pending, so a handle
529
+ // acquired after that point is disposed instead of being driven.
530
+ abortRequested: false,
531
+ abortPromise: null,
532
+ resolveAbort: null,
533
+ };
534
+ job.abortPromise = new Promise((resolve) => { job.resolveAbort = resolve; });
535
+ const disposeJobHandle = async () => {
536
+ if (!job.handle) return;
537
+ if (!job.handle_dispose_promise) {
538
+ try {
539
+ job.handle_dispose_promise = Promise.resolve(job.handle.dispose());
540
+ } catch (error) {
541
+ job.handle_dispose_promise = Promise.reject(error);
542
+ }
543
+ }
544
+ await job.handle_dispose_promise;
545
+ };
546
+ job.disposeHandle = disposeJobHandle;
331
547
  // Read-only pre-run snapshot (async, never blocks dispatch): the audit
332
548
  // only needs the before-state by the time the worker finishes. Non-repos
333
549
  // degrade to { kind:'no-git' } instead of failing the job.
334
- job.baseline = await captureWorkspaceBaseline({ cwd: executionCwd }).catch(() => ({ kind: 'no-git', reason: NOT_A_GIT_REPOSITORY, error: 'workspace audit failed' }));
550
+ job.baseline = await captureWorkspaceBaseline({ cwd: executionCwd }).catch(() => ({ kind: 'no-git', reason: NOT_A_GIT_REPOSITORY, error: 'workspace audit failed' }));
335
551
  this.jobs.set(id, job);
336
552
 
337
- const presets = this.ctx.get('agentPresets');
338
- const wanted = preset ?? (tier === 'flash' ? cfg.preset_flash : cfg.preset_pro);
339
- const presetId = presets === undefined
340
- ? undefined
341
- : (await presets.resolve(!wanted || wanted === 'default' ? undefined : wanted)).id;
342
-
343
553
  const onEvent = (session, event) => {
344
554
  switch (event.type) {
345
555
  case 'step/start':
@@ -355,9 +565,9 @@ export class WorkerRegistry { constructor(ctx) {
355
565
  job.tokens.output += u.outputTokens ?? 0;
356
566
  job.tokens.reasoning += u.reasoningTokens ?? 0;
357
567
  }
358
- const text = (event.data?.message?.content ?? [])
359
- .filter((c) => c?.type === 'text').map((c) => c.text).join('');
360
- if (text) job.lastAssistantText = text;
568
+ const text = (event.data?.message?.content ?? [])
569
+ .filter((c) => c?.type === 'text').map((c) => c.text).join('');
570
+ if (text) job.lastAssistantText = text;
361
571
  break;
362
572
  }
363
573
  case 'turn/end':
@@ -369,9 +579,9 @@ export class WorkerRegistry { constructor(ctx) {
369
579
  };
370
580
 
371
581
  const run = async () => {
372
- const handle = await this.ctx.agents.create({
582
+ const createPromise = this.ctx.agents.create({
373
583
  sessionId,
374
- meta: { cwd: executionCwd, ...(presetId === undefined ? {} : { agentPreset: presetId }) },
584
+ meta: { cwd: executionCwd, ...(presetId === undefined ? {} : { agentPreset: presetId }) },
375
585
  agentOptions: { provider: selection.provider, model: selection.model },
376
586
  setup: async (agentCtx) => {
377
587
  installModelSelection(agentCtx, { current: selection, assembled: undefined });
@@ -383,58 +593,90 @@ export class WorkerRegistry { constructor(ctx) {
383
593
  });
384
594
  },
385
595
  });
596
+ const created = await Promise.race([
597
+ createPromise.then((handle) => ({ handle })),
598
+ job.abortPromise.then(() => ({ aborted: true })),
599
+ ]);
600
+ if (created.aborted) {
601
+ createPromise.then(async (handle) => {
602
+ if (handle && (job.status !== 'running' || job.abortRequested)) {
603
+ job.handle = handle;
604
+ try { await job.disposeHandle(); } catch (error) {
605
+ job.cleanup_warning = `agent cleanup failed: ${error?.message ?? String(error)}`;
606
+ }
607
+ }
608
+ }).catch(() => {});
609
+ return;
610
+ }
611
+ const handle = created.handle;
386
612
  job.handle = handle;
613
+ // The job may have become terminal (cancel/timeout) while create() was
614
+ // pending; the old disposer was a no-op in that window. Dispose the
615
+ // freshly acquired handle and never deliver the prompt.
616
+ if (job.status !== 'running' || job.abortRequested) {
617
+ try { await job.disposeHandle(); } catch (error) {
618
+ job.cleanup_warning = `agent cleanup failed: ${error?.message ?? String(error)}`;
619
+ }
620
+ return;
621
+ }
387
622
  try {
388
623
  // Group the worker session under the workspace of its cwd; create the
389
624
  // workspace when none exists yet (resolveByPath is exact-match, so a
390
625
  // job cwd never inherits a parent directory's workspace).
391
626
  const registry = this.ctx.get('workspaceRegistry');
392
627
  if (registry !== undefined) {
393
- const ws = (await registry.resolveByPath(executionCwd)) ?? (await registry.create(executionCwd));
628
+ const ws = (await registry.resolveByPath(executionCwd)) ?? (await registry.create(executionCwd));
394
629
  await ws.attachSession(sessionId);
395
630
  }
396
631
  } catch (err) {
397
- this.ctx.logger?.warn?.(`dsh-crew: workspace attach failed for ${executionCwd}: ${err?.message ?? err}`);
632
+ this.ctx.logger?.warn?.(`dsh-crew: workspace attach failed for ${executionCwd}: ${err?.message ?? err}`);
398
633
  }
399
634
  await handle.agent.whenIdle();
635
+ // Cancel/timeout may have become terminal while waiting for the initial
636
+ // idle boundary; do not deliver the prompt to a job that is no longer
637
+ // running. The shared finally block still disposes the handle exactly once.
638
+ if (job.status !== 'running' || job.abortRequested) return;
400
639
  handle.agent.followup(userMessage(job.prompt));
401
640
  await handle.agent.whenIdle();
402
- job.result = job.lastAssistantText ?? '';
403
- if (job.status === 'running') job.status = job.stopReason === 'completed' ? 'done' : 'failed';
641
+ job.result = job.lastAssistantText ?? '';
642
+ if (job.status === 'running') job.status = job.stopReason === 'completed' ? 'done' : 'failed';
404
643
  if (job.status === 'failed' && !job.error) job.error = `turn ended: ${job.stopReason ?? 'unknown'}`;
405
644
  await this.ctx.sessions.flush(handle.agent.session);
406
645
  };
407
646
 
408
- const timeoutMs = Number.isInteger(timeout_seconds) && timeout_seconds > 0 ? timeout_seconds * 1000 : null;
409
- if (timeoutMs) {
410
- job.timeoutHandle = setTimeout(async () => {
411
- if (job.status !== 'running') return;
412
- job.status = 'failed';
413
- job.error = `job timed out after ${timeout_seconds}s`;
414
- job.error_code = 'JOB_TIMEOUT';
415
- job.endedAt = new Date().toISOString();
416
- this.publish();
417
- for (const waiter of job.waiters.splice(0)) waiter();
418
- try { await disposeJobHandle(); } catch (error) {
419
- job.cleanup_warning = `agent cleanup failed: ${error?.message ?? String(error)}`;
420
- this.publish();
421
- }
422
- }, timeoutMs);
423
- job.timeoutHandle.unref?.();
424
- }
425
- job.promise = run()
647
+ const timeoutMs = Number.isInteger(timeout_seconds) && timeout_seconds > 0 ? timeout_seconds * 1000 : null;
648
+ if (timeoutMs) {
649
+ job.timeoutHandle = setTimeout(async () => {
650
+ if (job.status !== 'running') return;
651
+ job.abortRequested = true;
652
+ job.status = 'failed';
653
+ job.phase = JOB_PHASES.FAILED;
654
+ job.error = `job timed out after ${timeout_seconds}s`;
655
+ job.error_code = 'JOB_TIMEOUT';
656
+ job.endedAt = new Date().toISOString();
657
+ job.resolveAbort?.();
658
+ this.publish();
659
+ for (const waiter of job.waiters.splice(0)) waiter();
660
+ try { await disposeJobHandle(); } catch (error) {
661
+ job.cleanup_warning = `agent cleanup failed: ${error?.message ?? String(error)}`;
662
+ this.publish();
663
+ }
664
+ }, timeoutMs);
665
+ job.timeoutHandle.unref?.();
666
+ }
667
+ job.promise = run()
426
668
  .catch((err) => {
427
669
  if (job.status === 'running') job.status = 'failed';
428
670
  job.error = job.error ?? (err?.message ?? String(err));
429
- })
430
- .finally(async () => {
431
- if (job.timeoutHandle) clearTimeout(job.timeoutHandle);
432
- job.endedAt = new Date().toISOString();
433
- job.currentTool = null;
434
- let handleCleanupWarning = job.cleanup_warning ?? null;
435
- try { await disposeJobHandle(); } catch (error) {
436
- handleCleanupWarning = `agent cleanup failed: ${error?.message ?? String(error)}`;
437
- }
671
+ })
672
+ .finally(async () => {
673
+ if (job.timeoutHandle) clearTimeout(job.timeoutHandle);
674
+ job.endedAt = new Date().toISOString();
675
+ job.currentTool = null;
676
+ let handleCleanupWarning = job.cleanup_warning ?? null;
677
+ try { await disposeJobHandle(); } catch (error) {
678
+ handleCleanupWarning = `agent cleanup failed: ${error?.message ?? String(error)}`;
679
+ }
438
680
  // Delivery completeness is separate from execution status: a job can
439
681
  // be done yet fail to report Diff/Tests/Risks (or Review sections for
440
682
  // an automatic review). Parse whatever final message the worker
@@ -442,21 +684,21 @@ export class WorkerRegistry { constructor(ctx) {
442
684
  const parsed = parseDeliveryReport(job.result ?? '');
443
685
  job.delivery_complete = parsed.complete;
444
686
  job.delivery_missing = parsed.missing;
445
- job.delivery_metadata = formatDeliveryMetadata(parsed);
446
- if (job.role === 'reviewer') {
447
- const verdictText = String(parsed.sections?.Verdict ?? '').trim().toLowerCase();
448
- const verdict = /^(approve|approved|pass)\b/.test(verdictText)
449
- ? 'approve'
450
- : /request.*chang|chang.*request|reject|needs changes/i.test(verdictText)
451
- ? 'request_changes'
452
- : 'inconclusive';
453
- const lines = (value) => String(value ?? '').split(/\r?\n/).map((line) => line.trim()).filter(Boolean).slice(0, 40);
454
- job.review = {
455
- verdict, status: job.status, delivery_complete: parsed.complete === true,
456
- findings: lines(parsed.sections?.['Review Findings']),
457
- evidence: lines(parsed.sections?.Evidence), risks: lines(parsed.sections?.Risks),
458
- };
459
- }
687
+ job.delivery_metadata = formatDeliveryMetadata(parsed);
688
+ if (job.role === 'reviewer') {
689
+ const verdictText = String(parsed.sections?.Verdict ?? '').trim().toLowerCase();
690
+ const verdict = /^(approve|approved|pass)\b/.test(verdictText)
691
+ ? 'approve'
692
+ : /request.*chang|chang.*request|reject|needs changes/i.test(verdictText)
693
+ ? 'request_changes'
694
+ : 'inconclusive';
695
+ const lines = (value) => String(value ?? '').split(/\r?\n/).map((line) => line.trim()).filter(Boolean).slice(0, 40);
696
+ job.review = {
697
+ verdict, status: job.status, delivery_complete: parsed.complete === true,
698
+ findings: lines(parsed.sections?.['Review Findings']),
699
+ evidence: lines(parsed.sections?.Evidence), risks: lines(parsed.sections?.Risks),
700
+ };
701
+ }
460
702
  // Canonical structured outcome (shared workflow layer) + terminal phase.
461
703
  job.outcome = buildOutcome({
462
704
  result: job.result ?? '',
@@ -467,9 +709,9 @@ export class WorkerRegistry { constructor(ctx) {
467
709
  });
468
710
  job.phase = job.status === 'done' ? JOB_PHASES.COMPLETED : job.status === 'cancelled' ? JOB_PHASES.CANCELLED : JOB_PHASES.FAILED;
469
711
  // Read-only after-snapshot of the workspace: bounded, redacted patch.
470
- job.workspaceDiff = job.baseline.kind === 'git'
471
- ? await captureWorkspaceDiff({ cwd: executionCwd, baseline: job.baseline }).catch(() => ({ kind: 'no-git', reason: NOT_A_GIT_REPOSITORY, error: 'workspace diff failed' }))
472
- : job.baseline;
712
+ job.workspaceDiff = job.baseline.kind === 'git'
713
+ ? await captureWorkspaceDiff({ cwd: executionCwd, baseline: job.baseline }).catch(() => ({ kind: 'no-git', reason: NOT_A_GIT_REPOSITORY, error: 'workspace diff failed' }))
714
+ : job.baseline;
473
715
  job.outcome = applyHubWorkspaceEvidence({
474
716
  outcome: job.outcome,
475
717
  workspaceDiff: job.workspaceDiff,
@@ -477,23 +719,32 @@ export class WorkerRegistry { constructor(ctx) {
477
719
  isolation: job.isolation,
478
720
  role: job.role,
479
721
  });
480
- if (job.role === 'reviewer' && job.review && job.workspaceDiff?.kind === 'git') {
481
- const changes = job.workspaceDiff.changes ?? {};
482
- const mutated = ['modified', 'deleted', 'renamed', 'untracked'].some((key) => Array.isArray(changes[key]) && changes[key].length > 0);
483
- if (mutated) {
484
- job.review.mutated_candidate = true;
485
- job.review.invalidated = true;
486
- job.review.verdict = 'request_changes';
487
- }
488
- }
489
- if (job.isolatedWorkspace) {
490
- const cleanup = await cleanupIsolatedWorkspace(job.isolatedWorkspace).catch((error) => ({ ok: false, error: error?.message ?? String(error) }));
491
- job.workspace_retained = cleanup.ok !== true;
492
- const workspaceCleanupWarning = cleanup.ok === true ? null : cleanup.error ?? 'worktree cleanup failed';
493
- job.cleanup_warning = [handleCleanupWarning, workspaceCleanupWarning].filter(Boolean).join('; ') || null;
494
- } else {
495
- job.cleanup_warning = handleCleanupWarning;
722
+ // Record only sanitized callability evidence. Cancelled jobs are
723
+ // operator actions, not provider health signals.
724
+ if (job.status !== 'cancelled') {
725
+ this.healthStore.record(job.provider, job.model, {
726
+ ok: job.status === 'done',
727
+ observed_at: Date.parse(job.endedAt ?? '') || Date.now(),
728
+ error: { code: job.error_code, message: job.error },
729
+ });
496
730
  }
731
+ if (job.role === 'reviewer' && job.review && job.workspaceDiff?.kind === 'git') {
732
+ const changes = job.workspaceDiff.changes ?? {};
733
+ const mutated = ['modified', 'deleted', 'renamed', 'untracked'].some((key) => Array.isArray(changes[key]) && changes[key].length > 0);
734
+ if (mutated) {
735
+ job.review.mutated_candidate = true;
736
+ job.review.invalidated = true;
737
+ job.review.verdict = 'request_changes';
738
+ }
739
+ }
740
+ if (job.isolatedWorkspace) {
741
+ const cleanup = await cleanupIsolatedWorkspace(job.isolatedWorkspace).catch((error) => ({ ok: false, error: error?.message ?? String(error) }));
742
+ job.workspace_retained = cleanup.ok !== true;
743
+ const workspaceCleanupWarning = cleanup.ok === true ? null : cleanup.error ?? 'worktree cleanup failed';
744
+ job.cleanup_warning = [handleCleanupWarning, workspaceCleanupWarning].filter(Boolean).join('; ') || null;
745
+ } else {
746
+ job.cleanup_warning = handleCleanupWarning;
747
+ }
497
748
  this.publish();
498
749
  for (const w of job.waiters.splice(0)) w();
499
750
  });
@@ -506,66 +757,49 @@ export class WorkerRegistry { constructor(ctx) {
506
757
  const job = this.jobs.get(id);
507
758
  if (!job) return undefined;
508
759
  if (job.status !== 'running') return job;
509
- await Promise.race([
510
- new Promise((res) => job.waiters.push(res)),
511
- timeoutMs > 0 ? new Promise((res) => setTimeout(res, timeoutMs)) : Promise.resolve(),
512
- ]);
760
+ await raceWaiters(job.waiters, { timeoutMs, immediateWithoutTimeout: true });
513
761
  return job;
514
762
  }
515
763
 
516
- async cancel(id) {
764
+ async cancel(id) {
517
765
  const job = this.jobs.get(id);
518
766
  if (!job) return undefined;
519
767
  if (job.status === 'running') {
768
+ job.abortRequested = true;
520
769
  job.status = 'cancelled';
521
770
  job.phase = JOB_PHASES.CANCELLED;
522
771
  job.error = 'cancelled by request';
523
- try { await job.disposeHandle?.(); } catch (error) {
524
- job.cleanup_warning = `agent cleanup failed: ${error?.message ?? String(error)}`;
525
- }
772
+ job.resolveAbort?.();
773
+ for (const waiter of job.waiters.splice(0)) waiter();
774
+ try { await job.disposeHandle?.(); } catch (error) {
775
+ job.cleanup_warning = `agent cleanup failed: ${error?.message ?? String(error)}`;
776
+ }
526
777
  this.publish();
527
778
  }
528
779
  return job;
529
780
  }
530
781
 
531
- async dispose() {
532
- for (const job of this.jobs.values()) {
533
- if (job.status === 'running') {
534
- try { await job.disposeHandle?.(); } catch (error) {
535
- job.cleanup_warning = `agent cleanup failed: ${error?.message ?? String(error)}`;
536
- }
537
- }
538
- }
539
- }
782
+ async dispose() {
783
+ for (const job of this.jobs.values()) {
784
+ if (job.status === 'running') {
785
+ try { await job.disposeHandle?.(); } catch (error) {
786
+ job.cleanup_warning = `agent cleanup failed: ${error?.message ?? String(error)}`;
787
+ }
788
+ }
789
+ }
790
+ }
540
791
  }
541
792
 
542
793
  // ---------- loopback route helpers (pattern from dsh-noema) ----------
543
794
 
544
- function isIpv4Loopback(a) { return /^127(\.\d{1,3}){3}$/.test(a); }
545
- function isLoopbackAddress(address) {
546
- if (!address) return false;
547
- const n = address.toLowerCase().split('%', 1)[0];
548
- if (n === '::1' || isIpv4Loopback(n)) return true;
549
- if (!n.startsWith('::ffff:')) return false;
550
- return isIpv4Loopback(n.slice(7));
795
+ // The common trust core and its Origin policies live in one shared module so
796
+ // the hub and the 3080 bridge cannot drift apart (see local-request-guard.mjs).
797
+ export function isLoopbackRequest(req) {
798
+ if (!localRequestCore(req)) return false;
799
+ const origin = req.headers?.origin;
800
+ if (origin === undefined) return true;
801
+ return originLoopback(origin);
551
802
  }
552
- export function isLoopbackRequest(req) {
553
- if (!isLoopbackAddress(req.socket?.remoteAddress)) return false;
554
- let host;
555
- try { host = new URL(`http://${String(req.headers.host ?? '')}`).hostname.toLowerCase(); } catch { return false; }
556
- if (!(host === 'localhost' || host === '127.0.0.1' || host === '[::1]' || isIpv4Loopback(host))) return false;
557
- const fetchSite = String(req.headers?.['sec-fetch-site'] ?? '').toLowerCase();
558
- if (fetchSite && fetchSite !== 'same-origin' && fetchSite !== 'none') return false;
559
- const origin = req.headers?.origin;
560
- if (origin !== undefined) {
561
- if (typeof origin !== 'string') return false;
562
- let parsed;
563
- try { parsed = new URL(origin); } catch { return false; }
564
- const originHost = parsed.hostname.toLowerCase();
565
- if (!(originHost === 'localhost' || originHost === '127.0.0.1' || originHost === '::1' || originHost === '[::1]' || isIpv4Loopback(originHost))) return false;
566
- }
567
- return true;
568
- }
569
803
  function sendJson(res, status, value, headers = {}) {
570
804
  const body = JSON.stringify(value);
571
805
  res.writeHead(status, {
@@ -607,102 +841,103 @@ async function readBody(req, limit = 64 * 1024) {
607
841
  * Returns { ok: true, payload } (payload.tier = effective tier) or
608
842
  * { ok: false, code, error }.
609
843
  */
610
- export function resolveHubSpawnPayload(payload, getConfig = () => ({}), dependencies = {}) {
611
- const config = normalizeGlobalConfig(getConfig());
612
- const raw = payload ?? {};
613
- const advanced = raw.profile !== undefined || raw.workspace_id !== undefined || raw.workspace !== undefined
614
- || raw.constraints !== undefined || raw.context_refs !== undefined || raw.job_id !== undefined || raw.objective !== undefined;
615
- let normalized = { ...raw };
616
- if (advanced) {
617
- if (raw.workspace !== undefined && (!raw.workspace || typeof raw.workspace !== 'object' || Array.isArray(raw.workspace))) {
618
- return { ok: false, code: 'WORKSPACE_REQUEST_INVALID', error: 'workspace must be an object' };
619
- }
620
- if (raw.constraints !== undefined && (!raw.constraints || typeof raw.constraints !== 'object' || Array.isArray(raw.constraints))) {
621
- return { ok: false, code: 'JOB_CONSTRAINTS_INVALID', error: 'constraints must be an object' };
622
- }
623
- if (raw.workspace?.repo_root !== undefined && typeof raw.workspace.repo_root !== 'string') {
624
- return { ok: false, code: 'WORKSPACE_REQUEST_INVALID', error: 'workspace repo_root must be a string' };
625
- }
626
- if (raw.workspace?.worktree !== undefined && !['auto', 'existing', 'none'].includes(raw.workspace.worktree)) {
627
- return { ok: false, code: 'WORKSPACE_REQUEST_INVALID', error: 'invalid worktree policy' };
628
- }
629
- if (raw.workspace?.branch !== undefined && !isSafeBranchName(raw.workspace.branch)) {
630
- return { ok: false, code: 'WORKSPACE_REQUEST_INVALID', error: 'invalid workspace branch' };
631
- }
632
- if (raw.constraints?.timeout_seconds !== undefined && (!Number.isInteger(raw.constraints.timeout_seconds) || raw.constraints.timeout_seconds < 1 || raw.constraints.timeout_seconds > 7200)) {
633
- return { ok: false, code: 'JOB_CONSTRAINTS_INVALID', error: 'invalid timeout_seconds' };
634
- }
635
- if (raw.constraints?.allow_fallback !== undefined && typeof raw.constraints.allow_fallback !== 'boolean') {
636
- return { ok: false, code: 'JOB_CONSTRAINTS_INVALID', error: 'allow_fallback must be boolean' };
637
- }
638
- if (raw.constraints?.allow_no_changes !== undefined && typeof raw.constraints.allow_no_changes !== 'boolean') {
639
- return { ok: false, code: 'JOB_CONSTRAINTS_INVALID', error: 'allow_no_changes must be boolean' };
640
- }
641
- const profileRegistry = dependencies.profileRegistry ?? loadRoleProfiles();
642
- const workspaceRegistry = dependencies.workspaceRegistry ?? loadWorkspaceContexts();
643
- if (!profileRegistry.ok) return { ok: false, code: 'PROFILE_FILE_INVALID', error: 'role profile registry is invalid' };
644
- if (!workspaceRegistry.ok) return { ok: false, code: 'WORKSPACE_CONTEXT_FILE_INVALID', error: 'workspace registry is invalid' };
645
- const profileRole = raw.profile ? profileRegistry.profiles?.[raw.profile]?.role : undefined;
646
- const requestedRole = raw.role ?? profileRole ?? 'worker';
647
- const resolvedProfile = resolveRoleProfile(profileRegistry, raw.profile, requestedRole);
648
- if (!resolvedProfile.ok) return { ok: false, code: resolvedProfile.code, error: resolvedProfile.code };
649
- const knownContext = raw.workspace_id ? workspaceRegistry.contexts?.[raw.workspace_id] : null;
650
- const cwd = raw.workspace?.repo_root ?? raw.cwd ?? knownContext?.repo_root;
651
- if (!cwd) return { ok: false, code: 'WORKSPACE_CONTEXT_NOT_FOUND', error: 'workspace repo_root or cwd is required' };
652
- const workspace = resolveWorkspaceContext(workspaceRegistry, { workspace_id: raw.workspace_id, cwd });
653
- if (!workspace.ok) return { ok: false, code: workspace.code, error: workspace.code };
654
- const withRefs = addContextReferences(workspace.context, raw.context_refs, { cwd });
655
- if (!withRefs.ok) return { ok: false, code: withRefs.code, error: withRefs.code };
656
- const profile = resolvedProfile.profile;
657
- const worktree = raw.workspace?.worktree;
658
- const requestedIsolation = worktree === 'auto' ? 'worktree'
659
- : worktree === 'existing' || worktree === 'none' ? 'shared'
660
- : profile.isolation;
661
- const objective = raw.objective ?? raw.task;
662
- if (typeof objective !== 'string' || objective.trim() === '') return { ok: false, code: 'JOB_OBJECTIVE_REQUIRED', error: 'task or objective is required' };
663
- if (raw.job_id !== undefined && !/^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/.test(raw.job_id)) {
664
- return { ok: false, code: 'JOB_ID_INVALID', error: 'invalid client job id' };
665
- }
666
- const contextTask = buildWorkspaceTask(objective, withRefs.context);
667
- normalized = {
668
- ...raw,
669
- task: profile.review_strictness === 'strict' && requestedRole === 'reviewer'
670
- ? `${contextTask}\n\nSTRICT REVIEW: fail closed on missing direct code or test evidence.`
671
- : contextTask,
672
- cwd,
673
- role: requestedRole,
674
- client_job_id: raw.job_id ?? null,
675
- requested_isolation: requestedIsolation,
676
- workspace_branch: raw.workspace?.branch ?? withRefs.context?.default_branch ?? null,
677
- timeout_seconds: raw.constraints?.timeout_seconds ?? profile.timeout_seconds,
678
- allow_fallback: raw.constraints?.allow_fallback ?? profile.fallback,
679
- allow_no_changes: raw.constraints?.allow_no_changes === true,
680
- routing: profile.routing,
681
- review_strictness: profile.review_strictness,
682
- profile_id: resolvedProfile.profile_id,
683
- workspace_context: withRefs.context,
684
- };
685
- for (const key of ['objective', 'job_id', 'profile', 'workspace_id', 'workspace', 'constraints', 'context_refs']) delete normalized[key];
686
- }
687
- // v0.2 role-based dispatch: reviewer / worker are gated by their role state,
688
- // and the tier slot is derived from the role (reviewer always → pro).
689
- if (normalized.role === 'worker' || normalized.role === 'reviewer') {
690
- const hint = resolveRoleTierHint(normalized.role, normalized.tier);
691
- if (!hint.ok) return { ok: false, code: hint.code, error: hint.error };
692
- const decision = canDispatchRole(config, normalized.role, true, {});
693
- if (!decision.ok) return { ok: false, code: decision.error.policyCode, error: decision.error.message };
694
- return { ok: true, payload: { ...normalized, role: normalized.role, tier: hint.tier } };
695
- }
696
- const decision = chooseDefaultTier(config, normalized.tier, {});
697
- if (!decision.ok) return { ok: false, code: decision.error.policyCode, error: decision.error.message };
698
- return { ok: true, payload: { ...normalized, tier: decision.tier } };
699
- }
844
+ export function resolveHubSpawnPayload(payload, getConfig = () => ({}), dependencies = {}) {
845
+ const config = normalizeGlobalConfig(getConfig());
846
+ const raw = payload ?? {};
847
+ const advanced = raw.profile !== undefined || raw.workspace_id !== undefined || raw.workspace !== undefined
848
+ || raw.constraints !== undefined || raw.context_refs !== undefined || raw.job_id !== undefined || raw.objective !== undefined;
849
+ let normalized = { ...raw };
850
+ if (advanced) {
851
+ if (raw.workspace !== undefined && (!raw.workspace || typeof raw.workspace !== 'object' || Array.isArray(raw.workspace))) {
852
+ return { ok: false, code: 'WORKSPACE_REQUEST_INVALID', error: 'workspace must be an object' };
853
+ }
854
+ if (raw.constraints !== undefined && (!raw.constraints || typeof raw.constraints !== 'object' || Array.isArray(raw.constraints))) {
855
+ return { ok: false, code: 'JOB_CONSTRAINTS_INVALID', error: 'constraints must be an object' };
856
+ }
857
+ if (raw.workspace?.repo_root !== undefined && typeof raw.workspace.repo_root !== 'string') {
858
+ return { ok: false, code: 'WORKSPACE_REQUEST_INVALID', error: 'workspace repo_root must be a string' };
859
+ }
860
+ if (raw.workspace?.worktree !== undefined && !['auto', 'existing', 'none'].includes(raw.workspace.worktree)) {
861
+ return { ok: false, code: 'WORKSPACE_REQUEST_INVALID', error: 'invalid worktree policy' };
862
+ }
863
+ if (raw.workspace?.branch !== undefined && !isSafeBranchName(raw.workspace.branch)) {
864
+ return { ok: false, code: 'WORKSPACE_REQUEST_INVALID', error: 'invalid workspace branch' };
865
+ }
866
+ if (raw.constraints?.timeout_seconds !== undefined && (!Number.isInteger(raw.constraints.timeout_seconds) || raw.constraints.timeout_seconds < 1 || raw.constraints.timeout_seconds > 7200)) {
867
+ return { ok: false, code: 'JOB_CONSTRAINTS_INVALID', error: 'invalid timeout_seconds' };
868
+ }
869
+ if (raw.constraints?.allow_fallback !== undefined && typeof raw.constraints.allow_fallback !== 'boolean') {
870
+ return { ok: false, code: 'JOB_CONSTRAINTS_INVALID', error: 'allow_fallback must be boolean' };
871
+ }
872
+ if (raw.constraints?.allow_no_changes !== undefined && typeof raw.constraints.allow_no_changes !== 'boolean') {
873
+ return { ok: false, code: 'JOB_CONSTRAINTS_INVALID', error: 'allow_no_changes must be boolean' };
874
+ }
875
+ const profileRegistry = dependencies.profileRegistry ?? loadRoleProfiles();
876
+ const workspaceRegistry = dependencies.workspaceRegistry ?? loadWorkspaceContexts();
877
+ if (!profileRegistry.ok) return { ok: false, code: 'PROFILE_FILE_INVALID', error: 'role profile registry is invalid' };
878
+ if (!workspaceRegistry.ok) return { ok: false, code: 'WORKSPACE_CONTEXT_FILE_INVALID', error: 'workspace registry is invalid' };
879
+ const profileRole = raw.profile ? profileRegistry.profiles?.[raw.profile]?.role : undefined;
880
+ const requestedRole = raw.role ?? profileRole ?? 'worker';
881
+ const resolvedProfile = resolveRoleProfile(profileRegistry, raw.profile, requestedRole);
882
+ if (!resolvedProfile.ok) return { ok: false, code: resolvedProfile.code, error: resolvedProfile.code };
883
+ const knownContext = raw.workspace_id ? workspaceRegistry.contexts?.[raw.workspace_id] : null;
884
+ const cwd = raw.workspace?.repo_root ?? raw.cwd ?? knownContext?.repo_root;
885
+ if (!cwd) return { ok: false, code: 'WORKSPACE_CONTEXT_NOT_FOUND', error: 'workspace repo_root or cwd is required' };
886
+ const workspace = resolveWorkspaceContext(workspaceRegistry, { workspace_id: raw.workspace_id, cwd });
887
+ if (!workspace.ok) return { ok: false, code: workspace.code, error: workspace.code };
888
+ const withRefs = addContextReferences(workspace.context, raw.context_refs, { cwd });
889
+ if (!withRefs.ok) return { ok: false, code: withRefs.code, error: withRefs.code };
890
+ const profile = resolvedProfile.profile;
891
+ const worktree = raw.workspace?.worktree;
892
+ const requestedIsolation = worktree === 'auto' ? 'worktree'
893
+ : worktree === 'existing' || worktree === 'none' ? 'shared'
894
+ : profile.isolation;
895
+ const objective = raw.objective ?? raw.task;
896
+ if (typeof objective !== 'string' || objective.trim() === '') return { ok: false, code: 'JOB_OBJECTIVE_REQUIRED', error: 'task or objective is required' };
897
+ if (raw.job_id !== undefined && !/^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/.test(raw.job_id)) {
898
+ return { ok: false, code: 'JOB_ID_INVALID', error: 'invalid client job id' };
899
+ }
900
+ const contextTask = buildWorkspaceTask(objective, withRefs.context);
901
+ normalized = {
902
+ ...raw,
903
+ task: profile.review_strictness === 'strict' && requestedRole === 'reviewer'
904
+ ? `${contextTask}\n\nSTRICT REVIEW: fail closed on missing direct code or test evidence.`
905
+ : contextTask,
906
+ cwd,
907
+ role: requestedRole,
908
+ client_job_id: raw.job_id ?? null,
909
+ requested_isolation: requestedIsolation,
910
+ workspace_branch: raw.workspace?.branch ?? withRefs.context?.default_branch ?? null,
911
+ timeout_seconds: raw.constraints?.timeout_seconds ?? profile.timeout_seconds,
912
+ allow_fallback: raw.constraints?.allow_fallback ?? profile.fallback,
913
+ allow_no_changes: raw.constraints?.allow_no_changes === true,
914
+ routing: profile.routing,
915
+ review_strictness: profile.review_strictness,
916
+ profile_id: resolvedProfile.profile_id,
917
+ workspace_context: withRefs.context,
918
+ };
919
+ for (const key of ['objective', 'job_id', 'profile', 'workspace_id', 'workspace', 'constraints', 'context_refs']) delete normalized[key];
920
+ }
921
+ // v0.2 role-based dispatch: reviewer / worker are gated by their role state,
922
+ // and the tier slot is derived from the role (reviewer always → pro).
923
+ if (normalized.role === 'worker' || normalized.role === 'reviewer') {
924
+ const hint = resolveRoleTierHint(normalized.role, normalized.tier);
925
+ if (!hint.ok) return { ok: false, code: hint.code, error: hint.error };
926
+ const decision = canDispatchRole(config, normalized.role, true, {});
927
+ if (!decision.ok) return { ok: false, code: decision.error.policyCode, error: decision.error.message };
928
+ return { ok: true, payload: { ...normalized, role: normalized.role, tier: hint.tier } };
929
+ }
930
+ const decision = chooseDefaultTier(config, normalized.tier, {});
931
+ if (!decision.ok) return { ok: false, code: decision.error.policyCode, error: decision.error.message };
932
+ return { ok: true, payload: { ...normalized, tier: decision.tier } };
933
+ }
700
934
 
701
935
  // ---------- plugin entry ----------
702
936
 
703
- export async function apply(ctx) {
704
- seedLangFromHost(ctx);
705
- const hub = new WorkerRegistry(ctx);
937
+ export async function apply(ctx) {
938
+ seedLangFromHost(ctx);
939
+ const hub = new WorkerRegistry(ctx);
940
+ const builtInProviderProbe = createProviderProbe(ctx);
706
941
  try {
707
942
  const { readGlobalConfig } = await import('../install/install.mjs');
708
943
  hub.getConfig = () => readGlobalConfig();
@@ -712,7 +947,18 @@ export async function apply(ctx) {
712
947
  // enforced its own), so the check uses the global config only. The route
713
948
  // calls resolveHubSpawnPayload (above) to stamp the effective tier onto
714
949
  // the spawn payload.
715
- const disposers = [];
950
+ const disposers = [];
951
+ const PROVIDER_DELETE_PLAN_TTL_MS = 10 * 60 * 1000;
952
+ const providerDeletePlans = new Map();
953
+ const credentialPurgePlans = new Map();
954
+ const rememberProviderDeletePlan = (plan) => {
955
+ providerDeletePlans.set(plan.plan_id, { plan, created_at: Date.now() });
956
+ while (providerDeletePlans.size > 32) providerDeletePlans.delete(providerDeletePlans.keys().next().value);
957
+ };
958
+ const rememberCredentialPurgePlan = (plan) => {
959
+ credentialPurgePlans.set(plan.plan_id, { plan, created_at: Date.now() });
960
+ while (credentialPurgePlans.size > 32) credentialPurgePlans.delete(credentialPurgePlans.keys().next().value);
961
+ };
716
962
 
717
963
  // Multimodal bridge: register describe_image / generate_image for the DS
718
964
  // model. Config is read per call so settings-page edits apply live; the
@@ -770,29 +1016,29 @@ export async function apply(ctx) {
770
1016
  const foreign = readMergedStatus({ excludeWriter: hub.shard.writer });
771
1017
  return sendJson(res, 200, { ok: true, jobs: [...own, ...foreign] });
772
1018
  }
773
- if (req.method === 'GET' && parts.length === 1) {
1019
+ if (req.method === 'GET' && parts.length === 1) {
774
1020
  const wait = Number(url.searchParams.get('wait') ?? 0);
775
1021
  const job = await hub.wait(parts[0], Math.min(wait, 600) * 1000);
776
1022
  if (!job) return sendJson(res, 404, { ok: false, error: 'no such job' });
777
- return sendJson(res, 200, { ok: true, job: hub.view(job, true) });
778
- }
779
- if (req.method === 'GET' && parts.length === 2 && parts[1] === 'events') {
780
- const job = hub.jobs.get(parts[0]);
781
- if (!job) return sendJson(res, 404, { ok: false, error: 'no such job' });
782
- const after = Math.max(0, Number(url.searchParams.get('after') ?? 0) || 0);
783
- const events = hubCanonicalEvents(job).filter((event) => event.sequence > after);
784
- return sendJson(res, 200, {
785
- ok: true, job_id: job.id, events,
786
- event_cursor: hubCanonicalEvents(job).at(-1)?.sequence ?? 0,
787
- });
788
- }
789
- if (req.method === 'GET' && parts.length === 2 && parts[1] === 'contract') {
790
- const job = hub.jobs.get(parts[0]);
791
- if (!job) return sendJson(res, 404, { ok: false, error: 'no such job' });
792
- const detail = url.searchParams.get('detail') === 'full' ? 'full' : 'compact';
793
- const afterSequence = Math.max(0, Number(url.searchParams.get('after') ?? 0) || 0);
794
- return sendJson(res, 200, { ok: true, job: projectWorkflowView(hub.view(job, true), { detail, afterSequence }) });
795
- }
1023
+ return sendJson(res, 200, { ok: true, job: hub.view(job, true) });
1024
+ }
1025
+ if (req.method === 'GET' && parts.length === 2 && parts[1] === 'events') {
1026
+ const job = hub.jobs.get(parts[0]);
1027
+ if (!job) return sendJson(res, 404, { ok: false, error: 'no such job' });
1028
+ const after = Math.max(0, Number(url.searchParams.get('after') ?? 0) || 0);
1029
+ const events = hubCanonicalEvents(job).filter((event) => event.sequence > after);
1030
+ return sendJson(res, 200, {
1031
+ ok: true, job_id: job.id, events,
1032
+ event_cursor: hubCanonicalEvents(job).at(-1)?.sequence ?? 0,
1033
+ });
1034
+ }
1035
+ if (req.method === 'GET' && parts.length === 2 && parts[1] === 'contract') {
1036
+ const job = hub.jobs.get(parts[0]);
1037
+ if (!job) return sendJson(res, 404, { ok: false, error: 'no such job' });
1038
+ const detail = url.searchParams.get('detail') === 'full' ? 'full' : 'compact';
1039
+ const afterSequence = Math.max(0, Number(url.searchParams.get('after') ?? 0) || 0);
1040
+ return sendJson(res, 200, { ok: true, job: projectWorkflowView(hub.view(job, true), { detail, afterSequence }) });
1041
+ }
796
1042
  if (req.method === 'POST' && parts.length === 0) {
797
1043
  const payload = await readBody(req);
798
1044
  // Same policy resolver as the MCP server (src/server.mjs), with the
@@ -801,7 +1047,8 @@ export async function apply(ctx) {
801
1047
  // as its raw default (pro-only + no tier used to spawn flash).
802
1048
  const resolved = resolveHubSpawnPayload(payload, () => hub.getConfig?.() ?? {});
803
1049
  if (!resolved.ok) return sendJson(res, 400, { ok: false, error: resolved.error, code: resolved.code });
804
- const job = await hub.spawn(resolved.payload);
1050
+ const ingress = req.headers?.['x-dsh-crew-ingress'] === 'official-3080' ? 'official-3080' : 'direct-3210';
1051
+ const job = await hub.spawn({ ...resolved.payload, ingress });
805
1052
  return sendJson(res, 200, { ok: true, job: hub.view(job) });
806
1053
  }
807
1054
  if (req.method === 'POST' && parts.length === 2 && parts[1] === 'cancel') {
@@ -816,90 +1063,101 @@ export async function apply(ctx) {
816
1063
  if (code) body.code = code;
817
1064
  return sendJson(res, 400, body);
818
1065
  }
819
- },
820
- }));
821
-
822
- disposers.push(webServer.register({
823
- kind: 'exact', path: `${ROUTE_BASE}/profiles`,
824
- handler: async (req, res) => {
825
- if (!isLoopbackRequest(req)) return sendJson(res, 403, { ok: false, error: 'loopback only' });
826
- if (req.method === 'GET') return sendJson(res, 200, { ok: true, ...loadRoleProfiles() });
827
- if (req.method === 'POST') {
828
- try {
829
- const saved = saveRoleProfiles(await readBody(req));
830
- return sendJson(res, saved.ok ? 200 : 400, { ...saved });
831
- } catch {
832
- return sendJson(res, 400, { ok: false, error: 'invalid JSON', code: 'PROFILE_FILE_INVALID' });
833
- }
834
- }
835
- return sendJson(res, 405, { ok: false, error: 'GET or POST' }, { allow: 'GET, POST' });
836
- },
837
- }));
838
-
839
- disposers.push(webServer.register({
840
- kind: 'exact', path: `${ROUTE_BASE}/workspaces`,
841
- handler: async (req, res) => {
842
- if (!isLoopbackRequest(req)) return sendJson(res, 403, { ok: false, error: 'loopback only' });
843
- if (req.method === 'GET') return sendJson(res, 200, { ok: true, ...loadWorkspaceContexts() });
844
- if (req.method === 'POST') {
845
- try {
846
- const saved = saveWorkspaceContexts(await readBody(req));
847
- return sendJson(res, saved.ok ? 200 : 400, { ...saved });
848
- } catch {
849
- return sendJson(res, 400, { ok: false, error: 'invalid JSON', code: 'WORKSPACE_CONTEXT_FILE_INVALID' });
850
- }
851
- }
852
- return sendJson(res, 405, { ok: false, error: 'GET or POST' }, { allow: 'GET, POST' });
853
- },
854
- }));
855
-
856
- disposers.push(webServer.register({
857
- kind: 'exact', path: `${ROUTE_BASE}/extension`,
858
- handler: async (req, res) => {
859
- if (!isLoopbackRequest(req)) return sendJson(res, 403, { ok: false, error: 'loopback only' });
860
- if (req.method !== 'GET') return sendJson(res, 405, { ok: false, error: 'GET only' }, { allow: 'GET' });
861
- const config = normalizeGlobalConfig(hub.getConfig?.() ?? {});
862
- let catalogStatus = { id: 'provider_catalog', status: 'NOT_RUN', reason_code: 'PROVIDER_MODE_UNKNOWN' };
863
- if (normalizeWorkerProviderMode(config.worker_provider_mode) === 'deepseek-official') {
864
- catalogStatus = { id: 'provider_catalog', status: 'SKIP', reason_code: 'PROVIDER_CATALOG_NOT_REQUIRED' };
865
- } else {
866
- try {
867
- await readHarnessModelCatalog({
868
- llm: ctx.llm ?? ctx.get('llm'),
869
- getCurrentSelection: () => ctx.get('agentDefaultModel')?.currentSelection?.(),
870
- });
871
- catalogStatus = { id: 'provider_catalog', status: 'PASS', reason_code: 'PROVIDER_CATALOG_RESOLVED' };
872
- } catch {
873
- catalogStatus = { id: 'provider_catalog', status: 'FAIL', reason_code: 'PROVIDER_CATALOG_UNAVAILABLE' };
874
- }
875
- }
876
- const profiles = loadRoleProfiles();
877
- const requestUrl = new URL(req.url, 'http://localhost');
878
- const requestedWorkspaceId = requestUrl.searchParams.get('workspace_id');
879
- const workspaceRegistry = loadWorkspaceContexts();
880
- const requestedContext = requestedWorkspaceId ? workspaceRegistry.contexts?.[requestedWorkspaceId] : null;
881
- const workspaceReadiness = requestedWorkspaceId && !requestedContext
882
- ? { status: 'UNAVAILABLE', reason_code: 'WORKSPACE_CONTEXT_NOT_FOUND' }
883
- : await assessWorkspaceReadiness({ cwd: requestedContext?.repo_root ?? null });
1066
+ },
1067
+ }));
1068
+
1069
+ disposers.push(webServer.register({
1070
+ kind: 'exact', path: `${ROUTE_BASE}/profiles`,
1071
+ handler: async (req, res) => {
1072
+ if (!isLoopbackRequest(req)) return sendJson(res, 403, { ok: false, error: 'loopback only' });
1073
+ if (req.method === 'GET') return sendJson(res, 200, { ok: true, ...loadRoleProfiles() });
1074
+ if (req.method === 'POST') {
1075
+ try {
1076
+ const saved = saveRoleProfiles(await readBody(req));
1077
+ return sendJson(res, saved.ok ? 200 : 400, { ...saved });
1078
+ } catch {
1079
+ return sendJson(res, 400, { ok: false, error: 'invalid JSON', code: 'PROFILE_FILE_INVALID' });
1080
+ }
1081
+ }
1082
+ return sendJson(res, 405, { ok: false, error: 'GET or POST' }, { allow: 'GET, POST' });
1083
+ },
1084
+ }));
1085
+
1086
+ disposers.push(webServer.register({
1087
+ kind: 'exact', path: `${ROUTE_BASE}/workspaces`,
1088
+ handler: async (req, res) => {
1089
+ if (!isLoopbackRequest(req)) return sendJson(res, 403, { ok: false, error: 'loopback only' });
1090
+ if (req.method === 'GET') return sendJson(res, 200, { ok: true, ...loadWorkspaceContexts() });
1091
+ if (req.method === 'POST') {
1092
+ try {
1093
+ const saved = saveWorkspaceContexts(await readBody(req));
1094
+ return sendJson(res, saved.ok ? 200 : 400, { ...saved });
1095
+ } catch {
1096
+ return sendJson(res, 400, { ok: false, error: 'invalid JSON', code: 'WORKSPACE_CONTEXT_FILE_INVALID' });
1097
+ }
1098
+ }
1099
+ return sendJson(res, 405, { ok: false, error: 'GET or POST' }, { allow: 'GET, POST' });
1100
+ },
1101
+ }));
1102
+
1103
+ disposers.push(webServer.register({
1104
+ kind: 'exact', path: `${ROUTE_BASE}/extension`,
1105
+ handler: async (req, res) => {
1106
+ if (!isLoopbackRequest(req)) return sendJson(res, 403, { ok: false, error: 'loopback only' });
1107
+ if (req.method !== 'GET') return sendJson(res, 405, { ok: false, error: 'GET only' }, { allow: 'GET' });
1108
+ const config = normalizeGlobalConfig(hub.getConfig?.() ?? {});
1109
+ let catalogStatus = { id: 'provider_catalog', status: 'NOT_RUN', reason_code: 'PROVIDER_MODE_UNKNOWN' };
1110
+ if (normalizeWorkerProviderMode(config.worker_provider_mode) === 'deepseek-official') {
1111
+ catalogStatus = { id: 'provider_catalog', status: 'SKIP', reason_code: 'PROVIDER_CATALOG_NOT_REQUIRED' };
1112
+ } else {
1113
+ try {
1114
+ await readHarnessModelCatalog({
1115
+ llm: ctx.llm ?? ctx.get('llm'),
1116
+ getCurrentSelection: () => ctx.get('agentDefaultModel')?.currentSelection?.(),
1117
+ });
1118
+ catalogStatus = { id: 'provider_catalog', status: 'PASS', reason_code: 'PROVIDER_CATALOG_RESOLVED' };
1119
+ } catch {
1120
+ catalogStatus = { id: 'provider_catalog', status: 'FAIL', reason_code: 'PROVIDER_CATALOG_UNAVAILABLE' };
1121
+ }
1122
+ }
1123
+ const profiles = loadRoleProfiles();
1124
+ const requestUrl = new URL(req.url, 'http://localhost');
1125
+ const requestedWorkspaceId = requestUrl.searchParams.get('workspace_id');
1126
+ const workspaceRegistry = loadWorkspaceContexts();
1127
+ const requestedContext = requestedWorkspaceId ? workspaceRegistry.contexts?.[requestedWorkspaceId] : null;
1128
+ const workspaceReadiness = requestedWorkspaceId && !requestedContext
1129
+ ? { status: 'UNAVAILABLE', reason_code: 'WORKSPACE_CONTEXT_NOT_FOUND' }
1130
+ : await assessWorkspaceReadiness({ cwd: requestedContext?.repo_root ?? null });
884
1131
  const liveJobs = typeof hub.list === 'function' ? hub.list() : [];
885
1132
  const [modelExecution, reviewerExecution] = buildHubExecutionRows(liveJobs);
886
- const contract = buildExtensionContract({
887
- config,
888
- readinessMatrix: { rows: [
889
- { id: 'hub_compatibility', status: 'PASS', reason_code: 'LIVE_CHECK_PASSED' },
890
- catalogStatus,
891
- modelExecution,
892
- reviewerExecution,
893
- ] },
894
- workspace: workspaceReadiness,
895
- profiles,
1133
+ const readinessMatrix = { rows: [
1134
+ { id: 'hub_compatibility', status: 'PASS', reason_code: 'LIVE_CHECK_PASSED' },
1135
+ catalogStatus,
1136
+ modelExecution,
1137
+ reviewerExecution,
1138
+ { id: 'provider_lifecycle_consistent', status: 'NOT_RUN', reason_code: 'NO_EXECUTION_EVIDENCE' },
1139
+ ] };
1140
+ const readinessSnapshot = buildRuntimeReadinessSnapshot({
896
1141
  runtime: getHubRuntimeIdentity(),
1142
+ readinessMatrix,
1143
+ selections: { worker: liveJobs.find((job) => job?.role === 'worker') ?? null, reviewer: liveJobs.find((job) => job?.role === 'reviewer') ?? null },
1144
+ health: hub.healthStore.list(),
1145
+ jobs: liveJobs,
1146
+ workspace: workspaceReadiness,
897
1147
  });
898
- return sendJson(res, 200, { ok: true, extension: contract });
899
- },
900
- }));
901
-
902
- disposers.push(webServer.register({
1148
+ const contract = buildExtensionContract({
1149
+ config,
1150
+ readinessMatrix,
1151
+ readinessSnapshot,
1152
+ workspace: workspaceReadiness,
1153
+ profiles,
1154
+ runtime: getHubRuntimeIdentity(),
1155
+ });
1156
+ return sendJson(res, 200, { ok: true, extension: contract });
1157
+ },
1158
+ }));
1159
+
1160
+ disposers.push(webServer.register({
903
1161
  kind: 'exact', path: `${ROUTE_BASE}/config`,
904
1162
  handler: async (req, res) => {
905
1163
  if (!isLoopbackRequest(req)) return sendJson(res, 403, { ok: false, error: 'loopback only' });
@@ -936,13 +1194,15 @@ export async function apply(ctx) {
936
1194
  },
937
1195
  }));
938
1196
 
939
- disposers.push(webServer.register({
940
- kind: 'exact', path: `${ROUTE_BASE}/provider`,
941
- handler: async (req, res) => {
942
- if (!isLoopbackRequest(req)) return sendJson(res, 403, { ok: false, error: 'loopback only' });
943
- try {
944
- const config = normalizeGlobalConfig(hub.getConfig?.() ?? {});
945
- const mode = normalizeWorkerProviderMode(config.worker_provider_mode);
1197
+ disposers.push(webServer.register({
1198
+ kind: 'exact', path: `${ROUTE_BASE}/provider`,
1199
+ handler: async (req, res) => {
1200
+ if (!isLoopbackRequest(req)) return sendJson(res, 403, { ok: false, error: 'loopback only' });
1201
+ try {
1202
+ const config = normalizeGlobalConfig(hub.getConfig?.() ?? {});
1203
+ const lifecycleState = readProviderLifecycleState();
1204
+ const healthGate = config.health_gate ?? config.worker?.model_policy?.health_gate;
1205
+ const mode = normalizeWorkerProviderMode(config.worker_provider_mode);
946
1206
  let selections;
947
1207
  if (mode === 'deepseek-official') {
948
1208
  selections = {
@@ -960,10 +1220,14 @@ export async function apply(ctx) {
960
1220
  tier,
961
1221
  priority: config[`${tier}_model_priority`],
962
1222
  priorityConfigured: config[`${tier}_model_priority_configured`],
963
- fallback: config[`${tier}_model_fallback`],
964
- catalog,
965
- harnessDefault: catalog.harness_default,
966
- });
1223
+ fallback: config[`${tier}_model_fallback`],
1224
+ catalog,
1225
+ harnessDefault: catalog.harness_default,
1226
+ healthStore: hub.healthStore,
1227
+ healthGate,
1228
+ allowFallback: config.allow_fallback !== false,
1229
+ tombstones: lifecycleState.tombstones,
1230
+ });
967
1231
  selections[tier] = selected.ok
968
1232
  ? { provider: selected.provider, model: selected.model, source: selected.source }
969
1233
  : { code: selected.code, error: selected.message };
@@ -978,11 +1242,313 @@ export async function apply(ctx) {
978
1242
  } catch (err) {
979
1243
  return sendJson(res, 503, { ok: false, code: 'MODEL_CATALOG_UNAVAILABLE', error: 'Unable to resolve Harness worker models.' });
980
1244
  }
981
- },
982
- }));
983
-
984
- disposers.push(webServer.register({
985
- kind: 'exact', path: `${ROUTE_BASE}/models`,
1245
+ },
1246
+ }));
1247
+
1248
+ // Secret-free Harness provider inventory. This is deliberately separate
1249
+ // from /models (catalog) and /provider (effective routing): it reports
1250
+ // declaration provenance, ownership, lifecycle state and policy refs.
1251
+ disposers.push(webServer.register({
1252
+ kind: 'exact', path: `${ROUTE_BASE}/providers`,
1253
+ handler: async (req, res) => {
1254
+ if (!isLoopbackRequest(req)) return sendJson(res, 403, { ok: false, error: 'loopback only' });
1255
+ if (req.method !== 'GET') return sendJson(res, 405, { ok: false, error: 'GET only' }, { allow: 'GET' });
1256
+ try {
1257
+ const config = normalizeGlobalConfig(hub.getConfig?.() ?? {});
1258
+ const inventory = await readProviderInventorySnapshot(hub, ctx, config);
1259
+ return sendJson(res, 200, {
1260
+ ok: true,
1261
+ ...inventory,
1262
+ runtime: getHubRuntimeIdentity(),
1263
+ });
1264
+ } catch {
1265
+ return sendJson(res, 503, { ok: false, code: 'PROVIDER_INVENTORY_UNAVAILABLE', error: 'Unable to read Harness provider inventory.' });
1266
+ }
1267
+ },
1268
+ }));
1269
+
1270
+ // Credential references are a separate, secret-free inventory. Provider
1271
+ // deletion can report these references, but never implies their purge.
1272
+ disposers.push(webServer.register({
1273
+ kind: 'exact', path: `${ROUTE_BASE}/credential-references`,
1274
+ handler: async (req, res) => {
1275
+ if (!isLoopbackRequest(req)) return sendJson(res, 403, { ok: false, error: 'loopback only' });
1276
+ if (req.method !== 'GET') return sendJson(res, 405, { ok: false, error: 'GET only' }, { allow: 'GET' });
1277
+ try {
1278
+ const config = normalizeGlobalConfig(hub.getConfig?.() ?? {});
1279
+ const snapshot = await readProviderInventorySnapshot(hub, ctx, config);
1280
+ const credentials = buildCredentialReferenceInventory({ providers: snapshot.records, additional_refs: snapshot.credential_history_refs, purged_refs: snapshot.credential_purged_refs });
1281
+ return sendJson(res, 200, { ok: true, ...credentials, unverified_purges: snapshot.credential_purge_unverified, runtime: getHubRuntimeIdentity() });
1282
+ } catch {
1283
+ return sendJson(res, 503, { ok: false, code: 'CREDENTIAL_REFERENCE_INVENTORY_UNAVAILABLE' });
1284
+ }
1285
+ },
1286
+ }));
1287
+
1288
+ disposers.push(webServer.register({
1289
+ kind: 'prefix', path: `${ROUTE_BASE}/credential-references`,
1290
+ handler: async (req, res) => {
1291
+ if (!isLoopbackRequest(req)) return sendJson(res, 403, { ok: false, error: 'loopback only' });
1292
+ let url;
1293
+ try { url = new URL(req.url, 'http://localhost'); } catch { return sendJson(res, 400, { ok: false, code: 'CREDENTIAL_PURGE_PLAN_INVALID' }); }
1294
+ const parts = url.pathname.slice(`${ROUTE_BASE}/credential-references`.length).split('/').filter(Boolean);
1295
+ try {
1296
+ const referenceId = decodeURIComponent(parts[0] ?? '');
1297
+ const body = await readBody(req);
1298
+ const config = normalizeGlobalConfig(hub.getConfig?.() ?? {});
1299
+ const snapshot = await readProviderInventorySnapshot(hub, ctx, config);
1300
+ const credentials = buildCredentialReferenceInventory({ providers: snapshot.records, additional_refs: snapshot.credential_history_refs, purged_refs: snapshot.credential_purged_refs });
1301
+ if (req.method === 'POST' && parts.length === 2 && parts[1] === 'purge-plan') {
1302
+ const planned = planCredentialPurge({ inventory: credentials, referenceId, expectedRevision: body?.expected_revision });
1303
+ if (!planned.ok) return sendJson(res, 409, { ok: false, code: planned.code });
1304
+ rememberCredentialPurgePlan(planned.plan);
1305
+ return sendJson(res, 200, { ok: true, plan: planned.plan });
1306
+ }
1307
+ if (req.method === 'DELETE' && parts.length === 1) {
1308
+ if (body?.confirm !== true) return sendJson(res, 400, { ok: false, code: 'CREDENTIAL_PURGE_CONFIRM_REQUIRED' });
1309
+ const stored = credentialPurgePlans.get(body?.plan_id);
1310
+ if (!stored || Date.now() - stored.created_at > PROVIDER_DELETE_PLAN_TTL_MS || stored.plan.reference_id !== referenceId) {
1311
+ credentialPurgePlans.delete(body?.plan_id);
1312
+ return sendJson(res, 409, { ok: false, code: 'CREDENTIAL_PURGE_PLAN_EXPIRED' });
1313
+ }
1314
+ const current = planCredentialPurge({ inventory: credentials, referenceId, expectedRevision: stored.plan.expected_revision });
1315
+ if (!current.ok) return sendJson(res, 409, { ok: false, code: current.code });
1316
+ if (current.plan.expected_revision !== stored.plan.expected_revision) return sendJson(res, 409, { ok: false, code: 'CREDENTIAL_REFERENCE_CHANGED' });
1317
+ credentialPurgePlans.delete(body.plan_id);
1318
+ const adapter = createCredentialPurgeFileHooks({
1319
+ credentialsFile: join(CONFIG_DIR, 'harness', '.credentials.yaml'),
1320
+ crewHome: join(CONFIG_DIR, 'harness'),
1321
+ });
1322
+ const result = await executeCredentialPurge(current.plan, {
1323
+ recheck: async (id) => {
1324
+ const freshSnapshot = await readProviderInventorySnapshot(hub, ctx, config);
1325
+ const freshCredentials = buildCredentialReferenceInventory({ providers: freshSnapshot.records, additional_refs: freshSnapshot.credential_history_refs, purged_refs: freshSnapshot.credential_purged_refs });
1326
+ const freshPlan = planCredentialPurge({ inventory: freshCredentials, referenceId: id });
1327
+ if (!freshPlan.ok) return { ok: false };
1328
+ const freshRecord = freshCredentials.records.find((entry) => entry.reference_id === id);
1329
+ return {
1330
+ ok: true,
1331
+ revision: freshPlan.plan.expected_revision,
1332
+ orphan: freshRecord?.orphan === true,
1333
+ ownership: freshRecord?.ownership,
1334
+ purge_capability: freshRecord?.purge_capability,
1335
+ };
1336
+ },
1337
+ ...(adapter.ok ? { purge: adapter.purge, verify: adapter.verify } : {}),
1338
+ });
1339
+ if (result.state === 'PURGED' || result.state === 'VERIFIED') {
1340
+ try {
1341
+ writeCredentialPurgeState(recordCredentialPurgeOutcome(readCredentialPurgeState(), current.plan, result));
1342
+ } catch {
1343
+ return sendJson(res, 503, { ok: false, code: 'CREDENTIAL_PURGE_AUDIT_FAILED', state: result.state, reference_id: referenceId });
1344
+ }
1345
+ }
1346
+ return sendJson(res, result.ok ? 200 : 503, result);
1347
+ }
1348
+ return sendJson(res, 404, { ok: false, code: 'CREDENTIAL_REFERENCE_ENDPOINT_NOT_FOUND' });
1349
+ } catch (error) {
1350
+ return sendJson(res, 400, { ok: false, code: boundedMachineCodeFromError(error) ?? 'CREDENTIAL_PURGE_PLAN_INVALID' });
1351
+ }
1352
+ },
1353
+ }));
1354
+
1355
+ // Deletion is two-phase: this endpoint only computes a revision-bound,
1356
+ // secret-free impact plan. A later mutation endpoint must present that
1357
+ // plan and perform restart/verification through an owned supervisor.
1358
+ disposers.push(webServer.register({
1359
+ kind: 'prefix', path: `${ROUTE_BASE}/providers`,
1360
+ handler: async (req, res) => {
1361
+ if (!isLoopbackRequest(req)) return sendJson(res, 403, { ok: false, error: 'loopback only' });
1362
+ let url;
1363
+ try { url = new URL(req.url, 'http://localhost'); } catch { return sendJson(res, 400, { ok: false, code: 'PROVIDER_PLAN_INVALID' }); }
1364
+ const parts = url.pathname.slice(`${ROUTE_BASE}/providers`.length).split('/').filter(Boolean);
1365
+ try {
1366
+ const providerId = decodeURIComponent(parts[0]);
1367
+ const body = await readBody(req);
1368
+ const config = normalizeGlobalConfig(hub.getConfig?.() ?? {});
1369
+ const inventory = await readProviderInventorySnapshot(hub, ctx, config);
1370
+ if (req.method === 'POST' && parts.length === 2 && parts[1] === 'delete-plan') {
1371
+ const profile = readProviderProfileRevision();
1372
+ if (!profile.ok) return sendJson(res, 409, { ok: false, code: profile.code });
1373
+ const planned = planProviderDelete({
1374
+ providerId,
1375
+ inventory,
1376
+ activeJobs: hub.list(),
1377
+ replacementDefault: body?.replacement_default,
1378
+ expectedRevision: profile.revision,
1379
+ });
1380
+ if (!planned.ok) return sendJson(res, 409, { ok: false, code: planned.code });
1381
+ rememberProviderDeletePlan(planned.plan);
1382
+ return sendJson(res, 200, { ok: true, profile_revision: profile.revision, plan: planned.plan });
1383
+ }
1384
+ if (req.method === 'POST' && parts.length === 2 && parts[1] === 'probe') {
1385
+ const record = inventory.records.find((entry) => entry.id === providerId);
1386
+ if (!record) return sendJson(res, 404, { ok: false, code: 'PROVIDER_NOT_FOUND' });
1387
+ if (record.desired_state === 'absent') return sendJson(res, 409, { ok: false, code: 'PROVIDER_TOMBSTONED' });
1388
+ const model = selectProviderProbeModel({ providerId, record, config });
1389
+ if (!model) return sendJson(res, 409, { ok: false, code: 'PROVIDER_PROBE_MODEL_UNAVAILABLE' });
1390
+ let explicitProviderProbe;
1391
+ try { explicitProviderProbe = ctx?.providerProbe; } catch { explicitProviderProbe = undefined; }
1392
+ const providerProbe = typeof explicitProviderProbe === 'function' ? explicitProviderProbe : builtInProviderProbe;
1393
+ if (typeof providerProbe !== 'function') return sendJson(res, 503, { ok: false, code: 'PROVIDER_PROBE_UNAVAILABLE' });
1394
+ let observed;
1395
+ let timer;
1396
+ try {
1397
+ const timeout = new Promise((resolve) => { timer = setTimeout(() => resolve({ error: { name: 'TimeoutError' } }), 15_000); });
1398
+ observed = await Promise.race([
1399
+ Promise.resolve().then(() => providerProbe({ provider: providerId, model, signal: AbortSignal.timeout(15_000) })),
1400
+ timeout,
1401
+ ]);
1402
+ } catch (error) {
1403
+ // Keep probe transport failures as bounded health evidence so a
1404
+ // credential/quota/timeout result is visible to routing policy;
1405
+ // never copy the raw provider error into the response.
1406
+ observed = { error };
1407
+ } finally {
1408
+ clearTimeout(timer);
1409
+ }
1410
+ const health = hub.healthStore.record(providerId, model, observed?.ok === true ? { ok: true } : { error: observed?.error ?? observed });
1411
+ return sendJson(res, 200, { ok: true, provider_id: providerId, model, health });
1412
+ }
1413
+ if (req.method === 'POST' && parts.length === 2 && parts[1] === 'verify-delete') {
1414
+ if (body?.confirm !== true) return sendJson(res, 400, { ok: false, code: 'PROVIDER_DELETE_VERIFY_CONFIRM_REQUIRED' });
1415
+ const { transaction_id: transactionId } = body ?? {};
1416
+ const hooks = createProviderDeleteFileHooks({
1417
+ profileFile: join(CONFIG_DIR, 'harness', 'profiles', 'dsh-crew', 'cordis.patch.yml'),
1418
+ configFile: join(CONFIG_DIR, 'config.json'),
1419
+ lifecycleFile: join(CONFIG_DIR, 'provider-lifecycle.json'),
1420
+ backupDir: join(CONFIG_DIR, 'provider-backups'),
1421
+ existingBackupId: transactionId,
1422
+ expectedProviderId: providerId,
1423
+ });
1424
+ const plan = hooks.backupPlan();
1425
+ if (!plan || plan.provider_id !== providerId) return sendJson(res, 409, { ok: false, code: 'PROVIDER_DELETE_PLAN_PROVIDER_MISMATCH' });
1426
+ await hooks.acquireLock();
1427
+ try {
1428
+ const verification = await hooks.verify(plan);
1429
+ if (verification?.providerAbsent !== true || verification?.routingClear !== true || verification?.tombstonePresent !== true) {
1430
+ return sendJson(res, 409, { ok: false, code: 'PROVIDER_DELETE_VERIFY_FAILED', verification });
1431
+ }
1432
+ await hooks.recordTransaction({ transaction_id: transactionId, provider_id: providerId, state: 'VERIFIED' }, plan);
1433
+ return sendJson(res, 200, { ok: true, transaction_id: transactionId, provider_id: providerId, state: 'VERIFIED', verification });
1434
+ } finally {
1435
+ await hooks.release();
1436
+ }
1437
+ }
1438
+ if (req.method === 'POST' && parts.length === 2 && parts[1] === 'rollback') {
1439
+ if (body?.confirm !== true) return sendJson(res, 400, { ok: false, code: 'PROVIDER_ROLLBACK_CONFIRM_REQUIRED' });
1440
+ const { transaction_id: transactionId } = body ?? {};
1441
+ const hooks = createProviderDeleteFileHooks({
1442
+ profileFile: join(CONFIG_DIR, 'harness', 'profiles', 'dsh-crew', 'cordis.patch.yml'),
1443
+ configFile: join(CONFIG_DIR, 'config.json'),
1444
+ lifecycleFile: join(CONFIG_DIR, 'provider-lifecycle.json'),
1445
+ backupDir: join(CONFIG_DIR, 'provider-backups'),
1446
+ existingBackupId: transactionId,
1447
+ expectedProviderId: providerId,
1448
+ });
1449
+ await hooks.acquireLock();
1450
+ try {
1451
+ const plan = hooks.backupPlan();
1452
+ if (!plan || plan.provider_id !== providerId) return sendJson(res, 409, { ok: false, code: 'PROVIDER_DELETE_PLAN_PROVIDER_MISMATCH' });
1453
+ await hooks.rollback(plan);
1454
+ try {
1455
+ await hooks.recordTransaction({ transaction_id: transactionId, provider_id: providerId, state: 'ROLLBACK_PENDING' }, plan);
1456
+ } catch (error) {
1457
+ return sendJson(res, 409, { ok: false, code: boundedMachineCodeFromError(error) ?? 'PROVIDER_LIFECYCLE_RECORD_FAILED' });
1458
+ }
1459
+ return sendJson(res, 202, { ok: true, restart_required: true, transaction_id: transactionId, provider_id: providerId, state: 'ROLLBACK_PENDING' });
1460
+ } finally {
1461
+ await hooks.release();
1462
+ }
1463
+ }
1464
+ if (req.method === 'POST' && parts.length === 2 && parts[1] === 'verify-rollback') {
1465
+ if (body?.confirm !== true) return sendJson(res, 400, { ok: false, code: 'PROVIDER_ROLLBACK_VERIFY_CONFIRM_REQUIRED' });
1466
+ const { transaction_id: transactionId } = body ?? {};
1467
+ const hooks = createProviderDeleteFileHooks({
1468
+ profileFile: join(CONFIG_DIR, 'harness', 'profiles', 'dsh-crew', 'cordis.patch.yml'),
1469
+ configFile: join(CONFIG_DIR, 'config.json'),
1470
+ lifecycleFile: join(CONFIG_DIR, 'provider-lifecycle.json'),
1471
+ backupDir: join(CONFIG_DIR, 'provider-backups'),
1472
+ existingBackupId: transactionId,
1473
+ expectedProviderId: providerId,
1474
+ });
1475
+ const plan = hooks.backupPlan();
1476
+ if (!plan || plan.provider_id !== providerId) return sendJson(res, 409, { ok: false, code: 'PROVIDER_DELETE_PLAN_PROVIDER_MISMATCH' });
1477
+ await hooks.acquireLock();
1478
+ try {
1479
+ const verified = await hooks.verifyRollback(plan);
1480
+ if (verified?.ok !== true) return sendJson(res, 409, { ok: false, code: 'PROVIDER_DELETE_ROLLBACK_VERIFY_FAILED' });
1481
+ await hooks.recordTransaction({ transaction_id: transactionId, provider_id: providerId, state: 'ROLLED_BACK' }, plan);
1482
+ return sendJson(res, 200, { ok: true, transaction_id: transactionId, provider_id: providerId, state: 'ROLLED_BACK' });
1483
+ } finally {
1484
+ await hooks.release();
1485
+ }
1486
+ }
1487
+ if (req.method === 'DELETE' && parts.length === 1) {
1488
+ if (body?.confirm !== true) return sendJson(res, 400, { ok: false, code: 'PROVIDER_DELETE_CONFIRM_REQUIRED' });
1489
+ if (body?.purge_orphan_credentials === true) {
1490
+ return sendJson(res, 400, { ok: false, code: 'CREDENTIAL_PURGE_REQUIRES_EXPLICIT_CONFIRMATION' });
1491
+ }
1492
+ const stored = providerDeletePlans.get(body?.plan_id);
1493
+ if (!stored || Date.now() - stored.created_at > PROVIDER_DELETE_PLAN_TTL_MS || stored.plan.provider_id !== providerId) {
1494
+ providerDeletePlans.delete(body?.plan_id);
1495
+ return sendJson(res, 409, { ok: false, code: 'PROVIDER_DELETE_PLAN_EXPIRED' });
1496
+ }
1497
+ const profile = readProviderProfileRevision();
1498
+ if (!profile.ok) return sendJson(res, 409, { ok: false, code: profile.code });
1499
+ if (body?.expected_revision !== stored.plan.expected_revision || profile.revision !== stored.plan.expected_revision) {
1500
+ return sendJson(res, 409, { ok: false, code: 'PROVIDER_PROFILE_CHANGED' });
1501
+ }
1502
+ const refreshed = planProviderDelete({
1503
+ providerId,
1504
+ inventory,
1505
+ activeJobs: hub.list(),
1506
+ replacementDefault: stored.plan.replacement_default,
1507
+ expectedRevision: profile.revision,
1508
+ });
1509
+ if (!refreshed.ok) return sendJson(res, 409, { ok: false, code: refreshed.code });
1510
+ if (refreshed.plan.replacement_default_model !== stored.plan.replacement_default_model) {
1511
+ return sendJson(res, 409, { ok: false, code: 'PROVIDER_DELETE_PLAN_CHANGED' });
1512
+ }
1513
+ const executionPlan = { ...refreshed.plan, plan_id: stored.plan.plan_id, expected_revision: stored.plan.expected_revision };
1514
+ const hooks = createProviderDeleteFileHooks({
1515
+ profileFile: join(CONFIG_DIR, 'harness', 'profiles', 'dsh-crew', 'cordis.patch.yml'),
1516
+ configFile: join(CONFIG_DIR, 'config.json'),
1517
+ lifecycleFile: join(CONFIG_DIR, 'provider-lifecycle.json'),
1518
+ backupDir: join(CONFIG_DIR, 'provider-backups'),
1519
+ });
1520
+ providerDeletePlans.delete(body.plan_id);
1521
+ const { executeProviderDelete } = await import('../provider-lifecycle.mjs');
1522
+ const result = await executeProviderDelete(executionPlan, hooks, { deferRestart: true });
1523
+ return sendJson(res, result.state === 'RESTART_PENDING' ? 202 : 409, { ok: result.state === 'RESTART_PENDING', restart_required: true, result });
1524
+ }
1525
+ return sendJson(res, 404, { ok: false, error: 'unknown providers endpoint' });
1526
+ } catch (err) {
1527
+ const isProbe = req.method === 'POST' && parts.length === 2 && parts[1] === 'probe';
1528
+ const code = boundedMachineCodeFromError(err) ?? (isProbe ? 'PROVIDER_PROBE_FAILED' : 'PROVIDER_PLAN_INVALID');
1529
+ const status = code === 'PROVIDER_DELETE_RESTART_SUPERVISOR_UNAVAILABLE' || code.startsWith('PROVIDER_PROBE') ? 503
1530
+ : code.startsWith('PROVIDER_') || code.startsWith('CREDENTIAL_') ? 409 : 400;
1531
+ return sendJson(res, status, { ok: false, code });
1532
+ }
1533
+ },
1534
+ }));
1535
+
1536
+ disposers.push(webServer.register({
1537
+ kind: 'exact', path: `${ROUTE_BASE}/provider-health`,
1538
+ handler: async (req, res) => {
1539
+ if (!isLoopbackRequest(req)) return sendJson(res, 403, { ok: false, error: 'loopback only' });
1540
+ if (req.method !== 'GET') return sendJson(res, 405, { ok: false, error: 'GET only' }, { allow: 'GET' });
1541
+ return sendJson(res, 200, {
1542
+ ok: true,
1543
+ schema_version: 1,
1544
+ health: hub.healthStore.list(),
1545
+ runtime: getHubRuntimeIdentity(),
1546
+ });
1547
+ },
1548
+ }));
1549
+
1550
+ disposers.push(webServer.register({
1551
+ kind: 'exact', path: `${ROUTE_BASE}/models`,
986
1552
  handler: async (req, res) => {
987
1553
  if (!isLoopbackRequest(req)) return sendJson(res, 403, { ok: false, error: 'loopback only' });
988
1554
  if (req.method !== 'GET' && req.method !== 'POST') return sendJson(res, 405, { ok: false, error: 'GET or POST' }, { allow: 'GET, POST' });
@@ -1058,8 +1624,8 @@ export async function apply(ctx) {
1058
1624
  // Cache-busted import: the installer must always run the code
1059
1625
  // currently on disk, not whatever this process first loaded —
1060
1626
  // a stale cached copy once re-broke user settings after a fix.
1061
- const { installClaudeCode, installCodex, installHudSegment, uninstallClaudeCode, uninstallCodex, installZCode, uninstallZCode } =
1062
- await import(`../install/install.mjs?t=${Date.now()}`);
1627
+ const { installClaudeCode, installCodex, installHudSegment, uninstallClaudeCode, uninstallCodex, installZCode, uninstallZCode } =
1628
+ await import(`../install/install.mjs?t=${Date.now()}`);
1063
1629
  if (target === 'claude') {
1064
1630
  const base = await installClaudeCode({ statusline: !!statusline });
1065
1631
  const hud = installHudSegment({});
@@ -1068,12 +1634,12 @@ export async function apply(ctx) {
1068
1634
  actions: [...base.actions, ...hud.actions.map((a) => `hud: ${a}`)],
1069
1635
  });
1070
1636
  }
1071
- if (target === 'codex') return sendJson(res, 200, installCodex({}));
1072
- if (target === 'zcode') return sendJson(res, 200, installZCode({}));
1073
- if (target === 'claude-uninstall') return sendJson(res, 200, uninstallClaudeCode({}));
1074
- if (target === 'codex-uninstall') return sendJson(res, 200, uninstallCodex({}));
1075
- if (target === 'zcode-uninstall') return sendJson(res, 200, uninstallZCode({}));
1076
- return sendJson(res, 400, { ok: false, error: 'target must be claude | codex | zcode | claude-uninstall | codex-uninstall | zcode-uninstall' });
1637
+ if (target === 'codex') return sendJson(res, 200, installCodex({}));
1638
+ if (target === 'zcode') return sendJson(res, 200, installZCode({}));
1639
+ if (target === 'claude-uninstall') return sendJson(res, 200, uninstallClaudeCode({}));
1640
+ if (target === 'codex-uninstall') return sendJson(res, 200, uninstallCodex({}));
1641
+ if (target === 'zcode-uninstall') return sendJson(res, 200, uninstallZCode({}));
1642
+ return sendJson(res, 400, { ok: false, error: 'target must be claude | codex | zcode | claude-uninstall | codex-uninstall | zcode-uninstall' });
1077
1643
  } catch (err) {
1078
1644
  return sendJson(res, 500, { ok: false, error: err?.message ?? String(err) });
1079
1645
  }