claude-code-hub 1.2.1 → 1.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.
package/README.md CHANGED
@@ -58,10 +58,13 @@ Explore the memory files that Claude Code loads for a project.
58
58
  ## Quick start
59
59
 
60
60
  ```bash
61
- npx claude-code-hub --open
61
+ npm i -g claude-code-hub
62
+ claude-code-hub --open
62
63
  ```
63
64
 
64
- The hub starts on `http://localhost:3540` and opens the browser. To get a window with no browser UI, install the page as an app from the browser menu.
65
+ The hub starts on `http://localhost:3540` and opens the browser. To get a window with no browser UI, install the page as an app from the browser menu. To update, run `npm i -g claude-code-hub@latest`.
66
+
67
+ To try the hub without installing it, run `npx claude-code-hub --open`. Each npx version runs from a different cache folder, so use a global install to keep the hub.
65
68
 
66
69
  ### From source
67
70
 
@@ -177,10 +180,25 @@ The shortcuts also work when focus is inside a tool. Kanban keeps <kbd>Ctrl</kbd
177
180
  --host <addr> Bind address (default: 127.0.0.1)
178
181
  --allowed-hosts <list> Extra host names to accept, comma-separated
179
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
180
188
  ```
181
189
 
182
190
  If a port is busy, the hub uses a free port and reports it.
183
191
 
192
+ ## Tray icon (Windows)
193
+
194
+ ```
195
+ claude-code-hub --autostart
196
+ ```
197
+
198
+ 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
+ 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
+
184
202
  ## Included tools
185
203
 
186
204
  | Tool | Docs | Repo | Submodule | Default port |
@@ -0,0 +1,46 @@
1
+ 'use strict';
2
+
3
+ const fs = require('node:fs');
4
+ const http = require('node:http');
5
+ const path = require('node:path');
6
+
7
+ function recordFile(hubDir) {
8
+ return path.join(hubDir, 'hub.json');
9
+ }
10
+
11
+ function readRecord(hubDir) {
12
+ try {
13
+ return JSON.parse(fs.readFileSync(recordFile(hubDir), 'utf8'));
14
+ } catch {
15
+ return null;
16
+ }
17
+ }
18
+
19
+ function writeRecord(hubDir, port) {
20
+ fs.writeFileSync(recordFile(hubDir), `${JSON.stringify({ pid: process.pid, port })}\n`);
21
+ }
22
+
23
+ function removeRecord(hubDir) {
24
+ if (readRecord(hubDir)?.pid === process.pid) fs.rmSync(recordFile(hubDir), { force: true });
25
+ }
26
+
27
+ // A killed hub leaves its record behind, and its pid and port can be reused, so only a hub that
28
+ // accepts this hub dir's token counts as live. http.get with no agent, not fetch: the callers exit
29
+ // right after, and on Windows process.exit with an undici socket still closing trips a libuv assertion.
30
+ function liveHub(hubDir, token) {
31
+ const record = readRecord(hubDir);
32
+ if (!Number.isInteger(record?.port)) return Promise.resolve(null);
33
+ return new Promise((resolve) => {
34
+ const req = http.get(
35
+ { host: '127.0.0.1', port: record.port, path: `/api/config?token=${token}`, agent: false, timeout: 2000 },
36
+ (res) => {
37
+ res.resume();
38
+ resolve(res.statusCode === 200 ? record : null);
39
+ },
40
+ );
41
+ req.on('timeout', () => req.destroy());
42
+ req.on('error', () => resolve(null));
43
+ });
44
+ }
45
+
46
+ module.exports = { writeRecord, removeRecord, liveHub };
package/lib/tray.js ADDED
@@ -0,0 +1,100 @@
1
+ 'use strict';
2
+
3
+ const { execFileSync, spawn } = require('node:child_process');
4
+ const crypto = require('node:crypto');
5
+ const fs = require('node:fs');
6
+ const path = require('node:path');
7
+ const { version } = require('../package.json');
8
+ const { liveHub } = require('./hub-record');
9
+
10
+ const TRAY_SRC = path.join(__dirname, '..', 'tray');
11
+ const RUN_KEY = 'HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Run';
12
+
13
+ // One Run value and one tray per hub dir, so a test hub never replaces the real autostart.
14
+ function runValue(hubDir) {
15
+ const hash = crypto.createHash('sha1').update(path.resolve(hubDir).toLowerCase()).digest('hex').slice(0, 8);
16
+ return `ClaudeCodeHub-${hash}`;
17
+ }
18
+
19
+ // An installed app is bound to the origin it was installed from, and its shortcut does not record
20
+ // it, so the tray opens the app only when the hub runs on the port the app is assumed to serve.
21
+ function appConfig({ appId, port, defaultPort }) {
22
+ return appId ? { id: appId, port } : { name: 'Claude Code Hub', port: defaultPort };
23
+ }
24
+
25
+ // 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 }) {
27
+ const dest = path.join(hubDir, 'tray');
28
+ fs.rmSync(dest, { recursive: true, force: true });
29
+ fs.cpSync(TRAY_SRC, dest, { recursive: true });
30
+ const config = {
31
+ port,
32
+ app: appConfig({ appId, port, defaultPort }),
33
+ version,
34
+ serverJs: path.join(__dirname, '..', 'server.js'),
35
+ node: process.execPath,
36
+ runValue: value,
37
+ };
38
+ fs.writeFileSync(path.join(dest, 'config.json'), `${JSON.stringify(config, null, 2)}\n`);
39
+ return path.join(dest, 'launch.vbs');
40
+ }
41
+
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 {}
55
+ let trayPid = null;
56
+ try {
57
+ trayPid = Number.parseInt(fs.readFileSync(path.join(hubDir, 'tray.pid'), 'utf8'), 10);
58
+ } catch {}
59
+ let hub = null;
60
+ try {
61
+ hub = await liveHub(hubDir, fs.readFileSync(path.join(hubDir, 'token'), 'utf8').trim());
62
+ } catch {}
63
+ console.log(` Hub dir: ${hubDir}`);
64
+ console.log(` Config dir: ${configDir}`);
65
+ console.log(` Autostart: ${autostart}`);
66
+ console.log(` Tray: ${pidAlive(trayPid, 'powershell.exe') ? `running (pid ${trayPid})` : 'not running'}`);
67
+ console.log(` Hub: ${hub ? `running on port ${hub.port} (pid ${hub.pid})` : 'stopped'}`);
68
+ }
69
+
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.`);
73
+ return false;
74
+ }
75
+ const value = runValue(hubDir);
76
+ if (flag === '--tray-status') {
77
+ await printStatus({ hubDir, configDir, value });
78
+ return true;
79
+ }
80
+ 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
+ }
87
+ return true;
88
+ }
89
+ const vbs = copyTray({ hubDir, port, appId, defaultPort, value });
90
+ console.log(` Tray copy: ${path.dirname(vbs)}`);
91
+ if (flag === '--autostart') {
92
+ execFileSync('reg', ['add', RUN_KEY, '/v', value, '/t', 'REG_SZ', '/d', `wscript.exe "${vbs}"`, '/f'], { stdio: 'ignore' });
93
+ console.log(' Autostart: on (starts at logon)');
94
+ }
95
+ spawn('wscript.exe', [vbs], { detached: true, stdio: 'ignore' }).unref();
96
+ console.log(' Tray: started. Look for the hub icon in the notification area.');
97
+ return true;
98
+ }
99
+
100
+ module.exports = { runTrayCommand, runValue, appConfig };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-code-hub",
3
- "version": "1.2.1",
3
+ "version": "1.3.1",
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.0",
49
- "claude-code-kanban": "5.4.0",
49
+ "claude-code-kanban": "6.1.1",
50
50
  "claude-code-marketplace": "1.3.0",
