Teams in the CLI (harness spec 3.2.0, team.md)
ci / check (push) Waiting to run

The four team tools (tool/team_tools.ts) on the rules of team/team.ts,
driven from real turns by team/runtime.ts:

- Members run on the subagent job machinery (app.ts spawn with `member`):
  background, unattended, their own prompt from blocks/team_member.md
  (the whole of team_lead.md for one with lead: true), team_send and
  team_read (team_add and team_stop for one that leads). Not jobs to the
  model: job_list and the job_* tools skip them; a client over the link
  still sees them as subagent jobs named "<name>: <role>".
- The room: messages and reports with their marks, delivered at the next
  step boundary or waking a finished member or the idle lead; report
  posts to the manager.
- A manager's work calls answer the refusal before the permission check,
  by risk class (an MCP tool by its readOnlyHint); the tool list is
  unchanged. job_stop and reading bash stay.
- Slots: the lead waits for one before its turn (Esc stops the wait);
  members wait in order; task helpers in a team count toward the size
  and hold a slot; job_send restarting a helper is refused when full.
- The offer is decided with the prompt and kept with it: a resumed
  session takes it from its kept tool list.
- The size: agents.min/max settings, refused outside 1 <= min <= max <=
  cap, kept across /new and resume; said in a note on the first message,
  after a change, after a compaction and a rewind.
- TUI: /team (tree and room, size, stop a member, dialogs for both), an
  "agents" row beside the effort in /effort, "team n/max" in the status
  bar. /team works over ACP too.

tests/team.test.ts runs a lead, a member and its report through the app.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
HomerandClaude Opus 5.5 committed 2026-11-09 09:08:52 +00:00
1 parent fbefe7f844
commit 6ec95fbd66
21 files changed
+834 -45

No files matched your search

