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