@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,63 @@
1
+ import { appEntries } from '@mohou/contract'
2
+
3
+ /** Loopback paths. Spell them here. Call sites use these helpers. */
4
+
5
+ export const httpLayout = {
6
+ apps: '/api/apps',
7
+ app: '/api/app',
8
+ events: '/api/events',
9
+ call: '/api/call',
10
+ hostConfig: '/api/host-config',
11
+ palettes: '/api/palettes',
12
+ about: '/api/about',
13
+ updates: '/api/updates',
14
+ updateInstall: '/api/updates/install',
15
+ updateAck: '/api/updates/ack',
16
+ providers: '/api/runtime-providers',
17
+ activate: '/api/runtime-providers/activate',
18
+ probe: '/api/runtime-providers/probe',
19
+ restart: '/api/restart',
20
+ authorSkill: '/api/author-skill',
21
+ authorSkillReveal: '/api/author-skill/reveal',
22
+ authorMcp: '/api/author-mcp',
23
+ authorMcpReveal: '/api/author-mcp/reveal',
24
+ mcpServers: '/api/mcp-servers',
25
+ mcpCheck: '/api/mcp-servers/check',
26
+ mcpAdmit: '/api/mcp-servers/admit',
27
+ mcpImport: '/api/mcp-servers/import',
28
+ credentials: '/api/credentials',
29
+ credentialRemove: '/api/credentials/remove',
30
+ tools: '/api/tools',
31
+ invoke: '/api/tools/invoke',
32
+ mcp: '/mcp',
33
+ runner: '/app',
34
+ trash: '/api/trash',
35
+ panel: '/',
36
+ panelScript: '/panel.js',
37
+ open: 'open',
38
+ reload: 'reload',
39
+ restore: 'restore',
40
+ restoreStorage: 'restore',
41
+ history: 'history',
42
+ storage: 'storage',
43
+ theme: 'theme',
44
+ ui: 'ui',
45
+ entry: 'entry.js',
46
+ sheet: appEntries.stylesheet,
47
+ assets: 'assets',
48
+ } as const
49
+
50
+ /** History list bound. Host policy, not a locked number. */
51
+ export const historyListBound = { default: 50, max: 200 } as const
52
+
53
+ export function runnerPath(appId: string): string {
54
+ return `${httpLayout.runner}/${encodeURIComponent(appId)}`
55
+ }
56
+
57
+ export function appResource(appId: string, ...rest: string[]): string {
58
+ return [httpLayout.app, encodeURIComponent(appId), ...rest.map(encodeURIComponent)].join('/')
59
+ }
60
+
61
+ export function appsResource(appId: string, ...rest: string[]): string {
62
+ return [httpLayout.apps, encodeURIComponent(appId), ...rest.map(encodeURIComponent)].join('/')
63
+ }
@@ -0,0 +1,380 @@
1
+ import type { Context, Hono } from 'hono'
2
+
3
+ import type { HostEnv } from './env.ts'
4
+
5
+ import type { McpEditorServer } from '../host/mcp-editor.ts'
6
+ import { authorMcpToolList } from '../tools/schemas.ts'
7
+ import { historyListBound, httpLayout } from './layout.ts'
8
+ import { aboutInfo, type LoopbackPorts } from './ports.ts'
9
+ import { authoringDenied, isPin, isRecord, mergePolicy, ok } from './reply.ts'
10
+
11
+ /** Panel and owner routes. No authoring token. */
12
+ export function mountOwner(app: Hono<HostEnv>, ports: LoopbackPorts): void {
13
+ if (ports.panel !== undefined) {
14
+ const panel = ports.panel
15
+ app.get(httpLayout.panel, async (c) => {
16
+ if (panel.paint === undefined) return c.html(panel.html)
17
+ const painted = await panel.paint()
18
+ return c.html(injectPanelPaint(panel.html, painted))
19
+ })
20
+ app.get(httpLayout.panelScript, c => c.body(panel.script, 200, {
21
+ 'content-type': 'text/javascript; charset=utf-8',
22
+ }))
23
+ }
24
+ app.get(httpLayout.apps, async (c) => {
25
+ return c.json({ apps: await ports.list() })
26
+ })
27
+ app.get(httpLayout.trash, async (c) => {
28
+ return c.json({ apps: await ports.listTrash() })
29
+ })
30
+ app.get(httpLayout.palettes, async (c) => {
31
+ return c.json(await ports.listPalettes())
32
+ })
33
+ app.get(httpLayout.hostConfig, c => c.json({ ok: true, policy: ports.readPolicy() }))
34
+ app.post(httpLayout.hostConfig, (c) => {
35
+ const denied = rejectWithoutToken(c, ports)
36
+ if (denied !== undefined) return denied
37
+ return writeConfig(c, ports)
38
+ })
39
+ app.get(httpLayout.about, c => c.json({
40
+ ok: true,
41
+ ...aboutInfo(),
42
+ authoring: {
43
+ url: `http://127.0.0.1:${ports.readPolicy().hostPort}${httpLayout.mcp}`,
44
+ token: ports.authoringToken,
45
+ tools: authorMcpToolList().map(tool => ({ name: tool.name, description: tool.description })),
46
+ },
47
+ }))
48
+ app.get(httpLayout.updates, async (c) => {
49
+ return c.json({ ok: true, ...await ports.checkUpdate() })
50
+ })
51
+ app.post(httpLayout.updateInstall, async (c) => {
52
+ const denied = rejectWithoutToken(c, ports)
53
+ if (denied !== undefined) return denied
54
+ if (ports.installUpdate === undefined) {
55
+ return c.json({ ok: false, error: { code: 'config-invalid', message: 'update install is not available' } }, 400)
56
+ }
57
+ const body = await readBody(c)
58
+ const version = isRecord(body) && typeof body.version === 'string' ? body.version : ''
59
+ return ok(c, async () => {
60
+ if (ports.installUpdate === undefined) throw new Error('update install is not available')
61
+ if (version.length === 0) throw new Error('update version is missing')
62
+ await ports.installUpdate(version)
63
+ })
64
+ })
65
+ app.post(httpLayout.updateAck, async (c) => {
66
+ if (ports.ackUpdate === undefined) {
67
+ return c.json({ ok: false, error: { code: 'config-invalid', message: 'update acknowledgement is not available' } }, 400)
68
+ }
69
+ const body = await readBody(c)
70
+ const at = isRecord(body) && typeof body.at === 'number' ? body.at : undefined
71
+ if (at === undefined) {
72
+ return c.json({ ok: false, error: { code: 'config-invalid', message: 'attempt time is required' } }, 400)
73
+ }
74
+ return ok(c, () => {
75
+ if (ports.ackUpdate === undefined) throw new Error('update acknowledgement is not available')
76
+ return ports.ackUpdate(at)
77
+ })
78
+ })
79
+ app.get(httpLayout.providers, async c => c.json({ providers: await providerList(ports) }))
80
+ app.post(httpLayout.activate, (c) => {
81
+ const denied = rejectWithoutToken(c, ports)
82
+ if (denied !== undefined) return denied
83
+ return activate(c, ports)
84
+ })
85
+ app.post(httpLayout.probe, (c) => {
86
+ const denied = rejectWithoutToken(c, ports)
87
+ if (denied !== undefined) return denied
88
+ return probe(c, ports)
89
+ })
90
+ app.get(httpLayout.authorSkill, (c) => {
91
+ if (ports.readAuthorSkill === undefined) {
92
+ return c.json({ ok: false, error: { code: 'config-invalid', message: 'author skill is not available' } }, 400)
93
+ }
94
+ const custom = c.req.query('custom') ?? ''
95
+ const customDirs = custom.length === 0 ? [] : custom.split('\n')
96
+ return ok(c, async () => {
97
+ if (ports.readAuthorSkill === undefined) throw new Error('author skill is not available')
98
+ return ports.readAuthorSkill(customDirs)
99
+ })
100
+ })
101
+ app.post(httpLayout.authorSkill, async (c) => {
102
+ const denied = rejectWithoutToken(c, ports)
103
+ if (denied !== undefined) return denied
104
+ if (ports.writeAuthorSkill === undefined) {
105
+ return c.json({ ok: false, error: { code: 'config-invalid', message: 'author skill is not available' } }, 400)
106
+ }
107
+ const body = await readBody(c)
108
+ const agentIds = isRecord(body) && Array.isArray(body.agentIds) ? body.agentIds.filter((item): item is string => typeof item === 'string') : []
109
+ const customDirs = isRecord(body) && Array.isArray(body.customDirs) ? body.customDirs.filter((item): item is string => typeof item === 'string') : []
110
+ return ok(c, async () => {
111
+ if (ports.writeAuthorSkill === undefined) throw new Error('author skill is not available')
112
+ return ports.writeAuthorSkill(agentIds, customDirs)
113
+ })
114
+ })
115
+ app.post(httpLayout.authorSkillReveal, async (c) => {
116
+ if (ports.revealAuthorSkill === undefined) {
117
+ return c.json({ ok: false, error: { code: 'config-invalid', message: 'author skill is not available' } }, 400)
118
+ }
119
+ const body = await readBody(c)
120
+ const dest = isRecord(body) && typeof body.dest === 'string' ? body.dest : ''
121
+ return ok(c, async () => {
122
+ if (ports.revealAuthorSkill === undefined) throw new Error('author skill is not available')
123
+ await ports.revealAuthorSkill(dest)
124
+ })
125
+ })
126
+ app.get(httpLayout.authorMcp, (c) => {
127
+ if (ports.readAuthorMcp === undefined) {
128
+ return c.json({ ok: false, error: { code: 'config-invalid', message: 'author mcp is not available' } }, 400)
129
+ }
130
+ return ok(c, async () => {
131
+ if (ports.readAuthorMcp === undefined) throw new Error('author mcp is not available')
132
+ return ports.readAuthorMcp()
133
+ })
134
+ })
135
+ app.post(httpLayout.authorMcp, async (c) => {
136
+ const denied = rejectWithoutToken(c, ports)
137
+ if (denied !== undefined) return denied
138
+ if (ports.writeAuthorMcp === undefined) {
139
+ return c.json({ ok: false, error: { code: 'config-invalid', message: 'author mcp is not available' } }, 400)
140
+ }
141
+ const body = await readBody(c)
142
+ const agentIds = isRecord(body) && Array.isArray(body.agentIds) ? body.agentIds.filter((item): item is string => typeof item === 'string') : []
143
+ const description = isRecord(body) && typeof body.description === 'string' ? body.description : ''
144
+ return ok(c, async () => {
145
+ if (ports.writeAuthorMcp === undefined) throw new Error('author mcp is not available')
146
+ return ports.writeAuthorMcp(agentIds, description)
147
+ })
148
+ })
149
+ app.post(httpLayout.authorMcpReveal, async (c) => {
150
+ if (ports.revealAuthorMcp === undefined) {
151
+ return c.json({ ok: false, error: { code: 'config-invalid', message: 'author mcp is not available' } }, 400)
152
+ }
153
+ const body = await readBody(c)
154
+ const dest = isRecord(body) && typeof body.dest === 'string' ? body.dest : ''
155
+ return ok(c, async () => {
156
+ if (ports.revealAuthorMcp === undefined) throw new Error('author mcp is not available')
157
+ await ports.revealAuthorMcp(dest)
158
+ })
159
+ })
160
+ app.post(httpLayout.restart, (c) => {
161
+ const denied = rejectWithoutToken(c, ports)
162
+ if (denied !== undefined) return denied
163
+ if (ports.restart === undefined) {
164
+ return c.json({ ok: false, error: { code: 'config-invalid', message: 'host restart is not available' } }, 400)
165
+ }
166
+ // Delay past response flush. Disposing too soon drops the TCP body and the panel hangs on "Restarting…".
167
+ setTimeout(() => {
168
+ void ports.restart?.().catch((error: unknown) => {
169
+ console.error('host restart failed', error)
170
+ })
171
+ }, 400)
172
+ return c.json({ ok: true, result: { restarting: true } })
173
+ })
174
+ app.get(httpLayout.mcpServers, c => ok(c, () => ports.readMcp()))
175
+ app.post(httpLayout.mcpServers, async (c) => {
176
+ const denied = rejectWithoutToken(c, ports)
177
+ if (denied !== undefined) return denied
178
+ const body = await readBody(c)
179
+ const servers = isRecord(body) ? mcpServers(body.servers) : undefined
180
+ if (servers === undefined) return c.json({ ok: false, error: { code: 'config-invalid', message: 'servers are invalid' } }, 400)
181
+ return ok(c, () => ports.writeMcp(servers))
182
+ })
183
+ app.post(httpLayout.mcpCheck, async (c) => {
184
+ const denied = rejectWithoutToken(c, ports)
185
+ if (denied !== undefined) return denied
186
+ const body = await readBody(c)
187
+ const server = mcpServer(body)
188
+ if (server === undefined) return c.json({ ok: false, error: { code: 'config-invalid', message: 'server is invalid' } }, 400)
189
+ return ok(c, () => ports.checkMcp(server))
190
+ })
191
+ app.post(httpLayout.mcpAdmit, async (c) => {
192
+ const body = await readBody(c)
193
+ const text = isRecord(body) && typeof body.text === 'string' ? body.text : undefined
194
+ if (text === undefined) return c.json({ ok: false, error: { code: 'config-invalid', message: 'mcp import is empty' } }, 400)
195
+ return ok(c, () => Promise.resolve({ servers: ports.admitMcp(text) }))
196
+ })
197
+ app.get(`${httpLayout.mcpImport}/:source`, c => ok(c, () => ports.importMcp(c.req.param('source')).then(servers => ({ servers }))))
198
+ app.get(httpLayout.credentials, (c) => {
199
+ if (!ports.authorized(c.req.header('authorization'))) return authoringDenied(c)
200
+ return ok(c, () => ports.readCredentials())
201
+ })
202
+ app.post(httpLayout.credentials, async (c) => {
203
+ if (!ports.authorized(c.req.header('authorization'))) return authoringDenied(c)
204
+ const body = await readBody(c)
205
+ const name = isRecord(body) && typeof body.name === 'string' ? body.name : undefined
206
+ const secret = isRecord(body) && typeof body.secret === 'string' ? body.secret : undefined
207
+ const description = isRecord(body) && typeof body.description === 'string' ? body.description : ''
208
+ if (name === undefined) {
209
+ return c.json({ ok: false, error: { code: 'credential-invalid', message: 'a credential needs a name' } }, 400)
210
+ }
211
+ return ok(c, () => ports.putCredential(name, description, secret))
212
+ })
213
+ app.post(httpLayout.credentialRemove, async (c) => {
214
+ if (!ports.authorized(c.req.header('authorization'))) return authoringDenied(c)
215
+ const body = await readBody(c)
216
+ const name = isRecord(body) && typeof body.name === 'string' ? body.name : undefined
217
+ if (name === undefined) return c.json({ ok: false, error: { code: 'credential-invalid', message: 'credential name is missing' } }, 400)
218
+ return ok(c, () => ports.removeCredential(name))
219
+ })
220
+ app.post(`${httpLayout.apps}/:appId/${httpLayout.open}`, async (c) => {
221
+ const body = await readBody(c)
222
+ const title = isRecord(body) && typeof body.title === 'string' ? body.title : undefined
223
+ return ok(c, () => ports.open(c.req.param('appId'), title))
224
+ })
225
+ app.post(`${httpLayout.apps}/:appId/${httpLayout.reload}`, c => ok(c, () => ports.reload(c.req.param('appId'))))
226
+ app.get(`${httpLayout.apps}/:appId/${httpLayout.history}`, async (c) => {
227
+ const raw = Number(c.req.query('limit') ?? '')
228
+ const limit = Math.min(Number.isFinite(raw) && raw > 0 ? raw : historyListBound.default, historyListBound.max)
229
+ const nodes = await ports.readHistory(c.req.param('appId'))
230
+ return c.json({ commits: nodes.slice(0, limit) })
231
+ })
232
+ app.get(`${httpLayout.apps}/:appId/${httpLayout.history}/:commitId`, (c) => {
233
+ return ok(c, () => ports.readCommit(c.req.param('appId'), c.req.param('commitId')))
234
+ })
235
+ app.post(`${httpLayout.apps}/:appId/${httpLayout.storage}/${httpLayout.restoreStorage}`, (c) => {
236
+ return ok(c, () => ports.restoreStorage(c.req.param('appId')))
237
+ })
238
+ app.get(`${httpLayout.apps}/:appId/${httpLayout.storage}`, c => ok(c, () => ports.readStorage(c.req.param('appId'))))
239
+ app.get(`${httpLayout.apps}/:appId/${httpLayout.storage}/:table`, (c) => {
240
+ return ok(c, () => ports.readTable(c.req.param('appId'), c.req.param('table')))
241
+ })
242
+ app.get(`${httpLayout.apps}/:appId/${httpLayout.theme}`, c => ok(c, async () => ({
243
+ pin: await ports.readPin(c.req.param('appId')),
244
+ appFile: await ports.appFile(c.req.param('appId')),
245
+ appTheme: await ports.readAppTheme(c.req.param('appId')),
246
+ })))
247
+ app.post(`${httpLayout.apps}/:appId/${httpLayout.theme}`, async (c) => {
248
+ const body = await readBody(c)
249
+ if (!isPin(body)) {
250
+ return c.json({ ok: false, error: { code: 'theme-invalid', message: 'pin is invalid' } }, 400)
251
+ }
252
+ return ok(c, () => ports.setPin(c.req.param('appId'), body))
253
+ })
254
+ app.delete(`${httpLayout.app}/:appId`, c => ok(c, () => ports.deleteApp(c.req.param('appId'))))
255
+ app.post(`${httpLayout.trash}/:appId/${httpLayout.restore}`, (c) => {
256
+ return ok(c, () => ports.undeleteApp(c.req.param('appId')))
257
+ })
258
+ }
259
+
260
+ async function providerList(ports: LoopbackPorts): Promise<unknown[]> {
261
+ return Promise.all(ports.providers.map(async (provider) => {
262
+ const describe = provider.describe?.bind(provider)
263
+ return {
264
+ id: provider.id,
265
+ ...provider.label === undefined ? {} : { label: provider.label },
266
+ fields: describe?.() ?? [],
267
+ models: provider.models === undefined ? [] : await provider.models(),
268
+ }
269
+ }))
270
+ }
271
+
272
+ async function writeConfig(c: Context, ports: LoopbackPorts): Promise<Response> {
273
+ const body = await readBody(c)
274
+ if (!isRecord(body)) {
275
+ return c.json({ ok: false, error: { code: 'config-invalid', message: 'invalid json' } }, 400)
276
+ }
277
+ return ok(c, () => ports.writePolicy(mergePolicy(ports.readPolicy(), body)))
278
+ }
279
+
280
+ async function activate(c: Context, ports: LoopbackPorts): Promise<Response> {
281
+ const body = await readBody(c)
282
+ if (!isRecord(body) || typeof body.id !== 'string') {
283
+ return c.json({ ok: false, error: { code: 'config-invalid', message: 'provider id is required' } }, 400)
284
+ }
285
+ const current = ports.readPolicy()
286
+ if (!isRecord(current)) {
287
+ return c.json({ ok: false, error: { code: 'config-missing', message: 'host config is missing' } }, 400)
288
+ }
289
+ return ok(c, () => ports.writePolicy({
290
+ ...current,
291
+ runtimeProvider: {
292
+ id: body.id,
293
+ ...isRecord(body.config) ? { config: body.config } : {},
294
+ },
295
+ }))
296
+ }
297
+
298
+ async function probe(c: Context, ports: LoopbackPorts): Promise<Response> {
299
+ const body = await readBody(c)
300
+ const id = isRecord(body) ? body.id : undefined
301
+ if (typeof id !== 'string') {
302
+ return c.json({ ok: false, error: { code: 'provider-missing', message: 'provider id is required' } }, 400)
303
+ }
304
+ return ok(c, () => ports.probe(id))
305
+ }
306
+
307
+ function mcpServers(value: unknown): readonly McpEditorServer[] | undefined {
308
+ if (!Array.isArray(value)) return undefined
309
+ const servers: McpEditorServer[] = []
310
+ for (const item of value) {
311
+ const server = mcpServer(item)
312
+ if (server === undefined) return undefined
313
+ servers.push(server)
314
+ }
315
+ return servers
316
+ }
317
+
318
+ function mcpServer(value: unknown): McpEditorServer | undefined {
319
+ if (!isRecord(value) || typeof value.id !== 'string' || value.id.trim().length === 0) return undefined
320
+ const args = stringList(value.args)
321
+ const env = stringRecord(value.env)
322
+ const headers = stringRecord(value.headers)
323
+ const transport = value.transport === 'sse' || value.transport === 'streamable-http' ? value.transport : undefined
324
+ let server: McpEditorServer = { id: value.id }
325
+ if (typeof value.description === 'string' && value.description.trim().length > 0) server = { ...server, description: value.description.trim() }
326
+ if (value.enabled === false || value.disabled === true) server = { ...server, enabled: false }
327
+ if (typeof value.command === 'string') server = { ...server, command: value.command }
328
+ if (args !== undefined) server = { ...server, args }
329
+ if (env !== undefined) server = { ...server, env }
330
+ if (typeof value.url === 'string') server = { ...server, url: value.url }
331
+ if (transport !== undefined) server = { ...server, transport }
332
+ if (headers !== undefined) server = { ...server, headers }
333
+ return server
334
+ }
335
+
336
+ function stringList(value: unknown): string[] | undefined {
337
+ if (!Array.isArray(value) || !value.every(item => typeof item === 'string')) return undefined
338
+ return value
339
+ }
340
+
341
+ function stringRecord(value: unknown): Record<string, string> | undefined {
342
+ if (!isRecord(value)) return undefined
343
+ const record: Record<string, string> = {}
344
+ for (const [key, item] of Object.entries(value)) {
345
+ if (typeof item !== 'string') return undefined
346
+ record[key] = item
347
+ }
348
+ return record
349
+ }
350
+
351
+ /** A route that changes host configuration or spawns a configured process requires the token. */
352
+ function rejectWithoutToken(c: Context, ports: LoopbackPorts): Response | undefined {
353
+ return ports.authorized(c.req.header('authorization')) ? undefined : authoringDenied(c)
354
+ }
355
+
356
+ async function readBody(c: Context): Promise<unknown> {
357
+ try {
358
+ return await c.req.json()
359
+ } catch {
360
+ return undefined
361
+ }
362
+ }
363
+
364
+ /** Bake the host palette into the panel document. Tailwind utilities read these variables. */
365
+ export function injectPanelPaint(
366
+ html: string,
367
+ paint: { readonly style: string; readonly appearance: 'system' | 'light' | 'dark' },
368
+ ): string {
369
+ const mode = paint.appearance === 'system' ? '' : ` data-mode="${paint.appearance}"`
370
+ const script = paint.appearance === 'system'
371
+ ? `<script>
372
+ const applyMode = () => { document.documentElement.dataset.mode = matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light' }
373
+ applyMode()
374
+ matchMedia('(prefers-color-scheme: dark)').addEventListener('change', applyMode)
375
+ </script>`
376
+ : ''
377
+ return html
378
+ .replace('<html>', `<html${mode}>`)
379
+ .replace('</head>', `<style id="mma-theme">${paint.style.replaceAll('</', '<\\/')}</style>${script}</head>`)
380
+ }
@@ -0,0 +1,114 @@
1
+ import { readFileSync } from 'node:fs'
2
+ import { readFile } from 'node:fs/promises'
3
+ import { createRequire } from 'node:module'
4
+
5
+ import type { RuntimeProvider } from '@mohou/runtime-provider'
6
+
7
+ import type { AuthorMcpLayout, AuthorMcpStatus } from '../host/author-mcp.ts'
8
+ import type { AuthorSkillLayout, AuthorSkillStatus } from '../host/author-skill.ts'
9
+ import { probeBrain, type HostPolicy } from '../host/config.ts'
10
+ import type { CredentialListing } from '../credentials/provider.ts'
11
+ import type { McpCheck, McpEditorServer } from '../host/mcp-editor.ts'
12
+ import type { McpLoadFailure } from '../host/mcp.ts'
13
+ import type { AppPin } from '../theme/pin.ts'
14
+ import type { UpdateCheck } from './updates.ts'
15
+
16
+ const require = createRequire(import.meta.url)
17
+
18
+ export interface LoopbackApp {
19
+ readonly id: string
20
+ readonly name: string
21
+ readonly description: string
22
+ readonly version: string
23
+ readonly acronym: string
24
+ readonly tags?: readonly string[]
25
+ readonly createdAt?: string
26
+ readonly updatedAt?: string
27
+ readonly activity?: { readonly openCount: number; readonly lastOpenedAt: string }
28
+ }
29
+
30
+ /** Owner and iframe calls. Author routes do not use this bag. */
31
+ export interface LoopbackPorts {
32
+ list(): Promise<ReadonlyArray<LoopbackApp & { pin: AppPin; appFile: boolean }>>
33
+ listTrash(): Promise<readonly LoopbackApp[]>
34
+ deleteApp(appId: string): Promise<void>
35
+ undeleteApp(appId: string): Promise<unknown>
36
+ open(appId: string, title?: string): Promise<unknown>
37
+ reload(appId: string): Promise<void>
38
+ call(appId: string, method: string, args: unknown, publish?: (value: unknown) => Promise<void> | void): Promise<unknown>
39
+ readPolicy(): HostPolicy
40
+ writePolicy(raw: unknown): Promise<{ policy: HostPolicy; restartRequired: boolean }>
41
+ probe(id: string): ReturnType<typeof probeBrain>
42
+ providers: readonly RuntimeProvider[]
43
+ listPalettes(): Promise<unknown>
44
+ readPin(appId: string): Promise<AppPin>
45
+ setPin(appId: string, pin: AppPin): Promise<AppPin>
46
+ appFile(appId: string): Promise<boolean>
47
+ readAppTheme(appId: string): Promise<{ name?: string; nameZh?: string; swatch: string; style: string } | null>
48
+ readHistory(appId: string): Promise<readonly unknown[]>
49
+ readCommit(appId: string, commitId: string): Promise<unknown>
50
+ readStorage(appId: string): Promise<unknown>
51
+ readTable(appId: string, table: string): Promise<unknown>
52
+ runnerDocument(appId: string): Promise<string>
53
+ entryScript(appId: string): Promise<string>
54
+ stylesheet(appId: string): Promise<string>
55
+ assetFile(appId: string, rest: string): Promise<{ bytes: Uint8Array<ArrayBuffer>; type: string }>
56
+ restoreStorage(appId: string): Promise<void>
57
+ readErrors(appId: string): unknown
58
+ vendorFile(file: string): Promise<string>
59
+ panel?: {
60
+ readonly html: string
61
+ readonly script: string
62
+ readonly paint?: () => Promise<{ readonly style: string; readonly appearance: 'system' | 'light' | 'dark' }>
63
+ }
64
+ authoringToken: string
65
+ authorized(header: string | undefined): boolean
66
+ subscribeHost(listener: (event: unknown) => void): () => void
67
+ subscribeFrames(listener: (event: unknown) => void): () => void
68
+ subscribeApp(appId: string, since: number, listener: (event: unknown) => void): () => void
69
+ checkUpdate(): Promise<UpdateCheck>
70
+ installUpdate?(version: string): Promise<void>
71
+ /** The panel showed the launcher's last attempt. The record is dropped, not shown twice. */
72
+ ackUpdate?(at: number): Promise<void>
73
+ restart?(): Promise<void>
74
+ authorSkill?: AuthorSkillLayout
75
+ readAuthorSkill?(customDirs?: readonly string[]): Promise<AuthorSkillStatus>
76
+ writeAuthorSkill?(agentIds: readonly string[], customDirs: readonly string[]): Promise<AuthorSkillStatus>
77
+ revealAuthorSkill?(dest: string): Promise<void>
78
+ authorMcp?: AuthorMcpLayout
79
+ readAuthorMcp?(): Promise<AuthorMcpStatus>
80
+ writeAuthorMcp?(agentIds: readonly string[], description: string): Promise<AuthorMcpStatus>
81
+ revealAuthorMcp?(dest: string): Promise<void>
82
+ readMcp(): Promise<{ servers: readonly McpEditorServer[]; unresolved: readonly McpLoadFailure[] }>
83
+ writeMcp(servers: readonly McpEditorServer[]): Promise<{ unresolved: readonly McpLoadFailure[] }>
84
+ checkMcp(server: McpEditorServer): Promise<McpCheck>
85
+ admitMcp(text: string): readonly McpEditorServer[]
86
+ importMcp(source: string): Promise<readonly McpEditorServer[]>
87
+ /** Names, descriptions, and whether the store accepts a write. A secret is never on this surface. */
88
+ readCredentials(): Promise<{ credentials: readonly CredentialListing[]; writable: boolean }>
89
+ /**
90
+ * Write one account. An absent or empty secret keeps the stored one, so an owner can fix a
91
+ * description without pasting a secret they may no longer hold. A name with no account and no
92
+ * secret has nothing to keep: `credential-invalid`.
93
+ */
94
+ putCredential(name: string, description: string, secret: string | undefined): Promise<void>
95
+ removeCredential(name: string): Promise<void>
96
+ }
97
+
98
+ export async function readVendorFile(dir: string, name: string): Promise<string> {
99
+ return readFile(`${dir}/${name}`, 'utf8')
100
+ }
101
+
102
+ export function aboutInfo(): { name: string; current: string; platform: string; private: boolean } {
103
+ const parsed = JSON.parse(readFileSync(require.resolve('@mohou/host/package.json'), 'utf8')) as {
104
+ name?: unknown
105
+ version?: unknown
106
+ private?: unknown
107
+ }
108
+ return {
109
+ name: typeof parsed.name === 'string' ? parsed.name : '',
110
+ current: typeof parsed.version === 'string' ? parsed.version : '',
111
+ platform: process.platform,
112
+ private: parsed.private === true,
113
+ }
114
+ }
@@ -0,0 +1,53 @@
1
+ import type { Context } from 'hono'
2
+
3
+ import type { AppPin } from '../theme/pin.ts'
4
+
5
+ export async function ok(c: Context, work: () => Promise<unknown>): Promise<Response> {
6
+ try {
7
+ return c.json({ ok: true, result: await work() })
8
+ } catch (error) {
9
+ return c.json({ ok: false, error: errorBody(error) }, 400)
10
+ }
11
+ }
12
+
13
+ /** The one refusal for a route that requires the authoring token. */
14
+ export function authoringDenied(c: Context): Response {
15
+ return c.json({ ok: false, error: { code: 'authoring-token', message: 'authoring token is missing or wrong' } }, 401)
16
+ }
17
+
18
+ export function errorBody(error: unknown): { code: string; message: string } {
19
+ const code = typeof error === 'object' && error !== null && 'code' in error && typeof error.code === 'string'
20
+ ? error.code
21
+ : 'failed'
22
+ const message = error instanceof Error ? error.message : 'request failed'
23
+ return { code, message }
24
+ }
25
+
26
+ export function isRecord(value: unknown): value is Record<string, unknown> {
27
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
28
+ }
29
+
30
+ export function mergePolicy(current: unknown, body: Record<string, unknown>): Record<string, unknown> {
31
+ const base = isRecord(current) ? current : {}
32
+ const runtimeRoot = typeof base.runtimeRoot === 'string' ? base.runtimeRoot : undefined
33
+ const provider = isRecord(body.runtimeProvider) ? body.runtimeProvider : undefined
34
+ const previous = isRecord(base.runtimeProvider) ? base.runtimeProvider : undefined
35
+ return {
36
+ ...base,
37
+ ...body,
38
+ ...runtimeRoot === undefined ? {} : { runtimeRoot },
39
+ ...provider === undefined || previous === undefined ? {} : {
40
+ runtimeProvider: {
41
+ ...previous,
42
+ ...provider,
43
+ ...provider.config === undefined && previous.config !== undefined ? { config: previous.config } : {},
44
+ },
45
+ },
46
+ }
47
+ }
48
+
49
+ export function isPin(value: unknown): value is AppPin {
50
+ if (!isRecord(value) || typeof value.kind !== 'string') return false
51
+ if (value.kind === 'default' || value.kind === 'follow-host' || value.kind === 'app-file') return true
52
+ return value.kind === 'palette' && typeof value.id === 'string' && value.id.length > 0
53
+ }
@@ -0,0 +1,19 @@
1
+ /** Codes the loopback surface emits that are not a tool code. Callers match `code`. */
2
+
3
+ export const routeCodes = ['not-found', 'unknown-vendor'] as const
4
+
5
+ export type RouteCode = (typeof routeCodes)[number]
6
+
7
+ export class RouteError extends Error {
8
+ readonly code: RouteCode
9
+
10
+ /**
11
+ * @param code - one of {@link routeCodes}
12
+ * @param message - human text; not the match key
13
+ */
14
+ constructor(code: RouteCode, message: string, options?: { cause?: unknown }) {
15
+ super(message, options)
16
+ this.name = 'RouteError'
17
+ this.code = code
18
+ }
19
+ }