@nanmicoder/dsh-agent-teams 0.1.13 → 0.1.14

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/command.js CHANGED
@@ -1,56 +1,40 @@
1
- /**
2
- * The `/agent-teams` slash command and its plain-text gesture boundary.
3
- *
4
- * Two deterministic activation paths, mirroring the Harness skill pipeline
5
- * (`dsh-tool-skill` + the `ui-skill` client source):
6
- *
7
- * 1. **Host command** — `ctx.commands.register` publishes the closed-namespace
8
- * `/agent-teams` command. The web GUI's slash menu (the Harness
9
- * `ui-commands` client) lists it from the host catalog with the input
10
- * hint; the argued line is claimed client-side and executed through
11
- * `command.execute`. The handler replays that exact line as an ordinary
12
- * user follow-up (`agent.followup`) so it remains visible in the chat; the
13
- * gesture boundary then adds the deterministic activation message.
14
- * 2. **Gesture boundary** — a `agent/pre-step` listener recognizes a leading
15
- * `/agent-teams` token in genuine user messages and injects the same
16
- * activation message. This covers surfaces with no command adjudication
17
- * (headless CLI, API, pasted text in plain composers) and also handles the
18
- * exact user line replayed by the host command. Mid-sentence mentions stay
19
- * ordinary prose; only `source.kind === 'user'` messages are scanned, so
20
- * injected or external text cannot forge the gesture.
21
- *
22
- * @module dsh-agent-teams/command
23
- */
24
1
  import { createUserMessage } from '@deepseek-ai/dsh-llm';
25
- /** The slash command name (without the leading slash). */
2
+ import { parseProfileInvocation, resolveProfileTaskPlanning } from "./profiles.js";
26
3
  export const AGENT_TEAMS_COMMAND = 'agent-teams';
27
- /**
28
- * A leading, whitespace-bounded `/agent-teams` token — the command grammar
29
- * shape the harness uses (`parseCommand`): `/` inside words, file paths and
30
- * mid-sentence mentions never match.
31
- */
4
+ const PROFILE_COMMAND_PREFIX = `${AGENT_TEAMS_COMMAND}-`;
32
5
  const GESTURE = /^\/agent-teams(?=$|[\t\n\r ])/u;
33
6
  /**
34
- * The deterministic activation text. The system-prompt usage section owns
35
- * the full protocol; this message only switches it on for one concrete goal.
36
- * @param goal - the user-supplied goal, or `''` for a bare invocation.
7
+ * Convert a configured profile key into a stable, closed-namespace command
8
+ * suffix. Only lowercase ASCII letters, digits and dashes are representable;
9
+ * this deliberately prevents accidental command aliases for ambiguous profile
10
+ * names such as `foo bar`, `foo_bar`, or non-ASCII keys.
37
11
  */
38
- export function buildActivationDirective(goal) {
39
- const goalLine = goal === ''
40
- ? 'The goal was not given — ask the user what the team should accomplish.'
41
- : `Goal: ${goal}`;
42
- return [
43
- 'The user invoked the `/agent-teams` command. Activate the AgentTeams protocol from your instructions now: you are the captain of a multi-agent team.',
44
- goalLine,
45
- ].join('\n');
12
+ export function profileCommandName(profileName) {
13
+ const normalized = profileName.trim().toLowerCase();
14
+ if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/u.test(normalized))
15
+ return undefined;
16
+ return `${PROFILE_COMMAND_PREFIX}${normalized}`;
46
17
  }
