@ran-sh/dsh-crew 0.3.6 → 0.3.8

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 (85) hide show
  1. package/.claude-plugin/plugin.json +8 -8
  2. package/.mcp.json +8 -8
  3. package/LICENSE +21 -21
  4. package/README.de.md +359 -359
  5. package/README.es.md +359 -359
  6. package/README.fr.md +359 -359
  7. package/README.hi.md +359 -359
  8. package/README.id.md +359 -359
  9. package/README.ja.md +359 -359
  10. package/README.ko.md +359 -359
  11. package/README.md +94 -180
  12. package/README.pt.md +359 -359
  13. package/README.ru.md +359 -359
  14. package/README.th.md +359 -359
  15. package/README.tr.md +359 -359
  16. package/README.vi.md +359 -359
  17. package/README.zh-TW.md +359 -359
  18. package/README.zh.md +94 -180
  19. package/agents/ds-flash.md +26 -26
  20. package/agents/ds-pro.md +32 -32
  21. package/agents/ds-reviewer.md +23 -23
  22. package/agents/ds-worker.md +22 -22
  23. package/codex/agents/ds-flash.toml +30 -30
  24. package/codex/agents/ds-pro.toml +31 -31
  25. package/codex/agents/ds-reviewer.toml +28 -28
  26. package/codex/agents/ds-worker.toml +28 -28
  27. package/codex/prompts/dsh-config.md +3 -3
  28. package/codex/prompts/dsh-status.md +1 -1
  29. package/commands/config.md +11 -11
  30. package/commands/off.md +5 -5
  31. package/commands/on.md +5 -5
  32. package/commands/status.md +5 -5
  33. package/cordis.patch.yml +4 -4
  34. package/lib/client.js +1655 -974
  35. package/official-web-bridge/cordis.patch.yml +4 -0
  36. package/official-web-bridge/entry.mjs +1 -0
  37. package/official-web-bridge/lib/client.js +3446 -0
  38. package/official-web-bridge/package.json +26 -0
  39. package/package.json +133 -131
  40. package/scripts/build-client.mjs +32 -28
  41. package/scripts/live-crew-smoke.mjs +39 -39
  42. package/scripts/live-policy-matrix.mjs +177 -177
  43. package/scripts/policy-probe.mjs +101 -101
  44. package/scripts/setup.mjs +284 -284
  45. package/scripts/smoke-real.mjs +110 -110
  46. package/scripts/smoke.mjs +78 -78
  47. package/scripts/verify-installer-fix.mjs +26 -26
  48. package/scripts/verify-official-bridge-e2e.mjs +159 -0
  49. package/src/adaptive-routing.mjs +260 -260
  50. package/src/client/activation-summary.tsx +64 -64
  51. package/src/client/collapsible-sections.mjs +55 -0
  52. package/src/client/entry.tsx +236 -236
  53. package/src/client/index.tsx +1213 -1120
  54. package/src/config-readiness.mjs +59 -59
  55. package/src/delivery.mjs +205 -205
  56. package/src/dsh-cli-runtime.mjs +458 -447
  57. package/src/failure-classification.mjs +172 -172
  58. package/src/hub/entry.mjs +98 -98
  59. package/src/hub-client.mjs +132 -132
  60. package/src/hub-compatibility.mjs +49 -49
  61. package/src/i18n.mjs +19 -19
  62. package/src/install/cli.mjs +28 -28
  63. package/src/install/install-legacy.mjs +462 -462
  64. package/src/install/install.mjs +451 -451
  65. package/src/install/npx-lifecycle.mjs +1119 -1055
  66. package/src/install/official-web.mjs +132 -0
  67. package/src/mcp-runtime.mjs +257 -257
  68. package/src/model-catalog.mjs +173 -173
  69. package/src/model-routing.mjs +391 -391
  70. package/src/official-web-bridge.mjs +196 -0
  71. package/src/policy.mjs +197 -197
  72. package/src/readiness-matrix.mjs +169 -169
  73. package/src/runtime-controls.mjs +90 -90
  74. package/src/runtime-identity.mjs +108 -108
  75. package/src/server.mjs +477 -477
  76. package/src/status-shard.mjs +52 -52
  77. package/src/structured-error-code.mjs +38 -38
  78. package/src/vision-route.mjs +138 -138
  79. package/src/workflow-runtime.mjs +573 -573
  80. package/src/workflow.mjs +160 -160
  81. package/src/workspace-audit.mjs +231 -231
  82. package/src/workspace-isolation.mjs +365 -365
  83. package/statusline/statusline.sh +14 -14
  84. package/statusline/worker-segment.sh +35 -35
  85. package/worker.cordis.yml +77 -77
