@jungjaehoon/mama-os 0.32.3 → 0.34.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 (105) hide show
  1. package/README.md +15 -17
  2. package/dist/agent/os-agent-capabilities.md +2 -2
  3. package/dist/api/graph-api.d.ts +4 -4
  4. package/dist/api/graph-api.d.ts.map +1 -1
  5. package/dist/api/graph-api.js +47 -78
  6. package/dist/api/graph-api.js.map +1 -1
  7. package/dist/api/index.d.ts +10 -0
  8. package/dist/api/index.d.ts.map +1 -1
  9. package/dist/api/index.js +9 -2
  10. package/dist/api/index.js.map +1 -1
  11. package/dist/api/operator-handler.d.ts +1 -1
  12. package/dist/api/operator-handler.js +1 -1
  13. package/dist/api/runtime-status-handler.d.ts +41 -0
  14. package/dist/api/runtime-status-handler.d.ts.map +1 -0
  15. package/dist/api/runtime-status-handler.js +55 -0
  16. package/dist/api/runtime-status-handler.js.map +1 -0
  17. package/dist/cli/commands/start.d.ts.map +1 -1
  18. package/dist/cli/commands/start.js +58 -23
  19. package/dist/cli/commands/start.js.map +1 -1
  20. package/dist/cli/runtime/api-routes-init.d.ts +2 -0
  21. package/dist/cli/runtime/api-routes-init.d.ts.map +1 -1
  22. package/dist/cli/runtime/api-routes-init.js +62 -1
  23. package/dist/cli/runtime/api-routes-init.js.map +1 -1
  24. package/dist/cli/runtime/api-server-init.d.ts +12 -1
  25. package/dist/cli/runtime/api-server-init.d.ts.map +1 -1
  26. package/dist/cli/runtime/api-server-init.js +54 -0
  27. package/dist/cli/runtime/api-server-init.js.map +1 -1
  28. package/dist/cli/runtime/utilities.d.ts +1 -1
  29. package/dist/cli/runtime/utilities.d.ts.map +1 -1
  30. package/dist/cli/runtime/utilities.js +3 -1
  31. package/dist/cli/runtime/utilities.js.map +1 -1
  32. package/dist/gateways/message-router.d.ts +4 -0
  33. package/dist/gateways/message-router.d.ts.map +1 -1
  34. package/dist/gateways/message-router.js +62 -13
  35. package/dist/gateways/message-router.js.map +1 -1
  36. package/dist/gateways/owner-report-inbox.d.ts +40 -0
  37. package/dist/gateways/owner-report-inbox.d.ts.map +1 -0
  38. package/dist/gateways/owner-report-inbox.js +128 -0
  39. package/dist/gateways/owner-report-inbox.js.map +1 -0
  40. package/dist/gateways/report-projection-v1.d.ts +41 -0
  41. package/dist/gateways/report-projection-v1.d.ts.map +1 -0
  42. package/dist/gateways/report-projection-v1.js +160 -0
  43. package/dist/gateways/report-projection-v1.js.map +1 -0
  44. package/dist/gateways/session-store.d.ts +22 -0
  45. package/dist/gateways/session-store.d.ts.map +1 -1
  46. package/dist/gateways/session-store.js +76 -3
  47. package/dist/gateways/session-store.js.map +1 -1
  48. package/dist/gateways/telegram-message-ledger.d.ts +10 -0
  49. package/dist/gateways/telegram-message-ledger.d.ts.map +1 -1
  50. package/dist/gateways/telegram-message-ledger.js +23 -2
  51. package/dist/gateways/telegram-message-ledger.js.map +1 -1
  52. package/dist/gateways/telegram-report-context-store.d.ts +193 -0
  53. package/dist/gateways/telegram-report-context-store.d.ts.map +1 -0
  54. package/dist/gateways/telegram-report-context-store.js +612 -0
  55. package/dist/gateways/telegram-report-context-store.js.map +1 -0
  56. package/dist/gateways/telegram.d.ts +9 -0
  57. package/dist/gateways/telegram.d.ts.map +1 -1
  58. package/dist/gateways/telegram.js +100 -0
  59. package/dist/gateways/telegram.js.map +1 -1
  60. package/dist/gateways/types.d.ts +12 -0
  61. package/dist/gateways/types.d.ts.map +1 -1
  62. package/dist/multi-agent/dashboard-agent-persona.js +2 -2
  63. package/dist/operator/board-slot-instructions.d.ts +1 -1
  64. package/dist/operator/board-slot-instructions.js +1 -1
  65. package/dist/operator/operator-trigger-loop.d.ts +17 -1
  66. package/dist/operator/operator-trigger-loop.d.ts.map +1 -1
  67. package/dist/operator/operator-trigger-loop.js +87 -23
  68. package/dist/operator/operator-trigger-loop.js.map +1 -1
  69. package/dist/operator/report-delivery-coordinator.d.ts +82 -0
  70. package/dist/operator/report-delivery-coordinator.d.ts.map +1 -0
  71. package/dist/operator/report-delivery-coordinator.js +123 -0
  72. package/dist/operator/report-delivery-coordinator.js.map +1 -0
  73. package/dist/operator/situation-report.d.ts +7 -0
  74. package/dist/operator/situation-report.d.ts.map +1 -1
  75. package/dist/operator/situation-report.js +12 -0
  76. package/dist/operator/situation-report.js.map +1 -1
  77. package/package.json +1 -2
  78. package/public/viewer/js/modules/system.js +183 -0
  79. package/public/viewer/js/utils/ui-commands.js +4 -9
  80. package/public/viewer/log-viewer.html +2 -192
  81. package/public/viewer/operator/operator.css +1 -0
  82. package/public/viewer/operator/operator.js +14830 -0
  83. package/public/viewer/src/modules/system.ts +242 -0
  84. package/public/viewer/src/utils/ui-commands.ts +4 -8
  85. package/public/viewer/sw.js +44 -10
  86. package/public/viewer/viewer.css +338 -331
  87. package/public/viewer/viewer.html +388 -1472
  88. package/public/ui/assets/index-BDXDGEV6.js +0 -62
  89. package/public/ui/assets/index-po2tN0xq.css +0 -1
  90. package/public/ui/index.html +0 -14
  91. package/public/ui/theme-init.js +0 -20
  92. package/public/viewer/js/modules/agents.js +0 -1153
  93. package/public/viewer/js/modules/chat.js +0 -1993
  94. package/public/viewer/js/modules/connector-feed.js +0 -352
  95. package/public/viewer/js/modules/dashboard.js +0 -456
  96. package/public/viewer/js/modules/entity-audit.js +0 -106
  97. package/public/viewer/js/modules/entity-review.js +0 -66
  98. package/public/viewer/js/modules/settings.js +0 -1257
  99. package/public/viewer/src/modules/agents.ts +0 -1302
  100. package/public/viewer/src/modules/chat.ts +0 -2426
  101. package/public/viewer/src/modules/connector-feed.ts +0 -391
  102. package/public/viewer/src/modules/dashboard.ts +0 -498
  103. package/public/viewer/src/modules/entity-audit.ts +0 -154
  104. package/public/viewer/src/modules/entity-review.ts +0 -175
  105. package/public/viewer/src/modules/settings.ts +0 -1547
