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.
Files changed (51) hide show
  1. package/CLAUDE.md +96 -0
  2. package/CONTRIBUTING.md +6 -0
  3. package/README.md +19 -1
  4. package/dist/main/agent-team-manager.js +500 -0
  5. package/dist/main/atlas-manager.js +812 -0
  6. package/dist/main/built-in-actions.js +22 -0
  7. package/dist/main/cli-manager.js +2 -0
  8. package/dist/main/index.js +33 -1
  9. package/dist/main/ipc-handlers.js +72 -1
  10. package/dist/main/session-manager.js +37 -0
  11. package/dist/main/settings-persistence.js +28 -0
  12. package/dist/renderer/assets/index-CR22a7j2.css +32 -0
  13. package/dist/renderer/assets/index-Dfce25tf.js +13821 -0
  14. package/dist/renderer/index.html +2 -2
  15. package/dist/shared/ipc-contract.js +39 -0
  16. package/dist/shared/message-parser.js +100 -0
  17. package/dist/shared/types/atlas-types.js +5 -0
  18. package/docs/AGENT_TEAMS.md +166 -0
  19. package/docs/QUICKSTART_AGENT_TEAMS.md +69 -0
  20. package/docs/REPO_ATLAS_EVALUATION.md +228 -0
  21. package/docs/atlas-ui-prototype.html +1377 -0
  22. package/docs/repo-index.md +150 -0
  23. package/package.json +2 -1
  24. package/src/main/agent-team-manager.ts +512 -0
  25. package/src/main/atlas-manager.ts +917 -0
  26. package/src/main/built-in-actions.ts +22 -0
  27. package/src/main/cli-manager.ts +2 -0
  28. package/src/main/index.ts +43 -1
  29. package/src/main/ipc-handlers.ts +61 -1
  30. package/src/main/session-manager.ts +35 -0
  31. package/src/main/settings-persistence.ts +32 -0
  32. package/src/renderer/App.tsx +59 -0
  33. package/src/renderer/components/AgentGraph.tsx +253 -0
  34. package/src/renderer/components/AtlasPanel.css +473 -0
  35. package/src/renderer/components/AtlasPanel.tsx +338 -0
  36. package/src/renderer/components/MessageStream.tsx +265 -0
  37. package/src/renderer/components/TaskBoard.tsx +310 -0
  38. package/src/renderer/components/TeamPanel.tsx +540 -0
  39. package/src/renderer/components/ui/SettingsDialog.tsx +421 -18
  40. package/src/renderer/components/ui/TabBar.tsx +112 -0
  41. package/src/renderer/hooks/index.ts +1 -0
  42. package/src/renderer/hooks/useAgentTeams.ts +103 -0
  43. package/src/renderer/hooks/useAtlas.ts +126 -0
  44. package/src/renderer/hooks/useAutoTeamLayout.ts +56 -0
  45. package/src/renderer/hooks/useMessageStream.ts +57 -0
  46. package/src/shared/ipc-contract.ts +86 -0
  47. package/src/shared/ipc-types.ts +74 -0
  48. package/src/shared/message-parser.ts +119 -0
  49. package/src/shared/types/atlas-types.ts +148 -0
  50. package/dist/renderer/assets/index-B6x9OLWJ.css +0 -32
  51. 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
  ![License](https://img.shields.io/badge/license-MIT-blue.svg)
4
4
  ![Platform](https://img.shields.io/badge/platform-Windows%20%7C%20macOS%20%7C%20Linux-lightgrey.svg)
5
- ![Version](https://img.shields.io/badge/version-4.1.1-green.svg)
5
+ ![Version](https://img.shields.io/badge/version-4.3.0-green.svg)
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;