agentvibes 4.0.0 → 4.2.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/.claude/config/audio-effects.cfg +3 -2
- package/.claude/config/background-music-position.txt +1 -1
- package/.claude/hooks/audio-processor.sh +87 -43
- package/.claude/hooks/bmad-speak.sh +184 -27
- package/.claude/hooks/play-tts-enhanced.sh +40 -5
- package/.claude/hooks/play-tts-macos.sh +29 -6
- package/.claude/hooks/play-tts-piper.sh +174 -67
- package/.claude/hooks/play-tts-soprano.sh +42 -6
- package/.claude/hooks/play-tts-ssh-remote.sh +117 -38
- package/.claude/hooks/play-tts.sh +12 -9
- package/.claude/hooks/session-start-tts.sh +10 -0
- package/.claude/hooks/stop-tts.sh +84 -0
- package/.claude/hooks/tts-queue-worker.sh +51 -20
- package/.claude/hooks/tts-queue.sh +37 -8
- package/.claude/hooks/voice-manager.sh +5 -1
- package/CLAUDE.md +0 -11
- package/README.md +176 -78
- package/RELEASE_NOTES.md +1197 -60
- package/bin/agentvibes-voice-browser.js +35 -21
- package/mcp-server/server.py +36 -0
- package/package.json +1 -3
- package/src/console/app.js +23 -5
- package/src/console/constants/personalities.js +44 -0
- package/src/console/footer-config.js +8 -0
- package/src/console/navigation.js +3 -1
- package/src/console/tabs/agents-tab.js +1219 -72
- package/src/console/tabs/install-tab.js +2 -1
- package/src/console/tabs/placeholder-tab.js +9 -1
- package/src/console/tabs/receiver-tab.js +1212 -0
- package/src/console/tabs/settings-tab.js +33 -323
- package/src/console/widgets/destroy-list.js +25 -0
- package/src/console/widgets/format-utils.js +89 -0
- package/src/console/widgets/notice.js +55 -0
- package/src/console/widgets/personality-picker.js +185 -0
- package/src/console/widgets/reverb-picker.js +94 -0
- package/src/console/widgets/track-picker.js +285 -0
- package/src/installer.js +54 -2
- package/src/services/agent-voice-store.js +282 -22
- package/src/services/config-service.js +24 -0
- package/src/services/navigation-service.js +1 -1
- package/src/utils/music-file-validator.js +41 -31
- package/templates/agentvibes-receiver.sh +431 -111
|
@@ -2,8 +2,23 @@
|
|
|
2
2
|
* AgentVibes Agent Voice Store
|
|
3
3
|
* Epic 11: Stories 11.1, 11.3, 11.5
|
|
4
4
|
*
|
|
5
|
-
* Manages global BMAD agent voice assignments at ~/.agentvibes/bmad-voice-map.json.
|
|
5
|
+
* Manages global BMAD agent voice/audio profile assignments at ~/.agentvibes/bmad-voice-map.json.
|
|
6
6
|
* All path operations use path.resolve() to prevent traversal.
|
|
7
|
+
*
|
|
8
|
+
* Store format:
|
|
9
|
+
* {
|
|
10
|
+
* "partyMode": true,
|
|
11
|
+
* "voiceMap": { "architect": "en_GB-alan-medium" }, // legacy compat
|
|
12
|
+
* "agents": {
|
|
13
|
+
* "architect": {
|
|
14
|
+
* "voice": "en_GB-alan-medium",
|
|
15
|
+
* "pretext": "Winston, Architect here.",
|
|
16
|
+
* "reverbPreset": "cathedral",
|
|
17
|
+
* "personality": "normal",
|
|
18
|
+
* "backgroundMusic": { "track": "soft_piano.mp3", "volume": 30, "enabled": true }
|
|
19
|
+
* }
|
|
20
|
+
* }
|
|
21
|
+
* }
|
|
7
22
|
*/
|
|
8
23
|
|
|
9
24
|
import fs from 'node:fs';
|
|
@@ -11,11 +26,66 @@ import os from 'node:os';
|
|
|
11
26
|
import path from 'node:path';
|
|
12
27
|
|
|
13
28
|
// ---------------------------------------------------------------------------
|
|
14
|
-
//
|
|
29
|
+
// Agent ID validation — prevents prototype pollution via __proto__ / constructor keys
|
|
30
|
+
|
|
31
|
+
const VALID_AGENT_ID = /^[a-z0-9][a-z0-9_-]*$/i;
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Validate an agent ID is safe for use as an object property key.
|
|
35
|
+
* Rejects __proto__, constructor, toString, etc.
|
|
36
|
+
* @param {string} id
|
|
37
|
+
* @returns {boolean}
|
|
38
|
+
*/
|
|
39
|
+
function _isValidAgentId(id) {
|
|
40
|
+
if (!id || typeof id !== 'string') return false;
|
|
41
|
+
if (!VALID_AGENT_ID.test(id)) return false;
|
|
42
|
+
// Explicit blocklist for prototype pollution vectors
|
|
43
|
+
const blocked = new Set(['__proto__', 'constructor', 'prototype', 'toString', 'valueOf', 'hasOwnProperty']);
|
|
44
|
+
return !blocked.has(id);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// ---------------------------------------------------------------------------
|
|
48
|
+
// Advisory file locking for atomic read-modify-write
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Acquire an advisory lock via exclusive file open.
|
|
52
|
+
* Retries briefly on EEXIST; returns fd on success, null on timeout.
|
|
53
|
+
*/
|
|
54
|
+
function _acquireLock(lockPath) {
|
|
55
|
+
const maxRetries = 20;
|
|
56
|
+
const retryMs = 50;
|
|
57
|
+
for (let i = 0; i < maxRetries; i++) {
|
|
58
|
+
try {
|
|
59
|
+
const fd = fs.openSync(lockPath, fs.constants.O_CREAT | fs.constants.O_EXCL | fs.constants.O_WRONLY, 0o600);
|
|
60
|
+
return fd;
|
|
61
|
+
} catch (err) {
|
|
62
|
+
if (err.code !== 'EEXIST') return null;
|
|
63
|
+
// Stale lock detection: if lock file is older than 5 seconds, remove it
|
|
64
|
+
try {
|
|
65
|
+
const stat = fs.statSync(lockPath);
|
|
66
|
+
if (Date.now() - stat.mtimeMs > 5000) {
|
|
67
|
+
try { fs.unlinkSync(lockPath); } catch {}
|
|
68
|
+
}
|
|
69
|
+
} catch {}
|
|
70
|
+
// Brief sync delay (10ms) — acceptable for a single-threaded TUI lock retry
|
|
71
|
+
const buf = new SharedArrayBuffer(4);
|
|
72
|
+
Atomics.wait(new Int32Array(buf), 0, 0, retryMs);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
return null; // Proceed without lock rather than blocking forever
|
|
76
|
+
}
|
|
15
77
|
|
|
16
78
|
/**
|
|
17
|
-
*
|
|
79
|
+
* Release advisory lock.
|
|
18
80
|
*/
|
|
81
|
+
function _releaseLock(fd, lockPath) {
|
|
82
|
+
try { if (fd != null) fs.closeSync(fd); } catch {}
|
|
83
|
+
try { fs.unlinkSync(lockPath); } catch {}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// ---------------------------------------------------------------------------
|
|
87
|
+
// Single-voice provider detection (story 11.3)
|
|
88
|
+
|
|
19
89
|
const SINGLE_VOICE_PROVIDERS = Object.freeze(new Set(['soprano']));
|
|
20
90
|
|
|
21
91
|
/**
|
|
@@ -28,16 +98,108 @@ export function isSingleVoiceProvider(provider) {
|
|
|
28
98
|
}
|
|
29
99
|
|
|
30
100
|
// ---------------------------------------------------------------------------
|
|
31
|
-
// BMAD agent
|
|
101
|
+
// BMAD agent manifest parser
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Parse the BMAD agent-manifest.csv to get rich agent metadata.
|
|
105
|
+
* Returns agents filtered to core and bmm modules only.
|
|
106
|
+
*
|
|
107
|
+
* @param {string} projectRoot
|
|
108
|
+
* @returns {{ id: string, displayName: string, title: string, icon: string, module: string }[]}
|
|
109
|
+
*/
|
|
110
|
+
export function parseBmadManifest(projectRoot) {
|
|
111
|
+
const safeRoot = path.resolve(projectRoot ?? process.cwd());
|
|
112
|
+
const manifestPath = path.resolve(safeRoot, '_bmad', '_config', 'agent-manifest.csv');
|
|
113
|
+
|
|
114
|
+
if (!fs.existsSync(manifestPath)) return [];
|
|
115
|
+
|
|
116
|
+
try {
|
|
117
|
+
const raw = fs.readFileSync(manifestPath, 'utf8');
|
|
118
|
+
const lines = raw.split('\n').filter(l => l.trim());
|
|
119
|
+
if (lines.length < 2) return [];
|
|
120
|
+
|
|
121
|
+
// Parse CSV header
|
|
122
|
+
const headers = _parseCSVLine(lines[0]);
|
|
123
|
+
const nameIdx = headers.indexOf('name');
|
|
124
|
+
const displayIdx = headers.indexOf('displayName');
|
|
125
|
+
const titleIdx = headers.indexOf('title');
|
|
126
|
+
const iconIdx = headers.indexOf('icon');
|
|
127
|
+
const moduleIdx = headers.indexOf('module');
|
|
128
|
+
|
|
129
|
+
if (nameIdx < 0) return [];
|
|
130
|
+
|
|
131
|
+
const agents = [];
|
|
132
|
+
for (let i = 1; i < lines.length; i++) {
|
|
133
|
+
const cols = _parseCSVLine(lines[i]);
|
|
134
|
+
const module = cols[moduleIdx] ?? '';
|
|
135
|
+
|
|
136
|
+
// Filter to core and bmm modules only
|
|
137
|
+
if (module !== 'core' && module !== 'bmm') continue;
|
|
138
|
+
|
|
139
|
+
const agentId = cols[nameIdx] ?? '';
|
|
140
|
+
if (!_isValidAgentId(agentId)) continue;
|
|
141
|
+
|
|
142
|
+
agents.push({
|
|
143
|
+
id: agentId,
|
|
144
|
+
displayName: cols[displayIdx] ?? cols[nameIdx] ?? '',
|
|
145
|
+
title: cols[titleIdx] ?? '',
|
|
146
|
+
icon: cols[iconIdx] ?? '',
|
|
147
|
+
module,
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
return agents.sort((a, b) => a.id.localeCompare(b.id));
|
|
152
|
+
} catch {
|
|
153
|
+
return [];
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Simple CSV line parser that handles quoted fields.
|
|
159
|
+
* @param {string} line
|
|
160
|
+
* @returns {string[]}
|
|
161
|
+
*/
|
|
162
|
+
function _parseCSVLine(line) {
|
|
163
|
+
const result = [];
|
|
164
|
+
let current = '';
|
|
165
|
+
let inQuotes = false;
|
|
166
|
+
|
|
167
|
+
for (let i = 0; i < line.length; i++) {
|
|
168
|
+
const ch = line[i];
|
|
169
|
+
if (ch === '"') {
|
|
170
|
+
if (inQuotes && line[i + 1] === '"') {
|
|
171
|
+
current += '"';
|
|
172
|
+
i++;
|
|
173
|
+
} else {
|
|
174
|
+
inQuotes = !inQuotes;
|
|
175
|
+
}
|
|
176
|
+
} else if (ch === ',' && !inQuotes) {
|
|
177
|
+
result.push(current.trim());
|
|
178
|
+
current = '';
|
|
179
|
+
} else {
|
|
180
|
+
current += ch;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
result.push(current.trim());
|
|
184
|
+
return result;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// ---------------------------------------------------------------------------
|
|
188
|
+
// BMAD agent scanner (story 11.5) — fallback when manifest is unavailable
|
|
32
189
|
|
|
33
190
|
/**
|
|
34
191
|
* Scan for BMAD agents in the project root.
|
|
35
|
-
*
|
|
192
|
+
* Prefers manifest-based discovery; falls back to directory scan.
|
|
36
193
|
*
|
|
37
194
|
* @param {string} projectRoot
|
|
38
|
-
* @returns {{ id: string, displayName: string }[]}
|
|
195
|
+
* @returns {{ id: string, displayName: string, title: string, icon: string, module: string }[]}
|
|
39
196
|
*/
|
|
40
197
|
export function scanBmadAgents(projectRoot) {
|
|
198
|
+
// Try manifest first
|
|
199
|
+
const fromManifest = parseBmadManifest(projectRoot);
|
|
200
|
+
if (fromManifest.length > 0) return fromManifest;
|
|
201
|
+
|
|
202
|
+
// Fallback: directory scan
|
|
41
203
|
const safeRoot = path.resolve(projectRoot ?? process.cwd());
|
|
42
204
|
const candidateDirs = [
|
|
43
205
|
path.resolve(safeRoot, '_bmad', 'bmm', 'agents'),
|
|
@@ -56,7 +218,7 @@ export function scanBmadAgents(projectRoot) {
|
|
|
56
218
|
.split('-')
|
|
57
219
|
.map(w => w.charAt(0).toUpperCase() + w.slice(1))
|
|
58
220
|
.join(' ');
|
|
59
|
-
return { id, displayName };
|
|
221
|
+
return { id, displayName, title: '', icon: '', module: 'bmm' };
|
|
60
222
|
})
|
|
61
223
|
.sort((a, b) => a.id.localeCompare(b.id));
|
|
62
224
|
} catch {
|
|
@@ -66,8 +228,26 @@ export function scanBmadAgents(projectRoot) {
|
|
|
66
228
|
return [];
|
|
67
229
|
}
|
|
68
230
|
|
|
231
|
+
/**
|
|
232
|
+
* Detect whether BMAD is installed in the project.
|
|
233
|
+
* @param {string} projectRoot
|
|
234
|
+
* @returns {boolean}
|
|
235
|
+
*/
|
|
236
|
+
export function isBmadDetected(projectRoot) {
|
|
237
|
+
const safeRoot = path.resolve(projectRoot ?? process.cwd());
|
|
238
|
+
const manifestPath = path.resolve(safeRoot, '_bmad', '_config', 'agent-manifest.csv');
|
|
239
|
+
if (fs.existsSync(manifestPath)) return true;
|
|
240
|
+
|
|
241
|
+
// Fallback checks
|
|
242
|
+
const dirs = [
|
|
243
|
+
path.resolve(safeRoot, '_bmad', 'bmm', 'agents'),
|
|
244
|
+
path.resolve(safeRoot, '.bmad', 'agents'),
|
|
245
|
+
];
|
|
246
|
+
return dirs.some(d => fs.existsSync(d));
|
|
247
|
+
}
|
|
248
|
+
|
|
69
249
|
// ---------------------------------------------------------------------------
|
|
70
|
-
// AgentVoiceStore class
|
|
250
|
+
// AgentVoiceStore class
|
|
71
251
|
|
|
72
252
|
export class AgentVoiceStore {
|
|
73
253
|
/**
|
|
@@ -80,12 +260,12 @@ export class AgentVoiceStore {
|
|
|
80
260
|
}
|
|
81
261
|
|
|
82
262
|
/**
|
|
83
|
-
* Read the full
|
|
84
|
-
* @returns {{ voiceMap: object, partyMode: boolean }}
|
|
263
|
+
* Read the full store.
|
|
264
|
+
* @returns {{ voiceMap: object, partyMode: boolean, agents: object }}
|
|
85
265
|
*/
|
|
86
266
|
_readStore() {
|
|
87
267
|
if (!fs.existsSync(this._filePath)) {
|
|
88
|
-
return { voiceMap: {}, partyMode: false };
|
|
268
|
+
return { voiceMap: {}, partyMode: false, agents: {} };
|
|
89
269
|
}
|
|
90
270
|
try {
|
|
91
271
|
const raw = fs.readFileSync(this._filePath, 'utf8');
|
|
@@ -93,41 +273,61 @@ export class AgentVoiceStore {
|
|
|
93
273
|
return {
|
|
94
274
|
voiceMap: data.voiceMap ?? {},
|
|
95
275
|
partyMode: data.partyMode ?? false,
|
|
276
|
+
agents: data.agents ?? {},
|
|
96
277
|
};
|
|
97
278
|
} catch {
|
|
98
|
-
return { voiceMap: {}, partyMode: false };
|
|
279
|
+
return { voiceMap: {}, partyMode: false, agents: {} };
|
|
99
280
|
}
|
|
100
281
|
}
|
|
101
282
|
|
|
102
283
|
/**
|
|
103
|
-
* Atomically write store data.
|
|
104
|
-
* @param {{ voiceMap: object, partyMode: boolean }} data
|
|
284
|
+
* Atomically write store data with advisory file locking.
|
|
285
|
+
* @param {{ voiceMap: object, partyMode: boolean, agents: object }} data
|
|
105
286
|
*/
|
|
106
287
|
_writeStore(data) {
|
|
107
288
|
const dir = path.dirname(this._filePath);
|
|
108
|
-
fs.mkdirSync(dir, { recursive: true });
|
|
109
|
-
|
|
110
|
-
fs.
|
|
111
|
-
|
|
112
|
-
|
|
289
|
+
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
290
|
+
// Ensure directory is user-only even if it already existed
|
|
291
|
+
try { fs.chmodSync(dir, 0o700); } catch {}
|
|
292
|
+
|
|
293
|
+
// Advisory lock: prevent concurrent read-modify-write from clobbering data
|
|
294
|
+
const lockPath = `${this._filePath}.lock`;
|
|
295
|
+
const lockFd = _acquireLock(lockPath);
|
|
296
|
+
try {
|
|
297
|
+
const tmpPath = `${this._filePath}.tmp`;
|
|
298
|
+
fs.writeFileSync(tmpPath, JSON.stringify(data, null, 2), { encoding: 'utf8', mode: 0o600 });
|
|
299
|
+
fs.renameSync(tmpPath, this._filePath);
|
|
300
|
+
fs.chmodSync(this._filePath, 0o600);
|
|
301
|
+
} finally {
|
|
302
|
+
_releaseLock(lockFd, lockPath);
|
|
303
|
+
}
|
|
113
304
|
}
|
|
114
305
|
|
|
115
306
|
/**
|
|
116
|
-
* Get the agent → voice ID map.
|
|
307
|
+
* Get the agent → voice ID map (legacy compat).
|
|
308
|
+
* Merges voiceMap with agents[id].voice for backward compat.
|
|
117
309
|
* @returns {object}
|
|
118
310
|
*/
|
|
119
311
|
getVoiceMap() {
|
|
120
|
-
|
|
312
|
+
const store = this._readStore();
|
|
313
|
+
const merged = { ...store.voiceMap };
|
|
314
|
+
for (const [id, profile] of Object.entries(store.agents)) {
|
|
315
|
+
if (profile.voice && !merged[id]) merged[id] = profile.voice;
|
|
316
|
+
}
|
|
317
|
+
return merged;
|
|
121
318
|
}
|
|
122
319
|
|
|
123
320
|
/**
|
|
124
|
-
* Assign a voice to an agent.
|
|
321
|
+
* Assign a voice to an agent (legacy compat — also updates agent profile).
|
|
125
322
|
* @param {string} agentId
|
|
126
323
|
* @param {string} voiceId
|
|
127
324
|
*/
|
|
128
325
|
setVoice(agentId, voiceId) {
|
|
326
|
+
if (!_isValidAgentId(agentId)) return;
|
|
129
327
|
const store = this._readStore();
|
|
130
328
|
store.voiceMap[agentId] = voiceId;
|
|
329
|
+
if (!store.agents[agentId]) store.agents[agentId] = {};
|
|
330
|
+
store.agents[agentId].voice = voiceId;
|
|
131
331
|
this._writeStore(store);
|
|
132
332
|
}
|
|
133
333
|
|
|
@@ -136,8 +336,10 @@ export class AgentVoiceStore {
|
|
|
136
336
|
* @param {string} agentId
|
|
137
337
|
*/
|
|
138
338
|
resetVoice(agentId) {
|
|
339
|
+
if (!_isValidAgentId(agentId)) return;
|
|
139
340
|
const store = this._readStore();
|
|
140
341
|
delete store.voiceMap[agentId];
|
|
342
|
+
if (store.agents[agentId]) delete store.agents[agentId].voice;
|
|
141
343
|
this._writeStore(store);
|
|
142
344
|
}
|
|
143
345
|
|
|
@@ -158,6 +360,64 @@ export class AgentVoiceStore {
|
|
|
158
360
|
store.partyMode = Boolean(enabled);
|
|
159
361
|
this._writeStore(store);
|
|
160
362
|
}
|
|
363
|
+
|
|
364
|
+
// -------------------------------------------------------------------------
|
|
365
|
+
// Per-agent profile API
|
|
366
|
+
|
|
367
|
+
/**
|
|
368
|
+
* Get the full profile for an agent. Missing fields are undefined (caller merges with global).
|
|
369
|
+
* @param {string} agentId
|
|
370
|
+
* @returns {{ voice?: string, pretext?: string, reverbPreset?: string, personality?: string, backgroundMusic?: object }}
|
|
371
|
+
*/
|
|
372
|
+
getAgentProfile(agentId) {
|
|
373
|
+
if (!_isValidAgentId(agentId)) return {};
|
|
374
|
+
const store = this._readStore();
|
|
375
|
+
const profile = { ...(store.agents[agentId] ?? {}) };
|
|
376
|
+
// Compat: if voice is only in voiceMap, include it
|
|
377
|
+
if (!profile.voice && store.voiceMap[agentId]) {
|
|
378
|
+
profile.voice = store.voiceMap[agentId];
|
|
379
|
+
}
|
|
380
|
+
return profile;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
/**
|
|
384
|
+
* Set (merge) profile fields for an agent. Only provided fields are updated.
|
|
385
|
+
* @param {string} agentId
|
|
386
|
+
* @param {{ voice?: string, pretext?: string, reverbPreset?: string, personality?: string, backgroundMusic?: object }} partial
|
|
387
|
+
*/
|
|
388
|
+
setAgentProfile(agentId, partial) {
|
|
389
|
+
if (!_isValidAgentId(agentId)) return;
|
|
390
|
+
const store = this._readStore();
|
|
391
|
+
if (!store.agents[agentId]) store.agents[agentId] = {};
|
|
392
|
+
Object.assign(store.agents[agentId], partial);
|
|
393
|
+
// Keep voiceMap in sync
|
|
394
|
+
if (partial.voice) store.voiceMap[agentId] = partial.voice;
|
|
395
|
+
this._writeStore(store);
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
/**
|
|
399
|
+
* Reset all profile settings for an agent.
|
|
400
|
+
* @param {string} agentId
|
|
401
|
+
*/
|
|
402
|
+
resetAgentProfile(agentId) {
|
|
403
|
+
if (!_isValidAgentId(agentId)) return;
|
|
404
|
+
const store = this._readStore();
|
|
405
|
+
delete store.agents[agentId];
|
|
406
|
+
delete store.voiceMap[agentId];
|
|
407
|
+
this._writeStore(store);
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
/**
|
|
411
|
+
* Generate a default pretext for an agent.
|
|
412
|
+
* @param {string} displayName - e.g. "Winston"
|
|
413
|
+
* @param {string} title - e.g. "Architect"
|
|
414
|
+
* @returns {string}
|
|
415
|
+
*/
|
|
416
|
+
static getDefaultPretext(displayName, title) {
|
|
417
|
+
if (!displayName) return '';
|
|
418
|
+
if (!title) return `${displayName} here.`;
|
|
419
|
+
return `${displayName}, ${title} here.`;
|
|
420
|
+
}
|
|
161
421
|
}
|
|
162
422
|
|
|
163
423
|
export default AgentVoiceStore;
|
|
@@ -122,6 +122,7 @@ export class ConfigService {
|
|
|
122
122
|
*/
|
|
123
123
|
saveAllToGlobal(data) {
|
|
124
124
|
this._writeConfigAtomic(this.getGlobalConfigPath(), data);
|
|
125
|
+
this._syncToTextFiles(path.resolve(this._homeDir, '.claude'), data);
|
|
125
126
|
}
|
|
126
127
|
|
|
127
128
|
/**
|
|
@@ -131,6 +132,7 @@ export class ConfigService {
|
|
|
131
132
|
*/
|
|
132
133
|
saveAllToLocal(data) {
|
|
133
134
|
this._writeConfigAtomic(this.getLocalConfigPath(), data);
|
|
135
|
+
this._syncToTextFiles(path.resolve(this._projectRoot, '.claude'), data);
|
|
134
136
|
}
|
|
135
137
|
|
|
136
138
|
// ---------------------------------------------------------------------------
|
|
@@ -170,6 +172,28 @@ export class ConfigService {
|
|
|
170
172
|
// ---------------------------------------------------------------------------
|
|
171
173
|
// Private helpers
|
|
172
174
|
|
|
175
|
+
/**
|
|
176
|
+
* Sync config.json values to .claude/ text files that TTS scripts read.
|
|
177
|
+
* Only writes files that the config has values for. Silently ignores errors.
|
|
178
|
+
* @param {string} claudeDir - Path to .claude/ directory
|
|
179
|
+
* @param {object} data - Config data object
|
|
180
|
+
*/
|
|
181
|
+
_syncToTextFiles(claudeDir, data) {
|
|
182
|
+
if (!claudeDir || !data) return;
|
|
183
|
+
try {
|
|
184
|
+
if (!fs.existsSync(claudeDir)) return;
|
|
185
|
+
if (data.voice) {
|
|
186
|
+
fs.writeFileSync(path.join(claudeDir, 'tts-voice.txt'), String(data.voice));
|
|
187
|
+
}
|
|
188
|
+
if (data.provider) {
|
|
189
|
+
fs.writeFileSync(path.join(claudeDir, 'tts-provider.txt'), String(data.provider));
|
|
190
|
+
}
|
|
191
|
+
if (data.verbosity) {
|
|
192
|
+
fs.writeFileSync(path.join(claudeDir, 'tts-verbosity.txt'), String(data.verbosity));
|
|
193
|
+
}
|
|
194
|
+
} catch { /* best-effort sync */ }
|
|
195
|
+
}
|
|
196
|
+
|
|
173
197
|
/**
|
|
174
198
|
* Read and parse a JSON config file.
|
|
175
199
|
* Returns null if the file does not exist or is not a regular file.
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
/** Ordered list of all tab IDs — used for cycling and validation */
|
|
10
|
-
export const TAB_ORDER = ['install', 'settings', 'voices', 'music', 'readme', 'help'];
|
|
10
|
+
export const TAB_ORDER = ['install', 'settings', 'voices', 'music', 'agents', 'receiver', 'readme', 'help'];
|
|
11
11
|
|
|
12
12
|
export class NavigationService {
|
|
13
13
|
/**
|
|
@@ -72,40 +72,19 @@ export function isPathSafe(userPath, userHomeDir = null) {
|
|
|
72
72
|
};
|
|
73
73
|
}
|
|
74
74
|
|
|
75
|
-
//
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
// Get file stats
|
|
85
|
-
const stats = fs.statSync(resolvedPath);
|
|
86
|
-
|
|
87
|
-
// Check if it's a regular file (not directory, symlink, etc)
|
|
88
|
-
if (!stats.isFile()) {
|
|
89
|
-
return {
|
|
90
|
-
isValid: false,
|
|
91
|
-
error: `Path must be a regular file, not a ${stats.isDirectory() ? 'directory' : 'special file'}`,
|
|
92
|
-
resolvedPath: null
|
|
93
|
-
};
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
// CRITICAL: Verify file ownership (CLAUDE.md requirement)
|
|
97
|
-
// Prevent other users from planting malicious files
|
|
98
|
-
const currentUserId = process.getuid ? process.getuid() : null;
|
|
99
|
-
if (currentUserId !== null && stats.uid !== currentUserId) {
|
|
100
|
-
return {
|
|
101
|
-
isValid: false,
|
|
102
|
-
error: 'Security validation failed: file not owned by current user',
|
|
103
|
-
resolvedPath: null
|
|
104
|
-
};
|
|
75
|
+
// SECURITY: Use lstatSync first to detect symlinks before following them (#131)
|
|
76
|
+
let lstats;
|
|
77
|
+
try {
|
|
78
|
+
lstats = fs.lstatSync(resolvedPath);
|
|
79
|
+
} catch (err) {
|
|
80
|
+
if (err.code === 'ENOENT') {
|
|
81
|
+
return { isValid: false, error: `File not found: ${userPath}`, resolvedPath: null };
|
|
82
|
+
}
|
|
83
|
+
return { isValid: false, error: `Cannot access file: ${err.message}`, resolvedPath: null };
|
|
105
84
|
}
|
|
106
85
|
|
|
107
86
|
// Check if symlink - if so, verify target is also within home directory
|
|
108
|
-
if (
|
|
87
|
+
if (lstats.isSymbolicLink()) {
|
|
109
88
|
try {
|
|
110
89
|
const targetPath = fs.realpathSync(resolvedPath);
|
|
111
90
|
const isTargetWithinHome = targetPath === resolvedHome ||
|
|
@@ -127,6 +106,37 @@ export function isPathSafe(userPath, userHomeDir = null) {
|
|
|
127
106
|
}
|
|
128
107
|
}
|
|
129
108
|
|
|
109
|
+
// Get file stats (follows symlinks for regular file check)
|
|
110
|
+
const stats = fs.statSync(resolvedPath);
|
|
111
|
+
|
|
112
|
+
// Check if it's a regular file (not directory, special file, etc)
|
|
113
|
+
if (!stats.isFile()) {
|
|
114
|
+
return {
|
|
115
|
+
isValid: false,
|
|
116
|
+
error: `Path must be a regular file, not a ${stats.isDirectory() ? 'directory' : 'special file'}`,
|
|
117
|
+
resolvedPath: null
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// CRITICAL: Verify file ownership (CLAUDE.md requirement)
|
|
122
|
+
// Prevent other users from planting malicious files
|
|
123
|
+
// SECURITY: Fail-secure on platforms where getuid is unavailable (#131)
|
|
124
|
+
const currentUserId = process.getuid ? process.getuid() : null;
|
|
125
|
+
if (currentUserId === null) {
|
|
126
|
+
return {
|
|
127
|
+
isValid: false,
|
|
128
|
+
error: 'Security validation failed: unable to verify file ownership on this platform',
|
|
129
|
+
resolvedPath: null
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
if (stats.uid !== currentUserId) {
|
|
133
|
+
return {
|
|
134
|
+
isValid: false,
|
|
135
|
+
error: 'Security validation failed: file not owned by current user',
|
|
136
|
+
resolvedPath: null
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
|
|
130
140
|
// Check if file is readable
|
|
131
141
|
try {
|
|
132
142
|
fs.accessSync(resolvedPath, fs.constants.R_OK);
|