claude-code-hub 1.5.1 → 1.7.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 +11 -6
- package/lib/apps.js +14 -1
- package/lib/keymap.js +78 -0
- package/lib/tray.js +125 -37
- package/package.json +8 -6
- package/packages/claude-hub-sdk/src/client.js +31 -9
- package/packages/claude-hub-sdk/src/keys.js +38 -0
- package/packages/claude-hub-sdk/src/server.js +10 -3
- package/packages/claude-hub-sdk/src/stub.js +3 -8
- package/plugin/plugins/claude-code-hub/.claude-plugin/plugin.json +1 -1
- package/plugin/plugins/claude-code-hub/skills/hub-builder/SKILL.md +3 -2
- package/plugin/plugins/claude-code-hub/skills/hub-builder/references/extensibility.md +4 -4
- package/plugin/plugins/claude-code-hub/skills/hub-builder/references/keys.md +50 -0
- package/public/app.js +93 -37
- package/public/index.html +14 -1
- package/public/sw.js +2 -2
- package/server.js +8 -1
- package/tray/mac/hub-tray.js +495 -0
|
@@ -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
|
+
}
|