+197
View File
@@ -0,0 +1,197 @@
// A lead session's team at work (harness spec 3.2.0, team.md): the rules of team.ts driven by real
// turns. The lead is the session's own engine; each member is a subagent engine of its own (the job
// machinery, app.ts spawn), started when the team gives it a slot. What the rules decide happens
// through three effects: a turn starts (a member's brief, a woken agent's messages), a message reaches
// a working agent at its next step, an agent is stopped.
import type { Bus } from "../bus/index.ts"
import { TOOL_SPECS } from "../harness.ts"
import type { Engine } from "../session/engine.ts"
import type { Tool } from "../tool/tool.ts"
import { parallelFrom, Team, TEAM, type TeamAgent } from "./team.ts"
/** A member's engine, as the app made it. */
export interface MemberHandle {
engine: Engine
/** Its next turn, with this text as the prompt (its context intact). */
run(text: string): void
/** Stop it where it is; nothing more comes from it. */
cancel(): void
}
export interface RuntimeOptions {
team: Team
/** The lead's bus: where the team's news is said (`team` events). */
bus: Bus
/** Make a member's engine (attached here with `attach`) and start its first turn with `text`. */
spawnMember(member: TeamAgent, text: string): void
/** The lead: a message at its next step (`note`), or — idle — a turn of its own (`wake`). */
lead: { note(text: string): void; wake(text: string): void }
/** An unknown agent or model, answered as task answers it; undefined when both are known. The
* model's ref as the session knows it. */
resolve?(agent: string, model: string | undefined): { error: string } | { model: string }
}
export class TeamRuntime {
team: Team
private members = new Map<string, MemberHandle>()
/** The lead's turn waiting for a slot: resolved when it is given one. */
private leadWaiter: (() => void) | undefined
constructor(private o: RuntimeOptions) {
this.team = o.team
this.wire()
}
private wire() {
this.team.effects = {
start: (name, text) => this.start(name, text),
deliver: (name, text) => this.deliver(name, text),
stop: (name) => this.stopped(name),
}
}
/** Another session: its members are stopped, and the team starts again, empty. */
reset(team: Team) {
this.stopAll()
this.leadWaiter = undefined
this.team = team
this.wire()
this.changed()
}
private changed() {
this.o.bus.emit({ type: "team" })
}
/** Something the tools did changed the team: said, for whoever shows it. */
notify() {
this.changed()
}
/** team_add for `as`: an unknown agent or model is answered as task answers it, after the
* team's own refusals (the name, the goal, the size), which come first. */
add(as: string, a: { name: string; role: string; task: string; goal?: string; lead?: boolean; agent?: string; model?: string }): { text: string; added?: TeamAgent } {
const agent = a.agent ?? "general"
const r = this.o.resolve?.(agent, a.model) ?? { model: a.model ?? this.team.get(as)?.model ?? this.team.model }
// The team's refusals first; an added member needs a known agent and model.
const dry = this.team.wouldRefuse(as, a)
if (dry) return { text: dry }
if ("error" in r) return { text: r.error }
const out = this.team.add(as, { ...a, agent, model: r.model })
this.changed()
return out
}
private start(name: string, text: string) {
if (name === TEAM.names.lead) {
const waiter = this.leadWaiter
this.leadWaiter = undefined
if (waiter) {
// The person's turn was waiting: it goes now, and what woke it meanwhile goes in at its first step.
if (text) this.o.lead.note(text)
waiter()
} else if (text) this.o.lead.wake(text)
this.changed()
return
}
const known = this.members.get(name)
if (known) known.run(text)
else {
const m = this.team.get(name)
if (m) this.o.spawnMember(m, text)
}
this.changed()
}
private deliver(name: string, text: string) {
if (name === TEAM.names.lead) this.o.lead.note(text)
else this.members.get(name)?.engine.note(text)
}
private stopped(name: string) {
const h = this.members.get(name)
this.members.delete(name)
h?.cancel()
this.changed()
}
/** The lead's turn is about to run: it holds a slot, or waits for one (the lead first). False
* when it was stopped while it waited. */
async beforeLeadTurn(signal: AbortSignal): Promise<boolean> {
const lead = this.team.get(TEAM.names.lead)!
if (lead.state === "working") return true
if (this.team.leadWants()) {
this.changed()
return true
}
this.o.bus.emit({ type: "notice", message: `waiting for a free slot: the model serves ${parallelFrom(this.team.capacityOf(this.team.model))} requests at once and the team's members hold them` })
this.changed()
return await new Promise<boolean>((resolve) => {
const onAbort = () => {
this.leadWaiter = undefined
this.team.leadIdle()
this.changed()
resolve(false)
}
signal.addEventListener("abort", onAbort, { once: true })
this.leadWaiter = () => {
signal.removeEventListener("abort", onAbort)
resolve(true)
}
})
}
/** The lead's turn ended. */
leadDone() {
this.team.finish(TEAM.names.lead)
this.changed()
}
/** A member's turn ended: its reply is its report; messages it was given too late wake it again. */
memberDone(name: string, reply: string, reason: string, leftover: string[]) {
if (!this.team.get(name)) return
this.team.finish(name, reply, reason)
if (leftover.length) this.team.undelivered(name, leftover)
this.changed()
}
/** What a call answers before its permission check, for the agent `as`. */
refuse(as: string, tool: Tool, args: unknown): string | undefined {
if (tool.name === "task" && as !== TEAM.names.lead) return undefined
// A tool the spec does not have (an MCP server's) by the class its call carries: read when it
// says readOnlyHint, interact for the server's memory, else execute.
let risk: string | undefined
if (!TOOL_SPECS[tool.name]) {
try {
risk = tool.permission(args as never, {} as never).class
} catch {
risk = "execute"
}
}
return this.team.refusal(as, tool.name, args, risk)
}
/** The person stopped a member in the UI (/team stop). */
personStop(name: string): string[] {
const gone = this.team.personStop(name)
this.changed()
return gone
}
/** Stop every member: the lead's session ends or is left. */
stopAll() {
for (const m of this.team.membersOf(TEAM.names.lead)) this.team.personStop(m.name)
this.team.forgetStopped()
for (const h of this.members.values()) h.cancel()
this.members.clear()
}
/** A member's engine, made. */
attach(name: string, h: MemberHandle) {
this.members.set(name, h)
}
handle(name: string): MemberHandle | undefined {
return this.members.get(name)
}
}
+28 -12
View File
@@ -156,18 +156,22 @@ export class Team {
return undefined
}
capacityOf(model: string): Capacity {
return this.o.capacity(model)
}
/** How many requests the lead's model serves at once now. */
parallelNow(): number {
return parallelFrom(this.o.capacity(this.model))
}
/** §2: offered when subagents are on and the chat's model serves two at once — fixed as the
* prompt is made (and made again on a model switch). */
fix(model = this.model): boolean {
* prompt is made (and made again on a model switch); `offered` is a kept prompt's decision. */
fix(model = this.model, offered?: boolean): boolean {
this.model = model
const lead = this.agents.get(LEAD)
if (lead) lead.model = model
this.offered = this.subagents && this.parallelNow() >= 2
this.offered = offered ?? (this.subagents && this.parallelNow() >= 2)
return this.offered
}
@@ -338,17 +342,24 @@ export class Team {
// ── the tools ───────────────────────────────────────────────────────────────
/** team_add's refusals, in the spec's order (§4), or undefined when it would add. */
wouldRefuse(by: string, a: { name: string; goal?: string }): string | undefined {
const no = this.offered ? this.unavailable() : TEAM.unavailable.one_agent
if (no) return no
if (a.name === "all") return TEXTS.all_name
if (!NAME.test(a.name)) return fill(TEXTS.bad_name, { name: a.name })
if (this.agents.has(a.name)) return fill(TEXTS.taken, { name: a.name })
if (by === LEAD && !this.agents.get(LEAD)!.goal && !a.goal?.trim()) return TEXTS.no_goal
if (this.size() + 1 > this.max) return fill(TEXTS.full, { max: this.max })
return undefined
}
/** team_add (§4). `brief` is what the member's first turn is given. */
add(by: string, a: { name: string; role: string; task: string; goal?: string; lead?: boolean; agent?: string; model?: string }): { text: string; added?: TeamAgent } {
const no = this.offered ? this.unavailable() : TEAM.unavailable.one_agent
const no = this.wouldRefuse(by, a)
if (no) return { text: no }
if (a.name === "all") return { text: TEXTS.all_name }
if (!NAME.test(a.name)) return { text: fill(TEXTS.bad_name, { name: a.name }) }
if (this.agents.has(a.name)) return { text: fill(TEXTS.taken, { name: a.name }) }
const lead = this.agents.get(LEAD)!
const goal = a.goal?.trim()
if (by === LEAD && !lead.goal && !goal) return { text: TEXTS.no_goal }
if (this.size() + 1 > this.max) return { text: fill(TEXTS.full, { max: this.max }) }
const caller = this.agents.get(by) ?? lead
if (by === LEAD && goal) lead.goal = goal
const member: TeamAgent = {
@@ -511,13 +522,18 @@ export class Team {
this.fillSlots()
}
/** The lead's turn is starting without having asked (the engine's own): it holds a slot. */
leadWorking() {
/** The lead stopped waiting (the person pressed Esc): idle, out of the queue. */
leadIdle() {
const lead = this.agents.get(LEAD)!
lead.state = "working"
if (lead.state === "waiting") lead.state = "idle"
lead.waitSince = undefined
}
/** Nothing of the stopped members is said: the session they were in is gone. */
forgetStopped() {
this.stoppedByPerson = []
}
// ── what the lead is told ───────────────────────────────────────────────────
/** §10: the note's entries for the lead's next user message — the size when it was not said yet