claude-code-hub 1.26.0 → 1.27.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.
@@ -0,0 +1,42 @@
1
+ {
2
+ "manifest": 1,
3
+ "kind": "feature",
4
+ "id": "voice",
5
+ "title": "Voice control",
6
+ "requires": {
7
+ "hub": "1.27.0",
8
+ "apps": ["kanban"],
9
+ "tools": [
10
+ {
11
+ "name": "Hex",
12
+ "check": "voice.hex",
13
+ "url": "https://anoma.ly/hex",
14
+ "optional": true,
15
+ "without": "Voice uses the browser's speech recognition, which is online. Install Hex to keep speech on your machine."
16
+ }
17
+ ]
18
+ },
19
+ "config": { "voice": { "enabled": true } },
20
+ "listing": {
21
+ "tagline": "Hold a key and speak: start a session or send a prompt with no Enter press",
22
+ "description": "Hold Ctrl+Shift+Space, speak and let go. \"New session, fix the flaky test\" opens the New session dialog with that prompt, and anything else goes to the session on screen as a prompt and is submitted. Kanban's New session microphone uses the same speech to text. Hex does the speech to text on your machine; without it, the browser's speech recognition does it.",
23
+ "tags": ["voice", "experimental"],
24
+ "screenshots": [
25
+ {
26
+ "url": "https://nikiforovall.blog/claude-code-hub/shots/extensions/voice-live-new-session.webp",
27
+ "alt": "Kanban's New session dialog opened by voice while the user still speaks: the Prompt field shows \"Fix the flaky login test and then run the\" and a Listening pill sits at the bottom of the hub window",
28
+ "title": "Words show up as you speak",
29
+ "caption": "\"New session\" opens the dialog at once, and the rest of what you say fills the Prompt field.",
30
+ "card": true
31
+ },
32
+ {
33
+ "url": "https://nikiforovall.blog/claude-code-hub/shots/extensions/voice-hex.webp",
34
+ "alt": "The Hex website: \"Speak. It appears.\", local voice dictation for Windows, with the Windows installer and a PowerShell install command",
35
+ "title": "Hex does the speech to text",
36
+ "caption": "Install Hex and start it once. It runs on your machine, and the hub sends it the sound while you hold the keys."
37
+ }
38
+ ],
39
+ "homepage": "https://nikiforovall.blog/claude-code-hub/guides/voice/",
40
+ "license": "MIT"
41
+ }
42
+ }
package/lib/ext.js CHANGED
@@ -25,6 +25,8 @@ const USAGE = `Usage: claude-code-hub ext <command>
25
25
  A Kanban dock add-on (addon.json) installs the same way: in place of a plugin, it goes on the
26
26
  Kanban dock of the config dir, for every session.
27
27
 
28
+ A feature the hub ships (voice) is turned on by ext install <id> and off by ext remove <id>.
29
+
28
30
  --hub-dir <path> The hub dir (default CLAUDE_HUB_DIR, else ~/.claude-hub)
29
31
  --dir <path> The config dir for plugin and dock steps (default CLAUDE_CONFIG_DIR, else ~/.claude)`;
30
32
 
@@ -270,10 +272,56 @@ function install(ctx, target) {
270
272
  if (app) return installAppPlugin(ctx, app);
271
273
  const [addon] = extEntries(config).find(([e, k]) => k === KINDS.addon && e.id === target) ?? [];
272
274
  if (addon) return dockStep(ctx, target, null, 'install', rootOf(ctx, addon), config);
275
+ const feature = readFeature(target, ctx.features);
276
+ if (feature) return setFeature(ctx, feature, true, file, config);
273
277
  if (!fs.existsSync(target) && ID_RE.test(target)) return installRegistry(ctx, target, file, config);
274
278
  return installFolder(ctx, target, file, config);
275
279
  }
276
280
 
