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 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
- --tray Windows: run the hub from a tray icon
184
- --autostart Windows: same as --tray, and start the tray at logon
185
- --no-autostart Windows: stop starting the tray at logon
186
- --tray-status Windows: show whether autostart is on and the tray and hub are running
187
- --app-id <id> Windows, with --tray or --autostart: the installed app that Open Hub starts
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
- const hash = crypto.createHash('sha1').update(path.resolve(hubDir).toLowerCase()).digest('hex').slice(0, 8);
16
- return `ClaudeCodeHub-${hash}`;
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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
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, value }) {
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
- runValue: value,
141
+ ...plat.prepare(),
37
142
  };
38
143
  fs.writeFileSync(path.join(dest, 'config.json'), `${JSON.stringify(config, null, 2)}\n`);
39
- return path.join(dest, 'launch.vbs');
144
+ return dest;
40
145
  }
41
146
 
42
- // A pid file outlives its process, and Windows reuses pids, so the image name must match too.
43
- function pidAlive(pid, image) {
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: ${pidAlive(trayPid, 'powershell.exe') ? `running (pid ${trayPid})` : 'not running'}`);
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, hubDir, port, defaultPort, appId, configDir, platform = process.platform }) {
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 value = runValue(hubDir);
169
+ const plat = platform === 'darwin' ? macos(opts.hubDir, launchAgentsDir) : windows(opts.hubDir);
76
170
  if (flag === '--tray-status') {
77
- await printStatus({ hubDir, configDir, value });
171
+ await printStatus(plat, opts);
78
172
  return true;
79
173
  }
80
174
  if (flag === '--no-autostart') {
81
- try {
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
- const vbs = copyTray({ hubDir, port, appId, defaultPort, value });
90
- console.log(` Tray copy: ${path.dirname(vbs)}`);
178
+ console.log(` Tray copy: ${copyTray(plat, opts)}`);
91
179
  if (flag === '--autostart') {
92
- execFileSync('reg', ['add', RUN_KEY, '/v', value, '/t', 'REG_SZ', '/d', `wscript.exe "${vbs}"`, '/f'], { stdio: 'ignore' });
180
+ plat.addAutostart();
93
181
  console.log(' Autostart: on (starts at logon)');
94
182
  }
95
- spawn('wscript.exe', [vbs], { detached: true, stdio: 'ignore' }).unref();
96
- console.log(' Tray: started. Look for the hub icon in the notification area.');
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.5.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.1.2",
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
+ }