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 +230 -0
- package/hooks/mc-claude-hook.py +347 -0
- package/hooks/mc-posttool-hook.py +51 -0
- package/hooks/mc-pretool-hook.py +53 -0
- package/hooks/mc-prompt-hook.py +85 -0
- package/package.json +34 -0
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
|
+
}
|