@mohou/host 1.0.20

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 (108) hide show
  1. package/README.md +10 -0
  2. package/package.json +65 -0
  3. package/src/apps/monogram.ts +15 -0
  4. package/src/apps/registry.ts +238 -0
  5. package/src/apps/startup-seed.ts +95 -0
  6. package/src/bash/client.ts +21 -0
  7. package/src/bash/codes.ts +21 -0
  8. package/src/compile/allowlist.ts +89 -0
  9. package/src/compile/asset-path.ts +48 -0
  10. package/src/compile/autogen.ts +71 -0
  11. package/src/compile/build-vendor-cli.ts +8 -0
  12. package/src/compile/build-vendor.ts +160 -0
  13. package/src/compile/bundle-ui.ts +137 -0
  14. package/src/compile/codes.ts +22 -0
  15. package/src/compile/failure.ts +38 -0
  16. package/src/compile/imports.ts +43 -0
  17. package/src/compile/leave.ts +47 -0
  18. package/src/compile/load-backend.ts +95 -0
  19. package/src/compile/review.ts +342 -0
  20. package/src/compile/runner.ts +205 -0
  21. package/src/compile/sheet.ts +174 -0
  22. package/src/compile/wrapper.ts +297 -0
  23. package/src/credentials/codes.ts +26 -0
  24. package/src/credentials/create.ts +69 -0
  25. package/src/credentials/file.ts +113 -0
  26. package/src/credentials/provider.ts +66 -0
  27. package/src/events/app-events.ts +214 -0
  28. package/src/events/error-ring.ts +109 -0
  29. package/src/events/host-events.ts +39 -0
  30. package/src/events/view-bridge.ts +402 -0
  31. package/src/events/view-eval.ts +213 -0
  32. package/src/files/codes.ts +28 -0
  33. package/src/files/skip.ts +23 -0
  34. package/src/files/tools.ts +215 -0
  35. package/src/history/codes.ts +22 -0
  36. package/src/history/store.ts +287 -0
  37. package/src/host/activity.ts +110 -0
  38. package/src/host/author-mcp.ts +248 -0
  39. package/src/host/author-skill.ts +196 -0
  40. package/src/host/codes.ts +21 -0
  41. package/src/host/config.ts +239 -0
  42. package/src/host/heat.ts +35 -0
  43. package/src/host/layout.ts +81 -0
  44. package/src/host/log.ts +142 -0
  45. package/src/host/mcp-editor.ts +205 -0
  46. package/src/host/mcp-import.ts +141 -0
  47. package/src/host/mcp.ts +109 -0
  48. package/src/host/port.ts +83 -0
  49. package/src/host/session.ts +358 -0
  50. package/src/host/workbench.ts +38 -0
  51. package/src/http/app.ts +46 -0
  52. package/src/http/asset.ts +49 -0
  53. package/src/http/author.ts +157 -0
  54. package/src/http/bind.ts +217 -0
  55. package/src/http/client.ts +175 -0
  56. package/src/http/codes.ts +29 -0
  57. package/src/http/env.ts +3 -0
  58. package/src/http/guard.ts +20 -0
  59. package/src/http/iframe.ts +212 -0
  60. package/src/http/layout.ts +63 -0
  61. package/src/http/owner.ts +380 -0
  62. package/src/http/ports.ts +114 -0
  63. package/src/http/reply.ts +53 -0
  64. package/src/http/route-codes.ts +19 -0
  65. package/src/http/updates.ts +258 -0
  66. package/src/index.ts +201 -0
  67. package/src/install/codes.ts +21 -0
  68. package/src/install/install.ts +230 -0
  69. package/src/install/layout.ts +16 -0
  70. package/src/kernel/bind-brain.ts +117 -0
  71. package/src/kernel/call.ts +134 -0
  72. package/src/kernel/codes.ts +21 -0
  73. package/src/kernel/index.ts +13 -0
  74. package/src/kernel/model-policy.ts +83 -0
  75. package/src/kernel/model-stream.ts +86 -0
  76. package/src/kernel/present.ts +4 -0
  77. package/src/kernel/working-directory.ts +35 -0
  78. package/src/metrics/codes.ts +22 -0
  79. package/src/metrics/read.ts +48 -0
  80. package/src/owner/read.ts +85 -0
  81. package/src/pwsh/client.ts +47 -0
  82. package/src/pwsh/codes.ts +21 -0
  83. package/src/shell/command.ts +172 -0
  84. package/src/storage/codes.ts +33 -0
  85. package/src/storage/layout.ts +44 -0
  86. package/src/storage/open.ts +461 -0
  87. package/src/storage/schema.ts +176 -0
  88. package/src/storage/sql.ts +40 -0
  89. package/src/theme/codes.ts +20 -0
  90. package/src/theme/layout.ts +42 -0
  91. package/src/theme/paint.ts +108 -0
  92. package/src/theme/parse.ts +58 -0
  93. package/src/theme/pin.ts +197 -0
  94. package/src/theme/tokens.ts +30 -0
  95. package/src/tools/author.ts +970 -0
  96. package/src/tools/codes.ts +21 -0
  97. package/src/tools/diagnostics.ts +53 -0
  98. package/src/tools/http.ts +48 -0
  99. package/src/tools/mcp-list.ts +90 -0
  100. package/src/tools/schemas.ts +185 -0
  101. package/themes/theme-default.css +4 -0
  102. package/themes/theme-forest.css +4 -0
  103. package/themes/theme-hokage.css +4 -0
  104. package/themes/theme-matcha.css +4 -0
  105. package/themes/theme-slate.css +4 -0
  106. package/themes/theme-tokyo.css +4 -0
  107. package/themes/theme-yellow.css +4 -0
  108. package/themes/theme-zoro.css +4 -0
