diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index d5982f9da..e4a78d177 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -121,6 +121,7 @@ - Added an optional `role` field to `task` spawns that gives each subagent a tailored specialist identity: the role is injected as a system-prompt specialization preamble and becomes the subagent's display name and telemetry identity in the registry, IRC roster, and Agent Hub, so delegated trees are no longer clones of one generic worker ([#2467](https://github.com/can1357/oh-my-pi/issues/2467)) - Added a non-blocking specialization advisory to the `task` tool: when a spawner with remaining depth capacity spawns generic role-less workers (a `task`/`quick_task` spawn without a `role`, or the same agent cloned ≥2× all without roles), the result steers it toward tailored specialists. Gated so a leaf at max recursion depth is never nudged; the task-tool depth gate is extracted into a shared `canSpawnAtDepth` helper ([#2469](https://github.com/can1357/oh-my-pi/issues/2469)) +- Added a work-aware IRC roster: `irc list` and the subagent peer roster now show each peer's current activity (a short gist of its latest intent/tool) alongside its role-derived display name, so a tree of tailored specialists is legible enough to coordinate. Backed by a new display-only `activity` field on the agent registry ([#2470](https://github.com/can1357/oh-my-pi/issues/2470)) ### Changed diff --git a/packages/coding-agent/src/registry/agent-registry.ts b/packages/coding-agent/src/registry/agent-registry.ts index f791fa225..b5d52847e 100644 --- a/packages/coding-agent/src/registry/agent-registry.ts +++ b/packages/coding-agent/src/registry/agent-registry.ts @@ -10,6 +10,7 @@ */ import type { AgentSession } from "../session/agent-session"; +import { oneLineLabel } from "../task/types"; export const MAIN_AGENT_ID = "Main"; @@ -34,6 +35,8 @@ export interface AgentRef { sessionFile: string | null; createdAt: number; lastActivity: number; + /** Short gist of what the agent is currently doing (latest intent or tool), for the work-aware roster. Display-only. */ + activity?: string; } export type RegistryEvent = @@ -93,10 +96,37 @@ export class AgentRegistry { const ref = this.#refs.get(id); if (!ref || ref.status === status) return; ref.status = status; + // Activity describes current work; it is meaningless once the agent + // leaves `running`, so drop it to avoid showing stale work in rosters. + if (status !== "running") ref.activity = undefined; ref.lastActivity = Date.now(); this.#emit({ type: "status_changed", ref }); } + /** + * Record a short activity gist for the work-aware roster. Display-only and + * read on demand (`irc list`, peer roster), so it emits no event — keeping + * the per-tool-call update rate off the registry listener path (same as + * `attachSession`, which also bumps `lastActivity` without emitting). Only a + * `running` agent has current work: a heartbeat for any other status is + * dropped, so a late progress flush can't resurrect activity on a ref that + * `setStatus` just cleared. Every running heartbeat refreshes `lastActivity` + * — even when the gist text is unchanged — so the roster's "active … ago" and + * recency sort track real work, not just the last status change. + * The gist is normalized to one bounded line (`oneLineLabel`) so model-derived + * intent text can neither break the roster nor smuggle terminal escapes — + * every caller is safe without sanitizing at its own call site. + */ + setActivity(id: string, activity: string): void { + const ref = this.#refs.get(id); + if (!ref) return; + if (ref.status !== "running") return; + const gist = oneLineLabel(activity); + ref.lastActivity = Date.now(); + if (ref.activity === gist) return; + ref.activity = gist; + } + attachSession(id: string, session: AgentSession, sessionFile?: string | null): void { const ref = this.#refs.get(id); if (!ref) return; diff --git a/packages/coding-agent/src/task/executor.ts b/packages/coding-agent/src/task/executor.ts index 67f0e464a..6a5a68865 100644 --- a/packages/coding-agent/src/task/executor.ts +++ b/packages/coding-agent/src/task/executor.ts @@ -125,7 +125,10 @@ function renderIrcPeerRoster(selfId: string): string { .list() .filter(ref => ref.id !== selfId && ref.status !== "aborted"); if (peers.length === 0) return "- (no other agents)"; - const lines = peers.map(peer => `- \`${peer.id}\` — ${peer.displayName} (${peer.kind}, ${peer.status})`); + const lines = peers.map( + peer => + `- \`${peer.id}\` — ${peer.displayName} (${peer.kind}, ${peer.status})${peer.activity ? `: ${peer.activity}` : ""}`, + ); if (peers.some(peer => peer.status === "idle" || peer.status === "parked")) { lines.push("Idle/parked peers are not gone: messaging them wakes (or revives) them."); } @@ -841,6 +844,9 @@ function createSubagentRunMonitor(args: RunMonitorArgs): SubagentRunMonitor { const emitProgressNow = () => { progress.durationMs = Date.now() - startTime; onProgress?.({ ...progress }); + const activityGist = + progress.lastIntent ?? (progress.currentTool ? `running ${progress.currentTool}` : undefined); + if (activityGist) AgentRegistry.global().setActivity(id, activityGist); if (args.eventBus) { args.eventBus.emit(TASK_SUBAGENT_PROGRESS_CHANNEL, { index, diff --git a/packages/coding-agent/src/tools/irc.ts b/packages/coding-agent/src/tools/irc.ts index 635f85a8e..1d420e30a 100644 --- a/packages/coding-agent/src/tools/irc.ts +++ b/packages/coding-agent/src/tools/irc.ts @@ -19,6 +19,7 @@ import { IrcBus, type IrcDeliveryReceipt, type IrcMessage } from "../irc/bus"; import type { Theme } from "../modes/theme/theme"; import ircDescription from "../prompts/tools/irc.md" with { type: "text" }; import type { AgentRegistry } from "../registry/agent-registry"; +import { canSpawnAtDepth } from "../task/types"; import { Ellipsis, renderStatusLine, renderTreeList, truncateToWidth } from "../tui"; import type { ToolSession } from "."; import { @@ -41,8 +42,10 @@ const DEFAULT_IRC_TIMEOUT_MS = 120_000; */ export function isIrcEnabled(settings: Settings, taskDepth: number): boolean { if (taskDepth > 0) return true; + // Top-level session: peers exist only if it can still spawn subagents — the + // same capacity gate the task tool uses, reused here to avoid drift. const maxDepth = settings.get("task.maxRecursionDepth") ?? 2; - return maxDepth < 0 || taskDepth < maxDepth; + return canSpawnAtDepth(maxDepth, taskDepth); } const ircSchema = z.object({ @@ -66,6 +69,7 @@ interface IrcPeerInfo { parentId?: string; unread: number; lastActivity: number; + activity?: string; } export interface IrcDetails { @@ -146,6 +150,7 @@ export class IrcTool implements AgentTool { parentId: ref.parentId, unread: bus.unreadCount(ref.id), lastActivity: ref.lastActivity, + activity: ref.activity, })); const lines: string[] = []; if (peers.length === 0) { @@ -154,6 +159,7 @@ export class IrcTool implements AgentTool { lines.push(`${peers.length} peer(s):`); for (const peer of peers) { const extras = [ + peer.activity || undefined, peer.unread > 0 ? `unread ${peer.unread}` : undefined, peer.parentId ? `parent ${peer.parentId}` : undefined, `active ${formatDuration(Date.now() - peer.lastActivity)} ago`, @@ -673,7 +679,9 @@ function renderListResult(details: Partial, expanded: boolean, theme const kindText = peer.parentId ? `${peer.kind}${theme.sep.dot}of ${peer.parentId}` : peer.kind; const unread = peer.unread > 0 ? ` ${formatBadge(`${peer.unread} unread`, "warning", theme)}` : ""; const age = messageAge(peer.lastActivity); - return `${peerStatusBadge(peer.status, theme)} ${theme.bold(replaceTabs(peer.id))} ${theme.fg("dim", kindText)}${unread}${age ? ` ${theme.fg("dim", age)}` : ""}`; + const activity = peer.activity ? ` ${theme.fg("dim", replaceTabs(peer.activity))}` : ""; + const name = theme.fg("dim", replaceTabs(peer.displayName)); + return `${peerStatusBadge(peer.status, theme)} ${theme.bold(replaceTabs(peer.id))} ${name} ${theme.fg("dim", kindText)}${activity}${unread}${age ? ` ${theme.fg("dim", age)}` : ""}`; }, }, theme, diff --git a/packages/coding-agent/test/tools/irc-renderer.test.ts b/packages/coding-agent/test/tools/irc-renderer.test.ts index 768ddc273..e22740612 100644 --- a/packages/coding-agent/test/tools/irc-renderer.test.ts +++ b/packages/coding-agent/test/tools/irc-renderer.test.ts @@ -231,6 +231,40 @@ describe("ircToolRenderer list", () => { expect(authIndex).toBeLessThan(rateIndex); expect(rendered.some(line => line.includes("RateLimiter") && line.includes("2 unread"))).toBe(true); }); + + it("renders a peer's role displayName and current activity in the row", async () => { + const uiTheme = await theme(); + const rendered = lines( + ircToolRenderer.renderResult( + { + content: [{ type: "text", text: "" }], + details: { + op: "list", + from: "Main", + peers: [ + { + id: "AuthScout", + displayName: "Auth-flow security reviewer", + kind: "sub", + status: "running", + parentId: "Main", + unread: 0, + lastActivity: Date.now() - 5_000, + activity: "auditing the token refresh path", + }, + ], + } satisfies IrcDetails, + }, + { expanded: false, isPartial: false }, + uiTheme, + { op: "list" }, + ), + ); + const row = rendered.find(line => line.includes("AuthScout")); + expect(row).toBeDefined(); + expect(row).toContain("Auth-flow security reviewer"); + expect(row).toContain("auditing the token refresh path"); + }); }); describe("ircToolRenderer body truncation", () => { diff --git a/packages/coding-agent/test/tools/irc-roster-activity.test.ts b/packages/coding-agent/test/tools/irc-roster-activity.test.ts new file mode 100644 index 000000000..40667039a --- /dev/null +++ b/packages/coding-agent/test/tools/irc-roster-activity.test.ts @@ -0,0 +1,115 @@ +import { afterEach, beforeEach, describe, expect, it, mock, spyOn } from "bun:test"; +import { Settings } from "@oh-my-pi/pi-coding-agent/config/settings"; +import { IrcBus } from "@oh-my-pi/pi-coding-agent/irc/bus"; +import { AgentLifecycleManager } from "@oh-my-pi/pi-coding-agent/registry/agent-lifecycle"; +import { AgentRegistry } from "@oh-my-pi/pi-coding-agent/registry/agent-registry"; +import type { ToolSession } from "@oh-my-pi/pi-coding-agent/tools"; +import { IrcTool } from "@oh-my-pi/pi-coding-agent/tools/irc"; + +// Contract: the work-aware roster (`irc list`) surfaces each peer's role +// (via displayName) and current activity gist, and a peer with no activity +// renders cleanly without a dangling empty clause. + +function makeToolSession(registry: AgentRegistry, agentId: string): ToolSession { + return { + cwd: "/tmp", + hasUI: false, + getSessionFile: () => null, + getSessionSpawns: () => "*", + settings: Settings.isolated(), + agentRegistry: registry, + getAgentId: () => agentId, + } as unknown as ToolSession; +} + +async function listText(registry: AgentRegistry, selfId: string): Promise { + const tool = new IrcTool(makeToolSession(registry, selfId)); + const result = await tool.execute("call", { op: "list" }); + return result.content.find(part => part.type === "text")?.text ?? ""; +} + +describe("IRC roster activity", () => { + let registry: AgentRegistry; + beforeEach(() => { + AgentRegistry.resetGlobalForTests(); + AgentLifecycleManager.resetGlobalForTests(); + IrcBus.resetGlobalForTests(); + registry = AgentRegistry.global(); + }); + afterEach(() => { + AgentRegistry.resetGlobalForTests(); + mock.restore(); + }); + + it("surfaces a peer's role and current activity in the list", async () => { + registry.register({ id: "Main", displayName: "main", kind: "main", session: null, status: "running" }); + registry.register({ + id: "AuthScout", + displayName: "Auth-flow security reviewer", + kind: "sub", + session: null, + status: "running", + }); + registry.setActivity("AuthScout", "auditing the token refresh path"); + + const text = await listText(registry, "Main"); + expect(text).toContain("Auth-flow security reviewer"); + expect(text).toContain("auditing the token refresh path"); + }); + + it("renders a peer with no activity without a dangling clause", async () => { + registry.register({ id: "Main", displayName: "main", kind: "main", session: null, status: "running" }); + registry.register({ id: "Quiet", displayName: "task", kind: "sub", session: null, status: "running" }); + + const text = await listText(registry, "Main"); + const line = text.split("\n").find(l => l.includes("Quiet")); + expect(line).toBeDefined(); + expect(line).not.toContain("— ,"); + expect(line).not.toContain("undefined"); + }); + + it("setActivity refreshes lastActivity so a working agent is not shown as stale", () => { + // irc list renders "active ago" and both list views sort by + // lastActivity, so an activity update must refresh it or live work looks idle. + const now = spyOn(Date, "now"); + now.mockReturnValue(1_000); + registry.register({ id: "Worker", displayName: "task", kind: "sub", session: null, status: "running" }); + now.mockReturnValue(60_000); + registry.setActivity("Worker", "running bash"); + expect(registry.get("Worker")?.lastActivity).toBe(60_000); + // A repeated identical gist is still a heartbeat: recency must refresh even + // though the activity text did not change. + now.mockReturnValue(90_000); + registry.setActivity("Worker", "running bash"); + expect(registry.get("Worker")?.lastActivity).toBe(90_000); + }); + + it("clears activity when a peer leaves running so finished work is not shown as current", () => { + registry.register({ id: "Done", displayName: "task", kind: "sub", session: null, status: "running" }); + registry.setActivity("Done", "running bash"); + expect(registry.get("Done")?.activity).toBe("running bash"); + registry.setStatus("Done", "idle"); + expect(registry.get("Done")?.activity).toBeUndefined(); + }); + + it("ignores activity heartbeats for an agent that is no longer running", () => { + registry.register({ id: "Stopped", displayName: "task", kind: "sub", session: null, status: "idle" }); + registry.setActivity("Stopped", "running bash"); + expect(registry.get("Stopped")?.activity).toBeUndefined(); + }); + + it("normalizes a multi-line activity gist to one bounded line", () => { + // A model-authored intent with newlines/tabs must not break out of its one + // roster row; setActivity collapses it centrally so every caller is safe. + registry.register({ id: "Noisy", displayName: "task", kind: "sub", session: null, status: "running" }); + registry.setActivity("Noisy", "editing\n- fake roster line\twith tabs"); + expect(registry.get("Noisy")?.activity).toBe("editing - fake roster line with tabs"); + }); + + it("setActivity is a no-op for an unknown agent id (registers no phantom ref)", () => { + const before = registry.list().length; + registry.setActivity("Ghost", "noop"); + expect(registry.get("Ghost")).toBeUndefined(); + expect(registry.list().length).toBe(before); + }); +});