mission-control-ai 1.0.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/bin/cli.mjs ADDED
@@ -0,0 +1,230 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { readFileSync, writeFileSync, mkdirSync, copyFileSync, existsSync, readdirSync, unlinkSync } from 'fs';
4
+ import { join, dirname } from 'path';
5
+ import { homedir } from 'os';
6
+ import { fileURLToPath } from 'url';
7
+
8
+ const __filename = fileURLToPath(import.meta.url);
9
+ const __dirname = dirname(__filename);
10
+
11
+ const HOOKS_SRC = join(__dirname, '..', 'hooks');
12
+ const MC_DIR = join(homedir(), '.mission-control');
13
+ const HOOKS_DEST = join(MC_DIR, 'hooks');
14
+ const CLAUDE_SETTINGS = join(homedir(), '.claude', 'settings.json');
15
+
16
+ const HOOK_FILES = [
17
+ 'mc-claude-hook.py',
18
+ 'mc-prompt-hook.py',
19
+ 'mc-pretool-hook.py',
20
+ 'mc-posttool-hook.py',
21
+ ];
22
+
23
+ const HOOK_CONFIG = {
24
+ hooks: {
25
+ Stop: [
26
+ {
27
+ matcher: '',
28
+ hooks: [
29
+ {
30
+ type: 'command',
31
+ command: `python3 ${join(HOOKS_DEST, 'mc-claude-hook.py')}`,
32
+ },
33
+ ],
34
+ },
35
+ ],
36
+ UserPromptSubmit: [
37
+ {
38
+ matcher: '',
39
+ hooks: [
40
+ {
41
+ type: 'command',
42
+ command: `python3 ${join(HOOKS_DEST, 'mc-prompt-hook.py')}`,
43
+ },
44
+ ],
45
+ },
46
+ ],
47
+ PreToolUse: [
48
+ {
49
+ matcher: '',
50
+ hooks: [
51
+ {
52
+ type: 'command',
53
+ command: `python3 ${join(HOOKS_DEST, 'mc-pretool-hook.py')}`,
54
+ },
55
+ ],
56
+ },
57
+ ],
58
+ PostToolUse: [
59
+ {
60
+ matcher: '',
61
+ hooks: [
62
+ {
63
+ type: 'command',
64
+ command: `python3 ${join(HOOKS_DEST, 'mc-posttool-hook.py')}`,
65
+ },
66
+ ],
67
+ },
68
+ ],
69
+ },
70
+ };
71
+
72
+ function printHelp() {
73
+ console.log(`
74
+ Mission Control AI — monitor AI coding sessions in real time
75
+
76
+ Usage:
77
+ npx mission-control-ai setup Install hooks for Claude Code
78
+ npx mission-control-ai uninstall Remove hooks
79
+ npx mission-control-ai status Show current session status
80
+ npx mission-control-ai help Show this help
81
+ `);
82
+ }
83
+
84
+ function setup() {
85
+ console.log('\n 🚀 Mission Control AI — Setup\n');
86
+
87
+ // 1. Create directories
88
+ mkdirSync(HOOKS_DEST, { recursive: true });
89
+ mkdirSync(dirname(CLAUDE_SETTINGS), { recursive: true });
90
+ console.log(' ✓ Created ~/.mission-control/hooks/');
91
+
92
+ // 2. Copy hook scripts
93
+ for (const file of HOOK_FILES) {
94
+ const src = join(HOOKS_SRC, file);
95
+ const dest = join(HOOKS_DEST, file);
96
+ if (existsSync(src)) {
97
+ copyFileSync(src, dest);
98
+ console.log(` ✓ Installed ${file}`);
99
+ } else {
100
+ console.log(` ✗ Missing ${file} — skipped`);
101
+ }
102
+ }
103
+
104
+ // 3. Configure Claude Code settings
105
+ let settings = {};
106
+ if (existsSync(CLAUDE_SETTINGS)) {
107
+ try {
108
+ settings = JSON.parse(readFileSync(CLAUDE_SETTINGS, 'utf-8'));
109
+ } catch {
110
+ console.log(' ⚠ Could not parse existing settings.json — creating new one');
111
+ }
112
+ }
113
+
114
+ // Merge hooks without overwriting existing ones
115
+ if (!settings.hooks) settings.hooks = {};
116
+
117
+ for (const [hookName, hookEntries] of Object.entries(HOOK_CONFIG.hooks)) {
118
+ if (!settings.hooks[hookName]) {
119
+ settings.hooks[hookName] = hookEntries;
120
+ } else {
121
+ // Check if our hook is already there
122
+ const existing = settings.hooks[hookName];
123
+ for (const entry of hookEntries) {
124
+ const cmd = entry.hooks[0].command;
125
+ const alreadyExists = existing.some((e) =>
126
+ e.hooks?.some((h) => h.command === cmd)
127
+ );
128
+ if (!alreadyExists) {
129
+ existing.push(entry);
130
+ }
131
+ }
132
+ }
133
+ }
134
+
135
+ writeFileSync(CLAUDE_SETTINGS, JSON.stringify(settings, null, 2) + '\n');
136
+ console.log(' ✓ Updated ~/.claude/settings.json');
137
+
138
+ console.log('\n ✅ Setup complete!');
139
+ console.log(' → Download the Mission Control app: https://github.com/alexko0421/MissionControl/releases');
140
+ console.log(' → Your Claude Code sessions will now appear in Mission Control.\n');
141
+ }
142
+
143
+ function uninstall() {
144
+ console.log('\n 🗑 Mission Control AI — Uninstall\n');
145
+
146
+ // 1. Remove hook files
147
+ if (existsSync(HOOKS_DEST)) {
148
+ for (const file of HOOK_FILES) {
149
+ const dest = join(HOOKS_DEST, file);
150
+ if (existsSync(dest)) {
151
+ unlinkSync(dest);
152
+ console.log(` ✓ Removed ${file}`);
153
+ }
154
+ }
155
+ }
156
+
157
+ // 2. Remove hooks from Claude settings
158
+ if (existsSync(CLAUDE_SETTINGS)) {
159
+ try {
160
+ const settings = JSON.parse(readFileSync(CLAUDE_SETTINGS, 'utf-8'));
161
+ if (settings.hooks) {
162
+ for (const hookName of Object.keys(HOOK_CONFIG.hooks)) {
163
+ if (settings.hooks[hookName]) {
164
+ settings.hooks[hookName] = settings.hooks[hookName].filter(
165
+ (e) => !e.hooks?.some((h) => h.command?.includes('mission-control'))
166
+ );
167
+ if (settings.hooks[hookName].length === 0) {
168
+ delete settings.hooks[hookName];
169
+ }
170
+ }
171
+ }
172
+ if (Object.keys(settings.hooks).length === 0) {
173
+ delete settings.hooks;
174
+ }
175
+ writeFileSync(CLAUDE_SETTINGS, JSON.stringify(settings, null, 2) + '\n');
176
+ console.log(' ✓ Cleaned ~/.claude/settings.json');
177
+ }
178
+ } catch {
179
+ console.log(' ⚠ Could not parse settings.json');
180
+ }
181
+ }
182
+
183
+ console.log('\n ✅ Uninstall complete.\n');
184
+ }
185
+
186
+ function status() {
187
+ const statusFile = join(MC_DIR, 'status.json');
188
+ if (!existsSync(statusFile)) {
189
+ console.log('\n No active sessions. Start a Claude Code session first.\n');
190
+ return;
191
+ }
192
+
193
+ try {
194
+ const agents = JSON.parse(readFileSync(statusFile, 'utf-8'));
195
+ if (!agents.length) {
196
+ console.log('\n No active sessions.\n');
197
+ return;
198
+ }
199
+
200
+ console.log('\n Mission Control — Active Sessions\n');
201
+ const statusIcons = { running: '🟢', blocked: '🟠', done: '✅', idle: '💤' };
202
+ for (const a of agents) {
203
+ const icon = statusIcons[a.status] || '⚪';
204
+ console.log(` ${icon} ${a.name} — ${a.status}`);
205
+ if (a.task) console.log(` ${a.task}`);
206
+ }
207
+ console.log();
208
+ } catch {
209
+ console.log('\n Could not read status file.\n');
210
+ }
211
+ }
212
+
213
+ // Main
214
+ const command = process.argv[2] || 'help';
215
+
216
+ switch (command) {
217
+ case 'setup':
218
+ case 'install':
219
+ setup();
220
+ break;
221
+ case 'uninstall':
222
+ case 'remove':
223
+ uninstall();
224
+ break;
225
+ case 'status':
226
+ status();
227
+ break;
228
+ default:
229
+ printHelp();
230
+ }
@@ -0,0 +1,347 @@
1
+ #!/usr/bin/env python3
2
+ """Claude Code Stop hook — reads Claude's last message from stdin and updates Mission Control.
3
+
4
+ No Gemini needed. Claude knows what it's doing — just ask it.
5
+
6
+ Usage in ~/.claude/settings.json:
7
+ {
8
+ "hooks": {
9
+ "Stop": [{
10
+ "matcher": "",
11
+ "hooks": [{
12
+ "type": "command",
13
+ "command": "python3 ~/Library/Mobile\\ Documents/com~apple~CloudDocs/MissionControl/scripts/mc-claude-hook.py"
14
+ }]
15
+ }]
16
+ }
17
+ }
18
+ """
19
+
20
+ import json
21
+ import os
22
+ import sys
23
+ import re
24
+ import hashlib
25
+ import subprocess
26
+ from datetime import datetime, timezone
27
+ from urllib.request import Request, urlopen
28
+
29
+ STATUS_DIR = os.path.expanduser("~/.mission-control")
30
+ STATUS_FILE = os.path.join(STATUS_DIR, "status.json")
31
+ os.makedirs(STATUS_DIR, exist_ok=True)
32
+
33
+ # Gemini API key for summarization
34
+ API_KEY = os.environ.get("GEMINI_API_KEY", "")
35
+ if not API_KEY:
36
+ key_file = os.path.join(STATUS_DIR, "gemini-key.txt")
37
+ if os.path.exists(key_file):
38
+ API_KEY = open(key_file).read().strip()
39
+
40
+ def get_project_name(cwd):
41
+ """Get a readable project name from the working directory.
42
+ Tries git branch name first (more descriptive), falls back to directory name.
43
+ """
44
+ if not cwd:
45
+ return "Unknown"
46
+ # Try git branch name
47
+ try:
48
+ branch = subprocess.check_output(
49
+ ["git", "-C", cwd, "branch", "--show-current"],
50
+ stderr=subprocess.DEVNULL, timeout=3
51
+ ).decode().strip()
52
+ if branch:
53
+ # Clean up: remove username prefix like "alexko0421/"
54
+ if "/" in branch:
55
+ branch = branch.split("/", 1)[1]
56
+ # Convert kebab-case to readable: "fix-security-audit-v1" → "fix security audit v1"
57
+ return branch.replace("-", " ").replace("_", " ")
58
+ except:
59
+ pass
60
+ return os.path.basename(cwd)
61
+
62
+ def detect_app():
63
+ """Detect which app this Claude session is running in."""
64
+ bundle_id = os.environ.get("__CFBundleIdentifier", "")
65
+ mapping = {
66
+ "com.apple.Terminal": "Terminal",
67
+ "com.google.antigravity": "Antigravity",
68
+ }
69
+ if bundle_id in mapping:
70
+ return mapping[bundle_id]
71
+ # Check parent process for conductor/codex
72
+ try:
73
+ ppid = os.getppid()
74
+ while ppid > 1:
75
+ cmd = subprocess.check_output(
76
+ ["ps", "-p", str(ppid), "-o", "comm="],
77
+ stderr=subprocess.DEVNULL, timeout=3
78
+ ).decode().strip()
79
+ cmd_lower = cmd.lower()
80
+ if "conductor" in cmd_lower:
81
+ return "Conductor"
82
+ if "codex" in cmd_lower:
83
+ return "Codex"
84
+ if "antigravity" in cmd_lower:
85
+ return "Antigravity"
86
+ # Get parent of parent
87
+ ppid_str = subprocess.check_output(
88
+ ["ps", "-p", str(ppid), "-o", "ppid="],
89
+ stderr=subprocess.DEVNULL, timeout=3
90
+ ).decode().strip()
91
+ ppid = int(ppid_str)
92
+ except:
93
+ pass
94
+ if bundle_id:
95
+ # Return bundle name as fallback
96
+ parts = bundle_id.split(".")
97
+ return parts[-1].capitalize() if parts else "Terminal"
98
+ return "Terminal"
99
+
100
+ def detect_tmux():
101
+ """Detect if we're running inside tmux and return session/window/pane."""
102
+ if not os.environ.get("TMUX"):
103
+ return None, 0, 0
104
+ try:
105
+ session = subprocess.check_output(
106
+ ["tmux", "display-message", "-p", "#{session_name}"],
107
+ stderr=subprocess.DEVNULL, timeout=3
108
+ ).decode().strip()
109
+ window = subprocess.check_output(
110
+ ["tmux", "display-message", "-p", "#{window_index}"],
111
+ stderr=subprocess.DEVNULL, timeout=3
112
+ ).decode().strip()
113
+ pane = subprocess.check_output(
114
+ ["tmux", "display-message", "-p", "#{pane_index}"],
115
+ stderr=subprocess.DEVNULL, timeout=3
116
+ ).decode().strip()
117
+ return session or None, int(window or 0), int(pane or 0)
118
+ except:
119
+ return None, 0, 0
120
+
121
+ def guess_status(message):
122
+ """Simple heuristic for agent status based on message content."""
123
+ msg = message.lower()
124
+ # Blocked signals: asking questions, waiting for input
125
+ blocked_signals = [
126
+ # English
127
+ "which option", "do you want", "should i", "please choose", "waiting for",
128
+ "need your", "what do you think", "let me know", "your choice", "pick one",
129
+ "would you like", "want me to", "prefer", "choose between",
130
+ "option a", "option b", "option 1", "option 2",
131
+ "approach a", "approach b", "approach 1", "approach 2",
132
+ "which do you", "which would you", "what would you",
133
+ "your decision", "your input", "your feedback",
134
+ "select one", "pick between", "decide between",
135
+ # Simplified Chinese
136
+ "你想", "你觉得", "要你决定", "需要你", "你的意见", "请选择",
137
+ "你选", "你希望", "你认为", "请确认", "你来决定", "你决定",
138
+ "方案a", "方案b", "方案1", "方案2", "选项a", "选项b",
139
+ "哪个", "哪种", "要哪", "选哪", "你要",
140
+ # Traditional Chinese
141
+ "你覺得", "你選", "你希望", "你認為", "請確認", "請選擇",
142
+ "你來決定", "邊個", "邊種",
143
+ ]
144
+ # Question mark at end of last few lines is a strong signal
145
+ lines = [l.strip() for l in message.strip().split("\n") if l.strip()]
146
+ for line in lines[-3:]: # check last 3 lines
147
+ if line.endswith("?") or line.endswith("?") or line.endswith("吗") or line.endswith("嗎") or line.endswith("呢"):
148
+ return "blocked"
149
+ for signal in blocked_signals:
150
+ if signal in msg:
151
+ return "blocked"
152
+ return "running"
153
+
154
+ def truncate(text, max_len):
155
+ """Truncate text to max_len, adding ... if needed."""
156
+ if len(text) <= max_len:
157
+ return text
158
+ return text[:max_len - 3] + "..."
159
+
160
+ def get_app_language():
161
+ """Read app language setting from UserDefaults."""
162
+ try:
163
+ result = subprocess.check_output(
164
+ ["defaults", "read", "com.missioncontrol.app", "appLanguage"],
165
+ stderr=subprocess.DEVNULL, timeout=3
166
+ ).decode().strip()
167
+ return result if result in ("En", "Zh") else "Zh"
168
+ except:
169
+ return "Zh"
170
+
171
+ def summarize_with_gemini(message, project_name):
172
+ """Use Gemini to distill Claude's raw output into a clean summary."""
173
+ if not API_KEY:
174
+ return None
175
+
176
+ lang = get_app_language()
177
+
178
+ # Truncate message to avoid huge API calls
179
+ msg = message[:3000] if len(message) > 3000 else message
180
+
181
+ if lang == "En":
182
+ prompt = (
183
+ "You are Mission Control's summary engine. Distill AI coding agent output into a clean summary.\n\n"
184
+ "Rules:\n"
185
+ "- Use concise English\n"
186
+ "- task: one sentence about current work (max 40 chars), e.g. 'Implement global hotkey toggle'\n"
187
+ "- summary: 2-3 sentences about what was done and progress (max 200 chars)\n"
188
+ "- nextAction: what to do next (max 100 chars)\n"
189
+ "- status rules (very important):\n"
190
+ " - blocked: message ends with question mark, or asks user to choose/decide/confirm\n"
191
+ " - done: explicitly says 'done'/'complete' with no follow-up questions\n"
192
+ " - running: all other cases\n"
193
+ "- Do not repeat raw output, distill key points\n"
194
+ "- Describe features, not filenames\n\n"
195
+ f"Project: {project_name}\n"
196
+ f"AI Agent raw output:\n{msg}\n\n"
197
+ "Reply in JSON only, no markdown code block.\n"
198
+ 'Format: {"status":"...","task":"...","summary":"...","nextAction":"..."}\n'
199
+ )
200
+ else:
201
+ prompt = (
202
+ "你是 Mission Control 的摘要引擎。你的工作是将 AI coding agent 的原始输出提炼成简洁的工作摘要。\n\n"
203
+ "规则:\n"
204
+ "- 用简体中文书面语\n"
205
+ "- task: 一句话讲当前在做什么(最多20字),例如「实现全局快捷键切换面板」\n"
206
+ "- summary: 2-3句讲做了什么、进度到哪(最多100字)\n"
207
+ "- nextAction: 下一步要做什么(最多50字)\n"
208
+ "- status 判断规则(非常重要):\n"
209
+ " - blocked: 消息结尾有问号、或要求用户做选择/决定/确认\n"
210
+ " - done: 明确说「完成」「done」且没有后续问题\n"
211
+ " - running: 其他所有情况(正在工作、刚做完但还有下一步)\n"
212
+ "- 不要重复原文,要提炼重点\n"
213
+ "- 不要提及文件名,讲功能\n\n"
214
+ f"Project: {project_name}\n"
215
+ f"AI Agent 原始输出:\n{msg}\n\n"
216
+ "用 JSON 回复,不要加 markdown code block。\n"
217
+ '格式:{"status":"...","task":"...","summary":"...","nextAction":"..."}\n'
218
+ )
219
+ body = json.dumps({
220
+ "contents": [{"parts": [{"text": prompt}]}],
221
+ "generationConfig": {"temperature": 0.1, "maxOutputTokens": 300}
222
+ }).encode()
223
+ url = f"https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash:generateContent?key={API_KEY}"
224
+
225
+ try:
226
+ resp = urlopen(Request(url, data=body, headers={"Content-Type": "application/json"}), timeout=8)
227
+ data = json.loads(resp.read())
228
+ text = data["candidates"][0]["content"]["parts"][0]["text"]
229
+ text = re.sub(r"```json?\s*", "", text)
230
+ text = re.sub(r"```", "", text)
231
+ return json.loads(text.strip())
232
+ except:
233
+ return None
234
+
235
+ def main():
236
+ # Read hook input from stdin
237
+ try:
238
+ raw = sys.stdin.read()
239
+ if not raw.strip():
240
+ return
241
+ hook_input = json.loads(raw)
242
+ except (json.JSONDecodeError, IOError):
243
+ return
244
+
245
+ message = hook_input.get("last_assistant_message", "")
246
+ cwd = hook_input.get("cwd", "")
247
+ session_id = hook_input.get("session_id", "")
248
+ stop_reason = hook_input.get("stop_reason", "")
249
+
250
+ # Debug: log raw hook input to see available fields
251
+ debug_file = os.path.join(STATUS_DIR, "hook-debug.json")
252
+ with open(debug_file, "w") as df:
253
+ json.dump({"keys": list(hook_input.keys()), "stop_reason": stop_reason, "raw_sample": {k: str(v)[:200] for k, v in hook_input.items()}}, df, indent=2)
254
+
255
+ if not message or not cwd:
256
+ return
257
+
258
+ project_name = get_project_name(cwd)
259
+ agent_id = hashlib.md5(cwd.encode()).hexdigest()[:8]
260
+ now = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
261
+
262
+ # If Claude stopped because it wants to use a tool → likely waiting for user approval
263
+ is_tool_use_stop = (stop_reason == "tool_use")
264
+
265
+ # Try Gemini summarization first, fall back to naive extraction
266
+ result = summarize_with_gemini(message, project_name)
267
+ if result:
268
+ status = result.get("status", "running")
269
+ task = truncate(result.get("task", "Working..."), 60)
270
+ summary = truncate(result.get("summary", ""), 300)
271
+ next_action = truncate(result.get("nextAction", ""), 200)
272
+ else:
273
+ status = guess_status(message)
274
+ lines = [l.strip() for l in message.split("\n") if l.strip() and len(l.strip()) > 5]
275
+ task = truncate(lines[0], 60) if lines else "Working..."
276
+ summary = truncate(" ".join(lines[:3]), 300) if lines else ""
277
+ next_action = truncate(lines[-1], 200) if lines else ""
278
+
279
+ # Override: tool_use stop means Claude is waiting for approval → blocked
280
+ if is_tool_use_stop and status != "done":
281
+ status = "blocked"
282
+
283
+ # Load existing agents
284
+ agents = []
285
+ if os.path.exists(STATUS_FILE):
286
+ try:
287
+ with open(STATUS_FILE) as f:
288
+ agents = json.load(f)
289
+ except (json.JSONDecodeError, IOError):
290
+ agents = []
291
+
292
+ # Update or add this agent
293
+ found = False
294
+ app_name = detect_app()
295
+ for i, a in enumerate(agents):
296
+ if a["id"] == agent_id:
297
+ tmux_s, tmux_w, tmux_p = detect_tmux()
298
+ agents[i].update({
299
+ "name": project_name,
300
+ "status": status,
301
+ "task": task,
302
+ "summary": summary,
303
+ "nextAction": next_action,
304
+ "updatedAt": now,
305
+ "app": app_name,
306
+ "tmuxSession": tmux_s,
307
+ "tmuxWindow": tmux_w,
308
+ "tmuxPane": tmux_p,
309
+ })
310
+ found = True
311
+ break
312
+
313
+ tmux_session, tmux_window, tmux_pane = detect_tmux()
314
+
315
+ if not found:
316
+ agents.append({
317
+ "id": agent_id,
318
+ "name": project_name,
319
+ "status": status,
320
+ "task": task,
321
+ "summary": summary,
322
+ "terminalLines": [],
323
+ "nextAction": next_action,
324
+ "updatedAt": now,
325
+ "worktree": cwd,
326
+ "app": app_name,
327
+ "tmuxSession": tmux_session,
328
+ "tmuxWindow": tmux_window,
329
+ "tmuxPane": tmux_pane,
330
+ })
331
+
332
+ # Cleanup stale/duplicate entries before writing
333
+ try:
334
+ import importlib.util
335
+ spec = importlib.util.spec_from_file_location("mc_cleanup",
336
+ os.path.join(os.path.dirname(os.path.abspath(__file__)), "mc-cleanup.py"))
337
+ mod = importlib.util.module_from_spec(spec)
338
+ spec.loader.exec_module(mod)
339
+ agents = mod.cleanup_agents(agents)
340
+ except:
341
+ pass
342
+
343
+ with open(STATUS_FILE, "w") as f:
344
+ json.dump(agents, f, ensure_ascii=False, indent=2)
345
+
346
+ if __name__ == "__main__":
347
+ main()
@@ -0,0 +1,51 @@
1
+ #!/usr/bin/env python3
2
+ """Claude Code PostToolUse hook — marks agent back to 'running' after tool executes.
3
+
4
+ After the user approves a tool and it executes, set status back to 'running'.
5
+ """
6
+
7
+ import json
8
+ import os
9
+ import sys
10
+ import hashlib
11
+ from datetime import datetime, timezone
12
+
13
+ STATUS_DIR = os.path.expanduser("~/.mission-control")
14
+ STATUS_FILE = os.path.join(STATUS_DIR, "status.json")
15
+ os.makedirs(STATUS_DIR, exist_ok=True)
16
+
17
+ def main():
18
+ try:
19
+ raw = sys.stdin.read()
20
+ if not raw.strip():
21
+ return
22
+ hook_input = json.loads(raw)
23
+ except (json.JSONDecodeError, IOError):
24
+ return
25
+
26
+ cwd = hook_input.get("cwd", "")
27
+ if not cwd:
28
+ return
29
+
30
+ agent_id = hashlib.md5(cwd.encode()).hexdigest()[:8]
31
+ now = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
32
+
33
+ agents = []
34
+ if os.path.exists(STATUS_FILE):
35
+ try:
36
+ with open(STATUS_FILE) as f:
37
+ agents = json.load(f)
38
+ except (json.JSONDecodeError, IOError):
39
+ agents = []
40
+
41
+ for i, a in enumerate(agents):
42
+ if a["id"] == agent_id:
43
+ agents[i]["status"] = "running"
44
+ agents[i]["updatedAt"] = now
45
+ break
46
+
47
+ with open(STATUS_FILE, "w") as f:
48
+ json.dump(agents, f, ensure_ascii=False, indent=2)
49
+
50
+ if __name__ == "__main__":
51
+ main()
@@ -0,0 +1,53 @@
1
+ #!/usr/bin/env python3
2
+ """Claude Code PreToolUse hook — marks agent as 'blocked' when a tool needs approval.
3
+
4
+ When Claude wants to use a tool (Edit, Write, Bash, etc.), the CLI may show
5
+ an approval prompt. This hook sets the agent status to 'blocked' so Mission
6
+ Control shows '需要你'.
7
+ """
8
+
9
+ import json
10
+ import os
11
+ import sys
12
+ import hashlib
13
+ from datetime import datetime, timezone
14
+
15
+ STATUS_DIR = os.path.expanduser("~/.mission-control")
16
+ STATUS_FILE = os.path.join(STATUS_DIR, "status.json")
17
+ os.makedirs(STATUS_DIR, exist_ok=True)
18
+
19
+ def main():
20
+ try:
21
+ raw = sys.stdin.read()
22
+ if not raw.strip():
23
+ return
24
+ hook_input = json.loads(raw)
25
+ except (json.JSONDecodeError, IOError):
26
+ return
27
+
28
+ cwd = hook_input.get("cwd", "")
29
+ if not cwd:
30
+ return
31
+
32
+ agent_id = hashlib.md5(cwd.encode()).hexdigest()[:8]
33
+ now = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
34
+
35
+ agents = []
36
+ if os.path.exists(STATUS_FILE):
37
+ try:
38
+ with open(STATUS_FILE) as f:
39
+ agents = json.load(f)
40
+ except (json.JSONDecodeError, IOError):
41
+ agents = []
42
+
43
+ for i, a in enumerate(agents):
44
+ if a["id"] == agent_id:
45
+ agents[i]["status"] = "blocked"
46
+ agents[i]["updatedAt"] = now
47
+ break
48
+
49
+ with open(STATUS_FILE, "w") as f:
50
+ json.dump(agents, f, ensure_ascii=False, indent=2)
51
+
52
+ if __name__ == "__main__":
53
+ main()
@@ -0,0 +1,85 @@
1
+ #!/usr/bin/env python3
2
+ """Claude Code UserPromptSubmit hook — immediately marks agent as 'running' when user sends a message.
3
+
4
+ This makes Mission Control update in real-time: the moment you hit Enter,
5
+ the status changes from blocked/done to running.
6
+ """
7
+
8
+ import json
9
+ import os
10
+ import sys
11
+ import hashlib
12
+ import subprocess
13
+ from datetime import datetime, timezone
14
+
15
+ STATUS_DIR = os.path.expanduser("~/.mission-control")
16
+ STATUS_FILE = os.path.join(STATUS_DIR, "status.json")
17
+ os.makedirs(STATUS_DIR, exist_ok=True)
18
+
19
+ def _get_name(cwd):
20
+ try:
21
+ branch = subprocess.check_output(
22
+ ["git", "-C", cwd, "branch", "--show-current"],
23
+ stderr=subprocess.DEVNULL, timeout=3
24
+ ).decode().strip()
25
+ if branch:
26
+ if "/" in branch:
27
+ branch = branch.split("/", 1)[1]
28
+ return branch.replace("-", " ").replace("_", " ")
29
+ except:
30
+ pass
31
+ return os.path.basename(cwd)
32
+
33
+ def main():
34
+ try:
35
+ raw = sys.stdin.read()
36
+ if not raw.strip():
37
+ return
38
+ hook_input = json.loads(raw)
39
+ except (json.JSONDecodeError, IOError):
40
+ return
41
+
42
+ cwd = hook_input.get("cwd", "")
43
+ if not cwd:
44
+ return
45
+
46
+ agent_id = hashlib.md5(cwd.encode()).hexdigest()[:8]
47
+ now = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
48
+
49
+ # Load existing agents
50
+ agents = []
51
+ if os.path.exists(STATUS_FILE):
52
+ try:
53
+ with open(STATUS_FILE) as f:
54
+ agents = json.load(f)
55
+ except (json.JSONDecodeError, IOError):
56
+ agents = []
57
+
58
+ # Find and update this agent to running
59
+ for i, a in enumerate(agents):
60
+ if a["id"] == agent_id:
61
+ agents[i]["status"] = "running"
62
+ agents[i]["updatedAt"] = now
63
+ break
64
+ else:
65
+ # Agent not found yet — create a minimal entry
66
+ agents.append({
67
+ "id": agent_id,
68
+ "name": _get_name(cwd),
69
+ "status": "running",
70
+ "task": "處理中...",
71
+ "summary": "",
72
+ "terminalLines": [],
73
+ "nextAction": "",
74
+ "updatedAt": now,
75
+ "worktree": cwd,
76
+ "tmuxSession": None,
77
+ "tmuxWindow": 0,
78
+ "tmuxPane": 0,
79
+ })
80
+
81
+ with open(STATUS_FILE, "w") as f:
82
+ json.dump(agents, f, ensure_ascii=False, indent=2)
83
+
84
+ if __name__ == "__main__":
85
+ main()
package/package.json ADDED
@@ -0,0 +1,34 @@
1
+ {
2
+ "name": "mission-control-ai",
3
+ "version": "1.0.0",
4
+ "description": "A macOS floating dashboard that monitors multiple AI coding sessions in real time",
5
+ "bin": {
6
+ "mission-control-ai": "bin/cli.mjs"
7
+ },
8
+ "type": "module",
9
+ "license": "MIT",
10
+ "author": "Ko Chunlong",
11
+ "repository": {
12
+ "type": "git",
13
+ "url": "git+https://github.com/alexko0421/MissionControl.git"
14
+ },
15
+ "keywords": [
16
+ "mission-control",
17
+ "ai",
18
+ "claude-code",
19
+ "claude",
20
+ "codex",
21
+ "antigravity",
22
+ "conductor",
23
+ "dashboard",
24
+ "macos",
25
+ "developer-tools"
26
+ ],
27
+ "engines": {
28
+ "node": ">=18"
29
+ },
30
+ "files": [
31
+ "bin/",
32
+ "hooks/"
33
+ ]
34
+ }