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
+831 -42

No files matched your search

+137 -8
View File
@@ -45,6 +45,8 @@ import { LIBRARY_SERVER, mcpInstructions } from "./mcp/index.ts"
import type { McpServer } from "./config/schema.ts" import type { McpServer } from "./config/schema.ts"
import { embedderFor } from "./library/embed.ts" import { embedderFor } from "./library/embed.ts"
import { isLevel, levelsFor, readLevel, type Level } from "./provider/effort.ts" import { isLevel, levelsFor, readLevel, type Level } from "./provider/effort.ts"
import { readingBashByDefault, Team, TEAM, TEAM_TOOLS, type Capacity, type TeamAgent } from "./team/team.ts"
import { TeamRuntime, type MemberHandle } from "./team/runtime.ts"
export interface AppOptions { export interface AppOptions {
cwd: string cwd: string
@@ -149,6 +151,8 @@ export interface App {
context: SessionContext context: SessionContext
/** Replace it (`{}`: none), naming the instance it came from for the prompt's heading. */ /** Replace it (`{}`: none), naming the instance it came from for the prompt's heading. */
setContext(ctx: SessionContext, from?: string): void setContext(ctx: SessionContext, from?: string): void
/** The session's team (spec 3.2.0, team.md): its rules and its members at work. */
team: TeamRuntime
/** The chat a session the client opened belongs to (its id): sent with every call to the server's /** The chat a session the client opened belongs to (its id): sent with every call to the server's
* memory, which answers in that chat's data group (harness/acp.md *Memory*); undefined for a * memory, which answers in that chat's data group (harness/acp.md *Memory*); undefined for a
* session of this device's own. The memory for the prompt is read again in it. */ * session of this device's own. The memory for the prompt is read again in it. */
@@ -360,7 +364,7 @@ export function createApp(o: AppOptions): App {
if (src.instance) named[`Instructions from ${src.instance.name}`] = src.instance.instructions if (src.instance) named[`Instructions from ${src.instance.name}`] = src.instance.instructions
return { memory: src.memory ?? "", personality: src.personality ?? "", files: named, mcp: src.mcp, source: src } return { memory: src.memory ?? "", personality: src.personality ?? "", files: named, mcp: src.mcp, source: src }
} }
const system = (m: Mode, current: ResolvedModel, ch: Changing, subagent?: { name: string; instructions: string; report: boolean; skills: boolean; recall: boolean }, at?: { cwd: string; root: string }, plan?: Plan) => { const system = (m: Mode, current: ResolvedModel, ch: Changing, subagent?: { name: string; instructions: string; report: boolean; skills: boolean; recall: boolean }, at?: { cwd: string; root: string }, plan?: Plan, member?: { name: string; manager: string; role: string; goal: string; lead: boolean }) => {
const src = ch.source as Source const src = ch.source as Source
return assembleSystem({ return assembleSystem({
modelRef: current.ref, modelRef: current.ref,
@@ -392,6 +396,9 @@ export function createApp(o: AppOptions): App {
library: libraryBlock(), library: libraryBlock(),
prototypes: prototypesBlock(), prototypes: prototypesBlock(),
context: subagent ? subagent.recall : true, context: subagent ? subagent.recall : true,
...(member ? { member } : {}),
// The lead's prompt says how to lead whenever the team tools are offered (team.md §10).
teamLead: !subagent && teamRt.team.offered,
}) })
} }
/** A mode's text for a note, as the prompt would have it. */ /** A mode's text for a note, as the prompt would have it. */
@@ -417,6 +424,7 @@ export function createApp(o: AppOptions): App {
const inWords = (sec: number) => (sec % 60 === 0 ? `${sec / 60} minute${sec === 60 ? "" : "s"}` : `${sec} seconds`) const inWords = (sec: number) => (sec % 60 === 0 ? `${sec / 60} minute${sec === 60 ? "" : "s"}` : `${sec} seconds`)
const vars: Record<string, Record<string, string>> = { const vars: Record<string, Record<string, string>> = {
task: { agents: agentList.map((a) => `${a.name} — ${a.description}`).join("; ") }, task: { agents: agentList.map((a) => `${a.name} — ${a.description}`).join("; ") },
team_add: { agents: agentList.map((a) => `${a.name} — ${a.description}`).join("; ") },
bash: { bash: {
timeout: inWords(loaded.config.limits?.bash_timeout ?? 120), timeout: inWords(loaded.config.limits?.bash_timeout ?? 120),
timeout_max: inWords(600), timeout_max: inWords(600),
@@ -464,11 +472,15 @@ export function createApp(o: AppOptions): App {
// The model asked for (task's `model`), else the agent's own, else the session's — and whether // The model asked for (task's `model`), else the agent's own, else the session's — and whether
// its server can take it beside the session's and beside every subagent already running // its server can take it beside the session's and beside every subagent already running
// (src/session/capacity.ts), before anything is made. // (src/session/capacity.ts), before anything is made.
const member = opts.member
const m = opts.model ? resolveModel(loaded, opts.model) : spec.model ? resolveModel(loaded, spec.model) : engine.model const m = opts.model ? resolveModel(loaded, opts.model) : spec.model ? resolveModel(loaded, spec.model) : engine.model
const running = registry.running(jobsSessionId()).filter((j) => j.kind === "subagent" && j.subagent) // A team's member is not refused for capacity: the team gave it its slot (team.md §8).
if (!member) {
const running = registry.running(jobsSessionId()).filter((j) => j.kind === "subagent" && j.subagent && !j.team)
const onIt = running.filter((j) => j.subagent!.engine.model.ref === m.ref).length const onIt = running.filter((j) => j.subagent!.engine.model.ref === m.ref).length
const refused = capacityRefusal(engine.model, m, onIt) || running.map((j) => capacityBeside(j.subagent!.engine.model, m, onIt)).find(Boolean) const refused = capacityRefusal(engine.model, m, onIt) || running.map((j) => capacityBeside(j.subagent!.engine.model, m, onIt)).find(Boolean)
if (refused) throw new ToolError(`${refused} Do this part yourself, or hand it to a model on another connection.`) if (refused) throw new ToolError(`${refused} Do this part yourself, or hand it to a model on another connection.`)
}
// A checkout and branch of its own, when asked for (the task's worktree, the agent's isolation). // A checkout and branch of its own, when asked for (the task's worktree, the agent's isolation).
let wt: Worktree | undefined let wt: Worktree | undefined
if (opts.worktree || spec.isolation === "worktree") { if (opts.worktree || spec.isolation === "worktree") {
@@ -496,8 +508,14 @@ export function createApp(o: AppOptions): App {
} }
: perm : perm
// `report` is every subagent's: its way of speaking to the main agent before its final reply. // `report` is every subagent's: its way of speaking to the main agent before its final reply.
const childTools = [...tools.filter((t) => !NOT_FOR_SUBAGENTS.has(t.name) && (!spec.tools || spec.tools.includes(t.name))), ...BUILTIN_TOOLS.filter((t) => SUBAGENT_ONLY.has(t.name))] // A team member also has team_send and team_read, and one that may lead team_add and team_stop
const description = opts.description ?? prompt.split("\n")[0]!.slice(0, 60) // (team.md §4); a task helper has none of them.
const memberTools = member ? (member.leads ? [...TEAM_TOOLS] : ["team_read", "team_send"]) : []
const childTools = [
...tools.filter((t) => !NOT_FOR_SUBAGENTS.has(t.name) && (!(TEAM_TOOLS as readonly string[]).includes(t.name) || memberTools.includes(t.name)) && (!spec.tools || spec.tools.includes(t.name) || memberTools.includes(t.name))),
...BUILTIN_TOOLS.filter((t) => SUBAGENT_ONLY.has(t.name)),
]
const description = member ? `${member.name}: ${member.role}` : (opts.description ?? prompt.split("\n")[0]!.slice(0, 60))
const ownerSession = jobsSessionId() const ownerSession = jobsSessionId()
const childRow = store?.createSession(at?.root ?? project.root, m.ref, engine.sessionId) const childRow = store?.createSession(at?.root ?? project.root, m.ref, engine.sessionId)
if (childRow) store!.setTitle(childRow.id, `${spec.name}: ${description}`) if (childRow) store!.setTitle(childRow.id, `${spec.name}: ${description}`)
@@ -532,7 +550,17 @@ export function createApp(o: AppOptions): App {
...baseToolCtx(), ...baseToolCtx(),
...(at ? { root: at.root, cwd: at.cwd, projectDir: undefined } : {}), ...(at ? { root: at.root, cwd: at.cwd, projectDir: undefined } : {}),
jobs: { registry, owner: () => ({ sessionId: childSessionId, job }) }, jobs: { registry, owner: () => ({ sessionId: childSessionId, job }) },
...(member ? { team: { runtime: teamRt, as: member.name } } : {}),
}, },
...(member
? {
team: {
fix: () => teamRt.team.offered,
offered: () => teamRt.team.offered,
refuse: (tool: Tool, args: unknown) => teamRt.refuse(member.name, tool, args),
},
}
: {}),
steerMark: MARKS.to_subagent, steerMark: MARKS.to_subagent,
changing: () => changing(true, at), changing: () => changing(true, at),
modeText: modeNote, modeText: modeNote,
@@ -552,6 +580,7 @@ export function createApp(o: AppOptions): App {
}, },
at, at,
plan, plan,
member ? { name: member.name, manager: member.manager ?? TEAM.names.lead, role: member.role, goal: member.goal, lead: member.leads } : undefined,
), ),
}) })
// Its own background commands wake it at its next step while it runs; once it has ended they // Its own background commands wake it at its next step while it runs; once it has ended they
@@ -567,7 +596,14 @@ export function createApp(o: AppOptions): App {
const end = registry.finishSubagent(job, { reason, report: text, ...(error ? { error } : {}), ...(worktree ? { worktree } : {}) }) const end = registry.finishSubagent(job, { reason, report: text, ...(error ? { error } : {}), ...(worktree ? { worktree } : {}) })
return { text: worktree ? `${text}\n\n[worktree] ${worktree}` : text, steps: job.steps ?? 0, tools: job.tools ?? 0, reason, ...(error ? { error } : {}), ...(end.stopped ? { stopped: end.stopped } : {}) } return { text: worktree ? `${text}\n\n[worktree] ${worktree}` : text, steps: job.steps ?? 0, tools: job.tools ?? 0, reason, ...(error ? { error } : {}), ...(end.stopped ? { stopped: end.stopped } : {}) }
} }
const run = (text: string) => child.prompt(text).then(finish, (e) => (job.lastError = (e as Error).message, finish("error"))) // A task helper started inside a team counts toward its size and holds a slot while it runs
// (team.md §3, §8).
const counted = (p: Promise<SubagentResult>) => {
if (member || !teamRt.team.offered) return p
const id = teamRt.team.helperStart(TEAM.names.lead, m.ref)
return p.finally(() => teamRt.team.helperDone(id))
}
const run = (text: string, machine = false) => counted(child.prompt(text, [], text, machine ? { machine: true } : {}).then(finish, (e) => (job.lastError = (e as Error).message, finish("error"))))
const handle: SubagentHandle = { const handle: SubagentHandle = {
bus: childBus, bus: childBus,
engine: child, engine: child,
@@ -575,7 +611,23 @@ export function createApp(o: AppOptions): App {
restart: (text) => run(text).then(() => undefined), restart: (text) => run(text).then(() => undefined),
cancel: () => child.cancel(), cancel: () => child.cancel(),
} }
job = registry.startSubagent({ sessionId: ownerSession, ownerCallId: opts.callId, ownerJobId: baseOwner().job?.id, agent: spec.name, model: m.ref, description, handle, childSessionId, silent: !opts.background }) job = registry.startSubagent({ sessionId: ownerSession, ownerCallId: opts.callId, ownerJobId: baseOwner().job?.id, agent: spec.name, model: m.ref, description, handle, childSessionId, silent: !opts.background || member !== undefined })
if (member) {
// A member's turn that ends is its report (team.md §5): the team, not a job note, carries it.
job.team = member.name
const memberRun = (text: string, machine: boolean) => {
if (job.state !== "running") registry.restartSubagent(job)
void run(text, machine).then((r) => {
const leftover = [...child.takeNotes(), ...child.takeInbox()]
const reason = r.reason === "error" ? `error — ${r.error ?? "unknown"}` : r.reason
if (r.reason !== "cancelled") teamRt.memberDone(member.name, r.text, reason, leftover)
})
}
const mh: MemberHandle = { engine: child, run: (text) => memberRun(text, true), cancel: () => child.cancel() }
teamRt.attach(member.name, mh)
memberRun(prompt, false)
return { id: job.id, done: Promise.resolve({ text: "", steps: 0, tools: 0, reason: "stop" }), member: mh }
}
// A synchronous subagent goes with its parent's turn (Esc stops both); a background one does not. // A synchronous subagent goes with its parent's turn (Esc stops both); a background one does not.
const onAbort = () => child.cancel() const onAbort = () => child.cancel()
opts.signal?.addEventListener("abort", onAbort, { once: true }) opts.signal?.addEventListener("abort", onAbort, { once: true })
@@ -601,16 +653,72 @@ export function createApp(o: AppOptions): App {
budget: { wall_seconds: loaded.config.limits?.wall_seconds, output_bytes: loaded.config.limits?.output_bytes, completion_tokens: loaded.config.limits?.completion_tokens }, budget: { wall_seconds: loaded.config.limits?.wall_seconds, output_bytes: loaded.config.limits?.output_bytes, completion_tokens: loaded.config.limits?.completion_tokens },
compaction, compaction,
perm, perm,
toolCtx: { ...baseToolCtx(), jobs: { registry, owner: baseOwner } }, toolCtx: { ...baseToolCtx(), jobs: { registry, owner: baseOwner }, team: { get runtime() { return teamRt }, as: TEAM.names.lead } },
system: (m, current, plan, ch) => system(m, current, ch, undefined, undefined, plan), system: (m, current, plan, ch) => system(m, current, ch, undefined, undefined, plan),
team: {
fix: (ref, offered) => teamRt.team.fix(ref, offered),
offered: () => teamRt.team.offered,
refuse: (tool, args) => teamRt.refuse(TEAM.names.lead, tool, args),
note: () => teamRt.team.noteLines(),
forget: () => teamRt.team.forgetTold(),
beforeTurn: (signal) => teamRt.beforeLeadTurn(signal),
},
changing: () => changing(), changing: () => changing(),
modeText: modeNote, modeText: modeNote,
// The configured MCP servers, up to loop.json's wait: their tools in the first list. // The configured MCP servers, up to loop.json's wait: their tools in the first list.
beforeFirst: () => Promise.race([mcpReady.catch(() => {}), Bun.sleep(LOOP.context.mcp_wait_ms)]), beforeFirst: () => Promise.race([mcpReady.catch(() => {}), Bun.sleep(LOOP.context.mcp_wait_ms)]),
persistRule: trusted ? (rule) => persistProjectRule(project, rule) : undefined, persistRule: trusted ? (rule) => persistProjectRule(project, rule) : undefined,
spawn, // subagents: false — no task tool, and no team (team.md §2).
spawn: loaded.config.subagents === false ? undefined : spawn,
}) })
// The session's team (spec 3.2.0, team.md): its size from the configuration (the cap the global
// one's), the capacity of each model as the server says it.
const capacityOf = (ref: string): Capacity => {
try {
const s = ref === engine.model.ref ? engine.model.spec : resolveModel(loaded, ref).spec
return { parallel: s.parallel, single_session: s.single_session, group: s.group }
} catch {
return {}
}
}
// The size as set while the process runs (any scope): a new or resumed session keeps it.
const teamSize: { min?: number; max?: number } = {}
const newTeam = () =>
new Team({
min: teamSize.min ?? loaded.config.agents?.min,
max: teamSize.max ?? loaded.config.agents?.max,
cap: loaded.config.agents?.cap,
subagents: loaded.config.subagents !== false,
model: engine.model.ref,
capacity: capacityOf,
leadState: "idle",
readingBash: (command) => readingBashByDefault(command, project.root),
})
const teamRt: TeamRuntime = new TeamRuntime({
team: newTeam(),
bus,
// spawn makes the member's engine and attaches it before its first await, so a message to it
// finds it at once.
spawnMember: (member: TeamAgent, text: string) => {
spawn(member.agent, text, { member, model: member.model, background: true, description: `${member.name}: ${member.role}` }).catch((e) => {
bus.emit({ type: "notice", level: "warn", message: `team: ${member.name} could not start: ${(e as Error).message}` })
teamRt.memberDone(member.name, "", `error — ${(e as Error).message}`, [])
})
},
lead: { note: (text) => engine.note(text), wake: (text) => wake(text, shownLine(text)) },
resolve: (agent, model) => {
if (!agentList.some((a) => a.name === agent)) return { error: `There is no agent "${agent}". The agents are: ${agentList.map((a) => a.name).join(", ")}.` }
try {
return { model: model ? resolveModel(loaded, model).ref : engine.model.ref }
} catch (e) {
return { error: (e as Error).message }
}
},
})
// The lead's turn ended: it is idle, and its slot frees (team.md §8).
bus.on((e) => e.type === "done" && teamRt.leadDone())
// MCP tools join the engine's list as servers connect (the engine reads it at each prompt). // MCP tools join the engine's list as servers connect (the engine reads it at each prompt).
let mcpNames = new Set<string>() let mcpNames = new Set<string>()
const syncMcp = () => { const syncMcp = () => {
@@ -722,6 +830,9 @@ export function createApp(o: AppOptions): App {
if (key === "model") return engine.model.ref if (key === "model") return engine.model.ref
if (key === "effort") return engine.effort if (key === "effort") return engine.effort
if (key === "mode") return engine.mode if (key === "mode") return engine.mode
// The team's size as the session holds it (the cap applied).
if (key === "agents.min") return teamRt.team.min
if (key === "agents.max") return teamRt.team.max
return key.split(".").reduce<unknown>((x, k) => (x && typeof x === "object" ? (x as Record<string, unknown>)[k] : undefined), loaded.config) return key.split(".").reduce<unknown>((x, k) => (x && typeof x === "object" ? (x as Record<string, unknown>)[k] : undefined), loaded.config)
}, },
check(key, v) { check(key, v) {
@@ -729,6 +840,12 @@ export function createApp(o: AppOptions): App {
if (key === "effort" && !(levelsFor(engine.model).levels as string[]).includes(String(v))) if (key === "effort" && !(levelsFor(engine.model).levels as string[]).includes(String(v)))
throw new Error(`${engine.model.ref} offers the levels ${levelsFor(engine.model).levels.join(", ")}`) throw new Error(`${engine.model.ref} offers the levels ${levelsFor(engine.model).levels.join(", ")}`)
if (key === "knowledge") for (const b of v as string[]) if (!library.base(b)) throw new Error(`there is no knowledge base "${b}" — lembas kb lists them`) if (key === "knowledge") for (const b of v as string[]) if (!library.base(b)) throw new Error(`there is no knowledge base "${b}" — lembas kb lists them`)
// The team's size (team.md §2): refused outside 1 ≤ min ≤ max ≤ cap, with the bounds.
if (key === "agents.min" || key === "agents.max") {
const t = teamRt.team
const no = Team.check(key === "agents.min" ? Number(v) : t.min, key === "agents.max" ? Number(v) : t.max, t.cap)
if (no) throw new Error(no)
}
}, },
apply(key, v) { apply(key, v) {
if (key === "model") switchModel(String(v)) if (key === "model") switchModel(String(v))
@@ -740,6 +857,14 @@ export function createApp(o: AppOptions): App {
else if (key === "limits.wall_seconds" || key === "limits.output_bytes" || key === "limits.completion_tokens") engine.o.budget = { ...engine.o.budget, [key.slice("limits.".length)]: Number(v) } else if (key === "limits.wall_seconds" || key === "limits.output_bytes" || key === "limits.completion_tokens") engine.o.budget = { ...engine.o.budget, [key.slice("limits.".length)]: Number(v) }
else if (key === "compaction.auto_at") compaction.autoAt = Number(v) else if (key === "compaction.auto_at") compaction.autoAt = Number(v)
else if (key === "knowledge") libraryCtx.bases = (v as string[]).length ? (v as string[]) : undefined else if (key === "knowledge") libraryCtx.bases = (v as string[]).length ? (v as string[]) : undefined
else if (key === "agents.min" || key === "agents.max") {
// Said to the lead as a note on its next message (team.md §2), never in the prompt.
const t = teamRt.team
t.setSetting(key === "agents.min" ? Number(v) : t.min, key === "agents.max" ? Number(v) : t.max)
teamSize.min = t.min
teamSize.max = t.max
bus.emit({ type: "team" })
}
if (key !== "model" && key !== "effort" && key !== "mode") setIn(key, v) if (key !== "model" && key !== "effort" && key !== "mode") setIn(key, v)
bus.emit({ type: "setting", key, value: v }) bus.emit({ type: "setting", key, value: v })
}, },
@@ -791,6 +916,7 @@ export function createApp(o: AppOptions): App {
mcpReady, mcpReady,
small, small,
close: () => { close: () => {
teamRt.stopAll()
detach() detach()
for (const d of childDetach.splice(0)) d() for (const d of childDetach.splice(0)) d()
return mcp.close() return mcp.close()
@@ -805,6 +931,7 @@ export function createApp(o: AppOptions): App {
jobsSessionId, jobsSessionId,
jobs: () => registry.list(jobsSessionId()), jobs: () => registry.list(jobsSessionId()),
wake, wake,
team: teamRt,
get context() { get context() {
return context return context
}, },
@@ -830,6 +957,7 @@ export function createApp(o: AppOptions): App {
const s = store?.createSession(project.root, engine.model.ref) const s = store?.createSession(project.root, engine.model.ref)
keepCwd(s?.id) keepCwd(s?.id)
standIn = `local_${crypto.randomUUID().slice(0, 8)}` standIn = `local_${crypto.randomUUID().slice(0, 8)}`
teamRt.reset(newTeam())
engine.newSession(s?.id) engine.newSession(s?.id)
turns.reset() turns.reset()
if (s) bus.emit({ type: "session", id: s.id, model: engine.model.ref, mode: engine.mode }) if (s) bus.emit({ type: "session", id: s.id, model: engine.model.ref, mode: engine.mode })
@@ -843,6 +971,7 @@ export function createApp(o: AppOptions): App {
const before = engine.sessionId const before = engine.sessionId
frozen = freeze() frozen = freeze()
void readServerMemory() void readServerMemory()
teamRt.reset(newTeam())
engine.resume(id, store.context(id)) engine.resume(id, store.context(id))
// A session from before titles gets its first prompt's line now, and the model's after the next reply. // A session from before titles gets its first prompt's line now, and the model's after the next reply.
firstShown = "" firstShown = ""
+3
View File
@@ -62,6 +62,9 @@ export type Event =
/** The note about that job was given to the model: inside a turn at its next step, or as the /** The note about that job was given to the model: inside a turn at its next step, or as the
* `machine` turn whose `prompt` follows. */ * `machine` turn whose `prompt` follows. */
| { type: "job_note"; jobId: string; text: string } | { type: "job_note"; jobId: string; text: string }
/** The session's team changed (spec 3.2.0): a member added, started, finished or stopped, a
* message in the room. Read the team itself for what. */
| { type: "team" }
/** The permission mode changed from inside a turn (plan approved). */ /** The permission mode changed from inside a turn (plan approved). */
| { type: "mode"; mode: Mode } | { type: "mode"; mode: Mode }
/** The session's model or effort changed, for whatever reason — /model, /effort, the settings /** The session's model or effort changed, for whatever reason — /model, /effort, the settings
+3
View File
@@ -88,6 +88,9 @@ export interface Job {
/** Set when the note's wake should not happen: a synchronous subagent whose result goes back /** Set when the note's wake should not happen: a synchronous subagent whose result goes back
* as the task call's output. */ * as the task call's output. */
silent?: boolean silent?: boolean
/** A team member's name (spec 3.2.0): not a job to the model — the job tools do not take it —
* but a subagent job to a client over the link (team.md §4, §11). */
team?: string
} }
/** A front end holding a session: where its jobs' events go, and how a note wakes it. The /** A front end holding a session: where its jobs' events go, and how a note wakes it. The
+22 -1
View File
@@ -236,6 +236,12 @@ export interface PromptContext {
/** Set for a subagent: who it is and what it is for; replaces the mode's own instructions. /** Set for a subagent: who it is and what it is for; replaces the mode's own instructions.
* `report`: it has the report tool. */ * `report`: it has the report tool. */
subagent?: { name: string; instructions: string; report?: boolean } subagent?: { name: string; instructions: string; report?: boolean }
/** Spec 3.2.0 (team.md §4): a team member — blocks/team_member.md in place of blocks/subagent.md,
* with the whole of blocks/team_lead.md for one that may lead. */
member?: { name: string; manager: string; role: string; goal: string; lead: boolean }
/** Spec 3.2.0 (team.md §10): the team tools are offered to this session's agent (the lead):
* blocks/team_lead.md is in its prompt. */
teamLead?: boolean
/** The memory as it was when the prompt was made ("" when both files are empty); undefined /** The memory as it was when the prompt was made ("" when both files are empty); undefined
* when memory is off. A later change by the person goes as a note. */ * when memory is off. A later change by the person goes as a note. */
memory?: string memory?: string
@@ -380,7 +386,22 @@ export function assembleSystem(ctx: PromptContext): string {
if (ctx.library && !ctx.subagent) sections.push(fillPrompt(promptText("blocks/library.md"), { knowledge_scope: ctx.library.scope })) if (ctx.library && !ctx.subagent) sections.push(fillPrompt(promptText("blocks/library.md"), { knowledge_scope: ctx.library.scope }))
if (ctx.prototypes && !ctx.subagent) sections.push(fillPrompt(promptText("blocks/prototypes.md"), { prototype_sources: ctx.prototypes.sources, prototype_surface: PROTOTYPE_SURFACE })) if (ctx.prototypes && !ctx.subagent) sections.push(fillPrompt(promptText("blocks/prototypes.md"), { prototype_sources: ctx.prototypes.sources, prototype_surface: PROTOTYPE_SURFACE }))
if (ctx.subagent) if (ctx.teamLead && !ctx.subagent) sections.push(promptText("blocks/team_lead.md"))
if (ctx.subagent && ctx.member)
sections.push(
fillPrompt(promptText("blocks/team_member.md"), {
name: ctx.member.name,
manager: ctx.member.manager,
role: ctx.member.role,
goal: ctx.member.goal,
instructions: ctx.subagent.instructions,
read_only: ctx.mode === "plan" ? SUBAGENT_READ_ONLY : "",
lead: ctx.member.lead ? promptText("blocks/team_lead.md") : "",
})
.replace(/\n{3,}/g, "\n\n")
.trim(),
)
else if (ctx.subagent)
sections.push( sections.push(
fillPrompt(promptText("blocks/subagent.md"), { fillPrompt(promptText("blocks/subagent.md"), {
name: ctx.subagent.name, name: ctx.subagent.name,
+47 -1
View File
@@ -16,6 +16,7 @@ import { customCommands, expandCommand } from "../project/commands.ts"
import { fill, promptText } from "../prompt/assemble.ts" import { fill, promptText } from "../prompt/assemble.ts"
import { skillMessage, slug, type Skill } from "../skill/index.ts" import { skillMessage, slug, type Skill } from "../skill/index.ts"
import { CONTINUE_PROMPT } from "./engine.ts" import { CONTINUE_PROMPT } from "./engine.ts"
import { TEAM, Team } from "../team/team.ts"
export type CommandKind = "custom" | "skill" | "builtin" export type CommandKind = "custom" | "skill" | "builtin"
@@ -34,7 +35,7 @@ export const TUI_COMMAND_NAMES = [
"agents", "continue", "help", "model", "effort", "mode", "plan", "plans", "prototype", "review", "changelog", "init", "release", "tasks", "decisions", "decide", "agents", "continue", "help", "model", "effort", "mode", "plan", "plans", "prototype", "review", "changelog", "init", "release", "tasks", "decisions", "decide",
"jobs", "sessions", "resume", "remote", "delete", "new", "clear", "copy", "undo", "redo", "diff", "checkpoint", "checkpoints", "branch", "commit", "context", "jobs", "sessions", "resume", "remote", "delete", "new", "clear", "copy", "undo", "redo", "diff", "checkpoint", "checkpoints", "branch", "commit", "context",
"compact", "memory", "skills", "skill", "personality", "voice", "speak", "mcp", "login", "logout", "settings", "usage", "kb", "theme", "skin", "terminal", "compact", "memory", "skills", "skill", "personality", "voice", "speak", "mcp", "login", "logout", "settings", "usage", "kb", "theme", "skin", "terminal",
"icons", "trust", "reload", "restart", "quit", "exit", "q", "icons", "trust", "reload", "restart", "quit", "exit", "q", "team",
] as const ] as const
/** The built-ins that work without a screen of their own. Descriptions as the TUI's (a test). */ /** The built-ins that work without a screen of their own. Descriptions as the TUI's (a test). */
@@ -47,8 +48,51 @@ export const SHARED_BUILTINS: { name: string; description: string; hint?: string
{ name: "init", description: "make or update the project's AGENTS.md from what the repository says", hint: "what to focus on" }, { name: "init", description: "make or update the project's AGENTS.md from what the repository says", hint: "what to focus on" },
{ name: "continue", description: "carry on — after esc stopped it, or when it should keep going (ctrl+g)" }, { name: "continue", description: "carry on — after esc stopped it, or when it should keep going (ctrl+g)" },
{ name: "agents", description: "subagents: list them, make one, or hand one a task", hint: "name" }, { name: "agents", description: "subagents: list them, make one, or hand one a task", hint: "name" },
{ name: "team", description: "the team: its agents and the room; its size; stop a member", hint: "size <min> <max> | stop <name>" },
] ]
/** The status bar's word for a team with members: its size of the most it may have, and how many
* work now; "" without members. */
export function teamLabel(team: Team): string {
if (!team.hasMembers()) return ""
const n = team.active()
return `team ${team.size()}/${team.max}${n ? ` · ${n} working` : ""}`
}
/** `/team [size <min> <max> | stop <name>]`: the team's tree and room, as the lead sees them (the
* person sees everything, team.md §5); its size for this session; a member stopped by the person,
* which the lead is told in a note. */
export function teamText(app: App, arg: string): string {
const rt = app.team
const t = rt.team
const [verb, ...rest] = arg.trim().split(/\s+/).filter(Boolean)
if (verb === "size") {
const [min, max] = rest.map(Number)
if (min === undefined || max === undefined || Number.isNaN(min) || Number.isNaN(max)) return `/team size <min> <max> — now ${t.min} to ${t.max} (at most ${t.cap})`
// Checked whole before anything is set: a refused pair changes nothing (team.md §2).
const no = Team.check(min, max, t.cap)
if (no) return no
try {
app.settings.set("agents.min", Math.min(min, t.max))
app.settings.set("agents.max", max)
app.settings.set("agents.min", min)
} catch (e) {
return (e as Error).message
}
return `The team may have ${t.min} to ${t.max} agents for this session; the lead is told with your next message.`
}
if (verb === "stop") {
const name = rest[0]
if (!name) return "/team stop <name> — a member and everything under it"
const gone = rt.personStop(name)
return gone.length ? `Stopped ${gone.join(", ")}; the lead is told with your next message.` : `No member called ${name} on the team.`
}
if (!t.offered)
return t.subagents ? `No team in this session: ${app.engine.model.ref} serves one request at a time, and a team needs two at once.` : "No team in this session: subagents are switched off (subagents: false)."
const since = Math.max(0, t.room.length - TEAM.read_max)
return `${t.read("lead", since)}\n\nSize ${t.min} to ${t.max} agents (/team size <min> <max>); /team stop <name> stops a member.`
}
/** `/agents [name]`: the subagents the task tool offers, or one of them — the text both the TUI and /** `/agents [name]`: the subagents the task tool offers, or one of them — the text both the TUI and
* a client over ACP show. Making one (`new`) and handing one a task need a screen: the TUI's. */ * a client over ACP show. Making one (`new`) and handing one a task need a screen: the TUI's. */
export function agentsText(app: App, arg: string): string { export function agentsText(app: App, arg: string): string {
@@ -175,6 +219,8 @@ export function expandSlash(app: App, text: string): Expanded | undefined {
// List and describe anywhere; `new` and `<name> <task>` are the TUI's own. // List and describe anywhere; `new` and `<name> <task>` are the TUI's own.
if (/^(new)\b/.test(arg) || arg.trim().split(/\s+/).length > 1) return { kind: "message", name, text: "/agents here lists and describes the subagents; making one or handing one a task is done at the terminal." } if (/^(new)\b/.test(arg) || arg.trim().split(/\s+/).length > 1) return { kind: "message", name, text: "/agents here lists and describes the subagents; making one or handing one a task is done at the terminal." }
return { kind: "message", name, text: agentsText(app, arg) } return { kind: "message", name, text: agentsText(app, arg) }
case "team":
return { kind: "message", name, text: teamText(app, arg) }
case "init": case "init":
// The prompt is ours; only what was typed after the name may attach files. // The prompt is ours; only what was typed after the name may attach files.
return { kind: "prompt", name, prompt: initPrompt(app, arg), shown: t, attachFrom: arg } return { kind: "prompt", name, prompt: initPrompt(app, arg), shown: t, attachFrom: arg }
+48 -2
View File
@@ -63,6 +63,21 @@ export interface EngineOptions {
/** What a message sent mid-turn starts with: the user's words for a session, the main agent's for /** What a message sent mid-turn starts with: the user's words for a session, the main agent's for
* a subagent (loop.json jobs.marks to_subagent). */ * a subagent (loop.json jobs.marks to_subagent). */
steerMark?: string steerMark?: string
/** The team this engine's agent is on (spec 3.2.0, team.md): whether it is offered (decided as
* the prompt is made, for `model`), what a call answers before its permission check (the team
* tools' requirement, task's size, a manager's work), the lead's note lines, and — the lead —
* a slot of the model before a turn runs. */
team?: {
/** Decide whether the team is offered for a new prompt on `model` — or, for a prompt the
* session kept (a resume), take what it decided then (`offered`). */
fix(model: string, offered?: boolean): boolean
offered(): boolean
refuse(tool: Tool, args: unknown): string | undefined
note?(): string[]
/** What the lead was told of the team is to be said again: a new prompt, or a rewind. */
forget?(): void
beforeTurn?(signal: AbortSignal): Promise<boolean>
}
} }
/** What a prompt carries besides its text. */ /** What a prompt carries besides its text. */
@@ -168,6 +183,8 @@ export class Engine {
private keptBase: Told | undefined private keptBase: Told | undefined
/** The last request's usage, cached input tokens included, and what it was for. */ /** The last request's usage, cached input tokens included, and what it was for. */
lastRequest: { usage: Usage; kind: "step" | "compaction" } | undefined lastRequest: { usage: Usage; kind: "step" | "compaction" } | undefined
/** The prompt the team's offer was last decided for. */
private teamFixed: Kept | undefined
constructor(readonly o: EngineOptions) { constructor(readonly o: EngineOptions) {
this.mode = o.mode this.mode = o.mode
@@ -201,6 +218,9 @@ export class Engine {
if (this.model.spec.vision === true) could.add("vision") if (this.model.spec.vision === true) could.add("vision")
if (this.o.toolCtx.projectDir !== undefined) could.add("project") if (this.o.toolCtx.projectDir !== undefined) could.add("project")
if (this.o.toolCtx.show !== undefined) could.add("display") if (this.o.toolCtx.show !== undefined) could.add("display")
// The team tools' requirement: offered as the prompt is made; whether it is met is the team's
// to answer at each call (team.md §2: one_agent or one_request).
if (this.o.team?.offered()) could.add("team")
const L = this.o.toolCtx.library const L = this.o.toolCtx.library
const have = L?.lib.bases().filter((b) => b.documents > 0).map((b) => b.name) ?? [] const have = L?.lib.bases().filter((b) => b.documents > 0).map((b) => b.name) ?? []
if (have.some((b) => !L!.bases || L!.bases.includes(b))) could.add("knowledge") if (have.some((b) => !L!.bases || L!.bases.includes(b))) could.add("knowledge")
@@ -223,7 +243,20 @@ export class Engine {
/** The session's prefix, made now if there is none yet (or it was made for another model). */ /** The session's prefix, made now if there is none yet (or it was made for another model). */
prefix(): Kept { prefix(): Kept {
if (this.kept && this.kept.model === this.model.ref) return this.kept if (this.kept && this.kept.model === this.model.ref) {
// A kept prompt (a resumed session) decided whether the team is offered when it was made: its
// tool list says so (team.md §2: fixed for as long as the prompt is).
if (this.teamFixed !== this.kept) {
this.teamFixed = this.kept
this.o.team?.fix(this.model.ref, !this.kept.left.includes("team_add"))
}
return this.kept
}
// Whether a team is offered is decided here, with the prompt and the tool list (team.md §2).
this.o.team?.fix(this.model.ref)
// After a compaction the size is said again, as the date is: neither is in the prompt. A model
// switch keeps the conversation, and what it said.
if (!this.kept) this.o.team?.forget?.()
const changing = this.o.changing?.() ?? NO_CHANGING const changing = this.o.changing?.() ?? NO_CHANGING
const now = this.now(changing) const now = this.now(changing)
const could = this.could() const could = this.could()
@@ -235,6 +268,7 @@ export class Engine {
if (this.kept?.told.date) told.date = this.kept.told.date if (this.kept?.told.date) told.date = this.kept.told.date
this.keptBase = { ...structuredClone(told), date: undefined } this.keptBase = { ...structuredClone(told), date: undefined }
this.kept = { system: this.o.system(this.mode, this.model, this.plan, changing), left, model: this.model.ref, told } this.kept = { system: this.o.system(this.mode, this.model, this.plan, changing), left, model: this.model.ref, told }
this.teamFixed = this.kept
this.saveKept() this.saveKept()
return this.kept return this.kept
} }
@@ -261,7 +295,7 @@ export class Engine {
* the model's from then on. */ * the model's from then on. */
private takeNote(): string { private takeNote(): string {
const kept = this.prefix() const kept = this.prefix()
const { note, told } = noteFor(kept.told, this.now(), (m) => this.o.modeText?.(m) ?? fillPrompt(promptText(`modes/${m}.md`), { plan_dir: this.o.toolCtx.planDir ?? "" })) const { note, told } = noteFor(kept.told, this.now(), (m) => this.o.modeText?.(m) ?? fillPrompt(promptText(`modes/${m}.md`), { plan_dir: this.o.toolCtx.planDir ?? "" }), this.o.team?.note?.() ?? [])
if (note) { if (note) {
kept.told = told kept.told = told
this.saveKept() this.saveKept()
@@ -282,6 +316,7 @@ export class Engine {
rewound() { rewound() {
if (!this.kept || !this.keptBase) return if (!this.kept || !this.keptBase) return
this.kept.told = structuredClone(this.keptBase) this.kept.told = structuredClone(this.keptBase)
this.o.team?.forget?.()
this.saveKept() this.saveKept()
} }
@@ -596,6 +631,11 @@ export class Engine {
// Before a session's first request: the configured MCP servers get a moment to connect, so // Before a session's first request: the configured MCP servers get a moment to connect, so
// their tools are in the first list (§10 rule 4). // their tools are in the first list (§10 rule 4).
if (!this.kept || this.kept.model !== this.model.ref) await this.o.beforeFirst?.() if (!this.kept || this.kept.model !== this.model.ref) await this.o.beforeFirst?.()
// A team's lead holds a slot of its model while its turn runs (team.md §8): it may wait for one.
if (this.o.team?.beforeTurn && !(await this.o.team.beforeTurn(signal))) {
bus.emit({ type: "done", reason: "cancelled" })
return "cancelled"
}
// What changed since the prompt was made goes after the person's words, as a note (§10 rule 3). // What changed since the prompt was made goes after the person's words, as a note (§10 rule 3).
const note = this.takeNote() const note = this.takeNote()
this.push({ this.push({
@@ -878,6 +918,7 @@ export class Engine {
const resolved = resolveCall(call.name, raw) const resolved = resolveCall(call.name, raw)
tool = toolMap.get(resolved.name) ?? tool tool = toolMap.get(resolved.name) ?? tool
raw = resolved.raw raw = resolved.raw
const team = this.o.team
// Offered for the whole session; while what it needs is not met, the spec's sentence (§10 rule 4). // Offered for the whole session; while what it needs is not met, the spec's sentence (§10 rule 4).
const no = unavailable(tool.name, tool.requires ? [tool.requires] : undefined, this.met()) const no = unavailable(tool.name, tool.requires ? [tool.requires] : undefined, this.met())
if (no) return { result: { output: no, isError: true } } if (no) return { result: { output: no, isError: true } }
@@ -888,10 +929,15 @@ export class Engine {
const { args: own, purpose } = splitPurpose(tool, raw) const { args: own, purpose } = splitPurpose(tool, raw)
const parsed = tool.schema.safeParse(own) const parsed = tool.schema.safeParse(own)
if (!parsed.success) { if (!parsed.success) {
const spoken = tool.invalid?.(own, { ...this.o.toolCtx, signal: new AbortController().signal })
if (spoken) return { result: { output: spoken, isError: true } }
const why = parsed.error.issues.map((i) => `${i.path.join(".") || "(arguments)"}: ${i.message}`).join("; ") const why = parsed.error.issues.map((i) => `${i.path.join(".") || "(arguments)"}: ${i.message}`).join("; ")
return { result: { output: `Invalid arguments for ${tool.name}: ${why}`, isError: true } } return { result: { output: `Invalid arguments for ${tool.name}: ${why}`, isError: true } }
} }
let args = parsed.data let args = parsed.data
// A team's answers come before the permission check — nothing is asked (team.md §6).
const byTeam = team?.refuse(tool, args)
if (byTeam) return { result: { output: byTeam, isError: true } }
const toolCtx: ToolContext = { ...this.o.toolCtx, signal: new AbortController().signal } const toolCtx: ToolContext = { ...this.o.toolCtx, signal: new AbortController().signal }
let request = tool.permission(args, toolCtx) let request = tool.permission(args, toolCtx)
let edited: string | undefined let edited: string | undefined
+4 -2
View File
@@ -74,7 +74,7 @@ export function toldAt(now: Now): Told {
/** The note for a user message: a line for each thing that differs from what the model was told, /** The note for a user message: a line for each thing that differs from what the model was told,
* in loop.json's order, inside the note's tags — "" when nothing does. `modeText` is the mode's * in loop.json's order, inside the note's tags — "" when nothing does. `modeText` is the mode's
* text (modes/<mode>.md, filled as the prompt fills it). Returns what is told after it. */ * text (modes/<mode>.md, filled as the prompt fills it). Returns what is told after it. */
export function noteFor(told: Told, now: Now, modeText: (mode: Mode) => string): { note: string; told: Told } { export function noteFor(told: Told, now: Now, modeText: (mode: Mode) => string, extra: string[] = []): { note: string; told: Told } {
const lines: string[] = [] const lines: string[] = []
const say = (text: string, vars: Record<string, string>) => lines.push(fillPrompt(text, vars)) const say = (text: string, vars: Record<string, string>) => lines.push(fillPrompt(text, vars))
const or = (s: string) => (s.trim() ? s : NOTHING) const or = (s: string) => (s.trim() ? s : NOTHING)
@@ -88,6 +88,8 @@ export function noteFor(told: Told, now: Now, modeText: (mode: Mode) => string):
// Only a server that has connected or changed since: one that went away takes its tools with it. // Only a server that has connected or changed since: one that went away takes its tools with it.
const arrived = Object.keys(now.mcp).filter((name) => told.mcp[name] !== now.mcp[name]) const arrived = Object.keys(now.mcp).filter((name) => told.mcp[name] !== now.mcp[name])
if (arrived.length) say(NOTE.mcp, { instructions: arrived.map((name) => mcpBlock(name, now.mcp[name]!)).join("\n") }) if (arrived.length) say(NOTE.mcp, { instructions: arrived.map((name) => mcpBlock(name, now.mcp[name]!)).join("\n") })
// Spec 3.2.0: the team's size and members the person stopped (team.md §10), last.
lines.push(...extra)
return { note: lines.length ? noteBlock(lines) : "", told: { ...toldAt(now), date: now.date } } return { note: lines.length ? noteBlock(lines) : "", told: { ...toldAt(now), date: now.date } }
} }
@@ -118,7 +120,7 @@ export function today(at = new Date()): string {
/** A requirement a tool can have (the spec's `requires`, and the CLI's own: vision, project, /** A requirement a tool can have (the spec's `requires`, and the CLI's own: vision, project,
* knowledge). */ * knowledge). */
export type Requirement = "vision" | "plan" | "project" | "knowledge" | "display" | "page" | "forge" export type Requirement = "vision" | "plan" | "project" | "knowledge" | "display" | "page" | "forge" | "team"
/** Rule 4: the tool list is fixed for the session. A tool is offered when every requirement it has /** Rule 4: the tool list is fixed for the session. A tool is offered when every requirement it has
* is one the session could meet at some point (`could`), decided once as the prompt is made; a * is one the session could meet at some point (`could`), decided once as the prompt is made; a
+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 return undefined
} }
capacityOf(model: string): Capacity {
return this.o.capacity(model)
}
/** How many requests the lead's model serves at once now. */ /** How many requests the lead's model serves at once now. */
parallelNow(): number { parallelNow(): number {
return parallelFrom(this.o.capacity(this.model)) 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 /** §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). */ * prompt is made (and made again on a model switch); `offered` is a kept prompt's decision. */
fix(model = this.model): boolean { fix(model = this.model, offered?: boolean): boolean {
this.model = model this.model = model
const lead = this.agents.get(LEAD) const lead = this.agents.get(LEAD)
if (lead) lead.model = model if (lead) lead.model = model
this.offered = this.subagents && this.parallelNow() >= 2 this.offered = offered ?? (this.subagents && this.parallelNow() >= 2)
return this.offered return this.offered
} }
@@ -338,17 +342,24 @@ export class Team {
// ── the tools ─────────────────────────────────────────────────────────────── // ── 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. */ /** 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 } { 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 (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 lead = this.agents.get(LEAD)!
const goal = a.goal?.trim() 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 const caller = this.agents.get(by) ?? lead
if (by === LEAD && goal) lead.goal = goal if (by === LEAD && goal) lead.goal = goal
const member: TeamAgent = { const member: TeamAgent = {
@@ -511,13 +522,18 @@ export class Team {
this.fillSlots() this.fillSlots()
} }
/** The lead's turn is starting without having asked (the engine's own): it holds a slot. */ /** The lead stopped waiting (the person pressed Esc): idle, out of the queue. */
leadWorking() { leadIdle() {
const lead = this.agents.get(LEAD)! const lead = this.agents.get(LEAD)!
lead.state = "working" if (lead.state === "waiting") lead.state = "idle"
lead.waitSince = undefined 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 ─────────────────────────────────────────────────── // ── 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 /** §10: the note's entries for the lead's next user message — the size when it was not said yet
+12 -2
View File
@@ -17,7 +17,8 @@ function jobsOf(ctx: ToolContext) {
function find(ctx: ToolContext, id: string): Job { function find(ctx: ToolContext, id: string): Job {
const { registry, owner } = jobsOf(ctx) const { registry, owner } = jobsOf(ctx)
const job = registry.get(owner().sessionId, id.trim()) const job = registry.get(owner().sessionId, id.trim())
if (!job) throw new ToolError(`No background job ${id}. job_list shows this session's jobs.`) // A team member is not a job to the model: the team tools take it (team.md §4).
if (!job || job.team) throw new ToolError(`No background job ${id}. job_list shows this session's jobs.`)
return job return job
} }
@@ -42,7 +43,7 @@ export const jobListTool = defineTool({
permission: () => ({ permission: "job_list", class: "read", patterns: ["*"] }), permission: () => ({ permission: "job_list", class: "read", patterns: ["*"] }),
async run(_a, ctx) { async run(_a, ctx) {
const { registry, owner } = jobsOf(ctx) const { registry, owner } = jobsOf(ctx)
const jobs = registry.list(owner().sessionId) const jobs = registry.list(owner().sessionId).filter((j) => !j.team)
if (!jobs.length) return { output: "No background jobs.", title: "0 jobs" } if (!jobs.length) return { output: "No background jobs.", title: "0 jobs" }
const now = Date.now() const now = Date.now()
const lines = jobs.map((j) => { const lines = jobs.map((j) => {
@@ -101,6 +102,9 @@ export const jobSendTool = defineTool({
job.subagent.send(a.text) job.subagent.send(a.text)
return { output: `Sent to ${job.id} (${whatWords(job)}); it is given to it at its next step.`, title: `${job.id} · message sent` } return { output: `Sent to ${job.id} (${whatWords(job)}); it is given to it at its next step.`, title: `${job.id} · message sent` }
} }
// Starting a finished helper again counts toward a team's size as task does (team.md §3).
const no = ctx.team?.runtime.team.taskRefusal()
if (no) throw new ToolError(no)
registry.restartSubagent(job) registry.restartSubagent(job)
void job.subagent.restart(a.text) void job.subagent.restart(a.text)
return { output: `${job.id} (${whatWords(job)}) is running again with your message as its new prompt, its context intact. Its report comes to you when it ends.`, title: `${job.id} · started again` } return { output: `${job.id} (${whatWords(job)}) is running again with your message as its new prompt, its context intact. Its report comes to you when it ends.`, title: `${job.id} · started again` }
@@ -114,6 +118,12 @@ export const reportTool = defineTool({
schema: z.object({ text: z.string().min(1) }), schema: z.object({ text: z.string().min(1) }),
permission: () => ({ permission: "report", class: "interact", patterns: ["*"] }), permission: () => ({ permission: "report", class: "interact", patterns: ["*"] }),
async run(a, ctx) { async run(a, ctx) {
// A team member's report goes through the room, to its manager (team.md §4).
if (ctx.team && ctx.team.as !== "lead") {
const sent = ctx.team.runtime.team.message(ctx.team.as, a.text)
ctx.team.runtime.notify()
if (sent) return { output: `${sent} An answer, if one comes, arrives as a message to you; carry on meanwhile.`, title: `message to ${ctx.team.runtime.team.get(ctx.team.as)?.manager ?? "your manager"}` }
}
const { registry, owner } = jobsOf(ctx) const { registry, owner } = jobsOf(ctx)
const job = owner().job const job = owner().job
if (!job) throw new ToolError("report is for a subagent: you are the main agent, so write to the user directly.") if (!job) throw new ToolError("report is for a subagent: you are the main agent, so write to the user directly.")
+2
View File
@@ -32,6 +32,8 @@ export function resolveCall(name: string, raw: unknown): { name: string; raw: un
const plan = fromTodos(obj.todos) const plan = fromTodos(obj.todos)
args = isPlan(plan) ? { title: plan.title, phases: plan.phases.map((p) => ({ title: p.title, status: p.status, tasks: p.tasks.map((t) => ({ text: t.text, status: t.status })) })) } : { title: "Work", phases: [] } args = isPlan(plan) ? { title: plan.title, phases: plan.phases.map((p) => ({ title: p.title, status: p.status, tasks: p.tasks.map((t) => ({ text: t.text, status: t.status })) })) } : { title: "Work", phases: [] }
} }
// team_send's `to` as one name: read as a list of one (team.md §5).
if (to === "team_send" && obj && typeof obj.to === "string") args = { ...obj, to: [obj.to] }
const renamed = ARGUMENTS_RENAMED[to] const renamed = ARGUMENTS_RENAMED[to]
if (renamed && args && typeof args === "object" && !Array.isArray(args)) { if (renamed && args && typeof args === "object" && !Array.isArray(args)) {
const a = { ...(args as Record<string, unknown>) } const a = { ...(args as Record<string, unknown>) }
+6
View File
@@ -11,6 +11,7 @@ import { planSubmitTool } from "./plan_submit.ts"
import { planUpdateTool } from "./plan_update.ts" import { planUpdateTool } from "./plan_update.ts"
import { planWriteTool } from "./plan_write.ts" import { planWriteTool } from "./plan_write.ts"
import { taskTool } from "./task.ts" import { taskTool } from "./task.ts"
import { teamAddTool, teamReadTool, teamSendTool, teamStopTool } from "./team_tools.ts"
import { decisionsTool, tasksTool } from "./project_tools.ts" import { decisionsTool, tasksTool } from "./project_tools.ts"
import { webFetchTool, webSearchTool } from "./web.ts" import { webFetchTool, webSearchTool } from "./web.ts"
import { memoryTool } from "./memory.ts" import { memoryTool } from "./memory.ts"
@@ -40,6 +41,11 @@ export const BUILTIN_TOOLS: Tool[] = [
webSearchTool, webSearchTool,
webFetchTool, webFetchTool,
taskTool, taskTool,
// Spec 3.2.0: a team (team.md), offered where the model serves two requests at once.
teamAddTool,
teamSendTool,
teamReadTool,
teamStopTool,
planWriteTool, planWriteTool,
planUpdateTool, planUpdateTool,
tasksTool, tasksTool,
+94
View File
@@ -0,0 +1,94 @@
// The team tools (harness spec 3.2.0, team.md; tools/team_*.json): add a member, send through the
// room, read the team and the room, stop a member. The rules are team/team.ts; these are its calls
// for the agent the context names (`ctx.team.as`).
import { z } from "zod"
import { defineTool, ToolError, type ToolContext } from "./tool.ts"
import { described, TOOL_SPECS } from "../harness.ts"
import { TEAM } from "../team/team.ts"
import { fillPrompt } from "../prompt/fill.ts"
function teamOf(ctx: ToolContext) {
if (!ctx.team) throw new ToolError(TOOL_SPECS.team_add!.unavailable ?? TEAM.unavailable.one_agent)
return ctx.team
}
/** The spec's own answer when a name does not parse (team.md §4's order: the requirement, `all`,
* then the pattern), before the generic one for invalid arguments. */
function badName(raw: unknown, ctx: ToolContext): string | undefined {
const name = (raw as { name?: unknown } | undefined)?.name
if (typeof name !== "string") return undefined
const no = ctx.team ? (ctx.team.runtime.team.offered ? ctx.team.runtime.team.unavailable() : TEAM.unavailable.one_agent) : TEAM.unavailable.one_agent
if (no) return no
if (name === "all") return TEAM.texts.all_name
if (!new RegExp(TEAM.names.pattern).test(name)) return fillPrompt(TEAM.texts.bad_name, { name })
return undefined
}
export const teamAddTool = defineTool({
...described("team_add"),
purpose: false,
access: "team_add",
requires: "team",
schema: z.object({
name: z.string().regex(new RegExp(TEAM.names.pattern)),
role: z.string().min(1),
task: z.string().min(1),
goal: z.string().optional(),
lead: z.boolean().optional(),
agent: z.string().optional(),
model: z.string().optional(),
}),
invalid: badName,
permission: (a) => ({ permission: "team_add", class: "interact", patterns: [a.agent ?? "general"] }),
async run(a, ctx) {
const t = teamOf(ctx)
const r = t.runtime.add(t.as, a)
return { output: r.text, title: r.added ? `${a.name}: ${a.role}` : "not added", isError: !r.added }
},
})
export const teamSendTool = defineTool({
...described("team_send"),
purpose: false,
access: "team_send",
requires: "team",
schema: z.object({ to: z.array(z.string()).min(1), message: z.string().min(1) }),
permission: () => ({ permission: "team_send", class: "interact", patterns: ["*"] }),
async run(a, ctx) {
const t = teamOf(ctx)
const before = t.runtime.team.room.length
const output = t.runtime.team.send(t.as, a)
t.runtime.notify()
const sent = t.runtime.team.room.length > before
return { output, title: sent ? `to ${a.to.includes("all") ? "all" : [...new Set(a.to)].join(", ")}` : "not sent", isError: !sent }
},
})
export const teamReadTool = defineTool({
...described("team_read"),
purpose: false,
access: "team_read",
requires: "team",
schema: z.object({ since: z.number().int().min(0).optional() }),
permission: () => ({ permission: "team_read", class: "read", patterns: ["*"] }),
async run(a, ctx) {
const t = teamOf(ctx)
return { output: t.runtime.team.read(t.as, a.since ?? 0), title: `team${a.since ? ` since #${a.since}` : ""}` }
},
})
export const teamStopTool = defineTool({
...described("team_stop"),
purpose: false,
access: "team_stop",
requires: "team",
schema: z.object({ name: z.string().min(1) }),
permission: (a) => ({ permission: "team_stop", class: "interact", patterns: [a.name] }),
async run(a, ctx) {
const t = teamOf(ctx)
const size = t.runtime.team.size()
const output = t.runtime.team.stopCmd(t.as, a.name)
t.runtime.notify()
return { output, title: t.runtime.team.size() < size ? `stopped ${a.name}` : "not stopped", isError: t.runtime.team.size() >= size }
},
})
+11 -1
View File
@@ -36,6 +36,9 @@ export interface ToolContext {
* (the TUI's ctrl+b): bash hands it to the registry as it would at its timeout. Set by the * (the TUI's ctrl+b): bash hands it to the registry as it would at its timeout. Set by the
* engine for the step's calls. */ * engine for the step's calls. */
backgroundNow?: AbortSignal backgroundNow?: AbortSignal
/** The team this agent is on (spec 3.2.0, team/runtime.ts) and its name there (`lead`, or a
* member's). Absent where no team is offered. */
team?: { runtime: import("../team/runtime.ts").TeamRuntime; as: string }
/** The id of the call being run (set by the engine per call). */ /** The id of the call being run (set by the engine per call). */
callId?: string callId?: string
/** The project directory (.agent), when the project has one. */ /** The project directory (.agent), when the project has one. */
@@ -91,12 +94,16 @@ export interface SpawnOptions {
description?: string description?: string
model?: string model?: string
background?: boolean background?: boolean
/** Spec 3.2.0: a team member (team.md §4) — run as one, not as a task helper. */
member?: import("../team/team.ts").TeamAgent
} }
/** What the task tool gets back from spawn: the job's id and the report when it ends. */ /** What the task tool gets back from spawn: the job's id and the report when it ends. */
export interface SubagentHandleInfo { export interface SubagentHandleInfo {
id: string id: string
done: Promise<import("./task.ts").SubagentResult> done: Promise<import("./task.ts").SubagentResult>
/** A team member's engine, for the team's runtime to drive. */
member?: import("../team/runtime.ts").MemberHandle
} }
export interface ToolResult { export interface ToolResult {
@@ -124,7 +131,7 @@ export interface Tool<S extends z.ZodType = z.ZodType> {
* display`). The tool list is the session's (harness context.md §10): one the session could * display`). The tool list is the session's (harness context.md §10): one the session could
* never meet leaves the tool out; one that comes and goes (plan mode, a display) keeps it in, and * never meet leaves the tool out; one that comes and goes (plan mode, a display) keeps it in, and
* a call while it is unmet answers the spec's `unavailable` text. */ * a call while it is unmet answers the spec's `unavailable` text. */
requires?: "vision" | "plan" | "project" | "knowledge" | "display" requires?: "vision" | "plan" | "project" | "knowledge" | "display" | "team"
description: string description: string
schema: S schema: S
/** The JSON Schema sent to the model, when it does not come from `schema` (MCP tools). */ /** The JSON Schema sent to the model, when it does not come from `schema` (MCP tools). */
@@ -136,6 +143,9 @@ export interface Tool<S extends z.ZodType = z.ZodType> {
* `purpose` argument added to every tool (the default), a field of the tool's own (bash's * `purpose` argument added to every tool (the default), a field of the tool's own (bash's
* `description`), or false for a tool that never asks. */ * `description`), or false for a tool that never asks. */
purpose?: string | false purpose?: string | false
/** What a call answers when its arguments do not parse, where the spec has its own words for it
* (team_add's name); undefined: the generic answer. */
invalid?(raw: unknown, ctx: ToolContext): string | undefined
permission(args: z.infer<S>, ctx: ToolContext): PermissionRequest permission(args: z.infer<S>, ctx: ToolContext): PermissionRequest
/** What the call would change, as a unified diff, shown when the user is asked. Writes nothing. */ /** What the call would change, as a unified diff, shown when the user is asked. Writes nothing. */
preview?(args: z.infer<S>, ctx: ToolContext): Promise<string> preview?(args: z.infer<S>, ctx: ToolContext): Promise<string>
+5 -2
View File
@@ -5,6 +5,7 @@ import { stricterMode, type App as Runtime } from "../app.ts"
import { isLocal } from "../config/paths.ts" import { isLocal } from "../config/paths.ts"
import type { Mode } from "../config/schema.ts" import type { Mode } from "../config/schema.ts"
import { levelLabel, type Level } from "../provider/effort.ts" import { levelLabel, type Level } from "../provider/effort.ts"
import { teamLabel } from "../session/commands.ts"
import { COMMANDS, findCommand, type CommandCtx } from "./commands.ts" import { COMMANDS, findCommand, type CommandCtx } from "./commands.ts"
import { Dialog, type DialogSpec } from "./components/dialog.tsx" import { Dialog, type DialogSpec } from "./components/dialog.tsx"
import { Messages } from "./components/messages.tsx" import { Messages } from "./components/messages.tsx"
@@ -60,6 +61,8 @@ export function Root(p: { app: Runtime; view: View; theme: Theme; onQuit: () =>
const mode = () => (rev(), app.engine.mode) const mode = () => (rev(), app.engine.mode)
const model = () => (rev(), app.engine.model.ref) const model = () => (rev(), app.engine.model.ref)
const effort = () => (rev(), levelLabel(app.engine.model, app.engine.effort)) const effort = () => (rev(), levelLabel(app.engine.model, app.engine.effort))
// "team 3/4 · 2 working" while the lead has members (team.md, the TUI's status).
const teamWord = () => (rev(), teamLabel(app.team.team))
const busy = () => view.state.phase !== "idle" const busy = () => view.state.phase !== "idle"
const branch = () => (rev(), app.project.gitRoot ? currentBranch(app.project.gitRoot) : undefined) const branch = () => (rev(), app.project.gitRoot ? currentBranch(app.project.gitRoot) : undefined)
// A word in the status bar that goes by itself (a copy): it says something happened without // A word in the status bar that goes by itself (a copy): it says something happened without
@@ -74,7 +77,7 @@ export function Root(p: { app: Runtime; view: View; theme: Theme; onQuit: () =>
onCleanup(() => clearTimeout(flashTimer)) onCleanup(() => clearTimeout(flashTimer))
// A mode changed inside a turn (a plan approved) must reach the status bar; so must a branch the // A mode changed inside a turn (a plan approved) must reach the status bar; so must a branch the
// model switched with git. // model switched with git.
app.bus.on((e) => (e.type === "mode" || e.type === "done") && refresh()) app.bus.on((e) => (e.type === "mode" || e.type === "done" || e.type === "team") && refresh())
app.bus.on((e) => e.type === "flash" && flash(e.text, 6000)) app.bus.on((e) => e.type === "flash" && flash(e.text, 6000))
// A setting changed by the agent, /settings or a command: show it. Theme and icons are drawn here. // A setting changed by the agent, /settings or a command: show it. Theme and icons are drawn here.
app.bus.on((e) => { app.bus.on((e) => {
@@ -642,7 +645,7 @@ export function Root(p: { app: Runtime; view: View; theme: Theme; onQuit: () =>
</Show> </Show>
<Queued /> <Queued />
<Prompt focused={() => !dialog() && !jobPanel() && !view.state.pending && !view.state.question && !view.state.plan} busy={busy} commands={palette} files={files} onSubmit={submit} ref={(r) => (input = r)} queue={queueAccess} onWheel={(dir, lines) => scroll?.scrollBy(dir * lines)} onBackground={backgroundNow} /> <Prompt focused={() => !dialog() && !jobPanel() && !view.state.pending && !view.state.question && !view.state.plan} busy={busy} commands={palette} files={files} onSubmit={submit} ref={(r) => (input = r)} queue={queueAccess} onWheel={(dir, lines) => scroll?.scrollBy(dir * lines)} onBackground={backgroundNow} />
<StatusBar mode={mode} model={model} effort={effort} branch={branch()} voice={voiceStatus} flash={flashText} remote={() => (rev(), share.label())} spend={spend} /> <StatusBar mode={mode} model={model} effort={effort} branch={branch()} voice={voiceStatus} flash={flashText} remote={() => (rev(), share.label())} spend={spend} team={teamWord} />
</box> </box>
<Show when={dialog()}> <Show when={dialog()}>
<Dialog spec={dialog()!} close={() => setDialog(undefined)} /> <Dialog spec={dialog()!} close={() => setDialog(undefined)} />
+53 -5
View File
@@ -11,7 +11,7 @@ import { doRelease, planRelease } from "../git/release.ts"
import { instructionFiles } from "../prompt/assemble.ts" import { instructionFiles } from "../prompt/assemble.ts"
import { addDecision, readBoard, readDecisions, renderBoard } from "../project/board.ts" import { addDecision, readBoard, readDecisions, renderBoard } from "../project/board.ts"
import { contextBreakdown, renderBreakdown } from "../session/context.ts" import { contextBreakdown, renderBreakdown } from "../session/context.ts"
import { changelogPrompt, initPrompt, reviewPrompt } from "../session/commands.ts" import { changelogPrompt, initPrompt, reviewPrompt, teamText } from "../session/commands.ts"
import { git } from "../git/run.ts" import { git } from "../git/run.ts"
import { branches, deleteBranch, switchBranch } from "../git/branch.ts" import { branches, deleteBranch, switchBranch } from "../git/branch.ts"
import type { Ctx } from "./context.ts" import type { Ctx } from "./context.ts"
@@ -94,6 +94,27 @@ You are the ${name} subagent. Say what to do, how to check it, and what to repor
report is all the session's model sees of your work. report is all the session's model sees of your work.
` `
const TEAM_ROW = "\u0000team"
/** The team's size for this session: a few usual pairs, or any typed as `<min> <max>`. */
function openTeamSize(c: CommandCtx) {
const t = c.app.team.team
const pairs = [
[1, 1, "one agent: no team, no helper"],
[1, 4, "you decide; up to three members"],
[2, 4, "a team from the start"],
[1, 8, "you decide; up to seven members"],
[3, 8, "a team of at least three"],
[1, Math.min(16, t.cap), "a bigger team, with sub-managers"],
] as const
const seen = new Set<string>()
const options = pairs
.filter(([min, max]) => max <= t.cap && !seen.has(`${min} ${max}`) && seen.add(`${min} ${max}`))
.map(([min, max, what]) => ({ label: `${min} to ${max}`, value: `${min} ${max}`, description: what, current: t.min === min && t.max === max }))
const apply = (v: string) => (c.view.info(teamText(c.app, `size ${v}`)), c.refresh())
c.openDialog({ title: `Agents — now ${t.min} to ${t.max} (at most ${t.cap})`, options, onSelect: apply, custom: { label: "set to", onSubmit: apply } })
}
export const COMMANDS: Command[] = [ export const COMMANDS: Command[] = [
{ {
name: "agents", name: "agents",
@@ -194,24 +215,51 @@ export const COMMANDS: Command[] = [
c.setEffort(v as Level) c.setEffort(v as Level)
c.view.info(control === "switch" ? `thinking ${v === "off" ? "off" : `on (${v})`}` : `effort: ${v}`) c.view.info(control === "switch" ? `thinking ${v === "off" ? "off" : `on (${v})`}` : `effort: ${v}`)
} }
if (control === "none") return c.view.info(`${m.ref} has one level (${levels[0]}): there is nothing to choose`) // The team's size sits beside the effort (team.md §2: shown only where a team is offered).
const team = c.app.team.team
const agentsRow = team.offered ? [{ label: `agents: ${team.min} to ${team.max}`, value: TEAM_ROW, description: "how many agents the team may have, you included" }] : []
const choose = (v: string) => (v === TEAM_ROW ? openTeamSize(c) : set(v))
if (control === "none" && (arg || !agentsRow.length)) return c.view.info(`${m.ref} has one level (${levels[0]}): there is nothing to choose`)
if (arg) return set(arg) if (arg) return set(arg)
if (control === "none") return c.openDialog({ title: `Chat settings — ${m.ref}`, options: agentsRow, onSelect: choose })
if (control === "switch") if (control === "switch")
return c.openDialog({ return c.openDialog({
title: `Thinking — ${m.ref}`, title: `Thinking — ${m.ref}`,
options: [ options: [
{ label: "on", value: on, description: on === "default" ? "the model's own" : on, current: c.app.engine.effort !== "off" }, { label: "on", value: on, description: on === "default" ? "the model's own" : on, current: c.app.engine.effort !== "off" },
{ label: "off", value: "off", current: c.app.engine.effort === "off" }, { label: "off", value: "off", current: c.app.engine.effort === "off" },
...agentsRow,
], ],
onSelect: set, onSelect: choose,
}) })
c.openDialog({ c.openDialog({
title: `Effort — ${m.ref}`, title: `Effort — ${m.ref}`,
options: levels.map((e) => ({ label: e === "off" ? "off (no thinking)" : e, value: e, current: c.app.engine.effort === e })), options: [...levels.map((e) => ({ label: e === "off" ? "off (no thinking)" : e, value: e, current: c.app.engine.effort === e })), ...agentsRow],
onSelect: set, onSelect: choose,
}) })
}, },
}, },
{
name: "team",
args: "[size [<min> <max>] | stop [<name>]]",
description: "the team: its agents and the room; its size; stop a member",
run(c, arg) {
// The tree and the room as the lead sees them — the person sees everything (team.md §5).
const words = arg.trim().split(/\s+/).filter(Boolean)
if (words[0] === "size" && words.length === 1) return openTeamSize(c)
if (words[0] === "stop" && words.length === 1) {
const members = c.app.team.team.tree().filter((a) => a.manager !== null)
if (!members.length) return c.view.info("the team has no members")
return c.openDialog({
title: "Stop a member, and everything under it",
options: members.map((a) => ({ label: a.name, value: a.name, description: `${a.role} · ${a.state}` })),
onSelect: (name) => (c.view.info(teamText(c.app, `stop ${name}`)), c.refresh()),
})
}
c.view.info(teamText(c.app, arg))
c.refresh()
},
},
{ {
name: "mode", name: "mode",
args: `[${MODES.join("|")}]`, args: `[${MODES.join("|")}]`,
+2 -1
View File
@@ -22,7 +22,7 @@ export function jobsWord(jobs: { state: string; unread: number; unseen?: boolean
* for a moment, a flash (a copy) in their place. The jobs word sits on the right, which is never * for a moment, a flash (a copy) in their place. The jobs word sits on the right, which is never
* truncated: at 80 columns a long project name cuts the left short, and a running job is the one * truncated: at 80 columns a long project name cuts the left short, and a running job is the one
* thing that must stay visible. */ * thing that must stay visible. */
export function StatusBar(p: { mode: () => string; model: () => string; effort: () => string; branch?: string; voice?: () => string; flash?: () => string | undefined; remote?: () => string; spend?: () => string }) { export function StatusBar(p: { mode: () => string; model: () => string; effort: () => string; branch?: string; voice?: () => string; flash?: () => string | undefined; remote?: () => string; spend?: () => string; team?: () => string }) {
const { theme, view, app } = useTui() const { theme, view, app } = useTui()
const c = () => theme().colors const c = () => theme().colors
const modeColor = () => ({ manual: c().statusText, edit: c().statusGood, auto: c().statusCritical, plan: c().info })[p.mode() as keyof typeof MODE_LABEL] ?? c().statusText const modeColor = () => ({ manual: c().statusText, edit: c().statusGood, auto: c().statusCritical, plan: c().info })[p.mode() as keyof typeof MODE_LABEL] ?? c().statusText
@@ -50,6 +50,7 @@ export function StatusBar(p: { mode: () => string; model: () => string; effort:
when={p.flash?.()} when={p.flash?.()}
fallback={ fallback={
<text wrapMode="none"> <text wrapMode="none">
<span style={{ fg: c().info }}>{p.team?.() ? `${p.team()} · ` : ""}</span>
<span style={{ fg: jobsWord(view.state.jobs).endsWith("!") ? c().statusWarn : c().statusText }}>{jobsWord(view.state.jobs) ? `${jobsWord(view.state.jobs)} · ` : ""}</span> <span style={{ fg: jobsWord(view.state.jobs).endsWith("!") ? c().statusWarn : c().statusText }}>{jobsWord(view.state.jobs) ? `${jobsWord(view.state.jobs)} · ` : ""}</span>
<span style={{ fg: c().statusDim }}>{view.state.rate ? `${Math.round(view.state.rate)} tok/s · ` : ""}</span> <span style={{ fg: c().statusDim }}>{view.state.rate ? `${Math.round(view.state.rate)} tok/s · ` : ""}</span>
<span style={{ fg: c().statusText }}>{p.spend?.() ? `${p.spend()} · ` : ""}</span> <span style={{ fg: c().statusText }}>{p.spend?.() ? `${p.spend()} · ` : ""}</span>
+1 -1
View File
@@ -54,7 +54,7 @@ describe("end to end", () => {
expect(fake!.requests[0].messages[0].content).toContain("Permission mode: edit") expect(fake!.requests[0].messages[0].content).toContain("Permission mode: edit")
// knowledge_search/get are offered once any knowledge base has documents, and the suite shares // knowledge_search/get are offered once any knowledge base has documents, and the suite shares
// one LEMBAS_HOME: another file's base must not decide this list (found in the full run). // one LEMBAS_HOME: another file's base must not decide this list (found in the full run).
expect(fake!.requests[0].tools.map((t: any) => t.function.name).filter((n: string) => !n.startsWith("knowledge_"))).toEqual(["read", "write", "edit", "multiedit", "apply_patch", "glob", "grep", "list", "bash", "job_output", "job_list", "job_stop", "job_send", "web_search", "web_fetch", "task", "plan_write", "plan_update", "ask_user", "plan_submit", "memory", "session_search", "notes_search", "note_view", "note_manage", "skills_list", "skill_view", "skill_manage", "settings", "recall"]) expect(fake!.requests[0].tools.map((t: any) => t.function.name).filter((n: string) => !n.startsWith("knowledge_"))).toEqual(["read", "write", "edit", "multiedit", "apply_patch", "glob", "grep", "list", "bash", "job_output", "job_list", "job_stop", "job_send", "web_search", "web_fetch", "task", "team_add", "team_send", "team_read", "team_stop", "plan_write", "plan_update", "ask_user", "plan_submit", "memory", "session_search", "notes_search", "note_view", "note_manage", "skills_list", "skill_view", "skill_manage", "settings", "recall"])
const ends = events.filter((e) => e.type === "tool_end") const ends = events.filter((e) => e.type === "tool_end")
expect(ends.map((e) => e.type === "tool_end" && e.result.title)).toEqual(["hello.txt · 1–1 of 1", "hello.txt +1 −1"]) expect(ends.map((e) => e.type === "tool_end" && e.result.title)).toEqual(["hello.txt · 1–1 of 1", "hello.txt +1 −1"])
const sid = store.sessions(1)[0]!.id const sid = store.sessions(1)[0]!.id
+4 -2
View File
@@ -16,7 +16,9 @@ export interface Fake {
/** `served`: what GET /v1/models lists, as a LLeMbas server lists its models (default two /** `served`: what GET /v1/models lists, as a LLeMbas server lists its models (default two
* placeholders). Any other GET — the instance's own description, its personalization — is a 404, * placeholders). Any other GET — the instance's own description, its personalization — is a 404,
* which a client reads as "this server does not have it". */ * which a client reads as "this server does not have it". */
export function fakeProvider(script: Scripted[], opts: { route?: (model: string) => Fake | undefined; served?: Record<string, unknown>[] } = {}): Fake { /** `script` may be a function of the request (and its number), for requests that race: a team's
* agents ask at once, and each is answered by who is asking. */
export function fakeProvider(script: Scripted[] | ((body: any, n: number) => Scripted | undefined), opts: { route?: (model: string) => Fake | undefined; served?: Record<string, unknown>[] } = {}): Fake {
const requests: any[] = [] const requests: any[] = []
const calls: { path: string; headers: Record<string, string> }[] = [] const calls: { path: string; headers: Record<string, string> }[] = []
let i = 0 let i = 0
@@ -34,7 +36,7 @@ export function fakeProvider(script: Scripted[], opts: { route?: (model: string)
if (to) return fetch(to.url.replace(/\/v1$/, "") + url.pathname + url.search, { method: "POST", headers: req.headers, body: JSON.stringify(body) }) if (to) return fetch(to.url.replace(/\/v1$/, "") + url.pathname + url.search, { method: "POST", headers: req.headers, body: JSON.stringify(body) })
requests.push(body) requests.push(body)
calls.push({ path: url.pathname + url.search, headers: Object.fromEntries(req.headers.entries()) }) calls.push({ path: url.pathname + url.search, headers: Object.fromEntries(req.headers.entries()) })
const r = script[i++] const r = typeof script === "function" ? script(body, i++) : script[i++]
if (!r) return new Response("script exhausted", { status: 500 }) if (!r) return new Response("script exhausted", { status: 500 })
if ("status" in r) return new Response(r.body, { status: r.status }) if ("status" in r) return new Response(r.body, { status: r.status })
// gapMs: a pause between chunks, to look at the screen mid-stream. // gapMs: a pause between chunks, to look at the screen mid-stream.
+4 -2
View File
@@ -52,7 +52,8 @@ test("no clock in the prompt: two requests across a date change send the same sy
expect(system(1)).toBe(system(0)) expect(system(1)).toBe(system(0))
expect(system(2)).toBe(system(0)) expect(system(2)).toBe(system(0))
expect(fake!.requests[2].tools).toEqual(fake!.requests[0].tools) expect(fake!.requests[2].tools).toEqual(fake!.requests[0].tools)
expect(lastUser(0)).toBe(`first\n${NOTE.open}\nToday is 2026-11-04.\n${NOTE.close}`) // A model with no capacity said (a hosted API) offers a team: its size is said once, last (spec 3.2.0).
expect(lastUser(0)).toBe(`first\n${NOTE.open}\nToday is 2026-11-04.\n\nThe team may have 1 to 4 agents, you included.\n${NOTE.close}`)
expect(lastUser(1)).toBe("second") expect(lastUser(1)).toBe("second")
expect(lastUser(2)).toBe(`third\n${NOTE.open}\nToday is 2026-11-05.\n${NOTE.close}`) expect(lastUser(2)).toBe(`third\n${NOTE.open}\nToday is 2026-11-05.\n${NOTE.close}`)
// A note is part of its message from then on: the next request sends the first one as it was. // A note is part of its message from then on: the next request sends the first one as it was.
@@ -140,7 +141,8 @@ test("compaction: the same system and tools, every message, then the compaction
expect(store.requests(sid).map((r) => r.kind)).toEqual(["step", "compaction"]) expect(store.requests(sid).map((r) => r.kind)).toEqual(["step", "compaction"])
// After it the prompt is made again, and the next user message says the date again. // After it the prompt is made again, and the next user message says the date again.
await app.engine.prompt("go on") await app.engine.prompt("go on")
expect(lastUser(2)).toMatch(/^go on\n<session-note>\nToday is \d{4}-\d{2}-\d{2}\.\n<\/session-note>$/) // The team's size too (spec 3.2.0): like the date, it is not in the prompt.
expect(lastUser(2)).toMatch(/^go on\n<session-note>\nToday is \d{4}-\d{2}-\d{2}\.\n\nThe team may have 1 to 4 agents, you included\.\n<\/session-note>$/)
}) })
test("a resumed session sends the prompt it kept, byte for byte; what changed meanwhile is a note", async () => { test("a resumed session sends the prompt it kept, byte for byte; what changed meanwhile is a note", async () => {
+148
View File
@@ -0,0 +1,148 @@
// Teams (harness spec 3.2.0, team.md) through the whole app: the lead adds a member that runs on the
// subagent machinery, its tools that change things answer the manager's refusal before anything is
// asked, the member's final reply comes back as a marked report that wakes the idle lead, and the
// size setting is said in a note. The rules themselves are conformance/team.json's.
import { afterEach, describe, expect, test } from "bun:test"
import { mkdtempSync, realpathSync, writeFileSync, mkdirSync } from "node:fs"
import { tmpdir } from "node:os"
import { join } from "node:path"
import { createApp, type App } from "../src/app.ts"
import type { AskReply } from "../src/bus/index.ts"
import { paths } from "../src/config/paths.ts"
import { LOOP } from "../src/harness.ts"
import { teamLabel, teamText } from "../src/session/commands.ts"
import { delta, fakeProvider, toolCall, type Fake, type Scripted } from "./fake-provider.ts"
let fake: Fake | undefined
let app: App | undefined
afterEach(async () => {
await app?.close()
app = undefined
fake?.stop()
})
const T = LOOP.team
const yes = { ask: async (): Promise<AskReply> => ({ kind: "once" }) }
const say = (text: string): Scripted => ({ chunks: [delta({ content: text }, "stop")] })
const call = (id: string, name: string, args: unknown): Scripted => ({ chunks: [toolCall(0, id, name, JSON.stringify(args)), delta({}, "tool_calls")] })
const system = (body: any): string => body.messages[0].content
const isMember = (body: any, name: string) => system(body).includes(`You are "${name}", a member of a team`)
const lastUser = (body: any): string => {
const u = [...body.messages].reverse().find((m: any) => m.role === "user")
return typeof u.content === "string" ? u.content : u.content.map((p: any) => p.text ?? "").join("")
}
// A result as the model reads it, less its id line (context.md §1).
const toolResults = (body: any): string[] => body.messages.filter((m: any) => m.role === "tool").map((m: any) => (typeof m.content === "string" ? m.content : JSON.stringify(m.content)).replace(/^\[#\d+\]\n/, ""))
function setup(answer: (body: any, n: number) => Scripted | undefined, config = "", spec = "{ context: 32768, parallel: 2 }") {
fake = fakeProvider(answer)
mkdirSync(paths.config, { recursive: true })
writeFileSync(join(paths.config, "connections.yaml"), `connections:\n fake:\n type: webui\n url: ${fake.url}\n models:\n fake/m: ${spec}\n`, { mode: 0o600 })
writeFileSync(join(paths.config, "config.yaml"), `model: fake/m\n${config}`)
const dir = realpathSync(mkdtempSync(join(tmpdir(), "lembas-team-")))
Bun.spawnSync(["git", "init", "-q", dir])
return dir
}
async function until(what: () => boolean, ms = 10000) {
const end = Date.now() + ms
while (!what()) {
if (Date.now() > end) throw new Error("timed out")
await Bun.sleep(20)
}
}
describe("a team at work (team.md)", () => {
test("the lead adds a member, is refused work, and is woken by the member's report", async () => {
let lead = 0
const dir = setup((body) => {
if (isMember(body, "parser")) return say("The parser is done: src/parse.ts.")
const n = lead++
if (n === 0) return call("a1", "team_add", { name: "parser", role: "the parser", task: "Write the parser; report the file.", goal: "a calculator" })
if (n === 1) return call("w1", "write", { path: "x.txt", content: "x" })
return say(n === 2 ? "parser is on it." : "All done.")
})
app = createApp({ cwd: dir, mode: "edit", asker: yes, store: false })
await app.engine.prompt("build a calculator")
// The lead's report arrives — at a step boundary, or as a turn of its own when it was idle.
await until(() => fake!.requests.some((r) => !isMember(r, "parser") && r.messages.some((m: any) => m.role === "user" && JSON.stringify(m.content).includes("[Team report from parser to lead:]"))))
await until(() => !app!.engine.busy && app!.team.team.get("lead")!.state === "idle")
const first = fake!.requests[0]
expect(first.tools.map((t: any) => t.function.name)).toEqual(expect.arrayContaining(["team_add", "team_send", "team_read", "team_stop"]))
expect(system(first)).toContain("team_add")
// The size is said once, in the note on the first message.
expect(lastUser(first)).toContain(LOOP.context.note.team.replace("{{min}}", "1").replace("{{max}}", "4"))
const leads = fake!.requests.filter((r) => !isMember(r, "parser"))
const results = toolResults(leads[leads.length - 1])
expect(results[0]).toContain("Added parser (the parser)")
expect(results[1]).toContain(T.texts.manager)
// The member: its own prompt, its brief as its first message, and the team tools a member has.
const member = fake!.requests.find((r) => isMember(r, "parser"))!
expect(system(member)).toContain("The team's goal: a calculator")
expect(system(member)).toContain('you report to "lead"')
expect(lastUser(member)).toContain("Write the parser; report the file.")
const memberTools = member.tools.map((t: any) => t.function.name)
expect(memberTools).toEqual(expect.arrayContaining(["team_send", "team_read"]))
expect(memberTools).not.toContain("team_add")
expect(memberTools).not.toContain("task")
// The room has the report; the lead manages the finished member; nothing was written.
const t = app.team.team
expect(t.room.map((m) => ({ from: m.from, to: m.to, report: m.report }))).toEqual([{ from: "parser", to: ["lead"], report: true }])
expect(t.get("parser")!.state).toBe("finished")
expect(t.isManager("lead")).toBe(true)
expect(teamLabel(t)).toBe("team 2/4")
expect(teamText(app, "")).toContain("- parser — the parser · finished")
expect(await Bun.file(join(dir, "x.txt")).exists()).toBe(false)
// A member is not a job to the model.
expect(app.jobs().every((j) => j.team === "parser")).toBe(true)
})
test("max 1: the tools are listed but answer one_agent, and task too; the note says to work alone", async () => {
let n = 0
const dir = setup(() => (n++ === 0 ? call("a1", "team_add", { name: "x", role: "r", task: "t", goal: "g" }) : n === 2 ? call("t1", "task", { description: "d", prompt: "p" }) : say("alone")), "agents: { min: 1, max: 1 }\n")
app = createApp({ cwd: dir, mode: "edit", asker: yes, store: false })
await app.engine.prompt("go")
expect(lastUser(fake!.requests[0])).toContain(LOOP.context.note.team_one)
const results = toolResults(fake!.requests[2])
expect(results).toEqual([T.unavailable.one_agent, T.unavailable.one_agent])
})
test("min 2: the lead is a manager from the start, and the note asks for members", async () => {
let n = 0
const dir = setup(() => (n++ === 0 ? call("e1", "bash", { command: "touch made.txt" }) : say("ok")), "agents: { min: 2, max: 4 }\n")
app = createApp({ cwd: dir, mode: "auto", asker: yes, store: false })
await app.engine.prompt("go")
expect(lastUser(fake!.requests[0])).toContain(LOOP.context.note.team_lead.replace("{{members}}", "1"))
expect(toolResults(fake!.requests[1])).toEqual([T.texts.manager])
expect(await Bun.file(join(dir, "made.txt")).exists()).toBe(false)
})
test("a model that serves one request at a time offers no team: no team tools, /team says why", async () => {
const dir = setup(() => say("ok"), "", "{ context: 32768, parallel: 1 }")
app = createApp({ cwd: dir, mode: "edit", asker: yes, store: false })
await app.engine.prompt("go")
expect(fake!.requests[0].tools.map((t: any) => t.function.name).filter((n: string) => n.startsWith("team_"))).toEqual([])
expect(lastUser(fake!.requests[0])).not.toContain("The team may have")
expect(teamText(app, "")).toContain("serves one request at a time")
})
test("the size is refused outside its bounds, whole, and a change is said in the next note", async () => {
let n = 0
const dir = setup(() => say(`ok ${n++}`))
app = createApp({ cwd: dir, mode: "edit", asker: yes, store: false })
await app.engine.prompt("one")
expect(teamText(app, "size 3 2")).toContain("1 ≤ min ≤ max ≤ 32")
expect(teamText(app, "size 1 40")).toContain("1 ≤ min ≤ max ≤ 32")
expect([app.team.team.min, app.team.team.max]).toEqual([1, 4])
expect(teamText(app, "size 2 6")).toContain("2 to 6")
expect(app.settings.get("agents.max").value).toBe(6)
await app.engine.prompt("two")
expect(lastUser(fake!.requests[1])).toContain("The team may have 2 to 6 agents, you included.")
await app.engine.prompt("three")
expect(lastUser(fake!.requests[2])).toBe("three")
})
})