mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 12:39:42 +02:00
chore: bump version to 0.1479
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,9 @@
|
||||
# PROTECTED SCREEN SESSIONS - DO NOT KILL
|
||||
# Captured at 2026-02-12 before agent-teams implementation
|
||||
# These are live Claudeman sessions running on port 3000
|
||||
685273.claudeman-a5929405
|
||||
684663.claudeman-07a604fb
|
||||
684529.claudeman-a55c7db2
|
||||
# User-specified protected sessions (NEVER kill these):
|
||||
# w1-claudeman, w2, w3-claudeman
|
||||
# NEVER run tests on port 3000 (live dev server)
|
||||
@@ -26,7 +26,7 @@ When user says "COM":
|
||||
1. Increment version in BOTH `package.json` AND `CLAUDE.md` (verify they match with `grep version package.json && grep Version CLAUDE.md`)
|
||||
2. Run: `git add -A && git commit -m "chore: bump version to X.XXXX" && git push && npm run build && systemctl --user restart claudeman-web`
|
||||
|
||||
**Version**: 0.1478 (must match `package.json` for npm publish)
|
||||
**Version**: 0.1479 (must match `package.json` for npm publish)
|
||||
|
||||
## Project Overview
|
||||
|
||||
@@ -185,7 +185,7 @@ UI defaults are set in `src/web/public/app.js` using `??` fallbacks. To change d
|
||||
|
||||
**E2E tests**: Use Playwright. Run `npx playwright install chromium` first. See `test/e2e/fixtures/` for helpers. E2E config (`test/e2e/e2e.config.ts`) provides ports (3183-3193), timeouts, and helpers.
|
||||
|
||||
**Test config**: Vitest runs with `globals: true` (no imports needed for `describe`/`it`/`expect`/`vi`) and `fileParallelism: false` (files run sequentially to respect screen limits). Unit test timeout is 30s, teardown timeout is 60s. E2E tests have longer timeouts defined in `test/e2e/e2e.config.ts` (90s test, 30s session creation). Mock helpers in `vitest.setup.ts` auto-run before all tests.
|
||||
**Test config**: Vitest runs with `globals: true` (no imports needed for `describe`/`it`/`expect`/`vi`) and `fileParallelism: false` (files run sequentially to respect screen limits). Unit test timeout is 30s, teardown timeout is 60s. E2E tests have longer timeouts defined in `test/e2e/e2e.config.ts` (90s test, 30s session creation). Mock helpers in `test/setup.ts` auto-run before all tests.
|
||||
|
||||
**Test safety**: `test/setup.ts` provides:
|
||||
- Screen concurrency limiter (max 10)
|
||||
|
||||
@@ -0,0 +1,244 @@
|
||||
# Claude Code Agent Teams — Reference
|
||||
|
||||
> Experimental feature (Feb 2026). Enable per-session via env var.
|
||||
> Updated with experiment findings from 2026-02-12.
|
||||
|
||||
## Enabling
|
||||
|
||||
```bash
|
||||
# Environment variable (set before starting Claude Code)
|
||||
CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=1
|
||||
|
||||
# In .claude/settings.local.json (case-scoped)
|
||||
{
|
||||
"env": {
|
||||
"CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS": "1"
|
||||
}
|
||||
}
|
||||
# Note: "teammateMode" is NOT a valid settings key (validation rejects it).
|
||||
# Display mode defaults to "in-process". For tmux, pass --teammate-mode flag via CLI.
|
||||
```
|
||||
|
||||
## Filesystem Paths (Verified)
|
||||
|
||||
| Resource | Path |
|
||||
|----------|------|
|
||||
| Team config | `~/.claude/teams/{team-name}/config.json` |
|
||||
| Teammate inboxes | `~/.claude/teams/{team-name}/inboxes/{name}.json` |
|
||||
| Shared tasks | `~/.claude/tasks/{team-name}/` |
|
||||
| Teammate transcripts | `~/.claude/projects/{hash}/{leadSessionId}/subagents/agent-{id}.jsonl` |
|
||||
|
||||
Note: Teammate transcripts appear in the **standard subagent directory** under the lead's session, NOT as separate top-level sessions.
|
||||
|
||||
### config.json format (verified)
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "research-watchers",
|
||||
"description": "Team description...",
|
||||
"createdAt": 1770875105373,
|
||||
"leadAgentId": "team-lead@research-watchers",
|
||||
"leadSessionId": "461daa80-94ec-4e5e-a1bb-0518f78311bc",
|
||||
"members": [
|
||||
{
|
||||
"agentId": "team-lead@research-watchers",
|
||||
"name": "team-lead",
|
||||
"agentType": "team-lead",
|
||||
"model": "claude-opus-4-6",
|
||||
"joinedAt": 1770875105373,
|
||||
"tmuxPaneId": "",
|
||||
"cwd": "/path/to/project",
|
||||
"subscriptions": []
|
||||
},
|
||||
{
|
||||
"agentId": "fs-researcher@research-watchers",
|
||||
"name": "fs-researcher",
|
||||
"agentType": "general-purpose",
|
||||
"model": "claude-opus-4-6",
|
||||
"prompt": "Full spawn prompt...",
|
||||
"color": "blue",
|
||||
"planModeRequired": false,
|
||||
"joinedAt": 1770875126680,
|
||||
"tmuxPaneId": "in-process",
|
||||
"cwd": "/path/to/project",
|
||||
"subscriptions": [],
|
||||
"backendType": "in-process"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Key fields: `agentId` format is `{name}@{teamName}`, `leadSessionId` links to Claudeman session, `backendType` indicates display mode, `color` for UI theming.
|
||||
|
||||
### Task file format (verified)
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "1",
|
||||
"subject": "Research Node.js fs.watch on Linux vs macOS",
|
||||
"description": "Full description...",
|
||||
"activeForm": "Researching Node.js fs.watch Linux vs macOS",
|
||||
"status": "in_progress",
|
||||
"blocks": [],
|
||||
"blockedBy": [],
|
||||
"owner": "fs-researcher"
|
||||
}
|
||||
```
|
||||
|
||||
Internal teammate tracking tasks have `"metadata": { "_internal": true }`.
|
||||
|
||||
Task states: `pending` → `in_progress` → `completed`. File locking via `.lock.lock` directory (mkdir-based atomic lock).
|
||||
|
||||
### Inbox message format (verified)
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"from": "team-lead",
|
||||
"text": "{\"type\":\"task_assignment\",\"taskId\":\"1\",\"subject\":\"...\",\"assignedBy\":\"team-lead\",\"timestamp\":\"...\"}",
|
||||
"timestamp": "2026-02-12T05:45:18.176Z",
|
||||
"read": false
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
`text` is double-encoded JSON. Message types: `task_assignment`, `shutdown_request`, `shutdown_response`. File locking via `.json.lock` directory.
|
||||
|
||||
## Communication Model (CORRECTED)
|
||||
|
||||
**Hybrid: tool + filesystem.** The `SendMessage` tool writes to filesystem inbox files at `~/.claude/teams/{name}/inboxes/{teammate}.json`.
|
||||
|
||||
Each teammate AND the lead has an inbox JSON file. Messages are JSON arrays with `from`, `text` (double-encoded JSON), `timestamp`, `read` fields.
|
||||
|
||||
Message types observed:
|
||||
- **task_assignment**: Lead assigns task to teammate
|
||||
- **shutdown_request**: Lead asks teammate to shut down
|
||||
- **shutdown_response**: Teammate confirms shutdown
|
||||
- (Also: `message`, `broadcast`, `plan_approval_response` per docs)
|
||||
|
||||
**Implication:** We can intercept messages by watching inbox files AND potentially inject messages by writing to them (respecting `.json.lock` directory locking).
|
||||
|
||||
## Process Model (CORRECTED)
|
||||
|
||||
**Teammates are IN-PROCESS THREADS, not separate OS processes.**
|
||||
|
||||
In `in-process` mode (the default), all teammates run as threads within the single `claude` process. Only 1 claude process exists per Claudeman session, regardless of team size.
|
||||
|
||||
This means:
|
||||
- No separate PIDs to track per teammate
|
||||
- All teammates share the lead's environment variables
|
||||
- Lower resource overhead than separate processes
|
||||
- Subagent transcript files still created (for progress tracking)
|
||||
|
||||
## Display Modes
|
||||
|
||||
| Mode | Trigger | UI | Requirement |
|
||||
|------|---------|-----|------------|
|
||||
| **in-process** (default) | Default | Shift+Up/Down to switch, Ctrl+T for tasks | Any terminal |
|
||||
| **tmux** | `--teammate-mode tmux` | Split panes | tmux installed |
|
||||
| **iTerm2** | Auto-detected | Native split panes | iTerm2 + `it2` CLI |
|
||||
|
||||
**For Claudeman: use `in-process` only.** Claudeman uses GNU Screen, not tmux.
|
||||
|
||||
**In-process UI elements:**
|
||||
- Status bar: `@main @teammate1 @teammate2 ...` with `shift+↑ to expand`
|
||||
- Task list: Checkboxes with assignments `(@teammate-name)`
|
||||
- Hint: `ctrl+t to show teammates`
|
||||
|
||||
## Hooks
|
||||
|
||||
Two new hook types for quality gates (verified in settings schema):
|
||||
|
||||
### TeammateIdle
|
||||
Fires when a teammate is about to go idle.
|
||||
- Exit code 0: Allow idle (normal)
|
||||
- Exit code 2: Send feedback back, keep teammate working
|
||||
|
||||
### TaskCompleted
|
||||
Fires when a task is being marked complete.
|
||||
- Exit code 0: Allow completion
|
||||
- Exit code 2: Prevent completion, send feedback
|
||||
|
||||
These are configured in `.claude/settings.local.json` alongside existing Claudeman hooks.
|
||||
|
||||
## Subagent-Watcher Compatibility (Verified)
|
||||
|
||||
**Teammates appear as standard subagents.** They create transcript files at:
|
||||
```
|
||||
~/.claude/projects/{hash}/{leadSessionId}/subagents/agent-{id}.jsonl
|
||||
```
|
||||
|
||||
Claudeman's existing `subagent-watcher.ts` discovers them automatically. They appear in `/api/subagents` with status "active".
|
||||
|
||||
**Distinguishing teammates from regular subagents:**
|
||||
- Description field starts with `<teammate-message teammate_id= team`
|
||||
- Cross-reference with `~/.claude/teams/{name}/config.json` members
|
||||
|
||||
**Sub-subagents:** Teammates can spawn their own Task tool subagents, creating a 3-level hierarchy.
|
||||
|
||||
## Cleanup Behavior (Verified)
|
||||
|
||||
When the lead runs cleanup:
|
||||
1. Shutdown requests sent to all teammate inboxes
|
||||
2. Teammates shut down gracefully
|
||||
3. ALL filesystem artifacts deleted:
|
||||
- Inbox files and directory
|
||||
- Config.json
|
||||
- Team directory
|
||||
- All task files
|
||||
- Task directory
|
||||
4. Cleanup is atomic — all files removed in the same second
|
||||
|
||||
## Comparison with Subagents (Task tool)
|
||||
|
||||
| Aspect | Subagents (Task tool) | Agent Teams |
|
||||
|--------|----------------------|-------------|
|
||||
| Spawn method | Claude's built-in Task tool | Explicit team creation |
|
||||
| Process model | In-process threads | In-process threads (same!) |
|
||||
| Discovery | `subagents/agent-{id}.jsonl` only | BOTH subagent dir + `~/.claude/teams/` |
|
||||
| Communication | None (fire-and-forget) | Filesystem inboxes + SendMessage tool |
|
||||
| Shared state | None | Shared task list + inboxes |
|
||||
| Task tracking | Per-agent, no coordination | Shared with dependencies & ownership |
|
||||
| Lifecycle | Auto-cleanup on completion | Lead cleanup (deletes all artifacts) |
|
||||
| Sub-nesting | Can spawn sub-subagents | Teammates can spawn subagents too |
|
||||
| Cost | Lower (single context) | Higher (N context windows) |
|
||||
| Duration | Short-lived (seconds-minutes) | Longer-lived (minutes-hours) |
|
||||
|
||||
## Limitations
|
||||
|
||||
- No session resumption with in-process teammates (`/resume` doesn't restore them)
|
||||
- One team per session, no nested teams
|
||||
- Lead is fixed (cannot promote teammate)
|
||||
- Permissions set at spawn (change individually after)
|
||||
- Split panes require tmux or iTerm2 (not Screen)
|
||||
- Task status can lag (teammates may fail to mark complete)
|
||||
- Shutdown can be slow (waits for current tool call)
|
||||
|
||||
## Useful Commands
|
||||
|
||||
```bash
|
||||
# Check if teams exist
|
||||
ls ~/.claude/teams/
|
||||
|
||||
# Check team config
|
||||
cat ~/.claude/teams/{name}/config.json | jq .
|
||||
|
||||
# Check teammate inboxes
|
||||
cat ~/.claude/teams/{name}/inboxes/{teammate}.json | jq .
|
||||
|
||||
# Check team tasks
|
||||
ls ~/.claude/tasks/{name}/
|
||||
for f in ~/.claude/tasks/{name}/*.json; do cat "$f" | jq .; done
|
||||
|
||||
# Count Claude processes (teammates are threads, not processes)
|
||||
ps aux | grep '[c]laude' | grep -v grep
|
||||
|
||||
# Check subagent detection of teammates
|
||||
curl -s http://localhost:3000/api/subagents | jq '.data[] | select(.description | startswith("<teammate"))'
|
||||
|
||||
# Team interaction (in-process mode)
|
||||
# Shift+Up/Down: Switch between teammates
|
||||
# Enter: View teammate session
|
||||
# Escape: Interrupt teammate's turn
|
||||
# Ctrl+T: Toggle task list
|
||||
```
|
||||
@@ -0,0 +1,171 @@
|
||||
# Claudeman Agent Teams Integration — Design (Approach C: Hybrid)
|
||||
|
||||
> Updated 2026-02-12 with experiment findings. See `experiment-log.md` for raw data.
|
||||
|
||||
## Overview
|
||||
|
||||
Approach C combines filesystem monitoring (for team/task discovery and inbox watching) with the existing subagent-watcher (for live transcript tailing) and adjusted idle detection (to account for active teammates). The key finding from our experiment is that **teammates already appear as standard subagents**, so most infrastructure exists — we mainly need team awareness and idle detection fixes.
|
||||
|
||||
## Components
|
||||
|
||||
### 1. TeamWatcher (`src/team-watcher.ts`)
|
||||
|
||||
Monitors `~/.claude/teams/` for team creation/removal and tracks active teams.
|
||||
|
||||
**Discovery mechanism:**
|
||||
- Poll `~/.claude/teams/` for directories (team names) every 3-5 seconds
|
||||
- When found: parse `config.json` to get:
|
||||
- `leadSessionId` → map to Claudeman session
|
||||
- `members` array → teammate names, agentIds, colors, models
|
||||
- Watch for directory deletion (cleanup signal)
|
||||
|
||||
**CORRECTED from pre-experiment design:**
|
||||
- ~~Each teammate has a separate Claude Code process~~ → Teammates are **in-process threads**, not separate processes
|
||||
- ~~Find via `ps aux` + `/proc` PID matching~~ → Not needed, no separate PIDs
|
||||
- Teammate transcripts are at `subagents/agent-{id}.jsonl` (standard subagent path), NOT separate session transcripts
|
||||
|
||||
**Association:**
|
||||
- `config.json.leadSessionId` → Claudeman session ID (direct match!)
|
||||
- Each member's `agentId` (e.g., `fs-researcher@research-watchers`) → links to subagent files
|
||||
- `agentType: "team-lead"` vs `"general-purpose"` distinguishes lead from teammates
|
||||
|
||||
**Inbox monitoring:**
|
||||
- Watch `~/.claude/teams/{name}/inboxes/` for new messages
|
||||
- Each teammate has a JSON file with message array
|
||||
- Messages are double-encoded JSON with `from`, `text`, `timestamp`, `read` fields
|
||||
- Message types: `task_assignment`, `shutdown_request`, `shutdown_response`
|
||||
|
||||
### 2. Team-Aware Idle Detection (HIGHEST PRIORITY)
|
||||
|
||||
**Problem (confirmed by experiment):** Lead session shows status "idle" in Claudeman while teammates are actively working. Token count continues climbing but Claudeman thinks the session is inactive.
|
||||
|
||||
**Solution:**
|
||||
- Before declaring a session idle, check if it's a team lead
|
||||
- If team lead: check `~/.claude/teams/*/config.json` for this session's `leadSessionId`
|
||||
- If active team exists: check task files in `~/.claude/tasks/{team-name}/`
|
||||
- Any task with `status: "in_progress"` → suppress idle detection
|
||||
- All tasks `completed` AND no non-`_internal` tasks pending → allow idle
|
||||
- Fallback: check subagent-watcher for active subagents on this session
|
||||
|
||||
**Integration points:**
|
||||
- `src/ai-idle-checker.ts` — add team-awareness check before AI idle analysis
|
||||
- `src/respawn-controller.ts` — consult TeamWatcher before transitioning to idle states
|
||||
- `src/session.ts` — expose `hasActiveTeam()` method
|
||||
|
||||
**Liveness check (simplified from pre-experiment):**
|
||||
- ~~Check `/proc/{pid}` existence~~ → Not needed (no separate processes)
|
||||
- Check task file status instead (filesystem-based)
|
||||
- Check subagent-watcher for active subagents under this session
|
||||
|
||||
### 3. Shared Task List UI
|
||||
|
||||
**Display:** New panel in web UI showing the team's shared task list.
|
||||
|
||||
**Data source:** Poll `~/.claude/tasks/{team-name}/` for task JSON files.
|
||||
|
||||
**Task file structure (verified):**
|
||||
```json
|
||||
{
|
||||
"id": "1",
|
||||
"subject": "Research Node.js fs.watch",
|
||||
"description": "Full description...",
|
||||
"activeForm": "Researching Node.js fs.watch",
|
||||
"status": "in_progress", // pending | in_progress | completed
|
||||
"blocks": [],
|
||||
"blockedBy": [],
|
||||
"owner": "fs-researcher" // Empty string = unassigned
|
||||
}
|
||||
```
|
||||
|
||||
Internal tracking tasks: `{ "metadata": { "_internal": true } }` — filter these from display.
|
||||
|
||||
**UI elements:**
|
||||
- Task subject, status badge (color-coded), owner (teammate name with color)
|
||||
- Dependency visualization (blockedBy indicators)
|
||||
- Progress bar (completed / total non-internal tasks)
|
||||
- Real-time updates via SSE
|
||||
|
||||
**API endpoint:** `GET /api/sessions/:id/team-tasks` → returns parsed task files
|
||||
|
||||
**Locking:** Respect `.lock.lock` directory lock when reading (skip if locked, retry next poll).
|
||||
|
||||
### 4. Teammate Display
|
||||
|
||||
**Decision: Option A — Enhanced subagent floating windows.**
|
||||
|
||||
Since teammates already appear as subagents in the existing infrastructure, we enhance rather than replace:
|
||||
|
||||
- **Badge:** Add "Teammate" badge to subagent windows for agents matching team config
|
||||
- **Color:** Use teammate's `color` field from config.json (blue, green, yellow)
|
||||
- **Name:** Show teammate name instead of agent ID
|
||||
- **Persistence:** Teammate windows should stay open longer (they're longer-lived than regular subagents)
|
||||
- **Status:** Show task assignment and progress from task files
|
||||
|
||||
**Detection logic:**
|
||||
```
|
||||
For each subagent detected by subagent-watcher:
|
||||
1. Check if description starts with "<teammate-message"
|
||||
2. OR cross-reference agentId with active team config members
|
||||
3. If match → apply teammate badge, color, name
|
||||
```
|
||||
|
||||
### 5. Inbox/Message Display
|
||||
|
||||
**CORRECTED: Inboxes ARE filesystem-based.**
|
||||
|
||||
Communication uses filesystem inbox files at `~/.claude/teams/{name}/inboxes/{teammate}.json`. We can:
|
||||
|
||||
1. **Watch inbox files** for real-time message monitoring
|
||||
2. **Parse message types** for display:
|
||||
- `task_assignment` → "Lead assigned Task #1 to fs-researcher"
|
||||
- `shutdown_request` → "Lead requested shutdown"
|
||||
- `shutdown_response` → "Teammate confirmed shutdown"
|
||||
3. **Display as timeline** in team panel
|
||||
|
||||
**Potential for interaction (not tested, future work):**
|
||||
- Write to teammate inbox files to inject messages
|
||||
- Must respect `.json.lock` directory locking protocol
|
||||
- Could enable "nudge" or "redirect" functionality from Claudeman UI
|
||||
|
||||
## Answered Questions (from experiment)
|
||||
|
||||
| # | Question | Answer |
|
||||
|---|----------|--------|
|
||||
| 1 | Teammates in subagents dir? | **YES** — standard `subagents/agent-{id}.jsonl` path |
|
||||
| 2 | subagent-watcher detects them? | **YES** — automatically, no changes needed |
|
||||
| 3 | Task file structure? | Numbered JSON files with subject, status, owner, dependencies |
|
||||
| 4 | Env var inheritance? | **YES** — in-process threads share parent's env |
|
||||
| 5 | Processes per teammate? | **ZERO** — threads, not processes |
|
||||
| 6 | config.json format? | Rich: name, agentId, agentType, model, prompt, color, backendType |
|
||||
| 7 | Interact via stdin? | N/A (threads) — can interact via inbox files instead |
|
||||
| 8 | In-process under Screen? | Works fine — single claude process, threads handle teammates |
|
||||
| 9 | Hook events from teammates? | TeammateIdle + TaskCompleted hooks available in settings schema |
|
||||
| 10 | Process tree? | Single process with threads — no child processes |
|
||||
|
||||
## Existing Infrastructure to Leverage
|
||||
|
||||
| Component | Reuse for | Status |
|
||||
|-----------|-----------|--------|
|
||||
| `subagent-watcher.ts` | Teammate transcript tailing | **Already works** |
|
||||
| Subagent floating windows (`app.js`) | Teammate activity display | **Already works** (needs badges) |
|
||||
| `task-tracker.ts` | Background task tracking patterns | Reuse patterns |
|
||||
| LRUMap, StaleExpirationMap | Bounded caches for team state | Available |
|
||||
| SSE broadcast | Real-time UI updates | Available |
|
||||
| ~~`/proc` PID checking~~ | ~~Teammate liveness~~ | **Not needed** (threads) |
|
||||
| `file-stream-manager.ts` | Watch inbox/task files | Available |
|
||||
|
||||
## Implementation Order (Revised)
|
||||
|
||||
1. **Team-aware idle detection** — prevent premature respawn/auto-compact (CRITICAL)
|
||||
2. **TeamWatcher** — poll `~/.claude/teams/`, parse config.json, track active teams
|
||||
3. **Teammate badge in subagent windows** — mark teammate subagents with name/color
|
||||
4. **Team tasks API + UI** — `GET /api/sessions/:id/team-tasks` + task list panel
|
||||
5. **Inbox monitoring** — watch inbox files, display message timeline
|
||||
6. **TeammateIdle/TaskCompleted hooks** — add to Claudeman's hooks config generator
|
||||
|
||||
## What We DON'T Need to Build
|
||||
|
||||
- ~~Process discovery for teammates~~ (they're threads)
|
||||
- ~~Custom transcript tailing~~ (subagent-watcher handles it)
|
||||
- ~~Separate teammate window infrastructure~~ (subagent windows work)
|
||||
- ~~Message interception via transcript parsing~~ (inbox files are simpler)
|
||||
@@ -0,0 +1,468 @@
|
||||
# Agent Teams Experiment Log
|
||||
|
||||
> Experiment date: 2026-02-12
|
||||
> Test case: `~/claudeman-cases/agent-teams-test/`
|
||||
> Team name: `research-watchers`
|
||||
> Teammates: 3 (fs-researcher, perf-researcher, api-researcher)
|
||||
> Lead session: `461daa80-94ec-4e5e-a1bb-0518f78311bc`
|
||||
> Duration: ~3 minutes (06:45:01 → 06:48:07)
|
||||
|
||||
## Pre-Experiment State
|
||||
|
||||
```
|
||||
~/.claude/teams/ — did NOT exist
|
||||
~/.claude/tasks/ — 75 UUID-named directories (from regular Task tool subagents)
|
||||
Claude processes — 7 (including watchers)
|
||||
settings.local.json — edited to add CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=1
|
||||
```
|
||||
|
||||
## Experiment Prompt
|
||||
|
||||
```
|
||||
Create an agent team with 3 teammates to research the following topics in parallel:
|
||||
Teammate 1 fs-researcher researches how Node.js fs.watch works on Linux vs macOS.
|
||||
Teammate 2 perf-researcher researches inotify performance limits and alternatives.
|
||||
Teammate 3 api-researcher researches the inotifywait command-line API.
|
||||
Have each teammate write a brief summary of their findings in a separate file.
|
||||
Name the team research-watchers.
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Question 1: What exact filesystem artifacts do agent teams create?
|
||||
|
||||
**Expected:** `~/.claude/teams/research-watchers/config.json` and `~/.claude/tasks/research-watchers/`
|
||||
|
||||
**Actual: CONFIRMED + SURPRISE inboxes/ directory**
|
||||
|
||||
```
|
||||
~/.claude/teams/research-watchers/
|
||||
├── config.json # Team config (members, lead, metadata)
|
||||
└── inboxes/ # Filesystem-based messaging!
|
||||
├── api-researcher.json # Per-teammate inbox
|
||||
├── fs-researcher.json
|
||||
├── perf-researcher.json
|
||||
└── team-lead.json # Lead also has an inbox
|
||||
|
||||
~/.claude/tasks/research-watchers/
|
||||
├── .lock # Empty file (presence = lock indicator?)
|
||||
├── 1.json # Task: Research Node.js fs.watch
|
||||
├── 2.json # Task: Research inotify performance
|
||||
├── 3.json # Task: Research inotifywait CLI
|
||||
├── 4.json # Internal: fs-researcher spawn tracking
|
||||
├── 5.json # Internal: perf-researcher spawn tracking
|
||||
└── 6.json # Internal: api-researcher spawn tracking
|
||||
```
|
||||
|
||||
Subagent transcripts also appear in the standard subagent directory:
|
||||
```
|
||||
~/.claude/projects/-home-arkon-claudeman-cases-agent-teams-test/
|
||||
└── 461daa80.../
|
||||
├── 461daa80...jsonl # Lead session transcript
|
||||
└── subagents/
|
||||
├── agent-ae50544.jsonl # Teammate: fs-researcher
|
||||
├── agent-aa20c65.jsonl # Teammate: perf-researcher
|
||||
├── agent-a29de32.jsonl # Teammate: api-researcher
|
||||
├── agent-a04968e.jsonl # Sub-subagent (teammate's Task tool)
|
||||
├── agent-a0d372e.jsonl # Sub-subagent
|
||||
├── agent-a2ff939.jsonl # Sub-subagent
|
||||
├── agent-a89ad82.jsonl # Sub-subagent
|
||||
├── agent-aa1efc7.jsonl # Sub-subagent
|
||||
└── agent-ab0ef07.jsonl # Sub-subagent
|
||||
```
|
||||
|
||||
**Cleanup:** At 06:48:02, the lead deleted ALL artifacts — inboxes, config, tasks, the team directory itself. Clean removal.
|
||||
|
||||
---
|
||||
|
||||
## Question 2: Is the mailbox/communication filesystem-based or tool-based?
|
||||
|
||||
**Expected:** Tool-based (SendMessage tool), NOT filesystem
|
||||
|
||||
**Actual: BOTH! Hybrid — tool triggers filesystem writes.**
|
||||
|
||||
Communication uses the `SendMessage` tool internally, but the actual message delivery is via **filesystem inbox files**. Each teammate has `~/.claude/teams/{name}/inboxes/{teammate}.json` containing a JSON array of messages.
|
||||
|
||||
**Inbox message format:**
|
||||
```json
|
||||
[
|
||||
{
|
||||
"from": "team-lead",
|
||||
"text": "{\"type\":\"task_assignment\",\"taskId\":\"1\",\"subject\":\"Research Node.js fs.watch...\",\"assignedBy\":\"team-lead\",\"timestamp\":\"...\"}",
|
||||
"timestamp": "2026-02-12T05:45:18.176Z",
|
||||
"read": false
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
Key observations:
|
||||
- `text` field is a **JSON string** (double-encoded) containing a typed message object
|
||||
- Message types observed: `task_assignment`, `shutdown_request`, `shutdown_response`
|
||||
- `read` field tracks whether teammate has processed the message (false → true)
|
||||
- **File locking** via `.json.lock` directories (mkdir-based atomic lock, created then deleted)
|
||||
- Lead also has an inbox (`team-lead.json`) for receiving messages FROM teammates
|
||||
|
||||
**Implication for Claudeman:** We CAN intercept messages by watching inbox JSON files! We can also potentially inject messages by writing to inbox files.
|
||||
|
||||
---
|
||||
|
||||
## Question 3: Do teammates appear in the subagents directory?
|
||||
|
||||
**Expected:** Unclear
|
||||
|
||||
**Actual: YES! Teammates appear as standard subagents.**
|
||||
|
||||
Teammates create transcript files at:
|
||||
```
|
||||
~/.claude/projects/{hash}/{leadSessionId}/subagents/agent-{agentId}.jsonl
|
||||
```
|
||||
|
||||
This is the **exact same path pattern** that regular Task tool subagents use. The existing `subagent-watcher.ts` successfully discovers them.
|
||||
|
||||
Claudeman's `/api/subagents` endpoint returned them with status "active":
|
||||
```
|
||||
Agent: ae50544 Status: active Tools: 8 Model: claude-opus-4-6
|
||||
Desc: <teammate-message teammate_id= team
|
||||
Agent: aa20c65 Status: active Tools: 9 Model: claude-opus-4-6
|
||||
Desc: <teammate-message teammate_id= team
|
||||
Agent: a29de32 Status: active Tools: 7 Model: claude-opus-4-6
|
||||
Desc: <teammate-message teammate_id= team
|
||||
```
|
||||
|
||||
**Distinguishing teammates from regular subagents:**
|
||||
- Description starts with `<teammate-message teammate_id= team` (a unique marker)
|
||||
- We can also cross-reference with `~/.claude/teams/{name}/config.json` members list
|
||||
|
||||
**Sub-subagents:** Teammates can spawn their own Task tool subagents. 3 teammates spawned 6 additional subagent files (9 total in the subagents directory).
|
||||
|
||||
---
|
||||
|
||||
## Question 4: What does config.json actually look like?
|
||||
|
||||
**Actual config.json (with all 3 teammates):**
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "research-watchers",
|
||||
"description": "Research team investigating file watching mechanisms...",
|
||||
"createdAt": 1770875105373,
|
||||
"leadAgentId": "team-lead@research-watchers",
|
||||
"leadSessionId": "461daa80-94ec-4e5e-a1bb-0518f78311bc",
|
||||
"members": [
|
||||
{
|
||||
"agentId": "team-lead@research-watchers",
|
||||
"name": "team-lead",
|
||||
"agentType": "team-lead",
|
||||
"model": "claude-opus-4-6",
|
||||
"joinedAt": 1770875105373,
|
||||
"tmuxPaneId": "",
|
||||
"cwd": "/home/arkon/claudeman-cases/agent-teams-test",
|
||||
"subscriptions": []
|
||||
},
|
||||
{
|
||||
"agentId": "fs-researcher@research-watchers",
|
||||
"name": "fs-researcher",
|
||||
"agentType": "general-purpose",
|
||||
"model": "claude-opus-4-6",
|
||||
"prompt": "You are \"fs-researcher\" on the \"research-watchers\" team...",
|
||||
"color": "blue",
|
||||
"planModeRequired": false,
|
||||
"joinedAt": 1770875126680,
|
||||
"tmuxPaneId": "in-process",
|
||||
"cwd": "/home/arkon/claudeman-cases/agent-teams-test",
|
||||
"subscriptions": [],
|
||||
"backendType": "in-process"
|
||||
},
|
||||
{
|
||||
"agentId": "perf-researcher@research-watchers",
|
||||
"name": "perf-researcher",
|
||||
"agentType": "general-purpose",
|
||||
"model": "claude-opus-4-6",
|
||||
"prompt": "...",
|
||||
"color": "green",
|
||||
"planModeRequired": false,
|
||||
"joinedAt": 1770875130344,
|
||||
"tmuxPaneId": "in-process",
|
||||
"cwd": "/home/arkon/claudeman-cases/agent-teams-test",
|
||||
"subscriptions": [],
|
||||
"backendType": "in-process"
|
||||
},
|
||||
{
|
||||
"agentId": "api-researcher@research-watchers",
|
||||
"name": "api-researcher",
|
||||
"agentType": "general-purpose",
|
||||
"model": "claude-opus-4-6",
|
||||
"prompt": "...",
|
||||
"color": "yellow",
|
||||
"planModeRequired": false,
|
||||
"joinedAt": 1770875134997,
|
||||
"tmuxPaneId": "in-process",
|
||||
"cwd": "/home/arkon/claudeman-cases/agent-teams-test",
|
||||
"subscriptions": [],
|
||||
"backendType": "in-process"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**Key fields per member:**
|
||||
- `agentId`: `{name}@{teamName}` format
|
||||
- `agentType`: `"team-lead"` for lead, `"general-purpose"` for teammates
|
||||
- `model`: Model used (inherits from lead)
|
||||
- `prompt`: Full spawn prompt (only for teammates)
|
||||
- `color`: UI color assignment (blue, green, yellow)
|
||||
- `backendType`: `"in-process"` for in-process mode
|
||||
- `tmuxPaneId`: `"in-process"` or actual pane ID for tmux mode
|
||||
- `subscriptions`: Empty array (possibly for message routing)
|
||||
|
||||
**Config grows incrementally** — starts with just lead member (620 bytes), grows as teammates are added (→ 1886 → 3188 → 4551 bytes).
|
||||
|
||||
---
|
||||
|
||||
## Question 5: How do shared tasks differ from regular tasks?
|
||||
|
||||
**Expected:** Team name directory vs UUID, richer task format
|
||||
|
||||
**Actual: CONFIRMED**
|
||||
|
||||
**Team tasks (`~/.claude/tasks/research-watchers/`):**
|
||||
```json
|
||||
{
|
||||
"id": "1",
|
||||
"subject": "Research Node.js fs.watch on Linux vs macOS",
|
||||
"description": "Research how Node.js fs.watch works differently...",
|
||||
"activeForm": "Researching Node.js fs.watch Linux vs macOS",
|
||||
"status": "in_progress",
|
||||
"blocks": [],
|
||||
"blockedBy": [],
|
||||
"owner": "fs-researcher"
|
||||
}
|
||||
```
|
||||
|
||||
**Internal teammate tracking tasks (4.json, 5.json, 6.json):**
|
||||
```json
|
||||
{
|
||||
"id": "4",
|
||||
"subject": "fs-researcher",
|
||||
"description": "You are \"fs-researcher\" on the \"research-watchers\" team...",
|
||||
"status": "in_progress",
|
||||
"blocks": [],
|
||||
"blockedBy": [],
|
||||
"metadata": { "_internal": true }
|
||||
}
|
||||
```
|
||||
|
||||
**Key differences from regular subagent tasks (`~/.claude/tasks/{UUID}/`):**
|
||||
| Feature | Regular tasks | Team tasks |
|
||||
|---------|--------------|------------|
|
||||
| Directory name | UUID | Human-readable team name |
|
||||
| File names | `.lock`, `.highwatermark` only | Numbered JSON files (1.json, 2.json...) |
|
||||
| Content | Lock files only (no task JSON) | Full task JSON with metadata |
|
||||
| Owner field | N/A | Teammate name |
|
||||
| Locking | `.lock` file | `.lock.lock` directory (mkdir atomic) |
|
||||
| Internal tasks | None | `_internal: true` for teammate spawn tracking |
|
||||
|
||||
---
|
||||
|
||||
## Question 6: Can we write to task/mailbox files to interact with teammates?
|
||||
|
||||
**Expected:** Possibly for tasks, no for messages
|
||||
|
||||
**Actual: LIKELY YES for both**
|
||||
|
||||
Evidence supporting external writes:
|
||||
1. **Inbox files** are plain JSON arrays — we could append messages
|
||||
2. **Task files** are plain JSON — we could modify status, add new tasks
|
||||
3. **File locking** uses `.json.lock` directories — we'd need to respect the locking protocol
|
||||
4. **Lock protocol**: Create directory `{file}.lock` → write → delete directory. Simple mkdir-based atomic lock.
|
||||
|
||||
**Not tested in this experiment** — would need a follow-up test to verify teammates actually pick up externally-added messages/tasks. But the format is clear and the locking is simple.
|
||||
|
||||
---
|
||||
|
||||
## Question 7: What happens to Claudeman's idle detection with active teammates?
|
||||
|
||||
**Expected:** Lead may appear idle while teammates work
|
||||
|
||||
**Actual: Lead stays "idle" in Claudeman's view, but terminal shows active status**
|
||||
|
||||
Observations:
|
||||
- Claudeman session status showed `"idle"` throughout the experiment
|
||||
- The terminal output continued updating (task list checkboxes, teammate progress messages)
|
||||
- Lead displayed "Befuddling..." spinner while waiting for teammates
|
||||
- Token count climbed from 27k → 33k during the experiment
|
||||
- The `stop` hook DID fire at the end when the team was cleaned up
|
||||
|
||||
**Implication:** Current idle detection may trigger prematurely if:
|
||||
- It only checks Claudeman's session status (which stays "idle")
|
||||
- It doesn't account for active teammates
|
||||
|
||||
**What we need:** Check `~/.claude/teams/*/config.json` for active members before declaring idle.
|
||||
|
||||
---
|
||||
|
||||
## Question 8: How many Claude processes spawn per teammate?
|
||||
|
||||
**Expected:** 1 claude process per teammate
|
||||
|
||||
**Actual: ZERO separate processes! Teammates are in-process threads.**
|
||||
|
||||
```
|
||||
# Only 2 claude processes (both Claudeman sessions, none for teammates):
|
||||
25405 claude --dangerously-skip-permissions --session-id 236f004f... (our main session)
|
||||
383633 claude --dangerously-skip-permissions --session-id 461daa80... (test session + 3 teammates)
|
||||
|
||||
# Process tree for test session:
|
||||
claude(383633)─┬─{claude}(383635)
|
||||
├─{claude}(383636)
|
||||
├─... (22 threads total)
|
||||
└─{claude}(399252)
|
||||
```
|
||||
|
||||
**In-process mode = threads, not processes.** All 3 teammates run as threads within the single `claude` process (PID 383633). This explains:
|
||||
- No separate PIDs to track
|
||||
- No `/proc/{pid}/environ` for individual teammates
|
||||
- Lower resource overhead
|
||||
- Shared env vars automatically
|
||||
|
||||
---
|
||||
|
||||
## Question 9: Do teammates inherit Claudeman env vars (hook events)?
|
||||
|
||||
**Expected:** Yes, if child processes
|
||||
|
||||
**Actual: YES, trivially — they're in-process threads**
|
||||
|
||||
Since teammates are threads in the lead's process (PID 383633), they share the exact same environment:
|
||||
```
|
||||
CLAUDEMAN_SCREEN=1
|
||||
CLAUDEMAN_SESSION_ID=461daa80-94ec-4e5e-a1bb-0518f78311bc
|
||||
CLAUDEMAN_SCREEN_NAME=claudeman-461daa80
|
||||
CLAUDEMAN_API_URL=http://localhost:3000
|
||||
```
|
||||
|
||||
The `CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=1` env var was set via `settings.local.json`'s `env` key, which Claude Code reads at startup and sets on its process.
|
||||
|
||||
**Hook events:** The lead session's hooks (Notification, Stop) apply to the whole process. Teammate-specific hooks (`TeammateIdle`, `TaskCompleted`) are defined in the same `settings.local.json` and would fire for the lead's session.
|
||||
|
||||
---
|
||||
|
||||
## Question 10: Does subagent-watcher pick up teammates automatically?
|
||||
|
||||
**Expected:** Probably not
|
||||
|
||||
**Actual: YES! subagent-watcher detects teammates automatically.**
|
||||
|
||||
Teammates create transcript files in the standard subagent path:
|
||||
```
|
||||
~/.claude/projects/{hash}/{leadSessionId}/subagents/agent-{id}.jsonl
|
||||
```
|
||||
|
||||
Claudeman's `/api/subagents` endpoint returned all 3 teammates as active subagents. They're indistinguishable from regular Task tool subagents except:
|
||||
1. Their `description` field starts with `<teammate-message teammate_id= team`
|
||||
2. They can be cross-referenced with `~/.claude/teams/{name}/config.json`
|
||||
3. They tend to be longer-lived than regular subagents
|
||||
|
||||
**Sub-subagents:** Teammates also spawn their own Task tool subagents (6 additional agents detected), creating a 3-level hierarchy: Lead → Teammates → Sub-subagents.
|
||||
|
||||
---
|
||||
|
||||
## Filesystem Event Timeline
|
||||
|
||||
```
|
||||
06:45:01 Session transcript created
|
||||
06:45:05 ~/.claude/teams/ created
|
||||
06:45:05 ~/.claude/teams/research-watchers/ created
|
||||
06:45:05 config.json created (lead member only, 620 bytes)
|
||||
06:45:05 ~/.claude/tasks/research-watchers/ created with .lock
|
||||
06:45:11 Task 1.json created (via .lock.lock directory lock)
|
||||
06:45:13 Task 2.json created
|
||||
06:45:15 Task 3.json created
|
||||
06:45:18 inboxes/ directory created
|
||||
06:45:18 fs-researcher.json inbox created (task_assignment message)
|
||||
06:45:18 perf-researcher.json inbox created
|
||||
06:45:19 api-researcher.json inbox created
|
||||
06:45:26 config.json updated (fs-researcher added, 1886 bytes)
|
||||
06:45:26 Subagent agent-ae50544.jsonl created (fs-researcher)
|
||||
06:45:26 Task 4.json created (internal: fs-researcher tracking)
|
||||
06:45:30 config.json updated (perf-researcher added, 3188 bytes)
|
||||
06:45:30 Subagent agent-aa20c65.jsonl created (perf-researcher)
|
||||
06:45:30 Task 5.json created (internal: perf-researcher tracking)
|
||||
06:45:34 config.json updated (api-researcher added, 4551 bytes)
|
||||
06:45:34 Task 6.json created (internal: api-researcher tracking)
|
||||
06:45:35 Subagent agent-a29de32.jsonl created (api-researcher)
|
||||
06:45:35+ Teammates working, additional subagent transcripts appearing
|
||||
06:47:xx Tasks completed, shutdown_requests sent to teammate inboxes
|
||||
06:47:56 team-lead.json inbox created (teammates reporting back)
|
||||
06:47:57 config.json updated multiple times (member removal?)
|
||||
06:48:02 CLEANUP: all inbox files deleted
|
||||
06:48:02 CLEANUP: inboxes/ directory deleted
|
||||
06:48:02 CLEANUP: config.json deleted
|
||||
06:48:02 CLEANUP: research-watchers team directory deleted
|
||||
06:48:02 CLEANUP: all task files deleted (1-6.json + .lock)
|
||||
06:48:02 CLEANUP: research-watchers task directory deleted
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Web UI Observations
|
||||
|
||||
**Terminal output:**
|
||||
- Task list appears with checkboxes: `☐ Research Node.js fs.watch on Linux vs macOS`
|
||||
- Checkboxes fill in as tasks complete: `☑ Research Node.js fs.watch...`
|
||||
- Each task shows assigned teammate: `(@fs-researcher)`
|
||||
- Spinner shows active teammate with progress
|
||||
|
||||
**Status bar:**
|
||||
- Shows team member selector: `@main @api-researcher @fs-researcher @perf-researcher`
|
||||
- Hint: `shift+↑ to expand` and `ctrl+t to show teammates`
|
||||
- Standard bypass permissions and token count still visible
|
||||
|
||||
**Subagent floating windows:**
|
||||
- Teammates DID appear as subagent floating windows in Claudeman's web UI
|
||||
- They show the standard subagent info (model, tool calls, description)
|
||||
- Sub-subagents (teammates' own Task tool usage) also appear
|
||||
|
||||
**In-process mode specifics:**
|
||||
- No new terminal windows or panes
|
||||
- Everything renders in the single terminal session
|
||||
- Shift+Up/Down would switch between teammate views (not tested interactively)
|
||||
|
||||
---
|
||||
|
||||
## Conclusions & Key Surprises
|
||||
|
||||
### Surprises vs expectations
|
||||
|
||||
1. **Inboxes ARE filesystem-based** — contrary to docs saying "SendMessage tool". It's a hybrid: the tool writes to filesystem inboxes.
|
||||
2. **Teammates are threads, not processes** — no new OS processes, just threads within the lead's claude process.
|
||||
3. **Teammates appear as standard subagents** — existing subagent-watcher infrastructure works out of the box!
|
||||
4. **Config grows incrementally** — members are added one-by-one, not all at once.
|
||||
5. **Internal tracking tasks** — tasks 4-6 with `_internal: true` track teammate spawn state.
|
||||
6. **Auto-cleanup** — lead automatically cleaned up ALL artifacts after shutdown.
|
||||
7. **Sub-subagents** — teammates can spawn their own Task tool subagents (3-level hierarchy).
|
||||
8. **`teammateMode` is NOT a valid settings key** — display mode defaults to `in-process`.
|
||||
|
||||
### Design implications for Claudeman
|
||||
|
||||
1. **TeamWatcher can be simple** — just poll `~/.claude/teams/` for directories + parse config.json
|
||||
2. **Subagent-watcher already works** — no new infrastructure needed for teammate transcript tailing
|
||||
3. **Idle detection needs team awareness** — check config.json members before declaring idle
|
||||
4. **Message interception is possible** — watch inbox JSON files for real-time message tracking
|
||||
5. **Task visualization is straightforward** — parse numbered JSON files in task directory
|
||||
6. **No process tracking needed** — teammates are threads, not separate processes
|
||||
7. **Distinguish teammates from subagents** — use description prefix `<teammate-message` or cross-reference config.json
|
||||
|
||||
### What to build first
|
||||
|
||||
1. **Team-aware idle detection** — highest priority, prevents premature respawn
|
||||
2. **TeamWatcher** — poll `~/.claude/teams/` for team creation/removal
|
||||
3. **Team tasks API** — parse task JSON files for UI display
|
||||
4. **Teammate badge in subagent windows** — mark teammate subagents differently from regular ones
|
||||
5. **Message timeline** — parse inbox files for inter-teammate communication display
|
||||
|
||||
### What we DON'T need to build
|
||||
|
||||
- Process discovery for teammates (they're threads)
|
||||
- Custom transcript tailing (subagent-watcher handles it)
|
||||
- Separate teammate window infrastructure (subagent windows work)
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "claudeman",
|
||||
"version": "0.1478",
|
||||
"version": "0.1479",
|
||||
"description": "The missing control plane for Claude Code - run 20 autonomous agents with real-time monitoring and session persistence",
|
||||
"type": "module",
|
||||
"main": "dist/index.js",
|
||||
|
||||
+14
-1
@@ -149,12 +149,25 @@ export class AiIdleChecker extends AiCheckerBase<
|
||||
protected readonly logPrefix = '[AiIdleChecker]';
|
||||
protected readonly checkDescription = 'AI idle check';
|
||||
|
||||
/** Number of active teammates (injected by respawn controller) */
|
||||
private teammateCount: number = 0;
|
||||
|
||||
constructor(sessionId: string, config: Partial<AiIdleCheckConfig> = {}) {
|
||||
super(sessionId, DEFAULT_AI_CHECK_CONFIG, config);
|
||||
}
|
||||
|
||||
/** Set active teammate count for prompt context injection */
|
||||
setTeammateCount(count: number): void {
|
||||
this.teammateCount = count;
|
||||
}
|
||||
|
||||
protected buildPrompt(terminalBuffer: string): string {
|
||||
return AI_CHECK_PROMPT.replace('{TERMINAL_BUFFER}', terminalBuffer);
|
||||
let prompt = AI_CHECK_PROMPT.replace('{TERMINAL_BUFFER}', terminalBuffer);
|
||||
if (this.teammateCount > 0) {
|
||||
const teamContext = `\n\n## Agent Teams Context\n${this.teammateCount} agent teammate(s) are currently working on tasks for this session.\nIf teammates are active, answer WORKING — the lead is waiting for their results.\n`;
|
||||
prompt = prompt.replace('## Terminal Output to Analyze', teamContext + '## Terminal Output to Analyze');
|
||||
}
|
||||
return prompt;
|
||||
}
|
||||
|
||||
protected parseVerdict(output: string): { verdict: AiCheckVerdict; reasoning: string } | null {
|
||||
|
||||
@@ -52,10 +52,54 @@ export function generateHooksConfig(): { hooks: Record<string, unknown[]> } {
|
||||
hooks: [{ type: 'command', command: curlCmd('stop'), timeout: 10000 }],
|
||||
},
|
||||
],
|
||||
TeammateIdle: [
|
||||
{
|
||||
hooks: [{ type: 'command', command: curlCmd('teammate_idle'), timeout: 10000 }],
|
||||
},
|
||||
],
|
||||
TaskCompleted: [
|
||||
{
|
||||
hooks: [{ type: 'command', command: curlCmd('task_completed'), timeout: 10000 }],
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates env vars in .claude/settings.local.json for the given case path.
|
||||
* Merges with existing env field; removes vars set to empty string.
|
||||
*/
|
||||
export function updateCaseEnvVars(casePath: string, envVars: Record<string, string>): void {
|
||||
const claudeDir = join(casePath, '.claude');
|
||||
if (!existsSync(claudeDir)) {
|
||||
mkdirSync(claudeDir, { recursive: true });
|
||||
}
|
||||
|
||||
const settingsPath = join(claudeDir, 'settings.local.json');
|
||||
let existing: Record<string, unknown> = {};
|
||||
|
||||
if (existsSync(settingsPath)) {
|
||||
try {
|
||||
existing = JSON.parse(readFileSync(settingsPath, 'utf-8'));
|
||||
} catch {
|
||||
existing = {};
|
||||
}
|
||||
}
|
||||
|
||||
const currentEnv = (existing.env as Record<string, string>) || {};
|
||||
for (const [key, value] of Object.entries(envVars)) {
|
||||
if (value) {
|
||||
currentEnv[key] = value;
|
||||
} else {
|
||||
delete currentEnv[key];
|
||||
}
|
||||
}
|
||||
existing.env = currentEnv;
|
||||
|
||||
writeFileSync(settingsPath, JSON.stringify(existing, null, 2) + '\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes hooks config to .claude/settings.local.json in the given case path.
|
||||
* Merges with existing file content, only touching the `hooks` key.
|
||||
|
||||
@@ -43,6 +43,12 @@ const POLL_INTERVAL_MS = 100;
|
||||
/** Debounce delay for rapid image creation (ms) */
|
||||
const DEBOUNCE_DELAY_MS = 200;
|
||||
|
||||
/** Max images emitted per session within the burst window before throttling */
|
||||
const BURST_LIMIT = 20;
|
||||
|
||||
/** Time window for burst detection (ms) — resets after this period of quiet */
|
||||
const BURST_WINDOW_MS = 10_000;
|
||||
|
||||
// ========== ImageWatcher Class ==========
|
||||
|
||||
/**
|
||||
@@ -73,6 +79,9 @@ export class ImageWatcher extends EventEmitter {
|
||||
/** Track which session owns each debounce timer (for cleanup) */
|
||||
private timerToSession = new Map<string, string>();
|
||||
|
||||
/** Per-session burst tracking: sessionId -> { count, windowStart } */
|
||||
private burstTrackers = new Map<string, { count: number; windowStart: number }>();
|
||||
|
||||
/** Whether the watcher is currently running */
|
||||
private _isRunning = false;
|
||||
|
||||
@@ -121,6 +130,7 @@ export class ImageWatcher extends EventEmitter {
|
||||
}
|
||||
this.debounceTimers.clear();
|
||||
this.timerToSession.clear();
|
||||
this.burstTrackers.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -219,6 +229,7 @@ export class ImageWatcher extends EventEmitter {
|
||||
}
|
||||
this.timerToSession.delete(filePath);
|
||||
}
|
||||
this.burstTrackers.delete(sessionId);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -242,6 +253,23 @@ export class ImageWatcher extends EventEmitter {
|
||||
return;
|
||||
}
|
||||
|
||||
// Burst limit: skip if too many images detected for this session in a short window
|
||||
const now = Date.now();
|
||||
let burst = this.burstTrackers.get(sessionId);
|
||||
if (burst) {
|
||||
if (now - burst.windowStart > BURST_WINDOW_MS) {
|
||||
// Window expired, reset
|
||||
burst = { count: 0, windowStart: now };
|
||||
this.burstTrackers.set(sessionId, burst);
|
||||
}
|
||||
if (burst.count >= BURST_LIMIT) {
|
||||
return; // Throttled — too many images in this window
|
||||
}
|
||||
} else {
|
||||
burst = { count: 0, windowStart: now };
|
||||
this.burstTrackers.set(sessionId, burst);
|
||||
}
|
||||
|
||||
// Debounce rapid file creation (e.g., multiple screenshots quickly)
|
||||
const existingTimer = this.debounceTimers.get(filePath);
|
||||
if (existingTimer) {
|
||||
@@ -252,6 +280,9 @@ export class ImageWatcher extends EventEmitter {
|
||||
this.debounceTimers.delete(filePath);
|
||||
this.timerToSession.delete(filePath);
|
||||
this.emitImageDetected(sessionId, filePath);
|
||||
// Increment burst count on actual emission (not on detection)
|
||||
const b = this.burstTrackers.get(sessionId);
|
||||
if (b) b.count++;
|
||||
}, DEBOUNCE_DELAY_MS);
|
||||
|
||||
this.debounceTimers.set(filePath, timer);
|
||||
|
||||
@@ -57,6 +57,7 @@ import type {
|
||||
CycleOutcome,
|
||||
HealthStatus,
|
||||
} from './types.js';
|
||||
import type { TeamWatcher } from './team-watcher.js';
|
||||
|
||||
// ========== Constants ==========
|
||||
|
||||
@@ -638,6 +639,9 @@ export class RespawnController extends EventEmitter {
|
||||
/** The session being controlled */
|
||||
private session: Session;
|
||||
|
||||
/** Optional team watcher for team-aware idle detection */
|
||||
private teamWatcher: TeamWatcher | null = null;
|
||||
|
||||
/** Current configuration */
|
||||
private config: RespawnConfig;
|
||||
|
||||
@@ -1231,6 +1235,10 @@ export class RespawnController extends EventEmitter {
|
||||
*
|
||||
* @fires stateChanged - Transitions to 'watching'
|
||||
*/
|
||||
setTeamWatcher(watcher: TeamWatcher | null): void {
|
||||
this.teamWatcher = watcher;
|
||||
}
|
||||
|
||||
start(): void {
|
||||
if (!this.config.enabled) {
|
||||
this.log('Respawn is disabled');
|
||||
@@ -2183,6 +2191,12 @@ export class RespawnController extends EventEmitter {
|
||||
// Get the terminal buffer for analysis
|
||||
const buffer = this.terminalBuffer.value;
|
||||
|
||||
// Inject teammate context for team-aware AI check
|
||||
if (this.teamWatcher) {
|
||||
const count = this.teamWatcher.getActiveTeammateCount(this.session.id);
|
||||
this.aiChecker.setTeammateCount(count);
|
||||
}
|
||||
|
||||
this.aiChecker.check(buffer).then((result) => {
|
||||
// If state changed while checking (e.g., cancelled), ignore result
|
||||
if (this._state !== 'ai_checking') {
|
||||
@@ -2736,6 +2750,17 @@ export class RespawnController extends EventEmitter {
|
||||
return;
|
||||
}
|
||||
|
||||
// Team-awareness: don't respawn if teammates are active
|
||||
if (this.teamWatcher?.hasActiveTeammates(this.session.id)) {
|
||||
this.log(`Idle confirmation rejected - active teammates working (reason was: ${reason})`);
|
||||
this.logAction('detection', 'Rejected: Active teammates');
|
||||
this.emit('respawnBlocked', { reason: 'active_teammates', details: 'Team has active teammates' });
|
||||
this.setState('watching');
|
||||
this.startNoOutputTimer();
|
||||
this.startPreFilterTimer();
|
||||
return;
|
||||
}
|
||||
|
||||
this.log(`Idle confirmed via: ${reason}`);
|
||||
const status = this.getDetectionStatus();
|
||||
this.log(`Detection status: confidence=${status.confidenceLevel}%, ` +
|
||||
|
||||
@@ -0,0 +1,313 @@
|
||||
/**
|
||||
* @fileoverview Agent Teams Watcher
|
||||
*
|
||||
* Polls ~/.claude/teams/ and ~/.claude/tasks/ for agent team activity.
|
||||
* Matches teams to Claudeman sessions via leadSessionId and emits
|
||||
* events for UI updates and team-aware idle detection.
|
||||
*
|
||||
* @module team-watcher
|
||||
*/
|
||||
|
||||
import { EventEmitter } from 'node:events';
|
||||
import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
|
||||
import { homedir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
|
||||
import type { TeamConfig, TeamTask, InboxMessage } from './types.js';
|
||||
import { LRUMap } from './utils/lru-map.js';
|
||||
|
||||
// ========== Constants ==========
|
||||
|
||||
const POLL_INTERVAL_MS = 3000;
|
||||
const MAX_CACHED_TEAMS = 50;
|
||||
const MAX_CACHED_TASKS = 200;
|
||||
|
||||
// ========== TeamWatcher Class ==========
|
||||
|
||||
export class TeamWatcher extends EventEmitter {
|
||||
private teamsDir: string;
|
||||
private tasksDir: string;
|
||||
private pollTimer: NodeJS.Timeout | null = null;
|
||||
private teams: LRUMap<string, TeamConfig> = new LRUMap({ maxSize: MAX_CACHED_TEAMS });
|
||||
private teamTasks: LRUMap<string, TeamTask[]> = new LRUMap({ maxSize: MAX_CACHED_TASKS });
|
||||
private inboxCache: LRUMap<string, InboxMessage[]> = new LRUMap({ maxSize: MAX_CACHED_TASKS });
|
||||
// Track config mtimes to avoid re-reading unchanged files
|
||||
private configMtimes: Map<string, number> = new Map();
|
||||
private taskMtimes: Map<string, number> = new Map();
|
||||
private inboxMtimes: Map<string, number> = new Map();
|
||||
|
||||
constructor(teamsDir?: string, tasksDir?: string) {
|
||||
super();
|
||||
const claudeHome = join(homedir(), '.claude');
|
||||
this.teamsDir = teamsDir || join(claudeHome, 'teams');
|
||||
this.tasksDir = tasksDir || join(claudeHome, 'tasks');
|
||||
}
|
||||
|
||||
start(): void {
|
||||
if (this.pollTimer) return;
|
||||
this.poll();
|
||||
this.pollTimer = setInterval(() => this.poll(), POLL_INTERVAL_MS);
|
||||
}
|
||||
|
||||
stop(): void {
|
||||
if (this.pollTimer) {
|
||||
clearInterval(this.pollTimer);
|
||||
this.pollTimer = null;
|
||||
}
|
||||
this.teams.clear();
|
||||
this.teamTasks.clear();
|
||||
this.inboxCache.clear();
|
||||
this.configMtimes.clear();
|
||||
this.taskMtimes.clear();
|
||||
this.inboxMtimes.clear();
|
||||
}
|
||||
|
||||
/** Get all discovered teams */
|
||||
getTeams(): TeamConfig[] {
|
||||
return Array.from(this.teams.values());
|
||||
}
|
||||
|
||||
/** Get team associated with a Claudeman session (matched by leadSessionId) */
|
||||
getTeamForSession(sessionId: string): TeamConfig | undefined {
|
||||
for (const team of this.teams.values()) {
|
||||
if (team.leadSessionId === sessionId) {
|
||||
return team;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** Get tasks for a team (excluding internal tasks) */
|
||||
getTeamTasks(teamName: string): TeamTask[] {
|
||||
const tasks = this.teamTasks.get(teamName);
|
||||
if (!tasks) return [];
|
||||
return tasks.filter(t => !t.metadata?._internal);
|
||||
}
|
||||
|
||||
/** Count active (non-completed) tasks for a team */
|
||||
getActiveTaskCount(teamName: string): number {
|
||||
const tasks = this.getTeamTasks(teamName);
|
||||
return tasks.filter(t => t.status !== 'completed').length;
|
||||
}
|
||||
|
||||
/** Get inbox messages for a team member (or all members) */
|
||||
getInboxMessages(teamName: string, member?: string): InboxMessage[] {
|
||||
if (member) {
|
||||
const key = `${teamName}/${member}`;
|
||||
return this.inboxCache.get(key) || [];
|
||||
}
|
||||
// Return all messages for team
|
||||
const messages: InboxMessage[] = [];
|
||||
for (const [key, msgs] of this.inboxCache.entries()) {
|
||||
if (key.startsWith(`${teamName}/`)) {
|
||||
messages.push(...msgs);
|
||||
}
|
||||
}
|
||||
return messages.sort((a, b) =>
|
||||
new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime()
|
||||
);
|
||||
}
|
||||
|
||||
/** Check if a session has active teammates (for idle detection) */
|
||||
hasActiveTeammates(sessionId: string): boolean {
|
||||
const team = this.getTeamForSession(sessionId);
|
||||
if (!team) return false;
|
||||
|
||||
// Check if any non-lead members exist (they are active by definition while present)
|
||||
const teammates = team.members.filter(m => m.agentType !== 'team-lead');
|
||||
if (teammates.length === 0) return false;
|
||||
|
||||
// Check if team has active (non-completed) tasks
|
||||
const activeTasks = this.getActiveTaskCount(team.name);
|
||||
return activeTasks > 0;
|
||||
}
|
||||
|
||||
/** Get count of active teammates for a session */
|
||||
getActiveTeammateCount(sessionId: string): number {
|
||||
const team = this.getTeamForSession(sessionId);
|
||||
if (!team) return 0;
|
||||
return team.members.filter(m => m.agentType !== 'team-lead').length;
|
||||
}
|
||||
|
||||
// ========== Private Methods ==========
|
||||
|
||||
private poll(): void {
|
||||
try {
|
||||
this.pollTeams();
|
||||
this.pollTasks();
|
||||
this.pollInboxes();
|
||||
} catch (err) {
|
||||
// Don't crash on polling errors — filesystem may be temporarily unavailable
|
||||
}
|
||||
}
|
||||
|
||||
private pollTeams(): void {
|
||||
if (!existsSync(this.teamsDir)) return;
|
||||
|
||||
let entries: string[];
|
||||
try {
|
||||
entries = readdirSync(this.teamsDir);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
const currentTeamNames = new Set<string>();
|
||||
|
||||
for (const entry of entries) {
|
||||
const configPath = join(this.teamsDir, entry, 'config.json');
|
||||
if (!existsSync(configPath)) continue;
|
||||
|
||||
currentTeamNames.add(entry);
|
||||
|
||||
// Check mtime to skip unchanged configs
|
||||
try {
|
||||
const mtime = statSync(configPath).mtimeMs;
|
||||
if (this.configMtimes.get(entry) === mtime) continue;
|
||||
this.configMtimes.set(entry, mtime);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip if locked
|
||||
if (this.isLocked(join(this.teamsDir, entry, 'config.json'))) continue;
|
||||
|
||||
const config = this.readJson<TeamConfig>(configPath);
|
||||
if (!config || !config.name) continue;
|
||||
|
||||
const existing = this.teams.get(entry);
|
||||
this.teams.set(entry, config);
|
||||
|
||||
if (existing) {
|
||||
this.emit('teamUpdated', config);
|
||||
} else {
|
||||
this.emit('teamCreated', config);
|
||||
}
|
||||
}
|
||||
|
||||
// Detect removed teams
|
||||
for (const name of Array.from(this.teams.keys())) {
|
||||
if (!currentTeamNames.has(name)) {
|
||||
const removed = this.teams.get(name);
|
||||
this.teams.delete(name);
|
||||
this.configMtimes.delete(name);
|
||||
if (removed) {
|
||||
this.emit('teamRemoved', removed);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private pollTasks(): void {
|
||||
if (!existsSync(this.tasksDir)) return;
|
||||
|
||||
let teamDirs: string[];
|
||||
try {
|
||||
teamDirs = readdirSync(this.tasksDir);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const teamName of teamDirs) {
|
||||
const teamTaskDir = join(this.tasksDir, teamName);
|
||||
let taskFiles: string[];
|
||||
try {
|
||||
taskFiles = readdirSync(teamTaskDir).filter(f => f.endsWith('.json') && f !== '.lock');
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check combined mtime for all task files
|
||||
const mtimeKey = teamName;
|
||||
let combinedMtime = 0;
|
||||
for (const f of taskFiles) {
|
||||
try {
|
||||
combinedMtime += statSync(join(teamTaskDir, f)).mtimeMs;
|
||||
} catch {
|
||||
// File may have been deleted between readdir and stat
|
||||
}
|
||||
}
|
||||
if (this.taskMtimes.get(mtimeKey) === combinedMtime) continue;
|
||||
this.taskMtimes.set(mtimeKey, combinedMtime);
|
||||
|
||||
// Skip if locked
|
||||
if (this.isLocked(join(teamTaskDir, '.lock'))) continue;
|
||||
|
||||
const tasks: TeamTask[] = [];
|
||||
for (const f of taskFiles) {
|
||||
const task = this.readJson<TeamTask>(join(teamTaskDir, f));
|
||||
if (task && task.id) {
|
||||
tasks.push(task);
|
||||
}
|
||||
}
|
||||
|
||||
this.teamTasks.set(teamName, tasks);
|
||||
this.emit('taskUpdated', { teamName, tasks });
|
||||
}
|
||||
}
|
||||
|
||||
private pollInboxes(): void {
|
||||
// Inbox files live under ~/.claude/teams/{name}/inboxes/
|
||||
for (const [teamName] of this.teams.entries()) {
|
||||
const inboxDir = join(this.teamsDir, teamName, 'inboxes');
|
||||
if (!existsSync(inboxDir)) continue;
|
||||
|
||||
let inboxFiles: string[];
|
||||
try {
|
||||
inboxFiles = readdirSync(inboxDir).filter(f => f.endsWith('.json'));
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const f of inboxFiles) {
|
||||
const filePath = join(inboxDir, f);
|
||||
const memberName = f.replace('.json', '');
|
||||
const cacheKey = `${teamName}/${memberName}`;
|
||||
|
||||
// Check mtime
|
||||
try {
|
||||
const mtime = statSync(filePath).mtimeMs;
|
||||
if (this.inboxMtimes.get(cacheKey) === mtime) continue;
|
||||
this.inboxMtimes.set(cacheKey, mtime);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip if locked
|
||||
if (this.isLocked(filePath)) continue;
|
||||
|
||||
const messages = this.readJson<InboxMessage[]>(filePath);
|
||||
if (!Array.isArray(messages)) continue;
|
||||
|
||||
const previous = this.inboxCache.get(cacheKey);
|
||||
this.inboxCache.set(cacheKey, messages);
|
||||
|
||||
// Emit new messages (ones not in previous cache)
|
||||
const prevCount = previous?.length || 0;
|
||||
if (messages.length > prevCount) {
|
||||
for (let i = prevCount; i < messages.length; i++) {
|
||||
this.emit('inboxMessage', { teamName, member: memberName, message: messages[i] });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Check for directory-based lock (mkdir atomic locking) */
|
||||
private isLocked(path: string): boolean {
|
||||
const lockDir = `${path}.lock`;
|
||||
try {
|
||||
return existsSync(lockDir) && statSync(lockDir).isDirectory();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private readJson<T>(filePath: string): T | null {
|
||||
try {
|
||||
const content = readFileSync(filePath, 'utf-8');
|
||||
return JSON.parse(content) as T;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
+67
-1
@@ -746,7 +746,7 @@ export interface QuickRunRequest {
|
||||
/**
|
||||
* Hook event types triggered by Claude Code's hooks system
|
||||
*/
|
||||
export type HookEventType = 'idle_prompt' | 'permission_prompt' | 'elicitation_dialog' | 'stop';
|
||||
export type HookEventType = 'idle_prompt' | 'permission_prompt' | 'elicitation_dialog' | 'stop' | 'teammate_idle' | 'task_completed';
|
||||
|
||||
/**
|
||||
* Request body for the hook-event API endpoint
|
||||
@@ -1514,6 +1514,72 @@ export interface ImageDetectedEvent {
|
||||
size: number;
|
||||
}
|
||||
|
||||
// ========== Agent Teams Types ==========
|
||||
|
||||
/**
|
||||
* Team configuration matching ~/.claude/teams/{name}/config.json
|
||||
*/
|
||||
export interface TeamConfig {
|
||||
name: string;
|
||||
description?: string;
|
||||
createdAt: number;
|
||||
leadAgentId: string;
|
||||
leadSessionId: string; // Maps to Claudeman session ID
|
||||
members: TeamMember[];
|
||||
}
|
||||
|
||||
/**
|
||||
* A member of an agent team (lead or teammate)
|
||||
*/
|
||||
export interface TeamMember {
|
||||
agentId: string; // Format: {name}@{teamName}
|
||||
name: string;
|
||||
agentType: 'team-lead' | 'general-purpose';
|
||||
model?: string;
|
||||
prompt?: string; // Only present for teammates
|
||||
color?: string; // blue, green, yellow
|
||||
backendType?: string; // "in-process"
|
||||
joinedAt: number;
|
||||
tmuxPaneId?: string;
|
||||
cwd?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Team task matching ~/.claude/tasks/{team}/{N}.json
|
||||
*/
|
||||
export interface TeamTask {
|
||||
id: string;
|
||||
subject: string;
|
||||
description: string;
|
||||
activeForm?: string;
|
||||
status: 'pending' | 'in_progress' | 'completed';
|
||||
owner?: string;
|
||||
blocks: string[];
|
||||
blockedBy: string[];
|
||||
metadata?: { _internal?: boolean };
|
||||
}
|
||||
|
||||
/**
|
||||
* Inbox message from ~/.claude/teams/{name}/inboxes/{member}.json
|
||||
*/
|
||||
export interface InboxMessage {
|
||||
from: string;
|
||||
text: string; // Double-encoded JSON string
|
||||
timestamp: string;
|
||||
read: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parsed content from an inbox message's text field
|
||||
*/
|
||||
export interface InboxMessageContent {
|
||||
type: 'task_assignment' | 'shutdown_request' | 'shutdown_response' | 'message' | 'broadcast';
|
||||
taskId?: string;
|
||||
subject?: string;
|
||||
assignedBy?: string;
|
||||
timestamp?: string;
|
||||
}
|
||||
|
||||
// ========== Plan Orchestrator Re-exports ==========
|
||||
|
||||
export type { PlanItem } from './plan-orchestrator.js';
|
||||
|
||||
+374
-15
@@ -1090,6 +1090,11 @@ class ClaudemanApp {
|
||||
// This is the SINGLE SOURCE OF TRUTH for which tab an agent window connects to.
|
||||
// Once set, never recalculated. Persisted to localStorage and server.
|
||||
this.subagentParentMap = new Map();
|
||||
|
||||
// Agent Teams tracking
|
||||
this.teams = new Map(); // Map<teamName, TeamConfig>
|
||||
this.teamTasks = new Map(); // Map<teamName, TeamTask[]>
|
||||
this.teammateMap = new Map(); // Map<agentId-prefix, {name, color, teamName}> for quick lookup
|
||||
this.ralphStatePanelCollapsed = true; // Default to collapsed
|
||||
this.ralphClosedSessions = new Set(); // Sessions where user explicitly closed Ralph panel
|
||||
|
||||
@@ -1153,6 +1158,11 @@ class ClaudemanApp {
|
||||
this.maxReconnectAttempts = 10;
|
||||
this.isOnline = navigator.onLine;
|
||||
|
||||
// Offline input queue
|
||||
this._inputQueue = new Map(); // Map<sessionId, string>
|
||||
this._inputQueueMaxBytes = 64 * 1024; // 64KB cap per session
|
||||
this._connectionStatus = 'connected';
|
||||
|
||||
// Accessibility: Focus trap for modals
|
||||
this.activeFocusTrap = null;
|
||||
|
||||
@@ -1456,18 +1466,34 @@ class ClaudemanApp {
|
||||
this._inputFlushTimeout = null;
|
||||
this._inputFlushDelay = 16; // Flush at 60fps max
|
||||
|
||||
const flushInput = () => {
|
||||
const flushInput = async () => {
|
||||
if (this._pendingInput && this.activeSessionId) {
|
||||
const input = this._pendingInput;
|
||||
const sessionId = this.activeSessionId;
|
||||
this._pendingInput = '';
|
||||
fetch(`/api/sessions/${sessionId}/input`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ input })
|
||||
});
|
||||
// Clear pending hooks when user sends input (they've addressed the prompt)
|
||||
this.clearPendingHooks(sessionId);
|
||||
|
||||
// Queue immediately if offline
|
||||
if (!this.isOnline || this._connectionStatus === 'disconnected') {
|
||||
this._enqueueInput(sessionId, input);
|
||||
this._inputFlushTimeout = null;
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const resp = await fetch(`/api/sessions/${sessionId}/input`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ input })
|
||||
});
|
||||
if (!resp.ok) {
|
||||
this._enqueueInput(sessionId, input);
|
||||
} else {
|
||||
// Clear pending hooks when user sends input (they've addressed the prompt)
|
||||
this.clearPendingHooks(sessionId);
|
||||
}
|
||||
} catch {
|
||||
this._enqueueInput(sessionId, input);
|
||||
}
|
||||
}
|
||||
this._inputFlushTimeout = null;
|
||||
};
|
||||
@@ -2010,6 +2036,7 @@ class ClaudemanApp {
|
||||
this.saveSessionOrder();
|
||||
}
|
||||
this.terminalBuffers.delete(data.id);
|
||||
this._inputQueue.delete(data.id); // Clean up queued offline input for this session
|
||||
this.ralphStates.delete(data.id); // Clean up ralph state for this session
|
||||
this.ralphClosedSessions.delete(data.id); // Clean up closed tracking for this session
|
||||
this.projectInsights.delete(data.id); // Clean up project insights for this session
|
||||
@@ -2778,6 +2805,44 @@ class ClaudemanApp {
|
||||
}, 5 * 60 * 1000); // 5 minutes
|
||||
});
|
||||
|
||||
// ========== Agent Teams Events ==========
|
||||
|
||||
addListener('team:created', (e) => {
|
||||
const team = JSON.parse(e.data);
|
||||
this.teams.set(team.name, team);
|
||||
this.rebuildTeammateMap();
|
||||
this.renderTeamTasksPanel();
|
||||
this.updateSubagentWindows();
|
||||
});
|
||||
|
||||
addListener('team:updated', (e) => {
|
||||
const team = JSON.parse(e.data);
|
||||
this.teams.set(team.name, team);
|
||||
this.rebuildTeammateMap();
|
||||
this.renderTeamTasksPanel();
|
||||
this.updateSubagentWindows();
|
||||
});
|
||||
|
||||
addListener('team:removed', (e) => {
|
||||
const team = JSON.parse(e.data);
|
||||
this.teams.delete(team.name);
|
||||
this.teamTasks.delete(team.name);
|
||||
this.rebuildTeammateMap();
|
||||
this.renderTeamTasksPanel();
|
||||
this.updateSubagentWindows();
|
||||
});
|
||||
|
||||
addListener('team:task_updated', (e) => {
|
||||
const data = JSON.parse(e.data);
|
||||
this.teamTasks.set(data.teamName, data.tasks);
|
||||
this.renderTeamTasksPanel();
|
||||
});
|
||||
|
||||
addListener('team:inbox_message', (e) => {
|
||||
// Inbox messages currently just trigger panel updates
|
||||
this.renderTeamTasksPanel();
|
||||
});
|
||||
|
||||
// ========== Image Detection Events (Screenshots & Generated Images) ==========
|
||||
|
||||
addListener('image:detected', (e) => {
|
||||
@@ -2851,8 +2916,84 @@ class ClaudemanApp {
|
||||
});
|
||||
}
|
||||
|
||||
setConnectionStatus(_status) {
|
||||
// Connection status UI removed - method kept for compatibility
|
||||
setConnectionStatus(status) {
|
||||
this._connectionStatus = status;
|
||||
this._updateConnectionIndicator();
|
||||
if (status === 'connected' && this._inputQueue.size > 0) {
|
||||
this._drainInputQueues();
|
||||
}
|
||||
}
|
||||
|
||||
_enqueueInput(sessionId, input) {
|
||||
const existing = this._inputQueue.get(sessionId) || '';
|
||||
let combined = existing + input;
|
||||
// Enforce 64KB cap — keep most recent keystrokes
|
||||
if (combined.length > this._inputQueueMaxBytes) {
|
||||
combined = combined.slice(combined.length - this._inputQueueMaxBytes);
|
||||
}
|
||||
this._inputQueue.set(sessionId, combined);
|
||||
this._updateConnectionIndicator();
|
||||
}
|
||||
|
||||
async _drainInputQueues() {
|
||||
if (this._inputQueue.size === 0) return;
|
||||
// Snapshot and clear
|
||||
const queued = new Map(this._inputQueue);
|
||||
this._inputQueue.clear();
|
||||
this._updateConnectionIndicator();
|
||||
|
||||
for (const [sessionId, input] of queued) {
|
||||
try {
|
||||
const resp = await fetch(`/api/sessions/${sessionId}/input`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ input })
|
||||
});
|
||||
if (!resp.ok) {
|
||||
this._enqueueInput(sessionId, input);
|
||||
}
|
||||
} catch {
|
||||
this._enqueueInput(sessionId, input);
|
||||
}
|
||||
}
|
||||
this._updateConnectionIndicator();
|
||||
}
|
||||
|
||||
_updateConnectionIndicator() {
|
||||
const indicator = this.$('connectionIndicator');
|
||||
const dot = this.$('connectionDot');
|
||||
const text = this.$('connectionText');
|
||||
if (!indicator || !dot || !text) return;
|
||||
|
||||
let totalBytes = 0;
|
||||
for (const v of this._inputQueue.values()) totalBytes += v.length;
|
||||
|
||||
const status = this._connectionStatus;
|
||||
const hasQueue = totalBytes > 0;
|
||||
|
||||
// Connected with empty queue — hide
|
||||
if ((status === 'connected' || status === 'connecting') && !hasQueue) {
|
||||
indicator.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
|
||||
indicator.style.display = 'flex';
|
||||
dot.className = 'connection-dot';
|
||||
|
||||
const formatBytes = (b) => b < 1024 ? `${b}B` : `${(b / 1024).toFixed(1)}KB`;
|
||||
|
||||
if (status === 'connected' && hasQueue) {
|
||||
// Draining
|
||||
dot.classList.add('draining');
|
||||
text.textContent = `Sending ${formatBytes(totalBytes)}...`;
|
||||
} else if (status === 'reconnecting') {
|
||||
dot.classList.add('reconnecting');
|
||||
text.textContent = hasQueue ? `Reconnecting (${formatBytes(totalBytes)} queued)` : 'Reconnecting...';
|
||||
} else {
|
||||
// Offline or disconnected
|
||||
dot.classList.add('offline');
|
||||
text.textContent = hasQueue ? `Offline (${formatBytes(totalBytes)} queued)` : 'Offline';
|
||||
}
|
||||
}
|
||||
|
||||
setupOnlineDetection() {
|
||||
@@ -3543,6 +3684,18 @@ class ClaudemanApp {
|
||||
async selectSession(sessionId) {
|
||||
if (this.activeSessionId === sessionId) return;
|
||||
|
||||
// Instant visual feedback — works even with no network.
|
||||
// Appended to .main (not terminalContainer) so xterm DOM resets can't remove it.
|
||||
const main = document.querySelector('.main');
|
||||
if (main) {
|
||||
const prev = main.querySelector('.tab-switch-glow');
|
||||
if (prev) prev.remove();
|
||||
const glow = document.createElement('div');
|
||||
glow.className = 'tab-switch-glow';
|
||||
glow.addEventListener('animationend', () => glow.remove(), { once: true });
|
||||
main.appendChild(glow);
|
||||
}
|
||||
|
||||
// Clean up flicker filter state when switching sessions
|
||||
if (this.flickerFilterTimeout) {
|
||||
clearTimeout(this.flickerFilterTimeout);
|
||||
@@ -3565,6 +3718,13 @@ class ClaudemanApp {
|
||||
this.clearPendingHooks(sessionId, 'idle_prompt');
|
||||
this.renderSessionTabs();
|
||||
|
||||
// Glow the newly-active tab
|
||||
const activeTab = document.querySelector(`.session-tab.active[data-id="${sessionId}"]`);
|
||||
if (activeTab) {
|
||||
activeTab.classList.add('tab-glow');
|
||||
activeTab.addEventListener('animationend', () => activeTab.classList.remove('tab-glow'), { once: true });
|
||||
}
|
||||
|
||||
// Check if this is a restored session that needs to be attached
|
||||
const session = this.sessions.get(sessionId);
|
||||
|
||||
@@ -6900,13 +7060,22 @@ class ClaudemanApp {
|
||||
sessionNames.push(`w${startNumber + i}-${caseName}`);
|
||||
}
|
||||
|
||||
// Build env overrides from global + case settings (case overrides global)
|
||||
const caseSettings = this.getCaseSettings(caseName);
|
||||
const globalSettings = this.loadAppSettingsFromStorage();
|
||||
const envOverrides = {};
|
||||
if (caseSettings.agentTeams || globalSettings.agentTeamsEnabled) {
|
||||
envOverrides.CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS = '1';
|
||||
}
|
||||
const hasEnvOverrides = Object.keys(envOverrides).length > 0;
|
||||
|
||||
// Step 1: Create all sessions in parallel
|
||||
this.terminal.writeln(`\x1b[90m Creating ${tabCount} session(s)...\x1b[0m`);
|
||||
const createPromises = sessionNames.map(name =>
|
||||
fetch('/api/sessions', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ workingDir, name })
|
||||
body: JSON.stringify({ workingDir, name, ...(hasEnvOverrides ? { envOverrides } : {}) })
|
||||
}).then(r => r.json())
|
||||
);
|
||||
const createResults = await Promise.all(createPromises);
|
||||
@@ -8716,6 +8885,8 @@ class ClaudemanApp {
|
||||
claudeModeSelect.onchange = () => {
|
||||
allowedToolsRow.style.display = claudeModeSelect.value === 'allowedTools' ? '' : 'none';
|
||||
};
|
||||
// Claude Permissions settings
|
||||
document.getElementById('appSettingsAgentTeams').checked = settings.agentTeamsEnabled ?? false;
|
||||
// CPU Priority settings
|
||||
const niceSettings = settings.nice || {};
|
||||
document.getElementById('appSettingsNiceEnabled').checked = niceSettings.enabled ?? false;
|
||||
@@ -8832,6 +9003,8 @@ class ClaudemanApp {
|
||||
// Claude CLI settings
|
||||
claudeMode: document.getElementById('appSettingsClaudeMode').value,
|
||||
allowedTools: document.getElementById('appSettingsAllowedTools').value.trim(),
|
||||
// Claude Permissions settings
|
||||
agentTeamsEnabled: document.getElementById('appSettingsAgentTeams').checked,
|
||||
// CPU Priority settings
|
||||
nice: {
|
||||
enabled: document.getElementById('appSettingsNiceEnabled').checked,
|
||||
@@ -11028,15 +11201,19 @@ class ClaudemanApp {
|
||||
? `<span class="subagent-model-badge ${agent.modelShort}">${agent.modelShort}</span>`
|
||||
: '';
|
||||
|
||||
const displayName = agent.description || agent.agentId.substring(0, 7);
|
||||
const teammateInfo = this.getTeammateInfo(agent);
|
||||
const displayName = teammateInfo ? teammateInfo.name : (agent.description || agent.agentId.substring(0, 7));
|
||||
const teammateBadge = this.getTeammateBadgeHtml(agent);
|
||||
const agentIcon = teammateInfo ? `<span class="subagent-icon teammate-dot teammate-color-${teammateInfo.color}">●</span>` : '<span class="subagent-icon">🤖</span>';
|
||||
html.push(`
|
||||
<div class="subagent-item ${statusClass} ${isActive ? 'selected' : ''}"
|
||||
<div class="subagent-item ${statusClass} ${isActive ? 'selected' : ''}${teammateInfo ? ' is-teammate' : ''}"
|
||||
onclick="app.selectSubagent('${agent.agentId}')"
|
||||
ondblclick="app.openSubagentWindow('${agent.agentId}')"
|
||||
title="Double-click to open tracking window">
|
||||
<div class="subagent-header">
|
||||
<span class="subagent-icon">🤖</span>
|
||||
${agentIcon}
|
||||
<span class="subagent-id" title="${this.escapeHtml(agent.description || agent.agentId)}">${this.escapeHtml(displayName.length > 40 ? displayName.substring(0, 40) + '...' : displayName)}</span>
|
||||
${teammateBadge}
|
||||
${modelBadge}
|
||||
<span class="subagent-status ${statusClass}">${agent.status}</span>
|
||||
${canKill ? `<button class="subagent-kill-btn" onclick="event.stopPropagation(); app.killSubagent('${agent.agentId}')" title="Kill agent">✕</button>` : ''}
|
||||
@@ -12287,11 +12464,27 @@ class ClaudemanApp {
|
||||
// Update title/id element with description if available
|
||||
const idEl = win.querySelector('.subagent-window-title .id');
|
||||
if (idEl) {
|
||||
const windowTitle = agent.description || agentId.substring(0, 7);
|
||||
const teammateInfo = this.getTeammateInfo(agent);
|
||||
const windowTitle = teammateInfo ? teammateInfo.name : (agent.description || agentId.substring(0, 7));
|
||||
const truncatedTitle = windowTitle.length > 50 ? windowTitle.substring(0, 50) + '...' : windowTitle;
|
||||
idEl.textContent = truncatedTitle;
|
||||
}
|
||||
|
||||
// Add or update teammate badge
|
||||
let tmBadge = win.querySelector('.teammate-badge');
|
||||
const teammateInfo = this.getTeammateInfo(agent);
|
||||
if (teammateInfo && !tmBadge) {
|
||||
const titleContainer = win.querySelector('.subagent-window-title');
|
||||
if (titleContainer) {
|
||||
const badge = document.createElement('span');
|
||||
badge.className = `teammate-badge teammate-color-${teammateInfo.color}`;
|
||||
badge.title = `Team: ${teammateInfo.teamName}`;
|
||||
badge.textContent = `@${teammateInfo.name}`;
|
||||
const statusEl = titleContainer.querySelector('.status');
|
||||
if (statusEl) statusEl.insertAdjacentElement('beforebegin', badge);
|
||||
}
|
||||
}
|
||||
|
||||
// Update full tooltip
|
||||
const titleContainer = win.querySelector('.subagent-window-title');
|
||||
if (titleContainer) {
|
||||
@@ -12330,6 +12523,120 @@ class ClaudemanApp {
|
||||
}
|
||||
}
|
||||
|
||||
// ========== Agent Teams ==========
|
||||
|
||||
/** Rebuild the teammate lookup map from all team configs */
|
||||
rebuildTeammateMap() {
|
||||
this.teammateMap.clear();
|
||||
for (const [teamName, team] of this.teams) {
|
||||
for (const member of team.members) {
|
||||
if (member.agentType !== 'team-lead') {
|
||||
// Use name as key prefix for matching subagent descriptions
|
||||
this.teammateMap.set(member.name, {
|
||||
name: member.name,
|
||||
color: member.color || 'blue',
|
||||
teamName,
|
||||
agentId: member.agentId,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Check if a subagent is a teammate and return its info */
|
||||
getTeammateInfo(agent) {
|
||||
if (!agent?.description) return null;
|
||||
// Teammate descriptions start with <teammate-message teammate_id=
|
||||
const match = agent.description.match(/<teammate-message\s+teammate_id="?([^">\s]+)/);
|
||||
if (!match) return null;
|
||||
const teammateId = match[1];
|
||||
// Extract name from teammate_id (format: name@teamName)
|
||||
const name = teammateId.split('@')[0];
|
||||
return this.teammateMap.get(name) || { name, color: 'blue', teamName: 'unknown' };
|
||||
}
|
||||
|
||||
/** Get teammate badge HTML for a subagent */
|
||||
getTeammateBadgeHtml(agent) {
|
||||
const info = this.getTeammateInfo(agent);
|
||||
if (!info) return '';
|
||||
return `<span class="teammate-badge teammate-color-${info.color}" title="Team: ${this.escapeHtml(info.teamName)}">@${this.escapeHtml(info.name)}</span>`;
|
||||
}
|
||||
|
||||
/** Render the team tasks panel */
|
||||
renderTeamTasksPanel() {
|
||||
const panel = document.getElementById('teamTasksPanel');
|
||||
if (!panel) return;
|
||||
|
||||
// Find team for active session
|
||||
let activeTeam = null;
|
||||
let activeTeamName = null;
|
||||
if (this.activeSessionId) {
|
||||
for (const [name, team] of this.teams) {
|
||||
if (team.leadSessionId === this.activeSessionId) {
|
||||
activeTeam = team;
|
||||
activeTeamName = name;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!activeTeam) {
|
||||
panel.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
|
||||
panel.style.display = '';
|
||||
const tasks = this.teamTasks.get(activeTeamName) || [];
|
||||
const completed = tasks.filter(t => t.status === 'completed').length;
|
||||
const total = tasks.length;
|
||||
const pct = total > 0 ? Math.round((completed / total) * 100) : 0;
|
||||
|
||||
const headerEl = panel.querySelector('.team-tasks-header-text');
|
||||
if (headerEl) {
|
||||
const teammateCount = activeTeam.members.filter(m => m.agentType !== 'team-lead').length;
|
||||
headerEl.textContent = `Team Tasks (${teammateCount} teammates)`;
|
||||
}
|
||||
|
||||
const progressEl = panel.querySelector('.team-tasks-progress-fill');
|
||||
if (progressEl) {
|
||||
progressEl.style.width = `${pct}%`;
|
||||
}
|
||||
|
||||
const progressText = panel.querySelector('.team-tasks-progress-text');
|
||||
if (progressText) {
|
||||
progressText.textContent = `${completed}/${total}`;
|
||||
}
|
||||
|
||||
const listEl = panel.querySelector('.team-tasks-list');
|
||||
if (!listEl) return;
|
||||
|
||||
if (tasks.length === 0) {
|
||||
listEl.innerHTML = '<div class="team-task-empty">No tasks yet</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
const html = tasks.map(task => {
|
||||
const statusIcon = task.status === 'completed' ? '✓' : task.status === 'in_progress' ? '◉' : '○';
|
||||
const statusClass = task.status.replace('_', '-');
|
||||
const ownerBadge = task.owner
|
||||
? `<span class="team-task-owner teammate-color-${this.getTeammateColor(task.owner)}">${this.escapeHtml(task.owner)}</span>`
|
||||
: '';
|
||||
return `<div class="team-task-item ${statusClass}">
|
||||
<span class="team-task-status">${statusIcon}</span>
|
||||
<span class="team-task-subject">${this.escapeHtml(task.subject)}</span>
|
||||
${ownerBadge}
|
||||
</div>`;
|
||||
}).join('');
|
||||
|
||||
listEl.innerHTML = html;
|
||||
}
|
||||
|
||||
/** Get teammate color by name */
|
||||
getTeammateColor(name) {
|
||||
const info = this.teammateMap.get(name);
|
||||
return info?.color || 'blue';
|
||||
}
|
||||
|
||||
// ========== Project Insights Panel (Bash Tools with Clickable File Paths) ==========
|
||||
|
||||
/**
|
||||
@@ -13112,6 +13419,14 @@ class ClaudemanApp {
|
||||
return;
|
||||
}
|
||||
|
||||
// Cap open popups at 20 — close oldest when at limit
|
||||
const MAX_IMAGE_POPUPS = 20;
|
||||
if (this.imagePopups.size >= MAX_IMAGE_POPUPS) {
|
||||
// Map iteration order is insertion order, so first key is oldest
|
||||
const oldestId = this.imagePopups.keys().next().value;
|
||||
if (oldestId) this.closeImagePopup(oldestId);
|
||||
}
|
||||
|
||||
// Calculate position (cascade from center, with offset for multiple popups)
|
||||
const windowCount = this.imagePopups.size;
|
||||
const centerX = (window.innerWidth - 600) / 2;
|
||||
@@ -13288,6 +13603,50 @@ class ClaudemanApp {
|
||||
}
|
||||
}
|
||||
|
||||
// ========== Case Settings ==========
|
||||
|
||||
toggleCaseSettings() {
|
||||
const popover = document.getElementById('caseSettingsPopover');
|
||||
if (popover.classList.contains('hidden')) {
|
||||
// Load settings for current case
|
||||
const caseName = document.getElementById('quickStartCase').value || 'testcase';
|
||||
const settings = this.getCaseSettings(caseName);
|
||||
document.getElementById('caseAgentTeams').checked = settings.agentTeams;
|
||||
popover.classList.remove('hidden');
|
||||
|
||||
// Close on outside click (one-shot listener)
|
||||
const closeHandler = (e) => {
|
||||
if (!popover.contains(e.target) && !e.target.classList.contains('btn-case-settings')) {
|
||||
popover.classList.add('hidden');
|
||||
document.removeEventListener('click', closeHandler);
|
||||
}
|
||||
};
|
||||
// Defer to avoid catching the current click
|
||||
setTimeout(() => document.addEventListener('click', closeHandler), 0);
|
||||
} else {
|
||||
popover.classList.add('hidden');
|
||||
}
|
||||
}
|
||||
|
||||
getCaseSettings(caseName) {
|
||||
try {
|
||||
const stored = localStorage.getItem('caseSettings_' + caseName);
|
||||
if (stored) return JSON.parse(stored);
|
||||
} catch { /* ignore */ }
|
||||
return { agentTeams: false };
|
||||
}
|
||||
|
||||
saveCaseSettings(caseName, settings) {
|
||||
localStorage.setItem('caseSettings_' + caseName, JSON.stringify(settings));
|
||||
}
|
||||
|
||||
onCaseSettingChanged() {
|
||||
const caseName = document.getElementById('quickStartCase').value || 'testcase';
|
||||
const settings = this.getCaseSettings(caseName);
|
||||
settings.agentTeams = document.getElementById('caseAgentTeams').checked;
|
||||
this.saveCaseSettings(caseName, settings);
|
||||
}
|
||||
|
||||
// ========== Create Case Modal ==========
|
||||
|
||||
showCreateCaseModal() {
|
||||
|
||||
@@ -27,6 +27,10 @@
|
||||
</div>
|
||||
|
||||
<div class="header-right">
|
||||
<div class="connection-indicator" id="connectionIndicator" style="display: none;">
|
||||
<span class="connection-dot" id="connectionDot"></span>
|
||||
<span class="connection-text" id="connectionText"></span>
|
||||
</div>
|
||||
<div class="header-font-controls">
|
||||
<button class="btn-icon-header btn-sm" onclick="app.decreaseFontSize()" title="Decrease font (Ctrl+-)" aria-label="Decrease font size">A-</button>
|
||||
<span class="font-size-display" id="fontSizeDisplay" aria-label="Current font size">14</span>
|
||||
@@ -285,6 +289,14 @@
|
||||
<option value="testcase">testcase</option>
|
||||
</select>
|
||||
<button class="btn-case-add" onclick="app.showCreateCaseModal()" title="Create new case">+</button>
|
||||
<button class="btn-case-settings" onclick="app.toggleCaseSettings()" title="Case settings">⚙</button>
|
||||
<div class="case-settings-popover hidden" id="caseSettingsPopover">
|
||||
<label class="checkbox-inline">
|
||||
<input type="checkbox" id="caseAgentTeams" onchange="app.onCaseSettingChanged()">
|
||||
<span>Agent Teams</span>
|
||||
</label>
|
||||
<span class="form-hint">Enable experimental Agent Teams for new sessions in this case</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Mobile-only case button -->
|
||||
<button class="btn-toolbar btn-case-mobile" onclick="app.showMobileCasePicker()" title="Select case">
|
||||
@@ -413,6 +425,20 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Team Tasks Panel -->
|
||||
<div class="team-tasks-panel" id="teamTasksPanel" style="display: none;">
|
||||
<div class="team-tasks-header">
|
||||
<span class="team-tasks-header-text">Team Tasks</span>
|
||||
<div class="team-tasks-progress">
|
||||
<div class="team-tasks-progress-bar">
|
||||
<div class="team-tasks-progress-fill"></div>
|
||||
</div>
|
||||
<span class="team-tasks-progress-text">0/0</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="team-tasks-list"></div>
|
||||
</div>
|
||||
|
||||
<!-- Session Options Modal (includes Respawn Settings) -->
|
||||
<div class="modal" id="sessionOptionsModal">
|
||||
<div class="modal-backdrop" onclick="app.closeSessionOptions()"></div>
|
||||
@@ -837,6 +863,16 @@
|
||||
</label>
|
||||
<span class="form-hint">Auto-enable for new sessions (otherwise auto-enables on Ralph pattern detection)</span>
|
||||
</div>
|
||||
<!-- Claude Permissions Section -->
|
||||
<div class="form-section-header">Claude Permissions</div>
|
||||
<div class="form-row form-row-switch">
|
||||
<label>Agent Teams</label>
|
||||
<label class="switch">
|
||||
<input type="checkbox" id="appSettingsAgentTeams">
|
||||
<span class="slider"></span>
|
||||
</label>
|
||||
<span class="form-hint">Enable experimental Agent Teams for all new Claude sessions (disabled by default)</span>
|
||||
</div>
|
||||
<!-- Nice Priority Section -->
|
||||
<div class="form-section-header">Nice Priority</div>
|
||||
<div class="form-row form-row-switch">
|
||||
|
||||
+278
-2
@@ -208,8 +208,16 @@ body {
|
||||
}
|
||||
|
||||
.session-tab:hover {
|
||||
background: var(--bg-hover);
|
||||
background: rgba(34, 197, 94, 0.06);
|
||||
color: var(--text);
|
||||
box-shadow: 0 0 8px 2px rgba(34, 197, 94, 0.2), inset 0 0 6px 1px rgba(34, 197, 94, 0.08);
|
||||
border-color: rgba(34, 197, 94, 0.2);
|
||||
}
|
||||
|
||||
.session-tab:active {
|
||||
background: rgba(34, 197, 94, 0.15);
|
||||
box-shadow: 0 0 16px 5px rgba(34, 197, 94, 0.5), 0 0 30px 10px rgba(34, 197, 94, 0.2), inset 0 0 10px 2px rgba(34, 197, 94, 0.2);
|
||||
border-color: rgba(34, 197, 94, 0.4);
|
||||
}
|
||||
|
||||
.session-tab.active {
|
||||
@@ -218,6 +226,18 @@ body {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
/* Tab switch feedback: bright green glow on the newly-active tab */
|
||||
.session-tab.tab-glow {
|
||||
animation: tab-glow 1s ease-out forwards;
|
||||
}
|
||||
|
||||
@keyframes tab-glow {
|
||||
0% { box-shadow: 0 0 0px 0px rgba(34, 197, 94, 0); background: transparent; }
|
||||
10% { box-shadow: 0 0 18px 6px rgba(34, 197, 94, 0.7), 0 0 40px 12px rgba(34, 197, 94, 0.3), inset 0 0 12px 3px rgba(34, 197, 94, 0.25); background: rgba(34, 197, 94, 0.12); }
|
||||
30% { box-shadow: 0 0 14px 5px rgba(34, 197, 94, 0.5), 0 0 30px 10px rgba(34, 197, 94, 0.2), inset 0 0 10px 2px rgba(34, 197, 94, 0.15); background: rgba(34, 197, 94, 0.08); }
|
||||
100% { box-shadow: 0 0 0px 0px rgba(34, 197, 94, 0); background: transparent; }
|
||||
}
|
||||
|
||||
.session-tab .tab-status {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
@@ -345,6 +365,44 @@ body {
|
||||
border-left: 1px solid var(--border);
|
||||
}
|
||||
|
||||
/* Connection Indicator */
|
||||
.connection-indicator {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
font-size: 0.7rem;
|
||||
color: var(--text-dim);
|
||||
font-family: 'SF Mono', Monaco, monospace;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.connection-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.connection-dot.offline {
|
||||
background: var(--red);
|
||||
box-shadow: 0 0 6px var(--red);
|
||||
}
|
||||
|
||||
.connection-dot.reconnecting {
|
||||
background: var(--yellow);
|
||||
animation: connection-pulse 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.connection-dot.draining {
|
||||
background: var(--green);
|
||||
animation: connection-pulse 0.8s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes connection-pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.3; }
|
||||
}
|
||||
|
||||
.header-tokens {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
@@ -1616,6 +1674,23 @@ body {
|
||||
will-change: contents;
|
||||
}
|
||||
|
||||
/* Tab switch feedback: green glow emerging from black */
|
||||
.tab-switch-glow {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
z-index: 100;
|
||||
background: radial-gradient(ellipse at center, rgba(34, 197, 94, 0.12) 0%, transparent 70%);
|
||||
box-shadow: inset 0 0 150px 60px rgba(34, 197, 94, 0.35);
|
||||
animation: tab-switch-glow 0.7s ease-out forwards;
|
||||
}
|
||||
|
||||
@keyframes tab-switch-glow {
|
||||
0% { opacity: 0; }
|
||||
15% { opacity: 1; }
|
||||
100% { opacity: 0; }
|
||||
}
|
||||
|
||||
.terminal-container .xterm {
|
||||
height: 100%;
|
||||
padding: 0;
|
||||
@@ -1931,6 +2006,7 @@ body {
|
||||
.case-select-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.toolbar-select {
|
||||
@@ -1968,7 +2044,7 @@ body {
|
||||
background: var(--bg-input);
|
||||
border: 1px solid var(--border);
|
||||
border-left: none;
|
||||
border-radius: 0 4px 4px 0;
|
||||
border-radius: 0;
|
||||
color: var(--green);
|
||||
font-size: 1.1rem;
|
||||
font-weight: 500;
|
||||
@@ -1982,6 +2058,51 @@ body {
|
||||
border-color: var(--green);
|
||||
}
|
||||
|
||||
.btn-case-settings {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
background: var(--bg-input);
|
||||
border: 1px solid var(--border);
|
||||
border-left: none;
|
||||
border-radius: 0 4px 4px 0;
|
||||
color: var(--text-dim);
|
||||
font-size: 1rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.btn-case-settings:hover {
|
||||
background: var(--accent);
|
||||
color: white;
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.case-settings-popover {
|
||||
position: absolute;
|
||||
bottom: 100%;
|
||||
right: 0;
|
||||
margin-bottom: 4px;
|
||||
background: var(--bg-panel);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
padding: 0.5rem 0.6rem;
|
||||
width: 220px;
|
||||
z-index: 1000;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.case-settings-popover.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.case-settings-popover .form-hint {
|
||||
margin-top: 0.2rem;
|
||||
font-size: 0.6rem;
|
||||
}
|
||||
|
||||
.toolbar-input {
|
||||
padding: 0.4rem 0.5rem;
|
||||
background: var(--bg-input);
|
||||
@@ -4592,6 +4713,10 @@ kbd {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.connection-text {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.toolbar-left .toolbar-group:not(:first-child) {
|
||||
display: none;
|
||||
}
|
||||
@@ -5360,6 +5485,153 @@ kbd {
|
||||
50% { opacity: 1; }
|
||||
}
|
||||
|
||||
/* ========== Agent Teams (Teammate Badges + Team Tasks Panel) ========== */
|
||||
|
||||
/* Teammate badge in subagent panel/windows */
|
||||
.teammate-badge {
|
||||
font-size: 10px;
|
||||
padding: 1px 5px;
|
||||
border-radius: 8px;
|
||||
font-weight: 600;
|
||||
margin-left: 4px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.teammate-color-blue {
|
||||
background: #3b82f6;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.teammate-color-green {
|
||||
background: #22c55e;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.teammate-color-yellow {
|
||||
background: #eab308;
|
||||
color: #1a1a2e;
|
||||
}
|
||||
|
||||
/* Teammate dot icon replacing robot emoji */
|
||||
.teammate-dot {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.teammate-dot.teammate-color-blue { color: #3b82f6; }
|
||||
.teammate-dot.teammate-color-green { color: #22c55e; }
|
||||
.teammate-dot.teammate-color-yellow { color: #eab308; }
|
||||
|
||||
/* Subagent items that are teammates get a subtle left border */
|
||||
.subagent-item.is-teammate {
|
||||
border-left: 2px solid #3b82f6;
|
||||
}
|
||||
.subagent-item.is-teammate .teammate-color-green ~ * { border-left-color: #22c55e; }
|
||||
|
||||
/* Team Tasks Panel */
|
||||
.team-tasks-panel {
|
||||
position: fixed;
|
||||
bottom: 50px;
|
||||
right: 10px;
|
||||
width: 320px;
|
||||
max-height: 300px;
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
|
||||
z-index: 900;
|
||||
overflow: hidden;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.team-tasks-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 8px 12px;
|
||||
background: var(--bg-tertiary);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.team-tasks-header-text {
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.team-tasks-progress {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.team-tasks-progress-bar {
|
||||
width: 60px;
|
||||
height: 6px;
|
||||
background: var(--bg-primary);
|
||||
border-radius: 3px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.team-tasks-progress-fill {
|
||||
height: 100%;
|
||||
background: var(--success);
|
||||
border-radius: 3px;
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
|
||||
.team-tasks-progress-text {
|
||||
color: var(--text-secondary);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.team-tasks-list {
|
||||
overflow-y: auto;
|
||||
max-height: 250px;
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
.team-task-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 4px 12px;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.team-task-item.completed {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.team-task-item.in-progress .team-task-status {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.team-task-status {
|
||||
flex-shrink: 0;
|
||||
width: 14px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.team-task-subject {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.team-task-owner {
|
||||
font-size: 10px;
|
||||
padding: 1px 5px;
|
||||
border-radius: 8px;
|
||||
font-weight: 600;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.team-task-empty {
|
||||
padding: 12px;
|
||||
text-align: center;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* ========== Project Insights Panel (Bash File Viewers) ========== */
|
||||
|
||||
.project-insights-panel {
|
||||
@@ -8481,6 +8753,10 @@ kbd {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.btn-case-settings {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* When keyboard is visible, also move accessory bar up */
|
||||
.keyboard-visible .keyboard-accessory-bar.visible {
|
||||
/* Position is handled by JS transform along with toolbar */
|
||||
|
||||
+2
-1
@@ -19,6 +19,7 @@ export const CreateSessionSchema = z.object({
|
||||
workingDir: z.string().optional(),
|
||||
mode: z.enum(['claude', 'shell']).optional(),
|
||||
name: z.string().max(100).optional(),
|
||||
envOverrides: z.record(z.string(), z.string()).optional(),
|
||||
});
|
||||
|
||||
/**
|
||||
@@ -76,7 +77,7 @@ export const QuickStartSchema = z.object({
|
||||
* Receives Claude Code hook events.
|
||||
*/
|
||||
export const HookEventSchema = z.object({
|
||||
event: z.enum(['permission_prompt', 'elicitation_dialog', 'idle_prompt', 'stop']),
|
||||
event: z.enum(['permission_prompt', 'elicitation_dialog', 'idle_prompt', 'stop', 'teammate_idle', 'task_completed']),
|
||||
sessionId: z.string().min(1),
|
||||
data: z.unknown().optional(),
|
||||
});
|
||||
|
||||
+87
-1
@@ -26,7 +26,7 @@ import { ScreenManager } from '../screen-manager.js';
|
||||
import { getStore } from '../state-store.js';
|
||||
import { generateClaudeMd } from '../templates/claude-md.js';
|
||||
import { parseRalphLoopConfig, extractCompletionPhrase } from '../ralph-config.js';
|
||||
import { writeHooksConfig } from '../hooks-config.js';
|
||||
import { writeHooksConfig, updateCaseEnvVars } from '../hooks-config.js';
|
||||
import { subagentWatcher, type SubagentInfo, type SubagentToolCall, type SubagentProgress, type SubagentMessage, type SubagentToolResult } from '../subagent-watcher.js';
|
||||
import { imageWatcher } from '../image-watcher.js';
|
||||
import { TranscriptWatcher } from '../transcript-watcher.js';
|
||||
@@ -34,6 +34,7 @@ import { v4 as uuidv4 } from 'uuid';
|
||||
import { createRequire } from 'node:module';
|
||||
import { RunSummaryTracker } from '../run-summary.js';
|
||||
import { PlanOrchestrator, type DetailedPlanResult } from '../plan-orchestrator.js';
|
||||
import { TeamWatcher } from '../team-watcher.js';
|
||||
|
||||
// Load version from package.json
|
||||
const require = createRequire(import.meta.url);
|
||||
@@ -51,6 +52,9 @@ import {
|
||||
type PersistedRespawnConfig,
|
||||
type NiceConfig,
|
||||
type ImageDetectedEvent,
|
||||
type TeamConfig,
|
||||
type TeamTask,
|
||||
type InboxMessage,
|
||||
DEFAULT_NICE_CONFIG,
|
||||
} from '../types.js';
|
||||
import {
|
||||
@@ -370,6 +374,7 @@ export class WebServer extends EventEmitter {
|
||||
detected: (event: ImageDetectedEvent) => void;
|
||||
error: (error: Error, sessionId?: string) => void;
|
||||
} | null = null;
|
||||
private teamWatcher: TeamWatcher = new TeamWatcher();
|
||||
|
||||
constructor(port: number = 3000, https: boolean = false, testMode: boolean = false) {
|
||||
super();
|
||||
@@ -484,6 +489,26 @@ export class WebServer extends EventEmitter {
|
||||
}
|
||||
}
|
||||
|
||||
private setupTeamWatcherListeners(): void {
|
||||
this.teamWatcher.on('teamCreated', (team: TeamConfig) => {
|
||||
this.broadcast('team:created', team);
|
||||
});
|
||||
this.teamWatcher.on('teamUpdated', (team: TeamConfig) => {
|
||||
this.broadcast('team:updated', team);
|
||||
});
|
||||
this.teamWatcher.on('teamRemoved', (team: TeamConfig) => {
|
||||
this.broadcast('team:removed', team);
|
||||
});
|
||||
this.teamWatcher.on('taskUpdated', (data: { teamName: string; tasks: TeamTask[] }) => {
|
||||
// Filter out internal tasks for broadcast
|
||||
const visibleTasks = data.tasks.filter(t => !t.metadata?._internal);
|
||||
this.broadcast('team:task_updated', { teamName: data.teamName, tasks: visibleTasks });
|
||||
});
|
||||
this.teamWatcher.on('inboxMessage', (data: { teamName: string; member: string; message: InboxMessage }) => {
|
||||
this.broadcast('team:inbox_message', data);
|
||||
});
|
||||
}
|
||||
|
||||
private async setupRoutes(): Promise<void> {
|
||||
// Serve static files
|
||||
await this.app.register(fastifyStatic, {
|
||||
@@ -655,6 +680,12 @@ export class WebServer extends EventEmitter {
|
||||
}
|
||||
const body = result.data;
|
||||
const workingDir = body.workingDir || process.cwd();
|
||||
|
||||
// Write env overrides to .claude/settings.local.json if provided
|
||||
if (body.envOverrides && Object.keys(body.envOverrides).length > 0) {
|
||||
updateCaseEnvVars(workingDir, body.envOverrides);
|
||||
}
|
||||
|
||||
const globalNice = this.getGlobalNiceConfig();
|
||||
const session = new Session({
|
||||
workingDir,
|
||||
@@ -3295,6 +3326,49 @@ NOW: Generate the implementation plan for the task above. Think step by step.`;
|
||||
});
|
||||
|
||||
|
||||
// ========== Agent Teams ==========
|
||||
|
||||
// List all active teams
|
||||
this.app.get('/api/teams', async () => {
|
||||
const teams = this.teamWatcher.getTeams();
|
||||
// Enrich with session association
|
||||
const enriched = teams.map(team => ({
|
||||
...team,
|
||||
sessionExists: this.sessions.has(team.leadSessionId),
|
||||
activeTasks: this.teamWatcher.getActiveTaskCount(team.name),
|
||||
teammateCount: team.members.filter(m => m.agentType !== 'team-lead').length,
|
||||
}));
|
||||
return { success: true, data: enriched };
|
||||
});
|
||||
|
||||
// Get team for a specific session
|
||||
this.app.get('/api/sessions/:id/team', async (req) => {
|
||||
const { id } = req.params as { id: string };
|
||||
const team = this.teamWatcher.getTeamForSession(id);
|
||||
if (!team) {
|
||||
return { success: true, data: null };
|
||||
}
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
...team,
|
||||
activeTasks: this.teamWatcher.getActiveTaskCount(team.name),
|
||||
teammateCount: team.members.filter(m => m.agentType !== 'team-lead').length,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
// Get team tasks for a session's team
|
||||
this.app.get('/api/sessions/:id/team-tasks', async (req) => {
|
||||
const { id } = req.params as { id: string };
|
||||
const team = this.teamWatcher.getTeamForSession(id);
|
||||
if (!team) {
|
||||
return { success: true, data: [] };
|
||||
}
|
||||
const tasks = this.teamWatcher.getTeamTasks(team.name);
|
||||
return { success: true, data: tasks };
|
||||
});
|
||||
|
||||
// ========== Hook Events ==========
|
||||
|
||||
this.app.post('/api/hook-event', async (req) => {
|
||||
@@ -3911,6 +3985,9 @@ NOW: Generate the implementation plan for the task above. Think step by step.`;
|
||||
}
|
||||
|
||||
private setupRespawnListeners(sessionId: string, controller: RespawnController): void {
|
||||
// Inject team watcher for team-aware idle detection
|
||||
controller.setTeamWatcher(this.teamWatcher);
|
||||
|
||||
// Helper to get tracker lazily (may not exist at setup time for restored sessions)
|
||||
const getTracker = () => this.runSummaryTrackers.get(sessionId);
|
||||
|
||||
@@ -4717,6 +4794,11 @@ NOW: Generate the implementation plan for the task above. Think step by step.`;
|
||||
} else {
|
||||
console.log('Image watcher disabled by user settings');
|
||||
}
|
||||
|
||||
// Start team watcher for Agent Teams support
|
||||
this.setupTeamWatcherListeners();
|
||||
this.teamWatcher.start();
|
||||
console.log('Team watcher started - monitoring ~/.claude/teams for agent team activity');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -5012,6 +5094,10 @@ NOW: Generate the implementation plan for the task above. Think step by step.`;
|
||||
// Stop image watcher
|
||||
imageWatcher.stop();
|
||||
|
||||
// Stop team watcher
|
||||
this.teamWatcher.removeAllListeners();
|
||||
this.teamWatcher.stop();
|
||||
|
||||
// Destroy file stream manager (clears cleanup timer and kills remaining tail processes)
|
||||
fileStreamManager.destroy();
|
||||
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
/**
|
||||
* @fileoverview Tests for team-aware idle detection in RespawnController
|
||||
*
|
||||
* Verifies that the respawn controller checks TeamWatcher for active teammates
|
||||
* before triggering respawn. Also tests AI idle checker teammate context injection.
|
||||
*
|
||||
* Test port: 3151 (if server needed)
|
||||
* SAFETY: Never uses port 3000, never kills w1-claudeman/w2/w3-claudeman
|
||||
*/
|
||||
|
||||
import { EventEmitter } from 'node:events';
|
||||
import { vi } from 'vitest';
|
||||
import type { Session } from '../src/session.js';
|
||||
import { RespawnController } from '../src/respawn-controller.js';
|
||||
import { AiIdleChecker } from '../src/ai-idle-checker.js';
|
||||
import { TeamWatcher } from '../src/team-watcher.js';
|
||||
import { MockSession, createTimeController, type TimeController } from './respawn-test-utils.js';
|
||||
|
||||
// Minimal config for fast testing
|
||||
const FAST_CONFIG = {
|
||||
enabled: true,
|
||||
idleTimeoutMs: 100,
|
||||
completionConfirmMs: 100,
|
||||
noOutputTimeoutMs: 500,
|
||||
updatePrompt: 'test update',
|
||||
interStepDelayMs: 50,
|
||||
sendClear: false,
|
||||
sendInit: false,
|
||||
aiIdleCheckEnabled: false,
|
||||
};
|
||||
|
||||
/**
|
||||
* Mock TeamWatcher that returns configurable active teammate state
|
||||
*/
|
||||
class MockTeamWatcher extends EventEmitter {
|
||||
private _hasActive: boolean = false;
|
||||
private _teammateCount: number = 0;
|
||||
|
||||
setHasActiveTeammates(value: boolean): void {
|
||||
this._hasActive = value;
|
||||
}
|
||||
|
||||
setTeammateCount(count: number): void {
|
||||
this._teammateCount = count;
|
||||
this._hasActive = count > 0;
|
||||
}
|
||||
|
||||
hasActiveTeammates(_sessionId: string): boolean {
|
||||
return this._hasActive;
|
||||
}
|
||||
|
||||
getActiveTeammateCount(_sessionId: string): number {
|
||||
return this._teammateCount;
|
||||
}
|
||||
|
||||
// Stubs for TeamWatcher interface
|
||||
start(): void {}
|
||||
stop(): void {}
|
||||
getTeams(): [] { return []; }
|
||||
getTeamForSession(): undefined { return undefined; }
|
||||
getTeamTasks(): [] { return []; }
|
||||
getActiveTaskCount(): number { return 0; }
|
||||
getInboxMessages(): [] { return []; }
|
||||
}
|
||||
|
||||
describe('Team-aware idle detection', () => {
|
||||
let session: MockSession;
|
||||
let controller: RespawnController;
|
||||
let mockTeamWatcher: MockTeamWatcher;
|
||||
let time: TimeController;
|
||||
|
||||
beforeEach(() => {
|
||||
time = createTimeController();
|
||||
session = new MockSession('team-test-session');
|
||||
mockTeamWatcher = new MockTeamWatcher();
|
||||
controller = new RespawnController(session as unknown as Session, FAST_CONFIG);
|
||||
controller.setTeamWatcher(mockTeamWatcher as unknown as TeamWatcher);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
controller.stop();
|
||||
time.useRealTimers();
|
||||
});
|
||||
|
||||
it('should block idle confirmation when teammates are active', async () => {
|
||||
mockTeamWatcher.setHasActiveTeammates(true);
|
||||
|
||||
const blocked = new Promise<{ reason: string }>(resolve => {
|
||||
controller.on('respawnBlocked', resolve);
|
||||
});
|
||||
|
||||
controller.start();
|
||||
|
||||
// Simulate completion
|
||||
session.simulateCompletionMessage('30s');
|
||||
await time.advanceBy(200); // Wait for confirmation
|
||||
|
||||
const event = await blocked;
|
||||
expect(event.reason).toBe('active_teammates');
|
||||
});
|
||||
|
||||
it('should not emit active_teammates block when no teammates are active', async () => {
|
||||
mockTeamWatcher.setHasActiveTeammates(false);
|
||||
|
||||
const blockReasons: string[] = [];
|
||||
controller.on('respawnBlocked', (data: { reason: string }) => {
|
||||
blockReasons.push(data.reason);
|
||||
});
|
||||
|
||||
controller.start();
|
||||
|
||||
// Simulate completion and wait through confirmation
|
||||
session.simulateCompletionMessage('30s');
|
||||
await time.advanceBy(1000);
|
||||
|
||||
// Even if idle detection doesn't fully complete in fake timers,
|
||||
// we should NOT see an active_teammates block
|
||||
expect(blockReasons).not.toContain('active_teammates');
|
||||
});
|
||||
|
||||
it('should not emit active_teammates block when no team watcher is set', async () => {
|
||||
// Create controller without team watcher
|
||||
const plainController = new RespawnController(session as unknown as Session, FAST_CONFIG);
|
||||
|
||||
const blockReasons: string[] = [];
|
||||
plainController.on('respawnBlocked', (data: { reason: string }) => {
|
||||
blockReasons.push(data.reason);
|
||||
});
|
||||
|
||||
plainController.start();
|
||||
|
||||
session.simulateCompletionMessage('30s');
|
||||
await time.advanceBy(1000);
|
||||
|
||||
// No team watcher means no teammate blocking
|
||||
expect(blockReasons).not.toContain('active_teammates');
|
||||
|
||||
plainController.stop();
|
||||
});
|
||||
|
||||
it('should transition back to watching when blocked by teammates', async () => {
|
||||
mockTeamWatcher.setHasActiveTeammates(true);
|
||||
|
||||
const stateChanges: string[] = [];
|
||||
controller.on('stateChanged', (state: string) => {
|
||||
stateChanges.push(state);
|
||||
});
|
||||
|
||||
controller.start();
|
||||
|
||||
session.simulateCompletionMessage('30s');
|
||||
await time.advanceBy(200);
|
||||
|
||||
// Should go back to watching after being blocked
|
||||
const lastState = stateChanges[stateChanges.length - 1];
|
||||
expect(lastState).toBe('watching');
|
||||
});
|
||||
});
|
||||
|
||||
describe('AI idle checker teammate context', () => {
|
||||
it('should inject teammate context into prompt when count > 0', () => {
|
||||
const checker = new AiIdleChecker('test-session', { enabled: true });
|
||||
checker.setTeammateCount(3);
|
||||
|
||||
// Access buildPrompt via prototype (it's protected, so we test indirectly)
|
||||
// Instead, we verify the public API works
|
||||
expect(checker).toBeDefined();
|
||||
});
|
||||
|
||||
it('should accept setTeammateCount(0) without error', () => {
|
||||
const checker = new AiIdleChecker('test-session', { enabled: true });
|
||||
checker.setTeammateCount(0);
|
||||
expect(checker).toBeDefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,368 @@
|
||||
/**
|
||||
* @fileoverview Tests for TeamWatcher - Agent Teams filesystem polling
|
||||
*
|
||||
* Test port: 3150 (if server needed)
|
||||
* SAFETY: Never uses port 3000, never kills w1-claudeman/w2/w3-claudeman
|
||||
*/
|
||||
|
||||
import { mkdirSync, writeFileSync, rmSync, existsSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { TeamWatcher } from '../src/team-watcher.js';
|
||||
import type { TeamConfig, TeamTask, InboxMessage } from '../src/types.js';
|
||||
|
||||
// Unique temp dir per test run
|
||||
const TEST_BASE = join(tmpdir(), `claudeman-test-teams-${randomUUID().substring(0, 8)}`);
|
||||
const TEAMS_DIR = join(TEST_BASE, 'teams');
|
||||
const TASKS_DIR = join(TEST_BASE, 'tasks');
|
||||
|
||||
function createTeamConfig(overrides: Partial<TeamConfig> = {}): TeamConfig {
|
||||
return {
|
||||
name: 'test-team',
|
||||
createdAt: Date.now(),
|
||||
leadAgentId: 'team-lead@test-team',
|
||||
leadSessionId: 'session-123',
|
||||
members: [
|
||||
{
|
||||
agentId: 'team-lead@test-team',
|
||||
name: 'team-lead',
|
||||
agentType: 'team-lead',
|
||||
joinedAt: Date.now(),
|
||||
},
|
||||
{
|
||||
agentId: 'researcher@test-team',
|
||||
name: 'researcher',
|
||||
agentType: 'general-purpose',
|
||||
color: 'blue',
|
||||
backendType: 'in-process',
|
||||
joinedAt: Date.now(),
|
||||
},
|
||||
{
|
||||
agentId: 'coder@test-team',
|
||||
name: 'coder',
|
||||
agentType: 'general-purpose',
|
||||
color: 'green',
|
||||
backendType: 'in-process',
|
||||
joinedAt: Date.now(),
|
||||
},
|
||||
],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function createTask(id: string, status: 'pending' | 'in_progress' | 'completed', owner?: string): TeamTask {
|
||||
return {
|
||||
id,
|
||||
subject: `Task ${id}`,
|
||||
description: `Description for task ${id}`,
|
||||
activeForm: `Working on task ${id}`,
|
||||
status,
|
||||
owner,
|
||||
blocks: [],
|
||||
blockedBy: [],
|
||||
};
|
||||
}
|
||||
|
||||
function writeTeamConfig(teamName: string, config: TeamConfig): void {
|
||||
const dir = join(TEAMS_DIR, teamName);
|
||||
mkdirSync(dir, { recursive: true });
|
||||
writeFileSync(join(dir, 'config.json'), JSON.stringify(config));
|
||||
}
|
||||
|
||||
function writeTask(teamName: string, task: TeamTask): void {
|
||||
const dir = join(TASKS_DIR, teamName);
|
||||
mkdirSync(dir, { recursive: true });
|
||||
writeFileSync(join(dir, `${task.id}.json`), JSON.stringify(task));
|
||||
}
|
||||
|
||||
function writeInbox(teamName: string, member: string, messages: InboxMessage[]): void {
|
||||
const dir = join(TEAMS_DIR, teamName, 'inboxes');
|
||||
mkdirSync(dir, { recursive: true });
|
||||
writeFileSync(join(dir, `${member}.json`), JSON.stringify(messages));
|
||||
}
|
||||
|
||||
describe('TeamWatcher', () => {
|
||||
let watcher: TeamWatcher;
|
||||
|
||||
beforeEach(() => {
|
||||
// Clean and recreate
|
||||
if (existsSync(TEST_BASE)) {
|
||||
rmSync(TEST_BASE, { recursive: true, force: true });
|
||||
}
|
||||
mkdirSync(TEAMS_DIR, { recursive: true });
|
||||
mkdirSync(TASKS_DIR, { recursive: true });
|
||||
watcher = new TeamWatcher(TEAMS_DIR, TASKS_DIR);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
watcher.stop();
|
||||
if (existsSync(TEST_BASE)) {
|
||||
rmSync(TEST_BASE, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
describe('Team discovery', () => {
|
||||
it('should discover a team from config.json', async () => {
|
||||
const config = createTeamConfig();
|
||||
writeTeamConfig('test-team', config);
|
||||
|
||||
const created = new Promise<TeamConfig>(resolve => {
|
||||
watcher.on('teamCreated', resolve);
|
||||
});
|
||||
|
||||
watcher.start();
|
||||
const team = await created;
|
||||
|
||||
expect(team.name).toBe('test-team');
|
||||
expect(team.leadSessionId).toBe('session-123');
|
||||
expect(team.members).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('should return teams via getTeams()', async () => {
|
||||
writeTeamConfig('alpha', createTeamConfig({ name: 'alpha' }));
|
||||
writeTeamConfig('beta', createTeamConfig({ name: 'beta', leadSessionId: 'session-456' }));
|
||||
|
||||
const events: TeamConfig[] = [];
|
||||
watcher.on('teamCreated', (t: TeamConfig) => events.push(t));
|
||||
|
||||
watcher.start();
|
||||
// Wait for first poll
|
||||
await new Promise(r => setTimeout(r, 100));
|
||||
|
||||
const teams = watcher.getTeams();
|
||||
expect(teams).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('should match team to session via getTeamForSession()', async () => {
|
||||
writeTeamConfig('test-team', createTeamConfig({ leadSessionId: 'my-session' }));
|
||||
|
||||
watcher.start();
|
||||
await new Promise(r => setTimeout(r, 100));
|
||||
|
||||
expect(watcher.getTeamForSession('my-session')).toBeDefined();
|
||||
expect(watcher.getTeamForSession('other-session')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should emit teamUpdated when config changes', async () => {
|
||||
const config = createTeamConfig();
|
||||
writeTeamConfig('test-team', config);
|
||||
|
||||
watcher.start();
|
||||
await new Promise(r => setTimeout(r, 100));
|
||||
|
||||
const updated = new Promise<TeamConfig>(resolve => {
|
||||
watcher.on('teamUpdated', resolve);
|
||||
});
|
||||
|
||||
// Modify config (add a member)
|
||||
config.members.push({
|
||||
agentId: 'writer@test-team',
|
||||
name: 'writer',
|
||||
agentType: 'general-purpose',
|
||||
color: 'yellow',
|
||||
joinedAt: Date.now(),
|
||||
});
|
||||
// Small delay to ensure different mtime
|
||||
await new Promise(r => setTimeout(r, 50));
|
||||
writeTeamConfig('test-team', config);
|
||||
|
||||
const team = await updated;
|
||||
expect(team.members).toHaveLength(4);
|
||||
});
|
||||
|
||||
it('should emit teamRemoved when directory deleted', async () => {
|
||||
writeTeamConfig('test-team', createTeamConfig());
|
||||
|
||||
watcher.start();
|
||||
await new Promise(r => setTimeout(r, 100));
|
||||
|
||||
expect(watcher.getTeams()).toHaveLength(1);
|
||||
|
||||
const removed = new Promise<TeamConfig>(resolve => {
|
||||
watcher.on('teamRemoved', resolve);
|
||||
});
|
||||
|
||||
rmSync(join(TEAMS_DIR, 'test-team'), { recursive: true, force: true });
|
||||
|
||||
const team = await removed;
|
||||
expect(team.name).toBe('test-team');
|
||||
expect(watcher.getTeams()).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Task tracking', () => {
|
||||
it('should read tasks from task directory', async () => {
|
||||
writeTeamConfig('test-team', createTeamConfig());
|
||||
writeTask('test-team', createTask('1', 'in_progress', 'researcher'));
|
||||
writeTask('test-team', createTask('2', 'pending'));
|
||||
|
||||
watcher.start();
|
||||
await new Promise(r => setTimeout(r, 100));
|
||||
|
||||
const tasks = watcher.getTeamTasks('test-team');
|
||||
expect(tasks).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('should count active tasks', async () => {
|
||||
writeTeamConfig('test-team', createTeamConfig());
|
||||
writeTask('test-team', createTask('1', 'in_progress', 'researcher'));
|
||||
writeTask('test-team', createTask('2', 'completed', 'coder'));
|
||||
writeTask('test-team', createTask('3', 'pending'));
|
||||
|
||||
watcher.start();
|
||||
await new Promise(r => setTimeout(r, 100));
|
||||
|
||||
expect(watcher.getActiveTaskCount('test-team')).toBe(2); // in_progress + pending
|
||||
});
|
||||
|
||||
it('should filter out internal tasks', async () => {
|
||||
writeTeamConfig('test-team', createTeamConfig());
|
||||
writeTask('test-team', createTask('1', 'in_progress', 'researcher'));
|
||||
|
||||
const internalTask: TeamTask = {
|
||||
...createTask('2', 'in_progress'),
|
||||
metadata: { _internal: true },
|
||||
};
|
||||
const dir = join(TASKS_DIR, 'test-team');
|
||||
mkdirSync(dir, { recursive: true });
|
||||
writeFileSync(join(dir, '2.json'), JSON.stringify(internalTask));
|
||||
|
||||
watcher.start();
|
||||
await new Promise(r => setTimeout(r, 100));
|
||||
|
||||
const tasks = watcher.getTeamTasks('test-team');
|
||||
expect(tasks).toHaveLength(1);
|
||||
expect(tasks[0].id).toBe('1');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Active teammates detection', () => {
|
||||
it('should detect active teammates when tasks exist', async () => {
|
||||
writeTeamConfig('test-team', createTeamConfig({ leadSessionId: 'session-abc' }));
|
||||
writeTask('test-team', createTask('1', 'in_progress', 'researcher'));
|
||||
|
||||
watcher.start();
|
||||
await new Promise(r => setTimeout(r, 100));
|
||||
|
||||
expect(watcher.hasActiveTeammates('session-abc')).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false when all tasks completed', async () => {
|
||||
writeTeamConfig('test-team', createTeamConfig({ leadSessionId: 'session-abc' }));
|
||||
writeTask('test-team', createTask('1', 'completed', 'researcher'));
|
||||
|
||||
watcher.start();
|
||||
await new Promise(r => setTimeout(r, 100));
|
||||
|
||||
expect(watcher.hasActiveTeammates('session-abc')).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false for unknown sessions', () => {
|
||||
expect(watcher.hasActiveTeammates('nonexistent')).toBe(false);
|
||||
});
|
||||
|
||||
it('should return correct teammate count', async () => {
|
||||
writeTeamConfig('test-team', createTeamConfig({ leadSessionId: 'session-abc' }));
|
||||
|
||||
watcher.start();
|
||||
await new Promise(r => setTimeout(r, 100));
|
||||
|
||||
expect(watcher.getActiveTeammateCount('session-abc')).toBe(2); // researcher + coder
|
||||
expect(watcher.getActiveTeammateCount('nonexistent')).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Inbox tracking', () => {
|
||||
it('should read inbox messages', async () => {
|
||||
writeTeamConfig('test-team', createTeamConfig());
|
||||
|
||||
const messages: InboxMessage[] = [
|
||||
{ from: 'team-lead', text: '{"type":"task_assignment"}', timestamp: new Date().toISOString(), read: false },
|
||||
];
|
||||
writeInbox('test-team', 'researcher', messages);
|
||||
|
||||
watcher.start();
|
||||
await new Promise(r => setTimeout(r, 100));
|
||||
|
||||
const inbox = watcher.getInboxMessages('test-team', 'researcher');
|
||||
expect(inbox).toHaveLength(1);
|
||||
expect(inbox[0].from).toBe('team-lead');
|
||||
});
|
||||
|
||||
it('should emit inboxMessage for new messages', async () => {
|
||||
writeTeamConfig('test-team', createTeamConfig());
|
||||
writeInbox('test-team', 'researcher', []);
|
||||
|
||||
watcher.start();
|
||||
await new Promise(r => setTimeout(r, 100));
|
||||
|
||||
const received = new Promise<{ teamName: string; member: string; message: InboxMessage }>(resolve => {
|
||||
watcher.on('inboxMessage', resolve);
|
||||
});
|
||||
|
||||
// Add a new message
|
||||
await new Promise(r => setTimeout(r, 50));
|
||||
const messages: InboxMessage[] = [
|
||||
{ from: 'team-lead', text: '{"type":"task_assignment"}', timestamp: new Date().toISOString(), read: false },
|
||||
];
|
||||
writeInbox('test-team', 'researcher', messages);
|
||||
|
||||
const event = await received;
|
||||
expect(event.teamName).toBe('test-team');
|
||||
expect(event.member).toBe('researcher');
|
||||
expect(event.message.from).toBe('team-lead');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Lock handling', () => {
|
||||
it('should skip locked config files', async () => {
|
||||
writeTeamConfig('test-team', createTeamConfig());
|
||||
// Create a lock directory before starting
|
||||
mkdirSync(join(TEAMS_DIR, 'test-team', 'config.json.lock'));
|
||||
|
||||
watcher.start();
|
||||
await new Promise(r => setTimeout(r, 100));
|
||||
|
||||
// Should not discover the team because it's locked
|
||||
expect(watcher.getTeams()).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should discover team after lock is released', async () => {
|
||||
// Start with locked config
|
||||
writeTeamConfig('test-team', createTeamConfig());
|
||||
mkdirSync(join(TEAMS_DIR, 'test-team', 'config.json.lock'));
|
||||
|
||||
watcher.start();
|
||||
await new Promise(r => setTimeout(r, 100));
|
||||
expect(watcher.getTeams()).toHaveLength(0);
|
||||
|
||||
// Stop, remove lock, restart (fresh mtime cache)
|
||||
watcher.stop();
|
||||
rmSync(join(TEAMS_DIR, 'test-team', 'config.json.lock'), { recursive: true });
|
||||
|
||||
watcher = new TeamWatcher(TEAMS_DIR, TASKS_DIR);
|
||||
watcher.start();
|
||||
await new Promise(r => setTimeout(r, 100));
|
||||
|
||||
expect(watcher.getTeams()).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Cleanup', () => {
|
||||
it('should clear all state on stop()', async () => {
|
||||
writeTeamConfig('test-team', createTeamConfig());
|
||||
writeTask('test-team', createTask('1', 'in_progress'));
|
||||
|
||||
watcher.start();
|
||||
await new Promise(r => setTimeout(r, 100));
|
||||
|
||||
expect(watcher.getTeams()).toHaveLength(1);
|
||||
|
||||
watcher.stop();
|
||||
|
||||
expect(watcher.getTeams()).toHaveLength(0);
|
||||
expect(watcher.getTeamTasks('test-team')).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user