tinywebui 0.1.4 → 0.1.5

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 (82) hide show
  1. package/README.md +20 -13
  2. package/bin/tinywebui.js +69 -53
  3. package/package.json +25 -8
  4. package/public/app.js +40 -34
  5. package/public/chat/composer.js +1 -1
  6. package/public/chat/sidebar.js +8 -5
  7. package/public/index.html +88 -53
  8. package/public/panels/admin.js +40 -1
  9. package/public/panels/model-editor.js +336 -0
  10. package/public/panels/panels.js +37 -12
  11. package/public/panels/settings-home.js +125 -0
  12. package/public/panels/settings.js +124 -36
  13. package/public/shared/attachment_note.js +7 -1
  14. package/public/shared/events.js +1 -0
  15. package/public/shared/features.js +1 -0
  16. package/public/shared/origins.js +1 -0
  17. package/public/shared/usage.js +16 -1
  18. package/public/styles.css +84 -21
  19. package/src/access/auth.js +155 -155
  20. package/src/access/auth_gate.js +39 -38
  21. package/src/access/email.js +17 -0
  22. package/src/access/policy.js +123 -136
  23. package/src/audit.js +1 -1
  24. package/src/automations/automation.js +135 -123
  25. package/src/automations/scheduler.js +138 -127
  26. package/src/chat/compact.js +149 -156
  27. package/src/chat/llm.js +380 -382
  28. package/src/chat/runs.js +162 -151
  29. package/src/chat/tool_executor.js +229 -218
  30. package/src/cli.js +535 -433
  31. package/src/config/approval.js +35 -29
  32. package/src/config/config.js +546 -454
  33. package/src/config/data_dir.js +24 -0
  34. package/src/config/models.js +191 -115
  35. package/src/config/refs.js +32 -0
  36. package/src/config/secrets.js +153 -0
  37. package/src/files/documents.js +57 -68
  38. package/src/files/images.js +30 -31
  39. package/src/harness/agent.js +60 -60
  40. package/src/harness/attribution.js +66 -58
  41. package/src/harness/checkpoint.js +67 -62
  42. package/src/harness/history.js +28 -23
  43. package/src/harness/prompt.js +40 -57
  44. package/src/harness/provider.js +284 -283
  45. package/src/harness/types.js +1 -0
  46. package/src/http.js +90 -72
  47. package/src/log.js +11 -12
  48. package/src/mcp.js +398 -374
  49. package/src/retrieval/embedding.js +228 -240
  50. package/src/retrieval/retrieval.js +182 -173
  51. package/src/routes/admin.js +178 -138
  52. package/src/routes/auth.js +184 -111
  53. package/src/routes/automations.js +99 -82
  54. package/src/routes/chats.js +240 -218
  55. package/src/routes/documents.js +130 -120
  56. package/src/routes/library.js +43 -46
  57. package/src/routes/mcp.js +49 -49
  58. package/src/routes/settings.js +127 -122
  59. package/src/routes/types.js +1 -0
  60. package/src/server.js +426 -415
  61. package/src/store/automations.js +106 -106
  62. package/src/store/chats.js +217 -242
  63. package/src/store/documents.js +181 -185
  64. package/src/store/embeddings.js +52 -54
  65. package/src/store/index.js +95 -49
  66. package/src/store/messages.js +55 -80
  67. package/src/store/schema.js +128 -107
  68. package/src/store/scope.js +18 -17
  69. package/src/store/search.js +25 -27
  70. package/src/store/turns.js +53 -53
  71. package/src/store/usage.js +343 -269
  72. package/src/store/users.js +162 -87
  73. package/src/store/wire.js +50 -41
  74. package/src/tools/ask_tool.js +68 -68
  75. package/src/tools/chat_search_tool.js +38 -44
  76. package/src/tools/context_tool.js +131 -138
  77. package/src/tools/document_tool.js +73 -78
  78. package/src/tools/mcp_tool.js +274 -232
  79. package/src/tools/task_tool.js +53 -50
  80. package/src/tools/types.js +1 -0
  81. package/public/panels/connectors.js +0 -184
  82. package/src/set-password.js +0 -43
package/src/server.js CHANGED
@@ -1,18 +1,16 @@
1
1
  import { createServer } from 'node:http';
2
- import { randomBytes } from 'node:crypto';
3
2
  import { readFile } from 'node:fs/promises';
4
3
  import { watch, existsSync } from 'node:fs';
5
4
  import { dirname } from 'node:path';
6
-
7
- import { createConfigSource, LockedError, configProblems, redactSecrets } from './config/config.js';
5
+ import { createConfigSource, LockedError, ConfigError, configProblems, redactSecrets } from './config/config.js';
8
6
  import { McpHub, missingCommand } from './mcp.js';
9
- import { isClosed, findEntry } from './config/models.js';
7
+ import { isClosed, findEntry, effectiveConfig } from './config/models.js';
10
8
  import { Store, SCHEMA_VERSION } from './store/index.js';
11
9
  import { fingerprint, featuresFor, modelsFor, resolveAccess } from './access/policy.js';
12
- import { hashPassword, applyAccessPolicy } from './access/auth.js';
10
+ import { applyAccessPolicy } from './access/auth.js';
13
11
  import { audit } from './audit.js';
14
12
  import { logger } from './log.js';
15
- import { resolveAuth, OWNER_ID } from './access/auth_gate.js';
13
+ import { resolveAuth, isSignedIn } from './access/auth_gate.js';
16
14
  import { json, HttpError, SECURITY_HEADERS, crossSite, badHost, serveStatic } from './http.js';
17
15
  import { expandToolDef, callExpand } from './tools/context_tool.js';
18
16
  import { documentToolDef, callReadDocument } from './tools/document_tool.js';
@@ -36,11 +34,8 @@ import { chatRoutes } from './routes/chats.js';
36
34
  import { adminRoutes } from './routes/admin.js';
37
35
  import { EVENT } from '../public/shared/events.js';
38
36
  import { FEATURE } from '../public/shared/features.js';
39
-
40
37
  const log = logger('server');
41
-
42
38
  const VERSION = JSON.parse(await readFile(new URL('../package.json', import.meta.url), 'utf8')).version;
43
-
44
39
  /**
45
40
  * Every API route, with the feature it needs (null: any signed-in caller, or
46
41
  * anyone for the pre-auth routes the auth gate lets through). Dispatch and
@@ -48,436 +43,452 @@ const VERSION = JSON.parse(await readFile(new URL('../package.json', import.meta
48
43
  * about who may use it. First match wins.
49
44
  */
