Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -243,7 +243,9 @@ tool actions, but are continuously visible — no extra tool calls needed.

The widget and panel show real-time worker state at a glance:

- **Task progress**: per-worker counts of pending, active (in-progress), and done tasks; total row with completion percentage
- **Time in state**: how long a worker has been in its current status (e.g. `3m12s`)
- **Active task ID**: shown inline next to status in the persistent widget (e.g. `streaming #3 2m15s`)
- **Stall detection**: when a streaming worker hasn't emitted any agent event for > 5 minutes, status changes to `⚠ stalled` (configurable via `PI_TEAMS_STALL_THRESHOLD_MS`)
- **Last message summary**: most recent assistant text (first 80–100 chars) visible in the panel's selected-worker detail section
- **Model per worker**: shown in the panel detail view when available
Expand Down
21 changes: 20 additions & 1 deletion docs/claude-parity.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ Legend: ✅ implemented • 🟡 partial • ❌ missing
| Shutdown handshake | Lead requests shutdown; comrade can approve/reject | ✅ | Protocol: `shutdown_request` → `shutdown_approved` / `shutdown_rejected`. `/team shutdown <name>` (graceful), `/team kill <name>` (SIGTERM). Wording is style-controlled (e.g. "was asked to shut down", "walked the plank"). | P1 |
| Cleanup team | "Clean up the team" removes shared resources after comrades stopped | ✅ | `/team done [--force]` ends run (stops teammates, hides widget, auto-detects completion). `/team cleanup [--force]` deletes artifacts. | P1 |
| Hooks / quality gates | `ComradeIdle`, `TaskCompleted` hooks | 🟡 | Optional leader-side hook runner (idle/task-complete/task-fail) via `PI_TEAMS_HOOKS_ENABLED=1` + scripts under `_hooks/`; inline failure surfacing + failure-action policies (`warn`/`followup`/`reopen`/`reopen_followup`) implemented; stable hook context payload exposed via `PI_TEAMS_HOOK_CONTEXT_JSON` + auto-remediation flow (reopen cap / follow-up owner policy / teammate notification). Runtime policy changes are agent-invocable via `teams` actions (`hooks_policy_get` / `hooks_policy_set`). | P2 |
| Widget liveliness | Status updates in near real-time | ✅ | Event-driven widget refresh on teammate tool start/end and turn completion; auto-done detection with `/team done` hint. | P2 |
| Widget liveliness | Status updates in near real-time | ✅ | Event-driven widget refresh on teammate tool start/end and turn completion; auto-done detection with `/team done` hint. Widget shows all three task states (pending/active/done) with stable height (no dynamic sub-lines). | P2 |
| Task list UX | Ctrl+T toggle; show all/clear tasks by asking | 🟡 | Widget + `/team task list` + `/team task show` + `/team task clear`; panel supports fast `t`/`shift+t` toggle into task-centric view (`Ctrl+T` is reserved by Pi for thinking blocks). | P0 |
| Shared task list across sessions | `CLAUDE_CODE_TASK_LIST_ID=...` | ✅ | Worker env: `PI_TEAMS_TASK_LIST_ID` (manual workers). Leader: `/team task use <taskListId>` (persisted). Newly spawned workers inherit; existing workers need restart. | P1 |
| Join/attach flow | Join existing team context from another running session | 🟡 | `/team attach list`, `/team attach <teamId> [--claim]`, `/team detach` plus claim heartbeat/takeover handshake added. Widget/panel now show attached-mode banner + detach hint. | P2 |
Expand Down Expand Up @@ -126,13 +126,32 @@ Legend: ✅ implemented • 🟡 partial • ❌ missing
- Implemented: agent-invocable governance actions via `teams` tool (`plan_approve|plan_reject`).
- Implemented: agent-invocable model policy introspection/check actions via `teams` tool (`model_policy_get|model_policy_check`) to validate spawn overrides before execution.
- Implemented: agent-invocable end-of-run via `teams` tool (`team_done`) with structured error classification (`status`/`reason`/`hint`).
- Implemented: in-progress task count in widget/panel (previously only showed pending + completed).
- Implemented: stable widget height — active task ID shown inline instead of sub-line.
- Implemented: correct total percentage including all task states (was: completed/[pending+completed], now: completed/total).
- Next: optional tmux split-pane integration and deeper dependency/task editing flows in panel.

