backeer-opencode 0.2.0 → 0.2.2

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "backeer-opencode",
3
- "version": "0.2.0",
3
+ "version": "0.2.2",
4
4
  "description": "Backeer lifecycle notifications and remote permission decisions for OpenCode",
5
5
  "type": "module",
6
6
  "main": "./src/index.js",
package/src/config.js CHANGED
@@ -85,6 +85,7 @@ export function loadConfig(options = {}) {
85
85
  return {
86
86
  origin,
87
87
  eventsEndpoint: new URL("/v1/events", origin),
88
+ sourceHealthEndpoint: new URL("/v1/source/health", origin),
88
89
  commandStreamEndpoint: new URL("/v1/commands/stream", origin),
89
90
  token: loadedToken.token,
90
91
  tokenSource: loadedToken.source,
@@ -0,0 +1,148 @@
1
+ import { randomUUID } from "node:crypto"
2
+ import { statSync } from "node:fs"
3
+
4
+ import { HttpError, postJsonWithRetry } from "./http.js"
5
+ import { PLUGIN_VERSION } from "./updates.js"
6
+ import { limit } from "./utilities.js"
7
+
8
+ const PASS = "✓"
9
+ const FAIL = "×"
10
+ const INFO = "•"
11
+
12
+ function tokenFileCheck(config) {
13
+ if (config.tokenSource === "environment") {
14
+ return { ok: true, text: "Token loaded from the environment; token file not required" }
15
+ }
16
+ try {
17
+ const stats = statSync(config.tokenFile)
18
+ const mode = stats.mode & 0o777
19
+ if (!stats.isFile()) return { ok: false, text: "Token path is not a regular file" }
20
+ if ((mode & 0o077) !== 0) {
21
+ return {
22
+ ok: false,
23
+ text: `Token file mode ${(mode).toString(8).padStart(3, "0")} is too permissive; expected 600`,
24
+ }
25
+ }
26
+ return {
27
+ ok: true,
28
+ text: `Token file is protected (mode ${mode.toString(8).padStart(3, "0")})`,
29
+ }
30
+ } catch {
31
+ return { ok: false, text: "Token file is missing or unreadable" }
32
+ }
33
+ }
34
+
35
+ async function probeSource(config, fetchImplementation = globalThis.fetch) {
36
+ const controller = new AbortController()
37
+ const timeout = setTimeout(() => controller.abort(), 5000)
38
+ try {
39
+ const response = await fetchImplementation(config.sourceHealthEndpoint, {
40
+ method: "GET",
41
+ headers: { Authorization: `Bearer ${config.token}` },
42
+ signal: controller.signal,
43
+ })
44
+ const responseBody = limit(await response.text(), 2000)
45
+ if (!response.ok) {
46
+ throw new HttpError(
47
+ `Gateway source check failed with status ${response.status}`,
48
+ response.status,
49
+ responseBody,
50
+ )
51
+ }
52
+ try {
53
+ return JSON.parse(responseBody)
54
+ } catch {
55
+ throw new Error("Gateway source check returned malformed JSON")
56
+ }
57
+ } finally {
58
+ clearTimeout(timeout)
59
+ }
60
+ }
61
+
62
+ export async function buildDoctorReport(
63
+ config,
64
+ { fetchImplementation = globalThis.fetch } = {},
65
+ ) {
66
+ const checks = []
67
+ checks.push({ ok: true, text: `Plugin version ${PLUGIN_VERSION}` })
68
+ checks.push({
69
+ ok: config.origin.protocol === "https:",
70
+ text: `Gateway uses ${config.origin.protocol === "https:" ? "HTTPS" : "an insecure protocol"}: ${config.origin.origin}`,
71
+ })
72
+ checks.push({
73
+ ok: Boolean(config.token),
74
+ text: config.token
75
+ ? `Source token loaded from ${config.tokenSource}`
76
+ : "Source token is missing",
77
+ })
78
+ checks.push(tokenFileCheck(config))
79
+
80
+ let source
81
+ if (config.token) {
82
+ try {
83
+ const result = await probeSource(config, fetchImplementation)
84
+ source = result?.source
85
+ checks.push({
86
+ ok: result?.ok === true && typeof source?.id === "string",
87
+ text: source?.id
88
+ ? `Source authentication accepted: ${source.name || source.id} (${source.id})`
89
+ : "Gateway did not return a source identity",
90
+ })
91
+ checks.push({
92
+ ok: result?.capabilities?.events === true &&
93
+ result?.capabilities?.commands === true,
94
+ text: result?.capabilities?.events === true &&
95
+ result?.capabilities?.commands === true
96
+ ? "Event ingest and command stream are available"
97
+ : "Event ingest or command stream is unavailable",
98
+ })
99
+ checks.push({
100
+ ok: result?.capabilities?.apns === true,
101
+ text: result?.capabilities?.apns === true
102
+ ? "Gateway APNs delivery is enabled"
103
+ : "Gateway APNs delivery is disabled",
104
+ })
105
+ } catch (error) {
106
+ const detail = error instanceof HttpError && error.status === 401
107
+ ? "Source authentication rejected (HTTP 401)"
108
+ : error?.name === "AbortError"
109
+ ? "Gateway source check timed out"
110
+ : `Gateway source check failed: ${limit(error?.message || error, 300)}`
111
+ checks.push({ ok: false, text: detail })
112
+ }
113
+ }
114
+
115
+ const healthy = checks.every((check) => check.ok)
116
+ return [
117
+ "## Backeer doctor",
118
+ "",
119
+ ...checks.map((check) => `${check.ok ? PASS : FAIL} ${check.text}`),
120
+ "",
121
+ healthy
122
+ ? `${PASS} Result: healthy. Use \`/backeer test\` to verify phone delivery.`
123
+ : `${FAIL} Result: issues found. Resolve failed checks and run \`/backeer doctor\` again.`,
124
+ "",
125
+ `${INFO} No event was created and the token value was not displayed.`,
126
+ ].join("\n")
127
+ }
128
+
129
+ export async function publishTestNotification(config, reporter) {
130
+ if (!config.token) throw new Error("Backeer source token is missing")
131
+ const testID = randomUUID()
132
+ const result = await postJsonWithRetry(config, config.eventsEndpoint, {
133
+ type: "application",
134
+ severity: "info",
135
+ title: "Backeer test notification",
136
+ body: "OpenCode integration is connected and push delivery is working.",
137
+ payload: [
138
+ { key: "Plugin version", value: PLUGIN_VERSION },
139
+ { key: "Test ID", value: testID },
140
+ ],
141
+ externalId: `opencode:test:${testID}`,
142
+ timestamp: new Date().toISOString(),
143
+ notify: true,
144
+ direction: "in",
145
+ })
146
+ reporter.success("Test notification sent", `HTTP ${result.status}`)
147
+ return testID
148
+ }
@@ -21,6 +21,7 @@ export async function publishPermission(
21
21
  request,
22
22
  directory,
23
23
  identity,
24
+ conversation,
24
25
  ) {
25
26
  const permissionType = request.permission ?? request.action ?? "unknown"
26
27
  const resources = Array.isArray(request.patterns)
@@ -53,6 +54,7 @@ export async function publishPermission(
53
54
  direction: "in",
54
55
  })
55
56
  reporter.success("Permission request sent", `HTTP ${result.status}`)
57
+ conversation?.success(identity.sessionID, "Permission request sent")
56
58
  }
57
59
 
58
60
  export function questionIdentity(request) {
@@ -104,6 +106,7 @@ export async function publishQuestion(
104
106
  identity,
105
107
  client,
106
108
  directory,
109
+ conversation,
107
110
  ) {
108
111
  const questions = Array.isArray(request.questions) ? request.questions : []
109
112
  if (questions.length === 0) return
@@ -153,6 +156,7 @@ export async function publishQuestion(
153
156
  direction: "in",
154
157
  })
155
158
  reporter.success("Question sent", `HTTP ${result.status}`)
159
+ conversation?.success(identity.sessionID, "Question sent")
156
160
  }
157
161
 
158
162
  export async function publishTodoCompletion(
@@ -162,6 +166,7 @@ export async function publishTodoCompletion(
162
166
  eventID,
163
167
  client,
164
168
  directory,
169
+ conversation,
165
170
  ) {
166
171
  const todos = request.todos
167
172
  const sessionTitle = await resolveSessionTitle(
@@ -211,4 +216,5 @@ export async function publishTodoCompletion(
211
216
  direction: "in",
212
217
  })
213
218
  reporter.success("Todo completion sent", `HTTP ${result.status}`)
219
+ conversation?.success(request.sessionID, "Todo completion sent")
214
220
  }
@@ -20,6 +20,7 @@ export function createLifecycleController({
20
20
  reporter,
21
21
  client,
22
22
  directory,
23
+ conversation,
23
24
  now = () => Date.now(),
24
25
  publishers = {},
25
26
  }) {
@@ -47,8 +48,11 @@ export function createLifecycleController({
47
48
  return state
48
49
  }
49
50
 
50
- function dispatch(label, operation) {
51
- void Promise.resolve(operation).catch((error) => reporter.error(label, error))
51
+ function dispatch(label, operation, sessionID) {
52
+ void Promise.resolve(operation).catch((error) => {
53
+ reporter.error(label, error)
54
+ conversation?.error(sessionID, label)
55
+ })
52
56
  }
53
57
 
54
58
  function beginCycle(state) {
@@ -83,7 +87,9 @@ export function createLifecycleController({
83
87
  sessionID,
84
88
  messageID: limit(assistant.id, 200),
85
89
  durationMs,
90
+ conversation,
86
91
  }),
92
+ sessionID,
87
93
  )
88
94
  }
89
95
 
@@ -135,7 +141,9 @@ export function createLifecycleController({
135
141
  correlation: limit(correlation, 200),
136
142
  attempt: status.attempt,
137
143
  next: status.next,
144
+ conversation,
138
145
  }),
146
+ sessionID,
139
147
  )
140
148
  }
141
149
 
@@ -157,7 +165,9 @@ export function createLifecycleController({
157
165
  sessionID,
158
166
  correlation: errorCorrelation(event, state),
159
167
  error: event.properties?.error,
168
+ conversation,
160
169
  }),
170
+ sessionID,
161
171
  )
162
172
  }
