@nanmicoder/dsh-agent-teams 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +72 -0
  3. package/assets/agent-teams/action-celebrating.png +0 -0
  4. package/assets/agent-teams/action-reporting.png +0 -0
  5. package/assets/agent-teams/action-sending.png +0 -0
  6. package/assets/agent-teams/action-sleeping.png +0 -0
  7. package/assets/agent-teams/action-thinking.png +0 -0
  8. package/assets/agent-teams/action-working.png +0 -0
  9. package/assets/agent-teams/data-analyst.png +0 -0
  10. package/assets/agent-teams/designer.png +0 -0
  11. package/assets/agent-teams/docs-coordinator.png +0 -0
  12. package/assets/agent-teams/engineer.png +0 -0
  13. package/assets/agent-teams/qa-engineer.png +0 -0
  14. package/assets/agent-teams/researcher.png +0 -0
  15. package/assets/agent-teams/security-reviewer.png +0 -0
  16. package/assets/agent-teams/team-lead.png +0 -0
  17. package/cordis.patch.yml +21 -0
  18. package/lib/client/ActivityPanel.js +340 -0
  19. package/lib/client/AgentTeamsCard.js +74 -0
  20. package/lib/client/activity-model.js +70 -0
  21. package/lib/client/agent-teams-card-definition.js +85 -0
  22. package/lib/client/artwork.js +40 -0
  23. package/lib/client/index.js +33 -0
  24. package/lib/client.js +1235 -0
  25. package/lib/client.js.map +1 -0
  26. package/lib/event-types.js +12 -0
  27. package/lib/events.js +60 -0
  28. package/lib/index.js +172 -0
  29. package/lib/members.js +168 -0
  30. package/lib/snapshot.js +155 -0
  31. package/lib/state.js +461 -0
  32. package/lib/tools.js +749 -0
  33. package/lib/types/client/ActivityPanel.d.ts +64 -0
  34. package/lib/types/client/AgentTeamsCard.d.ts +24 -0
  35. package/lib/types/client/activity-model.d.ts +31 -0
  36. package/lib/types/client/agent-teams-card-definition.d.ts +44 -0
  37. package/lib/types/client/artwork.d.ts +19 -0
  38. package/lib/types/client/index.d.ts +11 -0
  39. package/lib/types/event-types.d.ts +103 -0
  40. package/lib/types/events.d.ts +37 -0
  41. package/lib/types/index.d.ts +42 -0
  42. package/lib/types/members.d.ts +86 -0
  43. package/lib/types/snapshot.d.ts +83 -0
  44. package/lib/types/state.d.ts +144 -0
  45. package/lib/types/tools.d.ts +40 -0
  46. package/lib/types/types.d.ts +73 -0
  47. package/lib/types.js +11 -0
  48. package/package.json +108 -0
