@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,113 @@
1
+ import { chmod, mkdir, readFile, writeFile } from 'node:fs/promises'
2
+ import path from 'node:path'
3
+
4
+ import { CredentialError } from './codes.ts'
5
+ import { admitCredentialName, type CredentialProvider } from './provider.ts'
6
+
7
+ interface CredentialEntry {
8
+ readonly description: string
9
+ readonly secret: string
10
+ }
11
+
12
+ /**
13
+ * One JSON file as one credential store. Read and write reach the same file, so the two sides
14
+ * cannot drift. A missing file is an empty source.
15
+ * A present bad file throws `credential-unreadable` and is not rewritten.
16
+ * @param file - absolute path. The name lives in `homeLayout`.
17
+ */
18
+ export function createFileCredentials(file: string): CredentialProvider {
19
+ return {
20
+ writable: true,
21
+ async list() {
22
+ const entries = await readCredentialFile(file)
23
+ return [...entries.entries()]
24
+ .sort(([left], [right]) => left < right ? -1 : left > right ? 1 : 0)
25
+ .map(([name, entry]) => ({ name, description: entry.description }))
26
+ },
27
+ async get(name) {
28
+ admitCredentialName(name)
29
+ const entries = await readCredentialFile(file)
30
+ return entries.get(name)?.secret
31
+ },
32
+ put: (name, description, secret) => writeFileCredential(file, name, description, secret),
33
+ remove: name => deleteFileCredential(file, name),
34
+ }
35
+ }
36
+
37
+ /**
38
+ * Write one account into the JSON source. Not a method on the read port.
39
+ * A present bad file is not overwritten.
40
+ * @param file - absolute path
41
+ * @param name - owner-chosen account name
42
+ * @param description - text the author can recognize
43
+ * @param secret - the one value
44
+ */
45
+ export async function writeFileCredential(file: string, name: string, description: string, secret: string): Promise<void> {
46
+ admitCredentialName(name)
47
+ const entries = await readCredentialFile(file)
48
+ const next = new Map(entries)
49
+ next.set(name, { description, secret })
50
+ await writeEntries(file, next)
51
+ }
52
+
53
+ /**
54
+ * Remove one account from the JSON source. A missing name leaves the file unchanged.
55
+ * @param file - absolute path
56
+ * @param name - owner-chosen account name
57
+ */
58
+ export async function deleteFileCredential(file: string, name: string): Promise<void> {
59
+ admitCredentialName(name)
60
+ const entries = await readCredentialFile(file)
61
+ if (!entries.has(name)) return
62
+ const next = new Map(entries)
63
+ next.delete(name)
64
+ await writeEntries(file, next)
65
+ }
66
+
67
+ async function writeEntries(file: string, entries: ReadonlyMap<string, CredentialEntry>): Promise<void> {
68
+ await mkdir(path.dirname(file), { recursive: true })
69
+ const body: Record<string, CredentialEntry> = {}
70
+ for (const [name, entry] of entries) body[name] = entry
71
+ // The file holds secrets in the clear, so it is owner-only. `mode` covers a new file and the
72
+ // chmod tightens one that an earlier version wrote world-readable.
73
+ await writeFile(file, `${JSON.stringify(body, null, 2)}\n`, { mode: 0o600 })
74
+ await chmod(file, 0o600).catch(() => undefined)
75
+ }
76
+
77
+ async function readCredentialFile(file: string): Promise<ReadonlyMap<string, CredentialEntry>> {
78
+ let text: string
79
+ try {
80
+ text = await readFile(file, 'utf8')
81
+ } catch (error) {
82
+ if (isNotFound(error)) return new Map()
83
+ throw new CredentialError('credential-unreadable', 'credential file cannot be read', { cause: error })
84
+ }
85
+ let raw: unknown
86
+ try {
87
+ raw = JSON.parse(text)
88
+ } catch (error) {
89
+ throw new CredentialError('credential-unreadable', 'credential file is not JSON', { cause: error })
90
+ }
91
+ return resolveCredentialFile(raw)
92
+ }
93
+
94
+ function resolveCredentialFile(raw: unknown): ReadonlyMap<string, CredentialEntry> {
95
+ if (!isRecord(raw)) throw new CredentialError('credential-unreadable', 'credential file must be an object')
96
+ const entries = new Map<string, CredentialEntry>()
97
+ for (const [name, value] of Object.entries(raw)) {
98
+ if (name === '') throw new CredentialError('credential-unreadable', 'credential file has an empty name')
99
+ if (!isRecord(value) || typeof value.description !== 'string' || typeof value.secret !== 'string') {
100
+ throw new CredentialError('credential-unreadable', `credential file entry is incomplete: ${name}`)
101
+ }
102
+ entries.set(name, { description: value.description, secret: value.secret })
103
+ }
104
+ return entries
105
+ }
106
+
107
+ function isRecord(value: unknown): value is Record<string, unknown> {
108
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
109
+ }
110
+
111
+ function isNotFound(error: unknown): boolean {
112
+ return typeof error === 'object' && error !== null && 'code' in error && error.code === 'ENOENT'
113
+ }
@@ -0,0 +1,66 @@
1
+ import { CredentialError } from './codes.ts'
2
+
3
+ /** One account an author can recognize. No secret. */
4
+ export interface CredentialListing {
5
+ readonly name: string
6
+ readonly description: string
7
+ }
8
+
9
+ /**
10
+ * One credential store. Read and write are one abstraction, so Shell swaps the implementation in
11
+ * one place: the built-in JSON file today, a system keychain later.
12
+ *
13
+ * App code does not see this interface. `ctx.credentials` projects `get` alone.
14
+ */
15
+ export interface CredentialProvider {
16
+ /** Whether {@link put} and {@link remove} do anything. A read-only source is false. */
17
+ readonly writable: boolean
18
+ list(): Promise<readonly CredentialListing[]>
19
+ get(name: string): Promise<string | undefined>
20
+ /**
21
+ * Write one account. An existing name keeps its place and takes the new description and secret.
22
+ * @param name - owner-chosen account name
23
+ * @param description - text the owner recognizes
24
+ * @param secret - the one value
25
+ */
26
+ put(name: string, description: string, secret: string): Promise<void>
27
+ /**
28
+ * Remove one account. A missing name changes nothing.
29
+ * @param name - owner-chosen account name
30
+ */
31
+ remove(name: string): Promise<void>
32
+ }
33
+
34
+ /**
35
+ * No source. `list` is empty, `get` is `undefined`, and the write side says why it cannot write.
36
+ * An empty name is still `credential-invalid`.
37
+ */
38
+ export function emptyCredentials(): CredentialProvider {
39
+ return {
40
+ writable: false,
41
+ list: () => Promise.resolve([]),
42
+ get: name => Promise.resolve().then(() => {
43
+ admitCredentialName(name)
44
+ return undefined
45
+ }),
46
+ put: (name) => {
47
+ admitCredentialName(name)
48
+ return Promise.reject(cannotWrite())
49
+ },
50
+ remove: (name) => {
51
+ admitCredentialName(name)
52
+ return Promise.reject(cannotWrite())
53
+ },
54
+ }
55
+ }
56
+
57
+ /** Reject an empty name before the source is read. */
58
+ export function admitCredentialName(name: string): string {
59
+ if (name === '') throw new CredentialError('credential-invalid', 'credential name is empty')
60
+ return name
61
+ }
62
+
63
+ /** The failure of a write against a store that has no writable source. */
64
+ export function cannotWrite(): CredentialError {
65
+ return new CredentialError('credential-write-unavailable', 'no credential source can be written')
66
+ }
@@ -0,0 +1,214 @@
1
+ /** Retained events per app when the caller omits a bound. Host policy, not a locked number. */
2
+ export const DEFAULT_APP_EVENT_TAIL = 50
3
+
4
+ /** Retry hint sent when a subscriber opens. Host policy, not a locked number. */
5
+ export const DEFAULT_APP_EVENT_RETRY_MS = 3000
6
+
7
+ /** Ping interval while a subscriber is attached. Host policy, not a locked number. */
8
+ export const DEFAULT_APP_EVENT_PING_MS = 15_000
9
+
10
+ /** One author event. Diagnostics never use this shape. */
11
+ export interface AuthorEvent {
12
+ readonly name: string
13
+ readonly data: unknown
14
+ readonly seq: number
15
+ }
16
+
17
+ /** The retained tail no longer contains `since`. The UI refetches. It does not invent events. */
18
+ export interface AppGap {
19
+ readonly type: 'app:gap'
20
+ readonly appId: string
21
+ readonly since: number
22
+ }
23
+
24
+ /** Sent to the subscriber that just opened. Not an author channel. */
25
+ export interface AppRetry {
26
+ readonly type: 'retry'
27
+ readonly retryMs: number
28
+ }
29
+
30
+ /** Sent while at least one subscriber is attached. Not an author channel. */
31
+ export interface AppPing {
32
+ readonly type: 'ping'
33
+ }
34
+
35
+ export type AppStreamItem = AuthorEvent | AppGap | AppRetry | AppPing
36
+
37
+ /** What `useApp().on` and `useApp().onAny` see. Ping and the retry hint stay on the stream. */
38
+ export interface AppHooks {
39
+ on(name: string, callback: (data: unknown) => void): () => void
40
+ onAny(callback: (event: { name: string; data: unknown }) => void): () => void
41
+ }
42
+
43
+ /** The per-app author buffer. Diagnostics do not use this. */
44
+ export interface AppEvents {
45
+ push(appId: string, name: string, data?: unknown): void
46
+ subscribe(appId: string, lastSeq: number, listener: (item: AppStreamItem) => void): () => void
47
+ observe(listener: (appId: string, event: AuthorEvent) => void): () => void
48
+ retained(): ReadonlyArray<{ readonly appId: string; readonly gap?: { readonly since: number }; readonly events: readonly AuthorEvent[] }>
49
+ dispose(): void
50
+ }
51
+
52
+ interface AppLog {
53
+ seq: number
54
+ events: AuthorEvent[]
55
+ listeners: Set<(item: AppStreamItem) => void>
56
+ ping: ReturnType<typeof setInterval> | undefined
57
+ }
58
+
59
+ /**
60
+ * Per-app author stream. `tailLength` is host policy and is not locked.
61
+ * @param options - buffer bound and the warning log
62
+ */
63
+ export function createAppEvents(options: {
64
+ readonly tailLength: number
65
+ readonly retryMs?: number
66
+ readonly pingMs?: number
67
+ readonly log?: (message: string) => void
68
+ }): AppEvents {
69
+ const retryMs = options.retryMs ?? DEFAULT_APP_EVENT_RETRY_MS
70
+ const pingMs = options.pingMs ?? DEFAULT_APP_EVENT_PING_MS
71
+ const apps = new Map<string, AppLog>()
72
+ const observers = new Set<(appId: string, event: AuthorEvent) => void>()
73
+ const log = options.log ?? ((message: string) => {
74
+ console.warn(message)
75
+ })
76
+
77
+ function read(appId: string): AppLog {
78
+ const existing = apps.get(appId)
79
+ if (existing !== undefined) return existing
80
+ const created: AppLog = { seq: 0, events: [], listeners: new Set(), ping: undefined }
81
+ apps.set(appId, created)
82
+ return created
83
+ }
84
+
85
+ return {
86
+ push(appId, name, data) {
87
+ if (data !== undefined && !isJson(data)) {
88
+ log(`ctx.push("${name}") dropped: data is not JSON`)
89
+ return
90
+ }
91
+ const app = read(appId)
92
+ app.seq += 1
93
+ const event: AuthorEvent = { name, data, seq: app.seq }
94
+ app.events.push(event)
95
+ if (app.events.length > options.tailLength) {
96
+ app.events.splice(0, app.events.length - options.tailLength)
97
+ }
98
+ for (const listener of [...app.listeners]) listener(event)
99
+ for (const observer of [...observers]) observer(appId, event)
100
+ },
101
+ observe(listener) {
102
+ observers.add(listener)
103
+ return () => {
104
+ observers.delete(listener)
105
+ }
106
+ },
107
+ retained() {
108
+ const rows = []
109
+ for (const [appId, app] of apps) {
110
+ const oldest = app.events[0]
111
+ if (oldest === undefined) continue
112
+ const gap = oldest.seq > 1 ? { since: 0 } : undefined
113
+ rows.push({
114
+ appId,
115
+ events: app.events,
116
+ ...gap === undefined ? {} : { gap },
117
+ })
118
+ }
119
+ return rows
120
+ },
121
+ subscribe(appId, lastSeq, listener) {
122
+ const app = read(appId)
123
+ listener({ type: 'retry', retryMs })
124
+ const oldest = app.events[0]
125
+ if (oldest !== undefined && oldest.seq > lastSeq + 1) {
126
+ listener({ type: 'app:gap', appId, since: lastSeq })
127
+ }
128
+ for (const event of app.events) {
129
+ if (event.seq > lastSeq) listener(event)
130
+ }
131
+ app.listeners.add(listener)
132
+ if (app.listeners.size === 1 && pingMs > 0 && app.ping === undefined) {
133
+ const timer = setInterval(() => {
134
+ for (const current of [...app.listeners]) current({ type: 'ping' })
135
+ }, pingMs)
136
+ timer.unref()
137
+ app.ping = timer
138
+ }
139
+ return () => {
140
+ app.listeners.delete(listener)
141
+ if (app.listeners.size === 0 && app.ping !== undefined) {
142
+ clearInterval(app.ping)
143
+ app.ping = undefined
144
+ }
145
+ }
146
+ },
147
+ dispose() {
148
+ observers.clear()
149
+ for (const app of apps.values()) {
150
+ if (app.ping !== undefined) clearInterval(app.ping)
151
+ app.ping = undefined
152
+ app.listeners.clear()
153
+ }
154
+ },
155
+ }
156
+ }
157
+
158
+ /**
159
+ * Forward every app onto one listener. Live events are registered before the retained tail is copied.
160
+ * The panel uses this on the host stream so an open tab does not hold its own socket.
161
+ */
162
+ export function forwardFrames(events: AppEvents, listener: (event: unknown) => void): () => void {
163
+ const stop = events.observe((appId, event) => {
164
+ listener({ type: 'app:event', appId, name: event.name, data: event.data, seq: event.seq })
165
+ })
166
+ for (const row of events.retained()) {
167
+ if (row.gap !== undefined) listener({ type: 'app:gap', appId: row.appId, since: row.gap.since })
168
+ for (const event of row.events) {
169
+ listener({ type: 'app:event', appId: row.appId, name: event.name, data: event.data, seq: event.seq })
170
+ }
171
+ }
172
+ return stop
173
+ }
174
+
175
+ /** Map the author stream onto `on` / `onAny`. A gap is `{ name: "*", data: { gap: true } }` for `onAny` only. */
176
+ export function bindAppHooks(events: AppEvents, appId: string, lastSeq = 0): AppHooks {
177
+ return {
178
+ on(name, callback) {
179
+ return events.subscribe(appId, lastSeq, (item) => {
180
+ if (!isAuthorEvent(item)) return
181
+ if (name !== '*' && item.name !== name) return
182
+ callback(item.data)
183
+ })
184
+ },
185
+ onAny(callback) {
186
+ return events.subscribe(appId, lastSeq, (item) => {
187
+ if (isGap(item)) {
188
+ callback({ name: '*', data: { gap: true } })
189
+ return
190
+ }
191
+ if (!isAuthorEvent(item)) return
192
+ callback({ name: item.name, data: item.data })
193
+ })
194
+ },
195
+ }
196
+ }
197
+
198
+ function isAuthorEvent(item: AppStreamItem): item is AuthorEvent {
199
+ return 'seq' in item
200
+ }
201
+
202
+ function isGap(item: AppStreamItem): item is AppGap {
203
+ return 'type' in item && item.type === 'app:gap'
204
+ }
205
+
206
+ function isJson(value: unknown): boolean {
207
+ if (typeof value === 'function' || typeof value === 'symbol' || value === undefined) return false
208
+ try {
209
+ JSON.stringify(value)
210
+ return true
211
+ } catch {
212
+ return false
213
+ }
214
+ }
@@ -0,0 +1,109 @@
1
+ /** Kinds an iframe may report. A resource-load error with no message is dropped. */
2
+ export const errorKinds = ['render', 'module', 'uncaught', 'async'] as const
3
+
4
+ export type ErrorKind = (typeof errorKinds)[number]
5
+
6
+ export interface AppErrorRecord {
7
+ seq: number
8
+ kind: ErrorKind
9
+ message: string
10
+ componentStack?: string
11
+ }
12
+
13
+ /** Ring cap when the caller omits one. Host policy, not a locked number. */
14
+ const DEFAULT_CAP = 50
15
+
16
+ interface Ring {
17
+ seq: number
18
+ errors: AppErrorRecord[]
19
+ dropped: number
20
+ opened: boolean
21
+ }
22
+
23
+ interface AdmittedError {
24
+ kind: ErrorKind
25
+ message: string
26
+ componentStack?: string
27
+ }
28
+
29
+ /**
30
+ * Admit one iframe error report. A bad kind or an empty message is dropped.
31
+ * @param raw - untrusted JSON
32
+ */
33
+ export function admitErrorReport(raw: unknown): AdmittedError | undefined {
34
+ if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) return undefined
35
+ const kind = 'kind' in raw ? raw.kind : undefined
36
+ const message = 'message' in raw ? raw.message : undefined
37
+ const componentStack = 'componentStack' in raw ? raw.componentStack : undefined
38
+ if (typeof kind !== 'string' || !isErrorKind(kind)) return undefined
39
+ if (typeof message !== 'string' || message.length === 0) return undefined
40
+ return {
41
+ kind,
42
+ message,
43
+ ...typeof componentStack === 'string' ? { componentStack } : {},
44
+ }
45
+ }
46
+
47
+ /**
48
+ * Per-app error ring. Diagnostics never enter the author event buffer.
49
+ * @param cap - retained errors; older ones increment `dropped`
50
+ */
51
+ export function createErrorRing(cap = DEFAULT_CAP) {
52
+ const apps = new Map<string, Ring>()
53
+
54
+ function ring(appId: string): Ring {
55
+ const existing = apps.get(appId)
56
+ if (existing !== undefined) return existing
57
+ const created: Ring = { seq: 0, errors: [], dropped: 0, opened: false }
58
+ apps.set(appId, created)
59
+ return created
60
+ }
61
+
62
+ return {
63
+ record(appId: string, raw: unknown) {
64
+ const admitted = admitErrorReport(raw)
65
+ if (admitted === undefined) return
66
+ const current = ring(appId)
67
+ current.seq += 1
68
+ const entry: AppErrorRecord = {
69
+ seq: current.seq,
70
+ kind: admitted.kind,
71
+ message: admitted.message,
72
+ ...admitted.componentStack === undefined ? {} : { componentStack: admitted.componentStack },
73
+ }
74
+ current.errors.push(entry)
75
+ while (current.errors.length > cap) {
76
+ current.errors.shift()
77
+ current.dropped += 1
78
+ }
79
+ },
80
+ markOpened(appId: string) {
81
+ ring(appId).opened = true
82
+ },
83
+ clearReload(appId: string) {
84
+ const current = ring(appId)
85
+ current.errors = []
86
+ current.dropped = 0
87
+ current.opened = false
88
+ },
89
+ read(appId: string, since?: number, clear = false) {
90
+ const current = ring(appId)
91
+ if (clear) current.errors = []
92
+ const errors = since === undefined ? [...current.errors] : current.errors.filter(item => item.seq > since)
93
+ const hint = current.errors.length === 0 && !current.opened
94
+ return {
95
+ errors,
96
+ lastSeq: current.seq,
97
+ dropped: current.dropped,
98
+ ...hint ? { emptyHint: 'open the app before treating an empty ring as clean' } : {},
99
+ }
100
+ },
101
+ forget(appId: string) {
102
+ apps.delete(appId)
103
+ },
104
+ }
105
+ }
106
+
107
+ function isErrorKind(value: string): value is ErrorKind {
108
+ return (errorKinds as readonly string[]).includes(value)
109
+ }
@@ -0,0 +1,39 @@
1
+ /** Host stream events. An author UI does not subscribe to this map. */
2
+
3
+ export type HostEvent =
4
+ | { type: 'app:open'; appId: string; title?: string }
5
+ | { type: 'workbench:default'; appId: string }
6
+ | { type: 'app:reload'; appId: string }
7
+ | { type: 'storage-size'; appId: string; table: string; keys: string[] }
8
+ | {
9
+ type: 'app:eval'
10
+ appId: string
11
+ requestId: string
12
+ code: string
13
+ budgetMs: number
14
+ maxBytes: number
15
+ maxNodes: number
16
+ maxDepth: number
17
+ }
18
+
19
+ /**
20
+ * In-process host stream. `GET /api/events` is the HTTP projection.
21
+ * `app:open` focuses a tab. `app:reload` refetches and does not create one.
22
+ */
23
+ export function createHostEvents() {
24
+ const listeners = new Set<(event: HostEvent) => void>()
25
+ return {
26
+ subscribe(listener: (event: HostEvent) => void) {
27
+ listeners.add(listener)
28
+ return () => {
29
+ listeners.delete(listener)
30
+ }
31
+ },
32
+ publish(event: HostEvent) {
33
+ for (const listener of [...listeners]) listener(event)
34
+ },
35
+ get connected() {
36
+ return listeners.size > 0
37
+ },
38
+ }
39
+ }