pi-extended-teams 2.1.16
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +105 -0
- package/assets/pi-extended-teams-agent-navigation.png +0 -0
- package/assets/pi-extended-teams-in-action.png +0 -0
- package/extensions/agents/read-agent-report.ts +181 -0
- package/extensions/agents/read-agent-session-lifecycle.test.ts +605 -0
- package/extensions/agents/read-agent-session-lifecycle.ts +676 -0
- package/extensions/agents/read-agent.test.ts +3077 -0
- package/extensions/agents/read-agent.ts +1119 -0
- package/extensions/agents/write-agent.test.ts +513 -0
- package/extensions/agents/write-agent.ts +392 -0
- package/extensions/events/register-events.test.ts +465 -0
- package/extensions/events/register-events.ts +409 -0
- package/extensions/index.test.ts +1659 -0
- package/extensions/index.ts +1228 -0
- package/extensions/internal/agent-session-files.test.ts +164 -0
- package/extensions/internal/agent-session-files.ts +320 -0
- package/extensions/internal/debug.ts +44 -0
- package/extensions/internal/model-selection.ts +82 -0
- package/extensions/internal/pi-command.test.ts +191 -0
- package/extensions/internal/pi-command.ts +224 -0
- package/extensions/internal/pi-runtime-api.test.ts +43 -0
- package/extensions/internal/pi-runtime-api.ts +78 -0
- package/extensions/internal/schema.ts +19 -0
- package/extensions/internal/session-context-reference.test.ts +290 -0
- package/extensions/internal/session-context-reference.ts +430 -0
- package/extensions/internal/session-files.test.ts +225 -0
- package/extensions/internal/session-files.ts +273 -0
- package/extensions/internal/session-usage.ts +59 -0
- package/extensions/resources/spawn-resource-plan.test.ts +233 -0
- package/extensions/resources/spawn-resource-plan.ts +247 -0
- package/extensions/runtime/active-agent-sleep.test.ts +157 -0
- package/extensions/runtime/active-agent-sleep.ts +117 -0
- package/extensions/runtime/nested-read-agents.ts +22 -0
- package/extensions/runtime/pending-child-controller.test.ts +169 -0
- package/extensions/runtime/pending-child-controller.ts +280 -0
- package/extensions/runtime/types.ts +68 -0
- package/extensions/team/contracts.test.ts +111 -0
- package/extensions/team/lifecycle.test.ts +1095 -0
- package/extensions/team/lifecycle.ts +502 -0
- package/extensions/team/recipient-closure.test.ts +97 -0
- package/extensions/team/recipient-closure.ts +120 -0
- package/extensions/team/roster.test.ts +129 -0
- package/extensions/team/roster.ts +165 -0
- package/extensions/team/team-contracts.json +43 -0
- package/extensions/team/writer-screens.test.ts +50 -0
- package/extensions/team/writer-screens.ts +156 -0
- package/extensions/tools/agent-communication-tools.test.ts +306 -0
- package/extensions/tools/agent-communication-tools.ts +183 -0
- package/extensions/tools/coordination-tools.test.ts +670 -0
- package/extensions/tools/coordination-tools.ts +304 -0
- package/extensions/tools/delegation-guard.test.ts +95 -0
- package/extensions/tools/delegation-guard.ts +65 -0
- package/extensions/tools/file-claim-tools.test.ts +104 -0
- package/extensions/tools/file-claim-tools.ts +68 -0
- package/extensions/tools/model-tools.ts +53 -0
- package/extensions/tools/predefined-tools.test.ts +647 -0
- package/extensions/tools/predefined-tools.ts +331 -0
- package/extensions/tools/read-helper.test.ts +35 -0
- package/extensions/tools/task-runtime-tools.test.ts +334 -0
- package/extensions/tools/task-runtime-tools.ts +227 -0
- package/extensions/tools/team-tools.read-agent.test.ts +1924 -0
- package/extensions/tools/team-tools.ts +1270 -0
- package/extensions/ui/agent-follow-view.test.ts +520 -0
- package/extensions/ui/agent-follow-view.ts +779 -0
- package/extensions/ui/agent-navigation.test.ts +52 -0
- package/extensions/ui/agent-navigation.ts +55 -0
- package/extensions/ui/ansi.ts +16 -0
- package/extensions/ui/extensions-command.test.ts +336 -0
- package/extensions/ui/extensions-command.ts +282 -0
- package/extensions/ui/favorite-models-command.test.ts +280 -0
- package/extensions/ui/favorite-models-command.ts +423 -0
- package/extensions/ui/frame.ts +77 -0
- package/extensions/ui/input.ts +17 -0
- package/extensions/ui/read-agent-status.test.ts +117 -0
- package/extensions/ui/read-agent-status.ts +125 -0
- package/extensions/ui/renderers.ts +243 -0
- package/extensions/ui/status-widget.test.ts +143 -0
- package/extensions/ui/status-widget.ts +440 -0
- package/extensions/ui-frame.test.ts +69 -0
- package/package.json +75 -0
- package/skills/teams.md +256 -0
- package/src/adapters/terminal-registry.ts +78 -0
- package/src/adapters/tmux-adapter.test.ts +276 -0
- package/src/adapters/tmux-adapter.ts +200 -0
- package/src/orchestration/index.ts +437 -0
- package/src/orchestration/orchestrator.test.ts +396 -0
- package/src/orchestration/types.ts +125 -0
- package/src/utils/atomic-json.ts +21 -0
- package/src/utils/claims.test.ts +137 -0
- package/src/utils/claims.ts +169 -0
- package/src/utils/hooks.test.ts +75 -0
- package/src/utils/hooks.ts +35 -0
- package/src/utils/lifecycle-tombstone.test.ts +105 -0
- package/src/utils/lifecycle-tombstone.ts +268 -0
- package/src/utils/lock.race.child.ts +44 -0
- package/src/utils/lock.race.test.ts +198 -0
- package/src/utils/lock.test.ts +90 -0
- package/src/utils/lock.ts +186 -0
- package/src/utils/messaging.test.ts +337 -0
- package/src/utils/messaging.ts +441 -0
- package/src/utils/model-resolution.test.ts +231 -0
- package/src/utils/model-resolution.ts +322 -0
- package/src/utils/models.test.ts +8 -0
- package/src/utils/models.ts +115 -0
- package/src/utils/paths.ts +81 -0
- package/src/utils/predefined-teams/types.ts +48 -0
- package/src/utils/predefined-teams.save-template.test.ts +74 -0
- package/src/utils/predefined-teams.test.ts +441 -0
- package/src/utils/predefined-teams.ts +471 -0
- package/src/utils/read-helper-queue.ts +99 -0
- package/src/utils/report-events.test.ts +154 -0
- package/src/utils/report-events.ts +222 -0
- package/src/utils/runtime.test.ts +314 -0
- package/src/utils/runtime.ts +261 -0
- package/src/utils/security.test.ts +43 -0
- package/src/utils/settings.test.ts +480 -0
- package/src/utils/settings.ts +645 -0
- package/src/utils/shared-memory.test.ts +80 -0
- package/src/utils/shared-memory.ts +81 -0
- package/src/utils/tasks.race.test.ts +44 -0
- package/src/utils/tasks.test.ts +229 -0
- package/src/utils/tasks.ts +396 -0
- package/src/utils/teams.ts +231 -0
- package/src/utils/terminal-adapter.ts +103 -0
- package/src/utils/thinking-levels.test.ts +56 -0
- package/src/utils/thinking-levels.ts +47 -0
- package/src/utils/workflow-metadata.test.ts +11 -0
- package/src/utils/workflow-metadata.ts +88 -0
- package/src/utils/write-queue.test.ts +251 -0
- package/src/utils/write-queue.ts +202 -0
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import type { RunningReadAgent } from "../runtime/types";
|
|
2
|
+
import { formatElapsed } from "./renderers.js";
|
|
3
|
+
|
|
4
|
+
export const READ_AGENT_IDLE_NUDGE_MS = 5 * 60_000;
|
|
5
|
+
export const READ_AGENT_HANGING_NUDGE_MS = 15 * 60_000;
|
|
6
|
+
|
|
7
|
+
export type ReadAgentStatusLabel = RunningReadAgent["status"] | "idle" | "hanging";
|
|
8
|
+
export type ReadAgentIdleLevel = "none" | "soft" | "hard";
|
|
9
|
+
|
|
10
|
+
export interface ReadAgentStatusDescription {
|
|
11
|
+
label: ReadAgentStatusLabel;
|
|
12
|
+
detail: string;
|
|
13
|
+
idleLevel: ReadAgentIdleLevel;
|
|
14
|
+
idleMs: number;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export type ReadAgentStatusCounts = Partial<Record<ReadAgentStatusLabel, number>>;
|
|
18
|
+
|
|
19
|
+
export interface ReadAgentStatusSample {
|
|
20
|
+
agent: RunningReadAgent;
|
|
21
|
+
status: ReadAgentStatusDescription;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface ReadAgentStatusSummary {
|
|
25
|
+
total: number;
|
|
26
|
+
counts: ReadAgentStatusCounts;
|
|
27
|
+
samples: ReadAgentStatusSample[];
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface ReadAgentStatusSummaryOptions {
|
|
31
|
+
now?: number;
|
|
32
|
+
maxDetailed?: number;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
interface ReadAgentStatusClassification {
|
|
36
|
+
label: ReadAgentStatusLabel;
|
|
37
|
+
idleLevel: ReadAgentIdleLevel;
|
|
38
|
+
idleMs: number;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function buildReadAgentIdleNudgeMessage(agent: RunningReadAgent, status: ReadAgentStatusDescription): string {
|
|
42
|
+
const severity = status.idleLevel === "hard" ? "appears hung" : "has gone quiet";
|
|
43
|
+
return `Read agent ${agent.name} on team ${agent.teamName} ${severity}: no response or token change for ${formatElapsed(status.idleMs)}. Status is visible in the live agent view; do not ping/check repeatedly.`;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function shouldNudgeReadAgentIdle(_previousLevel: "soft" | "hard" | undefined, _currentLevel: ReadAgentIdleLevel): boolean {
|
|
47
|
+
return false;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function describeReadAgentStatus(agent: RunningReadAgent, now = Date.now()): ReadAgentStatusDescription {
|
|
51
|
+
return describeReadAgentStatusFromClassification(agent, classifyReadAgentStatus(agent, now));
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function summarizeReadAgentStatuses(
|
|
55
|
+
agents: Iterable<RunningReadAgent>,
|
|
56
|
+
options: ReadAgentStatusSummaryOptions = {}
|
|
57
|
+
): ReadAgentStatusSummary {
|
|
58
|
+
const now = options.now ?? Date.now();
|
|
59
|
+
const maxDetailed = Math.max(0, options.maxDetailed ?? Number.POSITIVE_INFINITY);
|
|
60
|
+
const counts: ReadAgentStatusCounts = {};
|
|
61
|
+
const samples: ReadAgentStatusSample[] = [];
|
|
62
|
+
let total = 0;
|
|
63
|
+
|
|
64
|
+
for (const agent of agents) {
|
|
65
|
+
const classification = classifyReadAgentStatus(agent, now);
|
|
66
|
+
counts[classification.label] = (counts[classification.label] ?? 0) + 1;
|
|
67
|
+
if (samples.length < maxDetailed) {
|
|
68
|
+
samples.push({
|
|
69
|
+
agent,
|
|
70
|
+
status: describeReadAgentStatusFromClassification(agent, classification),
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
total++;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return { total, counts, samples };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function classifyReadAgentStatus(agent: RunningReadAgent, now: number): ReadAgentStatusClassification {
|
|
80
|
+
const lastActivityAt = agent.lastActivityAt || agent.startedAt;
|
|
81
|
+
const idleMs = Math.max(0, now - lastActivityAt);
|
|
82
|
+
|
|
83
|
+
if (idleMs >= READ_AGENT_HANGING_NUDGE_MS) {
|
|
84
|
+
return { label: "hanging", idleLevel: "hard", idleMs };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
if (idleMs >= READ_AGENT_IDLE_NUDGE_MS) {
|
|
88
|
+
return { label: "idle", idleLevel: "soft", idleMs };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return { label: agent.status, idleLevel: "none", idleMs };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function describeReadAgentStatusFromClassification(
|
|
95
|
+
agent: RunningReadAgent,
|
|
96
|
+
classification: ReadAgentStatusClassification
|
|
97
|
+
): ReadAgentStatusDescription {
|
|
98
|
+
if (classification.idleLevel === "hard") {
|
|
99
|
+
return {
|
|
100
|
+
...classification,
|
|
101
|
+
detail: `no response/token change for ${formatElapsed(classification.idleMs)} · visible in live agent view`,
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
if (classification.idleLevel === "soft") {
|
|
106
|
+
return {
|
|
107
|
+
...classification,
|
|
108
|
+
detail: `no response/token change for ${formatElapsed(classification.idleMs)} · visible in live agent view`,
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
return {
|
|
113
|
+
...classification,
|
|
114
|
+
detail: describeActiveReadAgentWork(agent),
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function describeActiveReadAgentWork(agent: RunningReadAgent): string {
|
|
119
|
+
if (agent.status === "starting") return "starting session";
|
|
120
|
+
if (agent.status === "finishing") return "sending report";
|
|
121
|
+
if (agent.status === "working") {
|
|
122
|
+
return agent.activeToolName ? `using ${agent.activeToolName}` : "using tools";
|
|
123
|
+
}
|
|
124
|
+
return "waiting for model response";
|
|
125
|
+
}
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
import { keyText } from "@mariozechner/pi-coding-agent";
|
|
2
|
+
import { Text, truncateToWidth } from "@mariozechner/pi-tui";
|
|
3
|
+
import type { ContextUsageSnapshot } from "../../src/utils/runtime";
|
|
4
|
+
import { dimAnsi, pink, purple } from "./ansi";
|
|
5
|
+
|
|
6
|
+
const ANSI_ESCAPE_SEQUENCE = /\x1b(?:\[[0-?]*[ -/]*[@-~]|\][^\x07\x1b]*(?:\x07|\x1b\\)|_[^\x07\x1b]*(?:\x07|\x1b\\)|[PX^][^\x1b]*(?:\x1b\\|\x07)|[@-Z\\-_])/g;
|
|
7
|
+
const SAFE_SGR_SEQUENCE = /^\x1b\[[0-9;]*m$/;
|
|
8
|
+
const ANIMATED_PROGRESS_DOTS = [".", "..", "..."] as const;
|
|
9
|
+
|
|
10
|
+
export function sanitizeTuiText(text: string): string {
|
|
11
|
+
return text
|
|
12
|
+
.replace(ANSI_ESCAPE_SEQUENCE, (sequence) => SAFE_SGR_SEQUENCE.test(sequence) ? sequence : "")
|
|
13
|
+
.replace(/\r\n?/g, "\n")
|
|
14
|
+
.replace(/\t/g, " ")
|
|
15
|
+
.replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001A\u001C-\u001F\u007F-\u009F]/g, "")
|
|
16
|
+
.replace(/\x1b(?!\[[0-9;]*m)/g, "");
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function sanitizeTuiLine(text: string): string {
|
|
20
|
+
return sanitizeTuiText(text).replace(/\n/g, " ");
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function sanitizePlainTuiLine(text: string): string {
|
|
24
|
+
return sanitizeTuiLine(text).replace(/\x1b\[[0-9;]*m/g, "");
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function formatModelLabel(model?: string, thinking?: string): string {
|
|
28
|
+
const shortModel = model ? model.split("/").pop() || model : "";
|
|
29
|
+
const t = thinking && thinking !== "off" ? ` · ${thinking}` : "";
|
|
30
|
+
return shortModel ? `${shortModel}${t}` : "";
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function formatAnimatedProgress(text: string, now: number): string {
|
|
34
|
+
const base = sanitizePlainTuiLine(text).replace(/\.+$/, "");
|
|
35
|
+
const phase = Math.floor(now / 1000) % ANIMATED_PROGRESS_DOTS.length;
|
|
36
|
+
return `${base}${phase === 0 ? ANIMATED_PROGRESS_DOTS[0] : phase === 1 ? ANIMATED_PROGRESS_DOTS[1] : ANIMATED_PROGRESS_DOTS[2]}`;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function formatElapsed(ms: number): string {
|
|
40
|
+
const totalSeconds = Math.max(0, Math.floor(ms / 1000));
|
|
41
|
+
const minutes = Math.floor(totalSeconds / 60);
|
|
42
|
+
const seconds = totalSeconds % 60;
|
|
43
|
+
return minutes > 0 ? `${minutes}m${seconds.toString().padStart(2, "0")}s` : `${seconds}s`;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function formatTokenCount(count: number): string {
|
|
47
|
+
if (count < 1000) return `${count}`;
|
|
48
|
+
if (count < 1000000) return `${(count / 1000).toFixed(count < 10000 ? 1 : 0)}k`;
|
|
49
|
+
return `${(count / 1000000).toFixed(1)}M`;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function formatContextUsage(usage: ContextUsageSnapshot | undefined): string {
|
|
53
|
+
if (typeof usage?.tokens !== "number"
|
|
54
|
+
|| !Number.isFinite(usage.tokens)
|
|
55
|
+
|| typeof usage.percent !== "number"
|
|
56
|
+
|| !Number.isFinite(usage.percent)) {
|
|
57
|
+
return "? tok (?%)";
|
|
58
|
+
}
|
|
59
|
+
return `${formatTokenCount(usage.tokens)} tok (${Math.round(usage.percent)}%)`;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function extractTextParts(content: any): string {
|
|
63
|
+
if (typeof content === "string") return content.trim();
|
|
64
|
+
if (Array.isArray(content)) {
|
|
65
|
+
return content
|
|
66
|
+
.filter((part: any) => part?.type === "text" && typeof part.text === "string")
|
|
67
|
+
.map((part: any) => part.text)
|
|
68
|
+
.join("\n")
|
|
69
|
+
.trim();
|
|
70
|
+
}
|
|
71
|
+
return "";
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function getLastAssistantText(messages: any[]): string {
|
|
75
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
76
|
+
if (messages[i]?.role !== "assistant") continue;
|
|
77
|
+
const text = extractTextParts(messages[i].content);
|
|
78
|
+
if (text) return text;
|
|
79
|
+
}
|
|
80
|
+
return "";
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function formatTranscriptLines(messages: any[]): string[] {
|
|
84
|
+
const out: string[] = [];
|
|
85
|
+
for (const message of messages || []) {
|
|
86
|
+
if (message?.role === "user") {
|
|
87
|
+
const text = sanitizeTuiText(extractTextParts(message.content));
|
|
88
|
+
if (text) out.push(`${pink("user ▸")} ${text}`);
|
|
89
|
+
} else if (message?.role === "assistant") {
|
|
90
|
+
for (const part of Array.isArray(message.content) ? message.content : []) {
|
|
91
|
+
if (part?.type === "text" && part.text?.trim()) out.push(sanitizeTuiText(part.text.trim()));
|
|
92
|
+
else if (part?.type === "toolCall") out.push(purple(`⚙ ${sanitizeTuiLine(String(part.name || "tool"))}`));
|
|
93
|
+
}
|
|
94
|
+
} else if (message?.role === "toolResult") {
|
|
95
|
+
const text = sanitizeTuiText(extractTextParts(message.content));
|
|
96
|
+
const head = text.split("\n").find((line: string) => line.trim()) || "(no output)";
|
|
97
|
+
out.push(dimAnsi(` ↳ ${sanitizeTuiLine(String(message.toolName || "tool"))}: ${head}`));
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
return out;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export function summarizeInboxMessage(message: any): string {
|
|
104
|
+
const from = message?.from || "unknown";
|
|
105
|
+
const summary = message?.summary || "message";
|
|
106
|
+
const timestamp = message?.timestamp ? new Date(message.timestamp).toLocaleTimeString() : "";
|
|
107
|
+
return `${from}${timestamp ? ` ${timestamp}` : ""}: ${summary}`;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export function formatInboxMessagesForModel(messages: any[]): string {
|
|
111
|
+
if (messages.length === 0) return "No inbox messages.";
|
|
112
|
+
|
|
113
|
+
return messages.map((message, index) => {
|
|
114
|
+
const header = `[${index + 1}] ${summarizeInboxMessage(message)}`;
|
|
115
|
+
const text = String(message?.text || "").trim() || "(empty message)";
|
|
116
|
+
return `${header}\n${text}`;
|
|
117
|
+
}).join("\n\n");
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export function renderInboxMessages(result: any, expanded: boolean, theme: any) {
|
|
121
|
+
const messages = result.details?.messages || [];
|
|
122
|
+
if (messages.length === 0) {
|
|
123
|
+
return new Text(theme.fg("muted", "inbox: no messages"), 0, 0);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const lines = [theme.fg("toolTitle", theme.bold(`inbox: ${messages.length} message${messages.length === 1 ? "" : "s"}`))];
|
|
127
|
+
if (expanded) {
|
|
128
|
+
for (const [index, message] of messages.entries()) {
|
|
129
|
+
lines.push(theme.fg("muted", `\n${index + 1}. ${summarizeInboxMessage(message)}`));
|
|
130
|
+
lines.push(String(message?.text || "").trim() || theme.fg("dim", "(empty message)"));
|
|
131
|
+
}
|
|
132
|
+
} else {
|
|
133
|
+
for (const [index, message] of messages.slice(0, 5).entries()) {
|
|
134
|
+
lines.push(truncateToWidth(`${index + 1}. ${summarizeInboxMessage(message)}`, 120));
|
|
135
|
+
}
|
|
136
|
+
if (messages.length > 5) lines.push(theme.fg("dim", `… ${messages.length - 5} more`));
|
|
137
|
+
lines.push(theme.fg("dim", "expand tool output to read full message text"));
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
return new Text(lines.join("\n"), 0, 0);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export function formatTeammateStatusForModel(agentName: string, details: any): string {
|
|
144
|
+
const heartbeat = details.hasRecentHeartbeat ? "fresh heartbeat" : "stale/no heartbeat";
|
|
145
|
+
const ready = details.agentLoopReady ? "ready" : "not ready";
|
|
146
|
+
const unread = `${details.unreadCount ?? 0} unread`;
|
|
147
|
+
const released = details.releasedClaims?.length ? `; released claims: ${details.releasedClaims.join(", ")}` : "";
|
|
148
|
+
return `${agentName}: ${details.health}; ${unread}; ${ready}; ${heartbeat}${released}`;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export function renderTeammateStatus(result: any, expanded: boolean, theme: any) {
|
|
152
|
+
const details = result.details || {};
|
|
153
|
+
const agentName = details.agentName || "teammate";
|
|
154
|
+
const health = details.health || (details.alive ? "alive" : "dead");
|
|
155
|
+
const color = health === "dead" || health === "stalled" ? "warning" : "success";
|
|
156
|
+
const headline = `${agentName}: ${health} • ${details.unreadCount ?? 0} unread • ${details.agentLoopReady ? "ready" : "not ready"}`;
|
|
157
|
+
|
|
158
|
+
if (!expanded) {
|
|
159
|
+
return new Text(theme.fg(color, headline), 0, 0);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const runtimeStatus = details.runtime || {};
|
|
163
|
+
const lines = [theme.fg(color, theme.bold(headline))];
|
|
164
|
+
lines.push(`alive: ${details.alive ? "yes" : "no"}`);
|
|
165
|
+
lines.push(`heartbeat: ${details.hasRecentHeartbeat ? "fresh" : "stale/missing"}`);
|
|
166
|
+
if (runtimeStatus.startedAt) lines.push(`started: ${new Date(runtimeStatus.startedAt).toLocaleString()}`);
|
|
167
|
+
if (runtimeStatus.lastHeartbeatAt) lines.push(`last heartbeat: ${new Date(runtimeStatus.lastHeartbeatAt).toLocaleString()}`);
|
|
168
|
+
if (details.startupStalled) lines.push(theme.fg("warning", "startup appears stalled"));
|
|
169
|
+
if (details.releasedClaims?.length) lines.push(`released claims: ${details.releasedClaims.join(", ")}`);
|
|
170
|
+
return new Text(lines.join("\n"), 0, 0);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function expandKeyLabel(): string {
|
|
174
|
+
try {
|
|
175
|
+
return keyText("app.tools.expand") || "ctrl+o";
|
|
176
|
+
} catch {
|
|
177
|
+
return "ctrl+o";
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function plural(count: number, label: string): string {
|
|
182
|
+
return `${count} ${label}${count === 1 ? "" : "s"}`;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function memberStatusLine(member: any): string {
|
|
186
|
+
const runtimeStatus = member.runtime || {};
|
|
187
|
+
const bits = [
|
|
188
|
+
member.role,
|
|
189
|
+
member.health,
|
|
190
|
+
`${member.unreadCount ?? 0} unread`,
|
|
191
|
+
member.agentLoopReady ? "ready" : "not ready",
|
|
192
|
+
member.hasRecentHeartbeat ? "fresh heartbeat" : "stale/no heartbeat",
|
|
193
|
+
runtimeStatus.currentAction,
|
|
194
|
+
runtimeStatus.activeToolName ? `tool: ${runtimeStatus.activeToolName}` : "",
|
|
195
|
+
].filter(Boolean);
|
|
196
|
+
return `${member.agentName || member.member?.name || "teammate"}: ${bits.join(" · ")}`;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
export function renderTeamObservation(result: any, expanded: boolean, theme: any) {
|
|
200
|
+
const details = result.details || {};
|
|
201
|
+
const members = Array.isArray(details.members) ? details.members : [];
|
|
202
|
+
const tasks = Array.isArray(details.tasks) ? details.tasks : [];
|
|
203
|
+
const claims = Array.isArray(details.claims) ? details.claims : [];
|
|
204
|
+
const writeQueue = Array.isArray(details.writeQueue) ? details.writeQueue : [];
|
|
205
|
+
const reports = Array.isArray(details.reports) ? details.reports : [];
|
|
206
|
+
const activeMembers = members.filter((member: any) => member.agentName !== "team-lead" && member.alive !== false);
|
|
207
|
+
const unread = members.reduce((sum: number, member: any) => sum + (Number(member.unreadCount) || 0), 0);
|
|
208
|
+
const troubled = members.filter((member: any) => member.health === "dead" || member.health === "stalled");
|
|
209
|
+
const teamName = details.teamName || "team";
|
|
210
|
+
const summary = [
|
|
211
|
+
plural(members.length, "member"),
|
|
212
|
+
plural(activeMembers.length, "active agent"),
|
|
213
|
+
unread ? plural(unread, "unread message") : "0 unread messages",
|
|
214
|
+
tasks.length ? plural(tasks.length, "task") : "0 tasks",
|
|
215
|
+
claims.length ? plural(claims.length, "claim") : "0 claims",
|
|
216
|
+
writeQueue.length ? `${writeQueue.length} queued writer${writeQueue.length === 1 ? "" : "s"}` : "0 queued writers",
|
|
217
|
+
reports.length ? plural(reports.length, "report") : "0 reports",
|
|
218
|
+
].join(" · ");
|
|
219
|
+
const color = troubled.length > 0 ? "warning" : "success";
|
|
220
|
+
const headline = `${teamName}: ${summary}`;
|
|
221
|
+
|
|
222
|
+
if (!expanded) {
|
|
223
|
+
return new Text(`${theme.fg(color, headline)} ${theme.fg("dim", `(${expandKeyLabel()} details)`)}`, 0, 0);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
const lines = [theme.fg(color, theme.bold(headline))];
|
|
227
|
+
if (troubled.length > 0) lines.push(theme.fg("warning", `${troubled.length} member${troubled.length === 1 ? "" : "s"} need attention`));
|
|
228
|
+
|
|
229
|
+
if (members.length > 0) {
|
|
230
|
+
lines.push(theme.fg("muted", "members:"));
|
|
231
|
+
for (const member of members.slice(0, 12)) lines.push(` ${memberStatusLine(member)}`);
|
|
232
|
+
if (members.length > 12) lines.push(theme.fg("dim", ` … ${members.length - 12} more`));
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
const sections = [
|
|
236
|
+
tasks.length ? `tasks: ${tasks.map((task: any) => task.id || task.subject || task.path || "task").slice(0, 5).join(", ")}${tasks.length > 5 ? `, … ${tasks.length - 5} more` : ""}` : "tasks: none",
|
|
237
|
+
claims.length ? `claims: ${claims.map((claim: any) => claim.path || claim.file || "claim").slice(0, 5).join(", ")}${claims.length > 5 ? `, … ${claims.length - 5} more` : ""}` : "claims: none",
|
|
238
|
+
writeQueue.length ? `write queue: ${writeQueue.map((item: any) => item.name || item.agentName || "writer").slice(0, 5).join(", ")}${writeQueue.length > 5 ? `, … ${writeQueue.length - 5} more` : ""}` : "write queue: empty",
|
|
239
|
+
reports.length ? `reports: ${reports.map((report: any) => report.name || report.agentName || report.summary || "report").slice(0, 5).join(", ")}${reports.length > 5 ? `, … ${reports.length - 5} more` : ""}` : "reports: none",
|
|
240
|
+
];
|
|
241
|
+
lines.push(...sections.map((line) => theme.fg("muted", line)));
|
|
242
|
+
return new Text(lines.join("\n"), 0, 0);
|
|
243
|
+
}
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { teamActivityStatusWidget, type TeamActivityStatusEntry, type TeamActivityStatusSnapshot } from "./status-widget.js";
|
|
3
|
+
|
|
4
|
+
function makeSnapshot(overrides: Partial<TeamActivityStatusSnapshot> = {}): TeamActivityStatusSnapshot {
|
|
5
|
+
return {
|
|
6
|
+
activeCount: 0,
|
|
7
|
+
readCount: 0,
|
|
8
|
+
writeCount: 0,
|
|
9
|
+
unreadCount: 0,
|
|
10
|
+
entries: [],
|
|
11
|
+
updatedAt: Date.UTC(2026, 0, 1, 0, 0, 0),
|
|
12
|
+
...overrides,
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
describe("agent activity status widget", () => {
|
|
17
|
+
it("renders open snapshots without a collapse hint", () => {
|
|
18
|
+
const entries: TeamActivityStatusEntry[] = [{
|
|
19
|
+
name: "reader",
|
|
20
|
+
role: "read",
|
|
21
|
+
status: "thinking",
|
|
22
|
+
detail: "provider/model · 12 tok · waiting for model response",
|
|
23
|
+
}];
|
|
24
|
+
const snapshot = makeSnapshot({
|
|
25
|
+
activeCount: 1,
|
|
26
|
+
readCount: 1,
|
|
27
|
+
entries,
|
|
28
|
+
statusCounts: { thinking: 1 },
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
const lines = teamActivityStatusWidget(() => snapshot, () => false).render(120);
|
|
32
|
+
const rendered = lines.join("\n");
|
|
33
|
+
|
|
34
|
+
expect(lines[0]).toContain("agent activity");
|
|
35
|
+
expect(lines.at(-1)).toContain("─");
|
|
36
|
+
expect(rendered).toContain("1 active");
|
|
37
|
+
expect(rendered).toContain("1 read");
|
|
38
|
+
expect(rendered).toContain("↓ navigate");
|
|
39
|
+
expect(rendered).toContain("reader");
|
|
40
|
+
expect(rendered).toContain("thinking");
|
|
41
|
+
expect(rendered).not.toContain("collapse");
|
|
42
|
+
expect(rendered).not.toContain("ctrl+o");
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("fades the old progress and reveals the new phrase within one second", () => {
|
|
46
|
+
vi.useFakeTimers();
|
|
47
|
+
vi.setSystemTime(0);
|
|
48
|
+
try {
|
|
49
|
+
let snapshot = makeSnapshot({
|
|
50
|
+
activeCount: 1,
|
|
51
|
+
readCount: 1,
|
|
52
|
+
entries: [{
|
|
53
|
+
name: "reader",
|
|
54
|
+
role: "read",
|
|
55
|
+
displayText: "(reader) model/high · reading-fast · 1s · 10 tok · Collecting files.",
|
|
56
|
+
}],
|
|
57
|
+
});
|
|
58
|
+
const requestRender = vi.fn();
|
|
59
|
+
const widget = teamActivityStatusWidget(() => snapshot, () => false, requestRender);
|
|
60
|
+
expect(widget.render(120).join("\n")).toContain("Collecting files.");
|
|
61
|
+
|
|
62
|
+
snapshot = makeSnapshot({
|
|
63
|
+
activeCount: 1,
|
|
64
|
+
readCount: 1,
|
|
65
|
+
entries: [{
|
|
66
|
+
name: "reader",
|
|
67
|
+
role: "read",
|
|
68
|
+
displayText: "(reader) model/high · reading-fast · 2s · 12 tok · Reviewing results.",
|
|
69
|
+
}],
|
|
70
|
+
});
|
|
71
|
+
const fading = widget.render(120).join("\n");
|
|
72
|
+
expect(fading).toContain("Collecting files");
|
|
73
|
+
expect(fading).not.toContain("Reviewing results");
|
|
74
|
+
|
|
75
|
+
vi.advanceTimersByTime(650);
|
|
76
|
+
const revealing = widget.render(120).join("\n");
|
|
77
|
+
expect(revealing).toContain("Reviewing");
|
|
78
|
+
expect(revealing).not.toContain("Reviewing results.");
|
|
79
|
+
|
|
80
|
+
vi.advanceTimersByTime(350);
|
|
81
|
+
expect(widget.render(120).join("\n")).toMatch(/Reviewing results\.{1,3}/);
|
|
82
|
+
expect(requestRender).toHaveBeenCalled();
|
|
83
|
+
widget.dispose();
|
|
84
|
+
} finally {
|
|
85
|
+
vi.useRealTimers();
|
|
86
|
+
}
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it("updates a context-only suffix immediately instead of animating it as progress", () => {
|
|
90
|
+
let snapshot = makeSnapshot({
|
|
91
|
+
activeCount: 1,
|
|
92
|
+
readCount: 1,
|
|
93
|
+
entries: [{
|
|
94
|
+
name: "reader",
|
|
95
|
+
role: "read",
|
|
96
|
+
displayText: "(reader) model/high · read-review · 1s · 46k tok (23%)",
|
|
97
|
+
}],
|
|
98
|
+
});
|
|
99
|
+
const widget = teamActivityStatusWidget(() => snapshot, () => false);
|
|
100
|
+
|
|
101
|
+
expect(widget.render(120).join("\n")).toContain("46k tok (23%)");
|
|
102
|
+
snapshot = makeSnapshot({
|
|
103
|
+
activeCount: 1,
|
|
104
|
+
readCount: 1,
|
|
105
|
+
updatedAt: snapshot.updatedAt + 1,
|
|
106
|
+
entries: [{
|
|
107
|
+
name: "reader",
|
|
108
|
+
role: "read",
|
|
109
|
+
displayText: "(reader) model/high · read-review · 2s · 80k tok (40%)",
|
|
110
|
+
}],
|
|
111
|
+
});
|
|
112
|
+
const updated = widget.render(120).join("\n");
|
|
113
|
+
|
|
114
|
+
expect(updated).toContain("80k tok (40%)");
|
|
115
|
+
expect(updated).not.toContain("46k tok (23%)");
|
|
116
|
+
widget.dispose();
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it("keeps expanded rendering bounded while surfacing the aggregate summary", () => {
|
|
120
|
+
const entries = Array.from({ length: 12 }, (_, index) => ({
|
|
121
|
+
name: `reader-${index}`,
|
|
122
|
+
role: "read" as const,
|
|
123
|
+
status: index < 8 ? "thinking" : "working",
|
|
124
|
+
detail: `detail-${index}`,
|
|
125
|
+
}));
|
|
126
|
+
const snapshot = makeSnapshot({
|
|
127
|
+
activeCount: 12,
|
|
128
|
+
readCount: 12,
|
|
129
|
+
entries,
|
|
130
|
+
statusCounts: { thinking: 8, working: 4 },
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
const rendered = teamActivityStatusWidget(() => snapshot, () => true).render(160).join("\n");
|
|
134
|
+
|
|
135
|
+
expect(rendered).toContain("8 thinking");
|
|
136
|
+
expect(rendered).toContain("4 working");
|
|
137
|
+
expect(rendered).toContain("reader-0");
|
|
138
|
+
expect(rendered).toContain("reader-9");
|
|
139
|
+
expect(rendered).not.toContain("reader-10");
|
|
140
|
+
expect(rendered).not.toContain("detail-10");
|
|
141
|
+
expect(rendered).toContain("2 more active agents");
|
|
142
|
+
});
|
|
143
|
+
});
|