50
45
  function routeTable(app) {
51
- // What route groups may ask for. Each group's signature names the part it
52
- // uses; config, hub and passwordHash change at runtime, so they are read
53
- // through accessors rather than captured.
54
- const deps = {
55
- store: app.store, runs: app.runs, scheduler: app.scheduler, retrieval: app.retrieval,
56
- source: app.source, version: app.version,
57
- config: () => app.cfg, hub: () => app.hub, passwordHash: () => app.passwordHash, launchToken: () => app.launchToken,
58
- saveConfig: (patch, by) => app.saveConfig(patch, by), refreshConfig: () => app.refreshConfig(),
59
- auditMcp: (by, before) => app.auditMcp(by, before), swapHub: (servers) => app.swapHub(servers),
60
- multiUser: () => app.multiUser(), modelFor: (userId, role) => app.modelFor(userId, role),
61
- instructionsFor: (userId) => app.instructionsFor(userId),
62
- toolsFor: (features) => app.toolsFor(features), toolsView: () => app.toolsView(), configFor: (auth) => app.configFor(auth)
63
- };
64
- return [
65
- ...authRoutes(deps), ...settingsRoutes(deps), ...mcpRoutes(deps), ...libraryRoutes(deps),
66
- ...automationRoutes(deps), ...documentRoutes(deps), ...chatRoutes(deps), ...adminRoutes(deps)
67
- ];
46
+ // What route groups may ask for. Each group's signature names the part it
47
+ // uses; config and hub change at runtime, so they are read through
48
+ // accessors rather than captured.
49
+ const deps = {
50
+ store: app.store, runs: app.runs, scheduler: app.scheduler, retrieval: app.retrieval,
51
+ source: app.source, version: app.version,
52
+ config: () => app.cfg, hub: () => app.hub,
53
+ saveConfig: (patch, by) => app.saveConfig(patch, by), refreshConfig: () => app.refreshConfig(),
54
+ auditMcp: (by, before) => app.auditMcp(by, before), swapHub: (servers) => app.swapHub(servers),
55
+ multiUser: () => app.multiUser(), modelFor: (userId, role) => app.modelFor(userId, role),
56
+ instructionsFor: (userId) => app.instructionsFor(userId),
57
+ toolsFor: (features) => app.toolsFor(features), toolsView: () => app.toolsView(), configFor: (auth) => app.configFor(auth)
58
+ };
59
+ return [
60
+ ...authRoutes(deps), ...settingsRoutes(deps), ...mcpRoutes(deps), ...libraryRoutes(deps),
61
+ ...automationRoutes(deps), ...documentRoutes(deps), ...chatRoutes(deps), ...adminRoutes(deps)
62
+ ];
68
63
  }
69
-
70
64
  // Handlers only read their dependencies when called, so a stub lists the table.
71
65
  export const API_ROUTES = routeTable({}).map((r) => [r.method, r.path, r.feature]);
72
-
73
66
  // Fields a non-admin never sees: credentials, and where the deployment's
74
67
  // trust boundary sits.
75
68
  const ADMIN_ONLY_FIELDS = /^(apiKey|keyHint|connectors|authPassword|sessionSecret|google|baseUrl|trusted|adminEmails|models$)/;
76
-
77
69
  const LOOPBACK_BINDS = ['127.0.0.1', '::1', 'localhost'];
78
-
79
- // 'single': the stored hash, or one made in memory from $TINYWEBUI_PASSWORD
80
- // (handy for containers); null means sign-in is by launch link only.
81
- const ownerHash = (cfg) => cfg.authPassword || (process.env.TINYWEBUI_PASSWORD ? hashPassword(process.env.TINYWEBUI_PASSWORD) : null);
82
-
83
70
  /**
84
71
  * Starts an instance. Everything but port/host is optional and passed to
85
72
  * createConfigSource: `config` (keys set here win and are locked in the UI),
86
73
  * `configFile` (a path, or false for none), `mcpServers`, `mcpFile`, `dbPath`.
87
74
  */
