claude-code-hub 1.5.1 → 1.6.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 +9 -6
- package/lib/tray.js +125 -37
- package/package.json +2 -2
- package/server.js +1 -1
- package/tray/mac/hub-tray.js +495 -0
package/README.md
CHANGED
|
@@ -180,16 +180,17 @@ The shortcuts also work when focus is inside a tool. Kanban keeps <kbd>Ctrl</kbd
|
|
|
180
180
|
--host <addr> Bind address (default: 127.0.0.1)
|
|
181
181
|
--allowed-hosts <list> Extra host names to accept, comma-separated
|
|
182
182
|
--open Open the browser on start
|
|
183
|
-
--
|
|
184
|
-
--
|
|
185
|
-
--
|
|
186
|
-
--
|
|
187
|
-
--
|
|
183
|
+
--detached Keep running when stdin closes (the macOS tray uses it)
|
|
184
|
+
--tray Windows, macOS: run the hub from a tray icon
|
|
185
|
+
--autostart Windows, macOS: same as --tray, and start the tray at logon
|
|
186
|
+
--no-autostart Windows, macOS: stop starting the tray at logon
|
|
187
|
+
--tray-status Windows, macOS: show whether autostart is on and the tray and hub are running
|
|
188
|
+
--app-id <id> Windows, macOS, with --tray or --autostart: the installed app that Open Hub starts
|
|
188
189
|
```
|
|
189
190
|
|
|
190
191
|
If a port is busy, the hub uses a free port and reports it.
|
|
191
192
|
|
|
192
|
-
## Tray icon (Windows)
|
|
193
|
+
## Tray icon (Windows, macOS)
|
|
193
194
|
|
|
194
195
|
```
|
|
195
196
|
claude-code-hub --autostart
|
|
@@ -197,6 +198,8 @@ claude-code-hub --autostart
|
|
|
197
198
|
|
|
198
199
|
The hub runs in the background with an icon in the notification area. Click the icon to open the hub. Right-click it to stop, start or restart the hub, open its log, turn **Start with Windows** on or off, or quit. The dot on the icon shows the state: green is running, amber is starting, red is stopped. If the hub stops unexpectedly, the tray shows a notice and turns red; start the hub again from the menu.
|
|
199
200
|
|
|
201
|
+
On macOS (experimental), the icon is in the menu bar.
|
|
202
|
+
|
|
200
203
|
To turn off autostart, untick **Start with Windows** or run `claude-code-hub --no-autostart`. To check the state, run `claude-code-hub --tray-status`. See [Run the hub from the tray](https://nikiforovall.blog/claude-code-hub/guides/tray/).
|
|
201
204
|
|
|
202
205
|
## Included tools
|
package/lib/tray.js
CHANGED
|
@@ -3,17 +3,55 @@
|
|
|
3
3
|
const { execFileSync, spawn } = require('node:child_process');
|
|
4
4
|
const crypto = require('node:crypto');
|
|
5
5
|
const fs = require('node:fs');
|
|
6
|
+
const os = require('node:os');
|
|
6
7
|
const path = require('node:path');
|
|
7
8
|
const { version } = require('../package.json');
|
|
8
9
|
const { liveHub } = require('./hub-record');
|
|
9
10
|
|
|
10
11
|
const TRAY_SRC = path.join(__dirname, '..', 'tray');
|
|
11
12
|
const RUN_KEY = 'HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Run';
|
|
13
|
+
const LAUNCH_AGENTS = path.join(os.homedir(), 'Library', 'LaunchAgents');
|
|
14
|
+
|
|
15
|
+
function dirHash(hubDir) {
|
|
16
|
+
return crypto.createHash('sha1').update(path.resolve(hubDir).toLowerCase()).digest('hex').slice(0, 8);
|
|
17
|
+
}
|
|
12
18
|
|
|
13
19
|
// One Run value and one tray per hub dir, so a test hub never replaces the real autostart.
|
|
14
20
|
function runValue(hubDir) {
|
|
15
|
-
|
|
16
|
-
|
|
21
|
+
return `ClaudeCodeHub-${dirHash(hubDir)}`;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function launchAgentLabel(hubDir) {
|
|
25
|
+
return `com.claude-code-hub.tray.${dirHash(hubDir)}`;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const xml = (s) => s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
29
|
+
|
|
30
|
+
// AbandonProcessGroup: launchd kills what is left in a job's process group when the job exits,
|
|
31
|
+
// and the hub that the tray started is in it.
|
|
32
|
+
function launchAgentPlist({ label, script }) {
|
|
33
|
+
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
34
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
35
|
+
<plist version="1.0">
|
|
36
|
+
<dict>
|
|
37
|
+
<key>Label</key>
|
|
38
|
+
<string>${xml(label)}</string>
|
|
39
|
+
<key>ProgramArguments</key>
|
|
40
|
+
<array>
|
|
41
|
+
<string>/usr/bin/osascript</string>
|
|
42
|
+
<string>-l</string>
|
|
43
|
+
<string>JavaScript</string>
|
|
44
|
+
<string>${xml(script)}</string>
|
|
45
|
+
</array>
|
|
46
|
+
<key>RunAtLoad</key>
|
|
47
|
+
<true/>
|
|
48
|
+
<key>ProcessType</key>
|
|
49
|
+
<string>Interactive</string>
|
|
50
|
+
<key>AbandonProcessGroup</key>
|
|
51
|
+
<true/>
|
|
52
|
+
</dict>
|
|
53
|
+
</plist>
|
|
54
|
+
`;
|
|
17
55
|
}
|
|
18
56
|
|
|
19
57
|
// An installed app is bound to the origin it was installed from, and its shortcut does not record
|
|
@@ -22,8 +60,75 @@ function appConfig({ appId, port, defaultPort }) {
|
|
|
22
60
|
return appId ? { id: appId, port } : { name: 'Claude Code Hub', port: defaultPort };
|
|
23
61
|
}
|
|
24
62
|
|
|
63
|
+
// A pid file outlives its process, and pids are reused, so the process must still be the tray.
|
|
64
|
+
// tray/mac/hub-tray.js makes the same check before it starts.
|
|
65
|
+
function windows(hubDir) {
|
|
66
|
+
const value = runValue(hubDir);
|
|
67
|
+
const launcher = path.join(hubDir, 'tray', 'launch.vbs');
|
|
68
|
+
return {
|
|
69
|
+
area: 'notification area',
|
|
70
|
+
prepare: () => ({ runValue: value }),
|
|
71
|
+
trayAlive(pid) {
|
|
72
|
+
const out = execFileSync('tasklist', ['/fi', `PID eq ${pid}`, '/fo', 'csv', '/nh'], { encoding: 'utf8' });
|
|
73
|
+
return out.toLowerCase().startsWith('"powershell.exe"');
|
|
74
|
+
},
|
|
75
|
+
autostartName() {
|
|
76
|
+
try {
|
|
77
|
+
execFileSync('reg', ['query', RUN_KEY, '/v', value], { stdio: 'ignore' });
|
|
78
|
+
return value;
|
|
79
|
+
} catch {
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
},
|
|
83
|
+
addAutostart() {
|
|
84
|
+
execFileSync('reg', ['add', RUN_KEY, '/v', value, '/t', 'REG_SZ', '/d', `wscript.exe "${launcher}"`, '/f'], { stdio: 'ignore' });
|
|
85
|
+
},
|
|
86
|
+
removeAutostart() {
|
|
87
|
+
try {
|
|
88
|
+
execFileSync('reg', ['delete', RUN_KEY, '/v', value, '/f'], { stdio: 'ignore' });
|
|
89
|
+
return true;
|
|
90
|
+
} catch {
|
|
91
|
+
return false;
|
|
92
|
+
}
|
|
93
|
+
},
|
|
94
|
+
start: () => spawn('wscript.exe', [launcher], { detached: true, stdio: 'ignore' }).unref(),
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function macos(hubDir, launchAgentsDir) {
|
|
99
|
+
const label = launchAgentLabel(hubDir);
|
|
100
|
+
const plist = path.join(launchAgentsDir, `${label}.plist`);
|
|
101
|
+
const launcher = path.join(hubDir, 'tray', 'mac', 'hub-tray.js');
|
|
102
|
+
const trayPlist = path.join(hubDir, 'tray', 'mac', 'autostart.plist');
|
|
103
|
+
return {
|
|
104
|
+
area: 'menu bar',
|
|
105
|
+
prepare() {
|
|
106
|
+
fs.writeFileSync(trayPlist, launchAgentPlist({ label, script: launcher }));
|
|
107
|
+
return { launchAgent: plist, path: process.env.PATH };
|
|
108
|
+
},
|
|
109
|
+
trayAlive(pid) {
|
|
110
|
+
try {
|
|
111
|
+
return execFileSync('ps', ['-p', String(pid), '-o', 'command='], { encoding: 'utf8' }).includes('hub-tray.js');
|
|
112
|
+
} catch {
|
|
113
|
+
return false;
|
|
114
|
+
}
|
|
115
|
+
},
|
|
116
|
+
autostartName: () => (fs.existsSync(plist) ? label : null),
|
|
117
|
+
addAutostart() {
|
|
118
|
+
fs.mkdirSync(launchAgentsDir, { recursive: true });
|
|
119
|
+
fs.copyFileSync(trayPlist, plist);
|
|
120
|
+
},
|
|
121
|
+
removeAutostart() {
|
|
122
|
+
if (!fs.existsSync(plist)) return false;
|
|
123
|
+
fs.rmSync(plist);
|
|
124
|
+
return true;
|
|
125
|
+
},
|
|
126
|
+
start: () => spawn('/usr/bin/osascript', ['-l', 'JavaScript', launcher], { detached: true, stdio: 'ignore' }).unref(),
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
|
|
25
130
|
// The Run key and the tray start from this copy, never from the package: an npx cache path can vanish.
|
|
26
|
-
function copyTray({ hubDir, port, appId, defaultPort
|
|
131
|
+
function copyTray(plat, { hubDir, port, appId, defaultPort }) {
|
|
27
132
|
const dest = path.join(hubDir, 'tray');
|
|
28
133
|
fs.rmSync(dest, { recursive: true, force: true });
|
|
29
134
|
fs.cpSync(TRAY_SRC, dest, { recursive: true });
|
|
@@ -33,25 +138,14 @@ function copyTray({ hubDir, port, appId, defaultPort, value }) {
|
|
|
33
138
|
version,
|
|
34
139
|
serverJs: path.join(__dirname, '..', 'server.js'),
|
|
35
140
|
node: process.execPath,
|
|
36
|
-
|
|
141
|
+
...plat.prepare(),
|
|
37
142
|
};
|
|
38
143
|
fs.writeFileSync(path.join(dest, 'config.json'), `${JSON.stringify(config, null, 2)}\n`);
|
|
39
|
-
return
|
|
144
|
+
return dest;
|
|
40
145
|
}
|
|
41
146
|
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
if (!Number.isInteger(pid)) return false;
|
|
45
|
-
const out = execFileSync('tasklist', ['/fi', `PID eq ${pid}`, '/fo', 'csv', '/nh'], { encoding: 'utf8' });
|
|
46
|
-
return out.toLowerCase().startsWith(`"${image}"`);
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
async function printStatus({ hubDir, configDir, value }) {
|
|
50
|
-
let autostart = 'off';
|
|
51
|
-
try {
|
|
52
|
-
execFileSync('reg', ['query', RUN_KEY, '/v', value], { stdio: 'ignore' });
|
|
53
|
-
autostart = `on (${value})`;
|
|
54
|
-
} catch {}
|
|
147
|
+
async function printStatus(plat, { hubDir, configDir }) {
|
|
148
|
+
const autostart = plat.autostartName();
|
|
55
149
|
let trayPid = null;
|
|
56
150
|
try {
|
|
57
151
|
trayPid = Number.parseInt(fs.readFileSync(path.join(hubDir, 'tray.pid'), 'utf8'), 10);
|
|
@@ -62,39 +156,33 @@ async function printStatus({ hubDir, configDir, value }) {
|
|
|
62
156
|
} catch {}
|
|
63
157
|
console.log(` Hub dir: ${hubDir}`);
|
|
64
158
|
console.log(` Config dir: ${configDir}`);
|
|
65
|
-
console.log(` Autostart: ${autostart}`);
|
|
66
|
-
console.log(` Tray: ${
|
|
159
|
+
console.log(` Autostart: ${autostart ? `on (${autostart})` : 'off'}`);
|
|
160
|
+
console.log(` Tray: ${Number.isInteger(trayPid) && plat.trayAlive(trayPid) ? `running (pid ${trayPid})` : 'not running'}`);
|
|
67
161
|
console.log(` Hub: ${hub ? `running on port ${hub.port} (pid ${hub.pid})` : 'stopped'}`);
|
|
68
162
|
}
|
|
69
163
|
|
|
70
|
-
async function runTrayCommand({ flag,
|
|
71
|
-
if (platform !== 'win32') {
|
|
72
|
-
console.error(` ${flag} works only on Windows.`);
|
|
164
|
+
async function runTrayCommand({ flag, platform = process.platform, launchAgentsDir = LAUNCH_AGENTS, ...opts }) {
|
|
165
|
+
if (platform !== 'win32' && platform !== 'darwin') {
|
|
166
|
+
console.error(` ${flag} works only on Windows and macOS.`);
|
|
73
167
|
return false;
|
|
74
168
|
}
|
|
75
|
-
const
|
|
169
|
+
const plat = platform === 'darwin' ? macos(opts.hubDir, launchAgentsDir) : windows(opts.hubDir);
|
|
76
170
|
if (flag === '--tray-status') {
|
|
77
|
-
await printStatus(
|
|
171
|
+
await printStatus(plat, opts);
|
|
78
172
|
return true;
|
|
79
173
|
}
|
|
80
174
|
if (flag === '--no-autostart') {
|
|
81
|
-
|
|
82
|
-
execFileSync('reg', ['delete', RUN_KEY, '/v', value, '/f'], { stdio: 'ignore' });
|
|
83
|
-
console.log(' Autostart: removed');
|
|
84
|
-
} catch {
|
|
85
|
-
console.log(' Autostart: not set');
|
|
86
|
-
}
|
|
175
|
+
console.log(plat.removeAutostart() ? ' Autostart: removed' : ' Autostart: not set');
|
|
87
176
|
return true;
|
|
88
177
|
}
|
|
89
|
-
|
|
90
|
-
console.log(` Tray copy: ${path.dirname(vbs)}`);
|
|
178
|
+
console.log(` Tray copy: ${copyTray(plat, opts)}`);
|
|
91
179
|
if (flag === '--autostart') {
|
|
92
|
-
|
|
180
|
+
plat.addAutostart();
|
|
93
181
|
console.log(' Autostart: on (starts at logon)');
|
|
94
182
|
}
|
|
95
|
-
|
|
96
|
-
console.log(
|
|
183
|
+
plat.start();
|
|
184
|
+
console.log(` Tray: started. Look for the hub icon in the ${plat.area}.`);
|
|
97
185
|
return true;
|
|
98
186
|
}
|
|
99
187
|
|
|
100
|
-
module.exports = { runTrayCommand, runValue, appConfig };
|
|
188
|
+
module.exports = { runTrayCommand, runValue, launchAgentLabel, launchAgentPlist, appConfig };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "claude-code-hub",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.6.0",
|
|
4
4
|
"description": "Unified hub for Claude Code tools — Marketplace + Kanban in one PWA",
|
|
5
5
|
"main": "server.js",
|
|
6
6
|
"bin": {
|
|
@@ -46,7 +46,7 @@
|
|
|
46
46
|
"homepage": "https://github.com/NikiforovAll/claude-code-hub#readme",
|
|
47
47
|
"dependencies": {
|
|
48
48
|
"claude-code-cost": "1.2.1",
|
|
49
|
-
"claude-code-kanban": "7.
|
|
49
|
+
"claude-code-kanban": "7.2.0",
|
|
50
50
|
"claude-code-marketplace": "1.3.1",
|
|
51
51
|
"claude-code-memory-explorer": "1.1.2",
|
|
52
52
|
"express": "^4.21.0",
|
package/server.js
CHANGED
|
@@ -1104,7 +1104,7 @@ const onReady = (actual) => {
|
|
|
1104
1104
|
if (!TRAY_FLAG) {
|
|
1105
1105
|
liveHub(HUB_DIR, HUB_TOKEN).then((running) => {
|
|
1106
1106
|
if (!running) {
|
|
1107
|
-
watchStdin();
|
|
1107
|
+
if (!process.argv.includes('--detached')) watchStdin();
|
|
1108
1108
|
listenProxies();
|
|
1109
1109
|
listenWithFallback(app, HUB_PORT, onReady, '');
|
|
1110
1110
|
return;
|
|
@@ -0,0 +1,495 @@
|
|
|
1
|
+
// macOS tray, run by `osascript -l JavaScript`. In the JXA bridge a method with no arguments is
|
|
2
|
+
// read as a property (`task.launch`), a JS null object argument arrives as NSNull (pass `$()` for
|
|
3
|
+
// nil), and an exception in an ObjC callback is dropped silently, so every callback goes through
|
|
4
|
+
// guard().
|
|
5
|
+
ObjC.import('Cocoa');
|
|
6
|
+
ObjC.bindFunction('kill', ['int', ['int', 'int']]);
|
|
7
|
+
|
|
8
|
+
const sa = Application.currentApplication();
|
|
9
|
+
sa.includeStandardAdditions = true;
|
|
10
|
+
const fm = $.NSFileManager.defaultManager;
|
|
11
|
+
|
|
12
|
+
// AppKit enum values, because BridgeSupport may not expose the newer names.
|
|
13
|
+
const ACCESSORY_POLICY = 1;
|
|
14
|
+
const VARIABLE_LENGTH = -1;
|
|
15
|
+
const ON = 1;
|
|
16
|
+
const OFF = 0;
|
|
17
|
+
const SIGTERM = 15;
|
|
18
|
+
const SIGKILL = 9;
|
|
19
|
+
|
|
20
|
+
const STATE_COLORS = { running: [34, 197, 94], starting: [245, 158, 11], stopped: [239, 68, 68] };
|
|
21
|
+
|
|
22
|
+
let HUB_DIR, TRAY_DIR, HUB_FILE, LOG_FILE, TRAY_LOG, PID_FILE, config;
|
|
23
|
+
let target, statusItem, menu, icons;
|
|
24
|
+
let miHeader, miOpen, miStartStop, miRestart, miAuto;
|
|
25
|
+
let hubTask = null;
|
|
26
|
+
let checkedPid = null;
|
|
27
|
+
let checkedIsNode = false;
|
|
28
|
+
let hub = null;
|
|
29
|
+
let state = '';
|
|
30
|
+
let selfTestArgv = null;
|
|
31
|
+
|
|
32
|
+
const dirname = (p) => ObjC.unwrap($(p).stringByDeletingLastPathComponent);
|
|
33
|
+
const basename = (p) => ObjC.unwrap($(p).lastPathComponent);
|
|
34
|
+
const q = (s) => `'${String(s).replace(/'/g, `'\\''`)}'`;
|
|
35
|
+
const exists = (p) => fm.fileExistsAtPath(p);
|
|
36
|
+
|
|
37
|
+
function readText(p) {
|
|
38
|
+
const s = $.NSString.stringWithContentsOfFileEncodingError(p, $.NSUTF8StringEncoding, null);
|
|
39
|
+
return s.isNil() ? null : ObjC.unwrap(s);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function readJson(p) {
|
|
43
|
+
try {
|
|
44
|
+
return JSON.parse(readText(p));
|
|
45
|
+
} catch (e) {
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function appendHandle(p) {
|
|
51
|
+
if (!exists(p)) fm.createFileAtPathContentsAttributes(p, $.NSData.data, $());
|
|
52
|
+
const h = $.NSFileHandle.fileHandleForWritingAtPath(p);
|
|
53
|
+
h.seekToEndOfFile;
|
|
54
|
+
return h;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function log(msg) {
|
|
58
|
+
try {
|
|
59
|
+
const h = appendHandle(TRAY_LOG);
|
|
60
|
+
h.writeData($(`${new Date().toISOString()} ${msg}\n`).dataUsingEncoding($.NSUTF8StringEncoding));
|
|
61
|
+
h.closeFile;
|
|
62
|
+
} catch (e) {
|
|
63
|
+
console.log(`${msg} (tray.log: ${e})`);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function guard(name, fn) {
|
|
68
|
+
return (...args) => {
|
|
69
|
+
try {
|
|
70
|
+
fn(...args);
|
|
71
|
+
} catch (e) {
|
|
72
|
+
log(`${name}: ${e}`);
|
|
73
|
+
}
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function sh(cmd) {
|
|
78
|
+
return sa.doShellScript(cmd);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function notify(msg) {
|
|
82
|
+
try {
|
|
83
|
+
sa.displayNotification(msg, { withTitle: 'Claude Code Hub' });
|
|
84
|
+
} catch (e) {}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function processCommand(pid, column) {
|
|
88
|
+
try {
|
|
89
|
+
return sh(`/bin/ps -p ${pid} -o ${column}=`).trim();
|
|
90
|
+
} catch (e) {
|
|
91
|
+
return '';
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function schedule(seconds, selector, repeats) {
|
|
96
|
+
const t = $.NSTimer.timerWithTimeIntervalTargetSelectorUserInfoRepeats(seconds, target, selector, null, repeats);
|
|
97
|
+
$.NSRunLoop.currentRunLoop.addTimerForMode(t, $.NSRunLoopCommonModes);
|
|
98
|
+
return t;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// A child that exited stays a zombie until NSTask reaps it, and kill(pid, 0) still finds a zombie.
|
|
102
|
+
function alive(pid) {
|
|
103
|
+
if (hubTask && hubTask.processIdentifier === pid) return hubTask.isRunning;
|
|
104
|
+
return $.kill(pid, 0) === 0;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function waitExit(pid, seconds) {
|
|
108
|
+
for (let i = 0; i < seconds * 4 && alive(pid); i++) delay(0.25);
|
|
109
|
+
return !alive(pid);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// The hub writes hub.json once it listens; a dead pid there is a hub that is gone. A pid is checked
|
|
113
|
+
// to be node once, so the 3 s tick starts no process.
|
|
114
|
+
function getHub() {
|
|
115
|
+
const rec = readJson(HUB_FILE);
|
|
116
|
+
if (!rec || !Number.isInteger(rec.pid) || !alive(rec.pid)) return null;
|
|
117
|
+
if (rec.pid !== checkedPid) {
|
|
118
|
+
checkedPid = rec.pid;
|
|
119
|
+
checkedIsNode = /(^|\/)node$/.test(processCommand(rec.pid, 'comm'));
|
|
120
|
+
}
|
|
121
|
+
return checkedIsNode ? rec : null;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function token() {
|
|
125
|
+
return (readText(`${HUB_DIR}/token`) || '').trim();
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// The saved node comes first: the app packages were installed with it, and a version manager may
|
|
129
|
+
// have switched the one on PATH since.
|
|
130
|
+
function hubCommand() {
|
|
131
|
+
const hubArgs = ['--hub-dir', HUB_DIR, '--port', String(config.port), '--detached'];
|
|
132
|
+
const node = exists(config.node) ? config.node : 'node';
|
|
133
|
+
if (exists(config.serverJs)) return [node, config.serverJs, ...hubArgs];
|
|
134
|
+
notify(`The installed hub is gone. Starting ${config.version} with npx.`);
|
|
135
|
+
return ['npx', '-y', `claude-code-hub@${config.version}`, ...hubArgs];
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// launchd gives the tray only /usr/bin:/bin:/usr/sbin:/sbin, and the hub's tools run git and claude,
|
|
139
|
+
// so the hub gets the PATH that --tray saved.
|
|
140
|
+
function startHub() {
|
|
141
|
+
if (getHub()) return updateState();
|
|
142
|
+
if (exists(LOG_FILE) && fm.attributesOfItemAtPathError(LOG_FILE, null).fileSize > 5 * 1024 * 1024) {
|
|
143
|
+
fm.removeItemAtPathError(`${LOG_FILE}.1`, null);
|
|
144
|
+
fm.moveItemAtPathToPathError(LOG_FILE, `${LOG_FILE}.1`, null);
|
|
145
|
+
}
|
|
146
|
+
const cmd = hubCommand();
|
|
147
|
+
log(`start: ${cmd.join(' ')}`);
|
|
148
|
+
const env = $.NSProcessInfo.processInfo.environment.mutableCopy;
|
|
149
|
+
if (config.path) env.setObjectForKey(config.path, 'PATH');
|
|
150
|
+
const out = appendHandle(LOG_FILE);
|
|
151
|
+
const task = $.NSTask.alloc.init;
|
|
152
|
+
task.launchPath = '/usr/bin/env';
|
|
153
|
+
task.arguments = $(cmd);
|
|
154
|
+
task.environment = env;
|
|
155
|
+
task.currentDirectoryPath = HUB_DIR;
|
|
156
|
+
task.standardInput = $.NSFileHandle.fileHandleWithNullDevice;
|
|
157
|
+
task.standardOutput = out;
|
|
158
|
+
task.standardError = out;
|
|
159
|
+
task.launch;
|
|
160
|
+
out.closeFile;
|
|
161
|
+
hubTask = task;
|
|
162
|
+
setState('starting');
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// The shutdown route lets the hub stop its tools and remove hub.json; signals are the fallback for
|
|
166
|
+
// a hub that does not answer. The tools exit on their own when the hub's IPC channel closes.
|
|
167
|
+
function stopHub() {
|
|
168
|
+
const rec = getHub();
|
|
169
|
+
const pid = rec ? rec.pid : hubTask && hubTask.processIdentifier;
|
|
170
|
+
if (rec) {
|
|
171
|
+
log(`stop: pid ${rec.pid}`);
|
|
172
|
+
try {
|
|
173
|
+
sh(`/usr/bin/curl -s -m 3 -X POST ${q(`http://127.0.0.1:${rec.port}/api/shutdown?token=${token()}`)}`);
|
|
174
|
+
waitExit(rec.pid, 15);
|
|
175
|
+
} catch (e) {
|
|
176
|
+
log(`shutdown request failed: ${e}`);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
if (pid && alive(pid)) {
|
|
180
|
+
log(`kill: pid ${pid}`);
|
|
181
|
+
$.kill(pid, SIGTERM);
|
|
182
|
+
if (!waitExit(pid, 5)) $.kill(pid, SIGKILL);
|
|
183
|
+
}
|
|
184
|
+
hubTask = null;
|
|
185
|
+
updateState();
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function toggleHub() {
|
|
189
|
+
if (state === 'stopped') startHub();
|
|
190
|
+
else stopHub();
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function restartHub() {
|
|
194
|
+
stopHub();
|
|
195
|
+
startHub();
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// Chrome and Edge put an installed app's shim in ~/Applications, with the app id in its Info.plist.
|
|
199
|
+
function findAppShim() {
|
|
200
|
+
const home = ObjC.unwrap($.NSHomeDirectory());
|
|
201
|
+
const browsers = [
|
|
202
|
+
['Chrome Apps.localized', 'com.google.Chrome'],
|
|
203
|
+
['Edge Apps.localized', 'com.microsoft.edgemac'],
|
|
204
|
+
];
|
|
205
|
+
for (const [folder, bundle] of browsers) {
|
|
206
|
+
const base = `${home}/Applications/${folder}`;
|
|
207
|
+
const names = ObjC.deepUnwrap(fm.contentsOfDirectoryAtPathError(base, null)) || [];
|
|
208
|
+
for (const name of names) {
|
|
209
|
+
if (!name.endsWith('.app')) continue;
|
|
210
|
+
if (!config.app.id && name !== `${config.app.name}.app`) continue;
|
|
211
|
+
const info = ObjC.deepUnwrap($.NSDictionary.dictionaryWithContentsOfFile(`${base}/${name}/Contents/Info.plist`));
|
|
212
|
+
if (!info || !info.CrAppModeShortcutID) continue;
|
|
213
|
+
if (config.app.id && info.CrAppModeShortcutID !== config.app.id) continue;
|
|
214
|
+
return {
|
|
215
|
+
id: info.CrAppModeShortcutID,
|
|
216
|
+
bundle: info.CrBundleIdentifier || bundle,
|
|
217
|
+
profile: info.CrAppModeProfileDir ? basename(info.CrAppModeProfileDir) : null,
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
return null;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// Chrome loads any URL into an app, so a hub on another port must not open in it.
|
|
225
|
+
function openHub() {
|
|
226
|
+
const rec = getHub();
|
|
227
|
+
if (!rec) return;
|
|
228
|
+
const url = `http://localhost:${rec.port}/?token=${token()}`;
|
|
229
|
+
const shim = rec.port === config.app.port ? findAppShim() : null;
|
|
230
|
+
if (shim) {
|
|
231
|
+
const args = [`--app-id=${shim.id}`, `--app-launch-url-for-shortcuts-menu-item=${url}`];
|
|
232
|
+
if (shim.profile) args.unshift(`--profile-directory=${shim.profile}`);
|
|
233
|
+
try {
|
|
234
|
+
sh(`/usr/bin/open -n -b ${q(shim.bundle)} --args ${args.map(q).join(' ')}`);
|
|
235
|
+
return;
|
|
236
|
+
} catch (e) {
|
|
237
|
+
log(`open app failed: ${e}`);
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
sh(`/usr/bin/open ${q(url)}`);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function openLog() {
|
|
244
|
+
if (exists(LOG_FILE)) sh(`/usr/bin/open -t ${q(LOG_FILE)}`);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function autostartOn() {
|
|
248
|
+
return exists(config.launchAgent);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function toggleAutostart() {
|
|
252
|
+
if (autostartOn()) {
|
|
253
|
+
fm.removeItemAtPathError(config.launchAgent, null);
|
|
254
|
+
} else {
|
|
255
|
+
fm.createDirectoryAtPathWithIntermediateDirectoriesAttributesError(dirname(config.launchAgent), true, $(), null);
|
|
256
|
+
fm.copyItemAtPathToPathError(`${TRAY_DIR}/mac/autostart.plist`, config.launchAgent, null);
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function configDirName() {
|
|
261
|
+
const hubConfig = readJson(`${HUB_DIR}/config.json`);
|
|
262
|
+
return hubConfig && hubConfig.activeConfigDir ? basename(hubConfig.activeConfigDir) : null;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
function color(r, g, b) {
|
|
266
|
+
return $.NSColor.colorWithSRGBRedGreenBlueAlpha(r / 255, g / 255, b / 255, 1);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// public/icons/icon-192.svg redrawn at menu bar size, with a status dot in place of the bottom-right
|
|
270
|
+
// tile. AppKit's origin is bottom-left, so y is flipped from the SVG.
|
|
271
|
+
function trayImage(dotRgb) {
|
|
272
|
+
const size = 18;
|
|
273
|
+
const k = size / 192;
|
|
274
|
+
const img = $.NSImage.alloc.initWithSize($.NSMakeSize(size, size));
|
|
275
|
+
img.lockFocus;
|
|
276
|
+
color(26, 26, 46).setFill;
|
|
277
|
+
$.NSBezierPath.bezierPathWithRoundedRectXRadiusYRadius($.NSMakeRect(0, 0, size, size), 32 * k, 32 * k).fill;
|
|
278
|
+
const stroke = Math.max(1, 6 * k);
|
|
279
|
+
const tile = 40 * k;
|
|
280
|
+
color(233, 69, 96).setStroke;
|
|
281
|
+
for (const [x, y] of [
|
|
282
|
+
[44, 44],
|
|
283
|
+
[108, 44],
|
|
284
|
+
[44, 108],
|
|
285
|
+
]) {
|
|
286
|
+
const rect = $.NSMakeRect(x * k + stroke / 2, (192 - y - 40) * k + stroke / 2, tile - stroke, tile - stroke);
|
|
287
|
+
const p = $.NSBezierPath.bezierPathWithRoundedRectXRadiusYRadius(rect, Math.max(0.5, 6 * k), Math.max(0.5, 6 * k));
|
|
288
|
+
p.lineWidth = stroke;
|
|
289
|
+
p.stroke;
|
|
290
|
+
}
|
|
291
|
+
const c = 128 * k;
|
|
292
|
+
const dot = size * 0.2;
|
|
293
|
+
color(...dotRgb).setFill;
|
|
294
|
+
$.NSBezierPath.bezierPathWithOvalInRect($.NSMakeRect(c - dot, (192 - 128) * k - dot, 2 * dot, 2 * dot)).fill;
|
|
295
|
+
img.unlockFocus;
|
|
296
|
+
return img;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
function setState(s) {
|
|
300
|
+
if (state === s) return;
|
|
301
|
+
state = s;
|
|
302
|
+
statusItem.button.image = icons[s];
|
|
303
|
+
statusItem.button.toolTip = `Claude Code Hub: ${s}`;
|
|
304
|
+
miStartStop.title = s === 'stopped' ? 'Start Hub' : 'Stop Hub';
|
|
305
|
+
miOpen.enabled = s === 'running';
|
|
306
|
+
miRestart.enabled = s !== 'stopped';
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
function updateHeader() {
|
|
310
|
+
const parts = [hub ? `Running on port ${hub.port}` : state === 'starting' ? 'Starting' : 'Stopped'];
|
|
311
|
+
const name = configDirName();
|
|
312
|
+
if (name) parts.push(name);
|
|
313
|
+
miHeader.title = parts.join(` ${String.fromCharCode(0xb7)} `);
|
|
314
|
+
miAuto.state = autostartOn() ? ON : OFF;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
// stopHub clears hubTask, so a hubTask that has exited is a hub that died on its own.
|
|
318
|
+
function updateState() {
|
|
319
|
+
hub = getHub();
|
|
320
|
+
if (hub) return setState('running');
|
|
321
|
+
if (hubTask && hubTask.isRunning) return setState('starting');
|
|
322
|
+
if (hubTask) {
|
|
323
|
+
hubTask = null;
|
|
324
|
+
log('hub exited');
|
|
325
|
+
notify('The hub stopped. Start it from the menu bar; see Open log.');
|
|
326
|
+
}
|
|
327
|
+
setState('stopped');
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
function quit() {
|
|
331
|
+
stopHub();
|
|
332
|
+
fm.removeItemAtPathError(PID_FILE, null);
|
|
333
|
+
$.NSApplication.sharedApplication.terminate(null);
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
function menuSnapshot() {
|
|
337
|
+
updateState();
|
|
338
|
+
updateHeader();
|
|
339
|
+
const items = [];
|
|
340
|
+
for (let i = 0; i < menu.numberOfItems; i++) {
|
|
341
|
+
const m = menu.itemAtIndex(i);
|
|
342
|
+
if (m.isSeparatorItem) continue;
|
|
343
|
+
items.push({ title: ObjC.unwrap(m.title), enabled: m.isEnabled, checked: m.state === ON });
|
|
344
|
+
}
|
|
345
|
+
return { state, items };
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
function waitForRunning() {
|
|
349
|
+
for (let i = 0; i < 120 && state !== 'running'; i++) {
|
|
350
|
+
delay(1);
|
|
351
|
+
updateState();
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
const SELF_TEST_ACTIONS = {
|
|
356
|
+
start: () => {
|
|
357
|
+
toggleHub();
|
|
358
|
+
waitForRunning();
|
|
359
|
+
},
|
|
360
|
+
stop: toggleHub,
|
|
361
|
+
restart: () => {
|
|
362
|
+
restartHub();
|
|
363
|
+
waitForRunning();
|
|
364
|
+
},
|
|
365
|
+
autostart: toggleAutostart,
|
|
366
|
+
};
|
|
367
|
+
|
|
368
|
+
// --self-test [--actions stop,start,…] [--screenshot <png>]: runs the menu's handlers without
|
|
369
|
+
// clicks, prints the menu after each one as JSON, and captures the open menu.
|
|
370
|
+
function selfTest(argv) {
|
|
371
|
+
const arg = (name) => {
|
|
372
|
+
const i = argv.indexOf(name);
|
|
373
|
+
return i >= 0 ? argv[i + 1] : null;
|
|
374
|
+
};
|
|
375
|
+
const steps = [{ step: 'initial', ...menuSnapshot() }];
|
|
376
|
+
for (const action of (arg('--actions') || '').split(',').filter(Boolean)) {
|
|
377
|
+
if (!SELF_TEST_ACTIONS[action]) throw new Error(`unknown action ${action}`);
|
|
378
|
+
SELF_TEST_ACTIONS[action]();
|
|
379
|
+
steps.push({ step: action, ...menuSnapshot() });
|
|
380
|
+
}
|
|
381
|
+
const shot = arg('--screenshot');
|
|
382
|
+
if (shot) {
|
|
383
|
+
const cap = $.NSTask.alloc.init;
|
|
384
|
+
cap.launchPath = '/usr/sbin/screencapture';
|
|
385
|
+
cap.arguments = $(['-x', '-T', '1', shot]);
|
|
386
|
+
cap.launch;
|
|
387
|
+
schedule(2.5, 'closeMenu:', false);
|
|
388
|
+
statusItem.button.performClick(null);
|
|
389
|
+
cap.waitUntilExit;
|
|
390
|
+
}
|
|
391
|
+
return { ok: true, steps };
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
function runSelfTest() {
|
|
395
|
+
let result;
|
|
396
|
+
try {
|
|
397
|
+
result = selfTest(selfTestArgv);
|
|
398
|
+
} catch (e) {
|
|
399
|
+
result = { ok: false, error: String(e) };
|
|
400
|
+
}
|
|
401
|
+
if (hubTask) stopHub();
|
|
402
|
+
$.NSFileHandle.fileHandleWithStandardOutput.writeData(
|
|
403
|
+
$(`${JSON.stringify(result, null, 2)}\n`).dataUsingEncoding($.NSUTF8StringEncoding),
|
|
404
|
+
);
|
|
405
|
+
$.NSApplication.sharedApplication.terminate(null);
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
function buildUi() {
|
|
409
|
+
const handler = (name, fn) => ({ types: ['void', ['id']], implementation: guard(name, fn) });
|
|
410
|
+
ObjC.registerSubclass({
|
|
411
|
+
name: 'HubTrayTarget',
|
|
412
|
+
protocols: ['NSMenuDelegate'],
|
|
413
|
+
methods: {
|
|
414
|
+
'openHub:': handler('open', openHub),
|
|
415
|
+
'toggleHub:': handler('start/stop', toggleHub),
|
|
416
|
+
'restartHub:': handler('restart', restartHub),
|
|
417
|
+
'openLog:': handler('log', openLog),
|
|
418
|
+
'toggleAutostart:': handler('autostart', toggleAutostart),
|
|
419
|
+
'quit:': handler('quit', quit),
|
|
420
|
+
'tick:': handler('tick', updateState),
|
|
421
|
+
'menuWillOpen:': handler('menu', updateHeader),
|
|
422
|
+
'closeMenu:': handler('close menu', () => menu.cancelTracking),
|
|
423
|
+
'selfTest:': handler('self-test', runSelfTest),
|
|
424
|
+
},
|
|
425
|
+
});
|
|
426
|
+
target = $.HubTrayTarget.alloc.init;
|
|
427
|
+
|
|
428
|
+
icons = {};
|
|
429
|
+
for (const s of Object.keys(STATE_COLORS)) icons[s] = trayImage(STATE_COLORS[s]);
|
|
430
|
+
|
|
431
|
+
menu = $.NSMenu.alloc.init;
|
|
432
|
+
menu.autoenablesItems = false;
|
|
433
|
+
menu.delegate = target;
|
|
434
|
+
const add = (title, action) => {
|
|
435
|
+
const m = $.NSMenuItem.alloc.initWithTitleActionKeyEquivalent(title, action, '');
|
|
436
|
+
if (action) m.target = target;
|
|
437
|
+
menu.addItem(m);
|
|
438
|
+
return m;
|
|
439
|
+
};
|
|
440
|
+
const separator = () => menu.addItem($.NSMenuItem.separatorItem);
|
|
441
|
+
miHeader = add('Claude Code Hub', null);
|
|
442
|
+
miHeader.enabled = false;
|
|
443
|
+
separator();
|
|
444
|
+
miOpen = add('Open Hub', 'openHub:');
|
|
445
|
+
miStartStop = add('Stop Hub', 'toggleHub:');
|
|
446
|
+
miRestart = add('Restart Hub', 'restartHub:');
|
|
447
|
+
separator();
|
|
448
|
+
add('Open log', 'openLog:');
|
|
449
|
+
miAuto = add('Open at Login', 'toggleAutostart:');
|
|
450
|
+
separator();
|
|
451
|
+
add('Quit (stops the hub)', 'quit:');
|
|
452
|
+
|
|
453
|
+
statusItem = $.NSStatusBar.systemStatusBar.statusItemWithLength(VARIABLE_LENGTH);
|
|
454
|
+
statusItem.menu = menu;
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
function run(argv) {
|
|
458
|
+
const script = ObjC.deepUnwrap($.NSProcessInfo.processInfo.arguments).find((a) => a.endsWith('hub-tray.js'));
|
|
459
|
+
TRAY_DIR = dirname(dirname(script));
|
|
460
|
+
HUB_DIR = dirname(TRAY_DIR);
|
|
461
|
+
HUB_FILE = `${HUB_DIR}/hub.json`;
|
|
462
|
+
LOG_FILE = `${HUB_DIR}/hub.log`;
|
|
463
|
+
TRAY_LOG = `${HUB_DIR}/tray.log`;
|
|
464
|
+
PID_FILE = `${HUB_DIR}/tray.pid`;
|
|
465
|
+
config = readJson(`${TRAY_DIR}/config.json`);
|
|
466
|
+
if (!config) {
|
|
467
|
+
sa.displayAlert('Claude Code Hub', { message: `Missing ${TRAY_DIR}/config.json. Run: claude-code-hub --tray` });
|
|
468
|
+
return;
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
// lib/tray.js makes the same check for --tray-status.
|
|
472
|
+
const testing = argv.includes('--self-test');
|
|
473
|
+
const myPid = $.NSProcessInfo.processInfo.processIdentifier;
|
|
474
|
+
if (!testing) {
|
|
475
|
+
const other = Number.parseInt(readText(PID_FILE) || '', 10);
|
|
476
|
+
if (Number.isInteger(other) && other !== myPid && processCommand(other, 'command').includes('hub-tray.js')) return;
|
|
477
|
+
$(`${myPid}\n`).writeToFileAtomicallyEncodingError(PID_FILE, true, $.NSUTF8StringEncoding, null);
|
|
478
|
+
log(`tray started: pid ${myPid}`);
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
const app = $.NSApplication.sharedApplication;
|
|
482
|
+
app.setActivationPolicy(ACCESSORY_POLICY);
|
|
483
|
+
buildUi();
|
|
484
|
+
|
|
485
|
+
if (testing) {
|
|
486
|
+
selfTestArgv = argv;
|
|
487
|
+
setState('stopped');
|
|
488
|
+
schedule(0.5, 'selfTest:', false);
|
|
489
|
+
} else {
|
|
490
|
+
setState('starting');
|
|
491
|
+
guard('start', startHub)();
|
|
492
|
+
schedule(3, 'tick:', true);
|
|
493
|
+
}
|
|
494
|
+
app.run;
|
|
495
|
+
}
|