@@ -0,0 +1,970 @@
1
+ import { randomBytes } from 'node:crypto'
2
+ import { closeSync, openSync, writeSync } from 'node:fs'
3
+ import { readFile } from 'node:fs/promises'
4
+ import path from 'node:path'
5
+
6
+ import { appEntries, resolveManifest, type AppApiMethod, type AppConfig, type AppId, type AppWorkbench } from '@mohou/contract'
7
+ import type { McpClient } from '@mohou/mcp-client'
8
+ import type { RuntimeProvider } from '@mohou/runtime-provider'
9
+
10
+ import type { AppSummary } from '../apps/registry.ts'
11
+ import { cachedUiBundle, purgeAutogen } from '../compile/autogen.ts'
12
+ import { loadBackend } from '../compile/load-backend.ts'
13
+ import { reviewApp, type ReviewNotice } from '../compile/review.ts'
14
+ import { DEFAULT_APP_EVENT_TAIL, createAppEvents, type AppEvents } from '../events/app-events.ts'
15
+ import { createErrorRing } from '../events/error-ring.ts'
16
+ import { createHostEvents } from '../events/host-events.ts'
17
+ import { createViewQueries } from '../events/view-eval.ts'
18
+ import { createFileTools, type FileCommit } from '../files/tools.ts'
19
+ import { commitApp, listHistory, resetApp } from '../history/store.ts'
20
+ import { DEFAULT_STORAGE_NOTICE_BYTES } from '../storage/open.ts'
21
+ import { restoreStorageBackup } from '../storage/schema.ts'
22
+ import type { CredentialProvider } from '../credentials/provider.ts'
23
+ import { hostAuthoringToken } from '../host/layout.ts'
24
+ import { checkMcpEditor, maskEditorServers, readMcpEditor, writeMcpEditor, type McpEditorServer } from '../host/mcp-editor.ts'
25
+ import { mcpReferenceSources, type McpLoadFailure } from '../host/mcp.ts'
26
+ import { recordOpen } from '../host/activity.ts'
27
+ import { InstallError } from '../install/codes.ts'
28
+ import { installApp, type InstallRequest, type NpmRun } from '../install/install.ts'
29
+ import { installLayout } from '../install/layout.ts'
30
+ import { bindBrain } from '../kernel/bind-brain.ts'
31
+ import { runCall, type CallCapabilities } from '../kernel/call.ts'
32
+ import { openStorage, type StorageFile } from '../storage/open.ts'
33
+ import { listMcpForAuthor, toolsMcpForAuthor } from './mcp-list.ts'
34
+ import { AuthorError } from './codes.ts'
35
+
36
+ /** Tools this implementation can run. The rest of the catalog is not mounted. */
37
+ export const authorToolNames = [
38
+ 'mini_app_list',
39
+ 'mini_app_get',
40
+ 'mini_app_list_files',
41
+ 'mini_app_read',
42
+ 'mini_app_edit',
43
+ 'mini_app_write',
44
+ 'mini_app_delete',
45
+ 'mini_app_register',
46
+ 'mini_app_reload',
47
+ 'mini_app_call',
48
+ 'mini_app_mcp_list',
49
+ 'mini_app_mcp_tools',
50
+ 'mini_app_mcp_add',
51
+ 'mini_app_mcp_remove',
52
+ 'mini_app_credential_list',
53
+ 'mini_app_history_commit',
54
+ 'mini_app_history_list',
55
+ 'mini_app_history_reset',
56
+ 'mini_app_open',
57
+ 'mini_app_errors',
58
+ 'mini_app_install',
59
+ 'mini_app_view_eval',
60
+ ] as const
61
+
62
+ export type AuthorToolName = (typeof authorToolNames)[number]
63
+
64
+ /** Commit message for a successful reload. The tool description is the same sentence. */
65
+ export const reloadMessage = 'Compile and reload a mini-app.'
66
+
67
+ /** Byte-carrying file tools. HTTP invoke keeps them. MCP does not list or call them. */
68
+ export const authorByteToolNames = ['mini_app_edit', 'mini_app_write', 'mini_app_delete'] as const
69
+
70
+ export type AuthorByteToolName = (typeof authorByteToolNames)[number]
71
+
72
+ export const authorMcpToolNames = authorToolNames.filter(
73
+ (name): name is Exclude<AuthorToolName, AuthorByteToolName> => {
74
+ return !(authorByteToolNames as readonly string[]).includes(name)
75
+ },
76
+ )
77
+
78
+ /** Locked batch cap. A larger batch fails before any entry runs. */
79
+ const CALL_BATCH_MAX = 20
80
+
81
+ export interface AuthorCallPorts {
82
+ readonly credentials: CredentialProvider
83
+ readonly config: AppConfig
84
+ readonly log: (appId: string, ...args: unknown[]) => void
85
+ readonly push: (name: string, params?: unknown) => void
86
+ readonly http: CallCapabilities['http']
87
+ readonly bash: CallCapabilities['bash']
88
+ readonly pwsh: CallCapabilities['pwsh']
89
+ readonly metrics: CallCapabilities['metrics']
90
+ readonly processDirectory: string
91
+ readonly createTemp: () => string
92
+ readonly provider: RuntimeProvider
93
+ readonly workbench?: AppWorkbench
94
+ }
95
+
96
+ interface LiveApp {
97
+ api: Record<string, AppApiMethod>
98
+ storage: StorageFile
99
+ directory: string
100
+ ui?: string
101
+ kind?: 'workbench'
102
+ }
103
+
104
+ interface Registry {
105
+ readonly runtimeRoot: string
106
+ list: () => Promise<{ apps: AppSummary[]; runtimeRoot: string }>
107
+ get: (appId: string) => Promise<AppSummary>
108
+ register: (appId: string, files: Record<string, string>) => Promise<AppSummary>
109
+ trash: (appId: string) => Promise<void>
110
+ restoreTrashed: (appId: string) => Promise<AppSummary>
111
+ }
112
+
113
+ /**
114
+ * One authoring implementation. MCP and HTTP call this. They do not grow a second manager.
115
+ * @param options - registry, the external MCP client, and call ports
116
+ */
117
+ export function createAuthorTools(options: {
118
+ readonly registry: Registry
119
+ readonly mcp: McpClient
120
+ /** Servers the live client left out: boot's list, replaced by every write. The list reports them. */
121
+ readonly readMcpFailures?: () => readonly McpLoadFailure[]
122
+ readonly recordMcpFailures?: (failures: readonly McpLoadFailure[]) => void
123
+ /** Process environment. Names the MCP server file when `MINI_APP_MCP_CONFIG` is set. */
124
+ readonly env?: NodeJS.ProcessEnv
125
+ readonly ports: AuthorCallPorts
126
+ /** Called when a commit or a reset changes the tree. The session publishes `app:reload`. */
127
+ readonly onTreeChanged?: (appId: string) => void
128
+ /** Replaces npm in tests. Package names are not passed on the command line. */
129
+ readonly runNpm?: NpmRun
130
+ readonly installTimeoutMs?: number
131
+ /** View query wait when the tool omits one. Host policy, not a locked number. */
132
+ readonly viewTimeoutMs?: number
133
+ /** Author event tail. Host policy, not a locked number. */
134
+ readonly eventTailLength?: number
135
+ /** Bytes past which a committed write may emit one storage-size notice. Not locked. */
136
+ readonly storageNoticeBytes?: number
137
+ /** Platform keyframe names. Empty until a stylesheet owns them. */
138
+ readonly reservedKeyframes?: readonly string[]
139
+ /** Time of an open. Tests pass a fixed clock. */
140
+ readonly now?: () => string
141
+ }) {
142
+ const live = new Map<string, LiveApp>()
143
+ const hostEvents = createHostEvents()
144
+ const appEvents = createAppEvents({ tailLength: options.eventTailLength ?? DEFAULT_APP_EVENT_TAIL })
145
+ const errors = createErrorRing()
146
+ const views = createViewQueries({
147
+ ...options.viewTimeoutMs === undefined ? {} : { timeoutMs: options.viewTimeoutMs },
148
+ })
149
+ const bound = { ...options, hostEvents, appEvents, errors, views }
150
+ const inflight = new Set<Promise<unknown>>()
151
+ let stopping = false
152
+ const track = <T>(work: Promise<T>): Promise<T> => {
153
+ if (stopping) return Promise.reject(new AuthorError('cancelled', 'host is stopping'))
154
+ const tracked = work.finally(() => {
155
+ inflight.delete(tracked)
156
+ })
157
+ inflight.add(tracked)
158
+ return tracked
159
+ }
160
+ return {
161
+ names: authorToolNames,
162
+ hostEvents,
163
+ appEvents,
164
+ errors,
165
+ views,
166
+ liveBundle: (appId: string) => live.get(appId)?.ui,
167
+ invoke: (name: string, args: unknown) => track(invoke(bound, live, name, args)),
168
+ callObserved: (appId: string, method: string, args: unknown, publish: (value: unknown) => Promise<void> | void) => {
169
+ return track(oneCall(bound, live, appId, method, args, publish))
170
+ },
171
+ deleteApp: (appId: string) => track(removeApp(bound, live, appId)),
172
+ undeleteApp: (appId: string) => track(restoreDeleted(bound, appId)),
173
+ restoreStorage: (appId: string) => track(restoreAppStorage(bound, live, appId)),
174
+ settle() {
175
+ stopping = true
176
+ return Promise.allSettled([...inflight]).then(() => undefined)
177
+ },
178
+ dispose() {
179
+ stopping = true
180
+ for (const app of live.values()) app.storage.close()
181
+ live.clear()
182
+ appEvents.dispose()
183
+ },
184
+ }
185
+ }
186
+
187
+ /** Read `authoring.token`. Shell creates it. This does not invent a token. */
188
+ export async function readAuthoringToken(runtimeRoot: string): Promise<string> {
189
+ const text = await readFile(hostAuthoringToken(runtimeRoot), 'utf8').catch(() => '')
190
+ const token = text.trim()
191
+ if (token === '') throw new AuthorError('authoring-token', 'authoring token is missing')
192
+ return token
193
+ }
194
+
195
+ /**
196
+ * Create the authoring token when the file is absent.
197
+ * A present file is left as it is, including an empty one.
198
+ * POSIX mode bits are not a Windows access lock.
199
+ */
200
+ export function ensureAuthoringToken(runtimeRoot: string): void {
201
+ const file = hostAuthoringToken(runtimeRoot)
202
+ try {
203
+ const handle = openSync(file, 'wx', 0o600)
204
+ try {
205
+ writeSync(handle, randomBytes(32).toString('hex'))
206
+ } finally {
207
+ closeSync(handle)
208
+ }
209
+ } catch (error) {
210
+ if (!isAlreadyThere(error)) throw error
211
+ }
212
+ }
213
+
214
+ function isAlreadyThere(error: unknown): boolean {
215
+ return typeof error === 'object' && error !== null && 'code' in error && error.code === 'EEXIST'
216
+ }
217
+
218
+ async function invoke(
219
+ options: {
220
+ registry: Registry
221
+ mcp: McpClient
222
+ /** Servers boot left out because a reference names nothing. The author list reports them. */
223
+ readMcpFailures?: () => readonly McpLoadFailure[]
224
+ recordMcpFailures?: (failures: readonly McpLoadFailure[]) => void
225
+ env?: NodeJS.ProcessEnv
226
+ ports: AuthorCallPorts
227
+ onTreeChanged?: (appId: string) => void
228
+ hostEvents: ReturnType<typeof createHostEvents>
229
+ appEvents: AppEvents
230
+ errors: ReturnType<typeof createErrorRing>
231
+ views: ReturnType<typeof createViewQueries>
232
+ runNpm?: NpmRun
233
+ installTimeoutMs?: number
234
+ storageNoticeBytes?: number
235
+ },
236
+ live: Map<string, LiveApp>,
237
+ name: string,
238
+ args: unknown,
239
+ ): Promise<unknown> {
240
+ if (!isAuthorTool(name)) {
241
+ throw new AuthorError('unknown-tool', `unknown tool: ${name}. Catalog: ${authorToolNames.join(', ')}`)
242
+ }
243
+ const input = name === 'mini_app_list' || name === 'mini_app_mcp_list' || name === 'mini_app_credential_list' ? {} : record(args)
244
+ switch (name) {
245
+ case 'mini_app_list':
246
+ return options.registry.list()
247
+ case 'mini_app_get':
248
+ return options.registry.get(requiredString(input, 'appId'))
249
+ case 'mini_app_list_files':
250
+ return { files: await filesFor(options, requiredString(input, 'appId')).list() }
251
+ case 'mini_app_read':
252
+ return filesFor(options, requiredString(input, 'appId')).read(
253
+ requiredString(input, 'path'),
254
+ windowOf(input),
255
+ input.numbered === true,
256
+ )
257
+ case 'mini_app_edit':
258
+ return filesFor(options, requiredString(input, 'appId')).edit(
259
+ requiredString(input, 'path'),
260
+ editsOf(input),
261
+ input.commit !== false,
262
+ )
263
+ case 'mini_app_write':
264
+ return filesFor(options, requiredString(input, 'appId')).write(
265
+ requiredString(input, 'path'),
266
+ requiredString(input, 'content'),
267
+ input.commit !== false,
268
+ )
269
+ case 'mini_app_delete':
270
+ return filesFor(options, requiredString(input, 'appId')).delete(
271
+ requiredString(input, 'path'),
272
+ input.commit !== false,
273
+ )
274
+ case 'mini_app_register': {
275
+ const registered = await options.registry.register(requiredString(input, 'appId'), registerManifest(input))
276
+ return { ...registered, needed: neededFiles(registered.directory) }
277
+ }
278
+ case 'mini_app_reload':
279
+ return reload(options, live, requiredString(input, 'appId'), optionalCleanCaches(input.cleanCaches))
280
+ case 'mini_app_call':
281
+ return callApp(options, live, input)
282
+ case 'mini_app_mcp_list':
283
+ return listMcpForAuthor(options.mcp, options.readMcpFailures?.() ?? [])
284
+ case 'mini_app_mcp_tools':
285
+ return toolsMcpForAuthor(options.mcp, requiredString(input, 'serverId'), optionalString(input, 'toolName'), options.readMcpFailures?.() ?? [])
286
+ case 'mini_app_mcp_add':
287
+ return addMcpServer(options, input)
288
+ case 'mini_app_mcp_remove':
289
+ return removeMcpServer(options, input)
290
+ case 'mini_app_credential_list':
291
+ return { credentials: await options.ports.credentials.list() }
292
+ case 'mini_app_history_commit':
293
+ return historyCommit(options, requiredString(input, 'appId'), requiredString(input, 'message'))
294
+ case 'mini_app_history_list':
295
+ return historyList(options, requiredString(input, 'appId'), optionalLimit(input.limit))
296
+ case 'mini_app_history_reset':
297
+ return historyReset(options, requiredString(input, 'appId'), requiredString(input, 'commitId'))
298
+ case 'mini_app_open':
299
+ return openApp(options, requiredString(input, 'appId'), optionalString(input, 'title'))
300
+ case 'mini_app_errors':
301
+ return readErrors(options, requiredString(input, 'appId'), optionalSince(input.since), optionalClear(input.clear))
302
+ case 'mini_app_install':
303
+ return installPackages(options, requiredString(input, 'appId'), installRequest(input), input.commit !== false)
304
+ case 'mini_app_view_eval':
305
+ return evalView(options, requiredString(input, 'appId'), input)
306
+ default:
307
+ return name satisfies never
308
+ }
309
+ }
310
+
311
+ function filesFor(
312
+ options: { registry: Registry; onTreeChanged?: (appId: string) => void },
313
+ appId: string,
314
+ ) {
315
+ const tools = async () => {
316
+ const app = await options.registry.get(appId)
317
+ return createFileTools(app.directory, {
318
+ commit: message => trackedCommit(app.directory, app.id, message, options.onTreeChanged),
319
+ protectedPath: file => file === installLayout.manifest || file === installLayout.lockfile,
320
+ })
321
+ }
322
+ return {
323
+ list: async () => (await tools()).list(),
324
+ read: async (relative: string, window?: { start?: number; end?: number }, numbered?: boolean) =>
325
+ (await tools()).read(relative, window, numbered),
326
+ edit: async (relative: string, edits: Array<{ oldText: string; newText: string }>, commit: boolean) => {
327
+ return (await tools()).edit(relative, edits, commit)
328
+ },
329
+ write: async (relative: string, content: string, commit: boolean) => {
330
+ return (await tools()).write(relative, content, commit)
331
+ },
332
+ delete: async (relative: string, commit: boolean) => {
333
+ return (await tools()).delete(relative, commit)
334
+ },
335
+ }
336
+ }
337
+
338
+ async function trackedCommit(
339
+ appDir: string,
340
+ appId: string,
341
+ message: string,
342
+ onTreeChanged: ((appId: string) => void) | undefined,
343
+ ): Promise<FileCommit> {
344
+ try {
345
+ const result = await commitApp(appDir, message)
346
+ if (result.status === 'committed') onTreeChanged?.(appId)
347
+ return result
348
+ } catch (error) {
349
+ return { status: 'failed', reason: error instanceof Error ? error.message : 'commit failed' }
350
+ }
351
+ }
352
+
353
+ async function historyCommit(
354
+ options: { registry: Registry; onTreeChanged?: (appId: string) => void },
355
+ appId: string,
356
+ message: string,
357
+ ) {
358
+ const app = await options.registry.get(appId)
359
+ const result = await commitApp(app.directory, message)
360
+ if (result.status === 'committed') options.onTreeChanged?.(app.id)
361
+ return result
362
+ }
363
+
364
+ async function historyList(
365
+ options: { registry: Registry },
366
+ appId: string,
367
+ limit: number | undefined,
368
+ ) {
369
+ const app = await options.registry.get(appId)
370
+ return limit === undefined ? listHistory(app.directory) : listHistory(app.directory, limit)
371
+ }
372
+
373
+ async function historyReset(
374
+ options: {
375
+ registry: Registry
376
+ onTreeChanged?: (appId: string) => void
377
+ hostEvents: ReturnType<typeof createHostEvents>
378
+ },
379
+ appId: string,
380
+ commitId: string,
381
+ ) {
382
+ const app = await options.registry.get(appId)
383
+ const result = await resetApp(app.directory, commitId)
384
+ if (result.changed) {
385
+ options.onTreeChanged?.(app.id)
386
+ options.hostEvents.publish({ type: 'app:reload', appId: app.id })
387
+ }
388
+ return result
389
+ }
390
+
391
+ async function openApp(
392
+ options: {
393
+ registry: Registry
394
+ hostEvents: ReturnType<typeof createHostEvents>
395
+ errors: ReturnType<typeof createErrorRing>
396
+ now?: () => string
397
+ },
398
+ appId: string,
399
+ title: string | undefined,
400
+ ) {
401
+ const app = await options.registry.get(appId)
402
+ recordOpen(options.registry.runtimeRoot, app.id, (options.now ?? (() => new Date().toISOString()))())
403
+ options.errors.markOpened(app.id)
404
+ options.hostEvents.publish({
405
+ type: 'app:open',
406
+ appId: app.id,
407
+ ...title === undefined ? {} : { title },
408
+ })
409
+ return { panel: options.hostEvents.connected ? 'notified' : 'no-panel-connected' }
410
+ }
411
+
412
+ async function readErrors(
413
+ options: { registry: Registry; errors: ReturnType<typeof createErrorRing> },
414
+ appId: string,
415
+ since: number | undefined,
416
+ clear: boolean,
417
+ ) {
418
+ const app = await options.registry.get(appId)
419
+ return since === undefined ? options.errors.read(app.id, undefined, clear) : options.errors.read(app.id, since, clear)
420
+ }
421
+
422
+ async function evalView(
423
+ options: {
424
+ registry: Registry
425
+ hostEvents: ReturnType<typeof createHostEvents>
426
+ views: ReturnType<typeof createViewQueries>
427
+ },
428
+ appId: string,
429
+ args: Record<string, unknown>,
430
+ ) {
431
+ const app = await options.registry.get(appId)
432
+ const timeoutMs = optionalBudget(args.timeoutMs, 'timeoutMs')
433
+ const maxBytes = optionalBudget(args.maxBytes, 'maxBytes')
434
+ const code = args.code
435
+ if (code !== undefined && typeof code !== 'string') throw new AuthorError('tool-args', 'code must be text')
436
+ return options.views.ask({
437
+ appId: app.id,
438
+ connected: options.hostEvents.connected,
439
+ publish: (event) => {
440
+ options.hostEvents.publish(event)
441
+ },
442
+ ...timeoutMs === undefined ? {} : { timeoutMs },
443
+ ...maxBytes === undefined ? {} : { maxBytes },
444
+ ...typeof code === 'string' && code.length > 0 ? { code } : {},
445
+ })
446
+ }
447
+
448
+ function optionalBudget(value: unknown, key: string): number | undefined {
449
+ if (value === undefined) return undefined
450
+ if (typeof value !== 'number' || value < 1) throw new AuthorError('tool-args', `${key} must be a positive number`)
451
+ return value
452
+ }
453
+
454
+ async function installPackages(
455
+ options: {
456
+ registry: Registry
457
+ onTreeChanged?: (appId: string) => void
458
+ runNpm?: NpmRun
459
+ installTimeoutMs?: number
460
+ },
461
+ appId: string,
462
+ request: InstallRequest,
463
+ commit: boolean,
464
+ ) {
465
+ const app = await options.registry.get(appId)
466
+ try {
467
+ const result = await installApp(app.directory, request, {
468
+ ...options.runNpm === undefined ? {} : { run: options.runNpm },
469
+ ...options.installTimeoutMs === undefined ? {} : { timeoutMs: options.installTimeoutMs },
470
+ })
471
+ if (result.ok && commit && (request.packages.length > 0 || request.remove.length > 0)) {
472
+ await trackedCommit(app.directory, app.id, 'install dependencies', options.onTreeChanged)
473
+ }
474
+ return result
475
+ } catch (error) {
476
+ if (error instanceof InstallError) {
477
+ return { ok: false, packages: {}, lockfile: null, code: error.code, message: error.message }
478
+ }
479
+ throw error
480
+ }
481
+ }
482
+
483
+ function installRequest(args: Record<string, unknown>): InstallRequest {
484
+ return {
485
+ packages: packageSpecs(args.packages),
486
+ remove: nameList(args.remove),
487
+ }
488
+ }
489
+
490
+ function packageSpecs(value: unknown): InstallRequest['packages'] {
491
+ if (value === undefined) return []
492
+ if (!Array.isArray(value)) throw new AuthorError('tool-args', 'packages must be a list')
493
+ return value.map((item) => {
494
+ const spec = record(item)
495
+ const version = spec.version
496
+ if (version !== undefined && typeof version !== 'string') throw new AuthorError('tool-args', 'version must be text')
497
+ return {
498
+ name: requiredString(spec, 'name'),
499
+ ...typeof version === 'string' ? { version } : {},
500
+ }
501
+ })
502
+ }
503
+
504
+ function nameList(value: unknown): string[] {
505
+ if (value === undefined) return []
506
+ if (!Array.isArray(value)) throw new AuthorError('tool-args', 'remove must be a list')
507
+ return value.map((item) => {
508
+ if (typeof item !== 'string' || item.length === 0) throw new AuthorError('tool-args', 'remove names must be text')
509
+ return item
510
+ })
511
+ }
512
+
513
+ async function restoreAppStorage(
514
+ options: { registry: Registry },
515
+ live: Map<string, LiveApp>,
516
+ appId: string,
517
+ ) {
518
+ const app = await options.registry.get(appId)
519
+ live.get(app.id)?.storage.close()
520
+ live.delete(app.id)
521
+ restoreStorageBackup(app.directory)
522
+ }
523
+
524
+ async function removeApp(
525
+ options: { registry: Registry; errors: ReturnType<typeof createErrorRing> },
526
+ live: Map<string, LiveApp>,
527
+ appId: string,
528
+ ) {
529
+ const app = await options.registry.get(appId)
530
+ live.get(app.id)?.storage.close()
531
+ live.delete(app.id)
532
+ options.errors.forget(app.id)
533
+ await options.registry.trash(app.id)
534
+ }
535
+
536
+ async function restoreDeleted(
537
+ options: { registry: Registry },
538
+ appId: string,
539
+ ) {
540
+ return options.registry.restoreTrashed(appId)
541
+ }
542
+
543
+ async function reload(
544
+ options: {
545
+ registry: Registry
546
+ onTreeChanged?: (appId: string) => void
547
+ hostEvents: ReturnType<typeof createHostEvents>
548
+ errors: ReturnType<typeof createErrorRing>
549
+ views: ReturnType<typeof createViewQueries>
550
+ storageNoticeBytes?: number
551
+ reservedKeyframes?: readonly string[]
552
+ },
553
+ live: Map<string, LiveApp>,
554
+ appId: string,
555
+ cleanCaches = true,
556
+ ): Promise<{
557
+ ok: boolean
558
+ errors: Array<{ code: string; message: string }>
559
+ notices: ReviewNotice[]
560
+ compiled: { backend: boolean; ui: boolean }
561
+ committed: FileCommit & { id?: string }
562
+ caches: { appCss: 'dropped'; views: 'not-open' | 'refetch'; cleanCaches: boolean }
563
+ }> {
564
+ let opened: StorageFile | undefined
565
+ try {
566
+ const app = await options.registry.get(appId)
567
+ if (cleanCaches) await purgeAutogen(app.directory)
568
+ opened = await openStorage(app.directory, storageNotice(options, app.id))
569
+ const loaded = await loadBackend(app.directory)
570
+ const review = await reviewApp(app.directory, {
571
+ ...options.reservedKeyframes === undefined ? {} : { reservedKeyframes: options.reservedKeyframes },
572
+ })
573
+ if (review.errors.length > 0) {
574
+ opened.close()
575
+ return {
576
+ ok: false,
577
+ errors: review.errors,
578
+ notices: review.notices,
579
+ compiled: { backend: false, ui: false },
580
+ committed: { status: 'skipped' },
581
+ caches: { appCss: 'dropped', views: 'not-open', cleanCaches },
582
+ }
583
+ }
584
+ await cachedUiBundle(app.directory)
585
+ const previous = live.get(app.id)
586
+ live.set(app.id, { api: loaded.api, storage: opened, directory: app.directory })
587
+ previous?.storage.close()
588
+ const committed = await commitApp(app.directory, reloadMessage).catch((error: unknown) => ({
589
+ status: 'failed' as const,
590
+ reason: error instanceof Error ? error.message : 'commit failed',
591
+ }))
592
+ if (committed.status === 'committed') options.onTreeChanged?.(app.id)
593
+ options.errors.clearReload(app.id)
594
+ options.views.forgetAlive(app.id)
595
+ const views = options.hostEvents.connected ? 'refetch' as const : 'not-open' as const
596
+ options.hostEvents.publish({ type: 'app:reload', appId: app.id })
597
+ return {
598
+ ok: committed.status !== 'failed',
599
+ errors: committed.status === 'failed' ? [{ code: 'commit-failed', message: committed.reason ?? 'commit failed' }] : [],
600
+ notices: review.notices,
601
+ compiled: { backend: true, ui: true },
602
+ committed,
603
+ caches: { appCss: 'dropped', views, cleanCaches },
604
+ }
605
+ } catch (error) {
606
+ opened?.close()
607
+ return {
608
+ ok: false,
609
+ errors: [errorOf(error)],
610
+ notices: [],
611
+ compiled: { backend: false, ui: false },
612
+ committed: { status: 'skipped' },
613
+ caches: { appCss: 'dropped', views: 'not-open', cleanCaches },
614
+ }
615
+ }
616
+ }
617
+
618
+ async function callApp(
619
+ options: {
620
+ registry: Registry
621
+ mcp: McpClient
622
+ ports: AuthorCallPorts
623
+ appEvents: AppEvents
624
+ hostEvents: ReturnType<typeof createHostEvents>
625
+ storageNoticeBytes?: number
626
+ },
627
+ live: Map<string, LiveApp>,
628
+ input: Record<string, unknown>,
629
+ ): Promise<unknown> {
630
+ const appId = requiredString(input, 'appId')
631
+ const batch = input.calls
632
+ if (batch !== undefined) {
633
+ if (!Array.isArray(batch) || batch.length === 0) throw new AuthorError('tool-args', 'calls must be a non-empty list')
634
+ if (batch.length > CALL_BATCH_MAX) throw new AuthorError('call-batch', `calls exceed ${CALL_BATCH_MAX}`)
635
+ const results = []
636
+ for (const entry of batch) {
637
+ const item = record(entry)
638
+ results.push(await oneCall(options, live, appId, requiredString(item, 'method'), item.args))
639
+ }
640
+ return { results }
641
+ }
642
+ return oneCall(options, live, appId, requiredString(input, 'method'), input.args)
643
+ }
644
+
645
+ async function oneCall(
646
+ options: {
647
+ registry: Registry
648
+ mcp: McpClient
649
+ ports: AuthorCallPorts
650
+ appEvents: AppEvents
651
+ hostEvents: ReturnType<typeof createHostEvents>
652
+ storageNoticeBytes?: number
653
+ },
654
+ live: Map<string, LiveApp>,
655
+ appId: string,
656
+ method: string,
657
+ args: unknown,
658
+ publish?: (value: unknown) => Promise<void> | void,
659
+ ): Promise<{ ok: true; value: unknown } | { ok: false; error: { code: string; message: string } }> {
660
+ try {
661
+ const app = await ensureLive(options, live, appId)
662
+ const brain = await bindBrain({
663
+ provider: options.ports.provider,
664
+ appDir: app.directory,
665
+ processDirectory: options.ports.processDirectory,
666
+ createTemp: options.ports.createTemp,
667
+ push: (name, params) => {
668
+ options.appEvents.push(app.id, name, params)
669
+ },
670
+ })
671
+ try {
672
+ const value = await runCall(capabilitiesFor(options, app, brain), app.api, method, args, publish)
673
+ return { ok: true, value }
674
+ } finally {
675
+ await brain.stop()
676
+ }
677
+ } catch (error) {
678
+ return { ok: false, error: errorOf(error) }
679
+ }
680
+ }
681
+
682
+ function storageNotice(
683
+ options: { hostEvents: ReturnType<typeof createHostEvents>; storageNoticeBytes?: number },
684
+ appId: string,
685
+ ): { noticeBytes: number; onNotice: (notice: { table: string; keys: string[] }) => void } {
686
+ return {
687
+ noticeBytes: options.storageNoticeBytes ?? DEFAULT_STORAGE_NOTICE_BYTES,
688
+ onNotice: (notice) => {
689
+ options.hostEvents.publish({ type: 'storage-size', appId, table: notice.table, keys: notice.keys })
690
+ },
691
+ }
692
+ }
693
+
694
+ async function ensureLive(
695
+ options: { registry: Registry; hostEvents: ReturnType<typeof createHostEvents>; storageNoticeBytes?: number },
696
+ live: Map<string, LiveApp>,
697
+ appId: string,
698
+ ): Promise<LiveApp & { id: AppId }> {
699
+ const summary = await options.registry.get(appId)
700
+ const cached = live.get(summary.id)
701
+ if (cached !== undefined && cached.directory === summary.directory) return { ...withKind(cached, summary.kind), id: summary.id }
702
+ const storage = await openStorage(summary.directory, storageNotice(options, summary.id))
703
+ try {
704
+ const loaded = await loadBackend(summary.directory)
705
+ const next = withKind({ api: loaded.api, storage, directory: summary.directory }, summary.kind)
706
+ live.set(summary.id, next)
707
+ return { ...next, id: summary.id }
708
+ } catch (error) {
709
+ storage.close()
710
+ throw error
711
+ }
712
+ }
713
+
714
+ function capabilitiesFor(
715
+ options: { mcp: McpClient; ports: AuthorCallPorts; appEvents: AppEvents },
716
+ app: LiveApp & { id: AppId },
717
+ brain: CallCapabilities['brain'],
718
+ ): CallCapabilities {
719
+ const ports = options.ports
720
+ return {
721
+ appId: app.id,
722
+ appDir: app.directory,
723
+ state: {},
724
+ storage: app.storage.connect(),
725
+ credentials: ports.credentials,
726
+ config: ports.config,
727
+ log: (...args) => { ports.log(app.id, ...args) },
728
+ push: (name, params) => {
729
+ options.appEvents.push(app.id, name, params)
730
+ },
731
+ http: ports.http,
732
+ bash: ports.bash,
733
+ pwsh: ports.pwsh,
734
+ metrics: ports.metrics,
735
+ mcp: (serverId, toolName, args) => options.mcp.call(serverId, toolName, args ?? {}),
736
+ brain,
737
+ ...app.kind === 'workbench' && ports.workbench !== undefined ? { workbench: ports.workbench } : {},
738
+ }
739
+ }
740
+
741
+ function withKind(app: LiveApp, kind: 'workbench' | undefined): LiveApp {
742
+ if (kind === undefined) {
743
+ const { kind: gone, ...rest } = app
744
+ void gone
745
+ return rest
746
+ }
747
+ return { ...app, kind }
748
+ }
749
+
750
+ function errorOf(error: unknown): { code: string; message: string } {
751
+ const code = typeof error === 'object' && error !== null && 'code' in error && typeof error.code === 'string'
752
+ ? error.code
753
+ : 'unknown-tool'
754
+ const message = error instanceof Error ? error.message : 'tool failed'
755
+ return { code, message }
756
+ }
757
+
758
+ function isAuthorTool(name: string): name is AuthorToolName {
759
+ return (authorToolNames as readonly string[]).includes(name)
760
+ }
761
+
762
+ export function isAuthorMcpTool(name: string): boolean {
763
+ return isAuthorTool(name) && !(authorByteToolNames as readonly string[]).includes(name)
764
+ }
765
+
766
+ function record(value: unknown): Record<string, unknown> {
767
+ if (typeof value !== 'object' || value === null || Array.isArray(value)) {
768
+ throw new AuthorError('tool-args', 'tool arguments must be an object')
769
+ }
770
+ return value as Record<string, unknown>
771
+ }
772
+
773
+ function requiredString(args: Record<string, unknown>, key: string): string {
774
+ const value = args[key]
775
+ if (typeof value !== 'string' || value.length === 0) throw new AuthorError('tool-args', `${key} is required`)
776
+ return value
777
+ }
778
+
779
+ function optionalString(args: Record<string, unknown>, key: string): string | undefined {
780
+ const value = args[key]
781
+ if (value === undefined || value === '') return undefined
782
+ if (typeof value !== 'string') throw new AuthorError('tool-args', `${key} must be text`)
783
+ return value
784
+ }
785
+
786
+ function optionalLimit(value: unknown): number | undefined {
787
+ if (value === undefined) return undefined
788
+ if (typeof value !== 'number' || value < 1) throw new AuthorError('tool-args', 'limit must be a positive number')
789
+ return value
790
+ }
791
+
792
+ function optionalSince(value: unknown): number | undefined {
793
+ if (value === undefined) return undefined
794
+ if (typeof value !== 'number' || value < 0) throw new AuthorError('tool-args', 'since must be a non-negative number')
795
+ return value
796
+ }
797
+
798
+ function optionalClear(value: unknown): boolean {
799
+ if (value === undefined) return false
800
+ if (typeof value !== 'boolean') throw new AuthorError('tool-args', 'clear must be a boolean')
801
+ return value
802
+ }
803
+
804
+ /** Default true. No cache directory is named, so the flag is recorded and nothing is removed. */
805
+ function optionalCleanCaches(value: unknown): boolean {
806
+ if (value === undefined) return true
807
+ if (typeof value !== 'boolean') throw new AuthorError('tool-args', 'cleanCaches must be a boolean')
808
+ return value
809
+ }
810
+
811
+ /**
812
+ * Add or replace one MCP server: check it by opening it, write the row, and hand the live client
813
+ * the set it just wrote, so the server is usable without a restart. A failed check leaves both the
814
+ * file and the client alone unless `force` is true.
815
+ */
816
+ async function addMcpServer(
817
+ options: {
818
+ registry: Registry
819
+ mcp: McpClient
820
+ env?: NodeJS.ProcessEnv
821
+ ports: AuthorCallPorts
822
+ recordMcpFailures?: (failures: readonly McpLoadFailure[]) => void
823
+ },
824
+ input: Record<string, unknown>,
825
+ ) {
826
+ const env = options.env ?? process.env
827
+ const root = options.registry.runtimeRoot
828
+ const server = mcpServerRow(input)
829
+ const sources = mcpReferenceSources(env, options.ports.credentials)
830
+ const check = input.check === false ? undefined : await checkMcpEditor(server, env, sources)
831
+ // The write input is the file itself. The masked rows are for the answer, and writing those back
832
+ // would replace every other server's secret with its own mask.
833
+ const file = await readMcpEditor(root, env)
834
+ if (check !== undefined && !check.ok && input.force !== true) {
835
+ return { added: false, id: server.id, check, servers: maskEditorServers(file) }
836
+ }
837
+ const resolved = await writeMcpEditor(root, [...file.filter(row => row.id !== server.id), server], env, sources)
838
+ await options.mcp.setServers(resolved.servers)
839
+ options.recordMcpFailures?.(resolved.failures)
840
+ return {
841
+ added: true,
842
+ id: server.id,
843
+ ...check === undefined ? {} : { check },
844
+ servers: await readMcpEditor(root, env, true),
845
+ }
846
+ }
847
+
848
+ /** Remove one MCP server by id, live. An unknown id changes nothing. */
849
+ async function removeMcpServer(
850
+ options: {
851
+ registry: Registry
852
+ mcp: McpClient
853
+ env?: NodeJS.ProcessEnv
854
+ ports: AuthorCallPorts
855
+ recordMcpFailures?: (failures: readonly McpLoadFailure[]) => void
856
+ },
857
+ input: Record<string, unknown>,
858
+ ) {
859
+ const env = options.env ?? process.env
860
+ const root = options.registry.runtimeRoot
861
+ const id = requiredString(input, 'id')
862
+ const sources = mcpReferenceSources(env, options.ports.credentials)
863
+ const file = await readMcpEditor(root, env)
864
+ if (!file.some(row => row.id === id)) return { removed: false, id, servers: maskEditorServers(file) }
865
+ const resolved = await writeMcpEditor(root, file.filter(row => row.id !== id), env, sources)
866
+ await options.mcp.setServers(resolved.servers)
867
+ options.recordMcpFailures?.(resolved.failures)
868
+ return { removed: true, id, servers: await readMcpEditor(root, env, true) }
869
+ }
870
+
871
+ function mcpServerRow(input: Record<string, unknown>): McpEditorServer {
872
+ const id = requiredString(input, 'id')
873
+ const command = optionalString(input, 'command')
874
+ const url = optionalString(input, 'url')
875
+ if (command === undefined && url === undefined) throw new AuthorError('tool-args', 'give command or url')
876
+ const description = optionalString(input, 'description')
877
+ const transport = mcpTransport(input.transport)
878
+ const args = stringList(input.args, 'args')
879
+ const env = stringMap(input.env, 'env')
880
+ const headers = stringMap(input.headers, 'headers')
881
+ return {
882
+ id,
883
+ ...description === undefined ? {} : { description },
884
+ ...input.enabled === false ? { enabled: false } : {},
885
+ ...command === undefined ? {} : { command },
886
+ ...args === undefined ? {} : { args },
887
+ ...env === undefined ? {} : { env },
888
+ ...url === undefined ? {} : { url },
889
+ ...transport === undefined ? {} : { transport },
890
+ ...headers === undefined ? {} : { headers },
891
+ }
892
+ }
893
+
894
+ function mcpTransport(value: unknown): McpEditorServer['transport'] {
895
+ if (value === undefined) return undefined
896
+ if (value !== 'sse' && value !== 'streamable-http') throw new AuthorError('tool-args', 'transport must be sse or streamable-http')
897
+ return value
898
+ }
899
+
900
+ function stringList(value: unknown, key: string): string[] | undefined {
901
+ if (value === undefined) return undefined
902
+ if (!Array.isArray(value) || value.some(item => typeof item !== 'string')) {
903
+ throw new AuthorError('tool-args', `${key} must be an array of text`)
904
+ }
905
+ return value as string[]
906
+ }
907
+
908
+ function stringMap(value: unknown, key: string): Record<string, string> | undefined {
909
+ if (value === undefined) return undefined
910
+ if (typeof value !== 'object' || value === null || Array.isArray(value)) {
911
+ throw new AuthorError('tool-args', `${key} must be an object of text`)
912
+ }
913
+ const out: Record<string, string> = {}
914
+ for (const [name, item] of Object.entries(value)) {
915
+ if (typeof item !== 'string') throw new AuthorError('tool-args', `${key}.${name} must be text`)
916
+ out[name] = item
917
+ }
918
+ return out
919
+ }
920
+
921
+ function windowOf(args: Record<string, unknown>): { start?: number; end?: number } | undefined {
922
+ const start = args.start
923
+ const end = args.end
924
+ if (start === undefined && end === undefined) return undefined
925
+ return {
926
+ ...typeof start === 'number' ? { start } : {},
927
+ ...typeof end === 'number' ? { end } : {},
928
+ }
929
+ }
930
+
931
+ function editsOf(args: Record<string, unknown>): Array<{ oldText: string; newText: string }> {
932
+ const edits = args.edits
933
+ if (!Array.isArray(edits) || edits.length === 0) throw new AuthorError('tool-args', 'edits are required')
934
+ return edits.map((edit) => {
935
+ const item = record(edit)
936
+ return { oldText: requiredString(item, 'oldText'), newText: typeof item.newText === 'string' ? item.newText : '' }
937
+ })
938
+ }
939
+
940
+ function registerManifest(input: Record<string, unknown>): Record<string, string> {
941
+ if ('files' in input) throw new AuthorError('tool-args', 'files is not a register argument')
942
+ const appId = requiredString(input, 'appId')
943
+ const raw: Record<string, unknown> = {
944
+ id: appId,
945
+ name: requiredString(input, 'name'),
946
+ description: requiredString(input, 'description'),
947
+ version: requiredString(input, 'version'),
948
+ entry: appEntries.ui,
949
+ }
950
+ if (input.acronym !== undefined) raw.acronym = input.acronym
951
+ if (input.tags !== undefined) raw.tags = input.tags
952
+ if (input.kind !== undefined) raw.kind = input.kind
953
+ const admitted = resolveManifest(raw, appId)
954
+ const body: Record<string, unknown> = {
955
+ id: admitted.id,
956
+ name: admitted.name,
957
+ description: admitted.description,
958
+ version: admitted.version,
959
+ entry: admitted.entry,
960
+ }
961
+ if (admitted.acronym !== undefined) body.acronym = admitted.acronym
962
+ if (admitted.tags !== undefined) body.tags = admitted.tags
963
+ if (admitted.kind !== undefined) body.kind = admitted.kind
964
+ return { [appEntries.manifest]: `${JSON.stringify(body, null, 2)}
965
+ ` }
966
+ }
967
+
968
+ function neededFiles(directory: string): readonly string[] {
969
+ return [path.join(directory, appEntries.ui), path.join(directory, appEntries.backend)]
970
+ }