12) **Join/attach flow** 🟡 (partial)
- Implemented: `/team attach list`, `/team attach <teamId> [--claim]`, `/team detach`.
- Implemented: explicit attach claim handshake with heartbeat + force takeover (`--claim`).
- Implemented: attached-mode affordances in widget/panel (external team banner + `/team detach` hint).

## SYM-43 research triage (UI parity follow-up)

Research reference: `.research/claude-teams-ui-parity.md` + `.research/claude-teams-ui/`

| Research gap | Status | Resolution |
| --- | --- | --- |
| Always-visible status bar readability | ✅ Done | Widget shows all three task states (pending/active/done) with stable height. Active task ID shown inline, no dynamic sub-lines. |
| Event-driven updates for "live" feel | ✅ Done (prior) | Widget re-renders on teammate tool start/end/turn completion events. 1s refresh in panel. |
| Manual worker visibility/discovery | ✅ Done (prior) | `getVisibleWorkerNames()` includes workers from config + RPC + active task owners. Manual tmux workers auto-registered via idle notification. |
| End-of-run cleanup UX | ✅ Done (prior) | `/team done [--force]` stops teammates + hides widget. Auto-detects when all tasks complete (shows hint). `/team cleanup` removes artifacts. `/team gc` for stale dirs. |
| Keyboard conflict avoidance | ✅ Done (prior) | Uses `t`/`shift+t` (not `Ctrl+T`, reserved by Pi). No `Tab` conflicts. Panel shortcuts documented in README. |
| Total percentage bug | ✅ Fixed | Total row now includes in-progress tasks in denominator (was: completed/(pending+completed)). |
| Widget post-cleanup issue (persists after done) | ✅ Fixed (prior) | `/team done` hides widget. Widget auto-hides when no online members and no active tasks. |
| Compact collapsed mode (Claude-style bottom bar) | ❌ Deferred | Claude shows a single-line collapsed bar with `shift+↑ to expand`. Pi widget is always expanded. Would require Pi TUI API additions for collapsible widgets. |
| Display mode cycling (Shift+Up/Down) | ❌ Deferred | Claude's terminal-level teammate navigation. Not achievable without deeper Pi TUI integration. |

## Where changes would land (code map)

