@maci0/dsh-legion 0.0.0-stage → 0.8.1

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.
package/lib/client.js ADDED
@@ -0,0 +1,745 @@
1
+ /**
2
+ * dsh-legion browser half.
3
+ *
4
+ * The Legion card on the Plugins page, keyed on the
5
+ * `legion` settings namespace the host half installs. It edits the same
6
+ * decomposition knobs the `/legion` kickoff reads: subtask bounds, depth cap,
7
+ * workers per leaf, merge strategy, review retries, human root approval, and
8
+ * the per-run task budget.
9
+ *
10
+ * One form (`ctx.configForms.get`) is the only source either half
11
+ * sees, so the card and `/legion config` cannot disagree. Numbers commit on
12
+ * blur or Enter with per-field bounds (the same limits the host schema
13
+ * enforces), so a half-typed value never reaches the wire.
14
+ *
15
+ * Plain JavaScript on purpose: the client module system serves this file as a
16
+ * lazy-CJS factory on `window.__ModuleLoader__`; `react` is provided.
17
+ */
18
+
19
+ window.__ModuleLoader__.load({
20
+ id: '@maci0/dsh-legion',
21
+
22
+ factory: (require) => {
23
+ var module = { exports: {} }
24
+ var exports = module.exports
25
+ Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' })
26
+
27
+ const React = require('react')
28
+ const createElement = React.createElement
29
+
30
+ /** Settings namespace shared with the host half; also this card's slot key. */
31
+ const NAMESPACE = 'legion'
32
+
33
+ /** Locale namespace for the card's copy. */
34
+ const LOCALE_NS = 'legion'
35
+
36
+ /**
37
+ * Numeric fields: bounds mirror the host schema and lib/logic.js LIMITS.
38
+ * Copy resolves as `<key>Label` and `<key>Hint`.
39
+ */
40
+ const NUMBERS = [
41
+ { key: 'minSubtasks', min: 1, max: 20 },
42
+ { key: 'maxSubtasks', min: 1, max: 20 },
43
+ { key: 'maxDepth', min: 0, max: 20 },
44
+ { key: 'workersPerTask', min: 1, max: 16 },
45
+ { key: 'maxReviewRetries', min: 0, max: 20 },
46
+ { key: 'maxTasksPerRun', min: 0, max: 10000 },
47
+ ]
48
+
49
+ /** Merge strategies; copy resolves as `<value>Label` and `<value>Hint`. */
50
+ const STRATEGIES = ['best', 'reconcile']
51
+
52
+ const en = {
53
+ minSubtasksLabel: 'Min subtasks',
54
+ minSubtasksHint: 'Fewer proposals than this means the task is a leaf.',
55
+ maxSubtasksLabel: 'Max subtasks',
56
+ maxSubtasksHint: 'Extra proposals are truncated to this.',
57
+ maxDepthLabel: 'Max depth',
58
+ maxDepthHint: 'Hard cap on decomposition levels; 0 = no cap.',
59
+ workersPerTaskLabel: 'Workers per leaf',
60
+ workersPerTaskHint: 'Independent candidates combined by the merge strategy.',
61
+ maxReviewRetriesLabel: 'Review retries',
62
+ maxReviewRetriesHint: 'Rework attempts before a task fails terminally.',
63
+ maxTasksPerRunLabel: 'Task budget',
64
+ maxTasksPerRunHint: 'Tasks a single run may create; 0 = unlimited.',
65
+ bestLabel: 'Best',
66
+ bestHint: 'A judge picks the strongest candidate verbatim.',
67
+ reconcileLabel: 'Reconcile',
68
+ reconcileHint: 'Merge the strengths of every candidate.',
69
+ groupDecomposition: 'Decomposition',
70
+ groupExecution: 'Execution and review',
71
+ groupStrategy: 'Merge strategy',
72
+ groupApproval: 'Human root approval',
73
+ overridden: 'Overridden in your settings',
74
+ fieldRange: '{label} ({min}-{max})',
75
+ invalid: 'Whole number {min}-{max}.',
76
+ approvalRequired: 'Required',
77
+ approvalOff: 'Off',
78
+ approvalRequiredHint: 'The root deliverable waits for /legion approve or /legion reject.',
79
+ approvalOffHint: 'The lead closes the root itself once every child completes.',
80
+ persists: 'Applies to the next /legion run and persists in your settings.',
81
+ readOnly: 'Read-only: settings are not persisted in this deployment.',
82
+ reset: 'Reset {count}',
83
+ summary: 'Split into {min}-{max} subtasks, {depth}.',
84
+ depthUnlimited: 'depth unlimited',
85
+ depthCapped: 'depth ≤ {depth}',
86
+ refused: 'The settings document refused the change.',
87
+ viewTitle: 'Legion decomposition: {tasks} task(s), {levels} level(s)',
88
+ viewSummary: '{completed} completed · {running} in progress · {blocked} blocked · {members} member(s)',
89
+ statusCompleted: 'completed',
90
+ statusInProgress: 'in progress',
91
+ statusPending: 'pending',
92
+ statusDeleted: 'deleted',
93
+ roleLead: 'lead',
94
+ roleTeammate: 'teammate',
95
+ phaseActive: 'active',
96
+ phaseProvisioning: 'provisioning',
97
+ phaseFailed: 'failed',
98
+ member: '{name} ({role})',
99
+ memberPhase: '{name} ({role}, {phase})',
100
+ expand: 'Expand {id}',
101
+ collapse: 'Collapse {id}',
102
+ chipReady: 'ready',
103
+ extraBlockers: '+{count} blocker(s)',
104
+ overlaps: '{count} overlap(s)',
105
+ cycle: 'cycle',
106
+ cycleHint: 'a blocker chain loops back here',
107
+ hidden: '{count} hidden task(s)',
108
+ projectionFailure: 'Task board projection failure: {failure}',
109
+ noTeams: 'Agent Teams is not mounted in this composition, so there is no team task board to draw. Enable the experimental bundle with dsh plugin --profile <name> add @deepseek-ai/dsh-experimental-agent-team-profile, then restart the harness.',
110
+ noRun: 'No legion run in this session. Start one with /legion <task>, or open the lead session of a run.',
111
+ }
112
+
113
+ const zh = {
114
+ minSubtasksLabel: '最少子任务',
115
+ minSubtasksHint: '提出的子任务少于此数时,该任务即为叶子任务。',
116
+ maxSubtasksLabel: '最多子任务',
117
+ maxSubtasksHint: '超出的子任务提议会被截断到此数。',
118
+ maxDepthLabel: '最大深度',
119
+ maxDepthHint: '分解层级的硬上限;0 表示不设上限。',
120
+ workersPerTaskLabel: '每个叶子的执行者',
121
+ workersPerTaskHint: '由合并策略汇总的独立候选数。',
122
+ maxReviewRetriesLabel: '评审重试',
123
+ maxReviewRetriesHint: '任务最终失败前的返工次数。',
124
+ maxTasksPerRunLabel: '任务预算',
125
+ maxTasksPerRunHint: '单次运行可创建的任务数;0 表示不限。',
126
+ bestLabel: '择优',
127
+ bestHint: '由评审者原样选出最强的候选。',
128
+ reconcileLabel: '融合',
129
+ reconcileHint: '合并每个候选的长处。',
130
+ groupDecomposition: '分解',
131
+ groupExecution: '执行与评审',
132
+ groupStrategy: '合并策略',
133
+ groupApproval: '人工根审批',
134
+ overridden: '已在你的设置中覆盖',
135
+ fieldRange: '{label}({min}-{max})',
136
+ invalid: '请输入 {min}-{max} 之间的整数。',
137
+ approvalRequired: '需要',
138
+ approvalOff: '关闭',
139
+ approvalRequiredHint: '根交付物等待 /legion approve 或 /legion reject。',
140
+ approvalOffHint: '所有子任务完成后,负责人自行关闭根任务。',
141
+ persists: '应用于下一次 /legion 运行,并保存在你的设置中。',
142
+ readOnly: '只读:此部署不持久化设置。',
143
+ reset: '重置 {count} 项',
144
+ summary: '拆分为 {min}-{max} 个子任务,{depth}。',
145
+ depthUnlimited: '深度不限',
146
+ depthCapped: '深度 ≤ {depth}',
147
+ refused: '设置文档拒绝了此改动。',
148
+ viewTitle: 'Legion 分解:{tasks} 个任务,{levels} 层',
149
+ viewSummary: '已完成 {completed} · 进行中 {running} · 受阻 {blocked} · 成员 {members}',
150
+ statusCompleted: '已完成',
151
+ statusInProgress: '进行中',
152
+ statusPending: '待处理',
153
+ statusDeleted: '已删除',
154
+ roleLead: '负责人',
155
+ roleTeammate: '队员',
156
+ phaseActive: '活跃',
157
+ phaseProvisioning: '准备中',
158
+ phaseFailed: '失败',
159
+ member: '{name}({role})',
160
+ memberPhase: '{name}({role},{phase})',
161
+ expand: '展开 {id}',
162
+ collapse: '折叠 {id}',
163
+ chipReady: '就绪',
164
+ extraBlockers: '另有 {count} 个阻塞项',
165
+ overlaps: '{count} 处写入范围重叠',
166
+ cycle: '循环',
167
+ cycleHint: '阻塞链在此处形成循环',
168
+ hidden: '已隐藏 {count} 个任务',
169
+ projectionFailure: '任务板投影失败:{failure}',
170
+ noTeams: '此组合未挂载 Agent Teams,因此没有可绘制的团队任务板。请用 dsh plugin --profile <name> add @deepseek-ai/dsh-experimental-agent-team-profile 启用实验性组件包,然后重启 harness。',
171
+ noRun: '此会话没有 legion 运行。用 /legion <task> 开始一次,或打开某次运行的负责人会话。',
172
+ }
173
+
174
+ /** Every class is `lg-`-prefixed: the sheet lands in the page's own document. */
175
+ const CSS = [
176
+ '.lg-body{border-top:0.5px solid var(--dsw-alias-border-l2);margin:0 16px;padding:12px 0 8px;display:flex;flex-direction:column;gap:12px}',
177
+ '.lg-group{display:flex;flex-direction:column;gap:8px}',
178
+ '.lg-group-label{font-size:12px;font-weight:600;line-height:1.4;color:var(--dsw-alias-label-secondary);text-transform:uppercase;letter-spacing:.04em}',
179
+ '.lg-row{display:flex;flex-wrap:wrap;gap:8px}',
180
+ '.lg-field{flex:1 1 200px;min-width:0;display:flex;flex-direction:column;gap:4px}',
181
+ '.lg-label{font-size:13px;line-height:1.5;color:var(--dsw-alias-label-primary);display:flex;align-items:center;gap:6px}',
182
+ '.lg-dot{width:5px;height:5px;border-radius:50%;background:var(--dsw-alias-label-accent);flex:none}',
183
+ '.lg-hint{font-size:12px;line-height:1.45;color:var(--dsw-alias-label-tertiary)}',
184
+ '.lg-input{font:inherit;font-size:13px;line-height:1.5;padding:5px 12px;color:var(--dsw-alias-label-primary);background:var(--dsw-alias-bg-layer-4);border:1px solid var(--dsw-alias-border-l2);border-radius:8px;width:100%;box-sizing:border-box}',
185
+ '.lg-input:disabled{cursor:default;opacity:.5}',
186
+ '.lg-input-invalid{border-color:var(--dsw-alias-label-error)}',
187
+ '.lg-pill{appearance:none;font:inherit;font-size:13px;line-height:1.5;padding:5px 14px;cursor:pointer;color:var(--dsw-alias-label-secondary);background:none;border:1px solid var(--dsw-alias-border-l2);border-radius:999px}',
188
+ '.lg-pill-selected{color:var(--dsw-alias-label-primary);border-color:var(--dsw-alias-label-primary);background:var(--dsw-alias-bg-layer-4)}',
189
+ '.lg-pill:disabled{cursor:default;opacity:.5}',
190
+ '.lg-status{display:flex;align-items:center;gap:8px;font-size:12px;line-height:1.5;color:var(--dsw-alias-label-tertiary)}',
191
+ '.lg-reset{appearance:none;font:inherit;font-size:12px;line-height:1.5;padding:3px 10px;cursor:pointer;color:var(--dsw-alias-label-secondary);background:none;border:1px solid var(--dsw-alias-border-l2);border-radius:8px}',
192
+ '.lg-error{font-size:12px;line-height:1.5;color:var(--dsw-alias-label-error)}',
193
+ // Decomposition view: a hierarchical tree beside Chat and Trajectory.
194
+ // Round-rect cards on status-colored borders, joined by CSS rails: the
195
+ // same visual language as spydr's graph, with no canvas and no dependency.
196
+ // No own scrollport: the conversation view area already scrolls.
197
+ '.lgv-page{display:flex;flex-direction:column;gap:12px;padding:16px 20px;box-sizing:border-box}',
198
+ '.lgv-head{display:flex;flex-direction:column;gap:3px}',
199
+ '.lgv-title{font-size:14px;font-weight:600;line-height:1.4;color:var(--dsw-alias-label-primary)}',
200
+ '.lgv-summary{font-size:12px;line-height:1.5;color:var(--dsw-alias-label-tertiary)}',
201
+ '.lgv-legend{display:flex;flex-wrap:wrap;gap:12px;font-size:11px;color:var(--dsw-alias-label-tertiary)}',
202
+ '.lgv-legend span{display:inline-flex;align-items:center;gap:5px}',
203
+ '.lgv-legend i{display:inline-block;width:9px;height:9px;border-radius:3px;border:1.5px solid var(--dsw-alias-border-l2)}',
204
+ '.lgv-legend .lgv-l-completed{border-color:var(--dsw-alias-state-success-primary)}',
205
+ '.lgv-legend .lgv-l-in_progress{border-color:var(--dsw-alias-label-accent)}',
206
+ '.lgv-legend .lgv-l-pending{border-style:dashed}',
207
+ '.lgv-members{display:flex;flex-wrap:wrap;gap:6px}',
208
+ '.lgv-member{font-size:11px;line-height:1.6;padding:1px 9px;border:1px solid var(--dsw-alias-border-l2);border-radius:999px;color:var(--dsw-alias-label-secondary)}',
209
+ '.lgv-member-lead{border-color:var(--dsw-alias-label-accent);color:var(--dsw-alias-label-primary)}',
210
+ '.lgv-member-failed{color:var(--dsw-alias-state-error-primary);border-color:var(--dsw-alias-state-error-primary)}',
211
+ '.lgv-list,.lgv-children{list-style:none;margin:0;padding:0}',
212
+ '.lgv-list{display:flex;flex-direction:column;gap:4px}',
213
+ '.lgv-children{margin-left:11px;padding-left:15px;border-left:1px solid var(--dsw-alias-border-l2);display:flex;flex-direction:column;gap:4px}',
214
+ '.lgv-branch{position:relative}',
215
+ '.lgv-children>.lgv-branch::before{content:"";position:absolute;left:-15px;top:18px;width:13px;border-top:1px solid var(--dsw-alias-border-l2)}',
216
+ '.lgv-node{display:flex;align-items:center;gap:8px;padding:5px 10px;border:1.5px solid var(--dsw-alias-border-l2);border-radius:10px;background:var(--dsw-alias-bg-layer-3)}',
217
+ '.lgv-node-completed{border-color:var(--dsw-alias-state-success-primary);opacity:.8}',
218
+ '.lgv-node-in_progress{border-color:var(--dsw-alias-label-accent)}',
219
+ '.lgv-node-pending{border-style:dashed}',
220
+ '.lgv-node-deleted{opacity:.45}',
221
+ '.lgv-node-cycle{border-color:var(--dsw-alias-state-error-primary)}',
222
+ '@keyframes lgv-breathe{0%,100%{border-color:var(--dsw-alias-label-accent)}50%{border-color:var(--dsw-alias-label-dimmed)}}',
223
+ '@media (prefers-reduced-motion:no-preference){.lgv-node-in_progress{animation:lgv-breathe 2.4s ease-in-out infinite}}',
224
+ '.lgv-twisty{flex:none;width:17px;height:17px;padding:0;appearance:none;border:1px solid var(--dsw-alias-border-l2);border-radius:4px;background:none;color:var(--dsw-alias-label-secondary);font-size:10px;line-height:1;cursor:pointer}',
225
+ '.lgv-leaf{flex:none;width:17px;text-align:center;font-size:11px;color:var(--dsw-alias-label-dimmed)}',
226
+ '.lgv-status{flex:none;width:14px;text-align:center;font-size:12px;color:var(--dsw-alias-label-tertiary)}',
227
+ '.lgv-node-in_progress .lgv-status{color:var(--dsw-alias-label-accent)}',
228
+ '.lgv-node-completed .lgv-status{color:var(--dsw-alias-state-success-primary)}',
229
+ '.lgv-node-main{display:flex;flex-direction:column;gap:1px;min-width:0;flex:1 1 auto}',
230
+ '.lgv-subject{font-size:13px;line-height:1.45;color:var(--dsw-alias-label-primary);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}',
231
+ '.lgv-node-completed .lgv-subject{color:var(--dsw-alias-label-secondary)}',
232
+ '.lgv-meta{display:flex;flex-wrap:wrap;align-items:center;gap:6px;font-size:11px;line-height:1.4;color:var(--dsw-alias-label-tertiary)}',
233
+ '.lgv-id{font-family:ui-monospace,SFMono-Regular,Menlo,monospace}',
234
+ '.lgv-chip{padding:0 6px;border:1px solid var(--dsw-alias-border-l2);border-radius:6px}',
235
+ '.lgv-chip-owner{color:var(--dsw-alias-label-secondary)}',
236
+ '.lgv-chip-ready{color:var(--dsw-alias-state-success-primary);border-color:var(--dsw-alias-state-success-primary)}',
237
+ '.lgv-chip-warn{color:var(--dsw-alias-state-error-primary);border-color:var(--dsw-alias-state-error-primary)}',
238
+ '.lgv-hidden{font-size:11px;line-height:1.6;padding-left:25px;color:var(--dsw-alias-label-dimmed)}',
239
+ '.lgv-empty{font-size:13px;line-height:1.6;color:var(--dsw-alias-label-tertiary);padding:16px 20px}',
240
+ '.lgv-error{font-size:12px;line-height:1.5;color:var(--dsw-alias-state-error-primary);padding:0 20px}',
241
+ ].join('')
242
+
243
+ // Appended while the factory materializes: the module system claims the tag
244
+ // for this package and disposes it on unload. Guarded because the unit
245
+ // tests evaluate this file without a DOM.
246
+ if (typeof document !== 'undefined') {
247
+ const style = document.createElement('style')
248
+ style.textContent = CSS
249
+ document.head.append(style)
250
+ }
251
+
252
+ /**
253
+ * Bind one scope to a React subscription.
254
+ * @param scope - a scope bound to the legion settings namespace.
255
+ * @returns a hook reading that scope's current snapshot.
256
+ */
257
+ function useScope(scope) {
258
+ const subscribe = (listener) => scope.subscribe(listener)
259
+ const getSnapshot = () => scope.getSnapshot()
260
+ return () => React.useSyncExternalStore(subscribe, getSnapshot)
261
+ }
262
+
263
+ /**
264
+ * Read the resolved section from a snapshot, clamped to card defaults.
265
+ * A namespace this deployment does not serve reports `undefined`.
266
+ * @param snapshot - the settings scope snapshot.
267
+ * @returns the current field values, or `undefined` when unreadable.
268
+ */
269
+ function sectionOf(snapshot) {
270
+ if (snapshot.status !== 'ready') return undefined
271
+ const value = snapshot.value !== null && typeof snapshot.value === 'object' ? snapshot.value : {}
272
+ return value
273
+ }
274
+
275
+ /** `depth ≤ N`, or `depth unlimited` when the cap is off (0). */
276
+ function depthLabel(t, maxDepth) {
277
+ return maxDepth === 0 ? t('depthUnlimited') : t('depthCapped', { depth: maxDepth })
278
+ }
279
+
280
+ /** Task status glyph for one node card, and the legend's words. */
281
+ const STATUS_MARK = { completed: '✓', in_progress: '◐', pending: '○', deleted: '×' }
282
+ /** Task status → its locale key; the legend draws the first three. */
283
+ const STATUS_KEY = { completed: 'statusCompleted', in_progress: 'statusInProgress', pending: 'statusPending', deleted: 'statusDeleted' }
284
+ const STATUS_LEGEND = ['completed', 'in_progress', 'pending']
285
+
286
+ /** Member role and phase → their locale keys. */
287
+ const ROLE_KEY = { lead: 'roleLead', teammate: 'roleTeammate' }
288
+ const PHASE_KEY = { active: 'phaseActive', provisioning: 'phaseProvisioning', failed: 'phaseFailed' }
289
+
290
+ /** A locale key's word, or the raw value when the harness adds one this table lacks. */
291
+ const wordOf = (t, table, value) => (table[value] === undefined ? String(value) : t(table[value]))
292
+
293
+ /** Stable empty board, so the projection-absent path allocates once. */
294
+ const NO_TASKS = []
295
+
296
+ /** Size of a closed subtree, so a collapsed branch still says what it hides. */
297
+ function countDescendants(node) {
298
+ let total = 0
299
+ const stack = [...node.children]
300
+ while (stack.length > 0) {
301
+ const child = stack.pop()
302
+ total += 1
303
+ for (const grandchild of child.children) stack.push(grandchild)
304
+ }
305
+ return total
306
+ }
307
+
308
+ /**
309
+ * Fold the flat team board into the decomposition tree.
310
+ *
311
+ * `blockedBy` is the only parent edge the protocol writes, so a task hangs
312
+ * under its first blocker that is still on the board, and a task with no
313
+ * live blocker is a root (a blocker that was deleted reads as unblocked,
314
+ * not orphaned). Board rows that no root reaches are a malformed loop; they
315
+ * are promoted to roots so they stay visible, and the loop itself is marked
316
+ * on the node it re-enters instead of walking forever. Extra blockers of a
317
+ * node stay visible as a `+N blocker(s)` badge rather than a second subtree.
318
+ *
319
+ * @param {readonly object[]} tasks - live task views from the team projection.
320
+ * @returns {object[]} roots, each `{ task, depth, cycle, children }`.
321
+ */
322
+ function buildTaskTree(tasks) {
323
+ const byId = new Map(tasks.map((task) => [task.id, task]))
324
+ const children = new Map()
325
+ const roots = []
326
+ for (const task of tasks) {
327
+ const blockers = Array.isArray(task.blockedBy) ? task.blockedBy : []
328
+ const parent = blockers.find((id) => id !== task.id && byId.has(id))
329
+ if (parent === undefined) {
330
+ roots.push(task)
331
+ continue
332
+ }
333
+ const siblings = children.get(parent)
334
+ if (siblings === undefined) children.set(parent, [task])
335
+ else siblings.push(task)
336
+ }
337
+ // ponytail: recursion depth is the tree depth. Real runs stay shallow
338
+ // (the agent stops at atomic tasks); go iterative if depth 10k land.
339
+ const placed = new Set()
340
+ const path = new Set()
341
+ const walk = (task, depth) => {
342
+ const cycle = path.has(task.id)
343
+ if (!cycle) {
344
+ path.add(task.id)
345
+ placed.add(task.id)
346
+ }
347
+ const node = {
348
+ task,
349
+ depth,
350
+ cycle,
351
+ children: cycle ? [] : (children.get(task.id) ?? []).map((child) => walk(child, depth + 1)),
352
+ }
353
+ if (!cycle) path.delete(task.id)
354
+ return node
355
+ }
356
+ const tree = roots.map((task) => walk(task, 0))
357
+ for (const task of tasks) {
358
+ if (!placed.has(task.id)) tree.push(walk(task, 0))
359
+ }
360
+ return tree
361
+ }
362
+
363
+ /** Deepest level of a built tree, counted without recursion. */
364
+ function treeDepth(nodes) {
365
+ let depth = 0
366
+ const stack = nodes.map((node) => [node, 1])
367
+ while (stack.length > 0) {
368
+ const [node, level] = stack.pop()
369
+ if (level > depth) depth = level
370
+ for (const child of node.children) stack.push([child, level + 1])
371
+ }
372
+ return depth
373
+ }
374
+
375
+ /**
376
+ * One node card plus its subtree, drawn as a hierarchy.
377
+ *
378
+ * The rails painted by `.lgv-children` and `.lgv-branch` carry the
379
+ * parent/child edges, so the shape reads as a tree with no layout pass, no
380
+ * canvas, and no dependency: a card per task, status-colored, collapsible,
381
+ * indented by depth. Mirrors spydr's graph language (round-rect node on a
382
+ * status border, active work lit) in the harness's own CSS tokens.
383
+ *
384
+ * @param node - a node from `buildTaskTree`.
385
+ * @param collapsed - ids whose subtrees are closed.
386
+ * @param onToggle - close or open one subtree.
387
+ * @param t - translate bound to the plugin's locale namespace.
388
+ * @returns the branch list item.
389
+ */
390
+ function renderNode(node, collapsed, onToggle, t) {
391
+ const task = node.task
392
+ const hasChildren = node.children.length > 0
393
+ const closed = hasChildren && collapsed.has(task.id)
394
+ const blockers = Array.isArray(task.blockedBy) ? task.blockedBy : []
395
+ const extraBlockers = Math.max(0, blockers.length - 1)
396
+ const overlaps = Array.isArray(task.writeScopeWarnings) ? task.writeScopeWarnings.length : 0
397
+ const detail = task.description === undefined || task.description === ''
398
+ ? task.subject
399
+ : `${task.subject}\n\n${task.description}`
400
+ const chip = (extra, text, title) => createElement('span', {
401
+ className: `lgv-chip${extra}`, title,
402
+ }, text)
403
+
404
+ return createElement('li', { key: `${task.id}#${node.depth}`, className: 'lgv-branch' },
405
+ createElement('div', { className: `lgv-node lgv-node-${task.status}`, title: detail },
406
+ hasChildren
407
+ ? createElement('button', {
408
+ type: 'button',
409
+ className: 'lgv-twisty',
410
+ 'aria-expanded': !closed,
411
+ 'aria-label': t(closed ? 'expand' : 'collapse', { id: task.id }),
412
+ onClick: () => onToggle(task.id),
413
+ }, closed ? '▸' : '▾')
414
+ : createElement('span', { className: 'lgv-leaf', 'aria-hidden': true }, '•'),
415
+ createElement('span', { className: 'lgv-status', title: wordOf(t, STATUS_KEY, task.status) }, STATUS_MARK[task.status] ?? '•'),
416
+ createElement('span', { className: 'lgv-node-main' },
417
+ createElement('span', { className: 'lgv-subject' }, task.subject),
418
+ createElement('span', { className: 'lgv-meta' },
419
+ createElement('span', { className: 'lgv-id' }, task.id),
420
+ node.depth > 0 ? createElement('span', { className: 'lgv-level' }, `L${node.depth}`) : null,
421
+ task.ownerName === undefined ? null : chip(' lgv-chip-owner', `@${task.ownerName}`),
422
+ task.status === 'pending' && task.ready === true ? chip(' lgv-chip-ready', t('chipReady')) : null,
423
+ extraBlockers > 0 ? chip('', t('extraBlockers', { count: extraBlockers }), blockers.join(', ')) : null,
424
+ overlaps > 0
425
+ ? chip(' lgv-chip-warn', t('overlaps', { count: overlaps }), task.writeScopeWarnings.join('; '))
426
+ : null,
427
+ node.cycle ? chip(' lgv-chip-warn', t('cycle'), t('cycleHint')) : null,
428
+ ),
429
+ ),
430
+ ),
431
+ hasChildren && !closed
432
+ ? createElement('ul', { className: 'lgv-children' },
433
+ node.children.map((child) => renderNode(child, collapsed, onToggle, t)))
434
+ : null,
435
+ closed
436
+ ? createElement('div', { className: 'lgv-hidden' }, t('hidden', { count: countDescendants(node) }))
437
+ : null,
438
+ )
439
+ }
440
+
441
+ /**
442
+ * The Legion decomposition view beside Chat and Trajectory: the run's task
443
+ * board folded into the parent/child tree the protocol builds from
444
+ * `blocked_by` edges. The lead session's `agentTeam` projection carries the
445
+ * values, so the tree tracks the run live with no polling, no RPC, and no
446
+ * work on turns where the tab is not selected.
447
+ * @param t - translate bound to the plugin's locale namespace.
448
+ * @returns the component the slot renders.
449
+ */
450
+ function createView(t) {
451
+ return function LegionView({ useProjection }) {
452
+ const team = useProjection('agentTeam')
453
+ const tasks = team !== null && typeof team === 'object' && Array.isArray(team.tasks) ? team.tasks : NO_TASKS
454
+ const tree = React.useMemo(() => buildTaskTree(tasks), [tasks])
455
+ const [collapsed, setCollapsed] = React.useState(() => new Set())
456
+ const toggle = (id) => setCollapsed((current) => {
457
+ const next = new Set(current)
458
+ if (next.has(id)) next.delete(id)
459
+ else next.add(id)
460
+ return next
461
+ })
462
+
463
+ if (team === undefined || team === null) {
464
+ return createElement('div', { className: 'lgv-empty' }, t('noTeams'))
465
+ }
466
+ if (tasks.length === 0) {
467
+ return createElement('div', { className: 'lgv-empty' }, t('noRun'))
468
+ }
469
+ const members = Array.isArray(team.members) ? team.members : []
470
+ const settled = tasks.filter((task) => task.status === 'completed').length
471
+ const running = tasks.filter((task) => task.status === 'in_progress').length
472
+ // `ready` is the projection's own "no unmet blocker" answer: a pending
473
+ // task whose blockers all completed keeps its `blockedBy` ids but is not
474
+ // blocked, and the node card already draws it as ready.
475
+ const blocked = tasks.filter((task) => task.status === 'pending' && task.ready !== true).length
476
+ return createElement('div', { className: 'lgv-page' },
477
+ createElement('div', { className: 'lgv-head' },
478
+ createElement('span', { className: 'lgv-title' },
479
+ t('viewTitle', { tasks: tasks.length, levels: treeDepth(tree) })),
480
+ createElement('span', { className: 'lgv-summary' },
481
+ t('viewSummary', { completed: settled, running, blocked, members: members.length })),
482
+ ),
483
+ createElement('div', { className: 'lgv-legend' },
484
+ STATUS_LEGEND.map((status) => createElement('span', { key: status },
485
+ createElement('i', { className: `lgv-l-${status}` }), t(STATUS_KEY[status])))),
486
+ members.length === 0 ? null : createElement('div', { className: 'lgv-members' },
487
+ members.map((member) => createElement('span', {
488
+ key: member.id ?? member.name,
489
+ className: `lgv-member${member.role === 'lead' ? ' lgv-member-lead' : ''}`
490
+ + `${member.phase === 'failed' ? ' lgv-member-failed' : ''}`,
491
+ title: member.error ?? wordOf(t, PHASE_KEY, member.phase),
492
+ }, member.phase === 'active'
493
+ ? t('member', { name: member.name, role: wordOf(t, ROLE_KEY, member.role) })
494
+ : t('memberPhase', { name: member.name, role: wordOf(t, ROLE_KEY, member.role), phase: wordOf(t, PHASE_KEY, member.phase) })))),
495
+ team.failure === undefined
496
+ ? null
497
+ : createElement('div', { className: 'lgv-error' }, t('projectionFailure', { failure: team.failure })),
498
+ createElement('ul', { className: 'lgv-list' }, tree.map((node) => renderNode(node, collapsed, toggle, t))),
499
+ )
500
+ }
501
+ }
502
+
503
+ /**
504
+ * Build the card component over one bound settings scope.
505
+ * @param scope - the scope bound to the legion namespace.
506
+ * @param t - translate bound to the card's locale namespace.
507
+ * @returns the component the slot renders.
508
+ */
509
+ function createCard(scope, t) {
510
+ const useLegion = useScope(scope)
511
+
512
+ /** One numeric editor: local text while typing, committed on blur/Enter. */
513
+ function NumberField({ field, value, overridden, disabled, onCommit }) {
514
+ const [draft, setDraft] = React.useState(String(value))
515
+ const [invalid, setInvalid] = React.useState(false)
516
+ // Re-sync from the store only after external movement (a reset, another tab),
517
+ // so a keystroke the user is mid-way through is never clobbered.
518
+ const last = React.useRef(value)
519
+ React.useEffect(() => {
520
+ if (last.current !== value) {
521
+ last.current = value
522
+ setDraft(String(value))
523
+ setInvalid(false)
524
+ }
525
+ }, [value])
526
+
527
+ const commit = () => {
528
+ const n = Number(draft)
529
+ if (!Number.isSafeInteger(n) || n < field.min || n > field.max) {
530
+ setInvalid(true)
531
+ return
532
+ }
533
+ setInvalid(false)
534
+ if (n !== value) onCommit(field.key, n)
535
+ }
536
+
537
+ return createElement('div', { className: 'lg-field' },
538
+ createElement('label', { className: 'lg-label' },
539
+ overridden ? createElement('span', { className: 'lg-dot', title: t('overridden') }) : null,
540
+ t(`${field.key}Label`),
541
+ ),
542
+ createElement('input', {
543
+ className: `lg-input${invalid ? ' lg-input-invalid' : ''}`,
544
+ type: 'text',
545
+ inputMode: 'numeric',
546
+ value: draft,
547
+ disabled,
548
+ 'aria-label': t('fieldRange', { label: t(`${field.key}Label`), min: field.min, max: field.max }),
549
+ onChange: (e) => setDraft(e.target.value),
550
+ onBlur: commit,
551
+ onKeyDown: (e) => { if (e.key === 'Enter') commit() },
552
+ }),
553
+ createElement('span', { className: 'lg-hint' }, invalid
554
+ ? t('invalid', { min: field.min, max: field.max })
555
+ : t(`${field.key}Hint`)),
556
+ )
557
+ }
558
+
559
+ return function LegionCard(props) {
560
+ const snapshot = useLegion()
561
+ const [error, setError] = React.useState(null)
562
+
563
+ const section = sectionOf(snapshot)
564
+ // A namespace this deployment does not serve renders no trace of itself.
565
+ if (section === undefined) return null
566
+
567
+ const user = snapshot.user !== null && typeof snapshot.user === 'object' ? snapshot.user : {}
568
+ const overriddenCount = Object.keys(user).length
569
+ const disabled = !snapshot.writable
570
+
571
+ const report = (cause) => {
572
+ setError(cause instanceof Error ? cause.message : String(cause))
573
+ }
574
+ // `set`, `unset`, and `mutate` resolve `false` when the host refuses the
575
+ // write (an overlay row, a revision conflict), so a refusal is reported
576
+ // here, not thrown. A reset is one atomic mutation.
577
+ const write = (run) => {
578
+ setError(null)
579
+ Promise.resolve(run()).then((accepted) => {
580
+ if (accepted === false) setError(t('refused'))
581
+ }).catch(report)
582
+ }
583
+ // Keep the stored range consistent with clampSettings, which raises
584
+ // an inverted maximum to the minimum when a run starts.
585
+ const setField = (key, value) => write(() => {
586
+ if (key === 'minSubtasks' && value > section.maxSubtasks) {
587
+ return scope.mutate([
588
+ { op: 'set', path: ['minSubtasks'], value },
589
+ { op: 'set', path: ['maxSubtasks'], value },
590
+ ])
591
+ }
592
+ if (key === 'maxSubtasks' && value < section.minSubtasks) {
593
+ return scope.set('maxSubtasks', section.minSubtasks)
594
+ }
595
+ return scope.set(key, value)
596
+ })
597
+ const pickStrategy = (value) => setField('mergeStrategy', value)
598
+ const resetAll = () => write(() => scope.mutate(
599
+ Object.keys(user).map((key) => ({ op: 'unset', path: [key] })),
600
+ ))
601
+
602
+ const strategy = STRATEGIES.includes(section.mergeStrategy) ? section.mergeStrategy : STRATEGIES[0]
603
+ const approval = section.requireHumanApproval === true
604
+ if (props != null && props.view === 'summary') {
605
+ return t('summary', { min: section.minSubtasks, max: section.maxSubtasks, depth: depthLabel(t, section.maxDepth) })
606
+ }
607
+
608
+ return createElement(
609
+ 'div',
610
+ { className: 'lg-page' },
611
+ createElement(
612
+ 'div',
613
+ { className: 'lg-body' },
614
+ createElement(
615
+ 'div',
616
+ { className: 'lg-group' },
617
+ createElement('span', { className: 'lg-group-label' }, t('groupDecomposition')),
618
+ createElement(
619
+ 'div',
620
+ { className: 'lg-row' },
621
+ NUMBERS.slice(0, 3).map((field) => createElement(NumberField, {
622
+ key: field.key,
623
+ field,
624
+ value: section[field.key],
625
+ overridden: Object.prototype.hasOwnProperty.call(user, field.key),
626
+ disabled,
627
+ onCommit: setField,
628
+ })),
629
+ ),
630
+ ),
631
+ createElement(
632
+ 'div',
633
+ { className: 'lg-group' },
634
+ createElement('span', { className: 'lg-group-label' }, t('groupExecution')),
635
+ createElement(
636
+ 'div',
637
+ { className: 'lg-row' },
638
+ ...NUMBERS.slice(3).map((field) => createElement(NumberField, {
639
+ key: field.key,
640
+ field,
641
+ value: section[field.key],
642
+ overridden: Object.prototype.hasOwnProperty.call(user, field.key),
643
+ disabled,
644
+ onCommit: setField,
645
+ })),
646
+ ),
647
+ ),
648
+ createElement(
649
+ 'div',
650
+ { className: 'lg-group' },
651
+ createElement('span', { className: 'lg-group-label' }, t('groupStrategy')),
652
+ createElement(
653
+ 'div',
654
+ { className: 'lg-row', role: 'radiogroup', 'aria-label': t('groupStrategy') },
655
+ STRATEGIES.map((value) => createElement('button', {
656
+ key: value,
657
+ type: 'button',
658
+ role: 'radio',
659
+ 'aria-checked': section.mergeStrategy === value,
660
+ disabled,
661
+ className: `lg-pill${section.mergeStrategy === value ? ' lg-pill-selected' : ''}`,
662
+ title: t(`${value}Hint`),
663
+ onClick: () => pickStrategy(value),
664
+ }, t(`${value}Label`))),
665
+ ),
666
+ createElement('span', { className: 'lg-hint' }, t(`${strategy}Hint`)),
667
+ ),
668
+ createElement(
669
+ 'div',
670
+ { className: 'lg-group' },
671
+ createElement('span', { className: 'lg-group-label' }, t('groupApproval')),
672
+ createElement(
673
+ 'div',
674
+ { className: 'lg-row', role: 'radiogroup', 'aria-label': t('groupApproval') },
675
+ [['required', true], ['off', false]].map(([label, value]) => createElement('button', {
676
+ key: label,
677
+ type: 'button',
678
+ role: 'radio',
679
+ 'aria-checked': approval === value,
680
+ disabled,
681
+ className: `lg-pill${approval === value ? ' lg-pill-selected' : ''}`,
682
+ onClick: () => setField('requireHumanApproval', value),
683
+ }, t(label === 'required' ? 'approvalRequired' : 'approvalOff'))),
684
+ ),
685
+ createElement('span', { className: 'lg-hint' },
686
+ approval
687
+ ? t('approvalRequiredHint')
688
+ : t('approvalOffHint')),
689
+ ),
690
+ createElement(
691
+ 'div',
692
+ { className: 'lg-status' },
693
+ snapshot.writable
694
+ ? t('persists')
695
+ : t('readOnly'),
696
+ overriddenCount > 0
697
+ ? createElement('button', {
698
+ type: 'button',
699
+ className: 'lg-reset',
700
+ disabled,
701
+ onClick: resetAll,
702
+ }, t('reset', { count: overriddenCount }))
703
+ : null,
704
+ ),
705
+ error === null ? null : createElement('div', { className: 'lg-error' }, error),
706
+ ),
707
+ )
708
+ }
709
+ }
710
+
711
+ /**
712
+ * Mount the Plugins card and the decomposition view.
713
+ * @param ctx - the client root context.
714
+ */
715
+ function apply(ctx) {
716
+ const t = ctx.locale.bind(LOCALE_NS)
717
+ ctx.effect(() => ctx.locale.register(LOCALE_NS, { en, zh }), 'dsh-legion: locale dictionaries')
718
+ const scope = ctx.configForms.get(NAMESPACE)
719
+ const Card = createCard(scope, t)
720
+
721
+ ctx.slots.inject('plugins.row.config', () => ctx.slots.register({
722
+ name: 'plugins.row.config',
723
+ key: '@maci0/dsh-legion#legion',
724
+ locale: LOCALE_NS,
725
+ }, Card))
726
+
727
+ // The Legion view: a third tab beside Chat and Trajectory. Values come
728
+ // from the session's `agentTeam` projection, which the harness's own
729
+ // Agent Teams plugin publishes, so this half needs no response shape of
730
+ // its own and no change to the host half.
731
+ ctx.slots.inject('conversation.view', () => ctx.slots.register({
732
+ name: 'conversation.view',
733
+ id: 'legion',
734
+ order: 20,
735
+ label: 'Legion',
736
+ }, createView(t)))
737
+ }
738
+
739
+ exports.apply = apply
740
+ exports.inject = ['slots', 'configForms', 'locale']
741
+ // Exported for the unit tests: the tree fold is the view's only logic.
742
+ exports.buildTaskTree = buildTaskTree
743
+ return module.exports
744
+ },
745
+ })