47
- /**
48
- * The goal of the latest start-anchored `/agent-teams` gesture in genuine
49
- * user messages, or `undefined` when no message carries one. `''` means a
50
- * bare `/agent-teams` token with no goal.
51
- * @param messages - the step's claimed batch (user messages only scanned).
52
- */
53
- export function invokedAgentTeamsGoal(messages) {
18
+ /** Resolve a profile command only when it maps uniquely to a live profile. */
19
+ function profileForCommand(commandName, profiles) {
20
+ const matches = Object.keys(profiles).filter((profileName) => profileCommandName(profileName) === commandName);
21
+ return matches.length === 1 ? matches[0] : undefined;
22
+ }
23
+ /** Parse either the generic command or one generated profile alias. */
24
+ function parseCommandText(text, profiles) {
25
+ const trimmed = text.trimStart();
26
+ if (GESTURE.test(trimmed))
27
+ return parseProfileInvocation(trimmed.slice(AGENT_TEAMS_COMMAND.length + 1).trim());
28
+ if (!trimmed.startsWith(`/${PROFILE_COMMAND_PREFIX}`))
29
+ return undefined;
30
+ const tokenEnd = trimmed.search(/[\t\n\r ]/u);
31
+ const commandName = trimmed.slice(1, tokenEnd === -1 ? undefined : tokenEnd);
32
+ const profile = profileForCommand(commandName, profiles);
33
+ if (profile === undefined)
34
+ return undefined;
35
+ return { profile, goal: (tokenEnd === -1 ? '' : trimmed.slice(tokenEnd)).trim() };
36
+ }
37
+ export function invokedAgentTeamsInvocation(messages, getProfiles = () => ({})) {
54
38
  for (let index = messages.length - 1; index >= 0; index -= 1) {
55
39
  const message = messages[index];
56
40
  if (message === undefined || message.source.kind !== 'user')
@@ -58,69 +42,102 @@ export function invokedAgentTeamsGoal(messages) {
58
42
  for (const block of message.content) {
59
43
  if (block.type !== 'text')
60
44
  continue;
61
- const text = block.text.trimStart();
62
- if (!GESTURE.test(text))
63
- continue;
64
- return text.slice(AGENT_TEAMS_COMMAND.length + 1).trim();
45
+ const invocation = parseCommandText(block.text, getProfiles());
46
+ if (invocation !== undefined)
47
+ return invocation;
65
48
  }
66
49
  }
67
50
  return undefined;
68
51
  }
69
- /**
70
- * Register the closed-namespace `/agent-teams` host command. The handler
71
- * preserves the exact submitted slash line as an ordinary user follow-up;
72
- * the pre-step gesture boundary injects the activation directive and wakes
73
- * the captain deterministically. The registration rides the calling
74
- * context's fiber, so a disposed scope (HMR, plugin removal) unregisters the
75
- * command.
76
- * @param ctx - host context providing the `commands` registry.
77
- */
78
- export function registerAgentTeamsCommand(ctx) {
79
- ctx.effect(() => ctx.commands.register({
80
- name: AGENT_TEAMS_COMMAND,
81
- description: 'run a goal with a multi-agent team (you become the captain)',
82
- input: { hint: '<goal — what the team should accomplish>' },
83
- handler(invocation) {
84
- const goal = invocation.rawInput.trim();
85
- if (goal === '') {
86
- return {
87
- kind: 'error',
88
- text: `Usage: /${AGENT_TEAMS_COMMAND} <goal — what the team should accomplish>`,
89
- };
90
- }
91
- invocation.agent.followup(createUserMessage({
92
- content: [{ type: 'text', text: `/${AGENT_TEAMS_COMMAND}${invocation.rawInput}` }],
93
- source: { kind: 'user' },
52
+ export function invokedAgentTeamsGoal(messages) {
53
+ return invokedAgentTeamsInvocation(messages)?.goal;
54
+ }
55
+ export function buildActivationDirective(goal, profile, taskPlanning = 'seed') {
56
+ const lines = [
57
+ 'The user invoked an AgentTeams slash command. Activate the AgentTeams protocol from your instructions now: you are the captain of a multi-agent team.',
58
+ 'Call agent_teams_create with approval="required". Build the complete staged roster and DAG, then stop and ask the user to review the Web plan. Do not approve or start it in this same turn.',
59
+ ];
60
+ if (profile !== undefined) {
61
+ lines.push(`Use configured AgentTeams profile "${profile}" when calling agent_teams_create.`);
62
+ if (taskPlanning === 'captain') {
63
+ lines.push('This profile supplies the roster and guardrails. After create, do not recreate members.', 'Derive the smallest useful task graph from the goal while the team is staged; do not ask the user whether to split, merge, serialize, or parallelize.', 'Independent supplemental work must become separate ready tasks so idle members can run in parallel. Add dependencies only for genuine prerequisites and later synthesis.');
64
+ }
65
+ else {
66
+ lines.push('Do not recreate the same members or seed tasks manually.');
67
+ }
68
+ }
69
+ lines.push(goal === '' ? 'The goal was not given — ask the user what the team should accomplish.' : `Goal: ${goal}`);
70
+ return lines.join('\n');
71
+ }
72
+ export function registerAgentTeamsCommand(ctx, getProfiles = () => ({})) {
73
+ ctx.effect(() => {
74
+ const dispose = [];
75
+ dispose.push(ctx.commands.register({
76
+ name: AGENT_TEAMS_COMMAND,
77
+ description: 'run a goal with a multi-agent team (you become the captain)',
78
+ input: { hint: '[--profile <name>] <goal>' },
79
+ handler(invocation) {
80
+ let parsed;
81
+ try {
82
+ parsed = parseProfileInvocation(invocation.rawInput.trim());
83
+ }
84
+ catch (error) {
85
+ return { kind: 'error', text: String(error) };
86
+ }
87
+ if (parsed.profile !== undefined && !Object.keys(getProfiles()).some(key => key.trim() === parsed.profile))
88
+ return { kind: 'error', text: `unknown AgentTeams profile "${parsed.profile}"` };
89
+ if (parsed.profile === undefined && parsed.goal === '')
90
+ return { kind: 'error', text: `Usage: /${AGENT_TEAMS_COMMAND} [--profile <name>] <goal>` };
91
+ invocation.agent.followup(createUserMessage({ content: [{ type: 'text', text: `/${AGENT_TEAMS_COMMAND}${invocation.rawInput}` }], source: { kind: 'user' } }));
92
+ return { kind: 'success', text: `AgentTeams activated${parsed.profile === undefined ? '' : ` with profile ${parsed.profile}`} — the captain will assemble the team.` };
93
+ },
94
+ }));
95
+ for (const profileName of Object.keys(getProfiles())) {
96
+ const commandName = profileCommandName(profileName);
97
+ if (commandName === undefined)
98
+ continue;
99
+ dispose.push(ctx.commands.register({
100
+ name: commandName,
101
+ description: `run a goal with the AgentTeams ${profileName} profile`,
102
+ input: { hint: '<goal>' },
103
+ handler(invocation) {
104
+ const profile = profileForCommand(commandName, getProfiles());
105
+ if (profile === undefined)
106
+ return { kind: 'error', text: `AgentTeams profile command "/${commandName}" is unavailable` };
107
+ invocation.agent.followup(createUserMessage({ content: [{ type: 'text', text: `/${commandName}${invocation.rawInput}` }], source: { kind: 'user' } }));
108
+ return { kind: 'success', text: `AgentTeams activated with profile ${profile} — the captain will assemble the team.` };
109
+ },
94
110
  }));
95
- return {
96
- kind: 'success',
97
- text: `AgentTeams activated the captain will assemble a team for: ${goal}`,
98
- };
99
- },
100
- }), 'agent-teams: slash command');
111
+ }
112
+ return () => {
113
+ for (const unregister of dispose.reverse())
114
+ unregister();
115
+ };
116
+ }, 'agent-teams: slash commands');
101
117
  }
102
- /**
103
- * Install the `agent/pre-step` gesture boundary: a claimed user message
104
- * starting with `/agent-teams` gains the deterministic activation message
105
- * appended after every other injection, closest to the model's answer.
106
- * @param ctx - host context providing the `agent/pre-step` waterfall.
107
- */
108
- export function installAgentTeamsGestureBoundary(ctx) {
118
+ export function installAgentTeamsGestureBoundary(ctx, getProfiles = () => ({})) {
109
119
  ctx.on('agent/pre-step', async ({ messages, signal }, next) => {
110
120
  const decision = await next();
111
121
  if (decision.kind === 'reject')
112
122
  return decision;
113
- const goal = invokedAgentTeamsGoal(messages);
114
- if (goal === undefined)
123
+ let invocation;
124
+ try {
125
+ invocation = invokedAgentTeamsInvocation(messages, getProfiles);
126
+ }
127
+ catch (error) {
128
+ return { kind: 'enter', messages: [...decision.messages, createUserMessage({ content: [{ type: 'text', text: `AgentTeams profile parsing failed: ${String(error)}` }], source: { kind: 'agent-teams-command' } })] };
129
+ }
130
+ if (invocation === undefined)
115
131
  return decision;
116
132
  signal.throwIfAborted();
117
- const activation = createUserMessage({
118
- content: [{ type: 'text', text: buildActivationDirective(goal) }],
119
- source: {
120
- kind: 'agent-teams-command',
121
- ...goal === '' ? {} : { goal },
122
- },
123
- });
124
- return { kind: 'enter', messages: [...decision.messages, activation] };
133
+ const profiles = getProfiles();
134
+ const matched = invocation.profile === undefined
135
+ ? undefined
136
+ : Object.entries(profiles).find(([key]) => key.trim() === invocation.profile);
137
+ const known = invocation.profile === undefined || matched !== undefined;
138
+ const text = !known
139
+ ? `AgentTeams profile "${invocation.profile}" does not exist. Available profiles: ${Object.keys(profiles).join(', ') || '(none)'}. Do not create a team.`
140
+ : buildActivationDirective(invocation.goal, invocation.profile, resolveProfileTaskPlanning(matched?.[1]));
141
+ return { kind: 'enter', messages: [...decision.messages, createUserMessage({ content: [{ type: 'text', text }], source: { kind: 'agent-teams-command', ...invocation.goal === '' ? {} : { goal: invocation.goal }, ...invocation.profile === undefined ? {} : { profile: invocation.profile } } })] };
125
142
  });
126
143
  }
package/lib/index.js CHANGED
@@ -17,47 +17,95 @@
17
17
  * @module dsh-agent-teams
18
18
  */
19
19
  import z from '@deepseek-ai/schemastery';
20
- import { registerAgentTeamsTools } from "./tools.js";
20
+ import { haltTeamWork, registerAgentTeamsTools, } from "./tools.js";
21
21
  import { installAgentTeamsGestureBoundary, registerAgentTeamsCommand } from "./command.js";
22
22
  import { readFile } from 'node:fs/promises';
23
23
  import { join } from 'node:path';
24
24
  import { fileURLToPath } from 'node:url';
25
25
  import { collectArchivedTeamsActivity, collectTeamsActivity } from "./snapshot.js";
26
+ import { findTeamByCaptain } from "./state.js";
27
+ import { formatProfilesForPrompt } from "./profiles.js";
28
+ import { qualityPlanningPrompt } from "./quality-gates.js";
26
29
  /** Web-server service key candidates, newest first. */
27
30
  const WEB_SERVER_KEYS = ['webServer', 'httpServer'];
28
31
  /** Workspace registry service key candidates, newest first. */
29
32
  const WORKSPACE_KEYS = ['workspaceRegistry', 'workspace'];
30
33
  export const name = 'agent-teams';
31
34
  export const inject = ['tools', 'llm', 'subagents', 'systemPrompt', 'agents'];
35
+ // `z.object()` has an implicit `{}` default in Schemastery. Fallback routes
36
+ // are optional, so model absence explicitly; otherwise a missing route is
37
+ // validated as an empty object and fails on the required provider/model keys.
38
+ const fallbackRouteConfig = z.union([
39
+ z.object({ provider: z.string().required(), model: z.string().required() }),
40
+ z.const(undefined),
41
+ ]);
32
42
  export const Config = z.object({
33
43
  stateDir: z.string().default('.agent-teams'),
34
44
  memberProvider: z.string().default('spawn'),
35
45
  memberModel: z.string(),
46
+ executionPrompt: z.string(),
47
+ fallback: fallbackRouteConfig,
48
+ profiles: z.dict(z.object({
49
+ description: z.string(),
50
+ protocol: z.string(),
51
+ executionPrompt: z.string(),
52
+ fallback: fallbackRouteConfig,
53
+ members: z.array(z.object({
54
+ name: z.string().required(),
55
+ role: z.string(),
56
+ provider: z.string(),
57
+ model: z.string(),
58
+ reasoning_effort: z.string(),
59
+ executionPrompt: z.string(),
60
+ fallback: fallbackRouteConfig,
61
+ })).min(1).required(),
62
+ taskPlanning: z.union([z.const('captain'), z.const('seed')]),
63
+ reviewPolicy: z.object({
64
+ requirementsMinRounds: z.natural().min(1),
65
+ requirementsMaxRounds: z.natural().min(1),
66
+ codeMaxRounds: z.natural().min(1),
67
+ maxRepairAttempts: z.natural().min(1),
68
+ requiredReviewers: z.array(z.string()),
69
+ }),
70
+ tasks: z.array(z.object({
71
+ id: z.string().required(),
72
+ subject: z.string().required(),
73
+ description: z.string(),
74
+ assignee: z.string(),
75
+ dependencies: z.array(z.string()),
76
+ })),
77
+ })).default({}),
36
78
  memberMaxDepth: z.natural().default(1),
37
79
  maxMembers: z.natural().min(1).default(8),
38
80
  promptSectionOrder: z.natural().default(117),
39
81
  slashCommand: z.boolean().default(true),
40
82
  });
41
83
  /** The model-facing usage policy: when and how to drive AgentTeams. */
42
- function usageSectionText(toolNames) {
84
+ export function usageSectionText(toolNames, profilesText = '') {
43
85
  return `When the user asks to run something with AgentTeams (e.g. "use AgentTeams to do X"), or an activation message from the /agent-teams slash command arrives, you are the captain of a multi-agent team. Follow this protocol:
44
- 1. Call agent_teams_create with a team name and the goal as description. You become the captain and may lead one team at a time.
45
- 2. Call agent_teams_add_member once per role the goal needs (researcher, engineer, reviewer, ...). Members are durable subagents: they wait for your messages, then work a full turn. By default a member on your current provider/model snapshots your current reasoning effort; a member routed to a different provider or model automatically uses that target model's default effort. Never ask the user to choose these per member; only pass provider/model when the user explicitly requests a different route for that role, and reasoning_effort only when the user explicitly requests a particular effort ("default" explicitly selects the target model's default).
46
- 3. Break the goal into tasks with agent_teams_create_task and wire dependencies. Assign role-specific work when useful; unassigned ready work belongs to the shared pool. The scheduler automatically claims one ready task for each truly idle member and wakes it, including across later rounds.
47
- 4. Lead by delegation: monitor with agent_teams_status, send guidance with agent_teams_send_message, and let idle teammates execute ready work. Do not duplicate a teammate's work merely because its turn is slow. If the user requires every member to contribute or report, create one task per required contribution (or message each member directly); never wait for an unassigned member to produce work it was never given.
48
- 5. If the user explicitly asks to pause a running member, its open attempt remains parked after interruption; after answering the user, send that same member guidance with agent_teams_send_message so it continues the same attempt. Do not interrupt members for an ordinary user question that did not request a pause. If work must change owner, restart from scratch, or be taken over, call agent_teams_reassign_task first. Reassign to another idle member, retry with the same member, or use assignee=captain before doing it yourself. Reassignment revokes the old attempt and waits for that member to quiesce, preventing late results from overwriting the new attempt.
86
+ 1. Call agent_teams_create with a team name, the goal as description, and approval="required". This creates a staged plan and must not spawn members or schedule work. Use approval="automatic" only when the user explicitly asks to skip review and run immediately.
87
+ 2. Call agent_teams_add_member once per role the goal needs (researcher, engineer, reviewer, ...). In staging these are editable roster entries, not running subagents. By default a member snapshots your current provider/model/reasoning route; use a different route only when the goal or user requires it.
88
+ 3. Analyze the goal and create the smallest useful task DAG while staged. Every agent_teams_create_task call must include a non-empty subject, including verification and review tasks. Independent work should be parallel; dependencies are only genuine prerequisites. Finish the complete roster and DAG, tell the user the Web plan is ready, then end this turn. Never call agent_teams_approve during the planning turn. The user may click Approve & Run, explicitly approve in a later user turn, return to chat to request changes, or discard the plan. The review UI injects an authoritative control message for return/discard actions: follow it exactly and never infer that a missing or paused team should be recreated. When the user returns to chat, first ask one concise clarification question without editing or recreating; after their answer, call agent_teams_edit_plan once with an ordered atomic batch, update downstream dependencies/assignees before removals, summarize the revision, and wait for review again. Never inspect or edit .agent-teams state files or plugin source code to revise a plan. Only explicit approval may call agent_teams_approve.
89
+ 4. After approval, the final member configuration is spawned atomically and the scheduler starts ready work. Lead by delegation: monitor with agent_teams_status, send guidance with agent_teams_send_message, and let idle teammates execute ready work. Do not duplicate a teammate's work merely because its turn is slow. If the user requires every member to contribute or report, create one task per required contribution (or message each member directly); never wait for an unassigned member to produce work it was never given.
90
+ 5. If the user explicitly asks to pause a running member, its open attempt remains parked after interruption; after answering the user, send that same member guidance with agent_teams_send_message so it continues the same attempt. Do not interrupt members for an ordinary user question that did not request a pause. If work must change owner, restart from scratch, or be taken over, call agent_teams_reassign_task first. Prefer another idle member or a retry with the same member. Use assignee=captain only for one ready task that you will personally drive to a terminal status in this same turn; never start a second captain takeover while one is unfinished, and never end your turn with captain-owned work open. Reassignment revokes the old attempt and waits for that member to quiesce, preventing late results from overwriting the new attempt.
49
91
  6. Tasks carry attempt_id capabilities. Members must use the current attempt_id for updates; stale-attempt errors mean ownership changed. Check status after progress notifications until every required task is terminal and every member is idle/ready; do not busy-poll or require reports from members with no assigned work.
50
- 7. Present the team's results to the user, then agent_teams_delete the team unless the user wants to keep working with it.
92
+ 7. If the user names a configured profile / template / fixed roster, pass that name as profile= to agent_teams_create. After a successful profile create, do not recreate the same members. Seed profiles provide their template tasks; captain-planning profiles provide only the roster and guardrails, so you must design their DAG while staged. Add repair or retry tasks when review/test fails, but never make a new task depend on a failed task. Do not send_message to start the next stage; the scheduler assigns ready work after approval. Watch every required task until it is terminal before deleting the team. Never perform a real deployment without explicit user confirmation.
93
+ 8. Quality kinds (requirements, implementation, verification, review, repair, integration) need a contract: non-empty objective and acceptance; implementation/repair also need inScope and verify. Review/requirements can complete only with verdict=pass; needs_revision/reject must fail with findings. The system then opens repair + next review that depend on the successful source, never the failed review. Do not approve your own implementation. create_task no longer silently resumes a halted team — call agent_teams_resume with a reason, or create_task({resume:true, resumeReason}).
94
+ 9. ${qualityPlanningPrompt()}
95
+ 10. Present the team's results to the user, then agent_teams_delete the team unless the user wants to keep working with it. Stopping a team aborts the Captain's current turn as well as member work; only a later explicit user turn may resume it.
51
96
 
52
- Tools: ${toolNames}`;
97
+ Tools: ${toolNames}${profilesText === '' ? '' : `\n\n${profilesText}`}`;
53
98
  }
54
99
  export function apply(ctx, config) {
55
100
  const resolved = {
56
101
  stateDir: config.stateDir ?? '.agent-teams',
57
102
  memberProvider: config.memberProvider ?? 'spawn',
58
103
  memberModel: config.memberModel,
104
+ executionPrompt: config.executionPrompt,
105
+ fallback: config.fallback,
59
106
  memberMaxDepth: config.memberMaxDepth ?? 1,
60
107
  maxMembers: config.maxMembers ?? 8,
108
+ profiles: config.profiles ?? {},
61
109
  };
62
110
  // Provider registration is a sibling plugin's effect (`subagent-spawn` /
63
111
  // `subagent-fork` rows), which can land after this mount under the Loader's
@@ -66,6 +114,8 @@ export function apply(ctx, config) {
66
114
  // settled, rather than here.
67
115
  const toolNames = [
68
116
  'agent_teams_create',
117
+ 'agent_teams_approve',
118
+ 'agent_teams_edit_plan',
69
119
  'agent_teams_add_member',
70
120
  'agent_teams_remove_member',
71
121
  'agent_teams_create_task',
@@ -74,14 +124,16 @@ export function apply(ctx, config) {
74
124
  'agent_teams_update_task',
75
125
  'agent_teams_send_message',
76
126
  'agent_teams_status',
127
+ 'agent_teams_resume',
77
128
  'agent_teams_delete',
78
129
  ].join(', ');
79
130
  ctx.systemPrompt.section({
80
131
  name: 'agent-teams:usage',
81
132
  order: config.promptSectionOrder ?? 117,
82
- text: usageSectionText(toolNames),
133
+ text: () => usageSectionText(toolNames, formatProfilesForPrompt(config.profiles ?? {})),
83
134
  });
84
- registerAgentTeamsTools(ctx, resolved);
135
+ // Exported for TDD / docs checks. Not a public runtime API.
136
+ const agentTeamsRuntime = registerAgentTeamsTools(ctx, resolved);
85
137
  // Deterministic activation surfaces: the closed-namespace `/agent-teams`
86
138
  // host command (surfaces in the Web GUI slash menu via the Harness
87
139
  // ui-commands client) and the plain-text gesture boundary for surfaces
@@ -94,9 +146,9 @@ export function apply(ctx, config) {
94
146
  // never pends on it and simply never gains the slash command.
95
147
  if (config.slashCommand ?? true) {
96
148
  ctx.inject(['commands'], (commandCtx) => {
97
- registerAgentTeamsCommand(commandCtx);
149
+ registerAgentTeamsCommand(commandCtx, () => config.profiles ?? {});
98
150
  });
99
- installAgentTeamsGestureBoundary(ctx);
151
+ installAgentTeamsGestureBoundary(ctx, () => config.profiles ?? {});
100
152
  }
101
153
  // The activity panel data/artwork routes need the Web server and the
102
154
  // workspace registry, which headless profiles do not mount; under
@@ -136,6 +188,226 @@ export function apply(ctx, config) {
136
188
  res.end(body);
137
189
  },
138
190
  }), 'agent-teams: activity route');
191
+ ctx.effect(() => webServer.register({
192
+ kind: 'exact',
193
+ path: '/plugins/dsh-agent-teams/halt',
194
+ handler: async (req, res) => {
195
+ if (req.method !== 'POST') {
196
+ res.writeHead(405, { allow: 'POST', 'cache-control': 'no-store' });
197
+ res.end();
198
+ return;
199
+ }
200
+ let raw = '';
201
+ try {
202
+ raw = await new Promise((resolve, reject) => {
203
+ const chunks = [];
204
+ req.on('data', (chunk) => { chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); });
205
+ req.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')));
206
+ req.on('error', reject);
207
+ });
208
+ }
209
+ catch {
210
+ res.writeHead(400, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' });
211
+ res.end(JSON.stringify({ error: 'invalid request body' }));
212
+ return;
213
+ }
214
+ let payload;
215
+ try {
216
+ payload = raw.trim() === '' ? {} : JSON.parse(raw);
217
+ }
218
+ catch {
219
+ res.writeHead(400, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' });
220
+ res.end(JSON.stringify({ error: 'invalid JSON' }));
221
+ return;
222
+ }
223
+ const sessionId = typeof payload.sessionId === 'string' ? payload.sessionId.trim() : '';
224
+ const teamId = typeof payload.teamId === 'string' ? payload.teamId.trim() : '';
225
+ if (sessionId === '' || teamId === '') {
226
+ res.writeHead(400, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' });
227
+ res.end(JSON.stringify({ error: 'sessionId and teamId are required' }));
228
+ return;
229
+ }
230
+ const captain = ctx.agents.get(sessionId);
231
+ if (captain === undefined) {
232
+ res.writeHead(409, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' });
233
+ res.end(JSON.stringify({ error: 'captain session is not attached' }));
234
+ return;
235
+ }
236
+ const workspace = captain.session.header.cwd ?? process.cwd();
237
+ const stateRoot = join(workspace, resolved.stateDir);
238
+ const team = await findTeamByCaptain(stateRoot, captain.id);
239
+ if (team === undefined || team.id !== teamId) {
240
+ res.writeHead(404, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' });
241
+ res.end(JSON.stringify({ error: 'team not found for this captain' }));
242
+ return;
243
+ }
244
+ try {
245
+ const result = await haltTeamWork({
246
+ ctx,
247
+ stateRoot,
248
+ teamId,
249
+ captain,
250
+ });
251
+ res.writeHead(200, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' });
252
+ res.end(JSON.stringify(result));
253
+ }
254
+ catch (error) {
255
+ ctx.logger.warn(`agent-teams: halt failed for ${teamId}: ${String(error)}`);
256
+ res.writeHead(500, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' });
257
+ res.end(JSON.stringify({ error: 'failed to stop the team' }));
258
+ }
259
+ },
260
+ }), 'agent-teams: halt route');
261
+ ctx.effect(() => webServer.register({
262
+ kind: 'exact',
263
+ path: '/plugins/dsh-agent-teams/plan',
264
+ handler: async (req, res) => {
265
+ if (req.method !== 'POST') {
266
+ res.writeHead(405, { allow: 'POST', 'cache-control': 'no-store' });
267
+ res.end();
268
+ return;
269
+ }
270
+ let payload;
271
+ try {
272
+ const chunks = [];
273
+ const raw = await new Promise((resolve, reject) => {
274
+ let size = 0;
275
+ req.on('data', (chunk) => {
276
+ const part = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
277
+ size += part.length;
278
+ if (size > 1_000_000) {
279
+ reject(new Error('request body is too large'));
280
+ return;
281
+ }
282
+ chunks.push(part);
283
+ });
284
+ req.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')));
285
+ req.on('error', reject);
286
+ });
287
+ const parsed = raw.trim() === '' ? {} : JSON.parse(raw);
288
+ if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed))
289
+ throw new Error('body must be an object');
290
+ payload = parsed;
291
+ }
292
+ catch (error) {
293
+ res.writeHead(400, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' });
294
+ res.end(JSON.stringify({ error: error instanceof Error ? error.message : 'invalid request body' }));
295
+ return;
296
+ }
297
+ const sessionId = typeof payload['sessionId'] === 'string' ? payload['sessionId'].trim() : '';
298
+ const teamId = typeof payload['teamId'] === 'string' ? payload['teamId'].trim() : '';
299
+ const action = typeof payload['action'] === 'string' ? payload['action'] : '';
300
+ if (sessionId === '' || teamId === '' || action === '') {
301
+ res.writeHead(400, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' });
302
+ res.end(JSON.stringify({ error: 'sessionId, teamId, and action are required' }));
303
+ return;
304
+ }
305
+ const captain = ctx.agents.get(sessionId);
306
+ if (captain === undefined) {
307
+ res.writeHead(409, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' });
308
+ res.end(JSON.stringify({ error: 'captain session is not attached' }));
309
+ return;
310
+ }
311
+ const workspace = captain.session.header.cwd ?? process.cwd();
312
+ const stateRoot = join(workspace, resolved.stateDir);
313
+ const team = await findTeamByCaptain(stateRoot, captain.id);
314
+ if (team === undefined || team.id !== teamId) {
315
+ res.writeHead(404, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' });
316
+ res.end(JSON.stringify({ error: 'team not found for this captain' }));
317
+ return;
318
+ }
319
+ try {
320
+ if (action === 'approve') {
321
+ const approved = await agentTeamsRuntime.approveStagedTeam(captain, teamId);
322
+ res.writeHead(200, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' });
323
+ res.end(JSON.stringify({ ok: true, phase: 'running', ...approved }));
324
+ return;
325
+ }
326
+ if (action === 'continue') {
327
+ const continued = await agentTeamsRuntime.continueStagedPlanning(captain, teamId);
328
+ res.writeHead(200, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' });
329
+ res.end(JSON.stringify({ ok: true, phase: 'staged', review: 'awaiting_feedback', ...continued }));
330
+ return;
331
+ }
332
+ if (action === 'discard') {
333
+ const discarded = await agentTeamsRuntime.discardStagedTeam(captain, teamId);
334
+ res.writeHead(200, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' });
335
+ res.end(JSON.stringify({ ok: true, phase: 'archived', ...discarded }));
336
+ return;
337
+ }
338
+ const dependencies = Array.isArray(payload['dependencies'])
339
+ ? payload['dependencies'].filter((item) => typeof item === 'string')
340
+ : [];
341
+ let mutation;
342
+ if (action === 'update_member') {
343
+ if (typeof payload['memberName'] !== 'string'
344
+ || typeof payload['provider'] !== 'string'
345
+ || typeof payload['model'] !== 'string')
346
+ throw new Error('memberName, provider, and model are required');
347
+ mutation = {
348
+ action,
349
+ memberName: payload['memberName'],
350
+ provider: payload['provider'],
351
+ model: payload['model'],
352
+ ...typeof payload['role'] === 'string' || payload['role'] === null ? { role: payload['role'] } : {},
353
+ ...typeof payload['reasoningEffort'] === 'string' || payload['reasoningEffort'] === null
354
+ ? { reasoningEffort: payload['reasoningEffort'] }
355
+ : {},
356
+ ...typeof payload['executionPrompt'] === 'string' || payload['executionPrompt'] === null
357
+ ? { executionPrompt: payload['executionPrompt'] }
358
+ : {},
359
+ };
360
+ }
361
+ else if (action === 'update_task') {
362
+ if (typeof payload['taskId'] !== 'string' || typeof payload['subject'] !== 'string') {
363
+ throw new Error('taskId and subject are required');
364
+ }
365
+ mutation = {
366
+ action,
367
+ taskId: payload['taskId'],
368
+ subject: payload['subject'],
369
+ dependencies,
370
+ ...typeof payload['description'] === 'string' || payload['description'] === null
371
+ ? { description: payload['description'] }
372
+ : {},
373
+ ...typeof payload['assignee'] === 'string' || payload['assignee'] === null
374
+ ? { assignee: payload['assignee'] }
375
+ : {},
376
+ };
377
+ }
378
+ else if (action === 'add_task') {
379
+ if (typeof payload['subject'] !== 'string')
380
+ throw new Error('subject is required');
381
+ mutation = {
382
+ action,
383
+ subject: payload['subject'],
384
+ dependencies,
385
+ ...typeof payload['description'] === 'string' || payload['description'] === null
386
+ ? { description: payload['description'] }
387
+ : {},
388
+ ...typeof payload['assignee'] === 'string' || payload['assignee'] === null
389
+ ? { assignee: payload['assignee'] }
390
+ : {},
391
+ };
392
+ }
393
+ else if (action === 'remove_task') {
394
+ if (typeof payload['taskId'] !== 'string')
395
+ throw new Error('taskId is required');
396
+ mutation = { action, taskId: payload['taskId'] };
397
+ }
398
+ else {
399
+ throw new Error(`unknown plan action "${action}"`);
400
+ }
401
+ const updated = await agentTeamsRuntime.updateStagedPlan(captain, teamId, mutation);
402
+ res.writeHead(200, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' });
403
+ res.end(JSON.stringify({ ok: true, phase: updated.phase, members: updated.members.length, tasks: updated.tasks.length }));
404
+ }
405
+ catch (error) {
406
+ res.writeHead(409, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' });
407
+ res.end(JSON.stringify({ error: error instanceof Error ? error.message : 'plan operation failed' }));
408
+ }
409
+ },
410
+ }), 'agent-teams: plan route');
139
411
  // Whale mascot artwork: serve the packaged V2 role/action images to the
140
412
  // activity panel. An explicit allowlist guards the route (no path
141
413
  // traversal); the images ship with the bundle (files: assets/).