@ran-sh/dsh-crew 0.3.8 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1045,21 +1045,81 @@ Commands:
1045
1045
  integrate show Crew inside the official 3080 UI; backend stays isolated on 3210
1046
1046
  detach remove only the official 3080 bridge; isolated 3210 mode remains available
1047
1047
  status read-only report of launcher/installed versions and integrations
1048
+ inspect print the machine-readable extension capability/readiness contract
1049
+ jobs machine-first job API: list|get|watch|cancel|submit
1048
1050
  update resolve the newest permitted package from the configured npm registry (or
1049
1051
  --candidate), stage and validate it, then activate; idempotent when current
1050
1052
  uninstall remove the Crew-managed payload, registration, and integrations (config kept)
1051
1053
 
1052
1054
  Options:
1053
1055
  --candidate <path> update from a local payload directory or packed .tgz instead of the registry
1056
+ --after <sequence> with jobs watch/get: return canonical events after this cursor
1057
+ --detail <mode> with jobs get/watch: compact (default) or full
1058
+ --request <path> with jobs submit: JSON Job Request document
1054
1059
  --purge with uninstall: also remove ~/.config/dsh-crew config/backups (destructive)
1055
1060
  --help show this help
1056
1061
 
1057
1062
  Primary install: npm install -g @ran-sh/dsh-crew (then run: dsh-crew install)