163
173
 
@@ -53,7 +53,7 @@ async function resolveSessionTitle(client, directory, sessionID, reporter) {
53
53
  }
54
54
  }
55
55
 
56
- async function publish(config, reporter, event, feedback) {
56
+ async function publish(config, reporter, conversation, sessionID, event, feedback) {
57
57
  const result = await postJsonWithRetry(config, config.eventsEndpoint, {
58
58
  type: "application",
59
59
  timestamp: new Date().toISOString(),
@@ -62,6 +62,7 @@ async function publish(config, reporter, event, feedback) {
62
62
  ...event,
63
63
  })
64
64
  reporter.success(feedback, `HTTP ${result.status}`)
65
+ conversation?.success(sessionID, feedback)
65
66
  }
66
67
 
67
68
  function secondsLabel(durationMs) {
@@ -80,6 +81,7 @@ export async function publishSessionCompleted({
80
81
  sessionID,
81
82
  messageID,
82
83
  durationMs,
84
+ conversation,
83
85
  }) {
84
86
  const sessionTitle = await resolveSessionTitle(
85
87
  client,
@@ -89,7 +91,7 @@ export async function publishSessionCompleted({
89
91
  )
90
92
  const duration = secondsLabel(durationMs)
91
93
  const location = sessionTitle ? ` “${sessionTitle}”` : ""
92
- await publish(config, reporter, {
94
+ await publish(config, reporter, conversation, sessionID, {
93
95
  severity: "info",
94
96
  title: "OpenCode task completed",
95
97
  body: `OpenCode finished${location} in ${duration}.`,
@@ -111,6 +113,7 @@ export async function publishSessionRetry({
111
113
  correlation,
112
114
  attempt,
113
115
  next,
116
+ conversation,
114
117
  }) {
115
118
  const sessionTitle = await resolveSessionTitle(
116
119
  client,
@@ -119,7 +122,7 @@ export async function publishSessionRetry({
119
122
  reporter,
120
123
  )
121
124
  const retryAt = Number.isFinite(next) ? new Date(next).toISOString() : ""
122
- await publish(config, reporter, {
125
+ await publish(config, reporter, conversation, sessionID, {
123
126
  severity: "warning",
124
127
  title: "OpenCode is retrying",
125
128
  body: `OpenCode reached retry attempt ${attempt}. The operation is still running.`,
@@ -140,6 +143,7 @@ export async function publishSessionError({
140
143
  sessionID,
141
144
  correlation,
142
145
  error,
146
+ conversation,
143
147
  }) {
144
148
  const sessionTitle = await resolveSessionTitle(
145
149
  client,
@@ -152,7 +156,7 @@ export async function publishSessionError({
152
156
  const body = details.message
153
157
  ? `OpenCode failed${location}: ${details.message}`
154
158
  : `OpenCode failed${location}.`
155
- await publish(config, reporter, {
159
+ await publish(config, reporter, conversation, sessionID, {
156
160
  // Backeer/iOS currently support info, warning and critical severities.
157
161
  severity: "critical",
158
162
  title: "OpenCode task failed",
@@ -170,7 +174,7 @@ export async function publishSessionError({
170
174
 
171
175
  export async function publishOpenCodeUpdate({ config, reporter, version }) {
172
176
  const safeVersion = limit(version, 100)
173
- await publish(config, reporter, {
177
+ await publish(config, reporter, undefined, undefined, {
174
178
  severity: "info",
175
179
  title: "OpenCode update available",
176
180
  body: `OpenCode ${safeVersion} is available.`,
package/src/management.js CHANGED
@@ -8,13 +8,15 @@ import {
8
8
  } from "node:fs"
9
9
  import { dirname, resolve } from "node:path"
10
10
 
11
+ import { buildDoctorReport, publishTestNotification } from "./diagnostics.js"
12
+ import { sendSessionMessage } from "./session-messages.js"
11
13
  import {
12
14
  formatAgentUpdateInstruction,
13
15
  PLUGIN_VERSION,
14
16
  } from "./updates.js"
15
17
 
16
18
  const COMMAND_DESCRIPTION =
17
- "Show Backeer status, update it, or manage its token: /backeer [status|update-check|update|token|token-file PATH]"
19
+ "Check, test, update, or manage Backeer: /backeer [status|doctor|test|update-check|update|token|token-file PATH]"
18
20
 
19
21
  function tokenFileState(path) {
20
22
  try {
@@ -129,6 +131,8 @@ function helpMessage() {
129
131
  "## Backeer commands",
130
132
  "",
131
133
  "- `/backeer` or `/backeer status` — show integration status",
134
+ "- `/backeer doctor` — diagnose config, credentials, gateway, and APNs without creating an event",
135
+ "- `/backeer test` — send a real test notification to the Backeer application",
132
136
  "- `/backeer update-check` — check NPM for a newer version",
133
137
  "- `/backeer update` — ask the OpenCode agent to install and verify it",
134
138
  "- `/backeer token` — show secure token-change instructions",
@@ -153,19 +157,6 @@ function tokenInstructions() {
153
157
  ].join("\n")
154
158
  }
155
159
 
156
- async function sendCommandMessage(client, sessionID, text) {
157
- if (typeof client?.session?.prompt !== "function") {
158
- throw new Error("OpenCode session messaging is unavailable")
159
- }
160
- await client.session.prompt({
161
- path: { id: sessionID },
162
- body: {
163
- noReply: true,
164
- parts: [{ type: "text", text, ignored: true }],
165
- },
166
- })
167
- }
168
-
169
160
  export function registerBackeerCommand(opencodeConfig) {
170
161
  opencodeConfig.command ??= {}
171
162
  opencodeConfig.command.backeer = {
@@ -198,6 +189,17 @@ export function createBackeerCommandHandler({
198
189
  try {
199
190
  if (!subcommand || subcommand === "status") {
200
191
  message = await buildStatus(config, permissionController, updateChecker)
192
+ } else if (subcommand === "doctor") {
193
+ message = await buildDoctorReport(config)
194
+ } else if (subcommand === "test") {
195
+ const testID = await publishTestNotification(config, reporter)
196
+ message = [
197
+ "## Backeer test notification",
198
+ "",
199
+ "✓ The gateway accepted the test event.",
200
+ `- Test ID: ${testID}`,
201
+ "- Check the Backeer application for the notification.",
202
+ ].join("\n")
201
203
  } else if (subcommand === "update-check") {
202
204
  const update = await updateChecker.check({ force: true })
203
205
  message = [
@@ -238,7 +240,7 @@ export function createBackeerCommandHandler({
238
240
  message = helpMessage()
239
241
  }
240
242
  } catch (error) {
241
- reporter.error("Access token could not be updated", error)
243
+ reporter.error("Backeer command failed", error)
242
244
  message = [
243
245
  "## Backeer command failed",
244
246
  "",
@@ -248,6 +250,6 @@ export function createBackeerCommandHandler({
248
250
  ].join("\n")
249
251
  }
250
252
 
251
- await sendCommandMessage(client, input.sessionID, message)
253
+ await sendSessionMessage(client, input.sessionID, message)
252
254
  }
253
255
  }
@@ -35,6 +35,7 @@ export function createPermissionController({
35
35
  reporter,
36
36
  client,
37
37
  directory,
38
+ conversation,
38
39
  }) {
39
40
  const pendingByExternalID = new Map()
40
41
  const externalIDByRequestID = new Map()
@@ -104,6 +105,7 @@ export function createPermissionController({
104
105
  }
105
106
  if (stale) removePendingByRequestID(pending.requestID)
106
107
  reporter.error("Decision could not be applied", error)
108
+ conversation?.error(pending.sessionID, "Decision could not be applied")
107
109
  } finally {
108
110
  processingCommands.delete(command.id)
109
111
  }
@@ -120,12 +122,19 @@ export function createPermissionController({
120
122
  reporter.applied(
121
123
  `Decision applied: ${DECISION_LABELS[acknowledgement.decision]}`,
122
124
  )
125
+ conversation?.applied(
126
+ pending.sessionID,
127
+ `Decision applied: ${DECISION_LABELS[acknowledgement.decision]}`,
128
+ )
123
129
  } else if (acknowledgement.status === "expired") {
124
130
  reporter.warning("Decision expired before it could be applied")
131
+ conversation?.warning(pending.sessionID, "Decision expired before it could be applied")
125
132
  } else if (acknowledgement.status === "stale") {
126
133
  reporter.warning("Permission was already answered locally")
134
+ conversation?.warning(pending.sessionID, "Permission was already answered locally")
127
135
  } else if (acknowledgement.status === "rejected") {
128
136
  reporter.warning("Decision was rejected by validation")
137
+ conversation?.warning(pending.sessionID, "Decision was rejected by validation")
129
138
  }
130
139
 
131
140
  await acknowledgeCommand(config, command.id, acknowledgement).catch((error) => {
package/src/plugin.js CHANGED
@@ -12,7 +12,8 @@ import {
12
12
  } from "./management.js"
13
13
  import { createLifecycleController } from "./lifecycle-controller.js"
14
14
  import { createPermissionController } from "./permission-controller.js"
15
- import { createConsoleReporter } from "./reporter.js"
15
+ import { createOpenCodeReporter } from "./reporter.js"
16
+ import { createConversationFeedback } from "./session-messages.js"
16
17
  import {
17
18
  createUpdateChecker,
18
19
  createUpdateNoticeHook,
@@ -28,7 +29,8 @@ import { rememberBounded } from "./utilities.js"
28
29
  * protected file so it cannot accidentally be committed in opencode.json.
29
30
  */
30
31
  export default async function BackeerPlugin({ client, directory }, options = {}) {
31
- const bootstrapReporter = createConsoleReporter(
32
+ const bootstrapReporter = createOpenCodeReporter(
33
+ client,
32
34
  options.logLevel ?? process.env.BACKEER_LOG_LEVEL,
33
35
  )
34
36
  let config
@@ -39,19 +41,22 @@ export default async function BackeerPlugin({ client, directory }, options = {})
39
41
  return {}
40
42
  }
41
43
 
42
- const reporter = createConsoleReporter(config.logLevel)
44
+ const reporter = createOpenCodeReporter(client, config.logLevel)
45
+ const conversation = createConversationFeedback({ client, reporter })
43
46
 
44
47
  const permissionController = createPermissionController({
45
48
  config,
46
49
  reporter,
47
50
  client,
48
51
  directory,
52
+ conversation,
49
53
  })
50
54
  const lifecycleController = createLifecycleController({
51
55
  config,
52
56
  reporter,
53
57
  client,
54
58
  directory,
59
+ conversation,
55
60
  })
56
61
  const todoCompletionBySessionID = new Map()
57
62
  const publishedQuestions = new Map()
@@ -86,7 +91,11 @@ export default async function BackeerPlugin({ client, directory }, options = {})
86
91
  event.properties,
87
92
  directory,
88
93
  identity,
89
- ).catch((error) => reporter.error("Permission request could not be sent", error))
94
+ conversation,
95
+ ).catch((error) => {
96
+ reporter.error("Permission request could not be sent", error)
97
+ conversation.error(identity.sessionID, "Permission request could not be sent")
98
+ })
90
99
  }
91
100
 
92
101
  async function handleTodoUpdated(event) {
@@ -109,7 +118,11 @@ export default async function BackeerPlugin({ client, directory }, options = {})
109
118
  event.id,
110
119
  client,
111
120
  directory,
112
- ).catch((error) => reporter.error("Todo completion could not be sent", error))
121
+ conversation,
122
+ ).catch((error) => {
123
+ reporter.error("Todo completion could not be sent", error)
124
+ conversation.error(sessionID, "Todo completion could not be sent")
125
+ })
113
126
  }
114
127
 
115
128
  async function handleQuestionAsked(event) {
@@ -128,7 +141,11 @@ export default async function BackeerPlugin({ client, directory }, options = {})
128
141
  identity,
129
142
  client,
130
143
  directory,
131
- ).catch((error) => reporter.error("Question could not be sent", error))
144
+ conversation,
145
+ ).catch((error) => {
146
+ reporter.error("Question could not be sent", error)
147
+ conversation.error(identity.sessionID, "Question could not be sent")
148
+ })
132
149
  }
133
150
 
134
151
  return {
@@ -143,6 +160,7 @@ export default async function BackeerPlugin({ client, directory }, options = {})
143
160
  event: async ({ event }) => {
144
161
  if (!config.token) return
145
162
 
163
+ conversation.handle(event)
146
164
  lifecycleController.handle(event)
147
165
 
148
166
  if (config.permissions && event.type === "permission.asked") {
@@ -175,6 +193,7 @@ export default async function BackeerPlugin({ client, directory }, options = {})
175
193
  lifecycleController.dispose()
176
194
  todoCompletionBySessionID.clear()
177
195
  publishedQuestions.clear()
196
+ conversation.dispose()
178
197
  },
179
198
  }
180
199
  }
package/src/reporter.js CHANGED
@@ -58,3 +58,53 @@ export function createConsoleReporter(levelValue) {
58
58
  debug: (message, detail) => emit("debug", "debug", message, detail),
59
59
  }
60
60
  }
61
+
62
+ export function createOpenCodeReporter(client, levelValue) {
63
+ const level = normalizeLogLevel(levelValue)
64
+ const recentlyEmitted = new Map()
65
+ const method = client?.app?.log
66
+
67
+ function emit(requiredLevel, kind, message, detail, deduplicate = false) {
68
+ if (LOG_RANK[level] < LOG_RANK[requiredLevel] || typeof method !== "function") return
69
+
70
+ const key = `${kind}:${message}`
71
+ const now = Date.now()
72
+ if (deduplicate) {
73
+ const previous = recentlyEmitted.get(key)
74
+ if (previous && now - previous < LOG_DEDUPLICATION_MS) return
75
+ rememberRecent(recentlyEmitted, key, now)
76
+ }
77
+
78
+ const body = {
79
+ service: "backeer-opencode",
80
+ level: kind === "warning" ? "warn" : kind === "applied" || kind === "success"
81
+ ? "info"
82
+ : kind,
83
+ message: limit(message, 500),
84
+ }
85
+ if (level === "debug" && detail) {
86
+ body.extra = { detail: limit(detail?.message ?? detail, 500) }
87
+ }
88
+
89
+ // Logging during plugin initialization can deadlock older OpenCode builds.
90
+ // Defer it and never fall back to stdout, because stdout corrupts the TUI prompt.
91
+ setTimeout(() => {
92
+ void Promise.resolve(method.call(client.app, { body })).catch(() => {})
93
+ }, 0)
94
+ }
95
+
96
+ return {
97
+ info: (message, detail) => emit("info", "info", message, detail),
98
+ success: (message, detail) => emit("info", "success", message, detail),
99
+ applied: (message, detail) => emit("info", "applied", message, detail),
100
+ warning: (message, detail) => emit("error", "warning", message, detail, true),
101
+ error: (message, detail) => emit("error", "error", message, detail, true),
102
+ debug: (message, detail) => emit("debug", "debug", message, detail),
103
+ }
104
+ }
105
+
106
+ function rememberRecent(map, key, value) {
107
+ map.delete(key)
108
+ map.set(key, value)
109
+ while (map.size > 100) map.delete(map.keys().next().value)
110
+ }