281
+ // A feature is code the hub ships, off by default: `ext install` checks what it needs and merges its
282
+ // `config` into config.json; `ext remove` sets `enabled: false` in each of those keys and keeps the rest.
283
+ const FEATURES = path.join(HUB_ROOT, 'features');
284
+ const TOOL_CHECKS = {
285
+ 'voice.hex': () => !!require('./voice/hex').hexExe(),
286
+ };
287
+ const OS_NAMES = { win32: 'Windows', darwin: 'macOS', linux: 'Linux' };
288
+
289
+ function readFeature(id, features = FEATURES) {
290
+ if (!ID_RE.test(id ?? '')) return null;
291
+ const file = path.join(features, id, 'feature.json');
292
+ if (!fs.existsSync(file)) return null;
293
+ const manifest = JSON.parse(fs.readFileSync(file, 'utf8'));
294
+ if (manifest.kind !== 'feature' || manifest.id !== id || typeof manifest.config !== 'object') {
295
+ throw new Error(`${file}: a feature manifest needs "kind": "feature", the id ${id} and a config object`);
296
+ }
297
+ return manifest;
298
+ }
299
+
300
+ function setFeature(ctx, feature, on, file, config) {
301
+ const { id, title = id, requires = {} } = feature;
302
+ if (on) {
303
+ // The hub ships the feature, so its hub version is this one.
304
+ checkRequires(ctx, { id, requires: { ...requires, hub: undefined } }, config);
305
+ const platform = ctx.platform ?? process.platform;
306
+ if (requires.os && !requires.os.includes(platform)) {
307
+ throw new Error(`Cannot use ${id}: it runs on ${requires.os.map((o) => OS_NAMES[o] ?? o).join(' or ')} only.`);
308
+ }
309
+ const checks = { ...TOOL_CHECKS, ...ctx.toolChecks };
310
+ const absent = (requires.tools ?? []).filter((t) => !checks[t.check]?.());
311
+ const named = (t) => (t.url ? `${t.name} (${t.url})` : t.name);
312
+ const missing = absent.filter((t) => !t.optional);
313
+ if (missing.length) throw new Error(`Cannot use ${id}: install ${missing.map(named).join(' and ')} first.`);
314
+ for (const t of absent.filter((t) => t.optional)) console.log(`\n ${named(t)} is not installed. ${t.without ?? ''}`.trimEnd());
315
+ }
316
+ const next = { ...config };
317
+ for (const [key, value] of Object.entries(feature.config)) {
318
+ next[key] = on ? { ...config[key], ...value } : { ...config[key], enabled: false };
319
+ }
320
+ writeConfigFile(file, next);
321
+ console.log(`\n ${title} is ${on ? 'on' : 'off'}.\n${RESTART}`);
322
+ return true;
323
+ }
324
+
277
325
  // fetch is async and ext runs synchronously, so a child node reads the index.
278
326
  const FETCH =
279
327
  'fetch(process.argv[1]).then(async (r) => { if (!r.ok) throw new Error(`HTTP ${r.status}`); process.stdout.write(await r.text()); })' +
