@ran-sh/dsh-crew 1.3.2 → 1.5.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.
@@ -18,6 +18,11 @@ import { CREW_UI_SURFACES, classifyCrewSurface, surfaceResponsibilities } from '
18
18
  import { aggregateModelInvocations } from './task-telemetry.mjs';
19
19
  import { PanelHeader, PanelStyles } from './panel-chrome';
20
20
  import { HistoryPanel } from './history-panel';
21
+ // The schedule contract is shared, not mirrored: model-schedule.mjs imports
22
+ // nothing, so the panel can use the same normalization and peak test the router
23
+ // does. A hand-maintained copy could accept less than the server and then write
24
+ // the difference back on save.
25
+ import { normalizeModelSchedule, isPeakAt } from '../model-schedule.mjs';
21
26
 
22
27
  export const inject = ['slots', 'locale'];
23
28
 
@@ -42,6 +47,7 @@ function normalizeAdaptive(value: any): AdaptiveConfig {
42
47
  };
43
48
  }
44
49
 
50
+
45
51
  const COPY = {
46
52
  zh: {
47
53
  label: 'DSH Crew',
@@ -57,7 +63,7 @@ const COPY = {
57
63
  expandAll: '全部展开', collapseAll: '全部折叠',
58
64
  modelCount: (count: number) => `${count} 个模型`, providerCount: (count: number) => `${count} 个 Provider`,
59
65
  jobCount: (count: number) => `${count} 个任务`, runningCount: (count: number) => `${count} 个运行中`,
60
- sectionNames: { integrations: 'Codex / Claude / ZCode 集成状态', workflow: 'Crew 工作流设置', flash: 'Worker / Flash', pro: 'Reviewer / Pro', dispatch: '模型优先级与派发', adaptive: '自适应路由', runtime: '运行 / 生效边界', multimodal: '视觉与生图', harnessProviders: 'Harness Providers', providers: '多模态适配器', jobs: '任务状态' },
66
+ sectionNames: { integrations: 'Codex / Claude / ZCode 集成状态', workflow: 'Crew 工作流设置', flash: 'Worker / Flash', pro: 'Reviewer / Pro', dispatch: '模型优先级与派发', schedule: '波峰波谷调度', adaptive: '自适应路由', runtime: '运行 / 生效边界', multimodal: '视觉与生图', harnessProviders: 'Harness Providers', providers: '多模态适配器', jobs: '任务状态' },
61
67
  harnessHint: 'DeepSeek 官方 Harness 的 dsh-crew profile:Provider、Harness Models 与运行时配置',
62
68
  hostReadiness: '宿主集成就绪度', hostReadinessHint: '只使用结构化安装与运行时证据;缺少证据不会显示 READY。',
63
69
  readinessLabels: { codex_mcp: 'Codex MCP', ds_worker: 'ds-worker', ds_reviewer: 'ds-reviewer', claude_plugin: 'Claude plugin', zcode_mcp: 'ZCode MCP', crew_harness: 'Crew plugin profile', official_bridge: 'Official bridge' },
@@ -163,8 +169,14 @@ const COPY = {
163
169
  save: '保存', saved: '已保存', jobs: 'Worker 任务', empty: '当前没有 Worker / Reviewer 任务。',
164
170
  adaptiveTitle: '自适应模型路由(实验)', adaptiveHint: '默认关闭;只重排系统自动候选,手动模型优先级始终保持原序。健康信号仅来自本进程已观察到的成功、失败、超时与粗粒度延迟,重启后清空。',
165
171
  adaptiveEnabled: '启用自适应路由', adaptiveWindow: '健康窗口', adaptiveSamples: '最少样本', adaptiveBoundary: '下一个工作流生效',
166
- modelActivity: '模型活动概览', modelActivityHint: '聚合本 Hub 内存中最近 500 个含真实调用证据的任务;每个模型按任务计数(不是推测底层轮次),最多显示 50 个模型。不保存提示词、结果或凭据。', noModelActivity: '还没有真实模型活动。',
167
- calls: '任务数', invocationSource: '任务来源', routingSource: '路由来源', lastCalled: '最近调用', role: '角色', model: '模型', never: '—',
172
+ scheduleTitle: '波峰波谷调度', scheduleHint: '按本地时间限制指定模型:波峰时被“阻止”的模型会被跳过,由下一个候选接替任务;“提醒”的仍会使用并在选择轨迹中标记。未列出的模型不受限制。默认时段为 DeepSeek 公布的波峰时间(工作日 09:00-12:00、14:00-18:00)。',
173
+ scheduleTimezone: '时区(相对 UTC)', scheduleWindows: '波峰时段', schedulePeak: '现在处于波峰', scheduleOffPeak: '现在处于波谷',
174
+ scheduleAddWindow: '添加时段', scheduleStart: '开始', scheduleEnd: '结束',
175
+ scheduleBlock: '阻止', scheduleWarn: '提醒', scheduleOff: '关闭',
176
+ scheduleRestricted: '受限模型', scheduleNoRestricted: '还没有受限模型;未列出的模型不受时间限制。',
177
+ scheduleWeekdays: '生效工作日', scheduleBoundary: '下次派发生效',
178
+ modelActivity: '模型活动概览', modelActivityHint: '聚合本 Hub 内存中最近 500 个含真实调用证据的任务;每个模型按任务计数(不是推测底层轮次),最多显示 50 个模型。不保存提示词、结果或凭据。', noModelActivity: '还没有真实模型活动。',
179
+ calls: '任务数', invocationSource: '任务来源', routingSource: '路由来源', lastCalled: '最近调用', role: '角色', model: '模型', never: '—',
168
180
  col: { id: '任务', role: '角色', source: '来源', model: '模型', tier: '档位', status: '状态', progress: '进度', tokens: 'tokens ⇅', task: '内容' },
169
181
  working: '处理中…',
170
182
  tips: {
@@ -199,7 +211,7 @@ const COPY = {
199
211
  expandAll: 'Expand all', collapseAll: 'Collapse all',
200
212
  modelCount: (count: number) => `${count} models`, providerCount: (count: number) => `${count} providers`,
201
213
  jobCount: (count: number) => `${count} jobs`, runningCount: (count: number) => `${count} running`,
202
- sectionNames: { integrations: 'Codex / Claude / ZCode integration status', workflow: 'Crew workflow settings', flash: 'Worker / Flash', pro: 'Reviewer / Pro', dispatch: 'Model priority & dispatch', adaptive: 'Adaptive routing', runtime: 'Runtime / activation boundaries', multimodal: 'Vision & image generation', harnessProviders: 'Harness Providers', providers: 'Multimodal adapters', jobs: 'Task status' },
214
+ sectionNames: { integrations: 'Codex / Claude / ZCode integration status', workflow: 'Crew workflow settings', flash: 'Worker / Flash', pro: 'Reviewer / Pro', dispatch: 'Model priority & dispatch', schedule: 'Peak / off-peak scheduling', adaptive: 'Adaptive routing', runtime: 'Runtime / activation boundaries', multimodal: 'Vision & image generation', harnessProviders: 'Harness Providers', providers: 'Multimodal adapters', jobs: 'Task status' },
203
215
  harnessHint: 'The official DeepSeek Harness dsh-crew profile: providers, Harness Models, and runtime configuration',
204
216
  hostReadiness: 'Host integration readiness', hostReadinessHint: 'Uses structured installer and runtime evidence only; missing evidence is never READY.',
205
217
  readinessLabels: { codex_mcp: 'Codex MCP', ds_worker: 'ds-worker', ds_reviewer: 'ds-reviewer', claude_plugin: 'Claude plugin', zcode_mcp: 'ZCode MCP', crew_harness: 'Crew plugin profile', official_bridge: 'Official bridge' },
@@ -305,8 +317,14 @@ const COPY = {
305
317
  save: 'Save', saved: 'Saved', jobs: 'Worker jobs', empty: 'No Worker / Reviewer jobs yet.',
306
318
  adaptiveTitle: 'Adaptive model routing (experimental)', adaptiveHint: 'Off by default. Only system-derived candidates may be reordered; explicit model priorities always keep their order. Health uses only process-local success, failure, timeout, and coarse latency observations and resets on restart.',
307
319
  adaptiveEnabled: 'Enable adaptive routing', adaptiveWindow: 'Health window', adaptiveSamples: 'Minimum samples', adaptiveBoundary: 'Effective for the next workflow',
308
- modelActivity: 'Model activity overview', modelActivityHint: 'Aggregates the latest 500 in-memory Hub jobs with real model-activity evidence; each model is counted once per qualifying job (not an inferred turn count). Shows at most 50 models; prompts, results, and credentials are never stored here.', noModelActivity: 'No real model activity yet.',
309
- calls: 'Jobs', invocationSource: 'Task source', routingSource: 'Routing source', lastCalled: 'Last called', role: 'Role', model: 'Model', never: '—',
320
+ scheduleTitle: 'Peak / off-peak scheduling', scheduleHint: 'Restricts chosen models by local wall clock. During peak, a "block" model is skipped and the next candidate serves the job; a "warn" model is still used and flagged in the selection trace. Models not listed are unrestricted. Defaults are DeepSeek\'s published peak hours (weekdays 09:00-12:00 and 14:00-18:00).',
321
+ scheduleTimezone: 'Timezone (relative to UTC)', scheduleWindows: 'Peak windows', schedulePeak: 'Peak hours now', scheduleOffPeak: 'Off-peak now',
322
+ scheduleAddWindow: 'Add window', scheduleStart: 'Start', scheduleEnd: 'End',
323
+ scheduleBlock: 'Block', scheduleWarn: 'Warn', scheduleOff: 'Off',
324
+ scheduleRestricted: 'Restricted models', scheduleNoRestricted: 'No restricted models yet; unlisted models have no time limit.',
325
+ scheduleWeekdays: 'Days the windows apply', scheduleBoundary: 'Effective for the next dispatch',
326
+ modelActivity: 'Model activity overview', modelActivityHint: 'Aggregates the latest 500 in-memory Hub jobs with real model-activity evidence; each model is counted once per qualifying job (not an inferred turn count). Shows at most 50 models; prompts, results, and credentials are never stored here.', noModelActivity: 'No real model activity yet.',
327
+ calls: 'Jobs', invocationSource: 'Task source', routingSource: 'Routing source', lastCalled: 'Last called', role: 'Role', model: 'Model', never: '—',
310
328
  col: { id: 'job', role: 'role', source: 'source', model: 'model', tier: 'tier', status: 'status', progress: 'progress', tokens: 'tokens ⇅', task: 'task' },
311
329
  working: 'Working…',
312
330
  tips: {
@@ -1121,6 +1139,59 @@ function WorkersPanel({ ctx }: { ctx: any }) {
1121
1139
  const { next, ordering } = setAdaptiveLocal(candidate);
1122
1140
  void applyPatch({ worker: { model_policy: { adaptive: next, ordering } } });
1123
1141
  };
1142
+ // ---- per-model peak/off-peak scheduling ----
1143
+ // The schedule replaces as a whole (windows and model modes only mean anything
1144
+ // together), so every edit saves the complete object.
1145
+ const schedule = normalizeModelSchedule(config?.model_schedule);
1146
+ const saveSchedule = (candidate: any) => {
1147
+ const next = normalizeModelSchedule(candidate);
1148
+ setConfig((current: any) => ({ ...current, model_schedule: next }));
1149
+ void applyPatch({ model_schedule: next });
1150
+ };
1151
+ const schedulePeakNow = isPeakAt(schedule);
1152
+ const scheduleModeOf = (ref: any) => schedule.models.find(
1153
+ (entry: any) => entry.provider === ref.provider && entry.model === ref.model,
1154
+ )?.mode ?? 'off';
1155
+ const setScheduleMode = (ref: any, mode: string) => {
1156
+ const others = schedule.models.filter(
1157
+ (entry: any) => !(entry.provider === ref.provider && entry.model === ref.model),
1158
+ );
1159
+ saveSchedule({ ...schedule, models: mode === 'off' ? others : [...others, { ...ref, mode }] });
1160
+ };
1161
+ // Every model the router can actually select, not just what the flat tier
1162
+ // mirrors happen to expose. The Pro mirror collapses worker-escalation and
1163
+ // reviewer priority into one list — it shows whichever is set and hides the
1164
+ // other — so a reviewer-only model would be routable yet impossible to
1165
+ // restrict. Harness Default and the catalog are included for the same reason:
1166
+ // routing can land on a model that is in no priority list at all.
1167
+ const scheduleModelRefs = (() => {
1168
+ const seen = new Map<string, any>();
1169
+ const add = (ref: any) => {
1170
+ if (ref?.provider && ref?.model) seen.set(`${ref.provider}\0${ref.model}`, { provider: ref.provider, model: ref.model });
1171
+ };
1172
+ for (const ref of config?.flash_model_priority ?? []) add(ref);
1173
+ for (const ref of config?.pro_model_priority ?? []) add(ref);
1174
+ for (const role of ['worker', 'review'] as const) {
1175
+ const policy = config?.[role]?.model_policy;
1176
+ for (const ref of policy?.priority ?? []) add(ref);
1177
+ for (const ref of policy?.escalation_priority ?? []) add(ref);
1178
+ }
1179
+ add(modelCatalog?.harness_default);
1180
+ for (const provider of modelCatalog?.providers ?? []) {
1181
+ for (const model of provider?.models ?? []) add({ provider: provider.id, model: model?.id });
1182
+ }
1183
+ for (const entry of schedule.models) add(entry);
1184
+ return [...seen.values()];
1185
+ })();
1186
+ const scheduleWeekdayNames = locale === 'zh'
1187
+ ? ['日', '一', '二', '三', '四', '五', '六']
1188
+ : ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
1189
+ const toggleScheduleWeekday = (day: number) => {
1190
+ const days = schedule.weekdays.includes(day)
1191
+ ? schedule.weekdays.filter((value: number) => value !== day)
1192
+ : [...schedule.weekdays, day].sort((a: number, b: number) => a - b);
1193
+ saveSchedule({ ...schedule, weekdays: days });
1194
+ };
1124
1195
  const modelActivity = aggregateModelInvocations(jobs);
1125
1196
  const modelState = modelCallabilityState(readinessSnapshot?.model_callability, runtimeInfo);
1126
1197
  const currentSurfaceResponsibilities = surfaceResponsibilities(surface);
@@ -1466,6 +1537,87 @@ function WorkersPanel({ ctx }: { ctx: any }) {
1466
1537
  </>))}
1467
1538
  </CollapsibleSection>
1468
1539
 
1540
+ <CollapsibleSection sectionId="schedule" title={copy.sectionNames.schedule}
1541
+ summary={sectionSummary(schedulePeakNow ? copy.schedulePeak : copy.scheduleOffPeak,
1542
+ `UTC${schedule.timezone_offset_minutes >= 0 ? '+' : '-'}${Math.abs(schedule.timezone_offset_minutes) / 60}`,
1543
+ config?.model_schedule === undefined ? copy.scheduleOff : `${schedule.models.length} · ${copy.scheduleRestricted}`)}
1544
+ expanded={!!expandedSections.schedule} onToggle={() => toggleSection('schedule')}>
1545
+ {block({ t: copy.scheduleTitle, d: copy.scheduleHint }, (<>
1546
+ <label style={S.field}><span style={S.fieldLabel}>{copy.scheduleTimezone}</span>
1547
+ <CustomSelect value={String(schedule.timezone_offset_minutes)}
1548
+ onChange={(v) => saveSchedule({ ...schedule, timezone_offset_minutes: Number(v) })}
1549
+ options={(() => {
1550
+ // Real-world fixed offsets, not just whole hours: a stored
1551
+ // minute-level offset with no matching option would render as
1552
+ // a different value than it holds.
1553
+ const offsets = new Set<number>();
1554
+ for (let h = -14; h <= 14; h++) offsets.add(h * 60);
1555
+ for (const minutes of [330, 345, 570, -210, -570]) offsets.add(minutes);
1556
+ offsets.add(schedule.timezone_offset_minutes);
1557
+ return [...offsets].sort((a, b) => a - b).map((minutes) => ({
1558
+ value: String(minutes),
1559
+ label: `UTC${minutes >= 0 ? '+' : '-'}${Math.floor(Math.abs(minutes) / 60)}`
1560
+ + (Math.abs(minutes) % 60 ? `:${String(Math.abs(minutes) % 60).padStart(2, '0')}` : ''),
1561
+ }));
1562
+ })()} /></label>
1563
+ <div style={{ gridColumn: '1 / -1' }}>
1564
+ <span style={{ ...S.fieldLabel, display: 'block', marginBottom: 5 }}>{copy.scheduleWeekdays}</span>
1565
+ <div style={{ display: 'flex', gap: 5, flexWrap: 'wrap' }}>
1566
+ {scheduleWeekdayNames.map((name: string, day: number) => (
1567
+ <button key={day} type="button"
1568
+ style={{ ...S.btn, padding: '3px 9px', opacity: schedule.weekdays.includes(day) ? 1 : 0.45 }}
1569
+ onClick={() => toggleScheduleWeekday(day)}>{name}</button>
1570
+ ))}
1571
+ </div>
1572
+ </div>
1573
+ <div style={{ gridColumn: '1 / -1' }}>
1574
+ <span style={{ ...S.fieldLabel, display: 'block', marginBottom: 5 }}>{copy.scheduleWindows}</span>
1575
+ <div style={{ display: 'flex', flexDirection: 'column' as const, gap: 6 }}>
1576
+ {schedule.peak_windows.map((window: any, index: number) => (
1577
+ <div key={`${window.start}-${window.end}-${index}`} style={{ display: 'flex', gap: 6, alignItems: 'center' }}>
1578
+ <input type="time" value={window.start} style={S.input}
1579
+ onChange={(event) => {
1580
+ const next = schedule.peak_windows.map((row: any, i: number) => i === index ? { ...row, start: event.target.value } : row);
1581
+ saveSchedule({ ...schedule, peak_windows: next });
1582
+ }} />
1583
+ <span style={{ opacity: 0.6 }}>–</span>
1584
+ <input type="time" value={window.end} style={S.input}
1585
+ onChange={(event) => {
1586
+ const next = schedule.peak_windows.map((row: any, i: number) => i === index ? { ...row, end: event.target.value } : row);
1587
+ saveSchedule({ ...schedule, peak_windows: next });
1588
+ }} />
1589
+ <button type="button" style={{ ...S.btn, padding: '2px 8px' }}
1590
+ onClick={() => saveSchedule({ ...schedule, peak_windows: schedule.peak_windows.filter((_: any, i: number) => i !== index) })}>×</button>
1591
+ </div>
1592
+ ))}
1593
+ <button type="button" style={{ ...S.btn, alignSelf: 'flex-start' }}
1594
+ onClick={() => saveSchedule({ ...schedule, peak_windows: [...schedule.peak_windows, { start: '09:00', end: '12:00' }] })}>
1595
+ {copy.scheduleAddWindow}</button>
1596
+ </div>
1597
+ </div>
1598
+ <div style={{ gridColumn: '1 / -1' }}>
1599
+ <span style={{ ...S.fieldLabel, display: 'block', marginBottom: 5 }}>{copy.scheduleRestricted}</span>
1600
+ {scheduleModelRefs.length === 0
1601
+ ? <span style={{ fontSize: 11.5, opacity: 0.6 }}>{copy.scheduleNoRestricted}</span>
1602
+ : <div style={{ display: 'flex', flexDirection: 'column' as const, gap: 5 }}>
1603
+ {scheduleModelRefs.map((ref: any) => (
1604
+ <div key={`${ref.provider}\0${ref.model}`} style={{ display: 'flex', gap: 8, alignItems: 'center', padding: '5px 8px', border: '1px solid rgba(128,128,128,0.2)', borderRadius: 6 }}>
1605
+ <span style={{ flex: 1, minWidth: 0, fontSize: 11, fontFamily: 'monospace', overflow: 'hidden', textOverflow: 'ellipsis' }}>{ref.provider} / {ref.model}</span>
1606
+ <CustomSelect value={scheduleModeOf(ref)}
1607
+ onChange={(v) => setScheduleMode(ref, v)}
1608
+ options={[
1609
+ { value: 'off', label: copy.scheduleOff },
1610
+ { value: 'warn', label: copy.scheduleWarn },
1611
+ { value: 'block', label: copy.scheduleBlock },
1612
+ ]} />
1613
+ </div>
1614
+ ))}
1615
+ </div>}
1616
+ <span style={{ display: 'block', fontSize: 11, opacity: 0.55, marginTop: 5 }}>{copy.scheduleBoundary}</span>
1617
+ </div>
1618
+ </>))}
1619
+ </CollapsibleSection>
1620
+
1469
1621
  <CollapsibleSection sectionId="runtime" title={copy.sectionNames.runtime}
1470
1622
  summary={sectionSummary(config.mode, `${config.default_timeout_seconds}s`, config.hub_url)}
1471
1623
  expanded={!!expandedSections.runtime} onToggle={() => toggleSection('runtime')}>
@@ -1838,7 +1990,7 @@ function WorkersPanel({ ctx }: { ctx: any }) {
1838
1990
  <div role="row" key={`${entry.provider}/${entry.model}`} style={{ display: 'grid', gridTemplateColumns: 'minmax(190px, 1.35fr) 72px minmax(170px, 1fr) 125px', gap: 10, alignItems: 'center', padding: '7px 6px', borderTop: '1px solid rgba(128,128,128,0.16)' }}>
1839
1991
  <span role="cell" style={{ minWidth: 0 }}><strong style={{ display: 'block', fontSize: 12 }}>{entry.model}</strong><span style={{ ...S.mono, opacity: 0.55 }}>{entry.provider}</span></span>
1840
1992
  <span role="cell" style={{ ...S.mono, fontSize: 15, fontWeight: 650 }}>{entry.count}</span>
1841
- <span role="cell" style={{ minWidth: 0, fontSize: 11.5 }}><span style={{ display: 'block' }}>{entry.task_sources.map(sourceLabel).join(' · ')}</span><span style={{ display: 'block', opacity: 0.55 }}>{entry.selection_sources.join(' · ')} · {entry.roles.length > 0 ? entry.roles.join(' / ') : copy.never}</span></span>
1993
+ <span role="cell" style={{ minWidth: 0, fontSize: 11.5 }}><span style={{ display: 'block' }}>{entry.task_sources.map(sourceLabel).join(' · ')}</span><span style={{ display: 'block', opacity: 0.55 }}>{entry.selection_sources.join(' · ')} · {entry.roles.length > 0 ? entry.roles.join(' / ') : copy.never}</span></span>
1842
1994
  <span role="cell" style={{ ...S.mono, fontSize: 10.5 }}>{formatTimestamp(entry.last_called_at, locale)}</span>
1843
1995
  </div>
1844
1996
  ))}
@@ -28,18 +28,42 @@ function records(values) {
28
28
  return [...values].sort((a, b) => a.id.localeCompare(b.id, 'en'));
29
29
  }
30
30
 
31
- /** Pure preview only. No filesystem or project-path operations are performed. */
32
- export function planHistoryCleanup(snapshot, { operation = 'archive', scope = 'all', before } = {}) {
33
- if (!['archive', 'delete'].includes(operation) || !['all', 'before'].includes(scope)) throw new Error('HISTORY_INVALID_OPTIONS');
31
+ /**
32
+ * Pure preview only. No filesystem or project-path operations are performed.
33
+ *
34
+ * `scope` defaults to `crew`: a caller that forgets to narrow the range gets the
35
+ * one range that cannot remove sessions the operator opened themselves. Widening
36
+ * to `all` is always an explicit choice.
37
+ */
38
+ export function planHistoryCleanup(snapshot, { operation = 'archive', scope = 'crew', before } = {}) {
39
+ if (!['archive', 'delete'].includes(operation) || !['all', 'crew', 'worktree', 'before'].includes(scope)) throw new Error('HISTORY_INVALID_OPTIONS');
34
40
  const cutoff = scope === 'before' ? instant(before) : null;
35
41
  if (scope === 'before' && (typeof before !== 'string' || cutoff === null)) throw new Error('HISTORY_INVALID_CUTOFF');
36
42
  const workspaces = records(snapshot.workspaces);
37
43
  const sessions = records(snapshot.sessions);
38
44
  const active = ids(snapshot.activeSessionIds);
39
- const selected = new Set(sessions.filter(row => scope === 'all' || (instant(row.createdAt) !== null && instant(row.createdAt) < cutoff)).map(row => row.id));
45
+ // `crew` and `worktree` narrow by provenance, never by project path: a session
46
+ // is Crew's own only when Crew recorded creating it, and worktree scope adds
47
+ // the isolated-workspace marker the hub stamps at dispatch.
48
+ const origin = new Set(Array.isArray(snapshot.crewSessionIds) ? snapshot.crewSessionIds : []);
49
+ const admitted = (row) => {
50
+ if (scope === 'crew') return origin.has(row.id);
51
+ if (scope === 'worktree') return origin.has(row.id) && row.worktree === true;
52
+ return true;
53
+ };
54
+ const withinTime = (row) => cutoff === null
55
+ || (instant(row.createdAt) !== null && instant(row.createdAt) < cutoff);
56
+ const selected = new Set(sessions.filter(row => admitted(row) && withinTime(row)).map(row => row.id));
57
+ // A workspace carries no provenance of its own: it follows its sessions, and
58
+ // only when every one of them is selected. Checking `admitted(row)` here would
59
+ // test a workspace id against a session ledger and always fail. A provenance
60
+ // scope additionally requires at least one selected child — an empty workspace
61
+ // holds no Crew work, so it is not Crew's to remove; `all` keeps its historical
62
+ // behaviour of following an empty workspace.
63
+ const requiresOwnedChild = scope === 'crew' || scope === 'worktree';
40
64
  const workspaceIds = workspaces.filter(row => {
41
65
  const children = ids(row.sessionIds);
42
- return (scope === 'all' || (instant(row.createdAt) !== null && instant(row.createdAt) < cutoff))
66
+ return withinTime(row) && (!requiresOwnedChild || children.length > 0)
43
67
  && children.every(id => selected.has(id));
44
68
  }).map(row => row.id).sort();
45
69
  const sessionIds = [...selected].sort();
@@ -48,6 +72,7 @@ export function planHistoryCleanup(snapshot, { operation = 'archive', scope = 'a
48
72
  workspaces: workspaces.map(row => ({ id: row.id, createdAt: row.createdAt, updatedAt: row.updatedAt, sessionIds: ids(row.sessionIds) })),
49
73
  sessions: sessions.map(row => ({ id: row.id, createdAt: row.createdAt, revision: row.revision })),
50
74
  active,
75
+ crewSessionIds: [...origin].sort(),
51
76
  };
52
77
  return {
53
78
  schemaVersion: 1, operation, scope,
@@ -0,0 +1,34 @@
1
+ // Detach the history maintenance runner from the hub's process tree.
2
+ //
3
+ // The runner must outlive the hub it is maintaining: its transaction stops 3210,
4
+ // rewrites the session store while nothing holds it, then starts 3210 again. The
5
+ // supervisor stops a service by killing that service's whole tracked process
6
+ // tree, and the tree is walked through `ParentProcessId`. A runner spawned
7
+ // directly by the hub is therefore inside the tree and is killed by the very stop
8
+ // it requested, leaving the transaction stranded in STOPPING with a stale update
9
+ // lock and no operator-side way to finish it.
10
+ //
11
+ // This launcher is the intermediate: it starts the runner, confirms the process
12
+ // exists, and exits immediately. The runner's parent is then a process that no
13
+ // longer exists, so the supervisor's tree walk cannot reach it and it survives.
14
+ //
15
+ // Usage: node runner-detach.mjs <runnerPath> [...runnerArgs]
16
+ import { spawn } from 'node:child_process';
17
+
18
+ const [runnerPath, ...runnerArgs] = process.argv.slice(2);
19
+ if (!runnerPath) {
20
+ process.stderr.write('runner-detach: missing runner path\n');
21
+ process.exit(2);
22
+ }
23
+
24
+ const child = spawn(process.execPath, [runnerPath, ...runnerArgs], {
25
+ detached: true,
26
+ windowsHide: true,
27
+ stdio: 'ignore',
28
+ env: { ...process.env },
29
+ });
30
+ child.once('error', () => process.exit(1));
31
+ child.once('spawn', () => {
32
+ child.unref();
33
+ process.exit(0);
34
+ });
@@ -17,8 +17,15 @@ export function registerRuntimeHistory(ctx) {
17
17
  const service = createHistoryService({ crewRoot, agents: host.agents, persistence: host.sessionPersistence,
18
18
  runtimeId: runtime.runtime_id, launch(id, recover = false) {
19
19
  return new Promise((accept, reject) => {
20
- const child = spawn(process.execPath, [fileURLToPath(new URL('./runner.mjs', import.meta.url)), id, ...(recover ? ['--recover'] : [])], {
21
- detached: true, windowsHide: true, stdio: 'ignore', env: { ...process.env },
20
+ // Spawn through a launcher that exits immediately: a runner parented by
21
+ // this hub would sit inside the hub's tracked process tree and be killed
22
+ // by the stop it requested. See runner-detach.mjs.
23
+ const child = spawn(process.execPath, [
24
+ fileURLToPath(new URL('./runner-detach.mjs', import.meta.url)),
25
+ fileURLToPath(new URL('./runner.mjs', import.meta.url)),
26
+ id, ...(recover ? ['--recover'] : []),
27
+ ], {
28
+ windowsHide: true, stdio: 'ignore', env: { ...process.env },
22
29
  });
23
30
  child.once('error', reject); child.once('spawn', () => { child.unref(); accept(); });
24
31
  });
@@ -5,6 +5,8 @@ import { planHistoryCleanup } from './cleanup-plan.mjs';
5
5
  import { installHistoryAdmissionGate } from './admission-gate.mjs';
6
6
  import { historyHash, historyPath, readHistoryBytes, decodeWorkspaceStore, readHistoryManifest, isSessionArtifactPath } from './archive-store.mjs';
7
7
  import { readHistoryState, writeHistoryState, historyPending, publicHistoryState } from './state.mjs';
8
+ import { readSessionOrigins } from '../session-origins.mjs';
9
+ import { defaultWorktreeRoot } from '../workspace-isolation.mjs';
8
10
 
9
11
  export function createHistoryService({ crewRoot, agents, persistence, runtimeId, launch, now = Date.now }) {
10
12
  const gate = installHistoryAdmissionGate(agents, () => historyPending(crewRoot));
@@ -60,12 +62,21 @@ export function createHistoryService({ crewRoot, agents, persistence, runtimeId,
60
62
  }
61
63
  const raw = readHistoryBytes(historyPath(crewRoot, path)); total += raw.length;
62
64
  if (total > 512 * 1024 * 1024) throw Error('HISTORY_INVENTORY_TOO_LARGE');
63
- return { id: header.id, createdAt: header.createdAt, parentSession: header.parentSession,
65
+ return { id: header.id, createdAt: header.createdAt, parentSession: header.parentSession, cwd: header.cwd,
64
66
  revision: JSON.stringify([header.revision, historyHash(raw)]), artifact: { sessionId: header.id, relativePath: path, sha256: historyHash(raw) } };
65
67
  });
66
68
  // A retained child keeps its ancestor chain; do not leave a newer fork orphaned.
67
69
  const workspaces = Object.entries(store.tables.workspaces).map(([id, row]) => ({ id, ...row }));
68
- const plan = planHistoryCleanup({ workspaces, sessions, activeSessionIds: gate.idle() ? [] : ['active-agent'] }, options);
70
+ const crewSessionIds = [...readSessionOrigins()];
71
+ const crewSet = new Set(crewSessionIds);
72
+ for (const row of sessions) if (crewSet.has(row.id)) row.crew = true;
73
+ // The isolated-workspace marker: the hub stamps every worktree session with a
74
+ // cwd under the Crew worktree root, so that subset is scopeable on its own.
75
+ const worktreeRoot = defaultWorktreeRoot().replaceAll('\\', '/').toLowerCase();
76
+ for (const row of sessions) {
77
+ if (row.crew === true && typeof row.cwd === 'string' && row.cwd.replaceAll('\\', '/').toLowerCase().startsWith(worktreeRoot)) row.worktree = true;
78
+ }
79
+ const plan = planHistoryCleanup({ workspaces, sessions, crewSessionIds, activeSessionIds: gate.idle() ? [] : ['active-agent'] }, options);
69
80
  const selected = new Set(plan.sessionIds);
70
81
  const byId = new Map(sessions.map(row => [row.id, row]));
71
82
  const queue = sessions.filter(row => !selected.has(row.id));
package/src/hub/index.mjs CHANGED
@@ -18,6 +18,7 @@ import {
18
18
  resolveRoleTierHint,
19
19
  } from '../policy.mjs';
20
20
  import { buildDirectSelectionTrace, resolveWorkerModel, resolveModel } from '../model-routing.mjs';
21
+ import { scheduleAdmission } from '../model-schedule.mjs';
21
22
  import { readHarnessModelCatalog } from '../model-catalog.mjs';
22
23
  import { appendDeliveryInstructions, parseDeliveryReport, formatDeliveryMetadata } from '../delivery.mjs';
23
24
  import { captureWorkspaceBaseline, captureWorkspaceDiff, NOT_A_GIT_REPOSITORY } from '../workspace-audit.mjs';
@@ -101,6 +102,7 @@ function seedLangFromHost(ctx) {
101
102
  }
102
103
 
103
104
  import { setLang } from '../i18n.mjs';
105
+ import { appendSessionOrigin } from '../session-origins.mjs';
104
106
 
105
107
  const ROUTE_BASE = '/_dsh/dsh-crew';
106
108
  // Quick-config allowlist: the ONLY config keys writable from the official
@@ -701,12 +703,48 @@ export class WorkerRegistry { constructor(ctx) {
701
703
  const getCurrentSelection = () => this.ctx.get('agentDefaultModel')?.currentSelection?.();
702
704
  let selection;
703
705
  if (workerProviderMode === 'deepseek-official') {
706
+ // Strict mode names its model directly instead of walking a catalog, but a
707
+ // peak `block` must still apply: otherwise enabling the schedule would be
708
+ // silently ignored on every dispatch that uses this provider mode.
709
+ const strictRef = { provider: 'deepseek-official', model: legacyModel };
710
+ const strictAt = new Date();
711
+ const strictPeak = scheduleAdmission(cfg.model_schedule, strictRef, strictAt);
712
+ if (strictPeak?.mode === 'block') {
713
+ // Carry the same structured evidence the catalog path produces. The
714
+ // strict branch has no candidate list to fall through, so the trace is
715
+ // the operator's only record of why the dispatch died.
716
+ const blockedTrace = buildDirectSelectionTrace({
717
+ role: effRole,
718
+ logicalAttempt: attempt,
719
+ modelClassHint: effTier,
720
+ strategy: 'legacy-strict',
721
+ candidateSet: attempt > 0 ? 'escalation' : 'primary',
722
+ provider: strictRef.provider,
723
+ model: strictRef.model,
724
+ source: 'legacy-strict',
725
+ });
726
+ blockedTrace.ordered_candidates = [{
727
+ provider: strictRef.provider,
728
+ model: strictRef.model,
729
+ source: 'legacy-strict',
730
+ status: 'skipped',
731
+ reason_code: 'PEAK_RESTRICTED',
732
+ }];
733
+ blockedTrace.fallback_reason = 'PEAK_RESTRICTED';
734
+ throw Object.assign(new Error('MODEL_BLOCKED_PEAK'), {
735
+ policyCode: 'MODEL_BLOCKED_PEAK',
736
+ provider: strictRef.provider,
737
+ model: strictRef.model,
738
+ selection_trace: blockedTrace,
739
+ });
740
+ }
704
741
  selection = {
705
742
  ok: true,
706
743
  provider: 'deepseek-official',
707
744
  model: legacyModel,
708
745
  source: 'legacy-strict',
709
746
  reasoningEffort: effort,
747
+ ...(strictPeak?.mode === 'warn' ? { peak_advisory: true } : {}),
710
748
  selection_trace: buildDirectSelectionTrace({
711
749
  role: effRole,
712
750
  logicalAttempt: attempt,
@@ -716,6 +754,7 @@ export class WorkerRegistry { constructor(ctx) {
716
754
  provider: 'deepseek-official',
717
755
  model: legacyModel,
718
756
  source: 'legacy-strict',
757
+ ...(strictPeak?.mode === 'warn' ? { peakAdvisory: true } : {}),
719
758
  }),
720
759
  };
721
760
  } else {
@@ -747,6 +786,7 @@ export class WorkerRegistry { constructor(ctx) {
747
786
  healthGate: policy.health_gate ?? healthGate,
748
787
  allowFallback: cfg.allow_fallback !== false,
749
788
  tombstones: lifecycleState.tombstones,
789
+ schedule: cfg.model_schedule,
750
790
  });
751
791
  } else {
752
792
  selection = resolveWorkerModel({
@@ -760,6 +800,7 @@ export class WorkerRegistry { constructor(ctx) {
760
800
  healthGate,
761
801
  allowFallback: cfg.allow_fallback !== false,
762
802
  tombstones: lifecycleState.tombstones,
803
+ schedule: cfg.model_schedule,
763
804
  traceContext: {
764
805
  role: effRole,
765
806
  logicalAttempt: attempt,
@@ -809,9 +850,17 @@ export class WorkerRegistry { constructor(ctx) {
809
850
  isolatedWorkspace = { worktreePath: created.worktreePath, repoRoot: created.repoRoot };
810
851
  }
811
852
  const sessionId = `session-${randomUUID()}`;
853
+ // Record provenance while it is still knowable: a session header carries no
854
+ // field naming who asked for the session, so a later Crew-scoped cleanup can
855
+ // only tell Crew's own work apart from the operator's by this ledger.
856
+ appendSessionOrigin({ sessionId, role: jobRole, jobId: id });
812
857
  const job = {
813
858
  id, client_job_id: client_job_id ?? null, sessionId, role: jobRole, attempt, tier: effTier, provider: selection.provider, model: selection.model,
814
859
  selection_source: selection.source, selection_trace: selection.selection_trace ?? null,
860
+ // Routing already reports a peak `warn` as a top-level flag; carry it onto
861
+ // the job so a job consumer sees the same signal the router produced
862
+ // without having to re-derive it from the trace.
863
+ peak_advisory: selection.peak_advisory === true,
815
864
  effort, reasoning_effort: selection.reasoningEffort,
816
865
  task, source, cwd: executionCwd, requested_cwd: cwd,
817
866
  isolation: isolatedWorkspace ? 'worktree' : 'shared',
@@ -1530,6 +1579,9 @@ export async function apply(ctx) {
1530
1579
  healthGate: policy.health_gate ?? config.health_gate ?? config.worker?.model_policy?.health_gate,
1531
1580
  allowFallback: config.allow_fallback !== false,
1532
1581
  tombstones: lifecycleState.tombstones,
1582
+ // A projection must reflect the decision dispatch would make, or
1583
+ // the panel reports a model that routing would skip at peak.
1584
+ schedule: config.model_schedule,
1533
1585
  });
1534
1586
  }
1535
1587
  }
@@ -1768,6 +1820,9 @@ export async function apply(ctx) {
1768
1820
  healthGate,
1769
1821
  allowFallback: config.allow_fallback !== false,
1770
1822
  tombstones: lifecycleState.tombstones,
1823
+ // Same reason as the extension projection: report what dispatch
1824
+ // would actually pick.
1825
+ schedule: config.model_schedule,
1771
1826
  });
1772
1827
  selections[tier] = selected.ok
1773
1828
  ? { provider: selected.provider, model: selected.model, source: selected.source }
@@ -323,6 +323,14 @@ export const GLOBAL_CONFIG_DEFAULTS = {
323
323
  worker_state: undefined,
324
324
  review_state: undefined,
325
325
  auto_review: undefined,
326
+ // ---- per-model peak/off-peak scheduling ----
327
+ // Some providers price by wall clock (DeepSeek doubles its rate during peak
328
+ // hours). `models` lists only the restricted models — one absent from it is
329
+ // unrestricted — each at `warn` (selectable, flagged) or `block` (skipped, so
330
+ // the next candidate serves the job). Times are the operator's local wall
331
+ // clock at `timezone_offset_minutes`, defaulting to UTC+8 with DeepSeek's
332
+ // published peak hours expressed in it.
333
+ model_schedule: undefined,
326
334
  };
327
335
 
328
336
  export function mergeStoredGlobalConfig(stored) {
@@ -13,6 +13,7 @@ import { basename, dirname, join } from 'node:path';
13
13
  import { homedir } from 'node:os';
14
14
  import * as legacy from './install-legacy.mjs';
15
15
  import { normalizeModelPriority } from '../model-routing.mjs';
16
+ import { normalizeModelSchedule } from '../model-schedule.mjs';
16
17
  import { runtimeActivationMetadata } from '../runtime-controls.mjs';
17
18
  import {
18
19
  CONFIG_SCHEMA_VERSION,
@@ -93,12 +94,12 @@ function legacySnapshotFrom(config = {}) {
93
94
  };
94
95
  }
95
96
 
96
- function attachReadMetadata(config, { version, canonical }) {
97
- return {
97
+ function attachReadMetadata(config, { version, canonical }) {
98
+ return {
98
99
  ...config,
99
100
  config_schema_version: version,
100
- config_authority: canonical ? 'canonical' : 'legacy-import',
101
- config_migration_required: !canonical || version < CONFIG_SCHEMA_VERSION,
101
+ config_authority: canonical ? 'canonical' : 'legacy-import',
102
+ config_migration_required: !canonical || version < CONFIG_SCHEMA_VERSION,
102
103
  config_activation: runtimeActivationMetadata(),
103
104
  };
104
105
  }
@@ -210,6 +211,10 @@ function mergeCanonicalPatch(current, patch) {
210
211
  };
211
212
  }
212
213
  if (validObject(patch.legacy)) next.legacy = { ...next.legacy, ...patch.legacy };
214
+ // The peak/off-peak schedule replaces as a whole: its parts (windows, weekdays,
215
+ // offset, per-model modes) only make sense together, so a partial merge could
216
+ // pair one operator's windows with another's model list.
217
+ if (validObject(patch.model_schedule)) next.model_schedule = normalizeModelSchedule(patch.model_schedule);
213
218
 
214
219
  // v0.3 still has one provider-mode control. A direct canonical patch is
215
220
  // allowed, but it cannot manufacture separate worker/reviewer authorities.
@@ -397,10 +402,13 @@ export function writeGlobalConfig(patch, { configFile = GLOBAL_CONFIG_FILE } = {
397
402
  ? mergeLegacyPatchIntoCanonical(current, compatibilityView, candidate, patch)
398
403
  : { ...candidate, legacy: legacySnapshotFrom(candidate) };
399
404
 
405
+ // `model_schedule` is canonical-only (it has no flat mirror), so it must open
406
+ // the canonical merge path on its own rather than ride along with worker/review.
400
407
  const hasCanonicalPatch = validObject(patch?.worker)
401
408
  || validObject(patch?.review)
402
409
  || validObject(patch?.execution)
403
- || validObject(patch?.legacy);
410
+ || validObject(patch?.legacy)
411
+ || validObject(patch?.model_schedule);
404
412
  if (hasCanonicalPatch) normalized = mergeCanonicalPatch(normalized, patch);
405
413
 
406
414
  if (!validObject(normalized.legacy)) normalized.legacy = legacySnapshotFrom(candidate);