mob-coordinator 0.2.2 → 0.3.1

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.
@@ -5,7 +5,7 @@
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
6
  <title>Mob — Claude Instance Dashboard</title>
7
7
  <link rel="icon" href="/favicon.png" />
8
- <script type="module" crossorigin src="/assets/index-DkdPImCW.js"></script>
8
+ <script type="module" crossorigin src="/assets/index-D6WthCp0.js"></script>
9
9
  <link rel="stylesheet" crossorigin href="/assets/index-BE5nxcXU.css">
10
10
  </head>
11
11
  <body>
@@ -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();
@@ -4,16 +4,19 @@ export const DEFAULT_SETTINGS = {
4
4
  toggleSidebar: 'Alt+KeyB',
5
5
  cycleInstanceDown: 'Alt+ArrowDown',
6
6
  cycleInstanceUp: 'Alt+ArrowUp',
7
- jumpToInstance1: 'Mod+Digit1',
8
- jumpToInstance2: 'Mod+Digit2',
9
- jumpToInstance3: 'Mod+Digit3',
10
- jumpToInstance4: 'Mod+Digit4',
11
- jumpToInstance5: 'Mod+Digit5',
12
- jumpToInstance6: 'Mod+Digit6',
13
- jumpToInstance7: 'Mod+Digit7',
14
- jumpToInstance8: 'Mod+Digit8',
15
- jumpToInstance9: 'Mod+Digit9',
7
+ jumpToInstance1: 'Alt+Digit1',
8
+ jumpToInstance2: 'Alt+Digit2',
9
+ jumpToInstance3: 'Alt+Digit3',
10
+ jumpToInstance4: 'Alt+Digit4',
11
+ jumpToInstance5: 'Alt+Digit5',
12
+ jumpToInstance6: 'Alt+Digit6',
13
+ jumpToInstance7: 'Alt+Digit7',
14
+ jumpToInstance8: 'Alt+Digit8',
15
+ jumpToInstance9: 'Alt+Digit9',
16
16
  resumeInstance: 'Alt+KeyR',
17
+ killInstance: 'Alt+KeyW',
18
+ dismissInstance: 'Alt+KeyX',
19
+ openSettings: 'Alt+Comma',
17
20
  },
18
21
  launch: {
19
22
  cwd: '',
@@ -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.1",
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"