@hummer98/cmux-team 3.0.0 → 3.0.2

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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cmux-team",
3
- "version": "3.0.0",
3
+ "version": "3.0.2",
4
4
  "description": "Multi-agent development orchestration with Claude Code + cmux. Spawn, monitor, and integrate parallel sub-agents visually in terminal panes.",
5
5
  "author": {
6
6
  "name": "hummer98",
package/CHANGELOG.md CHANGED
@@ -1,5 +1,19 @@
1
1
  # Changelog
2
2
 
3
+ ## [3.0.2] - 2026-03-29
4
+
5
+ ### Fixed
6
+ - `cmux-team` コマンド実行時に `Cannot find module './dashboard'` エラーが発生する問題を修正(`.tsx` ファイルがパッケージに含まれていなかった)
7
+
8
+ ### Changed
9
+ - 不要な `spawn-team.sh` を削除(CLI に統合済み)
10
+
11
+ ## [3.0.1] - 2026-03-29
12
+
13
+ ### Fixed
14
+ - postinstall で Claude Code plugin を自動インストール(手動実行の案内を廃止)
15
+ - `npm pkg fix` による bin パスと repository URL の正規化
16
+
3
17
  ## [3.0.0] - 2026-03-29
4
18
 
5
19
  ### Added
@@ -21,6 +21,14 @@ try {
21
21
  console.warn(` cd ${managerDir} && bun install`);
22
22
  }
23
23
 
24
- // インストール完了メッセージ
24
+ // Claude Code plugin をインストール
25
+ try {
26
+ execFileSync("which", ["claude"], { stdio: "ignore" });
27
+ console.log("cmux-team: Claude Code plugin をインストール中...");
28
+ execFileSync("claude", ["plugin", "add", "hummer98/cmux-team"], { stdio: "inherit" });
29
+ } catch {
30
+ console.warn("cmux-team: claude が見つかりません。手動で実行してください:");
31
+ console.warn(" claude plugin add hummer98/cmux-team");
32
+ }
33
+
25
34
  console.log("cmux-team: インストール完了");
26
- console.log(" Plugin としても使う場合: claude plugin add hummer98/cmux-team");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hummer98/cmux-team",
3
- "version": "3.0.0",
3
+ "version": "3.0.2",
4
4
  "description": "Multi-agent development orchestration with Claude Code + cmux. Spawn, monitor, and integrate parallel sub-agents visually in terminal panes.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -15,6 +15,7 @@
15
15
  "skills/cmux-team/SKILL.md",
16
16
  "skills/cmux-team/templates/",
17
17
  "skills/cmux-team/manager/**/*.ts",
18
+ "skills/cmux-team/manager/**/*.tsx",
18
19
  "!skills/cmux-team/manager/**/*.test.ts",
19
20
  "skills/cmux-team/manager/package.json",
20
21
  "skills/cmux-team/manager/bun.lock",
@@ -0,0 +1,495 @@
1
+ /**
2
+ * TUI Dashboard — ink フルスクリーンダッシュボード
3
+ *
4
+ * top のようなフルスクリーン表示。ターミナルサイズにレスポンシブ。
5
+ * 上部: ヘッダー(ステータス・PID・uptime)
6
+ * 中部: Master / Conductors / Tasks パネル
7
+ * 下部: journal / log タブ切り替え(残りスペースを全て使う)
8
+ */
9
+ import React, { useState, useEffect } from "react";
10
+ import { render, Text, Box, useStdout, useInput } from "ink";
11
+ import { readFile } from "fs/promises";
12
+ import { join } from "path";
13
+ import type { DaemonState } from "./daemon";
14
+
15
+ type ActiveTab = "journal" | "log";
16
+
17
+ interface DashboardProps {
18
+ getState: () => DaemonState;
19
+ version?: string;
20
+ onReload?: () => void;
21
+ onQuit?: () => void;
22
+ }
23
+
24
+ function useTerminalSize() {
25
+ const { stdout } = useStdout();
26
+ const [size, setSize] = useState({
27
+ columns: stdout?.columns ?? 80,
28
+ rows: stdout?.rows ?? 24,
29
+ });
30
+
31
+ useEffect(() => {
32
+ const handler = () => {
33
+ setSize({
34
+ columns: stdout?.columns ?? 80,
35
+ rows: stdout?.rows ?? 24,
36
+ });
37
+ };
38
+ stdout?.on("resize", handler);
39
+ return () => { stdout?.off("resize", handler); };
40
+ }, [stdout]);
41
+
42
+ return size;
43
+ }
44
+
45
+ function useLogTail(projectRoot: string, lineCount: number) {
46
+ const [lines, setLines] = useState<string[]>([]);
47
+
48
+ useEffect(() => {
49
+ const logFile = join(projectRoot, ".team/logs/manager.log");
50
+ const read = async () => {
51
+ try {
52
+ const content = await readFile(logFile, "utf-8");
53
+ const all = content.trim().split("\n").filter(Boolean);
54
+ setLines(all.slice(-lineCount));
55
+ } catch {
56
+ setLines([]);
57
+ }
58
+ };
59
+ read();
60
+ const interval = setInterval(read, 2000);
61
+ return () => clearInterval(interval);
62
+ }, [projectRoot, lineCount]);
63
+
64
+ return lines;
65
+ }
66
+
67
+ // --- ジャーナルエントリ ---
68
+ interface JournalEntry {
69
+ time: string; // HH:MM
70
+ icon: string; // [+], [▶], [✓]
71
+ taskId: string;
72
+ message: string;
73
+ color: string;
74
+ }
75
+
76
+ function useJournalEntries(projectRoot: string): JournalEntry[] {
77
+ const [entries, setEntries] = useState<JournalEntry[]>([]);
78
+
79
+ useEffect(() => {
80
+ const logFile = join(projectRoot, ".team/logs/manager.log");
81
+ const read = async () => {
82
+ try {
83
+ const content = await readFile(logFile, "utf-8");
84
+ const lines = content.trim().split("\n").filter(Boolean);
85
+ const result: JournalEntry[] = [];
86
+
87
+ for (const line of lines) {
88
+ const match = line.match(/^\[([^\]]+)\]\s+(\S+)\s*(.*)/);
89
+ if (!match) continue;
90
+ const ts = match[1] ?? "";
91
+ const event = match[2] ?? "";
92
+ const detail = match[3] ?? "";
93
+ const time = utcToLocal(ts); // HH:MM:SS(ローカル時刻)
94
+
95
+ if (event === "task_received") {
96
+ const taskId = detail.match(/task_id=(\S+)/)?.[1] ?? "?";
97
+ const title = detail.match(/title=(.+?)(?:\s+\w+=|$)/)?.[1] ?? "";
98
+ result.push({ time, icon: "[+]", taskId, message: title, color: "cyan" });
99
+ } else if (event === "conductor_started") {
100
+ const taskId = detail.match(/task_id=(\S+)/)?.[1] ?? "?";
101
+ const title = detail.match(/title=(.+?)(?:\s+\w+=|$)/)?.[1] ?? "";
102
+ result.push({ time, icon: "[▶]", taskId, message: title || `${detail.match(/conductor_id=(\S+)/)?.[1] ?? ""} started`, color: "yellow" });
103
+ } else if (event === "task_completed") {
104
+ const taskId = detail.match(/task_id=(\S+)/)?.[1] ?? "?";
105
+ const title = detail.match(/title=(.+?)(?:\s+\w+=|$)/)?.[1] ?? "";
106
+ const summary = detail.match(/journal_summary=(.+)/)?.[1] ?? "";
107
+ result.push({ time, icon: "[✓]", taskId, message: summary || title || detail, color: "green" });
108
+ }
109
+ }
110
+
111
+ setEntries(result);
112
+ } catch {
113
+ setEntries([]);
114
+ }
115
+ };
116
+ read();
117
+ const interval = setInterval(read, 2000);
118
+ return () => clearInterval(interval);
119
+ }, [projectRoot]);
120
+
121
+ return entries;
122
+ }
123
+
124
+ function formatUptime(startMs: number): string {
125
+ const sec = Math.floor((Date.now() - startMs) / 1000);
126
+ if (sec < 60) return `${sec}s`;
127
+ if (sec < 3600) return `${Math.floor(sec / 60)}m${sec % 60}s`;
128
+ return `${Math.floor(sec / 3600)}h${Math.floor((sec % 3600) / 60)}m`;
129
+ }
130
+
131
+ function utcToLocal(isoTimestamp: string): string {
132
+ return new Date(isoTimestamp).toLocaleTimeString("ja-JP", {
133
+ hour: "2-digit",
134
+ minute: "2-digit",
135
+ second: "2-digit",
136
+ hour12: false,
137
+ });
138
+ }
139
+
140
+ function truncate(text: string, maxLen: number): string {
141
+ if (maxLen <= 0) return "";
142
+ if (text.length <= maxLen) return text;
143
+ if (maxLen <= 1) return "…";
144
+ return text.slice(0, maxLen - 1) + "…";
145
+ }
146
+
147
+ function formatElapsed(isoDate: string): string {
148
+ const sec = Math.floor((Date.now() - new Date(isoDate).getTime()) / 1000);
149
+ if (sec < 60) return `${sec}s`;
150
+ if (sec < 3600) return `${Math.floor(sec / 60)}m${sec % 60}s`;
151
+ return `${Math.floor(sec / 3600)}h${Math.floor((sec % 3600) / 60)}m`;
152
+ }
153
+
154
+ // --- ヘッダーバー ---
155
+ function Header({ state, cols }: { state: DaemonState; cols: number }) {
156
+ const status = state.running ? "RUNNING" : "STOPPED";
157
+ const statusColor = state.running ? "green" : "red";
158
+ const runningCount = [...state.conductors.values()].filter(c => c.status === "running").length;
159
+
160
+ // 各セグメントの幅を概算し、cols に収まらない場合は右から省略
161
+ // 最低限: " cmux-team STATUS conductors N/M tasks N open" ≒ 50文字
162
+ const showPid = cols >= 65;
163
+ const showPoll = cols >= 75;
164
+ const showReady = cols >= 85 && state.pendingTasks > 0;
165
+
166
+ return (
167
+ <Box width={cols}>
168
+ <Text bold color="cyan"> cmux-team </Text>
169
+ <Text> </Text>
170
+ <Text bold color={statusColor}>{status}</Text>
171
+ {showPid && (
172
+ <>
173
+ <Text> PID </Text>
174
+ <Text bold>{process.pid}</Text>
175
+ </>
176
+ )}
177
+ {showPoll && (
178
+ <>
179
+ <Text> poll </Text>
180
+ <Text>{state.pollInterval / 1000}s</Text>
181
+ </>
182
+ )}
183
+ <Text> conductors </Text>
184
+ <Text bold color="yellow">{runningCount}</Text>
185
+ <Text>/{state.maxConductors}</Text>
186
+ <Text> tasks </Text>
187
+ <Text bold>{state.openTasks}</Text>
188
+ <Text> open</Text>
189
+ {showReady && (
190
+ <>
191
+ <Text> </Text>
192
+ <Text bold color="green">{state.pendingTasks}</Text>
193
+ <Text> ready</Text>
194
+ </>
195
+ )}
196
+ </Box>
197
+ );
198
+ }
199
+
200
+ // --- セパレーター ---
201
+ function Sep({ cols, label }: { cols: number; label: string }) {
202
+ const line = "─".repeat(Math.max(0, cols - label.length - 3));
203
+ return (
204
+ <Box>
205
+ <Text dimColor>─ <Text bold dimColor={false}>{label}</Text> {line}</Text>
206
+ </Box>
207
+ );
208
+ }
209
+
210
+ // --- Master セクション ---
211
+ function MasterSection({ state }: { state: DaemonState }) {
212
+ if (state.masterSurface) {
213
+ return (
214
+ <Box paddingLeft={1}>
215
+ <Text color="green">● </Text>
216
+ <Text>[{state.masterSurface.replace("surface:", "")}]</Text>
217
+ </Box>
218
+ );
219
+ }
220
+ return (
221
+ <Box paddingLeft={1}>
222
+ <Text color="red">○ not spawned</Text>
223
+ </Box>
224
+ );
225
+ }
226
+
227
+ // --- Conductor セクション ---
228
+ function ConductorsSection({ state, cols }: { state: DaemonState; cols: number }) {
229
+ const conductors = [...state.conductors.values()];
230
+ if (conductors.length === 0) {
231
+ return (
232
+ <Box paddingLeft={1}>
233
+ <Text dimColor>idle — waiting for tasks</Text>
234
+ </Box>
235
+ );
236
+ }
237
+ return (
238
+ <>
239
+ {conductors.map((c) => {
240
+ const isIdle = c.status === "idle";
241
+ const isDone = c.status === "done";
242
+ const elapsed = formatElapsed(c.startedAt);
243
+ const agents = c.agents || [];
244
+ return (
245
+ <Box key={c.conductorId} flexDirection="column">
246
+ <Box paddingLeft={1}>
247
+ <Text color={isIdle ? "gray" : isDone ? "gray" : "yellow"}>
248
+ {isIdle ? "○ " : isDone ? "✓ " : "● "}
249
+ </Text>
250
+ <Text color={isIdle || isDone ? "gray" : undefined}>[{c.surface.replace("surface:", "")}]</Text>
251
+ {isIdle ? (
252
+ <Text dimColor> idle</Text>
253
+ ) : (
254
+ (() => {
255
+ const surfaceText = `[${c.surface.replace("surface:", "")}]`;
256
+ const taskIdText = ` #${(c.taskId ?? "").padStart(3, '0')}`;
257
+ const elapsedText = ` ${elapsed}`;
258
+ // paddingLeft(1) + icon(2) + surface + taskId + elapsed + space(1)
259
+ const fixedWidth = 1 + 2 + surfaceText.length + taskIdText.length + elapsedText.length + 1;
260
+ const maxTitle = cols - fixedWidth;
261
+ return (
262
+ <>
263
+ <Text bold={!isDone} color={isDone ? "gray" : undefined}>{taskIdText}</Text>
264
+ {c.taskTitle && <Text color={isDone ? "gray" : "white"}> {truncate(c.taskTitle, maxTitle)}</Text>}
265
+ <Text dimColor>{elapsedText}</Text>
266
+ </>
267
+ );
268
+ })()
269
+ )}
270
+ </Box>
271
+ {agents.map((a, i) => {
272
+ const roleIcons: Record<string, string> = {
273
+ impl: "⚙", implementer: "⚙",
274
+ docs: "📝", dockeeper: "📝",
275
+ reviewer: "🔍", review: "🔍",
276
+ researcher: "🔬", research: "🔬",
277
+ tester: "🧪", test: "🧪",
278
+ architect: "📐", design: "📐",
279
+ };
280
+ const icon = roleIcons[a.role ?? ""] ?? "🔧";
281
+ const label = a.taskTitle ?? a.role ?? "";
282
+ return (
283
+ <Box key={a.surface} paddingLeft={3}>
284
+ <Text dimColor>{i === agents.length - 1 ? "└─ " : "├─ "}</Text>
285
+ <Text color="cyan">[{a.surface.replace("surface:", "")}]</Text>
286
+ <Text> {icon} {label}</Text>
287
+ </Box>
288
+ );
289
+ })}
290
+ </Box>
291
+ );
292
+ })}
293
+ </>
294
+ );
295
+ }
296
+
297
+ // --- タスクセクション ---
298
+ function TasksSection({ state, cols }: { state: DaemonState; cols: number }) {
299
+ if (state.taskList.length === 0) {
300
+ return (
301
+ <Box paddingLeft={1}>
302
+ <Text dimColor>no tasks</Text>
303
+ </Box>
304
+ );
305
+ }
306
+
307
+ const assignedTaskIds = new Set(
308
+ [...state.conductors.values()].map((c) => c.taskId)
309
+ );
310
+
311
+ return (
312
+ <>
313
+ {state.taskList.map((task) => {
314
+ const assigned = assignedTaskIds.has(task.id);
315
+ const isClosed = task.status === "closed";
316
+ const isDraft = !assigned && task.status === "draft";
317
+ const color = assigned ? "green" : task.status === "ready" ? "yellow" : isClosed ? "#aaaaaa" : undefined;
318
+ const title = task.title;
319
+ const timeInfo = isClosed && task.closedAt
320
+ ? ` ${utcToLocal(task.closedAt).slice(0, 5)}`
321
+ : !isClosed && task.createdAt ? ` ${formatElapsed(task.createdAt)}` : "";
322
+ const label = assigned ? "running" : task.status;
323
+ // paddingLeft(1) + icon(2) + taskId(3) + " [label] "(label.length+3) + timeInfo
324
+ const fixedWidth = 1 + 2 + 3 + label.length + 3 + timeInfo.length;
325
+ const maxTitle = cols - fixedWidth;
326
+ return (
327
+ <Box key={task.id} paddingLeft={1}>
328
+ <Text color={color}>{isClosed ? "○" : "●"} </Text>
329
+ <Text color={color} bold={!isClosed}>{task.id.padStart(3, '0')}</Text>
330
+ <Text color={color}> [{label}] {truncate(title, maxTitle)}</Text>
331
+ {timeInfo && <Text color={color}>{timeInfo}</Text>}
332
+ </Box>
333
+ );
334
+ })}
335
+ </>
336
+ );
337
+ }
338
+
339
+ // --- ログセクション ---
340
+ function formatLogLine(line: string, cols: number): { time: string; event: string; detail: string; color: string } {
341
+ const match = line.match(/^\[([^\]]+)\]\s+(\S+)\s*(.*)/);
342
+ if (!match) return { time: "", event: "", detail: line.slice(0, cols - 2), color: "white" };
343
+ const ts = match[1] ?? "";
344
+ const event = match[2] ?? "";
345
+ const detail = match[3] ?? "";
346
+ const time = utcToLocal(ts);
347
+ const isError = event === "error";
348
+ const isComplete = event.includes("completed");
349
+ const color = isError ? "red" : isComplete ? "green" : "white";
350
+ return { time, event, detail: detail.slice(0, Math.max(0, cols - time.length - event.length - 5)), color };
351
+ }
352
+
353
+ function LogSection({ lines, cols }: { lines: string[]; cols: number }) {
354
+ if (lines.length === 0) {
355
+ return (
356
+ <Box paddingLeft={1}>
357
+ <Text dimColor>no log entries</Text>
358
+ </Box>
359
+ );
360
+ }
361
+ return (
362
+ <Box flexDirection="column">
363
+ {lines.map((line, i) => {
364
+ const { time, event, detail, color } = formatLogLine(line, cols);
365
+ return (
366
+ <Box key={i} paddingLeft={1}>
367
+ <Text>
368
+ <Text dimColor>{time}</Text>
369
+ {' '}
370
+ <Text color={color}>{event}</Text>
371
+ {' '}
372
+ <Text>{detail}</Text>
373
+ </Text>
374
+ </Box>
375
+ );
376
+ })}
377
+ </Box>
378
+ );
379
+ }
380
+
381
+ // --- ジャーナルセクション ---
382
+ function JournalSection({ entries, cols }: { entries: JournalEntry[]; cols: number }) {
383
+ if (entries.length === 0) {
384
+ return (
385
+ <Box paddingLeft={1}>
386
+ <Text dimColor>no journal entries</Text>
387
+ </Box>
388
+ );
389
+ }
390
+ return (
391
+ <Box flexDirection="column">
392
+ {entries.map((entry, i) => {
393
+ const maxMsg = Math.max(0, cols - entry.time.length - entry.icon.length - entry.taskId.length - 7);
394
+ return (
395
+ <Box key={i} paddingLeft={1}>
396
+ <Text>
397
+ <Text dimColor>{entry.time}</Text>
398
+ {' '}
399
+ <Text color={entry.color}>{entry.icon}</Text>
400
+ {' '}
401
+ <Text bold>#{entry.taskId.padStart(3, '0')}</Text>
402
+ {' '}
403
+ <Text>{entry.message.slice(0, maxMsg)}</Text>
404
+ </Text>
405
+ </Box>
406
+ );
407
+ })}
408
+ </Box>
409
+ );
410
+ }
411
+
412
+ // --- メインダッシュボード ---
413
+ function Dashboard({ getState, version, onReload, onQuit }: DashboardProps) {
414
+ const [state, setState] = useState(getState());
415
+ const [activeTab, setActiveTab] = useState<ActiveTab>("journal");
416
+ const { columns: cols, rows } = useTerminalSize();
417
+
418
+ useEffect(() => {
419
+ const interval = setInterval(() => {
420
+ setState({ ...getState() });
421
+ }, 2000);
422
+ return () => clearInterval(interval);
423
+ }, []);
424
+
425
+ useInput((input, key) => {
426
+ if (input === "1") setActiveTab("journal");
427
+ if (input === "2") setActiveTab("log");
428
+ if (key.tab) setActiveTab((prev) => (prev === "journal" ? "log" : "journal"));
429
+ if (input === "r" && onReload) onReload();
430
+ if (input === "q" && onQuit) onQuit();
431
+ });
432
+
433
+ // レイアウト計算
434
+ // header=1, sep=1, master=1, sep=1, conductor=max(1,N+agents), sep=1, tasks=max(1,M), sep=1, keyhint=1
435
+ const conductorLines = [...state.conductors.values()].reduce((sum, c) => sum + 1 + (c.agents?.length ?? 0), 0);
436
+ const conductorCount = Math.max(1, conductorLines);
437
+ const tasksCount = Math.max(1, state.taskList.length);
438
+ const fixedLines = 1 + 1 + 1 + 1 + conductorCount + 1 + tasksCount + 1 + 1;
439
+ const contentLines = Math.max(1, rows - fixedLines);
440
+ const logTail = useLogTail(state.projectRoot, contentLines);
441
+ const journalEntries = useJournalEntries(state.projectRoot);
442
+ const visibleJournal = journalEntries.slice(-contentLines);
443
+
444
+ const tabLabel = activeTab === "journal" ? "Journal" : "Log";
445
+
446
+ return (
447
+ <Box flexDirection="column" width={cols} height={rows}>
448
+ <Header state={state} cols={cols} />
449
+ <Sep cols={cols} label="Master" />
450
+ <MasterSection state={state} />
451
+ <Sep cols={cols} label={`Conductors ${state.conductors.size}/${state.maxConductors}`} />
452
+ <ConductorsSection state={state} cols={cols} />
453
+ <Sep cols={cols} label="Tasks" />
454
+ <TasksSection state={state} cols={cols} />
455
+ <Sep cols={cols} label={tabLabel} />
456
+ <Box flexDirection="column" height={contentLines} overflow="hidden">
457
+ {activeTab === "journal" ? (
458
+ <JournalSection entries={visibleJournal} cols={cols} />
459
+ ) : (
460
+ <LogSection lines={logTail} cols={cols} />
461
+ )}
462
+ </Box>
463
+ <Box justifyContent="space-between" width={cols}>
464
+ <Box>
465
+ <Text backgroundColor={activeTab === "journal" ? "white" : "gray"} color={activeTab === "journal" ? "black" : "white"} bold> 1 </Text>
466
+ <Text>journal </Text>
467
+ <Text backgroundColor={activeTab === "log" ? "white" : "gray"} color={activeTab === "log" ? "black" : "white"} bold> 2 </Text>
468
+ <Text>log </Text>
469
+ <Text backgroundColor="gray" color="white" bold> r </Text>
470
+ <Text>reload </Text>
471
+ <Text backgroundColor="gray" color="white" bold> q </Text>
472
+ <Text>quit</Text>
473
+ </Box>
474
+ {version && <Text dimColor>v{version}</Text>}
475
+ </Box>
476
+ </Box>
477
+ );
478
+ }
479
+
480
+ let inkInstance: ReturnType<typeof render> | null = null;
481
+
482
+ export function unmountDashboard(): void {
483
+ if (inkInstance) {
484
+ inkInstance.unmount();
485
+ inkInstance.cleanup();
486
+ inkInstance = null;
487
+ }
488
+ }
489
+
490
+ export function startDashboard(
491
+ getState: () => DaemonState,
492
+ opts?: { version?: string; onReload?: () => void; onQuit?: () => void }
493
+ ): void {
494
+ inkInstance = render(<Dashboard getState={getState} version={opts?.version} onReload={opts?.onReload} onQuit={opts?.onQuit} />);
495
+ }