@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,402 @@
1
+ import { themeTokens } from '../theme/tokens.ts'
2
+
3
+ /** Values the runner script may close over. Caps on a query arrive in the message. */
4
+ export interface ViewBridgeDeps {
5
+ readonly appId: string
6
+ readonly evalUrl: string
7
+ readonly tokens?: readonly string[]
8
+ }
9
+
10
+ interface BridgeBox {
11
+ left: number
12
+ top: number
13
+ width: number
14
+ height: number
15
+ }
16
+
17
+ interface BridgeElement {
18
+ nodeType: number
19
+ tagName: string
20
+ id: string
21
+ /** An SVG element exposes `SVGAnimatedString` here, not a string. */
22
+ className: string | { readonly baseVal: string }
23
+ isConnected: boolean
24
+ parentElement: BridgeElement | null
25
+ firstElementChild: BridgeElement | null
26
+ nextElementSibling: BridgeElement | null
27
+ previousElementSibling: BridgeElement | null
28
+ firstChild: { nodeType: number; nodeValue: string | null; nextSibling: BridgeElement['firstChild'] } | null
29
+ getBoundingClientRect(): BridgeBox
30
+ querySelector(selector: string): BridgeElement | null
31
+ querySelectorAll(selector: string): ArrayLike<BridgeElement>
32
+ }
33
+
34
+ interface ThemeRoot {
35
+ setAttribute(name: string, value: string): void
36
+ style: { setProperty(name: string, value: string): void }
37
+ }
38
+
39
+ interface BridgeDocument {
40
+ body: BridgeElement | null
41
+ documentElement?: ThemeRoot | null
42
+ querySelector(selector: string): BridgeElement | null
43
+ querySelectorAll(selector: string): ArrayLike<BridgeElement>
44
+ getElementById?(id: string): { textContent: string } | null
45
+ }
46
+
47
+ interface BridgeMessage {
48
+ source: unknown
49
+ origin: string
50
+ data: unknown
51
+ }
52
+
53
+ interface BridgeWindow {
54
+ parent: unknown
55
+ document: BridgeDocument
56
+ addEventListener(type: string, listener: (event: BridgeMessage) => void): void
57
+ getComputedStyle?(element: BridgeElement): { display: string }
58
+ CSS?: { escape(value: string): string }
59
+ fetch(url: string, init: { method: string; headers: Record<string, string>; body: string }): Promise<unknown>
60
+ }
61
+
62
+ interface EvalMessage {
63
+ type: 'app:eval'
64
+ appId: string
65
+ requestId: string
66
+ code: string
67
+ budgetMs: number
68
+ maxBytes: number
69
+ maxNodes: number
70
+ maxDepth: number
71
+ }
72
+
73
+ /**
74
+ * Install the three view helpers and answer `app:eval` from the parent origin.
75
+ * No imports and no closed-over module state: the runner emits this function as text.
76
+ * @param deps - app id and the diagnostic answer URL
77
+ */
78
+ export function installViewBridge(deps: ViewBridgeDeps): void {
79
+ const realm = globalThis as { window?: BridgeWindow }
80
+ let win: BridgeWindow
81
+ if (realm.window !== undefined) win = realm.window
82
+ else win = globalThis
83
+ const names = { query: '$', queryAll: '$$', selector: 'selector' } as const
84
+ let parentOrigin: string | undefined
85
+
86
+ function escapeIdent(value: string): string {
87
+ if (win.CSS) return win.CSS.escape(value)
88
+ return value.replace(/([^a-zA-Z0-9_-])/g, '\\$1')
89
+ }
90
+
91
+ let matched = 0
92
+
93
+ function query(selector: string, root?: BridgeElement | null): BridgeElement | null {
94
+ const base = root ?? win.document
95
+ const found = base.querySelector(selector)
96
+ if (found !== null) matched += 1
97
+ return found
98
+ }
99
+
100
+ function queryAll(selector: string, root?: BridgeElement | null): BridgeElement[] {
101
+ const base = root ?? win.document
102
+ const found = base.querySelectorAll(selector)
103
+ const list: BridgeElement[] = []
104
+ for (let index = 0; index < found.length; index += 1) {
105
+ const item = found[index]
106
+ if (item !== undefined) list.push(item)
107
+ }
108
+ matched += list.length
109
+ return list
110
+ }
111
+
112
+ function selectorOf(element: BridgeElement): string {
113
+ if (element.nodeType !== 1) return ''
114
+ const tag = element.tagName.toLowerCase()
115
+ if (element.id) {
116
+ const byId = `#${escapeIdent(element.id)}`
117
+ if (queryAll(byId).length === 1) return byId
118
+ }
119
+ const classes = classNames(element)
120
+ let dots = ''
121
+ for (let index = 0; index < classes.length; index += 1) {
122
+ dots += `.${escapeIdent(classes[index] ?? '')}`
123
+ if (queryAll(tag + dots).length === 1) return tag + dots
124
+ }
125
+ const parts: string[] = []
126
+ let node: BridgeElement | null = element
127
+ while (node !== null && node.nodeType === 1 && node !== win.document.body) {
128
+ if (node.id) {
129
+ parts.unshift(`#${escapeIdent(node.id)}`)
130
+ return parts.join(' > ')
131
+ }
132
+ let nth = 1
133
+ for (let sibling = node.previousElementSibling; sibling !== null; sibling = sibling.previousElementSibling) {
134
+ if (sibling.tagName === node.tagName) nth += 1
135
+ }
136
+ parts.unshift(`${node.tagName.toLowerCase()}:nth-of-type(${nth})`)
137
+ node = node.parentElement
138
+ }
139
+ return parts.join(' > ')
140
+ }
141
+
142
+ const helpers = {
143
+ [names.query]: query,
144
+ [names.queryAll]: queryAll,
145
+ [names.selector]: selectorOf,
146
+ }
147
+ ;(win as BridgeWindow & { mma?: typeof helpers }).mma = helpers
148
+
149
+ win.addEventListener('message', (event) => {
150
+ if (event.source !== win.parent) return
151
+ if (parentOrigin === undefined) parentOrigin = event.origin
152
+ if (event.origin !== parentOrigin) return
153
+ if (applyTheme(event.data)) return
154
+ const data = readEval(event.data)
155
+ if (data === undefined || data.appId !== deps.appId) return
156
+ void answer(data)
157
+ })
158
+
159
+ async function answer(data: EvalMessage): Promise<void> {
160
+ const budget = {
161
+ lines: [] as string[],
162
+ bytes: 0,
163
+ visited: 0,
164
+ matched,
165
+ dropped: 0,
166
+ stopped: '' as '' | 'bytes' | 'nodes' | 'depth' | 'timeout',
167
+ maxBytes: data.maxBytes,
168
+ maxNodes: data.maxNodes,
169
+ maxDepth: data.maxDepth,
170
+ deadline: Date.now() + data.budgetMs,
171
+ }
172
+ let error: string | undefined
173
+ try {
174
+ // The query is an async function body supplied by the owner. It is not a string the host builds.
175
+ // oxlint-disable-next-line typescript/no-implied-eval
176
+ const run = new Function('mma', `return (async () => {\n${data.code}\n})()`) as (mma: typeof helpers) => Promise<unknown>
177
+ const value = await run(helpers)
178
+ renderValue(value, budget)
179
+ } catch (caught) {
180
+ error = caught instanceof Error ? caught.message : 'view query failed'
181
+ }
182
+ if (budget.stopped !== '') pushLine(budget, `truncated: ${budget.stopped}`, true)
183
+ const body: Record<string, unknown> = {
184
+ requestId: data.requestId,
185
+ result: budget.lines.join('\n'),
186
+ view: 'live',
187
+ bytes: budget.bytes,
188
+ truncated: budget.stopped !== '',
189
+ stoppedBy: budget.stopped === '' ? null : budget.stopped,
190
+ visited: budget.visited,
191
+ matched,
192
+ budgetMs: data.budgetMs,
193
+ }
194
+ if (error !== undefined) body.error = error
195
+ await win.fetch(deps.evalUrl, {
196
+ method: 'POST',
197
+ headers: { 'content-type': 'application/json' },
198
+ body: JSON.stringify(body),
199
+ })
200
+ }
201
+
202
+ function renderValue(value: unknown, budget: Budget): void {
203
+ if (isElement(value)) {
204
+ walk(value, 0, '', budget)
205
+ return
206
+ }
207
+ if (isElementList(value)) {
208
+ for (let index = 0; index < value.length; index += 1) {
209
+ const item = value[index]
210
+ if (item === undefined) continue
211
+ walk(item, 0, '', budget)
212
+ if (budget.stopped !== '') return
213
+ }
214
+ return
215
+ }
216
+ pushLine(budget, textOf(value), false)
217
+ }
218
+
219
+ function walk(element: BridgeElement, depth: number, indent: string, budget: Budget): void {
220
+ if (budget.stopped !== '') return
221
+ if (Date.now() > budget.deadline) {
222
+ budget.stopped = 'timeout'
223
+ return
224
+ }
225
+ budget.visited += 1
226
+ if (budget.visited > budget.maxNodes) {
227
+ budget.stopped = 'nodes'
228
+ budget.visited -= 1
229
+ return
230
+ }
231
+ if (depth > budget.maxDepth) {
232
+ budget.stopped = 'depth'
233
+ budget.visited -= 1
234
+ return
235
+ }
236
+ pushLine(budget, indent + describe(element), false)
237
+ for (let child = element.firstElementChild; child !== null; child = child.nextElementSibling) {
238
+ walk(child, depth + 1, `${indent} `, budget)
239
+ }
240
+ }
241
+
242
+ function describe(element: BridgeElement): string {
243
+ let line = element.tagName.toLowerCase()
244
+ if (element.id) line += `#${element.id}`
245
+ const classes = classNames(element)
246
+ if (classes.length > 0) line += `.${classes.join('.')}`
247
+ if (!element.isConnected) return `${line} detached`
248
+ const box = element.getBoundingClientRect()
249
+ line += ` x=${Math.round(box.left)} y=${Math.round(box.top)} w=${Math.round(box.width)} h=${Math.round(box.height)}`
250
+ const kids = childCount(element)
251
+ if (kids > 0) line += ` (${kids} ${kids === 1 ? 'child' : 'children'})`
252
+ else {
253
+ const text = ownText(element)
254
+ if (text.length > 0) line += ` ${JSON.stringify(text)}`
255
+ }
256
+ const display = win.getComputedStyle?.(element).display
257
+ if (display === 'none') line += ' display:none'
258
+ return line
259
+ }
260
+
261
+ function pushLine(budget: Budget, text: string, force: boolean): void {
262
+ if (budget.stopped !== '' && !force) {
263
+ budget.dropped += 1
264
+ return
265
+ }
266
+ const cost = text.length + 1
267
+ if (!force && budget.bytes + cost > budget.maxBytes) {
268
+ budget.stopped = 'bytes'
269
+ budget.dropped += 1
270
+ return
271
+ }
272
+ budget.bytes += cost
273
+ budget.lines.push(text)
274
+ }
275
+
276
+ function classNames(element: BridgeElement): string[] {
277
+ const raw = element.className
278
+ const text = typeof raw === 'string' ? raw : raw.baseVal
279
+ return text.trim().split(/\s+/).filter((name) => {
280
+ return name.length > 0
281
+ })
282
+ }
283
+
284
+ function childCount(element: BridgeElement): number {
285
+ let count = 0
286
+ for (let child = element.firstElementChild; child !== null; child = child.nextElementSibling) count += 1
287
+ return count
288
+ }
289
+
290
+ function ownText(element: BridgeElement): string {
291
+ let text = ''
292
+ for (let node = element.firstChild; node !== null; node = node.nextSibling) {
293
+ if (node.nodeType === 3 && node.nodeValue !== null) text += node.nodeValue
294
+ }
295
+ return text.replace(/\s+/g, ' ').trim()
296
+ }
297
+
298
+ function isElement(value: unknown): value is BridgeElement {
299
+ return typeof value === 'object' && value !== null && 'tagName' in value && 'nodeType' in value && value.nodeType === 1
300
+ }
301
+
302
+ function isElementList(value: unknown): value is BridgeElement[] {
303
+ return Array.isArray(value) && value.every(isElement)
304
+ }
305
+
306
+ function textOf(value: unknown): string {
307
+ if (typeof value === 'function') return `[Function ${value.name || 'anonymous'}]`
308
+ if (typeof value === 'string') return JSON.stringify(value)
309
+ try {
310
+ return JSON.stringify(value)
311
+ } catch {
312
+ return Object.prototype.toString.call(value)
313
+ }
314
+ }
315
+
316
+ function applyTheme(value: unknown): boolean {
317
+ if (typeof value !== 'object' || value === null) return false
318
+ if (!('type' in value) || value.type !== 'theme') return false
319
+ const root = win.document.documentElement
320
+ if (root == null) return true
321
+ if ('mode' in value && (value.mode === 'light' || value.mode === 'dark') && typeof root.setAttribute === 'function') {
322
+ root.setAttribute('data-mode', value.mode)
323
+ }
324
+ if ('style' in value && typeof value.style === 'string') {
325
+ const painted = win.document.getElementById?.('mma-first-paint')
326
+ if (painted != null) painted.textContent = value.style
327
+ }
328
+ if (!('variables' in value) || typeof value.variables !== 'object' || value.variables === null) return true
329
+ const variables = value.variables as Record<string, unknown>
330
+ for (const key of Object.keys(variables)) {
331
+ const item = variables[key]
332
+ if (typeof item !== 'string' || !knownToken(key)) continue
333
+ root.style.setProperty(key, item)
334
+ }
335
+ return true
336
+ }
337
+
338
+ function knownToken(name: string): boolean {
339
+ if (!name.startsWith('--')) return false
340
+ const tokens = deps.tokens
341
+ if (tokens === undefined) return false
342
+ const bare = name.slice(2)
343
+ for (let index = 0; index < tokens.length; index += 1) {
344
+ if (tokens[index] === bare) return true
345
+ }
346
+ return false
347
+ }
348
+
349
+ function readEval(value: unknown): EvalMessage | undefined {
350
+ if (typeof value !== 'object' || value === null) return undefined
351
+ if (!('type' in value) || value.type !== 'app:eval') return undefined
352
+ if (!('appId' in value) || typeof value.appId !== 'string') return undefined
353
+ if (!('requestId' in value) || typeof value.requestId !== 'string') return undefined
354
+ if (!('code' in value) || typeof value.code !== 'string') return undefined
355
+ if (!('budgetMs' in value) || typeof value.budgetMs !== 'number') return undefined
356
+ if (!('maxBytes' in value) || typeof value.maxBytes !== 'number') return undefined
357
+ if (!('maxNodes' in value) || typeof value.maxNodes !== 'number') return undefined
358
+ if (!('maxDepth' in value) || typeof value.maxDepth !== 'number') return undefined
359
+ return {
360
+ type: 'app:eval',
361
+ appId: value.appId,
362
+ requestId: value.requestId,
363
+ code: value.code,
364
+ budgetMs: value.budgetMs,
365
+ maxBytes: value.maxBytes,
366
+ maxNodes: value.maxNodes,
367
+ maxDepth: value.maxDepth,
368
+ }
369
+ }
370
+ }
371
+
372
+ interface Budget {
373
+ lines: string[]
374
+ bytes: number
375
+ visited: number
376
+ matched: number
377
+ dropped: number
378
+ stopped: '' | 'bytes' | 'nodes' | 'depth' | 'timeout'
379
+ maxBytes: number
380
+ maxNodes: number
381
+ maxDepth: number
382
+ deadline: number
383
+ }
384
+
385
+ const bridgeHelpers = new Set(['__name'])
386
+
387
+ /** Source the runner inlines. Caps stay on the query message, not in this string. */
388
+ export function renderViewBridge(deps: ViewBridgeDeps): string {
389
+ const payload = {
390
+ appId: deps.appId,
391
+ evalUrl: deps.evalUrl,
392
+ tokens: deps.tokens ?? themeTokens,
393
+ }
394
+ const source = `{\nconst __name = (target) => target;\n(${installViewBridge.toString()})(${JSON.stringify(payload)})\n}`
395
+ for (const match of source.matchAll(/\b(__[A-Za-z]+)\s*\(/g)) {
396
+ const name = match[1]
397
+ if (name !== undefined && !bridgeHelpers.has(name)) {
398
+ throw new Error(`view bridge helper is not declared: ${name}`)
399
+ }
400
+ }
401
+ return source
402
+ }
@@ -0,0 +1,213 @@
1
+ import type { HostEvent } from './host-events.ts'
2
+
3
+ /** View states. Only `stuck` means the main thread is blocked. */
4
+ export const viewStates = ['live', 'not-open', 'runner-not-booted', 'pending', 'stuck'] as const
5
+
6
+ export type ViewState = (typeof viewStates)[number]
7
+
8
+ export type ViewStop = 'bytes' | 'nodes' | 'depth' | 'timeout'
9
+
10
+ export interface ViewEnvelope {
11
+ result: unknown
12
+ view: ViewState
13
+ tookMs: number
14
+ bytes: number
15
+ truncated: boolean
16
+ stoppedBy: ViewStop | null
17
+ visited: number
18
+ matched: number
19
+ dropped?: number
20
+ error?: string
21
+ hint?: string
22
+ budgetMs: number
23
+ }
24
+
25
+ /** Query wait when the caller omits one. Host policy, not a locked number. */
26
+ export const DEFAULT_VIEW_TIMEOUT_MS = 3_000
27
+
28
+ /** Byte cap when the caller omits one. Host policy, not a locked number. */
29
+ export const DEFAULT_VIEW_MAX_BYTES = 64_000
30
+
31
+ /** Node cap when the caller omits one. Host policy, not a locked number. */
32
+ export const DEFAULT_VIEW_MAX_NODES = 200
33
+
34
+ /** Depth cap when the caller omits one. Host policy, not a locked number. */
35
+ export const DEFAULT_VIEW_MAX_DEPTH = 8
36
+
37
+ /** Omitted `code` reads the document root. No fourth helper name. */
38
+ export const DEFAULT_VIEW_CODE = 'return mma.$("#root")'
39
+
40
+ interface PendingQuery {
41
+ appId: string
42
+ budgetMs: number
43
+ started: number
44
+ settle: (envelope: ViewEnvelope) => void
45
+ }
46
+
47
+ /**
48
+ * One in-flight view query per id. A late, duplicate, or wrong-app reply cannot settle it.
49
+ * The iframe runs the code. This module only routes it.
50
+ */
51
+ export function createViewQueries(defaults?: { timeoutMs?: number; maxBytes?: number }) {
52
+ const alive = new Set<string>()
53
+ const pending = new Map<string, PendingQuery>()
54
+ const timeoutMs = defaults?.timeoutMs ?? DEFAULT_VIEW_TIMEOUT_MS
55
+ const maxBytes = defaults?.maxBytes ?? DEFAULT_VIEW_MAX_BYTES
56
+ let seq = 0
57
+
58
+ return {
59
+ markAlive(appId: string) {
60
+ alive.add(appId)
61
+ },
62
+ forgetAlive(appId: string) {
63
+ alive.delete(appId)
64
+ },
65
+ /** A client that has no frame for this app. Pending queries settle `not-open`, and the stale flag goes. */
66
+ absent(appId: string) {
67
+ alive.delete(appId)
68
+ for (const [id, query] of [...pending]) {
69
+ if (query.appId !== appId) continue
70
+ pending.delete(id)
71
+ query.settle(envelope({
72
+ view: 'not-open',
73
+ budgetMs: query.budgetMs,
74
+ hint: 'open the app; no frame is mounted',
75
+ tookMs: Date.now() - query.started,
76
+ }))
77
+ }
78
+ },
79
+ ask(input: {
80
+ appId: string
81
+ code?: string
82
+ timeoutMs?: number
83
+ maxBytes?: number
84
+ maxNodes?: number
85
+ maxDepth?: number
86
+ connected: boolean
87
+ publish: (event: HostEvent) => void
88
+ }): Promise<ViewEnvelope> {
89
+ const budgetMs = input.timeoutMs ?? timeoutMs
90
+ const bytes = input.maxBytes ?? maxBytes
91
+ const nodes = input.maxNodes ?? DEFAULT_VIEW_MAX_NODES
92
+ const depth = input.maxDepth ?? DEFAULT_VIEW_MAX_DEPTH
93
+ const code = input.code ?? DEFAULT_VIEW_CODE
94
+ if (!input.connected) {
95
+ return Promise.resolve(envelope({
96
+ view: 'not-open',
97
+ budgetMs,
98
+ hint: 'open the app; no panel is attached',
99
+ tookMs: 0,
100
+ }))
101
+ }
102
+ if (alive.has(input.appId) && [...pending.values()].some(query => query.appId === input.appId)) {
103
+ return Promise.resolve(envelope({
104
+ view: 'pending',
105
+ budgetMs,
106
+ hint: 'the expression is still running',
107
+ tookMs: 0,
108
+ }))
109
+ }
110
+ seq += 1
111
+ const requestId = `view-${seq}`
112
+ return new Promise((resolve) => {
113
+ const timer = setTimeout(() => {
114
+ pending.delete(requestId)
115
+ resolve(envelope({
116
+ view: alive.has(input.appId) ? 'stuck' : 'runner-not-booted',
117
+ budgetMs,
118
+ stoppedBy: 'timeout',
119
+ hint: alive.has(input.appId)
120
+ ? 'the main thread is blocked; reload the tab'
121
+ : 'no live frame for this app; open or reload it in the panel',
122
+ tookMs: budgetMs,
123
+ }))
124
+ }, budgetMs)
125
+ pending.set(requestId, {
126
+ appId: input.appId,
127
+ budgetMs,
128
+ started: Date.now(),
129
+ settle: (value) => {
130
+ clearTimeout(timer)
131
+ resolve(value)
132
+ },
133
+ })
134
+ input.publish({
135
+ type: 'app:eval',
136
+ appId: input.appId,
137
+ requestId,
138
+ code,
139
+ budgetMs,
140
+ maxBytes: bytes,
141
+ maxNodes: nodes,
142
+ maxDepth: depth,
143
+ })
144
+ })
145
+ },
146
+ answer(requestId: string, appId: string, raw: unknown): boolean {
147
+ const query = pending.get(requestId)
148
+ if (query === undefined || query.appId !== appId) return false
149
+ pending.delete(requestId)
150
+ query.settle(envelopeFromAnswer(raw, query.budgetMs, Date.now() - query.started))
151
+ return true
152
+ },
153
+ }
154
+ }
155
+
156
+ function envelope(input: {
157
+ view: ViewState
158
+ budgetMs: number
159
+ tookMs: number
160
+ hint?: string
161
+ stoppedBy?: ViewStop
162
+ result?: unknown
163
+ error?: string
164
+ }): ViewEnvelope {
165
+ return {
166
+ result: input.result ?? null,
167
+ view: input.view,
168
+ tookMs: input.tookMs,
169
+ bytes: 0,
170
+ truncated: input.stoppedBy !== undefined,
171
+ stoppedBy: input.stoppedBy ?? null,
172
+ visited: 0,
173
+ matched: 0,
174
+ budgetMs: input.budgetMs,
175
+ ...input.hint === undefined ? {} : { hint: input.hint },
176
+ ...input.error === undefined ? {} : { error: input.error },
177
+ }
178
+ }
179
+
180
+ function envelopeFromAnswer(raw: unknown, budgetMs: number, tookMs: number): ViewEnvelope {
181
+ if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) {
182
+ return envelope({ view: 'live', budgetMs, tookMs, result: raw })
183
+ }
184
+ const view = 'view' in raw && isViewState(raw.view) ? raw.view : 'live'
185
+ const stoppedBy = 'stoppedBy' in raw && isStop(raw.stoppedBy) ? raw.stoppedBy : null
186
+ return {
187
+ result: 'result' in raw ? raw.result : null,
188
+ view,
189
+ tookMs,
190
+ bytes: numberField(raw, 'bytes'),
191
+ truncated: stoppedBy !== null,
192
+ stoppedBy,
193
+ visited: numberField(raw, 'visited'),
194
+ matched: numberField(raw, 'matched'),
195
+ budgetMs,
196
+ ...'error' in raw && typeof raw.error === 'string' ? { error: raw.error } : {},
197
+ ...'hint' in raw && typeof raw.hint === 'string' ? { hint: raw.hint } : {},
198
+ ...'dropped' in raw && typeof raw.dropped === 'number' ? { dropped: raw.dropped } : {},
199
+ }
200
+ }
201
+
202
+ function numberField(raw: object, key: string): number {
203
+ const value = key in raw ? raw[key as keyof typeof raw] : undefined
204
+ return typeof value === 'number' && Number.isFinite(value) ? value : 0
205
+ }
206
+
207
+ function isViewState(value: unknown): value is ViewState {
208
+ return typeof value === 'string' && (viewStates as readonly string[]).includes(value)
209
+ }
210
+
211
+ function isStop(value: unknown): value is ViewStop {
212
+ return value === 'bytes' || value === 'nodes' || value === 'depth' || value === 'timeout'
213
+ }
@@ -0,0 +1,28 @@
1
+ /** Codes the file tools emit. */
2
+
3
+ export const fileToolCodes = [
4
+ 'path-escape',
5
+ 'path-is-directory',
6
+ 'file-missing',
7
+ 'edit-not-unique',
8
+ 'manifest-protected',
9
+ 'app-not-registered',
10
+ ] as const
11
+
12
+ /** A file-tool failure. Callers match `code`. */
13
+ export type FileToolCode = (typeof fileToolCodes)[number]
14
+
15
+ /** Failure from an authoring file tool. The message is for a person. */
16
+ export class FileToolError extends Error {
17
+ readonly code: FileToolCode
18
+
19
+ /**
20
+ * @param code - one of {@link fileToolCodes}
21
+ * @param message - human text; not the match key
22
+ */
23
+ constructor(code: FileToolCode, message: string) {
24
+ super(message)
25
+ this.name = 'FileToolError'
26
+ this.code = code
27
+ }
28
+ }
@@ -0,0 +1,23 @@
1
+ import { hostLayout } from '../host/layout.ts'
2
+ import { installLayout } from '../install/layout.ts'
3
+ import { storageLayout } from '../storage/layout.ts'
4
+ import { themeLayout } from '../theme/layout.ts'
5
+
6
+ /** Git metadata directory. History owns the repo; other modules only skip the name. */
7
+ export const gitDir = '.git'
8
+
9
+ /**
10
+ * Generated directories a snapshot and a file listing both skip.
11
+ * Host writes `.autogen`. It does not create `dist` or `.cache`. `coverage` is not in this list.
12
+ */
13
+ export const buildCacheDirs = ['dist', '.cache', '.autogen'] as const
14
+
15
+ /** Names a snapshot and a file listing both skip. One table. */
16
+ export const snapshotSkip = new Set<string>([
17
+ gitDir,
18
+ storageLayout.dir,
19
+ installLayout.modules,
20
+ themeLayout.appPin,
21
+ hostLayout.logs,
22
+ ...buildCacheDirs,
23
+ ])