@ran-sh/dsh-crew 0.5.5 → 0.5.7

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 (39) hide show
  1. package/README.md +14 -7
  2. package/README.zh.md +12 -7
  3. package/docs/installation.md +1 -1
  4. package/docs/ui-surfaces.md +65 -46
  5. package/lib/client.js +345 -22
  6. package/official-web-bridge/lib/client.js +345 -22
  7. package/package.json +1 -1
  8. package/src/client/collapsible-sections.mjs +3 -2
  9. package/src/client/index.tsx +227 -51
  10. package/src/config-readiness.mjs +56 -5
  11. package/src/extension-contract.mjs +9 -5
  12. package/src/hub/index.mjs +854 -445
  13. package/src/hub-client.mjs +24 -4
  14. package/src/hub-compatibility.mjs +23 -7
  15. package/src/install/install.mjs +4 -4
  16. package/src/install/npx-lifecycle.mjs +254 -87
  17. package/src/job-contracts.mjs +19 -3
  18. package/src/jobs.mjs +2 -4
  19. package/src/local-request-guard.mjs +60 -0
  20. package/src/mcp-runtime.mjs +7 -5
  21. package/src/model-routing.mjs +141 -49
  22. package/src/multimodal.mjs +0 -0
  23. package/src/official-web-bridge.mjs +242 -84
  24. package/src/policy.mjs +62 -31
  25. package/src/provider-config-scrub.mjs +71 -0
  26. package/src/provider-delete-adapters.mjs +424 -0
  27. package/src/provider-health.mjs +124 -0
  28. package/src/provider-inventory.mjs +132 -0
  29. package/src/provider-lifecycle-state.mjs +81 -0
  30. package/src/provider-lifecycle.mjs +214 -0
  31. package/src/provider-profile-store.mjs +171 -0
  32. package/src/readiness-matrix.mjs +11 -5
  33. package/src/removable-waiter.mjs +29 -0
  34. package/src/runtime-identity.mjs +43 -13
  35. package/src/server.mjs +34 -16
  36. package/src/status-shard.mjs +13 -2
  37. package/src/workflow-runtime.mjs +44 -10
  38. package/windows/start-dsh-crew.cmd +3 -3
  39. 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, readFileSync } 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,24 @@ 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';
33
42
 
34
43
  // policy.mjs is pure (no @deepseek-ai imports, no ctx access), so importing it
35
44
  // here is safe for the profile-realm discipline: it never pulls in package
@@ -92,62 +101,171 @@ const LEGACY_TIER_MODELS = { flash: 'deepseek-v4-flash', pro: 'deepseek-v4-pro'
92
101
  const ROLES = { worker: true, reviewer: true };
93
102
  const CONFIG_DIR = join(homedir(), '.config', 'dsh-crew');
94
103
 
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;
100
- const definitions = [
101
- ['job.created', atStart, { client_job_id: job.client_job_id ?? null }],
102
- ['job.started', atStart, {}],
103
- ['model.selected', atStart, { provider: job.provider ?? null, model: job.model ?? null, source: job.selection_source ?? null }],
104
- [role === 'reviewer' ? 'review.started' : 'worker.started', atStart, { run_id: job.id }],
105
- ];
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 }]);
104
+ function readProviderLifecycleState() {
105
+ try {
106
+ return normalizeProviderLifecycleState(JSON.parse(readFileSync(join(CONFIG_DIR, 'provider-lifecycle.json'), 'utf8')));
107
+ } catch {
108
+ return normalizeProviderLifecycleState();
117
109
  }
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
110
  }
122
-
123
- // ---------- job registry ----------
124
111
 
125
- 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',
112
+ function providerInventoryPolicy(config) {
113
+ return {
114
+ worker: config?.worker?.model_policy ?? {
115
+ priority: config?.flash_model_priority,
116
+ escalation_priority: config?.pro_model_priority,
117
+ },
118
+ reviewer: config?.review?.model_policy ?? {
119
+ priority: config?.pro_model_priority,
120
+ escalation_priority: [],
121
+ },
122
+ };
123
+ }
124
+
125
+ async function readProviderInventorySnapshot(hub, ctx, config) {
126
+ let catalog = { providers: [], harness_default: null };
127
+ try {
128
+ catalog = await readHarnessModelCatalog({
129
+ llm: ctx.llm ?? ctx.get('llm'),
130
+ getCurrentSelection: () => ctx.get('agentDefaultModel')?.currentSelection?.(),
131
+ });
132
+ } catch {}
133
+ const profileFile = join(CONFIG_DIR, 'harness', 'profiles', 'dsh-crew', 'cordis.patch.yml');
134
+ let declarations = [];
135
+ try {
136
+ if (existsSync(profileFile)) {
137
+ const source = readFileSync(profileFile, 'utf8');
138
+ const parsed = readProviderDeclarations(source, { file: 'harness/profiles/dsh-crew/cordis.patch.yml' });
139
+ if (parsed.ok) declarations = parsed.declarations;
140
+ }
141
+ } catch {}
142
+ const lifecycle = readProviderLifecycleState();
143
+ return buildProviderInventory({
144
+ catalog,
145
+ declarations,
146
+ policy: providerInventoryPolicy(config),
147
+ tombstones: lifecycle.tombstones,
148
+ activeJobs: hub.list(),
135
149
  });
136
150
  }
151
+
152
+ function readProviderProfileRevision() {
153
+ const profileFile = join(CONFIG_DIR, 'harness', 'profiles', 'dsh-crew', 'cordis.patch.yml');
154
+ try {
155
+ if (!existsSync(profileFile)) return { ok: false, code: 'PROVIDER_PROFILE_SCHEMA_UNSUPPORTED' };
156
+ const parsed = inspectProviderProfile(readFileSync(profileFile, 'utf8'));
157
+ return parsed.ok ? { ok: true, revision: parsed.revision } : { ok: false, code: parsed.code };
158
+ } catch {
159
+ return { ok: false, code: 'PROVIDER_PROFILE_SCHEMA_UNSUPPORTED' };
160
+ }
161
+ }
137
162
 
138
- // Exported for the unit tests (test/hub-windows.test.mjs); instantiation
163
+ export function hubCanonicalEvents(job = {}) {
164
+ if (!job.id) return [];
165
+ const role = job.role === 'reviewer' ? 'reviewer' : 'worker';
166
+ const atStart = job.startedAt ?? null;
167
+ const atEnd = job.endedAt ?? atStart;
168
+ const definitions = [
169
+ ['job.created', atStart, { client_job_id: job.client_job_id ?? null }],
170
+ ['job.started', atStart, {}],
171
+ ['model.selected', atStart, { provider: job.provider ?? null, model: job.model ?? null, source: job.selection_source ?? null }],
172
+ [role === 'reviewer' ? 'review.started' : 'worker.started', atStart, { run_id: job.id }],
173
+ ];
174
+ if (job.status !== 'running') {
175
+ definitions.push([
176
+ role === 'reviewer' ? 'review.completed' : 'worker.completed',
177
+ atEnd,
178
+ role === 'reviewer'
179
+ ? { status: job.status ?? null, verdict: job.review?.verdict ?? null }
180
+ : { status: job.status ?? null, summary: job.outcome?.task_status ?? null },
181
+ ]);
182
+ if (job.status === 'done') definitions.push(['job.completed', atEnd, { result_ref: job.id }]);
183
+ else if (job.status === 'cancelled') definitions.push(['job.cancelled', atEnd, { reason: 'cancelled' }]);
184
+ else definitions.push(['job.failed', atEnd, { error_code: job.error_code ?? null }]);
185
+ }
186
+ return definitions.map(([type, at, data], index) => createCanonicalJobEvent({
187
+ jobId: job.id, type, sequence: index + 1, at, role, attempt: job.attempt ?? 0, data,
188
+ }));
189
+ }
190
+
191
+ // ---------- job registry ----------
192
+
193
+ export function applyHubWorkspaceEvidence({ outcome, workspaceDiff, allowNoChanges = false, isolation = 'shared', role = 'worker' } = {}) {
194
+ const changes = workspaceDiff?.changes ?? {};
195
+ const hasChanges = ['modified', 'deleted', 'renamed', 'untracked']
196
+ .some((key) => Array.isArray(changes[key]) && changes[key].length > 0);
197
+ const evidenceAvailable = workspaceDiff?.kind === 'git' && workspaceDiff.dirtyBaseline !== true;
198
+ return applyWorkspaceEvidence(outcome, {
199
+ evidenceAvailable,
200
+ hasChanges,
201
+ allowNoChanges: allowNoChanges === true && isolation === 'worktree',
202
+ requireNoChangeAuthorization: role === 'worker',
203
+ });
204
+ }
205
+
206
+ /**
207
+ * Build the explicit provider callability probe used by the 3210 lifecycle
208
+ * route. It performs one token-bounded Harness stream through the same llm
209
+ * runtime used by WorkerRegistry; no credential values are read or returned.
210
+ */
211
+ export function createProviderProbe(ctx) {
212
+ const llm = ctx?.llm ?? ctx?.get?.('llm');
213
+ if (!llm || typeof llm.stream !== 'function') return null;
214
+ return async ({ provider, model, signal }) => {
215
+ try {
216
+ const stream = llm.stream({
217
+ provider,
218
+ model,
219
+ messages: [userMessage('dsh-crew provider probe')],
220
+ maxTokens: 1,
221
+ temperature: 0,
222
+ signal,
223
+ });
224
+ for await (const chunk of stream) {
225
+ if (chunk?.type !== 'finish') continue;
226
+ const reason = chunk.reason;
227
+ if (reason?.kind === 'error' || reason?.kind === 'aborted') {
228
+ return { ok: false, error: reason.failure ?? { code: `PROVIDER_PROBE_${String(reason.kind).toUpperCase()}` } };
229
+ }
230
+ return { ok: true };
231
+ }
232
+ return { ok: false, error: { code: 'PROVIDER_PROBE_NO_FINISH' } };
233
+ } catch (error) {
234
+ return { ok: false, error };
235
+ }
236
+ };
237
+ }
238
+
239
+ /** Pick the first configured model for a provider, falling back to its live catalog. */
240
+ export function selectProviderProbeModel({ providerId, record, config } = {}) {
241
+ const advertised = new Set(Array.isArray(record?.models) ? record.models.filter((model) => typeof model === 'string') : []);
242
+ if (advertised.size === 0) return null;
243
+ const priorities = [
244
+ ...(Array.isArray(config?.worker?.model_policy?.priority) ? config.worker.model_policy.priority : []),
245
+ ...(Array.isArray(config?.flash_model_priority) ? config.flash_model_priority : []),
246
+ ...(Array.isArray(config?.review?.model_policy?.priority) ? config.review.model_policy.priority : []),
247
+ ...(Array.isArray(config?.pro_model_priority) ? config.pro_model_priority : []),
248
+ ];
249
+ for (const ref of priorities) {
250
+ if (ref?.provider === providerId && advertised.has(ref.model)) return ref.model;
251
+ }
252
+ return [...advertised][0];
253
+ }
254
+
255
+ // Exported for the unit tests (test/hub-windows.test.mjs); instantiation
139
256
  // needs only a duck-typed ctx, so spawn()'s path guard is testable without a
