@aiwuk/team 0.0.0-stage → 5.0.3

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 (116) hide show
  1. package/CHANGELOG.md +156 -0
  2. package/LICENSE +21 -0
  3. package/README.md +149 -2
  4. package/README.zh-CN.md +150 -0
  5. package/access.js +93 -0
  6. package/activity-summary.js +36 -0
  7. package/allocation.js +34 -0
  8. package/board-dispatch.js +141 -0
  9. package/brand.js +41 -0
  10. package/client/00-core-zh.js +284 -0
  11. package/client/01-i18n-en.js +265 -0
  12. package/client/02-styles.js +336 -0
  13. package/client/02b-stage-styles.js +65 -0
  14. package/client/03-shared.js +82 -0
  15. package/client/03b-collapsible-list.js +34 -0
  16. package/client/03c-config-panel.js +605 -0
  17. package/client/04-tool-cards.js +110 -0
  18. package/client/04a-dispatch-confirm.js +162 -0
  19. package/client/04b-more-cards.js +78 -0
  20. package/client/04c-model-picker.js +68 -0
  21. package/client/04d-confirm-dock.js +40 -0
  22. package/client/05-policy.js +217 -0
  23. package/client/06-usage.js +195 -0
  24. package/client/06b-model-source.js +67 -0
  25. package/client/07-board-parts.js +312 -0
  26. package/client/07a-role-theme.js +25 -0
  27. package/client/08-artifacts-report.js +375 -0
  28. package/client/08a-task-control.js +126 -0
  29. package/client/08b-stage-sprites.js +97 -0
  30. package/client/08c-stage-colors.js +55 -0
  31. package/client/08c-stage-scene.js +238 -0
  32. package/client/08d-stage-adapter.js +65 -0
  33. package/client/08e-stage-activity.js +83 -0
  34. package/client/09-team-board.js +181 -0
  35. package/client/09b-role-view.js +95 -0
  36. package/client/09c-model-tabs.js +20 -0
  37. package/client/10-overview.js +364 -0
  38. package/client/10b-onboarding.js +121 -0
  39. package/client/11-register.js +249 -0
  40. package/client.js +5053 -0
  41. package/config-projection.js +40 -0
  42. package/config.js +411 -0
  43. package/cordis.patch.yml +110 -0
  44. package/defaults.js +222 -0
  45. package/dispatch-confirm.js +293 -0
  46. package/dispatch-routes-store.js +31 -0
  47. package/failover.js +85 -0
  48. package/file-limits.js +119 -0
  49. package/free-worker.mjs +125 -0
  50. package/http-guard.js +14 -0
  51. package/i18n/roles.en.json +23 -0
  52. package/i18n/roles.zh.json +23 -0
  53. package/i18n/server.en.json +659 -0
  54. package/i18n/server.zh.json +659 -0
  55. package/i18n.js +34 -0
  56. package/icon.svg +7 -0
  57. package/index.js +111 -0
  58. package/integrations-config.js +301 -0
  59. package/integrations-messages.js +62 -0
  60. package/integrations.js +17 -0
  61. package/lead-route.js +30 -0
  62. package/locale/en.json +6 -0
  63. package/locale/zh.json +6 -0
  64. package/long-context.js +30 -0
  65. package/managed-config-route.js +49 -0
  66. package/managed-ref.js +38 -0
  67. package/managed-resolve.js +47 -0
  68. package/member-task-control.js +242 -0
  69. package/memory-backends.js +89 -0
  70. package/memory-embed.js +70 -0
  71. package/memory-hub.js +150 -0
  72. package/memory-openviking.js +101 -0
  73. package/memory-redact.js +34 -0
  74. package/memory-store.js +219 -0
  75. package/memory-sync-queue.js +28 -0
  76. package/model-policy.js +85 -0
  77. package/model-prefs.js +104 -0
  78. package/observability-api.js +76 -0
  79. package/observability-projection.js +141 -0
  80. package/onboarding.js +47 -0
  81. package/opencode.js +74 -0
  82. package/package.json +153 -4
  83. package/plan-gate.js +92 -0
  84. package/preset-guard.js +47 -0
  85. package/request-evidence.js +41 -0
  86. package/roles.js +69 -0
  87. package/roster-defaults.js +70 -0
  88. package/route-health.js +39 -0
  89. package/runtime-observability.js +78 -0
  90. package/runtime-view.js +21 -0
  91. package/scripts/memory-cli.mjs +138 -0
  92. package/search-engines.js +119 -0
  93. package/search.js +164 -0
  94. package/session-policy.js +59 -0
  95. package/session-rotation.js +206 -0
  96. package/settings-schema.js +40 -0
  97. package/shared/managed-config.js +84 -0
  98. package/single-model.js +44 -0
  99. package/skills/project-role-theme/SKILL.md +46 -0
  100. package/task-bridge.js +32 -0
  101. package/task-control-api.js +36 -0
  102. package/task-control-audit.js +60 -0
  103. package/team.js +101 -0
  104. package/teammate-context.js +120 -0
  105. package/teammate-wait.js +18 -0
  106. package/template.js +46 -0
  107. package/theme-plugin.js +172 -0
  108. package/theme.js +115 -0
  109. package/token-discipline.js +116 -0
  110. package/token-metrics.js +37 -0
  111. package/tool-names.js +10 -0
  112. package/tool-schema.js +23 -0
  113. package/transcribe.js +104 -0
  114. package/ui.js +113 -0
  115. package/usage.js +92 -0
  116. package/workflow.js +246 -0
