mirror of
https://github.com/catlog22/Claude-Code-Workflow.git
synced 2026-02-12 02:37:45 +08:00
feat: Add comprehensive tests for contentPattern and glob pattern matching
- Implemented final verification tests for contentPattern to validate behavior with empty strings, dangerous patterns, and normal patterns. - Created glob pattern matching tests to verify regex conversion and matching functionality. - Developed infinite loop risk tests using Worker threads to isolate potential blocking operations. - Introduced optimized contentPattern tests to validate improvements in the findMatches function. - Added verification tests to assess the effectiveness of contentPattern optimizations. - Conducted safety tests for contentPattern to identify edge cases and potential vulnerabilities. - Implemented unrestricted loop tests to analyze infinite loop risks without match limits. - Developed tests for zero-width pattern detection logic to ensure proper handling of dangerous regex patterns.
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
# CCW - Claude Code Workflow CLI
|
||||
NEW LINE
|
||||
|
||||
[](https://github.com/catlog22/Claude-Code-Workflow/releases)
|
||||
|
||||
|
||||
387
ccw/docs/team.md
Normal file
387
ccw/docs/team.md
Normal file
@@ -0,0 +1,387 @@
|
||||
> ## Documentation Index
|
||||
> Fetch the complete documentation index at: https://code.claude.com/docs/llms.txt
|
||||
> Use this file to discover all available pages before exploring further.
|
||||
|
||||
# Orchestrate teams of Claude Code sessions
|
||||
|
||||
> Coordinate multiple Claude Code instances working together as a team, with shared tasks, inter-agent messaging, and centralized management.
|
||||
|
||||
<Warning>
|
||||
Agent teams are experimental and disabled by default. Enable them by adding `CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS` to your [settings.json](/en/settings) or environment. Agent teams have [known limitations](#limitations) around session resumption, task coordination, and shutdown behavior.
|
||||
</Warning>
|
||||
|
||||
Agent teams let you coordinate multiple Claude Code instances working together. One session acts as the team lead, coordinating work, assigning tasks, and synthesizing results. Teammates work independently, each in its own context window, and communicate directly with each other.
|
||||
|
||||
Unlike [subagents](/en/sub-agents), which run within a single session and can only report back to the main agent, you can also interact with individual teammates directly without going through the lead.
|
||||
|
||||
This page covers:
|
||||
|
||||
* [When to use agent teams](#when-to-use-agent-teams), including best use cases and how they compare with subagents
|
||||
* [Starting a team](#start-your-first-agent-team)
|
||||
* [Controlling teammates](#control-your-agent-team), including display modes, task assignment, and delegation
|
||||
* [Best practices for parallel work](#best-practices)
|
||||
|
||||
## When to use agent teams
|
||||
|
||||
Agent teams are most effective for tasks where parallel exploration adds real value. See [use case examples](#use-case-examples) for full scenarios. The strongest use cases are:
|
||||
|
||||
* **Research and review**: multiple teammates can investigate different aspects of a problem simultaneously, then share and challenge each other's findings
|
||||
* **New modules or features**: teammates can each own a separate piece without stepping on each other
|
||||
* **Debugging with competing hypotheses**: teammates test different theories in parallel and converge on the answer faster
|
||||
* **Cross-layer coordination**: changes that span frontend, backend, and tests, each owned by a different teammate
|
||||
|
||||
Agent teams add coordination overhead and use significantly more tokens than a single session. They work best when teammates can operate independently. For sequential tasks, same-file edits, or work with many dependencies, a single session or [subagents](/en/sub-agents) are more effective.
|
||||
|
||||
### Compare with subagents
|
||||
|
||||
Both agent teams and [subagents](/en/sub-agents) let you parallelize work, but they operate differently. Choose based on whether your workers need to communicate with each other:
|
||||
|
||||
| | Subagents | Agent teams |
|
||||
| :---------------- | :----------------------------------------------- | :-------------------------------------------------- |
|
||||
| **Context** | Own context window; results return to the caller | Own context window; fully independent |
|
||||
| **Communication** | Report results back to the main agent only | Teammates message each other directly |
|
||||
| **Coordination** | Main agent manages all work | Shared task list with self-coordination |
|
||||
| **Best for** | Focused tasks where only the result matters | Complex work requiring discussion and collaboration |
|
||||
| **Token cost** | Lower: results summarized back to main context | Higher: each teammate is a separate Claude instance |
|
||||
|
||||
Use subagents when you need quick, focused workers that report back. Use agent teams when teammates need to share findings, challenge each other, and coordinate on their own.
|
||||
|
||||
## Enable agent teams
|
||||
|
||||
Agent teams are disabled by default. Enable them by setting the `CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS` environment variable to `1`, either in your shell environment or through [settings.json](/en/settings):
|
||||
|
||||
```json settings.json theme={null}
|
||||
{
|
||||
"env": {
|
||||
"CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS": "1"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Start your first agent team
|
||||
|
||||
After enabling agent teams, tell Claude to create an agent team and describe the task and the team structure you want in natural language. Claude creates the team, spawns teammates, and coordinates work based on your prompt.
|
||||
|
||||
This example works well because the three roles are independent and can explore the problem without waiting on each other:
|
||||
|
||||
```
|
||||
I'm designing a CLI tool that helps developers track TODO comments across
|
||||
their codebase. Create an agent team to explore this from different angles: one
|
||||
teammate on UX, one on technical architecture, one playing devil's advocate.
|
||||
```
|
||||
|
||||
From there, Claude creates a team with a [shared task list](/en/interactive-mode#task-list), spawns teammates for each perspective, has them explore the problem, synthesizes findings, and attempts to [clean up the team](#clean-up-the-team) when finished.
|
||||
|
||||
The lead's terminal lists all teammates and what they're working on. Use Shift+Up/Down to select a teammate and message them directly.
|
||||
|
||||
If you want each teammate in its own split pane, see [Choose a display mode](#choose-a-display-mode).
|
||||
|
||||
## Control your agent team
|
||||
|
||||
Tell the lead what you want in natural language. It handles team coordination, task assignment, and delegation based on your instructions.
|
||||
|
||||
### Choose a display mode
|
||||
|
||||
Agent teams support two display modes:
|
||||
|
||||
* **In-process**: all teammates run inside your main terminal. Use Shift+Up/Down to select a teammate and type to message them directly. Works in any terminal, no extra setup required.
|
||||
* **Split panes**: each teammate gets its own pane. You can see everyone's output at once and click into a pane to interact directly. Requires tmux, or iTerm2.
|
||||
|
||||
<Note>
|
||||
`tmux` has known limitations on certain operating systems and traditionally works best on macOS. Using `tmux -CC` in iTerm2 is the suggested entrypoint into `tmux`.
|
||||
</Note>
|
||||
|
||||
The default is `"auto"`, which uses split panes if you're already running inside a tmux session, and in-process otherwise. The `"tmux"` setting enables split-pane mode and auto-detects whether to use tmux or iTerm2 based on your terminal. To override, set `teammateMode` in your [settings.json](/en/settings):
|
||||
|
||||
```json theme={null}
|
||||
{
|
||||
"teammateMode": "in-process"
|
||||
}
|
||||
```
|
||||
|
||||
To force in-process mode for a single session, pass it as a flag:
|
||||
|
||||
```bash theme={null}
|
||||
claude --teammate-mode in-process
|
||||
```
|
||||
|
||||
Split-pane mode requires either [tmux](https://github.com/tmux/tmux/wiki) or iTerm2 with the [`it2` CLI](https://github.com/mkusaka/it2). To install manually:
|
||||
|
||||
* **tmux**: install through your system's package manager. See the [tmux wiki](https://github.com/tmux/tmux/wiki/Installing) for platform-specific instructions.
|
||||
* **iTerm2**: install the [`it2` CLI](https://github.com/mkusaka/it2), then enable the Python API in **iTerm2 → Settings → General → Magic → Enable Python API**.
|
||||
|
||||
### Specify teammates and models
|
||||
|
||||
Claude decides the number of teammates to spawn based on your task, or you can specify exactly what you want:
|
||||
|
||||
```
|
||||
Create a team with 4 teammates to refactor these modules in parallel.
|
||||
Use Sonnet for each teammate.
|
||||
```
|
||||
|
||||
### Require plan approval for teammates
|
||||
|
||||
For complex or risky tasks, you can require teammates to plan before implementing. The teammate works in read-only plan mode until the lead approves their approach:
|
||||
|
||||
```
|
||||
Spawn an architect teammate to refactor the authentication module.
|
||||
Require plan approval before they make any changes.
|
||||
```
|
||||
|
||||
When a teammate finishes planning, it sends a plan approval request to the lead. The lead reviews the plan and either approves it or rejects it with feedback. If rejected, the teammate stays in plan mode, revises based on the feedback, and resubmits. Once approved, the teammate exits plan mode and begins implementation.
|
||||
|
||||
The lead makes approval decisions autonomously. To influence the lead's judgment, give it criteria in your prompt, such as "only approve plans that include test coverage" or "reject plans that modify the database schema."
|
||||
|
||||
### Use delegate mode
|
||||
|
||||
Without delegate mode, the lead sometimes starts implementing tasks itself instead of waiting for teammates. Delegate mode prevents this by restricting the lead to coordination-only tools: spawning, messaging, shutting down teammates, and managing tasks.
|
||||
|
||||
This is useful when you want the lead to focus entirely on orchestration, such as breaking down work, assigning tasks, and synthesizing results, without touching code directly.
|
||||
|
||||
To enable it, start a team first, then press Shift+Tab to cycle into delegate mode.
|
||||
|
||||
### Talk to teammates directly
|
||||
|
||||
Each teammate is a full, independent Claude Code session. You can message any teammate directly to give additional instructions, ask follow-up questions, or redirect their approach.
|
||||
|
||||
* **In-process mode**: use Shift+Up/Down to select a teammate, then type to send them a message. Press Enter to view a teammate's session, then Escape to interrupt their current turn. Press Ctrl+T to toggle the task list.
|
||||
* **Split-pane mode**: click into a teammate's pane to interact with their session directly. Each teammate has a full view of their own terminal.
|
||||
|
||||
### Assign and claim tasks
|
||||
|
||||
The shared task list coordinates work across the team. The lead creates tasks and teammates work through them. Tasks have three states: pending, in progress, and completed. Tasks can also depend on other tasks: a pending task with unresolved dependencies cannot be claimed until those dependencies are completed.
|
||||
|
||||
The lead can assign tasks explicitly, or teammates can self-claim:
|
||||
|
||||
* **Lead assigns**: tell the lead which task to give to which teammate
|
||||
* **Self-claim**: after finishing a task, a teammate picks up the next unassigned, unblocked task on its own
|
||||
|
||||
Task claiming uses file locking to prevent race conditions when multiple teammates try to claim the same task simultaneously.
|
||||
|
||||
### Shut down teammates
|
||||
|
||||
To gracefully end a teammate's session:
|
||||
|
||||
```
|
||||
Ask the researcher teammate to shut down
|
||||
```
|
||||
|
||||
The lead sends a shutdown request. The teammate can approve, exiting gracefully, or reject with an explanation.
|
||||
|
||||
### Clean up the team
|
||||
|
||||
When you're done, ask the lead to clean up:
|
||||
|
||||
```
|
||||
Clean up the team
|
||||
```
|
||||
|
||||
This removes the shared team resources. When the lead runs cleanup, it checks for active teammates and fails if any are still running, so shut them down first.
|
||||
|
||||
<Warning>
|
||||
Always use the lead to clean up. Teammates should not run cleanup because their team context may not resolve correctly, potentially leaving resources in an inconsistent state.
|
||||
</Warning>
|
||||
|
||||
### Enforce quality gates with hooks
|
||||
|
||||
Use [hooks](/en/hooks) to enforce rules when teammates finish work or tasks complete:
|
||||
|
||||
* [`TeammateIdle`](/en/hooks#teammateidle): runs when a teammate is about to go idle. Exit with code 2 to send feedback and keep the teammate working.
|
||||
* [`TaskCompleted`](/en/hooks#taskcompleted): runs when a task is being marked complete. Exit with code 2 to prevent completion and send feedback.
|
||||
|
||||
## How agent teams work
|
||||
|
||||
This section covers the architecture and mechanics behind agent teams. If you want to start using them, see [Control your agent team](#control-your-agent-team) above.
|
||||
|
||||
### How Claude starts agent teams
|
||||
|
||||
There are two ways agent teams get started:
|
||||
|
||||
* **You request a team**: give Claude a task that benefits from parallel work and explicitly ask for an agent team. Claude creates one based on your instructions.
|
||||
* **Claude proposes a team**: if Claude determines your task would benefit from parallel work, it may suggest creating a team. You confirm before it proceeds.
|
||||
|
||||
In both cases, you stay in control. Claude won't create a team without your approval.
|
||||
|
||||
### Architecture
|
||||
|
||||
An agent team consists of:
|
||||
|
||||
| Component | Role |
|
||||
| :------------ | :----------------------------------------------------------------------------------------- |
|
||||
| **Team lead** | The main Claude Code session that creates the team, spawns teammates, and coordinates work |
|
||||
| **Teammates** | Separate Claude Code instances that each work on assigned tasks |
|
||||
| **Task list** | Shared list of work items that teammates claim and complete |
|
||||
| **Mailbox** | Messaging system for communication between agents |
|
||||
|
||||
See [Choose a display mode](#choose-a-display-mode) for display configuration options. Teammate messages arrive at the lead automatically.
|
||||
|
||||
The system manages task dependencies automatically. When a teammate completes a task that other tasks depend on, blocked tasks unblock without manual intervention.
|
||||
|
||||
Teams and tasks are stored locally:
|
||||
|
||||
* **Team config**: `~/.claude/teams/{team-name}/config.json`
|
||||
* **Task list**: `~/.claude/tasks/{team-name}/`
|
||||
|
||||
The team config contains a `members` array with each teammate's name, agent ID, and agent type. Teammates can read this file to discover other team members.
|
||||
|
||||
### Permissions
|
||||
|
||||
Teammates start with the lead's permission settings. If the lead runs with `--dangerously-skip-permissions`, all teammates do too. After spawning, you can change individual teammate modes, but you can't set per-teammate modes at spawn time.
|
||||
|
||||
### Context and communication
|
||||
|
||||
Each teammate has its own context window. When spawned, a teammate loads the same project context as a regular session: CLAUDE.md, MCP servers, and skills. It also receives the spawn prompt from the lead. The lead's conversation history does not carry over.
|
||||
|
||||
**How teammates share information:**
|
||||
|
||||
* **Automatic message delivery**: when teammates send messages, they're delivered automatically to recipients. The lead doesn't need to poll for updates.
|
||||
* **Idle notifications**: when a teammate finishes and stops, they automatically notify the lead.
|
||||
* **Shared task list**: all agents can see task status and claim available work.
|
||||
|
||||
**Teammate messaging:**
|
||||
|
||||
* **message**: send a message to one specific teammate
|
||||
* **broadcast**: send to all teammates simultaneously. Use sparingly, as costs scale with team size.
|
||||
|
||||
### Token usage
|
||||
|
||||
Agent teams use significantly more tokens than a single session. Each teammate has its own context window, and token usage scales with the number of active teammates. For research, review, and new feature work, the extra tokens are usually worthwhile. For routine tasks, a single session is more cost-effective. See [agent team token costs](/en/costs#agent-team-token-costs) for usage guidance.
|
||||
|
||||
## Use case examples
|
||||
|
||||
These examples show how agent teams handle tasks where parallel exploration adds value.
|
||||
|
||||
### Run a parallel code review
|
||||
|
||||
A single reviewer tends to gravitate toward one type of issue at a time. Splitting review criteria into independent domains means security, performance, and test coverage all get thorough attention simultaneously. The prompt assigns each teammate a distinct lens so they don't overlap:
|
||||
|
||||
```
|
||||
Create an agent team to review PR #142. Spawn three reviewers:
|
||||
- One focused on security implications
|
||||
- One checking performance impact
|
||||
- One validating test coverage
|
||||
Have them each review and report findings.
|
||||
```
|
||||
|
||||
Each reviewer works from the same PR but applies a different filter. The lead synthesizes findings across all three after they finish.
|
||||
|
||||
### Investigate with competing hypotheses
|
||||
|
||||
When the root cause is unclear, a single agent tends to find one plausible explanation and stop looking. The prompt fights this by making teammates explicitly adversarial: each one's job is not only to investigate its own theory but to challenge the others'.
|
||||
|
||||
```
|
||||
Users report the app exits after one message instead of staying connected.
|
||||
Spawn 5 agent teammates to investigate different hypotheses. Have them talk to
|
||||
each other to try to disprove each other's theories, like a scientific
|
||||
debate. Update the findings doc with whatever consensus emerges.
|
||||
```
|
||||
|
||||
The debate structure is the key mechanism here. Sequential investigation suffers from anchoring: once one theory is explored, subsequent investigation is biased toward it.
|
||||
|
||||
With multiple independent investigators actively trying to disprove each other, the theory that survives is much more likely to be the actual root cause.
|
||||
|
||||
## Best practices
|
||||
|
||||
### Give teammates enough context
|
||||
|
||||
Teammates load project context automatically, including CLAUDE.md, MCP servers, and skills, but they don't inherit the lead's conversation history. See [Context and communication](#context-and-communication) for details. Include task-specific details in the spawn prompt:
|
||||
|
||||
```
|
||||
Spawn a security reviewer teammate with the prompt: "Review the authentication module
|
||||
at src/auth/ for security vulnerabilities. Focus on token handling, session
|
||||
management, and input validation. The app uses JWT tokens stored in
|
||||
httpOnly cookies. Report any issues with severity ratings."
|
||||
```
|
||||
|
||||
### Size tasks appropriately
|
||||
|
||||
* **Too small**: coordination overhead exceeds the benefit
|
||||
* **Too large**: teammates work too long without check-ins, increasing risk of wasted effort
|
||||
* **Just right**: self-contained units that produce a clear deliverable, such as a function, a test file, or a review
|
||||
|
||||
<Tip>
|
||||
The lead breaks work into tasks and assigns them to teammates automatically. If it isn't creating enough tasks, ask it to split the work into smaller pieces. Having 5-6 tasks per teammate keeps everyone productive and lets the lead reassign work if someone gets stuck.
|
||||
</Tip>
|
||||
|
||||
### Wait for teammates to finish
|
||||
|
||||
Sometimes the lead starts implementing tasks itself instead of waiting for teammates. If you notice this:
|
||||
|
||||
```
|
||||
Wait for your teammates to complete their tasks before proceeding
|
||||
```
|
||||
|
||||
### Start with research and review
|
||||
|
||||
If you're new to agent teams, start with tasks that have clear boundaries and don't require writing code: reviewing a PR, researching a library, or investigating a bug. These tasks show the value of parallel exploration without the coordination challenges that come with parallel implementation.
|
||||
|
||||
### Avoid file conflicts
|
||||
|
||||
Two teammates editing the same file leads to overwrites. Break the work so each teammate owns a different set of files.
|
||||
|
||||
### Monitor and steer
|
||||
|
||||
Check in on teammates' progress, redirect approaches that aren't working, and synthesize findings as they come in. Letting a team run unattended for too long increases the risk of wasted effort.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Teammates not appearing
|
||||
|
||||
If teammates aren't appearing after you ask Claude to create a team:
|
||||
|
||||
* In in-process mode, teammates may already be running but not visible. Press Shift+Down to cycle through active teammates.
|
||||
* Check that the task you gave Claude was complex enough to warrant a team. Claude decides whether to spawn teammates based on the task.
|
||||
* If you explicitly requested split panes, ensure tmux is installed and available in your PATH:
|
||||
```bash theme={null}
|
||||
which tmux
|
||||
```
|
||||
* For iTerm2, verify the `it2` CLI is installed and the Python API is enabled in iTerm2 preferences.
|
||||
|
||||
### Too many permission prompts
|
||||
|
||||
Teammate permission requests bubble up to the lead, which can create friction. Pre-approve common operations in your [permission settings](/en/permissions) before spawning teammates to reduce interruptions.
|
||||
|
||||
### Teammates stopping on errors
|
||||
|
||||
Teammates may stop after encountering errors instead of recovering. Check their output using Shift+Up/Down in in-process mode or by clicking the pane in split mode, then either:
|
||||
|
||||
* Give them additional instructions directly
|
||||
* Spawn a replacement teammate to continue the work
|
||||
|
||||
### Lead shuts down before work is done
|
||||
|
||||
The lead may decide the team is finished before all tasks are actually complete. If this happens, tell it to keep going. You can also tell the lead to wait for teammates to finish before proceeding if it starts doing work instead of delegating.
|
||||
|
||||
### Orphaned tmux sessions
|
||||
|
||||
If a tmux session persists after the team ends, it may not have been fully cleaned up. List sessions and kill the one created by the team:
|
||||
|
||||
```bash theme={null}
|
||||
tmux ls
|
||||
tmux kill-session -t <session-name>
|
||||
```
|
||||
|
||||
## Limitations
|
||||
|
||||
Agent teams are experimental. Current limitations to be aware of:
|
||||
|
||||
* **No session resumption with in-process teammates**: `/resume` and `/rewind` do not restore in-process teammates. After resuming a session, the lead may attempt to message teammates that no longer exist. If this happens, tell the lead to spawn new teammates.
|
||||
* **Task status can lag**: teammates sometimes fail to mark tasks as completed, which blocks dependent tasks. If a task appears stuck, check whether the work is actually done and update the task status manually or tell the lead to nudge the teammate.
|
||||
* **Shutdown can be slow**: teammates finish their current request or tool call before shutting down, which can take time.
|
||||
* **One team per session**: a lead can only manage one team at a time. Clean up the current team before starting a new one.
|
||||
* **No nested teams**: teammates cannot spawn their own teams or teammates. Only the lead can manage the team.
|
||||
* **Lead is fixed**: the session that creates the team is the lead for its lifetime. You can't promote a teammate to lead or transfer leadership.
|
||||
* **Permissions set at spawn**: all teammates start with the lead's permission mode. You can change individual teammate modes after spawning, but you can't set per-teammate modes at spawn time.
|
||||
* **Split panes require tmux or iTerm2**: the default in-process mode works in any terminal. Split-pane mode isn't supported in VS Code's integrated terminal, Windows Terminal, or Ghostty.
|
||||
|
||||
<Tip>
|
||||
**`CLAUDE.md` works normally**: teammates read `CLAUDE.md` files from their working directory. Use this to provide project-specific guidance to all teammates.
|
||||
</Tip>
|
||||
|
||||
## Next steps
|
||||
|
||||
Explore related approaches for parallel work and delegation:
|
||||
|
||||
* **Lightweight delegation**: [subagents](/en/sub-agents) spawn helper agents for research or verification within your session, better for tasks that don't need inter-agent coordination
|
||||
* **Manual parallel sessions**: [Git worktrees](/en/common-workflows#run-parallel-claude-code-sessions-with-git-worktrees) let you run multiple Claude Code sessions yourself without automated team coordination
|
||||
* **Compare approaches**: see the [subagent vs agent team](/en/features-overview#compare-similar-features) comparison for a side-by-side breakdown
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
Layers,
|
||||
Wrench,
|
||||
Cog,
|
||||
Users,
|
||||
} from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
@@ -80,6 +81,7 @@ const navGroupDefinitions: NavGroupDef[] = [
|
||||
{ path: '/orchestrator', labelKey: 'navigation.main.orchestrator', icon: Workflow },
|
||||
{ path: '/history', labelKey: 'navigation.main.history', icon: Clock },
|
||||
{ path: '/issues', labelKey: 'navigation.main.issues', icon: AlertCircle },
|
||||
{ path: '/teams', labelKey: 'navigation.main.teams', icon: Users },
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -14,8 +14,10 @@ import {
|
||||
Shield,
|
||||
Database,
|
||||
FileText,
|
||||
Files,
|
||||
HardDrive,
|
||||
MessageCircleQuestion,
|
||||
MessagesSquare,
|
||||
SearchCode,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
@@ -93,10 +95,12 @@ export interface CcwToolsMcpCardProps {
|
||||
export const CCW_MCP_TOOLS: CcwTool[] = [
|
||||
{ name: 'write_file', desc: 'Write/create files', core: true },
|
||||
{ name: 'edit_file', desc: 'Edit/replace content', core: true },
|
||||
{ name: 'read_file', desc: 'Read file contents', core: true },
|
||||
{ name: 'read_file', desc: 'Read single file', core: true },
|
||||
{ name: 'read_many_files', desc: 'Read multiple files/dirs', core: true },
|
||||
{ name: 'core_memory', desc: 'Core memory management', core: true },
|
||||
{ name: 'ask_question', desc: 'Interactive questions (A2UI)', core: false },
|
||||
{ name: 'smart_search', desc: 'Intelligent code search', core: true },
|
||||
{ name: 'team_msg', desc: 'Agent team message bus', core: false },
|
||||
];
|
||||
|
||||
// ========== Component ==========
|
||||
@@ -507,12 +511,16 @@ function getToolIcon(toolName: string): React.ReactElement {
|
||||
return <Check {...iconProps} />;
|
||||
case 'read_file':
|
||||
return <Database {...iconProps} />;
|
||||
case 'read_many_files':
|
||||
return <Files {...iconProps} />;
|
||||
case 'core_memory':
|
||||
return <Settings {...iconProps} />;
|
||||
case 'ask_question':
|
||||
return <MessageCircleQuestion {...iconProps} />;
|
||||
case 'smart_search':
|
||||
return <SearchCode {...iconProps} />;
|
||||
case 'team_msg':
|
||||
return <MessagesSquare {...iconProps} />;
|
||||
default:
|
||||
return <Settings {...iconProps} />;
|
||||
}
|
||||
|
||||
35
ccw/frontend/src/components/team/TeamEmptyState.tsx
Normal file
35
ccw/frontend/src/components/team/TeamEmptyState.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
// ========================================
|
||||
// TeamEmptyState Component
|
||||
// ========================================
|
||||
// Empty state displayed when no teams are available
|
||||
|
||||
import { useIntl } from 'react-intl';
|
||||
import { Users } from 'lucide-react';
|
||||
import { Card, CardContent } from '@/components/ui/Card';
|
||||
|
||||
export function TeamEmptyState() {
|
||||
const { formatMessage } = useIntl();
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[60vh]">
|
||||
<Card className="max-w-md w-full">
|
||||
<CardContent className="flex flex-col items-center gap-4 py-12">
|
||||
<div className="w-16 h-16 rounded-full bg-muted flex items-center justify-center">
|
||||
<Users className="w-8 h-8 text-muted-foreground" />
|
||||
</div>
|
||||
<div className="text-center space-y-2">
|
||||
<h3 className="text-lg font-semibold">
|
||||
{formatMessage({ id: 'team.empty.title' })}
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{formatMessage({ id: 'team.empty.description' })}
|
||||
</p>
|
||||
</div>
|
||||
<code className="px-3 py-1.5 bg-muted rounded text-xs font-mono">
|
||||
/team:coordinate
|
||||
</code>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
94
ccw/frontend/src/components/team/TeamHeader.tsx
Normal file
94
ccw/frontend/src/components/team/TeamHeader.tsx
Normal file
@@ -0,0 +1,94 @@
|
||||
// ========================================
|
||||
// TeamHeader Component
|
||||
// ========================================
|
||||
// Team selector, stats chips, and controls
|
||||
|
||||
import { useIntl } from 'react-intl';
|
||||
import { Users, MessageSquare, Clock, RefreshCw } from 'lucide-react';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import { Switch } from '@/components/ui/Switch';
|
||||
import { Label } from '@/components/ui/Label';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/Select';
|
||||
import type { TeamSummary, TeamMember } from '@/types/team';
|
||||
|
||||
interface TeamHeaderProps {
|
||||
teams: TeamSummary[];
|
||||
selectedTeam: string | null;
|
||||
onSelectTeam: (name: string | null) => void;
|
||||
members: TeamMember[];
|
||||
totalMessages: number;
|
||||
autoRefresh: boolean;
|
||||
onToggleAutoRefresh: () => void;
|
||||
}
|
||||
|
||||
export function TeamHeader({
|
||||
teams,
|
||||
selectedTeam,
|
||||
onSelectTeam,
|
||||
members,
|
||||
totalMessages,
|
||||
autoRefresh,
|
||||
onToggleAutoRefresh,
|
||||
}: TeamHeaderProps) {
|
||||
const { formatMessage } = useIntl();
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
{/* Team Selector */}
|
||||
<Select
|
||||
value={selectedTeam ?? ''}
|
||||
onValueChange={(v) => onSelectTeam(v || null)}
|
||||
>
|
||||
<SelectTrigger className="w-[200px]">
|
||||
<SelectValue placeholder={formatMessage({ id: 'team.selectTeam' })} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{teams.map((t) => (
|
||||
<SelectItem key={t.name} value={t.name}>
|
||||
{t.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{/* Stats chips */}
|
||||
{selectedTeam && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="secondary" className="gap-1">
|
||||
<Users className="w-3 h-3" />
|
||||
{formatMessage({ id: 'team.members' })}: {members.length}
|
||||
</Badge>
|
||||
<Badge variant="secondary" className="gap-1">
|
||||
<MessageSquare className="w-3 h-3" />
|
||||
{formatMessage({ id: 'team.messages' })}: {totalMessages}
|
||||
</Badge>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Controls */}
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch
|
||||
id="auto-refresh"
|
||||
checked={autoRefresh}
|
||||
onCheckedChange={onToggleAutoRefresh}
|
||||
/>
|
||||
<Label htmlFor="auto-refresh" className="text-sm text-muted-foreground cursor-pointer">
|
||||
{formatMessage({ id: 'team.autoRefresh' })}
|
||||
</Label>
|
||||
{autoRefresh && (
|
||||
<RefreshCw className="w-3.5 h-3.5 text-primary animate-spin" style={{ animationDuration: '3s' }} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
114
ccw/frontend/src/components/team/TeamMembersPanel.tsx
Normal file
114
ccw/frontend/src/components/team/TeamMembersPanel.tsx
Normal file
@@ -0,0 +1,114 @@
|
||||
// ========================================
|
||||
// TeamMembersPanel Component
|
||||
// ========================================
|
||||
// Card-based member status display
|
||||
|
||||
import { useIntl } from 'react-intl';
|
||||
import { Card, CardContent } from '@/components/ui/Card';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { TeamMember } from '@/types/team';
|
||||
|
||||
interface TeamMembersPanelProps {
|
||||
members: TeamMember[];
|
||||
}
|
||||
|
||||
function formatRelativeTime(isoString: string): string {
|
||||
if (!isoString) return '';
|
||||
const now = Date.now();
|
||||
const then = new Date(isoString).getTime();
|
||||
const diffMs = now - then;
|
||||
|
||||
if (diffMs < 0) return 'now';
|
||||
const seconds = Math.floor(diffMs / 1000);
|
||||
if (seconds < 60) return `${seconds}s`;
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
if (minutes < 60) return `${minutes}m`;
|
||||
const hours = Math.floor(minutes / 60);
|
||||
if (hours < 24) return `${hours}h`;
|
||||
const days = Math.floor(hours / 24);
|
||||
return `${days}d`;
|
||||
}
|
||||
|
||||
function getMemberStatus(member: TeamMember): 'active' | 'idle' {
|
||||
if (!member.lastSeen) return 'idle';
|
||||
const diffMs = Date.now() - new Date(member.lastSeen).getTime();
|
||||
// Active if seen in last 2 minutes
|
||||
return diffMs < 2 * 60 * 1000 ? 'active' : 'idle';
|
||||
}
|
||||
|
||||
export function TeamMembersPanel({ members }: TeamMembersPanelProps) {
|
||||
const { formatMessage } = useIntl();
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<h3 className="text-sm font-medium text-muted-foreground">
|
||||
{formatMessage({ id: 'team.membersPanel.title' })}
|
||||
</h3>
|
||||
<div className="space-y-2">
|
||||
{members.map((m) => {
|
||||
const status = getMemberStatus(m);
|
||||
const isActive = status === 'active';
|
||||
|
||||
return (
|
||||
<Card key={m.member} className="overflow-hidden">
|
||||
<CardContent className="p-3">
|
||||
<div className="flex items-start gap-3">
|
||||
{/* Status indicator */}
|
||||
<div className="pt-0.5">
|
||||
<div
|
||||
className={cn(
|
||||
'w-2.5 h-2.5 rounded-full',
|
||||
isActive
|
||||
? 'bg-green-500 shadow-[0_0_6px_rgba(34,197,94,0.5)]'
|
||||
: 'bg-muted-foreground/40'
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-w-0 space-y-1">
|
||||
{/* Name + status badge */}
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium truncate">{m.member}</span>
|
||||
<Badge
|
||||
variant={isActive ? 'success' : 'secondary'}
|
||||
className="text-[10px] px-1.5 py-0"
|
||||
>
|
||||
{formatMessage({ id: `team.membersPanel.${status}` })}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{/* Last action */}
|
||||
{m.lastAction && (
|
||||
<p className="text-xs text-muted-foreground truncate">
|
||||
{m.lastAction}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Stats row */}
|
||||
<div className="flex items-center gap-3 text-[10px] text-muted-foreground">
|
||||
<span>
|
||||
{m.messageCount} {formatMessage({ id: 'team.messages' }).toLowerCase()}
|
||||
</span>
|
||||
{m.lastSeen && (
|
||||
<span>
|
||||
{formatRelativeTime(m.lastSeen)} {formatMessage({ id: 'team.membersPanel.ago' })}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
|
||||
{members.length === 0 && (
|
||||
<p className="text-xs text-muted-foreground text-center py-4">
|
||||
{formatMessage({ id: 'team.empty.noMessages' })}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
262
ccw/frontend/src/components/team/TeamMessageFeed.tsx
Normal file
262
ccw/frontend/src/components/team/TeamMessageFeed.tsx
Normal file
@@ -0,0 +1,262 @@
|
||||
// ========================================
|
||||
// TeamMessageFeed Component
|
||||
// ========================================
|
||||
// Message timeline with filtering and pagination
|
||||
|
||||
import { useState, useMemo } from 'react';
|
||||
import { useIntl } from 'react-intl';
|
||||
import { ChevronDown, ChevronUp, FileText, Filter, X } from 'lucide-react';
|
||||
import { Card, CardContent } from '@/components/ui/Card';
|
||||
import { Badge } from '@/components/ui/Badge';
|
||||
import { Button } from '@/components/ui/Button';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/Select';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { TeamMessage, TeamMessageType, TeamMessageFilter } from '@/types/team';
|
||||
|
||||
interface TeamMessageFeedProps {
|
||||
messages: TeamMessage[];
|
||||
total: number;
|
||||
filter: TeamMessageFilter;
|
||||
onFilterChange: (filter: Partial<TeamMessageFilter>) => void;
|
||||
onClearFilter: () => void;
|
||||
expanded: boolean;
|
||||
onExpandedChange: (expanded: boolean) => void;
|
||||
}
|
||||
|
||||
// Message type → color mapping
|
||||
const typeColorMap: Record<string, string> = {
|
||||
plan_ready: 'bg-blue-500/15 text-blue-600 dark:text-blue-400 border-blue-500/30',
|
||||
plan_approved: 'bg-blue-500/15 text-blue-600 dark:text-blue-400 border-blue-500/30',
|
||||
plan_revision: 'bg-amber-500/15 text-amber-600 dark:text-amber-400 border-amber-500/30',
|
||||
task_unblocked: 'bg-cyan-500/15 text-cyan-600 dark:text-cyan-400 border-cyan-500/30',
|
||||
impl_complete: 'bg-primary/15 text-primary border-primary/30',
|
||||
impl_progress: 'bg-primary/15 text-primary border-primary/30',
|
||||
test_result: 'bg-green-500/15 text-green-600 dark:text-green-400 border-green-500/30',
|
||||
review_result: 'bg-purple-500/15 text-purple-600 dark:text-purple-400 border-purple-500/30',
|
||||
fix_required: 'bg-red-500/15 text-red-600 dark:text-red-400 border-red-500/30',
|
||||
error: 'bg-red-500/15 text-red-600 dark:text-red-400 border-red-500/30',
|
||||
shutdown: 'bg-muted text-muted-foreground border-border',
|
||||
message: 'bg-muted text-muted-foreground border-border',
|
||||
};
|
||||
|
||||
function MessageTypeBadge({ type }: { type: string }) {
|
||||
const { formatMessage } = useIntl();
|
||||
const color = typeColorMap[type] || typeColorMap.message;
|
||||
const labelKey = `team.messageType.${type}`;
|
||||
|
||||
let label: string;
|
||||
try {
|
||||
label = formatMessage({ id: labelKey });
|
||||
} catch {
|
||||
label = type;
|
||||
}
|
||||
|
||||
return (
|
||||
<span className={cn('text-[10px] px-1.5 py-0.5 rounded border font-medium', color)}>
|
||||
{label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function MessageRow({ msg }: { msg: TeamMessage }) {
|
||||
const [dataExpanded, setDataExpanded] = useState(false);
|
||||
const time = msg.ts ? msg.ts.substring(11, 19) : '';
|
||||
|
||||
return (
|
||||
<div className="flex gap-3 py-2.5 border-b border-border last:border-b-0 animate-in fade-in slide-in-from-top-1 duration-300">
|
||||
{/* Timestamp */}
|
||||
<span className="text-[10px] font-mono text-muted-foreground w-16 shrink-0 pt-0.5">
|
||||
{time}
|
||||
</span>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 min-w-0 space-y-1">
|
||||
{/* Header: from → to + type */}
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="text-xs font-medium">{msg.from}</span>
|
||||
<span className="text-[10px] text-muted-foreground">→</span>
|
||||
<span className="text-xs font-medium">{msg.to}</span>
|
||||
<MessageTypeBadge type={msg.type} />
|
||||
{msg.id && (
|
||||
<span className="text-[10px] text-muted-foreground">{msg.id}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Summary */}
|
||||
<p className="text-xs text-foreground/80">{msg.summary}</p>
|
||||
|
||||
{/* Ref link */}
|
||||
{msg.ref && (
|
||||
<div className="flex items-center gap-1 text-[10px] text-muted-foreground">
|
||||
<FileText className="w-3 h-3" />
|
||||
<span className="font-mono truncate">{msg.ref}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Data toggle */}
|
||||
{msg.data && Object.keys(msg.data).length > 0 && (
|
||||
<div>
|
||||
<button
|
||||
onClick={() => setDataExpanded(!dataExpanded)}
|
||||
className="text-[10px] text-primary hover:underline flex items-center gap-0.5"
|
||||
>
|
||||
{dataExpanded ? (
|
||||
<>
|
||||
<ChevronUp className="w-3 h-3" /> collapse
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<ChevronDown className="w-3 h-3" /> data
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
{dataExpanded && (
|
||||
<pre className="text-[10px] bg-muted p-2 rounded mt-1 overflow-x-auto max-h-40">
|
||||
{JSON.stringify(msg.data, null, 2)}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function TeamMessageFeed({
|
||||
messages,
|
||||
total,
|
||||
filter,
|
||||
onFilterChange,
|
||||
onClearFilter,
|
||||
expanded,
|
||||
onExpandedChange,
|
||||
}: TeamMessageFeedProps) {
|
||||
const { formatMessage } = useIntl();
|
||||
const hasFilter = !!(filter.from || filter.to || filter.type);
|
||||
|
||||
// Extract unique senders/receivers for filter dropdowns
|
||||
const { senders, receivers, types } = useMemo(() => {
|
||||
const s = new Set<string>();
|
||||
const r = new Set<string>();
|
||||
const t = new Set<string>();
|
||||
for (const m of messages) {
|
||||
s.add(m.from);
|
||||
r.add(m.to);
|
||||
t.add(m.type);
|
||||
}
|
||||
return {
|
||||
senders: Array.from(s).sort(),
|
||||
receivers: Array.from(r).sort(),
|
||||
types: Array.from(t).sort(),
|
||||
};
|
||||
}, [messages]);
|
||||
|
||||
// Reverse for newest-first display
|
||||
const displayMessages = [...messages].reverse();
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<button
|
||||
onClick={() => onExpandedChange(!expanded)}
|
||||
className="flex items-center gap-2 text-sm font-medium text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
{expanded ? <ChevronUp className="w-4 h-4" /> : <ChevronDown className="w-4 h-4" />}
|
||||
{formatMessage({ id: 'team.timeline.title' })}
|
||||
<span className="text-xs font-normal">
|
||||
({formatMessage({ id: 'team.timeline.showing' }, { showing: messages.length, total })})
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{hasFilter && (
|
||||
<Button variant="ghost" size="sm" onClick={onClearFilter} className="h-6 text-xs gap-1">
|
||||
<X className="w-3 h-3" />
|
||||
{formatMessage({ id: 'team.timeline.clearFilters' })}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{expanded && (
|
||||
<>
|
||||
{/* Filters */}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Select
|
||||
value={filter.from ?? '__all__'}
|
||||
onValueChange={(v) => onFilterChange({ from: v === '__all__' ? undefined : v })}
|
||||
>
|
||||
<SelectTrigger className="w-[130px] h-7 text-xs">
|
||||
<Filter className="w-3 h-3 mr-1" />
|
||||
<SelectValue placeholder={formatMessage({ id: 'team.timeline.filterFrom' })} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__all__">{formatMessage({ id: 'team.filterAll' })}</SelectItem>
|
||||
{senders.map((s) => (
|
||||
<SelectItem key={s} value={s}>{s}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Select
|
||||
value={filter.to ?? '__all__'}
|
||||
onValueChange={(v) => onFilterChange({ to: v === '__all__' ? undefined : v })}
|
||||
>
|
||||
<SelectTrigger className="w-[130px] h-7 text-xs">
|
||||
<SelectValue placeholder={formatMessage({ id: 'team.timeline.filterTo' })} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__all__">{formatMessage({ id: 'team.filterAll' })}</SelectItem>
|
||||
{receivers.map((r) => (
|
||||
<SelectItem key={r} value={r}>{r}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Select
|
||||
value={filter.type ?? '__all__'}
|
||||
onValueChange={(v) => onFilterChange({ type: v === '__all__' ? undefined : v })}
|
||||
>
|
||||
<SelectTrigger className="w-[150px] h-7 text-xs">
|
||||
<SelectValue placeholder={formatMessage({ id: 'team.timeline.filterType' })} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__all__">{formatMessage({ id: 'team.filterAll' })}</SelectItem>
|
||||
{types.map((t) => (
|
||||
<SelectItem key={t} value={t}>{t}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Messages list */}
|
||||
<Card>
|
||||
<CardContent className="p-3">
|
||||
{displayMessages.length > 0 ? (
|
||||
<div className="divide-y-0">
|
||||
{displayMessages.map((msg) => (
|
||||
<MessageRow key={msg.id} msg={msg} />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-8">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{formatMessage({ id: 'team.empty.noMessages' })}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
{formatMessage({ id: 'team.empty.noMessagesHint' })}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
163
ccw/frontend/src/components/team/TeamPipeline.tsx
Normal file
163
ccw/frontend/src/components/team/TeamPipeline.tsx
Normal file
@@ -0,0 +1,163 @@
|
||||
// ========================================
|
||||
// TeamPipeline Component
|
||||
// ========================================
|
||||
// CSS-based pipeline stage visualization: PLAN → IMPL → TEST + REVIEW
|
||||
|
||||
import { useIntl } from 'react-intl';
|
||||
import { CheckCircle2, Circle, Loader2, Ban } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { TeamMessage, PipelineStage, PipelineStageStatus } from '@/types/team';
|
||||
|
||||
interface TeamPipelineProps {
|
||||
messages: TeamMessage[];
|
||||
}
|
||||
|
||||
const STAGES: PipelineStage[] = ['plan', 'impl', 'test', 'review'];
|
||||
|
||||
/** Derive pipeline stage status from message history */
|
||||
function derivePipelineStatus(messages: TeamMessage[]): Record<PipelineStage, PipelineStageStatus> {
|
||||
const status: Record<PipelineStage, PipelineStageStatus> = {
|
||||
plan: 'pending',
|
||||
impl: 'pending',
|
||||
test: 'pending',
|
||||
review: 'pending',
|
||||
};
|
||||
|
||||
for (const msg of messages) {
|
||||
const t = msg.type;
|
||||
// Plan stage
|
||||
if (t === 'plan_ready') status.plan = 'in_progress';
|
||||
if (t === 'plan_approved') {
|
||||
status.plan = 'completed';
|
||||
if (status.impl === 'pending') status.impl = 'in_progress';
|
||||
}
|
||||
if (t === 'plan_revision') status.plan = 'in_progress';
|
||||
// Impl stage
|
||||
if (t === 'impl_progress') status.impl = 'in_progress';
|
||||
if (t === 'impl_complete') {
|
||||
status.impl = 'completed';
|
||||
if (status.test === 'pending') status.test = 'in_progress';
|
||||
if (status.review === 'pending') status.review = 'in_progress';
|
||||
}
|
||||
// Test stage
|
||||
if (t === 'test_result') {
|
||||
const passed = msg.data?.passed ?? msg.summary?.toLowerCase().includes('pass');
|
||||
status.test = passed ? 'completed' : 'in_progress';
|
||||
}
|
||||
// Review stage
|
||||
if (t === 'review_result') {
|
||||
const approved = msg.data?.approved ?? msg.summary?.toLowerCase().includes('approv');
|
||||
status.review = approved ? 'completed' : 'in_progress';
|
||||
}
|
||||
// Fix required resets impl
|
||||
if (t === 'fix_required') {
|
||||
status.impl = 'in_progress';
|
||||
}
|
||||
// Error blocks stages
|
||||
if (t === 'error') {
|
||||
// Keep current status, don't override to blocked
|
||||
}
|
||||
}
|
||||
|
||||
return status;
|
||||
}
|
||||
|
||||
const statusConfig: Record<PipelineStageStatus, { icon: typeof CheckCircle2; color: string; bg: string; animate?: boolean }> = {
|
||||
completed: { icon: CheckCircle2, color: 'text-green-500', bg: 'bg-green-500/10 border-green-500/30' },
|
||||
in_progress: { icon: Loader2, color: 'text-blue-500', bg: 'bg-blue-500/10 border-blue-500/30', animate: true },
|
||||
pending: { icon: Circle, color: 'text-muted-foreground', bg: 'bg-muted border-border' },
|
||||
blocked: { icon: Ban, color: 'text-red-500', bg: 'bg-red-500/10 border-red-500/30' },
|
||||
};
|
||||
|
||||
function StageNode({ stage, status }: { stage: PipelineStage; status: PipelineStageStatus }) {
|
||||
const { formatMessage } = useIntl();
|
||||
const config = statusConfig[status];
|
||||
const Icon = config.icon;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex flex-col items-center gap-1.5 px-4 py-3 rounded-lg border-2 min-w-[90px] transition-all',
|
||||
config.bg
|
||||
)}
|
||||
>
|
||||
<Icon
|
||||
className={cn('w-5 h-5', config.color, config.animate && 'animate-spin')}
|
||||
style={config.animate ? { animationDuration: '2s' } : undefined}
|
||||
/>
|
||||
<span className="text-xs font-medium">
|
||||
{formatMessage({ id: `team.pipeline.${stage}` })}
|
||||
</span>
|
||||
<span className={cn('text-[10px]', config.color)}>
|
||||
{formatMessage({ id: `team.pipeline.${status === 'in_progress' ? 'inProgress' : status}` })}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Arrow() {
|
||||
return (
|
||||
<div className="flex items-center px-1">
|
||||
<div className="w-6 h-0.5 bg-border" />
|
||||
<div className="w-0 h-0 border-t-[4px] border-t-transparent border-b-[4px] border-b-transparent border-l-[6px] border-l-border" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ForkArrow() {
|
||||
return (
|
||||
<div className="flex items-center px-1">
|
||||
<div className="w-4 h-0.5 bg-border" />
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="w-3 h-0.5 bg-border -rotate-20" />
|
||||
<div className="w-3 h-0.5 bg-border rotate-20" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function TeamPipeline({ messages }: TeamPipelineProps) {
|
||||
const { formatMessage } = useIntl();
|
||||
const stageStatus = derivePipelineStatus(messages);
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<h3 className="text-sm font-medium text-muted-foreground">
|
||||
{formatMessage({ id: 'team.pipeline.title' })}
|
||||
</h3>
|
||||
|
||||
{/* Desktop: horizontal layout */}
|
||||
<div className="hidden sm:flex items-center gap-0">
|
||||
<StageNode stage="plan" status={stageStatus.plan} />
|
||||
<Arrow />
|
||||
<StageNode stage="impl" status={stageStatus.impl} />
|
||||
<Arrow />
|
||||
<div className="flex flex-col gap-2">
|
||||
<StageNode stage="test" status={stageStatus.test} />
|
||||
<StageNode stage="review" status={stageStatus.review} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mobile: vertical layout */}
|
||||
<div className="flex sm:hidden flex-col items-center gap-2">
|
||||
{STAGES.map((stage) => (
|
||||
<StageNode key={stage} stage={stage} status={stageStatus[stage]} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Legend */}
|
||||
<div className="flex flex-wrap gap-3 text-[10px] text-muted-foreground pt-1">
|
||||
{(['completed', 'in_progress', 'pending', 'blocked'] as PipelineStageStatus[]).map((s) => {
|
||||
const cfg = statusConfig[s];
|
||||
const Icon = cfg.icon;
|
||||
return (
|
||||
<span key={s} className="flex items-center gap-1">
|
||||
<Icon className={cn('w-3 h-3', cfg.color)} />
|
||||
{formatMessage({ id: `team.pipeline.${s === 'in_progress' ? 'inProgress' : s}` })}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
127
ccw/frontend/src/hooks/useTeamData.ts
Normal file
127
ccw/frontend/src/hooks/useTeamData.ts
Normal file
@@ -0,0 +1,127 @@
|
||||
// ========================================
|
||||
// useTeamData Hook
|
||||
// ========================================
|
||||
// TanStack Query hooks for team execution visualization
|
||||
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { fetchTeams, fetchTeamMessages, fetchTeamStatus } from '@/lib/api';
|
||||
import { useTeamStore } from '@/stores/teamStore';
|
||||
import type {
|
||||
TeamSummary,
|
||||
TeamMessage,
|
||||
TeamMember,
|
||||
TeamMessageFilter,
|
||||
TeamMessagesResponse,
|
||||
TeamStatusResponse,
|
||||
TeamsListResponse,
|
||||
} from '@/types/team';
|
||||
|
||||
// Query key factory
|
||||
export const teamKeys = {
|
||||
all: ['teams'] as const,
|
||||
lists: () => [...teamKeys.all, 'list'] as const,
|
||||
messages: (team: string, filter?: TeamMessageFilter) =>
|
||||
[...teamKeys.all, 'messages', team, filter] as const,
|
||||
status: (team: string) => [...teamKeys.all, 'status', team] as const,
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook: list all teams
|
||||
*/
|
||||
export function useTeams() {
|
||||
const autoRefresh = useTeamStore((s) => s.autoRefresh);
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: teamKeys.lists(),
|
||||
queryFn: async (): Promise<TeamsListResponse> => {
|
||||
const data = await fetchTeams();
|
||||
return { teams: data.teams ?? [] };
|
||||
},
|
||||
staleTime: 10_000,
|
||||
refetchInterval: autoRefresh ? 10_000 : false,
|
||||
});
|
||||
|
||||
return {
|
||||
teams: (query.data?.teams ?? []) as TeamSummary[],
|
||||
isLoading: query.isLoading,
|
||||
error: query.error,
|
||||
refetch: query.refetch,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook: get messages for selected team
|
||||
*/
|
||||
export function useTeamMessages(
|
||||
teamName: string | null,
|
||||
filter?: TeamMessageFilter,
|
||||
options?: { last?: number; offset?: number }
|
||||
) {
|
||||
const autoRefresh = useTeamStore((s) => s.autoRefresh);
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: teamKeys.messages(teamName ?? '', filter),
|
||||
queryFn: async (): Promise<TeamMessagesResponse> => {
|
||||
if (!teamName) return { total: 0, showing: 0, messages: [] };
|
||||
const data = await fetchTeamMessages(teamName, {
|
||||
...filter,
|
||||
last: options?.last ?? 50,
|
||||
offset: options?.offset,
|
||||
});
|
||||
return {
|
||||
total: data.total,
|
||||
showing: data.showing,
|
||||
messages: data.messages as unknown as TeamMessage[],
|
||||
};
|
||||
},
|
||||
enabled: !!teamName,
|
||||
staleTime: 5_000,
|
||||
refetchInterval: autoRefresh ? 5_000 : false,
|
||||
});
|
||||
|
||||
return {
|
||||
messages: (query.data?.messages ?? []) as TeamMessage[],
|
||||
total: query.data?.total ?? 0,
|
||||
isLoading: query.isLoading,
|
||||
error: query.error,
|
||||
refetch: query.refetch,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook: get member status for selected team
|
||||
*/
|
||||
export function useTeamStatus(teamName: string | null) {
|
||||
const autoRefresh = useTeamStore((s) => s.autoRefresh);
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: teamKeys.status(teamName ?? ''),
|
||||
queryFn: async (): Promise<TeamStatusResponse> => {
|
||||
if (!teamName) return { members: [], total_messages: 0 };
|
||||
const data = await fetchTeamStatus(teamName);
|
||||
return {
|
||||
members: data.members as TeamMember[],
|
||||
total_messages: data.total_messages,
|
||||
};
|
||||
},
|
||||
enabled: !!teamName,
|
||||
staleTime: 5_000,
|
||||
refetchInterval: autoRefresh ? 5_000 : false,
|
||||
});
|
||||
|
||||
return {
|
||||
members: (query.data?.members ?? []) as TeamMember[],
|
||||
totalMessages: query.data?.total_messages ?? 0,
|
||||
isLoading: query.isLoading,
|
||||
error: query.error,
|
||||
refetch: query.refetch,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook: invalidate all team queries
|
||||
*/
|
||||
export function useInvalidateTeamData() {
|
||||
const queryClient = useQueryClient();
|
||||
return () => queryClient.invalidateQueries({ queryKey: teamKeys.all });
|
||||
}
|
||||
@@ -5604,3 +5604,29 @@ export async function upgradeCcwInstallation(
|
||||
body: JSON.stringify({ path }),
|
||||
});
|
||||
}
|
||||
|
||||
// ========== Team API ==========
|
||||
|
||||
export async function fetchTeams(): Promise<{ teams: Array<{ name: string; messageCount: number; lastActivity: string }> }> {
|
||||
return fetchApi('/api/teams');
|
||||
}
|
||||
|
||||
export async function fetchTeamMessages(
|
||||
teamName: string,
|
||||
params?: { from?: string; to?: string; type?: string; last?: number; offset?: number }
|
||||
): Promise<{ total: number; showing: number; messages: Array<Record<string, unknown>> }> {
|
||||
const searchParams = new URLSearchParams();
|
||||
if (params?.from) searchParams.set('from', params.from);
|
||||
if (params?.to) searchParams.set('to', params.to);
|
||||
if (params?.type) searchParams.set('type', params.type);
|
||||
if (params?.last) searchParams.set('last', String(params.last));
|
||||
if (params?.offset) searchParams.set('offset', String(params.offset));
|
||||
const qs = searchParams.toString();
|
||||
return fetchApi(`/api/teams/${encodeURIComponent(teamName)}/messages${qs ? `?${qs}` : ''}`);
|
||||
}
|
||||
|
||||
export async function fetchTeamStatus(
|
||||
teamName: string
|
||||
): Promise<{ members: Array<{ member: string; lastSeen: string; lastAction: string; messageCount: number }>; total_messages: number }> {
|
||||
return fetchApi(`/api/teams/${encodeURIComponent(teamName)}/status`);
|
||||
}
|
||||
|
||||
@@ -38,6 +38,7 @@ import notifications from './notifications.json';
|
||||
import workspace from './workspace.json';
|
||||
import help from './help.json';
|
||||
import cliViewer from './cli-viewer.json';
|
||||
import team from './team.json';
|
||||
|
||||
/**
|
||||
* Flattens nested JSON object to dot-separated keys
|
||||
@@ -99,4 +100,5 @@ export default {
|
||||
...flattenMessages(workspace, 'workspace'),
|
||||
...flattenMessages(help, 'help'),
|
||||
...flattenMessages(cliViewer, 'cliViewer'),
|
||||
...flattenMessages(team, 'team'),
|
||||
} as Record<string, string>;
|
||||
|
||||
@@ -123,7 +123,11 @@
|
||||
},
|
||||
"read_file": {
|
||||
"name": "read_file",
|
||||
"desc": "Read file contents"
|
||||
"desc": "Read a single file with optional line pagination"
|
||||
},
|
||||
"read_many_files": {
|
||||
"name": "read_many_files",
|
||||
"desc": "Read multiple files or directories with glob filtering and content search"
|
||||
},
|
||||
"core_memory": {
|
||||
"name": "core_memory",
|
||||
@@ -136,6 +140,10 @@
|
||||
"smart_search": {
|
||||
"name": "smart_search",
|
||||
"desc": "Intelligent code search with fuzzy and semantic modes"
|
||||
},
|
||||
"team_msg": {
|
||||
"name": "team_msg",
|
||||
"desc": "Persistent JSONL message bus for Agent Team communication"
|
||||
}
|
||||
},
|
||||
"paths": {
|
||||
|
||||
@@ -34,7 +34,8 @@
|
||||
"hooks": "Hooks",
|
||||
"rules": "Rules",
|
||||
"explorer": "File Explorer",
|
||||
"graph": "Graph Explorer"
|
||||
"graph": "Graph Explorer",
|
||||
"teams": "Team Execution"
|
||||
},
|
||||
"sidebar": {
|
||||
"collapse": "Collapse",
|
||||
|
||||
65
ccw/frontend/src/locales/en/team.json
Normal file
65
ccw/frontend/src/locales/en/team.json
Normal file
@@ -0,0 +1,65 @@
|
||||
{
|
||||
"title": "Team Execution",
|
||||
"description": "Visualize agent team execution status and message flow",
|
||||
"selectTeam": "Select Team",
|
||||
"noTeamSelected": "Select a team to view",
|
||||
"members": "Members",
|
||||
"messages": "Messages",
|
||||
"elapsed": "Elapsed",
|
||||
"autoRefresh": "Auto-refresh",
|
||||
"filterByType": "Filter by type",
|
||||
"filterAll": "All Types",
|
||||
"stage": "Stage",
|
||||
"empty": {
|
||||
"title": "No Active Teams",
|
||||
"description": "Use /team:coordinate to create a team and start collaborating",
|
||||
"noMessages": "No Messages Yet",
|
||||
"noMessagesHint": "Team was just created, waiting for the first message"
|
||||
},
|
||||
"pipeline": {
|
||||
"title": "Pipeline Progress",
|
||||
"plan": "Plan",
|
||||
"impl": "Implement",
|
||||
"test": "Test",
|
||||
"review": "Review",
|
||||
"completed": "Completed",
|
||||
"inProgress": "In Progress",
|
||||
"pending": "Pending",
|
||||
"blocked": "Blocked"
|
||||
},
|
||||
"membersPanel": {
|
||||
"title": "Team Members",
|
||||
"active": "Active",
|
||||
"idle": "Idle",
|
||||
"lastAction": "Last Action",
|
||||
"messageCount": "Messages",
|
||||
"lastSeen": "Last Seen",
|
||||
"ago": "ago"
|
||||
},
|
||||
"timeline": {
|
||||
"title": "Message Timeline",
|
||||
"loadMore": "Load More",
|
||||
"showing": "Showing {showing} / {total} messages",
|
||||
"filterFrom": "From",
|
||||
"filterTo": "To",
|
||||
"filterType": "Type",
|
||||
"clearFilters": "Clear Filters",
|
||||
"expandData": "Expand Data",
|
||||
"collapseData": "Collapse Data",
|
||||
"noRef": "No reference"
|
||||
},
|
||||
"messageType": {
|
||||
"plan_ready": "Plan Ready",
|
||||
"plan_approved": "Plan Approved",
|
||||
"plan_revision": "Plan Revision",
|
||||
"task_unblocked": "Task Unblocked",
|
||||
"impl_complete": "Impl Complete",
|
||||
"impl_progress": "Impl Progress",
|
||||
"test_result": "Test Result",
|
||||
"review_result": "Review Result",
|
||||
"fix_required": "Fix Required",
|
||||
"error": "Error",
|
||||
"shutdown": "Shutdown",
|
||||
"message": "Message"
|
||||
}
|
||||
}
|
||||
@@ -38,6 +38,7 @@ import notifications from './notifications.json';
|
||||
import workspace from './workspace.json';
|
||||
import help from './help.json';
|
||||
import cliViewer from './cli-viewer.json';
|
||||
import team from './team.json';
|
||||
|
||||
/**
|
||||
* Flattens nested JSON object to dot-separated keys
|
||||
@@ -99,4 +100,5 @@ export default {
|
||||
...flattenMessages(workspace, 'workspace'),
|
||||
...flattenMessages(help, 'help'),
|
||||
...flattenMessages(cliViewer, 'cliViewer'),
|
||||
...flattenMessages(team, 'team'),
|
||||
} as Record<string, string>;
|
||||
|
||||
@@ -123,7 +123,11 @@
|
||||
},
|
||||
"read_file": {
|
||||
"name": "read_file",
|
||||
"desc": "读取文件内容"
|
||||
"desc": "读取单个文件内容"
|
||||
},
|
||||
"read_many_files": {
|
||||
"name": "read_many_files",
|
||||
"desc": "批量读取多个文件或目录,支持 glob 过滤和内容搜索"
|
||||
},
|
||||
"core_memory": {
|
||||
"name": "core_memory",
|
||||
@@ -136,6 +140,10 @@
|
||||
"smart_search": {
|
||||
"name": "smart_search",
|
||||
"desc": "智能代码搜索,支持模糊和语义搜索模式"
|
||||
},
|
||||
"team_msg": {
|
||||
"name": "team_msg",
|
||||
"desc": "Agent Team 持久化消息总线,用于团队协作通信"
|
||||
}
|
||||
},
|
||||
"paths": {
|
||||
|
||||
@@ -34,7 +34,8 @@
|
||||
"hooks": "Hooks",
|
||||
"rules": "规则",
|
||||
"explorer": "文件浏览器",
|
||||
"graph": "图浏览器"
|
||||
"graph": "图浏览器",
|
||||
"teams": "团队执行"
|
||||
},
|
||||
"sidebar": {
|
||||
"collapse": "收起",
|
||||
|
||||
65
ccw/frontend/src/locales/zh/team.json
Normal file
65
ccw/frontend/src/locales/zh/team.json
Normal file
@@ -0,0 +1,65 @@
|
||||
{
|
||||
"title": "团队执行",
|
||||
"description": "可视化 Agent 团队的执行状态和消息流",
|
||||
"selectTeam": "选择团队",
|
||||
"noTeamSelected": "请选择一个团队",
|
||||
"members": "成员",
|
||||
"messages": "消息",
|
||||
"elapsed": "已用时间",
|
||||
"autoRefresh": "自动刷新",
|
||||
"filterByType": "按类型筛选",
|
||||
"filterAll": "所有类型",
|
||||
"stage": "阶段",
|
||||
"empty": {
|
||||
"title": "暂无活跃团队",
|
||||
"description": "使用 /team:coordinate 创建团队以开始协作",
|
||||
"noMessages": "暂无消息",
|
||||
"noMessagesHint": "团队刚刚创建,等待第一条消息"
|
||||
},
|
||||
"pipeline": {
|
||||
"title": "Pipeline 进度",
|
||||
"plan": "计划",
|
||||
"impl": "实现",
|
||||
"test": "测试",
|
||||
"review": "审查",
|
||||
"completed": "已完成",
|
||||
"inProgress": "进行中",
|
||||
"pending": "待处理",
|
||||
"blocked": "已阻塞"
|
||||
},
|
||||
"membersPanel": {
|
||||
"title": "团队成员",
|
||||
"active": "活跃",
|
||||
"idle": "空闲",
|
||||
"lastAction": "最后动作",
|
||||
"messageCount": "消息数",
|
||||
"lastSeen": "最后活跃",
|
||||
"ago": "前"
|
||||
},
|
||||
"timeline": {
|
||||
"title": "消息时间线",
|
||||
"loadMore": "加载更多",
|
||||
"showing": "显示 {showing} / {total} 条消息",
|
||||
"filterFrom": "发送方",
|
||||
"filterTo": "接收方",
|
||||
"filterType": "消息类型",
|
||||
"clearFilters": "清除筛选",
|
||||
"expandData": "展开数据",
|
||||
"collapseData": "折叠数据",
|
||||
"noRef": "无引用"
|
||||
},
|
||||
"messageType": {
|
||||
"plan_ready": "计划就绪",
|
||||
"plan_approved": "计划批准",
|
||||
"plan_revision": "计划修订",
|
||||
"task_unblocked": "任务解锁",
|
||||
"impl_complete": "实现完成",
|
||||
"impl_progress": "实现进度",
|
||||
"test_result": "测试结果",
|
||||
"review_result": "审查结果",
|
||||
"fix_required": "需要修复",
|
||||
"error": "错误",
|
||||
"shutdown": "关闭",
|
||||
"message": "消息"
|
||||
}
|
||||
}
|
||||
118
ccw/frontend/src/pages/TeamPage.tsx
Normal file
118
ccw/frontend/src/pages/TeamPage.tsx
Normal file
@@ -0,0 +1,118 @@
|
||||
// ========================================
|
||||
// TeamPage
|
||||
// ========================================
|
||||
// Main page for team execution visualization
|
||||
|
||||
import { useEffect } from 'react';
|
||||
import { useIntl } from 'react-intl';
|
||||
import { Users } from 'lucide-react';
|
||||
import { Card, CardContent } from '@/components/ui/Card';
|
||||
import { useTeamStore } from '@/stores/teamStore';
|
||||
import { useTeams, useTeamMessages, useTeamStatus } from '@/hooks/useTeamData';
|
||||
import { TeamEmptyState } from '@/components/team/TeamEmptyState';
|
||||
import { TeamHeader } from '@/components/team/TeamHeader';
|
||||
import { TeamPipeline } from '@/components/team/TeamPipeline';
|
||||
import { TeamMembersPanel } from '@/components/team/TeamMembersPanel';
|
||||
import { TeamMessageFeed } from '@/components/team/TeamMessageFeed';
|
||||
|
||||
export function TeamPage() {
|
||||
const { formatMessage } = useIntl();
|
||||
const {
|
||||
selectedTeam,
|
||||
setSelectedTeam,
|
||||
autoRefresh,
|
||||
toggleAutoRefresh,
|
||||
messageFilter,
|
||||
setMessageFilter,
|
||||
clearMessageFilter,
|
||||
timelineExpanded,
|
||||
setTimelineExpanded,
|
||||
} = useTeamStore();
|
||||
|
||||
// Data hooks
|
||||
const { teams, isLoading: teamsLoading } = useTeams();
|
||||
const { messages, total: messageTotal, isLoading: messagesLoading } = useTeamMessages(
|
||||
selectedTeam,
|
||||
messageFilter
|
||||
);
|
||||
const { members, totalMessages, isLoading: statusLoading } = useTeamStatus(selectedTeam);
|
||||
|
||||
// Auto-select first team if none selected
|
||||
useEffect(() => {
|
||||
if (!selectedTeam && teams.length > 0) {
|
||||
setSelectedTeam(teams[0].name);
|
||||
}
|
||||
}, [selectedTeam, teams, setSelectedTeam]);
|
||||
|
||||
// Show empty state when no teams exist
|
||||
if (!teamsLoading && teams.length === 0) {
|
||||
return (
|
||||
<div className="p-6">
|
||||
<div className="flex items-center gap-2 mb-6">
|
||||
<Users className="w-5 h-5" />
|
||||
<h1 className="text-xl font-semibold">{formatMessage({ id: 'team.title' })}</h1>
|
||||
</div>
|
||||
<TeamEmptyState />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-6">
|
||||
{/* Page title */}
|
||||
<div className="flex items-center gap-2">
|
||||
<Users className="w-5 h-5" />
|
||||
<h1 className="text-xl font-semibold">{formatMessage({ id: 'team.title' })}</h1>
|
||||
</div>
|
||||
|
||||
{/* Team Header: selector + stats + controls */}
|
||||
<TeamHeader
|
||||
teams={teams}
|
||||
selectedTeam={selectedTeam}
|
||||
onSelectTeam={setSelectedTeam}
|
||||
members={members}
|
||||
totalMessages={totalMessages}
|
||||
autoRefresh={autoRefresh}
|
||||
onToggleAutoRefresh={toggleAutoRefresh}
|
||||
/>
|
||||
|
||||
{selectedTeam ? (
|
||||
<>
|
||||
{/* Main content grid: Pipeline (left) + Members (right) */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
{/* Pipeline visualization */}
|
||||
<Card className="lg:col-span-2">
|
||||
<CardContent className="p-4">
|
||||
<TeamPipeline messages={messages} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Members panel */}
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<TeamMembersPanel members={members} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Message timeline */}
|
||||
<TeamMessageFeed
|
||||
messages={messages}
|
||||
total={messageTotal}
|
||||
filter={messageFilter}
|
||||
onFilterChange={setMessageFilter}
|
||||
onClearFilter={clearMessageFilter}
|
||||
expanded={timelineExpanded}
|
||||
onExpandedChange={setTimelineExpanded}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<div className="text-center py-12 text-muted-foreground">
|
||||
{formatMessage({ id: 'team.noTeamSelected' })}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default TeamPage;
|
||||
@@ -34,3 +34,4 @@ export { CodexLensManagerPage } from './CodexLensManagerPage';
|
||||
export { ApiSettingsPage } from './ApiSettingsPage';
|
||||
export { CliViewerPage } from './CliViewerPage';
|
||||
export { IssueManagerPage } from './IssueManagerPage';
|
||||
export { TeamPage } from './TeamPage';
|
||||
|
||||
@@ -38,6 +38,7 @@ import {
|
||||
CodexLensManagerPage,
|
||||
ApiSettingsPage,
|
||||
CliViewerPage,
|
||||
TeamPage,
|
||||
} from '@/pages';
|
||||
|
||||
/**
|
||||
@@ -167,6 +168,10 @@ const routes: RouteObject[] = [
|
||||
path: 'graph',
|
||||
element: <GraphExplorerPage />,
|
||||
},
|
||||
{
|
||||
path: 'teams',
|
||||
element: <TeamPage />,
|
||||
},
|
||||
// Catch-all route for 404
|
||||
{
|
||||
path: '*',
|
||||
@@ -221,6 +226,7 @@ export const ROUTES = {
|
||||
HELP: '/help',
|
||||
EXPLORER: '/explorer',
|
||||
GRAPH: '/graph',
|
||||
TEAMS: '/teams',
|
||||
} as const;
|
||||
|
||||
export type RoutePath = (typeof ROUTES)[keyof typeof ROUTES];
|
||||
|
||||
41
ccw/frontend/src/stores/teamStore.ts
Normal file
41
ccw/frontend/src/stores/teamStore.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
// ========================================
|
||||
// Team Store
|
||||
// ========================================
|
||||
// UI state for team execution visualization
|
||||
|
||||
import { create } from 'zustand';
|
||||
import { devtools, persist } from 'zustand/middleware';
|
||||
import type { TeamMessageFilter } from '@/types/team';
|
||||
|
||||
interface TeamStore {
|
||||
selectedTeam: string | null;
|
||||
autoRefresh: boolean;
|
||||
messageFilter: TeamMessageFilter;
|
||||
timelineExpanded: boolean;
|
||||
setSelectedTeam: (name: string | null) => void;
|
||||
toggleAutoRefresh: () => void;
|
||||
setMessageFilter: (filter: Partial<TeamMessageFilter>) => void;
|
||||
clearMessageFilter: () => void;
|
||||
setTimelineExpanded: (expanded: boolean) => void;
|
||||
}
|
||||
|
||||
export const useTeamStore = create<TeamStore>()(
|
||||
devtools(
|
||||
persist(
|
||||
(set) => ({
|
||||
selectedTeam: null,
|
||||
autoRefresh: true,
|
||||
messageFilter: {},
|
||||
timelineExpanded: true,
|
||||
setSelectedTeam: (name) => set({ selectedTeam: name }),
|
||||
toggleAutoRefresh: () => set((s) => ({ autoRefresh: !s.autoRefresh })),
|
||||
setMessageFilter: (filter) =>
|
||||
set((s) => ({ messageFilter: { ...s.messageFilter, ...filter } })),
|
||||
clearMessageFilter: () => set({ messageFilter: {} }),
|
||||
setTimelineExpanded: (expanded) => set({ timelineExpanded: expanded }),
|
||||
}),
|
||||
{ name: 'ccw-team-store' }
|
||||
),
|
||||
{ name: 'TeamStore' }
|
||||
)
|
||||
);
|
||||
66
ccw/frontend/src/types/team.ts
Normal file
66
ccw/frontend/src/types/team.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
// ========================================
|
||||
// Team Types
|
||||
// ========================================
|
||||
// Types for team execution visualization
|
||||
|
||||
export interface TeamMessage {
|
||||
id: string;
|
||||
ts: string;
|
||||
from: string;
|
||||
to: string;
|
||||
type: TeamMessageType;
|
||||
summary: string;
|
||||
ref?: string;
|
||||
data?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export type TeamMessageType =
|
||||
| 'plan_ready'
|
||||
| 'plan_approved'
|
||||
| 'plan_revision'
|
||||
| 'task_unblocked'
|
||||
| 'impl_complete'
|
||||
| 'impl_progress'
|
||||
| 'test_result'
|
||||
| 'review_result'
|
||||
| 'fix_required'
|
||||
| 'error'
|
||||
| 'shutdown'
|
||||
| 'message';
|
||||
|
||||
export interface TeamMember {
|
||||
member: string;
|
||||
lastSeen: string;
|
||||
lastAction: string;
|
||||
messageCount: number;
|
||||
}
|
||||
|
||||
export interface TeamSummary {
|
||||
name: string;
|
||||
messageCount: number;
|
||||
lastActivity: string;
|
||||
}
|
||||
|
||||
export interface TeamMessagesResponse {
|
||||
total: number;
|
||||
showing: number;
|
||||
messages: TeamMessage[];
|
||||
}
|
||||
|
||||
export interface TeamStatusResponse {
|
||||
members: TeamMember[];
|
||||
total_messages: number;
|
||||
}
|
||||
|
||||
export interface TeamsListResponse {
|
||||
teams: TeamSummary[];
|
||||
}
|
||||
|
||||
export interface TeamMessageFilter {
|
||||
from?: string;
|
||||
to?: string;
|
||||
type?: string;
|
||||
}
|
||||
|
||||
export type PipelineStage = 'plan' | 'impl' | 'test' | 'review';
|
||||
export type PipelineStageStatus = 'completed' | 'in_progress' | 'pending' | 'blocked';
|
||||
126
ccw/src/core/routes/team-routes.ts
Normal file
126
ccw/src/core/routes/team-routes.ts
Normal file
@@ -0,0 +1,126 @@
|
||||
/**
|
||||
* Team Routes - REST API for team message visualization
|
||||
*
|
||||
* Endpoints:
|
||||
* - GET /api/teams - List all teams
|
||||
* - GET /api/teams/:name/messages - Get messages (with filters)
|
||||
* - GET /api/teams/:name/status - Get member status summary
|
||||
*/
|
||||
|
||||
import { existsSync, readdirSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import type { RouteContext } from './types.js';
|
||||
import { readAllMessages, getLogDir } from '../../tools/team-msg.js';
|
||||
import { getProjectRoot } from '../../utils/path-validator.js';
|
||||
|
||||
export async function handleTeamRoutes(ctx: RouteContext): Promise<boolean> {
|
||||
const { pathname, req, res, url } = ctx;
|
||||
|
||||
if (!pathname.startsWith('/api/teams')) return false;
|
||||
if (req.method !== 'GET') return false;
|
||||
|
||||
// GET /api/teams - List all teams
|
||||
if (pathname === '/api/teams') {
|
||||
try {
|
||||
const root = getProjectRoot();
|
||||
const teamMsgDir = join(root, '.workflow', '.team-msg');
|
||||
|
||||
if (!existsSync(teamMsgDir)) {
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ teams: [] }));
|
||||
return true;
|
||||
}
|
||||
|
||||
const entries = readdirSync(teamMsgDir, { withFileTypes: true });
|
||||
const teams = entries
|
||||
.filter(e => e.isDirectory())
|
||||
.map(e => {
|
||||
const messages = readAllMessages(e.name);
|
||||
const lastMsg = messages[messages.length - 1];
|
||||
return {
|
||||
name: e.name,
|
||||
messageCount: messages.length,
|
||||
lastActivity: lastMsg?.ts || '',
|
||||
};
|
||||
})
|
||||
.sort((a, b) => b.lastActivity.localeCompare(a.lastActivity));
|
||||
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ teams }));
|
||||
return true;
|
||||
} catch (error) {
|
||||
res.writeHead(500, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ error: (error as Error).message }));
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// Match /api/teams/:name/messages or /api/teams/:name/status
|
||||
const match = pathname.match(/^\/api\/teams\/([^/]+)\/(messages|status)$/);
|
||||
if (!match) return false;
|
||||
|
||||
const teamName = decodeURIComponent(match[1]);
|
||||
const action = match[2];
|
||||
|
||||
// GET /api/teams/:name/messages
|
||||
if (action === 'messages') {
|
||||
try {
|
||||
let messages = readAllMessages(teamName);
|
||||
|
||||
// Apply query filters
|
||||
const fromFilter = url.searchParams.get('from');
|
||||
const toFilter = url.searchParams.get('to');
|
||||
const typeFilter = url.searchParams.get('type');
|
||||
const last = parseInt(url.searchParams.get('last') || '50', 10);
|
||||
const offset = parseInt(url.searchParams.get('offset') || '0', 10);
|
||||
|
||||
if (fromFilter) messages = messages.filter(m => m.from === fromFilter);
|
||||
if (toFilter) messages = messages.filter(m => m.to === toFilter);
|
||||
if (typeFilter) messages = messages.filter(m => m.type === typeFilter);
|
||||
|
||||
const total = messages.length;
|
||||
const sliced = messages.slice(Math.max(0, total - last - offset), total - offset);
|
||||
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ total, showing: sliced.length, messages: sliced }));
|
||||
return true;
|
||||
} catch (error) {
|
||||
res.writeHead(500, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ error: (error as Error).message }));
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// GET /api/teams/:name/status
|
||||
if (action === 'status') {
|
||||
try {
|
||||
const messages = readAllMessages(teamName);
|
||||
|
||||
const memberMap = new Map<string, { member: string; lastSeen: string; lastAction: string; messageCount: number }>();
|
||||
|
||||
for (const msg of messages) {
|
||||
for (const role of [msg.from, msg.to]) {
|
||||
if (!memberMap.has(role)) {
|
||||
memberMap.set(role, { member: role, lastSeen: msg.ts, lastAction: '', messageCount: 0 });
|
||||
}
|
||||
}
|
||||
const entry = memberMap.get(msg.from)!;
|
||||
entry.lastSeen = msg.ts;
|
||||
entry.lastAction = `sent ${msg.type} -> ${msg.to}`;
|
||||
entry.messageCount++;
|
||||
}
|
||||
|
||||
const members = Array.from(memberMap.values()).sort((a, b) => b.lastSeen.localeCompare(a.lastSeen));
|
||||
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ members, total_messages: messages.length }));
|
||||
return true;
|
||||
} catch (error) {
|
||||
res.writeHead(500, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ error: (error as Error).message }));
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
@@ -38,6 +38,7 @@ import { handleTaskRoutes } from './routes/task-routes.js';
|
||||
import { handleDashboardRoutes } from './routes/dashboard-routes.js';
|
||||
import { handleOrchestratorRoutes } from './routes/orchestrator-routes.js';
|
||||
import { handleConfigRoutes } from './routes/config-routes.js';
|
||||
import { handleTeamRoutes } from './routes/team-routes.js';
|
||||
|
||||
// Import WebSocket handling
|
||||
import { handleWebSocketUpgrade, broadcastToClients, extractSessionIdFromPath } from './websocket.js';
|
||||
@@ -683,6 +684,11 @@ export async function startServer(options: ServerOptions = {}): Promise<http.Ser
|
||||
if (await handleLoopRoutes(routeContext)) return;
|
||||
}
|
||||
|
||||
// Team routes (/api/teams*)
|
||||
if (pathname.startsWith('/api/teams')) {
|
||||
if (await handleTeamRoutes(routeContext)) return;
|
||||
}
|
||||
|
||||
// Task routes (/api/tasks)
|
||||
if (pathname.startsWith('/api/tasks')) {
|
||||
if (await handleTaskRoutes(routeContext)) return;
|
||||
|
||||
@@ -22,7 +22,7 @@ const ENV_PROJECT_ROOT = 'CCW_PROJECT_ROOT';
|
||||
const ENV_ALLOWED_DIRS = 'CCW_ALLOWED_DIRS';
|
||||
|
||||
// Default enabled tools (core set - file operations, core memory, and smart search)
|
||||
const DEFAULT_TOOLS: string[] = ['write_file', 'edit_file', 'read_file', 'core_memory', 'smart_search'];
|
||||
const DEFAULT_TOOLS: string[] = ['write_file', 'edit_file', 'read_file', 'read_many_files', 'core_memory', 'smart_search'];
|
||||
|
||||
/**
|
||||
* Get list of enabled tools from environment or defaults
|
||||
|
||||
@@ -23,10 +23,12 @@ import { executeInitWithProgress } from './smart-search.js';
|
||||
import * as codexLensLspMod from './codex-lens-lsp.js';
|
||||
import * as vscodeLspMod from './vscode-lsp.js';
|
||||
import * as readFileMod from './read-file.js';
|
||||
import * as readManyFilesMod from './read-many-files.js';
|
||||
import * as coreMemoryMod from './core-memory.js';
|
||||
import * as contextCacheMod from './context-cache.js';
|
||||
import * as skillContextLoaderMod from './skill-context-loader.js';
|
||||
import * as askQuestionMod from './ask-question.js';
|
||||
import * as teamMsgMod from './team-msg.js';
|
||||
import type { ProgressInfo } from './codex-lens.js';
|
||||
|
||||
// Import legacy JS tools
|
||||
@@ -364,10 +366,12 @@ registerTool(toLegacyTool(smartSearchMod));
|
||||
registerTool(toLegacyTool(codexLensLspMod));
|
||||
registerTool(toLegacyTool(vscodeLspMod));
|
||||
registerTool(toLegacyTool(readFileMod));
|
||||
registerTool(toLegacyTool(readManyFilesMod));
|
||||
registerTool(toLegacyTool(coreMemoryMod));
|
||||
registerTool(toLegacyTool(contextCacheMod));
|
||||
registerTool(toLegacyTool(skillContextLoaderMod));
|
||||
registerTool(toLegacyTool(askQuestionMod));
|
||||
registerTool(toLegacyTool(teamMsgMod));
|
||||
|
||||
// Register legacy JS tools
|
||||
registerTool(uiGeneratePreviewTool);
|
||||
|
||||
@@ -1,417 +1,108 @@
|
||||
/**
|
||||
* Read File Tool - Read files with multi-file, directory, and regex support
|
||||
* Read File Tool - Single file precise reading with optional line pagination
|
||||
*
|
||||
* Features:
|
||||
* - Read single or multiple files
|
||||
* - Read all files in a directory (with depth control)
|
||||
* - Filter files by glob/regex pattern
|
||||
* - Content search with regex
|
||||
* - Compact output format
|
||||
* - Read a single file with full content
|
||||
* - Line-based pagination with offset/limit
|
||||
* - Binary file detection
|
||||
*/
|
||||
|
||||
import { z } from 'zod';
|
||||
import type { ToolSchema, ToolResult } from '../types/tool.js';
|
||||
import { readFileSync, readdirSync, statSync, existsSync } from 'fs';
|
||||
import { resolve, isAbsolute, join, relative, extname } from 'path';
|
||||
import { existsSync, statSync } from 'fs';
|
||||
import { relative } from 'path';
|
||||
import { validatePath, getProjectRoot } from '../utils/path-validator.js';
|
||||
import {
|
||||
MAX_CONTENT_LENGTH,
|
||||
readFileContent,
|
||||
type FileEntry,
|
||||
type ReadResult,
|
||||
} from '../utils/file-reader.js';
|
||||
|
||||
// Max content per file (truncate if larger)
|
||||
const MAX_CONTENT_LENGTH = 5000;
|
||||
// Max files to return
|
||||
const MAX_FILES = 50;
|
||||
// Max total content length
|
||||
const MAX_TOTAL_CONTENT = 50000;
|
||||
|
||||
// Define Zod schema for validation
|
||||
const ParamsSchema = z.object({
|
||||
paths: z.union([z.string(), z.array(z.string())]).describe('File path(s) or directory'),
|
||||
pattern: z.string().optional().describe('Glob pattern to filter files (e.g., "*.ts", "**/*.js")'),
|
||||
contentPattern: z.string().optional().describe('Regex to search within file content'),
|
||||
maxDepth: z.number().default(3).describe('Max directory depth to traverse'),
|
||||
includeContent: z.boolean().default(true).describe('Include file content in result'),
|
||||
maxFiles: z.number().default(MAX_FILES).describe('Max number of files to return'),
|
||||
offset: z.number().min(0).optional().describe('Line offset to start reading from (0-based, for single file only)'),
|
||||
limit: z.number().min(1).optional().describe('Number of lines to read (for single file only)'),
|
||||
}).refine((data) => {
|
||||
// Validate: offset/limit only allowed for single file mode
|
||||
const hasPagination = data.offset !== undefined || data.limit !== undefined;
|
||||
const isMultiple = Array.isArray(data.paths) && data.paths.length > 1;
|
||||
return !(hasPagination && isMultiple);
|
||||
}, {
|
||||
message: 'offset/limit parameters are only supported for single file mode. Cannot use with multiple paths.',
|
||||
path: ['offset', 'limit', 'paths'],
|
||||
path: z.string().describe('Single file path to read'),
|
||||
offset: z.number().min(0).optional().describe('Line offset to start reading from (0-based)'),
|
||||
limit: z.number().min(1).optional().describe('Number of lines to read'),
|
||||
});
|
||||
|
||||
type Params = z.infer<typeof ParamsSchema>;
|
||||
|
||||
interface FileEntry {
|
||||
path: string;
|
||||
size: number;
|
||||
content?: string;
|
||||
truncated?: boolean;
|
||||
matches?: string[];
|
||||
totalLines?: number;
|
||||
lineRange?: { start: number; end: number };
|
||||
}
|
||||
|
||||
interface ReadResult {
|
||||
files: FileEntry[];
|
||||
totalFiles: number;
|
||||
message: string;
|
||||
}
|
||||
|
||||
// Common binary extensions to skip
|
||||
const BINARY_EXTENSIONS = new Set([
|
||||
'.png', '.jpg', '.jpeg', '.gif', '.bmp', '.ico', '.webp', '.svg',
|
||||
'.pdf', '.doc', '.docx', '.xls', '.xlsx', '.ppt', '.pptx',
|
||||
'.zip', '.tar', '.gz', '.rar', '.7z',
|
||||
'.exe', '.dll', '.so', '.dylib',
|
||||
'.mp3', '.mp4', '.wav', '.avi', '.mov',
|
||||
'.woff', '.woff2', '.ttf', '.eot', '.otf',
|
||||
'.pyc', '.class', '.o', '.obj',
|
||||
]);
|
||||
|
||||
/**
|
||||
* Check if file is likely binary
|
||||
*/
|
||||
function isBinaryFile(filePath: string): boolean {
|
||||
const ext = extname(filePath).toLowerCase();
|
||||
return BINARY_EXTENSIONS.has(ext);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert glob pattern to regex
|
||||
*/
|
||||
function globToRegex(pattern: string): RegExp {
|
||||
const escaped = pattern
|
||||
.replace(/[.+^${}()|[\]\\]/g, '\\$&')
|
||||
.replace(/\*/g, '.*')
|
||||
.replace(/\?/g, '.');
|
||||
return new RegExp(`^${escaped}$`, 'i');
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if filename matches glob pattern
|
||||
*/
|
||||
function matchesPattern(filename: string, pattern: string): boolean {
|
||||
const regex = globToRegex(pattern);
|
||||
return regex.test(filename);
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively collect files from directory
|
||||
*/
|
||||
function collectFiles(
|
||||
dir: string,
|
||||
pattern: string | undefined,
|
||||
maxDepth: number,
|
||||
currentDepth: number = 0
|
||||
): string[] {
|
||||
if (currentDepth > maxDepth) return [];
|
||||
|
||||
const files: string[] = [];
|
||||
|
||||
try {
|
||||
const entries = readdirSync(dir, { withFileTypes: true });
|
||||
|
||||
for (const entry of entries) {
|
||||
// Skip hidden files/dirs and node_modules
|
||||
if (entry.name.startsWith('.') || entry.name === 'node_modules') continue;
|
||||
|
||||
const fullPath = join(dir, entry.name);
|
||||
|
||||
if (entry.isDirectory()) {
|
||||
files.push(...collectFiles(fullPath, pattern, maxDepth, currentDepth + 1));
|
||||
} else if (entry.isFile()) {
|
||||
if (!pattern || matchesPattern(entry.name, pattern)) {
|
||||
files.push(fullPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Skip directories we can't read
|
||||
}
|
||||
|
||||
return files;
|
||||
}
|
||||
|
||||
interface ReadContentOptions {
|
||||
maxLength: number;
|
||||
offset?: number;
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
interface ReadContentResult {
|
||||
content: string;
|
||||
truncated: boolean;
|
||||
totalLines?: number;
|
||||
lineRange?: { start: number; end: number };
|
||||
}
|
||||
|
||||
/**
|
||||
* Read file content with truncation and optional line-based pagination
|
||||
*/
|
||||
function readFileContent(filePath: string, options: ReadContentOptions): ReadContentResult {
|
||||
const { maxLength, offset, limit } = options;
|
||||
|
||||
if (isBinaryFile(filePath)) {
|
||||
return { content: '[Binary file]', truncated: false };
|
||||
}
|
||||
|
||||
try {
|
||||
const content = readFileSync(filePath, 'utf8');
|
||||
const lines = content.split('\n');
|
||||
const totalLines = lines.length;
|
||||
|
||||
// If offset/limit specified, use line-based pagination
|
||||
if (offset !== undefined || limit !== undefined) {
|
||||
const startLine = Math.min(offset ?? 0, totalLines);
|
||||
const endLine = limit !== undefined ? Math.min(startLine + limit, totalLines) : totalLines;
|
||||
const selectedLines = lines.slice(startLine, endLine);
|
||||
const selectedContent = selectedLines.join('\n');
|
||||
|
||||
const actualEnd = endLine;
|
||||
const hasMore = actualEnd < totalLines;
|
||||
|
||||
let finalContent = selectedContent;
|
||||
if (selectedContent.length > maxLength) {
|
||||
finalContent = selectedContent.substring(0, maxLength) + `\n... (+${selectedContent.length - maxLength} chars)`;
|
||||
}
|
||||
|
||||
// Calculate actual line range (handle empty selection)
|
||||
const actualLineEnd = selectedLines.length > 0 ? startLine + selectedLines.length - 1 : startLine;
|
||||
|
||||
return {
|
||||
content: finalContent,
|
||||
truncated: hasMore || selectedContent.length > maxLength,
|
||||
totalLines,
|
||||
lineRange: { start: startLine, end: actualLineEnd },
|
||||
};
|
||||
}
|
||||
|
||||
// Default behavior: truncate by character length
|
||||
if (content.length > maxLength) {
|
||||
return {
|
||||
content: content.substring(0, maxLength) + `\n... (+${content.length - maxLength} chars)`,
|
||||
truncated: true,
|
||||
totalLines,
|
||||
};
|
||||
}
|
||||
return { content, truncated: false, totalLines };
|
||||
} catch (error) {
|
||||
return { content: `[Error: ${(error as Error).message}]`, truncated: false };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Find regex matches in content
|
||||
*/
|
||||
function findMatches(content: string, pattern: string): string[] {
|
||||
try {
|
||||
const regex = new RegExp(pattern, 'gm');
|
||||
const matches: string[] = [];
|
||||
let match;
|
||||
|
||||
while ((match = regex.exec(content)) !== null && matches.length < 10) {
|
||||
// Get line containing match
|
||||
const lineStart = content.lastIndexOf('\n', match.index) + 1;
|
||||
const lineEnd = content.indexOf('\n', match.index);
|
||||
const line = content.substring(lineStart, lineEnd === -1 ? undefined : lineEnd).trim();
|
||||
matches.push(line.substring(0, 200)); // Truncate long lines
|
||||
}
|
||||
|
||||
return matches;
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// Tool schema for MCP
|
||||
export const schema: ToolSchema = {
|
||||
name: 'read_file',
|
||||
description: `Read files with multi-file, directory, regex support, and line-based pagination.
|
||||
description: `Read a single file with optional line-based pagination.
|
||||
|
||||
Usage:
|
||||
read_file(paths="file.ts") # Single file (full content)
|
||||
read_file(paths="file.ts", offset=100, limit=50) # Lines 100-149 (0-based)
|
||||
read_file(paths=["a.ts", "b.ts"]) # Multiple files
|
||||
read_file(paths="src/", pattern="*.ts") # Directory with pattern
|
||||
read_file(paths="src/", contentPattern="TODO") # Search content
|
||||
read_file(path="file.ts") # Full content
|
||||
read_file(path="file.ts", offset=100, limit=50) # Lines 100-149 (0-based)
|
||||
|
||||
Supports both absolute and relative paths. Relative paths are resolved from project root.
|
||||
Returns compact file list with optional content. Use offset/limit for large file pagination.`,
|
||||
Use offset/limit for large file pagination.`,
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
paths: {
|
||||
oneOf: [
|
||||
{ type: 'string', description: 'Single file or directory path' },
|
||||
{ type: 'array', items: { type: 'string' }, description: 'Array of file paths' }
|
||||
],
|
||||
description: 'File path(s) or directory to read',
|
||||
},
|
||||
pattern: {
|
||||
path: {
|
||||
type: 'string',
|
||||
description: 'Glob pattern to filter files (e.g., "*.ts", "*.{js,ts}")',
|
||||
},
|
||||
contentPattern: {
|
||||
type: 'string',
|
||||
description: 'Regex pattern to search within file content',
|
||||
},
|
||||
maxDepth: {
|
||||
type: 'number',
|
||||
description: 'Max directory depth to traverse (default: 3)',
|
||||
default: 3,
|
||||
},
|
||||
includeContent: {
|
||||
type: 'boolean',
|
||||
description: 'Include file content in result (default: true)',
|
||||
default: true,
|
||||
},
|
||||
maxFiles: {
|
||||
type: 'number',
|
||||
description: `Max number of files to return (default: ${MAX_FILES})`,
|
||||
default: MAX_FILES,
|
||||
description: 'Single file path to read',
|
||||
},
|
||||
offset: {
|
||||
type: 'number',
|
||||
description: 'Line offset to start reading from (0-based). **Only for single file mode** - validation error if used with multiple paths.',
|
||||
description: 'Line offset to start reading from (0-based)',
|
||||
minimum: 0,
|
||||
},
|
||||
limit: {
|
||||
type: 'number',
|
||||
description: 'Number of lines to read. **Only for single file mode** - validation error if used with multiple paths.',
|
||||
description: 'Number of lines to read',
|
||||
minimum: 1,
|
||||
},
|
||||
},
|
||||
required: ['paths'],
|
||||
required: ['path'],
|
||||
},
|
||||
};
|
||||
|
||||
// Handler function
|
||||
export async function handler(params: Record<string, unknown>): Promise<ToolResult<ReadResult>> {
|
||||
const parsed = ParamsSchema.safeParse(params);
|
||||
if (!parsed.success) {
|
||||
return { success: false, error: `Invalid params: ${parsed.error.message}` };
|
||||
}
|
||||
|
||||
const {
|
||||
paths,
|
||||
pattern,
|
||||
contentPattern,
|
||||
maxDepth,
|
||||
includeContent,
|
||||
maxFiles,
|
||||
const { path: filePath, offset, limit } = parsed.data;
|
||||
const cwd = getProjectRoot();
|
||||
const resolvedPath = await validatePath(filePath);
|
||||
|
||||
if (!existsSync(resolvedPath)) {
|
||||
return { success: false, error: `File not found: ${filePath}` };
|
||||
}
|
||||
|
||||
const stat = statSync(resolvedPath);
|
||||
if (!stat.isFile()) {
|
||||
return { success: false, error: `Not a file: ${filePath}. Use read_many_files for directories.` };
|
||||
}
|
||||
|
||||
const { content, truncated, totalLines, lineRange } = readFileContent(resolvedPath, {
|
||||
maxLength: MAX_CONTENT_LENGTH,
|
||||
offset,
|
||||
limit,
|
||||
} = parsed.data;
|
||||
});
|
||||
|
||||
const cwd = getProjectRoot();
|
||||
const entry: FileEntry = {
|
||||
path: relative(cwd, resolvedPath) || filePath,
|
||||
size: stat.size,
|
||||
content,
|
||||
truncated,
|
||||
totalLines,
|
||||
lineRange,
|
||||
};
|
||||
|
||||
// Normalize paths to array
|
||||
const inputPaths = Array.isArray(paths) ? paths : [paths];
|
||||
|
||||
// Collect all files to read
|
||||
const allFiles: string[] = [];
|
||||
|
||||
for (const inputPath of inputPaths) {
|
||||
const resolvedPath = await validatePath(inputPath);
|
||||
|
||||
if (!existsSync(resolvedPath)) {
|
||||
continue; // Skip non-existent paths
|
||||
}
|
||||
|
||||
const stat = statSync(resolvedPath);
|
||||
|
||||
if (stat.isDirectory()) {
|
||||
// Collect files from directory
|
||||
const dirFiles = collectFiles(resolvedPath, pattern, maxDepth);
|
||||
allFiles.push(...dirFiles);
|
||||
} else if (stat.isFile()) {
|
||||
// Add single file (check pattern if provided)
|
||||
if (!pattern || matchesPattern(relative(cwd, resolvedPath), pattern)) {
|
||||
allFiles.push(resolvedPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Limit files
|
||||
const limitedFiles = allFiles.slice(0, maxFiles);
|
||||
const totalFiles = allFiles.length;
|
||||
|
||||
// Process files
|
||||
const files: FileEntry[] = [];
|
||||
let totalContent = 0;
|
||||
|
||||
// Only apply offset/limit for single file mode
|
||||
const isSingleFile = limitedFiles.length === 1;
|
||||
const useLinePagination = isSingleFile && (offset !== undefined || limit !== undefined);
|
||||
|
||||
for (const filePath of limitedFiles) {
|
||||
if (totalContent >= MAX_TOTAL_CONTENT) break;
|
||||
|
||||
const stat = statSync(filePath);
|
||||
const entry: FileEntry = {
|
||||
path: relative(cwd, filePath) || filePath,
|
||||
size: stat.size,
|
||||
};
|
||||
|
||||
if (includeContent) {
|
||||
const remainingSpace = MAX_TOTAL_CONTENT - totalContent;
|
||||
const maxLen = Math.min(MAX_CONTENT_LENGTH, remainingSpace);
|
||||
|
||||
// Pass offset/limit only for single file mode
|
||||
const readOptions: ReadContentOptions = { maxLength: maxLen };
|
||||
if (useLinePagination) {
|
||||
if (offset !== undefined) readOptions.offset = offset;
|
||||
if (limit !== undefined) readOptions.limit = limit;
|
||||
}
|
||||
|
||||
const { content, truncated, totalLines, lineRange } = readFileContent(filePath, readOptions);
|
||||
|
||||
// If contentPattern provided, only include files with matches
|
||||
if (contentPattern) {
|
||||
const matches = findMatches(content, contentPattern);
|
||||
if (matches.length > 0) {
|
||||
entry.matches = matches;
|
||||
entry.content = content;
|
||||
entry.truncated = truncated;
|
||||
entry.totalLines = totalLines;
|
||||
entry.lineRange = lineRange;
|
||||
totalContent += content.length;
|
||||
} else {
|
||||
continue; // Skip files without matches
|
||||
}
|
||||
} else {
|
||||
entry.content = content;
|
||||
entry.truncated = truncated;
|
||||
entry.totalLines = totalLines;
|
||||
entry.lineRange = lineRange;
|
||||
totalContent += content.length;
|
||||
}
|
||||
}
|
||||
|
||||
files.push(entry);
|
||||
}
|
||||
|
||||
// Build message
|
||||
let message = `Read ${files.length} file(s)`;
|
||||
if (totalFiles > maxFiles) {
|
||||
message += ` (showing ${maxFiles} of ${totalFiles})`;
|
||||
}
|
||||
if (useLinePagination && files.length > 0 && files[0].lineRange) {
|
||||
const { start, end } = files[0].lineRange;
|
||||
message += ` [lines ${start}-${end} of ${files[0].totalLines}]`;
|
||||
}
|
||||
if (contentPattern) {
|
||||
message += ` matching "${contentPattern}"`;
|
||||
let message = `Read 1 file`;
|
||||
if (lineRange) {
|
||||
message += ` [lines ${lineRange.start}-${lineRange.end} of ${totalLines}]`;
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
result: {
|
||||
files,
|
||||
totalFiles,
|
||||
files: [entry],
|
||||
totalFiles: 1,
|
||||
message,
|
||||
},
|
||||
};
|
||||
|
||||
195
ccw/src/tools/read-many-files.ts
Normal file
195
ccw/src/tools/read-many-files.ts
Normal file
@@ -0,0 +1,195 @@
|
||||
/**
|
||||
* Read Many Files Tool - Multi-file batch reading with directory traversal and content search
|
||||
*
|
||||
* Features:
|
||||
* - Read multiple files at once
|
||||
* - Read all files in a directory (with depth control)
|
||||
* - Filter files by glob pattern
|
||||
* - Content search with regex
|
||||
* - Compact output format
|
||||
*/
|
||||
|
||||
import { z } from 'zod';
|
||||
import type { ToolSchema, ToolResult } from '../types/tool.js';
|
||||
import { existsSync, statSync } from 'fs';
|
||||
import { relative } from 'path';
|
||||
import { validatePath, getProjectRoot } from '../utils/path-validator.js';
|
||||
import {
|
||||
MAX_CONTENT_LENGTH,
|
||||
MAX_FILES,
|
||||
MAX_TOTAL_CONTENT,
|
||||
collectFiles,
|
||||
matchesPattern,
|
||||
readFileContent,
|
||||
findMatches,
|
||||
type FileEntry,
|
||||
type ReadResult,
|
||||
} from '../utils/file-reader.js';
|
||||
|
||||
const ParamsSchema = z.object({
|
||||
paths: z.union([z.string(), z.array(z.string())]).describe('File path(s) or directory'),
|
||||
pattern: z.string().optional().describe('Glob pattern to filter files (e.g., "*.ts", "**/*.js")'),
|
||||
contentPattern: z.string().optional().describe('Regex to search within file content'),
|
||||
maxDepth: z.number().default(3).describe('Max directory depth to traverse'),
|
||||
includeContent: z.boolean().default(true).describe('Include file content in result'),
|
||||
maxFiles: z.number().default(MAX_FILES).describe('Max number of files to return'),
|
||||
});
|
||||
|
||||
type Params = z.infer<typeof ParamsSchema>;
|
||||
|
||||
export const schema: ToolSchema = {
|
||||
name: 'read_many_files',
|
||||
description: `Read multiple files, directories, or search file content with regex.
|
||||
|
||||
Usage:
|
||||
read_many_files(paths=["a.ts", "b.ts"]) # Multiple files
|
||||
read_many_files(paths="src/", pattern="*.ts") # Directory with glob filter
|
||||
read_many_files(paths="src/", contentPattern="TODO") # Search content with regex
|
||||
read_many_files(paths="src/", pattern="*.ts", includeContent=false) # List files only
|
||||
|
||||
Supports both absolute and relative paths. Relative paths are resolved from project root.`,
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
paths: {
|
||||
oneOf: [
|
||||
{ type: 'string', description: 'Single file or directory path' },
|
||||
{ type: 'array', items: { type: 'string' }, description: 'Array of file paths' },
|
||||
],
|
||||
description: 'File path(s) or directory to read',
|
||||
},
|
||||
pattern: {
|
||||
type: 'string',
|
||||
description: 'Glob pattern to filter files (e.g., "*.ts", "*.{js,ts}")',
|
||||
},
|
||||
contentPattern: {
|
||||
type: 'string',
|
||||
description: 'Regex pattern to search within file content. Empty string "" returns all content. Dangerous patterns automatically fall back to returning all content for safety.',
|
||||
},
|
||||
maxDepth: {
|
||||
type: 'number',
|
||||
description: 'Max directory depth to traverse (default: 3)',
|
||||
default: 3,
|
||||
},
|
||||
includeContent: {
|
||||
type: 'boolean',
|
||||
description: 'Include file content in result (default: true)',
|
||||
default: true,
|
||||
},
|
||||
maxFiles: {
|
||||
type: 'number',
|
||||
description: `Max number of files to return (default: ${MAX_FILES})`,
|
||||
default: MAX_FILES,
|
||||
},
|
||||
},
|
||||
required: ['paths'],
|
||||
},
|
||||
};
|
||||
|
||||
export async function handler(params: Record<string, unknown>): Promise<ToolResult<ReadResult>> {
|
||||
const parsed = ParamsSchema.safeParse(params);
|
||||
if (!parsed.success) {
|
||||
return { success: false, error: `Invalid params: ${parsed.error.message}` };
|
||||
}
|
||||
|
||||
const { paths, pattern, contentPattern, maxDepth, includeContent, maxFiles } = parsed.data;
|
||||
const cwd = getProjectRoot();
|
||||
|
||||
// Normalize paths to array
|
||||
const inputPaths = Array.isArray(paths) ? paths : [paths];
|
||||
|
||||
// Collect all files to read
|
||||
const allFiles: string[] = [];
|
||||
|
||||
for (const inputPath of inputPaths) {
|
||||
const resolvedPath = await validatePath(inputPath);
|
||||
|
||||
if (!existsSync(resolvedPath)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const stat = statSync(resolvedPath);
|
||||
|
||||
if (stat.isDirectory()) {
|
||||
const dirFiles = collectFiles(resolvedPath, pattern, maxDepth);
|
||||
allFiles.push(...dirFiles);
|
||||
} else if (stat.isFile()) {
|
||||
if (!pattern || matchesPattern(relative(cwd, resolvedPath), pattern)) {
|
||||
allFiles.push(resolvedPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Limit files
|
||||
const limitedFiles = allFiles.slice(0, maxFiles);
|
||||
const totalFiles = allFiles.length;
|
||||
|
||||
// Process files
|
||||
const files: FileEntry[] = [];
|
||||
let totalContent = 0;
|
||||
|
||||
for (const filePath of limitedFiles) {
|
||||
if (totalContent >= MAX_TOTAL_CONTENT) break;
|
||||
|
||||
const stat = statSync(filePath);
|
||||
const entry: FileEntry = {
|
||||
path: relative(cwd, filePath) || filePath,
|
||||
size: stat.size,
|
||||
};
|
||||
|
||||
if (includeContent) {
|
||||
const remainingSpace = MAX_TOTAL_CONTENT - totalContent;
|
||||
const maxLen = Math.min(MAX_CONTENT_LENGTH, remainingSpace);
|
||||
|
||||
const { content, truncated, totalLines, lineRange } = readFileContent(filePath, { maxLength: maxLen });
|
||||
|
||||
if (contentPattern) {
|
||||
const matches = findMatches(content, contentPattern);
|
||||
|
||||
if (matches === null) {
|
||||
// Empty/dangerous pattern: include all content
|
||||
entry.content = content;
|
||||
entry.truncated = truncated;
|
||||
entry.totalLines = totalLines;
|
||||
entry.lineRange = lineRange;
|
||||
totalContent += content.length;
|
||||
} else if (matches.length > 0) {
|
||||
entry.matches = matches;
|
||||
entry.content = content;
|
||||
entry.truncated = truncated;
|
||||
entry.totalLines = totalLines;
|
||||
entry.lineRange = lineRange;
|
||||
totalContent += content.length;
|
||||
} else {
|
||||
// No matches: skip file
|
||||
continue;
|
||||
}
|
||||
} else {
|
||||
entry.content = content;
|
||||
entry.truncated = truncated;
|
||||
entry.totalLines = totalLines;
|
||||
entry.lineRange = lineRange;
|
||||
totalContent += content.length;
|
||||
}
|
||||
}
|
||||
|
||||
files.push(entry);
|
||||
}
|
||||
|
||||
let message = `Read ${files.length} file(s)`;
|
||||
if (totalFiles > maxFiles) {
|
||||
message += ` (showing ${maxFiles} of ${totalFiles})`;
|
||||
}
|
||||
if (contentPattern) {
|
||||
message += ` matching "${contentPattern}"`;
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
result: {
|
||||
files,
|
||||
totalFiles,
|
||||
message,
|
||||
},
|
||||
};
|
||||
}
|
||||
271
ccw/src/tools/team-msg.ts
Normal file
271
ccw/src/tools/team-msg.ts
Normal file
@@ -0,0 +1,271 @@
|
||||
/**
|
||||
* Team Message Bus - JSONL-based persistent message log for Agent Teams
|
||||
*
|
||||
* Operations:
|
||||
* - log: Append a message, returns auto-incremented ID
|
||||
* - read: Read message(s) by ID
|
||||
* - list: List recent messages with optional filters (from/to/type/last N)
|
||||
* - status: Summarize team member activity from message history
|
||||
*/
|
||||
|
||||
import { z } from 'zod';
|
||||
import type { ToolSchema, ToolResult } from '../types/tool.js';
|
||||
import { existsSync, mkdirSync, readFileSync, appendFileSync } from 'fs';
|
||||
import { join, dirname } from 'path';
|
||||
import { getProjectRoot } from '../utils/path-validator.js';
|
||||
|
||||
// --- Types ---
|
||||
|
||||
export interface TeamMessage {
|
||||
id: string;
|
||||
ts: string;
|
||||
from: string;
|
||||
to: string;
|
||||
type: string;
|
||||
summary: string;
|
||||
ref?: string;
|
||||
data?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface StatusEntry {
|
||||
member: string;
|
||||
lastSeen: string;
|
||||
lastAction: string;
|
||||
messageCount: number;
|
||||
}
|
||||
|
||||
// --- Zod Schema ---
|
||||
|
||||
const ParamsSchema = z.object({
|
||||
operation: z.enum(['log', 'read', 'list', 'status']).describe('Operation to perform'),
|
||||
team: z.string().describe('Team name (maps to .workflow/.team-msg/{team}/messages.jsonl)'),
|
||||
|
||||
// log params
|
||||
from: z.string().optional().describe('[log/list] Sender role name'),
|
||||
to: z.string().optional().describe('[log/list] Recipient role name'),
|
||||
type: z.string().optional().describe('[log/list] Message type (plan_ready, impl_complete, test_result, etc.)'),
|
||||
summary: z.string().optional().describe('[log] One-line human-readable summary'),
|
||||
ref: z.string().optional().describe('[log] File path reference for large content'),
|
||||
data: z.record(z.string(), z.unknown()).optional().describe('[log] Optional structured data'),
|
||||
|
||||
// read params
|
||||
id: z.string().optional().describe('[read] Message ID to read (e.g. MSG-003)'),
|
||||
|
||||
// list params
|
||||
last: z.number().min(1).max(100).optional().describe('[list] Return last N messages (default: 20)'),
|
||||
});
|
||||
|
||||
type Params = z.infer<typeof ParamsSchema>;
|
||||
|
||||
// --- Tool Schema ---
|
||||
|
||||
export const schema: ToolSchema = {
|
||||
name: 'team_msg',
|
||||
description: `Team message bus - persistent JSONL log for Agent Team communication.
|
||||
|
||||
Operations:
|
||||
team_msg(operation="log", team="my-team", from="planner", to="coordinator", type="plan_ready", summary="Plan ready: 3 tasks", ref=".workflow/.team-plan/my-team/plan.json")
|
||||
team_msg(operation="read", team="my-team", id="MSG-003")
|
||||
team_msg(operation="list", team="my-team")
|
||||
team_msg(operation="list", team="my-team", from="tester", last=5)
|
||||
team_msg(operation="status", team="my-team")
|
||||
|
||||
Message types: plan_ready, plan_approved, plan_revision, task_unblocked, impl_complete, impl_progress, test_result, review_result, fix_required, error, shutdown`,
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
operation: {
|
||||
type: 'string',
|
||||
enum: ['log', 'read', 'list', 'status'],
|
||||
description: 'Operation: log | read | list | status',
|
||||
},
|
||||
team: {
|
||||
type: 'string',
|
||||
description: 'Team name',
|
||||
},
|
||||
from: { type: 'string', description: '[log/list] Sender role' },
|
||||
to: { type: 'string', description: '[log/list] Recipient role' },
|
||||
type: { type: 'string', description: '[log/list] Message type' },
|
||||
summary: { type: 'string', description: '[log] One-line summary' },
|
||||
ref: { type: 'string', description: '[log] File path for large content' },
|
||||
data: { type: 'object', description: '[log] Optional structured data' },
|
||||
id: { type: 'string', description: '[read] Message ID (e.g. MSG-003)' },
|
||||
last: { type: 'number', description: '[list] Last N messages (default 20)', minimum: 1, maximum: 100 },
|
||||
},
|
||||
required: ['operation', 'team'],
|
||||
},
|
||||
};
|
||||
|
||||
// --- Helpers ---
|
||||
|
||||
export function getLogDir(team: string): string {
|
||||
const root = getProjectRoot();
|
||||
return join(root, '.workflow', '.team-msg', team);
|
||||
}
|
||||
|
||||
function getLogPath(team: string): string {
|
||||
return join(getLogDir(team), 'messages.jsonl');
|
||||
}
|
||||
|
||||
function ensureLogFile(team: string): string {
|
||||
const logPath = getLogPath(team);
|
||||
const dir = dirname(logPath);
|
||||
if (!existsSync(dir)) {
|
||||
mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
if (!existsSync(logPath)) {
|
||||
appendFileSync(logPath, '', 'utf-8');
|
||||
}
|
||||
return logPath;
|
||||
}
|
||||
|
||||
export function readAllMessages(team: string): TeamMessage[] {
|
||||
const logPath = getLogPath(team);
|
||||
if (!existsSync(logPath)) return [];
|
||||
|
||||
const content = readFileSync(logPath, 'utf-8').trim();
|
||||
if (!content) return [];
|
||||
|
||||
return content.split('\n').map(line => {
|
||||
try {
|
||||
return JSON.parse(line) as TeamMessage;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}).filter((m): m is TeamMessage => m !== null);
|
||||
}
|
||||
|
||||
function getNextId(messages: TeamMessage[]): string {
|
||||
const maxNum = messages.reduce((max, m) => {
|
||||
const match = m.id.match(/^MSG-(\d+)$/);
|
||||
return match ? Math.max(max, parseInt(match[1], 10)) : max;
|
||||
}, 0);
|
||||
return `MSG-${String(maxNum + 1).padStart(3, '0')}`;
|
||||
}
|
||||
|
||||
function nowISO(): string {
|
||||
return new Date().toISOString().replace('Z', '+00:00');
|
||||
}
|
||||
|
||||
// --- Operations ---
|
||||
|
||||
function opLog(params: Params): ToolResult {
|
||||
if (!params.from) return { success: false, error: 'log requires "from"' };
|
||||
if (!params.to) return { success: false, error: 'log requires "to"' };
|
||||
if (!params.summary) return { success: false, error: 'log requires "summary"' };
|
||||
|
||||
const logPath = ensureLogFile(params.team);
|
||||
const messages = readAllMessages(params.team);
|
||||
const id = getNextId(messages);
|
||||
|
||||
const msg: TeamMessage = {
|
||||
id,
|
||||
ts: nowISO(),
|
||||
from: params.from,
|
||||
to: params.to,
|
||||
type: params.type || 'message',
|
||||
summary: params.summary,
|
||||
};
|
||||
if (params.ref) msg.ref = params.ref;
|
||||
if (params.data) msg.data = params.data;
|
||||
|
||||
appendFileSync(logPath, JSON.stringify(msg) + '\n', 'utf-8');
|
||||
|
||||
return { success: true, result: { id, message: `Logged ${id}: [${msg.from} → ${msg.to}] ${msg.summary}` } };
|
||||
}
|
||||
|
||||
function opRead(params: Params): ToolResult {
|
||||
if (!params.id) return { success: false, error: 'read requires "id"' };
|
||||
|
||||
const messages = readAllMessages(params.team);
|
||||
const msg = messages.find(m => m.id === params.id);
|
||||
|
||||
if (!msg) {
|
||||
return { success: false, error: `Message ${params.id} not found in team "${params.team}"` };
|
||||
}
|
||||
|
||||
return { success: true, result: msg };
|
||||
}
|
||||
|
||||
function opList(params: Params): ToolResult {
|
||||
let messages = readAllMessages(params.team);
|
||||
|
||||
// Apply filters
|
||||
if (params.from) messages = messages.filter(m => m.from === params.from);
|
||||
if (params.to) messages = messages.filter(m => m.to === params.to);
|
||||
if (params.type) messages = messages.filter(m => m.type === params.type);
|
||||
|
||||
// Take last N
|
||||
const last = params.last || 20;
|
||||
const sliced = messages.slice(-last);
|
||||
|
||||
const lines = sliced.map(m => `${m.id} [${m.ts.substring(11, 19)}] ${m.from} → ${m.to} (${m.type}) ${m.summary}`);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
result: {
|
||||
total: messages.length,
|
||||
showing: sliced.length,
|
||||
messages: sliced,
|
||||
formatted: lines.join('\n'),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function opStatus(params: Params): ToolResult {
|
||||
const messages = readAllMessages(params.team);
|
||||
|
||||
if (messages.length === 0) {
|
||||
return { success: true, result: { members: [], summary: 'No messages recorded yet.' } };
|
||||
}
|
||||
|
||||
// Aggregate per-member stats
|
||||
const memberMap = new Map<string, StatusEntry>();
|
||||
|
||||
for (const msg of messages) {
|
||||
for (const role of [msg.from, msg.to]) {
|
||||
if (!memberMap.has(role)) {
|
||||
memberMap.set(role, { member: role, lastSeen: msg.ts, lastAction: '', messageCount: 0 });
|
||||
}
|
||||
}
|
||||
const fromEntry = memberMap.get(msg.from)!;
|
||||
fromEntry.lastSeen = msg.ts;
|
||||
fromEntry.lastAction = `sent ${msg.type} → ${msg.to}`;
|
||||
fromEntry.messageCount++;
|
||||
}
|
||||
|
||||
const members = Array.from(memberMap.values()).sort((a, b) => b.lastSeen.localeCompare(a.lastSeen));
|
||||
|
||||
const formatted = members.map(m =>
|
||||
`${m.member.padEnd(12)} | last: ${m.lastSeen.substring(11, 19)} | msgs: ${m.messageCount} | ${m.lastAction}`
|
||||
).join('\n');
|
||||
|
||||
return {
|
||||
success: true,
|
||||
result: {
|
||||
members,
|
||||
total_messages: messages.length,
|
||||
formatted,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// --- Handler ---
|
||||
|
||||
export async function handler(params: Record<string, unknown>): Promise<ToolResult> {
|
||||
const parsed = ParamsSchema.safeParse(params);
|
||||
if (!parsed.success) {
|
||||
return { success: false, error: `Invalid params: ${parsed.error.message}` };
|
||||
}
|
||||
|
||||
const p = parsed.data;
|
||||
|
||||
switch (p.operation) {
|
||||
case 'log': return opLog(p);
|
||||
case 'read': return opRead(p);
|
||||
case 'list': return opList(p);
|
||||
case 'status': return opStatus(p);
|
||||
default:
|
||||
return { success: false, error: `Unknown operation: ${p.operation}` };
|
||||
}
|
||||
}
|
||||
260
ccw/src/utils/file-reader.ts
Normal file
260
ccw/src/utils/file-reader.ts
Normal file
@@ -0,0 +1,260 @@
|
||||
/**
|
||||
* Shared file reading utilities
|
||||
*
|
||||
* Extracted from read-file.ts for reuse across read_file and read_many_files tools.
|
||||
*/
|
||||
|
||||
import { readFileSync, readdirSync, statSync } from 'fs';
|
||||
import { join, extname } from 'path';
|
||||
|
||||
// Max content per file (truncate if larger)
|
||||
export const MAX_CONTENT_LENGTH = 5000;
|
||||
// Max files to return
|
||||
export const MAX_FILES = 50;
|
||||
// Max total content length
|
||||
export const MAX_TOTAL_CONTENT = 50000;
|
||||
|
||||
// Common binary extensions to skip
|
||||
export const BINARY_EXTENSIONS = new Set([
|
||||
'.png', '.jpg', '.jpeg', '.gif', '.bmp', '.ico', '.webp', '.svg',
|
||||
'.pdf', '.doc', '.docx', '.xls', '.xlsx', '.ppt', '.pptx',
|
||||
'.zip', '.tar', '.gz', '.rar', '.7z',
|
||||
'.exe', '.dll', '.so', '.dylib',
|
||||
'.mp3', '.mp4', '.wav', '.avi', '.mov',
|
||||
'.woff', '.woff2', '.ttf', '.eot', '.otf',
|
||||
'.pyc', '.class', '.o', '.obj',
|
||||
]);
|
||||
|
||||
export interface FileEntry {
|
||||
path: string;
|
||||
size: number;
|
||||
content?: string;
|
||||
truncated?: boolean;
|
||||
matches?: string[];
|
||||
totalLines?: number;
|
||||
lineRange?: { start: number; end: number };
|
||||
}
|
||||
|
||||
export interface ReadContentOptions {
|
||||
maxLength: number;
|
||||
offset?: number;
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
export interface ReadContentResult {
|
||||
content: string;
|
||||
truncated: boolean;
|
||||
totalLines?: number;
|
||||
lineRange?: { start: number; end: number };
|
||||
}
|
||||
|
||||
export interface ReadResult {
|
||||
files: FileEntry[];
|
||||
totalFiles: number;
|
||||
message: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if file is likely binary
|
||||
*/
|
||||
export function isBinaryFile(filePath: string): boolean {
|
||||
const ext = extname(filePath).toLowerCase();
|
||||
return BINARY_EXTENSIONS.has(ext);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert glob pattern to regex
|
||||
*/
|
||||
export function globToRegex(pattern: string): RegExp {
|
||||
const escaped = pattern
|
||||
.replace(/[.+^${}()|[\]\\]/g, '\\$&')
|
||||
.replace(/\*/g, '.*')
|
||||
.replace(/\?/g, '.');
|
||||
return new RegExp(`^${escaped}$`, 'i');
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if filename matches glob pattern
|
||||
*/
|
||||
export function matchesPattern(filename: string, pattern: string): boolean {
|
||||
const regex = globToRegex(pattern);
|
||||
return regex.test(filename);
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively collect files from directory
|
||||
*/
|
||||
export function collectFiles(
|
||||
dir: string,
|
||||
pattern: string | undefined,
|
||||
maxDepth: number,
|
||||
currentDepth: number = 0
|
||||
): string[] {
|
||||
if (currentDepth > maxDepth) return [];
|
||||
|
||||
const files: string[] = [];
|
||||
|
||||
try {
|
||||
const entries = readdirSync(dir, { withFileTypes: true });
|
||||
|
||||
for (const entry of entries) {
|
||||
// Skip hidden files/dirs and node_modules
|
||||
if (entry.name.startsWith('.') || entry.name === 'node_modules') continue;
|
||||
|
||||
const fullPath = join(dir, entry.name);
|
||||
|
||||
if (entry.isDirectory()) {
|
||||
files.push(...collectFiles(fullPath, pattern, maxDepth, currentDepth + 1));
|
||||
} else if (entry.isFile()) {
|
||||
if (!pattern || matchesPattern(entry.name, pattern)) {
|
||||
files.push(fullPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Skip directories we can't read
|
||||
}
|
||||
|
||||
return files;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read file content with truncation and optional line-based pagination
|
||||
*/
|
||||
export function readFileContent(filePath: string, options: ReadContentOptions): ReadContentResult {
|
||||
const { maxLength, offset, limit } = options;
|
||||
|
||||
if (isBinaryFile(filePath)) {
|
||||
return { content: '[Binary file]', truncated: false };
|
||||
}
|
||||
|
||||
try {
|
||||
const content = readFileSync(filePath, 'utf8');
|
||||
const lines = content.split('\n');
|
||||
const totalLines = lines.length;
|
||||
|
||||
// If offset/limit specified, use line-based pagination
|
||||
if (offset !== undefined || limit !== undefined) {
|
||||
const startLine = Math.min(offset ?? 0, totalLines);
|
||||
const endLine = limit !== undefined ? Math.min(startLine + limit, totalLines) : totalLines;
|
||||
const selectedLines = lines.slice(startLine, endLine);
|
||||
const selectedContent = selectedLines.join('\n');
|
||||
|
||||
const actualEnd = endLine;
|
||||
const hasMore = actualEnd < totalLines;
|
||||
|
||||
let finalContent = selectedContent;
|
||||
if (selectedContent.length > maxLength) {
|
||||
finalContent = selectedContent.substring(0, maxLength) + `\n... (+${selectedContent.length - maxLength} chars)`;
|
||||
}
|
||||
|
||||
// Calculate actual line range (handle empty selection)
|
||||
const actualLineEnd = selectedLines.length > 0 ? startLine + selectedLines.length - 1 : startLine;
|
||||
|
||||
return {
|
||||
content: finalContent,
|
||||
truncated: hasMore || selectedContent.length > maxLength,
|
||||
totalLines,
|
||||
lineRange: { start: startLine, end: actualLineEnd },
|
||||
};
|
||||
}
|
||||
|
||||
// Default behavior: truncate by character length
|
||||
if (content.length > maxLength) {
|
||||
return {
|
||||
content: content.substring(0, maxLength) + `\n... (+${content.length - maxLength} chars)`,
|
||||
truncated: true,
|
||||
totalLines,
|
||||
};
|
||||
}
|
||||
return { content, truncated: false, totalLines };
|
||||
} catch (error) {
|
||||
return { content: `[Error: ${(error as Error).message}]`, truncated: false };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Find regex matches in content with safety protections
|
||||
* - Empty string pattern = "match all" (no filtering) - returns null
|
||||
* - Dangerous patterns (zero-width matches) = "match all" for safety - returns null
|
||||
* - Validates pattern to prevent infinite loops
|
||||
* - Limits iterations to prevent ReDoS attacks
|
||||
* - Deduplicates results to prevent duplicates
|
||||
* - Reports errors instead of silent failure
|
||||
*
|
||||
* @returns Array of matching lines, null to match all content (empty string or dangerous pattern), empty array for no matches
|
||||
*/
|
||||
export function findMatches(content: string, pattern: string): string[] | null {
|
||||
// 1. Empty string pattern = "match all" (no filtering)
|
||||
if (!pattern || pattern.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 2. Pattern length limit
|
||||
if (pattern.length > 1000) {
|
||||
console.error('[read_file] contentPattern error: Pattern too long (max 1000 characters), returning all content');
|
||||
return null;
|
||||
}
|
||||
|
||||
// 3. Dangerous pattern detection
|
||||
try {
|
||||
const testRegex = new RegExp(pattern, 'gm');
|
||||
const emptyTest = testRegex.exec('');
|
||||
|
||||
if (emptyTest && emptyTest[0] === '' && emptyTest.index === 0) {
|
||||
const secondMatch = testRegex.exec('');
|
||||
if (secondMatch && secondMatch.index === 0) {
|
||||
console.warn(`[read_file] contentPattern: Dangerous pattern "${pattern.substring(0, 50)}" detected, returning all content`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(`[read_file] contentPattern: Invalid regex pattern: ${(e as Error).message}, returning all content`);
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const regex = new RegExp(pattern, 'gm');
|
||||
const matches: string[] = [];
|
||||
const seen = new Set<string>();
|
||||
let match;
|
||||
let iterations = 0;
|
||||
let lastIndex = -1;
|
||||
const MAX_ITERATIONS = 1000;
|
||||
const MAX_MATCHES = 50;
|
||||
|
||||
while ((match = regex.exec(content)) !== null && matches.length < MAX_MATCHES) {
|
||||
iterations++;
|
||||
|
||||
if (iterations > MAX_ITERATIONS) {
|
||||
console.error(`[read_file] contentPattern warning: Exceeded ${MAX_ITERATIONS} iterations for pattern "${pattern.substring(0, 50)}"`);
|
||||
break;
|
||||
}
|
||||
|
||||
if (match.index === lastIndex) {
|
||||
regex.lastIndex = match.index + 1;
|
||||
continue;
|
||||
}
|
||||
lastIndex = match.index;
|
||||
|
||||
const lineStart = content.lastIndexOf('\n', match.index) + 1;
|
||||
const lineEnd = content.indexOf('\n', match.index);
|
||||
const line = content.substring(lineStart, lineEnd === -1 ? undefined : lineEnd).trim();
|
||||
|
||||
if (!line) continue;
|
||||
|
||||
if (!seen.has(line)) {
|
||||
seen.add(line);
|
||||
matches.push(line.substring(0, 200));
|
||||
}
|
||||
|
||||
if (matches.length >= 10) break;
|
||||
}
|
||||
|
||||
return matches;
|
||||
} catch (error) {
|
||||
const errorMsg = (error as Error).message;
|
||||
console.error(`[read_file] contentPattern error: ${errorMsg}`);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user