claudedesk 4.1.1 → 4.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLAUDE.md +96 -0
- package/CONTRIBUTING.md +6 -0
- package/README.md +19 -1
- package/dist/main/agent-team-manager.js +500 -0
- package/dist/main/atlas-manager.js +812 -0
- package/dist/main/built-in-actions.js +22 -0
- package/dist/main/cli-manager.js +2 -0
- package/dist/main/index.js +33 -1
- package/dist/main/ipc-handlers.js +72 -1
- package/dist/main/session-manager.js +37 -0
- package/dist/main/settings-persistence.js +28 -0
- package/dist/renderer/assets/index-CR22a7j2.css +32 -0
- package/dist/renderer/assets/index-Dfce25tf.js +13821 -0
- package/dist/renderer/index.html +2 -2
- package/dist/shared/ipc-contract.js +39 -0
- package/dist/shared/message-parser.js +100 -0
- package/dist/shared/types/atlas-types.js +5 -0
- package/docs/AGENT_TEAMS.md +166 -0
- package/docs/QUICKSTART_AGENT_TEAMS.md +69 -0
- package/docs/REPO_ATLAS_EVALUATION.md +228 -0
- package/docs/atlas-ui-prototype.html +1377 -0
- package/docs/repo-index.md +150 -0
- package/package.json +2 -1
- package/src/main/agent-team-manager.ts +512 -0
- package/src/main/atlas-manager.ts +917 -0
- package/src/main/built-in-actions.ts +22 -0
- package/src/main/cli-manager.ts +2 -0
- package/src/main/index.ts +43 -1
- package/src/main/ipc-handlers.ts +61 -1
- package/src/main/session-manager.ts +35 -0
- package/src/main/settings-persistence.ts +32 -0
- package/src/renderer/App.tsx +59 -0
- package/src/renderer/components/AgentGraph.tsx +253 -0
- package/src/renderer/components/AtlasPanel.css +473 -0
- package/src/renderer/components/AtlasPanel.tsx +338 -0
- package/src/renderer/components/MessageStream.tsx +265 -0
- package/src/renderer/components/TaskBoard.tsx +310 -0
- package/src/renderer/components/TeamPanel.tsx +540 -0
- package/src/renderer/components/ui/SettingsDialog.tsx +421 -18
- package/src/renderer/components/ui/TabBar.tsx +112 -0
- package/src/renderer/hooks/index.ts +1 -0
- package/src/renderer/hooks/useAgentTeams.ts +103 -0
- package/src/renderer/hooks/useAtlas.ts +126 -0
- package/src/renderer/hooks/useAutoTeamLayout.ts +56 -0
- package/src/renderer/hooks/useMessageStream.ts +57 -0
- package/src/shared/ipc-contract.ts +86 -0
- package/src/shared/ipc-types.ts +74 -0
- package/src/shared/message-parser.ts +119 -0
- package/src/shared/types/atlas-types.ts +148 -0
- package/dist/renderer/assets/index-B6x9OLWJ.css +0 -32
- package/dist/renderer/assets/index-VvIMscbd.js +0 -12863
package/CLAUDE.md
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
# ClaudeDesk
|
|
2
|
+
|
|
3
|
+
Electron desktop app wrapping Claude Code CLI with multi-session terminals, split views, and agent team visualization.
|
|
4
|
+
|
|
5
|
+
## Tech Stack
|
|
6
|
+
|
|
7
|
+
Electron 28 | React 18 | TypeScript | xterm.js | node-pty | Tailwind CSS | reactflow
|
|
8
|
+
|
|
9
|
+
## Architecture
|
|
10
|
+
|
|
11
|
+
```
|
|
12
|
+
┌─────────────────────────────────────────────┐
|
|
13
|
+
│ Main Process (Node.js) │
|
|
14
|
+
│ 8 managers + IPC handlers + session pool │
|
|
15
|
+
└──────────────────┬──────────────────────────┘
|
|
16
|
+
│ IPC (80 methods)
|
|
17
|
+
┌──────────────────┴──────────────────────────┐
|
|
18
|
+
│ Preload (auto-derived context bridge) │
|
|
19
|
+
└──────────────────┬──────────────────────────┘
|
|
20
|
+
│
|
|
21
|
+
┌──────────────────┴──────────────────────────┐
|
|
22
|
+
│ Renderer (React 18) │
|
|
23
|
+
│ Hooks → Components → UI │
|
|
24
|
+
└─────────────────────────────────────────────┘
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
**3-layer pattern per domain:** Manager (main) → Hook (renderer) → Components (renderer)
|
|
28
|
+
|
|
29
|
+
**IPC contract** (`src/shared/ipc-contract.ts`) is the single source of truth — 80 methods. The preload bridge and `ElectronAPI` type are auto-derived from it.
|
|
30
|
+
|
|
31
|
+
## Domain Map
|
|
32
|
+
|
|
33
|
+
| Domain | Main (manager) | Renderer (hook + UI) | Shared types | IPC prefix |
|
|
34
|
+
|--------|---------------|---------------------|-------------|------------|
|
|
35
|
+
| Sessions | session-manager, cli-manager, session-pool, session-persistence | useSessionManager, Terminal | ipc-types.ts | `session:*` |
|
|
36
|
+
| Split View | settings-persistence | useSplitView, SplitLayout, PaneHeader, PaneSessionPicker | ipc-types.ts | `settings:*` |
|
|
37
|
+
| Agent Teams | agent-team-manager | useAgentTeams, useAutoTeamLayout, useMessageStream, TeamPanel, TaskBoard, MessageStream, AgentGraph | ipc-types.ts, message-parser.ts | `teams:*` |
|
|
38
|
+
| Templates | prompt-templates-manager, built-in-actions | useCommandPalette, CommandPalette, TemplateEditor | types/prompt-templates.ts | `template:*` |
|
|
39
|
+
| History | history-manager | useHistory, HistoryPanel | types/history-types.ts | `history:*` |
|
|
40
|
+
| Checkpoints | checkpoint-manager, checkpoint-persistence | useCheckpoints, CheckpointPanel, CheckpointDialog | types/checkpoint-types.ts | `checkpoint:*` |
|
|
41
|
+
| Quota | quota-service | useQuota, BudgetPanel, BudgetSettings | ipc-types.ts | `quota:*`, `burnRate:*` |
|
|
42
|
+
| Drag-Drop | file-dragdrop-handler, file-utils | useDragDrop, DragDropOverlay, DragDropContextMenu, DragDropSettings | ipc-types.ts | `dragdrop:*` |
|
|
43
|
+
| Workspaces | settings-persistence | SettingsDialog | ipc-types.ts | `workspace:*` |
|
|
44
|
+
| Atlas | atlas-manager | useAtlas, AtlasPanel | types/atlas-types.ts | `atlas:*` |
|
|
45
|
+
| Window | index.ts | ConfirmDialog, SettingsDialog, AboutDialog, TitleBarBranding | ipc-types.ts | `window:*`, `dialog:*` |
|
|
46
|
+
|
|
47
|
+
## Adding a New IPC Method
|
|
48
|
+
|
|
49
|
+
1. Add entry to `src/shared/ipc-contract.ts` (in `IPCContractMap`)
|
|
50
|
+
2. Add handler in `src/main/ipc-handlers.ts` using `registry.handle()` / `registry.on()`
|
|
51
|
+
|
|
52
|
+
That's it. The preload bridge and types auto-derive.
|
|
53
|
+
|
|
54
|
+
## Adding a New Domain
|
|
55
|
+
|
|
56
|
+
1. Create `src/main/<domain>-manager.ts`
|
|
57
|
+
2. Create `src/renderer/hooks/use<Domain>.ts`
|
|
58
|
+
3. Create component(s) in `src/renderer/components/`
|
|
59
|
+
4. Add IPC methods to `ipc-contract.ts` with `<domain>:*` prefix
|
|
60
|
+
5. Wire manager in `src/main/index.ts` (import, instantiate, pass to `setupIPCHandlers`)
|
|
61
|
+
6. Update `docs/repo-index.md`
|
|
62
|
+
|
|
63
|
+
## Critical Implementation Patterns
|
|
64
|
+
|
|
65
|
+
- **Directory locking**: Shell overrides in `cli-manager.ts:64-100` prevent `cd` in sessions. PowerShell needs `\r` line endings, paths need `\\` escaping.
|
|
66
|
+
- **Output buffering**: 16ms batches in `CLIManager` prevent IPC flooding (~60fps).
|
|
67
|
+
- **Claude readiness**: Pattern detection ("Claude Code", "Sonnet", "Tips for getting started") + 5s fallback timeout in `Terminal.tsx`.
|
|
68
|
+
- **Ctrl+C interception**: Caught in `terminal.onData()`, shows `ConfirmDialog`. Never forward `\x03` to Claude (it exits).
|
|
69
|
+
- **Session pool**: Pre-warmed shells in `session-pool.ts` for faster session creation. Delayed init (2.5s after app start).
|
|
70
|
+
- **IPC contract**: One entry in `IPCContractMap` = auto-derived channel, kind, preload bridge method, and TypeScript type. No manual wiring needed.
|
|
71
|
+
- **Split view**: `useSplitView` manages a tree of leaf/branch nodes. Max 4 panes. State persisted in settings.
|
|
72
|
+
- **Agent team detection**: `AgentTeamManager` watches `~/.claude/teams/` and `~/.claude/tasks/` via `fs.watch()`. Auto-links sessions within 30s of team creation.
|
|
73
|
+
|
|
74
|
+
## Pitfalls
|
|
75
|
+
|
|
76
|
+
- PowerShell needs `\r` not `\n` line endings (`.replace(/\n/g, '\r')`)
|
|
77
|
+
- Windows paths need `.replace(/\\/g, '\\\\')`
|
|
78
|
+
- Never send Ctrl+C (`\x03`) to Claude — it exits immediately
|
|
79
|
+
- Never use React hooks inside callbacks (caused SplitLayout crash — see `useSplitView.ts`)
|
|
80
|
+
- Always batch PTY output (16ms `FLUSH_INTERVAL` in CLIManager)
|
|
81
|
+
- `transformTree` in `useSplitView.ts` must recurse children BEFORE applying transformation (infinite recursion otherwise)
|
|
82
|
+
- Reuse existing UI components from `components/ui/` for consistency
|
|
83
|
+
- No global state library — React hooks only
|
|
84
|
+
|
|
85
|
+
## Design Language
|
|
86
|
+
|
|
87
|
+
Dark theme (Tokyo Night): bg `#1a1b26`, accent `#7aa2f7`, danger `#f7768e`, border `#292e42`
|
|
88
|
+
Font: JetBrains Mono. Monospace everywhere.
|
|
89
|
+
|
|
90
|
+
## Docs
|
|
91
|
+
|
|
92
|
+
- [Repo Index](docs/repo-index.md) — detailed domain-to-file mapping
|
|
93
|
+
- [Agent Teams Guide](docs/AGENT_TEAMS.md)
|
|
94
|
+
- [Quick Start: Agent Teams](docs/QUICKSTART_AGENT_TEAMS.md)
|
|
95
|
+
- [Contributing](CONTRIBUTING.md)
|
|
96
|
+
- [Repository Atlas Evaluation](docs/REPO_ATLAS_EVALUATION.md)
|
package/CONTRIBUTING.md
CHANGED
|
@@ -284,6 +284,12 @@ describe('fuzzySearch', () => {
|
|
|
284
284
|
|
|
285
285
|
5. **Ensure CI passes** - Once CI is set up, all checks must pass
|
|
286
286
|
|
|
287
|
+
### Keeping the Atlas Current
|
|
288
|
+
|
|
289
|
+
If your PR adds, removes, or renames source files:
|
|
290
|
+
- Update `docs/repo-index.md` with the new file(s)
|
|
291
|
+
- If a new domain is introduced, add it to the Domain Map in `CLAUDE.md`
|
|
292
|
+
|
|
287
293
|
### PR Title Format
|
|
288
294
|
|
|
289
295
|
Use conventional commit style:
|
package/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|

|
|
4
4
|

|
|
5
|
-

|
|
6
6
|
|
|
7
7
|
> A powerful desktop terminal for Claude Code CLI with multi-session management, split-view layouts, and advanced productivity features.
|
|
8
8
|
|
|
@@ -66,6 +66,24 @@ _Screenshots coming soon! For now, see [Features](#-features) for a detailed ove
|
|
|
66
66
|
- **Custom theme** - Tokyo Night inspired dark theme
|
|
67
67
|
- **Monospace font** - JetBrains Mono for optimal readability
|
|
68
68
|
|
|
69
|
+
### Agent Teams
|
|
70
|
+
- **Automatic team detection** - recursively monitors `~/.claude/teams/` directories for agent team activity
|
|
71
|
+
- **Team Panel** - sidebar showing team hierarchy, members with color badges, and status
|
|
72
|
+
- **Task Board** - Kanban-style visualization with per-team task directories
|
|
73
|
+
- **Message Stream** - real-time inter-agent communication feed (parses `@agent>` messages, strips ANSI codes)
|
|
74
|
+
- **Agent Graph** - interactive node-based relationship visualization
|
|
75
|
+
- **Auto-layout** - automatically arranges panes when teammates join
|
|
76
|
+
- **Lifecycle management** - stale teams auto-cleaned on startup; teams removed when sessions end
|
|
77
|
+
- See [Agent Teams Guide](docs/AGENT_TEAMS.md) and [Quick Start](docs/QUICKSTART_AGENT_TEAMS.md)
|
|
78
|
+
|
|
79
|
+
### Repository Atlas Engine
|
|
80
|
+
- **Automated codebase mapping** - scans files, analyzes imports, infers domain boundaries
|
|
81
|
+
- **CLAUDE.md generation** - creates architectural atlas for AI tools to navigate the repo
|
|
82
|
+
- **Domain-to-file index** - generates `docs/repo-index.md` with per-domain file tables
|
|
83
|
+
- **Inline entrypoint tags** - suggests `@atlas-entrypoint` comments for key files
|
|
84
|
+
- **Preview and approve** - review generated content before writing to disk
|
|
85
|
+
- **Configurable settings** - domain sensitivity, max inline tags, exclude patterns
|
|
86
|
+
|
|
69
87
|
### Session Control
|
|
70
88
|
- **Permission modes** - control Claude's access level per session
|
|
71
89
|
- **Ctrl+C handling** - graceful session termination with confirmation
|
|
@@ -0,0 +1,500 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
+
}) : function(o, v) {
|
|
16
|
+
o["default"] = v;
|
|
17
|
+
});
|
|
18
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
19
|
+
var ownKeys = function(o) {
|
|
20
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
21
|
+
var ar = [];
|
|
22
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
23
|
+
return ar;
|
|
24
|
+
};
|
|
25
|
+
return ownKeys(o);
|
|
26
|
+
};
|
|
27
|
+
return function (mod) {
|
|
28
|
+
if (mod && mod.__esModule) return mod;
|
|
29
|
+
var result = {};
|
|
30
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
31
|
+
__setModuleDefault(result, mod);
|
|
32
|
+
return result;
|
|
33
|
+
};
|
|
34
|
+
})();
|
|
35
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
+
exports.AgentTeamManager = void 0;
|
|
37
|
+
const fs = __importStar(require("fs"));
|
|
38
|
+
const path = __importStar(require("path"));
|
|
39
|
+
const electron_1 = require("electron");
|
|
40
|
+
const ipc_emitter_1 = require("./ipc-emitter");
|
|
41
|
+
const CLAUDE_DIR = path.join(electron_1.app.getPath('home'), '.claude');
|
|
42
|
+
const TEAMS_DIR = path.join(CLAUDE_DIR, 'teams');
|
|
43
|
+
const TASKS_DIR = path.join(CLAUDE_DIR, 'tasks');
|
|
44
|
+
const DEBOUNCE_MS = 200;
|
|
45
|
+
const WATCHER_RETRY_COUNT = 3;
|
|
46
|
+
const WATCHER_RETRY_DELAY_MS = 2000;
|
|
47
|
+
const AUTO_LINK_WINDOW_MS = 30000; // 30 seconds
|
|
48
|
+
const STALE_TEAM_THRESHOLD_MS = 5 * 60 * 1000; // 5 minutes — skip teams older than this on startup
|
|
49
|
+
/**
|
|
50
|
+
* Map real Claude Code agent types to our internal types.
|
|
51
|
+
* Real values: "team-lead", "general-purpose"
|
|
52
|
+
* Our values: "lead", "teammate"
|
|
53
|
+
*/
|
|
54
|
+
function normalizeAgentType(raw) {
|
|
55
|
+
if (raw === 'team-lead')
|
|
56
|
+
return 'lead';
|
|
57
|
+
return 'teammate';
|
|
58
|
+
}
|
|
59
|
+
class AgentTeamManager {
|
|
60
|
+
constructor() {
|
|
61
|
+
this.teams = new Map();
|
|
62
|
+
this.sessionTeamMap = new Map(); // sessionId → teamName
|
|
63
|
+
this.teamsWatcher = null;
|
|
64
|
+
this.tasksWatcher = null;
|
|
65
|
+
this.debounceTimers = new Map();
|
|
66
|
+
this.emitter = null;
|
|
67
|
+
this.getSessionsFn = null;
|
|
68
|
+
this.updateSessionMetadataFn = null;
|
|
69
|
+
this.closeSessionFn = null;
|
|
70
|
+
}
|
|
71
|
+
setMainWindow(window) {
|
|
72
|
+
this.emitter = new ipc_emitter_1.IPCEmitter(window);
|
|
73
|
+
}
|
|
74
|
+
setSessionAccessors(getSessions, updateMetadata, closeSession) {
|
|
75
|
+
this.getSessionsFn = getSessions;
|
|
76
|
+
this.updateSessionMetadataFn = updateMetadata;
|
|
77
|
+
this.closeSessionFn = closeSession;
|
|
78
|
+
}
|
|
79
|
+
async initialize() {
|
|
80
|
+
// Ensure directories exist
|
|
81
|
+
this.ensureDir(TEAMS_DIR);
|
|
82
|
+
this.ensureDir(TASKS_DIR);
|
|
83
|
+
// Scan existing directories
|
|
84
|
+
await this.scanTeams();
|
|
85
|
+
await this.scanTasks();
|
|
86
|
+
// Start watchers
|
|
87
|
+
this.startTeamsWatcher();
|
|
88
|
+
this.startTasksWatcher();
|
|
89
|
+
}
|
|
90
|
+
destroy() {
|
|
91
|
+
if (this.teamsWatcher) {
|
|
92
|
+
this.teamsWatcher.close();
|
|
93
|
+
this.teamsWatcher = null;
|
|
94
|
+
}
|
|
95
|
+
if (this.tasksWatcher) {
|
|
96
|
+
this.tasksWatcher.close();
|
|
97
|
+
this.tasksWatcher = null;
|
|
98
|
+
}
|
|
99
|
+
for (const timer of this.debounceTimers.values()) {
|
|
100
|
+
clearTimeout(timer);
|
|
101
|
+
}
|
|
102
|
+
this.debounceTimers.clear();
|
|
103
|
+
this.teams.clear();
|
|
104
|
+
this.sessionTeamMap.clear();
|
|
105
|
+
}
|
|
106
|
+
// ── Public API ──
|
|
107
|
+
getTeams() {
|
|
108
|
+
return Array.from(this.teams.values());
|
|
109
|
+
}
|
|
110
|
+
getTeamForSession(sessionId) {
|
|
111
|
+
const teamName = this.sessionTeamMap.get(sessionId);
|
|
112
|
+
if (!teamName)
|
|
113
|
+
return null;
|
|
114
|
+
return this.teams.get(teamName) || null;
|
|
115
|
+
}
|
|
116
|
+
getTeamSessions(teamName) {
|
|
117
|
+
if (!this.getSessionsFn)
|
|
118
|
+
return [];
|
|
119
|
+
const sessions = this.getSessionsFn();
|
|
120
|
+
return sessions.filter(s => s.teamName === teamName);
|
|
121
|
+
}
|
|
122
|
+
linkSessionToTeam(sessionId, teamName, agentId) {
|
|
123
|
+
const team = this.teams.get(teamName);
|
|
124
|
+
if (!team)
|
|
125
|
+
return false;
|
|
126
|
+
const member = team.members.find(m => m.agentId === agentId);
|
|
127
|
+
if (!member)
|
|
128
|
+
return false;
|
|
129
|
+
this.sessionTeamMap.set(sessionId, teamName);
|
|
130
|
+
if (this.updateSessionMetadataFn) {
|
|
131
|
+
this.updateSessionMetadataFn(sessionId, {
|
|
132
|
+
teamName,
|
|
133
|
+
agentId,
|
|
134
|
+
agentType: member.agentType,
|
|
135
|
+
isTeammate: member.agentType === 'teammate',
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
return true;
|
|
139
|
+
}
|
|
140
|
+
unlinkSessionFromTeam(sessionId) {
|
|
141
|
+
const hadTeam = this.sessionTeamMap.has(sessionId);
|
|
142
|
+
this.sessionTeamMap.delete(sessionId);
|
|
143
|
+
if (this.updateSessionMetadataFn) {
|
|
144
|
+
this.updateSessionMetadataFn(sessionId, {
|
|
145
|
+
teamName: undefined,
|
|
146
|
+
agentId: undefined,
|
|
147
|
+
agentType: undefined,
|
|
148
|
+
isTeammate: undefined,
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
return hadTeam;
|
|
152
|
+
}
|
|
153
|
+
async closeTeam(teamName) {
|
|
154
|
+
const team = this.teams.get(teamName);
|
|
155
|
+
if (!team || !this.closeSessionFn || !this.getSessionsFn)
|
|
156
|
+
return false;
|
|
157
|
+
const sessions = this.getSessionsFn().filter(s => s.teamName === teamName);
|
|
158
|
+
for (const session of sessions) {
|
|
159
|
+
await this.closeSessionFn(session.id);
|
|
160
|
+
this.sessionTeamMap.delete(session.id);
|
|
161
|
+
}
|
|
162
|
+
return true;
|
|
163
|
+
}
|
|
164
|
+
/**
|
|
165
|
+
* Called when a session is closed or exits.
|
|
166
|
+
* Removes the session→team mapping and cleans up the team
|
|
167
|
+
* if it has no remaining linked sessions.
|
|
168
|
+
*/
|
|
169
|
+
onSessionClosed(sessionId) {
|
|
170
|
+
const teamName = this.sessionTeamMap.get(sessionId);
|
|
171
|
+
if (!teamName)
|
|
172
|
+
return;
|
|
173
|
+
this.sessionTeamMap.delete(sessionId);
|
|
174
|
+
// Check if any sessions still belong to this team
|
|
175
|
+
const hasRemaining = Array.from(this.sessionTeamMap.values()).some(t => t === teamName);
|
|
176
|
+
if (!hasRemaining) {
|
|
177
|
+
// No more sessions for this team — remove it from memory
|
|
178
|
+
this.teams.delete(teamName);
|
|
179
|
+
this.emitter?.emit('onTeamRemoved', { teamName });
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
// ── Auto-linking ──
|
|
183
|
+
autoLinkSessions(teamName) {
|
|
184
|
+
if (!this.getSessionsFn || !this.updateSessionMetadataFn)
|
|
185
|
+
return;
|
|
186
|
+
const team = this.teams.get(teamName);
|
|
187
|
+
if (!team)
|
|
188
|
+
return;
|
|
189
|
+
const now = Date.now();
|
|
190
|
+
const sessions = this.getSessionsFn();
|
|
191
|
+
// Find sessions created recently that haven't been linked
|
|
192
|
+
const recentUnlinked = sessions
|
|
193
|
+
.filter(s => !s.teamName && (now - s.createdAt) < AUTO_LINK_WINDOW_MS)
|
|
194
|
+
.sort((a, b) => a.createdAt - b.createdAt); // oldest first
|
|
195
|
+
if (recentUnlinked.length === 0)
|
|
196
|
+
return;
|
|
197
|
+
// Find the lead member
|
|
198
|
+
const leadMember = team.members.find(m => m.agentType === 'lead');
|
|
199
|
+
if (!leadMember)
|
|
200
|
+
return;
|
|
201
|
+
// Link the oldest recent session as lead
|
|
202
|
+
const leadSession = recentUnlinked.find(s => s.status === 'running') || recentUnlinked[0];
|
|
203
|
+
if (leadSession) {
|
|
204
|
+
this.linkSessionToTeam(leadSession.id, teamName, leadMember.agentId);
|
|
205
|
+
team.leadSessionId = leadSession.id;
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
// ── Directory & File Helpers ──
|
|
209
|
+
ensureDir(dir) {
|
|
210
|
+
try {
|
|
211
|
+
if (!fs.existsSync(dir)) {
|
|
212
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
catch (err) {
|
|
216
|
+
console.error(`Failed to create directory ${dir}:`, err);
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
/**
|
|
220
|
+
* Scan ~/.claude/teams/ for team directories.
|
|
221
|
+
* Each team is a directory containing config.json.
|
|
222
|
+
* On startup, skip stale teams (config not modified recently).
|
|
223
|
+
*/
|
|
224
|
+
async scanTeams() {
|
|
225
|
+
try {
|
|
226
|
+
const entries = fs.readdirSync(TEAMS_DIR, { withFileTypes: true });
|
|
227
|
+
const now = Date.now();
|
|
228
|
+
for (const entry of entries) {
|
|
229
|
+
if (!entry.isDirectory())
|
|
230
|
+
continue;
|
|
231
|
+
// Check config.json freshness — skip stale teams from previous runs
|
|
232
|
+
const configPath = path.join(TEAMS_DIR, entry.name, 'config.json');
|
|
233
|
+
try {
|
|
234
|
+
const stat = fs.statSync(configPath);
|
|
235
|
+
if (now - stat.mtimeMs > STALE_TEAM_THRESHOLD_MS) {
|
|
236
|
+
continue; // Stale team from a previous session
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
catch {
|
|
240
|
+
continue; // No config.json
|
|
241
|
+
}
|
|
242
|
+
this.loadTeamConfig(entry.name);
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
catch (err) {
|
|
246
|
+
console.error('Failed to scan teams directory:', err);
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
/**
|
|
250
|
+
* Load a team's config from ~/.claude/teams/<teamName>/config.json
|
|
251
|
+
*/
|
|
252
|
+
loadTeamConfig(teamName) {
|
|
253
|
+
try {
|
|
254
|
+
const configPath = path.join(TEAMS_DIR, teamName, 'config.json');
|
|
255
|
+
if (!fs.existsSync(configPath))
|
|
256
|
+
return;
|
|
257
|
+
const content = fs.readFileSync(configPath, 'utf-8');
|
|
258
|
+
const config = JSON.parse(content);
|
|
259
|
+
if (!config.members || !Array.isArray(config.members))
|
|
260
|
+
return;
|
|
261
|
+
const existing = this.teams.get(teamName);
|
|
262
|
+
const members = config.members.map((m) => ({
|
|
263
|
+
name: m.name || m.agentId || 'Unknown',
|
|
264
|
+
agentId: m.agentId || m.name || 'unknown',
|
|
265
|
+
agentType: normalizeAgentType(m.agentType || ''),
|
|
266
|
+
color: m.color,
|
|
267
|
+
model: m.model,
|
|
268
|
+
}));
|
|
269
|
+
const team = {
|
|
270
|
+
name: teamName,
|
|
271
|
+
description: config.description,
|
|
272
|
+
leadSessionId: config.leadSessionId || existing?.leadSessionId,
|
|
273
|
+
members,
|
|
274
|
+
tasks: existing?.tasks || [],
|
|
275
|
+
createdAt: existing?.createdAt || Date.now(),
|
|
276
|
+
updatedAt: Date.now(),
|
|
277
|
+
};
|
|
278
|
+
const isNew = !existing;
|
|
279
|
+
this.teams.set(teamName, team);
|
|
280
|
+
if (isNew) {
|
|
281
|
+
this.emitter?.emit('onTeamDetected', team);
|
|
282
|
+
// Try auto-linking
|
|
283
|
+
this.autoLinkSessions(teamName);
|
|
284
|
+
}
|
|
285
|
+
// Check for new teammates
|
|
286
|
+
if (existing) {
|
|
287
|
+
const existingIds = new Set(existing.members.map(m => m.agentId));
|
|
288
|
+
for (const member of team.members) {
|
|
289
|
+
if (!existingIds.has(member.agentId)) {
|
|
290
|
+
this.emitter?.emit('onTeammateAdded', {
|
|
291
|
+
teamName,
|
|
292
|
+
member,
|
|
293
|
+
});
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
catch (err) {
|
|
299
|
+
// Skip malformed configs silently
|
|
300
|
+
console.warn(`Failed to parse team config for ${teamName}:`, err);
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
/**
|
|
304
|
+
* Scan ~/.claude/tasks/ for per-team task directories.
|
|
305
|
+
*/
|
|
306
|
+
async scanTasks() {
|
|
307
|
+
try {
|
|
308
|
+
const entries = fs.readdirSync(TASKS_DIR, { withFileTypes: true });
|
|
309
|
+
for (const entry of entries) {
|
|
310
|
+
if (entry.isDirectory()) {
|
|
311
|
+
this.loadTeamTasks(entry.name);
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
catch (err) {
|
|
316
|
+
console.error('Failed to scan tasks directory:', err);
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
/**
|
|
320
|
+
* Load all task files from ~/.claude/tasks/<teamName>/*.json
|
|
321
|
+
* Skips .lock files.
|
|
322
|
+
*/
|
|
323
|
+
loadTeamTasks(teamName) {
|
|
324
|
+
try {
|
|
325
|
+
const taskDir = path.join(TASKS_DIR, teamName);
|
|
326
|
+
if (!fs.existsSync(taskDir))
|
|
327
|
+
return;
|
|
328
|
+
const files = fs.readdirSync(taskDir);
|
|
329
|
+
const tasks = [];
|
|
330
|
+
for (const file of files) {
|
|
331
|
+
// Skip non-JSON and lock files
|
|
332
|
+
if (!file.endsWith('.json') || file === '.lock')
|
|
333
|
+
continue;
|
|
334
|
+
try {
|
|
335
|
+
const filePath = path.join(taskDir, file);
|
|
336
|
+
const content = fs.readFileSync(filePath, 'utf-8');
|
|
337
|
+
const data = JSON.parse(content);
|
|
338
|
+
const task = this.normalizeTask(data);
|
|
339
|
+
if (task)
|
|
340
|
+
tasks.push(task);
|
|
341
|
+
}
|
|
342
|
+
catch (err) {
|
|
343
|
+
// Skip individual malformed task files
|
|
344
|
+
console.warn(`Failed to parse task file ${teamName}/${file}:`, err);
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
// Find the matching team (or any team if name doesn't match directly)
|
|
348
|
+
let team = this.teams.get(teamName);
|
|
349
|
+
if (!team) {
|
|
350
|
+
// Try to find team by checking task owners against team members
|
|
351
|
+
for (const [, t] of this.teams) {
|
|
352
|
+
const memberIds = new Set(t.members.map(m => m.agentId));
|
|
353
|
+
if (tasks.some(task => task.owner && memberIds.has(task.owner))) {
|
|
354
|
+
team = t;
|
|
355
|
+
break;
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
if (team && tasks.length > 0) {
|
|
360
|
+
team.tasks = tasks;
|
|
361
|
+
team.updatedAt = Date.now();
|
|
362
|
+
this.emitter?.emit('onTasksUpdated', { teamName: team.name, tasks });
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
catch (err) {
|
|
366
|
+
console.warn(`Failed to load tasks for team ${teamName}:`, err);
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
normalizeTask(data) {
|
|
370
|
+
if (!data || typeof data !== 'object')
|
|
371
|
+
return null;
|
|
372
|
+
if (!data.id && !data.taskId && !data.subject)
|
|
373
|
+
return null;
|
|
374
|
+
return {
|
|
375
|
+
taskId: String(data.id || data.taskId || `task-${Date.now()}`),
|
|
376
|
+
subject: String(data.subject || data.title || 'Untitled'),
|
|
377
|
+
description: String(data.description || ''),
|
|
378
|
+
status: this.normalizeTaskStatus(data.status),
|
|
379
|
+
owner: data.owner ? String(data.owner) : undefined,
|
|
380
|
+
blockedBy: Array.isArray(data.blockedBy) ? data.blockedBy.map(String) : undefined,
|
|
381
|
+
blocks: Array.isArray(data.blocks) ? data.blocks.map(String) : undefined,
|
|
382
|
+
};
|
|
383
|
+
}
|
|
384
|
+
normalizeTaskStatus(status) {
|
|
385
|
+
const s = String(status || '').toLowerCase();
|
|
386
|
+
if (s === 'in_progress' || s === 'in-progress' || s === 'active' || s === 'running')
|
|
387
|
+
return 'in_progress';
|
|
388
|
+
if (s === 'completed' || s === 'done' || s === 'finished')
|
|
389
|
+
return 'completed';
|
|
390
|
+
return 'pending';
|
|
391
|
+
}
|
|
392
|
+
// ── File Watchers ──
|
|
393
|
+
/**
|
|
394
|
+
* Watch ~/.claude/teams/ recursively.
|
|
395
|
+
* Parses relative paths to determine what changed:
|
|
396
|
+
* <team-name>/config.json → reload team config
|
|
397
|
+
* new directory at top level → check for config.json
|
|
398
|
+
* team directory deleted → remove team
|
|
399
|
+
*/
|
|
400
|
+
startTeamsWatcher(retryCount = 0) {
|
|
401
|
+
try {
|
|
402
|
+
this.teamsWatcher = fs.watch(TEAMS_DIR, { recursive: true }, (_eventType, filename) => {
|
|
403
|
+
if (!filename)
|
|
404
|
+
return;
|
|
405
|
+
// Normalize path separators (Windows uses backslashes)
|
|
406
|
+
const normalized = filename.replace(/\\/g, '/');
|
|
407
|
+
const parts = normalized.split('/');
|
|
408
|
+
if (parts.length === 0)
|
|
409
|
+
return;
|
|
410
|
+
const teamName = parts[0];
|
|
411
|
+
this.debounce(`team:${teamName}`, () => {
|
|
412
|
+
const teamDir = path.join(TEAMS_DIR, teamName);
|
|
413
|
+
if (!fs.existsSync(teamDir)) {
|
|
414
|
+
// Team directory was deleted
|
|
415
|
+
if (this.teams.has(teamName)) {
|
|
416
|
+
this.teams.delete(teamName);
|
|
417
|
+
this.emitter?.emit('onTeamRemoved', { teamName });
|
|
418
|
+
}
|
|
419
|
+
return;
|
|
420
|
+
}
|
|
421
|
+
// Check if it's a directory (new or updated team)
|
|
422
|
+
try {
|
|
423
|
+
const stat = fs.statSync(teamDir);
|
|
424
|
+
if (stat.isDirectory()) {
|
|
425
|
+
this.loadTeamConfig(teamName);
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
catch {
|
|
429
|
+
// Stat failed, directory may have been removed between check and stat
|
|
430
|
+
}
|
|
431
|
+
});
|
|
432
|
+
});
|
|
433
|
+
this.teamsWatcher.on('error', (err) => {
|
|
434
|
+
console.error('Teams watcher error:', err);
|
|
435
|
+
this.teamsWatcher?.close();
|
|
436
|
+
this.teamsWatcher = null;
|
|
437
|
+
if (retryCount < WATCHER_RETRY_COUNT) {
|
|
438
|
+
setTimeout(() => this.startTeamsWatcher(retryCount + 1), WATCHER_RETRY_DELAY_MS);
|
|
439
|
+
}
|
|
440
|
+
});
|
|
441
|
+
}
|
|
442
|
+
catch (err) {
|
|
443
|
+
console.error('Failed to start teams watcher:', err);
|
|
444
|
+
if (retryCount < WATCHER_RETRY_COUNT) {
|
|
445
|
+
setTimeout(() => this.startTeamsWatcher(retryCount + 1), WATCHER_RETRY_DELAY_MS);
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
/**
|
|
450
|
+
* Watch ~/.claude/tasks/ recursively.
|
|
451
|
+
* Parses relative paths: <team-name>/<id>.json → reload that team's tasks.
|
|
452
|
+
* Skips .lock files.
|
|
453
|
+
*/
|
|
454
|
+
startTasksWatcher(retryCount = 0) {
|
|
455
|
+
try {
|
|
456
|
+
this.tasksWatcher = fs.watch(TASKS_DIR, { recursive: true }, (_eventType, filename) => {
|
|
457
|
+
if (!filename)
|
|
458
|
+
return;
|
|
459
|
+
// Normalize path separators
|
|
460
|
+
const normalized = filename.replace(/\\/g, '/');
|
|
461
|
+
const parts = normalized.split('/');
|
|
462
|
+
// We expect <team-name>/<file>.json
|
|
463
|
+
if (parts.length < 2)
|
|
464
|
+
return;
|
|
465
|
+
const teamName = parts[0];
|
|
466
|
+
const file = parts[parts.length - 1];
|
|
467
|
+
// Skip non-JSON and lock files
|
|
468
|
+
if (!file.endsWith('.json') || file === '.lock')
|
|
469
|
+
return;
|
|
470
|
+
this.debounce(`tasks:${teamName}`, () => {
|
|
471
|
+
this.loadTeamTasks(teamName);
|
|
472
|
+
});
|
|
473
|
+
});
|
|
474
|
+
this.tasksWatcher.on('error', (err) => {
|
|
475
|
+
console.error('Tasks watcher error:', err);
|
|
476
|
+
this.tasksWatcher?.close();
|
|
477
|
+
this.tasksWatcher = null;
|
|
478
|
+
if (retryCount < WATCHER_RETRY_COUNT) {
|
|
479
|
+
setTimeout(() => this.startTasksWatcher(retryCount + 1), WATCHER_RETRY_DELAY_MS);
|
|
480
|
+
}
|
|
481
|
+
});
|
|
482
|
+
}
|
|
483
|
+
catch (err) {
|
|
484
|
+
console.error('Failed to start tasks watcher:', err);
|
|
485
|
+
if (retryCount < WATCHER_RETRY_COUNT) {
|
|
486
|
+
setTimeout(() => this.startTasksWatcher(retryCount + 1), WATCHER_RETRY_DELAY_MS);
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
debounce(key, fn) {
|
|
491
|
+
const existing = this.debounceTimers.get(key);
|
|
492
|
+
if (existing)
|
|
493
|
+
clearTimeout(existing);
|
|
494
|
+
this.debounceTimers.set(key, setTimeout(() => {
|
|
495
|
+
this.debounceTimers.delete(key);
|
|
496
|
+
fn();
|
|
497
|
+
}, DEBOUNCE_MS));
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
exports.AgentTeamManager = AgentTeamManager;
|