140
257
  // 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
- }
258
+ export class WorkerRegistry { constructor(ctx) {
259
+ this.ctx = ctx;
260
+ this.jobs = new Map();
261
+ this.nextId = 1;
262
+ this.shard = createShardWriter('hub');
263
+ this.healthStore = createProviderHealthStore();
264
+ }
147
265
 
148
266
  view(job, withResult = false) {
149
267
  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,
268
+ id: job.id, client_job_id: job.client_job_id ?? null, sessionId: job.sessionId, role: job.role ?? 'worker', attempt: job.attempt ?? 0,
151
269
  tier: job.tier, provider: job.provider, model: job.model,
152
270
  selection_source: job.selection_source,
153
271
  selection_trace: job.selection_trace ?? null,
@@ -156,31 +274,32 @@ export class WorkerRegistry { constructor(ctx) {
156
274
  cwd: job.cwd, turn: job.turn, step: job.step, currentTool: job.currentTool,
157
275
  phase: job.phase ?? null,
158
276
  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,
277
+ startedAt: job.startedAt, endedAt: job.endedAt,
278
+ isolation: job.isolation ?? 'shared', workspace_branch: job.workspace_branch ?? null,
279
+ delivery_complete: !!job.delivery_complete,
280
+ allow_no_changes: job.allow_no_changes === true,
281
+ task_status: job.outcome?.task_status ?? null,
282
+ workspace_evidence_ok: job.outcome?.workspace_evidence_ok ?? null,
283
+ review_verdict: job.review?.verdict ?? null,
166
284
  workspace_diff_available: !!job.workspaceDiff && job.workspaceDiff.kind === 'git',
167
285
  workspace_retained: job.workspace_retained === true,
168
286
  cleanup_warning: job.cleanup_warning ?? null,
169
287
  profile_id: job.profile_id ?? null,
170
288
  workspace_context: job.workspace_context ?? null,
289
+ execution_context: job.execution_context ?? null,
171
290
  event_cursor: hubCanonicalEvents(job).at(-1)?.sequence ?? 0,
172
291
  };
173
292
  if (withResult) {
174
293
  v.result = job.result; v.error = job.error; v.stopReason = job.stopReason;
175
294
  v.reasonDetail = job.reasonDetail;
176
295
  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;
296
+ v.delivery_missing = job.delivery_missing ?? [];
297
+ v.outcome = job.outcome ?? null;
298
+ v.review = job.review ?? null;
180
299
  v.workspace_diff = job.workspaceDiff ?? null;
181
- v.workspace_baseline_dirty = !!job.workspaceDiff?.dirtyBaseline;
182
- v.canonical_events = hubCanonicalEvents(job);
183
- }
300
+ v.workspace_baseline_dirty = !!job.workspaceDiff?.dirtyBaseline;
301
+ v.canonical_events = hubCanonicalEvents(job);
302
+ }
184
303
  return v;
185
304
  }
186
305
 
@@ -200,7 +319,7 @@ export class WorkerRegistry { constructor(ctx) {
200
319
  * `role` (worker | reviewer) records who does the work; `tier` remains the
201
320
  * legacy model-class slot. Reviewer-role jobs always use the pro slot.
202
321
  */
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 }) {
322
+ 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 }) {
204
323
  // role is only honored when the caller explicitly names it; a legacy
205
324
  // tier-only spawn (role === undefined) keeps the exact v0.1 resolution.
206
325
  const hasRole = role === 'worker' || role === 'reviewer';
@@ -219,7 +338,9 @@ export class WorkerRegistry { constructor(ctx) {
219
338
  // Provider routing: follow-dsh reads the DSH Models selection live; the
220
339
  // default deepseek-official keeps legacy setups unchanged. Re-resolved on
221
340
  // every spawn so a Models change takes effect on the next worker.
222
- const cfg = normalizeGlobalConfig(this.getConfig?.() ?? {});
341
+ const cfg = normalizeGlobalConfig(this.getConfig?.() ?? {});
342
+ const lifecycleState = readProviderLifecycleState();
343
+ const healthGate = cfg.health_gate ?? cfg.worker?.model_policy?.health_gate;
223
344
  const workerProviderMode = normalizeWorkerProviderMode(cfg.worker_provider_mode);
224
345
  const getCurrentSelection = () => this.ctx.get('agentDefaultModel')?.currentSelection?.();
225
346
  let selection;
@@ -260,22 +381,30 @@ export class WorkerRegistry { constructor(ctx) {
260
381
  // tier-only spawns resolve through the tier resolver exactly as before.
261
382
  if (hasRole) {
262
383
  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
- });
384
+ selection = resolveModel({
385
+ role: effRole,
386
+ attempt,
387
+ policy,
388
+ catalog,
389
+ harnessDefault: catalog.harness_default ?? getCurrentSelection(),
390
+ healthStore: this.healthStore,
391
+ healthGate: policy.health_gate ?? healthGate,
392
+ allowFallback: cfg.allow_fallback !== false,
393
+ tombstones: lifecycleState.tombstones,
394
+ });
270
395
  } else {
271
396
  selection = resolveWorkerModel({
272
397
  tier: effTier,
273
398
  priority: cfg[`${effTier}_model_priority`],
274
399
  priorityConfigured: cfg[`${effTier}_model_priority_configured`],
275
400
  fallback: cfg[`${effTier}_model_fallback`],
276
- catalog,
277
- harnessDefault: catalog.harness_default ?? getCurrentSelection(),
278
- traceContext: {
401
+ catalog,
402
+ harnessDefault: catalog.harness_default ?? getCurrentSelection(),
403
+ healthStore: this.healthStore,
404
+ healthGate,
405
+ allowFallback: cfg.allow_fallback !== false,
406
+ tombstones: lifecycleState.tombstones,
407
+ traceContext: {
279
408
  role: effRole,
280
409
  logicalAttempt: attempt,
281
410
  modelClassHint: effTier,
@@ -293,53 +422,79 @@ export class WorkerRegistry { constructor(ctx) {
293
422
  const jobRole = hasRole ? role : (delivery === 'review' || role === 'reviewer' ? 'reviewer' : 'worker');
294
423
  const workerPrompt = appendDeliveryInstructions(task, { tier: effTier, role: jobRole, isReview: delivery === 'review' || jobRole === 'reviewer' });
295
424
 
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,
425
+ const id = `hub-${this.nextId++}-${Date.now().toString(36)}`;
426
+
427
+ // Resolve presets before any side effect (registry entry, worktree): a
428
+ // rejected preset must fail spawn() cleanly instead of leaving a ghost
429
+ // "running" job and a leaked worktree behind.
430
+ const presets = this.ctx.get('agentPresets');
431
+ const wanted = preset ?? (tier === 'flash' ? cfg.preset_flash : cfg.preset_pro);
432
+ const presetId = presets === undefined
433
+ ? undefined
434
+ : (await presets.resolve(!wanted || wanted === 'default' ? undefined : wanted)).id;
435
+
436
+ let executionCwd = cwd;
437
+ let isolatedWorkspace = null;
438
+ if ((requested_isolation === 'worktree' && jobRole === 'worker') || requested_isolation === 'readonly') {
439
+ const created = await createIsolatedWorkspace({ cwd, jobId: id, baseRevision: workspace_branch });
440
+ if (!created.ok) throw Object.assign(new Error(created.error ?? created.reason), { code: created.reason });
441
+ executionCwd = created.worktreePath;
442
+ isolatedWorkspace = { worktreePath: created.worktreePath, repoRoot: created.repoRoot };
443
+ }
444
+ const sessionId = `session-${randomUUID()}`;
445
+ const job = {
446
+ id, client_job_id: client_job_id ?? null, sessionId, role: jobRole, attempt, tier: effTier, provider: selection.provider, model: selection.model,
308
447
  selection_source: selection.source, selection_trace: selection.selection_trace ?? null,
309
448
  effort, reasoning_effort: selection.reasoningEffort,
310
449
  task, source, cwd: executionCwd, requested_cwd: cwd,
311
450
  isolation: isolatedWorkspace ? 'worktree' : 'shared',
451
+ execution_context: (() => {
452
+ const runtime = getHubRuntimeIdentity();
453
+ return {
454
+ execution_plane: runtime.execution_plane,
455
+ profile: runtime.profile,
456
+ listen_port: runtime.listen_port,
457
+ runtime_id: runtime.runtime_id,
458
+ };
459
+ })(),
312
460
  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,
461
+ workspace_branch: workspace_branch ?? null, isolatedWorkspace,
462
+ profile_id: profile_id ?? null, workspace_context: workspace_context ?? null,
315
463
  prompt: workerPrompt, delivery: delivery === 'review' ? 'review' : 'coding',
316
464
  phase: JOB_PHASES.RUNNING,
317
465
  status: 'running', turn: 0, step: 0, currentTool: null, toolCalls: 0,
318
466
  tokens: { input: 0, output: 0, reasoning: 0 },
319
467
  startedAt: new Date().toISOString(), endedAt: null,
320
468
  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;
469
+ delivery_complete: false, delivery_missing: [], delivery_metadata: null,
470
+ outcome: null,
471
+ lastAssistantText: null, handle_dispose_promise: null, disposeHandle: null,
472
+ // Abort latch: cancel()/timeout set this the moment the job becomes
473
+ // terminal, even while agents.create() is still pending, so a handle
474
+ // acquired after that point is disposed instead of being driven.
475
+ abortRequested: false,
476
+ abortPromise: null,
477
+ resolveAbort: null,
478
+ };
479
+ job.abortPromise = new Promise((resolve) => { job.resolveAbort = resolve; });
480
+ const disposeJobHandle = async () => {
481
+ if (!job.handle) return;
482
+ if (!job.handle_dispose_promise) {
483
+ try {
484
+ job.handle_dispose_promise = Promise.resolve(job.handle.dispose());
485
+ } catch (error) {
486
+ job.handle_dispose_promise = Promise.reject(error);
487
+ }
488
+ }
489
+ await job.handle_dispose_promise;
490
+ };
491
+ job.disposeHandle = disposeJobHandle;
331
492
  // Read-only pre-run snapshot (async, never blocks dispatch): the audit
332
493
  // only needs the before-state by the time the worker finishes. Non-repos
333
494
  // 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' }));
495
+ job.baseline = await captureWorkspaceBaseline({ cwd: executionCwd }).catch(() => ({ kind: 'no-git', reason: NOT_A_GIT_REPOSITORY, error: 'workspace audit failed' }));
335
496
  this.jobs.set(id, job);
336
497
 
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
498
  const onEvent = (session, event) => {
344
499
  switch (event.type) {
345
500
  case 'step/start':
@@ -355,9 +510,9 @@ export class WorkerRegistry { constructor(ctx) {
355
510
  job.tokens.output += u.outputTokens ?? 0;
356
511
  job.tokens.reasoning += u.reasoningTokens ?? 0;
357
512
  }
358
- const text = (event.data?.message?.content ?? [])
359
- .filter((c) => c?.type === 'text').map((c) => c.text).join('');
360
- if (text) job.lastAssistantText = text;
513
+ const text = (event.data?.message?.content ?? [])
514
+ .filter((c) => c?.type === 'text').map((c) => c.text).join('');
515
+ if (text) job.lastAssistantText = text;
361
516
  break;
362
517
  }
363
518
  case 'turn/end':
@@ -369,9 +524,9 @@ export class WorkerRegistry { constructor(ctx) {
369
524
  };
370
525
 
371
526
  const run = async () => {
372
- const handle = await this.ctx.agents.create({
527
+ const createPromise = this.ctx.agents.create({
373
528
  sessionId,
374
- meta: { cwd: executionCwd, ...(presetId === undefined ? {} : { agentPreset: presetId }) },
529
+ meta: { cwd: executionCwd, ...(presetId === undefined ? {} : { agentPreset: presetId }) },
375
530
  agentOptions: { provider: selection.provider, model: selection.model },
376
531
  setup: async (agentCtx) => {
377
532
  installModelSelection(agentCtx, { current: selection, assembled: undefined });
@@ -383,58 +538,90 @@ export class WorkerRegistry { constructor(ctx) {
383
538
  });
384
539
  },
385
540
  });
541
+ const created = await Promise.race([
542
+ createPromise.then((handle) => ({ handle })),
543
+ job.abortPromise.then(() => ({ aborted: true })),
544
+ ]);
545
+ if (created.aborted) {
546
+ createPromise.then(async (handle) => {
547
+ if (handle && (job.status !== 'running' || job.abortRequested)) {
548
+ job.handle = handle;
549
+ try { await job.disposeHandle(); } catch (error) {
550
+ job.cleanup_warning = `agent cleanup failed: ${error?.message ?? String(error)}`;
551
+ }
552
+ }
553
+ }).catch(() => {});
554
+ return;
555
+ }
556
+ const handle = created.handle;
386
557
  job.handle = handle;
558
+ // The job may have become terminal (cancel/timeout) while create() was
559
+ // pending; the old disposer was a no-op in that window. Dispose the
560
+ // freshly acquired handle and never deliver the prompt.
561
+ if (job.status !== 'running' || job.abortRequested) {
562
+ try { await job.disposeHandle(); } catch (error) {
563
+ job.cleanup_warning = `agent cleanup failed: ${error?.message ?? String(error)}`;
564
+ }
565
+ return;
566
+ }
387
567
  try {
388
568
  // Group the worker session under the workspace of its cwd; create the
389
569
  // workspace when none exists yet (resolveByPath is exact-match, so a
390
570
  // job cwd never inherits a parent directory's workspace).
391
571
  const registry = this.ctx.get('workspaceRegistry');
392
572
  if (registry !== undefined) {
393
- const ws = (await registry.resolveByPath(executionCwd)) ?? (await registry.create(executionCwd));
573
+ const ws = (await registry.resolveByPath(executionCwd)) ?? (await registry.create(executionCwd));
394
574
  await ws.attachSession(sessionId);
395
575
  }
396
576
  } catch (err) {
397
- this.ctx.logger?.warn?.(`dsh-crew: workspace attach failed for ${executionCwd}: ${err?.message ?? err}`);
577
+ this.ctx.logger?.warn?.(`dsh-crew: workspace attach failed for ${executionCwd}: ${err?.message ?? err}`);
398
578
  }
399
579
  await handle.agent.whenIdle();
580
+ // Cancel/timeout may have become terminal while waiting for the initial
581
+ // idle boundary; do not deliver the prompt to a job that is no longer
582
+ // running. The shared finally block still disposes the handle exactly once.
583
+ if (job.status !== 'running' || job.abortRequested) return;
400
584
  handle.agent.followup(userMessage(job.prompt));
401
585
  await handle.agent.whenIdle();
402
- job.result = job.lastAssistantText ?? '';
403
- if (job.status === 'running') job.status = job.stopReason === 'completed' ? 'done' : 'failed';
586
+ job.result = job.lastAssistantText ?? '';
587
+ if (job.status === 'running') job.status = job.stopReason === 'completed' ? 'done' : 'failed';
404
588
  if (job.status === 'failed' && !job.error) job.error = `turn ended: ${job.stopReason ?? 'unknown'}`;
405
589
  await this.ctx.sessions.flush(handle.agent.session);
406
590
  };
407
591
 
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()
592
+ const timeoutMs = Number.isInteger(timeout_seconds) && timeout_seconds > 0 ? timeout_seconds * 1000 : null;
593
+ if (timeoutMs) {
594
+ job.timeoutHandle = setTimeout(async () => {
595
+ if (job.status !== 'running') return;
596
+ job.abortRequested = true;
597
+ job.status = 'failed';
598
+ job.phase = JOB_PHASES.FAILED;
599
+ job.error = `job timed out after ${timeout_seconds}s`;
600
+ job.error_code = 'JOB_TIMEOUT';
601
+ job.endedAt = new Date().toISOString();
602
+ job.resolveAbort?.();
603
+ this.publish();
604
+ for (const waiter of job.waiters.splice(0)) waiter();
605
+ try { await disposeJobHandle(); } catch (error) {
606
+ job.cleanup_warning = `agent cleanup failed: ${error?.message ?? String(error)}`;
607
+ this.publish();
608
+ }
609
+ }, timeoutMs);
610
+ job.timeoutHandle.unref?.();
611
+ }
612
+ job.promise = run()
426
613
  .catch((err) => {
427
614
  if (job.status === 'running') job.status = 'failed';
428
615
  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
- }
616
+ })
617
+ .finally(async () => {
618
+ if (job.timeoutHandle) clearTimeout(job.timeoutHandle);
619
+ job.endedAt = new Date().toISOString();
620
+ job.currentTool = null;
621
+ let handleCleanupWarning = job.cleanup_warning ?? null;
622
+ try { await disposeJobHandle(); } catch (error) {
623
+ handleCleanupWarning = `agent cleanup failed: ${error?.message ?? String(error)}`;
624
+ }
438
625
  // Delivery completeness is separate from execution status: a job can
439
626
  // be done yet fail to report Diff/Tests/Risks (or Review sections for
440
627
  // an automatic review). Parse whatever final message the worker
@@ -442,21 +629,21 @@ export class WorkerRegistry { constructor(ctx) {
442
629
  const parsed = parseDeliveryReport(job.result ?? '');
443
630
  job.delivery_complete = parsed.complete;
444
631
  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
- }
632
+ job.delivery_metadata = formatDeliveryMetadata(parsed);
633
+ if (job.role === 'reviewer') {
634
+ const verdictText = String(parsed.sections?.Verdict ?? '').trim().toLowerCase();
635
+ const verdict = /^(approve|approved|pass)\b/.test(verdictText)
636
+ ? 'approve'
637
+ : /request.*chang|chang.*request|reject|needs changes/i.test(verdictText)
638
+ ? 'request_changes'
639
+ : 'inconclusive';
640
+ const lines = (value) => String(value ?? '').split(/\r?\n/).map((line) => line.trim()).filter(Boolean).slice(0, 40);
641
+ job.review = {
642
+ verdict, status: job.status, delivery_complete: parsed.complete === true,
643
+ findings: lines(parsed.sections?.['Review Findings']),
644
+ evidence: lines(parsed.sections?.Evidence), risks: lines(parsed.sections?.Risks),
645
+ };
646
+ }
460
647
  // Canonical structured outcome (shared workflow layer) + terminal phase.
461
648
  job.outcome = buildOutcome({
462
649
  result: job.result ?? '',
@@ -467,9 +654,9 @@ export class WorkerRegistry { constructor(ctx) {
467
654
  });
468
655
  job.phase = job.status === 'done' ? JOB_PHASES.COMPLETED : job.status === 'cancelled' ? JOB_PHASES.CANCELLED : JOB_PHASES.FAILED;
469
656
  // 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;
657
+ job.workspaceDiff = job.baseline.kind === 'git'
658
+ ? await captureWorkspaceDiff({ cwd: executionCwd, baseline: job.baseline }).catch(() => ({ kind: 'no-git', reason: NOT_A_GIT_REPOSITORY, error: 'workspace diff failed' }))
659
+ : job.baseline;
473
660
  job.outcome = applyHubWorkspaceEvidence({
474
661
  outcome: job.outcome,
475
662
  workspaceDiff: job.workspaceDiff,
@@ -477,23 +664,32 @@ export class WorkerRegistry { constructor(ctx) {
477
664
  isolation: job.isolation,
478
665
  role: job.role,
479
666
  });
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;
667
+ // Record only sanitized callability evidence. Cancelled jobs are
668
+ // operator actions, not provider health signals.
669
+ if (job.status !== 'cancelled') {
670
+ this.healthStore.record(job.provider, job.model, {
671
+ ok: job.status === 'done',
672
+ observed_at: Date.parse(job.endedAt ?? '') || Date.now(),
673
+ error: { code: job.error_code, message: job.error },
674
+ });
496
675
  }
676
+ if (job.role === 'reviewer' && job.review && job.workspaceDiff?.kind === 'git') {
677
+ const changes = job.workspaceDiff.changes ?? {};
678
+ const mutated = ['modified', 'deleted', 'renamed', 'untracked'].some((key) => Array.isArray(changes[key]) && changes[key].length > 0);
679
+ if (mutated) {
680
+ job.review.mutated_candidate = true;
681
+ job.review.invalidated = true;
682
+ job.review.verdict = 'request_changes';
683
+ }
684
+ }
685
+ if (job.isolatedWorkspace) {
686
+ const cleanup = await cleanupIsolatedWorkspace(job.isolatedWorkspace).catch((error) => ({ ok: false, error: error?.message ?? String(error) }));
687
+ job.workspace_retained = cleanup.ok !== true;
688
+ const workspaceCleanupWarning = cleanup.ok === true ? null : cleanup.error ?? 'worktree cleanup failed';
689
+ job.cleanup_warning = [handleCleanupWarning, workspaceCleanupWarning].filter(Boolean).join('; ') || null;
690
+ } else {
691
+ job.cleanup_warning = handleCleanupWarning;
692
+ }
497
693
  this.publish();
498
694
  for (const w of job.waiters.splice(0)) w();
499
695
  });
@@ -506,66 +702,49 @@ export class WorkerRegistry { constructor(ctx) {
506
702
  const job = this.jobs.get(id);
507
703
  if (!job) return undefined;
508
704
  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
- ]);
705
+ await raceWaiters(job.waiters, { timeoutMs, immediateWithoutTimeout: true });
513
706
  return job;
514
707
  }
515
708
 
516
- async cancel(id) {
709
+ async cancel(id) {
517
710
  const job = this.jobs.get(id);
518
711
  if (!job) return undefined;
519
712
  if (job.status === 'running') {
713
+ job.abortRequested = true;
520
714
  job.status = 'cancelled';
521
715
  job.phase = JOB_PHASES.CANCELLED;
522
716
  job.error = 'cancelled by request';
523
- try { await job.disposeHandle?.(); } catch (error) {
524
- job.cleanup_warning = `agent cleanup failed: ${error?.message ?? String(error)}`;
525
- }
717
+ job.resolveAbort?.();
718
+ for (const waiter of job.waiters.splice(0)) waiter();
719
+ try { await job.disposeHandle?.(); } catch (error) {
720
+ job.cleanup_warning = `agent cleanup failed: ${error?.message ?? String(error)}`;
721
+ }
526
722
  this.publish();
527
723
  }
528
724
  return job;
529
725
  }
530
726
 
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
- }
727
+ async dispose() {
728
+ for (const job of this.jobs.values()) {
729
+ if (job.status === 'running') {
730
+ try { await job.disposeHandle?.(); } catch (error) {
731
+ job.cleanup_warning = `agent cleanup failed: ${error?.message ?? String(error)}`;
732
+ }
733
+ }
734
+ }
735
+ }
540
736
  }
541
737
 
542
738
  // ---------- loopback route helpers (pattern from dsh-noema) ----------
543
739
 
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));
740
+ // The common trust core and its Origin policies live in one shared module so
741
+ // the hub and the 3080 bridge cannot drift apart (see local-request-guard.mjs).
742
+ export function isLoopbackRequest(req) {
743
+ if (!localRequestCore(req)) return false;
744
+ const origin = req.headers?.origin;
745
+ if (origin === undefined) return true;
746
+ return originLoopback(origin);
551
747
  }
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
748
  function sendJson(res, status, value, headers = {}) {
570
749
  const body = JSON.stringify(value);
571
750
  res.writeHead(status, {
@@ -607,102 +786,103 @@ async function readBody(req, limit = 64 * 1024) {
607
786
  * Returns { ok: true, payload } (payload.tier = effective tier) or
608
787
  * { ok: false, code, error }.
609
788
  */
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
- }
789
+ export function resolveHubSpawnPayload(payload, getConfig = () => ({}), dependencies = {}) {
790
+ const config = normalizeGlobalConfig(getConfig());
791
+ const raw = payload ?? {};
792
+ const advanced = raw.profile !== undefined || raw.workspace_id !== undefined || raw.workspace !== undefined
793
+ || raw.constraints !== undefined || raw.context_refs !== undefined || raw.job_id !== undefined || raw.objective !== undefined;
794
+ let normalized = { ...raw };
795
+ if (advanced) {
796
+ if (raw.workspace !== undefined && (!raw.workspace || typeof raw.workspace !== 'object' || Array.isArray(raw.workspace))) {
797
+ return { ok: false, code: 'WORKSPACE_REQUEST_INVALID', error: 'workspace must be an object' };
798
+ }
799
+ if (raw.constraints !== undefined && (!raw.constraints || typeof raw.constraints !== 'object' || Array.isArray(raw.constraints))) {
800
+ return { ok: false, code: 'JOB_CONSTRAINTS_INVALID', error: 'constraints must be an object' };
801
+ }
802
+ if (raw.workspace?.repo_root !== undefined && typeof raw.workspace.repo_root !== 'string') {
803
+ return { ok: false, code: 'WORKSPACE_REQUEST_INVALID', error: 'workspace repo_root must be a string' };
804
+ }
805
+ if (raw.workspace?.worktree !== undefined && !['auto', 'existing', 'none'].includes(raw.workspace.worktree)) {
806
+ return { ok: false, code: 'WORKSPACE_REQUEST_INVALID', error: 'invalid worktree policy' };
807
+ }
808
+ if (raw.workspace?.branch !== undefined && !isSafeBranchName(raw.workspace.branch)) {
809
+ return { ok: false, code: 'WORKSPACE_REQUEST_INVALID', error: 'invalid workspace branch' };
810
+ }
811
+ if (raw.constraints?.timeout_seconds !== undefined && (!Number.isInteger(raw.constraints.timeout_seconds) || raw.constraints.timeout_seconds < 1 || raw.constraints.timeout_seconds > 7200)) {
812
+ return { ok: false, code: 'JOB_CONSTRAINTS_INVALID', error: 'invalid timeout_seconds' };
813
+ }
814
+ if (raw.constraints?.allow_fallback !== undefined && typeof raw.constraints.allow_fallback !== 'boolean') {
815
+ return { ok: false, code: 'JOB_CONSTRAINTS_INVALID', error: 'allow_fallback must be boolean' };
816
+ }
817
+ if (raw.constraints?.allow_no_changes !== undefined && typeof raw.constraints.allow_no_changes !== 'boolean') {
818
+ return { ok: false, code: 'JOB_CONSTRAINTS_INVALID', error: 'allow_no_changes must be boolean' };
819
+ }
820
+ const profileRegistry = dependencies.profileRegistry ?? loadRoleProfiles();
821
+ const workspaceRegistry = dependencies.workspaceRegistry ?? loadWorkspaceContexts();
822
+ if (!profileRegistry.ok) return { ok: false, code: 'PROFILE_FILE_INVALID', error: 'role profile registry is invalid' };
823
+ if (!workspaceRegistry.ok) return { ok: false, code: 'WORKSPACE_CONTEXT_FILE_INVALID', error: 'workspace registry is invalid' };
824
+ const profileRole = raw.profile ? profileRegistry.profiles?.[raw.profile]?.role : undefined;
825
+ const requestedRole = raw.role ?? profileRole ?? 'worker';
826
+ const resolvedProfile = resolveRoleProfile(profileRegistry, raw.profile, requestedRole);
827
+ if (!resolvedProfile.ok) return { ok: false, code: resolvedProfile.code, error: resolvedProfile.code };
828
+ const knownContext = raw.workspace_id ? workspaceRegistry.contexts?.[raw.workspace_id] : null;
829
+ const cwd = raw.workspace?.repo_root ?? raw.cwd ?? knownContext?.repo_root;
830
+ if (!cwd) return { ok: false, code: 'WORKSPACE_CONTEXT_NOT_FOUND', error: 'workspace repo_root or cwd is required' };
831
+ const workspace = resolveWorkspaceContext(workspaceRegistry, { workspace_id: raw.workspace_id, cwd });
832
+ if (!workspace.ok) return { ok: false, code: workspace.code, error: workspace.code };
833
+ const withRefs = addContextReferences(workspace.context, raw.context_refs, { cwd });
834
+ if (!withRefs.ok) return { ok: false, code: withRefs.code, error: withRefs.code };
835
+ const profile = resolvedProfile.profile;
836
+ const worktree = raw.workspace?.worktree;
837
+ const requestedIsolation = worktree === 'auto' ? 'worktree'
838
+ : worktree === 'existing' || worktree === 'none' ? 'shared'
839
+ : profile.isolation;
840
+ const objective = raw.objective ?? raw.task;
841
+ if (typeof objective !== 'string' || objective.trim() === '') return { ok: false, code: 'JOB_OBJECTIVE_REQUIRED', error: 'task or objective is required' };
842
+ if (raw.job_id !== undefined && !/^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/.test(raw.job_id)) {
843
+ return { ok: false, code: 'JOB_ID_INVALID', error: 'invalid client job id' };
844
+ }
845
+ const contextTask = buildWorkspaceTask(objective, withRefs.context);
846
+ normalized = {
847
+ ...raw,
848
+ task: profile.review_strictness === 'strict' && requestedRole === 'reviewer'
849
+ ? `${contextTask}\n\nSTRICT REVIEW: fail closed on missing direct code or test evidence.`
850
+ : contextTask,
851
+ cwd,
852
+ role: requestedRole,
853
+ client_job_id: raw.job_id ?? null,
854
+ requested_isolation: requestedIsolation,
855
+ workspace_branch: raw.workspace?.branch ?? withRefs.context?.default_branch ?? null,
856
+ timeout_seconds: raw.constraints?.timeout_seconds ?? profile.timeout_seconds,
857
+ allow_fallback: raw.constraints?.allow_fallback ?? profile.fallback,
858
+ allow_no_changes: raw.constraints?.allow_no_changes === true,
859
+ routing: profile.routing,
860
+ review_strictness: profile.review_strictness,
861
+ profile_id: resolvedProfile.profile_id,
862
+ workspace_context: withRefs.context,
863
+ };
864
+ for (const key of ['objective', 'job_id', 'profile', 'workspace_id', 'workspace', 'constraints', 'context_refs']) delete normalized[key];
865
+ }
866
+ // v0.2 role-based dispatch: reviewer / worker are gated by their role state,
867
+ // and the tier slot is derived from the role (reviewer always → pro).
868
+ if (normalized.role === 'worker' || normalized.role === 'reviewer') {
869
+ const hint = resolveRoleTierHint(normalized.role, normalized.tier);
870
+ if (!hint.ok) return { ok: false, code: hint.code, error: hint.error };
871
+ const decision = canDispatchRole(config, normalized.role, true, {});
872
+ if (!decision.ok) return { ok: false, code: decision.error.policyCode, error: decision.error.message };
873
+ return { ok: true, payload: { ...normalized, role: normalized.role, tier: hint.tier } };
874
+ }
875
+ const decision = chooseDefaultTier(config, normalized.tier, {});
876
+ if (!decision.ok) return { ok: false, code: decision.error.policyCode, error: decision.error.message };
877
+ return { ok: true, payload: { ...normalized, tier: decision.tier } };
878
+ }
700
879
 
701
880
  // ---------- plugin entry ----------
702
881
 
703
- export async function apply(ctx) {
704
- seedLangFromHost(ctx);
705
- const hub = new WorkerRegistry(ctx);
882
+ export async function apply(ctx) {
883
+ seedLangFromHost(ctx);
884
+ const hub = new WorkerRegistry(ctx);
885
+ const builtInProviderProbe = createProviderProbe(ctx);
706
886
  try {
707
887
  const { readGlobalConfig } = await import('../install/install.mjs');
708
888
  hub.getConfig = () => readGlobalConfig();
@@ -712,7 +892,13 @@ export async function apply(ctx) {
712
892
  // enforced its own), so the check uses the global config only. The route
713
893
  // calls resolveHubSpawnPayload (above) to stamp the effective tier onto
714
894
  // the spawn payload.
715
- const disposers = [];
895
+ const disposers = [];
896
+ const PROVIDER_DELETE_PLAN_TTL_MS = 10 * 60 * 1000;
897
+ const providerDeletePlans = new Map();
898
+ const rememberProviderDeletePlan = (plan) => {
899
+ providerDeletePlans.set(plan.plan_id, { plan, created_at: Date.now() });
900
+ while (providerDeletePlans.size > 32) providerDeletePlans.delete(providerDeletePlans.keys().next().value);
901
+ };
716
902
 
717
903
  // Multimodal bridge: register describe_image / generate_image for the DS
718
904
  // model. Config is read per call so settings-page edits apply live; the
@@ -770,29 +956,29 @@ export async function apply(ctx) {
770
956
  const foreign = readMergedStatus({ excludeWriter: hub.shard.writer });
771
957
  return sendJson(res, 200, { ok: true, jobs: [...own, ...foreign] });
772
958
  }
773
- if (req.method === 'GET' && parts.length === 1) {
959
+ if (req.method === 'GET' && parts.length === 1) {
774
960
  const wait = Number(url.searchParams.get('wait') ?? 0);
775
961
  const job = await hub.wait(parts[0], Math.min(wait, 600) * 1000);
776
962
  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
- }
963
+ return sendJson(res, 200, { ok: true, job: hub.view(job, true) });
964
+ }
965
+ if (req.method === 'GET' && parts.length === 2 && parts[1] === 'events') {
966
+ const job = hub.jobs.get(parts[0]);
967
+ if (!job) return sendJson(res, 404, { ok: false, error: 'no such job' });
968
+ const after = Math.max(0, Number(url.searchParams.get('after') ?? 0) || 0);
969
+ const events = hubCanonicalEvents(job).filter((event) => event.sequence > after);
970
+ return sendJson(res, 200, {
971
+ ok: true, job_id: job.id, events,
972
+ event_cursor: hubCanonicalEvents(job).at(-1)?.sequence ?? 0,
973
+ });
974
+ }
975
+ if (req.method === 'GET' && parts.length === 2 && parts[1] === 'contract') {
976
+ const job = hub.jobs.get(parts[0]);
977
+ if (!job) return sendJson(res, 404, { ok: false, error: 'no such job' });
978
+ const detail = url.searchParams.get('detail') === 'full' ? 'full' : 'compact';
979
+ const afterSequence = Math.max(0, Number(url.searchParams.get('after') ?? 0) || 0);
980
+ return sendJson(res, 200, { ok: true, job: projectWorkflowView(hub.view(job, true), { detail, afterSequence }) });
981
+ }
796
982
  if (req.method === 'POST' && parts.length === 0) {
797
983
  const payload = await readBody(req);
798
984
  // Same policy resolver as the MCP server (src/server.mjs), with the
@@ -816,90 +1002,90 @@ export async function apply(ctx) {
816
1002
  if (code) body.code = code;
817
1003
  return sendJson(res, 400, body);
818
1004
  }
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 });
884
- const liveJobs = typeof hub.list === 'function' ? hub.list() : [];
885
- 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,
896
- runtime: getHubRuntimeIdentity(),
897
- });
898
- return sendJson(res, 200, { ok: true, extension: contract });
899
- },
900
- }));
901
-
902
- disposers.push(webServer.register({
1005
+ },
1006
+ }));
1007
+
1008
+ disposers.push(webServer.register({
1009
+ kind: 'exact', path: `${ROUTE_BASE}/profiles`,
1010
+ handler: async (req, res) => {
1011
+ if (!isLoopbackRequest(req)) return sendJson(res, 403, { ok: false, error: 'loopback only' });
1012
+ if (req.method === 'GET') return sendJson(res, 200, { ok: true, ...loadRoleProfiles() });
1013
+ if (req.method === 'POST') {
1014
+ try {
1015
+ const saved = saveRoleProfiles(await readBody(req));
1016
+ return sendJson(res, saved.ok ? 200 : 400, { ...saved });
1017
+ } catch {
1018
+ return sendJson(res, 400, { ok: false, error: 'invalid JSON', code: 'PROFILE_FILE_INVALID' });
1019
+ }
1020
+ }
1021
+ return sendJson(res, 405, { ok: false, error: 'GET or POST' }, { allow: 'GET, POST' });
1022
+ },
1023
+ }));
1024
+
1025
+ disposers.push(webServer.register({
1026
+ kind: 'exact', path: `${ROUTE_BASE}/workspaces`,
1027
+ handler: async (req, res) => {
1028
+ if (!isLoopbackRequest(req)) return sendJson(res, 403, { ok: false, error: 'loopback only' });
1029
+ if (req.method === 'GET') return sendJson(res, 200, { ok: true, ...loadWorkspaceContexts() });
1030
+ if (req.method === 'POST') {
1031
+ try {
1032
+ const saved = saveWorkspaceContexts(await readBody(req));
1033
+ return sendJson(res, saved.ok ? 200 : 400, { ...saved });
1034
+ } catch {
1035
+ return sendJson(res, 400, { ok: false, error: 'invalid JSON', code: 'WORKSPACE_CONTEXT_FILE_INVALID' });
1036
+ }
1037
+ }
1038
+ return sendJson(res, 405, { ok: false, error: 'GET or POST' }, { allow: 'GET, POST' });
1039
+ },
1040
+ }));
1041
+
1042
+ disposers.push(webServer.register({
1043
+ kind: 'exact', path: `${ROUTE_BASE}/extension`,
1044
+ handler: async (req, res) => {
1045
+ if (!isLoopbackRequest(req)) return sendJson(res, 403, { ok: false, error: 'loopback only' });
1046
+ if (req.method !== 'GET') return sendJson(res, 405, { ok: false, error: 'GET only' }, { allow: 'GET' });
1047
+ const config = normalizeGlobalConfig(hub.getConfig?.() ?? {});
1048
+ let catalogStatus = { id: 'provider_catalog', status: 'NOT_RUN', reason_code: 'PROVIDER_MODE_UNKNOWN' };
1049
+ if (normalizeWorkerProviderMode(config.worker_provider_mode) === 'deepseek-official') {
1050
+ catalogStatus = { id: 'provider_catalog', status: 'SKIP', reason_code: 'PROVIDER_CATALOG_NOT_REQUIRED' };
1051
+ } else {
1052
+ try {
1053
+ await readHarnessModelCatalog({
1054
+ llm: ctx.llm ?? ctx.get('llm'),
1055
+ getCurrentSelection: () => ctx.get('agentDefaultModel')?.currentSelection?.(),
1056
+ });
1057
+ catalogStatus = { id: 'provider_catalog', status: 'PASS', reason_code: 'PROVIDER_CATALOG_RESOLVED' };
1058
+ } catch {
1059
+ catalogStatus = { id: 'provider_catalog', status: 'FAIL', reason_code: 'PROVIDER_CATALOG_UNAVAILABLE' };
1060
+ }
1061
+ }
1062
+ const profiles = loadRoleProfiles();
1063
+ const requestUrl = new URL(req.url, 'http://localhost');
1064
+ const requestedWorkspaceId = requestUrl.searchParams.get('workspace_id');
1065
+ const workspaceRegistry = loadWorkspaceContexts();
1066
+ const requestedContext = requestedWorkspaceId ? workspaceRegistry.contexts?.[requestedWorkspaceId] : null;
1067
+ const workspaceReadiness = requestedWorkspaceId && !requestedContext
1068
+ ? { status: 'UNAVAILABLE', reason_code: 'WORKSPACE_CONTEXT_NOT_FOUND' }
1069
+ : await assessWorkspaceReadiness({ cwd: requestedContext?.repo_root ?? null });
1070
+ const liveJobs = typeof hub.list === 'function' ? hub.list() : [];
1071
+ const [modelExecution, reviewerExecution] = buildHubExecutionRows(liveJobs);
1072
+ const contract = buildExtensionContract({
1073
+ config,
1074
+ readinessMatrix: { rows: [
1075
+ { id: 'hub_compatibility', status: 'PASS', reason_code: 'LIVE_CHECK_PASSED' },
1076
+ catalogStatus,
1077
+ modelExecution,
1078
+ reviewerExecution,
1079
+ ] },
1080
+ workspace: workspaceReadiness,
1081
+ profiles,
1082
+ runtime: getHubRuntimeIdentity(),
1083
+ });
1084
+ return sendJson(res, 200, { ok: true, extension: contract });
1085
+ },
1086
+ }));
1087
+
1088
+ disposers.push(webServer.register({
903
1089
  kind: 'exact', path: `${ROUTE_BASE}/config`,
904
1090
  handler: async (req, res) => {
905
1091
  if (!isLoopbackRequest(req)) return sendJson(res, 403, { ok: false, error: 'loopback only' });
@@ -936,13 +1122,15 @@ export async function apply(ctx) {
936
1122
  },
937
1123
  }));
938
1124
 
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);
1125
+ disposers.push(webServer.register({
1126
+ kind: 'exact', path: `${ROUTE_BASE}/provider`,
1127
+ handler: async (req, res) => {
1128
+ if (!isLoopbackRequest(req)) return sendJson(res, 403, { ok: false, error: 'loopback only' });
1129
+ try {
1130
+ const config = normalizeGlobalConfig(hub.getConfig?.() ?? {});
1131
+ const lifecycleState = readProviderLifecycleState();
1132
+ const healthGate = config.health_gate ?? config.worker?.model_policy?.health_gate;
1133
+ const mode = normalizeWorkerProviderMode(config.worker_provider_mode);
946
1134
  let selections;
947
1135
  if (mode === 'deepseek-official') {
948
1136
  selections = {
@@ -960,10 +1148,14 @@ export async function apply(ctx) {
960
1148
  tier,
961
1149
  priority: config[`${tier}_model_priority`],
962
1150
  priorityConfigured: config[`${tier}_model_priority_configured`],
963
- fallback: config[`${tier}_model_fallback`],
964
- catalog,
965
- harnessDefault: catalog.harness_default,
966
- });
1151
+ fallback: config[`${tier}_model_fallback`],
1152
+ catalog,
1153
+ harnessDefault: catalog.harness_default,
1154
+ healthStore: hub.healthStore,
1155
+ healthGate,
1156
+ allowFallback: config.allow_fallback !== false,
1157
+ tombstones: lifecycleState.tombstones,
1158
+ });
967
1159
  selections[tier] = selected.ok
968
1160
  ? { provider: selected.provider, model: selected.model, source: selected.source }
969
1161
  : { code: selected.code, error: selected.message };
@@ -978,11 +1170,228 @@ export async function apply(ctx) {
978
1170
  } catch (err) {
979
1171
  return sendJson(res, 503, { ok: false, code: 'MODEL_CATALOG_UNAVAILABLE', error: 'Unable to resolve Harness worker models.' });
980
1172
  }
981
- },
982
- }));
983
-
984
- disposers.push(webServer.register({
985
- kind: 'exact', path: `${ROUTE_BASE}/models`,
1173
+ },
1174
+ }));
1175
+
1176
+ // Secret-free Harness provider inventory. This is deliberately separate
1177
+ // from /models (catalog) and /provider (effective routing): it reports
1178
+ // declaration provenance, ownership, lifecycle state and policy refs.
1179
+ disposers.push(webServer.register({
1180
+ kind: 'exact', path: `${ROUTE_BASE}/providers`,
1181
+ handler: async (req, res) => {
1182
+ if (!isLoopbackRequest(req)) return sendJson(res, 403, { ok: false, error: 'loopback only' });
1183
+ if (req.method !== 'GET') return sendJson(res, 405, { ok: false, error: 'GET only' }, { allow: 'GET' });
1184
+ try {
1185
+ const config = normalizeGlobalConfig(hub.getConfig?.() ?? {});
1186
+ const inventory = await readProviderInventorySnapshot(hub, ctx, config);
1187
+ return sendJson(res, 200, {
1188
+ ok: true,
1189
+ ...inventory,
1190
+ runtime: getHubRuntimeIdentity(),
1191
+ });
1192
+ } catch {
1193
+ return sendJson(res, 503, { ok: false, code: 'PROVIDER_INVENTORY_UNAVAILABLE', error: 'Unable to read Harness provider inventory.' });
1194
+ }
1195
+ },
1196
+ }));
1197
+
1198
+ // Deletion is two-phase: this endpoint only computes a revision-bound,
1199
+ // secret-free impact plan. A later mutation endpoint must present that
1200
+ // plan and perform restart/verification through an owned supervisor.
1201
+ disposers.push(webServer.register({
1202
+ kind: 'prefix', path: `${ROUTE_BASE}/providers`,
1203
+ handler: async (req, res) => {
1204
+ if (!isLoopbackRequest(req)) return sendJson(res, 403, { ok: false, error: 'loopback only' });
1205
+ let url;
1206
+ try { url = new URL(req.url, 'http://localhost'); } catch { return sendJson(res, 400, { ok: false, code: 'PROVIDER_PLAN_INVALID' }); }
1207
+ const parts = url.pathname.slice(`${ROUTE_BASE}/providers`.length).split('/').filter(Boolean);
1208
+ try {
1209
+ const providerId = decodeURIComponent(parts[0]);
1210
+ const body = await readBody(req);
1211
+ const config = normalizeGlobalConfig(hub.getConfig?.() ?? {});
1212
+ const inventory = await readProviderInventorySnapshot(hub, ctx, config);
1213
+ if (req.method === 'POST' && parts.length === 2 && parts[1] === 'delete-plan') {
1214
+ const profile = readProviderProfileRevision();
1215
+ if (!profile.ok) return sendJson(res, 409, { ok: false, code: profile.code });
1216
+ const planned = planProviderDelete({
1217
+ providerId,
1218
+ inventory,
1219
+ activeJobs: hub.list(),
1220
+ replacementDefault: body?.replacement_default,
1221
+ expectedRevision: profile.revision,
1222
+ });
1223
+ if (!planned.ok) return sendJson(res, 409, { ok: false, code: planned.code });
1224
+ rememberProviderDeletePlan(planned.plan);
1225
+ return sendJson(res, 200, { ok: true, profile_revision: profile.revision, plan: planned.plan });
1226
+ }
1227
+ if (req.method === 'POST' && parts.length === 2 && parts[1] === 'probe') {
1228
+ const record = inventory.records.find((entry) => entry.id === providerId);
1229
+ if (!record) return sendJson(res, 404, { ok: false, code: 'PROVIDER_NOT_FOUND' });
1230
+ if (record.desired_state === 'absent') return sendJson(res, 409, { ok: false, code: 'PROVIDER_TOMBSTONED' });
1231
+ const model = selectProviderProbeModel({ providerId, record, config });
1232
+ if (!model) return sendJson(res, 409, { ok: false, code: 'PROVIDER_PROBE_MODEL_UNAVAILABLE' });
1233
+ let explicitProviderProbe;
1234
+ try { explicitProviderProbe = ctx?.providerProbe; } catch { explicitProviderProbe = undefined; }
1235
+ const providerProbe = typeof explicitProviderProbe === 'function' ? explicitProviderProbe : builtInProviderProbe;
1236
+ if (typeof providerProbe !== 'function') return sendJson(res, 503, { ok: false, code: 'PROVIDER_PROBE_UNAVAILABLE' });
1237
+ let observed;
1238
+ let timer;
1239
+ try {
1240
+ const timeout = new Promise((resolve) => { timer = setTimeout(() => resolve({ error: { name: 'TimeoutError' } }), 15_000); });
1241
+ observed = await Promise.race([
1242
+ Promise.resolve().then(() => providerProbe({ provider: providerId, model, signal: AbortSignal.timeout(15_000) })),
1243
+ timeout,
1244
+ ]);
1245
+ } catch (error) {
1246
+ // Keep probe transport failures as bounded health evidence so a
1247
+ // credential/quota/timeout result is visible to routing policy;
1248
+ // never copy the raw provider error into the response.
1249
+ observed = { error };
1250
+ } finally {
1251
+ clearTimeout(timer);
1252
+ }
1253
+ const health = hub.healthStore.record(providerId, model, observed?.ok === true ? { ok: true } : { error: observed?.error ?? observed });
1254
+ return sendJson(res, 200, { ok: true, provider_id: providerId, model, health });
1255
+ }
1256
+ if (req.method === 'POST' && parts.length === 2 && parts[1] === 'verify-delete') {
1257
+ if (body?.confirm !== true) return sendJson(res, 400, { ok: false, code: 'PROVIDER_DELETE_VERIFY_CONFIRM_REQUIRED' });
1258
+ const { transaction_id: transactionId } = body ?? {};
1259
+ const hooks = createProviderDeleteFileHooks({
1260
+ profileFile: join(CONFIG_DIR, 'harness', 'profiles', 'dsh-crew', 'cordis.patch.yml'),
1261
+ configFile: join(CONFIG_DIR, 'config.json'),
1262
+ lifecycleFile: join(CONFIG_DIR, 'provider-lifecycle.json'),
1263
+ backupDir: join(CONFIG_DIR, 'provider-backups'),
1264
+ existingBackupId: transactionId,
1265
+ expectedProviderId: providerId,
1266
+ });
1267
+ const plan = hooks.backupPlan();
1268
+ if (!plan || plan.provider_id !== providerId) return sendJson(res, 409, { ok: false, code: 'PROVIDER_DELETE_PLAN_PROVIDER_MISMATCH' });
1269
+ await hooks.acquireLock();
1270
+ try {
1271
+ const verification = await hooks.verify(plan);
1272
+ if (verification?.providerAbsent !== true || verification?.routingClear !== true || verification?.tombstonePresent !== true) {
1273
+ return sendJson(res, 409, { ok: false, code: 'PROVIDER_DELETE_VERIFY_FAILED', verification });
1274
+ }
1275
+ await hooks.recordTransaction({ transaction_id: transactionId, provider_id: providerId, state: 'VERIFIED' }, plan);
1276
+ return sendJson(res, 200, { ok: true, transaction_id: transactionId, provider_id: providerId, state: 'VERIFIED', verification });
1277
+ } finally {
1278
+ await hooks.release();
1279
+ }
1280
+ }
1281
+ if (req.method === 'POST' && parts.length === 2 && parts[1] === 'rollback') {
1282
+ if (body?.confirm !== true) return sendJson(res, 400, { ok: false, code: 'PROVIDER_ROLLBACK_CONFIRM_REQUIRED' });
1283
+ const { transaction_id: transactionId } = body ?? {};
1284
+ const hooks = createProviderDeleteFileHooks({
1285
+ profileFile: join(CONFIG_DIR, 'harness', 'profiles', 'dsh-crew', 'cordis.patch.yml'),
1286
+ configFile: join(CONFIG_DIR, 'config.json'),
1287
+ lifecycleFile: join(CONFIG_DIR, 'provider-lifecycle.json'),
1288
+ backupDir: join(CONFIG_DIR, 'provider-backups'),
1289
+ existingBackupId: transactionId,
1290
+ expectedProviderId: providerId,
1291
+ });
1292
+ await hooks.acquireLock();
1293
+ try {
1294
+ const plan = hooks.backupPlan();
1295
+ if (!plan || plan.provider_id !== providerId) return sendJson(res, 409, { ok: false, code: 'PROVIDER_DELETE_PLAN_PROVIDER_MISMATCH' });
1296
+ await hooks.rollback(plan);
1297
+ try {
1298
+ await hooks.recordTransaction({ transaction_id: transactionId, provider_id: providerId, state: 'ROLLBACK_PENDING' }, plan);
1299
+ } catch (error) {
1300
+ return sendJson(res, 409, { ok: false, code: boundedMachineCodeFromError(error) ?? 'PROVIDER_LIFECYCLE_RECORD_FAILED' });
1301
+ }
1302
+ return sendJson(res, 202, { ok: true, restart_required: true, transaction_id: transactionId, provider_id: providerId, state: 'ROLLBACK_PENDING' });
1303
+ } finally {
1304
+ await hooks.release();
1305
+ }
1306
+ }
1307
+ if (req.method === 'POST' && parts.length === 2 && parts[1] === 'verify-rollback') {
1308
+ if (body?.confirm !== true) return sendJson(res, 400, { ok: false, code: 'PROVIDER_ROLLBACK_VERIFY_CONFIRM_REQUIRED' });
1309
+ const { transaction_id: transactionId } = body ?? {};
1310
+ const hooks = createProviderDeleteFileHooks({
1311
+ profileFile: join(CONFIG_DIR, 'harness', 'profiles', 'dsh-crew', 'cordis.patch.yml'),
1312
+ configFile: join(CONFIG_DIR, 'config.json'),
1313
+ lifecycleFile: join(CONFIG_DIR, 'provider-lifecycle.json'),
1314
+ backupDir: join(CONFIG_DIR, 'provider-backups'),
1315
+ existingBackupId: transactionId,
1316
+ expectedProviderId: providerId,
1317
+ });
1318
+ const plan = hooks.backupPlan();
1319
+ if (!plan || plan.provider_id !== providerId) return sendJson(res, 409, { ok: false, code: 'PROVIDER_DELETE_PLAN_PROVIDER_MISMATCH' });
1320
+ await hooks.acquireLock();
1321
+ try {
1322
+ const verified = await hooks.verifyRollback(plan);
1323
+ if (verified?.ok !== true) return sendJson(res, 409, { ok: false, code: 'PROVIDER_DELETE_ROLLBACK_VERIFY_FAILED' });
1324
+ await hooks.recordTransaction({ transaction_id: transactionId, provider_id: providerId, state: 'ROLLED_BACK' }, plan);
1325
+ return sendJson(res, 200, { ok: true, transaction_id: transactionId, provider_id: providerId, state: 'ROLLED_BACK' });
1326
+ } finally {
1327
+ await hooks.release();
1328
+ }
1329
+ }
1330
+ if (req.method === 'DELETE' && parts.length === 1) {
1331
+ if (body?.confirm !== true) return sendJson(res, 400, { ok: false, code: 'PROVIDER_DELETE_CONFIRM_REQUIRED' });
1332
+ if (body?.purge_orphan_credentials === true) {
1333
+ return sendJson(res, 400, { ok: false, code: 'CREDENTIAL_PURGE_REQUIRES_EXPLICIT_CONFIRMATION' });
1334
+ }
1335
+ const stored = providerDeletePlans.get(body?.plan_id);
1336
+ if (!stored || Date.now() - stored.created_at > PROVIDER_DELETE_PLAN_TTL_MS || stored.plan.provider_id !== providerId) {
1337
+ providerDeletePlans.delete(body?.plan_id);
1338
+ return sendJson(res, 409, { ok: false, code: 'PROVIDER_DELETE_PLAN_EXPIRED' });
1339
+ }
1340
+ const profile = readProviderProfileRevision();
1341
+ if (!profile.ok) return sendJson(res, 409, { ok: false, code: profile.code });
1342
+ if (body?.expected_revision !== stored.plan.expected_revision || profile.revision !== stored.plan.expected_revision) {
1343
+ return sendJson(res, 409, { ok: false, code: 'PROVIDER_PROFILE_CHANGED' });
1344
+ }
1345
+ const refreshed = planProviderDelete({
1346
+ providerId,
1347
+ inventory,
1348
+ activeJobs: hub.list(),
1349
+ replacementDefault: stored.plan.replacement_default,
1350
+ expectedRevision: profile.revision,
1351
+ });
1352
+ if (!refreshed.ok) return sendJson(res, 409, { ok: false, code: refreshed.code });
1353
+ if (refreshed.plan.replacement_default_model !== stored.plan.replacement_default_model) {
1354
+ return sendJson(res, 409, { ok: false, code: 'PROVIDER_DELETE_PLAN_CHANGED' });
1355
+ }
1356
+ const executionPlan = { ...refreshed.plan, plan_id: stored.plan.plan_id, expected_revision: stored.plan.expected_revision };
1357
+ const hooks = createProviderDeleteFileHooks({
1358
+ profileFile: join(CONFIG_DIR, 'harness', 'profiles', 'dsh-crew', 'cordis.patch.yml'),
1359
+ configFile: join(CONFIG_DIR, 'config.json'),
1360
+ lifecycleFile: join(CONFIG_DIR, 'provider-lifecycle.json'),
1361
+ backupDir: join(CONFIG_DIR, 'provider-backups'),
1362
+ });
1363
+ providerDeletePlans.delete(body.plan_id);
1364
+ const { executeProviderDelete } = await import('../provider-lifecycle.mjs');
1365
+ const result = await executeProviderDelete(executionPlan, hooks, { deferRestart: true });
1366
+ return sendJson(res, result.state === 'RESTART_PENDING' ? 202 : 409, { ok: result.state === 'RESTART_PENDING', restart_required: true, result });
1367
+ }
1368
+ return sendJson(res, 404, { ok: false, error: 'unknown providers endpoint' });
1369
+ } catch (err) {
1370
+ const isProbe = req.method === 'POST' && parts.length === 2 && parts[1] === 'probe';
1371
+ const code = boundedMachineCodeFromError(err) ?? (isProbe ? 'PROVIDER_PROBE_FAILED' : 'PROVIDER_PLAN_INVALID');
1372
+ const status = code === 'PROVIDER_DELETE_RESTART_SUPERVISOR_UNAVAILABLE' || code.startsWith('PROVIDER_PROBE') ? 503
1373
+ : code.startsWith('PROVIDER_') || code.startsWith('CREDENTIAL_') ? 409 : 400;
1374
+ return sendJson(res, status, { ok: false, code });
1375
+ }
1376
+ },
1377
+ }));
1378
+
1379
+ disposers.push(webServer.register({
1380
+ kind: 'exact', path: `${ROUTE_BASE}/provider-health`,
1381
+ handler: async (req, res) => {
1382
+ if (!isLoopbackRequest(req)) return sendJson(res, 403, { ok: false, error: 'loopback only' });
1383
+ if (req.method !== 'GET') return sendJson(res, 405, { ok: false, error: 'GET only' }, { allow: 'GET' });
1384
+ return sendJson(res, 200, {
1385
+ ok: true,
1386
+ schema_version: 1,
1387
+ health: hub.healthStore.list(),
1388
+ runtime: getHubRuntimeIdentity(),
1389
+ });
1390
+ },
1391
+ }));
1392
+
1393
+ disposers.push(webServer.register({
1394
+ kind: 'exact', path: `${ROUTE_BASE}/models`,
986
1395
  handler: async (req, res) => {
987
1396
  if (!isLoopbackRequest(req)) return sendJson(res, 403, { ok: false, error: 'loopback only' });
988
1397
  if (req.method !== 'GET' && req.method !== 'POST') return sendJson(res, 405, { ok: false, error: 'GET or POST' }, { allow: 'GET, POST' });
@@ -1058,8 +1467,8 @@ export async function apply(ctx) {
1058
1467
  // Cache-busted import: the installer must always run the code
1059
1468
  // currently on disk, not whatever this process first loaded —
1060
1469
  // 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()}`);
1470
+ const { installClaudeCode, installCodex, installHudSegment, uninstallClaudeCode, uninstallCodex, installZCode, uninstallZCode } =
1471
+ await import(`../install/install.mjs?t=${Date.now()}`);
1063
1472
  if (target === 'claude') {
1064
1473
  const base = await installClaudeCode({ statusline: !!statusline });
1065
1474
  const hud = installHudSegment({});
@@ -1068,12 +1477,12 @@ export async function apply(ctx) {
1068
1477
  actions: [...base.actions, ...hud.actions.map((a) => `hud: ${a}`)],
1069
1478
  });
1070
1479
  }
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' });
1480
+ if (target === 'codex') return sendJson(res, 200, installCodex({}));
1481
+ if (target === 'zcode') return sendJson(res, 200, installZCode({}));
1482
+ if (target === 'claude-uninstall') return sendJson(res, 200, uninstallClaudeCode({}));
1483
+ if (target === 'codex-uninstall') return sendJson(res, 200, uninstallCodex({}));
1484
+ if (target === 'zcode-uninstall') return sendJson(res, 200, uninstallZCode({}));
1485
+ return sendJson(res, 400, { ok: false, error: 'target must be claude | codex | zcode | claude-uninstall | codex-uninstall | zcode-uninstall' });
1077
1486
  } catch (err) {
1078
1487
  return sendJson(res, 500, { ok: false, error: err?.message ?? String(err) });
1079
1488
  }