@@ -1,1547 +0,0 @@
1
- /**
2
- * Settings Module - MAMA OS Settings Management
3
- * @module modules/settings
4
- * @version 1.0.0
5
- *
6
- * Handles Settings tab functionality including:
7
- * - Load current configuration
8
- * - Save configuration changes
9
- * - Form validation
10
- * - Gateway enable/disable toggles
11
- */
12
-
13
- /* eslint-env browser */
14
-
15
- import { showToast, escapeHtml, escapeAttr, getElementByIdOrNull } from '../utils/dom.js';
16
- import { formatModelName } from '../utils/format.js';
17
- import { DebugLogger } from '../utils/debug-logger.js';
18
- import { reportPageContext } from '../utils/ui-commands.js';
19
- import {
20
- API,
21
- type ApiConfigResponse,
22
- type ApiRoleDefinition,
23
- type CronJob,
24
- type CronJobsResponse,
25
- type EffortLevel,
26
- type McpServer,
27
- type McpServersResponse,
28
- type MultiAgentAgentsResponse,
29
- type SkillsResponse,
30
- } from '../utils/api.js';
31
-
32
- const logger = new DebugLogger('Settings');
33
-
34
- export type AgentBackend = 'claude' | 'codex' | 'cline';
35
- type SettingsFilterValue = 'loading' | 'error' | 'success' | '';
36
- type SettingsPayloadToolConfig = {
37
- gateway: string[];
38
- mcp: string[];
39
- mcp_config: string;
40
- };
41
-
42
- type SettingsPayload = {
43
- discord: {
44
- enabled: boolean;
45
- token: string;
46
- default_channel_id: string;
47
- };
48
- slack: {
49
- enabled: boolean;
50
- bot_token: string;
51
- app_token: string;
52
- };
53
- telegram: {
54
- enabled: boolean;
55
- token: string;
56
- };
57
- chatwork: {
58
- enabled: boolean;
59
- api_token: string;
60
- };
61
- heartbeat: {
62
- enabled: boolean;
63
- interval: number;
64
- quiet_start: number;
65
- quiet_end: number;
66
- };
67
- use_claude_cli: boolean;
68
- agent: {
69
- backend: AgentBackend;
70
- model: string;
71
- effort?: EffortLevel;
72
- max_turns: number;
73
- timeout: number;
74
- tools: SettingsPayloadToolConfig;
75
- };
76
- token_budget: {
77
- daily_limit?: number;
78
- alert_threshold?: number;
79
- };
80
- metrics: {
81
- enabled: boolean;
82
- retention_days: number;
83
- };
84
- timeouts: {
85
- request_ms: number;
86
- agent_ms: number;
87
- session_ms: number;
88
- workflow_step_ms: number;
89
- workflow_max_ms: number;
90
- ultrawork_ms: number;
91
- };
92
- };
93
-
94
- // Model options by backend (single source of truth)
95
- // Claude models: https://platform.claude.com/docs/en/about-claude/models/overview
96
- const MODEL_OPTIONS: Record<AgentBackend, readonly string[]> = {
97
- codex: [
98
- 'gpt-5.4',
99
- 'gpt-5.4-mini',
100
- 'gpt-4.1',
101
- 'gpt-4o',
102
- 'gpt-4o-mini',
103
- 'o1',
104
- 'o1-mini',
105
- 'o3-mini',
106
- ],
107
- claude: [
108
- // Latest models (4.6)
109
- 'claude-opus-4-6',
110
- 'claude-sonnet-4-6',
111
- // Previous gen (4.5)
112
- 'claude-opus-4-5-20251101',
113
- 'claude-sonnet-4-5-20250929',
114
- 'claude-haiku-4-5-20251001',
115
- // Legacy models
116
- 'claude-sonnet-4-20250514',
117
- 'claude-opus-4-20250514',
118
- 'claude-3-7-sonnet-20250219',
119
- 'claude-3-haiku-20240307',
120
- ],
121
- cline: ['deepseek/deepseek-v4-flash'],
122
- };
123
-
124
- const DEFAULT_MODEL: Record<AgentBackend, string> = {
125
- claude: 'claude-sonnet-4-6',
126
- codex: 'gpt-5.4',
127
- cline: 'deepseek/deepseek-v4-flash',
128
- };
129
-
130
- export function getSettingsModelOptions(backend: AgentBackend, currentModel?: string): string[] {
131
- const options = MODEL_OPTIONS[backend] || MODEL_OPTIONS.claude;
132
- return currentModel && !options.includes(currentModel)
133
- ? [...options, currentModel]
134
- : [...options];
135
- }
136
-
137
- const EFFORT_SUPPORTED_MODELS = new Set<string>(['claude-opus-4-6', 'claude-sonnet-4-6']);
138
- const MAX_EFFORT_MODELS = new Set<string>(['claude-opus-4-6']);
139
-
140
- /**
141
- * Settings Module Class
142
- */
143
- export class SettingsModule {
144
- config: ApiConfigResponse | null = null;
145
- mcpServersData: McpServersResponse = { servers: [] };
146
- multiAgentData: MultiAgentAgentsResponse = { agents: [] };
147
- initialized = false;
148
- backendListenersInitialized = false;
149
- delegatedListenersInitialized = false;
150
-
151
- constructor() {}
152
-
153
- private supportsEffortModel(model: string): boolean {
154
- return EFFORT_SUPPORTED_MODELS.has(model);
155
- }
156
-
157
- private supportsMaxEffortModel(model: string): boolean {
158
- return MAX_EFFORT_MODELS.has(model);
159
- }
160
-
161
- private normalizeEffortForModel(model: string, effort: EffortLevel): EffortLevel {
162
- if (effort === 'max' && !this.supportsMaxEffortModel(model)) {
163
- return 'high';
164
- }
165
- return effort;
166
- }
167
-
168
- private getEffortLevelsForModel(model: string): EffortLevel[] {
169
- if (this.supportsMaxEffortModel(model)) {
170
- return ['low', 'medium', 'high', 'max'];
171
- }
172
- return ['low', 'medium', 'high'];
173
- }
174
-
175
- private buildEffortOptions(model: string, selectedEffort: EffortLevel): string {
176
- return this.getEffortLevelsForModel(model)
177
- .map((effort) => {
178
- const selected = selectedEffort === effort ? ' selected' : '';
179
- const label = effort === 'max' ? `${effort} (Opus)` : effort;
180
- return `<option value="${effort}"${selected}>${label}</option>`;
181
- })
182
- .join('');
183
- }
184
-
185
- private refreshAgentEffortControls(agentId: string, model: string): void {
186
- const effortContainer = getElementByIdOrNull<HTMLElement>(`agent-effort-container-${agentId}`);
187
- if (!effortContainer) {
188
- return;
189
- }
190
-
191
- const supportsEffort = this.supportsEffortModel(model);
192
- effortContainer.style.display = supportsEffort ? 'block' : 'none';
193
-
194
- const effortSelect = getElementByIdOrNull<HTMLSelectElement>(`agent-effort-${agentId}`);
195
- if (!effortSelect) {
196
- return;
197
- }
198
-
199
- const currentEffort = (effortSelect.value || 'medium') as EffortLevel;
200
- const normalizedEffort = this.normalizeEffortForModel(model, currentEffort);
201
- effortSelect.innerHTML = this.buildEffortOptions(model, normalizedEffort);
202
- effortSelect.value = normalizedEffort;
203
- }
204
-
205
- /**
206
- * Parse and validate required integer input.
207
- */
208
- private parseIntegerInput(id: string, min: number, max: number, fallback: number | null): number {
209
- const raw = this.getValue(id);
210
- if (raw === null) {
211
- if (fallback === null) {
212
- throw new Error(`필수 값이 비어 있습니다: ${id}`);
213
- }
214
- return fallback;
215
- }
216
-
217
- const trimmed = raw.trim();
218
- if (!trimmed) {
219
- if (fallback === null) {
220
- throw new Error(`필수 값이 비어 있습니다: ${id}`);
221
- }
222
- return fallback;
223
- }
224
-
225
- const parsed = Number(trimmed);
226
- if (!Number.isFinite(parsed) || !Number.isInteger(parsed)) {
227
- throw new Error(`숫자 형식이 유효하지 않습니다: ${id}`);
228
- }
229
-
230
- if (parsed < min || parsed > max) {
231
- throw new Error(`${id}는 ${min}~${max} 사이여야 합니다.`);
232
- }
233
-
234
- return parsed;
235
- }
236
-
237
- private parseOptionalNumber(
238
- id: string,
239
- fieldName: string,
240
- options: {
241
- min: number;
242
- max?: number;
243
- integerOnly?: boolean;
244
- }
245
- ): number | undefined {
246
- const raw = this.getValue(id);
247
- const trimmed = raw.trim();
248
- if (!trimmed) {
249
- return undefined;
250
- }
251
-
252
- const parsed = Number(trimmed);
253
- if (!Number.isFinite(parsed)) {
254
- throw new Error(`${fieldName}은(는) 유효한 숫자여야 합니다.`);
255
- }
256
-
257
- if (parsed < options.min) {
258
- throw new Error(`${fieldName}은(는) ${options.min} 이상이어야 합니다.`);
259
- }
260
-
261
- if (options.max !== undefined && parsed > options.max) {
262
- throw new Error(`${fieldName}은(는) ${options.max} 이하여야 합니다.`);
263
- }
264
-
265
- if (options.integerOnly !== false && !Number.isInteger(parsed)) {
266
- throw new Error(`${fieldName}은(는) 정수여야 합니다.`);
267
- }
268
-
269
- return parsed;
270
- }
271
-
272
- /**
273
- * Initialize settings module
274
- */
275
- async init(): Promise<void> {
276
- if (this.initialized) {
277
- return;
278
- }
279
- this.initialized = true;
280
-
281
- await this.loadSettings();
282
- this.initBackendModelBinding();
283
- this.initDelegatedEventHandlers();
284
- }
285
-
286
- initDelegatedEventHandlers(): void {
287
- if (this.delegatedListenersInitialized) {
288
- return;
289
- }
290
- this.delegatedListenersInitialized = true;
291
-
292
- document.addEventListener('change', (e: Event) => {
293
- const target = e.target;
294
- if (!(target instanceof HTMLElement)) {
295
- return;
296
- }
297
-
298
- const actionElement = target.closest<HTMLElement>('[data-action]');
299
- const action = actionElement?.dataset.action;
300
- if (!action || !actionElement) {
301
- return;
302
- }
303
-
304
- if (action === 'agent-toggle') {
305
- const checkbox = actionElement as HTMLInputElement;
306
- const agentId = checkbox.dataset.agentId || '';
307
- if (agentId) {
308
- void this.toggleAgent(agentId, checkbox.checked);
309
- }
310
- return;
311
- }
312
-
313
- if (action === 'agent-backend') {
314
- const select = actionElement as HTMLSelectElement;
315
- const agentId = select.dataset.agentId || '';
316
- if (agentId) {
317
- this.onAgentBackendChange(agentId);
318
- }
319
- return;
320
- }
321
-
322
- if (action === 'agent-model') {
323
- const select = actionElement as HTMLSelectElement;
324
- const agentId = select.dataset.agentId || '';
325
- if (agentId) {
326
- this.onAgentModelChange(agentId);
327
- }
328
- return;
329
- }
330
-
331
- if (action === 'cron-toggle') {
332
- const checkbox = actionElement as HTMLInputElement;
333
- const cronId = checkbox.dataset.cronId || '';
334
- if (cronId) {
335
- void this.toggleCronJob(cronId, checkbox.checked);
336
- }
337
- return;
338
- }
339
- });
340
-
341
- document.addEventListener('click', (e: MouseEvent) => {
342
- const target = e.target;
343
- if (!(target instanceof HTMLElement)) {
344
- return;
345
- }
346
-
347
- // Handle agent-save button click
348
- const saveButton = target.closest<HTMLElement>('[data-action="agent-save"]');
349
- if (saveButton) {
350
- e.preventDefault();
351
- const agentId = saveButton.dataset.agentId || '';
352
- if (agentId) {
353
- void this.saveAgentConfig(agentId);
354
- }
355
- return;
356
- }
357
-
358
- // Handle cron-delete button click
359
- const deleteButton = target.closest<HTMLElement>('[data-action="cron-delete"]');
360
- if (deleteButton) {
361
- e.preventDefault();
362
- const cronId = deleteButton.dataset.cronId || '';
363
- if (cronId) {
364
- void this.deleteCronJob(cronId);
365
- }
366
- return;
367
- }
368
-
369
- const goAgentsButton = target.closest<HTMLElement>('[data-action="go-agents-tab"]');
370
- if (goAgentsButton) {
371
- e.preventDefault();
372
- if (typeof window.switchTab === 'function') {
373
- window.switchTab('agents');
374
- }
375
- }
376
- });
377
- }
378
-
379
- /**
380
- * Load current settings from API
381
- */
382
- async loadSettings(): Promise<void> {
383
- this.setStatus('Loading...');
384
-
385
- try {
386
- this.config = await API.get<ApiConfigResponse>('/api/config');
387
-
388
- // Load MCP servers data
389
- try {
390
- this.mcpServersData = await API.get<McpServersResponse>('/api/mcp-servers');
391
- } catch (e) {
392
- logger.warn('MCP servers data unavailable:', e);
393
- this.mcpServersData = { servers: [] };
394
- }
395
-
396
- // Load multi-agent data (F3)
397
- try {
398
- this.multiAgentData = await API.get<MultiAgentAgentsResponse>('/api/multi-agent/agents');
399
- } catch (e) {
400
- logger.warn('Multi-agent data unavailable:', e);
401
- this.multiAgentData = { agents: [] };
402
- }
403
-
404
- this.populateForm();
405
- this.setStatus('');
406
- } catch (error) {
407
- const message = error instanceof Error ? error.message : String(error);
408
- logger.error('Load error:', message);
409
- this.setStatus(`Error: ${message}`, 'error');
410
- }
411
- }
412
-
413
- /**
414
- * Populate form with current config values
415
- */
416
- populateForm(): void {
417
- if (!this.config) {
418
- return;
419
- }
420
-
421
- // Discord
422
- this.setCheckbox('settings-discord-enabled', this.config.discord?.enabled);
423
- this.setValue('settings-discord-token', this.config.discord?.token || '', true);
424
- this.setValue('settings-discord-channel', this.config.discord?.default_channel_id || '');
425
-
426
- // Slack
427
- this.setCheckbox('settings-slack-enabled', this.config.slack?.enabled);
428
- this.setValue('settings-slack-bot-token', this.config.slack?.bot_token || '', true);
429
- this.setValue('settings-slack-app-token', this.config.slack?.app_token || '', true);
430
-
431
- // Telegram
432
- this.setCheckbox('settings-telegram-enabled', this.config.telegram?.enabled);
433
- this.setValue('settings-telegram-token', this.config.telegram?.token || '', true);
434
-
435
- // Chatwork
436
- this.setCheckbox('settings-chatwork-enabled', this.config.chatwork?.enabled);
437
- this.setValue('settings-chatwork-token', this.config.chatwork?.api_token || '', true);
438
-
439
- // Heartbeat
440
- this.setCheckbox('settings-heartbeat-enabled', this.config.heartbeat?.enabled);
441
- this.setValue(
442
- 'settings-heartbeat-interval',
443
- Math.round((this.config.heartbeat?.interval || 1800000) / 60000)
444
- );
445
- this.setValue('settings-heartbeat-quiet-start', this.config.heartbeat?.quiet_start ?? 23);
446
- this.setValue('settings-heartbeat-quiet-end', this.config.heartbeat?.quiet_end ?? 8);
447
-
448
- // Agent
449
- const backend = (this.config.agent?.backend || 'claude') as AgentBackend;
450
- const model = this.config.agent?.model || 'claude-sonnet-4-6';
451
- const effort = (this.config.agent?.effort || 'medium') as EffortLevel;
452
- this.setSelectValue('settings-agent-backend', backend);
453
- this.updateModelOptions(backend, model);
454
- const normalizedModel = this.getNormalizedModelForBackend(backend, model);
455
- this.setSelectValue('settings-agent-model', normalizedModel);
456
- this.setSelectValue('settings-agent-effort', effort);
457
- this.updateEffortVisibility(normalizedModel);
458
- this.setValue('settings-agent-max-turns', this.config.agent?.max_turns || 10);
459
- this.setValue(
460
- 'settings-agent-timeout',
461
- Math.round((this.config.agent?.timeout || 300000) / 1000)
462
- );
463
-
464
- // Timeouts
465
- this.populateTimeouts();
466
-
467
- // Tool Mode
468
- this.populateToolMode();
469
-
470
- // Role Permissions
471
- this.populateRoles();
472
-
473
- // Multi-Agent Team (F3)
474
- this.populateMultiAgentSection();
475
-
476
- // Metrics
477
- this.populateMetricsSection();
478
-
479
- // Skills + Token Budget + Cron
480
- this.populateSkillsSection();
481
- this.populateTokenSection();
482
- this.populateCronSection();
483
-
484
- reportPageContext('settings', {
485
- pageType: 'settings-overview',
486
- gateways: {
487
- discord: this.config.discord?.enabled ?? false,
488
- slack: this.config.slack?.enabled ?? false,
489
- telegram: this.config.telegram?.enabled ?? false,
490
- chatwork: this.config.chatwork?.enabled ?? false,
491
- },
492
- agent: {
493
- backend: this.config.agent?.backend ?? 'claude',
494
- model: this.config.agent?.model ?? null,
495
- max_turns: this.config.agent?.max_turns ?? null,
496
- timeout: this.config.agent?.timeout ?? null,
497
- },
498
- multiAgentCount: this.multiAgentData.agents.length,
499
- mcpServerCount: this.mcpServersData.servers.length,
500
- });
501
- }
502
-
503
- /**
504
- * Populate timeouts section from config
505
- */
506
- populateTimeouts(): void {
507
- if (!this.config) {
508
- return;
509
- }
510
- const t = this.config.timeouts;
511
- if (!t) {
512
- return;
513
- }
514
- this.setValue('settings-timeout-request', Math.round((t.request_ms ?? 120000) / 1000));
515
- this.setValue('settings-timeout-agent', Math.round((t.agent_ms ?? 300000) / 1000));
516
- this.setValue('settings-timeout-session', Math.round((t.session_ms ?? 1800000) / 60000));
517
- this.setValue('settings-timeout-wf-step', Math.round((t.workflow_step_ms ?? 300000) / 1000));
518
- this.setValue('settings-timeout-wf-max', Math.round((t.workflow_max_ms ?? 1800000) / 60000));
519
- this.setValue('settings-timeout-ultrawork', Math.round((t.ultrawork_ms ?? 300000) / 1000));
520
- }
521
-
522
- /**
523
- * Populate role permissions from config
524
- */
525
- populateRoles(): void {
526
- const container = getElementByIdOrNull<HTMLElement>('settings-roles-container');
527
- if (!container || !this.config.roles) {
528
- return;
529
- }
530
-
531
- const { definitions, sourceMapping } = this.config.roles;
532
- if (!definitions || !sourceMapping) {
533
- return;
534
- }
535
-
536
- // Build reverse mapping: role -> sources
537
- const roleSources: Record<string, string[]> = {};
538
- for (const [source, role] of Object.entries(sourceMapping)) {
539
- if (!roleSources[role]) {
540
- roleSources[role] = [];
541
- }
542
- roleSources[role].push(source);
543
- }
544
-
545
- // Render each role
546
- const roleColors: Record<string, { badge: string; label: string }> = {
547
- os_agent: { badge: 'green', label: 'Full Access' },
548
- chat_bot: { badge: 'yellow', label: 'Limited' },
549
- };
550
-
551
- const roleIcons: Record<string, string> = {
552
- os_agent: '🖥️',
553
- chat_bot: '🤖',
554
- };
555
-
556
- const roleDefs = definitions as Record<string, ApiRoleDefinition>;
557
- const html = Object.entries(roleDefs)
558
- .map(([roleName, roleConfig]) => {
559
- const sources = roleSources[roleName] || [];
560
- const color = roleColors[roleName] || { badge: 'gray', label: 'Custom' };
561
- const icon = roleIcons[roleName] || '⚙️';
562
- const displayName = escapeHtml(
563
- roleName.replace(/_/g, ' ').replace(/\b\w/g, (l) => l.toUpperCase())
564
- );
565
-
566
- const allowedTools = roleConfig.allowedTools || [];
567
- const blockedTools = roleConfig.blockedTools || [];
568
- const hasSystemControl = roleConfig.systemControl;
569
- const hasSensitiveAccess = roleConfig.sensitiveAccess;
570
- const model = roleConfig.model || 'default';
571
- const maxTurns = roleConfig.maxTurns;
572
-
573
- // Format model name for display (and escape)
574
- const displayModel = escapeHtml(formatModelName(model));
575
-
576
- return `
577
- <div class="bg-white border border-gray-200 rounded-lg p-2.5">
578
- <div class="flex items-center justify-between mb-2">
579
- <div class="flex items-center gap-2">
580
- <span class="text-xl">${icon}</span>
581
- <h3 class="font-semibold text-gray-900 text-sm">${displayName}</h3>
582
- <span class="text-[10px] bg-${color.badge}-100 text-${color.badge}-800 px-1.5 py-0.5 rounded">${color.label}</span>
583
- </div>
584
- </div>
585
- <div class="text-xs text-gray-600 space-y-1">
586
- <div class="flex items-center gap-2">
587
- <span class="font-medium">Model:</span>
588
- <span class="bg-indigo-50 text-indigo-700 px-1.5 py-0.5 rounded text-[10px] font-medium">${displayModel}</span>
589
- ${
590
- maxTurns
591
- ? `<span class="text-gray-400">| ${escapeHtml(String(maxTurns))} turns</span>`
592
- : ''
593
- }
594
- </div>
595
- <div><span class="font-medium">Source:</span> ${sources.map((s) => `<code class="bg-gray-100 px-1 rounded">${escapeHtml(s)}</code>`).join(' ')}</div>
596
- <div><span class="font-medium">Allowed:</span> <code class="text-green-600 text-[10px]">${escapeHtml(allowedTools.join(', '))}</code></div>
597
- ${blockedTools.length > 0 ? `<div><span class="font-medium">Blocked:</span> <code class="text-red-600 text-[10px]">${escapeHtml(blockedTools.join(', '))}</code></div>` : ''}
598
- ${
599
- hasSystemControl || hasSensitiveAccess
600
- ? `<div><span class="font-medium">Permissions:</span>
601
- ${hasSystemControl ? '<span class="inline-block bg-blue-100 text-blue-800 text-[10px] px-1 rounded mr-1">systemControl</span>' : ''}
602
- ${hasSensitiveAccess ? '<span class="inline-block bg-purple-100 text-purple-800 text-[10px] px-1 rounded">sensitiveAccess</span>' : ''}
603
- </div>`
604
- : ''
605
- }
606
- </div>
607
- </div>
608
- `;
609
- })
610
- .join('');
611
-
612
- container.innerHTML = html;
613
- }
614
-
615
- /**
616
- * Populate tool selection checkboxes
617
- */
618
- populateToolMode(): void {
619
- const tools = this.config.agent?.tools || { gateway: ['*'], mcp: [] };
620
- const gatewayTools = tools.gateway || ['*'];
621
- const mcpTools = tools.mcp || [];
622
-
623
- // Set Gateway tool checkboxes
624
- const gatewayCheckboxes = document.querySelectorAll<HTMLInputElement>('.gateway-tool');
625
- const isGatewayAll = gatewayTools.includes('*');
626
-
627
- gatewayCheckboxes.forEach((cb) => {
628
- if (isGatewayAll) {
629
- cb.checked = true;
630
- } else {
631
- cb.checked = gatewayTools.includes(cb.value);
632
- }
633
- });
634
-
635
- // Set Select All checkbox
636
- const gatewaySelectAll = getElementByIdOrNull<HTMLInputElement>('gateway-select-all');
637
- if (gatewaySelectAll) {
638
- gatewaySelectAll.checked = isGatewayAll || this.allChecked('.gateway-tool');
639
- }
640
-
641
- // Dynamically render MCP servers from API
642
- this.renderMCPServers(mcpTools);
643
-
644
- // Update summary
645
- this.updateToolSummary();
646
- }
647
-
648
- /**
649
- * Render MCP servers dynamically from loaded data
650
- */
651
- renderMCPServers(selectedTools: string[] = []): void {
652
- const container = getElementByIdOrNull<HTMLElement>('mcp-tools-list');
653
- if (!container) {
654
- return;
655
- }
656
-
657
- const servers = (this.mcpServersData?.servers || []) as McpServer[];
658
- const isMCPAll = selectedTools.includes('*');
659
-
660
- if (servers.length === 0) {
661
- container.innerHTML = `
662
- <p class="text-xs text-gray-500 col-span-full">
663
- No MCP servers configured. Add servers to ~/.mama/mama-mcp-config.json
664
- </p>
665
- `;
666
- return;
667
- }
668
-
669
- const serverColors: Record<string, { border: string; bg: string; icon: string }> = {
670
- 'brave-devtools': { border: 'border-blue-200', bg: 'bg-blue-50', icon: '🌐' },
671
- 'brave-search': { border: 'border-orange-200', bg: 'bg-orange-50', icon: '🔍' },
672
- mama: { border: 'border-purple-200', bg: 'bg-purple-50', icon: '🧠' },
673
- default: { border: 'border-gray-200', bg: 'bg-gray-50', icon: '🔌' },
674
- };
675
-
676
- const html = servers
677
- .map((server) => {
678
- const serverName = server.name || '';
679
- const colors = serverColors[serverName] || serverColors['default'];
680
- const toolValue = `mcp__${serverName}__*`;
681
- const isChecked = isMCPAll || selectedTools.includes(toolValue);
682
- // Escape server.name for safe HTML rendering (XSS prevention)
683
- const safeName = escapeHtml(serverName);
684
- const safeToolValue = escapeAttr(toolValue);
685
-
686
- return `
687
- <label class="flex items-center gap-2 p-2 border ${colors.border} rounded-lg text-xs cursor-pointer hover:${colors.bg}">
688
- <input type="checkbox" class="mcp-tool" value="${safeToolValue}" ${isChecked ? 'checked' : ''}>
689
- ${colors.icon} ${safeName}
690
- </label>
691
- `;
692
- })
693
- .join('');
694
-
695
- container.innerHTML = html;
696
-
697
- // Update Select All checkbox
698
- const mcpSelectAll = getElementByIdOrNull<HTMLInputElement>('mcp-select-all');
699
- if (mcpSelectAll) {
700
- mcpSelectAll.checked = isMCPAll || this.allChecked('.mcp-tool');
701
- }
702
- }
703
-
704
- /**
705
- * Check if all checkboxes of a class are checked
706
- */
707
- allChecked(selector: string): boolean {
708
- const checkboxes = document.querySelectorAll<HTMLInputElement>(selector);
709
- return Array.from(checkboxes).every((cb) => cb.checked);
710
- }
711
-
712
- /**
713
- * Toggle all Gateway tools
714
- */
715
- toggleAllGateway(checked: boolean): void {
716
- document.querySelectorAll<HTMLInputElement>('.gateway-tool').forEach((cb) => {
717
- cb.checked = checked;
718
- });
719
- this.updateToolSummary();
720
- }
721
-
722
- /**
723
- * Toggle all MCP tools
724
- */
725
- toggleAllMCP(checked: boolean): void {
726
- document.querySelectorAll<HTMLInputElement>('.mcp-tool').forEach((cb) => {
727
- cb.checked = checked;
728
- });
729
- this.updateToolSummary();
730
- }
731
-
732
- /**
733
- * Update tool summary display
734
- */
735
- updateToolSummary(): void {
736
- const gatewayCount =
737
- document.querySelectorAll<HTMLInputElement>('.gateway-tool:checked').length;
738
- const mcpCount = document.querySelectorAll<HTMLInputElement>('.mcp-tool:checked').length;
739
-
740
- const summaryEl = getElementByIdOrNull<HTMLElement>('tool-summary');
741
- if (summaryEl) {
742
- summaryEl.textContent = `Gateway: ${gatewayCount} tools | MCP: ${mcpCount} tools`;
743
- }
744
- }
745
-
746
- /**
747
- * Save settings and restart daemon to apply changes
748
- */
749
- async saveAndRestart(): Promise<void> {
750
- this.setStatus('Saving...');
751
-
752
- try {
753
- const updates = this.collectFormData();
754
- await API.put('/api/config', updates);
755
-
756
- this.setStatus('Saved! Restarting...', 'success');
757
- showToast('Settings saved. Restarting daemon...');
758
-
759
- // Trigger restart after save
760
- try {
761
- await API.post('/api/restart', {});
762
- } catch {
763
- // Expected: connection drops when server exits
764
- }
765
-
766
- const isServiceReady = await this.waitForServiceAfterRestart();
767
- if (!isServiceReady) {
768
- this.setStatus('Restarted, but reconnect timed out. Please refresh manually.', 'error');
769
- showToast('Restart request sent. Auto reconnect timed out - please refresh page.');
770
- return;
771
- }
772
-
773
- this.setStatus('Reconnected. Reloading page...', 'success');
774
- setTimeout(() => {
775
- window.location.reload();
776
- }, 400);
777
- } catch (error) {
778
- const message = error instanceof Error ? error.message : String(error);
779
- logger.error('Save error:', message);
780
- this.setStatus(`Error: ${message}`, 'error');
781
- showToast(`Failed to save: ${message}`);
782
- }
783
- }
784
-
785
- /**
786
- * Wait for service recovery after restart by polling dashboard status endpoint.
787
- */
788
- private async waitForServiceAfterRestart(): Promise<boolean> {
789
- const maxAttempts = 40;
790
- const intervalMs = 1000;
791
- const readinessChecks = ['/api/health', '/api/dashboard/status'];
792
- // Server waits 500ms + shell sleeps 1s before stopping, so wait at least 2.5s
793
- // before first poll to ensure old server is actually down
794
- const initialDelayMs = 2500;
795
-
796
- this.setStatus('Restarting... waiting for shutdown', '');
797
- await new Promise((resolve) => setTimeout(resolve, initialDelayMs));
798
-
799
- for (let attempt = 1; attempt <= maxAttempts; attempt++) {
800
- this.setStatus(`Restarting... reconnecting (${attempt}/${maxAttempts})`, '');
801
-
802
- let isReady = false;
803
- for (const endpoint of readinessChecks) {
804
- try {
805
- // Use shared API client for strict JSON response parsing and consistent errors.
806
- await API.get(endpoint);
807
- logger.debug('[Settings] Service ready check passed:', endpoint);
808
- isReady = true;
809
- break;
810
- } catch {
811
- logger.debug('[Settings] Service not ready yet:', endpoint);
812
- }
813
- }
814
-
815
- if (isReady) {
816
- return true;
817
- }
818
-
819
- await new Promise((resolve) => setTimeout(resolve, intervalMs));
820
- }
821
-
822
- return false;
823
- }
824
-
825
- /**
826
- * Collect form data into config update object
827
- */
828
- collectFormData(): SettingsPayload {
829
- const backend = (this.getSelectValue('settings-agent-backend') ||
830
- this.config?.agent?.backend ||
831
- 'claude') as AgentBackend;
832
- const model = this.getSelectValue('settings-agent-model') || this.config?.agent?.model || '';
833
- const effort = (this.getSelectValue('settings-agent-effort') ||
834
- this.config?.agent?.effort ||
835
- 'medium') as EffortLevel;
836
- const useClaudeCli = backend === 'claude';
837
- const resolvedModel = this.getNormalizedModelForBackend(backend, model);
838
- const normalizedEffort = this.supportsEffortModel(resolvedModel)
839
- ? this.normalizeEffortForModel(resolvedModel, effort)
840
- : undefined;
841
- const hasToolControls =
842
- document.querySelectorAll<HTMLInputElement>('.gateway-tool, .mcp-tool').length > 0;
843
- const configuredTools = this.config?.agent?.tools;
844
- const tools: SettingsPayloadToolConfig = hasToolControls
845
- ? this.collectToolModeData()
846
- : {
847
- gateway: configuredTools?.gateway ?? [],
848
- mcp: configuredTools?.mcp ?? [],
849
- mcp_config:
850
- typeof configuredTools?.mcp_config === 'string'
851
- ? configuredTools.mcp_config
852
- : '~/.mama/mama-mcp-config.json',
853
- };
854
-
855
- // Get token values - if empty and original was masked, keep original
856
- const discordToken = this.getTokenValue('settings-discord-token', this.config?.discord?.token);
857
- const slackBotToken = this.getTokenValue(
858
- 'settings-slack-bot-token',
859
- this.config?.slack?.bot_token
860
- );
861
- const slackAppToken = this.getTokenValue(
862
- 'settings-slack-app-token',
863
- this.config?.slack?.app_token
864
- );
865
- const telegramToken = this.getTokenValue(
866
- 'settings-telegram-token',
867
- this.config?.telegram?.token
868
- );
869
- const chatworkToken = this.getTokenValue(
870
- 'settings-chatwork-token',
871
- this.config?.chatwork?.api_token
872
- );
873
-
874
- return {
875
- discord: {
876
- enabled: this.getCheckbox('settings-discord-enabled'),
877
- token: discordToken,
878
- default_channel_id: this.getValue('settings-discord-channel'),
879
- },
880
- slack: {
881
- enabled: this.getCheckbox('settings-slack-enabled'),
882
- bot_token: slackBotToken,
883
- app_token: slackAppToken,
884
- },
885
- telegram: {
886
- enabled: this.getCheckbox('settings-telegram-enabled'),
887
- token: telegramToken,
888
- },
889
- chatwork: {
890
- enabled: this.getCheckbox('settings-chatwork-enabled'),
891
- api_token: chatworkToken,
892
- },
893
- heartbeat: {
894
- enabled: this.getCheckbox('settings-heartbeat-enabled'),
895
- interval: this.parseIntegerInput('settings-heartbeat-interval', 1, 1440, 30) * 60000,
896
- quiet_start: this.parseIntegerInput('settings-heartbeat-quiet-start', 0, 23, 23),
897
- quiet_end: this.parseIntegerInput('settings-heartbeat-quiet-end', 0, 23, 8),
898
- },
899
- use_claude_cli: useClaudeCli,
900
- agent: {
901
- backend,
902
- model: resolvedModel,
903
- // Effort for Claude 4.6 models (adaptive thinking). 'max' is Opus-only.
904
- effort: normalizedEffort,
905
- max_turns: this.parseIntegerInput(
906
- 'settings-agent-max-turns',
907
- 1,
908
- 100,
909
- this.config?.agent?.max_turns ?? 10
910
- ),
911
- timeout:
912
- this.parseIntegerInput(
913
- 'settings-agent-timeout',
914
- 1,
915
- 600,
916
- Math.round((this.config?.agent?.timeout ?? 300000) / 1000)
917
- ) * 1000,
918
- tools,
919
- },
920
- token_budget: {
921
- // Keep existing integer constraint for daily limit to avoid partial values.
922
- daily_limit: this.parseOptionalNumber('settings-token-daily-limit', 'daily_limit', {
923
- min: 0,
924
- integerOnly: true,
925
- }),
926
- alert_threshold: this.parseOptionalNumber(
927
- 'settings-token-alert-threshold',
928
- 'alert_threshold',
929
- {
930
- min: 0,
931
- max: 100,
932
- integerOnly: false,
933
- }
934
- ),
935
- },
936
- metrics: {
937
- enabled: this.getCheckbox('settings-metrics-enabled'),
938
- retention_days: this.parseIntegerInput('settings-metrics-retention', 1, 90, 7),
939
- },
940
- timeouts: {
941
- request_ms: this.parseIntegerInput('settings-timeout-request', 0, 600, 120) * 1000,
942
- agent_ms: this.parseIntegerInput('settings-timeout-agent', 0, 3600, 300) * 1000,
943
- session_ms: this.parseIntegerInput('settings-timeout-session', 0, 1440, 30) * 60000,
944
- workflow_step_ms: this.parseIntegerInput('settings-timeout-wf-step', 0, 7200, 300) * 1000,
945
- workflow_max_ms: this.parseIntegerInput('settings-timeout-wf-max', 0, 1440, 30) * 60000,
946
- ultrawork_ms: this.parseIntegerInput('settings-timeout-ultrawork', 0, 7200, 300) * 1000,
947
- },
948
- };
949
- }
950
-
951
- initBackendModelBinding(): void {
952
- if (this.backendListenersInitialized) {
953
- return;
954
- }
955
- this.backendListenersInitialized = true;
956
- const backendSelect = getElementByIdOrNull<HTMLSelectElement>('settings-agent-backend');
957
- if (!backendSelect) {
958
- return;
959
- }
960
- backendSelect.addEventListener('change', () => {
961
- const backend = (this.getSelectValue('settings-agent-backend') || 'claude') as AgentBackend;
962
- const currentModel = this.getSelectValue('settings-agent-model');
963
- this.updateModelOptions(backend, currentModel);
964
- const normalizedModel = this.getNormalizedModelForBackend(backend, currentModel);
965
- this.setSelectValue('settings-agent-model', normalizedModel);
966
- this.updateEffortVisibility(normalizedModel);
967
- });
968
-
969
- // Also listen for model changes to update effort visibility
970
- const modelSelect = getElementByIdOrNull<HTMLSelectElement>('settings-agent-model');
971
- if (modelSelect) {
972
- modelSelect.addEventListener('change', () => {
973
- const model = this.getSelectValue('settings-agent-model');
974
- this.updateEffortVisibility(model);
975
- });
976
- }
977
- }
978
-
979
- updateModelOptions(backend: AgentBackend, currentModel: string): void {
980
- const select = getElementByIdOrNull<HTMLSelectElement>('settings-agent-model');
981
- if (!select) {
982
- return;
983
- }
984
- const normalized = this.getNormalizedModelForBackend(backend, currentModel);
985
- const modelList = getSettingsModelOptions(backend, normalized);
986
- select.innerHTML = modelList
987
- .map(
988
- (m) =>
989
- `<option value="${escapeHtml(m)}" ${m === normalized ? 'selected' : ''}>${escapeHtml(formatModelName(m))}</option>`
990
- )
991
- .join('');
992
-
993
- // Update effort visibility when model options change
994
- this.updateEffortVisibility(normalized);
995
- }
996
-
997
- /**
998
- * Show/hide effort level dropdown based on model selection
999
- * Effort applies to Claude 4.6 models, with 'max' reserved for Opus.
1000
- */
1001
- updateEffortVisibility(model: string): void {
1002
- const effortContainer = getElementByIdOrNull<HTMLElement>('settings-effort-container');
1003
- if (!effortContainer) {
1004
- return;
1005
- }
1006
- const supportsEffort = this.supportsEffortModel(model);
1007
- effortContainer.style.display = supportsEffort ? 'block' : 'none';
1008
-
1009
- const effortSelect = getElementByIdOrNull<HTMLSelectElement>('settings-agent-effort');
1010
- if (!effortSelect) {
1011
- return;
1012
- }
1013
-
1014
- const currentEffort = (effortSelect.value || 'medium') as EffortLevel;
1015
- const normalizedEffort = this.normalizeEffortForModel(model, currentEffort);
1016
- effortSelect.innerHTML = this.buildEffortOptions(model, normalizedEffort);
1017
- effortSelect.value = normalizedEffort;
1018
- }
1019
-
1020
- getNormalizedModelForBackend(backend: AgentBackend, model: string): string {
1021
- const isCodexBackend = backend === 'codex';
1022
- if (!model) {
1023
- return DEFAULT_MODEL[backend];
1024
- }
1025
- const isClaudeModel = /^claude-/i.test(model);
1026
- if (isCodexBackend && isClaudeModel) {
1027
- return DEFAULT_MODEL.codex;
1028
- }
1029
- if (backend === 'cline' && (isClaudeModel || /^gpt-/i.test(model))) {
1030
- return DEFAULT_MODEL.cline;
1031
- }
1032
- if (backend === 'claude' && !isClaudeModel) {
1033
- return DEFAULT_MODEL.claude;
1034
- }
1035
- return model;
1036
- }
1037
-
1038
- /**
1039
- * Collect tool selection data from checkboxes
1040
- */
1041
- collectToolModeData(): SettingsPayloadToolConfig {
1042
- const gatewayTools: string[] = [];
1043
- const mcpTools: string[] = [];
1044
-
1045
- // Collect selected Gateway tools
1046
- document.querySelectorAll<HTMLInputElement>('.gateway-tool:checked').forEach((cb) => {
1047
- gatewayTools.push(cb.value);
1048
- });
1049
-
1050
- // Collect selected MCP tools
1051
- document.querySelectorAll<HTMLInputElement>('.mcp-tool:checked').forEach((cb) => {
1052
- mcpTools.push(cb.value);
1053
- });
1054
-
1055
- // If all Gateway tools are selected, use wildcard
1056
- const allGateway = document.querySelectorAll<HTMLInputElement>('.gateway-tool');
1057
- if (gatewayTools.length === allGateway.length && gatewayTools.length > 0) {
1058
- return {
1059
- gateway: ['*'],
1060
- mcp: mcpTools,
1061
- mcp_config: '~/.mama/mama-mcp-config.json',
1062
- };
1063
- }
1064
-
1065
- return {
1066
- gateway: gatewayTools,
1067
- mcp: mcpTools,
1068
- mcp_config: '~/.mama/mama-mcp-config.json',
1069
- };
1070
- }
1071
-
1072
- /**
1073
- * Reset form to current saved values
1074
- */
1075
- resetForm(): void {
1076
- this.populateForm();
1077
- this.setStatus('Form reset');
1078
- setTimeout(() => this.setStatus(''), 2000);
1079
- }
1080
-
1081
- /**
1082
- * Helper: Set checkbox value
1083
- */
1084
- setCheckbox(id: string, checked: boolean): void {
1085
- const el = getElementByIdOrNull<HTMLInputElement>(id);
1086
- if (el) {
1087
- el.checked = !!checked;
1088
- }
1089
- }
1090
-
1091
- /**
1092
- * Helper: Get checkbox value
1093
- */
1094
- getCheckbox(id: string): boolean {
1095
- const el = getElementByIdOrNull<HTMLInputElement>(id);
1096
- return el ? el.checked : false;
1097
- }
1098
-
1099
- /**
1100
- * Helper: Set input value
1101
- * @param {string} id - Element ID
1102
- * @param {string} value - Value to set
1103
- * @param {boolean} isSensitive - If true, treat as sensitive token (keep if masked)
1104
- */
1105
- setValue(id: string, value: string | number, isSensitive = false): void {
1106
- const el = getElementByIdOrNull<HTMLInputElement>(id);
1107
- if (el) {
1108
- // For sensitive fields (tokens), preserve placeholder if value is masked
1109
- const normalized = String(value ?? '');
1110
- if (isSensitive && this.isMaskedToken(normalized)) {
1111
- el.placeholder = normalized;
1112
- el.value = '';
1113
- } else {
1114
- el.value = normalized;
1115
- }
1116
- }
1117
- }
1118
-
1119
- /**
1120
- * Check if a token is masked (e.g., "***[redacted]***")
1121
- */
1122
- isMaskedToken(token: string | number | undefined): boolean {
1123
- if (token === undefined || token === null) {
1124
- return false;
1125
- }
1126
- const str = String(token);
1127
- return str === '***[redacted]***' || (str.startsWith('***[') && str.endsWith(']***'));
1128
- }
1129
-
1130
- /**
1131
- * Get token value from input, preserving original if input is empty and original was masked
1132
- * @param {string} id - Input element ID
1133
- * @param {string} originalToken - Original token value from config
1134
- * @returns {string} Token to send (either new value or original masked token)
1135
- */
1136
- getTokenValue(id: string, originalToken?: string): string {
1137
- const inputValue = this.getValue(id);
1138
-
1139
- // If user entered a new value, use it
1140
- if (inputValue && inputValue.trim() !== '') {
1141
- return inputValue;
1142
- }
1143
-
1144
- // If input is empty and original was masked, keep the masked token (backend will preserve it)
1145
- if (this.isMaskedToken(originalToken)) {
1146
- return originalToken;
1147
- }
1148
-
1149
- // Otherwise return the input value (may be empty)
1150
- return inputValue;
1151
- }
1152
-
1153
- /**
1154
- * Helper: Get input value
1155
- */
1156
- getValue(id: string): string {
1157
- const el = getElementByIdOrNull<HTMLInputElement>(id);
1158
- return el ? el.value : '';
1159
- }
1160
-
1161
- /**
1162
- * Helper: Set select value
1163
- */
1164
- setSelectValue(id: string, value: string): void {
1165
- const el = getElementByIdOrNull<HTMLSelectElement>(id);
1166
- if (el) {
1167
- el.value = value;
1168
- }
1169
- }
1170
-
1171
- /**
1172
- * Helper: Get select value
1173
- */
1174
- getSelectValue(id: string): string {
1175
- const el = getElementByIdOrNull<HTMLSelectElement>(id);
1176
- return el ? el.value : '';
1177
- }
1178
-
1179
- /**
1180
- * Helper: Set radio button
1181
- */
1182
- setRadio(id: string, checked: boolean): void {
1183
- const el = getElementByIdOrNull<HTMLInputElement>(id);
1184
- if (el) {
1185
- el.checked = !!checked;
1186
- }
1187
- }
1188
-
1189
- /**
1190
- * Helper: Get radio button value
1191
- */
1192
- getRadio(id: string): boolean {
1193
- const el = getElementByIdOrNull<HTMLInputElement>(id);
1194
- return el ? el.checked : false;
1195
- }
1196
-
1197
- /**
1198
- * Set status message
1199
- */
1200
- setStatus(message: string, type: SettingsFilterValue = ''): void {
1201
- const statusEl = getElementByIdOrNull<HTMLElement>('settings-status');
1202
- if (statusEl) {
1203
- statusEl.textContent = message;
1204
- statusEl.className = `text-sm ${
1205
- type === 'error' ? 'text-red-500' : type === 'success' ? 'text-green-500' : 'text-gray-500'
1206
- }`;
1207
- }
1208
- }
1209
-
1210
- /**
1211
- * Populate Multi-Agent Team section (F3)
1212
- * Agent editing has moved to the Agents tab — show redirect notice.
1213
- */
1214
- populateMultiAgentSection(): void {
1215
- const container = getElementByIdOrNull<HTMLElement>('settings-multi-agent-container');
1216
- if (!container) {
1217
- return;
1218
- }
1219
-
1220
- container.innerHTML = `
1221
- <div class="bg-white border border-gray-200 rounded-lg p-4 text-center">
1222
- <p class="text-sm text-gray-600 mb-2">Agent management has moved to the Agents tab.</p>
1223
- <button data-action="go-agents-tab"
1224
- class="text-sm px-4 py-1.5 rounded-md bg-mama-yellow text-mama-black hover:bg-mama-yellow-hover font-medium">
1225
- Go to Agents
1226
- </button>
1227
- </div>`;
1228
- }
1229
-
1230
- /**
1231
- * Toggle agent enabled status (F3)
1232
- */
1233
- async toggleAgent(agentId: string, enabled: boolean): Promise<void> {
1234
- try {
1235
- await API.put(`/api/multi-agent/agents/${agentId}`, { enabled });
1236
-
1237
- logger.info(`Agent ${agentId} ${enabled ? 'enabled' : 'disabled'}`);
1238
- } catch (error) {
1239
- logger.error('Failed to toggle agent:', error);
1240
- // Revert checkbox on error
1241
- const checkbox = document.querySelector<HTMLInputElement>(
1242
- `input[data-action="agent-toggle"][data-agent-id="${agentId}"]`
1243
- );
1244
- if (checkbox) {
1245
- checkbox.checked = !enabled;
1246
- }
1247
- alert(`Failed to update agent: ${error instanceof Error ? error.message : String(error)}`);
1248
- }
1249
- }
1250
-
1251
- onAgentBackendChange(agentId: string): void {
1252
- const backendSelect = getElementByIdOrNull<HTMLSelectElement>(`agent-backend-${agentId}`);
1253
- const modelSelect = getElementByIdOrNull<HTMLSelectElement>(`agent-model-${agentId}`);
1254
- if (!backendSelect || !modelSelect) {
1255
- return;
1256
- }
1257
-
1258
- const backend = (backendSelect.value || 'claude') as AgentBackend;
1259
- const currentModel = modelSelect.value || '';
1260
- const normalized = this.getNormalizedModelForBackend(backend, currentModel);
1261
- const options = getSettingsModelOptions(backend, normalized);
1262
-
1263
- modelSelect.innerHTML = options
1264
- .map(
1265
- (m) =>
1266
- `<option value="${escapeAttr(m)}" ${m === normalized ? 'selected' : ''}>${escapeHtml(formatModelName(m))}</option>`
1267
- )
1268
- .join('');
1269
-
1270
- this.refreshAgentEffortControls(agentId, normalized);
1271
- }
1272
-
1273
- onAgentModelChange(agentId: string): void {
1274
- const modelSelect = getElementByIdOrNull<HTMLSelectElement>(`agent-model-${agentId}`);
1275
- if (!modelSelect) {
1276
- return;
1277
- }
1278
-
1279
- const model = modelSelect.value || '';
1280
- this.refreshAgentEffortControls(agentId, model);
1281
- }
1282
-
1283
- async saveAgentConfig(agentId: string): Promise<void> {
1284
- try {
1285
- const backendSelect = getElementByIdOrNull<HTMLSelectElement>(`agent-backend-${agentId}`);
1286
- const modelSelect = getElementByIdOrNull<HTMLSelectElement>(`agent-model-${agentId}`);
1287
- const delegateCheckbox = getElementByIdOrNull<HTMLInputElement>(`agent-delegate-${agentId}`);
1288
- const allToolsCheckbox = getElementByIdOrNull<HTMLInputElement>(`agent-alltools-${agentId}`);
1289
-
1290
- if (!backendSelect || !modelSelect) {
1291
- throw new Error('Agent settings inputs not found');
1292
- }
1293
-
1294
- const backend = (backendSelect.value || 'claude') as AgentBackend;
1295
- const model = this.getNormalizedModelForBackend(backend, modelSelect.value || '');
1296
- const can_delegate = delegateCheckbox?.checked ?? false;
1297
- const hasAllTools = allToolsCheckbox?.checked ?? false;
1298
-
1299
- // Effort level
1300
- const effortSelect = getElementByIdOrNull<HTMLSelectElement>(`agent-effort-${agentId}`);
1301
- const supportsEffort = this.supportsEffortModel(model);
1302
- const effort: EffortLevel | undefined =
1303
- supportsEffort && effortSelect
1304
- ? this.normalizeEffortForModel(model, effortSelect.value as EffortLevel)
1305
- : undefined;
1306
-
1307
- // Build tool_permissions based on checkbox
1308
- const tool_permissions = hasAllTools
1309
- ? { allowed: ['*'], blocked: [] }
1310
- : { allowed: ['Read', 'Grep', 'Glob'], blocked: [] };
1311
-
1312
- await API.put(`/api/multi-agent/agents/${agentId}`, {
1313
- backend,
1314
- model,
1315
- effort,
1316
- can_delegate,
1317
- tool_permissions,
1318
- });
1319
-
1320
- showToast(`Saved ${agentId} (applied)`);
1321
- await this.loadSettings();
1322
- } catch (error) {
1323
- logger.error('Failed to save agent config:', error);
1324
- alert(
1325
- `Failed to save agent config: ${error instanceof Error ? error.message : String(error)}`
1326
- );
1327
- }
1328
- }
1329
-
1330
- /**
1331
- * Populate installed skills section
1332
- */
1333
- async populateSkillsSection(): Promise<void> {
1334
- const container = getElementByIdOrNull<HTMLElement>('settings-skills-container');
1335
- if (!container) {
1336
- return;
1337
- }
1338
-
1339
- try {
1340
- const { skills } = await API.get<SkillsResponse>('/api/skills');
1341
- if (!skills || skills.length === 0) {
1342
- container.innerHTML = '<p class="text-xs text-gray-400">No skills installed</p>';
1343
- return;
1344
- }
1345
-
1346
- const sourceColors = {
1347
- mama: 'bg-yellow-100 text-yellow-700',
1348
- cowork: 'bg-blue-100 text-blue-700',
1349
- external: 'bg-purple-100 text-purple-700',
1350
- };
1351
-
1352
- container.innerHTML = `
1353
- <div class="space-y-1.5">
1354
- ${skills
1355
- .map(
1356
- (s) => `
1357
- <div class="flex items-center justify-between py-1">
1358
- <div class="flex items-center gap-2">
1359
- <span class="text-xs font-medium text-gray-900">${escapeHtml(s.name)}</span>
1360
- <span class="text-[10px] px-1.5 py-0.5 rounded ${sourceColors[s.source] || 'bg-gray-100 text-gray-600'}">${escapeHtml(s.source)}</span>
1361
- </div>
1362
- <label class="relative inline-flex items-center cursor-pointer">
1363
- <input type="checkbox" ${s.enabled !== false ? 'checked' : ''}
1364
- data-skill-source="${escapeHtml(s.source)}"
1365
- data-skill-id="${escapeHtml(s.id)}"
1366
- class="sr-only peer">
1367
- <div class="w-9 h-5 bg-gray-200 peer-focus:ring-2 peer-focus:ring-yellow-300 rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-4 after:w-4 after:transition-all peer-checked:bg-yellow-400"></div>
1368
- </label>
1369
- </div>
1370
- `
1371
- )
1372
- .join('')}
1373
- </div>
1374
- `;
1375
- container.querySelectorAll('input[data-skill-id]').forEach((input) => {
1376
- input.addEventListener('change', (event) => {
1377
- const target = event.target;
1378
- if (!(target instanceof HTMLInputElement)) {
1379
- return;
1380
- }
1381
- const source = target.dataset.skillSource || '';
1382
- const id = target.dataset.skillId || '';
1383
- if (!source || !id) {
1384
- return;
1385
- }
1386
- this.toggleSkill(source, id, target.checked);
1387
- });
1388
- });
1389
- } catch (error) {
1390
- logger.warn('Skills load error:', error instanceof Error ? error.message : String(error));
1391
- container.innerHTML = '<p class="text-xs text-gray-400">Failed to load skills</p>';
1392
- }
1393
- }
1394
-
1395
- /**
1396
- * Toggle skill enabled/disabled from settings
1397
- */
1398
- async toggleSkill(source: string, name: string, enabled: boolean): Promise<void> {
1399
- try {
1400
- await API.toggleSkill(name, enabled, source);
1401
- } catch (error) {
1402
- logger.error('Skill toggle failed:', error instanceof Error ? error.message : String(error));
1403
- this.populateSkillsSection();
1404
- }
1405
- }
1406
-
1407
- /**
1408
- * Populate scheduled jobs section
1409
- */
1410
- async populateCronSection(): Promise<void> {
1411
- const container = getElementByIdOrNull<HTMLElement>('settings-cron-container');
1412
- if (!container) {
1413
- return;
1414
- }
1415
-
1416
- try {
1417
- const { jobs } = await API.get<CronJobsResponse>('/api/cron');
1418
- if (!jobs || jobs.length === 0) {
1419
- container.innerHTML = '<p class="text-xs text-gray-400">No scheduled jobs</p>';
1420
- return;
1421
- }
1422
-
1423
- const cronJobs = jobs as CronJob[];
1424
- container.innerHTML = `<div class="space-y-1.5">${cronJobs
1425
- .map((job) => {
1426
- const nextRun = job.nextRun
1427
- ? new Date(job.nextRun).toLocaleString([], {
1428
- month: 'short',
1429
- day: 'numeric',
1430
- hour: '2-digit',
1431
- minute: '2-digit',
1432
- })
1433
- : '-';
1434
- return `
1435
- <div class="flex items-center justify-between py-1">
1436
- <div class="flex-1 min-w-0">
1437
- <div class="flex items-center gap-2">
1438
- <span class="text-xs font-medium text-gray-900">${escapeHtml(job.name || job.id)}</span>
1439
- <code class="text-[10px] bg-gray-100 px-1 rounded">${escapeHtml(job.schedule || job.cronExpr || '')}</code>
1440
- </div>
1441
- <p class="text-[10px] text-gray-500 truncate">${escapeHtml((job.prompt || '').slice(0, 80))}</p>
1442
- </div>
1443
- <div class="flex items-center gap-1 ml-2 shrink-0">
1444
- <span class="text-[10px] text-gray-400">${nextRun}</span>
1445
- <label class="relative inline-flex items-center cursor-pointer">
1446
- <input
1447
- type="checkbox"
1448
- ${job.enabled !== false ? 'checked' : ''}
1449
- data-action="cron-toggle"
1450
- data-cron-id="${escapeAttr(job.id)}"
1451
- class="sr-only peer cron-toggle"
1452
- >
1453
- <div class="w-9 h-5 bg-gray-200 peer-focus:ring-2 peer-focus:ring-yellow-300 rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-4 after:w-4 after:transition-all peer-checked:bg-yellow-400"></div>
1454
- </label>
1455
- <button
1456
- type="button"
1457
- data-action="cron-delete"
1458
- data-cron-id="${escapeAttr(job.id)}"
1459
- class="text-red-400 hover:text-red-600 text-xs px-1"
1460
- title="Delete"
1461
- >
1462
- ✕
1463
- </button>
1464
- </div>
1465
- </div>`;
1466
- })
1467
- .join('')}</div>`;
1468
- } catch (error) {
1469
- logger.warn('Cron load error:', error);
1470
- container.innerHTML = '<p class="text-xs text-gray-400">Failed to load jobs</p>';
1471
- }
1472
- }
1473
-
1474
- async addCronJob(): Promise<void> {
1475
- const nameInput = getElementByIdOrNull<HTMLInputElement>('settings-cron-name');
1476
- const cronExprInput = getElementByIdOrNull<HTMLInputElement>('settings-cron-expr');
1477
- const promptInput = getElementByIdOrNull<HTMLInputElement>('settings-cron-prompt');
1478
- if (!nameInput || !cronExprInput || !promptInput) {
1479
- return;
1480
- }
1481
-
1482
- const name = nameInput.value.trim();
1483
- const cronExpr = cronExprInput.value.trim();
1484
- const prompt = promptInput.value.trim();
1485
-
1486
- if (!name || !cronExpr || !prompt) {
1487
- showToast('Please fill in all fields');
1488
- return;
1489
- }
1490
-
1491
- try {
1492
- await API.post('/api/cron', { name, cron_expr: cronExpr, prompt });
1493
-
1494
- nameInput.value = '';
1495
- cronExprInput.value = '';
1496
- promptInput.value = '';
1497
- showToast('Job created');
1498
- this.populateCronSection();
1499
- } catch (error) {
1500
- showToast(`Failed: ${error instanceof Error ? error.message : String(error)}`);
1501
- }
1502
- }
1503
-
1504
- async toggleCronJob(id: string, enabled: boolean): Promise<void> {
1505
- try {
1506
- await API.updateCronJob(id, { enabled });
1507
- } catch (error) {
1508
- logger.error('Cron toggle failed:', error);
1509
- this.populateCronSection();
1510
- }
1511
- }
1512
-
1513
- async deleteCronJob(id: string): Promise<void> {
1514
- if (!confirm('Delete this scheduled job?')) {
1515
- return;
1516
- }
1517
- try {
1518
- await API.del(`/api/cron/${encodeURIComponent(id)}`);
1519
- showToast('Job deleted');
1520
- this.populateCronSection();
1521
- } catch (error) {
1522
- showToast(`Failed: ${error instanceof Error ? error.message : String(error)}`);
1523
- }
1524
- }
1525
-
1526
- /**
1527
- * Populate metrics section from config
1528
- */
1529
- populateMetricsSection(): void {
1530
- const metrics = this.config?.metrics;
1531
- this.setCheckbox('settings-metrics-enabled', metrics?.enabled !== false);
1532
- this.setValue('settings-metrics-retention', metrics?.retention_days ?? 7);
1533
- }
1534
-
1535
- /**
1536
- * Populate token budget section from config
1537
- */
1538
- populateTokenSection(): void {
1539
- const budget = this.config?.token_budget;
1540
- if (!budget) {
1541
- return;
1542
- }
1543
-
1544
- this.setValue('settings-token-daily-limit', budget.daily_limit || '');
1545
- this.setValue('settings-token-alert-threshold', budget.alert_threshold || '');
1546
- }
1547
- }