@@ -454,6 +502,8 @@ function update(ctx, id) {
454
502
 
455
503
  function remove(ctx, id) {
456
504
  const { file, config } = readConfig(ctx.hubDir);
505
+ const feature = readFeature(id, ctx.features);
506
+ if (feature) return setFeature(ctx, feature, false, file, config);
457
507
  const [entry, kind] = managedEntry(config, id, 'be removed');
458
508
  const root = rootOf(ctx, entry);
459
509
  let ext = null;
package/lib/keymap.js CHANGED
@@ -10,6 +10,7 @@ const HUB_ACTIONS = [
10
10
  'hub.prevApp',
11
11
  'hub.nextApp',
12
12
  'hub.appByNumber',
13
+ 'hub.voice',
13
14
  ];
14
15
  // {n} stands for the tab number, 1 to 9.
15
16
  const PATTERN_ACTIONS = new Set(['hub.appByNumber']);
@@ -0,0 +1,77 @@
1
+ 'use strict';
2
+
3
+ // Decider: a few fixed phrases, no network. Gives null for anything else, so the next decider can
4
+ // try it. A command starts with the reserved word "hub", so a prompt never turns into one.
5
+ const norm = (s) =>
6
+ String(s ?? '')
7
+ .toLowerCase()
8
+ .replace(/[^a-z0-9]+/g, ' ')
9
+ .trim();
10
+
11
+ // Speech services often hear "hub" as these.
12
+ const PREFIX = /^\s*(?:hub|hob|hab)\b[\s,.:;!?-]*/i;
13
+
14
+ const TAIL = '\\b[\\s,.:;!?-]*([\\s\\S]*?)\\s*$';
15
+ const phrase = (verbs, noun) =>
16
+ new RegExp(`^(?:(?:${verbs})\\s+(?:(?:a|the)\\s+)?(?:new\\s+)?)?${noun}${TAIL}`, 'i');
17
+ const dropEndPunct = (s) => s.replace(/[.!?]+$/, '');
18
+
19
+ // `param` takes the words after the phrase; `tidy` cleans them. `say` and `help` feed the page's help panel.
20
+ // `live` names the app's dialog action that the page fills as the user speaks, and the param that takes
21
+ // the words so far.
22
+ const COMMANDS = [
23
+ {
24
+ action: 'session.new',
25
+ label: 'New session',
26
+ phrase: phrase('start|create|new', 'session'),
27
+ param: 'prompt',
28
+ say: 'new session',
29
+ help: 'Opens New session with your prompt',
30
+ },
31
+ {
32
+ action: 'session.find',
33
+ label: 'Open session',
34
+ phrase: new RegExp(`^(?:open|switch\\s+to|go\\s+to|show)\\s+(?:(?:the|my)\\s+)?session${TAIL}`, 'i'),
35
+ param: 'name',
36
+ tidy: dropEndPunct,
37
+ say: 'open session',
38
+ help: 'Opens the session that matches',
39
+ },
40
+ {
41
+ action: 'task.add',
42
+ label: 'Add task',
43
+ phrase: phrase('add|create|new', 'task'),
44
+ param: 'subject',
45
+ tidy: dropEndPunct,
46
+ say: 'add a task',
47
+ help: 'Opens Add task with your words',
48
+ live: { action: 'task.live', param: 'description' },
49
+ },
50
+ ];
51
+
52
+ const commandList = () =>
53
+ COMMANDS.map(({ action, label, param, say, help, live }) => ({ action, label, param, say, help, live }));
54
+
55
+ // {action, label, param, rest} for the first command an app handles, else null. After the prefix
56
+ // with no such command yet, action is null: the words are still not a prompt.
57
+ function matchCommand(text, can = () => true) {
58
+ const said = String(text ?? '');
59
+ const prefix = PREFIX.exec(said);
60
+ if (!prefix) return null;
61
+ const body = said.slice(prefix[0].length);
62
+ for (const c of COMMANDS) {
63
+ const m = c.phrase.exec(body);
64
+ if (!m || !can(c.action)) continue;
65
+ return { action: c.action, label: c.label, param: c.param, live: c.live, rest: c.tidy ? c.tidy(m[1]) : m[1] };
66
+ }
67
+ return { action: null, label: 'Hub', rest: body.trim() };
68
+ }
69
+
70
+ async function grammar(ctx) {
71
+ const m = matchCommand(ctx.text, ctx.can);
72
+ if (!m) return null;
73
+ if (!m.action) return { kind: 'unknown' };
74
+ return { kind: 'invoke', action: m.action, params: m.rest ? { [m.param]: m.rest } : {}, label: m.label };
75
+ }
76
+
77
+ module.exports = { grammar, matchCommand, norm, commandList };
@@ -0,0 +1,155 @@
1
+ 'use strict';
2
+
3
+ // Live speech through the Hex app's local API (/streams): the hub page sends mic audio, Hex sends
4
+ // partial text back. The bearer token and the stream's owner token stay in the hub.
5
+ const fs = require('node:fs');
6
+ const path = require('node:path');
7
+
8
+ const SAMPLE_RATE = 16000;
9
+ // Hex drops a stream whose lease (10 s) runs out with no call.
10
+ const HEARTBEAT_MS = 4000;
11
+
12
+ function readApi(file) {
13
+ try {
14
+ const api = JSON.parse(fs.readFileSync(file, 'utf8'));
15
+ return api.port && api.token ? api : null;
16
+ } catch {
17
+ return null;
18
+ }
19
+ }
20
+
21
+ const failOf = (r) => ({ ok: false, error: r.json.error || `Hex answered ${r.status}` });
22
+
23
+ function createHexStream({ apiFile = path.join(process.env.APPDATA || '', 'hex', 'local-api.json'), fetchImpl = fetch } = {}) {
24
+ let live = null;
25
+
26
+ async function call(method, url, { headers = {}, body, signal } = {}) {
27
+ const r = await fetchImpl(url, { method, headers, body, signal });
28
+ const text = await r.text();
29
+ let json = {};
30
+ try {
31
+ json = text ? JSON.parse(text) : {};
32
+ } catch {}
33
+ return { status: r.status, ok: r.ok, json };
34
+ }
35
+
36
+ function textOf(s) {
37
+ const done = s.segments.filter((t, i) => t && i < s.current);
38
+ return [...done, s.partial].filter(Boolean).join(' ').trim();
39
+ }
40
+
41
+ function onEvent(s, e) {
42
+ if (e.type === 'partial') {
43
+ s.current = e.segment;
44
+ s.partial = `${e.stable ?? ''}${e.tentative ?? ''}`;
45
+ } else if (e.type === 'segment') {
46
+ s.segments[e.segment] = e.text;
47
+ if (e.segment >= s.current) {
48
+ s.current = e.segment + 1;
49
+ s.partial = '';
50
+ }
51
+ }
52
+ s.text = textOf(s);
53
+ }
54
+
55
+ async function listen(s) {
56
+ try {
57
+ const r = await fetchImpl(`${s.base}/streams/${s.id}/events`, { headers: s.headers, signal: s.abort.signal });
58
+ const dec = new TextDecoder();
59
+ let buf = '';
60
+ for await (const chunk of r.body) {
61
+ buf += dec.decode(chunk, { stream: true });
62
+ let i = buf.indexOf('\n\n');
63
+ while (i >= 0) {
64
+ const data = buf.slice(0, i).split('\n').find((l) => l.startsWith('data:'));
65
+ buf = buf.slice(i + 2);
66
+ if (data) {
67
+ try {
68
+ onEvent(s, JSON.parse(data.slice(5)));
69
+ } catch {}
70
+ }
71
+ i = buf.indexOf('\n\n');
72
+ }
73
+ }
74
+ } catch {}
75
+ }
76
+
77
+ function end(s) {
78
+ clearInterval(s.heartbeat);
79
+ s.abort.abort();
80
+ if (live === s) live = null;
81
+ }
82
+
83
+ // {ok, unsupported?}: unsupported means fall back to the CLI (no local API, or no /streams).
84
+ async function start() {
85
+ if (live) await cancel();
86
+ const api = readApi(apiFile);
87
+ if (!api) return { ok: false, unsupported: true, error: 'Hex local API not found' };
88
+ const base = `http://127.0.0.1:${api.port}`;
89
+ const auth = { Authorization: `Bearer ${api.token}` };
90
+ let r;
91
+ try {
92
+ r = await call('POST', `${base}/streams`, {
93
+ headers: { ...auth, 'Content-Type': 'application/json' },
94
+ body: JSON.stringify({ sampleFormat: 'f32le', sampleRate: SAMPLE_RATE }),
95
+ });
96
+ } catch (err) {
97
+ return { ok: false, unsupported: true, error: err.message };
98
+ }
99
+ if (r.status === 404) return { ok: false, unsupported: true, error: 'Hex has no /streams' };
100
+ if (!r.ok) return failOf(r);
101
+ const s = {
102
+ base,
103
+ id: r.json.id,
104
+ headers: { ...auth, 'X-Hex-Stream-Token': r.json.ownerToken },
105
+ abort: new AbortController(),
106
+ segments: [],
107
+ current: 0,
108
+ partial: '',
109
+ text: '',
110
+ };
111
+ s.heartbeat = setInterval(() => {
112
+ call('POST', `${base}/streams/${s.id}/heartbeat`, { headers: s.headers }).catch(() => {});
113
+ }, HEARTBEAT_MS);
114
+ live = s;
115
+ listen(s);
116
+ return { ok: true, sampleRate: SAMPLE_RATE };
117
+ }
118
+
119
+ async function audio(buf) {
120
+ const s = live;
121
+ if (!s) return { ok: false, error: 'no live stream' };
122
+ const r = await call('POST', `${s.base}/streams/${s.id}/audio`, {
123
+ headers: { ...s.headers, 'Content-Type': 'application/octet-stream' },
124
+ body: buf,
125
+ }).catch((err) => ({ ok: false, json: { error: err.message } }));
126
+ if (!r.ok) return failOf(r);
127
+ return { ok: true, text: s.text };
128
+ }
129
+
130
+ async function finish() {
131
+ const s = live;
132
+ if (!s) return { ok: false, error: 'no live stream' };
133
+ try {
134
+ const r = await call('POST', `${s.base}/streams/${s.id}/finish`, { headers: { ...s.headers, 'Content-Length': '0' } });
135
+ if (!r.ok) return failOf(r);
136
+ return { ok: true, text: (r.json.text ?? '').trim() };
137
+ } catch (err) {
138
+ return { ok: false, error: err.message };
139
+ } finally {
140
+ end(s);
141
+ }
142
+ }
143
+
144
+ async function cancel() {
145
+ const s = live;
146
+ if (!s) return { ok: true };
147
+ end(s);
148
+ await call('POST', `${s.base}/streams/${s.id}/cancel`, { headers: s.headers }).catch(() => {});
149
+ return { ok: true };
150
+ }
151
+
152
+ return { start, audio, finish, cancel };
153
+ }
154
+
155
+ module.exports = { createHexStream };
@@ -0,0 +1,53 @@
1
+ 'use strict';
2
+
3
+ // Speech provider: the Hex app's CLI (https://github.com/anomalyco/hex).
4
+ const fs = require('node:fs');
5
+ const path = require('node:path');
6
+ const { spawn } = require('node:child_process');
7
+
8
+ // Hex keeps one folder per version and names the current one in current-version.
9
+ function hexExe(root = path.join(process.env.APPDATA || '', 'hex')) {
10
+ if (process.env.HEX_EXE) return process.env.HEX_EXE;
11
+ try {
12
+ const v = fs.readFileSync(path.join(root, 'current-version'), 'utf8').trim();
13
+ const exe = path.join(root, 'versions', v, process.platform === 'win32' ? 'hex.exe' : 'hex');
14
+ return fs.existsSync(exe) ? exe : null;
15
+ } catch {
16
+ return null;
17
+ }
18
+ }
19
+
20
+ function run(cmd, args, timeoutMs) {
21
+ return new Promise((resolve) => {
22
+ const p = spawn(cmd, args, { windowsHide: true });
23
+ let out = '';
24
+ let err = '';
25
+ const timer = setTimeout(() => p.kill(), timeoutMs);
26
+ p.stdout.on('data', (d) => (out += d));
27
+ p.stderr.on('data', (d) => (err += d));
28
+ p.on('error', (e) => {
29
+ clearTimeout(timer);
30
+ resolve({ code: -1, out, err: e.message });
31
+ });
32
+ p.on('close', (code) => {
33
+ clearTimeout(timer);
34
+ resolve({ code, out, err });
35
+ });
36
+ });
37
+ }
38
+
39
+ // `dictation stop --print` prints the transcript, and exits 4 when Hex heard no speech.
40
+ function createHex({ exe = hexExe() } = {}) {
41
+ async function call(action) {
42
+ if (!exe) return { ok: false, error: 'Hex is not installed' };
43
+ const args = action === 'stop' ? ['dictation', 'stop', '--print'] : ['dictation', action];
44
+ const r = await run(exe, args, 70_000);
45
+ const error = r.code ? (r.err || r.out).trim() || `hex exited ${r.code}` : undefined;
46
+ if (action !== 'stop') return { ok: !r.code, error };
47
+ if (r.code === 4) return { ok: true, text: '' };
48
+ return { ok: !r.code, text: r.out.trim(), error };
49
+ }
50
+ return { start: () => call('start'), stop: () => call('stop'), cancel: () => call('cancel') };
51
+ }
52
+
53
+ module.exports = { createHex, hexExe };
@@ -0,0 +1,111 @@
1
+ 'use strict';
2
+
3
+ // Voice control: a speech provider gives text, deciders turn it into a command for the hub page.
4
+ // A command is {kind: 'invoke', action, params, label?} | {kind: 'none'} | {kind: 'unknown'}.
5
+ // The local grammar runs first; a decider named in voice.decider gets only what the grammar did not
6
+ // know. Text no decider takes is a prompt: pasted into the session on screen as a draft. The page
7
+ // sends it when the user pressed Enter during the hold.
8
+ // The browser provider runs the browser's speech recognition on the hub page, which posts the text
9
+ // to text(); the server only decides. auto picks Hex when it is installed, else the browser.
10
+ const { createHex, hexExe } = require('./hex');
11
+ const { createHexStream } = require('./hex-stream');
12
+ const { grammar, matchCommand, norm, commandList } = require('./grammar');
13
+ const { titleTask, askClaude } = require('./title');
14
+
15
+ const SPEECH = { hex: createHex };
16
+ const STREAMS = { hex: () => createHexStream() };
17
+ const DECIDERS = { grammar: () => grammar };
18
+ const PROVIDERS = ['auto', 'hex', 'browser'];
19
+ const IN_BROWSER = async () => ({ ok: false, error: 'speech runs in the browser' });
20
+
21
+ function resolveProvider(name = 'auto', hasHex = () => !!hexExe()) {
22
+ if (!PROVIDERS.includes(name)) throw new Error(`unknown voice provider ${name}`);
23
+ return name === 'auto' ? (hasHex() ? 'hex' : 'browser') : name;
24
+ }
25
+
26
+ // cfg: config.json `voice`. ctx(): {apps, actions} for one decision, read per call.
27
+ function createVoice(cfg = {}, ctx = () => ({}), deps = {}) {
28
+ const provider = resolveProvider(cfg.provider, deps.hasHex);
29
+ const inBrowser = provider === 'browser';
30
+ const speech = inBrowser ? null : (deps.speech ?? SPEECH[provider])(cfg);
31
+ const named = cfg.decider && cfg.decider !== 'grammar' ? cfg.decider : null;
32
+ if (named && !DECIDERS[named]) throw new Error(`unknown voice decider ${named}`);
33
+ const deciders = [grammar, ...(named ? [DECIDERS[named](cfg)] : [])];
34
+
35
+ // app: the page's tab. Voice runs only the actions that app handles, so a hold never switches tabs.
36
+ const canIn = (c, app) => (action) => Object.hasOwn(c.actions ?? {}, action) && (!app || c.actions[action].app === app);
37
+ const titleModel = cfg.taskTitleModel;
38
+ const ask = titleModel ? (deps.ask ?? ((text, c) => askClaude(text, titleModel, c.configDir))) : null;
39
+
40
+ async function withTitle(command, c) {
41
+ if (command.action !== 'task.add' || !command.params.subject) return command;
42
+ const params = await titleTask(command.params.subject, ask && ((text) => ask(text, c)));
43
+ return { ...command, params };
44
+ }
45
+
46
+ async function decide(text, app) {
47
+ if (!norm(text)) return { kind: 'none' };
48
+ const c = ctx();
49
+ const input = { text, apps: c.apps ?? {}, can: canIn(c, app) };
50
+ for (const d of deciders) {
51
+ const command = await d(input);
52
+ if (command) return command.kind === 'invoke' ? withTitle(command, c) : command;
53
+ }
54
+ if (!input.can('session.paste')) return { kind: 'unknown' };
55
+ return { kind: 'invoke', action: 'session.paste', params: { text: text.trim() } };
56
+ }
57
+
58
+ async function stop(app) {
59
+ const r = await speech.stop();
60
+ if (!r.ok) return r;
61
+ return { ...r, command: await decide(r.text, app) };
62
+ }
63
+
64
+ // Live text while the key is held: {text, newSession, command?}. With newSession, text is the prompt
65
+ // after "new session", so the page opens the dialog at once and types the rest into it. With
66
+ // command (its label), text is the rest after the prefix or phrase, which the page shows but never types.
67
+ // action names the matched row; a row with a live dialog carries the words so far as its params.
68
+ function live(text, app) {
69
+ const m = matchCommand(text, canIn(ctx(), app));
70
+ if (!m) return { text, newSession: false };
71
+ if (m.action === 'session.new') return { text: m.rest, newSession: true };
72
+ const r = { text: m.rest, newSession: false, command: m.label };
73
+ if (!m.action) return r;
74
+ return { ...r, action: m.action, ...(m.live && { params: { [m.live.param]: m.rest } }) };
75
+ }
76
+
77
+ // The browser provider's text so far, and its final text with the command.
78
+ async function text(said, final, app) {
79
+ const t = String(said ?? '').trim();
80
+ if (!final) return { ok: true, ...live(t, app) };
81
+ return { ok: true, ...live(t, app), command: await decide(t, app) };
82
+ }
83
+
84
+ const stream = inBrowser ? null : ((deps.stream ?? STREAMS[provider])?.(cfg) ?? null);
85
+ const streamOps = stream && {
86
+ start: () => stream.start(),
87
+ audio: async (buf, app) => {
88
+ const r = await stream.audio(buf);
89
+ return r.ok ? { ...r, ...live(r.text, app) } : r;
90
+ },
91
+ finish: async (_body, app) => {
92
+ const r = await stream.finish();
93
+ return r.ok ? { ...r, ...live(r.text, app), command: await decide(r.text, app) } : r;
94
+ },
95
+ cancel: () => stream.cancel(),
96
+ };
97
+
98
+ const base = {
99
+ provider,
100
+ auto: (cfg.provider ?? 'auto') === 'auto',
101
+ commands: commandList(),
102
+ decide,
103
+ live,
104
+ text,
105
+ stream: streamOps,
106
+ };
107
+ if (inBrowser) return { ...base, start: IN_BROWSER, stop: IN_BROWSER, dictate: IN_BROWSER, cancel: IN_BROWSER };
108
+ return { ...base, start: () => speech.start(), stop, dictate: () => speech.stop(), cancel: () => speech.cancel() };
109
+ }
110
+
111
+ module.exports = { createVoice };
@@ -0,0 +1,77 @@
1
+ 'use strict';
2
+
3
+ // Turns a spoken task into a title and a description. Short text is the title as said. Longer text
4
+ // stays whole in the description, as said, and gets a title: written by voice.taskTitleModel when
5
+ // it is set and answers, else the words before the first sentence break, else none.
6
+ const { spawn } = require('node:child_process');
7
+ const os = require('node:os');
8
+
9
+ const MAX_TITLE_WORDS = 8;
10
+ const MAX_MODEL_TITLE_WORDS = 12;
11
+ const ASK_TIMEOUT_MS = 15000;
12
+ const BREAK = /^(.+?)[,.;:!?](?:\s|$)/;
13
+
14
+ const PROMPT =
15
+ 'You get a spoken task. Write a short title for it, 3 to 8 words, in the language of the task, using its own words where you can. No quotes, no period. Reply with the title only.';
16
+
17
+ const words = (s) => s.split(/\s+/).filter(Boolean).length;
18
+
19
+ function cleanTitle(answer) {
20
+ const title = String(answer ?? '')
21
+ .trim()
22
+ .replace(/^["'“”‘’]+|["'“”‘’]+$/g, '')
23
+ .replace(/[.!]+$/, '')
24
+ .trim();
25
+ if (!title || title.includes('\n') || words(title) > MAX_MODEL_TITLE_WORDS) return '';
26
+ return title;
27
+ }
28
+
29
+ // `ask(text)` answers a title; an empty, multi-line or long answer is ignored.
30
+ async function titleTask(said, ask) {
31
+ const text = String(said ?? '').trim();
32
+ if (words(text) <= MAX_TITLE_WORDS) return { subject: text };
33
+ const title = ask ? cleanTitle(await ask(text).catch(() => '')) : '';
34
+ const subject = title || (BREAK.exec(text)?.[1] ?? '');
35
+ return { subject, description: text };
36
+ }
37
+
38
+ // One plain answer from the Claude Code CLI: no tools, MCP servers, settings, hooks or skills, the
39
+ // default system prompt replaced, and no transcript, so no session shows on the board. `configDir`
40
+ // is the hub's active one when it is not the default, so the CLI uses that dir's login.
41
+ function askClaude(text, model, configDir) {
42
+ const env = configDir ? { ...process.env, CLAUDE_CONFIG_DIR: configDir } : process.env;
43
+ const args = ['-p', '--model', model, '--output-format', 'json', '--tools', '', '--strict-mcp-config'];
44
+ args.push('--setting-sources', '', '--disable-slash-commands', '--no-session-persistence', '--max-turns', '1');
45
+ args.push('--system-prompt', PROMPT, text);
46
+ return new Promise((resolve, reject) => {
47
+ let child;
48
+ try {
49
+ // stdin closed: the CLI otherwise waits 3 s for piped input.
50
+ child = spawn('claude', args, { cwd: os.tmpdir(), env, windowsHide: true, stdio: ['ignore', 'pipe', 'ignore'] });
51
+ } catch (err) {
52
+ return reject(err);
53
+ }
54
+ let out = '';
55
+ const timer = setTimeout(() => child.kill(), ASK_TIMEOUT_MS);
56
+ child.stdout.on('data', (d) => {
57
+ out += d;
58
+ });
59
+ child.on('error', (err) => {
60
+ clearTimeout(timer);
61
+ reject(err);
62
+ });
63
+ child.on('close', () => {
64
+ clearTimeout(timer);
65
+ try {
66
+ const r = JSON.parse(out);
67
+ // A failed login or API error comes back as text in `result`, flagged by is_error.
68
+ if (r.is_error) throw new Error(String(r.result ?? 'claude failed'));
69
+ resolve(String(r.result ?? ''));
70
+ } catch (err) {
71
+ reject(err);
72
+ }
73
+ });
74
+ });
75
+ }
76
+
77
+ module.exports = { titleTask, askClaude };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-code-hub",
3
- "version": "1.26.0",
3
+ "version": "1.27.1",
4
4
  "description": "Unified hub for Claude Code tools — Marketplace + Kanban in one PWA",
5
5
  "main": "server.js",
6
6
  "bin": {
@@ -46,10 +46,10 @@
46
46
  },
47
47
  "homepage": "https://github.com/NikiforovAll/claude-code-hub#readme",
48
48
  "dependencies": {
49
- "claude-code-cost": "1.6.0",
50
- "claude-code-kanban": "7.23.0",
51
- "claude-code-marketplace": "1.7.1",
52
- "claude-code-memory-explorer": "1.3.4",
49
+ "claude-code-cost": "1.6.1",
50
+ "claude-code-kanban": "7.24.0",
51
+ "claude-code-marketplace": "1.7.2",
52
+ "claude-code-memory-explorer": "1.3.5",
53
53
  "express": "^4.21.0",
54
54
  "open": "^10.1.0"
55
55
  },
@@ -60,6 +60,7 @@
60
60
  "server.js",
61
61
  "public/**/*",
62
62
  "lib/**/*",
63
+ "features/**/*",
63
64
  "plugin/**/*",
64
65
  "tray/**/*",
65
66
  "packages/claude-hub-sdk/src/**/*"
@@ -17,7 +17,7 @@ var ClaudeHubKeys = (() => {
17
17
  function comboOf(e) {
18
18
  const lower = typeof e.key === 'string' ? e.key.toLowerCase() : '';
19
19
  const m = !e.getModifierState?.('AltGraph') && /^(?:Key|Digit)([A-Z1-9])$/.exec(e.code || '');
20
- const key = /^[a-z1-9]$/.test(lower) ? lower : m ? m[1].toLowerCase() : e.key;
20
+ const key = /^[a-z1-9]$/.test(lower) ? lower : m ? m[1].toLowerCase() : e.key === ' ' ? 'Space' : e.key;
21
21
  const mods = [e.ctrlKey && 'ctrl', e.altKey && 'alt', e.shiftKey && 'shift', e.metaKey && 'meta'];
22
22
  return [...mods, key].filter(Boolean).join('+');
23
23
  }