@@ -0,0 +1,132 @@
1
+ import {
2
+ copyFileSync,
3
+ existsSync,
4
+ lstatSync,
5
+ mkdirSync,
6
+ readFileSync,
7
+ realpathSync,
8
+ unlinkSync,
9
+ writeFileSync,
10
+ } from 'node:fs';
11
+ import { homedir } from 'node:os';
12
+ import { dirname, join } from 'node:path';
13
+ import { ensurePluginRegistration, removeCrewPluginRegistration } from '../dsh-cli-runtime.mjs';
14
+
15
+ export const OFFICIAL_BRIDGE_PACKAGE = '@ran-sh/dsh-crew-web-bridge';
16
+ const STATE_FILENAME = 'official-web.json';
17
+
18
+ export function officialWebProfileDir({ home = homedir() } = {}) {
19
+ return join(home, '.dsh', 'profiles', 'web');
20
+ }
21
+
22
+ export function officialWebIntegrationStateFile({ home = homedir() } = {}) {
23
+ return join(home, '.config', 'dsh-crew', STATE_FILENAME);
24
+ }
25
+
26
+ function readState(home) {
27
+ try {
28
+ const value = JSON.parse(readFileSync(officialWebIntegrationStateFile({ home }), 'utf8'));
29
+ return value && typeof value === 'object' ? value : null;
30
+ } catch { return null; }
31
+ }
32
+
33
+ function writeState(home, value) {
34
+ const file = officialWebIntegrationStateFile({ home });
35
+ mkdirSync(dirname(file), { recursive: true });
36
+ writeFileSync(file, JSON.stringify(value, null, 2) + '\n');
37
+ }
38
+
39
+ function validOfficialManifest(file) {
40
+ if (!existsSync(file)) return { ok: false, code: 'OFFICIAL_WEB_PROFILE_NOT_FOUND' };
41
+ try {
42
+ const raw = readFileSync(file, 'utf8');
43
+ const manifest = JSON.parse(raw);
44
+ if (!manifest || typeof manifest !== 'object'
45
+ || (manifest.dependencies !== undefined && (!manifest.dependencies || typeof manifest.dependencies !== 'object' || Array.isArray(manifest.dependencies)))
46
+ || !Array.isArray(manifest.dsh?.profile?.bundles)) {
47
+ return { ok: false, code: 'OFFICIAL_WEB_PROFILE_INVALID' };
48
+ }
49
+ return { ok: true, raw, manifest };
50
+ } catch { return { ok: false, code: 'OFFICIAL_WEB_PROFILE_INVALID' }; }
51
+ }
52
+
53
+ function makeBackup({ home, profileManifest, previous }) {
54
+ if (previous?.backup_file && existsSync(previous.backup_file)) return previous.backup_file;
55
+ const backupDir = join(home, '.config', 'dsh-crew', 'backups');
56
+ mkdirSync(backupDir, { recursive: true });
57
+ const backupFile = join(backupDir, `official-web-package-${Date.now()}.json`);
58
+ copyFileSync(profileManifest, backupFile);
59
+ return backupFile;
60
+ }
61
+
62
+ export function ensureOfficialWebIntegration({ home = homedir(), releaseDir } = {}) {
63
+ let resolvedRelease;
64
+ try { resolvedRelease = realpathSync(releaseDir); } catch { return { ok: false, code: 'RELEASE_NOT_FOUND' }; }
65
+ const bridgeRoot = join(resolvedRelease, 'official-web-bridge');
66
+ const profileRoot = officialWebProfileDir({ home });
67
+ const profileManifest = join(profileRoot, 'package.json');
68
+ const profile = validOfficialManifest(profileManifest);
69
+ if (!profile.ok) return profile;
70
+ const previous = readState(home);
71
+ const backupFile = makeBackup({ home, profileManifest, previous });
72
+ const registration = ensurePluginRegistration({
73
+ profileRoot,
74
+ root: bridgeRoot,
75
+ name: OFFICIAL_BRIDGE_PACKAGE,
76
+ createProfile: false,
77
+ });
78
+ if (!registration.ok) {
79
+ return { ok: false, code: registration.code === 'CREW_PROFILE_METADATA_INVALID' ? 'OFFICIAL_WEB_PROFILE_INVALID' : registration.code };
80
+ }
81
+ const stateChanged = previous?.enabled !== true || previous?.release_dir !== resolvedRelease || previous?.backup_file !== backupFile;
82
+ writeState(home, {
83
+ enabled: true,
84
+ release_dir: resolvedRelease,
85
+ backup_file: backupFile,
86
+ package: OFFICIAL_BRIDGE_PACKAGE,
87
+ });
88
+ return { ...registration, changed: registration.changed || stateChanged, backupFile };
89
+ }
90
+
91
+ export function officialWebIntegrationStatus({ home = homedir(), releaseDir } = {}) {
92
+ const state = readState(home);
93
+ if (state?.enabled !== true) return { enabled: false, healthy: false, state };
94
+ const expectedRelease = releaseDir ?? state.release_dir;
95
+ let expectedBridge;
96
+ try { expectedBridge = realpathSync(join(expectedRelease, 'official-web-bridge')); } catch {
97
+ return { enabled: true, healthy: false, code: 'BRIDGE_RELEASE_MISSING', state };
98
+ }
99
+ const profileRoot = officialWebProfileDir({ home });
100
+ const profile = validOfficialManifest(join(profileRoot, 'package.json'));
101
+ if (!profile.ok) return { enabled: true, healthy: false, code: profile.code, state };
102
+ const dependency = profile.manifest.dependencies?.[OFFICIAL_BRIDGE_PACKAGE];
103
+ const bundled = profile.manifest.dsh.profile.bundles.includes(OFFICIAL_BRIDGE_PACKAGE);
104
+ const linkPath = join(profileRoot, 'node_modules', ...OFFICIAL_BRIDGE_PACKAGE.split('/'));
105
+ let linked = false;
106
+ try { linked = lstatSync(linkPath).isSymbolicLink() && realpathSync(linkPath) === expectedBridge; } catch {}
107
+ return { enabled: true, healthy: Boolean(dependency && bundled && linked), state, linkPath, expectedBridge };
108
+ }
109
+
110
+ export function removeOfficialWebIntegration({ home = homedir(), remember = true, preserveIntent = false } = {}) {
111
+ const profileRoot = officialWebProfileDir({ home });
112
+ const profileManifest = join(profileRoot, 'package.json');
113
+ const profile = validOfficialManifest(profileManifest);
114
+ if (!profile.ok && profile.code !== 'OFFICIAL_WEB_PROFILE_NOT_FOUND') return profile;
115
+ let removed = false;
116
+ if (profile.ok) {
117
+ const result = removeCrewPluginRegistration({ home, name: OFFICIAL_BRIDGE_PACKAGE, profileRoot });
118
+ if (!result.ok) return { ok: false, code: result.code === 'CREW_PROFILE_METADATA_INVALID' ? 'OFFICIAL_WEB_PROFILE_INVALID' : result.code };
119
+ removed = result.removed;
120
+ }
121
+ const previous = readState(home);
122
+ if (remember) writeState(home, {
123
+ ...(previous ?? {}),
124
+ enabled: preserveIntent ? previous?.enabled === true : false,
125
+ package: OFFICIAL_BRIDGE_PACKAGE,
126
+ });
127
+ else {
128
+ const stateFile = officialWebIntegrationStateFile({ home });
129
+ if (existsSync(stateFile)) unlinkSync(stateFile);
130
+ }
131
+ return { ok: true, removed };
132
+ }
@@ -1,257 +1,257 @@
1
- // MCP-side workflow runtime wiring: builds the shared workflow runtime with
2
- // the real Hub / Standalone attempt adapters and the git-worktree workspace
3
- // adapter. This is the only place that knows which transport executes an
4
- // attempt; the MCP server (src/server.mjs) only does schema, validation,
5
- // policy gating and formatting.
6
- //
7
- // Behaviour decisions (escalation, review, candidate, queue, cancellation)
8
- // live entirely in workflow-runtime.mjs; the adapters here just execute.
9
-
10
- import { createWorkflowRuntime } from './workflow-runtime.mjs';
11
- import { normalizeGlobalConfig } from './policy.mjs';
12
- import { buildDirectSelectionTrace, enrichSelectionTrace } from './model-routing.mjs';
13
- import {
14
- createIsolatedWorkspace,
15
- cleanupIsolatedWorkspace,
16
- inspectRepository,
17
- captureCandidate as captureIsolationCandidate,
18
- } from './workspace-isolation.mjs';
19
- import { startJob, waitJob, jobView, cancelJob } from './jobs.mjs';
20
- import { hub } from './hub-client.mjs';
21
-
22
- const SESSION_CONFIG_KEYS = [
23
- 'default_tier', 'default_effort', 'mode', 'default_timeout_seconds',
24
- 'tier_policy', 'escalate_on_failure', 'collaboration_mode', 'main_agent_mode',
25
- 'flash_state', 'pro_state', 'pro_reviews_flash',
26
- ];
27
-
28
- /**
29
- * Merge only defined session overrides onto the live global config before the
30
- * workflow snapshots its policy. This keeps dsh_worker_config authoritative
31
- * for escalation/review decisions instead of applying it only to the MCP gate.
32
- */
33
- export function buildEffectiveRuntimeConfig(globalRaw = {}, session = {}) {
34
- const patch = {};
35
- for (const key of SESSION_CONFIG_KEYS) {
36
- if (session?.[key] !== undefined) patch[key] = session[key];
37
- }
38
- if (session?.enabled === false) patch.subagents_enabled = false;
39
- return normalizeGlobalConfig({ ...globalRaw, ...patch });
40
- }
41
-
42
- /**
43
- * Translate a role/workflow attempt into the legacy model-class slot needed by
44
- * Standalone and DeepSeek Official Hub routing. A user-requested strong worker
45
- * starts on Pro; every escalated worker attempt also uses the strong slot.
46
- */
47
- export function resolveAttemptTier({ role = 'worker', attempt = 0, modelClassHint } = {}) {
48
- if (role === 'reviewer') return 'pro';
49
- if (Number.isInteger(attempt) && attempt > 0) return 'pro';
50
- if (modelClassHint === 'pro') return 'pro';
51
- return 'flash';
52
- }
53
-
54
- /** Map a Hub/Standalone job view into the AttemptResult the runtime expects. */
55
- export function attemptFromView(view, spec) {
56
- const role = view?.role ?? spec.role ?? 'worker';
57
- const logicalAttempt = spec.attempt ?? 0;
58
- const source = view?.selection_source ?? null;
59
- const transportTrace = view?.selection_trace ?? buildDirectSelectionTrace({
60
- role,
61
- logicalAttempt,
62
- modelClassHint: spec.model_class_hint ?? null,
63
- strategy: source ?? 'transport-selection',
64
- candidateSet: logicalAttempt > 0 ? 'escalation' : 'primary',
65
- provider: view?.provider ?? null,
66
- model: view?.model ?? null,
67
- source: source ?? 'transport-selection',
68
- escalationReason: spec.escalation_reason ?? null,
69
- });
70
- const selectionTrace = enrichSelectionTrace(transportTrace, {
71
- role,
72
- logicalAttempt,
73
- modelClassHint: spec.model_class_hint ?? null,
74
- escalationReason: spec.escalation_reason ?? null,
75
- });
76
- return {
77
- id: view?.id ?? spec.id,
78
- role,
79
- // `view.attempt` may be an adapter routing-attempt (see below); the
80
- // workflow's logical attempt number is always the spec value.
81
- attempt: logicalAttempt,
82
- provider: view?.provider ?? null,
83
- model: view?.model ?? null,
84
- selection_source: source,
85
- selection_trace: selectionTrace,
86
- status: view?.status ?? 'failed',
87
- result: view?.result ?? null,
88
- stopReason: view?.stopReason ?? null,
89
- outcome: view?.outcome ?? null,
90
- usage: view?.tokens ?? null,
91
- error: view?.error ?? null,
92
- error_code: view?.error_code ?? view?.code ?? null,
93
- };
94
- }
95
-
96
- function timedOutAttempt(view, spec, timeoutMs) {
97
- return {
98
- ...attemptFromView(view, spec),
99
- status: 'failed',
100
- stopReason: 'timeout',
101
- error: `attempt timed out after ${Math.ceil(timeoutMs / 1000)}s and was cancelled before any retry`,
102
- timed_out: true,
103
- };
104
- }
105
-
106
- /**
107
- * Build the workflow runtime used by the MCP server.
108
- *
109
- * deps:
110
- * getSessionConfig() -> session-level config (effort, timeout, mode...)
111
- * resolveMode() -> 'hub' | 'standalone'
112
- * presetForTier(tier) -> hub agent preset id for a tier slot (optional)
113
- * readGlobalConfig() -> raw global config reader
114
- * buildReviewTask(task, view) -> reviewer prompt builder
115
- * attemptTimeoutMs() -> default attempt execution timeout (optional)
116
- */
117
- export function buildMcpWorkflowRuntime(deps) {
118
- const { getSessionConfig, resolveMode, presetForTier, readGlobalConfig, buildReviewTask } = deps;
119
- const getConfig = () => buildEffectiveRuntimeConfig(readGlobalConfig(), getSessionConfig?.() ?? {});
120
- const getRuntimeControls = () => ({
121
- max_parallel: getConfig().execution?.max_parallel ?? 3,
122
- });
123
-
124
- const executeAttempt = async (spec) => {
125
- const session = getSessionConfig?.() ?? {};
126
- const effort = spec.effort ?? session.default_effort ?? 'max';
127
- const timeoutMs = (deps.attemptTimeoutMs?.() ?? (session.default_timeout_seconds ?? 1800) * 1000);
128
- const tier = resolveAttemptTier({ role: spec.role, attempt: spec.attempt, modelClassHint: spec.model_class_hint });
129
- const delivery = spec.role === 'reviewer' || spec.delivery === 'review' ? 'review' : 'coding';
130
- const source = spec.source ?? 'api';
131
- const preset = presetForTier?.(tier);
132
-
133
- if ((await resolveMode()) === 'hub') {
134
- // Hub follow-dsh currently uses `attempt` to choose primary vs escalation
135
- // model policy. For an explicit worker+pro hint, route selection through
136
- // the strong pool while preserving the workflow's logical attempt number
137
- // in the normalized AttemptResult.
138
- const routingAttempt = spec.role === 'worker' && spec.attempt === 0 && spec.model_class_hint === 'pro'
139
- ? 1
140
- : spec.attempt;
141
- const spawned = await hub.spawn({
142
- task: spec.task,
143
- tier,
144
- role: spec.role,
145
- attempt: routingAttempt,
146
- effort,
147
- cwd: spec.cwd,
148
- source,
149
- preset,
150
- delivery,
151
- });
152
- spec.onAttemptStarted?.(spawned.id);
153
-
154
- const deadline = Date.now() + timeoutMs;
155
- let resolved = spawned;
156
- while (resolved?.status === 'running') {
157
- const remainingMs = deadline - Date.now();
158
- if (remainingMs <= 0) {
159
- let cancelled = resolved;
160
- try { cancelled = await hub.cancel(spawned.id); } catch {}
161
- return timedOutAttempt(cancelled, spec, timeoutMs);
162
- }
163
- const waitSeconds = Math.max(1, Math.min(600, Math.ceil(remainingMs / 1000)));
164
- resolved = await hub.get(spawned.id, waitSeconds);
165
- }
166
- return attemptFromView(resolved, spec);
167
- }
168
-
169
- const job = startJob({
170
- task: spec.task,
171
- tier,
172
- role: spec.role,
173
- attempt: spec.attempt,
174
- effort,
175
- cwd: spec.cwd,
176
- timeoutMs,
177
- source,
178
- delivery,
179
- modelClassHint: spec.model_class_hint ?? null,
180
- escalationReason: spec.escalation_reason ?? null,
181
- });
182
- spec.onAttemptStarted?.(job.id);
183
- await waitJob(job.id, timeoutMs);
184
- if (job.status === 'running') {
185
- await cancelJob(job.id).catch(() => {});
186
- return timedOutAttempt(jobView(job, { withResult: true }), spec, timeoutMs);
187
- }
188
- return attemptFromView(jobView(job, { withResult: true }), spec);
189
- };
190
-
191
- const cancelAttempt = async (id) => {
192
- if (String(id ?? '').startsWith('hub-')) {
193
- try { await hub.cancel(id); } catch {}
194
- return;
195
- }
196
- try { await cancelJob(id); } catch {}
197
- };
198
-
199
- const allocateWorkspace = async (job) => {
200
- const config = getConfig();
201
- const isolation = config.execution?.isolation ?? 'worktree';
202
- // Reviewer role / explicit review and shared mode never draft a candidate;
203
- // they run in the requested workspace.
204
- if (job.role === 'reviewer' || job.delivery === 'review' || isolation === 'shared') {
205
- return { ok: true, execution_cwd: job.requested_cwd, isolation: 'shared', base_revision: null, primary_workspace_dirty: false, handle: null };
206
- }
207
- // Coding worker under worktree isolation: fail closed when the workspace
208
- // is not a git repo — never silently fall back to sharing the working tree.
209
- const repo = await inspectRepository({ cwd: job.requested_cwd });
210
- if (!repo.ok) {
211
- return { ok: false, reason: repo.reason ?? 'ISOLATION_UNAVAILABLE', error: `coding worker needs an isolated git worktree: ${repo.error ?? repo.reason}` };
212
- }
213
- const created = await createIsolatedWorkspace({ cwd: job.requested_cwd, jobId: job.id, baseRevision: repo.baseRevision });
214
- if (!created.ok) {
215
- return { ok: false, reason: created.reason ?? 'WORKTREE_CREATE_FAILED', error: `worktree create failed: ${created.error ?? ''}` };
216
- }
217
- return {
218
- ok: true,
219
- execution_cwd: created.worktreePath,
220
- base_revision: created.baseRevision,
221
- isolation: 'worktree',
222
- primary_workspace_dirty: repo.dirty === true,
223
- handle: { worktreePath: created.worktreePath, repoRoot: created.repoRoot },
224
- };
225
- };
226
-
227
- const captureCandidate = async ({ cwd, baseRevision }) => captureIsolationCandidate({ worktreePath: cwd, baseRevision });
228
-
229
- const releaseWorkspace = async (handle) => {
230
- if (!handle) return { ok: true };
231
- try {
232
- const r = await cleanupIsolatedWorkspace({ worktreePath: handle.worktreePath, repoRoot: handle.repoRoot });
233
- return { ok: r.ok, error: r.ok ? undefined : r.error };
234
- } catch (err) {
235
- return { ok: false, error: err?.message ?? String(err) };
236
- }
237
- };
238
-
239
- const initialConfig = getConfig();
240
- const runtime = createWorkflowRuntime(
241
- {
242
- executeAttempt,
243
- cancelAttempt,
244
- allocateWorkspace,
245
- captureCandidate,
246
- releaseWorkspace,
247
- buildReviewTask,
248
- getConfig,
249
- getRuntimeControls,
250
- },
251
- {
252
- maxParallel: initialConfig.execution?.max_parallel ?? 3,
253
- },
254
- );
255
-
256
- return runtime;
257
- }
1
+ // MCP-side workflow runtime wiring: builds the shared workflow runtime with
2
+ // the real Hub / Standalone attempt adapters and the git-worktree workspace
3
+ // adapter. This is the only place that knows which transport executes an
4
+ // attempt; the MCP server (src/server.mjs) only does schema, validation,
5
+ // policy gating and formatting.
6
+ //
7
+ // Behaviour decisions (escalation, review, candidate, queue, cancellation)
8
+ // live entirely in workflow-runtime.mjs; the adapters here just execute.
9
+
10
+ import { createWorkflowRuntime } from './workflow-runtime.mjs';
11
+ import { normalizeGlobalConfig } from './policy.mjs';
12
+ import { buildDirectSelectionTrace, enrichSelectionTrace } from './model-routing.mjs';
13
+ import {
14
+ createIsolatedWorkspace,
15
+ cleanupIsolatedWorkspace,
16
+ inspectRepository,
17
+ captureCandidate as captureIsolationCandidate,
18
+ } from './workspace-isolation.mjs';
19
+ import { startJob, waitJob, jobView, cancelJob } from './jobs.mjs';
20
+ import { hub } from './hub-client.mjs';
21
+
22
+ const SESSION_CONFIG_KEYS = [
23
+ 'default_tier', 'default_effort', 'mode', 'default_timeout_seconds',
24
+ 'tier_policy', 'escalate_on_failure', 'collaboration_mode', 'main_agent_mode',
25
+ 'flash_state', 'pro_state', 'pro_reviews_flash',
26
+ ];
27
+
28
+ /**
29
+ * Merge only defined session overrides onto the live global config before the
30
+ * workflow snapshots its policy. This keeps dsh_worker_config authoritative
31
+ * for escalation/review decisions instead of applying it only to the MCP gate.
32
+ */
33
+ export function buildEffectiveRuntimeConfig(globalRaw = {}, session = {}) {
34
+ const patch = {};
35
+ for (const key of SESSION_CONFIG_KEYS) {
36
+ if (session?.[key] !== undefined) patch[key] = session[key];
37
+ }
38
+ if (session?.enabled === false) patch.subagents_enabled = false;
39
+ return normalizeGlobalConfig({ ...globalRaw, ...patch });
40
+ }
41
+
42
+ /**
43
+ * Translate a role/workflow attempt into the legacy model-class slot needed by
44
+ * Standalone and DeepSeek Official Hub routing. A user-requested strong worker
45
+ * starts on Pro; every escalated worker attempt also uses the strong slot.
46
+ */
47
+ export function resolveAttemptTier({ role = 'worker', attempt = 0, modelClassHint } = {}) {
48
+ if (role === 'reviewer') return 'pro';
49
+ if (Number.isInteger(attempt) && attempt > 0) return 'pro';
50
+ if (modelClassHint === 'pro') return 'pro';
51
+ return 'flash';
52
+ }
53
+
54
+ /** Map a Hub/Standalone job view into the AttemptResult the runtime expects. */
55
+ export function attemptFromView(view, spec) {
56
+ const role = view?.role ?? spec.role ?? 'worker';
57
+ const logicalAttempt = spec.attempt ?? 0;
58
+ const source = view?.selection_source ?? null;
59
+ const transportTrace = view?.selection_trace ?? buildDirectSelectionTrace({
60
+ role,
61
+ logicalAttempt,
62
+ modelClassHint: spec.model_class_hint ?? null,
63
+ strategy: source ?? 'transport-selection',
64
+ candidateSet: logicalAttempt > 0 ? 'escalation' : 'primary',
65
+ provider: view?.provider ?? null,
66
+ model: view?.model ?? null,
67
+ source: source ?? 'transport-selection',
68
+ escalationReason: spec.escalation_reason ?? null,
69
+ });
70
+ const selectionTrace = enrichSelectionTrace(transportTrace, {
71
+ role,
72
+ logicalAttempt,
73
+ modelClassHint: spec.model_class_hint ?? null,
74
+ escalationReason: spec.escalation_reason ?? null,
75
+ });
76
+ return {
77
+ id: view?.id ?? spec.id,
78
+ role,
79
+ // `view.attempt` may be an adapter routing-attempt (see below); the
80
+ // workflow's logical attempt number is always the spec value.
81
+ attempt: logicalAttempt,
82
+ provider: view?.provider ?? null,
83
+ model: view?.model ?? null,
84
+ selection_source: source,
85
+ selection_trace: selectionTrace,
86
+ status: view?.status ?? 'failed',
87
+ result: view?.result ?? null,
88
+ stopReason: view?.stopReason ?? null,
89
+ outcome: view?.outcome ?? null,
90
+ usage: view?.tokens ?? null,
91
+ error: view?.error ?? null,
92
+ error_code: view?.error_code ?? view?.code ?? null,
93
+ };
94
+ }
95
+
96
+ function timedOutAttempt(view, spec, timeoutMs) {
97
+ return {
98
+ ...attemptFromView(view, spec),
99
+ status: 'failed',
100
+ stopReason: 'timeout',
101
+ error: `attempt timed out after ${Math.ceil(timeoutMs / 1000)}s and was cancelled before any retry`,
102
+ timed_out: true,
103
+ };
104
+ }
105
+
106
+ /**
107
+ * Build the workflow runtime used by the MCP server.
108
+ *
109
+ * deps:
110
+ * getSessionConfig() -> session-level config (effort, timeout, mode...)
111
+ * resolveMode() -> 'hub' | 'standalone'
112
+ * presetForTier(tier) -> hub agent preset id for a tier slot (optional)
113
+ * readGlobalConfig() -> raw global config reader
114
+ * buildReviewTask(task, view) -> reviewer prompt builder
115
+ * attemptTimeoutMs() -> default attempt execution timeout (optional)
116
+ */
117
+ export function buildMcpWorkflowRuntime(deps) {
118
+ const { getSessionConfig, resolveMode, presetForTier, readGlobalConfig, buildReviewTask } = deps;
119
+ const getConfig = () => buildEffectiveRuntimeConfig(readGlobalConfig(), getSessionConfig?.() ?? {});
120
+ const getRuntimeControls = () => ({
121
+ max_parallel: getConfig().execution?.max_parallel ?? 3,
122
+ });
123
+
124
+ const executeAttempt = async (spec) => {
125
+ const session = getSessionConfig?.() ?? {};
126
+ const effort = spec.effort ?? session.default_effort ?? 'max';
127
+ const timeoutMs = (deps.attemptTimeoutMs?.() ?? (session.default_timeout_seconds ?? 1800) * 1000);
128
+ const tier = resolveAttemptTier({ role: spec.role, attempt: spec.attempt, modelClassHint: spec.model_class_hint });
129
+ const delivery = spec.role === 'reviewer' || spec.delivery === 'review' ? 'review' : 'coding';
130
+ const source = spec.source ?? 'api';
131
+ const preset = presetForTier?.(tier);
132
+
133
+ if ((await resolveMode()) === 'hub') {
134
+ // Hub follow-dsh currently uses `attempt` to choose primary vs escalation
135
+ // model policy. For an explicit worker+pro hint, route selection through
136
+ // the strong pool while preserving the workflow's logical attempt number
137
+ // in the normalized AttemptResult.
138
+ const routingAttempt = spec.role === 'worker' && spec.attempt === 0 && spec.model_class_hint === 'pro'
139
+ ? 1
140
+ : spec.attempt;
141
+ const spawned = await hub.spawn({
142
+ task: spec.task,
143
+ tier,
144
+ role: spec.role,
145
+ attempt: routingAttempt,
146
+ effort,
147
+ cwd: spec.cwd,
148
+ source,
149
+ preset,
150
+ delivery,
151
+ });
152
+ spec.onAttemptStarted?.(spawned.id);
153
+
154
+ const deadline = Date.now() + timeoutMs;
155
+ let resolved = spawned;
156
+ while (resolved?.status === 'running') {
157
+ const remainingMs = deadline - Date.now();
158
+ if (remainingMs <= 0) {
159
+ let cancelled = resolved;
160
+ try { cancelled = await hub.cancel(spawned.id); } catch {}
161
+ return timedOutAttempt(cancelled, spec, timeoutMs);
162
+ }
163
+ const waitSeconds = Math.max(1, Math.min(600, Math.ceil(remainingMs / 1000)));
164
+ resolved = await hub.get(spawned.id, waitSeconds);
165
+ }
166
+ return attemptFromView(resolved, spec);
167
+ }
168
+
169
+ const job = startJob({
170
+ task: spec.task,
171
+ tier,
172
+ role: spec.role,
173
+ attempt: spec.attempt,
174
+ effort,
175
+ cwd: spec.cwd,
176
+ timeoutMs,
177
+ source,
178
+ delivery,
179
+ modelClassHint: spec.model_class_hint ?? null,
180
+ escalationReason: spec.escalation_reason ?? null,
181
+ });
182
+ spec.onAttemptStarted?.(job.id);
183
+ await waitJob(job.id, timeoutMs);
184
+ if (job.status === 'running') {
185
+ await cancelJob(job.id).catch(() => {});
186
+ return timedOutAttempt(jobView(job, { withResult: true }), spec, timeoutMs);
187
+ }
188
+ return attemptFromView(jobView(job, { withResult: true }), spec);
189
+ };
190
+
191
+ const cancelAttempt = async (id) => {
192
+ if (String(id ?? '').startsWith('hub-')) {
193
+ try { await hub.cancel(id); } catch {}
194
+ return;
195
+ }
196
+ try { await cancelJob(id); } catch {}
197
+ };
198
+
199
+ const allocateWorkspace = async (job) => {
200
+ const config = getConfig();
201
+ const isolation = config.execution?.isolation ?? 'worktree';
202
+ // Reviewer role / explicit review and shared mode never draft a candidate;
203
+ // they run in the requested workspace.
204
+ if (job.role === 'reviewer' || job.delivery === 'review' || isolation === 'shared') {
205
+ return { ok: true, execution_cwd: job.requested_cwd, isolation: 'shared', base_revision: null, primary_workspace_dirty: false, handle: null };
206
+ }
207
+ // Coding worker under worktree isolation: fail closed when the workspace
208
+ // is not a git repo — never silently fall back to sharing the working tree.
209
+ const repo = await inspectRepository({ cwd: job.requested_cwd });
210
+ if (!repo.ok) {
211
+ return { ok: false, reason: repo.reason ?? 'ISOLATION_UNAVAILABLE', error: `coding worker needs an isolated git worktree: ${repo.error ?? repo.reason}` };
212
+ }
213
+ const created = await createIsolatedWorkspace({ cwd: job.requested_cwd, jobId: job.id, baseRevision: repo.baseRevision });
214
+ if (!created.ok) {
215
+ return { ok: false, reason: created.reason ?? 'WORKTREE_CREATE_FAILED', error: `worktree create failed: ${created.error ?? ''}` };
216
+ }
217
+ return {
218
+ ok: true,
219
+ execution_cwd: created.worktreePath,
220
+ base_revision: created.baseRevision,
221
+ isolation: 'worktree',
222
+ primary_workspace_dirty: repo.dirty === true,
223
+ handle: { worktreePath: created.worktreePath, repoRoot: created.repoRoot },
224
+ };
225
+ };
226
+
227
+ const captureCandidate = async ({ cwd, baseRevision }) => captureIsolationCandidate({ worktreePath: cwd, baseRevision });
228
+
229
+ const releaseWorkspace = async (handle) => {
230
+ if (!handle) return { ok: true };
231
+ try {
232
+ const r = await cleanupIsolatedWorkspace({ worktreePath: handle.worktreePath, repoRoot: handle.repoRoot });
233
+ return { ok: r.ok, error: r.ok ? undefined : r.error };
234
+ } catch (err) {
235
+ return { ok: false, error: err?.message ?? String(err) };
236
+ }
237
+ };
238
+
239
+ const initialConfig = getConfig();
240
+ const runtime = createWorkflowRuntime(
241
+ {
242
+ executeAttempt,
243
+ cancelAttempt,
244
+ allocateWorkspace,
245
+ captureCandidate,
246
+ releaseWorkspace,
247
+ buildReviewTask,
248
+ getConfig,
249
+ getRuntimeControls,
250
+ },
251
+ {
252
+ maxParallel: initialConfig.execution?.max_parallel ?? 3,
253
+ },
254
+ );
255
+
256
+ return runtime;
257
+ }