mob-coordinator 0.2.2 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -33,19 +33,13 @@ mob
33
33
 
34
34
  Open `http://localhost:4040` in your browser.
35
35
 
36
- ### Install Claude Code Hooks
37
-
38
- To enable status reporting (state, branch, auto-naming) from Claude instances, clone the repo and run the hook installer:
36
+ Claude Code hooks are installed automatically on first launch. These enable status reporting (state, branch, auto-naming) from Claude instances. To manually manage hooks:
39
37
 
40
38
  ```bash
41
- git clone https://github.com/nickelbob/mob.git
42
- cd mob
43
- npm install
44
- npm run install-hooks
39
+ mob install-hooks # Re-install hooks
40
+ mob uninstall-hooks # Remove hooks
45
41
  ```
46
42
 
47
- This adds hook entries to `~/.claude/settings.json` that point to the hook scripts in the cloned repo. You only need to do this once — the hooks work regardless of whether you run mob via npm or from source.
48
-
49
43
  ### Development
50
44
 
51
45
  To contribute or run from source:
@@ -137,10 +131,8 @@ See `CLAUDE.md` for detailed architecture documentation.
137
131
 
138
132
  ## Uninstalling Hooks
139
133
 
140
- From the cloned repo:
141
-
142
134
  ```bash
143
- npm run uninstall-hooks
135
+ mob uninstall-hooks
144
136
  ```
145
137
 
146
138
  ## Troubleshooting
@@ -166,7 +158,7 @@ MOB_PORT=4050 npm run dev
166
158
 
167
159
  ### Hooks not reporting status
168
160
 
169
- 1. Make sure hooks are installed: `npm run install-hooks`
161
+ 1. Hooks are auto-installed on startup. To force re-install: `mob install-hooks`
170
162
  2. Check `~/.claude/settings.json` has entries for `PreToolUse`, `PostToolUse`, `Stop`, `UserPromptSubmit`, and `Notification`
171
163
  3. On Windows, ensure PowerShell can run the hook script (execution policy)
172
164
 
@@ -176,6 +168,13 @@ If hooks aren't firing (crash, subtask weirdness), the terminal state fallback s
176
168
 
177
169
  ## Changelog
178
170
 
171
+ ### 0.3.0
172
+
173
+ - Auto-install Claude Code hooks on server startup — no manual setup required
174
+ - Add `mob install-hooks` and `mob uninstall-hooks` CLI subcommands
175
+ - Include hook scripts in npm package
176
+ - Add landing page website for GitHub Pages
177
+
179
178
  ### 0.2.1
180
179
 
181
180
  - Fix false "Needs Input" showing when Claude finishes a task (completion notifications no longer trigger waiting state)
package/bin/mob.js CHANGED
@@ -6,15 +6,26 @@ import { dirname, join } from 'path';
6
6
 
7
7
  const __dirname = dirname(fileURLToPath(import.meta.url));
8
8
  const root = join(__dirname, '..');
9
- const serverEntry = join(root, 'dist', 'server', 'server', 'index.js');
10
9
 
11
- // Run the server, forwarding stdio and signals
12
- const child = spawn(process.execPath, [serverEntry], {
13
- cwd: root,
14
- stdio: 'inherit',
15
- env: { ...process.env },
16
- });
10
+ const subcommand = process.argv[2];
17
11
 