51
51
  "claude-code-memory-explorer": "1.1.1",
52
52
  "express": "^4.21.0",
@@ -60,6 +60,7 @@
60
60
  "public/**/*",
61
61
  "lib/**/*",
62
62
  "plugin/**/*",
63
+ "tray/**/*",
63
64
  "packages/claude-hub-sdk/src/**/*"
64
65
  ],
65
66
  "devDependencies": {
package/server.js CHANGED
@@ -16,6 +16,7 @@ const { ping, stats } = require('./lib/ipc');
16
16
  const { killChild } = require('./lib/kill-child');
17
17
  const { readRegistry, themeConfig } = require('./lib/themes');
18
18
  const { createUpdateCheck } = require('./lib/update-check');
19
+ const { liveHub, removeRecord, writeRecord } = require('./lib/hub-record');
19
20
  const { version: HUB_VERSION } = require('./package.json');
20
21
 
21
22
  function getArg(name) {
@@ -26,7 +27,8 @@ function getArg(name) {
26
27
  return process.argv[idx + 1] || null;
27
28
  }
28
29
 
29
- const HUB_PORT = parseInt(getArg('port') || process.env.PORT || '3540', 10);
30
+ const DEFAULT_HUB_PORT = 3540;
31
+ const HUB_PORT = parseInt(getArg('port') || process.env.PORT || DEFAULT_HUB_PORT, 10);
30
32
  let hubPort = HUB_PORT;
31
33
 
32
34
  const POOL_SIZE = Math.max(1, parseInt(getArg('pool-size') || '3', 10));
@@ -51,6 +53,19 @@ function activePool() {
51
53
  const HUB_DIR = path.resolve(expandHome(getArg('hub-dir') || process.env.CLAUDE_HUB_DIR || '~/.claude-hub'));
52
54
  const HUB_CONFIG_FILE = path.join(HUB_DIR, 'config.json');
53
55
 
56
+ const TRAY_FLAG = ['--tray', '--autostart', '--no-autostart', '--tray-status'].find((f) => process.argv.includes(f));
57
+ if (TRAY_FLAG) {
58
+ const { runTrayCommand } = require('./lib/tray');
59
+ runTrayCommand({
60
+ flag: TRAY_FLAG,
61
+ hubDir: HUB_DIR,
62
+ port: HUB_PORT,
63
+ defaultPort: DEFAULT_HUB_PORT,
64
+ appId: getArg('app-id'),
65
+ configDir: resolveHubConfig().config.activeConfigDir,
66
+ }).then((ok) => process.exit(ok ? 0 : 1));
67
+ }
68
+
54
69
  if (process.argv.includes('--install') || process.argv.includes('--uninstall')) {
55
70
  const { runInstall, runUninstall } = require('./lib/install');
56
71
  const dir = getArg('dir') || process.env.CLAUDE_CONFIG_DIR || '~/.claude';
@@ -82,6 +97,14 @@ function canonicalDir(p) {
82
97
  }
83
98
 
84
99
  function loadHubConfig() {
100
+ const { config, raw, fallback } = resolveHubConfig();
101
+ defaultConfigDir = fallback;
102
+ // Configs saved before canonicalization can hold one dir under two spellings; persist the merge.
103
+ if (serializeConfig(config) !== raw) writeHubConfig(config);
104
+ return config;
105
+ }
106
+
107
+ function resolveHubConfig() {
85
108
  const canonical = (p) => {
86
109
  try {
87
110
  return canonicalDir(p);
@@ -92,7 +115,6 @@ function loadHubConfig() {
92
115
  const fallback = canonical(
93
116
  process.env.CLAUDE_CONFIG_DIR || process.env.CLAUDE_DIR || path.join(os.homedir(), '.claude'),
94
117
  );
95
- defaultConfigDir = fallback;
96
118
  let raw = '';
97
119
  let saved = {};
98
120
  try {
@@ -108,9 +130,7 @@ function loadHubConfig() {
108
130
  // Hand-edited only; kept verbatim so a rewrite of the file does not drop it.
109
131
  if (saved.terminal && typeof saved.terminal === 'object') config.terminal = saved.terminal;
110
132
  if (Array.isArray(saved.apps)) config.apps = saved.apps;
111
- // Configs saved before canonicalization can hold one dir under two spellings; persist the merge.
112
- if (serializeConfig(config) !== raw) writeHubConfig(config);
113
- return config;
133
+ return { config, raw, fallback };
114
134
  }
115
135
 
116
136
  function serializeConfig(config) {
@@ -398,20 +418,24 @@ process.on('SIGINT', shutdown);
398
418
  process.on('SIGTERM', shutdown);
399
419
  process.on('SIGHUP', shutdown);
400
420
  process.on('exit', killAll);
401
-
402
- // Git Bash + tmux on Windows doesn't deliver signals — read stdin directly
403
- process.stdin.setEncoding('utf8');
404
- process.stdin.resume();
405
- process.stdin.on('data', (data) => {
406
- const d = data.trim().toLowerCase();
407
- // Ctrl+C (0x03), Ctrl+D (0x04), or typed "q"/"exit"
408
- if (data.includes('\x03') || data.includes('\x04') || d === 'q' || d === 'exit') {
409
- console.log('\nShutting down...');
410
- shutdown();
411
- }
412
- });
413
- process.stdin.on('end', shutdown);
414
- process.stdin.on('close', shutdown);
421
+ process.on('exit', () => removeRecord(HUB_DIR));
422
+
423
+ // Git Bash + tmux on Windows doesn't deliver signals — read stdin directly. Only a hub that starts
424
+ // watches it: a closed stdin would end a CLI command or the running-hub check halfway.
425
+ function watchStdin() {
426
+ process.stdin.setEncoding('utf8');
427
+ process.stdin.resume();
428
+ process.stdin.on('data', (data) => {
429
+ const d = data.trim().toLowerCase();
430
+ // Ctrl+C (0x03), Ctrl+D (0x04), or typed "q"/"exit"
431
+ if (data.includes('\x03') || data.includes('\x04') || d === 'q' || d === 'exit') {
432
+ console.log('\nShutting down...');
433
+ shutdown();
434
+ }
435
+ });
436
+ process.stdin.on('end', shutdown);
437
+ process.stdin.on('close', shutdown);
438
+ }
415
439
 
416
440
  // Raise header size limit to 64KB — localhost cookies from sibling apps can pile up and
417
441
  // trip Node's default 16KB limit, breaking iframes with HTTP 431.
@@ -798,16 +822,18 @@ function listenWithFallback(handler, port, onReady, label, onUpgrade) {
798
822
  return server;
799
823
  }
800
824
 
801
- for (const [name, port] of Object.entries(publicPorts)) {
802
- listenWithFallback(
803
- proxyHandler(name),
804
- port,
805
- (actual) => {
806
- publicPorts[name] = actual;
807
- },
808
- `[${name}] `,
809
- proxyUpgrade(name),
810
- );
825
+ function listenProxies() {
826
+ for (const [name, port] of Object.entries(publicPorts)) {
827
+ listenWithFallback(
828
+ proxyHandler(name),
829
+ port,
830
+ (actual) => {
831
+ publicPorts[name] = actual;
832
+ },
833
+ `[${name}] `,
834
+ proxyUpgrade(name),
835
+ );
836
+ }
811
837
  }
812
838
 
813
839
  const app = express();
@@ -1000,6 +1026,13 @@ app.get('/api/projects', async (_req, res) => {
1000
1026
  }
1001
1027
  });
1002
1028
 
1029
+ // The tray stops the hub through this rather than taskkill, so the children and hub.json are cleaned up.
1030
+ app.post('/api/shutdown', (_req, res) => {
1031
+ console.log('Shutdown asked for over the API');
1032
+ res.on('finish', shutdown);
1033
+ res.json({ ok: true });
1034
+ });
1035
+
1003
1036
  // List-picked project paths skip this: they are byte-exact copies of what kanban reported.
1004
1037
  app.get('/api/resolve-path', (req, res) => {
1005
1038
  res.setHeader('Cache-Control', 'no-store');
@@ -1014,6 +1047,7 @@ app.use(express.static(path.join(__dirname, 'public')));
1014
1047
  // the hub actually bound: HUB_PORT may be busy and fall back to a random one.
1015
1048
  const onReady = (actual) => {
1016
1049
  hubPort = actual;
1050
+ writeRecord(HUB_DIR, actual);
1017
1051
  ensurePool(hubConfig.activeConfigDir);
1018
1052
  updateCheck.refresh();
1019
1053
  printBanner(actual);
@@ -1022,7 +1056,24 @@ const onReady = (actual) => {
1022
1056
  }
1023
1057
  };
1024
1058
 
1025
- listenWithFallback(app, HUB_PORT, onReady, '');
1059
+ // One hub per hub dir: a second one would overwrite hub.json, and the first would run unseen.
1060
+ // A tray flag's command is still running here and exits on its own.
1061
+ if (!TRAY_FLAG) {
1062
+ liveHub(HUB_DIR, HUB_TOKEN).then((running) => {
1063
+ if (!running) {
1064
+ watchStdin();
1065
+ listenProxies();
1066
+ listenWithFallback(app, HUB_PORT, onReady, '');
1067
+ return;
1068
+ }
1069
+ console.log(`Claude Code Hub already runs for ${HUB_DIR} at ${accessUrl(running.port)}`);
1070
+ if (process.argv.includes('--open')) {
1071
+ import('open').then((m) => m.default(accessUrl(running.port))).finally(() => process.exit(0));
1072
+ } else {
1073
+ process.exit(0);
1074
+ }
1075
+ });
1076
+ }
1026
1077
 
1027
1078
  function accessUrl(port) {
1028
1079
  return `http://localhost:${port}/?token=${HUB_TOKEN}`;
@@ -0,0 +1,230 @@
1
+ Add-Type -AssemblyName System.Windows.Forms, System.Drawing
2
+ # DPI-aware, so the icon is drawn at the tray's real size instead of 16 px scaled up.
3
+ Add-Type -Namespace HubTray -Name Native -MemberDefinition '[DllImport("user32.dll")] public static extern bool SetProcessDPIAware();'
4
+ [void][HubTray.Native]::SetProcessDPIAware()
5
+ [System.Windows.Forms.Application]::EnableVisualStyles()
6
+ . (Join-Path $PSScriptRoot 'icon.ps1')
7
+
8
+ $TrayDir = $PSScriptRoot
9
+ $ConfigFile = Join-Path $TrayDir 'config.json'
10
+ if (-not (Test-Path $ConfigFile)) {
11
+ [System.Windows.Forms.MessageBox]::Show("Missing $ConfigFile. Run: claude-code-hub --tray", 'Claude Code Hub') | Out-Null
12
+ exit 1
13
+ }
14
+ $config = Get-Content $ConfigFile -Raw | ConvertFrom-Json
15
+
16
+ # createdNew, not WaitOne: a killed tray leaves the mutex abandoned, and WaitOne then throws instead of returning false.
17
+ $createdNew = $false
18
+ $mutex = New-Object System.Threading.Mutex($false, "Local\$($config.runValue)", [ref]$createdNew)
19
+ if (-not $createdNew) { exit }
20
+
21
+ $HubDir = Split-Path $TrayDir
22
+ $RunKey = 'Registry::HKCU\Software\Microsoft\Windows\CurrentVersion\Run'
23
+ $TrayPidFile = Join-Path $HubDir 'tray.pid'
24
+ Set-Content -Path $TrayPidFile -Value $PID
25
+ $HubFile = Join-Path $HubDir 'hub.json'
26
+ $LogFile = Join-Path $HubDir 'hub.log'
27
+ $TrayLog = Join-Path $HubDir 'tray.log'
28
+
29
+ $script:hubProc = $null
30
+ $script:hub = $null
31
+ $script:state = ''
32
+ $script:appLnk = $null
33
+
34
+ function Write-TrayLog($msg) {
35
+ Add-Content -Path $TrayLog -Value "$(Get-Date -Format s) $msg"
36
+ }
37
+
38
+ # The hub writes hub.json once it listens; a dead pid there is a hub that is gone. Get-Process still
39
+ # returns a killed process while any handle to it is open, so HasExited is checked too.
40
+ function Get-Hub {
41
+ if (-not (Test-Path $HubFile)) { return $null }
42
+ $hub = Get-Content $HubFile -Raw | ConvertFrom-Json
43
+ $proc = Get-Process -Id $hub.pid -ErrorAction SilentlyContinue
44
+ if ($proc -and $proc.ProcessName -eq 'node' -and -not $proc.HasExited) { return $hub }
45
+ return $null
46
+ }
47
+
48
+ # The saved node comes first: the app packages were installed with it, and a version manager may
49
+ # have switched the one on PATH since.
50
+ function Get-HubCommand {
51
+ $hubArgs = "--hub-dir `"$HubDir`" --port $($config.port)"
52
+ $node = if (Test-Path $config.node) { $config.node } else { (Get-Command node.exe -ErrorAction SilentlyContinue | Select-Object -First 1).Source }
53
+ if ($node -and (Test-Path $config.serverJs)) {
54
+ return "`"$node`" `"$($config.serverJs)`" $hubArgs"
55
+ }
56
+ $tray.ShowBalloonTip(5000, 'Claude Code Hub', "The installed hub is gone. Starting $($config.version) with npx.", 'Warning')
57
+ return "npx -y claude-code-hub@$($config.version) $hubArgs"
58
+ }
59
+
60
+ function Start-Hub {
61
+ if (-not (Get-Hub)) {
62
+ if ((Test-Path $LogFile) -and (Get-Item $LogFile).Length -gt 5MB) { Move-Item $LogFile "$LogFile.1" -Force }
63
+ $cmd = Get-HubCommand
64
+ Write-TrayLog "start: $cmd"
65
+ $psi = New-Object System.Diagnostics.ProcessStartInfo
66
+ $psi.FileName = $env:ComSpec
67
+ $psi.Arguments = "/d /s /c `"$cmd >> `"$LogFile`" 2>&1`""
68
+ $psi.UseShellExecute = $false
69
+ $psi.CreateNoWindow = $true
70
+ $psi.WorkingDirectory = $HubDir
71
+ $script:hubProc = [System.Diagnostics.Process]::Start($psi)
72
+ }
73
+ Update-State
74
+ }
75
+
76
+ function Get-Token {
77
+ (Get-Content (Join-Path $HubDir 'token') -Raw).Trim()
78
+ }
79
+
80
+ # The shutdown route lets the hub stop its tools and remove hub.json; taskkill is the fallback for a
81
+ # hub that does not answer. Killing the node tree also ends the cmd that the tray started.
82
+ function Stop-Hub {
83
+ $hub = Get-Hub
84
+ if ($hub) {
85
+ Write-TrayLog "stop: pid $($hub.pid)"
86
+ try {
87
+ Invoke-RestMethod -Method Post -Uri "http://127.0.0.1:$($hub.port)/api/shutdown?token=$(Get-Token)" -TimeoutSec 3 | Out-Null
88
+ Wait-Process -Id $hub.pid -Timeout 15 -ErrorAction SilentlyContinue
89
+ } catch {
90
+ Write-TrayLog "shutdown request failed: $_"
91
+ }
92
+ }
93
+ $id = if (Get-Hub) { $hub.pid } elseif ($script:hubProc -and -not $script:hubProc.HasExited) { $script:hubProc.Id }
94
+ if ($id) {
95
+ Write-TrayLog "kill: pid $id"
96
+ & taskkill.exe /f /t /pid $id 2>&1 | Out-Null
97
+ }
98
+ if ($script:hubProc) {
99
+ [void]$script:hubProc.WaitForExit(5000)
100
+ $script:hubProc.Dispose()
101
+ $script:hubProc = $null
102
+ }
103
+ if ($hub) { Wait-Process -Id $hub.pid -Timeout 5 -ErrorAction SilentlyContinue }
104
+ }
105
+
106
+ $Shell = New-Object -ComObject WScript.Shell
107
+
108
+ # Chrome and Edge put an installed app's shortcut in the Start Menu, with the app id in its arguments.
109
+ function Find-AppShortcut {
110
+ if ($script:appLnk -and (Test-Path $script:appLnk.FullName)) { return $script:appLnk }
111
+ $script:appLnk = $null
112
+ $filter = if ($config.app.id) { '*.lnk' } else { "$($config.app.name).lnk" }
113
+ foreach ($file in Get-ChildItem "$env:APPDATA\Microsoft\Windows\Start Menu\Programs" -Recurse -Filter $filter -ErrorAction SilentlyContinue) {
114
+ $lnk = $Shell.CreateShortcut($file.FullName)
115
+ if ($lnk.Arguments -notmatch '--app-id=(\w+)') { continue }
116
+ if (-not $config.app.id -or $Matches[1] -eq $config.app.id) { $script:appLnk = $lnk; return $lnk }
117
+ }
118
+ return $null
119
+ }
120
+
121
+ # Chrome loads any URL into an app, so a hub on another port must not open in it.
122
+ function Open-Hub {
123
+ $hub = Get-Hub
124
+ if (-not $hub) { return }
125
+ $url = "http://localhost:$($hub.port)/?token=$(Get-Token)"
126
+ $app = if ($hub.port -eq $config.app.port) { Find-AppShortcut }
127
+ if ($app) {
128
+ Start-Process $app.TargetPath "$($app.Arguments) --app-launch-url-for-shortcuts-menu-item=`"$url`""
129
+ } else {
130
+ Start-Process $url
131
+ }
132
+ }
133
+
134
+ function Get-Autostart {
135
+ $null -ne (Get-ItemProperty $RunKey -Name $config.runValue -ErrorAction SilentlyContinue)
136
+ }
137
+
138
+ $icons = @{}
139
+ $iconSize = [System.Windows.Forms.SystemInformation]::SmallIconSize.Width
140
+ foreach ($s in $StateColors.Keys) {
141
+ $bmp = New-TrayBitmap $iconSize $StateColors[$s]
142
+ $icons[$s] = [System.Drawing.Icon]::FromHandle($bmp.GetHicon())
143
+ $bmp.Dispose()
144
+ }
145
+
146
+ function Get-ConfigDirName {
147
+ try {
148
+ $dir = (Get-Content (Join-Path $HubDir 'config.json') -Raw | ConvertFrom-Json).activeConfigDir
149
+ if ($dir) { return Split-Path $dir -Leaf }
150
+ } catch {}
151
+ return $null
152
+ }
153
+
154
+ # The classic ContextMenu is a native Win32 menu, so it takes the Windows 11 menu style; ContextMenuStrip draws its own.
155
+ $tray = New-Object System.Windows.Forms.NotifyIcon
156
+ $menu = New-Object System.Windows.Forms.ContextMenu
157
+ $miHeader = New-Object System.Windows.Forms.MenuItem 'Claude Code Hub'
158
+ $miHeader.Enabled = $false
159
+ $miOpen = New-Object System.Windows.Forms.MenuItem 'Open Hub'
160
+ $miOpen.DefaultItem = $true
161
+ $miStartStop = New-Object System.Windows.Forms.MenuItem 'Stop Hub'
162
+ $miRestart = New-Object System.Windows.Forms.MenuItem 'Restart Hub'
163
+ $miLog = New-Object System.Windows.Forms.MenuItem 'Open log'
164
+ $miAuto = New-Object System.Windows.Forms.MenuItem 'Start with Windows'
165
+ $miQuit = New-Object System.Windows.Forms.MenuItem 'Quit (stops the hub)'
166
+ foreach ($item in @($miHeader, '-', $miOpen, $miStartStop, $miRestart, '-', $miLog, $miAuto, '-', $miQuit)) {
167
+ [void]$menu.MenuItems.Add($item)
168
+ }
169
+ $tray.ContextMenu = $menu
170
+
171
+ function Set-State($s) {
172
+ if ($script:state -eq $s) { return }
173
+ $script:state = $s
174
+ $tray.Icon = $icons[$s]
175
+ $tray.Text = "Claude Code Hub: $s"
176
+ $miStartStop.Text = if ($s -eq 'stopped') { 'Start Hub' } else { 'Stop Hub' }
177
+ $miOpen.Enabled = $s -eq 'running'
178
+ $miRestart.Enabled = $s -ne 'stopped'
179
+ }
180
+
181
+ function Update-Header {
182
+ $dot = [char]0x00B7
183
+ $parts = @(if ($script:hub) { "Running on port $($script:hub.port)" } elseif ($script:state -eq 'starting') { 'Starting' } else { 'Stopped' })
184
+ $name = Get-ConfigDirName
185
+ if ($name) { $parts += $name }
186
+ $miHeader.Text = $parts -join " $dot "
187
+ }
188
+
189
+ # Stop-Hub clears hubProc, so a hubProc that has exited is a hub that died on its own.
190
+ function Update-State {
191
+ $script:hub = Get-Hub
192
+ if ($script:hub) { Set-State 'running'; return }
193
+ if ($script:hubProc -and -not $script:hubProc.HasExited) { Set-State 'starting'; return }
194
+ if ($script:hubProc) {
195
+ $script:hubProc.Dispose()
196
+ $script:hubProc = $null
197
+ Write-TrayLog 'hub exited'
198
+ $tray.ShowBalloonTip(5000, 'Claude Code Hub', 'The hub stopped. Start it from the tray menu; see Open log.', 'Warning')
199
+ }
200
+ Set-State 'stopped'
201
+ }
202
+
203
+ $tray.add_MouseClick({ param($s, $e) if ($e.Button -eq 'Left') { Open-Hub } })
204
+ $miOpen.add_Click({ Open-Hub })
205
+ $miStartStop.add_Click({ if ($script:state -eq 'stopped') { Start-Hub } else { Stop-Hub; Update-State } })
206
+ $miRestart.add_Click({ Stop-Hub; Start-Hub })
207
+ $miLog.add_Click({ if (Test-Path $LogFile) { Start-Process $LogFile } })
208
+ $menu.add_Popup({ $miAuto.Checked = Get-Autostart; Update-Header })
209
+ $miAuto.add_Click({
210
+ if (Get-Autostart) { Remove-ItemProperty $RunKey -Name $config.runValue }
211
+ else { Set-ItemProperty $RunKey -Name $config.runValue -Value "wscript.exe `"$(Join-Path $TrayDir 'launch.vbs')`"" }
212
+ })
213
+ $miQuit.add_Click({
214
+ $timer.Stop()
215
+ Stop-Hub
216
+ Remove-Item $TrayPidFile -ErrorAction SilentlyContinue
217
+ $tray.Visible = $false
218
+ [System.Windows.Forms.Application]::Exit()
219
+ })
220
+
221
+ $timer = New-Object System.Windows.Forms.Timer
222
+ $timer.Interval = 3000
223
+ $timer.add_Tick({ try { Update-State } catch { Write-TrayLog "tick: $_" } })
224
+
225
+ Set-State 'starting'
226
+ $tray.Visible = $true
227
+ try { Start-Hub } catch { Write-TrayLog "start failed: $_" }
228
+ $timer.Start()
229
+ [System.Windows.Forms.Application]::Run()
230
+ $tray.Dispose()
package/tray/icon.ps1 ADDED
@@ -0,0 +1,49 @@
1
+ function New-RoundRect([float]$x, [float]$y, [float]$w, [float]$h, [float]$r) {
2
+ $p = New-Object System.Drawing.Drawing2D.GraphicsPath
3
+ $d = $r * 2
4
+ $p.AddArc($x, $y, $d, $d, 180, 90)
5
+ $p.AddArc($x + $w - $d, $y, $d, $d, 270, 90)
6
+ $p.AddArc($x + $w - $d, $y + $h - $d, $d, $d, 0, 90)
7
+ $p.AddArc($x, $y + $h - $d, $d, $d, 90, 90)
8
+ $p.CloseFigure()
9
+ $p
10
+ }
11
+
12
+ # public/icons/icon-192.svg redrawn at the tray's own size, with a status dot in place of the bottom-right tile.
13
+ function New-TrayBitmap([int]$size, [System.Drawing.Color]$status) {
14
+ $k = $size / 192
15
+ $bmp = New-Object System.Drawing.Bitmap $size, $size
16
+ $g = [System.Drawing.Graphics]::FromImage($bmp)
17
+ $g.SmoothingMode = 'AntiAlias'
18
+ $g.PixelOffsetMode = 'Half'
19
+ $bg = New-Object System.Drawing.SolidBrush ([System.Drawing.Color]::FromArgb(26, 26, 46))
20
+ $path = New-RoundRect 0 0 $size $size (32 * $k)
21
+ $g.FillPath($bg, $path)
22
+ $path.Dispose()
23
+ # Whole-pixel tiles and stroke keep the outlines sharp at 16 px.
24
+ $stroke = [Math]::Max(1, [Math]::Round(6 * $k))
25
+ $tile = [Math]::Round(40 * $k)
26
+ $pen = New-Object System.Drawing.Pen ([System.Drawing.Color]::FromArgb(233, 69, 96)), $stroke
27
+ foreach ($pos in @(@(44, 44), @(108, 44), @(44, 108))) {
28
+ $x = [Math]::Round($pos[0] * $k) + $stroke / 2
29
+ $y = [Math]::Round($pos[1] * $k) + $stroke / 2
30
+ $path = New-RoundRect $x $y ($tile - $stroke) ($tile - $stroke) ([Math]::Max(0.5, 6 * $k))
31
+ $g.DrawPath($pen, $path)
32
+ $path.Dispose()
33
+ }
34
+ $c = 128 * $k
35
+ $dot = $size * 0.2
36
+ $brush = New-Object System.Drawing.SolidBrush $status
37
+ $g.FillEllipse($brush, $c - $dot, $c - $dot, 2 * $dot, 2 * $dot)
38
+ $brush.Dispose()
39
+ $pen.Dispose()
40
+ $bg.Dispose()
41
+ $g.Dispose()
42
+ $bmp
43
+ }
44
+
45
+ $StateColors = @{
46
+ running = [System.Drawing.Color]::FromArgb(34, 197, 94)
47
+ starting = [System.Drawing.Color]::FromArgb(245, 158, 11)
48
+ stopped = [System.Drawing.Color]::FromArgb(239, 68, 68)
49
+ }
@@ -0,0 +1,4 @@
1
+ ' Starts the tray with no console window; powershell -WindowStyle Hidden still flashes one.
2
+ Set fso = CreateObject("Scripting.FileSystemObject")
3
+ dir = fso.GetParentFolderName(WScript.ScriptFullName)
4
+ CreateObject("WScript.Shell").Run "powershell.exe -NoProfile -ExecutionPolicy Bypass -STA -WindowStyle Hidden -File """ & dir & "\hub-tray.ps1""", 0, False