package/defaults.js ADDED
@@ -0,0 +1,222 @@
1
+ import { m as modelText } from './i18n.js';
2
+ // Default matching rules: classify whatever chat models the user's DSH channels expose
3
+ // (official APIs, OpenAI-compatible gateways, self-hosted relays) into capability tiers,
4
+ // then fill every capability that has no explicit route with a primary and same-tier backups.
5
+ //
6
+ // Rules key on vendor naming conventions, never on one user's channel names or catalog.
7
+ // Naming snapshot: 2026-10 (checked against the OpenRouter public catalog). Vendors rename their
8
+ // lines between generations, so each rule lists both the current and the previous scheme.
9
+ // "Current" is relative to the user's own catalog: when a newer major version of the same vendor
10
+ // is present, older ones count as legacy (one tier lower, ranked last). Unknown ids are reported.
11
+ import { routeMatcher } from './model-policy.js';
12
+
13
+ /** Non-chat or non-agent models are never candidates. */
14
+ // Also excluded: voice-only Live models, robotics, managed agents (deep-research, antigravity),
15
+ // restricted specialty models (daybreak, rosalind, gpt-5.6-cyber), roleplay (-her) and models without tool calling
16
+ // (grok multi-agent): a team member must be able to call tools.
17
+ const NON_CHAT = /(image|imagine|video|veo|sora|dall-?e|tts|speech|voice|audio|realtime|transcri|whisper|embed|rerank|moderation|safety|guard|ocr|banana|lyria|auto-review|computer-use|search-preview|-lora\b|(^|-)live($|-)|translate|robotics|deep-research|antigravity|daybreak|rosalind|-her\b|multi-agent|-cyber\b)/;
18
+ /** Free / trial variants are unreliable for routing. */
19
+ const FREE = /(^|[-:_/])free($|[-:_])/;
20
+
21
+ /** Turn any gateway spelling into one comparable id. */
22
+ export function normalizeModelId(id) {
23
+ let s = String(id).toLowerCase().trim();
24
+ s = s.replace(/:(free|beta|extended|thinking|online|nitro|floor|batch)$/, '');
25
+ s = s.slice(s.lastIndexOf('/') + 1); // anthropic/…, bai/…, pro/deepseek-ai/…, accounts/x/models/…
26
+ s = s.replace(/^models[-_]/, '');
27
+ s = s.replace(/-(\d{4}-\d{2}-\d{2}|\d{8}|\d{6})(?=$|-)/g, ''); // dated snapshots
28
+ s = s.replace(/-(0[1-9]|1[0-2])(0[1-9]|[12]\d|3[01])(?=$|-)/g, ''); // MMDD snapshots
29
+ s = s.replace(/-chat-latest$/, '');
30
+ s = s.replace(/(\d)-(\d)(?=$|-[a-z])/g, '$1.$2'); // claude-opus-4-5 → 4.5
31
+ return s;
32
+ }
33
+ const strip = s => s.replace(/\b(a)?\d+(\.\d+)?[bkmt]\b/g, ' ').replace(/\d{3,}/g, ' ');
34
+ function versionOf(norm) {
35
+ const m = strip(norm.replace(/-\d{3}$/, '')).match(/\d+(?:\.\d+)?/); // drop revision suffixes like -001
36
+ return m ? Number(m[0]) : undefined;
37
+ }
38
+ /** Total parameters in billions, for open-weight sizing (`-32b`, `-2.4t-a95b`). */
39
+ function paramsOf(norm) {
40
+ const m = norm.match(/(?:^|[-_])(\d+(?:\.\d+)?)([bt])(?:$|[-_])/);
41
+ return m ? Number(m[1]) * (m[2] === 't' ? 1000 : 1) : undefined;
42
+ }
43
+ const bySize = (norm, fallback) => {
44
+ const p = paramsOf(norm);
45
+ return p === undefined ? fallback : p <= 40 ? 'low' : p <= 150 ? 'medium' : 'upper';
46
+ };
47
+ /** Effort / speed / mode variants of one model share an identity, so they never fill two backup slots. */
48
+ const MODES = /-(xhigh|high|medium|low|minimal|max|thinking|reasoning|non-reasoning|nothink|instruct|it|preview|exp|latest|ultraspeed|fast|customtools)$/;
49
+
50
+ /**
51
+ * Vendor rules, checked in order; the first matching class wins, so line names precede suffixes.
52
+ * `floor`: versions below it are too old for agent work and are excluded.
53
+ * `modes`: vendor-specific suffixes that are a mode of the same model rather than another tier.
54
+ */
55
+ export const VENDOR_RULES = Object.freeze([
56
+ // Claude: Opus / Fable flagship, Sonnet main, Haiku light.
57
+ { vendor: 'claude', match: /^claude-/, floor: 4, base: 'upper',
58
+ classes: [[/(opus|fable)/, 'high'], [/sonnet/, 'upper'], [/haiku/, 'low']] },
59
+ // OpenAI GPT-6+: astra flagship, sol main, terra balanced, luna light; "-pro" exists on every
60
+ // line at the same price, so it is a reasoning mode. GPT-5.x: -pro flagship, -mini, -nano.
61
+ { vendor: 'gpt', match: /^(gpt|chatgpt)-(?!oss)/, floor: 5, base: 'upper', modes: /-pro$/,
62
+ classes: [[/-astra/, 'high'], [/-sol/, 'upper'], [/-terra/, 'medium'], [/-luna/, 'low'],
63
+ [/-pro\b/, 'high'], [/-nano\b/, 'low'], [/-(mini|instant|chat)\b/, 'medium']] },
64
+ { vendor: 'gpt-oss', match: /^gpt-oss/, base: 'medium', size: true },
65
+ // Gemini 4 names its flagship by codename (gemini-4-argon); 3.x used -pro. Flash / Flash-Lite below.
66
+ // gemini-omni-* is video generation, not chat.
67
+ { vendor: 'gemini', match: /^gemini-/, exclude: /omni/, floor: 3, base: 'upper',
68
+ classes: [[/(flash-lite|-lite\b|-nano\b)/, 'low'], [/flash/, 'medium'], [/(pro|ultra)/, 'high'], [/^gemini-\d+(\.\d+)?-[a-z]+($|-)/, 'high']] },
69
+ // Grok's own version is the number after "grok-"; product lines (grok-build-0.1, grok-composer-2.5)
70
+ // carry their own numbering and are treated as versionless.
71
+ { vendor: 'grok', match: /^grok-/, version: /^grok-(\d+(?:\.\d+)?)/, floor: 4, base: 'upper',
72
+ classes: [[/heavy/, 'high'], [/mini/, 'low'], [/(build|composer|code|non-reasoning)/, 'medium']] },
73
+ // DeepSeek Flash is a full general model sold cheaply, not a trimmed mini: balanced tier.
74
+ // Official API ids (2026-10): versionless `deepseek-flash` (now V4.1-Flash-1017) and `deepseek-v4-pro`;
75
+ // the old `deepseek-v4-flash` id is retired and silently served by V4.1 Flash.
76
+ { vendor: 'deepseek', match: /^deepseek-/, floor: 3, base: 'upper',
77
+ classes: [[/distill/, 'low'], [/(flash|lite)/, 'medium']] },
78
+ // Other Chinese vendors' "-flash" lines are their light models (~0.1/0.5 per M), unlike Gemini Flash.
79
+ { vendor: 'qwen', match: /^(qwen|qwq)/, floor: 3, base: 'upper', size: true,
80
+ classes: [[/(max|prime|coder)/, 'upper'], [/plus/, 'medium'], [/(turbo|flash)/, 'low']] },
81
+ { vendor: 'kimi', match: /^(kimi-|moonshot-)/, floor: 2, base: 'upper', classes: [[/(turbo|mini)/, 'medium']] },
82
+ { vendor: 'glm', match: /^glm-/, floor: 4.5, base: 'upper', classes: [[/flashx/, 'medium'], [/flash/, 'low'], [/air/, 'medium']] },
83
+ { vendor: 'minimax', match: /^minimax-/, floor: 2, base: 'upper', classes: [[/flash/, 'low']] },
84
+ { vendor: 'mimo', match: /^mimo-/, floor: 2, base: 'medium', classes: [[/pro/, 'upper'], [/flash/, 'low']] },
85
+ { vendor: 'doubao', match: /^doubao-/, base: 'upper', classes: [[/lite/, 'low'], [/flash/, 'medium']] },
86
+ { vendor: 'mistral', match: /^(mistral|magistral|codestral|devstral|ministral)/, base: 'medium', size: true,
87
+ classes: [[/large/, 'upper'], [/medium/, 'medium'], [/(small|ministral|tiny)/, 'low']] },
88
+ // NVIDIA Nemotron (often `llama-3.3-nemotron-super-…`): checked before Llama so the line name wins.
89
+ { vendor: 'nemotron', match: /nemotron/, version: /(?!)/, base: 'medium', size: true,
90
+ classes: [[/ultra/, 'upper'], [/super/, 'medium'], [/(nano|mini)/, 'low']] },
91
+ // StepFun Step 3+ (`step3`, `step-3.5-flash`): the Flash line is a large MoE, so it sits in the balanced tier.
92
+ { vendor: 'stepfun', match: /^step-?\d/, floor: 3, base: 'upper', classes: [[/(mini|lite)/, 'low'], [/flash/, 'medium']] },
93
+ // Cohere Command: A is the current flagship line; R / R+ and the 7B are older or light. Digits in both\n // Nemotron and Command ids are base-model versions or dates, so they are treated as versionless.
94
+ { vendor: 'cohere', match: /^(c4ai-)?command-/, version: /(?!)/, base: 'medium',
95
+ classes: [[/command-a/, 'upper'], [/(r7b|light)/, 'low'], [/command-r-plus/, 'medium'], [/command-r\b/, 'low']] },
96
+ { vendor: 'llama', match: /^(llama|meta-llama)/, floor: 4, base: 'medium', size: true },
97
+ { vendor: 'gemma', match: /^gemma/, floor: 4, base: 'low', size: true },
98
+ ]);
99
+ export const TIER_ORDER = Object.freeze(['low', 'medium', 'upper', 'high']);
100
+ const down = tier => TIER_ORDER[Math.max(0, TIER_ORDER.indexOf(tier) - 1)];
101
+
102
+ /**
103
+ * Why a model can never serve as a team member's route unless a project lists it in `tiers` itself: non-chat
104
+ * models cannot call tools and free/trial variants are unreliable. Old versions and unknown vendors are not
105
+ * refused here: they are merely left ungraded, and a user may pick them explicitly.
106
+ */
107
+ export function unroutableReason(modelId) {
108
+ if (FREE.test(String(modelId).toLowerCase())) return modelText('defaults.160');
109
+ const norm = normalizeModelId(modelId);
110
+ if (NON_CHAT.test(norm) || VENDOR_RULES.find(r => r.match.test(norm))?.exclude?.test(norm)) return modelText('defaults.161');
111
+ return undefined;
112
+ }
113
+ /** Classify one id on its own; generation (legacy) is decided against the whole catalog in autoMatch. */
114
+ export function classify(modelId, { rules = VENDOR_RULES, allowFree = false } = {}) {
115
+ const raw = String(modelId).toLowerCase();
116
+ if (!allowFree && FREE.test(raw)) return undefined;
117
+ const norm = normalizeModelId(modelId);
118
+ if (NON_CHAT.test(norm)) return undefined;
119
+ const rule = rules.find(r => r.match.test(norm));
120
+ if (!rule || rule.exclude?.test(norm)) return undefined;
121
+ const version = rule.version ? (m => (m ? Number(m[1]) : undefined))(norm.match(rule.version)) : versionOf(norm);
122
+ if (rule.floor !== undefined && version !== undefined && version < rule.floor) return undefined;
123
+ const hit = (rule.classes ?? []).find(([re]) => re.test(norm));
124
+ const tier = hit ? hit[1] : rule.size ? bySize(norm, rule.base) : rule.base;
125
+ let family = norm;
126
+ for (let prev; prev !== family;) { prev = family; family = family.replace(MODES, ''); if (rule.modes) family = family.replace(rule.modes, ''); }
127
+ return { norm, family, vendor: rule.vendor, tier, version };
128
+ }
129
+
130
+ /**
131
+ * A model is legacy only when the same vendor line (same vendor, same tier) has a newer major in
132
+ * this catalog: gemini-3.1-pro is legacy next to gemini-4-argon, but gemini-3.8-flash is not, because
133
+ * no Gemini 4 Flash exists. Legacy models drop one tier and rank last.
134
+ */
135
+ function markGenerations(candidates) {
136
+ const newest = {};
137
+ const line = c => `${c.vendor}\u0000${c.tier}`;
138
+ for (const c of candidates) if (c.version !== undefined) newest[line(c)] = Math.max(newest[line(c)] ?? 0, Math.floor(c.version));
139
+ for (const c of candidates) {
140
+ if (c.version !== undefined && Math.floor(c.version) < newest[line(c)]) { c.legacy = true; c.tier = down(c.tier); }
141
+ }
142
+ // Within one major, an older minor of the same line (deepseek-v4-flash next to v4.1-flash) stays in
143
+ // its tier but is marked superseded: it ranks after every current model, as a last-resort backup.
144
+ const lineKey = c => `${c.vendor}\u0000${c.tier}\u0000${c.family.replace(/\d+(\.\d+)?/g, '#')}`;
145
+ const latest = {};
146
+ for (const c of candidates) if (c.version !== undefined && !c.legacy) latest[lineKey(c)] = Math.max(latest[lineKey(c)] ?? 0, c.version);
147
+ for (const c of candidates) if (c.version !== undefined && !c.legacy && c.version < latest[lineKey(c)]) c.superseded = true;
148
+ return candidates;
149
+ }
150
+
151
+ /** Pick an order inside one tier: current generation, preferred vendor, newer version, provider order. */
152
+ function ranker(prefer, providerOrder) {
153
+ const pref = v => { const i = prefer.indexOf(v); return i < 0 ? prefer.length : i; };
154
+ const prov = p => { const i = providerOrder.indexOf(p); return i < 0 ? providerOrder.length : i; };
155
+ // A bare id beats its mode variants (gpt-6.1-sol before gpt-6.1-sol-pro) as the default primary.
156
+ const variant = c => Number(c.norm !== c.family);
157
+ // `-latest` aliases point at a moving target that may include previews: use stable ids first.
158
+ // A plain versionless id (DeepSeek's official `deepseek-flash`) is the vendor's stable name, not penalized.
159
+ const alias = c => Number(/latest/.test(c.norm));
160
+ const old = c => Number(!!c.legacy) * 2 + Number(!!c.superseded);
161
+ return (a, b) => old(a) - old(b) || alias(a) - alias(b) || pref(a.vendor) - pref(b.vendor) || (b.version ?? 0) - (a.version ?? 0)
162
+ || variant(a) - variant(b) || prov(a.provider) - prov(b.provider) || a.model.localeCompare(b.model);
163
+ }
164
+ export const MAX_AUTO_FALLBACKS = 3;
165
+ /** Same-tier backups: the primary's own model on another channel first, then other vendors, then the rest. */
166
+ export function pickFallbacks(pool, primary, limit = MAX_AUTO_FALLBACKS) {
167
+ const rest = pool.filter(c => !(c.provider === primary.provider && c.model === primary.model));
168
+ // Same model on another channel first: identical behavior, only the route differs.
169
+ const same = rest.filter(c => c.norm === primary.norm && c.provider !== primary.provider).slice(0, 1);
170
+ const seen = new Set([primary.family]); const distinct = [];
171
+ for (const c of rest) if (!seen.has(c.family)) { seen.add(c.family); distinct.push(c); }
172
+ const current = distinct.filter(c => !c.legacy && !c.superseded && !/latest/.test(c.norm));
173
+ const others = current.filter(c => c.vendor !== primary.vendor);
174
+ // One model per other vendor first, so a single vendor outage cannot take out every backup.
175
+ const vendors = new Set(); const spread = [];
176
+ for (const c of others) if (!vendors.has(c.vendor)) { vendors.add(c.vendor); spread.push(c); }
177
+ const order = [...same, ...spread, ...others.filter(c => !spread.includes(c)), ...current.filter(c => c.vendor === primary.vendor),
178
+ ...distinct.filter(c => !current.includes(c))];
179
+ return order.slice(0, limit).map(({ provider, model }) => ({ provider, model }));
180
+ }
181
+
182
+ /**
183
+ * Match the live catalog to capabilities.
184
+ * @param entries [{provider, model}] in DSH provider registration order
185
+ * @param capabilities {id: {tier, prefer?}} the required tier per capability
186
+ * Never downgrades: an empty tier moves the capability up to the nearest non-empty higher tier.
187
+ */
188
+ export function autoMatch(entries, capabilities, options = {}) {
189
+ const candidates = [];
190
+ const unknown = []; const seen = new Set();
191
+ for (const e of entries) {
192
+ const id = `${e.provider}\u0000${e.model}`;
193
+ if (seen.has(id)) continue;
194
+ seen.add(id);
195
+ const c = classify(e.model, options);
196
+ if (c) candidates.push({ provider: e.provider, model: e.model, ...c });
197
+ else unknown.push(`${e.provider}/${e.model}`);
198
+ }
199
+ markGenerations(candidates);
200
+ const providerOrder = [...new Set(entries.map(e => e.provider))];
201
+ const tiers = Object.fromEntries(TIER_ORDER.map(t => [t, [...new Set(candidates.filter(c => c.tier === t).map(c => c.model))]]));
202
+ const disabled = routeMatcher(options.disabledRoutes ?? []);
203
+ const eligible = candidates.filter(c => !disabled(`${c.provider}/${c.model}`));
204
+ const routes = {}; const report = [];
205
+ for (const [id, cap] of Object.entries(capabilities)) {
206
+ const start = TIER_ORDER.indexOf(cap.tier);
207
+ if (start < 0) { report.push({ capability: id, status: 'unassigned', reason: modelText('defaults.162', { 0: cap.tier }) }); continue; }
208
+ const has = t => eligible.some(c => c.tier === t);
209
+ // Never fill a required capability with a lower tier just to make the roster look ready.
210
+ const tier = TIER_ORDER.slice(start).find(has);
211
+ if (!tier) { report.push({ capability: id, status: 'unassigned', reason: modelText('defaults.163', { 0: cap.tier }) }); continue; }
212
+ const pool = eligible.filter(c => c.tier === tier).sort(ranker(cap.prefer ?? [], providerOrder));
213
+ const [primary] = pool;
214
+ const fallbacks = pickFallbacks(pool, primary);
215
+ const delta = TIER_ORDER.indexOf(tier) - start;
216
+ const status = delta === 0 ? 'matched' : delta > 0 ? 'upgraded' : 'below-tier';
217
+ const reason = { matched: undefined, upgraded: modelText('defaults.164', { 0: cap.tier, 1: tier }), 'below-tier': modelText('defaults.165', { 0: cap.tier, 1: tier }) }[status];
218
+ routes[id] = { provider: primary.provider, model: primary.model, tier, fallbacks };
219
+ report.push({ capability: id, status, tier, primary: `${primary.provider}/${primary.model}`, fallbacks: fallbacks.map(f => `${f.provider}/${f.model}`), ...(reason ? { reason } : {}) });
220
+ }
221
+ return { tiers, routes, pools: candidates, report, unknown };
222
+ }
@@ -0,0 +1,293 @@
1
+ import { m as modelText, t } from './i18n.js';
2
+ // Dispatch confirmation: before a delegate plan takes effect, the user sees every assigned role with its
3
+ // primary model and up to three fallbacks, and may change them for this task or save them as the project
4
+ // default. The Web card answers over api/capability-team.dispatch-confirm; hosts without the card fall back
5
+ // to DSH's native question box. Confirmed per-task routes live in memory per Lead and are applied by
6
+ // team.resolve before the global model policy, so disabled models stay disabled.
7
+ import { crossSiteRequest } from './http-guard.js';
8
+ import { createHash } from 'node:crypto';
9
+ import { modelCatalog, verifyRoutes } from './allocation.js';
10
+ import { routeKey, routeMatcher } from './model-policy.js';
11
+ import { saveProject } from './theme.js';
12
+ import { applyPreferences, unhealthyRoutes } from './model-prefs.js';
13
+ export const DISPATCH_CONFIRM_PATH = '/api/capability-team.dispatch-confirm';
14
+ export { CONFIRM_MODES } from './config.js';
15
+ export const MAX_ROUTE_FALLBACKS = 3;
16
+ const CARD_GRACE_MS = 8000; // no card asked for the request in this time → the host has no card; use the question box
17
+ const HEARTBEAT_MS = 15000; // a card that stopped polling this long ago is gone (tab closed, page left); use the question box
18
+ const CONFIRM_TIMEOUT_MS = 10 * 60 * 1000; // nobody answers at all → fail instead of hanging the Lead's turn forever
19
+ const leads = new Map(); // lead id → { pending: Map(callId → request), routes: Map(role → route), confirmed: Map(role → key) }
20
+ let routeStore; // optional persistence for per-task routes (dispatch-routes-store.js), set by the plugin
21
+ /** Persist confirmed per-task routes so they survive a DSH restart; `undefined` keeps them in memory only. */
22
+ export function setRouteStore(store) { routeStore = store; }
23
+ const stateOf = id => {
24
+ if (!leads.has(id)) {
25
+ const saved = routeStore?.load(id);
26
+ leads.set(id, { pending: new Map(), routes: new Map(saved?.routes ?? []), confirmed: new Map(saved?.confirmed ?? []) });
27
+ }
28
+ return leads.get(id);
29
+ };
30
+ const sameRoute = (a, b) => a?.provider === b?.provider && a?.model === b?.model;
31
+ const keyOf = r => JSON.stringify([r.primary ? routeKey(r.primary) : null, r.fallbacks.map(routeKey)]);
32
+ export const parseRoute = text => {
33
+ const s = String(text ?? '').trim(); const i = s.indexOf('/');
34
+ return i > 0 && i < s.length - 1 && !/\s/.test(s) ? { provider: s.slice(0, i), model: s.slice(i + 1) } : undefined;
35
+ };
36
+ /** Per-task route the user confirmed for a member of this Lead, if any. */
37
+ export function confirmedRoute(leadId, role) { return (leads.has(leadId) || routeStore ? stateOf(leadId) : undefined)?.routes.get(role); }
38
+ /** Apply a confirmed per-task route to a resolved role (before the model policy). */
39
+ export function withConfirmedRoute(role, route) {
40
+ if (!route?.primary) return role;
41
+ return { ...role, provider: route.primary.provider, model: route.primary.model, fallbacks: route.fallbacks, confirmedByUser: true };
42
+ }
43
+ export function forgetLead(leadId) { leads.delete(leadId); }
44
+ /** The roles of a delegate plan with their effective routes and same-tier candidates. */
45
+ export function planRoutes(plan, effective, disabledRoutes, catalog) {
46
+ const disabled = routeMatcher(disabledRoutes);
47
+ return plan.assignments.map(a => {
48
+ const c = effective.capabilities[a.capability];
49
+ const tierModels = effective.tiers[c.tier] ?? [];
50
+ const candidates = catalog.entries.filter(e => !disabled(routeKey(e)))
51
+ .map(e => ({ provider: e.provider, model: e.model, sameTier: tierModels.includes(e.model) }))
52
+ .sort((a, b) => Number(b.sameTier) - Number(a.sameTier));
53
+ const allowed = [...(c.provider ? [{ provider: c.provider, model: c.model }] : []), ...(c.fallbacks ?? [])].filter(r => !disabled(routeKey(r)));
54
+ return { role: a.role, displayName: a.displayName, capability: a.capability, tier: c.tier, task: a.task,
55
+ primary: allowed[0] ?? null, fallbacks: allowed.slice(1, 1 + MAX_ROUTE_FALLBACKS), candidates };
56
+ });
57
+ }
58
+ /** Validate the user's answer against the offered candidates; returns normalized routes or throws. */
59
+ export function validateAnswer(offered, answer) {
60
+ if (!answer || typeof answer !== 'object') throw new Error(modelText('dispatch-confirm.166'));
61
+ if (answer.decision === 'cancel') return { decision: 'cancel' };
62
+ if (answer.decision !== 'confirm') throw new Error(modelText('dispatch-confirm.167'));
63
+ const scope = answer.scope ?? 'task';
64
+ if (!['task', 'project'].includes(scope)) throw new Error(modelText('dispatch-confirm.168'));
65
+ const given = new Map((Array.isArray(answer.routes) ? answer.routes : []).map(r => [r?.role, r]));
66
+ const routes = offered.map(o => {
67
+ const r = given.get(o.role);
68
+ const chosen = r ?? { primary: o.primary, fallbacks: o.fallbacks };
69
+ const allowed = route => o.candidates.some(c => sameRoute(c, route));
70
+ const primary = typeof chosen.primary === 'string' ? parseRoute(chosen.primary) : chosen.primary;
71
+ if (!primary?.provider || !primary?.model || !allowed(primary)) throw new Error(modelText('dispatch-confirm.169', { 0: o.displayName ?? o.role, 1: primary ? routeKey(primary) : String(chosen.primary) }));
72
+ const list = (Array.isArray(chosen.fallbacks) ? chosen.fallbacks : []).map(f => (typeof f === 'string' ? parseRoute(f) : f));
73
+ if (list.length > MAX_ROUTE_FALLBACKS) throw new Error(modelText('dispatch-confirm.170', { 0: o.displayName ?? o.role, 1: MAX_ROUTE_FALLBACKS }));
74
+ const seen = new Set([routeKey(primary)]);
75
+ for (const f of list) {
76
+ if (!f?.provider || !f?.model || !allowed(f)) throw new Error(modelText('dispatch-confirm.171', { 0: o.displayName ?? o.role, 1: f ? routeKey(f) : '?' }));
77
+ if (seen.has(routeKey(f))) throw new Error(modelText('dispatch-confirm.172', { 0: o.displayName ?? o.role, 1: routeKey(f) }));
78
+ seen.add(routeKey(f));
79
+ }
80
+ if (scope === 'project' && [primary, ...list].some(route => o.candidates.find(c => sameRoute(c, route))?.sameTier === false))
81
+ throw new Error(modelText('dispatch-confirm.173'));
82
+ // Compared with the configured route, so a preselected habit still counts as a change when saved as default.
83
+ const ref = o.base ?? o;
84
+ const changed = !sameRoute(primary, ref.primary) || keyOf({ primary, fallbacks: list }) !== keyOf(ref);
85
+ return { role: o.role, capability: o.capability, primary, fallbacks: list, changed };
86
+ });
87
+ if (scope === 'project') {
88
+ const byCap = new Map();
89
+ for (const r of routes.filter(x => x.changed)) {
90
+ const prior = byCap.get(r.capability);
91
+ if (prior && keyOf(prior) !== keyOf(r)) throw new Error(modelText('dispatch-confirm.174', { 0: r.capability }));
92
+ byCap.set(r.capability, r);
93
+ }
94
+ }
95
+ return { decision: 'confirm', scope, routes };
96
+ }
97
+ /** Public view of a pending request for the card; without a callId, the Lead's latest one (for the composer dock). */
98
+ export function pendingView(leadId, callId) {
99
+ const pending = leads.get(leadId)?.pending;
100
+ if (!callId && pending?.size) callId = [...pending.keys()].at(-1);
101
+ const request = pending?.get(callId);
102
+ if (!request) return { schemaVersion: 1, status: 'none' };
103
+ request.lastSeenAt = Date.now(); // heartbeat: the card keeps polling while it is on screen
104
+ // The native question box is already open for this request: the card only points there, so one question has one place to answer.
105
+ const native = request.nativeAsked === true && request.nativeFailed !== true;
106
+ if (request.kind === 'direct') return { schemaVersion: 1, status: 'pending', kind: 'direct', callId, native, analysis: request.plan.analysis, reason: request.plan.reason };
107
+ return { schemaVersion: 1, status: 'pending', callId, native, catalogVersion: request.catalogVersion, maxFallbacks: MAX_ROUTE_FALLBACKS, routes: request.offered };
108
+ }
109
+ /** Resolve a pending request with the card's answer; throws (with status) when it cannot. */
110
+ export function answerPending(leadId, callId, answer) {
111
+ const request = leads.get(leadId)?.pending.get(callId);
112
+ if (!request) throw Object.assign(new Error(modelText('dispatch-confirm.175')), { status: 404, code: 'NOT_FOUND' });
113
+ if (answer?.catalogVersion !== undefined && answer.catalogVersion !== request.catalogVersion) throw Object.assign(new Error(modelText('dispatch-confirm.176')), { status: 409, code: 'CONFLICT' });
114
+ let result;
115
+ try { result = request.validate(answer); } catch (error) { throw Object.assign(error, { status: 422, code: 'INVALID' }); }
116
+ request.resolve(result);
117
+ return { schemaVersion: 1, status: 'resolved', decision: result.decision };
118
+ }
119
+ const fallbackText = r => r.fallbacks.length ? r.fallbacks.map(routeKey).join('、') : t('dispatch-confirm.177');
120
+ const routeLine = r => t('dispatch-confirm.178', { 0: r.displayName ?? r.role, 1: r.capability, 2: r.primary ? routeKey(r.primary) : t('dispatch-confirm.179'), 3: fallbackText(r) });
121
+ /** Native question box: confirm as offered, or type changes as "role: primary, fallback1, fallback2". */
122
+ async function askNative(userQuestions, exec, offered, signal) {
123
+ const answer = await userQuestions.ask({ agent: exec.agent, signal, questions: [{
124
+ id: 'dispatch', header: t('dispatch-confirm.180'),
125
+ question: t('dispatch-confirm.181'),
126
+ detail: offered.map(routeLine).join('\n'),
127
+ options: [{ label: t('dispatch-confirm.182') }, { label: t('dispatch-confirm.183') }, { label: t('dispatch-confirm.184') }, { label: t('dispatch-confirm.185') }],
128
+ }] });
129
+ const item = answer.answers.find(a => a.id === 'dispatch');
130
+ const choice = item?.selected?.[0];
131
+ if (!choice || choice === t('dispatch-confirm.186')) return { decision: 'cancel', note: item?.custom };
132
+ if (choice === t('dispatch-confirm.187')) return { decision: 'confirm', scope: 'task', routes: [] };
133
+ const routes = String(item.custom ?? '').split(/\n|;|;/).map(line => line.trim()).filter(Boolean).map(line => {
134
+ const [role, rest = ''] = line.split(/[::]/);
135
+ const [primary, ...fallbacks] = rest.split(/[,,、]/).map(s => s.trim()).filter(Boolean);
136
+ return { role: role.trim(), primary, fallbacks };
137
+ });
138
+ return { decision: 'confirm', scope: choice === t('dispatch-confirm.188') ? 'project' : 'task', routes };
139
+ }
140
+ /** The Lead's request to work without its step limit has one question: allow or refuse. */
141
+ export function validateDirectAnswer(answer) {
142
+ if (answer?.decision !== 'confirm' && answer?.decision !== 'cancel') throw new Error(modelText('dispatch-confirm.189'));
143
+ return { decision: answer.decision };
144
+ }
145
+ async function askDirectNative(userQuestions, exec, plan, signal) {
146
+ const answer = await userQuestions.ask({ agent: exec.agent, signal, questions: [{
147
+ id: 'direct', header: t('dispatch-confirm.190'),
148
+ question: t('dispatch-confirm.191'),
149
+ detail: t('dispatch-confirm.192', { 0: plan.analysis, 1: plan.reason }),
150
+ options: [{ label: t('dispatch-confirm.193') }, { label: t('dispatch-confirm.194') }],
151
+ }] });
152
+ return { decision: answer.answers.find(a => a.id === 'direct')?.selected?.[0] === t('dispatch-confirm.195') ? 'confirm' : 'cancel' };
153
+ }
154
+ const minutesOf = ms => Number((ms / 60000).toFixed(1));
155
+ /**
156
+ * Register a pending request and wait for its answer from the card or the native question box. Every exit
157
+ * (answer, cancel, timeout, abort) clears the abort listener, both timers and the pending entry, and aborts an
158
+ * open native question box so no stale dialog is left behind.
159
+ */
160
+ function waitForAnswer(ctx, state, exec, callId, spec, { graceMs, heartbeatMs, timeoutMs }) {
161
+ const startedAt = Date.now();
162
+ const native = new AbortController(); // ends the native question box when the request ends any other way
163
+ let settled = false, deadline, check, onAbort, nativeError;
164
+ const request = { ...spec, lastSeenAt: undefined, nativeAsked: false };
165
+ const cleanup = () => {
166
+ clearTimeout(deadline); clearInterval(check);
167
+ if (onAbort) exec.signal?.removeEventListener?.('abort', onAbort);
168
+ if (state.pending.get(callId) === request) state.pending.delete(callId);
169
+ native.abort();
170
+ };
171
+ return new Promise((resolve, reject) => {
172
+ const finish = (fn, value) => { if (settled) return; settled = true; cleanup(); fn(value); };
173
+ request.resolve = value => finish(resolve, value);
174
+ const fail = error => finish(reject, error);
175
+ if (exec.signal?.aborted) { fail(new Error(modelText('dispatch-confirm.196', { 0: spec.subject }))); return; }
176
+ state.pending.set(callId, request);
177
+ onAbort = () => fail(new Error(modelText('dispatch-confirm.197', { 0: spec.subject })));
178
+ exec.signal?.addEventListener?.('abort', onAbort, { once: true });
179
+ deadline = setTimeout(() => fail(new Error(modelText('dispatch-confirm.198', { 0: spec.subject, 1: minutesOf(timeoutMs), 2: spec.consequence })
180
+ + `${nativeError ? modelText('dispatch-confirm.199', { 0: nativeError.code ?? nativeError.message }) : ''}${spec.retry}`)), timeoutMs);
181
+ deadline.unref?.();
182
+ // Only a card that never showed up within graceMs means the host has no card: then ask once in the native
183
+ // question box. A card that was seen keeps the request even when its heartbeat stalls (background tabs throttle
184
+ // timers), so the user never gets the same question twice; the overall timeout still ends an abandoned wait.
185
+ // heartbeatMs stays accepted for callers but no longer triggers the fallback.
186
+ void heartbeatMs;
187
+ const cardGone = () => request.lastSeenAt === undefined && Date.now() - startedAt >= graceMs;
188
+ check = setInterval(() => {
189
+ if (settled || request.nativeAsked || !ctx.userQuestions?.ask || !cardGone()) return;
190
+ request.nativeAsked = true; // asked at most once: a second dialog for the same plan would only confuse
191
+ clearInterval(check);
192
+ spec.ask(native.signal).then(answer => {
193
+ if (settled) return;
194
+ try { request.resolve(spec.validate(answer)); } catch (error) { fail(error); }
195
+ }, error => {
196
+ // The question box failed (e.g. NO_PROVIDER when no client is attached, or it was dismissed by the host).
197
+ // That is not the user's answer: the card can still answer while the request is pending (the user may
198
+ // reopen the conversation), so keep waiting; the overall timeout still ends the wait with a clear error.
199
+ if (!settled) { nativeError = error; request.nativeFailed = true; }
200
+ });
201
+ }, Math.max(5, Math.min(1000, graceMs, heartbeatMs)));
202
+ check.unref?.();
203
+ });
204
+ }
205
+ export function createDispatchConfirm(ctx, team, { graceMs = CARD_GRACE_MS, heartbeatMs = HEARTBEAT_MS, timeoutMs = CONFIRM_TIMEOUT_MS, prefs, health = () => ({}) } = {}) {
206
+ async function saveDefaults(exec, routes) {
207
+ const changed = routes.filter(r => r.changed);
208
+ if (!changed.length) return null;
209
+ const project = await team.pin(exec.agent);
210
+ const { revision } = await team.load(exec.agent);
211
+ const capabilities = Object.fromEntries(changed.map(r => [r.capability, { provider: r.primary.provider, model: r.primary.model, fallbacks: r.fallbacks }]));
212
+ const result = await saveProject(project, { capabilities }, revision, { base: team.base, root: team.root,
213
+ async check(_before, after) { verifyRoutes(changed.flatMap(r => [r.primary, ...r.fallbacks].map(x => ({ ...x, capability: r.capability }))), await modelCatalog(ctx.llm, after.tiers)); } });
214
+ return result.revision;
215
+ }
216
+ return {
217
+ /** Ask the user whether the Lead may finish this turn without its step limit; throws unless they allow it. */
218
+ async confirmDirect(exec, plan) {
219
+ const callId = exec.callId ?? `direct-${Date.now()}`;
220
+ const result = await waitForAnswer(ctx, stateOf(exec.agent.id), exec, callId, { kind: 'direct', plan, subject: modelText('dispatch-confirm.200'), consequence: modelText('dispatch-confirm.201'), retry: modelText('dispatch-confirm.202'),
221
+ validate: validateDirectAnswer, ask: signal => askDirectNative(ctx.userQuestions, exec, plan, signal) }, { graceMs, heartbeatMs, timeoutMs });
222
+ if (result.decision === 'cancel') throw new Error(modelText('dispatch-confirm.203'));
223
+ return true;
224
+ },
225
+ /**
226
+ * Ask the user to confirm a delegate plan. Resolves with the confirmed routes (applied to this Lead),
227
+ * or throws when the user cancels. `mode` is workflow.confirmDispatch.
228
+ */
229
+ async confirm(exec, plan, effective, policy, mode = 'on-change') {
230
+ const leadId = exec.agent.id;
231
+ const state = stateOf(leadId);
232
+ const catalog = await modelCatalog(ctx.llm, effective.tiers);
233
+ // Learned habits and recent failures shape what the card preselects; a route the user already confirmed in
234
+ // this session wins over both. `base` keeps the configured route, which "save as default" compares against.
235
+ const project = prefs ? await team.pin(exec.agent) : undefined;
236
+ const habits = project ? await prefs.habits(project).catch(() => ({})) : {};
237
+ const unhealthy = unhealthyRoutes(health());
238
+ const offered = planRoutes(plan, effective, policy.disabledRoutes, catalog).map(r => {
239
+ const base = { primary: r.primary, fallbacks: r.fallbacks };
240
+ const prior = state.routes.get(r.role);
241
+ const shaped = prior ? { ...r, primary: prior.primary, fallbacks: prior.fallbacks, learned: { unhealthy: {} } } : applyPreferences(r, habits[r.capability], unhealthy);
242
+ return { ...shaped, base };
243
+ });
244
+ const unchanged = offered.every(r => state.confirmed.get(r.role) === keyOf(r));
245
+ if (mode === 'never' || (mode === 'on-change' && unchanged)) return { asked: false, routes: offered };
246
+ const catalogVersion = createHash('sha256').update(JSON.stringify(offered.map(r => r.candidates))).digest('hex').slice(0, 16);
247
+ const callId = exec.callId ?? `dispatch-${Date.now()}`;
248
+ const result = await waitForAnswer(ctx, state, exec, callId, { offered, catalogVersion, subject: modelText('dispatch-confirm.204'), consequence: modelText('dispatch-confirm.205'), retry: modelText('dispatch-confirm.206'),
249
+ validate: answer => validateAnswer(offered, answer), ask: signal => askNative(ctx.userQuestions, exec, offered, signal) }, { graceMs, heartbeatMs, timeoutMs });
250
+ if (result.decision === 'cancel') throw new Error(modelText('dispatch-confirm.207'));
251
+ if (result.scope === 'project') await saveDefaults(exec, result.routes);
252
+ if (project) void prefs.record(project, result.routes).catch(() => {});
253
+ for (const r of result.routes) {
254
+ if (result.scope === 'task' && r.changed) state.routes.set(r.role, { primary: r.primary, fallbacks: r.fallbacks });
255
+ if (result.scope === 'project') state.routes.delete(r.role);
256
+ state.confirmed.set(r.role, keyOf(r));
257
+ }
258
+ routeStore?.save(leadId, state.routes, state.confirmed);
259
+ return { asked: true, scope: result.scope, routes: result.routes.map(r => ({ ...offered.find(o => o.role === r.role), primary: r.primary, fallbacks: r.fallbacks, changed: r.changed })) };
260
+ },
261
+ };
262
+ }
263
+ /** GET ?sessionId=[&callId=] → pending view (latest one without callId); POST {sessionId, callId, decision, scope, routes, catalogVersion}. */
264
+ import { ownsCapabilityTeam } from './preset-guard.js';
265
+ export async function handleDispatchConfirm(ctx, request) {
266
+ const fail = (status, code, message) => Response.json({ code, message }, { status });
267
+ try {
268
+ const url = new URL(request.url);
269
+ const lead = id => {
270
+ const agent = typeof id === 'string' && /^[\w.:-]{1,256}$/.test(id) ? ctx.get('agents')?.get(id) : undefined;
271
+ return ownsCapabilityTeam(ctx, agent) && ctx.get('agentTeams')?.tryMembership(agent)?.role === 'lead' ? agent : undefined;
272
+ };
273
+ if (request.method === 'GET') {
274
+ const agent = lead(url.searchParams.get('sessionId'));
275
+ if (!agent) return fail(404, 'NOT_FOUND', 'Lead unavailable');
276
+ return Response.json(pendingView(agent.id, url.searchParams.get('callId') ?? ''), { headers: { 'cache-control': 'no-store' } });
277
+ }
278
+ if (request.method !== 'POST') return fail(400, 'BAD_REQUEST', 'Method not allowed');
279
+ if (crossSiteRequest(request)) return fail(403, 'FORBIDDEN', 'Same-origin required');
280
+ if (!/^application\/json(?:\s*;|$)/i.test(request.headers.get('content-type') ?? '')) return fail(400, 'BAD_REQUEST', 'JSON required');
281
+ const text = await request.text();
282
+ if (text.length > 16384) return fail(400, 'BAD_REQUEST', 'Body too large');
283
+ let body; try { body = JSON.parse(text); } catch { return fail(400, 'BAD_REQUEST', 'Invalid JSON'); }
284
+ const agent = lead(body?.sessionId);
285
+ if (!agent) return fail(404, 'NOT_FOUND', 'Lead unavailable');
286
+ const { sessionId: _s, callId, ...answer } = body;
287
+ return Response.json(answerPending(agent.id, callId, answer), { headers: { 'cache-control': 'no-store' } });
288
+ } catch (error) { return fail(error.status ?? 503, error.code ?? 'UNAVAILABLE', error.status ? error.message : 'Dispatch confirm unavailable'); }
289
+ }
290
+ export function applyDispatchConfirmApi(ctx) {
291
+ ctx.inject?.(['connection', 'agents', 'agentTeams'], scoped => scoped.connection.fetch.register({
292
+ path: DISPATCH_CONFIRM_PATH, methods: ['GET', 'POST'], requestBody: 'buffered', fetch: req => handleDispatchConfirm(scoped, req) }));
293
+ }
@@ -0,0 +1,31 @@
1
+ // Persistence for per-task routes confirmed in the dispatch card ("this task only"). Lead sessions survive a
2
+ // DSH restart, so their confirmed routes do too: one small JSON file per Lead under the plugin root, written
3
+ // atomically (temp file + rename) and read lazily the first time a Lead is seen after start.
4
+ import { createHash, randomUUID } from 'node:crypto';
5
+ import { mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from 'node:fs';
6
+ import { join } from 'node:path';
7
+ const MAX_AGE_MS = 30 * 86400000; // a Lead untouched for 30 days has finished that task
8
+ export function createRouteStore(root, { now = Date.now } = {}) {
9
+ const dir = join(root, 'dispatch-routes');
10
+ const fileOf = leadId => join(dir, `${createHash('sha256').update(String(leadId)).digest('hex').slice(0, 32)}.json`);
11
+ return {
12
+ /** { routes: [[role, route]], confirmed: [[role, key]] } for a Lead, or undefined. */
13
+ load(leadId) {
14
+ try {
15
+ const data = JSON.parse(readFileSync(fileOf(leadId), 'utf8'));
16
+ if (data?.leadId !== leadId || !Array.isArray(data.routes) || !Array.isArray(data.confirmed)) return undefined;
17
+ if (now() - (data.savedAt ?? 0) > MAX_AGE_MS) { this.remove(leadId); return undefined; }
18
+ return data;
19
+ } catch { return undefined; }
20
+ },
21
+ save(leadId, routes, confirmed) {
22
+ const file = fileOf(leadId), temp = `${file}.${randomUUID()}.tmp`;
23
+ try {
24
+ mkdirSync(dir, { recursive: true });
25
+ writeFileSync(temp, JSON.stringify({ schemaVersion: 1, leadId, savedAt: now(), routes: [...routes], confirmed: [...confirmed] }) + '\n');
26
+ renameSync(temp, file);
27
+ } catch { /* routing still works from memory for this run */ } finally { try { unlinkSync(temp); } catch { /* renamed */ } }
28
+ },
29
+ remove(leadId) { try { unlinkSync(fileOf(leadId)); } catch { /* absent */ } },
30
+ };
31
+ }
package/failover.js ADDED
@@ -0,0 +1,85 @@
1
+ import { m as modelText } from './i18n.js';
2
+ // Same-route retries, then ordered same-tier failover, with a per-route cooldown that doubles while a route keeps
3
+ // failing (a dead provider is not retried every minute) and resets once it has stayed quiet for a while.
4
+ // DSH's own provider retry policy (dsh-llm-retry) runs first; this layer only acts once it declines.
5
+ import { isCredential } from './route-health.js';
6
+ const key = r => `${r.provider}/${r.model}`;
7
+ export const MAX_COOLDOWN_MS = 30 * 60_000;
8
+ /** Cooldown for the n-th consecutive failure: base, 2×, 4×… capped at 30 minutes. */
9
+ export const escalatedCooldown = (baseMs, strikes) => Math.min(baseMs * 2 ** Math.max(0, strikes - 1), Math.max(baseMs, MAX_COOLDOWN_MS));
10
+ function wait(ms, signal) {
11
+ if (signal?.aborted) return Promise.resolve(false);
12
+ return new Promise(resolve => {
13
+ const timer = setTimeout(() => { signal?.removeEventListener('abort', stop); resolve(true); }, ms);
14
+ function stop() { clearTimeout(timer); resolve(false); }
15
+ signal?.addEventListener('abort', stop, { once: true });
16
+ });
17
+ }
18
+ export function createFailover({ now = Date.now, sleep = wait, registered = () => true, log = () => {}, telemetry = () => {} } = {}) {
19
+ const observe = event => { try { telemetry(event); } catch { /* observability never changes recovery */ } };
20
+ const cooling = new Map(); // route key → { until, failure, strikes, lastFailureAt }
21
+ const steps = new WeakMap(); // agent → per-step state
22
+ const healthy = route => (cooling.get(key(route))?.until ?? 0) <= now();
23
+ const candidates = role => [{ provider: role.provider, model: role.model }, ...(role.fallbacks ?? [])];
24
+ return {
25
+ /** Route for this attempt: the step's current candidate, else the first healthy registered one. */
26
+ route(agent, turn, step, role) {
27
+ const list = candidates(role);
28
+ const signature = `${list.map(key).join('|')}|${role.taskLeaseSignature ?? ''}`;
29
+ let state = steps.get(agent);
30
+ if (!state || state.turn !== turn || state.step !== step || state.signature !== signature) {
31
+ const usable = list.map((r, i) => i).filter(i => registered(list[i].provider));
32
+ const start = usable.find(i => healthy(list[i])) ?? usable[0] ?? 0;
33
+ state = { turn, step, signature, index: start, retries: 0, tried: [], role };
34
+ steps.set(agent, state);
35
+ }
36
+ state.role = role;
37
+ return { ...list[state.index], primary: state.index === 0, index: state.index };
38
+ },
39
+ /** Decide recovery after DSH's own retries declined. Returns true to retry the step. */
40
+ async recover(agent, failure, signal) {
41
+ const state = steps.get(agent);
42
+ if (!state || signal?.aborted) return false;
43
+ const { failover: policy } = state.role;
44
+ const list = candidates(state.role);
45
+ const current = list[state.index];
46
+ if (policy.retryCodes.includes(failure.code) && state.retries < policy.retries) {
47
+ state.retries += 1;
48
+ const backoff = Math.min(policy.initialDelayMs * 2 ** (state.retries - 1), policy.maxDelayMs);
49
+ const hinted = failure.providerRetryAfterMs > 0 && failure.providerRetryAfterMs <= policy.maxDelayMs ? failure.providerRetryAfterMs : backoff;
50
+ log(modelText('failover.208', { 0: key(current), 1: state.retries, 2: policy.retries, 3: failure.code, 4: hinted }));
51
+ const retry = await sleep(hinted, signal);
52
+ if (retry) observe({ kind: 'retry', agent });
53
+ return retry;
54
+ }
55
+ // Some adapters report the HTTP status only inside the message ("401 {...}"); read it from there too.
56
+ const status = failure.status ?? Number(/^(\d{3})\b/.exec(String(failure.message ?? ''))?.[1] ?? NaN);
57
+ const eligible = policy.failoverCodes.includes(failure.code) || policy.failoverStatuses.includes(status);
58
+ state.tried.push({ route: key(current), code: failure.code, status: failure.status, message: String(failure.message).slice(0, 300) });
59
+ if (!eligible) return false;
60
+ // A failure soon after the last cooldown ended counts as the same outage; a long quiet spell starts over.
61
+ const prior = cooling.get(key(current));
62
+ const strikes = prior && now() - prior.until < 10 * policy.cooldownMs ? prior.strikes + 1 : 1;
63
+ const until = now() + escalatedCooldown(policy.cooldownMs, strikes);
64
+ cooling.set(key(current), { until, failure: failure.code, strikes, lastFailureAt: now(), credential: isCredential(failure.code, status) });
65
+ observe({ kind: 'cooldown', agent, route: current, until, code: failure.code, status, strikes });
66
+ const tried = new Set(state.tried.map(item => item.route));
67
+ const next = list.findIndex(r => !tried.has(key(r)) && registered(r.provider) && healthy(r));
68
+ if (next < 0) { log(modelText('failover.209', { 0: JSON.stringify(state.tried) })); return false; }
69
+ log(modelText('failover.210', { 0: key(current), 1: failure.code, 2: failure.status ? '/' + failure.status : '', 3: key(list[next]) }));
70
+ observe({ kind: 'switch', agent, from: current, to: list[next], code: failure.code, status: failure.status });
71
+ state.index = next; state.retries = 0;
72
+ return true;
73
+ },
74
+ /** Model-facing status for the team context. */
75
+ status(agent) {
76
+ const state = steps.get(agent);
77
+ if (!state) return undefined;
78
+ const list = candidates(state.role);
79
+ return { active: key(list[state.index]), primary: state.index === 0, failed: state.tried };
80
+ },
81
+ cooling: () => [...cooling].filter(([, v]) => v.until > now()).map(([route, v]) => ({ route, failure: v.failure, until: new Date(v.until).toISOString(), strikes: v.strikes })),
82
+ /** Recent failure record per route (for ranking choices), including routes whose cooldown has ended. */
83
+ health: () => Object.fromEntries([...cooling].map(([route, v]) => [route, { strikes: v.strikes, lastFailureAt: v.lastFailureAt, cooling: v.until > now(), credential: v.credential }])),
84
+ };
85
+ }