1058
1063
  Source checkouts use scripts/setup.mjs instead.`;
1059
1064
 
1065
+ export async function npxInspect({
1066
+ log = console.log,
1067
+ fetchImpl = globalThis.fetch,
1068
+ readConfig = realInstaller.readGlobalConfig,
1069
+ } = {}) {
1070
+ const hubUrl = readConfig()?.hub_url ?? 'http://127.0.0.1:3210';
1071
+ const url = `${String(hubUrl).replace(/\/$/, '')}/_dsh/dsh-crew/extension`;
1072
+ const response = await fetchImpl(url, { headers: { accept: 'application/json' } });
1073
+ const body = await response.json();
1074
+ if (!response.ok || body?.ok !== true || !body.extension) {
1075
+ throw new Error('isolated Crew Hub extension contract is unavailable');
1076
+ }
1077
+ log(JSON.stringify(body.extension, null, 2));
1078
+ return { ok: true, extension: body.extension };
1079
+ }
1080
+
1081
+ export async function npxJobs({
1082
+ args = [],
1083
+ after = 0,
1084
+ detail = 'compact',
1085
+ request,
1086
+ log = console.log,
1087
+ fetchImpl = globalThis.fetch,
1088
+ readConfig = realInstaller.readGlobalConfig,
1089
+ } = {}) {
1090
+ const hubUrl = String(readConfig()?.hub_url ?? 'http://127.0.0.1:3210').replace(/\/$/, '');
1091
+ const base = `${hubUrl}/_dsh/dsh-crew/jobs`;
1092
+ const action = args[0] ?? 'list';
1093
+ const id = args[1];
1094
+ let url = base;
1095
+ let init = { headers: { accept: 'application/json' } };
1096
+ if (action === 'get' || action === 'watch') {
1097
+ if (!id) throw new Error(`jobs ${action} requires a job id`);
1098
+ url = `${base}/${encodeURIComponent(id)}/contract?detail=${detail}&after=${after}`;
1099
+ } else if (action === 'cancel') {
1100
+ if (!id) throw new Error('jobs cancel requires a job id');
1101
+ url = `${base}/${encodeURIComponent(id)}/cancel`;
1102
+ init = { ...init, method: 'POST' };
1103
+ } else if (action === 'submit') {
1104
+ if (!request) throw new Error('jobs submit requires --request <json-file>');
1105
+ const document = JSON.parse(readFileSync(resolve(request), 'utf8'));
1106
+ init = { method: 'POST', headers: { accept: 'application/json', 'content-type': 'application/json' }, body: JSON.stringify(document) };
1107
+ } else if (action !== 'list') {
1108
+ throw new Error(`unknown jobs action: ${action}`);
1109
+ }
1110
+ const response = await fetchImpl(url, init);
1111
+ const body = await response.json();
1112
+ if (!response.ok || body?.ok === false) throw new Error(body?.error ?? 'Crew jobs API unavailable');
1113
+ log(JSON.stringify(body, null, 2));
1114
+ return { ok: true, body };
1115
+ }
1116
+
1060
1117
  function normalizeCommand(argv) {
1061
1118
  const flags = argv.slice(1);
1062
1119
  let candidate;
1120
+ let after = 0;
1121
+ let detail = 'compact';
1122
+ let request;
1063
1123
  for (let index = 0; index < flags.length; index += 1) {
1064
1124
  if (flags[index] === '--candidate') {
1065
1125
  candidate = flags[index + 1];
@@ -1069,11 +1129,28 @@ function normalizeCommand(argv) {
1069
1129
  candidate = flags[index].slice('--candidate='.length);
1070
1130
  flags.splice(index, 1);
1071
1131
  index -= 1;
1132
+ } else if (flags[index] === '--after' || flags[index] === '--detail' || flags[index] === '--request') {
1133
+ const name = flags[index];
1134
+ const value = flags[index + 1];
1135
+ if (name === '--after') after = Number(value);
1136
+ if (name === '--detail') detail = value;
1137
+ if (name === '--request') request = value;
1138
+ flags.splice(index, 2);
1139
+ index -= 1;
1140
+ } else if (flags[index]?.startsWith('--after=')) {
1141
+ after = Number(flags[index].slice('--after='.length)); flags.splice(index, 1); index -= 1;
1142
+ } else if (flags[index]?.startsWith('--detail=')) {
1143
+ detail = flags[index].slice('--detail='.length); flags.splice(index, 1); index -= 1;
1144
+ } else if (flags[index]?.startsWith('--request=')) {
1145
+ request = flags[index].slice('--request='.length); flags.splice(index, 1); index -= 1;
1072
1146
  }
1073
1147
  }
1074
1148
  const knownFlags = new Set(['--purge']);
1075
1149
  const unknown = flags.filter((f) => f.startsWith('--') && !knownFlags.has(f));
1076
- return { command: argv[0], purge: flags.includes('--purge'), candidate, unknown };
1150
+ const args = flags.filter((f) => !f.startsWith('--'));
1151
+ if (!Number.isInteger(after) || after < 0) unknown.push('--after');
1152
+ if (!['compact', 'full'].includes(detail)) unknown.push('--detail');
1153
+ return { command: argv[0], purge: flags.includes('--purge'), candidate, after, detail, request, args, unknown };
1077
1154
  }
1078
1155
 
1079
1156
  /**
@@ -1085,7 +1162,7 @@ export async function runNpxCli({
1085
1162
  error = console.error,
1086
1163
  commands = {},
1087
1164
  } = {}) {
1088
- const { command, purge, candidate, unknown } = normalizeCommand(argv);
1165
+ const { command, purge, candidate, after, detail, request, args, unknown } = normalizeCommand(argv);
1089
1166
  if (command === '--help' || command === '-h' || command === 'help') {
1090
1167
  log(USAGE);
1091
1168
  return 0;
@@ -1094,7 +1171,7 @@ export async function runNpxCli({
1094
1171
  error(USAGE);
1095
1172
  return 1;
1096
1173
  }
1097
- if (unknown.length > 0 || !['install', 'integrate', 'detach', 'status', 'update', 'uninstall'].includes(command)) {
1174
+ if (unknown.length > 0 || !['install', 'integrate', 'detach', 'status', 'inspect', 'jobs', 'update', 'uninstall'].includes(command)) {
1098
1175
  error(`unknown command: ${command ?? '<none>'}\n\n${USAGE}`);
1099
1176
  return 1;
1100
1177
  }
@@ -1104,12 +1181,15 @@ export async function runNpxCli({
1104
1181
  integrate: commands.integrate ?? npxIntegrate,
1105
1182
  detach: commands.detach ?? npxDetach,
1106
1183
  status: commands.status ?? npxStatus,
1184
+ inspect: commands.inspect ?? npxInspect,
1185
+ jobs: commands.jobs ?? npxJobs,
1107
1186
  update: commands.update ?? npxUpdate,
1108
1187
  uninstall: commands.uninstall ?? npxUninstall,
1109
1188
  };
1110
1189
  let result;
1111
1190
  if (command === 'uninstall') result = await actions.uninstall({ purge, log });
1112
1191
  else if (command === 'update') result = await actions.update({ candidate, log });
1192
+ else if (command === 'jobs') result = await actions.jobs({ args, after, detail, request, log });
1113
1193
  else result = await actions[command]({ log });
1114
1194
  return result?.ok === false ? 1 : 0;
1115
1195
  } catch (err) {
@@ -0,0 +1,218 @@
1
+ // Versioned, transport-neutral contracts for one DSH Crew workflow.
2
+ //
3
+ // The workflow runtime may keep richer internal state for recovery and debug,
4
+ // but callers receive this bounded event/evidence layer by default. Full
5
+ // internal details remain available only through an explicit detail=full
6
+ // request at the MCP boundary.
7
+
8
+ export const JOB_CONTRACT_SCHEMA_VERSION = 1;
9
+
10
+ export const JOB_EVENT_TYPES = Object.freeze([
11
+ 'job.created',
12
+ 'job.started',
13
+ 'model.selected',
14
+ 'model.fallback',
15
+ 'worker.started',
16
+ 'worker.completed',
17
+ 'review.started',
18
+ 'review.completed',
19
+ 'approval.required',
20
+ 'job.completed',
21
+ 'job.failed',
22
+ 'job.cancelled',
23
+ ]);
24
+
25
+ const EVENT_TYPE_SET = new Set(JOB_EVENT_TYPES);
26
+ const RESULT_STATUSES = Object.freeze(['PASS', 'FAIL', 'PARTIAL', 'BLOCKED']);
27
+
28
+ function boundedText(value, limit = 800) {
29
+ if (value == null) return null;
30
+ const text = String(value).trim();
31
+ if (!text) return null;
32
+ return text.length <= limit ? text : `${text.slice(0, limit)}…`;
33
+ }
34
+
35
+ function boundedStrings(values, { count = 80, length = 400 } = {}) {
36
+ if (!Array.isArray(values)) return [];
37
+ return values.slice(0, count)
38
+ .map((value) => boundedText(value, length))
39
+ .filter(Boolean);
40
+ }
41
+
42
+ function boundedTests(values) {
43
+ if (!Array.isArray(values)) return [];
44
+ return values.slice(0, 40).map((test) => ({
45
+ status: RESULT_STATUSES.includes(test?.status) ? test.status : boundedText(test?.status, 40),
46
+ command: boundedText(test?.command, 300),
47
+ summary: boundedText(test?.summary, 500),
48
+ }));
49
+ }
50
+
51
+ /** Create one canonical, monotonically sequenced workflow event. */
52
+ export function createCanonicalJobEvent({
53
+ jobId,
54
+ type,
55
+ sequence,
56
+ at,
57
+ role = null,
58
+ attempt = null,
59
+ data = {},
60
+ } = {}) {
61
+ if (!EVENT_TYPE_SET.has(type)) throw new Error(`unknown canonical job event: ${String(type)}`);
62
+ if (typeof jobId !== 'string' || !jobId) throw new Error('canonical job event requires jobId');
63
+ if (!Number.isInteger(sequence) || sequence < 1) throw new Error('canonical job event requires a positive sequence');
64
+ return {
65
+ schema_version: JOB_CONTRACT_SCHEMA_VERSION,
66
+ event_id: `${jobId}:${sequence}`,
67
+ job_id: jobId,
68
+ sequence,
69
+ type,
70
+ at,
71
+ role,
72
+ attempt,
73
+ data: data && typeof data === 'object' && !Array.isArray(data) ? { ...data } : {},
74
+ };
75
+ }
76
+
77
+ function evidenceStatus(view) {
78
+ if (view?.status === 'cancelled' || view?.phase === 'cancelled') return 'BLOCKED';
79
+ if (view?.status === 'failed' || view?.phase === 'failed' || view?.outcome?.execution_status === 'failed') return 'FAIL';
80
+ if (view?.review?.verdict === 'request_changes' || view?.outcome?.task_status === 'partial') return 'PARTIAL';
81
+ if (view?.outcome?.task_status === 'blocked') return 'BLOCKED';
82
+ if (view?.status === 'done' && view?.outcome?.task_status === 'success') return 'PASS';
83
+ return 'PARTIAL';
84
+ }
85
+
86
+ function compactSelectionTrace(attempts) {
87
+ if (!Array.isArray(attempts)) return [];
88
+ return attempts.slice(0, 16).map((attempt) => {
89
+ const trace = attempt?.selection_trace ?? {};
90
+ const selected = trace.selected ?? (
91
+ attempt?.provider || attempt?.model
92
+ ? { provider: attempt?.provider ?? null, model: attempt?.model ?? null, source: attempt?.selection_source ?? null }
93
+ : null
94
+ );
95
+ const candidates = Array.isArray(trace.ordered_candidates)
96
+ ? trace.ordered_candidates.slice(0, 32).map((candidate) => ({
97
+ model: candidate?.model ?? null,
98
+ provider: candidate?.provider ?? null,
99
+ status: String(candidate?.status ?? 'CANDIDATE').toUpperCase(),
100
+ ...(candidate?.reason ? { reason: boundedText(candidate.reason, 200) } : {}),
101
+ }))
102
+ : selected ? [{ model: selected.model ?? null, provider: selected.provider ?? null, status: 'SELECTED' }] : [];
103
+ return {
104
+ attempt: attempt?.attempt ?? null,
105
+ role: attempt?.role ?? null,
106
+ selected,
107
+ selected_model: selected?.model ?? null,
108
+ candidates,
109
+ fallback_chain: trace.fallback_reason ? [boundedText(trace.fallback_reason, 200)] : [],
110
+ decision_reason: selected?.source ?? attempt?.selection_source ?? null,
111
+ fallback_reason: trace.fallback_reason ?? null,
112
+ escalation_reason: trace.escalation_reason ?? null,
113
+ };
114
+ });
115
+ }
116
+
117
+ function changedFilesFromView(view) {
118
+ if (Array.isArray(view?.candidate?.changed_files)) return view.candidate.changed_files;
119
+ const changes = view?.workspace_diff?.changes;
120
+ if (!changes || typeof changes !== 'object') return [];
121
+ return [...new Set([
122
+ ...(Array.isArray(changes.modified) ? changes.modified : []),
123
+ ...(Array.isArray(changes.deleted) ? changes.deleted : []),
124
+ ...(Array.isArray(changes.renamed) ? changes.renamed : []),
125
+ ...(Array.isArray(changes.untracked) ? changes.untracked : []),
126
+ ])];
127
+ }
128
+
129
+ /**
130
+ * Build the machine-first Result Contract for a workflow.
131
+ *
132
+ * Deliberately excluded: worker prose, raw provider payloads and candidate
133
+ * patch text. The envelope contains enough evidence for orchestration while
134
+ * artifact inspection remains an explicit follow-up operation.
135
+ */
136
+ export function buildEvidenceEnvelope(view = {}) {
137
+ const outcome = view.outcome ?? {};
138
+ const candidate = view.candidate ?? {};
139
+ const review = view.review ?? null;
140
+ const errorMessage = boundedText(view.error, 1000);
141
+ const changedFiles = boundedStrings(changedFilesFromView(view), { count: 120, length: 500 });
142
+ const status = evidenceStatus(view);
143
+ return {
144
+ schema_version: JOB_CONTRACT_SCHEMA_VERSION,
145
+ job_id: view.id ?? null,
146
+ client_job_id: view.client_job_id ?? null,
147
+ role: view.role ?? null,
148
+ status,
149
+ summary: {
150
+ phase: view.phase ?? null,
151
+ task_status: outcome.task_status ?? null,
152
+ execution_status: outcome.execution_status ?? null,
153
+ tests_status: outcome.tests_status ?? null,
154
+ delivery_complete: outcome.delivery?.complete === true,
155
+ review_verdict: review?.verdict ?? null,
156
+ },
157
+ selection_trace: compactSelectionTrace(view.child_attempts),
158
+ changed_files: changedFiles,
159
+ changes: boundedStrings(outcome.changes),
160
+ tests: boundedTests(outcome.tests),
161
+ risks: boundedStrings(outcome.risks),
162
+ unverified: boundedStrings(outcome.unverified),
163
+ review: review ? {
164
+ verdict: review.verdict ?? null,
165
+ status: review.status ?? null,
166
+ findings: boundedStrings(review.findings),
167
+ evidence: boundedStrings(review.evidence),
168
+ risks: boundedStrings(review.risks),
169
+ delivery_complete: review.delivery_complete === true,
170
+ mutated_candidate: review.mutated_candidate === true,
171
+ } : null,
172
+ artifacts: {
173
+ candidate_available: view.candidate_available === true || changedFiles.length > 0,
174
+ candidate_fingerprint: candidate.fingerprint ?? null,
175
+ base_revision: candidate.base_revision ?? view.base_revision ?? null,
176
+ workspace_retained: view.workspace_retained === true,
177
+ candidate_capture_failed: view.candidate_capture_failed === true,
178
+ },
179
+ errors: errorMessage || view.error_code ? [{ code: view.error_code ?? null, message: errorMessage }] : [],
180
+ };
181
+ }
182
+
183
+ /** Project a rich internal workflow view onto compact or explicit full detail. */
184
+ export function projectWorkflowView(view, { detail = 'compact', afterSequence = 0 } = {}) {
185
+ if (!view || typeof view !== 'object') return view;
186
+ const evidence = buildEvidenceEnvelope(view);
187
+ const allCanonical = Array.isArray(view.canonical_events) ? view.canonical_events : [];
188
+ const cursor = allCanonical.at(-1)?.sequence ?? view.event_cursor ?? 0;
189
+ const canonicalEvents = allCanonical.filter((event) => Number(event?.sequence) > afterSequence);
190
+ const eventProjection = {
191
+ canonical_events: canonicalEvents,
192
+ event_cursor: cursor,
193
+ events_truncated_before_cursor: afterSequence > 0 && canonicalEvents.length > 0
194
+ ? canonicalEvents[0].sequence !== afterSequence + 1
195
+ : false,
196
+ };
197
+ if (detail === 'full') return { ...view, ...eventProjection, detail: 'full', evidence };
198
+
199
+ const {
200
+ candidate: _candidate,
201
+ outcome: _outcome,
202
+ review: _review,
203
+ events: _legacyEvents,
204
+ child_attempts: _childAttempts,
205
+ result: _rawResult,
206
+ workspace_diff: _workspaceDiff,
207
+ reasonDetail: _reasonDetail,
208
+ ...safe
209
+ } = view;
210
+ return {
211
+ ...safe,
212
+ ...eventProjection,
213
+ error: boundedText(view.error, 1000),
214
+ cleanup_warning: boundedText(view.cleanup_warning, 1000),
215
+ detail: 'compact',
216
+ evidence,
217
+ };
218
+ }
@@ -25,6 +25,13 @@ const SESSION_CONFIG_KEYS = [
25
25
  'flash_state', 'pro_state', 'pro_reviews_flash',
26
26
  ];
27
27
 
28
+ const HUB_POLL_SLICE_SECONDS = 20;
29
+
30
+ /** Keep each Hub request below intermediary/MCP transport idle deadlines. */
31
+ export function hubPollWaitSeconds(remainingMs) {
32
+ return Math.max(1, Math.min(HUB_POLL_SLICE_SECONDS, Math.ceil(remainingMs / 1000)));
33
+ }
34
+
28
35
  /**
29
36
  * Merge only defined session overrides onto the live global config before the
30
37
  * workflow snapshots its policy. This keeps dsh_worker_config authoritative
@@ -124,7 +131,7 @@ export function buildMcpWorkflowRuntime(deps) {
124
131
  const executeAttempt = async (spec) => {
125
132
  const session = getSessionConfig?.() ?? {};
126
133
  const effort = spec.effort ?? session.default_effort ?? 'max';
127
- const timeoutMs = (deps.attemptTimeoutMs?.() ?? (session.default_timeout_seconds ?? 1800) * 1000);
134
+ const timeoutMs = (spec.timeout_seconds ?? session.default_timeout_seconds ?? 1800) * 1000;
128
135
  const tier = resolveAttemptTier({ role: spec.role, attempt: spec.attempt, modelClassHint: spec.model_class_hint });
129
136
  const delivery = spec.role === 'reviewer' || spec.delivery === 'review' ? 'review' : 'coding';
130
137
  const source = spec.source ?? 'api';
@@ -160,7 +167,7 @@ export function buildMcpWorkflowRuntime(deps) {
160
167
  try { cancelled = await hub.cancel(spawned.id); } catch {}
161
168
  return timedOutAttempt(cancelled, spec, timeoutMs);
162
169
  }
163
- const waitSeconds = Math.max(1, Math.min(600, Math.ceil(remainingMs / 1000)));
170
+ const waitSeconds = hubPollWaitSeconds(remainingMs);
164
171
  resolved = await hub.get(spawned.id, waitSeconds);
165
172
  }
166
173
  return attemptFromView(resolved, spec);
@@ -199,18 +206,19 @@ export function buildMcpWorkflowRuntime(deps) {
199
206
  const allocateWorkspace = async (job) => {
200
207
  const config = getConfig();
201
208
  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') {
209
+ // Explicit shared mode uses the requested workspace. Readonly profiles,
210
+ // including the default Reviewer, use a disposable worktree below so an
211
+ // accidental edit can be detected and never pollutes the primary tree.
212
+ if (job.requested_isolation === 'shared' || isolation === 'shared') {
205
213
  return { ok: true, execution_cwd: job.requested_cwd, isolation: 'shared', base_revision: null, primary_workspace_dirty: false, handle: null };
206
214
  }
207
- // Coding worker under worktree isolation: fail closed when the workspace
215
+ // Isolated roles fail closed when the workspace
208
216
  // is not a git repo — never silently fall back to sharing the working tree.
209
217
  const repo = await inspectRepository({ cwd: job.requested_cwd });
210
218
  if (!repo.ok) {
211
- return { ok: false, reason: repo.reason ?? 'ISOLATION_UNAVAILABLE', error: `coding worker needs an isolated git worktree: ${repo.error ?? repo.reason}` };
219
+ return { ok: false, reason: repo.reason ?? 'ISOLATION_UNAVAILABLE', error: `${job.role ?? 'worker'} needs an isolated git worktree: ${repo.error ?? repo.reason}` };
212
220
  }
213
- const created = await createIsolatedWorkspace({ cwd: job.requested_cwd, jobId: job.id, baseRevision: repo.baseRevision });
221
+ const created = await createIsolatedWorkspace({ cwd: job.requested_cwd, jobId: job.id, baseRevision: job.workspace_branch ?? repo.baseRevision });
214
222
  if (!created.ok) {
215
223
  return { ok: false, reason: created.reason ?? 'WORKTREE_CREATE_FAILED', error: `worktree create failed: ${created.error ?? ''}` };
216
224
  }
@@ -21,6 +21,18 @@ function isLocalHostname(hostname) {
21
21
  return hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '[::1]' || hostname === '::1';
22
22
  }
23
23
 
24
+ export function resolveCrewBridgeTarget(env = process.env) {
25
+ const raw = env?.DSH_CREW_BRIDGE_TARGET;
26
+ if (!raw) return CREW_BRIDGE_TARGET;
27
+ try {
28
+ const target = new URL(raw);
29
+ if (target.protocol !== 'http:' || !isLocalHostname(target.hostname.toLowerCase()) || target.pathname !== '/' || target.search || target.hash) return CREW_BRIDGE_TARGET;
30
+ const port = Number(target.port);
31
+ if (!Number.isInteger(port) || port < 1 || port > 65535) return CREW_BRIDGE_TARGET;
32
+ return target.origin;
33
+ } catch { return CREW_BRIDGE_TARGET; }
34
+ }
35
+
24
36
  export function isTrustedLocalRequest(req) {
25
37
  if (!isLoopbackAddress(req?.socket?.remoteAddress)) return false;
26
38
  const host = typeof req?.headers?.host === 'string' ? req.headers.host.trim().toLowerCase() : '';
@@ -74,9 +86,9 @@ async function readBoundedBody(req, limit = MAX_BODY_BYTES) {
74
86
  return Buffer.concat(chunks);
75
87
  }
76
88
 
77
- async function defaultHealthCheck(fetchImpl = globalThis.fetch) {
89
+ async function defaultHealthCheck(fetchImpl = globalThis.fetch, bridgeTarget = CREW_BRIDGE_TARGET) {
78
90
  try {
79
- const response = await fetchImpl(`${CREW_BRIDGE_TARGET}${CREW_BRIDGE_PREFIX}/ping`, {
91
+ const response = await fetchImpl(`${bridgeTarget}${CREW_BRIDGE_PREFIX}/ping`, {
80
92
  signal: AbortSignal.timeout(1_500),
81
93
  headers: { accept: 'application/json' },
82
94
  });
@@ -89,7 +101,8 @@ const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
89
101
  export function createCrewSidecarSupervisor({
90
102
  home = homedir(),
91
103
  exists = existsSync,
92
- healthCheck = () => defaultHealthCheck(),
104
+ bridgeTarget = resolveCrewBridgeTarget(),
105
+ healthCheck = () => defaultHealthCheck(globalThis.fetch, bridgeTarget),
93
106
  spawnImpl = spawn,
94
107
  wait = delay,
95
108
  maxAttempts = 120,
@@ -99,6 +112,7 @@ export function createCrewSidecarSupervisor({
99
112
  let runningChild = null;
100
113
  const runtime = crewDshRuntimeModule({ home });
101
114
  const dshHome = crewDshHome({ home });
115
+ const bridgePort = new URL(bridgeTarget).port;
102
116
 
103
117
  async function start() {
104
118
  if (await healthCheck()) return { ok: true, started: false };
@@ -106,7 +120,7 @@ export function createCrewSidecarSupervisor({
106
120
  const childAlive = runningChild && runningChild.killed !== true && runningChild.exitCode == null;
107
121
  if (!childAlive) {
108
122
  runningChild = spawnImpl(process.execPath, [
109
- runtime, '--profile', 'dsh-crew', '--host', '127.0.0.1', '--port', '3210',
123
+ runtime, '--profile', 'dsh-crew', '--host', '127.0.0.1', '--port', bridgePort,
110
124
  ], {
111
125
  cwd: dshHome,
112
126
  env: { ...process.env, DSH_HOME: dshHome },
@@ -138,6 +152,7 @@ const processSupervisor = createCrewSidecarSupervisor();
138
152
  export async function proxyCrewRequest(req, res, {
139
153
  fetchImpl = globalThis.fetch,
140
154
  ensureBackend = () => processSupervisor.ensure(),
155
+ bridgeTarget = resolveCrewBridgeTarget(),
141
156
  } = {}) {
142
157
  if (!isTrustedLocalRequest(req)) {
143
158
  sendJson(res, 403, { ok: false, code: 'LOCAL_SAME_ORIGIN_ONLY' });
@@ -154,7 +169,7 @@ export async function proxyCrewRequest(req, res, {
154
169
  if (backend?.ok === false) throw new Error('backend unavailable');
155
170
  const method = String(req.method ?? 'GET').toUpperCase();
156
171
  const bodyBuffer = method === 'GET' || method === 'HEAD' ? null : await readBoundedBody(req);
157
- const response = await fetchImpl(`${CREW_BRIDGE_TARGET}${req.url}`, {
172
+ const response = await fetchImpl(`${bridgeTarget}${req.url}`, {
158
173
  method,
159
174
  headers: safeHeaders(req.headers),
160
175
  body: bodyBuffer === null ? undefined : new Blob([bodyBuffer]),
@@ -0,0 +1,107 @@
1
+ // Versioned, narrow Worker/Reviewer profiles. Profiles configure one DSH
2
+ // delegation; they are not general Agent personas and never contain prompts or
3
+ // credentials.
4
+
5
+ import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs';
6
+ import { dirname, join } from 'node:path';
7
+ import { homedir } from 'node:os';
8
+
9
+ export const ROLE_PROFILE_SCHEMA_VERSION = 1;
10
+ export const DEFAULT_ROLE_PROFILES = Object.freeze({
11
+ 'worker-default': Object.freeze({
12
+ role: 'worker', routing: 'auto', isolation: 'worktree', fallback: true,
13
+ timeout_seconds: 1800, review_strictness: 'standard',
14
+ }),
15
+ 'reviewer-default': Object.freeze({
16
+ role: 'reviewer', routing: 'stable', isolation: 'readonly', fallback: false,
17
+ timeout_seconds: 1800, review_strictness: 'strict',
18
+ }),
19
+ });
20
+
21
+ const ID = /^[a-z0-9][a-z0-9._-]{0,63}$/;
22
+ const ROLES = new Set(['worker', 'reviewer']);
23
+ const ROUTING = new Set(['auto', 'priority', 'stable']);
24
+ const ISOLATION = new Set(['worktree', 'readonly', 'shared']);
25
+ const STRICTNESS = new Set(['standard', 'strict']);
26
+
27
+ function normalizeProfile(id, raw) {
28
+ if (!ID.test(id) || !raw || typeof raw !== 'object' || Array.isArray(raw)) return null;
29
+ if (!ROLES.has(raw.role)) return null;
30
+ const base = DEFAULT_ROLE_PROFILES[`${raw.role}-default`];
31
+ const routing = raw.routing ?? base.routing;
32
+ const isolation = raw.isolation ?? base.isolation;
33
+ const reviewStrictness = raw.review_strictness ?? base.review_strictness;
34
+ const timeout = raw.timeout_seconds ?? base.timeout_seconds;
35
+ if (!ROUTING.has(routing) || !ISOLATION.has(isolation) || !STRICTNESS.has(reviewStrictness)) return null;
36
+ if (!Number.isInteger(timeout) || timeout < 1 || timeout > 7200) return null;
37
+ if (raw.fallback !== undefined && typeof raw.fallback !== 'boolean') return null;
38
+ return {
39
+ role: raw.role,
40
+ routing,
41
+ isolation,
42
+ fallback: raw.fallback ?? base.fallback,
43
+ timeout_seconds: timeout,
44
+ review_strictness: reviewStrictness,
45
+ };
46
+ }
47
+
48
+ export function roleProfilesFile({ home = homedir() } = {}) {
49
+ return join(home, '.config', 'dsh-crew', 'profiles.json');
50
+ }
51
+
52
+ export function loadRoleProfiles({ home = homedir(), file = roleProfilesFile({ home }) } = {}) {
53
+ if (!existsSync(file)) {
54
+ return { schema_version: ROLE_PROFILE_SCHEMA_VERSION, ok: true, source: 'defaults', profiles: { ...DEFAULT_ROLE_PROFILES }, errors: [] };
55
+ }
56
+ let raw;
57
+ try { raw = JSON.parse(readFileSync(file, 'utf8')); } catch {
58
+ return { schema_version: ROLE_PROFILE_SCHEMA_VERSION, ok: false, source: 'file', profiles: { ...DEFAULT_ROLE_PROFILES }, errors: [{ code: 'PROFILE_FILE_INVALID' }] };
59
+ }
60
+ return parseRoleProfiles(raw);
61
+ }
62
+
63
+ function parseRoleProfiles(raw) {
64
+ const errors = [];
65
+ const profiles = { ...DEFAULT_ROLE_PROFILES };
66
+ if (raw?.schema_version !== ROLE_PROFILE_SCHEMA_VERSION || !raw.profiles || typeof raw.profiles !== 'object' || Array.isArray(raw.profiles)) {
67
+ return { schema_version: ROLE_PROFILE_SCHEMA_VERSION, ok: false, source: 'file', profiles, errors: [{ code: 'PROFILE_FILE_INVALID' }] };
68
+ }
69
+ for (const [id, value] of Object.entries(raw.profiles)) {
70
+ if (id in DEFAULT_ROLE_PROFILES) {
71
+ const normalized = normalizeProfile(id, value);
72
+ if (!normalized || JSON.stringify(normalized) !== JSON.stringify(DEFAULT_ROLE_PROFILES[id])) {
73
+ errors.push({ code: 'PROFILE_DEFAULT_RESERVED', profile_id: id });
74
+ }
75
+ continue;
76
+ }
77
+ const profile = normalizeProfile(id, value);
78
+ if (!profile) errors.push({ code: 'PROFILE_INVALID', profile_id: ID.test(id) ? id : '<invalid>' });
79
+ else profiles[id] = profile;
80
+ }
81
+ return { schema_version: ROLE_PROFILE_SCHEMA_VERSION, ok: errors.length === 0, source: 'file', profiles, errors: errors.slice(0, 32) };
82
+ }
83
+
84
+ export function saveRoleProfiles(document, { home = homedir(), file = roleProfilesFile({ home }) } = {}) {
85
+ const parsed = parseRoleProfiles(document);
86
+ if (!parsed.ok) return parsed;
87
+ const custom = Object.fromEntries(Object.entries(parsed.profiles).filter(([id]) => !(id in DEFAULT_ROLE_PROFILES)));
88
+ const payload = { schema_version: ROLE_PROFILE_SCHEMA_VERSION, profiles: custom };
89
+ mkdirSync(dirname(file), { recursive: true });
90
+ const temp = `${file}.tmp-${process.pid}-${Date.now()}`;
91
+ try {
92
+ writeFileSync(temp, `${JSON.stringify(payload, null, 2)}\n`, { encoding: 'utf8', mode: 0o600 });
93
+ renameSync(temp, file);
94
+ } catch (error) {
95
+ rmSync(temp, { force: true });
96
+ return { ...parsed, ok: false, errors: [{ code: 'PROFILE_FILE_WRITE_FAILED' }], error_code: 'PROFILE_FILE_WRITE_FAILED' };
97
+ }
98
+ return { ...parsed, source: 'file' };
99
+ }
100
+
101
+ export function resolveRoleProfile(registry, profileId, role = 'worker') {
102
+ const id = profileId ?? `${role}-default`;
103
+ const profile = registry?.profiles?.[id];
104
+ if (!profile) return { ok: false, code: 'PROFILE_NOT_FOUND', profile_id: id };
105
+ if (profile.role !== role) return { ok: false, code: 'PROFILE_ROLE_MISMATCH', profile_id: id, expected_role: role };
106
+ return { ok: true, profile_id: id, profile: { ...profile } };
107
+ }
@@ -8,7 +8,7 @@
8
8
  // Keep this module pure and dependency-free so Hub, MCP and tests all use the
9
9
  // exact same compatibility rules.
10
10
 
11
- export const RUNTIME_VERSION = '0.3.8';
11
+ export const RUNTIME_VERSION = '0.4.0';
12
12
  export const HUB_PROTOCOL_VERSION = 1;
13
13
 
14
14
  export const HUB_CAPABILITIES = Object.freeze([
@@ -21,6 +21,11 @@ export const HUB_CAPABILITIES = Object.freeze([
21
21
  'model-catalog',
22
22
  'presets',
23
23
  'config',
24
+ 'canonical-events',
25
+ 'evidence',
26
+ 'profiles',
27
+ 'workspace-context',
28
+ 'extension-contract',
24
29
  ]);
25
30
 
26
31
  // Capabilities the current MCP workflow depends on for full Hub execution.