package/lib/tools.js ADDED
@@ -0,0 +1,749 @@
1
+ /**
2
+ * The `agent_teams_*` model-facing tools.
3
+ *
4
+ * The captain (the agent that created the team) orchestrates: members are
5
+ * continuable subagents it spawns and wakes. Members share the same tools and
6
+ * drive their own task state, mirroring the Claude Code AgentTeams flow:
7
+ * create team → add members → create tasks with dependencies → claim/assign →
8
+ * work → report → status → delete.
9
+ * @module dsh-agent-teams/tools
10
+ */
11
+ import { createUserMessage } from '@deepseek-ai/dsh-llm';
12
+ import { defineTool } from '@deepseek-ai/dsh-tools';
13
+ import { join } from 'node:path';
14
+ import { appendTeamEvent, captainSessionOf } from "./events.js";
15
+ import { appendMailbox, archiveTeamDir, CAPTAIN_KEY, createMessage, createTeamDir, findTeamByCaptain, findTeamByParticipant, readMailbox, readTeam, sanitizeKey, transitionError, unsatisfiedDependencies, withTeamLock, writeTeam, } from "./state.js";
16
+ import { deliverToMember, interruptMember, memberActivity, spawnMember, } from "./members.js";
17
+ /** The caller agent, or a loud failure for non-agent callers. */
18
+ function requireCaptain(exec) {
19
+ if (!exec.agent) {
20
+ throw new Error('agent_teams tools require a calling agent (exec.agent was undefined)');
21
+ }
22
+ return exec.agent;
23
+ }
24
+ /** The captain's workspace directory (team state root parent). */
25
+ function workspaceOf(agent) {
26
+ return agent.session.header.cwd ?? process.cwd();
27
+ }
28
+ /** Resolved absolute state root. */
29
+ function stateRootOf(workspace, config) {
30
+ return join(workspace, config.stateDir);
31
+ }
32
+ /** Process-local lock key scoped by workspace state root and team id. */
33
+ function teamLockKey(stateRoot, teamId) {
34
+ return `team:${stateRoot}:${teamId}`;
35
+ }
36
+ /** Process-local lock key enforcing one active team per captain session. */
37
+ function captainLockKey(stateRoot, captainId) {
38
+ return `captain:${stateRoot}:${captainId}`;
39
+ }
40
+ /** The team this captain currently leads, or a loud failure. */
41
+ async function requireCaptainTeam(workspace, config, captain) {
42
+ const team = await findTeamByCaptain(stateRootOf(workspace, config), captain.id);
43
+ if (team === undefined) {
44
+ throw new Error('you are not leading any team yet — call agent_teams_create first');
45
+ }
46
+ return team;
47
+ }
48
+ /** The team this captain or active member currently participates in. */
49
+ async function requireParticipantTeam(workspace, config, caller) {
50
+ const team = await findTeamByParticipant(stateRootOf(workspace, config), caller.id);
51
+ if (team === undefined) {
52
+ throw new Error('you do not lead or belong to any active team yet');
53
+ }
54
+ return team;
55
+ }
56
+ /** Re-derive a caller's role from fresh state while holding the team lock. */
57
+ function participantIdentityOf(team, agentId) {
58
+ if (team.captainSessionId === agentId)
59
+ return { kind: 'captain', name: CAPTAIN_KEY };
60
+ const member = team.members.find((candidate) => candidate.id === agentId && candidate.status !== 'removed');
61
+ return member === undefined ? undefined : { kind: 'member', name: member.name };
62
+ }
63
+ /** Fresh state for a team that still exists; never falls back to stale lookup data. */
64
+ async function requireFreshTeam(stateRoot, teamId) {
65
+ const fresh = await readTeam(stateRoot, teamId);
66
+ if (fresh === undefined)
67
+ throw new Error(`team "${teamId}" is no longer active`);
68
+ return fresh;
69
+ }
70
+ /** Fresh state with captain authorization rechecked inside the lock. */
71
+ async function requireFreshCaptainTeam(stateRoot, teamId, captainId) {
72
+ const fresh = await requireFreshTeam(stateRoot, teamId);
73
+ if (fresh.captainSessionId !== captainId) {
74
+ throw new Error(`only the captain of team "${fresh.name}" may perform this operation`);
75
+ }
76
+ return fresh;
77
+ }
78
+ /** Fresh state and caller identity rechecked inside the lock. */
79
+ async function requireFreshParticipant(stateRoot, teamId, callerId) {
80
+ const fresh = await requireFreshTeam(stateRoot, teamId);
81
+ const identity = participantIdentityOf(fresh, callerId);
82
+ if (identity === undefined)
83
+ throw new Error(`you are no longer an active participant in team "${fresh.name}"`);
84
+ return { team: fresh, identity };
85
+ }
86
+ /** Look up one live (non-removed) member by display name. */
87
+ function requireMember(team, name) {
88
+ const member = team.members.find((candidate) => candidate.name === name && candidate.status !== 'removed');
89
+ if (member === undefined) {
90
+ throw new Error(`no active member named "${name}" in team "${team.name}"`);
91
+ }
92
+ return member;
93
+ }
94
+ /** Look up one task by id. */
95
+ function requireTask(team, taskId) {
96
+ const task = team.tasks.find((candidate) => candidate.id === taskId);
97
+ if (task === undefined) {
98
+ throw new Error(`no task "${taskId}" in team "${team.name}" — use agent_teams_status to list tasks`);
99
+ }
100
+ return task;
101
+ }
102
+ /**
103
+ * Deliver a durable member report at the captain's nearest model boundary.
104
+ *
105
+ * `Agent.steer()` targets the next step while the captain is running, wakes a
106
+ * new turn when it is idle, and lets the Agent runtime reclassify an aborted
107
+ * activity to `next-turn`. This prevents reports from waiting behind the
108
+ * captain's entire orchestration turn.
109
+ */
110
+ export function steerCaptainReport(captain, from, content) {
111
+ try {
112
+ captain.steer(createUserMessage({
113
+ content: [{ type: 'text', text: `AgentTeams message from member ${from}:\n\n${content}` }],
114
+ source: { kind: 'plugin', plugin: 'dsh-agent-teams' },
115
+ }));
116
+ return true;
117
+ }
118
+ catch {
119
+ // The plugin mailbox was persisted before this best-effort live delivery.
120
+ return false;
121
+ }
122
+ }
123
+ /**
124
+ * Register every `agent_teams_*` tool into the shared tools registry.
125
+ * @param ctx - the plugin context (injects `tools`).
126
+ * @param config - resolved tool config.
127
+ */
128
+ export function registerAgentTeamsTools(ctx, config) {
129
+ ctx.tools.register(defineTool({
130
+ name: 'agent_teams_create',
131
+ description: 'Create a new AgentTeams team: you (the calling agent) become the captain. A captain leads one team at a time; create tasks and members afterwards with agent_teams_add_member and agent_teams_create_task.',
132
+ parameters: {
133
+ name: { type: 'string', required: true, description: 'Name for the new team (used as its stable id).' },
134
+ description: { type: 'string', description: 'Team purpose / the goal the team will work on.' },
135
+ },
136
+ output: {
137
+ schema: {
138
+ type: 'object',
139
+ additionalProperties: false,
140
+ properties: {
141
+ team_id: { type: 'string', required: true },
142
+ team_name: { type: 'string', required: true },
143
+ state_dir: { type: 'string', required: true },
144
+ },
145
+ },
146
+ render: (args, value) => [{
147
+ type: 'text',
148
+ text: `Team "${value.team_name}" created (id ${value.team_id}) under ${value.state_dir}. You are the captain.`,
149
+ }],
150
+ },
151
+ async execute(args, exec) {
152
+ const captain = requireCaptain(exec);
153
+ const workspace = workspaceOf(captain);
154
+ const stateRoot = stateRootOf(workspace, config);
155
+ const teamName = args.name.trim();
156
+ if (teamName === '')
157
+ throw new Error('team name must not be empty');
158
+ const teamId = sanitizeKey(teamName);
159
+ return withTeamLock(captainLockKey(stateRoot, captain.id), async () => {
160
+ const current = await findTeamByParticipant(stateRoot, captain.id);
161
+ if (current !== undefined) {
162
+ const relationship = current.captainSessionId === captain.id ? 'lead' : 'belong to';
163
+ throw new Error(`you already ${relationship} team "${current.name}" — end or leave it before creating another`);
164
+ }
165
+ return withTeamLock(teamLockKey(stateRoot, teamId), async () => {
166
+ const existing = await readTeam(stateRoot, teamId);
167
+ if (existing !== undefined) {
168
+ throw new Error(`team id "${teamId}" is taken by another captain — pick a different team name`);
169
+ }
170
+ const state = {
171
+ name: teamName,
172
+ id: teamId,
173
+ description: args.description,
174
+ captainSessionId: captain.id,
175
+ createdAt: Date.now(),
176
+ members: [],
177
+ tasks: [],
178
+ taskSeq: 0,
179
+ };
180
+ await createTeamDir(stateRoot, state);
181
+ appendTeamEvent(ctx, captain.session, 'agent-teams/team-created', {
182
+ teamId: state.id,
183
+ captainSessionId: captain.id,
184
+ name: state.name,
185
+ ...state.description !== undefined ? { description: state.description } : {},
186
+ });
187
+ return { team_id: state.id, team_name: state.name, state_dir: join(stateRoot, state.id) };
188
+ });
189
+ });
190
+ },
191
+ }));
192
+ ctx.tools.register(defineTool({
193
+ name: 'agent_teams_add_member',
194
+ description: 'Add a member to your team: spawns a durable continuable subagent with a member persona. The member waits for your messages and works on assigned tasks; it can message you and teammates. One team per captain, members are capped by config.',
195
+ parameters: {
196
+ name: { type: 'string', required: true, description: 'Unique member name inside the team.' },
197
+ role: { type: 'string', description: 'Role of the member (e.g. researcher, engineer, reviewer).' },
198
+ model: { type: 'string', description: 'Optional model override for this member (defaults to the captain\'s model).' },
199
+ },
200
+ output: {
201
+ schema: {
202
+ type: 'object',
203
+ additionalProperties: false,
204
+ properties: {
205
+ member_name: { type: 'string', required: true },
206
+ member_id: { type: 'string', required: true },
207
+ status: { type: 'string', required: true },
208
+ },
209
+ },
210
+ render: (args, value) => [{
211
+ type: 'text',
212
+ text: `Member "${value.member_name}" added (subagent id ${value.member_id}, status ${value.status}).`,
213
+ }],
214
+ },
215
+ async execute(args, exec) {
216
+ const captain = requireCaptain(exec);
217
+ const workspace = workspaceOf(captain);
218
+ const stateRoot = stateRootOf(workspace, config);
219
+ const team = await requireCaptainTeam(workspace, config, captain);
220
+ return withTeamLock(teamLockKey(stateRoot, team.id), async () => {
221
+ const fresh = await requireFreshCaptainTeam(stateRoot, team.id, captain.id);
222
+ const memberName = args.name.trim();
223
+ if (memberName === '')
224
+ throw new Error('member name must not be empty');
225
+ const memberKey = sanitizeKey(memberName);
226
+ if (memberKey === CAPTAIN_KEY) {
227
+ throw new Error(`member name "${args.name}" is reserved for the captain`);
228
+ }
229
+ if (fresh.members.some((candidate) => sanitizeKey(candidate.name) === memberKey)) {
230
+ throw new Error(`member name "${args.name}" has already been used in team "${fresh.name}"`);
231
+ }
232
+ if (fresh.members.filter((candidate) => candidate.status !== 'removed').length >= config.maxMembers) {
233
+ throw new Error(`team "${fresh.name}" is at its member cap (${config.maxMembers})`);
234
+ }
235
+ const member = {
236
+ id: '',
237
+ name: memberName,
238
+ role: args.role,
239
+ model: args.model,
240
+ joinedAt: Date.now(),
241
+ status: 'idle',
242
+ };
243
+ await spawnMember(ctx, memberRuntime(config), captain, fresh, member, config.stateDir, exec.signal);
244
+ fresh.members.push(member);
245
+ await writeTeam(stateRoot, fresh);
246
+ appendTeamEvent(ctx, captainSessionOf(ctx, fresh.captainSessionId, captain.session), 'agent-teams/member-added', {
247
+ teamId: fresh.id,
248
+ memberId: member.id,
249
+ name: member.name,
250
+ ...member.role !== undefined ? { role: member.role } : {},
251
+ });
252
+ return { member_name: member.name, member_id: member.id, status: member.status };
253
+ });
254
+ },
255
+ }));
256
+ ctx.tools.register(defineTool({
257
+ name: 'agent_teams_remove_member',
258
+ description: 'Remove a member from your team: interrupts its live turn (best effort) and marks it removed. Its mailbox and past task outputs stay on disk.',
259
+ parameters: {
260
+ name: { type: 'string', required: true, description: 'Name of the member to remove.' },
261
+ },
262
+ output: {
263
+ schema: {
264
+ type: 'object',
265
+ additionalProperties: false,
266
+ properties: {
267
+ member_name: { type: 'string', required: true },
268
+ status: { type: 'string', required: true },
269
+ },
270
+ },
271
+ render: (args, value) => [{
272
+ type: 'text',
273
+ text: `Member "${value.member_name}" removed (status ${value.status}).`,
274
+ }],
275
+ },
276
+ async execute(args, exec) {
277
+ const captain = requireCaptain(exec);
278
+ const workspace = workspaceOf(captain);
279
+ const stateRoot = stateRootOf(workspace, config);
280
+ const team = await requireCaptainTeam(workspace, config, captain);
281
+ return withTeamLock(teamLockKey(stateRoot, team.id), async () => {
282
+ const fresh = await requireFreshCaptainTeam(stateRoot, team.id, captain.id);
283
+ const member = requireMember(fresh, args.name);
284
+ if (member.id !== '')
285
+ interruptMember(ctx, captain, member.id);
286
+ member.status = 'removed';
287
+ await writeTeam(stateRoot, fresh);
288
+ appendTeamEvent(ctx, captainSessionOf(ctx, fresh.captainSessionId, captain.session), 'agent-teams/member-removed', {
289
+ teamId: fresh.id,
290
+ memberId: member.id,
291
+ });
292
+ return { member_name: member.name, status: member.status };
293
+ });
294
+ },
295
+ }));
296
+ ctx.tools.register(defineTool({
297
+ name: 'agent_teams_create_task',
298
+ description: 'Create a task in your team\'s task list. Tasks can depend on other tasks (dependencies): a task is only claimable once every dependency is completed. Optionally assign it to a member, who still claims it before working.',
299
+ parameters: {
300
+ subject: { type: 'string', required: true, description: 'Brief title for the task.' },
301
+ description: { type: 'string', description: 'What needs to be done, in detail.' },
302
+ dependencies: {
303
+ type: 'array',
304
+ items: { type: 'string' },
305
+ description: 'Task ids this task depends on (must be completed before this task can be claimed).',
306
+ },
307
+ assignee: { type: 'string', description: 'Optional member name this task is intended for.' },
308
+ },
309
+ output: {
310
+ schema: {
311
+ type: 'object',
312
+ additionalProperties: false,
313
+ properties: {
314
+ task_id: { type: 'string', required: true },
315
+ subject: { type: 'string', required: true },
316
+ status: { type: 'string', required: true },
317
+ assignee: { type: 'string' },
318
+ },
319
+ },
320
+ render: (args, value) => [{
321
+ type: 'text',
322
+ text: `Task "${value.subject}" created as ${value.task_id} (status ${value.status}${value.assignee ? `, assigned to ${value.assignee}` : ''}).`,
323
+ }],
324
+ },
325
+ async execute(args, exec) {
326
+ const captain = requireCaptain(exec);
327
+ const workspace = workspaceOf(captain);
328
+ const stateRoot = stateRootOf(workspace, config);
329
+ const team = await requireCaptainTeam(workspace, config, captain);
330
+ return withTeamLock(teamLockKey(stateRoot, team.id), async () => {
331
+ const fresh = await requireFreshCaptainTeam(stateRoot, team.id, captain.id);
332
+ const dependencies = args.dependencies ?? [];
333
+ for (const dependency of dependencies) {
334
+ if (!fresh.tasks.some((task) => task.id === dependency)) {
335
+ throw new Error(`dependency "${dependency}" does not exist in team "${fresh.name}"`);
336
+ }
337
+ }
338
+ if (args.assignee !== undefined)
339
+ requireMember(fresh, args.assignee);
340
+ const task = {
341
+ id: `t${fresh.taskSeq + 1}`,
342
+ subject: args.subject,
343
+ description: args.description,
344
+ status: 'pending',
345
+ assignee: args.assignee,
346
+ dependencies,
347
+ createdAt: Date.now(),
348
+ updatedAt: Date.now(),
349
+ };
350
+ fresh.taskSeq += 1;
351
+ fresh.tasks.push(task);
352
+ await writeTeam(stateRoot, fresh);
353
+ appendTeamEvent(ctx, captainSessionOf(ctx, fresh.captainSessionId, captain.session), 'agent-teams/task-created', {
354
+ teamId: fresh.id,
355
+ taskId: task.id,
356
+ subject: task.subject,
357
+ dependencies: task.dependencies,
358
+ ...task.assignee !== undefined ? { assignee: task.assignee } : {},
359
+ });
360
+ return {
361
+ task_id: task.id,
362
+ subject: task.subject,
363
+ status: task.status,
364
+ ...task.assignee !== undefined ? { assignee: task.assignee } : {},
365
+ };
366
+ });
367
+ },
368
+ }));
369
+ ctx.tools.register(defineTool({
370
+ name: 'agent_teams_claim_task',
371
+ description: 'Claim a task for a member (or for yourself when you are the member). Blocked while any dependency is unfinished — the error lists the pending dependencies. The captain may claim on behalf of an assignee; a member may only claim tasks assigned to it (or unassigned).',
372
+ parameters: {
373
+ task_id: { type: 'string', required: true, description: 'The task id to claim.' },
374
+ assignee: { type: 'string', description: 'Member to claim for (captain only; defaults to the task\'s assignee).' },
375
+ },
376
+ output: {
377
+ schema: {
378
+ type: 'object',
379
+ additionalProperties: false,
380
+ properties: {
381
+ task_id: { type: 'string', required: true },
382
+ status: { type: 'string', required: true },
383
+ assignee: { type: 'string', required: true },
384
+ },
385
+ },
386
+ render: (args, value) => [{
387
+ type: 'text',
388
+ text: `Task ${value.task_id} claimed by ${value.assignee} (status ${value.status}).`,
389
+ }],
390
+ },
391
+ async execute(args, exec) {
392
+ const caller = requireCaptain(exec);
393
+ const workspace = workspaceOf(caller);
394
+ const stateRoot = stateRootOf(workspace, config);
395
+ const team = await requireParticipantTeam(workspace, config, caller);
396
+ return withTeamLock(teamLockKey(stateRoot, team.id), async () => {
397
+ const { team: fresh, identity } = await requireFreshParticipant(stateRoot, team.id, caller.id);
398
+ const task = requireTask(fresh, args.task_id);
399
+ let assignee = task.assignee;
400
+ if (identity.kind === 'captain') {
401
+ if (args.assignee !== undefined) {
402
+ requireMember(fresh, args.assignee);
403
+ assignee = args.assignee;
404
+ }
405
+ }
406
+ else {
407
+ if (args.assignee !== undefined) {
408
+ throw new Error('members cannot set assignee when claiming a task');
409
+ }
410
+ if (assignee !== undefined && assignee !== identity.name) {
411
+ throw new Error(`task ${task.id} is assigned to "${assignee}", not you`);
412
+ }
413
+ assignee = identity.name;
414
+ }
415
+ // Authorization must happen before the idempotent return: another
416
+ // member must not receive a false success for somebody else's task.
417
+ if (task.status === 'claimed' || task.status === 'in_progress') {
418
+ if (assignee === undefined || task.assignee !== assignee) {
419
+ throw new Error(`task ${task.id} is already claimed by "${task.assignee ?? 'nobody'}"`);
420
+ }
421
+ return { task_id: task.id, status: task.status, assignee };
422
+ }
423
+ const pending = unsatisfiedDependencies(fresh.tasks, task.dependencies);
424
+ if (pending.length > 0) {
425
+ throw new Error(`task ${task.id} is blocked by unfinished dependencies: ${pending.join(', ')} — complete them first`);
426
+ }
427
+ const transition = transitionError(task.status, 'claimed');
428
+ if (transition !== undefined)
429
+ throw new Error(transition);
430
+ if (assignee === undefined) {
431
+ throw new Error('claiming an unassigned task needs an assignee (claim on behalf of a member)');
432
+ }
433
+ task.status = 'claimed';
434
+ task.assignee = assignee;
435
+ task.updatedAt = Date.now();
436
+ await writeTeam(stateRoot, fresh);
437
+ appendTeamEvent(ctx, captainSessionOf(ctx, fresh.captainSessionId, caller.session), 'agent-teams/task-updated', {
438
+ teamId: fresh.id,
439
+ taskId: task.id,
440
+ status: task.status,
441
+ assignee: task.assignee,
442
+ });
443
+ return { task_id: task.id, status: task.status, assignee: task.assignee ?? '' };
444
+ });
445
+ },
446
+ }));
447
+ ctx.tools.register(defineTool({
448
+ name: 'agent_teams_update_task',
449
+ description: 'Update a task\'s status and/or write its output. Transitions: claimed → in_progress → completed|failed|cancelled (pending may also be cancelled). The captain may update any task; a member may only update tasks assigned to it. Set output when completing or failing a task.',
450
+ parameters: {
451
+ task_id: { type: 'string', required: true, description: 'The task id to update.' },
452
+ status: {
453
+ type: 'string',
454
+ enum: ['in_progress', 'completed', 'failed', 'cancelled'],
455
+ description: 'New status (in_progress, completed, failed, cancelled).',
456
+ },
457
+ output: { type: 'string', description: 'Result summary; set when completing or failing.' },
458
+ },
459
+ output: {
460
+ schema: {
461
+ type: 'object',
462
+ additionalProperties: false,
463
+ properties: {
464
+ task_id: { type: 'string', required: true },
465
+ status: { type: 'string', required: true },
466
+ output: { type: 'string' },
467
+ },
468
+ },
469
+ render: (args, value) => [{
470
+ type: 'text',
471
+ text: `Task ${value.task_id} → ${value.status}${value.output !== undefined ? `\nOutput: ${value.output}` : ''}`,
472
+ }],
473
+ },
474
+ async execute(args, exec) {
475
+ const caller = requireCaptain(exec);
476
+ const workspace = workspaceOf(caller);
477
+ const stateRoot = stateRootOf(workspace, config);
478
+ const team = await requireParticipantTeam(workspace, config, caller);
479
+ return withTeamLock(teamLockKey(stateRoot, team.id), async () => {
480
+ const { team: fresh, identity } = await requireFreshParticipant(stateRoot, team.id, caller.id);
481
+ const task = requireTask(fresh, args.task_id);
482
+ if (identity.kind === 'member') {
483
+ if (task.assignee !== identity.name) {
484
+ throw new Error(`task ${task.id} is assigned to "${task.assignee ?? 'nobody'}", not you`);
485
+ }
486
+ }
487
+ if (args.status !== undefined) {
488
+ const transition = transitionError(task.status, args.status);
489
+ if (transition !== undefined)
490
+ throw new Error(transition);
491
+ task.status = args.status;
492
+ }
493
+ if (args.output !== undefined)
494
+ task.output = args.output;
495
+ task.updatedAt = Date.now();
496
+ await writeTeam(stateRoot, fresh);
497
+ appendTeamEvent(ctx, captainSessionOf(ctx, fresh.captainSessionId, caller.session), 'agent-teams/task-updated', {
498
+ teamId: fresh.id,
499
+ taskId: task.id,
500
+ status: task.status,
501
+ ...task.assignee !== undefined ? { assignee: task.assignee } : {},
502
+ ...task.output !== undefined ? { output: task.output } : {},
503
+ });
504
+ return {
505
+ task_id: task.id,
506
+ status: task.status,
507
+ ...task.output !== undefined ? { output: task.output } : {},
508
+ };
509
+ });
510
+ },
511
+ }));
512
+ ctx.tools.register(defineTool({
513
+ name: 'agent_teams_send_message',
514
+ description: 'Send a message to the captain or to a teammate. Messages go straight into the recipient\'s mailbox; when the captain agent is online the plugin also schedules live delivery (member recipients get the message as their next turn; a running captain sees it at the nearest model step). No relay is involved: teammates talk to each other directly, exactly like the Claude Code AgentTeams mailbox model.',
515
+ parameters: {
516
+ to: { type: 'string', required: true, description: 'Recipient: "captain" or a member name.' },
517
+ content: { type: 'string', required: true, description: 'The message text.' },
518
+ from: { type: 'string', description: 'Sender (defaults to the caller: the captain, or the calling member).' },
519
+ },
520
+ output: {
521
+ schema: {
522
+ type: 'object',
523
+ additionalProperties: false,
524
+ properties: {
525
+ message_id: { type: 'string', required: true },
526
+ from: { type: 'string', required: true },
527
+ to: { type: 'string', required: true },
528
+ delivered: { type: 'string', required: true, description: 'live (accepted by the live captain), wake (member recipient woken), or mailbox (durable inbox only).' },
529
+ },
530
+ },
531
+ render: (args, value) => [{
532
+ type: 'text',
533
+ text: `Message ${value.message_id} ${value.from} → ${value.to} delivered via ${value.delivered}.`,
534
+ }],
535
+ },
536
+ async execute(args, exec) {
537
+ const caller = requireCaptain(exec);
538
+ const workspace = workspaceOf(caller);
539
+ const stateRoot = stateRootOf(workspace, config);
540
+ const team = await requireParticipantTeam(workspace, config, caller);
541
+ const to = args.to.trim();
542
+ const prepared = await withTeamLock(teamLockKey(stateRoot, team.id), async () => {
543
+ const { team: fresh, identity } = await requireFreshParticipant(stateRoot, team.id, caller.id);
544
+ const from = identity.name;
545
+ // `from` may only be the caller's own identity: impersonating another
546
+ // member (or the captain) would poison the mailbox and event records.
547
+ if (args.from !== undefined && args.from !== from) {
548
+ throw new Error(`agent_teams_send_message: "from" must be your own identity ("${from}"), not "${args.from}"`);
549
+ }
550
+ if (to === CAPTAIN_KEY) {
551
+ const message = createMessage(from, CAPTAIN_KEY, args.content);
552
+ await appendMailbox(stateRoot, fresh.id, CAPTAIN_KEY, message);
553
+ appendTeamEvent(ctx, captainSessionOf(ctx, fresh.captainSessionId, caller.session), 'agent-teams/message-sent', {
554
+ teamId: fresh.id,
555
+ messageId: message.id,
556
+ from,
557
+ to: CAPTAIN_KEY,
558
+ content: args.content,
559
+ ts: message.ts,
560
+ });
561
+ return { kind: 'captain', fresh, identity, message, from };
562
+ }
563
+ const recipient = requireMember(fresh, to);
564
+ const message = createMessage(from, recipient.name, args.content);
565
+ await appendMailbox(stateRoot, fresh.id, recipient.name, message);
566
+ appendTeamEvent(ctx, captainSessionOf(ctx, fresh.captainSessionId, caller.session), 'agent-teams/message-sent', {
567
+ teamId: fresh.id,
568
+ messageId: message.id,
569
+ from,
570
+ to: recipient.name,
571
+ content: args.content,
572
+ ts: message.ts,
573
+ });
574
+ return { kind: 'member', fresh, identity, message, from, recipient };
575
+ });
576
+ // Resolve the exact live captain only after releasing the state lock.
577
+ // The plugin mailbox is already durable if live delivery cannot proceed.
578
+ const captain = ctx.agents.get(prepared.fresh.captainSessionId);
579
+ if (prepared.kind === 'captain') {
580
+ let delivered = 'mailbox';
581
+ if (captain !== undefined && prepared.identity.kind === 'member') {
582
+ delivered = steerCaptainReport(captain, prepared.from, args.content) ? 'live' : 'mailbox';
583
+ }
584
+ return { message_id: prepared.message.id, from: prepared.from, to: CAPTAIN_KEY, delivered };
585
+ }
586
+ let delivered = 'mailbox';
587
+ if (captain !== undefined && prepared.recipient.id !== '') {
588
+ const senderText = prepared.from === CAPTAIN_KEY
589
+ ? args.content
590
+ : `Message from team member ${prepared.from}:\n\n${args.content}`;
591
+ const text = `AgentTeams state policy: inspect ${config.stateDir}/${prepared.fresh.id}/ read-only; never edit team.json or inbox files directly. Use agent_teams_* tools for team state.\n\n${senderText}`;
592
+ const accepted = await deliverToMember(ctx, captain, prepared.recipient.id, text, exec.signal);
593
+ delivered = accepted ? 'wake' : 'mailbox';
594
+ }
595
+ return {
596
+ message_id: prepared.message.id,
597
+ from: prepared.from,
598
+ to: prepared.recipient.name,
599
+ delivered,
600
+ };
601
+ },
602
+ }));
603
+ ctx.tools.register(defineTool({
604
+ name: 'agent_teams_status',
605
+ description: 'Team snapshot: members with live activity and tasks with status/assignee/dependencies/output. Captains also see every team mailbox; members see only their own inbox. Poll this to watch progress.',
606
+ parameters: {},
607
+ output: {
608
+ schema: { type: 'object', additionalProperties: true, properties: {} },
609
+ render: (_args, value) => [{ type: 'text', text: renderStatus(value) }],
610
+ },
611
+ async execute(_args, exec) {
612
+ const caller = requireCaptain(exec);
613
+ const workspace = workspaceOf(caller);
614
+ const stateRoot = stateRootOf(workspace, config);
615
+ const located = await requireParticipantTeam(workspace, config, caller);
616
+ const { team, identity } = await withTeamLock(teamLockKey(stateRoot, located.id), () => requireFreshParticipant(stateRoot, located.id, caller.id));
617
+ const activity = await memberActivity(ctx, team.captainSessionId);
618
+ const members = team.members
619
+ .filter((member) => member.status !== 'removed')
620
+ .map((member) => ({
621
+ name: member.name,
622
+ role: member.role ?? '',
623
+ model: member.model ?? '',
624
+ status: member.status,
625
+ activity: member.id !== '' ? (activity.get(member.id) ?? 'unknown') : 'unspawned',
626
+ }));
627
+ const tasks = team.tasks.map((task) => ({
628
+ id: task.id,
629
+ subject: task.subject,
630
+ status: task.status,
631
+ assignee: task.assignee ?? '',
632
+ dependencies: task.dependencies,
633
+ ...task.output !== undefined ? { output: task.output } : {},
634
+ }));
635
+ const mailboxWarnings = [];
636
+ let mailboxWarningCount = 0;
637
+ const reportMalformed = (agentKey) => (lineNumber) => {
638
+ mailboxWarningCount += 1;
639
+ if (mailboxWarnings.length < 10) {
640
+ mailboxWarnings.push(`${agentKey} mailbox line ${lineNumber}`);
641
+ }
642
+ };
643
+ const captainInbox = identity.kind === 'captain'
644
+ ? await readMailbox(stateRoot, team.id, CAPTAIN_KEY, reportMalformed(CAPTAIN_KEY))
645
+ : [];
646
+ const memberInboxes = {};
647
+ const visibleMembers = identity.kind === 'captain'
648
+ ? members
649
+ : members.filter((member) => member.name === identity.name);
650
+ for (const member of visibleMembers) {
651
+ const messages = await readMailbox(stateRoot, team.id, member.name, reportMalformed(member.name));
652
+ if (messages.length > 0) {
653
+ memberInboxes[member.name] = {
654
+ count: messages.length,
655
+ latest: messages[messages.length - 1]?.content.slice(0, 200) ?? '',
656
+ };
657
+ }
658
+ }
659
+ return {
660
+ team_id: team.id,
661
+ team_name: team.name,
662
+ description: team.description ?? '',
663
+ viewer: identity.name,
664
+ members,
665
+ tasks,
666
+ captain_inbox: captainInbox.slice(-10).map((message) => ({
667
+ from: message.from,
668
+ content: message.content,
669
+ ts: message.ts,
670
+ })),
671
+ member_inboxes: memberInboxes,
672
+ mailbox_warnings: mailboxWarnings,
673
+ mailbox_warning_count: mailboxWarningCount,
674
+ };
675
+ },
676
+ }));
677
+ ctx.tools.register(defineTool({
678
+ name: 'agent_teams_delete',
679
+ description: 'End your team: interrupts all members (best effort) and deletes the team\'s state directory (team file, tasks, mailboxes). Use when the team\'s work is done or abandoned.',
680
+ parameters: {},
681
+ output: {
682
+ schema: {
683
+ type: 'object',
684
+ additionalProperties: false,
685
+ properties: {
686
+ deleted: { type: 'boolean', required: true },
687
+ team_name: { type: 'string', required: true },
688
+ },
689
+ },
690
+ render: (args, value) => [{
691
+ type: 'text',
692
+ text: `Team "${value.team_name}" deleted.`,
693
+ }],
694
+ },
695
+ async execute(_args, exec) {
696
+ const captain = requireCaptain(exec);
697
+ const workspace = workspaceOf(captain);
698
+ const stateRoot = stateRootOf(workspace, config);
699
+ const team = await requireCaptainTeam(workspace, config, captain);
700
+ await withTeamLock(teamLockKey(stateRoot, team.id), async () => {
701
+ const fresh = await requireFreshCaptainTeam(stateRoot, team.id, captain.id);
702
+ for (const member of fresh.members) {
703
+ if (member.status !== 'removed' && member.id !== '')
704
+ interruptMember(ctx, captain, member.id);
705
+ }
706
+ appendTeamEvent(ctx, captainSessionOf(ctx, fresh.captainSessionId, captain.session), 'agent-teams/team-deleted', {
707
+ teamId: fresh.id,
708
+ });
709
+ // Archive, not delete: tasks (with their dependency graph) and the
710
+ // mailboxes stay on disk for later review and dependency rebuilds.
711
+ await archiveTeamDir(stateRoot, fresh.id);
712
+ });
713
+ return { deleted: true, team_name: team.name };
714
+ },
715
+ }));
716
+ }
717
+ /** Build the `memberRuntime` config handed to member helpers. */
718
+ function memberRuntime(config) {
719
+ return {
720
+ provider: config.memberProvider,
721
+ model: config.memberModel,
722
+ maxDepth: config.memberMaxDepth,
723
+ };
724
+ }
725
+ /** Render the status snapshot as compact text for the model. */
726
+ function renderStatus(value) {
727
+ const team = value;
728
+ const lines = [
729
+ `Team "${team.team_name}"${team.description ? ` — ${team.description}` : ''}`,
730
+ `Viewing as: ${team.viewer}`,
731
+ `Members (${team.members.length}):`,
732
+ ...team.members.map((member) => ` - ${member.name} [${member.role}] ${member.status}/${member.activity}`),
733
+ `Tasks (${team.tasks.length}):`,
734
+ ...team.tasks.map((task) => {
735
+ const deps = task.dependencies.length > 0 ? ` (deps: ${task.dependencies.join(',')})` : '';
736
+ const output = task.output !== undefined ? `\n output: ${task.output.slice(0, 300)}` : '';
737
+ return ` - ${task.id} [${task.status}] ${task.subject} → ${task.assignee || 'unassigned'}${deps}${output}`;
738
+ }),
739
+ `Captain inbox (${team.captain_inbox.length}):`,
740
+ ...team.captain_inbox.map((message) => ` - [${message.from}] ${message.content.slice(0, 200)}`),
741
+ ];
742
+ for (const [name, inbox] of Object.entries(team.member_inboxes)) {
743
+ lines.push(`Member inbox ${name} (${inbox.count}): latest — ${inbox.latest.slice(0, 120)}`);
744
+ }
745
+ if (team.mailbox_warning_count > 0) {
746
+ lines.push(`Mailbox warnings (${team.mailbox_warning_count}; malformed lines were skipped; showing up to 10):`, ...team.mailbox_warnings.map((warning) => ` - ${warning}`));
747
+ }
748
+ return lines.join('\n');
749
+ }