88
75
  export async function start({ port = 7777, host = '127.0.0.1', ...sourceOpts } = {}) {
89
- const source = createConfigSource(sourceOpts);
90
- const initial = source.load();
91
- const problems = configProblems(initial);
92
- if (problems.length) throw new Error(problems.join('\n'));
93
- const loopback = LOOPBACK_BINDS.includes(host);
94
- if (initial.authMode === 'none' && !loopback) {
95
- throw new Error("authMode 'none' is limited to loopback; use password login or a trusted-header gateway for network access");
96
- }
97
- if (initial.authMode === 'single' && !ownerHash(initial) && !loopback) {
98
- throw new Error("authMode 'single' needs a password on a network bind: run `tinywebui set-password` or set $TINYWEBUI_PASSWORD");
99
- }
100
-
101
- const store = new Store(source.dbPath(initial), { migrate: initial.autoMigrate !== false });
102
- // Runs live in this process, so nothing from before it can still be waiting.
103
- store.chats.expireQuestions();
104
- if (store.migratedFrom !== null && store.migratedFrom < SCHEMA_VERSION) {
105
- log.info(`migrated database from schema ${store.migratedFrom} to ${SCHEMA_VERSION}`);
106
- }
107
- if (initial.authMode === 'trusted-header') applyAccessPolicy(store, initial);
108
-
109
- // The embedding bundle loads now, so a problem with it stops startup with a
110
- // clear message instead of surfacing on the first search -- except under
111
- // 'auto', where a model that simply isn't installed means lexical.
112
- let resolved;
113
- try {
114
- const load = sourceOpts.embedder ? async () => sourceOpts.embedder : loadEmbedder;
115
- resolved = await resolveRetrieval(initial.retrieval, defaultModelsDir(source.path()), load);
116
- } catch (err) { store.close(); throw err; }
117
- const { embedder } = resolved;
118
- if (resolved.fallback) log.warn(`retrieval: lexical only -- ${resolved.fallback}`);
119
- else if (embedder) log.info(`retrieval: ${resolved.mode} with ${embedder.spec?.repoId || initial.retrieval.model} (${embedder.dim} dims)`);
120
-
121
- if (initial.authMode === 'single') store.users.ensureOwner(OWNER_ID);
122
-
123
- /**
124
- * What every part of the server shares. `cfg` and `hub` are replaced, not
125
- * mutated, when settings are saved or files reload, so read them from here
126
- * at use time rather than holding on to them.
127
- */
128
- const app = {
129
- version: VERSION,
130
- source,
131
- store,
132
- retrieval: new Retrieval(store, { ...initial.retrieval, mode: resolved.mode }, embedder),
133
- cfg: initial,
134
- hub: null,
135
- passwordHash: initial.authMode === 'single' ? ownerHash(initial) : null,
136
- // A passwordless loopback install signs in with this instead: printed as a
137
- // link at startup, so only someone who can read this terminal gets in.
138
- // Made whenever it could be needed, so a reload that drops the password
139
- // still leaves a way in.
140
- launchToken: initial.authMode === 'single' && loopback ? randomBytes(32).toString('hex') : null,
141
-
142
- // Every UI/API change lands in config.json (source.save) and is audited:
143
- // who, which keys, the new values (none of these keys are secrets), and the
144
- // config fingerprint before and after, to line up with a git diff.
145
- saveConfig(patch, by) {
146
- const before = fingerprint(app.cfg, source.loadMcpServers());
147
- app.cfg = source.save(patch);
148
- audit('config.changed', { by: by ?? 'local', keys: Object.keys(patch), values: redactSecrets(patch), before, after: fingerprint(app.cfg, source.loadMcpServers()) });
149
- return app.cfg;
150
- },
151
- /** Re-reads the config after something other than saveConfig wrote the file. */
152
- refreshConfig() {
153
- app.cfg = source.load();
154
- },
155
- // `change` (action and server name, never a value) says what the assistant did.
156
- auditMcp: (by, before, change) => audit('mcp.changed', { by: by ?? 'local', ...(change ? { change } : {}), before, after: fingerprint(app.cfg, source.loadMcpServers()) }),
157
-
158
- /** What manage_mcp may do. It works on the file, not the live hub, so it sees what the next connect will. */
159
- mcpEdit: {
160
- locked: () => source.mcpLocked,
161
- servers() {
162
- let parsed;
163
- try { parsed = JSON.parse(source.readMcpFile()); }
164
- catch (err) { throw new Error(`mcp.json is not valid JSON (${err.message}); ask the user to fix it before anything is changed`); }
165
- return structuredClone(parsed.mcpServers || parsed);
166
- },
167
- save(servers, { by, change }) {
168
- const before = fingerprint(app.cfg, source.loadMcpServers());
169
- source.saveMcpFile(JSON.stringify({ mcpServers: servers }, null, 2));
170
- app.auditMcp(by, before, change);
171
- // Noted now so the file watcher takes this write for our own. The
172
- // reconnect waits for the reply to finish (onRunIdle): swapping now
173
- // would close the servers the running turn is still calling.
174
- mcpText = source.readMcpFile();
175
- mcpDirty = true;
176
- },
177
- status: () => Object.fromEntries(app.hub.inventory(app.cfg.disabledTools).servers
178
- .map((s) => [s.name, { status: s.status, error: s.error, tools: s.tools.length }])),
179
- check: (spec) => missingCommand(spec)?.message ?? null
180
- },
181
-
182
- multiUser: () => app.cfg.authMode === 'trusted-header',
183
-
76
+ const source = createConfigSource(sourceOpts);
77
+ const initial = source.load();
78
+ const problems = configProblems(initial);
79
+ if (problems.length)
80
+ throw new Error(problems.join('\n'));
81
+ if (initial.authMode === 'local' && !LOOPBACK_BINDS.includes(host)) {
82
+ throw new Error("authMode 'local' is limited to loopback; use multiuser or a trusted-header gateway for network access");
83
+ }
84
+ const store = new Store(source.dbPath(initial), { migrate: initial.autoMigrate !== false });
85
+ // Runs live in this process, so nothing from before it can still be waiting.
86
+ store.chats.expireQuestions();
87
+ if (store.migratedFrom !== null && store.migratedFrom < SCHEMA_VERSION) {
88
+ log.info(`migrated database from schema ${store.migratedFrom} to ${SCHEMA_VERSION}`);
89
+ }
90
+ if (initial.authMode === 'trusted-header')
91
+ applyAccessPolicy(store, initial);
92
+ // The embedding bundle loads now, so a problem with it stops startup with a
93
+ // clear message instead of surfacing on the first search -- except under
94
+ // 'auto', where a model that simply isn't installed means lexical.
95
+ let resolved;
96
+ try {
97
+ const load = sourceOpts.embedder ? async () => sourceOpts.embedder : loadEmbedder;
98
+ resolved = await resolveRetrieval(initial.retrieval, defaultModelsDir(source.path()), load);
99
+ }
100
+ catch (err) {
101
+ store.close();
102
+ throw err;
103
+ }
104
+ const { embedder } = resolved;
105
+ if (resolved.fallback)
106
+ log.warn(`retrieval: lexical only -- ${resolved.fallback}`);
107
+ else if (embedder)
108
+ log.info(`retrieval: ${resolved.mode} with ${embedder.spec?.repoId || initial.retrieval.model} (${embedder.dim} dims)`);
184
109
  /**
185
- * The model a person's turns use. Tiers 1-2: the configured model. Tier 3:
186
- * their own pick if their role still allows it, else the configured model
187
- * if allowed, else the first model their role allows.
110
+ * What every part of the server shares. `cfg` and `hub` are replaced, not
111
+ * mutated, when settings are saved or files reload, so read them from here
112
+ * at use time rather than holding on to them.
188
113
  */
189
- modelFor(userId, role) {
190
- const { cfg } = app;
191
- // With a catalog, everything is an entry id; a provider id (as the
192
- // default `model` may be) is resolved to its entry.
193
- const known = (m) => (isClosed(cfg) ? findEntry(cfg, m)?.id : m);
194
- const fallback = known(cfg.model) || cfg.model;
195
- if (cfg.authMode !== 'trusted-header' || !userId) return fallback;
196
- const allowed = modelsFor(cfg, role);
197
- const ok = (m) => m && (allowed === '*' || allowed.includes(m));
198
- const pref = known(store.users.getPrefs(userId).model);
199
- if (ok(pref)) return pref;
200
- if (ok(fallback)) return fallback;
201
- // First of the role's models still enabled (a parked one never runs).
202
- return allowed.map(known).find(Boolean) || fallback;
203
- },
204
-
205
- /** A person's own instructions, added after the system prompt, when an admin lets users set them. */
206
- instructionsFor(userId) {
207
- if (!userId || app.cfg.authMode !== 'trusted-header') return null;
208
- if (!resolveAccess(app.cfg).customize.includes('instructions')) return null;
209
- return store.users.getPrefs(userId).instructions || null;
210
- },
211
-
212
- /** The tools a turn gets: what is switched on, minus manage_automation for
213
- * anyone whose role has no automations. */
214
- toolsFor: (features) => app.hub.activeTools(app.cfg.disabledTools)
215
- .filter((t) => features.has(FEATURE.AUTOMATIONS) || t.function.name !== 'manage_automation')
216
- .filter((t) => features.has(FEATURE.MCP) || t.function.name !== MANAGE_MCP),
217
-
218
- /** A stored owner's features: null (tiers 1-2) is the one person. */
219
- ownerFeatures: (userId) => featuresFor(app.cfg, userId ? store.users.get(userId)?.role : null),
220
-
221
- /** The tools panel payload: inventory plus each tool's approval state. */
222
- toolsView() {
223
- const { cfg, hub } = app;
224
- const inv = hub.inventory(cfg.disabledTools);
225
- const mark = (t) => ({ ...t, approval: overrideFor(cfg, t.name) });
226
- return {
227
- // Built-ins never ask, so they carry no approval state to show.
228
- internal: inv.internal,
229
- servers: inv.servers.map((s) => ({ ...s, tools: s.tools.map(mark) })),
230
- disabledTools: cfg.disabledTools || [],
231
- toolApproval: cfg.toolApproval
232
- };
233
- },
234
-
235
- configFor(auth) {
236
- const pub = source.public(app.cfg);
237
- if (auth.features.has(FEATURE.SETTINGS) && auth.isAdmin) return pub;
238
- if (auth.features.has(FEATURE.SETTINGS)) {
239
- // Allowed to edit settings, but not to see where the deployment's keys point.
240
- const out = {};
241
- for (const [k, v] of Object.entries(pub)) if (!ADMIN_ONLY_FIELDS.test(k)) out[k] = v;
242
- return out;
243
- }
244
- const out = {};
245
- for (const [k, v] of Object.entries(pub)) if (!ADMIN_ONLY_FIELDS.test(k)) out[k] = v;
246
- return { ...out, readOnly: true };
247
- },
248
-
249
- /** Replaces the hub wholesale: the old one's child processes are shut
250
- * down after the new one is up, so a rename cannot leave an orphan. */
251
- swapHub(servers) {
252
- // Recorded now, not once connected: the file watcher fires 300 ms after
253
- // our own save, and a slow connect would make that echo look like an
254
- // outside edit and start a second swap.
255
- mcpText = source.readMcpFile();
256
- // One swap at a time: two in flight would both close the same old hub
257
- // and leave the first new one, and its child processes, running.
258
- const run = swapping.then(async () => {
259
- if (stopping) return;
260
- const old = app.hub;
261
- app.hub = await connectHub(servers);
262
- await old.close();
263
- });
264
- swapping = run.catch(() => {});
265
- return run;
266
- },
267
-
268
- onRunIdle: (chatId) => {
269
- app.scheduler.drain(chatId);
270
- // manage_mcp saved mcp.json during that run. Re-read the file rather than
271
- // reuse what it saved: someone may have edited it since.
272
- if (mcpDirty) {
273
- mcpDirty = false;
274
- app.swapHub(source.loadMcpServers()).catch((err) => log.error(`mcp reconnect failed: ${err.message}`));
275
- }
276
- }
277
- };
278
- app.runs = createRuns(app);
279
- app.scheduler = createScheduler(app);
280
- const { retrieval, runs, scheduler } = app;
281
-
282
- // The built-in tools are ours, not an MCP server's, but the model should not
283
- // be able to tell: they are registered onto the same hub and called the same way.
284
- const connectHub = async (servers) =>
285
- (await new McpHub(servers).connect())
286
- .registerLocal(expandToolDef(), (args, ctx) => callExpand(args, { ...ctx, store }), { readOnly: true })
287
- .registerLocal(documentToolDef(), (args, ctx) => callReadDocument(args, { ...ctx, store, retrieval }), { readOnly: true })
288
- .registerLocal(chatSearchToolDef(), (args, ctx) => callSearchChats(args, { ...ctx, store, retrieval }), { readOnly: true })
289
- // Changes nothing, so it never waits for approval; never alongside
290
- // other calls, so a question cannot race a write it is asking about.
291
- .registerLocal(askToolDef(), callAskUser, { readOnly: true, idempotent: false, executionMode: 'sequential' })
292
- .registerLocal(taskToolDef(), (args, ctx) => callManageTasks(args, {
114
+ const app = {
115
+ version: VERSION,
116
+ source,
117
+ store,
118
+ retrieval: new Retrieval(store, { ...initial.retrieval, mode: resolved.mode }, embedder),
119
+ cfg: initial,
120
+ // Set by connectHub below, before the server listens.
121
+ hub: null,
122
+ // Every UI/API change lands in config.json (source.save) and is audited:
123
+ // who, which keys, the new values (none of these keys are secrets), and the
124
+ // config fingerprint before and after, to line up with a git diff.
125
+ saveConfig(patch, by) {
126
+ const before = fingerprint(app.cfg, source.loadMcpServers());
127
+ app.cfg = source.save(patch);
128
+ audit('config.changed', { by: by ?? 'local', keys: Object.keys(patch), values: redactSecrets(patch), before, after: fingerprint(app.cfg, source.loadMcpServers()) });
129
+ return app.cfg;
130
+ },
131
+ /** Re-reads the config after something other than saveConfig wrote the file. */
132
+ refreshConfig() {
133
+ app.cfg = source.load();
134
+ },
135
+ // `change` (action and server name, never a value) says what the assistant did.
136
+ auditMcp: (by, before, change) => audit('mcp.changed', { by: by ?? 'local', ...(change ? { change } : {}), before, after: fingerprint(app.cfg, source.loadMcpServers()) }),
137
+ /** What manage_mcp may do. It works on the file, not the live hub, so it sees what the next connect will. */
138
+ mcpEdit: {
139
+ locked: () => source.mcpLocked,
140
+ servers() {
141
+ let parsed;
142
+ try {
143
+ parsed = JSON.parse(source.readMcpFile());
144
+ }
145
+ catch (err) {
146
+ throw new Error(`mcp.json is not valid JSON (${err.message}); ask the user to fix it before anything is changed`);
147
+ }
148
+ return structuredClone(parsed.mcpServers || parsed);
149
+ },
150
+ save(servers, { by, change }) {
151
+ const before = fingerprint(app.cfg, source.loadMcpServers());
152
+ source.saveMcpFile(JSON.stringify({ mcpServers: servers }, null, 2));
153
+ app.auditMcp(by, before, change);
154
+ // Noted now so the file watcher takes this write for our own. The
155
+ // reconnect waits for the reply to finish (onRunIdle): swapping now
156
+ // would close the servers the running turn is still calling.
157
+ mcpText = source.readMcpFile();
158
+ mcpDirty = true;
159
+ },
160
+ status: () => Object.fromEntries(app.hub.inventory(app.cfg.disabledTools).servers
161
+ .map((s) => [s.name, { status: s.status, error: s.error, tools: s.tools.length }])),
162
+ check: (spec) => missingCommand(spec)?.message ?? null
163
+ },
164
+ multiUser: () => ['multiuser', 'trusted-header'].includes(app.cfg.authMode),
165
+ /**
166
+ * The model a person's turns use. Tiers 1-2: the configured model. Tier 3:
167
+ * their own pick if their role still allows it, else the configured model
168
+ * if allowed, else the first model their role allows.
169
+ */
170
+ modelFor(userId, role) {
171
+ const { cfg } = app;
172
+ // With a catalog, everything is an entry id; a provider id (as the
173
+ // default `model` may be) is resolved to its entry.
174
+ const known = (m) => (isClosed(cfg) ? findEntry(cfg, m)?.id : m);
175
+ const fallback = known(cfg.model) || cfg.model;
176
+ if (!app.multiUser() || !userId)
177
+ return fallback;
178
+ const allowed = modelsFor(cfg, role);
179
+ const ok = (m) => Boolean(m && (allowed === '*' || allowed.includes(m)));
180
+ const pref = known(store.users.getPrefs(userId).model);
181
+ if (ok(pref))
182
+ return pref;
183
+ if (ok(fallback))
184
+ return fallback;
185
+ // Every model allowed and none set up yet: there is nothing to pick.
186
+ if (allowed === '*')
187
+ return fallback;
188
+ // First of the role's models still enabled (a parked one never runs).
189
+ return allowed.map(known).find(Boolean) || fallback;
190
+ },
191
+ /** A person's own instructions, added after the system prompt, when an admin lets users set them. */
192
+ instructionsFor(userId) {
193
+ if (!userId || !app.multiUser())
194
+ return null;
195
+ if (!resolveAccess(app.cfg).customize.includes('instructions'))
196
+ return null;
197
+ return store.users.getPrefs(userId).instructions || null;
198
+ },
199
+ /** The tools a turn gets: what is switched on, minus manage_automation for
200
+ * anyone whose role has no automations. */
201
+ toolsFor: (features) => app.hub.activeTools(app.cfg.disabledTools)
202
+ .filter((t) => features.has(FEATURE.AUTOMATIONS) || t.function.name !== 'manage_automation')
203
+ .filter((t) => features.has(FEATURE.MCP) || t.function.name !== MANAGE_MCP),
204
+ /** A stored owner's features: null (tiers 1-2) is the one person. */
205
+ ownerFeatures: (userId) => featuresFor(app.cfg, userId ? store.users.get(userId)?.role : null),
206
+ /** The tools panel payload: inventory plus each tool's approval state. */
207
+ toolsView() {
208
+ const { cfg, hub } = app;
209
+ const inv = hub.inventory(cfg.disabledTools);
210
+ const mark = (t) => ({ ...t, approval: overrideFor(cfg, t.name) });
211
+ return {
212
+ // Built-ins never ask, so they carry no approval state to show.
213
+ internal: inv.internal,
214
+ servers: inv.servers.map((s) => ({ ...s, tools: s.tools.map(mark) })),
215
+ disabledTools: cfg.disabledTools || [],
216
+ toolApproval: cfg.toolApproval
217
+ };
218
+ },
219
+ configFor(auth) {
220
+ const pub = source.public(app.cfg);
221
+ if (auth.features.has(FEATURE.SETTINGS) && auth.isAdmin)
222
+ return pub;
223
+ if (auth.features.has(FEATURE.SETTINGS)) {
224
+ // Allowed to edit settings, but not to see where the deployment's keys point.
225
+ const out = {};
226
+ for (const [k, v] of Object.entries(pub))
227
+ if (!ADMIN_ONLY_FIELDS.test(k))
228
+ out[k] = v;
229
+ return out;
230
+ }
231
+ const out = {};
232
+ for (const [k, v] of Object.entries(pub))
233
+ if (!ADMIN_ONLY_FIELDS.test(k))
234
+ out[k] = v;
235
+ return { ...out, readOnly: true };
236
+ },
237
+ /** Replaces the hub wholesale: the old one's child processes are shut
238
+ * down after the new one is up, so a rename cannot leave an orphan. */
239
+ swapHub(servers) {
240
+ // Recorded now, not once connected: the file watcher fires 300 ms after
241
+ // our own save, and a slow connect would make that echo look like an
242
+ // outside edit and start a second swap.
243
+ mcpText = source.readMcpFile();
244
+ // One swap at a time: two in flight would both close the same old hub
245
+ // and leave the first new one, and its child processes, running.
246
+ const run = swapping.then(async () => {
247
+ if (stopping)
248
+ return;
249
+ const old = app.hub;
250
+ app.hub = await connectHub(servers);
251
+ await old.close();
252
+ });
253
+ swapping = run.catch(() => { });
254
+ return run;
255
+ },
256
+ onRunIdle: (chatId) => {
257
+ app.scheduler.drain(chatId);
258
+ // manage_mcp saved mcp.json during that run. Re-read the file rather than
259
+ // reuse what it saved: someone may have edited it since.
260
+ if (mcpDirty) {
261
+ mcpDirty = false;
262
+ app.swapHub(source.loadMcpServers()).catch((err) => log.error(`mcp reconnect failed: ${err.message}`));
263
+ }
264
+ }
265
+ };
266
+ app.runs = createRuns(app);
267
+ app.scheduler = createScheduler(app);
268
+ const { retrieval, runs, scheduler } = app;
269
+ // The built-in tools are ours, not an MCP server's, but the model should not
270
+ // be able to tell: they are registered onto the same hub and called the same way.
271
+ const connectHub = async (servers) => (await new McpHub(servers).connect())
272
+ .registerLocal(expandToolDef(), (args, ctx) => callExpand(args, { ...ctx, store }), { readOnly: true })
273
+ .registerLocal(documentToolDef(), (args, ctx) => callReadDocument(args, { ...ctx, store, retrieval }), { readOnly: true })
274
+ .registerLocal(chatSearchToolDef(), (args, ctx) => callSearchChats(args, { ...ctx, store, retrieval }), { readOnly: true })
275
+ // Changes nothing, so it never waits for approval; never alongside
276
+ // other calls, so a question cannot race a write it is asking about.
277
+ .registerLocal(askToolDef(), callAskUser, { readOnly: true, idempotent: false, executionMode: 'sequential' })
278
+ .registerLocal(taskToolDef(), (args, ctx) => callManageTasks(args, {
293
279
  ...ctx, store, onChange: () => runs.get(ctx.chatId)?.emit({ type: EVENT.TASKS, tasks: store.chats.visibleTasks(ctx.chatId) })
294
- }), { executionMode: 'sequential' })
295
- .registerLocal(automationToolDef(), (args, ctx) => {
280
+ }), { executionMode: 'sequential' })
281
+ .registerLocal(automationToolDef(), (args, ctx) => {
296
282
  const out = manageAutomation(args, { ...ctx, store, triggerAutomation: scheduler.trigger });
297
283
  scheduler.arm();
298
284
  return out;
299
- })
300
- // Unlike the tools above it asks before anything but "list": a local
301
- // server is a command run on this machine, and mcp.json holds credentials.
302
- .registerLocal(mcpToolDef(), (args, ctx) => callManageMcp(args, { ...ctx, mcp: app.mcpEdit }), {
285
+ })
286
+ // Unlike the tools above it asks before anything but "list": a local
287
+ // server is a command run on this machine, and mcp.json holds credentials.
288
+ .registerLocal(mcpToolDef(), (args, ctx) => callManageMcp(args, { ...ctx, mcp: app.mcpEdit }), {
303
289
  readOnly: (args) => args?.action === 'list',
304
290
  gated: (args) => args?.action !== 'list',
305
291
  executionMode: 'sequential'
306
- });
307
-
308
- app.hub = await connectHub(source.loadMcpServers());
309
- // What mcp.json said when the hub was last (re)built, so a reload can tell
310
- // a real edit from the echo of the UI's own save.
311
- let mcpText = source.readMcpFile();
312
- // Set when manage_mcp has saved mcp.json and the hub still has to follow.
313
- let mcpDirty = false;
314
- // The hub swap in progress, if any, and whether shutdown has begun.
315
- let swapping = Promise.resolve();
316
- let stopping = false;
317
- const serversOf = (text) => { try { const p = JSON.parse(text); return p.mcpServers || p; } catch { return {}; } };
318
-
319
- log.info(`config: ${source.path() || '(in code, no file)'}`);
320
- log.info(`mcp: ${source.mcpPath() || '(in code)'}`);
321
- log.info(`db: ${source.dbPath(app.cfg)}`);
322
- log.info(`model: ${app.cfg.model} via ${app.cfg.baseUrl}`);
323
- log.info(`tools: ${app.hub.tools.length} from ${app.hub.clients.size} MCP server(s)`);
324
- // Repeated after the banner, grouped, so it is the last thing a first run shows.
325
- const offFor = Map.groupBy(app.hub.install, ([, need]) => need.url);
326
- for (const [url, entries] of offFor) {
327
- log.warn(`${entries.map(([name]) => name).join(', ')} off until ${entries[0][1].name} is installed: ${url}`);
328
- }
329
- log.info(`policy: ${fingerprint(app.cfg, source.loadMcpServers())} (fingerprint)`);
330
-
331
- const routes = routeTable(app);
332
-
333
- const server = createServer(async (req, res) => {
334
- try {
335
- for (const [k, v] of Object.entries(SECURITY_HEADERS)) res.setHeader(k, v);
336
- const url = new URL(req.url || '/', 'http://x');
337
- const { pathname } = url;
338
- if (badHost(req, app.cfg)) {
339
- audit('request.bad_host_rejected', { host: String(req.headers.host).slice(0, 100), path: pathname });
340
- return json(res, 403, { error: 'unknown host' });
341
- }
342
- if (crossSite(req, app.cfg)) {
343
- audit('request.cross_site_rejected', { method: req.method, path: pathname });
344
- return json(res, 403, { error: 'cross-site request refused' });
345
- }
346
- // Probes for orchestrators: no auth, nothing sensitive. /healthz says
347
- // the process answers; /readyz that it can serve (database reachable),
348
- // and which build and config it serves.
349
- if (req.method === 'GET' && pathname === '/healthz') return json(res, 200, { ok: true });
350
- if (req.method === 'GET' && pathname === '/readyz') {
351
- let dbOk = false;
352
- try { dbOk = store.ping(); } catch { /* not ready */ }
353
- return json(res, dbOk ? 200 : 503, { ready: dbOk, version: VERSION, fingerprint: fingerprint(app.cfg, source.loadMcpServers()) });
354
- }
355
- const auth = resolveAuth(req, pathname, app.cfg, store);
356
- if (auth.notFound) return json(res, 404, { error: 'not found' });
357
- if (auth.unauthorized) return json(res, 401, { error: 'unauthorized' });
358
- if (auth.disabled) return json(res, 403, { error: 'disabled' });
359
- if (auth.pending) return json(res, 403, { error: 'pending_approval' });
360
-
361
- if (!pathname.startsWith('/api/')) return serveStatic(req, res);
362
- for (const route of routes) {
363
- if (route.method !== req.method) continue;
364
- const match = route.path.exec(pathname);
365
- if (!match) continue;
366
- if (route.feature && !auth.features.has(route.feature)) return json(res, 403, { error: 'feature_disabled', feature: route.feature });
367
- return await route.handle({ req, res, auth, url, params: match.slice(1) });
368
- }
369
- return json(res, 404, { error: 'not found' });
370
- } catch (err) {
371
- if (err instanceof HttpError && err.status === 413) req.resume?.();
372
- if (!res.headersSent) json(res, err instanceof HttpError ? err.status : err instanceof LockedError ? 409 : 500, { error: err.message });
373
- else res.end();
292
+ });
293
+ app.hub = await connectHub(source.loadMcpServers());
294
+ // What mcp.json said when the hub was last (re)built, so a reload can tell
295
+ // a real edit from the echo of the UI's own save.
296
+ let mcpText = source.readMcpFile();
297
+ // Set when manage_mcp has saved mcp.json and the hub still has to follow.
298
+ let mcpDirty = false;
299
+ // The hub swap in progress, if any, and whether shutdown has begun.
300
+ let swapping = Promise.resolve();
301
+ let stopping = false;
302
+ const serversOf = (text) => { try {
303
+ const p = JSON.parse(text);
304
+ return p.mcpServers || p;
374
305
  }
375
- });
376
-
377
- await new Promise((resolve) => server.listen(port, host, resolve));
378
- scheduler.arm();
379
- const origin = `http://${host.includes(':') ? `[${host}]` : host}:${server.address().port}`;
380
- // In the fragment, so it never reaches a server log or a Referer header.
381
- app.launchUrl = app.launchToken ? `${origin}/#token=${app.launchToken}` : origin;
382
- log.info(app.launchToken && !app.passwordHash ? `sign in: ${app.launchUrl}` : origin);
383
- // Anything stored before dense retrieval was on (or under another model)
384
- // is embedded in the background; document queries fill in on demand too.
385
- retrieval.backfill((msg) => log.info(`retrieval: ${msg}`))
386
- .catch((err) => log.error(`retrieval backfill failed: ${err.message}`));
387
-
388
- /**
389
- * Re-reads config.json and mcp.json after a hand or agent edit. All or
390
- * nothing: a file that fails to parse or validate is reported and the
391
- * running config stays exactly as it was. tinywebui.config.js is code and
392
- * is only read at startup, as is authMode (switching it live would strand
393
- * sessions and half the auth state).
394
- */
395
- async function reloadFromFiles(reason) {
396
- let next, servers, text;
397
- try {
398
- next = source.load();
399
- text = source.readMcpFile();
400
- const parsed = JSON.parse(text);
401
- servers = parsed.mcpServers || parsed;
402
- for (const [name, spec] of Object.entries(servers)) {
403
- if (!spec || (!spec.command && !spec.url)) throw new Error(`mcp server "${name}" needs either "command" or "url"`);
404
- }
405
- } catch (err) {
406
- log.warn(`reload rejected (${reason}): ${err.message}`);
407
- audit('config.reload_rejected', { reason, problems: [err.message] });
408
- return false;
409
- }
410
- const problems = configProblems(next);
411
- if (next.authMode !== app.cfg.authMode) problems.push('authMode changes need a restart');
412
- if (JSON.stringify(next.retrieval) !== JSON.stringify(app.cfg.retrieval)) problems.push('retrieval changes need a restart');
413
- if (problems.length) {
414
- log.warn(`reload rejected (${reason}):\n ${problems.join('\n ')}`);
415
- audit('config.reload_rejected', { reason, problems });
416
- return false;
306
+ catch {
307
+ return {};
308
+ } };
309
+ log.info(`config: ${source.path() || '(in code, no file)'}`);
310
+ log.info(`keys: ${source.secretsPath() || '(in memory)'}`);
311
+ log.info(`mcp: ${source.mcpPath() || '(in code)'}`);
312
+ log.info(`db: ${source.dbPath(app.cfg)}`);
313
+ const via = effectiveConfig(app.cfg).baseUrl;
314
+ log.info(`model: ${app.cfg.model ? `${app.cfg.model} via ${via || '(no endpoint)'}` : 'none yet: add a connector and its models in Settings'}`);
315
+ log.info(`tools: ${app.hub.tools.length} from ${app.hub.clients.size} MCP server(s)`);
316
+ // Repeated after the banner, grouped, so it is the last thing a first run shows.
317
+ const offFor = Map.groupBy(app.hub.install, ([, need]) => need.url);
318
+ for (const [url, entries] of offFor) {
319
+ log.warn(`${entries.map(([name]) => name).join(', ')} off until ${entries[0][1].name} is installed: ${url}`);
417
320
  }
418
- const before = fingerprint(app.cfg, serversOf(mcpText));
419
- const after = fingerprint(next, servers);
420
- const mcpChanged = text !== mcpText;
421
- if (before === after && !mcpChanged) return true; // our own write, or a no-op save
422
- app.cfg = next;
423
- if (next.authMode === 'single') {
424
- app.passwordHash = ownerHash(next);
425
- if (!app.passwordHash) log.info(`sign in: ${app.launchUrl}`);
426
- }
427
- if (next.authMode === 'trusted-header') applyAccessPolicy(store, next);
428
- if (mcpChanged) await app.swapHub(servers);
321
+ log.info(`policy: ${fingerprint(app.cfg, source.loadMcpServers())} (fingerprint)`);
322
+ const routes = routeTable(app);
323
+ const server = createServer(async (req, res) => {
324
+ try {
325
+ for (const [k, v] of Object.entries(SECURITY_HEADERS))
326
+ res.setHeader(k, v);
327
+ const url = new URL(req.url || '/', 'http://x');
328
+ const { pathname } = url;
329
+ if (badHost(req, app.cfg)) {
330
+ audit('request.bad_host_rejected', { host: String(req.headers.host).slice(0, 100), path: pathname });
331
+ return json(res, 403, { error: 'unknown host' });
332
+ }
333
+ if (crossSite(req, app.cfg)) {
334
+ audit('request.cross_site_rejected', { method: req.method, path: pathname });
335
+ return json(res, 403, { error: 'cross-site request refused' });
336
+ }
337
+ // Probes for orchestrators: no auth, nothing sensitive. /healthz says
338
+ // the process answers; /readyz that it can serve (database reachable),
339
+ // and which build and config it serves.
340
+ if (req.method === 'GET' && pathname === '/healthz')
341
+ return json(res, 200, { ok: true });
342
+ if (req.method === 'GET' && pathname === '/readyz') {
343
+ let dbOk = false;
344
+ try {
345
+ dbOk = store.ping();
346
+ }
347
+ catch { /* not ready */ }
348
+ return json(res, dbOk ? 200 : 503, { ready: dbOk, version: VERSION, fingerprint: fingerprint(app.cfg, source.loadMcpServers()) });
349
+ }
350
+ const auth = resolveAuth(req, pathname, app.cfg, store);
351
+ if (auth.notFound)
352
+ return json(res, 404, { error: 'not found' });
353
+ if (auth.unauthorized)
354
+ return json(res, 401, { error: 'unauthorized' });
355
+ if (auth.disabled)
356
+ return json(res, 403, { error: 'disabled' });
357
+ if (auth.pending)
358
+ return json(res, 403, { error: 'pending_approval' });
359
+ if (!pathname.startsWith('/api/'))
360
+ return serveStatic(req, res);
361
+ for (const route of routes) {
362
+ if (route.method !== req.method)
363
+ continue;
364
+ const match = route.path.exec(pathname);
365
+ if (!match)
366
+ continue;
367
+ const ctx = { req, res, url, params: match.slice(1) };
368
+ if (!route.feature)
369
+ return await route.handle({ ...ctx, auth });
370
+ if (!auth.features.has(route.feature) || !isSignedIn(auth))
371
+ return json(res, 403, { error: 'feature_disabled', feature: route.feature });
372
+ return await route.handle({ ...ctx, auth });
373
+ }
374
+ return json(res, 404, { error: 'not found' });
375
+ }
376
+ catch (err) {
377
+ if (err instanceof HttpError && err.status === 413)
378
+ req.resume?.();
379
+ const status = err instanceof HttpError ? err.status : err instanceof LockedError ? 409 : err instanceof ConfigError ? 400 : 500;
380
+ if (!res.headersSent)
381
+ json(res, status, { error: err.message });
382
+ else
383
+ res.end();
384
+ }
385
+ });
386
+ await new Promise((resolve) => server.listen(port, host, resolve));
429
387
  scheduler.arm();
430
- log.info(`reloaded (${reason}): policy ${after}`);
431
- audit('config.reloaded', { reason, before, after, mcpChanged });
432
- return true;
433
- }
434
-
435
- // Editors often replace a file rather than write into it, which a watch on
436
- // the file itself would lose; watching the folder catches both.
437
- const watchers = [];
438
- let reloadTimer = null;
439
- const scheduleReload = (reason) => {
440
- clearTimeout(reloadTimer);
441
- reloadTimer = setTimeout(() => { reloadFromFiles(reason).catch((err) => log.error(`reload failed: ${err.message}`)); }, 300);
442
- reloadTimer.unref?.();
443
- };
444
- if (sourceOpts.watch !== false) {
445
- const targets = [source.path(), source.mcpPath()].filter(Boolean);
446
- for (const folder of new Set(targets.map((t) => dirname(t)))) {
447
- if (!existsSync(folder)) continue;
448
- const names = new Set(targets.filter((t) => dirname(t) === folder).map((t) => t.slice(folder.length + 1)));
449
- try {
450
- const w = watch(folder, (_, name) => { if (name && names.has(String(name))) scheduleReload('file changed'); });
451
- w.unref?.();
452
- watchers.push(w);
453
- } catch (err) { log.error(`cannot watch ${folder}: ${err.message}`); }
388
+ const origin = `http://${host.includes(':') ? `[${host}]` : host}:${server.address().port}`;
389
+ log.info(origin);
390
+ // Anything stored before dense retrieval was on (or under another model)
391
+ // is embedded in the background; document queries fill in on demand too.
392
+ retrieval.backfill((msg) => log.info(`retrieval: ${msg}`))
393
+ .catch((err) => log.error(`retrieval backfill failed: ${err.message}`));
394
+ /**
395
+ * Re-reads config.json and mcp.json after a hand or agent edit. All or
396
+ * nothing: a file that fails to parse or validate is reported and the
397
+ * running config stays exactly as it was. tinywebui.config.js is code and
398
+ * is only read at startup, as is authMode (switching it live would strand
399
+ * sessions and half the auth state).
400
+ */
401
+ async function reloadFromFiles(reason) {
402
+ let next, servers, text;
403
+ try {
404
+ next = source.load();
405
+ text = source.readMcpFile();
406
+ const parsed = JSON.parse(text);
407
+ servers = parsed.mcpServers || parsed;
408
+ for (const [name, spec] of Object.entries(servers)) {
409
+ if (!spec || (!spec.command && !spec.url))
410
+ throw new Error(`mcp server "${name}" needs either "command" or "url"`);
411
+ }
412
+ }
413
+ catch (err) {
414
+ log.warn(`reload rejected (${reason}): ${err.message}`);
415
+ audit('config.reload_rejected', { reason, problems: [err.message] });
416
+ return false;
417
+ }
418
+ const problems = configProblems(next);
419
+ if (next.authMode !== app.cfg.authMode)
420
+ problems.push('authMode changes need a restart');
421
+ if (JSON.stringify(next.retrieval) !== JSON.stringify(app.cfg.retrieval))
422
+ problems.push('retrieval changes need a restart');
423
+ if (problems.length) {
424
+ log.warn(`reload rejected (${reason}):\n ${problems.join('\n ')}`);
425
+ audit('config.reload_rejected', { reason, problems });
426
+ return false;
427
+ }
428
+ const before = fingerprint(app.cfg, serversOf(mcpText));
429
+ const after = fingerprint(next, servers);
430
+ const mcpChanged = text !== mcpText;
431
+ if (before === after && !mcpChanged)
432
+ return true; // our own write, or a no-op save
433
+ app.cfg = next;
434
+ if (next.authMode === 'trusted-header')
435
+ applyAccessPolicy(store, next);
436
+ if (mcpChanged)
437
+ await app.swapHub(servers);
438
+ scheduler.arm();
439
+ log.info(`reloaded (${reason}): policy ${after}`);
440
+ audit('config.reloaded', { reason, before, after, mcpChanged });
441
+ return true;
442
+ }
443
+ // Editors often replace a file rather than write into it, which a watch on
444
+ // the file itself would lose; watching the folder catches both.
445
+ const watchers = [];
446
+ let reloadTimer;
447
+ const scheduleReload = (reason) => {
448
+ clearTimeout(reloadTimer);
449
+ reloadTimer = setTimeout(() => { reloadFromFiles(reason).catch((err) => log.error(`reload failed: ${err.message}`)); }, 300);
450
+ reloadTimer.unref?.();
451
+ };
452
+ if (sourceOpts.watch !== false) {
453
+ const targets = [source.path(), source.mcpPath()].filter((t) => Boolean(t));
454
+ for (const folder of new Set(targets.map((t) => dirname(t)))) {
455
+ if (!existsSync(folder))
456
+ continue;
457
+ const names = new Set(targets.filter((t) => dirname(t) === folder).map((t) => t.slice(folder.length + 1)));
458
+ try {
459
+ const w = watch(folder, (_, name) => { if (name && names.has(String(name)))
460
+ scheduleReload('file changed'); });
461
+ w.unref?.();
462
+ watchers.push(w);
463
+ }
464
+ catch (err) {
465
+ log.error(`cannot watch ${folder}: ${err.message}`);
466
+ }
467
+ }
454
468
  }
455
- }
456
- const onHup = () => scheduleReload('SIGHUP');
457
- process.on('SIGHUP', onHup);
458
- server.reload = reloadFromFiles;
459
- server.launchUrl = app.launchUrl;
460
-
461
- // Real teardown, reused two ways: on SIGINT/SIGTERM it exits the process, and
462
- // as `server.shutdown()` it does not -- which is what a test harness needs.
463
- const shutdown = async () => {
464
- scheduler.stop();
465
- clearTimeout(reloadTimer);
466
- for (const w of watchers) w.close();
467
- process.off('SIGHUP', onHup);
468
- // A swap still connecting would otherwise finish after this and leave
469
- // its servers running.
470
- stopping = true;
471
- await swapping;
472
- await app.hub.close();
473
- await Promise.allSettled([...retrieval.pending.values()]);
474
- embedder?.close?.();
475
- store.close();
476
- await new Promise((resolve) => server.close(resolve));
477
- };
478
- process.on('SIGINT', () => shutdown().then(() => process.exit(0)));
479
- process.on('SIGTERM', () => shutdown().then(() => process.exit(0)));
480
-
481
- server.shutdown = shutdown;
482
- return server;
469
+ const onHup = () => scheduleReload('SIGHUP');
470
+ process.on('SIGHUP', onHup);
471
+ server.reload = reloadFromFiles;
472
+ // Real teardown, reused two ways: on SIGINT/SIGTERM it exits the process, and
473
+ // as `server.shutdown()` it does not -- which is what a test harness needs.
474
+ const shutdown = async () => {
475
+ scheduler.stop();
476
+ clearTimeout(reloadTimer);
477
+ for (const w of watchers)
478
+ w.close();
479
+ process.off('SIGHUP', onHup);
480
+ // A swap still connecting would otherwise finish after this and leave
481
+ // its servers running.
482
+ stopping = true;
483
+ await swapping;
484
+ await app.hub.close();
485
+ await Promise.allSettled([...retrieval.pending.values()]);
486
+ embedder?.close?.();
487
+ store.close();
488
+ await new Promise((resolve) => server.close(resolve));
489
+ };
490
+ process.on('SIGINT', () => shutdown().then(() => process.exit(0)));
491
+ process.on('SIGTERM', () => shutdown().then(() => process.exit(0)));
492
+ server.shutdown = shutdown;
493
+ return server;
483
494
  }