@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/LICENSE +21 -0
- package/README.md +154 -2
- package/cordis.patch.yml +22 -0
- package/icon.svg +7 -0
- package/index.js +245 -0
- package/lib/client.js +745 -0
- package/lib/logic.js +245 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +90 -4
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Marcel W. Wysocki
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,155 @@
|
|
|
1
|
-
#
|
|
1
|
+
# dsh-legion
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Recursive task decomposition for DeepSeek Harness: spydr's workflow on top of
|
|
4
|
+
the harness's native Agent Teams. One root task splits into a tree, leaves run
|
|
5
|
+
as teammates against a shared task board, results consolidate upward, and the
|
|
6
|
+
human gates the root.
|
|
7
|
+
|
|
8
|
+
## Install
|
|
9
|
+
|
|
10
|
+
> **Install it as a bundle.** `dsh plugin add …` mounts the row from the
|
|
11
|
+
> package's own patch layer, which is what the settings editor can write to. A
|
|
12
|
+
> row added with `--patch` is an overlay: it disappears at the next start, and
|
|
13
|
+
> the Plugins card cannot save into it (the editor refuses a write an overlay
|
|
14
|
+
> would win).
|
|
15
|
+
|
|
16
|
+
```sh
|
|
17
|
+
dsh plugin --profile web add @maci0/dsh-legion@0.8.1
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
This installs the public npm package; no GitHub token or `~/.secrets` setup is needed.
|
|
21
|
+
The version is pinned. To upgrade, run the same command with a newer version,
|
|
22
|
+
then restart `dsh web` (bundle layers compose at boot).
|
|
23
|
+
|
|
24
|
+
The bundled `cordis.patch.yml` inserts the `legion` row automatically.
|
|
25
|
+
|
|
26
|
+
Requires Agent Teams (`ctx.agentTeams`). When it is not mounted, every verb but
|
|
27
|
+
`config` answers an error naming the bundle to enable and queues nothing, and
|
|
28
|
+
the Legion view says the same. Agent Teams ships as an experimental bundle; enable it in the same
|
|
29
|
+
profile:
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
dsh plugin --profile <name> add @deepseek-ai/dsh-experimental-agent-team-profile
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
That bundle mounts `agent-team`, `tool-agent-team`, and the Agent Teams Web UI,
|
|
36
|
+
and its own patch disables `tool-subagent`, `tool-subagent-fork`,
|
|
37
|
+
`tool-subagent-list-agents`, and `tool-subagent-control`: the Team tools replace
|
|
38
|
+
direct delegation. Its defaults are `maxMembers: 8` and `maxTasks: 256`. Restart
|
|
39
|
+
the harness after the install.
|
|
40
|
+
|
|
41
|
+
## Commands
|
|
42
|
+
|
|
43
|
+
```
|
|
44
|
+
/legion refactor the auth layer # start a run (attachments allowed)
|
|
45
|
+
/legion start status page redo # start a task whose title begins with a verb
|
|
46
|
+
/legion status # roster + task board
|
|
47
|
+
/legion config # effective settings
|
|
48
|
+
/legion stop # interrupt every teammate, halt the lead
|
|
49
|
+
/legion approve # accept the root deliverable
|
|
50
|
+
/legion approve ship it # accept, with a note
|
|
51
|
+
/legion reject the API contract # send it back with a reason
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
A verb is matched **whole**: `/legion status page redesign` starts a run whose
|
|
55
|
+
title begins with "status". To start a task that *is* one of the verb words,
|
|
56
|
+
prefix it: `/legion start stop`.
|
|
57
|
+
|
|
58
|
+
## Configure
|
|
59
|
+
|
|
60
|
+
Two layers, same namespace (`legion`), same precedence as every DSH settings
|
|
61
|
+
section: the patch row is the base, the settings document overrides it.
|
|
62
|
+
|
|
63
|
+
| Field | Default | Meaning |
|
|
64
|
+
|---|---|---|
|
|
65
|
+
| `minSubtasks` | 2 | Fewer proposals than this ⇒ the task is a leaf |
|
|
66
|
+
| `maxSubtasks` | 4 | Extra proposals are truncated to this |
|
|
67
|
+
| `maxDepth` | 0 | Hard cap on decomposition levels; `0` = no cap |
|
|
68
|
+
| `workersPerTask` | 1 | Independent candidates per leaf |
|
|
69
|
+
| `mergeStrategy` | `best` | `best` \| `reconcile` |
|
|
70
|
+
| `maxReviewRetries` | 2 | Rework attempts before terminal failure |
|
|
71
|
+
| `requireHumanApproval` | `true` | Root waits for `/legion approve` \| `reject` |
|
|
72
|
+
| `maxTasksPerRun` | 0 | Task cap per run; `0` = unlimited |
|
|
73
|
+
|
|
74
|
+
Edit either way:
|
|
75
|
+
|
|
76
|
+
- **Config menu**: Plugins → the `legion` row's **Configure** control: every field with its bounds,
|
|
77
|
+
merge-strategy and approval toggles, overridden-field markers and a one-click
|
|
78
|
+
Reset, read-only state when the deployment does not persist settings. Its copy ships in
|
|
79
|
+
English and Chinese through the locale service.
|
|
80
|
+
- **Patch row**: a `- id: legion` row in your profile's `cordis.patch.yml`
|
|
81
|
+
(or the `config:` block in this package's `cordis.patch.yml`).
|
|
82
|
+
|
|
83
|
+
Changes validate immediately and apply to the next `/legion` run; `/legion
|
|
84
|
+
config` always prints what the next run will actually use.
|
|
85
|
+
|
|
86
|
+
## The Legion view
|
|
87
|
+
|
|
88
|
+
A **Legion** tab sits beside Chat and Trajectory in the session. It draws the
|
|
89
|
+
run's task board as a hierarchy: one round-rect node card per task on a border
|
|
90
|
+
colored by status (completed, in progress, pending), joined by tree rails, each
|
|
91
|
+
carrying the task id, subject, owner, readiness, extra blockers, and write-scope
|
|
92
|
+
overlaps. Branches collapse from their own twisty, a legend names the statuses,
|
|
93
|
+
and the header counts tasks, levels, and members. That is spydr's graph language
|
|
94
|
+
in the harness's own CSS: no canvas, no graph library, no extra dependency. Its copy
|
|
95
|
+
ships in English and Chinese, from the same dictionaries as the card.
|
|
96
|
+
|
|
97
|
+
The roster ceiling is Agent Teams' `maxMembers`, not this plugin's; raise it in
|
|
98
|
+
the same profile patch (the bundled default is 8, i.e. the lead plus 7
|
|
99
|
+
teammates) so a wide decomposition can keep every ready leaf in flight.
|
|
100
|
+
|
|
101
|
+
The values come from the session's own `agentTeam` projection, which the
|
|
102
|
+
harness's Agent Teams plugin already publishes, so the tree follows the run
|
|
103
|
+
live (no polling, no RPC, no host-half state) and costs nothing while the tab is
|
|
104
|
+
not selected. Open the lead session of a run; a session with no team shows the
|
|
105
|
+
empty state instead.
|
|
106
|
+
|
|
107
|
+
## How it works
|
|
108
|
+
|
|
109
|
+
`/legion <task>` queues a kickoff relay as the agent's next turn. The relay
|
|
110
|
+
carries the decomposition protocol with the current settings inlined:
|
|
111
|
+
|
|
112
|
+
- split non-atomic tasks into `minSubtasks`-`maxSubtasks` subtasks; fewer than
|
|
113
|
+
`minSubtasks` means the task is a leaf;
|
|
114
|
+
- never decompose past `maxDepth` (`0` = no cap: split until a task is atomic);
|
|
115
|
+
- `workersPerTask` candidates per leaf, combined by `mergeStrategy`
|
|
116
|
+
(`best` = judge picks the strongest verbatim, `reconcile` = merge strengths);
|
|
117
|
+
- the authoring agent reviews each child result, up to `maxReviewRetries`
|
|
118
|
+
rework attempts before a task fails terminally;
|
|
119
|
+
- `requireHumanApproval` holds the root at an `/legion approve | reject` gate;
|
|
120
|
+
- `maxTasksPerRun` caps how many tasks one run may create (`0` = unlimited).
|
|
121
|
+
|
|
122
|
+
The tree lives on the shared team task board (`team_task_create` with
|
|
123
|
+
`blocked_by` edges), so flow guarantees are structural: tasks flow top-down
|
|
124
|
+
only, results bottom-up only, and siblings never exchange work, the same
|
|
125
|
+
guarantees spydr enforces in its data model. `status`, `stop`, and the
|
|
126
|
+
approval verbs read and steer that live team state; nothing is kept in
|
|
127
|
+
process memory, so a restart cannot strand a run.
|
|
128
|
+
|
|
129
|
+
## Limits
|
|
130
|
+
|
|
131
|
+
- Attachments ride the kickoff only; any other verb with attachments is
|
|
132
|
+
rejected so the composer keeps the originals.
|
|
133
|
+
- `/legion stop` is lead-only, like every roster mutation in the Team domain.
|
|
134
|
+
Its reply counts the teammates that had a running turn; idle ones are
|
|
135
|
+
reported separately.
|
|
136
|
+
- `/legion approve` and `/legion reject` go to the Team Lead from any session
|
|
137
|
+
of the team, and are refused from a session outside one.
|
|
138
|
+
- Settings are read when a verb runs, not when the plugin loads: no restart,
|
|
139
|
+
no card reload needed.
|
|
140
|
+
|
|
141
|
+
## Development
|
|
142
|
+
|
|
143
|
+
`bun test`: pure-logic tests (`parseLegion`, `clampSettings`, `buildKickoff`,
|
|
144
|
+
`formatStatus`, `formatConfig`, `relayText`), the view's tree fold
|
|
145
|
+
(`buildTaskTree`, loaded from the browser half with a stubbed module loader),
|
|
146
|
+
handler tests over a fake host context, and a real Cordis composition. `bun install --frozen-lockfile` first: the handler
|
|
147
|
+
and composition tests load the real module and need the dev dependencies.
|
|
148
|
+
|
|
149
|
+
dsh loads plugins on Node `^22.19.0 || >=24.0.0`; development and tests run on bun.
|
|
150
|
+
|
|
151
|
+
For local development, `dsh plugin --profile <name> add <path-to-checkout>`.
|
|
152
|
+
|
|
153
|
+
## Licence
|
|
154
|
+
|
|
155
|
+
MIT. See `LICENSE`.
|
package/cordis.patch.yml
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# The dsh-legion bundle patch: applied automatically when a profile lists
|
|
2
|
+
# this bundle (`dsh plugin add`/`update` appends the package to
|
|
3
|
+
# dsh.profile.bundles). Users override this row from their profile's own
|
|
4
|
+
# cordis.patch.yml (live-watched; dsh.profile.bundles is frozen at boot) with a
|
|
5
|
+
# `- id: legion` row, which replaces the row's whole `config`.
|
|
6
|
+
# `name` stays a bare package specifier: the browser half is served by the
|
|
7
|
+
# client module system, which resolves the Loader entry's package, reads its
|
|
8
|
+
# `dsh.client` manifest, and serves `exports["./client"]`.
|
|
9
|
+
# Do not also insert this same row into the profile patch: insert does not
|
|
10
|
+
# dedupe ids, and a second row would register the plugin twice.
|
|
11
|
+
- insert:
|
|
12
|
+
- id: legion
|
|
13
|
+
name: '@maci0/dsh-legion'
|
|
14
|
+
config:
|
|
15
|
+
minSubtasks: 2
|
|
16
|
+
maxSubtasks: 4
|
|
17
|
+
maxDepth: 0
|
|
18
|
+
workersPerTask: 1
|
|
19
|
+
mergeStrategy: best
|
|
20
|
+
maxReviewRetries: 2
|
|
21
|
+
requireHumanApproval: true
|
|
22
|
+
maxTasksPerRun: 0
|
package/icon.svg
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
<svg width="36" height="36" viewBox="0 0 36 36" fill="none" xmlns="http://www.w3.org/2000/svg">
|
|
2
|
+
<rect x="6" y="7" width="7" height="7" rx="1.5" fill="#45D9E7"/>
|
|
3
|
+
<rect x="14.5" y="7" width="7" height="7" rx="1.5" fill="#7CB7FF"/>
|
|
4
|
+
<rect x="23" y="7" width="7" height="7" rx="1.5" fill="#145AF3"/>
|
|
5
|
+
<path d="M9.5 14v3.2h17V14" stroke="#145AF3" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/>
|
|
6
|
+
<rect x="7" y="20.5" width="22" height="8" rx="1.5" fill="#E8F4FF" stroke="#145AF3" stroke-width="1.6"/>
|
|
7
|
+
</svg>
|
package/index.js
ADDED
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-legion: spydr-style recursive task decomposition for DeepSeek Harness.
|
|
3
|
+
*
|
|
4
|
+
* One `/legion` command with a verb grammar, backed by the harness's native
|
|
5
|
+
* Agent Teams instead of a private scheduler:
|
|
6
|
+
*
|
|
7
|
+
* /legion <task> queue the decomposition protocol (start)
|
|
8
|
+
* /legion status roster + shared task board
|
|
9
|
+
* /legion config effective settings
|
|
10
|
+
* /legion stop interrupt every teammate, tell the lead to halt
|
|
11
|
+
* /legion approve [note] answer the human root-approval gate
|
|
12
|
+
* /legion reject <reason> reject the root deliverable with a reason
|
|
13
|
+
*
|
|
14
|
+
* Configuration lives in the `legion` settings namespace (patch row or the
|
|
15
|
+
* Plugins-page card). It rides into the run inside the kickoff relay,
|
|
16
|
+
* so a change applies to the next run with no reload and costs no prompt
|
|
17
|
+
* on turns where no run is active.
|
|
18
|
+
*
|
|
19
|
+
* For local development, `dsh plugin --profile <name> add <path-to-checkout>`.
|
|
20
|
+
*/
|
|
21
|
+
import z from '@deepseek-ai/schemastery'
|
|
22
|
+
import { createUserMessage } from '@deepseek-ai/dsh-llm/message'
|
|
23
|
+
import {
|
|
24
|
+
USAGE,
|
|
25
|
+
buildKickoff,
|
|
26
|
+
clampSettings,
|
|
27
|
+
formatConfig,
|
|
28
|
+
formatStatus,
|
|
29
|
+
parseLegion,
|
|
30
|
+
relayText,
|
|
31
|
+
DEFAULTS,
|
|
32
|
+
} from './lib/logic.js'
|
|
33
|
+
|
|
34
|
+
export const name = 'legion'
|
|
35
|
+
|
|
36
|
+
// `commands` registers the verb grammar; `agentTeams` is an optional service
|
|
37
|
+
// reached through ctx.get, so a team-less composition still mounts the
|
|
38
|
+
// command and degrades per verb.
|
|
39
|
+
export const inject = ['commands']
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Row schema; the Plugins-page card edits this shape. `.volatile()` lets a
|
|
43
|
+
* profile edit land without remounting the plugin.
|
|
44
|
+
*/
|
|
45
|
+
export const Config = z.object({
|
|
46
|
+
minSubtasks: z.number().step(1).min(1).max(20).default(DEFAULTS.minSubtasks).volatile(),
|
|
47
|
+
maxSubtasks: z.number().step(1).min(1).max(20).default(DEFAULTS.maxSubtasks).volatile(),
|
|
48
|
+
maxDepth: z.number().step(1).min(0).max(20).default(DEFAULTS.maxDepth).volatile(),
|
|
49
|
+
workersPerTask: z.number().step(1).min(1).max(16).default(DEFAULTS.workersPerTask).volatile(),
|
|
50
|
+
mergeStrategy: z.union(['best', 'reconcile']).default(DEFAULTS.mergeStrategy).volatile(),
|
|
51
|
+
maxReviewRetries: z.number().step(1).min(0).max(20).default(DEFAULTS.maxReviewRetries).volatile(),
|
|
52
|
+
requireHumanApproval: z.boolean().default(DEFAULTS.requireHumanApproval).volatile(),
|
|
53
|
+
maxTasksPerRun: z.number().step(1).min(0).max(10000).default(DEFAULTS.maxTasksPerRun).volatile(),
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Queue one relay message as the agent's next turn.
|
|
58
|
+
* @param {object} agent - the exact receiving agent.
|
|
59
|
+
* @param {string} text - the relay body.
|
|
60
|
+
* @param {readonly object[]} [attachments] - durable blocks carried first.
|
|
61
|
+
*/
|
|
62
|
+
function relay(agent, text, attachments = []) {
|
|
63
|
+
agent.followup(createUserMessage({
|
|
64
|
+
content: [...attachments, { type: 'text', text }],
|
|
65
|
+
source: { kind: 'legion', form: 'relay' },
|
|
66
|
+
}))
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Error for a verb that needs Agent Teams when the service is not mounted. */
|
|
70
|
+
const NO_TEAMS = 'Agent Teams is not mounted in this composition; /legion needs it. '
|
|
71
|
+
+ 'Enable the experimental bundle: '
|
|
72
|
+
+ 'dsh plugin --profile <name> add @deepseek-ai/dsh-experimental-agent-team-profile'
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Resolve the team service's view for one agent, or the reason there is none.
|
|
76
|
+
* @param {object|undefined} teams - `ctx.agentTeams`, when mounted.
|
|
77
|
+
* @param {object} agent - the command's receiving agent.
|
|
78
|
+
* @returns {{ok: true, members: object[], tasks: object[]} |
|
|
79
|
+
* {ok: false, reason: string}}
|
|
80
|
+
*/
|
|
81
|
+
function teamView(teams, agent) {
|
|
82
|
+
if (teams === undefined) return { ok: false, reason: NO_TEAMS }
|
|
83
|
+
const membership = teams.tryMembership?.(agent)
|
|
84
|
+
if (membership === undefined) {
|
|
85
|
+
return { ok: false, reason: 'This session is not part of a team. Start a run with /legion <task>.' }
|
|
86
|
+
}
|
|
87
|
+
return { ok: true, members: teams.listMembers(agent), tasks: teams.listTasks(agent) }
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Resolve the Team Lead a root-approval verdict goes to.
|
|
92
|
+
* @param {object|undefined} teams - `ctx.agentTeams`, when mounted.
|
|
93
|
+
* @param {object} agent - the command's receiving agent.
|
|
94
|
+
* @returns {{ok: true, agent: object} | {ok: false, reason: string}}
|
|
95
|
+
*/
|
|
96
|
+
function leadOf(teams, agent) {
|
|
97
|
+
if (teams === undefined) return { ok: false, reason: NO_TEAMS }
|
|
98
|
+
const membership = teams.tryMembership?.(agent)
|
|
99
|
+
if (membership === undefined) {
|
|
100
|
+
return { ok: false, reason: 'This session is not part of a team. Start a run with /legion <task>.' }
|
|
101
|
+
}
|
|
102
|
+
return { ok: true, agent: membership.root }
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Unwrap a volatile config ref (anything with `get()`) to its current value. */
|
|
106
|
+
function plainConfig(value) {
|
|
107
|
+
if (value !== null && typeof value === 'object' && typeof value.get === 'function') return value.get()
|
|
108
|
+
return value
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Snapshot every row field, unwrapping volatile refs. */
|
|
112
|
+
function liveConfig(config) {
|
|
113
|
+
const plain = {}
|
|
114
|
+
for (const [key, value] of Object.entries(config)) plain[key] = plainConfig(value)
|
|
115
|
+
return plain
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Mount the plugin.
|
|
120
|
+
* @param {object} ctx - the host context.
|
|
121
|
+
* @param {object} [config] - the validated patch-row configuration.
|
|
122
|
+
*/
|
|
123
|
+
export function apply(ctx, config = {}) {
|
|
124
|
+
// Volatile fields update this object in place. Read it when the command runs.
|
|
125
|
+
const source = () => clampSettings(liveConfig(config))
|
|
126
|
+
|
|
127
|
+
ctx.effect(() => {
|
|
128
|
+
const dispose = ctx.commands.register({
|
|
129
|
+
definitionId: 'dsh-legion:legion',
|
|
130
|
+
name: 'legion',
|
|
131
|
+
description: '⚔ Recursive task decomposition: /legion <task> starts a run; status | config | stop | approve [note] | reject <reason>',
|
|
132
|
+
input: { hint: '<task> | start <task> | status | config | stop | approve [note] | reject <reason>', attachments: true },
|
|
133
|
+
handler: (invocation) => legionHandler(invocation, ctx, source),
|
|
134
|
+
})
|
|
135
|
+
return () => dispose()
|
|
136
|
+
})
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Run one `/legion` invocation.
|
|
141
|
+
* @param {object} invocation - the command invocation.
|
|
142
|
+
* @param {object} ctx - the host context.
|
|
143
|
+
* @param {() => object} source - the settings source reader.
|
|
144
|
+
* @returns {Promise<{kind: 'success'|'error', text?: string}>} the UI result.
|
|
145
|
+
*/
|
|
146
|
+
function legionHandler(invocation, ctx, source) {
|
|
147
|
+
const parsed = parseLegion(invocation.rawInput)
|
|
148
|
+
const agent = invocation.agent
|
|
149
|
+
const teams = ctx.get?.('agentTeams')
|
|
150
|
+
|
|
151
|
+
// Attachments are admitted by the definition; only a starting run can carry
|
|
152
|
+
// them into the kickoff, so any other verb gives the originals back.
|
|
153
|
+
if (invocation.attachments?.length > 0 && parsed.kind !== 'start') {
|
|
154
|
+
return Promise.resolve({
|
|
155
|
+
kind: 'error',
|
|
156
|
+
text: 'Attachments only accompany a starting run (/legion <task>). Send the verb without them.',
|
|
157
|
+
})
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
switch (parsed.kind) {
|
|
161
|
+
case 'usage':
|
|
162
|
+
return Promise.resolve({ kind: 'error', text: USAGE })
|
|
163
|
+
|
|
164
|
+
case 'error':
|
|
165
|
+
return Promise.resolve({ kind: 'error', text: parsed.text })
|
|
166
|
+
|
|
167
|
+
case 'start': {
|
|
168
|
+
// The run lives on the team board; without it the kickoff would name
|
|
169
|
+
// tools the model does not have.
|
|
170
|
+
if (teams === undefined) return Promise.resolve({ kind: 'error', text: NO_TEAMS })
|
|
171
|
+
relay(agent, buildKickoff(parsed.task, source()), invocation.attachments ?? [])
|
|
172
|
+
const c = clampSettings(source())
|
|
173
|
+
return Promise.resolve({
|
|
174
|
+
kind: 'success',
|
|
175
|
+
text: `Legion run started: "${parsed.task}". Settings: ${c.maxDepth === 0 ? 'no depth cap' : `up to ${c.maxDepth} level(s) deep`}, `
|
|
176
|
+
+ `${c.minSubtasks}-${c.maxSubtasks} subtasks per split, ${c.workersPerTask} worker(s) per leaf, `
|
|
177
|
+
+ `human root approval ${c.requireHumanApproval ? 'required' : 'off'}. Track it with /legion status.`,
|
|
178
|
+
})
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
case 'status': {
|
|
182
|
+
const view = teamView(teams, agent)
|
|
183
|
+
if (!view.ok) return Promise.resolve({ kind: 'error', text: view.reason })
|
|
184
|
+
return Promise.resolve({ kind: 'success', text: formatStatus(view) })
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
case 'config':
|
|
188
|
+
return Promise.resolve({ kind: 'success', text: formatConfig(source()) })
|
|
189
|
+
|
|
190
|
+
case 'stop': {
|
|
191
|
+
const view = teamView(teams, agent)
|
|
192
|
+
if (!view.ok) return Promise.resolve({ kind: 'error', text: view.reason })
|
|
193
|
+
const membership = teams.tryMembership(agent)
|
|
194
|
+
if (membership.role !== 'lead') {
|
|
195
|
+
return Promise.resolve({ kind: 'error', text: 'Only the Team Lead can stop a legion run.' })
|
|
196
|
+
}
|
|
197
|
+
let interrupted = 0
|
|
198
|
+
let idle = 0
|
|
199
|
+
for (const member of view.members) {
|
|
200
|
+
if (member.role === 'teammate') {
|
|
201
|
+
try {
|
|
202
|
+
// `previousStatus` is sampled before the cancel: an inactive
|
|
203
|
+
// teammate had no turn to stop.
|
|
204
|
+
if (teams.interrupt(agent, member.name).previousStatus === 'running') interrupted += 1
|
|
205
|
+
else idle += 1
|
|
206
|
+
} catch {
|
|
207
|
+
// A teammate that settled between the roster read and the interrupt
|
|
208
|
+
// needs no report: the run is being stopped either way.
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
relay(agent, relayText('stop'))
|
|
213
|
+
return Promise.resolve({
|
|
214
|
+
kind: 'success',
|
|
215
|
+
text: `Stopped ${interrupted} running teammate(s)${idle > 0 ? `, ${idle} already idle` : ''}; `
|
|
216
|
+
+ 'the lead was told to halt and summarize.',
|
|
217
|
+
})
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
case 'approve': {
|
|
221
|
+
const lead = leadOf(teams, agent)
|
|
222
|
+
if (!lead.ok) return Promise.resolve({ kind: 'error', text: lead.reason })
|
|
223
|
+
relay(lead.agent, relayText('approve', parsed.note))
|
|
224
|
+
return Promise.resolve({
|
|
225
|
+
kind: 'success',
|
|
226
|
+
text: parsed.note
|
|
227
|
+
? `Approval relayed to the lead with your note: "${parsed.note}".`
|
|
228
|
+
: 'Approval relayed to the lead; the run closes.',
|
|
229
|
+
})
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
case 'reject': {
|
|
233
|
+
const lead = leadOf(teams, agent)
|
|
234
|
+
if (!lead.ok) return Promise.resolve({ kind: 'error', text: lead.reason })
|
|
235
|
+
relay(lead.agent, relayText('reject', parsed.reason))
|
|
236
|
+
return Promise.resolve({
|
|
237
|
+
kind: 'success',
|
|
238
|
+
text: `Rejection relayed to the lead: "${parsed.reason}". The deliverable goes back for rework.`,
|
|
239
|
+
})
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
default:
|
|
243
|
+
return Promise.resolve({ kind: 'error', text: USAGE })
|
|
244
|
+
}
|
|
245
|
+
}
|