claude-code-session-manager 0.2.6 → 0.3.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.
Files changed (32) hide show
  1. package/dist/assets/{cssMode-k2weP8Ee.js → cssMode-CmcpKapf.js} +1 -1
  2. package/dist/assets/{editor.main-DY3GhIm6.js → editor.main-QdhRqSRj.js} +3 -3
  3. package/dist/assets/{freemarker2-O9B_mSg1.js → freemarker2-MB42wuNH.js} +1 -1
  4. package/dist/assets/{handlebars-D8eGynvT.js → handlebars-BNqpiTGG.js} +1 -1
  5. package/dist/assets/{html-D_1J65SJ.js → html-Uip3YcGr.js} +1 -1
  6. package/dist/assets/{htmlMode-CPHN_TXP.js → htmlMode-DYyTepoa.js} +1 -1
  7. package/dist/assets/index-BzbwWnyF.css +32 -0
  8. package/dist/assets/index-DIBKIDmx.js +2971 -0
  9. package/dist/assets/{javascript-CIJ7nm35.js → javascript-DrEGK9I4.js} +1 -1
  10. package/dist/assets/{jsonMode-S3mSlD5S.js → jsonMode-CSTtGACB.js} +1 -1
  11. package/dist/assets/{liquid-ePkiDuZB.js → liquid-RBrA4NW9.js} +1 -1
  12. package/dist/assets/{lspLanguageFeatures-zMm9RPxQ.js → lspLanguageFeatures-BduwC_yH.js} +1 -1
  13. package/dist/assets/{mdx-DIYfdpmA.js → mdx-DkPoTolY.js} +1 -1
  14. package/dist/assets/{python-BXgUyeux.js → python-CVO-ynpa.js} +1 -1
  15. package/dist/assets/{razor-DObpPm_2.js → razor-M76CcRjZ.js} +1 -1
  16. package/dist/assets/{tsMode-B8apA2ua.js → tsMode-D8OXqf9n.js} +1 -1
  17. package/dist/assets/{typescript-vbToHgci.js → typescript-CBXprweT.js} +1 -1
  18. package/dist/assets/{whisperWorker-BhltUYOx.js → whisperWorker-HvcbMQn6.js} +17 -17
  19. package/dist/assets/{xml-B9aEuD-v.js → xml-EEhcqUb-.js} +1 -1
  20. package/dist/assets/{yaml-jf6LIEfe.js → yaml-B8BKq6X4.js} +1 -1
  21. package/dist/index.html +2 -2
  22. package/package.json +1 -1
  23. package/src/main/index.cjs +50 -1
  24. package/src/main/lib/voice-hotkey-log.cjs +60 -0
  25. package/src/main/logs.cjs +82 -0
  26. package/src/main/voiceHotkey.cjs +346 -0
  27. package/src/main/voiceSettings.cjs +337 -0
  28. package/src/main/voiceWizard.cjs +54 -0
  29. package/src/preload/api.d.ts +86 -0
  30. package/src/preload/index.cjs +33 -0
  31. package/dist/assets/index-BYHTuGIu.css +0 -32
  32. package/dist/assets/index-CS33Ey9b.js +0 -2971
