diff --git a/src/app.ts b/src/app.ts index 4ffee9d..fcdf403 100644 --- a/src/app.ts +++ b/src/app.ts @@ -645,6 +645,7 @@ export function createApp(o: AppOptions): App { 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 + member.jobId = job.id // Its progress and context use, for the tree (spec 3.4.0 §12). childBus.on((e) => { if (e.type === "phase") { diff --git a/src/session/commands.ts b/src/session/commands.ts index f817b2b..0dca903 100644 --- a/src/session/commands.ts +++ b/src/session/commands.ts @@ -18,6 +18,8 @@ import { skillMessage, slug, type Skill } from "../skill/index.ts" import { CONTINUE_PROMPT } from "./engine.ts" import { TEAM, Team } from "../team/team.ts" import { SUBAGENTS } from "../team/subagents.ts" +import { progressLine } from "./progress.ts" +import { fillPrompt } from "../prompt/fill.ts" export type CommandKind = "custom" | "skill" | "builtin" @@ -49,7 +51,7 @@ 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: "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: "team", description: "subagents: the team and the room; how many; stop one", hint: "size | stop " }, + { name: "team", description: "subagents: the tree and the room; how many; stop one; their findings", hint: "size | stop | findings " }, ] /** The status bar's word for a team with members: its size of the most it may have, and how many @@ -60,6 +62,34 @@ export function teamLabel(team: Team): string { return `agents ${team.size()}/${team.max}${n ? ` · ${n} subagent${n === 1 ? "" : "s"} working` : ""}` } +/** Spec 3.4.0 (subagents.md §12): the tree as the person sees it — every agent with its position, + * state, model, mode, context use, progress line and messages; open slots; the ended ones. */ +export function treeText(team: Team, now = Date.now()): string { + const info = team.info() + const R = TEAM.read + const depth = (a: { parent: string | null }): number => (a.parent ? 1 + depth(info.agents.find((x) => x.name === a.parent) ?? { parent: null }) : 0) + const lines = [`${SUBAGENTS.setting.label} (${info.size} of ${info.max} agents; ${SUBAGENTS.setting.min} ${info.min}, ${SUBAGENTS.setting.max} ${info.max}):`] + for (const a of info.agents) { + const bits = [ + `${" ".repeat(depth(a))}- ${a.name}${a.id === a.name ? "" : ` (${a.id})`} — ${a.position}`, + R.states[a.state], + a.model, + a.request.length ? `${a.mode} + ${a.request.join(", ")}` : a.mode, + a.context && a.context.window ? `${Math.floor((a.context.used * 100) / a.context.window)}% context` : "", + a.progress ? progressLine(a.progress, now) : "", + a.messages ? `${a.messages} message${a.messages === 1 ? "" : "s"}` : "", + a.findings ? `${a.findings} finding${a.findings === 1 ? "" : "s"}` : "", + ].filter(Boolean) + lines.push(bits.join(" · ")) + } + if (info.open_slots) lines.push(fillPrompt(R.slots, { n: String(info.open_slots) })) + if (info.ended.length) { + lines.push(R.ended) + for (const a of info.ended) lines.push(`- ${a.name} (${a.id}) — ${a.position} · ${R.states[a.state]}${a.findings ? ` · ${a.findings} finding${a.findings === 1 ? "" : "s"}` : ""}`) + } + return lines.join("\n") +} + /** `/team [size | stop ]`: 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. */ @@ -83,6 +113,11 @@ export function teamText(app: App, arg: string): string { } return `${S.label}: ${S.min} ${t.min}, ${S.max} ${t.max} — ${S.short}. Set for this session; the agent is told with your next message.` } + if (verb === "findings") { + const name = rest[0] + if (!name) return "/team findings " + return t.readFindings("lead", name) + } if (verb === "stop") { const name = rest[0] if (!name) return "/team stop — a member and everything under it" @@ -95,7 +130,10 @@ export function teamText(app: App, arg: string): string { : "No subagents in this session: they are switched off (subagents: false)." const since = Math.max(0, t.room.length - TEAM.read_max) const own = t.couldServe().own ? "" : `\n${S.own}` - return `${t.read("lead", since)}\n\n${S.label}: ${S.min} ${t.min}, ${S.max} ${t.max} — ${S.short} (/team size ); /team stop stops a member.${own}` + // The tree as the person sees it (spec 3.4.0 §12), then the room as team_read shows it. + const read = t.read("lead", since) + const room = read.slice(read.indexOf("\n\n") + 2) + return `${treeText(t)}\n\n${room}\n\n${S.label}: ${S.min} ${t.min}, ${S.max} ${t.max} — ${S.short} (/team size ); /team stop stops a subagent; /team open shows its session, read-only; /team findings ; /context .${own}` } /** `/agents [name]`: the subagents the task tool offers, or one of them — the text both the TUI and diff --git a/src/team/team.ts b/src/team/team.ts index 5367424..be88ecf 100644 --- a/src/team/team.ts +++ b/src/team/team.ts @@ -104,6 +104,8 @@ export interface TeamAgent { endedAt?: number /** The sub-manager it was under before that one ended (team_replace puts it back). */ movedFrom?: string + /** The device's job that runs it (its transcript, read-only). */ + jobId?: string } export interface RoomMessage { @@ -883,6 +885,7 @@ export class Team { movedFrom: undefined, ended: undefined, waitSince: undefined, + jobId: undefined, } this.agents.set(fresh.name, fresh) for (const c of this.agents.values()) diff --git a/src/tui/app.tsx b/src/tui/app.tsx index 4d7cfc1..ce5dd3a 100644 --- a/src/tui/app.tsx +++ b/src/tui/app.tsx @@ -129,6 +129,8 @@ export function Root(p: { app: Runtime; view: View; theme: Theme; onQuit: () => const sendToJob = (text: string) => { const id = entered()! const job = app.registry.get(app.jobsSessionId(), id) + // Spec 3.4.0 §11: a subagent of the tree is read-only; the person speaks through the main agent. + if (job?.team) return view.info(`${id} is ${job.team}, a subagent of the main agent: its session is read-only — tell the main agent, or /team stop ${job.team}`, "warn") if (!job?.subagent) return view.info(`${id} cannot be messaged any more — an earlier process ran it`, "warn") if (job.state === "running") { job.subagent.send(text) diff --git a/src/tui/commands.ts b/src/tui/commands.ts index 50d08cd..5e78dfc 100644 --- a/src/tui/commands.ts +++ b/src/tui/commands.ts @@ -12,6 +12,7 @@ import { instructionFiles } from "../prompt/assemble.ts" import { addDecision, readBoard, readDecisions, renderBoard } from "../project/board.ts" import { contextBreakdown, renderBreakdown } from "../session/context.ts" import { SUBAGENTS } from "../team/subagents.ts" +import { TEAM } from "../team/team.ts" import { changelogPrompt, initPrompt, reviewPrompt, teamText } from "../session/commands.ts" import { git } from "../git/run.ts" import { branches, deleteBranch, switchBranch } from "../git/branch.ts" @@ -262,12 +263,29 @@ export const COMMANDS: Command[] = [ }, { name: "team", - args: "[size [ ] | stop []]", - description: "subagents: the team and the room; how many; stop one", + args: "[size [ ] | stop [] | open [] | findings ]", + description: "subagents: the tree and the room; how many; stop one; their findings", 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) + // Spec 3.4.0 §11: a subagent's session, read-only, live or after it ended. + if (words[0] === "open") { + const t = c.app.team.team + const all = [...t.tree().filter((a) => a.manager !== null), ...t.ended] + const open = (key: string) => { + const a = t.find(key) + if (!a?.jobId) return c.view.info(`no subagent ${key} with a session here — /team lists them`, "warn") + c.enterJob(a.jobId) + } + if (words[1]) return open(words[1]) + if (!all.length) return c.view.info("no subagents yet") + return c.openDialog({ + title: "A subagent's session — read-only", + options: all.map((a) => ({ label: `${a.name} (${a.id})`, value: a.id, description: `${a.role} · ${TEAM.read.states[a.state]}` })), + onSelect: open, + }) + } 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") @@ -708,8 +726,24 @@ export const COMMANDS: Command[] = [ }, { name: "context", - description: "where the context window goes", - run(c) { + args: "[]", + description: "where the context window goes — the session's, or a subagent's", + run(c, arg) { + // Spec 3.4.0 §12: a subagent's context, by its name or id. + const who = arg.trim() + if (who) { + const t = c.app.team.team + const a = t.find(who) + if (!a) return c.view.info(`no subagent ${who} — /team lists them`, "warn") + const h = c.app.team.handle(a.name) + if (!h || a.endState) { + const u = a.context + return c.view.info(u ? `${a.name} (${a.id}): ${u.used} of ${u.window} tokens (${Math.floor((u.used * 100) / u.window)}%) when it ${a.endState ? TEAM.read.states[a.state] : "last said"}` : `${a.name} (${a.id}): no context use known yet`) + } + const se = h.engine + const sys = se.systemPrompt() + return c.view.info(`${a.name} (${a.id}) — ${a.role}\n${renderBreakdown(contextBreakdown(se, sys, [], undefined, se.o.tools))}`) + } const e = c.app.engine // The prompt the session sends (made now if no request has gone yet). const system = e.systemPrompt() diff --git a/src/tui/components/spinner.tsx b/src/tui/components/spinner.tsx index a837ae1..b74283b 100644 --- a/src/tui/components/spinner.tsx +++ b/src/tui/components/spinner.tsx @@ -1,11 +1,18 @@ import { createSignal, For, onCleanup } from "solid-js" import { duration } from "../../duration.ts" +import { progressLine } from "../../session/progress.ts" import { useTui } from "../context.ts" import { fmtTokens } from "./status.tsx" /** "reading the prompt 64% · 90k of 140k · ~2m left": the server's own count (llama.cpp's * return_progress). The time left is guessed from the reading so far, once there is enough of it; * a long prompt reads slower towards its end, so it is a floor more than a promise. */ +/** How long the rest of the prompt will take, from the server's pace so far; "" until it is known. */ +export function etaOf(p: { total: number; done: number; processed: number; ms: number }): string { + if (p.processed >= 2048 && p.ms >= 2000 && p.done < p.total) return `~${duration(((p.total - p.done) / p.processed) * p.ms, true)} left` + return "" +} + export function reading(p: { total: number; done: number; processed: number; ms: number }): string { const pct = Math.floor((p.done / p.total) * 100) const parts = [`reading the prompt ${pct}%`, `${fmtTokens(p.done)} of ${fmtTokens(p.total)}`] @@ -34,6 +41,16 @@ export function Spinner() { } const what = () => { const s = view.state + // Spec 3.4.0: the phase the engine says, drawn as every client draws it (progress.md §3). + if (s.now) { + tick() + // The CLI's own look around the shared line: how long the rest of the prompt will take. + // The server's counts as they come (the phase event is paced for the link; the screen is not). + const reading = s.now.phase === "reading_prompt" && s.progress + const now = reading ? { ...s.now, done: s.progress!.done, total: s.progress!.total } : s.now + const eta = reading ? etaOf(s.progress!) : "" + return `${progressLine(now)}${eta ? ` · ${eta}` : ""}` + } if (s.preparing) return `preparing ${s.preparing}` if (s.phase === "waiting" && s.progress) return reading(s.progress) if (s.phase === "thinking") return pick(sp().verbs, 30) @@ -45,9 +62,9 @@ export function Spinner() { {pick(sp().frames, 1)} - {what()}… + {what()}{view.state.now ? " · " : "… "} - {elapsed()} · {tokens()} · esc to stop + {view.state.now ? `turn ${elapsed()}` : elapsed()} · {tokens()} · esc to stop diff --git a/src/tui/components/subagent.tsx b/src/tui/components/subagent.tsx index 7aa4d7d..3ec3511 100644 --- a/src/tui/components/subagent.tsx +++ b/src/tui/components/subagent.tsx @@ -27,7 +27,7 @@ export function SubagentView(p: { id: string; view: View; scrollRef?: (r: Scroll const j = job() if (!j) return `subagent · ${p.id} · gone · esc back` const state = j.state === "running" ? `running ${duration(Date.now() - j.started, true)}` : jobWords(j) - const message = j.subagent ? "type to message" : "cannot be messaged (an earlier process ran it)" + const message = j.team ? `read-only: ${j.team} works for the main agent` : j.subagent ? "type to message" : "cannot be messaged (an earlier process ran it)" return [`subagent ${j.agent ?? ""}`.trimEnd(), j.id, state, "esc back", message].join(" · ") } const what = () => job()?.description ?? "" diff --git a/src/tui/state.ts b/src/tui/state.ts index 7096c9d..a884ffd 100644 --- a/src/tui/state.ts +++ b/src/tui/state.ts @@ -120,6 +120,8 @@ export interface ViewState { progress?: { total: number; done: number; processed: number; ms: number } /** The session's background jobs (the registry's list, kept from `job` events), running first. */ jobs: TrackedJob[] + /** Spec 3.4.0 (harness/progress.md): what the agent is doing now — the spinner line. */ + now?: import("../session/progress.ts").Progress | null } // What the model was given beside the words typed — attachments, and a session note @@ -285,6 +287,9 @@ export function createView(bus: Bus) { if (e.state === "start") startTask() else inTask = false break + case "phase": + set("now", e.progress ? { ...e.progress } : null) + break case "sub_tool": set( "items", diff --git a/tests/e2e.test.ts b/tests/e2e.test.ts index 49eb64e..18d5e89 100644 --- a/tests/e2e.test.ts +++ b/tests/e2e.test.ts @@ -54,7 +54,7 @@ describe("end to end", () => { 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 // 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", "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"]) + 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", "team_whoami", "team_findings", "team_decide", "team_assign", "team_replace", "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") 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 diff --git a/tests/tui/app.test.tsx b/tests/tui/app.test.tsx index 0542220..f3a1bdc 100644 --- a/tests/tui/app.test.tsx +++ b/tests/tui/app.test.tsx @@ -372,14 +372,14 @@ test("thinking is one line — its text is not shown; the provider's count of it expect(await t.until("SECRET-INNER-STEP")).toBe(true) }) -test("while it thinks: the thought says only Thinking…; the line at the bottom has the verb, the task's time and tokens", async () => { +test("while it thinks: the thought says only Thinking…; the line at the bottom has the phase (progress.md), the task's time and tokens", async () => { t = await tui([{ gapMs: 1500, chunks: [delta({ reasoning_content: "Weighing it up: HIDDEN-WHILE-THINKING and more words here." }), delta({ content: "Done thinking." })] }]) await t.input.typeText("go") t.input.pressEnter() expect(await t.until("~15 tokens")).toBe(true) const frame = t.frame() expect(frame).toMatch(/^\s*Thinking\.{1,3}\s*$/m) - expect(frame).toMatch(/(pondering|contemplating|musing|cogitating|ruminating|deliberating|mulling|reflecting|reasoning|analyzing|synthesizing|formulating)… \d+s · ~15 tokens · esc to stop/) + expect(frame).toMatch(/Thinking · \d+s · turn \d+s · ~15 tokens · esc to stop/) expect(frame).not.toContain("HIDDEN-WHILE-THINKING") expect(await t.until("Done thinking.")).toBe(true) expect(t.frame()).toMatch(/Thought for \d\.\ds · ~15 tokens/) @@ -397,7 +397,7 @@ test("the line at the bottom counts the whole task: the clock and the tokens go // The spinner line redraws on its own tick, slower than the reply's text: on a slow machine the // frame that first shows "Half" can still carry the line from before. The 1.2 s gap holds this // state, so wait for the line within it rather than reading the first frame. - const counted = /writing… \d+s · ~4\d tokens · esc to stop/ + const counted = /Writing(: [\d.]+ tok\/s)? · \d+s · turn \d+s · ~4\d tokens · esc to stop/ for (let i = 0; i < 20 && !counted.test(t.frame()); i++) await Bun.sleep(50), await t.settle() expect(t.frame()).toMatch(counted) expect(await t.until("way.")).toBe(true) @@ -922,10 +922,12 @@ test("while the server reads a long prompt: the line at the bottom says how far t = await tui([{ gapMs: 500, chunks: [pp(0, 0), pp(80000, 160000), delta({ content: "Read it all." }, "stop")] }]) await t.input.typeText("go") t.input.pressEnter() - expect(await t.until("reading the prompt 64% · 90k of 140k · ~1m 40s left")).toBe(true) + // At 100 columns the line is cut in its middle; its start and its end are what is checked. + expect(await t.until("~1m 40s left")).toBe(true) + expect(t.frame()).toContain("Reading the prompt: 90000 / 140000 tokens (64%") expect(await t.until("Read it all.")).toBe(true) await t.settle() - expect(t.frame()).not.toContain("reading the prompt") + expect(t.frame()).not.toContain("Reading the prompt") }) test("a mouse wheel that arrives as a burst of ↑ scrolls the transcript; one ↑ still walks history", async () => {