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