@@ -1 +1 @@
1
- import{l as e}from"./editor.main-DY3GhIm6.js";import"./index-CS33Ey9b.js";const o={comments:{blockComment:["<!--","-->"]},brackets:[["<",">"]],autoClosingPairs:[{open:"<",close:">"},{open:"'",close:"'"},{open:'"',close:'"'}],surroundingPairs:[{open:"<",close:">"},{open:"'",close:"'"},{open:'"',close:'"'}],onEnterRules:[{beforeText:new RegExp("<([_:\\w][_:\\w-.\\d]*)([^/>]*(?!/)>)[^<]*$","i"),afterText:/^<\/([_:\w][_:\w-.\d]*)\s*>$/i,action:{indentAction:e.IndentAction.IndentOutdent}},{beforeText:new RegExp("<(\\w[\\w\\d]*)([^/>]*(?!/)>)[^<]*$","i"),action:{indentAction:e.IndentAction.Indent}}]},i={defaultToken:"",tokenPostfix:".xml",ignoreCase:!0,qualifiedName:/(?:[\w\.\-]+:)?[\w\.\-]+/,tokenizer:{root:[[/[^<&]+/,""],{include:"@whitespace"},[/(<)(@qualifiedName)/,[{token:"delimiter"},{token:"tag",next:"@tag"}]],[/(<\/)(@qualifiedName)(\s*)(>)/,[{token:"delimiter"},{token:"tag"},"",{token:"delimiter"}]],[/(<\?)(@qualifiedName)/,[{token:"delimiter"},{token:"metatag",next:"@tag"}]],[/(<\!)(@qualifiedName)/,[{token:"delimiter"},{token:"metatag",next:"@tag"}]],[/<\!\[CDATA\[/,{token:"delimiter.cdata",next:"@cdata"}],[/&\w+;/,"string.escape"]],cdata:[[/[^\]]+/,""],[/\]\]>/,{token:"delimiter.cdata",next:"@pop"}],[/\]/,""]],tag:[[/[ \t\r\n]+/,""],[/(@qualifiedName)(\s*=\s*)("[^"]*"|'[^']*')/,["attribute.name","","attribute.value"]],[/(@qualifiedName)(\s*=\s*)("[^">?\/]*|'[^'>?\/]*)(?=[\?\/]\>)/,["attribute.name","","attribute.value"]],[/(@qualifiedName)(\s*=\s*)("[^">]*|'[^'>]*)/,["attribute.name","","attribute.value"]],[/@qualifiedName/,"attribute.name"],[/\?>/,{token:"delimiter",next:"@pop"}],[/(\/)(>)/,[{token:"tag"},{token:"delimiter",next:"@pop"}]],[/>/,{token:"delimiter",next:"@pop"}]],whitespace:[[/[ \t\r\n]+/,""],[/<!--/,{token:"comment",next:"@comment"}]],comment:[[/[^<\-]+/,"comment.content"],[/-->/,{token:"comment",next:"@pop"}],[/<!--/,"comment.content.invalid"],[/[<\-]/,"comment.content"]]}};export{o as conf,i as language};
1
+ import{l as e}from"./editor.main-QdhRqSRj.js";import"./index-DIBKIDmx.js";const o={comments:{blockComment:["<!--","-->"]},brackets:[["<",">"]],autoClosingPairs:[{open:"<",close:">"},{open:"'",close:"'"},{open:'"',close:'"'}],surroundingPairs:[{open:"<",close:">"},{open:"'",close:"'"},{open:'"',close:'"'}],onEnterRules:[{beforeText:new RegExp("<([_:\\w][_:\\w-.\\d]*)([^/>]*(?!/)>)[^<]*$","i"),afterText:/^<\/([_:\w][_:\w-.\d]*)\s*>$/i,action:{indentAction:e.IndentAction.IndentOutdent}},{beforeText:new RegExp("<(\\w[\\w\\d]*)([^/>]*(?!/)>)[^<]*$","i"),action:{indentAction:e.IndentAction.Indent}}]},i={defaultToken:"",tokenPostfix:".xml",ignoreCase:!0,qualifiedName:/(?:[\w\.\-]+:)?[\w\.\-]+/,tokenizer:{root:[[/[^<&]+/,""],{include:"@whitespace"},[/(<)(@qualifiedName)/,[{token:"delimiter"},{token:"tag",next:"@tag"}]],[/(<\/)(@qualifiedName)(\s*)(>)/,[{token:"delimiter"},{token:"tag"},"",{token:"delimiter"}]],[/(<\?)(@qualifiedName)/,[{token:"delimiter"},{token:"metatag",next:"@tag"}]],[/(<\!)(@qualifiedName)/,[{token:"delimiter"},{token:"metatag",next:"@tag"}]],[/<\!\[CDATA\[/,{token:"delimiter.cdata",next:"@cdata"}],[/&\w+;/,"string.escape"]],cdata:[[/[^\]]+/,""],[/\]\]>/,{token:"delimiter.cdata",next:"@pop"}],[/\]/,""]],tag:[[/[ \t\r\n]+/,""],[/(@qualifiedName)(\s*=\s*)("[^"]*"|'[^']*')/,["attribute.name","","attribute.value"]],[/(@qualifiedName)(\s*=\s*)("[^">?\/]*|'[^'>?\/]*)(?=[\?\/]\>)/,["attribute.name","","attribute.value"]],[/(@qualifiedName)(\s*=\s*)("[^">]*|'[^'>]*)/,["attribute.name","","attribute.value"]],[/@qualifiedName/,"attribute.name"],[/\?>/,{token:"delimiter",next:"@pop"}],[/(\/)(>)/,[{token:"tag"},{token:"delimiter",next:"@pop"}]],[/>/,{token:"delimiter",next:"@pop"}]],whitespace:[[/[ \t\r\n]+/,""],[/<!--/,{token:"comment",next:"@comment"}]],comment:[[/[^<\-]+/,"comment.content"],[/-->/,{token:"comment",next:"@pop"}],[/<!--/,"comment.content.invalid"],[/[<\-]/,"comment.content"]]}};export{o as conf,i as language};
@@ -1 +1 @@
1
- import{l as e}from"./editor.main-DY3GhIm6.js";import"./index-CS33Ey9b.js";const o={comments:{lineComment:"#"},brackets:[["{","}"],["[","]"],["(",")"]],autoClosingPairs:[{open:"{",close:"}"},{open:"[",close:"]"},{open:"(",close:")"},{open:'"',close:'"'},{open:"'",close:"'"}],surroundingPairs:[{open:"{",close:"}"},{open:"[",close:"]"},{open:"(",close:")"},{open:'"',close:'"'},{open:"'",close:"'"}],folding:{offSide:!0},onEnterRules:[{beforeText:/:\s*$/,action:{indentAction:e.IndentAction.Indent}}]},r={tokenPostfix:".yaml",brackets:[{token:"delimiter.bracket",open:"{",close:"}"},{token:"delimiter.square",open:"[",close:"]"}],keywords:["true","True","TRUE","false","False","FALSE","null","Null","Null","~"],numberInteger:/(?:0|[+-]?[0-9]+)/,numberFloat:/(?:0|[+-]?[0-9]+)(?:\.[0-9]+)?(?:e[-+][1-9][0-9]*)?/,numberOctal:/0o[0-7]+/,numberHex:/0x[0-9a-fA-F]+/,numberInfinity:/[+-]?\.(?:inf|Inf|INF)/,numberNaN:/\.(?:nan|Nan|NAN)/,numberDate:/\d{4}-\d\d-\d\d([Tt ]\d\d:\d\d:\d\d(\.\d+)?(( ?[+-]\d\d?(:\d\d)?)|Z)?)?/,escapes:/\\(?:[btnfr\\"']|[0-7][0-7]?|[0-3][0-7]{2})/,tokenizer:{root:[{include:"@whitespace"},{include:"@comment"},[/%[^ ]+.*$/,"meta.directive"],[/---/,"operators.directivesEnd"],[/\.{3}/,"operators.documentEnd"],[/[-?:](?= )/,"operators"],{include:"@anchor"},{include:"@tagHandle"},{include:"@flowCollections"},{include:"@blockStyle"},[/@numberInteger(?![ \t]*\S+)/,"number"],[/@numberFloat(?![ \t]*\S+)/,"number.float"],[/@numberOctal(?![ \t]*\S+)/,"number.octal"],[/@numberHex(?![ \t]*\S+)/,"number.hex"],[/@numberInfinity(?![ \t]*\S+)/,"number.infinity"],[/@numberNaN(?![ \t]*\S+)/,"number.nan"],[/@numberDate(?![ \t]*\S+)/,"number.date"],[/(".*?"|'.*?'|[^#'"]*?)([ \t]*)(:)( |$)/,["type","white","operators","white"]],{include:"@flowScalars"},[/.+?(?=(\s+#|$))/,{cases:{"@keywords":"keyword","@default":"string"}}]],object:[{include:"@whitespace"},{include:"@comment"},[/\}/,"@brackets","@pop"],[/,/,"delimiter.comma"],[/:(?= )/,"operators"],[/(?:".*?"|'.*?'|[^,\{\[]+?)(?=: )/,"type"],{include:"@flowCollections"},{include:"@flowScalars"},{include:"@tagHandle"},{include:"@anchor"},{include:"@flowNumber"},[/[^\},]+/,{cases:{"@keywords":"keyword","@default":"string"}}]],array:[{include:"@whitespace"},{include:"@comment"},[/\]/,"@brackets","@pop"],[/,/,"delimiter.comma"],{include:"@flowCollections"},{include:"@flowScalars"},{include:"@tagHandle"},{include:"@anchor"},{include:"@flowNumber"},[/[^\],]+/,{cases:{"@keywords":"keyword","@default":"string"}}]],multiString:[[/^( +).+$/,"string","@multiStringContinued.$1"]],multiStringContinued:[[/^( *).+$/,{cases:{"$1==$S2":"string","@default":{token:"@rematch",next:"@popall"}}}]],whitespace:[[/[ \t\r\n]+/,"white"]],comment:[[/#.*$/,"comment"]],flowCollections:[[/\[/,"@brackets","@array"],[/\{/,"@brackets","@object"]],flowScalars:[[/"([^"\\]|\\.)*$/,"string.invalid"],[/'([^'\\]|\\.)*$/,"string.invalid"],[/'[^']*'/,"string"],[/"/,"string","@doubleQuotedString"]],doubleQuotedString:[[/[^\\"]+/,"string"],[/@escapes/,"string.escape"],[/\\./,"string.escape.invalid"],[/"/,"string","@pop"]],blockStyle:[[/[>|][0-9]*[+-]?$/,"operators","@multiString"]],flowNumber:[[/@numberInteger(?=[ \t]*[,\]\}])/,"number"],[/@numberFloat(?=[ \t]*[,\]\}])/,"number.float"],[/@numberOctal(?=[ \t]*[,\]\}])/,"number.octal"],[/@numberHex(?=[ \t]*[,\]\}])/,"number.hex"],[/@numberInfinity(?=[ \t]*[,\]\}])/,"number.infinity"],[/@numberNaN(?=[ \t]*[,\]\}])/,"number.nan"],[/@numberDate(?=[ \t]*[,\]\}])/,"number.date"]],tagHandle:[[/\![^ ]*/,"tag"]],anchor:[[/[&*][^ ]+/,"namespace"]]}};export{o as conf,r as language};
1
+ import{l as e}from"./editor.main-QdhRqSRj.js";import"./index-DIBKIDmx.js";const o={comments:{lineComment:"#"},brackets:[["{","}"],["[","]"],["(",")"]],autoClosingPairs:[{open:"{",close:"}"},{open:"[",close:"]"},{open:"(",close:")"},{open:'"',close:'"'},{open:"'",close:"'"}],surroundingPairs:[{open:"{",close:"}"},{open:"[",close:"]"},{open:"(",close:")"},{open:'"',close:'"'},{open:"'",close:"'"}],folding:{offSide:!0},onEnterRules:[{beforeText:/:\s*$/,action:{indentAction:e.IndentAction.Indent}}]},r={tokenPostfix:".yaml",brackets:[{token:"delimiter.bracket",open:"{",close:"}"},{token:"delimiter.square",open:"[",close:"]"}],keywords:["true","True","TRUE","false","False","FALSE","null","Null","Null","~"],numberInteger:/(?:0|[+-]?[0-9]+)/,numberFloat:/(?:0|[+-]?[0-9]+)(?:\.[0-9]+)?(?:e[-+][1-9][0-9]*)?/,numberOctal:/0o[0-7]+/,numberHex:/0x[0-9a-fA-F]+/,numberInfinity:/[+-]?\.(?:inf|Inf|INF)/,numberNaN:/\.(?:nan|Nan|NAN)/,numberDate:/\d{4}-\d\d-\d\d([Tt ]\d\d:\d\d:\d\d(\.\d+)?(( ?[+-]\d\d?(:\d\d)?)|Z)?)?/,escapes:/\\(?:[btnfr\\"']|[0-7][0-7]?|[0-3][0-7]{2})/,tokenizer:{root:[{include:"@whitespace"},{include:"@comment"},[/%[^ ]+.*$/,"meta.directive"],[/---/,"operators.directivesEnd"],[/\.{3}/,"operators.documentEnd"],[/[-?:](?= )/,"operators"],{include:"@anchor"},{include:"@tagHandle"},{include:"@flowCollections"},{include:"@blockStyle"},[/@numberInteger(?![ \t]*\S+)/,"number"],[/@numberFloat(?![ \t]*\S+)/,"number.float"],[/@numberOctal(?![ \t]*\S+)/,"number.octal"],[/@numberHex(?![ \t]*\S+)/,"number.hex"],[/@numberInfinity(?![ \t]*\S+)/,"number.infinity"],[/@numberNaN(?![ \t]*\S+)/,"number.nan"],[/@numberDate(?![ \t]*\S+)/,"number.date"],[/(".*?"|'.*?'|[^#'"]*?)([ \t]*)(:)( |$)/,["type","white","operators","white"]],{include:"@flowScalars"},[/.+?(?=(\s+#|$))/,{cases:{"@keywords":"keyword","@default":"string"}}]],object:[{include:"@whitespace"},{include:"@comment"},[/\}/,"@brackets","@pop"],[/,/,"delimiter.comma"],[/:(?= )/,"operators"],[/(?:".*?"|'.*?'|[^,\{\[]+?)(?=: )/,"type"],{include:"@flowCollections"},{include:"@flowScalars"},{include:"@tagHandle"},{include:"@anchor"},{include:"@flowNumber"},[/[^\},]+/,{cases:{"@keywords":"keyword","@default":"string"}}]],array:[{include:"@whitespace"},{include:"@comment"},[/\]/,"@brackets","@pop"],[/,/,"delimiter.comma"],{include:"@flowCollections"},{include:"@flowScalars"},{include:"@tagHandle"},{include:"@anchor"},{include:"@flowNumber"},[/[^\],]+/,{cases:{"@keywords":"keyword","@default":"string"}}]],multiString:[[/^( +).+$/,"string","@multiStringContinued.$1"]],multiStringContinued:[[/^( *).+$/,{cases:{"$1==$S2":"string","@default":{token:"@rematch",next:"@popall"}}}]],whitespace:[[/[ \t\r\n]+/,"white"]],comment:[[/#.*$/,"comment"]],flowCollections:[[/\[/,"@brackets","@array"],[/\{/,"@brackets","@object"]],flowScalars:[[/"([^"\\]|\\.)*$/,"string.invalid"],[/'([^'\\]|\\.)*$/,"string.invalid"],[/'[^']*'/,"string"],[/"/,"string","@doubleQuotedString"]],doubleQuotedString:[[/[^\\"]+/,"string"],[/@escapes/,"string.escape"],[/\\./,"string.escape.invalid"],[/"/,"string","@pop"]],blockStyle:[[/[>|][0-9]*[+-]?$/,"operators","@multiString"]],flowNumber:[[/@numberInteger(?=[ \t]*[,\]\}])/,"number"],[/@numberFloat(?=[ \t]*[,\]\}])/,"number.float"],[/@numberOctal(?=[ \t]*[,\]\}])/,"number.octal"],[/@numberHex(?=[ \t]*[,\]\}])/,"number.hex"],[/@numberInfinity(?=[ \t]*[,\]\}])/,"number.infinity"],[/@numberNaN(?=[ \t]*[,\]\}])/,"number.nan"],[/@numberDate(?=[ \t]*[,\]\}])/,"number.date"]],tagHandle:[[/\![^ ]*/,"tag"]],anchor:[[/[&*][^ ]+/,"namespace"]]}};export{o as conf,r as language};
package/dist/index.html CHANGED
@@ -4,8 +4,8 @@
4
4
  <meta charset="UTF-8" />
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
6
  <title>Claude Session Manager</title>
7
- <script type="module" crossorigin src="./assets/index-CS33Ey9b.js"></script>
8
- <link rel="stylesheet" crossorigin href="./assets/index-BYHTuGIu.css">
7
+ <script type="module" crossorigin src="./assets/index-DIBKIDmx.js"></script>
8
+ <link rel="stylesheet" crossorigin href="./assets/index-BzbwWnyF.css">
9
9
  </head>
10
10
  <body class="bg-bg text-fg font-mono antialiased">
11
11
  <div id="root"></div>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-code-session-manager",
3
- "version": "0.2.6",
3
+ "version": "0.3.0",
4
4
  "description": "Local cockpit for Claude Code CLI sessions — terminal + full config surface.",
5
5
  "type": "module",
6
6
  "main": "src/main/index.cjs",
@@ -1,4 +1,4 @@
1
- const { app, BrowserWindow, ipcMain, dialog, Menu, session, systemPreferences } = require('electron');
1
+ const { app, BrowserWindow, ipcMain, dialog, Menu, session, systemPreferences, globalShortcut } = require('electron');
2
2
  const { spawn, execFileSync } = require('node:child_process');
3
3
  const path = require('node:path');
4
4
  const fs = require('node:fs');
@@ -8,6 +8,9 @@ const configMgr = require('./config.cjs');
8
8
  const transcripts = require('./transcripts.cjs');
9
9
  const sessionsStore = require('./sessionsStore.cjs');
10
10
  const billing = require('./usage.cjs');
11
+ const logs = require('./logs.cjs');
12
+ const voiceHotkey = require('./voiceHotkey.cjs');
13
+ const voiceWizard = require('./voiceWizard.cjs');
11
14
 
12
15
  let mainWindow = null;
13
16
  let rebooting = false;
@@ -90,6 +93,9 @@ async function rebootApp() {
90
93
  ptyManager.attachWindow(mainWindow);
91
94
  configMgr.attachWindow(mainWindow);
92
95
  transcripts.attachWindow(mainWindow);
96
+ voiceHotkey.init(mainWindow).catch((e) => {
97
+ logs.writeLine({ scope: 'voice-hotkey', level: 'error', message: 'reinit failed', meta: { error: e?.message } });
98
+ });
93
99
  rebooting = false;
94
100
  return;
95
101
  }
@@ -184,10 +190,44 @@ configMgr.registerConfigHandlers();
184
190
  transcripts.registerTranscriptHandlers();
185
191
  sessionsStore.registerSessionsHandlers();
186
192
  billing.registerBillingHandlers();
193
+ logs.registerLogHandlers();
194
+ voiceHotkey.registerHotkeyHandlers();
195
+ voiceWizard.registerWizardHandlers();
187
196
 
188
197
  // --- App lifecycle ---
189
198
 
199
+ // Single-instance lock (PRD F1 v2 §requestSingleInstanceLock).
200
+ // In dev mode we skip the lock so two-dev-instance workflows still work.
201
+ // E2E tests also skip so playwright.electron.launch can run multiple specs
202
+ // back-to-back without the prior app's lock causing the next launch to quit.
203
+ const isDev = process.env.SM_DEV === '1' || process.env.SM_E2E === '1';
204
+ if (!isDev) {
205
+ const gotLock = app.requestSingleInstanceLock();
206
+ if (!gotLock) {
207
+ app.quit();
208
+ } else {
209
+ app.on('second-instance', () => {
210
+ if (mainWindow && !mainWindow.isDestroyed()) {
211
+ if (mainWindow.isMinimized()) mainWindow.restore();
212
+ mainWindow.show();
213
+ mainWindow.focus();
214
+ }
215
+ });
216
+ }
217
+ }
218
+
190
219
  app.whenReady().then(async () => {
220
+ logs.pruneOld();
221
+ logs.writeLine({ scope: 'main', level: 'info', message: 'app start', meta: { version: app.getVersion(), platform: process.platform } });
222
+
223
+ process.on('uncaughtException', (err) => {
224
+ logs.writeLine({ scope: 'main', level: 'error', message: 'uncaughtException', meta: { error: err?.message, stack: err?.stack } });
225
+ });
226
+ process.on('unhandledRejection', (reason) => {
227
+ const r = reason instanceof Error ? { error: reason.message, stack: reason.stack } : { reason: String(reason) };
228
+ logs.writeLine({ scope: 'main', level: 'error', message: 'unhandledRejection', meta: r });
229
+ });
230
+
191
231
  // Grant microphone / media permissions so the renderer's getUserMedia
192
232
  // (Whisper voice-to-text) resolves instead of being silently denied.
193
233
  const MEDIA_PERMS = new Set(['media', 'audioCapture', 'microphone']);
@@ -260,6 +300,15 @@ app.whenReady().then(async () => {
260
300
  ptyManager.attachWindow(mainWindow);
261
301
  configMgr.attachWindow(mainWindow);
262
302
  transcripts.attachWindow(mainWindow);
303
+ voiceHotkey.init(mainWindow).catch((e) => {
304
+ logs.writeLine({ scope: 'voice-hotkey', level: 'error', message: 'init failed', meta: { error: e?.message } });
305
+ });
306
+ });
307
+
308
+ app.on('will-quit', () => {
309
+ // PRD F1 v2 §IPC plumbing: must unregisterAll on will-quit.
310
+ try { globalShortcut.unregisterAll(); } catch { /* */ }
311
+ voiceHotkey.disposeOnQuit();
263
312
  });
264
313
 
265
314
  app.on('window-all-closed', () => {
@@ -0,0 +1,60 @@
1
+ /**
2
+ * voice-hotkey-log — sanitizing logger wrapper for the voice-hotkey scope.
3
+ *
4
+ * Enforces the F1 PRD §Security no-transcript rule: any meta key whose name
5
+ * matches /transcript|interim|final|text/i is dropped before the line hits
6
+ * disk. In dev (`!app.isPackaged`), if the dropped value was non-empty we
7
+ * throw so tests catch a leak immediately.
8
+ *
9
+ * This module is intentionally tiny and dependency-free apart from logs.cjs
10
+ * and electron's `app` for the dev check.
11
+ */
12
+
13
+ const { app } = require('electron');
14
+ const logs = require('../logs.cjs');
15
+
16
+ const SENSITIVE_KEY_RE = /transcript|interim|final|text/i;
17
+
18
+ function isNonEmpty(value) {
19
+ if (value === null || value === undefined) return false;
20
+ if (typeof value === 'string') return value.length > 0;
21
+ if (Array.isArray(value)) return value.length > 0;
22
+ if (typeof value === 'object') return Object.keys(value).length > 0;
23
+ // numbers, booleans → "non-empty" if truthy enough that a leak would matter
24
+ return Boolean(value);
25
+ }
26
+
27
+ function sanitize(meta) {
28
+ if (!meta || typeof meta !== 'object' || Array.isArray(meta)) return meta;
29
+ const out = {};
30
+ let leaked = null;
31
+ for (const [k, v] of Object.entries(meta)) {
32
+ if (SENSITIVE_KEY_RE.test(k)) {
33
+ if (isNonEmpty(v) && leaked === null) leaked = k;
34
+ continue;
35
+ }
36
+ out[k] = v;
37
+ }
38
+ return { sanitized: out, leakedKey: leaked };
39
+ }
40
+
41
+ function voiceHotkeyLog(level, event, meta) {
42
+ let safeMeta = meta;
43
+ if (meta && typeof meta === 'object' && !Array.isArray(meta)) {
44
+ const { sanitized, leakedKey } = sanitize(meta);
45
+ safeMeta = sanitized;
46
+ // In dev, throw on a non-empty stripped value so a leak crashes tests
47
+ // instead of silently passing. `app.isPackaged` is the proxy for prod.
48
+ if (leakedKey !== null) {
49
+ const isDev = (() => {
50
+ try { return !app.isPackaged; } catch { return true; }
51
+ })();
52
+ if (isDev) {
53
+ throw new Error(`voice-hotkey log leaked sensitive key: ${leakedKey}`);
54
+ }
55
+ }
56
+ }
57
+ logs.writeLine({ scope: 'voice-hotkey', level, message: event, meta: safeMeta });
58
+ }
59
+
60
+ module.exports = { voiceHotkeyLog, SENSITIVE_KEY_RE };
@@ -0,0 +1,82 @@
1
+ const { app, ipcMain } = require('electron');
2
+ const fs = require('node:fs');
3
+ const path = require('node:path');
4
+
5
+ // Rolling daily files under <userData>/logs/. Anything mtime'd older than
6
+ // RETENTION_MS is pruned at boot; we never compact mid-session, since the
7
+ // app is short-lived and a once-per-launch sweep is enough.
8
+ const RETENTION_MS = 3 * 24 * 60 * 60 * 1000;
9
+ const LOG_PREFIX = 'session-manager-';
10
+ const LOG_SUFFIX = '.log';
11
+
12
+ function logDir() {
13
+ return path.join(app.getPath('userData'), 'logs');
14
+ }
15
+
16
+ function todayPath() {
17
+ const d = new Date();
18
+ const yyyy = d.getFullYear();
19
+ const mm = String(d.getMonth() + 1).padStart(2, '0');
20
+ const dd = String(d.getDate()).padStart(2, '0');
21
+ return path.join(logDir(), `${LOG_PREFIX}${yyyy}-${mm}-${dd}${LOG_SUFFIX}`);
22
+ }
23
+
24
+ function ensureDir() {
25
+ try { fs.mkdirSync(logDir(), { recursive: true }); } catch { /* best-effort */ }
26
+ }
27
+
28
+ function pruneOld() {
29
+ ensureDir();
30
+ let entries;
31
+ try { entries = fs.readdirSync(logDir()); } catch { return; }
32
+ const cutoff = Date.now() - RETENTION_MS;
33
+ for (const name of entries) {
34
+ if (!name.startsWith(LOG_PREFIX) || !name.endsWith(LOG_SUFFIX)) continue;
35
+ const full = path.join(logDir(), name);
36
+ try {
37
+ const st = fs.statSync(full);
38
+ if (st.mtimeMs < cutoff) fs.unlinkSync(full);
39
+ } catch { /* skip unreadable entries */ }
40
+ }
41
+ }
42
+
43
+ // Sanitize meta keys that look like they might carry transcript or user
44
+ // content. Voice features go to logs and the hotkey wrapper already strips
45
+ // these in main, but renderer-side log calls go through this writer too.
46
+ // Defense in depth: drop and replace with `[redacted]` so leaks are obvious.
47
+ const REDACT_KEY = /^(transcript|interim|final|text|content|partial|userText|message)$/i;
48
+ function sanitizeMeta(meta) {
49
+ if (!meta || typeof meta !== 'object') return meta;
50
+ if (Array.isArray(meta)) return meta;
51
+ const out = {};
52
+ for (const [k, v] of Object.entries(meta)) {
53
+ out[k] = REDACT_KEY.test(k) ? '[redacted]' : v;
54
+ }
55
+ return out;
56
+ }
57
+
58
+ function writeLine(payload) {
59
+ if (!payload || typeof payload !== 'object') return;
60
+ ensureDir();
61
+ const ts = new Date().toISOString();
62
+ const lvl = String(payload.level || 'info').toUpperCase();
63
+ const scope = String(payload.scope || '-');
64
+ const message = String(payload.message ?? '');
65
+ let line = `[${ts}] [${lvl}] [${scope}] ${message}`;
66
+ if (payload.meta !== undefined) {
67
+ try { line += ' ' + JSON.stringify(sanitizeMeta(payload.meta)); } catch { line += ' [unserializable meta]'; }
68
+ }
69
+ // Open with 0o600 so logs don't leak to other local users; a session
70
+ // transcript could include device labels or recently-typed terminal text.
71
+ try {
72
+ const fd = fs.openSync(todayPath(), 'a', 0o600);
73
+ try { fs.writeSync(fd, line + '\n'); } finally { fs.closeSync(fd); }
74
+ } catch { /* best-effort */ }
75
+ }
76
+
77
+ function registerLogHandlers() {
78
+ ipcMain.on('log:write', (_e, payload) => writeLine(payload));
79
+ ipcMain.handle('log:dir', () => logDir());
80
+ }
81
+
82
+ module.exports = { pruneOld, writeLine, registerLogHandlers, logDir };
@@ -0,0 +1,346 @@
1
+ /**
2
+ * voiceHotkey — F1 push-to-talk hotkey wiring (main process).
3
+ *
4
+ * Two layers per F1 v2 PRD:
5
+ * 1. Window-local: mainWindow.webContents.on('before-input-event', …) —
6
+ * always on, fires before xterm, blocks the terminal via preventDefault.
7
+ * 2. Global: globalShortcut.register(...) — opt-in via voice.json.global,
8
+ * always toggle-only (globalShortcut has no keyup signal).
9
+ *
10
+ * Precedence: when window is focused, globalShortcut is unregistered so the
11
+ * window-local layer owns the chord. On blur we re-register if global=true.
12
+ *
13
+ * Wayland: globalShortcut is broken on Wayland in Electron 32–40 (see
14
+ * electron#45607, #49806). For v1 we just disable global mode if
15
+ * XDG_SESSION_TYPE=wayland and log a warn. The DBus portal probe is deferred.
16
+ */
17
+
18
+ const { app, globalShortcut, ipcMain } = require('electron');
19
+ const voiceSettings = require('./voiceSettings.cjs');
20
+ const { voiceHotkeyLog } = require('./lib/voice-hotkey-log.cjs');
21
+
22
+ let mainWindow = null;
23
+ let currentConfig = null;
24
+ let beforeInputHandler = null;
25
+ // Per-press state for hold mode: we only care that *some* chord-member is
26
+ // held, then any chord-member keyup ends the press (PRD F1 v2 §Edge Cases #13).
27
+ let holdActive = false;
28
+ let globalRegisteredAccel = null;
29
+ // Suppress the first global callback within 500ms of registration to dodge
30
+ // the "key held during cold start" edge case (PRD §Edge Cases #4).
31
+ let globalRegisteredAt = 0;
32
+
33
+ /**
34
+ * Map an Electron Accelerator string to a token set we can compare against
35
+ * before-input-event Input. Handles CommandOrControl/Cmd/Ctrl/Alt/Option/
36
+ * Shift/Super and a key (letter, F-key, or named key like Space).
37
+ *
38
+ * Complexity: O(p) per call where p = number of '+' segments (≤ 5 in practice).
39
+ */
40
+ function parseAccelerator(accel) {
41
+ if (typeof accel !== 'string' || !accel) return null;
42
+ const parts = accel.split('+').map((s) => s.trim()).filter(Boolean);
43
+ if (parts.length < 2) return null; // PRD requires ≥1 modifier + 1 key
44
+ const mods = { ctrl: false, meta: false, alt: false, shift: false };
45
+ let key = null;
46
+ for (const raw of parts) {
47
+ const p = raw.toLowerCase();
48
+ if (p === 'commandorcontrol') {
49
+ if (process.platform === 'darwin') mods.meta = true;
50
+ else mods.ctrl = true;
51
+ } else if (p === 'cmd' || p === 'command' || p === 'super') {
52
+ mods.meta = true;
53
+ } else if (p === 'ctrl' || p === 'control') {
54
+ mods.ctrl = true;
55
+ } else if (p === 'alt' || p === 'option') {
56
+ mods.alt = true;
57
+ } else if (p === 'shift') {
58
+ mods.shift = true;
59
+ } else {
60
+ key = raw;
61
+ }
62
+ }
63
+ if (!key) return null;
64
+ return { mods, key };
65
+ }
66
+
67
+ /** True if the modifier flags on `input` exactly match `mods`. */
68
+ function modsMatch(input, mods) {
69
+ return (
70
+ !!input.control === mods.ctrl &&
71
+ !!input.meta === mods.meta &&
72
+ !!input.alt === mods.alt &&
73
+ !!input.shift === mods.shift
74
+ );
75
+ }
76
+
77
+ /** True if the input.key matches an accelerator key token. */
78
+ function keyMatches(input, key) {
79
+ const ik = String(input.key || '').toLowerCase();
80
+ const ic = String(input.code || '').toLowerCase();
81
+ const kk = String(key).toLowerCase();
82
+ if (ik === kk) return true;
83
+ // Space: input.key may be ' ' (literal space) or 'space'.
84
+ if (kk === 'space' && (ik === ' ' || ik === 'space' || ic === 'space')) return true;
85
+ // Letters: input.code is 'KeyV' for V key. This is robust to dead-key
86
+ // modifier substitutions on macOS (e.g. Option+V → '√' in input.key).
87
+ if (kk.length === 1 && /[a-z0-9]/.test(kk) && ic === `key${kk}`) return true;
88
+ // Digit row: input.code is 'Digit1' for '1'.
89
+ if (/^[0-9]$/.test(kk) && ic === `digit${kk}`) return true;
90
+ // F-keys: input.code matches 'F1'..'F24', case differs.
91
+ if (/^f([1-9]|1[0-9]|2[0-4])$/.test(kk) && ic === kk) return true;
92
+ return false;
93
+ }
94
+
95
+ /** True if `input.key` is one of the chord's modifier keys (for hold-end). */
96
+ function isChordMemberRelease(input, parsed) {
97
+ const k = String(input.key || '').toLowerCase();
98
+ if (parsed.mods.ctrl && (k === 'control')) return true;
99
+ if (parsed.mods.meta && (k === 'meta' || k === 'cmd' || k === 'command' || k === 'os' || k === 'super')) return true;
100
+ if (parsed.mods.alt && (k === 'alt' || k === 'option')) return true;
101
+ if (parsed.mods.shift && (k === 'shift')) return true;
102
+ return false;
103
+ }
104
+
105
+ function detachBeforeInput() {
106
+ if (mainWindow && !mainWindow.isDestroyed() && beforeInputHandler) {
107
+ try { mainWindow.webContents.removeListener('before-input-event', beforeInputHandler); } catch { /* */ }
108
+ }
109
+ beforeInputHandler = null;
110
+ holdActive = false;
111
+ }
112
+
113
+ function attachBeforeInput() {
114
+ if (!mainWindow || mainWindow.isDestroyed()) return;
115
+ detachBeforeInput();
116
+ const cfg = currentConfig;
117
+ if (!cfg) return;
118
+ const parsed = parseAccelerator(cfg.accelerator);
119
+ if (!parsed) {
120
+ voiceHotkeyLog('error', 'register-failed', {
121
+ accelerator: cfg.accelerator,
122
+ platform: process.platform,
123
+ sessionType: process.env.XDG_SESSION_TYPE || null,
124
+ reason: 'parse',
125
+ });
126
+ return;
127
+ }
128
+
129
+ const handler = (event, input) => {
130
+ if (input.type !== 'keyDown' && input.type !== 'keyUp') return;
131
+ // PRD F1 v2 §Auto-repeat: discard isAutoRepeat in main.
132
+ if (input.isAutoRepeat) return;
133
+
134
+ if (input.type === 'keyDown') {
135
+ // For keyDown, both modifiers and the key must match exactly.
136
+ if (!modsMatch(input, parsed.mods)) return;
137
+ if (!keyMatches(input, parsed.key)) return;
138
+ event.preventDefault();
139
+ if (cfg.mode === 'hold') {
140
+ if (holdActive) return; // ignore re-press while held
141
+ holdActive = true;
142
+ emitHotkey('down', 'window');
143
+ } else {
144
+ // Toggle: every fresh keyDown emits 'down'. Renderer state machine
145
+ // decides whether that means start or stop.
146
+ emitHotkey('down', 'window');
147
+ }
148
+ return;
149
+ }
150
+
151
+ // keyUp: in hold mode, any chord-member release ends the press
152
+ // (PRD §Edge Cases #13 — modifier release timing).
153
+ if (cfg.mode === 'hold' && holdActive) {
154
+ const releaseEndsHold = keyMatches(input, parsed.key) || isChordMemberRelease(input, parsed);
155
+ if (releaseEndsHold) {
156
+ event.preventDefault();
157
+ holdActive = false;
158
+ emitHotkey('up', 'window');
159
+ }
160
+ }
161
+ };
162
+
163
+ mainWindow.webContents.on('before-input-event', handler);
164
+ beforeInputHandler = handler;
165
+ }
166
+
167
+ function emitHotkey(phase, source) {
168
+ if (!mainWindow || mainWindow.isDestroyed()) return;
169
+ mainWindow.webContents.send('voice:hotkey', { phase, source });
170
+ voiceHotkeyLog('info', phase === 'down' ? 'down' : 'up', { source });
171
+ }
172
+
173
+ function unregisterGlobal() {
174
+ if (globalRegisteredAccel) {
175
+ try { globalShortcut.unregister(globalRegisteredAccel); } catch { /* */ }
176
+ globalRegisteredAccel = null;
177
+ }
178
+ }
179
+
180
+ function registerGlobalIfWanted() {
181
+ unregisterGlobal();
182
+ const cfg = currentConfig;
183
+ if (!cfg || !cfg.global) return;
184
+
185
+ // Wayland: disable global for v1 (PRD §Wayland is experimental).
186
+ if (process.platform === 'linux' && process.env.XDG_SESSION_TYPE === 'wayland') {
187
+ voiceHotkeyLog('warn', 'global-disabled-wayland', {
188
+ accelerator: cfg.accelerator,
189
+ sessionType: 'wayland',
190
+ });
191
+ return;
192
+ // TODO(F1-followup): DBus probe org.freedesktop.portal.GlobalShortcuts
193
+ // and enable global mode when present (xdg-desktop-portal ≥ 1.17).
194
+ }
195
+
196
+ let ok = false;
197
+ try {
198
+ ok = globalShortcut.register(cfg.accelerator, () => {
199
+ // Suppress callbacks within 500ms of registration (PRD §Edge Cases #4).
200
+ if (Date.now() - globalRegisteredAt < 500) return;
201
+ // Global is toggle-only (no keyup). Renderer state machine handles
202
+ // arm/disarm regardless of configured mode.
203
+ emitHotkey('down', 'global');
204
+ });
205
+ } catch (err) {
206
+ voiceHotkeyLog('error', 'register-failed', {
207
+ accelerator: cfg.accelerator,
208
+ platform: process.platform,
209
+ sessionType: process.env.XDG_SESSION_TYPE || null,
210
+ reason: err && err.message ? err.message : 'unknown',
211
+ });
212
+ return;
213
+ }
214
+
215
+ if (ok) {
216
+ globalRegisteredAccel = cfg.accelerator;
217
+ globalRegisteredAt = Date.now();
218
+ voiceHotkeyLog('info', 'register', {
219
+ accelerator: cfg.accelerator,
220
+ global: true,
221
+ mode: cfg.mode,
222
+ ok: true,
223
+ sessionType: process.env.XDG_SESSION_TYPE || null,
224
+ });
225
+ } else {
226
+ voiceHotkeyLog('warn', 'collision', {
227
+ accelerator: cfg.accelerator,
228
+ platform: process.platform,
229
+ });
230
+ }
231
+ }
232
+
233
+ function applyConfig(cfg) {
234
+ currentConfig = cfg;
235
+ attachBeforeInput();
236
+ // If a focused window owns the chord, keep global unregistered until blur.
237
+ const focused = mainWindow && !mainWindow.isDestroyed() && mainWindow.isFocused();
238
+ if (focused) {
239
+ unregisterGlobal();
240
+ } else {
241
+ registerGlobalIfWanted();
242
+ }
243
+ // Always log the window-local registration so the e2e test can see it
244
+ // even if global is off / suppressed.
245
+ voiceHotkeyLog('info', 'register', {
246
+ accelerator: cfg.accelerator,
247
+ global: !!cfg.global,
248
+ mode: cfg.mode,
249
+ ok: true,
250
+ sessionType: process.env.XDG_SESSION_TYPE || null,
251
+ layer: 'window',
252
+ });
253
+ }
254
+
255
+ function attachWindow(win) {
256
+ mainWindow = win;
257
+ if (!mainWindow) return;
258
+ attachBeforeInput();
259
+
260
+ // Precedence: window focus → unregister global; blur → re-register.
261
+ // Debounce focus 50ms so second-instance show()→focus() doesn't race
262
+ // with a held chord. (PRD §requestSingleInstanceLock).
263
+ // BrowserWindow emits `focus`/`blur` (not `browser-window-focus`/`-blur`,
264
+ // which are app-level events). The legacy listener was a no-op.
265
+ let focusDebounce = null;
266
+ const onFocus = () => {
267
+ if (focusDebounce) clearTimeout(focusDebounce);
268
+ focusDebounce = setTimeout(() => unregisterGlobal(), 50);
269
+ };
270
+ const onBlur = () => {
271
+ if (focusDebounce) { clearTimeout(focusDebounce); focusDebounce = null; }
272
+ registerGlobalIfWanted();
273
+ };
274
+ mainWindow.on('focus', onFocus);
275
+ mainWindow.on('blur', onBlur);
276
+ mainWindow.on('closed', () => {
277
+ if (focusDebounce) { clearTimeout(focusDebounce); focusDebounce = null; }
278
+ try { mainWindow.removeListener('focus', onFocus); } catch { /* */ }
279
+ try { mainWindow.removeListener('blur', onBlur); } catch { /* */ }
280
+ detachBeforeInput();
281
+ mainWindow = null;
282
+ });
283
+ }
284
+
285
+ async function init(win) {
286
+ attachWindow(win);
287
+ currentConfig = await voiceSettings.load();
288
+ applyConfig(currentConfig);
289
+ }
290
+
291
+ function registerHotkeyHandlers() {
292
+ ipcMain.handle('voice:get-hotkey-config', async () => {
293
+ if (!currentConfig) currentConfig = await voiceSettings.load();
294
+ return currentConfig;
295
+ });
296
+
297
+ ipcMain.handle('voice:set-hotkey', async (_e, cfg) => {
298
+ if (!voiceSettings.isValidConfig(cfg)) {
299
+ throw new Error('Invalid voice hotkey config');
300
+ }
301
+ await voiceSettings.save(cfg);
302
+ currentConfig = await voiceSettings.load();
303
+ applyConfig(currentConfig);
304
+ if (mainWindow && !mainWindow.isDestroyed()) {
305
+ mainWindow.webContents.send('voice:hotkey-changed', currentConfig);
306
+ }
307
+ return { ok: true, config: currentConfig };
308
+ });
309
+
310
+ ipcMain.handle('voice:get-hotkey-config-path', () => voiceSettings.storePath());
311
+
312
+ // F5 — device picker preference (additive subtree on voice.json).
313
+ ipcMain.handle('voice:get-device-pref', async () => {
314
+ return await voiceSettings.loadDevice();
315
+ });
316
+
317
+ ipcMain.handle('voice:set-device-pref', async (_e, pref) => {
318
+ if (!voiceSettings.isValidDevicePref(pref)) {
319
+ throw new Error('Invalid device pref payload');
320
+ }
321
+ await voiceSettings.saveDevice(pref);
322
+ return { ok: true };
323
+ });
324
+
325
+ // Renderer pings this when isRecording flips so we can prefix the window
326
+ // title with `● REC — ` (PRD F1 v2 §Security: privacy invariant).
327
+ ipcMain.on('voice:set-recording', (_e, recording) => {
328
+ if (!mainWindow || mainWindow.isDestroyed()) return;
329
+ const baseTitle = 'Claude Session Manager';
330
+ const next = recording ? `● REC — ${baseTitle}` : baseTitle;
331
+ try { mainWindow.setTitle(next); } catch { /* */ }
332
+ });
333
+ }
334
+
335
+ function disposeOnQuit() {
336
+ unregisterGlobal();
337
+ detachBeforeInput();
338
+ }
339
+
340
+ module.exports = {
341
+ init,
342
+ registerHotkeyHandlers,
343
+ disposeOnQuit,
344
+ // exported for tests
345
+ parseAccelerator,
346
+ };