18
- child.on('exit', (code) => process.exit(code ?? 0));
19
- process.on('SIGINT', () => child.kill('SIGINT'));
20
- process.on('SIGTERM', () => child.kill('SIGTERM'));
12
+ if (subcommand === 'install-hooks') {
13
+ const { installHooks } = await import(join(root, 'dist', 'server', 'server', 'hooks.js'));
14
+ installHooks(root);
15
+ } else if (subcommand === 'uninstall-hooks') {
16
+ const { uninstallHooks } = await import(join(root, 'dist', 'server', 'server', 'hooks.js'));
17
+ uninstallHooks();
18
+ } else {
19
+ const serverEntry = join(root, 'dist', 'server', 'server', 'index.js');
20
+
21
+ // Run the server, forwarding stdio and signals
22
+ const child = spawn(process.execPath, [serverEntry], {
23
+ cwd: root,
24
+ stdio: 'inherit',
25
+ env: { ...process.env },
26
+ });
27
+
28
+ child.on('exit', (code) => process.exit(code ?? 0));
29
+ process.on('SIGINT', () => child.kill('SIGINT'));
30
+ process.on('SIGTERM', () => child.kill('SIGTERM'));
31
+ }
@@ -0,0 +1,119 @@
1
+ import fs from 'fs';
2
+ import path from 'path';
3
+ import os from 'os';
4
+ const CLAUDE_SETTINGS_PATH = path.join(os.homedir(), '.claude', 'settings.json');
5
+ const HOOK_EVENTS = [
6
+ 'PreToolUse',
7
+ 'PostToolUse',
8
+ 'Stop',
9
+ 'UserPromptSubmit',
10
+ 'Notification',
11
+ ];
12
+ function getHookCommand(packageRoot) {
13
+ if (process.platform === 'win32') {
14
+ const hookScript = path.resolve(packageRoot, 'hooks', 'mob-status.ps1');
15
+ const psPath = path.join(process.env.SystemRoot || 'C:\\Windows', 'System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe');
16
+ return `"${psPath}" -NoProfile -ExecutionPolicy Bypass -File "${hookScript}"`;
17
+ }
18
+ const hookScript = path.resolve(packageRoot, 'hooks', 'mob-status.sh');
19
+ return `bash "${hookScript}"`;
20
+ }
21
+ function readSettings() {
22
+ const settingsDir = path.dirname(CLAUDE_SETTINGS_PATH);
23
+ if (!fs.existsSync(settingsDir)) {
24
+ fs.mkdirSync(settingsDir, { recursive: true });
25
+ }
26
+ if (fs.existsSync(CLAUDE_SETTINGS_PATH)) {
27
+ try {
28
+ return JSON.parse(fs.readFileSync(CLAUDE_SETTINGS_PATH, 'utf-8'));
29
+ }
30
+ catch {
31
+ // Corrupted settings — start fresh
32
+ }
33
+ }
34
+ return {};
35
+ }
36
+ function writeSettings(settings) {
37
+ fs.writeFileSync(CLAUDE_SETTINGS_PATH, JSON.stringify(settings, null, 2) + '\n');
38
+ }
39
+ function isMobHook(entry) {
40
+ // New format: { matcher, hooks: [{ type, command }] }
41
+ if (entry.hooks && Array.isArray(entry.hooks)) {
42
+ return entry.hooks.some((h) => h.type === 'command' && typeof h.command === 'string' && h.command.includes('mob-status'));
43
+ }
44
+ // Old format: { type, command }
45
+ if (entry.type === 'command' && typeof entry.command === 'string' && entry.command.includes('mob-status')) {
46
+ return true;
47
+ }
48
+ return false;
49
+ }
50
+ export function installHooks(packageRoot, quiet = false) {
51
+ const hookScript = process.platform === 'win32'
52
+ ? path.resolve(packageRoot, 'hooks', 'mob-status.ps1')
53
+ : path.resolve(packageRoot, 'hooks', 'mob-status.sh');
54
+ if (!fs.existsSync(hookScript)) {
55
+ if (!quiet)
56
+ console.error(`Hook script not found: ${hookScript}`);
57
+ return;
58
+ }
59
+ const settings = readSettings();
60
+ if (!settings.hooks)
61
+ settings.hooks = {};
62
+ const hookCommand = getHookCommand(packageRoot);
63
+ for (const event of HOOK_EVENTS) {
64
+ if (!settings.hooks[event]) {
65
+ settings.hooks[event] = [];
66
+ }
67
+ // Remove any existing mob hooks (old or new format)
68
+ settings.hooks[event] = settings.hooks[event].filter((entry) => !isMobHook(entry));
69
+ // Add hook in current format
70
+ settings.hooks[event].push({
71
+ matcher: '',
72
+ hooks: [{ type: 'command', command: hookCommand }],
73
+ });
74
+ }
75
+ writeSettings(settings);
76
+ if (!quiet) {
77
+ console.log('Mob hooks installed successfully!');
78
+ console.log(`Hook script: ${hookScript}`);
79
+ console.log(`Events: ${HOOK_EVENTS.join(', ')}`);
80
+ }
81
+ }
82
+ export function uninstallHooks(quiet = false) {
83
+ if (!fs.existsSync(CLAUDE_SETTINGS_PATH)) {
84
+ if (!quiet)
85
+ console.log('No Claude settings found, nothing to uninstall.');
86
+ return;
87
+ }
88
+ let settings;
89
+ try {
90
+ settings = JSON.parse(fs.readFileSync(CLAUDE_SETTINGS_PATH, 'utf-8'));
91
+ }
92
+ catch {
93
+ if (!quiet)
94
+ console.error('Could not parse settings.json');
95
+ return;
96
+ }
97
+ if (!settings.hooks) {
98
+ if (!quiet)
99
+ console.log('No hooks found in settings.');
100
+ return;
101
+ }
102
+ let removed = 0;
103
+ for (const event of Object.keys(settings.hooks)) {
104
+ if (Array.isArray(settings.hooks[event])) {
105
+ const before = settings.hooks[event].length;
106
+ settings.hooks[event] = settings.hooks[event].filter((entry) => !isMobHook(entry));
107
+ removed += before - settings.hooks[event].length;
108
+ if (settings.hooks[event].length === 0) {
109
+ delete settings.hooks[event];
110
+ }
111
+ }
112
+ }
113
+ if (Object.keys(settings.hooks).length === 0) {
114
+ delete settings.hooks;
115
+ }
116
+ writeSettings(settings);
117
+ if (!quiet)
118
+ console.log(`Removed ${removed} mob hook(s) from Claude settings.`);
119
+ }
@@ -9,6 +9,7 @@ import { ScrollbackBuffer } from './scrollback-buffer.js';
9
9
  import { SettingsManager } from './settings-manager.js';