- Leader orchestration: `extensions/teams/leader.ts`
Expand Down
16 changes: 13 additions & 3 deletions extensions/teams/teams-panel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ interface Row {
displayName: string;
statusKey: DisplayStatus;
pending: number;
inProgress: number;
completed: number;
tokensStr: string;
activityText: string;
Expand Down Expand Up @@ -328,6 +329,7 @@ export async function openInteractiveWidget(ctx: ExtensionCommandContext, deps:
displayName: strings.leaderControlTitle,
statusKey: "idle",
pending: leadTasks.filter((t) => t.status === "pending").length,
inProgress: leadTasks.filter((t) => t.status === "in_progress").length,
completed: leadTasks.filter((t) => t.status === "completed").length,
tokensStr: "\u2014",
activityText: "",
Expand Down Expand Up @@ -360,6 +362,7 @@ export async function openInteractiveWidget(ctx: ExtensionCommandContext, deps:
displayName: formatMemberDisplayName(style, name),
statusKey,
pending: owned.filter((t) => t.status === "pending").length,
inProgress: owned.filter((t) => t.status === "in_progress").length,
completed: owned.filter((t) => t.status === "completed").length,
tokensStr: formatTokens(activity.totalTokens),
activityText: toolActivity(activity.currentToolName),
Expand Down Expand Up @@ -415,6 +418,7 @@ export async function openInteractiveWidget(ctx: ExtensionCommandContext, deps:
} else {
// Column widths
const totalPending = tasks.filter((t) => t.status === "pending").length;
const totalInProgress = tasks.filter((t) => t.status === "in_progress").length;
const totalCompleted = tasks.filter((t) => t.status === "completed").length;
let totalTokensRaw = 0;
for (const name of memberNames) totalTokensRaw += tracker.get(name).totalTokens;
Expand All @@ -425,6 +429,10 @@ export async function openInteractiveWidget(ctx: ExtensionCommandContext, deps:
...rows.map((r) => String(r.pending).length),
String(totalPending).length,
);
const iW = Math.max(
...rows.map((r) => String(r.inProgress).length),
String(totalInProgress).length,
);
const cW = Math.max(
...rows.map((r) => String(r.completed).length),
String(totalCompleted).length,
Expand All @@ -444,11 +452,12 @@ export async function openInteractiveWidget(ctx: ExtensionCommandContext, deps:
: theme.bold(r.displayName);
const statusLabel = theme.fg(DISPLAY_STATUS_COLOR[r.statusKey], padRight(r.statusKey, 9));
const pNum = String(r.pending).padStart(pW);
const iNum = String(r.inProgress).padStart(iW);
const cNum = String(r.completed).padStart(cW);
const tokStr = r.tokensStr.padStart(tokW);
const cols = theme.fg(
"dim",
` \u00b7 ${pNum} pending \u00b7 ${cNum} complete \u00b7 ${tokStr} tokens`,
` \u00b7 ${pNum} pending \u00b7 ${iNum} active \u00b7 ${cNum} done \u00b7 ${tokStr} tokens`,
);
const elapsedLabel = r.elapsedStr ? " " + theme.fg("dim", r.elapsedStr) : "";
const actLabel = r.activityText
Expand All @@ -474,16 +483,17 @@ export async function openInteractiveWidget(ctx: ExtensionCommandContext, deps:
lines.push(truncateToWidth(sepLine, width));

const totalLabel = theme.bold("Total");
const totalTaskCount = totalPending + totalCompleted;
const totalTaskCount = totalPending + totalInProgress + totalCompleted;
const pct =
totalTaskCount > 0 ? Math.round((totalCompleted / totalTaskCount) * 100) : 0;
const pctLabel = theme.fg("success", padRight(`${pct}%`, 9));
const tpNum = String(totalPending).padStart(pW);
const tiNum = String(totalInProgress).padStart(iW);
const tcNum = String(totalCompleted).padStart(cW);
const ttokStr = totalTokensStr.padStart(tokW);
const totalSuffix = theme.fg(
"muted",
` \u00b7 ${tpNum} pending \u00b7 ${tcNum} complete \u00b7 ${ttokStr} tokens`,
` \u00b7 ${tpNum} pending \u00b7 ${tiNum} active \u00b7 ${tcNum} done \u00b7 ${ttokStr} tokens`,
);
const totalRow = ` ${padRight(totalLabel, nameColWidth + 3)} ${pctLabel}${totalSuffix}`;
lines.push(truncateToWidth(totalRow, width));
Expand Down
30 changes: 17 additions & 13 deletions extensions/teams/teams-widget.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ interface WidgetRow {
displayName: string;
statusKey: DisplayStatus;
pending: number;
inProgress: number;
completed: number;
tokensStr: string; // "—" for chairman
activityText: string;
Expand All @@ -53,8 +54,8 @@ interface WidgetRow {
modelLabel: string | null;
/** Thinking level (e.g. "high") or null. */
thinkingLabel: string | null;
/** Active task subject (if any). */
activeTaskSubject: string | null;
/** Active task ID (e.g. "#3") when the worker has an in-progress task, else null. */
activeTaskId: string | null;
}

function shortTeamId(teamId: string): string {
Expand Down Expand Up @@ -138,13 +139,14 @@ export function createTeamsWidget(deps: WidgetDeps): WidgetFactory {
displayName: strings.leaderControlTitle,
statusKey: "idle",
pending: leadTasks.filter((t) => t.status === "pending").length,
inProgress: leadTasks.filter((t) => t.status === "in_progress").length,
completed: leadTasks.filter((t) => t.status === "completed").length,
tokensStr: "\u2014",
activityText: "",
elapsedStr: "",
modelLabel: null,
thinkingLabel: null,
activeTaskSubject: null,
activeTaskId: null,
});
}

Expand Down Expand Up @@ -174,25 +176,28 @@ export function createTeamsWidget(deps: WidgetDeps): WidgetFactory {
displayName: formatMemberDisplayName(style, name),
statusKey,
pending: owned.filter((t) => t.status === "pending").length,
inProgress: owned.filter((t) => t.status === "in_progress").length,
completed: owned.filter((t) => t.status === "completed").length,
tokensStr: formatTokens(activity.totalTokens),
activityText: toolActivity(activity.currentToolName),
elapsedStr: elapsed,
modelLabel: memberModel ? shortModelLabel(memberModel) : null,
thinkingLabel: memberThinking,
activeTaskSubject: activeTask ? `#${String(activeTask.id)} ${activeTask.subject}` : null,
activeTaskId: activeTask ? `#${String(activeTask.id)}` : null,
});
}

// ── Compute column widths ──
const totalPending = tasks.filter((t) => t.status === "pending").length;
const totalInProgress = tasks.filter((t) => t.status === "in_progress").length;
const totalCompleted = tasks.filter((t) => t.status === "completed").length;
let totalTokensRaw = 0;
for (const name of workerNames) totalTokensRaw += tracker.get(name).totalTokens;
const totalTokensStr = formatTokens(totalTokensRaw);

const nameColWidth = Math.max(...rows.map((r) => visibleWidth(r.displayName)));
const pW = Math.max(...rows.map((r) => String(r.pending).length), String(totalPending).length);
const iW = Math.max(...rows.map((r) => String(r.inProgress).length), String(totalInProgress).length);
const cW = Math.max(...rows.map((r) => String(r.completed).length), String(totalCompleted).length);
const tokW = Math.max(...rows.map((r) => r.tokensStr.length), totalTokensStr.length);

Expand All @@ -202,43 +207,42 @@ export function createTeamsWidget(deps: WidgetDeps): WidgetFactory {
const styledName = theme.bold(r.displayName);
const statusLabel = theme.fg(DISPLAY_STATUS_COLOR[r.statusKey], padRight(r.statusKey, 9));
const pNum = String(r.pending).padStart(pW);
const iNum = String(r.inProgress).padStart(iW);
const cNum = String(r.completed).padStart(cW);
const tokStr = r.tokensStr.padStart(tokW);
const cols = theme.fg(
"dim",
` \u00b7 ${pNum} pending \u00b7 ${cNum} complete \u00b7 ${tokStr} tokens`,
` \u00b7 ${pNum} pending \u00b7 ${iNum} active \u00b7 ${cNum} done \u00b7 ${tokStr} tokens`,
);
const elapsedLabel = r.elapsedStr ? " " + theme.fg("dim", r.elapsedStr) : "";
const actLabel = r.activityText ? " " + theme.fg("warning", r.activityText) : "";
// Active task ID inline (compact, stable height)
const taskIdLabel = r.activeTaskId ? " " + theme.fg("warning", r.activeTaskId) : "";
// Model + thinking badge (compact)
const badges: string[] = [];
if (r.modelLabel) badges.push(r.modelLabel);
if (r.thinkingLabel && r.thinkingLabel !== "off") badges.push(`t:${r.thinkingLabel}`);
const badgeStr = badges.length > 0 ? " " + theme.fg("muted", badges.join(" \u00b7 ")) : "";

const row = ` ${icon} ${padRight(styledName, nameColWidth)} ${statusLabel}${elapsedLabel}${cols}${actLabel}${badgeStr}`;
const row = ` ${icon} ${padRight(styledName, nameColWidth)} ${statusLabel}${taskIdLabel}${elapsedLabel}${cols}${actLabel}${badgeStr}`;
lines.push(truncateToWidth(row, width));
// Active task on second line (indented, only when actively working)
if (r.activeTaskSubject) {
const taskLine = ` ${theme.fg("dim", "\u2514")} ${theme.fg("warning", r.activeTaskSubject)}`;
lines.push(truncateToWidth(taskLine, width));
}
}

// ── Total row ──
const sepLine = " " + theme.fg("dim", "\u2500".repeat(Math.max(0, width - 2)));
lines.push(truncateToWidth(sepLine, width));

const totalLabel = theme.bold("Total");
const totalTaskCount = totalPending + totalCompleted;
const totalTaskCount = totalPending + totalInProgress + totalCompleted;
const pct = totalTaskCount > 0 ? Math.round((totalCompleted / totalTaskCount) * 100) : 0;
const pctLabel = theme.fg("success", padRight(`${pct}%`, 9));
const tpNum = String(totalPending).padStart(pW);
const tiNum = String(totalInProgress).padStart(iW);
const tcNum = String(totalCompleted).padStart(cW);
const ttokStr = totalTokensStr.padStart(tokW);
const totalSuffix = theme.fg(
"muted",
` \u00b7 ${tpNum} pending \u00b7 ${tcNum} complete \u00b7 ${ttokStr} tokens`,
` \u00b7 ${tpNum} pending \u00b7 ${tiNum} active \u00b7 ${tcNum} done \u00b7 ${ttokStr} tokens`,
);
// nameColWidth + 4 = " ◆ " + name; then " " + pctLabel fills the status column
const totalRow = ` ${padRight(totalLabel, nameColWidth + 3)} ${pctLabel}${totalSuffix}`;
Expand Down
Loading