claude-code-kanban 5.2.0 → 5.4.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 +2 -2
- package/cli.js +10 -5
- package/install.js +43 -123
- package/lib/approvals.js +16 -17
- package/lib/claude-dir.js +7 -1
- package/lib/git-branch.js +47 -0
- package/lib/net-guard.js +41 -14
- package/lib/parsers.js +96 -59
- package/lib/plugin-status.js +1 -1
- package/lib/proc-stats.js +82 -0
- package/lib/retention.js +12 -9
- package/lib/session-cache.js +1 -1
- package/lib/terminal.js +19 -5
- package/lib/worktrees.js +105 -0
- package/package.json +1 -2
- package/plugin/.claude-plugin/marketplace.json +1 -1
- package/plugin/plugins/claude-code-kanban/.claude-plugin/plugin.json +2 -2
- package/plugin/plugins/claude-code-kanban/hooks/activity.ts +266 -0
- package/plugin/plugins/claude-code-kanban/hooks/context.ts +77 -0
- package/plugin/plugins/claude-code-kanban/hooks/hooks.json +1 -93
- package/plugin/plugins/claude-code-kanban/hooks/index.ts +8 -0
- package/plugin/plugins/claude-code-kanban/scripts/postman.js +4 -4
- package/plugin/plugins/claude-code-kanban/skills/dispatch/SKILL.md +1 -1
- package/plugin/plugins/claude-code-kanban/skills/kanban/SKILL.md +0 -1
- package/plugin/plugins/claude-code-kanban/tests/activity.test.ts +300 -0
- package/plugin/plugins/claude-code-kanban/tests/context.test.ts +72 -0
- package/plugin/plugins/claude-code-kanban/tsconfig.json +3 -0
- package/public/app.js +661 -380
- package/public/index.html +7 -3
- package/public/project-match.js +6 -4
- package/public/style.css +258 -79
- package/public/terminal-frame.js +341 -0
- package/public/terminal.html +27 -0
- package/public/vendor/claude-hub-sdk.js +13 -3
- package/server.js +163 -145
- package/skill-guides/dispatch.md +4 -4
- package/plugin/plugins/claude-code-kanban/scripts/agent-spy.sh +0 -135
- package/plugin/plugins/claude-code-kanban/scripts/approval-gate.sh +0 -208
- package/plugin/plugins/claude-code-kanban/scripts/context-status.sh +0 -18
|
@@ -0,0 +1,341 @@
|
|
|
1
|
+
// The terminal half of the board's TERMINAL region. The board frames this page from the other
|
|
2
|
+
// loopback name (see terminalFrameOrigin in app.js), a different site, so Chrome runs it in its own
|
|
3
|
+
// renderer process: keys, output and acks never wait behind board work. The board keeps every decision
|
|
4
|
+
// (when to open, retries, prompts, focus); this page keeps xterm and the socket.
|
|
5
|
+
((root) => {
|
|
6
|
+
'use strict';
|
|
7
|
+
|
|
8
|
+
const ACK_BATCH_BYTES = 32 * 1024;
|
|
9
|
+
const MSG = 'cck-term:';
|
|
10
|
+
|
|
11
|
+
const modsOf = (e) =>
|
|
12
|
+
[e.ctrlKey && 'ctrl', e.altKey && 'alt', e.shiftKey && 'shift', e.metaKey && 'meta'].filter(Boolean).join('+');
|
|
13
|
+
|
|
14
|
+
// A claim names a key by its physical code, or by its character when the layout moves it.
|
|
15
|
+
function keySigs(e) {
|
|
16
|
+
const mods = modsOf(e);
|
|
17
|
+
return [e.code && `${mods}|c:${e.code}`, typeof e.key === 'string' && `${mods}|k:${e.key.toLowerCase()}`].filter(
|
|
18
|
+
Boolean,
|
|
19
|
+
);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const claimSig = (c) => keySigs(c)[0];
|
|
23
|
+
|
|
24
|
+
const isClaimed = (claims, e) => keySigs(e).some((s) => claims.has(s));
|
|
25
|
+
|
|
26
|
+
// Hub keys are matched the way the SDK matches them, so a layout that moves a key agrees on both sides.
|
|
27
|
+
const handsBack = (claims, forward, comboOf, e) => (!!comboOf && forward.has(comboOf(e))) || isClaimed(claims, e);
|
|
28
|
+
|
|
29
|
+
function parentOriginOf(hash) {
|
|
30
|
+
const m = /[#&]p=([^&]*)/.exec(hash || '');
|
|
31
|
+
if (!m) return null;
|
|
32
|
+
try {
|
|
33
|
+
return new URL(decodeURIComponent(m[1])).origin;
|
|
34
|
+
} catch (_) {
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function tailOf(buf, count) {
|
|
40
|
+
if (!buf) return '';
|
|
41
|
+
const lines = [];
|
|
42
|
+
for (let i = buf.length - 1; i >= 0 && lines.length < count; i--) {
|
|
43
|
+
const text = buf.getLine(i)?.translateToString(true) ?? '';
|
|
44
|
+
if (text.trim() || lines.length) lines.unshift(text);
|
|
45
|
+
}
|
|
46
|
+
return lines.join('\n');
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function oscColor(hex) {
|
|
50
|
+
const m = /^#([0-9a-f]{6})$/i.exec(hex || '');
|
|
51
|
+
if (!m) return null;
|
|
52
|
+
return `rgb:${[0, 2, 4].map((i) => m[1].slice(i, i + 2).repeat(2)).join('/')}`;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function createTerminalFrame(win) {
|
|
56
|
+
const doc = win.document;
|
|
57
|
+
const parentOrigin = parentOriginOf(win.location.hash);
|
|
58
|
+
const host = doc.getElementById('host');
|
|
59
|
+
const state = {
|
|
60
|
+
term: null,
|
|
61
|
+
fit: null,
|
|
62
|
+
webgl: null,
|
|
63
|
+
ws: null,
|
|
64
|
+
attached: false,
|
|
65
|
+
ackPending: 0,
|
|
66
|
+
ackTimer: null,
|
|
67
|
+
claims: new Set(),
|
|
68
|
+
forward: new Set(),
|
|
69
|
+
};
|
|
70
|
+
const comboOf = win.ClaudeHub?.comboOf;
|
|
71
|
+
|
|
72
|
+
function up(t, data) {
|
|
73
|
+
if (parentOrigin) win.parent.postMessage({ ...data, type: MSG + t }, parentOrigin);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function send(msg) {
|
|
77
|
+
if (state.ws?.readyState === win.WebSocket.OPEN) state.ws.send(JSON.stringify(msg));
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// The WebGL renderer keeps its last frame and glyph atlas while the frame is 0×0, and comes back
|
|
81
|
+
// showing stale cells or backgrounds with no text. xterm does not repaint on its own.
|
|
82
|
+
function repaint() {
|
|
83
|
+
const term = state.term;
|
|
84
|
+
if (!term) return;
|
|
85
|
+
try {
|
|
86
|
+
state.webgl?.clearTextureAtlas();
|
|
87
|
+
} catch (_) {}
|
|
88
|
+
term.refresh(0, term.rows - 1);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// The fit addon measures 0×0 while the board's pane (or the hub's iframe) is display:none.
|
|
92
|
+
function fit() {
|
|
93
|
+
if (!state.fit || !host.offsetWidth || !host.offsetHeight) return;
|
|
94
|
+
try {
|
|
95
|
+
state.fit.fit();
|
|
96
|
+
} catch (_) {}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function setBg(options) {
|
|
100
|
+
doc.documentElement.style.setProperty('--bg', options?.theme?.background || '');
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function applyTheme(options) {
|
|
104
|
+
Object.assign(state.term.options, options);
|
|
105
|
+
setBg(options);
|
|
106
|
+
repaint();
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function applyFont(size) {
|
|
110
|
+
const term = state.term;
|
|
111
|
+
term.options.fontSize = size;
|
|
112
|
+
fit();
|
|
113
|
+
doc.fonts
|
|
114
|
+
?.load(`${size}px ${term.options.fontFamily}`)
|
|
115
|
+
.then(repaint)
|
|
116
|
+
.catch(() => {});
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function forwardClaimed(e) {
|
|
120
|
+
if (!handsBack(state.claims, state.forward, comboOf, e)) return false;
|
|
121
|
+
e.preventDefault();
|
|
122
|
+
up('key', { key: e.key, code: e.code, ctrl: e.ctrlKey, alt: e.altKey, shift: e.shiftKey, meta: e.metaKey });
|
|
123
|
+
return true;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function keyFilter(e) {
|
|
127
|
+
if (e.type !== 'keydown') return true;
|
|
128
|
+
if (forwardClaimed(e)) return false;
|
|
129
|
+
const ctrlOnly = e.ctrlKey && !e.altKey && !e.metaKey;
|
|
130
|
+
if (ctrlOnly && e.code === 'KeyC' && (e.shiftKey || state.term.hasSelection())) {
|
|
131
|
+
e.preventDefault();
|
|
132
|
+
win.navigator.clipboard?.writeText(state.term.getSelection()).catch(() => {});
|
|
133
|
+
state.term.clearSelection();
|
|
134
|
+
return false;
|
|
135
|
+
}
|
|
136
|
+
// The browser's own paste event reaches xterm's textarea.
|
|
137
|
+
return !(ctrlOnly && e.code === 'KeyV');
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function init(o) {
|
|
141
|
+
if (state.term) return;
|
|
142
|
+
const term = new win.Terminal({
|
|
143
|
+
fontFamily: o.fontFamily,
|
|
144
|
+
fontSize: o.fontSize,
|
|
145
|
+
scrollback: o.scrollback,
|
|
146
|
+
cursorBlink: true,
|
|
147
|
+
allowProposedApi: true,
|
|
148
|
+
...o.themeOptions,
|
|
149
|
+
});
|
|
150
|
+
state.term = term;
|
|
151
|
+
state.fit = new win.FitAddon.FitAddon();
|
|
152
|
+
term.loadAddon(state.fit);
|
|
153
|
+
term.loadAddon(new win.Unicode11Addon.Unicode11Addon());
|
|
154
|
+
term.unicode.activeVersion = '11';
|
|
155
|
+
term.open(host);
|
|
156
|
+
try {
|
|
157
|
+
const gl = new win.WebglAddon.WebglAddon();
|
|
158
|
+
gl.onContextLoss(() => {
|
|
159
|
+
gl.dispose();
|
|
160
|
+
state.webgl = null;
|
|
161
|
+
repaint();
|
|
162
|
+
});
|
|
163
|
+
term.loadAddon(gl);
|
|
164
|
+
state.webgl = gl;
|
|
165
|
+
} catch (_) {
|
|
166
|
+
// No WebGL: xterm falls back to its DOM renderer.
|
|
167
|
+
}
|
|
168
|
+
setBg(o.themeOptions);
|
|
169
|
+
applyFont(o.fontSize);
|
|
170
|
+
term.attachCustomKeyEventHandler(keyFilter);
|
|
171
|
+
// After a click on the scrollbar, or before the board's focus message lands, the frame has focus
|
|
172
|
+
// but xterm's textarea does not, and the board's keys must still get out.
|
|
173
|
+
doc.addEventListener('keydown', (e) => {
|
|
174
|
+
if (e.target !== term.textarea) forwardClaimed(e);
|
|
175
|
+
});
|
|
176
|
+
term.textarea.addEventListener('focus', () => up('focused'));
|
|
177
|
+
// xterm leaves OSC 10/11 color queries unanswered, so apps that pick a palette from the
|
|
178
|
+
// background (Claude Code's Auto theme) assume dark even on a light theme.
|
|
179
|
+
for (const [code, key] of [
|
|
180
|
+
[10, 'foreground'],
|
|
181
|
+
[11, 'background'],
|
|
182
|
+
]) {
|
|
183
|
+
term.parser.registerOscHandler(code, (data) => {
|
|
184
|
+
if (data !== '?') return false;
|
|
185
|
+
const rgb = oscColor(term.options.theme[key]);
|
|
186
|
+
if (rgb) send({ t: 'in', d: `\x1b]${code};${rgb}\x1b\\` });
|
|
187
|
+
return true;
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
term.onData((d) => send({ t: 'in', d }));
|
|
191
|
+
term.onResize(({ cols, rows }) => send({ t: 'resize', cols, rows }));
|
|
192
|
+
// One fit per frame while a drag resizes the pane. The atlas is rebuilt only when the frame comes
|
|
193
|
+
// back from 0×0, which is also how a hidden hub iframe or pane shows again.
|
|
194
|
+
let frame = 0;
|
|
195
|
+
let visible = false;
|
|
196
|
+
new win.ResizeObserver(() => {
|
|
197
|
+
if (frame) return;
|
|
198
|
+
frame = win.requestAnimationFrame(() => {
|
|
199
|
+
frame = 0;
|
|
200
|
+
const nowVisible = host.offsetWidth > 0;
|
|
201
|
+
fit();
|
|
202
|
+
if (nowVisible && !visible) repaint();
|
|
203
|
+
visible = nowVisible;
|
|
204
|
+
});
|
|
205
|
+
}).observe(host);
|
|
206
|
+
// Vimium eats Escape inside a text field and only blurs it, so the key never reaches Claude.
|
|
207
|
+
// A blur no click caused, while this frame keeps focus, is that Escape. When the board takes
|
|
208
|
+
// focus back, this document loses it, so that blur is not one.
|
|
209
|
+
let pointerDown = false;
|
|
210
|
+
doc.addEventListener('mousedown', () => (pointerDown = true), true);
|
|
211
|
+
doc.addEventListener('mouseup', () => (pointerDown = false), true);
|
|
212
|
+
term.textarea.addEventListener('blur', () => {
|
|
213
|
+
if (pointerDown) return;
|
|
214
|
+
win.requestAnimationFrame(() => {
|
|
215
|
+
if (!state.attached || !host.offsetWidth) return;
|
|
216
|
+
if (!doc.hasFocus() || doc.activeElement !== doc.body) return;
|
|
217
|
+
send({ t: 'in', d: '\x1b' });
|
|
218
|
+
term.focus();
|
|
219
|
+
});
|
|
220
|
+
});
|
|
221
|
+
up('term');
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function ack(ws, n) {
|
|
225
|
+
if (state.ws !== ws) return;
|
|
226
|
+
state.ackPending += n;
|
|
227
|
+
const flush = () => {
|
|
228
|
+
win.clearTimeout(state.ackTimer);
|
|
229
|
+
state.ackTimer = null;
|
|
230
|
+
if (state.ws === ws && state.ackPending) send({ t: 'ack', n: state.ackPending });
|
|
231
|
+
state.ackPending = 0;
|
|
232
|
+
};
|
|
233
|
+
if (state.ackPending >= ACK_BATCH_BYTES) flush();
|
|
234
|
+
else if (!state.ackTimer) state.ackTimer = win.setTimeout(flush, 50);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// RIS through the write queue, not term.reset(): reset() runs at once, and output the previous
|
|
238
|
+
// session had already queued would still be drawn after it.
|
|
239
|
+
function clearScreen() {
|
|
240
|
+
state.term.write('\x1bc', repaint);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function detach() {
|
|
244
|
+
const ws = state.ws;
|
|
245
|
+
state.ws = null;
|
|
246
|
+
state.attached = false;
|
|
247
|
+
win.clearTimeout(state.ackTimer);
|
|
248
|
+
state.ackTimer = null;
|
|
249
|
+
state.ackPending = 0;
|
|
250
|
+
if (ws) {
|
|
251
|
+
ws.onclose = null;
|
|
252
|
+
ws.close();
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function open({ socketId, hello, reset }) {
|
|
257
|
+
detach();
|
|
258
|
+
up('opening', { socketId });
|
|
259
|
+
const term = state.term;
|
|
260
|
+
let outputSent = false;
|
|
261
|
+
if (reset) clearScreen();
|
|
262
|
+
fit();
|
|
263
|
+
const proto = win.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
|
264
|
+
const ws = new win.WebSocket(`${proto}//${win.location.host}/api/terminal/ws`);
|
|
265
|
+
ws.binaryType = 'arraybuffer';
|
|
266
|
+
state.ws = ws;
|
|
267
|
+
ws.onopen = () => ws.send(JSON.stringify({ ...hello, t: 'hello', cols: term.cols, rows: term.rows }));
|
|
268
|
+
ws.onmessage = (ev) => {
|
|
269
|
+
if (state.ws !== ws) return;
|
|
270
|
+
if (typeof ev.data !== 'string') {
|
|
271
|
+
const bytes = new Uint8Array(ev.data);
|
|
272
|
+
term.write(bytes, () => {
|
|
273
|
+
ack(ws, bytes.length);
|
|
274
|
+
if (!outputSent && state.ws === ws && tailOf(term.buffer.active, 1)) {
|
|
275
|
+
outputSent = true;
|
|
276
|
+
up('output', { socketId });
|
|
277
|
+
}
|
|
278
|
+
});
|
|
279
|
+
return;
|
|
280
|
+
}
|
|
281
|
+
let msg;
|
|
282
|
+
try {
|
|
283
|
+
msg = JSON.parse(ev.data);
|
|
284
|
+
} catch (_) {
|
|
285
|
+
return;
|
|
286
|
+
}
|
|
287
|
+
if (msg.t === 'ready') {
|
|
288
|
+
state.attached = true;
|
|
289
|
+
// A retry keeps the last screen until the replay arrives.
|
|
290
|
+
if (!reset) clearScreen();
|
|
291
|
+
send({ t: 'resize', cols: term.cols, rows: term.rows });
|
|
292
|
+
} else if (msg.t === 'exit') state.attached = false;
|
|
293
|
+
up('ws', { socketId, msg, ...(msg.t === 'exit' && { tail: tailOf(term.buffer.active, 6) }) });
|
|
294
|
+
};
|
|
295
|
+
ws.onclose = (ev) => {
|
|
296
|
+
if (state.ws !== ws) return;
|
|
297
|
+
state.ws = null;
|
|
298
|
+
state.attached = false;
|
|
299
|
+
up('close', { socketId, code: ev.code });
|
|
300
|
+
};
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
function onMessage(e) {
|
|
304
|
+
if (e.source !== win.parent || e.origin !== parentOrigin) return;
|
|
305
|
+
const m = e.data;
|
|
306
|
+
if (typeof m?.type !== 'string' || !m.type.startsWith(MSG)) return;
|
|
307
|
+
if (m.type === `${MSG}init`) return init(m);
|
|
308
|
+
if (!state.term) return;
|
|
309
|
+
switch (m.type.slice(MSG.length)) {
|
|
310
|
+
case 'open':
|
|
311
|
+
return open(m);
|
|
312
|
+
case 'detach':
|
|
313
|
+
return detach();
|
|
314
|
+
case 'send':
|
|
315
|
+
return send(m.msg);
|
|
316
|
+
case 'theme':
|
|
317
|
+
return applyTheme(m.options);
|
|
318
|
+
case 'font':
|
|
319
|
+
return applyFont(m.size);
|
|
320
|
+
case 'claims':
|
|
321
|
+
state.claims = new Set((m.keys || []).map(claimSig));
|
|
322
|
+
state.forward = new Set(m.forward || []);
|
|
323
|
+
return;
|
|
324
|
+
case 'focus':
|
|
325
|
+
return state.term.focus();
|
|
326
|
+
case 'refresh':
|
|
327
|
+
fit();
|
|
328
|
+
return repaint();
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
win.addEventListener('message', onMessage);
|
|
333
|
+
up('loaded');
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
if (typeof module === 'object' && module.exports) {
|
|
337
|
+
module.exports = { createTerminalFrame, claimSig, isClaimed, handsBack, parentOriginOf, tailOf };
|
|
338
|
+
} else {
|
|
339
|
+
createTerminalFrame(root);
|
|
340
|
+
}
|
|
341
|
+
})(typeof window === 'undefined' ? globalThis : window);
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<title>Terminal</title>
|
|
6
|
+
<link rel="stylesheet" href="/fonts/fonts.css">
|
|
7
|
+
<link rel="stylesheet" href="/vendor/xterm/xterm.css">
|
|
8
|
+
<style>
|
|
9
|
+
html, body { margin: 0; height: 100%; overflow: hidden; background: var(--bg, transparent); }
|
|
10
|
+
#host { height: 100%; }
|
|
11
|
+
#host .xterm { height: 100%; }
|
|
12
|
+
/* xterm.css paints the viewport #000; it shows below the last row when the height is not a whole number of rows. */
|
|
13
|
+
/* xterm draws its own scrollbar; the viewport's native one would show at the bottom-right corner. */
|
|
14
|
+
#host .xterm .xterm-viewport { background-color: var(--bg, transparent); scrollbar-width: none; }
|
|
15
|
+
</style>
|
|
16
|
+
<!-- For ClaudeHub.comboOf only. In <head>, where there is no body yet for the SDK to paint theme vars on. -->
|
|
17
|
+
<script src="/vendor/claude-hub-sdk.js"></script>
|
|
18
|
+
</head>
|
|
19
|
+
<body>
|
|
20
|
+
<div id="host"></div>
|
|
21
|
+
<script src="/vendor/xterm/xterm.js"></script>
|
|
22
|
+
<script src="/vendor/xterm/addon-fit.js"></script>
|
|
23
|
+
<script src="/vendor/xterm/addon-webgl.js"></script>
|
|
24
|
+
<script src="/vendor/xterm/addon-unicode11.js"></script>
|
|
25
|
+
<script src="/terminal-frame.js"></script>
|
|
26
|
+
</body>
|
|
27
|
+
</html>
|
|
@@ -1,9 +1,18 @@
|
|
|
1
|
-
// claude-hub-sdk 1.
|
|
1
|
+
// claude-hub-sdk 1.2.0 (sha256 62c7d9f477b2). Copied by npm run sdk:sync in claude-code-hub. Do not edit.
|
|
2
2
|
// Claude Code Hub SDK stub: what an app serves when no hub runs it. Under a hub, the hub hands the
|
|
3
3
|
// app its real SDK (client.js) in place of this file. The API must match client.js; a test checks it.
|
|
4
4
|
((root) => {
|
|
5
5
|
'use strict';
|
|
6
6
|
|
|
7
|
+
// A copy of client.js comboOf(); a test checks they match.
|
|
8
|
+
function comboOf(e) {
|
|
9
|
+
const lower = typeof e.key === 'string' ? e.key.toLowerCase() : '';
|
|
10
|
+
const m = /^(?:Key|Digit)([A-Z1-9])$/.exec(e.code || '');
|
|
11
|
+
const key = /^[a-z1-9]$/.test(lower) ? lower : m ? m[1].toLowerCase() : e.key;
|
|
12
|
+
const mods = [e.ctrlKey && 'ctrl', e.altKey && 'alt', e.shiftKey && 'shift', e.metaKey && 'meta'];
|
|
13
|
+
return [...mods, key].filter(Boolean).join('+');
|
|
14
|
+
}
|
|
15
|
+
|
|
7
16
|
function createClaudeHub(win) {
|
|
8
17
|
let hub = null;
|
|
9
18
|
const off = () => {};
|
|
@@ -33,6 +42,7 @@
|
|
|
33
42
|
},
|
|
34
43
|
can: (action) => !!standaloneFn(action),
|
|
35
44
|
forwards: () => false,
|
|
45
|
+
forwardCombos: () => [],
|
|
36
46
|
closeGuard() {},
|
|
37
47
|
openExternal(url) {
|
|
38
48
|
win.open(url, '_blank', 'noopener');
|
|
@@ -42,9 +52,9 @@
|
|
|
42
52
|
return hub;
|
|
43
53
|
}
|
|
44
54
|
|
|
45
|
-
return { connect };
|
|
55
|
+
return { connect, comboOf };
|
|
46
56
|
}
|
|
47
57
|
|
|
48
|
-
if (typeof module === 'object' && module.exports) module.exports = { createClaudeHub };
|
|
58
|
+
if (typeof module === 'object' && module.exports) module.exports = { createClaudeHub, comboOf };
|
|
49
59
|
else root.ClaudeHub = createClaudeHub(root);
|
|
50
60
|
})(typeof window !== 'undefined' ? window : globalThis);
|