10
10
  import { ensureDir, getMobDir, getInstancesDir, getSessionsDir, getScrollbackDir } from './util/platform.js';
11
11
  import { DEFAULT_PORT } from '../shared/constants.js';
12
+ import { installHooks } from './hooks.js';
12
13
  const port = parseInt(process.env.MOB_PORT || '', 10) || DEFAULT_PORT;
13
14
  const host = process.env.MOB_HOST || '127.0.0.1';
14
15
  // Ensure directories exist
@@ -16,6 +17,9 @@ ensureDir(getMobDir());
16
17
  ensureDir(getInstancesDir());
17
18
  ensureDir(getSessionsDir());
18
19
  ensureDir(getScrollbackDir());
20
+ // Auto-install Claude Code hooks so external instances are discovered
21
+ const packageRoot = new URL('../../..', import.meta.url).pathname;
22
+ installHooks(packageRoot, true);
19
23
  const settingsManager = new SettingsManager();
20
24
  const ptyManager = new PtyManager();
21
25
  const discovery = new DiscoveryService();
@@ -0,0 +1,151 @@
1
+ # Mob status hook for Claude Code (Windows PowerShell)
2
+ # Reads hook event from stdin, writes instance status to ~/.mob/instances/{id}.json
3
+
4
+ $MobDir = Join-Path $env:USERPROFILE ".mob"
5
+ $InstancesDir = Join-Path $MobDir "instances"
6
+ $LogFile = Join-Path $MobDir "hook-debug.log"
7
+ $MobPort = if ($env:MOB_PORT) { $env:MOB_PORT } else { "4040" }
8
+
9
+ New-Item -ItemType Directory -Force -Path $InstancesDir | Out-Null
10
+
11
+ function Log($msg) {
12
+ $ts = Get-Date -Format "HH:mm:ss.fff"
13
+ "$ts $msg" | Out-File -Append -FilePath $LogFile -Encoding UTF8
14
+ }
15
+
16
+ Log "=== Hook started (PID=$PID) ==="
17
+
18
+ # Only report for mob-launched instances
19
+ if (-not $env:MOB_INSTANCE_ID) {
20
+ Log "No MOB_INSTANCE_ID, skipping"
21
+ exit 0
22
+ }
23
+
24
+ # Read JSON from stdin
25
+ try {
26
+ $RawInput = [Console]::In.ReadToEnd()
27
+ } catch {
28
+ Log "stdin read FAILED: $_"
29
+ exit 0
30
+ }
31
+
32
+ if (-not $RawInput -or $RawInput.Trim().Length -eq 0) {
33
+ Log "Empty stdin, exiting"
34
+ exit 0
35
+ }
36
+
37
+ try {
38
+ $Data = $RawInput | ConvertFrom-Json
39
+ } catch {
40
+ Log "JSON parse FAILED: $_"
41
+ exit 0
42
+ }
43
+
44
+ # Claude Code uses hook_event_name, not event
45
+ $HookEvent = if ($Data.hook_event_name) { "$($Data.hook_event_name)" }
46
+ elseif ($Data.event) { "$($Data.event)" }
47
+ else { "" }
48
+ Log "Event=$HookEvent"
49
+
50
+ # Determine instance ID
51
+ $InstanceId = if ($env:MOB_INSTANCE_ID) { $env:MOB_INSTANCE_ID }
52
+ elseif ($Data.session_id) { "$($Data.session_id)" }
53
+ else { "ext-$PID" }
54
+ Log "InstanceId=$InstanceId"
55
+
56
+ $Cwd = if ($Data.cwd) { "$($Data.cwd)" } else { (Get-Location).Path }
57
+
58
+ # Git branch
59
+ $GitBranch = ""
60
+ try { $GitBranch = git -C "$Cwd" branch --show-current 2>$null } catch {}
61
+
62
+ # State from event
63
+ $State = switch ($HookEvent) {
64
+ "SessionStart" { "running" }
65
+ "SessionEnd" { "stopped" }
66
+ "Stop" { "idle" }
67
+ "PreToolUse" { "running" }
68
+ "PostToolUse" { "running" }
69
+ "Notification" { "waiting" }
70
+ "UserPromptSubmit" { "idle" }
71
+ default { "running" }
72
+ }
73
+ Log "State=$State"
74
+
75
+ $ToolName = if ($Data.tool_name) { "$($Data.tool_name)" } else { "" }
76
+
77
+ # Extract user prompt from UserPromptSubmit for auto-naming
78
+ # Claude Code sends the prompt in the "prompt" field
79
+ $Topic = ""
80
+ if ($HookEvent -eq "UserPromptSubmit" -and $Data.prompt) {
81
+ $Topic = ("$($Data.prompt)" -split "`n")[0]
82
+ if ($Topic.Length -gt 80) { $Topic = $Topic.Substring(0, 80) }
83
+ Log "Topic=$Topic"
84
+ }
85
+
86
+ # Task metadata
87
+ $Ticket = ""
88
+ $Subtask = ""
89
+ $Progress = $null
90
+ $TicketStatus = ""
91
+ $TaskFile = Join-Path $Cwd ".mob-task.json"
92
+ if (Test-Path $TaskFile) {
93
+ $Task = Get-Content $TaskFile | ConvertFrom-Json
94
+ $Ticket = if ($Task.ticket) { "$($Task.ticket)" } else { "" }
95
+ $Subtask = if ($Task.subtask) { "$($Task.subtask)" } else { "" }
96
+ $Progress = $Task.progress
97
+ $TicketStatus = if ($Task.ticketStatus) { "$($Task.ticketStatus)" } else { "" }
98
+ }
99
+
100
+ $Timestamp = [long](Get-Date -UFormat %s) * 1000
101
+
102
+ $Status = @{
103
+ id = $InstanceId
104
+ cwd = "$Cwd"
105
+ gitBranch = "$GitBranch"
106
+ state = "$State"
107
+ hookEvent = "$HookEvent"
108
+ ticket = "$Ticket"
109
+ ticketStatus = "$TicketStatus"
110
+ subtask = "$Subtask"
111
+ currentTool = "$ToolName"
112
+ lastUpdated = $Timestamp
113
+ sessionId = "$InstanceId"
114
+ topic = "$Topic"
115
+ }
116
+ if ($null -ne $Progress) { $Status.progress = $Progress }
117
+
118
+ $Json = $Status | ConvertTo-Json -Compress
119
+ Log "JSON=$Json"
120
+
121
+ # Atomic write
122
+ try {
123
+ $TmpFile = Join-Path $InstancesDir ".tmp.$InstanceId.json"
124
+ $FinalFile = Join-Path $InstancesDir "$InstanceId.json"
125
+ $Json | Out-File -FilePath $TmpFile -Encoding UTF8 -NoNewline
126
+ Move-Item -Force $TmpFile $FinalFile
127
+ Log "File written OK"
128
+ } catch {
129
+ Log "File write FAILED: $_"
130
+ }
131
+
132
+ # Best-effort POST (bypass system proxy, use 127.0.0.1 to avoid IPv6)
133
+ try {
134
+ $wc = New-Object System.Net.WebClient
135
+ $wc.Proxy = $null
136
+ $wc.Headers.Add("Content-Type", "application/json")
137
+ $wc.UploadString("http://127.0.0.1:${MobPort}/api/hook", $Json) | Out-Null
138
+ Log "POST OK"
139
+ } catch {
140
+ Log "POST failed: $_"
141
+ }
142
+
143
+ # Clean up on stop
144
+ if ($State -eq "stopped") {
145
+ Start-Job -ScriptBlock {
146
+ Start-Sleep -Seconds 5
147
+ Remove-Item -Force $using:FinalFile
148
+ } | Out-Null
149
+ }
150
+
151
+ Log "=== Hook finished ==="
@@ -0,0 +1,120 @@
1
+ #!/usr/bin/env bash
2
+ # Mob status hook for Claude Code
3
+ # Reads hook event from stdin, writes instance status to ~/.mob/instances/{id}.json
4
+ # and POSTs to the dashboard for instant updates.
5
+
6
+ set -euo pipefail
7
+
8
+ MOB_DIR="$HOME/.mob"
9
+ INSTANCES_DIR="$MOB_DIR/instances"
10
+ MOB_PORT="${MOB_PORT:-4040}"
11
+
12
+ mkdir -p "$INSTANCES_DIR"
13
+
14
+ # Only report for mob-launched instances
15
+ if [ -z "${MOB_INSTANCE_ID:-}" ]; then
16
+ exit 0
17
+ fi
18
+
19
+ # Read JSON from stdin
20
+ INPUT=$(cat)
21
+
22
+ # Determine instance ID
23
+ if [ -n "${MOB_INSTANCE_ID:-}" ]; then
24
+ INSTANCE_ID="$MOB_INSTANCE_ID"
25
+ else
26
+ INSTANCE_ID=$(echo "$INPUT" | jq -r '.session_id // empty' 2>/dev/null || echo "")
27
+ if [ -z "$INSTANCE_ID" ]; then
28
+ INSTANCE_ID="ext-$$"
29
+ fi
30
+ fi
31
+
32
+ # Extract fields from hook input
33
+ CWD=$(echo "$INPUT" | jq -r '.cwd // empty' 2>/dev/null || pwd)
34
+ [ -z "$CWD" ] && CWD=$(pwd)
35
+
36
+ # Try to get git branch
37
+ GIT_BRANCH=""
38
+ if command -v git &>/dev/null && [ -d "$CWD/.git" ] || git -C "$CWD" rev-parse --git-dir &>/dev/null 2>&1; then
39
+ GIT_BRANCH=$(git -C "$CWD" branch --show-current 2>/dev/null || echo "")
40
+ fi
41
+
42
+ # Determine state from hook event type
43
+ # Claude Code uses hook_event_name, fall back to event for compatibility
44
+ HOOK_EVENT=$(echo "$INPUT" | jq -r '.hook_event_name // .event // empty' 2>/dev/null || echo "")
45
+ TOOL_NAME=$(echo "$INPUT" | jq -r '.tool_name // empty' 2>/dev/null || echo "")
46
+
47
+ case "$HOOK_EVENT" in
48
+ "SessionStart") STATE="running" ;;
49
+ "SessionEnd") STATE="stopped" ;;
50
+ "Stop") STATE="idle" ;;
51
+ "PreToolUse") STATE="running" ;;
52
+ "PostToolUse") STATE="running" ;;
53
+ "Notification") STATE="waiting" ;;
54
+ "UserPromptSubmit") STATE="idle" ;;
55
+ *) STATE="running" ;;
56
+ esac
57
+
58
+ # Extract user prompt from UserPromptSubmit for auto-naming
59
+ TOPIC=""
60
+ if [ "$HOOK_EVENT" = "UserPromptSubmit" ]; then
61
+ RAW_MSG=$(echo "$INPUT" | jq -r '.prompt // .message // empty' 2>/dev/null || echo "")
62
+ if [ -n "$RAW_MSG" ]; then
63
+ # Truncate to first 80 chars, first line only
64
+ TOPIC=$(echo "$RAW_MSG" | head -1 | cut -c1-80)
65
+ fi
66
+ fi
67
+
68
+ # Extract session_id from hook input
69
+ SESSION_ID=$(echo "$INPUT" | jq -r '.session_id // empty' 2>/dev/null || echo "")
70
+
71
+ # Read optional task metadata from .mob-task.json in working directory
72
+ TICKET=""
73
+ SUBTASK=""
74
+ PROGRESS=""
75
+ TICKET_STATUS=""
76
+ if [ -f "$CWD/.mob-task.json" ]; then
77
+ TICKET=$(jq -r '.ticket // empty' "$CWD/.mob-task.json" 2>/dev/null || echo "")
78
+ SUBTASK=$(jq -r '.subtask // empty' "$CWD/.mob-task.json" 2>/dev/null || echo "")
79
+ PROGRESS=$(jq -r '.progress // empty' "$CWD/.mob-task.json" 2>/dev/null || echo "")
80
+ TICKET_STATUS=$(jq -r '.ticketStatus // empty' "$CWD/.mob-task.json" 2>/dev/null || echo "")
81
+ fi
82
+
83
+ TIMESTAMP=$(date +%s)000
84
+
85
+ # Build JSON status
86
+ STATUS_JSON=$(cat <<ENDJSON
87
+ {
88
+ "id": "$INSTANCE_ID",
89
+ "cwd": "$CWD",
90
+ "gitBranch": "$GIT_BRANCH",
91
+ "state": "$STATE",
92
+ "hookEvent": "$HOOK_EVENT",
93
+ "ticket": "$TICKET",
94
+ "ticketStatus": "$TICKET_STATUS",
95
+ "subtask": "$SUBTASK",
96
+ "topic": "$TOPIC",
97
+ $([ -n "$PROGRESS" ] && echo "\"progress\": $PROGRESS," || echo "")
98
+ "currentTool": "$TOOL_NAME",
99
+ "lastUpdated": $TIMESTAMP,
100
+ "sessionId": "${SESSION_ID:-$INSTANCE_ID}"
101
+ }
102
+ ENDJSON
103
+ )
104
+
105
+ # Atomic write via tmp+rename
106
+ TMP_FILE="$INSTANCES_DIR/.tmp.${INSTANCE_ID}.json"
107
+ echo "$STATUS_JSON" > "$TMP_FILE"
108
+ mv "$TMP_FILE" "$INSTANCES_DIR/${INSTANCE_ID}.json"
109
+
110
+ # Best-effort POST to dashboard (non-blocking)
111
+ if command -v curl &>/dev/null; then
112
+ curl -s -X POST "http://localhost:${MOB_PORT}/api/hook" \
113
+ -H "Content-Type: application/json" \
114
+ -d "$STATUS_JSON" &>/dev/null &
115
+ fi
116
+
117
+ # If session ended, clean up the status file after a delay
118
+ if [ "$STATE" = "stopped" ]; then
119
+ (sleep 5 && rm -f "$INSTANCES_DIR/${INSTANCE_ID}.json") &
120
+ fi
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mob-coordinator",
3
- "version": "0.2.2",
3
+ "version": "0.3.0",
4
4
  "description": "Local web dashboard for coordinating multiple Claude Code CLI sessions",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -35,6 +35,7 @@
35
35
  "files": [
36
36
  "bin/",
37
37
  "dist/",
38
+ "hooks/",
38
39
  "scripts/postinstall.cjs",
39
40
  "package.json",
40
41
  "README.md"