merge: integrate origin/main (406 commits) into live branch

Merge upstream's paired-room stabilization, dedicated message-runtime-loop,
outbound delivery refactor (ipc-outbound-delivery / deliverFormattedCanonicalMessage),
discord module split, dashboard rework, and migrations 015-018 into our branch.

Conflict policy: prefer upstream for the heavily-refactored paired-room /
message-runtime / db cluster (it supersedes our earlier loop band-aids), keep
only orthogonal unique fixes:
- codex bundled-JS launcher fix (codex-warmup.ts)
- slot-aligned Claude+Codex usage primer (usage-primer.ts) re-wired into index.ts
- MemoryHigh=3G cgroup bound, discord perms diagnostic, prompt docs
- progress null-race + silent-failure publish guards (message-turn-controller.ts)

Dropped (superseded by upstream or unwired): reviewer STEP_DONE/TASK_DONE loop
band-aids, router markdown-escape override, register tribunal-default+git-init,
restart-context rewindToSeq (unwired).

Verified: typecheck clean, build clean, vitest shows zero new regressions vs the
origin/main baseline (only pre-existing env-config failures remain).
This commit is contained in:
Codex
2026-06-08 21:15:39 +09:00
409 changed files with 53523 additions and 10332 deletions

View File

@@ -56,7 +56,7 @@ Codex 관련 선택 설정은 **별도 서비스 파일이 아니라 같은 `.en
```bash
# 같은 .env 또는 서비스 Environment=에 추가 가능
CODEX_MODEL=gpt-5.4
CODEX_MODEL=gpt-5.5
CODEX_EFFORT=xhigh
OPENAI_API_KEY=...
```

View File

@@ -13,6 +13,7 @@ DISCORD_ARBITER_BOT_TOKEN= # Arbiter bot
# --- Agent type configuration ---
OWNER_AGENT_TYPE=codex # codex | claude-code
REVIEWER_AGENT_TYPE=claude-code # claude-code | codex
# ARBITER_AGENT_TYPE=codex # optional: codex | claude-code
# --- API keys ---
CLAUDE_CODE_OAUTH_TOKENS= # Canonical comma-separated Claude Code OAuth tokens (use one value for single-account too)
@@ -23,18 +24,31 @@ GROQ_API_KEY= # Voice transcription (Groq Whisper, free:
ASSISTANT_NAME=claude # Trigger name (@claude)
# --- Claude model ---
CLAUDE_MODEL=claude-opus-4-6 # Claude model
CLAUDE_MODEL=claude-opus-4-8 # Claude model
CLAUDE_EFFORT=high # Claude effort level
CLAUDE_THINKING=adaptive # Claude thinking mode
# --- Codex model ---
CODEX_MODEL=gpt-5.4 # Codex model
CODEX_MODEL=gpt-5.5 # Codex model
CODEX_EFFORT=xhigh # Codex reasoning effort
# Codex fast mode / goals: dashboard Settings → Codex, or ~/.codex/config.toml:
# [features]
# fast_mode = true
# goals = true # /goal — opt-in, default OFF on Codex 0.133+
# --- Usage dashboard ---
STATUS_CHANNEL_ID= # Discord channel ID for live status updates
# STATUS_SHOW_ROOMS=true # Show room status in dashboard
# STATUS_SHOW_ROOM_DETAILS=false # Show detailed room info
# WEB_DASHBOARD_ENABLED=false # Local dashboard server
# WEB_DASHBOARD_HOST=127.0.0.1 # Keep localhost unless using VPN/tunnel
# WEB_DASHBOARD_PORT=8734
# WEB_DASHBOARD_TOKEN= # Optional bearer token for /api/* when exposing to phone clients
# --- Discord image attachments ---
# Extra local directories that agents may attach from. Keep this narrow:
# prefer screenshot/output folders over /home. Comma or path-delimiter separated.
# EJCLAW_ATTACHMENT_ALLOWED_DIRS=~/Pictures/Screenshots,~/Downloads/ejclaw-images
# --- Codex warm-up (optional, disabled by default) ---
# Sends a tiny real Codex prompt only when both 5h and 7d usage are 0%, so
@@ -64,16 +78,16 @@ STATUS_CHANNEL_ID= # Discord channel ID for live status updat
# --- Per-role model overrides (paired rooms) ---
# Override global CLAUDE_MODEL/CODEX_MODEL per role.
# Model is injected based on each role's agent type (claude-code → CLAUDE_MODEL, codex → CODEX_MODEL).
# OWNER_MODEL=gpt-5.4 # Owner model override
# OWNER_MODEL=gpt-5.5 # Owner model override
# OWNER_EFFORT=xhigh # Owner effort override
# OWNER_FALLBACK_ENABLED=true # Fall back to codex on Claude failure (default: true)
#
# REVIEWER_MODEL=claude-opus-4-6 # Reviewer model override
# REVIEWER_MODEL=claude-opus-4-8 # Reviewer model override
# REVIEWER_EFFORT=high # Reviewer effort override
# REVIEWER_FALLBACK_ENABLED=true # Fall back to codex on Claude failure (default: true)
#
# ARBITER_MODEL=claude-sonnet-4-6 # Arbiter model override
# ARBITER_EFFORT=high # Arbiter effort override
# ARBITER_MODEL=gpt-5.5 # Arbiter model override when ARBITER_AGENT_TYPE=codex
# ARBITER_EFFORT=xhigh # Arbiter effort override
# ARBITER_FALLBACK_ENABLED=true # Fall back to codex on Claude failure (default: true)
# --- Mixture of Agents (MoA) ---
@@ -82,11 +96,13 @@ STATUS_CHANNEL_ID= # Discord channel ID for live status updat
# The SDK arbiter (subscription-based, no extra cost) aggregates all perspectives.
# MOA_ENABLED=true
# MOA_REF_MODELS=kimi,glm # Comma-separated reference model names
# MOA_KIMI_MODEL=kimi-k2.5 # Kimi model
# MOA_KIMI_MODEL=kimi-k2.6 # Kimi model
# MOA_KIMI_BASE_URL=https://api.kimi.com/coding # Kimi API endpoint
# MOA_KIMI_API_FORMAT=anthropic # Kimi Code API format
# MOA_KIMI_API_KEY=sk-kimi-xxx # Kimi API key
# MOA_GLM_MODEL=glm-4-plus # GLM model
# MOA_GLM_BASE_URL=https://open.bigmodel.cn/api/paas/v4 # GLM API endpoint
# MOA_GLM_MODEL=glm-5.1 # GLM model
# MOA_GLM_BASE_URL=https://open.bigmodel.cn/api/anthropic # GLM API endpoint
# MOA_GLM_API_FORMAT=anthropic # GLM Anthropic-compatible API
# MOA_GLM_API_KEY=xxx # GLM API key
# --- Advanced ---

5
.gitignore vendored
View File

@@ -4,6 +4,10 @@ node_modules/
# Build output
dist/
runners/*/dist/
apps/android/.gradle/
apps/android/build/
apps/android/app/build/
apps/android/local.properties
# Local data & auth
store/
@@ -35,6 +39,7 @@ tmp/
backups/
dist-backups/
.deploy-backups/
recovery/
cache/
runners/*/node_modules/
runners/codex-runner-backups/

View File

@@ -1,8 +1,8 @@
# EJClaw
![Version](https://img.shields.io/badge/version-0.2.3-blue)
![Claude Agent SDK](https://img.shields.io/badge/Claude_Agent_SDK-0.2.101-blueviolet)
![Codex SDK](https://img.shields.io/badge/Codex_SDK-0.120.0-green)
![Claude Agent SDK](https://img.shields.io/badge/Claude_Agent_SDK-0.2.126-blueviolet)
![Codex SDK](https://img.shields.io/badge/Codex_SDK-0.133.0-green)
![Bun](https://img.shields.io/badge/Bun-1.3+-f9f1e1?logo=bun&logoColor=black)
![Discord](https://img.shields.io/badge/Discord-Tribunal-5865F2?logo=discord&logoColor=white)
@@ -115,6 +115,11 @@ Discord ──► SQLite (WAL) ──► GroupQueue ──┬──► Owner (ho
- Codex CLI
- Discord 봇 토큰 3개(owner / reviewer / arbiter)
현재 runner 번들 기준 버전:
- Claude Agent SDK: `@anthropic-ai/claude-agent-sdk@0.2.126`
- Codex SDK/CLI: `@openai/codex@0.133.0`
### 설치
```bash

11
apps/android/.gitignore vendored Normal file
View File

@@ -0,0 +1,11 @@
.gradle/
build/
local.properties
*.apk
*.ap_
*.aab
*.iml
.idea/
.cxx/
captures/
app/build/

54
apps/android/README.md Normal file
View File

@@ -0,0 +1,54 @@
# EJClaw Android
Personal Android companion for EJClaw. This is a thin client: EJClaw still runs
on the existing Bun service, and the Android app talks to the dashboard API.
## Build
```bash
cd apps/android
cp local.properties.example local.properties
./gradlew assembleDebug
```
The debug APK is written to:
```text
apps/android/app/build/outputs/apk/debug/app-debug.apk
```
Install with:
```bash
adb install -r apps/android/app/build/outputs/apk/debug/app-debug.apk
```
## Runtime Setup
- Keep EJClaw behind Tailscale, VPN, SSH tunnel, or localhost forwarding.
- Base URL defaults to `http://100.101.210.95:8734`.
- If `WEB_DASHBOARD_TOKEN` is enabled on the server, paste the same token into
the app. It sends `Authorization: Bearer <token>`.
## Current MVP
- Connect to `/api/health`.
- Load rooms from `/api/rooms-timeline`.
- Open a room timeline from `/api/rooms/:jid/timeline`.
- Send text through `/api/rooms/:jid/messages`.
- Keep the Ray-Ban Display integration isolated behind `DisplaySurface`.
## Meta DAT
The default APK does not link the Meta DAT SDK yet. Meta's Android DAT SDK is
distributed through GitHub Packages and needs Developer Preview access, a
GitHub package token, and a Wearables Developer Center application id.
The integration point is already present:
```text
app/src/main/java/com/ejclaw/android/display/
```
After DAT access is ready, replace `MetaDatDisplaySurface` with SDK calls and
keep `NoopDisplaySurface` for phone-only testing.

View File

@@ -0,0 +1,26 @@
plugins {
id("com.android.application")
id("org.jetbrains.kotlin.android")
}
android {
namespace = "com.ejclaw.android"
compileSdk = 36
defaultConfig {
applicationId = "com.ejclaw.android"
minSdk = 26
targetSdk = 36
versionCode = 1
versionName = "0.1.0"
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_21
targetCompatibility = JavaVersion.VERSION_21
}
}
kotlin {
jvmToolchain(21)
}

View File

@@ -0,0 +1,25 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<application
android:allowBackup="false"
android:label="@string/app_name"
android:networkSecurityConfig="@xml/network_security_config"
android:supportsRtl="true"
android:theme="@style/AppTheme"
android:usesCleartextTraffic="false">
<!-- Fill when Meta DAT Developer Preview app registration is available. -->
<meta-data
android:name="com.meta.wearable.mwdat.ANALYTICS_OPT_OUT"
android:value="true" />
<activity
android:name=".MainActivity"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>
</manifest>

View File

@@ -0,0 +1,166 @@
package com.ejclaw.android
import android.app.Activity
import android.os.Bundle
import android.widget.Toast
import com.ejclaw.android.api.EJClawApi
import com.ejclaw.android.display.DisplayBridge
import com.ejclaw.android.model.RoomActivity
import com.ejclaw.android.model.RoomSummary
import com.ejclaw.android.ui.EJClawView
import java.util.concurrent.Executors
class MainActivity : Activity() {
private val executor = Executors.newSingleThreadExecutor()
private val displayBridge = DisplayBridge()
private lateinit var view: EJClawView
private var api: EJClawApi? = null
private var selectedRoom: RoomSummary? = null
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
view = EJClawView(this)
setContentView(view.root)
loadPrefs()
bindActions()
}
override fun onDestroy() {
executor.shutdownNow()
super.onDestroy()
}
private fun bindActions() {
view.connectButton.setOnClickListener { connectAndLoadRooms() }
view.refreshButton.setOnClickListener { loadRooms() }
view.sendButton.setOnClickListener { sendMessage() }
view.roomsList.setOnItemClickListener { _, _, position, _ ->
selectedRoom = view.roomsAdapter.getItem(position)
selectedRoom?.let { loadRoom(it) }
}
}
private fun loadPrefs() {
val prefs = getPreferences(MODE_PRIVATE)
view.baseUrlInput.setText(prefs.getString("baseUrl", "http://100.101.210.95:8734"))
view.tokenInput.setText(prefs.getString("token", ""))
view.nicknameInput.setText(prefs.getString("nickname", "android"))
}
private fun savePrefs() {
getPreferences(MODE_PRIVATE).edit()
.putString("baseUrl", view.baseUrlInput.text.toString().trim())
.putString("token", view.tokenInput.text.toString())
.putString("nickname", view.nicknameInput.text.toString().trim())
.apply()
}
private fun connectAndLoadRooms() {
savePrefs()
api = EJClawApi(
view.baseUrlInput.text.toString(),
view.tokenInput.text.toString(),
)
runNetwork("Connecting") {
requireNotNull(api).health()
requireNotNull(api).rooms()
}.onSuccess { rooms ->
view.statusView.text = "Connected: ${rooms.size} rooms"
replaceRooms(rooms)
}
}
private fun loadRooms() {
runNetwork("Loading rooms") {
requireNotNull(api ?: createApi()).rooms()
}.onSuccess { rooms ->
view.statusView.text = "Loaded: ${rooms.size} rooms"
replaceRooms(rooms)
}
}
private fun loadRoom(room: RoomSummary) {
runNetwork("Loading ${room.name}") {
requireNotNull(api ?: createApi()).roomTimeline(room.jid)
}.onSuccess { activity ->
renderRoom(activity)
displayBridge.updateRoom(activity.summary.name, activity.summary.status, null)
displayBridge.showLatest(activity.summary.name, activity.summary.latestText)
}
}
private fun sendMessage() {
val room = selectedRoom ?: return toast("Select a room first")
val text = view.messageInput.text.toString().trim()
if (text.isBlank()) return
runNetwork("Sending") {
requireNotNull(api ?: createApi()).sendRoomMessage(
room.jid,
text,
view.nicknameInput.text.toString(),
)
requireNotNull(api).roomTimeline(room.jid)
}.onSuccess { activity ->
view.messageInput.setText("")
renderRoom(activity)
}
}
private fun createApi(): EJClawApi {
savePrefs()
val next = EJClawApi(view.baseUrlInput.text.toString(), view.tokenInput.text.toString())
api = next
return next
}
private fun replaceRooms(rooms: List<RoomSummary>) {
view.roomsAdapter.clear()
view.roomsAdapter.addAll(rooms)
view.roomsAdapter.notifyDataSetChanged()
}
private fun renderRoom(activity: RoomActivity) {
val lines = activity.messages.takeLast(40).joinToString("\n\n") { message ->
val marker = if (message.fromMe) "me" else message.senderName.ifBlank { "agent" }
"[$marker] ${message.content}"
}
view.threadView.text = lines.ifBlank { "No messages" }
view.statusView.text = "${activity.summary.name}: ${activity.summary.status}"
}
private fun <T> runNetwork(label: String, block: () -> T): PendingUi<T> {
view.statusView.text = label
val pending = PendingUi<T>()
executor.execute {
try {
val result = block()
runOnUiThread { pending.succeed(result) }
} catch (error: Throwable) {
runOnUiThread {
view.statusView.text = "Error: ${error.message}"
toast(error.message ?: "Request failed")
}
}
}
return pending
}
private fun toast(message: String) {
Toast.makeText(this, message, Toast.LENGTH_SHORT).show()
}
}
private class PendingUi<T> {
private var callback: ((T) -> Unit)? = null
private var result: T? = null
fun onSuccess(next: (T) -> Unit) {
callback = next
result?.let(next)
}
fun succeed(value: T) {
result = value
callback?.invoke(value)
}
}

View File

@@ -0,0 +1,104 @@
package com.ejclaw.android.api
import com.ejclaw.android.model.RoomActivity
import com.ejclaw.android.model.RoomMessage
import com.ejclaw.android.model.RoomSummary
import java.io.BufferedReader
import java.io.InputStreamReader
import java.net.HttpURLConnection
import java.net.URL
import java.util.UUID
import org.json.JSONObject
class EJClawApi(
baseUrl: String,
private val token: String,
) {
private val baseUrl = baseUrl.trim().trimEnd('/')
fun health(): Boolean {
val payload = request("GET", "/api/health")
return JSONObject(payload).optBoolean("ok", false)
}
fun rooms(): List<RoomSummary> {
val payload = request("GET", "/api/rooms-timeline")
val root = JSONObject(payload)
return root.keys().asSequence().mapNotNull { key ->
root.optJSONObject(key)?.let { parseRoomSummary(it) }
}.sortedBy { it.name.lowercase() }.toList()
}
fun roomTimeline(jid: String): RoomActivity {
val payload = request("GET", "/api/rooms/${encode(jid)}/timeline")
val room = JSONObject(payload)
val messages = room.optJSONArray("messages") ?: return RoomActivity(parseRoomSummary(room), emptyList())
return RoomActivity(
parseRoomSummary(room),
(0 until messages.length()).mapNotNull { index ->
messages.optJSONObject(index)?.let { message ->
RoomMessage(
senderName = message.optString("senderName", message.optString("sender", "")),
content = message.optString("content", ""),
timestamp = message.optString("timestamp", ""),
fromMe = message.optBoolean("isFromMe", false),
)
}
},
)
}
fun sendRoomMessage(jid: String, text: String, nickname: String): Boolean {
val body = JSONObject()
.put("requestId", UUID.randomUUID().toString())
.put("text", text)
.put("nickname", nickname.ifBlank { "android" })
val payload = request("POST", "/api/rooms/${encode(jid)}/messages", body.toString())
return JSONObject(payload).optBoolean("ok", false)
}
private fun parseRoomSummary(room: JSONObject): RoomSummary {
val messages = room.optJSONArray("messages")
val latest = if (messages != null && messages.length() > 0) {
messages.optJSONObject(messages.length() - 1)?.optString("content", "") ?: ""
} else {
""
}
return RoomSummary(
jid = room.optString("jid"),
name = room.optString("name", room.optString("jid")),
status = room.optString("status", "unknown"),
latestText = latest.take(120),
)
}
private fun request(method: String, path: String, body: String? = null): String {
val connection = URL("$baseUrl$path").openConnection() as HttpURLConnection
connection.requestMethod = method
connection.connectTimeout = 8_000
connection.readTimeout = 12_000
connection.setRequestProperty("Accept", "application/json")
if (token.isNotBlank()) connection.setRequestProperty("Authorization", "Bearer $token")
if (body != null) {
val bytes = body.toByteArray(Charsets.UTF_8)
connection.doOutput = true
connection.setRequestProperty("Content-Type", "application/json")
connection.setRequestProperty("Content-Length", bytes.size.toString())
connection.outputStream.use { it.write(bytes) }
}
val status = connection.responseCode
val stream = if (status in 200..299) connection.inputStream else connection.errorStream
val response = stream?.use {
BufferedReader(InputStreamReader(it, Charsets.UTF_8)).readText()
} ?: ""
connection.disconnect()
if (status !in 200..299) {
val message = response.ifBlank { "HTTP $status" }
throw IllegalStateException(message)
}
return response
}
private fun encode(value: String): String =
java.net.URLEncoder.encode(value, Charsets.UTF_8.name()).replace("+", "%20")
}

View File

@@ -0,0 +1,13 @@
package com.ejclaw.android.display
class DisplayBridge(
private val surface: DisplaySurface = NoopDisplaySurface(),
) {
fun updateRoom(roomName: String, status: String, progress: String?) {
surface.showStatus(roomName, status, progress)
}
fun showLatest(roomName: String, text: String) {
if (text.isNotBlank()) surface.showMessage(roomName, text)
}
}

View File

@@ -0,0 +1,6 @@
package com.ejclaw.android.display
interface DisplaySurface {
fun showStatus(roomName: String, state: String, progress: String?)
fun showMessage(roomName: String, text: String)
}

View File

@@ -0,0 +1,15 @@
package com.ejclaw.android.display
/**
* Meta DAT integration point.
*
* Keep this class dependency-free until the Wearables Developer Center project,
* GitHub Packages token, and DAT application id are available. The phone app can
* validate EJClaw connectivity through NoopDisplaySurface first, then this class
* can be swapped to call the Meta SDK.
*/
class MetaDatDisplaySurface : DisplaySurface {
override fun showStatus(roomName: String, state: String, progress: String?) = Unit
override fun showMessage(roomName: String, text: String) = Unit
}

View File

@@ -0,0 +1,16 @@
package com.ejclaw.android.display
class NoopDisplaySurface : DisplaySurface {
var lastStatus: String = ""
private set
var lastMessage: String = ""
private set
override fun showStatus(roomName: String, state: String, progress: String?) {
lastStatus = listOfNotNull(roomName, state, progress).joinToString(" / ")
}
override fun showMessage(roomName: String, text: String) {
lastMessage = "$roomName: $text"
}
}

View File

@@ -0,0 +1,25 @@
package com.ejclaw.android.model
data class RoomSummary(
val jid: String,
val name: String,
val status: String,
val latestText: String,
) {
override fun toString(): String {
val suffix = if (latestText.isBlank()) "" else "\n$latestText"
return "$name [$status]$suffix"
}
}
data class RoomMessage(
val senderName: String,
val content: String,
val timestamp: String,
val fromMe: Boolean,
)
data class RoomActivity(
val summary: RoomSummary,
val messages: List<RoomMessage>,
)

View File

@@ -0,0 +1,80 @@
package com.ejclaw.android.ui
import android.app.Activity
import android.graphics.Typeface
import android.text.InputType
import android.view.View
import android.widget.ArrayAdapter
import android.widget.Button
import android.widget.EditText
import android.widget.LinearLayout
import android.widget.ListView
import android.widget.ScrollView
import android.widget.TextView
import com.ejclaw.android.model.RoomSummary
class EJClawView(
activity: Activity,
) {
val baseUrlInput = EditText(activity)
val tokenInput = EditText(activity)
val nicknameInput = EditText(activity)
val connectButton = Button(activity)
val refreshButton = Button(activity)
val sendButton = Button(activity)
val messageInput = EditText(activity)
val statusView = TextView(activity)
val threadView = TextView(activity)
val roomsAdapter = ArrayAdapter<RoomSummary>(activity, android.R.layout.simple_list_item_1)
val roomsList = ListView(activity)
val root: View
init {
baseUrlInput.hint = "http://100.101.210.95:8734"
baseUrlInput.inputType = InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_URI
tokenInput.hint = "WEB_DASHBOARD_TOKEN"
tokenInput.inputType = InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_PASSWORD
nicknameInput.hint = "nickname"
nicknameInput.inputType = InputType.TYPE_CLASS_TEXT
messageInput.hint = "message"
messageInput.minLines = 2
connectButton.text = "Connect"
refreshButton.text = "Refresh"
sendButton.text = "Send"
statusView.text = "Disconnected"
threadView.text = "Select a room"
threadView.setTypeface(Typeface.MONOSPACE)
roomsList.adapter = roomsAdapter
val controls = LinearLayout(activity).apply {
orientation = LinearLayout.VERTICAL
setPadding(24, 24, 24, 12)
addView(baseUrlInput)
addView(tokenInput)
addView(nicknameInput)
addView(LinearLayout(activity).apply {
orientation = LinearLayout.HORIZONTAL
addView(connectButton, LinearLayout.LayoutParams(0, -2, 1f))
addView(refreshButton, LinearLayout.LayoutParams(0, -2, 1f))
})
addView(statusView)
}
val scroll = ScrollView(activity).apply {
addView(threadView)
}
val sendRow = LinearLayout(activity).apply {
orientation = LinearLayout.HORIZONTAL
addView(messageInput, LinearLayout.LayoutParams(0, -2, 1f))
addView(sendButton)
}
root = LinearLayout(activity).apply {
orientation = LinearLayout.VERTICAL
addView(controls)
addView(roomsList, LinearLayout.LayoutParams(-1, 0, 1f))
addView(scroll, LinearLayout.LayoutParams(-1, 0, 1f))
addView(sendRow)
}
}
}

View File

@@ -0,0 +1,3 @@
<resources>
<string name="app_name">EJClaw</string>
</resources>

View File

@@ -0,0 +1,7 @@
<resources>
<style name="AppTheme" parent="android:style/Theme.Material.Light.NoActionBar">
<item name="android:fontFamily">sans</item>
<item name="android:windowLightStatusBar">true</item>
<item name="android:colorAccent">#2563EB</item>
</style>
</resources>

View File

@@ -0,0 +1,6 @@
<network-security-config>
<domain-config cleartextTrafficPermitted="true">
<!-- Personal Tailscale dashboard endpoint used by the debug APK. -->
<domain includeSubdomains="false">100.101.210.95</domain>
</domain-config>
</network-security-config>

View File

@@ -0,0 +1,4 @@
plugins {
id("com.android.application") version "8.13.0" apply false
id("org.jetbrains.kotlin.android") version "2.2.21" apply false
}

View File

@@ -0,0 +1,3 @@
android.useAndroidX=true
android.nonTransitiveRClass=true
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8

Binary file not shown.

View File

@@ -0,0 +1,7 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.3-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists

251
apps/android/gradlew vendored Executable file
View File

@@ -0,0 +1,251 @@
#!/bin/sh
#
# Copyright © 2015-2021 the original authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
# SPDX-License-Identifier: Apache-2.0
#
##############################################################################
#
# Gradle start up script for POSIX generated by Gradle.
#
# Important for running:
#
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
# noncompliant, but you have some other compliant shell such as ksh or
# bash, then to run this script, type that shell name before the whole
# command line, like:
#
# ksh Gradle
#
# Busybox and similar reduced shells will NOT work, because this script
# requires all of these POSIX shell features:
# * functions;
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
# * compound commands having a testable exit status, especially «case»;
# * various built-in commands including «command», «set», and «ulimit».
#
# Important for patching:
#
# (2) This script targets any POSIX shell, so it avoids extensions provided
# by Bash, Ksh, etc; in particular arrays are avoided.
#
# The "traditional" practice of packing multiple parameters into a
# space-separated string is a well documented source of bugs and security
# problems, so this is (mostly) avoided, by progressively accumulating
# options in "$@", and eventually passing that to Java.
#
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
# see the in-line comments for details.
#
# There are tweaks for specific operating systems such as AIX, CygWin,
# Darwin, MinGW, and NonStop.
#
# (3) This script is generated from the Groovy template
# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# within the Gradle project.
#
# You can find Gradle at https://github.com/gradle/gradle/.
#
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
app_path=$0
# Need this for daisy-chained symlinks.
while
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
[ -h "$app_path" ]
do
ls=$( ls -ld "$app_path" )
link=${ls#*' -> '}
case $link in #(
/*) app_path=$link ;; #(
*) app_path=$APP_HOME$link ;;
esac
done
# This is normally unused
# shellcheck disable=SC2034
APP_BASE_NAME=${0##*/}
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD=maximum
warn () {
echo "$*"
} >&2
die () {
echo
echo "$*"
echo
exit 1
} >&2
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "$( uname )" in #(
CYGWIN* ) cygwin=true ;; #(
Darwin* ) darwin=true ;; #(
MSYS* | MINGW* ) msys=true ;; #(
NONSTOP* ) nonstop=true ;;
esac
CLASSPATH="\\\"\\\""
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD=$JAVA_HOME/jre/sh/java
else
JAVACMD=$JAVA_HOME/bin/java
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD=java
if ! command -v java >/dev/null 2>&1
then
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
fi
# Increase the maximum file descriptors if we can.
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
case $MAX_FD in #(
max*)
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
MAX_FD=$( ulimit -H -n ) ||
warn "Could not query maximum file descriptor limit"
esac
case $MAX_FD in #(
'' | soft) :;; #(
*)
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
ulimit -n "$MAX_FD" ||
warn "Could not set maximum file descriptor limit to $MAX_FD"
esac
fi
# Collect all arguments for the java command, stacking in reverse order:
# * args from the command line
# * the main class name
# * -classpath
# * -D...appname settings
# * --module-path (only if needed)
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
# For Cygwin or MSYS, switch paths to Windows format before running java
if "$cygwin" || "$msys" ; then
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
JAVACMD=$( cygpath --unix "$JAVACMD" )
# Now convert the arguments - kludge to limit ourselves to /bin/sh
for arg do
if
case $arg in #(
-*) false ;; # don't mess with options #(
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
[ -e "$t" ] ;; #(
*) false ;;
esac
then
arg=$( cygpath --path --ignore --mixed "$arg" )
fi
# Roll the args list around exactly as many times as the number of
# args, so each arg winds up back in the position where it started, but
# possibly modified.
#
# NB: a `for` loop captures its iteration list before it begins, so
# changing the positional parameters here affects neither the number of
# iterations, nor the values presented in `arg`.
shift # remove old arg
set -- "$@" "$arg" # push replacement arg
done
fi
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Collect all arguments for the java command:
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
# and any embedded shellness will be escaped.
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
# treated as '${Hostname}' itself on the command line.
set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \
-classpath "$CLASSPATH" \
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
"$@"
# Stop when "xargs" is not available.
if ! command -v xargs >/dev/null 2>&1
then
die "xargs is not available"
fi
# Use "xargs" to parse quoted args.
#
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
#
# In Bash we could simply go:
#
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
# set -- "${ARGS[@]}" "$@"
#
# but POSIX shell has neither arrays nor command substitution, so instead we
# post-process each arg (as a line of input to sed) to backslash-escape any
# character that might be a shell metacharacter, then use eval to reverse
# that process (while maintaining the separation between arguments), and wrap
# the whole thing up as a single "set" statement.
#
# This will of course break if any of these variables contains a newline or
# an unmatched quote.
#
eval "set -- $(
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
xargs -n1 |
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
tr '\n' ' '
)" '"$@"'
exec "$JAVACMD" "$@"

94
apps/android/gradlew.bat vendored Normal file
View File

@@ -0,0 +1,94 @@
@rem
@rem Copyright 2015 the original author or authors.
@rem
@rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at
@rem
@rem https://www.apache.org/licenses/LICENSE-2.0
@rem
@rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@rem SPDX-License-Identifier: Apache-2.0
@rem
@if "%DEBUG%"=="" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0
if "%DIRNAME%"=="" set DIRNAME=.
@rem This is normally unused
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if %ERRORLEVEL% equ 0 goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:execute
@rem Setup the command line
set CLASSPATH=
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %*
:end
@rem End local scope for the variables with windows NT shell
if %ERRORLEVEL% equ 0 goto mainEnd
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
set EXIT_CODE=%ERRORLEVEL%
if %EXIT_CODE% equ 0 set EXIT_CODE=1
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
exit /b %EXIT_CODE%
:mainEnd
if "%OS%"=="Windows_NT" endlocal
:omega

View File

@@ -0,0 +1,7 @@
## Copy to local.properties when building outside Android Studio.
## Do not commit local.properties.
sdk.dir=/home/ejclaw/android-sdk
## Optional later, when enabling Meta Wearables DAT dependencies.
## github_token=ghp_xxx
## meta_dat_application_id=your_meta_wearables_application_id

View File

@@ -0,0 +1,18 @@
pluginManagement {
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
}
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
google()
mavenCentral()
}
}
rootProject.name = "EJClawAndroid"
include(":app")

View File

@@ -1,9 +1,19 @@
<!doctype html>
<html lang="ko">
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>EJClaw Dashboard</title>
<meta
name="viewport"
content="width=device-width, initial-scale=1.0, viewport-fit=cover"
/>
<meta name="theme-color" content="#2b3726" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-title" content="EJClaw" />
<meta name="mobile-web-app-capable" content="yes" />
<link rel="manifest" href="/manifest.webmanifest" />
<link rel="icon" href="/icons/icon-192.svg" type="image/svg+xml" />
<link rel="apple-touch-icon" href="/icons/icon-192.png" />
<title>EJClaw Ops</title>
</head>
<body>
<div id="root"></div>

Binary file not shown.

After

Width:  |  Height:  |  Size: 861 B

View File

@@ -0,0 +1,8 @@
<svg xmlns="http://www.w3.org/2000/svg" width="192" height="192" viewBox="0 0 192 192" role="img" aria-label="EJClaw">
<rect width="192" height="192" rx="46" fill="#2b3726" />
<path d="M39 122c18 21 47 29 81 17 18-6 30-18 36-35" fill="none" stroke="#f7edda" stroke-width="14" stroke-linecap="round" />
<path d="M50 78c16-24 45-36 76-25 13 5 24 13 32 25" fill="none" stroke="#bf5f2c" stroke-width="14" stroke-linecap="round" />
<circle cx="74" cy="94" r="11" fill="#f7edda" />
<circle cx="118" cy="94" r="11" fill="#f7edda" />
<path d="M88 126h21" stroke="#f7edda" stroke-width="10" stroke-linecap="round" />
</svg>

After

Width:  |  Height:  |  Size: 627 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

View File

@@ -0,0 +1,8 @@
<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 512 512" role="img" aria-label="EJClaw">
<rect width="512" height="512" rx="124" fill="#2b3726" />
<path d="M104 325c48 58 126 78 217 47 48-16 80-49 96-93" fill="none" stroke="#f7edda" stroke-width="38" stroke-linecap="round" />
<path d="M134 208c43-64 120-96 203-66 36 13 64 35 86 66" fill="none" stroke="#bf5f2c" stroke-width="38" stroke-linecap="round" />
<circle cx="198" cy="250" r="30" fill="#f7edda" />
<circle cx="314" cy="250" r="30" fill="#f7edda" />
<path d="M235 336h56" stroke="#f7edda" stroke-width="28" stroke-linecap="round" />
</svg>

After

Width:  |  Height:  |  Size: 641 B

View File

@@ -0,0 +1,27 @@
{
"name": "EJClaw Ops",
"short_name": "EJClaw",
"description": "EJClaw operations console",
"start_url": "/",
"scope": "/",
"display": "standalone",
"display_override": ["window-controls-overlay", "standalone", "browser"],
"background_color": "#f3efe4",
"theme_color": "#2b3726",
"orientation": "portrait-primary",
"categories": ["productivity", "utilities"],
"icons": [
{
"src": "/icons/icon-192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any maskable"
},
{
"src": "/icons/icon-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any maskable"
}
]
}

View File

@@ -0,0 +1,87 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta
name="viewport"
content="width=device-width, initial-scale=1.0, viewport-fit=cover"
/>
<meta name="theme-color" content="#2b3726" />
<title>EJClaw Offline</title>
<style>
:root {
color-scheme: light;
font-family:
Avenir Next,
Segoe UI Variable,
Noto Sans KR,
ui-sans-serif,
sans-serif;
background: #f3efe4;
color: #1c211c;
}
body {
display: grid;
min-height: 100vh;
margin: 0;
place-items: center;
background:
radial-gradient(
circle at 20% 18%,
rgba(191, 95, 44, 0.2),
transparent 26rem
),
linear-gradient(135deg, #f4ead5, #edf1df);
}
main {
width: min(420px, calc(100% - 32px));
padding: 24px;
border: 1px solid rgba(43, 55, 38, 0.16);
border-radius: 28px;
background: rgba(255, 250, 240, 0.86);
box-shadow: 0 24px 70px rgba(44, 39, 25, 0.16);
}
span {
color: #8f351b;
font-size: 12px;
font-weight: 850;
letter-spacing: 0.16em;
text-transform: uppercase;
}
h1 {
margin: 8px 0;
font-size: 30px;
letter-spacing: -0.05em;
}
p {
margin: 0 0 18px;
color: #6d735f;
line-height: 1.45;
}
button {
min-height: 44px;
padding: 0 18px;
border: 0;
border-radius: 999px;
color: #fffaf0;
background: linear-gradient(135deg, #bf5f2c, #8f351b);
font: inherit;
font-weight: 900;
}
</style>
</head>
<body>
<main>
<span>EJClaw</span>
<h1>Offline</h1>
<p>네트워크가 복구되면 대시보드를 다시 불러옵니다.</p>
<button type="button" onclick="window.location.reload()">Retry</button>
</main>
</body>
</html>

View File

@@ -0,0 +1,78 @@
const CACHE_NAME = 'ejclaw-dashboard-v1';
const PRECACHE_URLS = [
'/',
'/index.html',
'/offline.html',
'/manifest.webmanifest',
'/icons/icon-192.png',
'/icons/icon-512.png',
'/icons/icon-192.svg',
'/icons/icon-512.svg',
];
self.addEventListener('install', (event) => {
event.waitUntil(
caches
.open(CACHE_NAME)
.then((cache) => cache.addAll(PRECACHE_URLS))
.then(() => self.skipWaiting()),
);
});
self.addEventListener('activate', (event) => {
event.waitUntil(
caches
.keys()
.then((keys) =>
Promise.all(
keys
.filter((key) => key !== CACHE_NAME)
.map((key) => caches.delete(key)),
),
)
.then(() => self.clients.claim()),
);
});
self.addEventListener('fetch', (event) => {
const { request } = event;
const url = new URL(request.url);
if (request.method !== 'GET' || url.origin !== self.location.origin) {
return;
}
if (url.pathname.startsWith('/api/')) {
event.respondWith(fetch(request));
return;
}
if (request.mode === 'navigate') {
event.respondWith(
fetch(request)
.then((response) => {
const copy = response.clone();
caches.open(CACHE_NAME).then((cache) => cache.put('/', copy));
return response;
})
.catch(() =>
caches
.match('/')
.then((cached) => cached || caches.match('/offline.html')),
),
);
return;
}
event.respondWith(
caches.match(request).then((cached) => {
if (cached) return cached;
return fetch(request).then((response) => {
if (!response.ok) return response;
const copy = response.clone();
caches.open(CACHE_NAME).then((cache) => cache.put(request, copy));
return response;
});
}),
);
});

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,245 @@
import { type ReactNode } from 'react';
import { Clock, Gauge, MessageSquare, Settings } from 'lucide-react';
import { type DashboardOverview, type StatusSnapshot } from './api';
import { type Messages } from './i18n';
export type DashboardView = 'usage' | 'rooms' | 'scheduled' | 'settings';
export type DashboardFreshness = 'fresh' | 'stale' | 'offline';
interface DashboardNavData {
overview: DashboardOverview;
snapshots: StatusSnapshot[];
}
const NAV_ICONS: Record<DashboardView, ReactNode> = {
usage: <Gauge size={20} strokeWidth={2} aria-hidden />,
rooms: <MessageSquare size={20} strokeWidth={2} aria-hidden />,
scheduled: <Clock size={20} strokeWidth={2} aria-hidden />,
settings: <Settings size={20} strokeWidth={2} aria-hidden />,
};
function navItems(t: Messages) {
return [
{ href: '#/rooms', label: t.nav.rooms, view: 'rooms' as const },
{ href: '#/scheduled', label: t.nav.scheduled, view: 'scheduled' as const },
{ href: '#/usage', label: t.nav.usage, view: 'usage' as const },
{ href: '#/settings', label: t.nav.settings, view: 'settings' as const },
];
}
function navStats(data: DashboardNavData | null) {
if (!data) return null;
const queue = data.snapshots.reduce(
(acc, snapshot) => {
for (const entry of snapshot.entries) {
if (entry.status === 'processing') acc.processing += 1;
}
return acc;
},
{ processing: 0 },
);
return {
processing: queue.processing,
};
}
function navBadge(
view: DashboardView,
stats: ReturnType<typeof navStats>,
): number | null {
if (view === 'rooms' && stats && stats.processing > 0) {
return stats.processing;
}
return null;
}
function DashboardBrandLink({
onNavigate,
}: {
onNavigate: (view: DashboardView) => void;
}) {
return (
<a
className="rail-brand"
href="#/rooms"
onClick={() => onNavigate('rooms')}
title="EJClaw"
>
EJ
</a>
);
}
function DashboardNavLinks({
activeView,
data,
onAfterNavigate,
onNavigate,
t,
}: {
activeView: DashboardView;
data: DashboardNavData | null;
onAfterNavigate?: () => void;
onNavigate: (view: DashboardView) => void;
t: Messages;
}) {
const stats = navStats(data);
return (
<nav className="rail-nav" aria-label={t.nav.drawerNavAria}>
{navItems(t).map((item) => {
const badge = navBadge(item.view, stats);
return (
<a
aria-current={activeView === item.view ? 'page' : undefined}
aria-label={item.label}
className={`rail-item${activeView === item.view ? ' is-active' : ''}`}
href={item.href}
key={item.href}
onClick={() => {
onNavigate(item.view);
onAfterNavigate?.();
}}
title={item.label}
>
<span className="rail-icon">{NAV_ICONS[item.view]}</span>
<span className="rail-label">{item.label}</span>
{badge !== null ? (
<span className="rail-badge">{badge}</span>
) : null}
</a>
);
})}
</nav>
);
}
export function SideRail({
activeView,
data,
onNavigate,
t,
}: {
activeView: DashboardView;
data: DashboardNavData | null;
onNavigate: (view: DashboardView) => void;
t: Messages;
}) {
return (
<aside className="side-rail icon-rail" aria-label={t.nav.drawerAria}>
<DashboardBrandLink onNavigate={onNavigate} />
<DashboardNavLinks
activeView={activeView}
data={data}
onNavigate={onNavigate}
t={t}
/>
</aside>
);
}
export function SectionNav({
activeView,
data,
drawerOpen,
freshness,
freshnessText,
onCloseDrawer,
onNavigate,
onOpenDrawer,
onRefresh,
refreshing,
t,
}: {
activeView: DashboardView;
data: DashboardNavData | null;
drawerOpen: boolean;
freshness: DashboardFreshness;
freshnessText: string;
onCloseDrawer: () => void;
onNavigate: (view: DashboardView) => void;
onOpenDrawer: () => void;
onRefresh: () => void;
refreshing: boolean;
t: Messages;
}) {
const activeLabel =
navItems(t).find((item) => item.view === activeView)?.label ?? t.nav.rooms;
const showManualRefresh = freshness !== 'fresh';
return (
<>
<nav className="section-nav" aria-label={t.nav.aria}>
<button
aria-controls="dashboard-menu"
aria-expanded={drawerOpen}
aria-label={drawerOpen ? t.nav.menuClose : t.nav.menuOpen}
className="menu-button"
onClick={drawerOpen ? onCloseDrawer : onOpenDrawer}
type="button"
>
<span />
<span />
<span />
</button>
<strong className="topbar-label">{activeLabel}</strong>
<span className={`topbar-status topbar-status-${freshness}`}>
{freshnessText}
</span>
{showManualRefresh ? (
<button
aria-busy={refreshing}
aria-label={refreshing ? t.actions.refreshing : t.actions.refresh}
className="refresh-button"
disabled={refreshing}
onClick={onRefresh}
type="button"
>
{refreshing ? '...' : t.actions.refresh}
</button>
) : null}
</nav>
{drawerOpen ? (
<>
<button
aria-label={t.nav.menuClose}
className="drawer-backdrop"
onClick={onCloseDrawer}
type="button"
/>
<aside
aria-label={t.nav.drawerAria}
aria-modal="true"
className="nav-drawer"
id="dashboard-menu"
role="dialog"
>
<div className="drawer-head">
<DashboardBrandLink onNavigate={onNavigate} />
<div className="drawer-title">
<span className="eyebrow">EJClaw</span>
<strong>{t.nav.operations}</strong>
</div>
<button
aria-label={t.nav.menuClose}
className="rail-btn drawer-close"
onClick={onCloseDrawer}
type="button"
>
{t.actions.close}
</button>
</div>
<DashboardNavLinks
activeView={activeView}
data={data}
onAfterNavigate={onCloseDrawer}
onNavigate={onNavigate}
t={t}
/>
</aside>
</>
) : null}
</>
);
}

View File

@@ -0,0 +1,5 @@
import type { ReactNode } from 'react';
export function EmptyState({ children }: { children: ReactNode }) {
return <div className="empty-state">{children}</div>;
}

View File

@@ -0,0 +1,207 @@
import { useEffect, useState } from 'react';
import {
type DashboardInboxAction,
type DashboardOverview,
type StatusSnapshot,
fetchDashboardData,
runInboxAction,
sendRoomMessage,
} from './api';
import { isLocale, matchLocale, type Locale } from './i18n';
import { GlassesPanel } from './GlassesPanel';
import './styles.css';
import './glasses.css';
interface GlassesState {
overview: DashboardOverview;
snapshots: StatusSnapshot[];
}
type InboxItem = DashboardOverview['inbox'][number];
type InboxActionKey = `${string}:${DashboardInboxAction}`;
const REFRESH_INTERVAL_MS = 15_000;
const DASHBOARD_STALE_MS = 75_000;
const LOCALE_STORAGE_KEY = 'ejclaw.dashboard.locale.v2';
function makeClientRequestId(): string {
return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
}
function readInitialLocale(): Locale {
const stored =
typeof window === 'undefined'
? null
: window.localStorage.getItem(LOCALE_STORAGE_KEY);
if (isLocale(stored)) return stored;
const languages =
typeof navigator === 'undefined'
? []
: [...(navigator.languages || []), navigator.language];
for (const language of languages) {
const matched = matchLocale(language);
if (matched) return matched;
}
return 'en';
}
function dashboardAgeMs(value: string | null | undefined): number | null {
if (!value) return null;
const date = new Date(value);
if (Number.isNaN(date.getTime())) return null;
return Math.max(0, Date.now() - date.getTime());
}
function freshnessLabel(
online: boolean,
generatedAt: string | null | undefined,
): string {
if (!online) return 'offline';
const age = dashboardAgeMs(generatedAt);
if (age !== null && age > DASHBOARD_STALE_MS) return 'stale';
return 'fresh';
}
function readNickname(): string {
if (typeof window === 'undefined') return '';
return window.localStorage.getItem('ejclaw-nickname') ?? '';
}
export function GlassesApp() {
const [data, setData] = useState<GlassesState | null>(null);
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
const [online, setOnline] = useState(() =>
typeof navigator === 'undefined' ? true : navigator.onLine,
);
const [locale] = useState<Locale>(readInitialLocale);
const [inboxActionKey, setInboxActionKey] = useState<InboxActionKey | null>(
null,
);
const [roomMessageKey, setRoomMessageKey] = useState<string | null>(null);
async function refresh(showSpinner = false) {
if (showSpinner) setRefreshing(true);
try {
const nextData = await fetchDashboardData();
setData({
overview: nextData.overview,
snapshots: nextData.snapshots,
});
setError(null);
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setLoading(false);
setRefreshing(false);
}
}
async function handleInboxAction(
item: InboxItem,
action: DashboardInboxAction,
) {
const actionKey: InboxActionKey = `${item.id}:${action}`;
setInboxActionKey(actionKey);
try {
await runInboxAction(item.id, action, {
lastOccurredAt: item.lastOccurredAt,
requestId: makeClientRequestId(),
});
await refresh(false);
return true;
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
return false;
} finally {
setInboxActionKey(null);
}
}
async function handleRoomMessage(
roomJid: string,
text: string,
requestId: string,
) {
setRoomMessageKey(roomJid);
try {
await sendRoomMessage(roomJid, text, requestId, readNickname() || null);
void refresh(false);
return true;
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
return false;
} finally {
setRoomMessageKey(null);
}
}
useEffect(() => {
document.documentElement.lang = locale;
}, [locale]);
useEffect(() => {
function handleOnline() {
setOnline(true);
}
function handleOffline() {
setOnline(false);
}
window.addEventListener('online', handleOnline);
window.addEventListener('offline', handleOffline);
return () => {
window.removeEventListener('online', handleOnline);
window.removeEventListener('offline', handleOffline);
};
}, []);
useEffect(() => {
void refresh();
const id = window.setInterval(() => {
void refresh();
}, REFRESH_INTERVAL_MS);
return () => window.clearInterval(id);
}, []);
if (loading && !data) {
return (
<main className="glasses-shell" aria-busy="true">
<div className="glasses-empty">
<strong>EJClaw</strong>
<span>Loading</span>
</div>
</main>
);
}
if (!data) {
return (
<main className="glasses-shell">
<p className="glasses-error">{error ?? 'Dashboard unavailable'}</p>
</main>
);
}
return (
<GlassesPanel
createRequestId={makeClientRequestId}
error={error}
freshnessText={freshnessLabel(online, data.overview.generatedAt)}
inboxActionKey={inboxActionKey}
locale={locale}
onInboxAction={handleInboxAction}
onRefresh={() => void refresh(true)}
onSendRoomMessage={handleRoomMessage}
overview={data.overview}
refreshing={refreshing}
roomMessageKey={roomMessageKey}
snapshots={data.snapshots}
/>
);
}

View File

@@ -0,0 +1,120 @@
import { createElement } from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { describe, expect, it } from 'vitest';
import { GlassesPanel } from './GlassesPanel';
import type { DashboardOverview, StatusSnapshot } from './api';
const overview: DashboardOverview = {
generatedAt: '2026-06-07T13:00:00.000Z',
services: [],
rooms: {
total: 1,
active: 1,
waiting: 0,
inactive: 0,
},
tasks: {
total: 1,
active: 1,
paused: 0,
completed: 0,
watchers: {
active: 0,
paused: 0,
completed: 0,
},
},
usage: {
rows: [],
fetchedAt: null,
},
inbox: [
{
createdAt: '2026-06-07T12:59:30.000Z',
groupKey: 'task:review',
id: 'inbox-1',
kind: 'reviewer-request',
lastOccurredAt: '2026-06-07T12:59:30.000Z',
occurrences: 1,
occurredAt: '2026-06-07T12:59:30.000Z',
roomJid: 'dc:ops',
roomName: 'Ops',
severity: 'warn',
source: 'paired-task',
summary: 'Reviewer requested owner changes',
taskId: 'task-1',
taskStatus: 'active',
title: 'Review follow-up',
},
],
};
const snapshots: StatusSnapshot[] = [
{
agentType: 'codex',
assistantName: 'Codex',
serviceId: 'codex-main',
updatedAt: '2026-06-07T13:00:00.000Z',
entries: [
{
agentType: 'codex',
elapsedMs: 12000,
folder: 'ejclaw',
jid: 'dc:ops',
name: 'Ops',
pendingMessages: false,
pendingTasks: 1,
status: 'processing',
},
],
},
];
describe('GlassesPanel', () => {
it('renders a compact display queue and voice input surface', () => {
const html = renderToStaticMarkup(
createElement(GlassesPanel, {
createRequestId: () => 'req-1',
error: null,
freshnessText: 'fresh',
inboxActionKey: null,
locale: 'ko',
onInboxAction: async () => true,
onRefresh: () => {},
onSendRoomMessage: async () => true,
overview,
refreshing: false,
roomMessageKey: null,
snapshots,
}),
);
expect(html).toContain('glasses-shell');
expect(html).toContain('Review follow-up');
expect(html).toContain('리뷰');
expect(html).toContain('Voice');
});
it('tolerates partial status snapshot payloads while data is refreshing', () => {
const html = renderToStaticMarkup(
createElement(GlassesPanel, {
createRequestId: () => 'req-1',
error: null,
freshnessText: 'fresh',
inboxActionKey: null,
locale: 'ko',
onInboxAction: async () => true,
onRefresh: () => {},
onSendRoomMessage: async () => true,
overview,
refreshing: false,
roomMessageKey: null,
snapshots: [{} as StatusSnapshot],
}),
);
expect(html).toContain('glasses-shell');
expect(html).toContain('Review follow-up');
});
});

View File

@@ -0,0 +1,607 @@
import {
type KeyboardEvent,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
import { Check, Inbox, Mic, RefreshCw, Send, X } from 'lucide-react';
import {
type DashboardInboxAction,
type DashboardOverview,
type StatusSnapshot,
} from './api';
import { formatDate } from './dashboardHelpers';
import type { Locale } from './i18n';
type InboxItem = DashboardOverview['inbox'][number];
type GlassesMode = 'queue' | 'voice';
interface RoomChoice {
jid: string;
label: string;
status: StatusSnapshot['entries'][number]['status'];
pendingTasks: number;
}
interface SpeechRecognitionResultLike {
readonly 0?: { transcript?: string };
}
interface SpeechRecognitionEventLike {
readonly results: ArrayLike<SpeechRecognitionResultLike>;
}
interface SpeechRecognitionLike {
continuous: boolean;
interimResults: boolean;
lang: string;
maxAlternatives: number;
onend: (() => void) | null;
onerror: (() => void) | null;
onresult: ((event: SpeechRecognitionEventLike) => void) | null;
start: () => void;
}
type SpeechRecognitionConstructor = new () => SpeechRecognitionLike;
interface SpeechWindow extends Window {
SpeechRecognition?: SpeechRecognitionConstructor;
webkitSpeechRecognition?: SpeechRecognitionConstructor;
}
export interface GlassesPanelProps {
createRequestId: () => string;
error: string | null;
freshnessText: string;
inboxActionKey: `${string}:${DashboardInboxAction}` | null;
locale: Locale;
onInboxAction: (
item: InboxItem,
action: DashboardInboxAction,
) => Promise<boolean>;
onRefresh: () => void;
onSendRoomMessage: (
roomJid: string,
text: string,
requestId: string,
) => Promise<boolean>;
overview: DashboardOverview;
refreshing: boolean;
roomMessageKey: string | null;
snapshots: StatusSnapshot[];
}
const QUEUE_KIND_ORDER: Record<InboxItem['kind'], number> = {
approval: 0,
'reviewer-request': 1,
'arbiter-request': 2,
'ci-failure': 3,
mention: 4,
'pending-room': 5,
};
const SEVERITY_ORDER: Record<InboxItem['severity'], number> = {
error: 0,
warn: 1,
info: 2,
};
const SPEECH_LOCALES: Record<Locale, string> = {
en: 'en-US',
ja: 'ja-JP',
ko: 'ko-KR',
zh: 'zh-CN',
};
function getSpeechRecognition(): SpeechRecognitionConstructor | null {
if (typeof window === 'undefined') return null;
const speechWindow = window as SpeechWindow;
return (
speechWindow.SpeechRecognition ??
speechWindow.webkitSpeechRecognition ??
null
);
}
function inboxActionsFor(item: InboxItem): DashboardInboxAction[] {
if (
item.source === 'paired-task' &&
(item.kind === 'reviewer-request' ||
item.kind === 'approval' ||
item.kind === 'arbiter-request')
) {
return ['run', 'decline', 'dismiss'];
}
return ['dismiss'];
}
function actionLabel(item: InboxItem, action: DashboardInboxAction): string {
if (action === 'dismiss') return '닫기';
if (action === 'decline') return '거절';
if (item.kind === 'approval') return '최종화';
if (item.kind === 'reviewer-request') return '리뷰';
if (item.kind === 'arbiter-request') return '중재';
return '실행';
}
function actionIcon(action: DashboardInboxAction) {
if (action === 'decline') return <X size={16} aria-hidden />;
if (action === 'dismiss') return <Check size={16} aria-hidden />;
return <Send size={16} aria-hidden />;
}
function sortInboxItems(items: InboxItem[]): InboxItem[] {
return [...items].sort((a, b) => {
const severity = SEVERITY_ORDER[a.severity] - SEVERITY_ORDER[b.severity];
if (severity !== 0) return severity;
const kind = QUEUE_KIND_ORDER[a.kind] - QUEUE_KIND_ORDER[b.kind];
if (kind !== 0) return kind;
return b.lastOccurredAt.localeCompare(a.lastOccurredAt);
});
}
function buildRoomChoices(snapshots: StatusSnapshot[]): RoomChoice[] {
const rooms = new Map<string, RoomChoice>();
for (const snapshot of snapshots) {
const entries = Array.isArray(snapshot.entries) ? snapshot.entries : [];
for (const entry of entries) {
const existing = rooms.get(entry.jid);
const label = entry.name || entry.folder || entry.jid;
if (!existing) {
rooms.set(entry.jid, {
jid: entry.jid,
label,
pendingTasks: entry.pendingTasks,
status: entry.status,
});
continue;
}
if (
entry.status === 'processing' ||
entry.pendingTasks > existing.pendingTasks
) {
rooms.set(entry.jid, {
jid: entry.jid,
label,
pendingTasks: entry.pendingTasks,
status: entry.status,
});
}
}
}
return [...rooms.values()].sort((a, b) => {
if (a.status === 'processing' && b.status !== 'processing') return -1;
if (b.status === 'processing' && a.status !== 'processing') return 1;
return b.pendingTasks - a.pendingTasks || a.label.localeCompare(b.label);
});
}
function clampIndex(index: number, length: number): number {
if (length <= 0) return 0;
return Math.max(0, Math.min(index, length - 1));
}
function nextIndex(index: number, length: number, delta: number): number {
if (length <= 0) return 0;
return (index + delta + length) % length;
}
function readTranscript(event: SpeechRecognitionEventLike): string {
return Array.from(event.results)
.map((result) => result[0]?.transcript?.trim() ?? '')
.filter(Boolean)
.join(' ')
.trim();
}
interface QueueCardProps {
inboxActionKey: `${string}:${DashboardInboxAction}` | null;
inboxActions: DashboardInboxAction[];
inboxItems: InboxItem[];
locale: Locale;
onInboxAction: (
item: InboxItem,
action: DashboardInboxAction,
) => Promise<boolean>;
selectedActionIndex: number;
selectedInbox: InboxItem | undefined;
selectedInboxIndex: number;
setSelectedActionIndex: (index: number) => void;
}
function QueueCard({
inboxActionKey,
inboxActions,
inboxItems,
locale,
onInboxAction,
selectedActionIndex,
selectedInbox,
selectedInboxIndex,
setSelectedActionIndex,
}: QueueCardProps) {
if (!selectedInbox) {
return (
<section className="glasses-card glasses-queue-card" aria-live="polite">
<div className="glasses-empty">
<strong>Queue clear</strong>
<span> </span>
</div>
</section>
);
}
return (
<section className="glasses-card glasses-queue-card" aria-live="polite">
<div className="glasses-card-head">
<span className={`glasses-pill sev-${selectedInbox.severity}`}>
{selectedInbox.kind}
</span>
<small>
{selectedInboxIndex + 1}/{inboxItems.length}
</small>
</div>
<h2>{selectedInbox.title}</h2>
<p>{selectedInbox.summary}</p>
<dl className="glasses-meta">
<div>
<dt>Target</dt>
<dd>
{selectedInbox.roomName ??
selectedInbox.groupFolder ??
selectedInbox.roomJid ??
selectedInbox.taskId ??
'-'}
</dd>
</div>
<div>
<dt>Time</dt>
<dd>{formatDate(selectedInbox.lastOccurredAt, locale)}</dd>
</div>
</dl>
<div className="glasses-actions">
{inboxActions.map((action, index) => {
const actionKey = `${selectedInbox.id}:${action}`;
const busy = inboxActionKey === actionKey;
return (
<button
aria-busy={busy || undefined}
aria-pressed={index === selectedActionIndex}
className={
index === selectedActionIndex ? 'is-active' : undefined
}
disabled={busy || Boolean(inboxActionKey)}
key={action}
onClick={() => {
setSelectedActionIndex(index);
void onInboxAction(selectedInbox, action);
}}
type="button"
>
{actionIcon(action)}
{busy ? '처리 중' : actionLabel(selectedInbox, action)}
</button>
);
})}
</div>
</section>
);
}
interface VoiceCardProps {
canListen: boolean;
listening: boolean;
onSendVoiceText: () => void;
onStartListening: () => void;
selectedRoom: RoomChoice | undefined;
sendingVoice: boolean;
setSelectedRoomIndex: (index: number) => void;
setVoiceText: (value: string) => void;
rooms: RoomChoice[];
voiceText: string;
}
function VoiceCard({
canListen,
listening,
onSendVoiceText,
onStartListening,
selectedRoom,
sendingVoice,
setSelectedRoomIndex,
setVoiceText,
rooms,
voiceText,
}: VoiceCardProps) {
return (
<section className="glasses-card glasses-voice-card">
<div className="glasses-card-head">
<span className="glasses-pill">voice</span>
<small>{selectedRoom?.label ?? 'No room'}</small>
</div>
<textarea
aria-label="음성 또는 키보드 입력"
onChange={(event) => setVoiceText(event.target.value)}
placeholder="말하거나 입력..."
value={voiceText}
/>
<div className="glasses-room-strip" aria-label="room target">
{rooms.slice(0, 4).map((room, index) => (
<button
aria-pressed={room.jid === selectedRoom?.jid}
className={room.jid === selectedRoom?.jid ? 'is-active' : undefined}
key={room.jid}
onClick={() => setSelectedRoomIndex(index)}
type="button"
>
{room.label}
</button>
))}
</div>
<div className="glasses-actions">
<button
disabled={!canListen || listening}
onClick={onStartListening}
type="button"
>
<Mic size={16} aria-hidden />
{listening ? '듣는 중' : '말하기'}
</button>
<button
className="is-active"
disabled={!selectedRoom || !voiceText.trim() || sendingVoice}
onClick={onSendVoiceText}
type="button"
>
<Send size={16} aria-hidden />
{sendingVoice ? '전송 중' : '전송'}
</button>
</div>
</section>
);
}
interface GlassesHeaderProps {
onRefresh: () => void;
refreshing: boolean;
}
function GlassesHeader({ onRefresh, refreshing }: GlassesHeaderProps) {
return (
<header className="glasses-header">
<div>
<span className="glasses-kicker">EJClaw</span>
<h1>Display</h1>
</div>
<button
aria-busy={refreshing || undefined}
aria-label="새로고침"
className="glasses-icon-button"
disabled={refreshing}
onClick={onRefresh}
type="button"
>
<RefreshCw size={18} aria-hidden />
</button>
</header>
);
}
interface ModeTabsProps {
mode: GlassesMode;
setMode: (mode: GlassesMode) => void;
}
function ModeTabs({ mode, setMode }: ModeTabsProps) {
return (
<nav className="glasses-tabs" aria-label="display modes">
<button
aria-pressed={mode === 'queue'}
className={mode === 'queue' ? 'is-active' : undefined}
onClick={() => setMode('queue')}
type="button"
>
<Inbox size={16} aria-hidden />
Queue
</button>
<button
aria-pressed={mode === 'voice'}
className={mode === 'voice' ? 'is-active' : undefined}
onClick={() => setMode('voice')}
type="button"
>
<Mic size={16} aria-hidden />
Voice
</button>
</nav>
);
}
function footerStatus(
mode: GlassesMode,
busyActionKey: string | null,
selectedRoom: RoomChoice | undefined,
): string {
if (mode === 'queue') return busyActionKey ?? 'ready';
return selectedRoom?.status ?? 'ready';
}
export function GlassesPanel({
createRequestId,
error,
freshnessText,
inboxActionKey,
locale,
onInboxAction,
onRefresh,
onSendRoomMessage,
overview,
refreshing,
roomMessageKey,
snapshots,
}: GlassesPanelProps) {
const shellRef = useRef<HTMLElement | null>(null);
const [mode, setMode] = useState<GlassesMode>('queue');
const [selectedInboxIndex, setSelectedInboxIndex] = useState(0);
const [selectedActionIndex, setSelectedActionIndex] = useState(0);
const [selectedRoomIndex, setSelectedRoomIndex] = useState(0);
const [voiceText, setVoiceText] = useState('');
const [listening, setListening] = useState(false);
const inboxItems = useMemo(
() => sortInboxItems(Array.isArray(overview.inbox) ? overview.inbox : []),
[overview.inbox],
);
const rooms = useMemo(
() => buildRoomChoices(Array.isArray(snapshots) ? snapshots : []),
[snapshots],
);
const selectedInbox =
inboxItems[clampIndex(selectedInboxIndex, inboxItems.length)];
const inboxActions = selectedInbox ? inboxActionsFor(selectedInbox) : [];
const selectedAction =
inboxActions[clampIndex(selectedActionIndex, inboxActions.length)];
const selectedRoom = rooms[clampIndex(selectedRoomIndex, rooms.length)];
const canListen = getSpeechRecognition() !== null;
const busyActionKey =
selectedInbox && selectedAction
? `${selectedInbox.id}:${selectedAction}`
: null;
const sendingVoice = selectedRoom
? roomMessageKey === selectedRoom.jid
: false;
useEffect(() => {
shellRef.current?.focus();
}, []);
useEffect(() => {
setSelectedInboxIndex((value) => clampIndex(value, inboxItems.length));
}, [inboxItems.length]);
useEffect(() => {
setSelectedActionIndex((value) => clampIndex(value, inboxActions.length));
}, [inboxActions.length]);
useEffect(() => {
setSelectedRoomIndex((value) => clampIndex(value, rooms.length));
}, [rooms.length]);
function startListening() {
const SpeechRecognition = getSpeechRecognition();
if (!SpeechRecognition || listening) return;
const recognition = new SpeechRecognition();
recognition.lang = SPEECH_LOCALES[locale];
recognition.continuous = false;
recognition.interimResults = false;
recognition.maxAlternatives = 1;
recognition.onresult = (event) => {
const nextText = readTranscript(event);
if (nextText) setVoiceText(nextText);
};
recognition.onerror = () => setListening(false);
recognition.onend = () => setListening(false);
setListening(true);
recognition.start();
}
async function runSelectedAction() {
if (!selectedInbox || !selectedAction || inboxActionKey) return;
await onInboxAction(selectedInbox, selectedAction);
}
async function sendVoiceText() {
const text = voiceText.trim();
if (!selectedRoom || !text || sendingVoice) return;
const ok = await onSendRoomMessage(
selectedRoom.jid,
text,
createRequestId(),
);
if (ok) setVoiceText('');
}
function handleKeyDown(event: KeyboardEvent<HTMLElement>) {
if (
event.target instanceof HTMLInputElement ||
event.target instanceof HTMLTextAreaElement
) {
return;
}
if (event.key === 'ArrowLeft' || event.key === 'ArrowRight') {
event.preventDefault();
setMode((value) => (value === 'queue' ? 'voice' : 'queue'));
return;
}
if (event.key === 'ArrowUp' || event.key === 'ArrowDown') {
event.preventDefault();
const delta = event.key === 'ArrowDown' ? 1 : -1;
if (mode === 'queue') {
setSelectedInboxIndex((value) =>
nextIndex(value, inboxItems.length, delta),
);
} else {
setSelectedRoomIndex((value) => nextIndex(value, rooms.length, delta));
}
return;
}
if (event.key === 'Enter') {
event.preventDefault();
if (mode === 'queue') void runSelectedAction();
else void sendVoiceText();
}
}
return (
<main
aria-label="EJClaw Ray-Ban Display"
className="glasses-shell"
onKeyDown={handleKeyDown}
ref={shellRef}
tabIndex={0}
>
<GlassesHeader onRefresh={onRefresh} refreshing={refreshing} />
<section className="glasses-status-row" aria-label="상태">
<span>{freshnessText}</span>
<strong>{inboxItems.length} items</strong>
</section>
{error ? <p className="glasses-error">{error}</p> : null}
<ModeTabs mode={mode} setMode={setMode} />
{mode === 'queue' ? (
<QueueCard
inboxActionKey={inboxActionKey}
inboxActions={inboxActions}
inboxItems={inboxItems}
locale={locale}
onInboxAction={onInboxAction}
selectedActionIndex={selectedActionIndex}
selectedInbox={selectedInbox}
selectedInboxIndex={selectedInboxIndex}
setSelectedActionIndex={setSelectedActionIndex}
/>
) : (
<VoiceCard
canListen={canListen}
listening={listening}
onSendVoiceText={() => void sendVoiceText()}
onStartListening={startListening}
rooms={rooms}
selectedRoom={selectedRoom}
sendingVoice={sendingVoice}
setSelectedRoomIndex={setSelectedRoomIndex}
setVoiceText={setVoiceText}
voiceText={voiceText}
/>
)}
<footer className="glasses-footer">
<span>{footerStatus(mode, busyActionKey, selectedRoom)}</span>
</footer>
</main>
);
}

View File

@@ -0,0 +1,82 @@
import { createElement } from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { describe, expect, it } from 'vitest';
import type { DashboardOverview } from './api';
import { InboxPanel, type InboxPanelProps } from './InboxPanel';
import { messages } from './i18n';
const t = messages.en;
function overview(inbox: DashboardOverview['inbox']): DashboardOverview {
return {
generatedAt: '2026-04-28T04:00:00.000Z',
inbox,
operations: { serviceRestarts: [] },
rooms: { active: 0, inactive: 0, total: 0, waiting: 0 },
services: [],
tasks: {
active: 0,
completed: 0,
paused: 0,
total: 0,
watchers: { active: 0, completed: 0, paused: 0 },
},
usage: { fetchedAt: null, rows: [] },
};
}
const inboxItem: DashboardOverview['inbox'][number] = {
createdAt: '2026-04-28T03:55:00.000Z',
groupFolder: 'eyejokerdb',
groupKey: 'paired:room-1',
id: 'inbox-1',
kind: 'reviewer-request',
lastOccurredAt: '2026-04-28T03:59:00.000Z',
occurrences: 2,
occurredAt: '2026-04-28T03:58:00.000Z',
roomJid: 'room-1',
roomName: 'eyejokerdb-main',
severity: 'warn',
source: 'paired-task',
summary: '<internal>hidden</internal>Reviewer needs action',
title: '<internal>hidden</internal>Review requested',
};
const baseProps: InboxPanelProps = {
inboxActionKey: null,
locale: 'en',
onInboxAction: () => {},
onTaskAction: () => {},
overview: overview([inboxItem]),
taskActionKey: null,
tasks: [],
t,
};
describe('InboxPanel', () => {
it('renders inbox summary, filters, and paired task actions', () => {
const html = renderToStaticMarkup(
createElement(InboxPanel, { ...baseProps }),
);
expect(html).toContain(t.inbox.summary);
expect(html).toContain('Review requested');
expect(html).toContain('Reviewer needs action');
expect(html).toContain(t.inbox.actions.runReview);
expect(html).toContain(t.inbox.actions.decline);
expect(html).toContain(t.inbox.actions.dismiss);
expect(html).not.toContain('internal');
});
it('renders empty state when no inbox items exist', () => {
const html = renderToStaticMarkup(
createElement(InboxPanel, {
...baseProps,
overview: overview([]),
}),
);
expect(html).toContain(t.inbox.empty);
});
});

View File

@@ -0,0 +1,282 @@
import { useMemo, useState } from 'react';
import type {
DashboardInboxAction,
DashboardOverview,
DashboardTask,
DashboardTaskAction,
} from './api';
import { formatDate } from './dashboardHelpers';
import { EmptyState } from './EmptyState';
import type { Locale, Messages } from './i18n';
import { TaskActionButtons } from './TaskActionButtons';
export type InboxItem = DashboardOverview['inbox'][number];
export type InboxActionKey = `${string}:${DashboardInboxAction}`;
type InboxFilter = 'all' | InboxItem['kind'];
export interface InboxPanelProps {
inboxActionKey: InboxActionKey | null;
locale: Locale;
onInboxAction: (item: InboxItem, action: DashboardInboxAction) => void;
onTaskAction: (task: DashboardTask, action: DashboardTaskAction) => void;
overview: DashboardOverview;
taskActionKey: string | null;
tasks: DashboardTask[];
t: Messages;
}
const INBOX_FILTERS: InboxFilter[] = [
'all',
'ci-failure',
'approval',
'reviewer-request',
'arbiter-request',
'pending-room',
'mention',
];
function sanitizeInboxText(value: string | null | undefined): string {
if (!value) return '';
return value
.replace(/<\/?internal[^>]*>/gi, '')
.replace(/<\/?intern\.{3}/gi, '')
.replace(/<\/?[a-z][a-z0-9-]*[^>]*>/gi, '')
.replace(/\s{2,}/g, ' ')
.trim();
}
function inboxActionsFor(item: InboxItem): DashboardInboxAction[] {
if (
item.source === 'paired-task' &&
(item.kind === 'reviewer-request' ||
item.kind === 'approval' ||
item.kind === 'arbiter-request')
) {
return ['run', 'decline', 'dismiss'];
}
return ['dismiss'];
}
function inboxActionLabel(
item: InboxItem,
action: DashboardInboxAction,
t: Messages,
): string {
if (action === 'dismiss') return t.inbox.actions.dismiss;
if (action === 'decline') return t.inbox.actions.decline;
if (item.kind === 'reviewer-request') return t.inbox.actions.runReview;
if (item.kind === 'approval') return t.inbox.actions.finalize;
if (item.kind === 'arbiter-request') return t.inbox.actions.runArbiter;
return t.inbox.actions.run;
}
function inboxTargetHref(item: InboxItem): string | null {
if (item.taskId) return '#/scheduled';
if (item.roomJid || item.groupFolder) return '#/rooms';
return null;
}
interface InboxCardProps {
inboxActionKey: InboxActionKey | null;
item: InboxItem;
locale: Locale;
onInboxAction: (item: InboxItem, action: DashboardInboxAction) => void;
onTaskAction: (task: DashboardTask, action: DashboardTaskAction) => void;
taskActionKey: string | null;
tasks: DashboardTask[];
t: Messages;
}
function InboxCard({
inboxActionKey,
item,
locale,
onInboxAction,
onTaskAction,
taskActionKey,
tasks,
t,
}: InboxCardProps) {
const href = inboxTargetHref(item);
const linkedTask =
item.source === 'scheduled-task' && item.taskId
? tasks.find((task) => task.id === item.taskId)
: undefined;
const inboxActions = inboxActionsFor(item);
return (
<article className={`inbox-card inbox-${item.severity}`}>
<div className="inbox-card-head">
<div>
<span className="eyebrow">{t.inbox.kinds[item.kind]}</span>
<strong>{sanitizeInboxText(item.title) || item.title}</strong>
</div>
<div className="inbox-card-badges">
<span className={`pill pill-${item.severity}`}>
{t.inbox.severity[item.severity]}
</span>
{item.occurrences > 1 ? (
<span className="pill pill-info">x{item.occurrences}</span>
) : null}
</div>
</div>
<p>{sanitizeInboxText(item.summary) || t.inbox.noSummary}</p>
<div className="inbox-meta">
<span>
<small>{t.inbox.occurred}</small>
<strong>{formatDate(item.occurredAt, locale)}</strong>
</span>
<span>
<small>{t.inbox.source}</small>
<strong>{item.source}</strong>
</span>
<span>
<small>{t.inbox.target}</small>
<strong>
{item.taskId ??
item.roomName ??
item.groupFolder ??
item.roomJid ??
'-'}
</strong>
</span>
</div>
{href ? (
<a className="inbox-target" href={href}>
{item.taskId ? t.inbox.openTask : t.inbox.openRoom}
</a>
) : null}
{linkedTask ? (
<TaskActionButtons
className="inbox-actions"
onTaskAction={onTaskAction}
task={linkedTask}
taskActionKey={taskActionKey}
t={t}
/>
) : null}
{inboxActions.length > 0 ? (
<div className="task-actions inbox-actions">
{inboxActions.map((action) => {
const actionKey: InboxActionKey = `${item.id}:${action}`;
const busy = inboxActionKey === actionKey;
return (
<button
aria-busy={busy || undefined}
className={`task-action task-action-${action}${busy ? ' is-busy' : ''}`}
disabled={busy}
key={action}
onClick={() => onInboxAction(item, action)}
type="button"
>
{busy
? t.inbox.actions.busy
: inboxActionLabel(item, action, t)}
</button>
);
})}
</div>
) : null}
</article>
);
}
export function InboxPanel({
overview,
tasks,
locale,
onInboxAction,
onTaskAction,
inboxActionKey,
taskActionKey,
t,
}: InboxPanelProps) {
const [filter, setFilter] = useState<InboxFilter>('all');
const items = overview.inbox ?? [];
const counts = useMemo(() => {
const next: Record<InboxFilter, number> = {
all: items.length,
'pending-room': 0,
'reviewer-request': 0,
approval: 0,
'arbiter-request': 0,
'ci-failure': 0,
mention: 0,
};
for (const item of items) next[item.kind] += 1;
return next;
}, [items]);
const filteredItems =
filter === 'all' ? items : items.filter((item) => item.kind === filter);
const severityCounts = items.reduce(
(acc, item) => {
acc[item.severity] += 1;
return acc;
},
{ error: 0, warn: 0, info: 0 },
);
if (items.length === 0) {
return <EmptyState>{t.inbox.empty}</EmptyState>;
}
return (
<div className="inbox-board">
<section className="inbox-summary" aria-label={t.inbox.summary}>
<div>
<span>{t.inbox.total}</span>
<strong>{items.length}</strong>
</div>
<div>
<span>{t.inbox.severity.error}</span>
<strong>{severityCounts.error}</strong>
</div>
<div>
<span>{t.inbox.severity.warn}</span>
<strong>{severityCounts.warn}</strong>
</div>
<div>
<span>{t.inbox.severity.info}</span>
<strong>{severityCounts.info}</strong>
</div>
</section>
<div className="inbox-filters" aria-label={t.inbox.filters}>
{INBOX_FILTERS.map((item) => {
if (item !== 'all' && counts[item] === 0) return null;
const label = item === 'all' ? t.inbox.all : t.inbox.kinds[item];
return (
<button
aria-pressed={filter === item}
className={filter === item ? 'is-active' : undefined}
key={item}
onClick={() => setFilter(item)}
type="button"
>
{label}
<span>{counts[item]}</span>
</button>
);
})}
</div>
<div className="inbox-list" aria-label={t.inbox.cardsAria}>
{filteredItems.map((item) => (
<InboxCard
inboxActionKey={inboxActionKey}
item={item}
key={item.id}
locale={locale}
onInboxAction={onInboxAction}
onTaskAction={onTaskAction}
taskActionKey={taskActionKey}
tasks={tasks}
t={t}
/>
))}
</div>
</div>
);
}

View File

@@ -0,0 +1,390 @@
import { useEffect, useState } from 'react';
import {
type MoaModelSettingsSnapshot,
type MoaReferenceStatus,
type MoaSettingsSnapshot,
checkMoaModel,
fetchMoaSettings,
updateMoaSettings,
} from './api';
import { type Messages } from './i18n';
import { SettingsSaveBar, SettingsSectionHeading } from './SettingsPanelChrome';
export function MoaSettingsPanel({ t }: { t: Messages }) {
const [config, setConfig] = useState<MoaSettingsSnapshot | null>(null);
const [draft, setDraft] = useState<MoaSettingsSnapshot | null>(null);
const [apiKeys, setApiKeys] = useState<Record<string, string>>({});
const [busy, setBusy] = useState(false);
const [checking, setChecking] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [savedAt, setSavedAt] = useState<number | null>(null);
const section = t.settings.sections.moa;
useEffect(() => {
let cancelled = false;
setBusy(true);
fetchMoaSettings()
.then((next) => {
if (cancelled) return;
setConfig(next);
setDraft(next);
setError(null);
})
.catch((err) => {
if (cancelled) return;
setError(err instanceof Error ? err.message : String(err));
})
.finally(() => {
if (!cancelled) setBusy(false);
});
return () => {
cancelled = true;
};
}, []);
function setModel(name: string, patch: Partial<MoaModelSettingsSnapshot>) {
setDraft((prev) =>
prev
? {
...prev,
models: prev.models.map((model) =>
model.name === name ? { ...model, ...patch } : model,
),
}
: prev,
);
}
function updateStatus(name: string, status: MoaReferenceStatus) {
const update = (prev: MoaSettingsSnapshot | null) =>
prev
? {
...prev,
models: prev.models.map((model) =>
model.name === name ? { ...model, lastStatus: status } : model,
),
}
: prev;
setConfig(update);
setDraft(update);
}
async function save() {
if (!draft) return;
setBusy(true);
setError(null);
try {
const next = await updateMoaSettings({
enabled: draft.enabled,
models: draft.models.map((model) => ({
name: model.name,
enabled: model.enabled,
model: model.model,
baseUrl: model.baseUrl,
apiFormat: model.apiFormat,
apiKey: apiKeys[model.name]?.trim() || undefined,
})),
});
setConfig(next);
setDraft(next);
setApiKeys({});
setSavedAt(Date.now());
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setBusy(false);
}
}
async function testModel(name: string) {
setChecking(name);
setError(null);
try {
updateStatus(name, await checkMoaModel(name));
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setChecking(null);
}
}
const dirty =
draft !== null &&
config !== null &&
(JSON.stringify(draft) !== JSON.stringify(config) ||
Object.values(apiKeys).some((value) => value.trim()));
return (
<section
aria-labelledby="settings-moa-tab"
className="settings-section"
id="settings-moa"
role="tabpanel"
>
<SettingsSectionHeading
description={section.description}
detail={section.kicker}
title={section.title}
/>
{error ? <p className="settings-error">{error}</p> : null}
{!draft ? (
<p className="settings-hint">
{busy ? t.settings.common.loading : t.settings.moa.empty}
</p>
) : (
<MoaSettingsContent
apiKeys={apiKeys}
busy={busy}
checking={checking}
dirty={dirty}
draft={draft}
onApiKeyChange={(name, value) =>
setApiKeys((prev) => ({ ...prev, [name]: value }))
}
onModelChange={setModel}
onSave={save}
onTest={testModel}
onToggle={() =>
setDraft((prev) =>
prev ? { ...prev, enabled: !prev.enabled } : prev,
)
}
savedAt={savedAt}
t={t}
/>
)}
</section>
);
}
function MoaSettingsContent({
apiKeys,
busy,
checking,
dirty,
draft,
onApiKeyChange,
onModelChange,
onSave,
onTest,
onToggle,
savedAt,
t,
}: {
apiKeys: Record<string, string>;
busy: boolean;
checking: string | null;
dirty: boolean;
draft: MoaSettingsSnapshot;
onApiKeyChange: (name: string, value: string) => void;
onModelChange: (
name: string,
patch: Partial<MoaModelSettingsSnapshot>,
) => void;
onSave: () => Promise<void>;
onTest: (name: string) => Promise<void>;
onToggle: () => void;
savedAt: number | null;
t: Messages;
}) {
return (
<>
<MoaMasterToggle
busy={busy}
enabled={draft.enabled}
onToggle={onToggle}
t={t}
/>
<ul className="settings-moa-list">
{draft.models.map((model) => (
<MoaModelRow
apiKeyValue={apiKeys[model.name] ?? ''}
busy={busy}
checking={checking}
dirty={dirty}
key={model.name}
model={model}
onApiKeyChange={onApiKeyChange}
onModelChange={onModelChange}
onTest={onTest}
t={t}
/>
))}
</ul>
<SettingsSaveBar
busy={busy || checking !== null}
dirty={dirty}
label={t.settings.moa.save}
onSave={() => void onSave()}
savedHint={t.settings.common.savedRestartHint}
savingLabel={t.settings.common.saving}
showSavedHint={savedAt !== null && !dirty}
/>
</>
);
}
function formatMoaStatus(
status: MoaReferenceStatus | null,
t: Messages,
): string {
if (!status) return t.settings.moa.notTested;
const at = new Date(status.checkedAt);
const checkedAt = Number.isNaN(at.getTime())
? status.checkedAt
: at.toLocaleString('ko-KR');
if (status.ok) {
return `${t.settings.moa.statusOk} · ${checkedAt}`;
}
return `${t.settings.moa.statusFail} · ${checkedAt} · ${status.error ?? 'unknown error'}`;
}
function MoaMasterToggle({
busy,
enabled,
onToggle,
t,
}: {
busy: boolean;
enabled: boolean;
onToggle: () => void;
t: Messages;
}) {
return (
<label className="settings-toggle-row">
<span className="settings-toggle-label">
<span className="settings-toggle-title">{t.settings.moa.master}</span>
<small className="settings-hint">{t.settings.moa.masterHint}</small>
</span>
<input
checked={enabled}
disabled={busy}
onChange={onToggle}
type="checkbox"
/>
</label>
);
}
function MoaModelRow({
apiKeyValue,
busy,
checking,
dirty,
model,
onApiKeyChange,
onModelChange,
onTest,
t,
}: {
apiKeyValue: string;
busy: boolean;
checking: string | null;
dirty: boolean;
model: MoaModelSettingsSnapshot;
onApiKeyChange: (name: string, value: string) => void;
onModelChange: (
name: string,
patch: Partial<MoaModelSettingsSnapshot>,
) => void;
onTest: (name: string) => Promise<void>;
t: Messages;
}) {
return (
<li className="settings-moa-card">
<header className="settings-moa-card-head">
<label className="settings-moa-name">
<input
checked={model.enabled}
disabled={busy}
onChange={() =>
onModelChange(model.name, { enabled: !model.enabled })
}
type="checkbox"
/>
<span className="settings-account-tag">{model.name}</span>
</label>
<span
className={`settings-account-badge ${
model.lastStatus?.ok === false ? 'is-expired' : 'is-active'
}`}
title={model.lastStatus?.error ?? undefined}
>
{formatMoaStatus(model.lastStatus, t)}
</span>
</header>
<div className="settings-moa-fields">
<label className="settings-row">
<span className="settings-label">{t.settings.moa.modelLabel}</span>
<input
aria-label={`${model.name} MoA model`}
onChange={(e) =>
onModelChange(model.name, { model: e.target.value })
}
placeholder="model"
type="text"
value={model.model}
/>
</label>
<label className="settings-row">
<span className="settings-label">{t.settings.moa.baseUrlLabel}</span>
<input
aria-label={`${model.name} MoA base URL`}
onChange={(e) =>
onModelChange(model.name, { baseUrl: e.target.value })
}
placeholder="https://…"
type="text"
value={model.baseUrl}
/>
</label>
<label className="settings-row">
<span className="settings-label">{t.settings.moa.formatLabel}</span>
<select
aria-label={`${model.name} MoA API format`}
onChange={(e) =>
onModelChange(model.name, {
apiFormat: e.target.value as 'openai' | 'anthropic',
})
}
value={model.apiFormat}
>
<option value="anthropic">anthropic</option>
<option value="openai">openai</option>
</select>
</label>
<label className="settings-row">
<span className="settings-label">API key</span>
<input
aria-label={`${model.name} MoA API key`}
onChange={(e) => onApiKeyChange(model.name, e.target.value)}
placeholder={
model.apiKeyConfigured
? t.settings.moa.apiKeySet
: t.settings.moa.apiKeyPlaceholder
}
type="password"
value={apiKeyValue}
/>
</label>
</div>
<div className="settings-account-actions">
<button
className="settings-secondary"
disabled={
busy || checking !== null || dirty || !model.apiKeyConfigured
}
onClick={() => void onTest(model.name)}
type="button"
>
{checking === model.name
? t.settings.moa.testing
: dirty
? t.settings.moa.testAfterSave
: t.settings.moa.test}
</button>
</div>
</li>
);
}

View File

@@ -0,0 +1,32 @@
import { useMemo, type ReactNode } from 'react';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import { redactSecretsForMarkdown } from './redaction';
export function ParsedBody({
text,
truncate,
}: {
text: string | null | undefined;
truncate?: number;
}): ReactNode {
const cleaned = useMemo(() => {
if (!text) return '';
let out = redactSecretsForMarkdown(text);
if (truncate && out.length > truncate) {
out = out.slice(0, truncate) + '…';
}
return out;
}, [text, truncate]);
if (!cleaned.trim()) {
return <span className="parsed-empty"></span>;
}
return (
<div className="parsed-body">
<ReactMarkdown remarkPlugins={[remarkGfm]}>{cleaned}</ReactMarkdown>
</div>
);
}

View File

@@ -0,0 +1,38 @@
import type { RoomThreadEntry } from './roomThread';
type RoomAttachment = NonNullable<RoomThreadEntry['attachments']>[number];
function attachmentDisplayName(attachment: RoomAttachment): string {
if (attachment.name) return attachment.name;
const segments = attachment.path.split(/[\\/]/);
return segments.at(-1) || 'attachment';
}
function attachmentUrl(attachment: RoomAttachment): string {
return `/api/attachments?path=${encodeURIComponent(attachment.path)}`;
}
export function RoomAttachmentGallery({
attachments,
}: {
attachments?: RoomThreadEntry['attachments'];
}) {
if (!attachments || attachments.length === 0) return null;
return (
<ul className="room-attachments">
{attachments.map((attachment) => {
const name = attachmentDisplayName(attachment);
const url = attachmentUrl(attachment);
return (
<li className="room-attachment" key={`${attachment.path}:${name}`}>
<a href={url} rel="noreferrer" target="_blank">
<img alt={name} loading="lazy" src={url} />
<span>{name}</span>
</a>
</li>
);
})}
</ul>
);
}

View File

@@ -0,0 +1,122 @@
import { createElement } from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { describe, expect, it } from 'vitest';
import type { DashboardRoomActivity, StatusSnapshot } from './api';
import { messages } from './i18n';
import { RoomBoardV2, type RoomBoardV2Props } from './RoomBoardV2';
const t = messages.ko;
const snapshot: StatusSnapshot = {
serviceId: 'svc-1',
assistantName: 'codex',
agentType: 'codex',
updatedAt: '2026-04-28T04:00:00.000Z',
entries: [
{
jid: 'room-1',
name: 'eyejokerdb-main',
folder: 'eyejokerdb',
agentType: 'codex',
status: 'processing',
elapsedMs: 180_000,
pendingMessages: false,
pendingTasks: 3,
},
],
};
const activity: DashboardRoomActivity = {
serviceId: 'svc-1',
jid: 'room-1',
name: 'eyejokerdb-main',
folder: 'eyejokerdb',
agentType: 'codex',
status: 'processing',
elapsedMs: 180_000,
pendingMessages: false,
pendingTasks: 3,
messages: [
{
id: 'bot-1',
sender: 'bot-1',
senderName: 'owner',
content: 'TASK_DONE\n\nprod 배포 완료',
timestamp: '2026-04-28T02:00:00.000Z',
isFromMe: false,
isBotMessage: true,
sourceKind: 'bot',
},
],
pairedTask: null,
};
const baseProps: RoomBoardV2Props = {
createRequestId: () => 'request-1',
formatDate: (value) => value ?? '-',
formatDuration: (value) => (value === null ? '-' : `${value}`),
formatLiveElapsed: (value) => `${value}`,
inbox: [],
locale: 'ko',
onSelectedJidChange: () => {},
onSendRoomMessage: async () => true,
pendingMessages: {},
roomActivity: { 'room-1': activity },
roomActivityLoading: false,
roomMessageKey: null,
selectedJid: 'room-1',
senderRoleClass: (value) =>
(value ?? '').toLowerCase().includes('owner') ? 'role-owner' : 'role-human',
snapshots: [snapshot],
statusLabel: (status) => status,
t,
};
describe('RoomBoardV2', () => {
it('renders room list and selected detail thread', () => {
const html = renderToStaticMarkup(createElement(RoomBoardV2, baseProps));
expect(html).toContain('eyejokerdb-main');
expect(html).toContain('TASK_DONE');
expect(html).toContain('prod 배포 완료');
});
it('surfaces merge approval items as room action badges', () => {
const html = renderToStaticMarkup(
createElement(RoomBoardV2, {
...baseProps,
inbox: [
{
id: 'paired:merge-1:merge_ready',
groupKey: 'paired:merge-1:merge_ready',
kind: 'approval',
severity: 'warn',
title: 'Ready to merge',
summary: 'merge_ready',
occurredAt: '2026-04-28T04:00:00.000Z',
lastOccurredAt: '2026-04-28T04:00:00.000Z',
createdAt: '2026-04-28T04:00:00.000Z',
occurrences: 1,
source: 'paired-task',
roomJid: 'room-1',
taskId: 'merge-1',
taskStatus: 'merge_ready',
},
],
}),
);
expect(html).toContain(t.inbox.kinds.approval);
expect(html).toContain('room-inbox-pip sev-warn');
expect(html).toContain('rooms-list-bell sev-warn');
});
it('renders an empty state without snapshots', () => {
const html = renderToStaticMarkup(
createElement(RoomBoardV2, { ...baseProps, snapshots: [] }),
);
expect(html).toContain(t.rooms.empty);
});
});

View File

@@ -0,0 +1,397 @@
import { useEffect, useState } from 'react';
import type {
DashboardOverview,
DashboardRoomActivity,
StatusSnapshot,
} from './api';
import type { Locale, Messages } from './i18n';
import { RoomCardV2, type RoomEntryWithService } from './RoomCardV2';
import { EmptyState } from './EmptyState';
import type { RoomActivityMap } from './useRoomActivity';
type InboxItem = DashboardOverview['inbox'][number];
type RoomFilter = 'all' | 'processing' | 'waiting' | 'inactive';
type RoomSort = 'recent' | 'name' | 'queue';
const ROOM_FILTER_ORDER: RoomFilter[] = [
'all',
'processing',
'waiting',
'inactive',
];
export interface RoomBoardV2Props {
createRequestId: () => string;
formatDate: (value: string | null | undefined, locale: Locale) => string;
formatDuration: (value: number | null, t: Messages) => string;
formatLiveElapsed: (value: number, t: Messages) => string;
inbox: InboxItem[];
locale: Locale;
onSelectedJidChange: (jid: string | null) => void;
onSendRoomMessage: (
roomJid: string,
text: string,
requestId: string,
) => Promise<boolean>;
pendingMessages: Record<
string,
Array<DashboardRoomActivity['messages'][number]>
>;
roomActivity: RoomActivityMap;
roomActivityLoading: boolean;
roomMessageKey: string | null;
selectedJid: string | null;
senderRoleClass: (value: string | null | undefined) => string;
snapshots: StatusSnapshot[];
statusLabel: (status: string, t: Messages) => string;
t: Messages;
}
function roomFilterLabel(
filter: RoomFilter,
statusLabel: (status: string, t: Messages) => string,
t: Messages,
): string {
if (filter === 'all') return t.rooms.filterAll;
return statusLabel(filter, t);
}
function roomSortLabel(sort: RoomSort, t: Messages): string {
if (sort === 'recent') return t.rooms.sortRecent;
if (sort === 'queue') return t.rooms.sortQueue;
return t.rooms.sortName;
}
function scrollDetailToBottom() {
if (typeof window === 'undefined') return;
const detail = document.querySelector('.rooms-detail') as HTMLElement | null;
if (!detail) return;
const tick = (n: number) => {
detail.scrollTop = detail.scrollHeight;
if (n > 0) requestAnimationFrame(() => tick(n - 1));
};
requestAnimationFrame(() => tick(3));
}
interface RoomsToolbarProps {
counts: Record<RoomFilter, number>;
filter: RoomFilter;
onFilterChange: (filter: RoomFilter) => void;
onSortChange: (sort: RoomSort) => void;
sort: RoomSort;
statusLabel: (status: string, t: Messages) => string;
t: Messages;
}
function RoomsToolbar({
counts,
filter,
onFilterChange,
onSortChange,
sort,
statusLabel,
t,
}: RoomsToolbarProps) {
return (
<div className="rooms-toolbar" role="toolbar" aria-label="Rooms filters">
<div className="rooms-filters">
{ROOM_FILTER_ORDER.map((filterKey) => (
<button
aria-pressed={filter === filterKey}
className={filter === filterKey ? 'is-active' : undefined}
key={filterKey}
onClick={() => onFilterChange(filterKey)}
type="button"
>
{roomFilterLabel(filterKey, statusLabel, t)}
<span>{counts[filterKey]}</span>
</button>
))}
</div>
<label className="rooms-sort">
<span>{t.rooms.sortLabel}</span>
<select
onChange={(event) => onSortChange(event.target.value as RoomSort)}
value={sort}
>
<option value="recent">{roomSortLabel('recent', t)}</option>
<option value="queue">{roomSortLabel('queue', t)}</option>
<option value="name">{roomSortLabel('name', t)}</option>
</select>
</label>
</div>
);
}
interface RoomsListProps {
entries: RoomEntryWithService[];
inbox: InboxItem[];
onSelect: (jid: string) => void;
selectedJid: string | null;
t: Messages;
}
function RoomsList({
entries,
inbox,
onSelect,
selectedJid,
t,
}: RoomsListProps) {
return (
<aside className="rooms-list" aria-label={t.rooms.cardsAria}>
{entries.map((entry) => {
const items = inbox.filter((item) => item.roomJid === entry.jid);
const queue = entry.pendingTasks + (entry.pendingMessages ? 1 : 0);
const active = selectedJid === entry.jid;
return (
<button
aria-current={active ? 'page' : undefined}
className={`rooms-list-item status-${entry.status}${active ? ' is-active' : ''}`}
key={`${entry.serviceId}:${entry.jid}`}
onClick={() => onSelect(entry.jid)}
type="button"
>
<span className={`room-pulse pulse-${entry.status}`}>
{entry.status === 'processing' ? (
<span className="pulse-dot" />
) : null}
</span>
<span className="rooms-list-text">
<strong>{entry.name}</strong>
<small>{entry.agentType}</small>
</span>
{items.length > 0 ? (
<span
className={`rooms-list-bell sev-${items[0].severity}`}
title={items.map((item) => t.inbox.kinds[item.kind]).join(', ')}
>
{items.length}
</span>
) : null}
{queue > 0 ? (
<span className="rooms-list-queue">{queue}</span>
) : null}
</button>
);
})}
</aside>
);
}
interface RoomsDetailProps extends Pick<
RoomBoardV2Props,
| 'formatDate'
| 'formatDuration'
| 'formatLiveElapsed'
| 'locale'
| 'pendingMessages'
| 'roomActivity'
| 'roomActivityLoading'
| 'roomMessageKey'
| 'senderRoleClass'
| 'statusLabel'
| 't'
> {
drafts: Record<string, string>;
inbox: InboxItem[];
onBack: () => void;
onDraftChange: (jid: string, value: string) => void;
onSubmit: (jid: string) => void;
selectedEntry: RoomEntryWithService | null;
}
function RoomsDetail({
drafts,
formatDate,
formatDuration,
formatLiveElapsed,
inbox,
locale,
onBack,
onDraftChange,
onSubmit,
pendingMessages,
roomActivity,
roomActivityLoading,
roomMessageKey,
selectedEntry,
senderRoleClass,
statusLabel,
t,
}: RoomsDetailProps) {
return (
<main className="rooms-detail">
<button className="rooms-mobile-back" onClick={onBack} type="button">
{t.panels.rooms}
</button>
{selectedEntry ? (
<RoomCardV2
activity={roomActivity[selectedEntry.jid]}
activityLoading={roomActivityLoading}
busy={roomMessageKey === selectedEntry.jid}
draft={drafts[selectedEntry.jid] ?? ''}
entry={selectedEntry}
expanded={true}
formatDate={formatDate}
formatDuration={formatDuration}
formatLiveElapsed={formatLiveElapsed}
inboxItems={inbox.filter(
(item) => item.roomJid === selectedEntry.jid,
)}
key={`${selectedEntry.serviceId}:${selectedEntry.jid}`}
locale={locale}
onDraftChange={(value) => onDraftChange(selectedEntry.jid, value)}
onSendMessage={() => onSubmit(selectedEntry.jid)}
onToggle={() => {}}
pendingMessages={pendingMessages[selectedEntry.jid] ?? []}
pinned={true}
senderRoleClass={senderRoleClass}
statusLabel={statusLabel}
t={t}
/>
) : (
<EmptyState>{t.rooms.empty}</EmptyState>
)}
</main>
);
}
export function RoomBoardV2({
createRequestId,
formatDate,
formatDuration,
formatLiveElapsed,
inbox,
onSendRoomMessage,
pendingMessages,
roomActivity,
roomActivityLoading,
roomMessageKey,
selectedJid,
locale,
onSelectedJidChange,
senderRoleClass,
snapshots,
statusLabel,
t,
}: RoomBoardV2Props) {
const [filter, setFilter] = useState<RoomFilter>('all');
const [sort, setSort] = useState<RoomSort>('recent');
const [drafts, setDrafts] = useState<Record<string, string>>({});
const [mobileDetailOpen, setMobileDetailOpen] = useState(false);
const allEntries: RoomEntryWithService[] = snapshots.flatMap((snapshot) =>
snapshot.entries.map((entry) => ({
...entry,
serviceId: snapshot.serviceId,
})),
);
const counts = {
all: allEntries.length,
processing: allEntries.filter((entry) => entry.status === 'processing')
.length,
waiting: allEntries.filter((entry) => entry.status === 'waiting').length,
inactive: allEntries.filter((entry) => entry.status === 'inactive').length,
};
const filtered = allEntries.filter(
(entry) => filter === 'all' || entry.status === filter,
);
const sorted = Array.from(filtered).sort((a, b) => {
if (sort === 'name') return a.name.localeCompare(b.name);
if (sort === 'queue') {
const aQ = a.pendingTasks + (a.pendingMessages ? 1 : 0);
const bQ = b.pendingTasks + (b.pendingMessages ? 1 : 0);
return bQ - aQ;
}
const aA = roomActivity[a.jid]?.pairedTask?.updatedAt;
const bA = roomActivity[b.jid]?.pairedTask?.updatedAt;
const aT = aA ? new Date(aA).getTime() : (a.elapsedMs ?? 0);
const bT = bA ? new Date(bA).getTime() : (b.elapsedMs ?? 0);
return bT - aT;
});
const selectedEntry =
sorted.find((entry) => entry.jid === selectedJid) ?? sorted[0] ?? null;
useEffect(() => {
const nextJid = selectedEntry?.jid ?? null;
if (nextJid !== selectedJid) {
onSelectedJidChange(nextJid);
}
}, [onSelectedJidChange, selectedEntry?.jid, selectedJid]);
const detailOpen = mobileDetailOpen && selectedEntry?.jid === selectedJid;
if (allEntries.length === 0) {
return <EmptyState>{t.rooms.empty}</EmptyState>;
}
function setDraft(jid: string, value: string) {
setDrafts((previous) => ({ ...previous, [jid]: value }));
}
async function submitRoomMessage(jid: string) {
const text = drafts[jid]?.trim();
if (!text) return;
scrollDetailToBottom();
const success = await onSendRoomMessage(jid, text, createRequestId());
if (success) {
setDraft(jid, '');
scrollDetailToBottom();
}
}
return (
<div className="rooms-v2">
<RoomsToolbar
counts={counts}
filter={filter}
onFilterChange={setFilter}
onSortChange={setSort}
sort={sort}
statusLabel={statusLabel}
t={t}
/>
{sorted.length === 0 ? (
<EmptyState>{t.rooms.empty}</EmptyState>
) : (
<div className={`rooms-twopane${detailOpen ? ' is-detail-open' : ''}`}>
<RoomsList
entries={sorted}
inbox={inbox}
onSelect={(jid) => {
onSelectedJidChange(jid);
setMobileDetailOpen(true);
}}
selectedJid={selectedEntry?.jid ?? null}
t={t}
/>
<RoomsDetail
drafts={drafts}
formatDate={formatDate}
formatDuration={formatDuration}
formatLiveElapsed={formatLiveElapsed}
inbox={inbox}
locale={locale}
onBack={() => setMobileDetailOpen(false)}
onDraftChange={setDraft}
onSubmit={(jid) => void submitRoomMessage(jid)}
pendingMessages={pendingMessages}
roomActivity={roomActivity}
roomActivityLoading={roomActivityLoading}
roomMessageKey={roomMessageKey}
selectedEntry={selectedEntry}
senderRoleClass={senderRoleClass}
statusLabel={statusLabel}
t={t}
/>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,418 @@
import { createElement } from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { describe, expect, it } from 'vitest';
import type { DashboardRoomActivity } from './api';
import { messages } from './i18n';
import { RoomCardV2, type RoomEntryWithService } from './RoomCardV2';
const t = messages.ko;
const entry: RoomEntryWithService = {
jid: 'room-1',
name: 'eyejokerdb-main',
folder: 'eyejokerdb',
agentType: 'codex',
status: 'inactive',
elapsedMs: null,
pendingMessages: false,
pendingTasks: 0,
serviceId: 'svc-1',
};
const formatters = {
formatDate: (value: string | null | undefined) => value ?? '-',
formatDuration: (value: number | null) => (value === null ? '-' : `${value}`),
formatLiveElapsed: (value: number) => `${value}`,
senderRoleClass: (value: string | null | undefined) => {
const role = (value ?? '').toLowerCase();
if (role.includes('owner')) return 'role-owner';
if (role.includes('reviewer')) return 'role-reviewer';
if (role.includes('arbiter')) return 'role-arbiter';
return 'role-human';
},
statusLabel: (status: string) => status,
};
function activity(
overrides: Partial<DashboardRoomActivity> = {},
): DashboardRoomActivity {
return {
serviceId: 'svc-1',
jid: 'room-1',
name: 'eyejokerdb-main',
folder: 'eyejokerdb',
agentType: 'codex',
status: 'inactive',
elapsedMs: null,
pendingMessages: false,
pendingTasks: 0,
messages: [],
pairedTask: null,
...overrides,
};
}
describe('RoomCardV2', () => {
it('renders an inactive room without activity', () => {
const html = renderToStaticMarkup(
createElement(RoomCardV2, {
activity: undefined,
activityLoading: false,
busy: false,
draft: '',
entry,
expanded: false,
inboxItems: [],
locale: 'ko',
onDraftChange: () => {},
onSendMessage: () => {},
onToggle: () => {},
pinned: false,
t,
...formatters,
}),
);
expect(html).toContain('eyejokerdb-main');
expect(html).toContain(t.rooms.noActivity);
});
it('renders regular bot messages in the expanded thread', () => {
const html = renderToStaticMarkup(
createElement(RoomCardV2, {
activity: activity({
messages: [
{
id: 'bot-1',
sender: 'bot-1',
senderName: 'owner',
content: 'TASK_DONE\n\nprod 배포 완료',
timestamp: '2026-04-28T02:00:00.000Z',
isFromMe: false,
isBotMessage: true,
sourceKind: 'bot',
},
],
}),
activityLoading: false,
busy: false,
draft: '',
entry,
expanded: true,
inboxItems: [],
locale: 'ko',
onDraftChange: () => {},
onSendMessage: () => {},
onToggle: () => {},
pinned: true,
t,
...formatters,
}),
);
expect(html).toContain('TASK_DONE');
expect(html).toContain('prod 배포 완료');
});
it('uses canonical room messages for collapsed previews when outputs are empty', () => {
const html = renderToStaticMarkup(
createElement(RoomCardV2, {
activity: activity({
messages: [
{
id: 'bot-1',
sender: 'bot-1',
senderName: 'owner',
content: 'TASK_DONE\n\nprod 배포 완료',
timestamp: '2026-04-28T02:00:00.000Z',
isFromMe: false,
isBotMessage: true,
sourceKind: 'bot',
},
],
pairedTask: {
id: 'task-1',
title: 'Deploy production',
status: 'completed',
roundTripCount: 1,
updatedAt: '2026-04-28T02:01:00.000Z',
currentTurn: null,
outputs: [],
},
}),
activityLoading: false,
busy: false,
draft: '',
entry,
expanded: false,
inboxItems: [],
locale: 'ko',
onDraftChange: () => {},
onSendMessage: () => {},
onToggle: () => {},
pinned: false,
t,
...formatters,
}),
);
expect(html).toContain('TASK_DONE');
expect(html).toContain('prod 배포 완료');
expect(html).not.toContain(t.rooms.noActivity);
});
it('localizes protocol role and verdict labels in Korean rooms', () => {
const html = renderToStaticMarkup(
createElement(RoomCardV2, {
activity: activity({
pairedTask: {
id: 'task-1',
title: 'Review pending',
status: 'in_review',
roundTripCount: 2,
updatedAt: '2026-04-28T02:03:00.000Z',
currentTurn: null,
outputs: [
{
id: 1,
turnNumber: 2,
role: 'reviewer',
verdict: 'continue',
createdAt: '2026-04-28T02:02:00.000Z',
outputText: '검증 완료. 다음 단계로 진행 가능합니다.',
},
],
},
}),
activityLoading: false,
busy: false,
draft: '',
entry,
expanded: true,
inboxItems: [],
locale: 'ko',
onDraftChange: () => {},
onSendMessage: () => {},
onToggle: () => {},
pinned: true,
t,
...formatters,
}),
);
expect(html).toContain('>리뷰어</span>');
expect(html).toContain('>계속</span>');
expect(html).not.toContain('>reviewer</span>');
expect(html).not.toContain('>continue</span>');
});
it('renders live progress as markdown', () => {
const html = renderToStaticMarkup(
createElement(RoomCardV2, {
activity: activity({
pairedTask: {
id: 'task-1',
title: 'OpenAPI sync',
status: 'running',
roundTripCount: 1,
updatedAt: '2026-04-28T02:01:00.000Z',
currentTurn: {
turnId: 'turn-1',
role: 'owner',
intentKind: 'implementation',
state: 'running',
attemptNo: 1,
executorServiceId: 'svc-1',
executorAgentType: 'codex',
activeRunId: null,
createdAt: '2026-04-28T02:00:00.000Z',
updatedAt: '2026-04-28T02:01:00.000Z',
completedAt: null,
lastError: null,
progressText:
'최신 `origin/dev`로 fast-forward한 뒤 `pnpm openapi:sync`를 실행합니다.',
progressUpdatedAt: '2026-04-28T02:01:00.000Z',
},
outputs: [],
},
}),
activityLoading: false,
busy: false,
draft: '',
entry: { ...entry, status: 'processing' },
expanded: false,
inboxItems: [],
locale: 'ko',
onDraftChange: () => {},
onSendMessage: () => {},
onToggle: () => {},
pinned: false,
t,
...formatters,
}),
);
expect(html).toContain('class="live-progress"');
expect(html).toContain('<code>origin/dev</code>');
expect(html).toContain('<code>pnpm openapi:sync</code>');
expect(html).not.toContain('`origin/dev`');
});
});
describe('RoomCardV2 room thread details', () => {
it('does not render site-only active turn placeholders without visible progress', () => {
const pairedTask: NonNullable<DashboardRoomActivity['pairedTask']> = {
id: 'task-1',
title: 'OpenAPI sync',
status: 'running',
roundTripCount: 1,
updatedAt: '2026-04-28T02:01:00.000Z',
currentTurn: {
turnId: 'turn-1',
role: 'owner',
intentKind: 'implementation',
state: 'running',
attemptNo: 1,
executorServiceId: 'svc-1',
executorAgentType: 'codex',
activeRunId: 'run-site-only',
createdAt: '2026-04-28T02:00:00.000Z',
updatedAt: '2026-04-28T02:01:00.000Z',
completedAt: null,
lastError: null,
progressText: null,
progressUpdatedAt: null,
},
outputs: [],
};
const collapsed = renderToStaticMarkup(
createElement(RoomCardV2, {
activity: activity({ pairedTask }),
activityLoading: false,
busy: false,
draft: '',
entry: { ...entry, status: 'processing' },
expanded: false,
inboxItems: [],
locale: 'ko',
onDraftChange: () => {},
onSendMessage: () => {},
onToggle: () => {},
pinned: false,
t,
...formatters,
}),
);
const expanded = renderToStaticMarkup(
createElement(RoomCardV2, {
activity: activity({ pairedTask }),
activityLoading: false,
busy: false,
draft: '',
entry: { ...entry, status: 'processing' },
expanded: true,
inboxItems: [],
locale: 'ko',
onDraftChange: () => {},
onSendMessage: () => {},
onToggle: () => {},
pinned: false,
t,
...formatters,
}),
);
expect(collapsed).not.toContain('class="room-live"');
expect(expanded).not.toContain('room-timeline-live');
expect(expanded).not.toContain(t.rooms.loadingActivity);
expect(expanded).toContain(t.rooms.noActivity);
});
it('renders message attachments as dashboard images', () => {
const html = renderToStaticMarkup(
createElement(RoomCardV2, {
activity: activity({
messages: [
{
id: 'bot-attachment',
sender: 'bot-1',
senderName: 'owner',
content: '라벨 좌측 클리핑 회귀 수정했습니다.',
timestamp: '2026-04-28T02:00:00.000Z',
isFromMe: false,
isBotMessage: true,
sourceKind: 'bot',
attachments: [
{
path: '/tmp/bar-chart-label-fit-playwright.png',
name: 'bar-chart-label-fit-playwright.png',
mime: 'image/png',
},
],
},
],
}),
activityLoading: false,
busy: false,
draft: '',
entry,
expanded: true,
inboxItems: [],
locale: 'ko',
onDraftChange: () => {},
onSendMessage: () => {},
onToggle: () => {},
pinned: true,
t,
...formatters,
}),
);
expect(html).toContain('class="room-attachments"');
expect(html).toContain(
'/api/attachments?path=%2Ftmp%2Fbar-chart-label-fit-playwright.png',
);
expect(html).toContain('bar-chart-label-fit-playwright.png');
});
it('renders expanded watcher messages without truncating content', () => {
const watcherTail = 'WATCHER_TAIL_VISIBLE';
const html = renderToStaticMarkup(
createElement(RoomCardV2, {
activity: activity({
messages: [
{
id: 'watcher-1',
sender: 'bot-1',
senderName: 'reviewer',
content: `[Watcher] ${'검증 로그 '.repeat(40)} ${watcherTail}`,
timestamp: '2026-04-28T02:00:00.000Z',
isFromMe: false,
isBotMessage: true,
sourceKind: 'bot',
},
],
}),
activityLoading: false,
busy: false,
draft: '',
entry,
expanded: true,
inboxItems: [],
locale: 'ko',
onDraftChange: () => {},
onSendMessage: () => {},
onToggle: () => {},
pinned: true,
t,
...formatters,
}),
);
expect(html).toContain('class="room-watcher-fold"');
expect(html).toContain('>리뷰어</strong>');
expect(html).toContain(watcherTail);
});
});

View File

@@ -0,0 +1,892 @@
import { useEffect, useRef, useState } from 'react';
import { Send } from 'lucide-react';
import type { DashboardOverview, DashboardRoomActivity } from './api';
import type { Locale, Messages } from './i18n';
import { ParsedBody } from './ParsedBody';
import { RoomAttachmentGallery } from './RoomAttachmentGallery';
import { displayRole, displayVerdict } from './roomDisplayLabels';
import {
buildRoomThreadEntries,
isWatcherRoomMessage,
type RoomThreadEntry,
} from './roomThread';
type InboxItem = DashboardOverview['inbox'][number];
type RoomMessage = DashboardRoomActivity['messages'][number];
type RoomOutput = NonNullable<
DashboardRoomActivity['pairedTask']
>['outputs'][number];
type RoomTask = NonNullable<DashboardRoomActivity['pairedTask']>;
type RoomTurn = NonNullable<RoomTask['currentTurn']>;
export interface RoomEntryWithService {
jid: string;
name: string;
folder: string;
agentType: string;
status: 'processing' | 'waiting' | 'inactive';
elapsedMs: number | null;
pendingMessages: boolean;
pendingTasks: number;
serviceId: string;
}
interface RoomCardFormatters {
formatDate: (value: string | null | undefined, locale: Locale) => string;
formatDuration: (value: number | null, t: Messages) => string;
formatLiveElapsed: (value: number, t: Messages) => string;
senderRoleClass: (value: string | null | undefined) => string;
statusLabel: (status: string, t: Messages) => string;
}
interface RoomMessageFormProps {
busy: boolean;
onChange: (value: string) => void;
onSubmit: () => void;
t: Messages;
value: string;
}
function RoomMessageForm({
busy,
onChange,
onSubmit,
t,
value,
}: RoomMessageFormProps) {
return (
<form
className="room-compose"
onSubmit={(event) => {
event.preventDefault();
onSubmit();
}}
>
<textarea
aria-label={t.rooms.message}
maxLength={8000}
onChange={(event) => onChange(event.target.value)}
onKeyDown={(event) => {
if (event.key !== 'Enter') return;
if (event.shiftKey || event.nativeEvent.isComposing) return;
if (busy || !value.trim()) return;
event.preventDefault();
onSubmit();
}}
placeholder={t.rooms.messagePlaceholder}
rows={1}
value={value}
/>
<button
aria-label={busy ? t.rooms.sending : t.rooms.send}
disabled={busy || !value.trim()}
title={busy ? t.rooms.sending : t.rooms.send}
type="submit"
>
<Send size={16} strokeWidth={2} aria-hidden />
</button>
</form>
);
}
export interface RoomCardV2Props extends RoomCardFormatters {
activity: DashboardRoomActivity | undefined;
activityLoading: boolean;
busy: boolean;
draft: string;
entry: RoomEntryWithService;
expanded: boolean;
inboxItems: InboxItem[];
locale: Locale;
onDraftChange: (value: string) => void;
onSendMessage: () => void;
onToggle: () => void;
pendingMessages?: RoomMessage[];
pinned?: boolean;
t: Messages;
}
export function RoomCardV2({
activity,
activityLoading,
busy,
draft,
entry,
expanded,
formatDate,
formatDuration,
formatLiveElapsed,
inboxItems,
locale,
onDraftChange,
onSendMessage,
onToggle,
pendingMessages = [],
pinned = false,
senderRoleClass,
statusLabel,
t,
}: RoomCardV2Props) {
const task = activity?.pairedTask ?? null;
const turn = task?.currentTurn ?? null;
const outputs = task?.outputs ?? [];
const latestOutput = outputs.at(-1) ?? null;
const messages = activity?.messages ?? [];
const isProcessing = entry.status === 'processing';
const liveTurnStart =
turn && turn.completedAt === null
? new Date(turn.createdAt).getTime()
: null;
const [tick, setTick] = useState(() => Date.now());
useEffect(() => {
if (!isProcessing || liveTurnStart === null) return;
const id = window.setInterval(() => setTick(Date.now()), 1000);
return () => window.clearInterval(id);
}, [isProcessing, liveTurnStart]);
const liveElapsedMs =
liveTurnStart === null ? null : Math.max(0, tick - liveTurnStart);
const liveProgressDisplay =
turn?.progressText && turn.progressText.trim()
? turn.progressText
: getLiveStatusFallback({
isProcessing,
messages,
senderRoleClass,
turn,
});
const lastUpdated = turn?.updatedAt ?? task?.updatedAt ?? null;
const watcherMessages = messages.filter(isWatcherRoomMessage);
const lastWatcher = watcherMessages.at(-1) ?? null;
const agentMessages = buildRoomThreadEntries({
messages,
outputs,
pendingMessages,
});
useRoomDetailAutoscroll({
entryJid: entry.jid,
messagesLen: messages.length,
outputsLen: outputs.length,
pendingLen: pendingMessages.length,
});
const formatters = {
formatDate,
formatDuration,
formatLiveElapsed,
senderRoleClass,
statusLabel,
};
return (
<article
aria-expanded={pinned ? undefined : expanded}
className={`room-card-v2 status-${entry.status}${expanded ? ' is-expanded' : ''}${pinned ? ' is-pinned' : ''}`}
onClick={pinned ? undefined : onToggle}
onKeyDown={
pinned
? undefined
: (e) => {
if (e.target !== e.currentTarget) return;
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
onToggle();
}
}
}
role={pinned ? undefined : 'button'}
tabIndex={pinned ? undefined : 0}
>
<RoomCardHead
entry={entry}
expanded={expanded}
inboxItems={inboxItems}
isProcessing={isProcessing}
pinned={pinned}
statusLabel={statusLabel}
t={t}
/>
<RoomCurrentTurnSummary
formatDate={formatDate}
lastUpdated={lastUpdated}
locale={locale}
senderRoleClass={senderRoleClass}
task={task}
turn={turn}
t={t}
/>
<RoomCardMeta
entry={entry}
formatDuration={formatDuration}
isProcessing={isProcessing}
t={t}
/>
<CollapsedLiveTurn
expanded={expanded}
formatDate={formatDate}
isProcessing={isProcessing}
liveProgressDisplay={liveProgressDisplay}
locale={locale}
senderRoleClass={senderRoleClass}
t={t}
turn={turn}
/>
<CollapsedOutputPreview
agentMessages={agentMessages}
expanded={expanded}
isProcessing={isProcessing}
latestOutput={latestOutput}
/>
<CollapsedWatcherStrip
expanded={expanded}
formatDate={formatDate}
lastWatcher={lastWatcher}
locale={locale}
senderRoleClass={senderRoleClass}
watcherCount={watcherMessages.length}
/>
<CollapsedEmptyState
activity={activity}
activityLoading={activityLoading}
expanded={expanded}
isProcessing={isProcessing}
t={t}
visibleActivityCount={agentMessages.length + watcherMessages.length}
/>
{expanded ? (
<RoomExpandedContent
agentMessages={agentMessages}
busy={busy}
draft={draft}
formatters={formatters}
isProcessing={isProcessing}
liveElapsedMs={liveElapsedMs}
liveProgressDisplay={liveProgressDisplay}
locale={locale}
onDraftChange={onDraftChange}
onSendMessage={onSendMessage}
t={t}
turn={turn}
watcherMessages={watcherMessages}
/>
) : null}
</article>
);
}
function useRoomDetailAutoscroll({
entryJid,
messagesLen,
outputsLen,
pendingLen,
}: {
entryJid: string;
messagesLen: number;
outputsLen: number;
pendingLen: number;
}) {
const wasNearBottomRef = useRef(true);
useEffect(() => {
const detail = document.querySelector(
'.rooms-detail',
) as HTMLElement | null;
if (!detail) return;
if (!wasNearBottomRef.current) return;
const run = () => {
detail.scrollTop = detail.scrollHeight;
};
requestAnimationFrame(() => requestAnimationFrame(run));
}, [entryJid, messagesLen, outputsLen, pendingLen]);
useEffect(() => {
const detail = document.querySelector(
'.rooms-detail',
) as HTMLElement | null;
if (!detail) return;
const onScroll = () => {
const distance =
detail.scrollHeight - detail.scrollTop - detail.clientHeight;
wasNearBottomRef.current = distance < 80;
};
detail.addEventListener('scroll', onScroll, { passive: true });
return () => detail.removeEventListener('scroll', onScroll);
}, [entryJid]);
}
const TASK_STATUS_PREFIX = '';
function getLiveStatusFallback({
isProcessing,
messages,
senderRoleClass,
turn,
}: {
isProcessing: boolean;
messages: RoomMessage[];
senderRoleClass: RoomCardFormatters['senderRoleClass'];
turn: RoomTurn | null;
}): string | null {
if (!isProcessing) return null;
if (!turn) return null;
if (turn.progressText && turn.progressText.trim()) return null;
const turnStartMs = turn.createdAt ? new Date(turn.createdAt).getTime() : 0;
const turnRole = senderRoleClass(turn.role);
for (let i = messages.length - 1; i >= 0; i--) {
const message = messages[i];
if (!message.content.startsWith(TASK_STATUS_PREFIX)) continue;
const ts = message.timestamp ? new Date(message.timestamp).getTime() : 0;
if (ts < turnStartMs) continue;
const senderRole = senderRoleClass(message.senderName);
if (senderRole !== turnRole) continue;
const body = message.content.slice(TASK_STATUS_PREFIX.length);
const stripped = body.replace(/\n\n\d+[초smhMSH]?$/, '').trim();
return stripped || null;
}
return null;
}
function RoomCardHead({
entry,
expanded,
inboxItems,
isProcessing,
pinned,
statusLabel,
t,
}: {
entry: RoomEntryWithService;
expanded: boolean;
inboxItems: InboxItem[];
isProcessing: boolean;
pinned: boolean;
statusLabel: RoomCardFormatters['statusLabel'];
t: Messages;
}) {
return (
<div className="room-card-head">
<span className={`room-pulse pulse-${entry.status}`}>
{isProcessing ? <span className="pulse-dot" /> : null}
</span>
<span className="room-title-block">
<strong>{entry.name}</strong>
<span className="room-sub">
<em>{entry.agentType}</em>
{inboxItems.length > 0 ? (
<span className="room-inbox-badges" aria-label={t.panels.inbox}>
{inboxItems.slice(0, 3).map((item) => (
<span
className={`room-inbox-pip sev-${item.severity}`}
key={item.id}
title={t.inbox.kinds[item.kind]}
>
{t.inbox.kinds[item.kind]}
</span>
))}
</span>
) : null}
</span>
</span>
{!isProcessing ? (
<span className={`room-status pill pill-${entry.status}`}>
{statusLabel(entry.status, t)}
</span>
) : (
<span />
)}
{!pinned ? (
<span className="room-toggle" aria-hidden>
{expanded ? '▾' : '▸'}
</span>
) : null}
</div>
);
}
function RoomCurrentTurnSummary({
formatDate,
lastUpdated,
locale,
senderRoleClass,
task,
turn,
t,
}: {
formatDate: RoomCardFormatters['formatDate'];
lastUpdated: string | null;
locale: Locale;
senderRoleClass: RoomCardFormatters['senderRoleClass'];
task: RoomTask | null;
turn: RoomTurn | null;
t: Messages;
}) {
if (!turn || turn.completedAt === null) return null;
return (
<div className="room-current-turn">
<span>
<strong className={senderRoleClass(turn.role)}>
{displayRole(turn.role, locale)}
</strong>
<em>{turn.intentKind}</em>
{turn.attemptNo > 1 ? (
<small>
{t.rooms.attempt} {turn.attemptNo}
</small>
) : null}
</span>
{task && task.roundTripCount > 0 ? (
<small>
· {t.rooms.round} {task.roundTripCount}
</small>
) : null}
{lastUpdated ? (
<small style={{ marginLeft: 'auto' }}>
{formatDate(lastUpdated, locale)}
</small>
) : null}
</div>
);
}
function RoomCardMeta({
entry,
formatDuration,
isProcessing,
t,
}: {
entry: RoomEntryWithService;
formatDuration: RoomCardFormatters['formatDuration'];
isProcessing: boolean;
t: Messages;
}) {
const queueChips: string[] = [];
if (entry.pendingTasks > 0)
queueChips.push(`${entry.pendingTasks} ${t.rooms.tasks}`);
if (entry.pendingMessages) queueChips.push(t.rooms.queueWaitingMessages);
if (
queueChips.length === 0 &&
!(entry.elapsedMs && entry.elapsedMs > 0 && isProcessing)
) {
return null;
}
return (
<div className="room-card-meta">
{queueChips.length > 0 ? (
<span className="meta-cell">
<strong>{queueChips.join(' · ')}</strong>
</span>
) : null}
{isProcessing && entry.elapsedMs ? (
<span className="meta-cell">
<strong>{formatDuration(entry.elapsedMs, t)}</strong>
</span>
) : null}
</div>
);
}
function CollapsedLiveTurn({
expanded,
formatDate,
isProcessing,
liveProgressDisplay,
locale,
senderRoleClass,
t,
turn,
}: {
expanded: boolean;
formatDate: RoomCardFormatters['formatDate'];
isProcessing: boolean;
liveProgressDisplay: string | null;
locale: Locale;
senderRoleClass: RoomCardFormatters['senderRoleClass'];
t: Messages;
turn: RoomTurn | null;
}) {
if (expanded || !isProcessing || !turn || !liveProgressDisplay) return null;
return (
<div className="room-live">
<header>
<span className="live-dot" aria-hidden />
<span className="live-label">LIVE</span>
<strong className={senderRoleClass(turn.role)}>
{displayRole(turn.role, locale)}
</strong>
<em>{turn.intentKind}</em>
<span className="live-state pill pill-processing">{turn.state}</span>
{turn.executorServiceId ? (
<span className="live-exec">{turn.executorServiceId}</span>
) : null}
{turn.attemptNo > 1 ? (
<span className="live-attempt">
{t.rooms.attempt} {turn.attemptNo}
</span>
) : null}
<time>
{formatDate(turn.progressUpdatedAt ?? turn.updatedAt, locale)}
</time>
</header>
<LiveProgressBody text={liveProgressDisplay} />
</div>
);
}
function LiveProgressBody({ text }: { text: string }) {
return (
<div className="live-progress">
<ParsedBody text={text} />
</div>
);
}
function CollapsedOutputPreview({
agentMessages,
expanded,
isProcessing,
latestOutput,
}: {
agentMessages: RoomThreadEntry[];
expanded: boolean;
isProcessing: boolean;
latestOutput: RoomOutput | null;
}) {
const latestThreadEntry = agentMessages.at(-1) ?? null;
const previewText =
latestOutput?.outputText ?? latestThreadEntry?.content ?? null;
if (expanded || !previewText || isProcessing) return null;
return (
<div className="room-preview">
<ParsedBody text={previewText} truncate={140} />
</div>
);
}
function CollapsedWatcherStrip({
expanded,
formatDate,
lastWatcher,
locale,
senderRoleClass,
watcherCount,
}: {
expanded: boolean;
formatDate: RoomCardFormatters['formatDate'];
lastWatcher: RoomMessage | null;
locale: Locale;
senderRoleClass: RoomCardFormatters['senderRoleClass'];
watcherCount: number;
}) {
if (expanded || watcherCount === 0 || !lastWatcher) return null;
return (
<div className="room-watcher-strip">
<span className="watcher-tag"> {watcherCount}</span>
<span className="watcher-line">
<strong className={senderRoleClass(lastWatcher.senderName)}>
{displayRole(lastWatcher.senderName, locale)}
</strong>
<span>{lastWatcher.content.slice(0, 90)}</span>
</span>
<time>{formatDate(lastWatcher.timestamp, locale)}</time>
</div>
);
}
function CollapsedEmptyState({
activity,
activityLoading,
expanded,
isProcessing,
t,
visibleActivityCount,
}: {
activity: DashboardRoomActivity | undefined;
activityLoading: boolean;
expanded: boolean;
isProcessing: boolean;
t: Messages;
visibleActivityCount: number;
}) {
if (expanded) return null;
if (visibleActivityCount === 0 && !isProcessing && !activityLoading) {
return <p className="room-empty">{t.rooms.noActivity}</p>;
}
if (activityLoading && !activity) {
return <p className="room-empty">{t.rooms.loadingActivity}</p>;
}
return null;
}
function RoomExpandedContent({
agentMessages,
busy,
draft,
formatters,
isProcessing,
liveElapsedMs,
liveProgressDisplay,
locale,
onDraftChange,
onSendMessage,
t,
turn,
watcherMessages,
}: {
agentMessages: RoomThreadEntry[];
busy: boolean;
draft: string;
formatters: RoomCardFormatters;
isProcessing: boolean;
liveElapsedMs: number | null;
liveProgressDisplay: string | null;
locale: Locale;
onDraftChange: (value: string) => void;
onSendMessage: () => void;
t: Messages;
turn: RoomTurn | null;
watcherMessages: RoomMessage[];
}) {
return (
<div
className="room-expanded"
onClick={(e) => e.stopPropagation()}
onKeyDown={(e) => e.stopPropagation()}
>
{turn?.lastError ? (
<div className="room-alert">
<strong>{t.rooms.error}</strong>
<ParsedBody text={turn.lastError} />
</div>
) : null}
<RoomThreadSection
agentMessages={agentMessages}
formatters={formatters}
isProcessing={isProcessing}
liveElapsedMs={liveElapsedMs}
liveProgressDisplay={liveProgressDisplay}
locale={locale}
t={t}
turn={turn}
/>
<RoomWatcherFold
formatDate={formatters.formatDate}
locale={locale}
senderRoleClass={formatters.senderRoleClass}
watcherMessages={watcherMessages}
/>
<section className="room-section room-compose-section">
<RoomMessageForm
busy={busy}
onChange={onDraftChange}
onSubmit={onSendMessage}
t={t}
value={draft}
/>
</section>
</div>
);
}
function RoomThreadSection({
agentMessages,
formatters,
isProcessing,
liveElapsedMs,
liveProgressDisplay,
locale,
t,
turn,
}: {
agentMessages: RoomThreadEntry[];
formatters: RoomCardFormatters;
isProcessing: boolean;
liveElapsedMs: number | null;
liveProgressDisplay: string | null;
locale: Locale;
t: Messages;
turn: RoomTurn | null;
}) {
const showLiveItem = !!(
turn &&
turn.completedAt === null &&
liveProgressDisplay
);
return (
<section className="room-section room-thread-section">
<header>
<h4>{t.rooms.activity}</h4>
<small>{agentMessages.length}</small>
</header>
{agentMessages.length === 0 && !showLiveItem ? (
<p className="room-empty">{t.rooms.noActivity}</p>
) : (
<ol className="room-timeline">
{agentMessages.map((entry) => (
<RoomTimelineEntry
entry={entry}
formatDate={formatters.formatDate}
key={entry.id}
locale={locale}
senderRoleClass={formatters.senderRoleClass}
/>
))}
{showLiveItem ? (
<RoomLiveTimelineEntry
formatLiveElapsed={formatters.formatLiveElapsed}
formatDate={formatters.formatDate}
isProcessing={isProcessing}
liveElapsedMs={liveElapsedMs}
liveProgressDisplay={liveProgressDisplay}
locale={locale}
senderRoleClass={formatters.senderRoleClass}
t={t}
turn={turn}
/>
) : null}
</ol>
)}
</section>
);
}
function RoomTimelineEntry({
entry,
formatDate,
locale,
senderRoleClass,
}: {
entry: RoomThreadEntry;
formatDate: RoomCardFormatters['formatDate'];
locale: Locale;
senderRoleClass: RoomCardFormatters['senderRoleClass'];
}) {
const verdict = entry.verdict;
const verdictTone = verdict
? /fail|error/i.test(verdict)
? 'fail'
: /done|ok|pass/i.test(verdict)
? 'done'
: 'info'
: null;
const sectionClass = senderRoleClass(entry.senderName).replace(
'role-',
'role-section-',
);
return (
<li className={`room-timeline-item ${sectionClass}`}>
<header className="room-timeline-header">
<span className={`role-chip ${senderRoleClass(entry.senderName)}`}>
{displayRole(entry.senderName, locale)}
</span>
<time>{formatDate(entry.timestamp, locale)}</time>
{entry.turnNumber !== undefined ? (
<span className="parsed-marker parsed-marker-info">
#{entry.turnNumber}
</span>
) : null}
{verdict ? (
<span className={`parsed-marker parsed-marker-${verdictTone}`}>
{displayVerdict(verdict, locale)}
</span>
) : null}
</header>
<div className="room-timeline-body">
<ParsedBody text={entry.content} />
<RoomAttachmentGallery attachments={entry.attachments} />
</div>
</li>
);
}
function RoomLiveTimelineEntry({
formatDate,
formatLiveElapsed,
isProcessing,
liveElapsedMs,
liveProgressDisplay,
locale,
senderRoleClass,
t,
turn,
}: {
formatDate: RoomCardFormatters['formatDate'];
formatLiveElapsed: RoomCardFormatters['formatLiveElapsed'];
isProcessing: boolean;
liveElapsedMs: number | null;
liveProgressDisplay: string | null;
locale: Locale;
senderRoleClass: RoomCardFormatters['senderRoleClass'];
t: Messages;
turn: RoomTurn;
}) {
return (
<li
className={`room-timeline-item ${senderRoleClass(turn.role).replace('role-', 'role-section-')} ${isProcessing ? 'room-timeline-live' : 'room-timeline-paused'}`}
>
<header className="room-timeline-header">
{isProcessing ? (
<span className="live-dot live-dot-inline" aria-hidden />
) : (
<span className="paused-dot" aria-hidden />
)}
<span className={`role-chip ${senderRoleClass(turn.role)}`}>
{displayRole(turn.role, locale)}
</span>
<time>
{formatDate(turn.progressUpdatedAt ?? turn.updatedAt, locale)}
</time>
{liveElapsedMs !== null ? (
<span className="live-elapsed">
+{formatLiveElapsed(liveElapsedMs, t)}
</span>
) : null}
{!isProcessing ? (
<span className="live-label paused"></span>
) : null}
</header>
<div className="room-timeline-body">
{liveProgressDisplay ? (
<LiveProgressBody text={liveProgressDisplay} />
) : (
<p className="room-empty">{t.rooms.loadingActivity}</p>
)}
</div>
</li>
);
}
function RoomWatcherFold({
formatDate,
locale,
senderRoleClass,
watcherMessages,
}: {
formatDate: RoomCardFormatters['formatDate'];
locale: Locale;
senderRoleClass: RoomCardFormatters['senderRoleClass'];
watcherMessages: RoomMessage[];
}) {
if (watcherMessages.length === 0) return null;
return (
<details className="room-watcher-fold">
<summary>
<span className="watcher-tag"></span>
<small>{watcherMessages.length}</small>
</summary>
<ul className="room-watcher-list">
{watcherMessages.map((message) => (
<li className="room-watcher-item" key={message.id}>
<header>
<strong className={senderRoleClass(message.senderName)}>
{displayRole(message.senderName, locale)}
</strong>
<time>{formatDate(message.timestamp, locale)}</time>
</header>
<ParsedBody text={message.content} />
</li>
))}
</ul>
</details>
);
}

View File

@@ -0,0 +1,337 @@
import { useEffect, useMemo, useState } from 'react';
import {
fetchRoomSkillSettings,
updateRoomSkillSetting,
type RoomSkillCatalogItem,
type RoomSkillSettingUpdateInput,
type RoomSkillSettingsSnapshot,
} from './api';
import { SettingsCard, SettingsSectionHeading } from './SettingsPanelChrome';
import { type Messages } from './i18n';
type RoomSkillRoom = RoomSkillSettingsSnapshot['rooms'][number];
type RoomSkillAgent = RoomSkillRoom['agents'][number];
type AgentType = RoomSkillAgent['agentType'];
function agentLabel(agentType: AgentType, t: Messages): string {
return agentType === 'codex'
? t.settings.runtime.agentCodex
: t.settings.runtime.agentClaude;
}
function scopeLabel(scope: RoomSkillCatalogItem['scope'], t: Messages): string {
if (scope === 'codex-user') return t.settings.runtime.scopeCodexUser;
if (scope === 'claude-user') return t.settings.runtime.scopeClaudeUser;
return t.settings.runtime.scopeRunner;
}
function applyOptimisticToggle(
snapshot: RoomSkillSettingsSnapshot,
input: RoomSkillSettingUpdateInput,
): RoomSkillSettingsSnapshot {
const { roomJid, agentType, skillId, enabled } = input;
return {
...snapshot,
rooms: snapshot.rooms.map((room) => {
if (room.jid !== roomJid) return room;
return {
...room,
agents: room.agents.map((agent) => {
if (agent.agentType !== agentType) return agent;
const disabledSkillIds = enabled
? agent.disabledSkillIds.filter((id) => id !== skillId)
: agent.disabledSkillIds.includes(skillId)
? agent.disabledSkillIds
: [...agent.disabledSkillIds, skillId];
const effectiveEnabledSkillIds = enabled
? agent.availableSkillIds.filter(
(id) => !disabledSkillIds.includes(id),
)
: agent.availableSkillIds.filter(
(id) => id !== skillId && !disabledSkillIds.includes(id),
);
return {
...agent,
mode: disabledSkillIds.length === 0 ? 'all-enabled' : 'custom',
disabledSkillIds,
effectiveEnabledSkillIds,
};
}),
};
}),
};
}
interface RoomSkillControlsProps {
onAgentChange: (agentType: AgentType) => void;
onRoomChange: (roomJid: string) => void;
selectedAgentType: AgentType;
selectedRoom: RoomSkillRoom | null;
selectedRoomJid: string;
snapshot: RoomSkillSettingsSnapshot;
t: Messages;
}
function RoomSkillControls({
onAgentChange,
onRoomChange,
selectedAgentType,
selectedRoom,
selectedRoomJid,
snapshot,
t,
}: RoomSkillControlsProps) {
return (
<div className="settings-form-grid settings-skill-controls">
<label className="settings-row">
<span className="settings-label">
{t.settings.runtime.selectRoomLabel}
</span>
<select
onChange={(event) => onRoomChange(event.target.value)}
value={selectedRoomJid}
>
{snapshot.rooms.map((room) => (
<option key={room.jid} value={room.jid}>
{room.name} ({room.folder})
</option>
))}
</select>
</label>
{selectedRoom && selectedRoom.agents.length > 1 ? (
<div className="settings-row">
<span className="settings-label">
{t.settings.runtime.selectAgentLabel}
</span>
<div className="settings-skill-agent-tabs" role="tablist">
{selectedRoom.agents.map((agent) => (
<button
aria-selected={selectedAgentType === agent.agentType}
className={
selectedAgentType === agent.agentType
? 'is-active'
: undefined
}
key={agent.agentType}
onClick={() => onAgentChange(agent.agentType)}
role="tab"
type="button"
>
{agentLabel(agent.agentType, t)}
</button>
))}
</div>
</div>
) : null}
</div>
);
}
interface SkillToggleListProps {
catalogById: Map<string, RoomSkillCatalogItem>;
onToggle: (input: RoomSkillSettingUpdateInput) => void;
savingKey: string | null;
selectedAgent: RoomSkillAgent;
selectedRoomJid: string;
t: Messages;
}
function SkillToggleList({
catalogById,
onToggle,
savingKey,
selectedAgent,
selectedRoomJid,
t,
}: SkillToggleListProps) {
return (
<div className="settings-toggle-stack">
{selectedAgent.availableSkillIds.map((skillId) => {
const skill = catalogById.get(skillId);
const key = `${selectedRoomJid}:${selectedAgent.agentType}:${skillId}`;
const enabled =
selectedAgent.effectiveEnabledSkillIds.includes(skillId);
return (
<label
aria-busy={savingKey === key}
className={`settings-toggle-row${savingKey === key ? ' is-busy' : ''}`}
key={skillId}
>
<span className="settings-toggle-label">
<span className="settings-toggle-title">
{skill?.displayName ?? skillId}
</span>
<small>
{skill ? scopeLabel(skill.scope, t) : null}
{skill?.description ? ` · ${skill.description}` : null}
</small>
</span>
<input
checked={enabled}
disabled={savingKey === key}
onChange={(event) =>
onToggle({
roomJid: selectedRoomJid,
agentType: selectedAgent.agentType,
skillId,
enabled: event.currentTarget.checked,
})
}
type="checkbox"
/>
</label>
);
})}
</div>
);
}
export function RuntimeInventorySettings({ t }: { t: Messages }) {
const [snapshot, setSnapshot] = useState<RoomSkillSettingsSnapshot | null>(
null,
);
const [error, setError] = useState<string | null>(null);
const [savingKey, setSavingKey] = useState<string | null>(null);
const [selectedRoomJid, setSelectedRoomJid] = useState<string>('');
const [selectedAgentType, setSelectedAgentType] =
useState<AgentType>('codex');
useEffect(() => {
let cancelled = false;
fetchRoomSkillSettings()
.then((value) => {
if (cancelled) return;
setSnapshot(value);
setError(null);
})
.catch((err) => {
if (cancelled) return;
setError(err instanceof Error ? err.message : String(err));
});
return () => {
cancelled = true;
};
}, []);
const selectedRoom = useMemo(
() => snapshot?.rooms.find((room) => room.jid === selectedRoomJid) ?? null,
[snapshot, selectedRoomJid],
);
const selectedAgent = useMemo(
() =>
selectedRoom?.agents.find(
(agent) => agent.agentType === selectedAgentType,
) ?? null,
[selectedRoom, selectedAgentType],
);
useEffect(() => {
if (!snapshot || snapshot.rooms.length === 0) return;
if (!snapshot.rooms.some((room) => room.jid === selectedRoomJid)) {
setSelectedRoomJid(snapshot.rooms[0]?.jid ?? '');
}
}, [snapshot, selectedRoomJid]);
useEffect(() => {
if (!selectedRoom || selectedRoom.agents.length === 0) return;
if (
!selectedRoom.agents.some(
(agent) => agent.agentType === selectedAgentType,
)
) {
setSelectedAgentType(selectedRoom.agents[0]?.agentType ?? 'codex');
}
}, [selectedRoom, selectedAgentType]);
const catalogById = useMemo(
() =>
new Map<string, RoomSkillCatalogItem>(
snapshot?.catalog.map((skill) => [skill.id, skill]) ?? [],
),
[snapshot],
);
async function handleToggle(input: RoomSkillSettingUpdateInput) {
const key = `${input.roomJid}:${input.agentType}:${input.skillId}`;
setError(null);
setSnapshot((current) =>
current ? applyOptimisticToggle(current, input) : current,
);
setSavingKey(key);
try {
const next = await updateRoomSkillSetting(input);
setSnapshot(next);
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
try {
setSnapshot(await fetchRoomSkillSettings());
} catch {
/* keep optimistic state if refetch also fails */
}
} finally {
setSavingKey(null);
}
}
return (
<section
aria-labelledby="settings-runtime-tab"
className="settings-section"
id="settings-runtime"
role="tabpanel"
>
<SettingsSectionHeading
description={t.settings.sections.runtime.description}
detail={t.settings.sections.runtime.kicker}
title={t.settings.sections.runtime.title}
/>
<p className="settings-hint settings-skill-default-hint">
{t.settings.runtime.defaultHint}
</p>
{error ? <p className="settings-error">{error}</p> : null}
{!snapshot ? (
<p className="settings-hint">{t.settings.common.loading}</p>
) : snapshot.rooms.length === 0 ? (
<SettingsCard title={t.settings.runtime.selectRoomLabel}>
<p className="settings-hint">{t.settings.runtime.emptyRooms}</p>
</SettingsCard>
) : (
<SettingsCard title={t.settings.runtime.selectRoomLabel}>
<RoomSkillControls
onAgentChange={setSelectedAgentType}
onRoomChange={setSelectedRoomJid}
selectedAgentType={selectedAgentType}
selectedRoom={selectedRoom}
selectedRoomJid={selectedRoomJid}
snapshot={snapshot}
t={t}
/>
{!selectedAgent || selectedAgent.availableSkillIds.length === 0 ? (
<p className="settings-hint">{t.settings.runtime.emptySkills}</p>
) : (
<SkillToggleList
catalogById={catalogById}
onToggle={(input) => void handleToggle(input)}
savingKey={savingKey}
selectedAgent={selectedAgent}
selectedRoomJid={selectedRoomJid}
t={t}
/>
)}
</SettingsCard>
)}
</section>
);
}

View File

@@ -0,0 +1,219 @@
import { type ModelConfigSnapshot, type ModelRoleConfig } from './api';
import { type Messages } from './i18n';
import {
type AgentType,
type EffortValue,
effortValuesForAgent,
formatEffortOption,
isEffortSupported,
isPresetModel,
PRESET_MODELS,
} from './settings-options';
const MODEL_ROLES = ['owner', 'reviewer', 'arbiter'] as const;
export type ModelRole = (typeof MODEL_ROLES)[number];
function modelRoleLabel(role: ModelRole, t: Messages): string {
if (role === 'owner') return t.settings.models.roleOwner;
if (role === 'reviewer') return t.settings.models.roleReviewer;
return t.settings.models.roleArbiter;
}
function modelRoleHint(role: ModelRole, t: Messages): string {
if (role === 'owner') return t.settings.models.roleOwnerHint;
if (role === 'reviewer') return t.settings.models.roleReviewerHint;
return t.settings.models.roleArbiterHint;
}
function agentTypeLabel(agentType: AgentType, t: Messages): string {
return agentType === 'codex'
? t.settings.models.agentTypeCodex
: t.settings.models.agentTypeClaude;
}
function effortLabel(value: EffortValue, t: Messages): string {
if (value === '') return t.settings.models.effortDefault;
const key = value as keyof typeof t.settings.models.effortOptions;
const localized = t.settings.models.effortOptions[key] ?? value;
return formatEffortOption(value, localized);
}
function modelGroupLabel(model: string, t: Messages): string {
if ((PRESET_MODELS.codex as readonly string[]).includes(model)) {
return t.settings.models.groupCodex;
}
if ((PRESET_MODELS.claude as readonly string[]).includes(model)) {
return t.settings.models.groupClaude;
}
return t.settings.models.groupCustom;
}
function effortOptionsForRole(
draft: ModelConfigSnapshot,
role: ModelRole,
): readonly EffortValue[] {
const agentType = draft.agentTypes?.[role];
if (agentType) return effortValuesForAgent(agentType);
return effortValuesForAgent('codex');
}
export function hasUnsupportedModelEffort(draft: ModelConfigSnapshot): boolean {
for (const role of MODEL_ROLES) {
const agentType = draft.agentTypes?.[role];
if (!agentType) continue;
if (!isEffortSupported(agentType, draft[role].effort)) return true;
}
return false;
}
export function ModelRoleFields({
draft,
onChange,
t,
}: {
draft: ModelConfigSnapshot;
onChange: (role: ModelRole, patch: Partial<ModelRoleConfig>) => void;
t: Messages;
}) {
return (
<div className="settings-model-stack">
{MODEL_ROLES.map((role) => {
const roleConfig = draft[role];
const agentType = draft.agentTypes?.[role] ?? null;
const effortOptions = effortOptionsForRole(draft, role);
const effortInvalid =
agentType !== null &&
roleConfig.effort !== '' &&
!isEffortSupported(agentType, roleConfig.effort);
const usingCustom =
roleConfig.model.trim() !== '' && !isPresetModel(roleConfig.model);
return (
<article className="settings-model-card" key={role}>
<header className="settings-model-card-head">
<span className="settings-kicker">{role}</span>
<strong>{modelRoleLabel(role, t)}</strong>
<p className="settings-hint">{modelRoleHint(role, t)}</p>
{agentType ? (
<p className="settings-inline-meta">
{t.settings.models.agentTypeLabel}:{' '}
{agentTypeLabel(agentType, t)}
</p>
) : null}
</header>
<div className="settings-model-fields">
<label className="settings-row">
<span className="settings-label">
{t.settings.models.modelLabel}
</span>
<select
aria-label={`${modelRoleLabel(role, t)} ${t.settings.models.modelLabel}`}
onChange={(event) => {
const value = event.target.value;
if (value === '__custom__') {
onChange(role, {
model: usingCustom ? roleConfig.model : '',
});
return;
}
onChange(role, { model: value });
}}
value={usingCustom ? '__custom__' : roleConfig.model}
>
<option value="">{t.settings.models.modelDefault}</option>
<optgroup label={t.settings.models.groupCodex}>
{PRESET_MODELS.codex.map((model) => (
<option key={model} value={model}>
{model}
</option>
))}
</optgroup>
<optgroup label={t.settings.models.groupClaude}>
{PRESET_MODELS.claude.map((model) => (
<option key={model} value={model}>
{model}
</option>
))}
</optgroup>
<option value="__custom__">
{t.settings.models.modelCustom}
</option>
</select>
</label>
{usingCustom ? (
<label className="settings-row">
<span className="settings-label">
{t.settings.models.modelCustomLabel}
</span>
<input
onChange={(event) =>
onChange(role, { model: event.target.value })
}
placeholder={t.settings.models.modelPlaceholder}
type="text"
value={roleConfig.model}
/>
</label>
) : null}
<label className="settings-row">
<span className="settings-label">
{t.settings.models.effortLabel}
</span>
<select
aria-describedby={
effortInvalid ? `settings-effort-warn-${role}` : undefined
}
aria-invalid={effortInvalid || undefined}
aria-label={`${modelRoleLabel(role, t)} ${t.settings.models.effortLabel}`}
onChange={(event) =>
onChange(role, { effort: event.target.value })
}
value={roleConfig.effort}
>
{effortOptions.map((value) => (
<option key={value || 'default'} value={value}>
{effortLabel(value, t)}
</option>
))}
{effortInvalid ? (
<option value={roleConfig.effort}>
{effortLabel(roleConfig.effort as EffortValue, t)}
</option>
) : null}
</select>
</label>
{effortInvalid && agentType ? (
<p
className="settings-effort-warn"
id={`settings-effort-warn-${role}`}
role="alert"
>
{t.settings.models.effortInvalid
.replace('{value}', roleConfig.effort)
.replace('{agent}', agentTypeLabel(agentType, t))}
</p>
) : null}
{roleConfig.model || roleConfig.effort ? (
<p className="settings-inline-meta">
{roleConfig.model
? modelGroupLabel(roleConfig.model, t)
: t.settings.models.modelDefault}
{' · '}
{roleConfig.effort
? effortLabel(roleConfig.effort as EffortValue, t)
: t.settings.models.effortDefault}
</p>
) : null}
</div>
</article>
);
})}
</div>
);
}

View File

@@ -0,0 +1,59 @@
import { createElement } from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { describe, expect, it } from 'vitest';
import { messages } from './i18n';
import { SettingsPanel, type SettingsPanelProps } from './SettingsPanel';
const t = messages.en;
const baseProps: SettingsPanelProps = {
locale: 'en',
nickname: 'Night Owl',
onLocaleChange: () => {},
onNicknameChange: () => {},
onRestartStack: () => {},
t,
};
describe('SettingsPanel', () => {
it('renders general nickname and language settings', () => {
const html = renderToStaticMarkup(createElement(SettingsPanel, baseProps));
expect(html).toContain('settings-panel');
expect(html).not.toContain('settings-hero');
expect(html).toContain('settings-sidebar');
expect(html).toContain('settings-nav');
expect(html).toContain(t.settings.nicknameLabel);
expect(html).toContain('value="Night Owl"');
expect(html).toContain(t.settings.languageLabel);
expect(html).toContain('한국어');
expect(html).toContain('English');
});
it('renders model, runtime, MoA, fast mode, and account controls', () => {
const html = renderToStaticMarkup(createElement(SettingsPanel, baseProps));
expect(html).toContain('role="tablist"');
expect(html).toContain('role="tabpanel"');
expect(html).toContain('aria-selected="true"');
expect(html).toContain('data-settings-target="settings-models"');
expect(html).toContain('data-settings-target="settings-runtime"');
expect(html).toContain('data-settings-target="settings-moa"');
expect(html).toContain('data-settings-target="settings-codex"');
expect(html).toContain('data-settings-target="settings-accounts"');
expect(html).toContain('aria-controls="settings-runtime"');
expect(html).toContain('aria-controls="settings-codex"');
expect(html).not.toContain('href="#settings-codex"');
expect(html).toContain('settings-apply-card');
expect(html).not.toContain('settings-apply-bar');
expect(html).toContain(t.settings.apply.title);
expect(html).toContain(t.settings.sections.runtime.title);
expect(html).toContain(t.settings.accounts.claude);
expect(html).toContain(t.settings.nav.accounts.title);
expect(html).toContain(t.settings.apply.restart);
expect(html.match(/class="settings-restart"/g)).toHaveLength(1);
expect(html).toContain('settings-card');
expect(html).toContain('settings-section-stack');
});
});

View File

@@ -0,0 +1,837 @@
import { useEffect, useState } from 'react';
import {
type ClaudeAccountSummary,
type CodexAccountSummary,
type CodexFeatureSnapshot,
type FastModeSnapshot,
type ModelConfigSnapshot,
type ModelRoleConfig,
addClaudeAccount,
deleteAccount,
fetchAccounts,
fetchCodexFeatures,
fetchFastMode,
fetchModelConfig,
refreshAllCodexAccounts as refreshAllCodexAccountsApi,
refreshCodexAccount as refreshCodexAccountApi,
setCurrentCodexAccount as setCurrentCodexAccountApi,
updateCodexFeatures,
updateFastMode,
updateModels,
} from './api';
import {
formatDateTime,
formatJwtCacheExpiry,
formatLiveStatusBadge,
formatUsageBadge,
} from './codexAccountBadges';
import { type Locale, type Messages } from './i18n';
import { MoaSettingsPanel } from './MoaSettingsPanel';
import { RuntimeInventorySettings } from './RuntimeInventorySettings';
import {
ModelRoleFields,
hasUnsupportedModelEffort,
type ModelRole,
} from './SettingsModelFields';
import {
GeneralSettings,
SettingsApplyCard,
SettingsCard,
SettingsNav,
SettingsSaveBar,
SettingsSectionHeading,
type SettingsSectionId,
} from './SettingsPanelChrome';
type AccountProvider = 'claude' | 'codex';
interface AccountData {
claude: ClaudeAccountSummary[];
codex: CodexAccountSummary[];
codexCurrentIndex?: number;
}
export interface SettingsPanelProps {
locale: Locale;
nickname: string;
onLocaleChange: (locale: Locale) => void;
onNicknameChange: (next: string) => void;
onRestartStack: () => void;
t: Messages;
}
export function SettingsPanel({
locale,
nickname,
onLocaleChange,
onNicknameChange,
onRestartStack,
t,
}: SettingsPanelProps) {
const [activeSection, setActiveSection] =
useState<SettingsSectionId>('settings-general');
return (
<div className="settings-panel">
<div className="settings-layout">
<aside className="settings-sidebar" aria-label={t.settings.sidebarAria}>
<SettingsNav
activeSection={activeSection}
onSelect={setActiveSection}
t={t}
/>
<SettingsApplyCard onRestartStack={onRestartStack} t={t} />
</aside>
<main className="settings-content" aria-label={t.settings.contentAria}>
<div hidden={activeSection !== 'settings-general'}>
<GeneralSettings
locale={locale}
nickname={nickname}
onLocaleChange={onLocaleChange}
onNicknameChange={onNicknameChange}
t={t}
/>
</div>
<div hidden={activeSection !== 'settings-models'}>
<ModelSettings t={t} />
</div>
<div hidden={activeSection !== 'settings-runtime'}>
<RuntimeInventorySettings t={t} />
</div>
<div hidden={activeSection !== 'settings-moa'}>
<MoaSettingsPanel t={t} />
</div>
<div hidden={activeSection !== 'settings-codex'}>
<CodexRuntimeSettings t={t} />
</div>
<div hidden={activeSection !== 'settings-accounts'}>
<AccountSettings t={t} />
</div>
</main>
</div>
</div>
);
}
function ModelSettings({ t }: { t: Messages }) {
const [config, setConfig] = useState<ModelConfigSnapshot | null>(null);
const [draft, setDraft] = useState<ModelConfigSnapshot | null>(null);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const [savedAt, setSavedAt] = useState<number | null>(null);
const section = t.settings.sections.models;
useEffect(() => {
let cancelled = false;
setBusy(true);
fetchModelConfig()
.then((c) => {
if (cancelled) return;
setConfig(c);
setDraft(c);
setError(null);
})
.catch((err) => {
if (cancelled) return;
setError(err instanceof Error ? err.message : String(err));
})
.finally(() => {
if (!cancelled) setBusy(false);
});
return () => {
cancelled = true;
};
}, []);
async function save() {
if (!draft || !config) return;
if (hasUnsupportedModelEffort(draft)) {
setError(t.settings.models.effortSaveBlocked);
return;
}
setBusy(true);
setError(null);
try {
const next = await updateModels({
owner: draft.owner,
reviewer: draft.reviewer,
arbiter: draft.arbiter,
});
setConfig(next);
setDraft(next);
setSavedAt(Date.now());
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setBusy(false);
}
}
function setRole(role: ModelRole, patch: Partial<ModelRoleConfig>) {
setDraft((prev) =>
prev
? {
...prev,
[role]: { ...prev[role], ...patch },
}
: prev,
);
}
const dirty =
draft !== null &&
config !== null &&
JSON.stringify(draft) !== JSON.stringify(config);
const effortBlocked = draft !== null && hasUnsupportedModelEffort(draft);
return (
<section
aria-labelledby="settings-models-tab"
className="settings-section"
id="settings-models"
role="tabpanel"
>
<SettingsSectionHeading
description={section.description}
detail={section.kicker}
title={section.title}
/>
{error ? <p className="settings-error">{error}</p> : null}
{!draft ? (
<p className="settings-hint">
{busy ? t.settings.common.loading : t.settings.models.empty}
</p>
) : (
<>
<ModelRoleFields
draft={draft}
onChange={(role, patch) => setRole(role, patch)}
t={t}
/>
<SettingsSaveBar
busy={busy}
dirty={dirty}
label={t.settings.models.save}
onSave={() => void save()}
saveDisabled={effortBlocked}
savedHint={t.settings.common.savedRestartHint}
savingLabel={t.settings.common.saving}
showSavedHint={savedAt !== null && !dirty}
/>
</>
)}
</section>
);
}
function CodexRuntimeSettings({ t }: { t: Messages }) {
const section = t.settings.sections.codex;
return (
<section
aria-labelledby="settings-codex-tab"
className="settings-section"
id="settings-codex"
role="tabpanel"
>
<SettingsSectionHeading
description={section.description}
detail={section.kicker}
title={section.title}
/>
<div className="settings-section-stack">
<FastModeSettings t={t} />
<CodexFeatureSettings t={t} />
</div>
</section>
);
}
function FastModeSettings({ t }: { t: Messages }) {
const [state, setState] = useState<FastModeSnapshot | null>(null);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
fetchFastMode()
.then((s) => {
if (cancelled) return;
setState(s);
setError(null);
})
.catch((err) => {
if (cancelled) return;
setError(err instanceof Error ? err.message : String(err));
});
return () => {
cancelled = true;
};
}, []);
async function toggle(provider: keyof FastModeSnapshot) {
if (!state) return;
const next = !state[provider];
const optimistic = { ...state, [provider]: next };
setState(optimistic);
setBusy(true);
setError(null);
try {
const fresh = await updateFastMode({ [provider]: next });
setState(fresh);
} catch (err) {
setState(state);
setError(err instanceof Error ? err.message : String(err));
} finally {
setBusy(false);
}
}
return (
<SettingsCard title={t.settings.codex.fastMode}>
{error ? <p className="settings-error">{error}</p> : null}
{!state ? (
<p className="settings-hint">{t.settings.common.loading}</p>
) : (
<div className="settings-toggle-stack">
<label className="settings-toggle-row">
<span className="settings-toggle-label">
<span className="settings-toggle-title">
{t.settings.codex.codexFast}
</span>
<small className="settings-hint">
{t.settings.codex.codexFastHint}
</small>
</span>
<input
checked={state.codex}
disabled={busy}
onChange={() => void toggle('codex')}
type="checkbox"
/>
</label>
<label className="settings-toggle-row">
<span className="settings-toggle-label">
<span className="settings-toggle-title">
{t.settings.codex.claudeFast}
</span>
<small className="settings-hint">
{t.settings.codex.claudeFastHint}
</small>
</span>
<input
checked={state.claude}
disabled={busy}
onChange={() => void toggle('claude')}
type="checkbox"
/>
</label>
<small className="settings-hint">
{t.settings.codex.fastModeApplyHint}
</small>
</div>
)}
</SettingsCard>
);
}
function CodexFeatureSettings({ t }: { t: Messages }) {
const [state, setState] = useState<CodexFeatureSnapshot | null>(null);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
fetchCodexFeatures()
.then((snapshot) => {
if (cancelled) return;
setState(snapshot);
setError(null);
})
.catch((err) => {
if (cancelled) return;
setError(err instanceof Error ? err.message : String(err));
});
return () => {
cancelled = true;
};
}, []);
async function toggleGoals() {
if (!state) return;
const previous = state;
const optimistic = { ...state, goals: !state.goals };
setState(optimistic);
setBusy(true);
setError(null);
try {
const fresh = await updateCodexFeatures({ goals: optimistic.goals });
setState(fresh);
} catch (err) {
setState(previous);
setError(err instanceof Error ? err.message : String(err));
} finally {
setBusy(false);
}
}
return (
<SettingsCard title={t.settings.codex.features}>
{error ? <p className="settings-error">{error}</p> : null}
{!state ? (
<p className="settings-hint">{t.settings.common.loading}</p>
) : (
<>
<label className="settings-toggle-row">
<span className="settings-toggle-label">
<span className="settings-toggle-title">
{t.settings.codex.goal}
</span>
<small className="settings-hint">
{t.settings.codex.goalHint}
</small>
</span>
<input
checked={state.goals}
disabled={busy}
onChange={() => void toggleGoals()}
type="checkbox"
/>
</label>
<small className="settings-hint">
{t.settings.codex.goalApplyHint}
</small>
</>
)}
</SettingsCard>
);
}
function AccountSettings({ t }: { t: Messages }) {
const [data, setData] = useState<AccountData | null>(null);
const [busy, setBusy] = useState(false);
const [perRowBusy, setPerRowBusy] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [tokenInput, setTokenInput] = useState('');
const section = t.settings.sections.accounts;
async function refresh() {
setBusy(true);
setError(null);
try {
const fresh = await fetchAccounts();
setData(fresh);
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setBusy(false);
}
}
useEffect(() => {
void refresh();
}, []);
async function handleDelete(provider: AccountProvider, index: number) {
if (
!window.confirm(
t.settings.accounts.deleteConfirm
.replace('{provider}', provider)
.replace('{index}', String(index)),
)
) {
return;
}
setBusy(true);
setError(null);
try {
await deleteAccount(provider, index);
await refresh();
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setBusy(false);
}
}
async function handleAdd() {
const token = tokenInput.trim();
if (!token) return;
setBusy(true);
setError(null);
try {
await addClaudeAccount(token);
setTokenInput('');
await refresh();
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setBusy(false);
}
}
async function handleCodexRefresh(index: number) {
setPerRowBusy(`refresh:${index}`);
setError(null);
try {
await refreshCodexAccountApi(index);
await refresh();
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setPerRowBusy(null);
}
}
async function handleRefreshAllCodex() {
setBusy(true);
setError(null);
try {
const result = await refreshAllCodexAccountsApi();
await refresh();
if (result.failed.length > 0) {
setError(
t.settings.accounts.refreshFailed.replace(
'{indexes}',
result.failed.map((f) => `#${f.index}`).join(', '),
),
);
}
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setBusy(false);
}
}
async function handleSwitchCodex(index: number) {
setPerRowBusy(`switch:${index}`);
setError(null);
try {
await setCurrentCodexAccountApi(index);
await refresh();
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setPerRowBusy(null);
}
}
return (
<section
aria-labelledby="settings-accounts-tab"
className="settings-section"
id="settings-accounts"
role="tabpanel"
>
<SettingsSectionHeading
description={section.description}
detail={section.kicker}
title={section.title}
/>
{error ? <p className="settings-error">{error}</p> : null}
<div className="settings-section-stack">
<ClaudeAccounts
busy={busy}
data={data}
onAdd={() => void handleAdd()}
onDelete={(index) => void handleDelete('claude', index)}
onTokenInputChange={setTokenInput}
t={t}
tokenInput={tokenInput}
/>
<CodexAccounts
busy={busy}
data={data}
onDelete={(index) => void handleDelete('codex', index)}
onRefresh={(index) => void handleCodexRefresh(index)}
onRefreshAll={() => void handleRefreshAllCodex()}
onSwitch={(index) => void handleSwitchCodex(index)}
perRowBusy={perRowBusy}
t={t}
/>
</div>
</section>
);
}
function ClaudeAccounts({
busy,
data,
onAdd,
onDelete,
onTokenInputChange,
tokenInput,
t,
}: {
busy: boolean;
data: AccountData | null;
onAdd: () => void;
onDelete: (index: number) => void;
onTokenInputChange: (value: string) => void;
tokenInput: string;
t: Messages;
}) {
return (
<SettingsCard title={t.settings.accounts.claude}>
{!data ? (
<p className="settings-hint">
{busy ? t.settings.common.loading : t.settings.common.none}
</p>
) : data.claude.length === 0 ? (
<p className="settings-hint">{t.settings.accounts.noAccounts}</p>
) : (
<ul className="settings-account-list">
{data.claude.map((acc) => (
<li key={acc.index}>
<article
className={`settings-account-row${acc.index === 0 ? ' is-primary' : ''}`}
>
<div className="settings-account-row-main">
<span className="settings-account-index">#{acc.index}</span>
<div className="settings-account-row-copy">
<strong>{acc.subscriptionType ?? 'unknown'}</strong>
<span className="settings-hint">
{acc.rateLimitTier ?? 'claude-code'}
{' · '}
{t.settings.accounts.autoRefresh}
</span>
</div>
{acc.index === 0 ? (
<span className="settings-pill is-primary is-compact">
{t.settings.accounts.primaryAccount}
</span>
) : null}
</div>
{acc.index > 0 ? (
<div className="settings-account-actions">
<button
className="settings-delete"
disabled={busy}
onClick={() => onDelete(acc.index)}
type="button"
>
{t.settings.common.delete}
</button>
</div>
) : null}
</article>
</li>
))}
</ul>
)}
<div className="settings-add-token">
<label className="settings-row">
<span className="settings-label">
{t.settings.accounts.addTokenLabel}
</span>
<textarea
onChange={(e) => onTokenInputChange(e.target.value)}
placeholder={t.settings.accounts.tokenPlaceholder}
rows={2}
value={tokenInput}
/>
</label>
<button
className="settings-save"
disabled={!tokenInput.trim() || busy}
onClick={onAdd}
type="button"
>
{t.settings.common.add}
</button>
</div>
</SettingsCard>
);
}
function CodexAccounts({
busy,
data,
onDelete,
onRefresh,
onRefreshAll,
onSwitch,
perRowBusy,
t,
}: {
busy: boolean;
data: AccountData | null;
onDelete: (index: number) => void;
onRefresh: (index: number) => void;
onRefreshAll: () => void;
onSwitch: (index: number) => void;
perRowBusy: string | null;
t: Messages;
}) {
return (
<SettingsCard
actions={
<button
className="settings-secondary"
disabled={busy}
onClick={onRefreshAll}
type="button"
>
{t.settings.common.refreshAll}
</button>
}
description={t.settings.accounts.codexRefreshHint}
title={t.settings.accounts.codex}
>
{!data ? (
<p className="settings-hint">
{busy ? t.settings.common.loading : t.settings.common.none}
</p>
) : data.codex.length === 0 ? (
<p className="settings-hint">{t.settings.accounts.noAccounts}</p>
) : (
<ul className="settings-account-list">
{data.codex.map((acc) => (
<li key={acc.index}>
<CodexAccountRow
acc={acc}
busy={busy}
isActive={data.codexCurrentIndex === acc.index}
onDelete={onDelete}
onRefresh={onRefresh}
onSwitch={onSwitch}
perRowBusy={perRowBusy}
t={t}
/>
</li>
))}
</ul>
)}
</SettingsCard>
);
}
function CodexAccountRow({
acc,
busy,
isActive,
onDelete,
onRefresh,
onSwitch,
perRowBusy,
t,
}: {
acc: CodexAccountSummary;
busy: boolean;
isActive: boolean;
onDelete: (index: number) => void;
onRefresh: (index: number) => void;
onSwitch: (index: number) => void;
perRowBusy: string | null;
t: Messages;
}) {
const liveBadge = formatLiveStatusBadge(acc.liveStatus);
const usageBadge = formatUsageBadge(acc.liveStatus);
const cachedExpiry = acc.liveStatus
? null
: formatJwtCacheExpiry(acc.subscriptionUntil);
const checkedAt = acc.subscriptionLastChecked
? formatDateTime(acc.subscriptionLastChecked)
: null;
const refreshing = perRowBusy === `refresh:${acc.index}`;
const switching = perRowBusy === `switch:${acc.index}`;
return (
<article
className={`settings-account-row${isActive ? ' is-active-account' : ''}`}
>
<div className="settings-account-row-main">
<span className="settings-account-index">#{acc.index}</span>
<div className="settings-account-row-copy">
<strong>{acc.email ?? acc.planType ?? 'Codex account'}</strong>
<span className="settings-account-row-meta">
<span className="settings-account-plan">
{acc.planType ?? 'unknown'}
</span>
{liveBadge ? (
<span
className={`settings-account-badge ${liveBadge.cls}`}
title={liveBadge.title}
>
{liveBadge.label}
</span>
) : cachedExpiry ? (
<span
className={`settings-account-badge ${cachedExpiry.cls}`}
title={cachedExpiry.title}
>
{cachedExpiry.label}
</span>
) : null}
{usageBadge ? (
<span
className="settings-account-badge is-muted"
title={usageBadge.title}
>
{usageBadge.label}
</span>
) : null}
{checkedAt ? (
<span
className="settings-account-badge is-muted"
title={
acc.liveStatus
? 'wham/usage live checked_at'
: 'JWT subscription_last_checked cache'
}
>
{t.settings.common.refresh} {checkedAt}
</span>
) : null}
</span>
</div>
{isActive ? (
<span className="settings-pill is-active is-compact">
{t.settings.accounts.activeAccount}
</span>
) : null}
</div>
<div className="settings-account-actions">
<button
className="settings-secondary"
disabled={busy || perRowBusy !== null}
onClick={() => onRefresh(acc.index)}
title={t.settings.accounts.refreshTitle}
type="button"
>
{refreshing
? t.settings.common.refreshing
: t.settings.common.refresh}
</button>
{!isActive ? (
<button
className="settings-secondary"
disabled={busy || perRowBusy !== null}
onClick={() => onSwitch(acc.index)}
title={t.settings.accounts.switchTitle}
type="button"
>
{switching ? t.settings.common.switching : t.settings.common.switch}
</button>
) : null}
{acc.index > 0 ? (
<button
className="settings-delete"
disabled={busy || perRowBusy !== null}
onClick={() => onDelete(acc.index)}
type="button"
>
{t.settings.common.delete}
</button>
) : null}
</div>
</article>
);
}

View File

@@ -0,0 +1,267 @@
import { useState, type ReactNode } from 'react';
import { LOCALES, languageNames, type Locale, type Messages } from './i18n';
export const SETTINGS_NAV_ITEMS = [
{ targetId: 'settings-general', navKey: 'general' },
{ targetId: 'settings-models', navKey: 'models' },
{ targetId: 'settings-runtime', navKey: 'runtime' },
{ targetId: 'settings-moa', navKey: 'moa' },
{ targetId: 'settings-codex', navKey: 'codex' },
{ targetId: 'settings-accounts', navKey: 'accounts' },
] as const;
export type SettingsSectionId = (typeof SETTINGS_NAV_ITEMS)[number]['targetId'];
type SettingsNavKey = (typeof SETTINGS_NAV_ITEMS)[number]['navKey'];
function navItem(t: Messages, navKey: SettingsNavKey) {
return t.settings.nav[navKey];
}
export function SettingsNav({
activeSection,
onSelect,
t,
}: {
activeSection: SettingsSectionId;
onSelect: (section: SettingsSectionId) => void;
t: Messages;
}) {
return (
<div className="settings-nav-scroll">
<div
className="settings-nav"
aria-label={t.settings.navAria}
role="tablist"
>
{SETTINGS_NAV_ITEMS.map((item) => {
const copy = navItem(t, item.navKey);
return (
<button
aria-controls={item.targetId}
aria-selected={activeSection === item.targetId}
data-settings-target={item.targetId}
id={`${item.targetId}-tab`}
key={item.targetId}
onClick={() => onSelect(item.targetId)}
role="tab"
type="button"
>
<strong>{copy.title}</strong>
<small>{copy.detail}</small>
</button>
);
})}
</div>
</div>
);
}
export function GeneralSettings({
locale,
nickname,
onLocaleChange,
onNicknameChange,
t,
}: {
locale: Locale;
nickname: string;
onLocaleChange: (locale: Locale) => void;
onNicknameChange: (next: string) => void;
t: Messages;
}) {
const section = t.settings.sections.general;
return (
<section
aria-labelledby="settings-general-tab"
className="settings-section"
id="settings-general"
role="tabpanel"
>
<SettingsSectionHeading
description={section.description}
detail={section.kicker}
title={section.title}
/>
<div className="settings-card">
<div className="settings-form-grid">
<label className="settings-row">
<span className="settings-label">{t.settings.nicknameLabel}</span>
<input
maxLength={32}
onChange={(event) => onNicknameChange(event.target.value)}
placeholder={t.settings.nicknamePlaceholder}
type="text"
value={nickname}
/>
<small className="settings-hint">{t.settings.nicknameHelp}</small>
</label>
<label className="settings-row">
<span className="settings-label">{t.settings.languageLabel}</span>
<select
aria-label={t.settings.languageLabel}
onChange={(event) => onLocaleChange(event.target.value as Locale)}
value={locale}
>
{LOCALES.map((item) => (
<option key={item} value={item}>
{languageNames[item]}
</option>
))}
</select>
</label>
</div>
</div>
</section>
);
}
export function SettingsSectionHeading({
detail,
title,
description,
}: {
detail: string;
title: string;
description: string;
}) {
return (
<header className="settings-section-head">
<span>{detail}</span>
<h3>{title}</h3>
<p>{description}</p>
</header>
);
}
export function SettingsCard({
title,
description,
actions,
children,
}: {
title?: string;
description?: string;
actions?: ReactNode;
children: ReactNode;
}) {
return (
<section className="settings-card">
{title || description || actions ? (
<header className="settings-card-head">
<div>
{title ? <h4>{title}</h4> : null}
{description ? (
<p className="settings-hint">{description}</p>
) : null}
</div>
{actions ? (
<div className="settings-card-actions">{actions}</div>
) : null}
</header>
) : null}
{children}
</section>
);
}
export function SettingsCollapsible({
title,
description,
defaultOpen = false,
children,
}: {
title: string;
description?: string;
defaultOpen?: boolean;
children: ReactNode;
}) {
const [open, setOpen] = useState(defaultOpen);
return (
<section className={`settings-collapsible${open ? ' is-open' : ''}`}>
<button
aria-expanded={open}
className="settings-collapsible-trigger"
onClick={() => setOpen((value) => !value)}
type="button"
>
<span className="settings-collapsible-text">
<strong>{title}</strong>
{description ? <small>{description}</small> : null}
</span>
<span aria-hidden="true" className="settings-collapsible-chevron">
{open ? '▾' : '▸'}
</span>
</button>
{open ? (
<div className="settings-collapsible-body">{children}</div>
) : null}
</section>
);
}
export function SettingsSaveBar({
busy,
dirty,
label,
savingLabel,
onSave,
savedHint,
showSavedHint,
saveDisabled = false,
}: {
busy: boolean;
dirty: boolean;
label: string;
savingLabel: string;
onSave: () => void;
savedHint?: string;
showSavedHint?: boolean;
saveDisabled?: boolean;
}) {
return (
<div className="settings-actions">
<button
className="settings-save"
disabled={!dirty || busy || saveDisabled}
onClick={onSave}
type="button"
>
{busy ? savingLabel : label}
</button>
{showSavedHint && savedHint ? (
<small className="settings-hint">{savedHint}</small>
) : null}
</div>
);
}
export function SettingsApplyCard({
onRestartStack,
t,
}: {
onRestartStack: () => void;
t: Messages;
}) {
const apply = t.settings.apply;
return (
<section aria-label={apply.aria} className="settings-apply-card">
<div>
<span className="settings-kicker">{apply.kicker}</span>
<strong>{apply.title}</strong>
<small>{apply.hint}</small>
</div>
<button
className="settings-restart"
onClick={onRestartStack}
type="button"
>
{apply.restart}
</button>
</section>
);
}

View File

@@ -0,0 +1,99 @@
import { createElement } from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { describe, expect, it } from 'vitest';
import type { DashboardOverview } from './api';
import { messages } from './i18n';
import { SystemStatusStrip } from './SystemStatusStrip';
const t = messages.en;
function overview(
overrides: Partial<DashboardOverview> = {},
): DashboardOverview {
return {
generatedAt: '2026-04-28T04:15:00.000Z',
inbox: [],
operations: { serviceRestarts: [] },
rooms: { active: 0, inactive: 0, total: 0, waiting: 0 },
services: [
{
activeRooms: 2,
agentType: 'codex',
assistantName: 'owner',
serviceId: 'svc-owner',
totalRooms: 3,
updatedAt: '2026-04-28T04:14:00.000Z',
},
],
tasks: {
active: 0,
completed: 0,
paused: 0,
total: 0,
watchers: { active: 0, completed: 0, paused: 0 },
},
usage: { fetchedAt: '2026-04-28T04:15:00.000Z', rows: [] },
...overrides,
};
}
describe('SystemStatusStrip', () => {
it('stays hidden when services and CI watchers are healthy', () => {
const html = renderToStaticMarkup(
createElement(SystemStatusStrip, { overview: overview(), t }),
);
expect(html).toBe('');
});
it('surfaces paused CI watchers as a room-level system warning', () => {
const html = renderToStaticMarkup(
createElement(SystemStatusStrip, {
overview: overview({
tasks: {
active: 0,
completed: 0,
paused: 2,
total: 2,
watchers: { active: 0, completed: 0, paused: 2 },
},
}),
t,
}),
);
expect(html).toContain('system-status-strip');
expect(html).toContain(t.health.ciFailures);
expect(html).toContain('2');
});
it('surfaces stale and missing service heartbeat signals', () => {
const staleHtml = renderToStaticMarkup(
createElement(SystemStatusStrip, {
overview: overview({
services: [
{
activeRooms: 0,
agentType: 'claude',
assistantName: 'reviewer',
serviceId: 'svc-reviewer',
totalRooms: 1,
updatedAt: '2026-04-28T03:58:00.000Z',
},
],
}),
t,
}),
);
const missingHtml = renderToStaticMarkup(
createElement(SystemStatusStrip, {
overview: overview({ services: [] }),
t,
}),
);
expect(staleHtml).toContain(t.health.levels.down);
expect(missingHtml).toContain(t.service.empty);
});
});

View File

@@ -0,0 +1,66 @@
import type { DashboardOverview } from './api';
import type { Messages } from './i18n';
type HealthLevel = 'stale' | 'down';
type ServiceRow = DashboardOverview['services'][number];
export interface SystemStatusStripProps {
overview: DashboardOverview;
t: Messages;
}
const HEALTH_STALE_MS = 5 * 60_000;
const HEALTH_DOWN_MS = 15 * 60_000;
function serviceAgeMs(service: ServiceRow, generatedAt: string): number | null {
const updated = new Date(service.updatedAt).getTime();
const now = new Date(generatedAt).getTime();
if (Number.isNaN(updated) || Number.isNaN(now)) return null;
return Math.max(0, now - updated);
}
function serviceHealthLevel(
service: ServiceRow,
generatedAt: string,
): HealthLevel | null {
const age = serviceAgeMs(service, generatedAt);
if (age === null) return 'stale';
if (age >= HEALTH_DOWN_MS) return 'down';
if (age >= HEALTH_STALE_MS) return 'stale';
return null;
}
export function SystemStatusStrip({ overview, t }: SystemStatusStripProps) {
const serviceLevels = overview.services.map((service) =>
serviceHealthLevel(service, overview.generatedAt),
);
const down = serviceLevels.filter((level) => level === 'down').length;
const stale = serviceLevels.filter((level) => level === 'stale').length;
const ciPaused = overview.tasks.watchers.paused;
const hasNoHeartbeat = overview.services.length === 0;
const level: HealthLevel = hasNoHeartbeat || down > 0 ? 'down' : 'stale';
if (!hasNoHeartbeat && down === 0 && stale === 0 && ciPaused === 0) {
return null;
}
const signals: string[] = [];
if (hasNoHeartbeat) signals.push(t.service.empty);
if (down > 0) signals.push(`${down} ${t.health.levels.down}`);
if (stale > 0) signals.push(`${stale} ${t.health.stale}`);
if (ciPaused > 0) signals.push(`${ciPaused} ${t.health.ciFailures}`);
return (
<aside
aria-label={t.health.signals}
className={`system-status-strip system-status-${level}`}
role="status"
>
<div>
<span className="eyebrow">{t.health.system}</span>
<strong>{t.health.levels[level]}</strong>
</div>
<p>{signals.join(' · ')}</p>
</aside>
);
}

View File

@@ -0,0 +1,50 @@
import { createElement } from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { describe, expect, it } from 'vitest';
import type { DashboardTask } from './api';
import { messages } from './i18n';
import { TaskActionButtons } from './TaskActionButtons';
const t = messages.en;
const task: DashboardTask = {
agentType: 'codex',
chatJid: 'room-1',
ciMetadata: null,
ciProvider: null,
contextMode: 'group',
createdAt: '2026-04-28T04:00:00.000Z',
groupFolder: 'eyejokerdb',
id: 'task-1',
isWatcher: false,
lastResult: null,
lastRun: null,
nextRun: '2026-04-28T05:00:00.000Z',
promptLength: 42,
promptPreview: 'Run production check',
scheduleType: 'interval',
scheduleValue: '10m',
status: 'active',
suspendedUntil: null,
};
describe('TaskActionButtons', () => {
it('renders task actions with shared busy state styling', () => {
const html = renderToStaticMarkup(
createElement(TaskActionButtons, {
className: 'inbox-actions',
onTaskAction: () => {},
task,
taskActionKey: 'task-1:pause',
t,
}),
);
expect(html).toContain('task-actions inbox-actions');
expect(html).toContain('task-action task-action-pause is-busy');
expect(html).toContain('aria-busy="true"');
expect(html).toContain(t.tasks.actions.busy);
expect(html).toContain(t.tasks.actions.cancel);
});
});

View File

@@ -0,0 +1,52 @@
import type { DashboardTask, DashboardTaskAction } from './api';
import { taskActionsFor } from './dashboardHelpers';
import type { Messages } from './i18n';
export type TaskActionKey =
| 'create'
| `${string}:edit`
| `${string}:${DashboardTaskAction}`;
interface TaskActionButtonsProps {
className?: string;
onTaskAction: (task: DashboardTask, action: DashboardTaskAction) => void;
task: DashboardTask;
taskActionKey: string | null;
t: Messages;
}
export function TaskActionButtons({
className,
onTaskAction,
task,
taskActionKey,
t,
}: TaskActionButtonsProps) {
const taskActions = taskActionsFor(task);
if (taskActions.length === 0) return null;
const rootClassName = className
? `task-actions ${className}`
: 'task-actions';
return (
<div className={rootClassName}>
{taskActions.map((action) => {
const actionKey: TaskActionKey = `${task.id}:${action}`;
const busy = taskActionKey === actionKey;
return (
<button
aria-busy={busy || undefined}
className={`task-action task-action-${action}${busy ? ' is-busy' : ''}`}
disabled={busy}
key={action}
onClick={() => onTaskAction(task, action)}
type="button"
>
{busy ? t.tasks.actions.busy : t.tasks.actions[action]}
</button>
);
})}
</div>
);
}

View File

@@ -0,0 +1,647 @@
.task-board {
display: grid;
gap: 10px;
min-width: 0;
}
.task-summary-bar {
display: flex;
gap: 10px;
align-items: stretch;
justify-content: space-between;
padding: 12px 14px;
border: 1px solid var(--panel-border);
border-radius: 14px;
background: rgba(255, 255, 255, 0.03);
}
.task-summary-next {
display: grid;
gap: 2px;
min-width: 0;
flex: 1 1 auto;
}
.task-summary-next small {
color: var(--muted);
font-size: 10px;
font-weight: 800;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.task-summary-next strong {
font-size: 20px;
line-height: 1.1;
letter-spacing: -0.04em;
}
.task-summary-next span {
overflow: hidden;
color: var(--muted);
font-size: 12px;
text-overflow: ellipsis;
white-space: nowrap;
}
.task-summary-chips {
display: flex;
flex-wrap: wrap;
gap: 6px;
align-content: center;
justify-content: flex-end;
flex: none;
}
.task-chip {
display: inline-flex;
min-height: 26px;
align-items: center;
padding: 0 8px;
border: 1px solid var(--panel-border);
border-radius: 999px;
color: var(--muted);
font-size: 10px;
font-weight: 700;
white-space: nowrap;
background: rgba(255, 255, 255, 0.03);
}
.task-filter-scroll {
min-width: 0;
width: 100%;
border-bottom: 1px solid var(--panel-border);
}
.task-filter-tabs {
display: flex;
gap: 8px;
overflow-x: auto;
overflow-y: hidden;
padding: 6px 0 10px;
-webkit-overflow-scrolling: touch;
}
.task-filter-tab {
display: grid;
gap: 1px;
flex: 0 0 auto;
min-width: 72px;
min-height: 46px;
padding: 7px 10px;
border: 1px solid var(--panel-border);
border-radius: 12px;
background: rgba(19, 24, 22, 0.72);
color: var(--muted);
font: inherit;
text-align: left;
cursor: pointer;
}
.task-filter-tab[aria-selected='true'] {
border-color: rgba(214, 130, 88, 0.38);
background: rgba(214, 130, 88, 0.12);
color: var(--bg-ink);
}
.task-filter-tab strong {
font-size: 12px;
}
.task-filter-tab small {
color: inherit;
font-size: 10px;
opacity: 0.85;
}
.task-create-panel {
border: 1px solid var(--panel-border);
border-radius: 14px;
background: rgba(255, 255, 255, 0.025);
}
.task-create-panel > summary {
padding: 12px 14px;
color: var(--accent-strong);
font-size: 13px;
font-weight: 800;
cursor: pointer;
list-style: none;
}
.task-create-panel > summary::-webkit-details-marker {
display: none;
}
.task-create-form {
display: grid;
gap: 10px;
padding: 0 12px 12px;
}
.task-create-hint {
margin: 0;
color: var(--muted);
font-size: 11px;
line-height: 1.45;
}
.task-form-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 8px;
}
.task-create-form label,
.task-edit-form label {
display: grid;
gap: 4px;
min-width: 0;
color: var(--muted);
font-size: 11px;
font-weight: 700;
}
.task-create-form input,
.task-create-form select,
.task-create-form textarea,
.task-edit-form input,
.task-edit-form select,
.task-edit-form textarea {
width: 100%;
min-height: 40px;
padding: 8px 10px;
border: 1px solid var(--panel-border-strong);
border-radius: 10px;
background: var(--bg-card);
color: var(--bg-ink);
font: inherit;
font-size: 13px;
}
.task-create-form textarea,
.task-edit-form textarea {
min-height: 72px;
resize: vertical;
}
.task-create-form button,
.task-edit-form button {
justify-self: start;
min-height: 40px;
padding: 0 16px;
border: 0;
border-radius: 999px;
background: var(--accent);
color: var(--bg);
font: inherit;
font-size: 12px;
font-weight: 800;
cursor: pointer;
}
.task-create-form button:disabled,
.task-edit-form button:disabled {
cursor: progress;
opacity: 0.55;
}
.task-form-wide {
grid-column: 1 / -1;
}
.task-lanes {
display: grid;
gap: 8px;
}
.task-group {
display: grid;
gap: 6px;
min-width: 0;
}
.task-group-completed {
padding: 10px 12px;
border: 1px solid var(--panel-border);
border-radius: 12px;
background: rgba(255, 255, 255, 0.02);
}
.task-group-completed summary {
cursor: pointer;
list-style: none;
}
.task-group-completed summary::-webkit-details-marker {
display: none;
}
.task-group-head {
display: flex;
gap: 8px;
align-items: center;
justify-content: space-between;
padding: 0 2px;
}
.task-group-head strong {
font-size: 13px;
letter-spacing: -0.02em;
}
.task-list {
display: grid;
gap: 4px;
}
.task-row {
display: grid;
gap: 0;
min-width: 0;
border: 1px solid var(--panel-border);
border-radius: 12px;
background: rgba(255, 255, 255, 0.02);
}
.task-row-head {
display: flex;
gap: 10px;
align-items: flex-start;
justify-content: space-between;
padding: 8px 10px;
}
.task-row-lead {
display: flex;
gap: 8px;
align-items: flex-start;
min-width: 0;
flex: 1 1 auto;
}
.task-row-copy {
display: grid;
gap: 2px;
min-width: 0;
}
.task-kind {
color: var(--accent-strong);
font-size: 10px;
font-weight: 800;
letter-spacing: 0.06em;
text-transform: uppercase;
}
.task-row-copy strong {
overflow: hidden;
font-size: 13px;
line-height: 1.35;
letter-spacing: -0.02em;
text-overflow: ellipsis;
white-space: nowrap;
}
.task-meta-strip {
display: flex;
flex-wrap: wrap;
gap: 4px;
}
.task-meta-strip span {
display: inline-flex;
max-width: 100%;
min-height: 20px;
align-items: center;
padding: 0 7px;
overflow: hidden;
border-radius: 999px;
color: var(--muted);
font-size: 10px;
font-weight: 700;
background: rgba(255, 255, 255, 0.04);
text-overflow: ellipsis;
white-space: nowrap;
}
.task-row-aside {
display: grid;
gap: 6px;
justify-items: end;
flex: none;
}
.task-row-next {
display: grid;
gap: 1px;
justify-items: end;
text-align: right;
}
.task-row-next small,
.task-row-next em {
color: var(--muted);
font-size: 10px;
font-style: normal;
font-weight: 600;
}
.task-row-next strong {
font-size: 13px;
letter-spacing: -0.02em;
}
.task-row-actions {
justify-content: flex-end;
}
.task-actions {
display: flex;
flex-wrap: wrap;
gap: 4px;
}
.task-action {
min-height: 30px;
padding: 0 10px;
border: 1px solid var(--panel-border-strong);
border-radius: 999px;
background: rgba(255, 255, 255, 0.04);
color: var(--bg-ink);
font: inherit;
font-size: 10px;
font-weight: 800;
cursor: pointer;
}
.task-action:hover,
.task-action:focus-visible {
border-color: rgba(214, 130, 88, 0.42);
outline: none;
box-shadow: 0 0 0 2px rgba(214, 130, 88, 0.12);
}
.task-action:disabled {
cursor: progress;
opacity: 0.55;
}
.task-action.is-busy {
display: inline-flex;
gap: 6px;
align-items: center;
justify-content: center;
border-color: rgba(214, 130, 88, 0.32);
background: rgba(214, 130, 88, 0.12);
}
.task-action.is-busy::before {
content: '';
display: block;
width: 10px;
height: 10px;
border: 2px solid currentColor;
border-right-color: transparent;
border-radius: 50%;
animation: ej-spin 0.7s linear infinite;
}
@keyframes ej-spin {
to {
transform: rotate(360deg);
}
}
.task-action-cancel {
color: #8f2f22;
border-color: rgba(183, 71, 52, 0.22);
background: rgba(183, 71, 52, 0.08);
}
.task-row-details {
border-top: 1px solid var(--panel-border);
}
.task-row-details > summary {
padding: 7px 10px;
color: var(--muted);
font-size: 11px;
font-weight: 800;
cursor: pointer;
list-style: none;
}
.task-row-details > summary::-webkit-details-marker {
display: none;
}
.task-row-details-body {
display: grid;
gap: 8px;
padding: 0 10px 10px;
}
.task-time-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 6px;
}
.task-time-grid > span,
.task-suspended,
.task-result {
display: grid;
gap: 2px;
min-width: 0;
padding: 8px;
border: 1px solid var(--panel-border);
border-radius: 10px;
background: rgba(255, 255, 255, 0.02);
}
.task-next-run {
border-color: rgba(214, 130, 88, 0.25);
background: rgba(214, 130, 88, 0.08);
}
.task-time-grid small,
.task-suspended span,
.task-result span,
.task-time-grid em,
.task-suspended em {
color: var(--muted);
font-size: 10px;
font-style: normal;
font-weight: 600;
}
.task-time-grid strong,
.task-suspended strong,
.task-result strong {
overflow: hidden;
font-size: 12px;
text-overflow: ellipsis;
white-space: nowrap;
}
.task-suspended {
border-color: rgba(181, 138, 34, 0.22);
background: rgba(181, 138, 34, 0.08);
}
.task-result {
grid-template-columns: auto minmax(0, 1fr);
align-items: center;
gap: 8px;
border-color: rgba(63, 127, 81, 0.15);
}
.result-fail {
border-color: rgba(183, 71, 52, 0.28);
background: rgba(183, 71, 52, 0.08);
}
.result-none {
border-color: rgba(109, 115, 95, 0.14);
}
.task-prompt-block {
display: grid;
gap: 4px;
padding: 8px;
border: 1px solid var(--panel-border);
border-radius: 10px;
background: rgba(255, 255, 255, 0.02);
}
.task-prompt-block > span {
color: var(--muted);
font-size: 10px;
font-weight: 800;
letter-spacing: 0.06em;
text-transform: uppercase;
}
.task-prompt-block p {
margin: 0;
overflow-wrap: anywhere;
font-size: 12px;
line-height: 1.45;
}
.task-prompt-block small {
overflow: hidden;
color: var(--muted);
font-family: 'JetBrains Mono', ui-monospace, monospace;
font-size: 10px;
text-overflow: ellipsis;
white-space: nowrap;
}
.task-edit {
border: 1px solid var(--panel-border);
border-radius: 10px;
background: rgba(255, 255, 255, 0.02);
}
.task-edit > summary {
padding: 8px 10px;
color: var(--accent-strong);
font-size: 11px;
font-weight: 800;
cursor: pointer;
list-style: none;
}
.task-edit > summary::-webkit-details-marker {
display: none;
}
.task-edit-form {
display: grid;
gap: 8px;
padding: 0 10px 10px;
}
.task-group-empty {
padding: 10px 12px;
border-radius: 10px;
color: var(--muted);
font-size: 12px;
background: rgba(255, 255, 255, 0.02);
}
.pill-watchers {
color: #d7f5e7;
background: rgba(80, 180, 130, 0.14);
}
.pill-scheduled,
.pill-paused {
color: #f0dfaa;
background: rgba(181, 138, 34, 0.16);
}
.pill-completed {
color: var(--muted);
background: rgba(109, 115, 95, 0.16);
}
@media (max-width: 760px) {
.task-summary-bar {
flex-direction: column;
padding: 10px 12px;
}
.task-summary-chips {
justify-content: flex-start;
}
.task-form-grid {
grid-template-columns: 1fr;
}
.task-create-form button {
width: 100%;
justify-self: stretch;
}
.task-row-head {
flex-direction: column;
gap: 8px;
}
.task-row-aside {
width: 100%;
grid-template-columns: minmax(0, 1fr) auto;
align-items: center;
justify-items: stretch;
}
.task-row-next {
justify-items: start;
text-align: left;
}
.task-row-actions {
justify-content: flex-end;
}
.task-time-grid {
grid-template-columns: 1fr;
}
.task-action {
min-height: 36px;
padding-inline: 12px;
font-size: 11px;
}
}
@media (max-width: 640px) {
.task-filter-tab {
min-width: 64px;
min-height: 44px;
}
}

View File

@@ -0,0 +1,67 @@
import { createElement } from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { describe, expect, it } from 'vitest';
import type { DashboardTask } from './api';
import { messages } from './i18n';
import { TaskPanel, type TaskPanelProps } from './TaskPanel';
const t = messages.en;
const task: DashboardTask = {
agentType: 'codex',
chatJid: 'room-1',
ciMetadata: null,
ciProvider: null,
contextMode: 'group',
createdAt: '2026-04-28T04:00:00.000Z',
groupFolder: 'eyejokerdb',
id: 'task-1',
isWatcher: false,
lastResult: '<internal>hidden</internal>Task completed',
lastRun: '2026-04-28T04:30:00.000Z',
nextRun: '2026-04-28T05:00:00.000Z',
promptLength: 42,
promptPreview: '<internal>hidden</internal>Run production check',
scheduleType: 'interval',
scheduleValue: '10m',
status: 'active',
suspendedUntil: null,
};
const baseProps: TaskPanelProps = {
locale: 'en',
onTaskAction: () => {},
onTaskCreate: () => {},
onTaskUpdate: () => {},
rooms: [{ folder: 'eyejokerdb', jid: 'room-1', name: 'eyejokerdb-main' }],
taskActionKey: null,
tasks: [task],
t,
};
describe('TaskPanel', () => {
it('renders task groups, actions, and sanitized previews', () => {
const html = renderToStaticMarkup(createElement(TaskPanel, baseProps));
expect(html).toContain(t.tasks.groups.scheduled);
expect(html).toContain('eyejokerdb-main');
expect(html).toContain(t.tasks.next);
expect(html).toContain('10m');
expect(html).toContain(t.tasks.actions.pause);
expect(html).toContain(t.tasks.actions.cancel);
expect(html).toContain('Task completed');
expect(html).toContain('Run production check');
expect(html).toContain('task-row');
expect(html).toContain('task-filter-tabs');
expect(html).not.toContain('internal');
});
it('renders an empty state without scheduled tasks', () => {
const html = renderToStaticMarkup(
createElement(TaskPanel, { ...baseProps, tasks: [] }),
);
expect(html).toContain(t.tasks.empty);
});
});

View File

@@ -0,0 +1,881 @@
import { useMemo, useState } from 'react';
import type {
CreateScheduledTaskInput,
DashboardTask,
DashboardTaskAction,
DashboardTaskContextMode,
DashboardTaskScheduleType,
UpdateScheduledTaskInput,
} from './api';
import { EmptyState } from './EmptyState';
import { localeTags, type Locale, type Messages } from './i18n';
import { redactSecretsForPreview } from './redaction';
import { statusLabel } from './dashboardHelpers';
import { TaskActionButtons, type TaskActionKey } from './TaskActionButtons';
import './TaskPanel.css';
export type { TaskActionKey } from './TaskActionButtons';
type TaskGroupKey = 'watchers' | 'scheduled' | 'paused' | 'completed';
type TaskFilterKey = TaskGroupKey | 'all';
type TaskResultTone = 'ok' | 'fail' | 'none';
export interface RoomOption {
jid: string;
name: string;
folder: string;
}
export interface TaskPanelProps {
tasks: DashboardTask[];
rooms: RoomOption[];
locale: Locale;
onTaskAction: (task: DashboardTask, action: DashboardTaskAction) => void;
onTaskCreate: (input: CreateScheduledTaskInput) => void;
onTaskUpdate: (task: DashboardTask, input: UpdateScheduledTaskInput) => void;
taskActionKey: TaskActionKey | null;
t: Messages;
}
interface TaskGroup {
key: TaskGroupKey;
tasks: DashboardTask[];
}
interface TaskSummary {
completed: number;
nextTask: DashboardTask | null;
paused: number;
scheduled: number;
total: number;
watchers: number;
}
interface TaskCreateFormProps {
rooms: RoomOption[];
onTaskCreate: (input: CreateScheduledTaskInput) => void;
taskActionKey: TaskActionKey | null;
t: Messages;
}
interface TaskGroupSectionProps {
group: TaskGroup;
locale: Locale;
onTaskAction: (task: DashboardTask, action: DashboardTaskAction) => void;
onTaskUpdate: (task: DashboardTask, input: UpdateScheduledTaskInput) => void;
taskActionKey: TaskActionKey | null;
t: Messages;
}
interface TaskCardProps {
groupKey: TaskGroupKey;
locale: Locale;
onTaskAction: (task: DashboardTask, action: DashboardTaskAction) => void;
onTaskUpdate: (task: DashboardTask, input: UpdateScheduledTaskInput) => void;
task: DashboardTask;
taskActionKey: TaskActionKey | null;
t: Messages;
}
interface TaskDateProps {
locale: Locale;
t: Messages;
task: DashboardTask;
}
interface TaskEditFormProps {
onTaskUpdate: (task: DashboardTask, input: UpdateScheduledTaskInput) => void;
task: DashboardTask;
taskActionKey: TaskActionKey | null;
t: Messages;
}
const TASK_TIME_FORMATTERS: Record<Locale, Intl.DateTimeFormat> = {
en: new Intl.DateTimeFormat(localeTags.en, {
hour: '2-digit',
minute: '2-digit',
hour12: false,
}),
ja: new Intl.DateTimeFormat(localeTags.ja, {
hour: '2-digit',
minute: '2-digit',
hour12: false,
}),
ko: new Intl.DateTimeFormat(localeTags.ko, {
hour: '2-digit',
minute: '2-digit',
hour12: false,
}),
zh: new Intl.DateTimeFormat(localeTags.zh, {
hour: '2-digit',
minute: '2-digit',
hour12: false,
}),
};
const EN_TASK_DATE_TIME_FORMATTER = new Intl.DateTimeFormat(localeTags.en, {
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
hour12: false,
});
const RELATIVE_TIME_FORMATTERS: Record<Locale, Intl.RelativeTimeFormat> = {
en: new Intl.RelativeTimeFormat(localeTags.en, {
numeric: 'auto',
style: 'short',
}),
ja: new Intl.RelativeTimeFormat(localeTags.ja, {
numeric: 'auto',
style: 'short',
}),
ko: new Intl.RelativeTimeFormat(localeTags.ko, {
numeric: 'auto',
style: 'short',
}),
zh: new Intl.RelativeTimeFormat(localeTags.zh, {
numeric: 'auto',
style: 'short',
}),
};
function formatTaskDate(
value: string | null | undefined,
locale: Locale,
): string {
if (!value) return '-';
const date = new Date(value);
if (Number.isNaN(date.getTime())) return value;
const time = TASK_TIME_FORMATTERS[locale].format(date);
if (locale === 'ko')
return `${date.getMonth() + 1}${date.getDate()}${time}`;
if (locale === 'ja' || locale === 'zh')
return `${date.getMonth() + 1}${date.getDate()}${time}`;
return EN_TASK_DATE_TIME_FORMATTER.format(date);
}
function formatRelativeDate(
value: string | null | undefined,
locale: Locale,
t: Messages,
): string {
if (!value) return t.tasks.noTime;
const date = new Date(value);
if (Number.isNaN(date.getTime())) return value;
const diffMs = date.getTime() - Date.now();
const absMs = Math.abs(diffMs);
if (absMs < 45_000) return t.tasks.now;
const units: Array<[Intl.RelativeTimeFormatUnit, number]> = [
['day', 86_400_000],
['hour', 3_600_000],
['minute', 60_000],
];
const [unit, unitMs] =
units.find(([, threshold]) => absMs >= threshold) ?? units.at(-1)!;
return RELATIVE_TIME_FORMATTERS[locale].format(
Math.round(diffMs / unitMs),
unit,
);
}
function safePreview(
value: string | null | undefined,
fallback: string,
): string {
const cleaned = redactSecretsForPreview(value ?? '')
.replace(/<\/?internal[^>]*>/gi, '')
.replace(/\s+/g, ' ')
.trim();
if (!cleaned) return fallback;
return cleaned.length > 120 ? `${cleaned.slice(0, 120)}...` : cleaned;
}
function taskGroupKey(task: DashboardTask): TaskGroupKey {
if (task.status === 'completed') return 'completed';
if (task.status === 'paused') return 'paused';
if (task.isWatcher) return 'watchers';
return 'scheduled';
}
function taskResultTone(task: DashboardTask): TaskResultTone {
if (!task.lastResult) return 'none';
const normalized = task.lastResult.toLowerCase();
if (
normalized.includes('fail') ||
normalized.includes('error') ||
normalized.includes('timeout') ||
normalized.includes('cancel') ||
normalized.includes('reject')
) {
return 'fail';
}
return 'ok';
}
function taskDisplayName(task: DashboardTask, t: Messages): string {
if (task.isWatcher) return t.tasks.ciWatch;
if (task.scheduleType) return task.scheduleType;
return task.id;
}
function taskHeadline(task: DashboardTask, t: Messages): string {
if (task.isWatcher && task.ciProvider) {
return `${t.tasks.ciWatch} · ${task.ciProvider}`;
}
return safePreview(task.promptPreview, taskDisplayName(task, t));
}
function taskScheduleText(task: DashboardTask, t: Messages): string {
const scheduleType =
task.scheduleType === 'cron' ||
task.scheduleType === 'interval' ||
task.scheduleType === 'once'
? t.tasks.scheduleTypes[task.scheduleType]
: task.scheduleType;
return `${scheduleType} · ${task.scheduleValue || '-'}`;
}
function isTaskScheduleType(
value: FormDataEntryValue | null,
): value is DashboardTaskScheduleType {
return value === 'cron' || value === 'interval' || value === 'once';
}
function isTaskContextMode(
value: FormDataEntryValue | null,
): value is DashboardTaskContextMode {
return value === 'group' || value === 'isolated';
}
function readRequiredText(form: FormData, name: string): string | null {
const value = form.get(name);
if (typeof value !== 'string') return null;
const trimmed = value.trim();
return trimmed ? trimmed : null;
}
function readTaskForm(
form: FormData,
includeRoom: true,
): CreateScheduledTaskInput | null;
function readTaskForm(
form: FormData,
includeRoom: false,
): UpdateScheduledTaskInput | null;
function readTaskForm(
form: FormData,
includeRoom: boolean,
): CreateScheduledTaskInput | UpdateScheduledTaskInput | null {
const prompt = readRequiredText(form, 'prompt');
const scheduleValue = readRequiredText(form, 'scheduleValue');
const scheduleTypeValue = form.get('scheduleType');
if (!scheduleValue || !isTaskScheduleType(scheduleTypeValue)) {
return null;
}
const scheduleType = scheduleTypeValue;
if (!includeRoom) {
return prompt
? { prompt, scheduleType, scheduleValue }
: { scheduleType, scheduleValue };
}
if (!prompt) {
return null;
}
const roomJid = readRequiredText(form, 'roomJid');
const contextMode = form.get('contextMode');
if (!roomJid || !isTaskContextMode(contextMode)) return null;
return {
contextMode,
prompt,
roomJid,
scheduleType,
scheduleValue,
};
}
function buildTaskGroups(tasks: DashboardTask[]): TaskGroup[] {
const groups: Record<TaskGroupKey, DashboardTask[]> = {
watchers: [],
scheduled: [],
paused: [],
completed: [],
};
for (const task of tasks) {
groups[taskGroupKey(task)].push(task);
}
for (const groupTasks of Object.values(groups)) {
groupTasks.sort((a, b) =>
(a.nextRun ?? a.lastRun ?? a.createdAt).localeCompare(
b.nextRun ?? b.lastRun ?? b.createdAt,
),
);
}
return [
{ key: 'watchers', tasks: groups.watchers },
{ key: 'scheduled', tasks: groups.scheduled },
{ key: 'paused', tasks: groups.paused },
{ key: 'completed', tasks: groups.completed },
];
}
function buildTaskSummary(tasks: DashboardTask[]): TaskSummary {
const summary: TaskSummary = {
completed: 0,
nextTask: null,
paused: 0,
scheduled: 0,
total: tasks.length,
watchers: 0,
};
for (const task of tasks) {
const group = taskGroupKey(task);
if (group === 'watchers') summary.watchers += 1;
if (group === 'scheduled') summary.scheduled += 1;
if (group === 'paused') summary.paused += 1;
if (group === 'completed') summary.completed += 1;
if (task.status !== 'active' || !task.nextRun) continue;
const nextTime = new Date(task.nextRun).getTime();
if (Number.isNaN(nextTime)) continue;
const currentTime = summary.nextTask?.nextRun
? new Date(summary.nextTask.nextRun).getTime()
: Number.POSITIVE_INFINITY;
if (nextTime < currentTime) {
summary.nextTask = task;
}
}
return summary;
}
function TaskSummaryBar({
locale,
summary,
t,
}: {
locale: Locale;
summary: TaskSummary;
t: Messages;
}) {
const nextTask = summary.nextTask;
return (
<section className="task-summary-bar" aria-label={t.panels.scheduled}>
<div className="task-summary-next">
<small>{t.tasks.next}</small>
<strong>
{nextTask ? formatRelativeDate(nextTask.nextRun, locale, t) : '-'}
</strong>
<span>
{nextTask
? taskHeadline(nextTask, t)
: summary.total > 0
? `${summary.total} ${t.tasks.count}`
: t.tasks.empty}
</span>
</div>
<div className="task-summary-chips" aria-hidden="true">
<span className="task-chip">
{t.tasks.groups.scheduled} {summary.scheduled}
</span>
<span className="task-chip">
{t.tasks.groups.watchers} {summary.watchers}
</span>
<span className="task-chip">
{t.tasks.groups.paused} {summary.paused}
</span>
<span className="task-chip">
{t.tasks.groups.completed} {summary.completed}
</span>
</div>
</section>
);
}
function TaskFilterTabs({
activeFilter,
counts,
onSelect,
t,
}: {
activeFilter: TaskFilterKey;
counts: Record<TaskFilterKey, number>;
onSelect: (filter: TaskFilterKey) => void;
t: Messages;
}) {
const tabs: Array<{ key: TaskFilterKey; label: string }> = [
{ key: 'all', label: t.tasks.filterAll },
{ key: 'scheduled', label: t.tasks.groups.scheduled },
{ key: 'watchers', label: t.tasks.groups.watchers },
{ key: 'paused', label: t.tasks.groups.paused },
{ key: 'completed', label: t.tasks.groups.completed },
];
return (
<div className="task-filter-scroll">
<div
aria-label={t.panels.scheduled}
className="task-filter-tabs"
role="tablist"
>
{tabs.map((tab) => (
<button
aria-selected={activeFilter === tab.key}
className="task-filter-tab"
key={tab.key}
onClick={() => onSelect(tab.key)}
role="tab"
type="button"
>
<strong>{tab.label}</strong>
<small>{counts[tab.key]}</small>
</button>
))}
</div>
</div>
);
}
function TaskCreateForm({
rooms,
onTaskCreate,
taskActionKey,
t,
}: TaskCreateFormProps) {
return (
<details className="task-create-panel">
<summary>{t.tasks.createTitle}</summary>
<form
className="task-create-form"
onSubmit={(event) => {
event.preventDefault();
const form = new FormData(event.currentTarget);
const input = readTaskForm(form, true);
if (!input) return;
onTaskCreate(input);
event.currentTarget.reset();
}}
>
<p className="task-create-hint">{t.tasks.scheduleValueHint}</p>
<label className="task-form-wide">
<span>{t.tasks.prompt}</span>
<textarea
name="prompt"
placeholder={t.tasks.promptPlaceholder}
required
rows={3}
/>
</label>
<div className="task-form-grid">
<label>
<span>{t.tasks.room}</span>
<select name="roomJid" required>
<option value="">{t.tasks.selectRoom}</option>
{rooms.map((room) => (
<option key={room.jid} value={room.jid}>
{room.name} · {room.folder}
</option>
))}
</select>
</label>
<label>
<span>{t.tasks.scheduleType}</span>
<select name="scheduleType" required>
<option value="once">{t.tasks.scheduleTypes.once}</option>
<option value="interval">{t.tasks.scheduleTypes.interval}</option>
<option value="cron">{t.tasks.scheduleTypes.cron}</option>
</select>
</label>
<label>
<span>{t.tasks.scheduleValue}</span>
<input
name="scheduleValue"
placeholder={t.tasks.scheduleValueHint}
required
/>
</label>
<label>
<span>{t.tasks.context}</span>
<select name="contextMode" required>
<option value="isolated">{t.tasks.contextModes.isolated}</option>
<option value="group">{t.tasks.contextModes.group}</option>
</select>
</label>
</div>
<button disabled={taskActionKey === 'create'} type="submit">
{taskActionKey === 'create'
? t.tasks.actions.busy
: t.tasks.actions.create}
</button>
</form>
</details>
);
}
function TaskTimeGrid({ locale, t, task }: TaskDateProps) {
return (
<div className="task-time-grid">
<span className="task-next-run">
<small>{t.tasks.next}</small>
<strong>{formatRelativeDate(task.nextRun, locale, t)}</strong>
<em>{formatTaskDate(task.nextRun, locale)}</em>
</span>
<span>
<small>{t.tasks.last}</small>
<strong>{formatTaskDate(task.lastRun, locale)}</strong>
<em>{formatRelativeDate(task.lastRun, locale, t)}</em>
</span>
<span>
<small>{t.tasks.schedule}</small>
<strong>{taskScheduleText(task, t)}</strong>
<em>{task.contextMode}</em>
</span>
</div>
);
}
function TaskSuspendedUntil({ locale, t, task }: TaskDateProps) {
if (!task.suspendedUntil) return null;
return (
<div className="task-suspended">
<span>{t.tasks.suspendedUntil}</span>
<strong>{formatTaskDate(task.suspendedUntil, locale)}</strong>
<em>{formatRelativeDate(task.suspendedUntil, locale, t)}</em>
</div>
);
}
function TaskResult({ task, t }: { task: DashboardTask; t: Messages }) {
const resultTone = taskResultTone(task);
const lastResult = safePreview(task.lastResult, t.tasks.noResult);
return (
<div className={`task-result result-${resultTone}`}>
<span>
{resultTone === 'fail'
? t.tasks.resultFail
: resultTone === 'ok'
? t.tasks.resultOk
: t.tasks.result}
</span>
<strong>{lastResult}</strong>
</div>
);
}
function TaskPromptBlock({ task, t }: { task: DashboardTask; t: Messages }) {
return (
<div className="task-prompt-block">
<span>{t.tasks.prompt}</span>
<p>{safePreview(task.promptPreview, t.tasks.emptyPrompt)}</p>
<small>
{task.id} · {taskScheduleText(task, t)} · {task.promptLength}{' '}
{t.units.chars}
</small>
</div>
);
}
function TaskMetaStrip({ task, t }: { task: DashboardTask; t: Messages }) {
return (
<div className="task-meta-strip">
<span>{task.groupFolder}</span>
<span>{task.agentType ?? t.tasks.task}</span>
{task.ciProvider ? <span>{task.ciProvider}</span> : null}
</div>
);
}
function TaskEditForm({
onTaskUpdate,
task,
taskActionKey,
t,
}: TaskEditFormProps) {
if (task.isWatcher || task.status === 'completed') return null;
return (
<details className="task-edit">
<summary>{t.tasks.actions.edit}</summary>
<form
className="task-edit-form"
onSubmit={(event) => {
event.preventDefault();
const form = new FormData(event.currentTarget);
const input = readTaskForm(form, false);
if (!input) return;
onTaskUpdate(task, input);
}}
>
<label className="task-form-wide">
<span>{t.tasks.prompt}</span>
<textarea name="prompt" placeholder={t.tasks.editPromptPlaceholder} />
</label>
<label>
<span>{t.tasks.scheduleType}</span>
<select name="scheduleType" defaultValue={task.scheduleType} required>
<option value="once">{t.tasks.scheduleTypes.once}</option>
<option value="interval">{t.tasks.scheduleTypes.interval}</option>
<option value="cron">{t.tasks.scheduleTypes.cron}</option>
</select>
</label>
<label>
<span>{t.tasks.scheduleValue}</span>
<input
name="scheduleValue"
defaultValue={task.scheduleValue}
required
/>
</label>
<button disabled={taskActionKey === `${task.id}:edit`} type="submit">
{taskActionKey === `${task.id}:edit`
? t.tasks.actions.busy
: t.tasks.actions.save}
</button>
</form>
</details>
);
}
function TaskCard({
groupKey,
locale,
onTaskAction,
onTaskUpdate,
task,
taskActionKey,
t,
}: TaskCardProps) {
return (
<article className={`task-row task-row-${groupKey}`}>
<div className="task-row-head">
<div className="task-row-lead">
<span className={`pill pill-${task.status}`}>
{statusLabel(task.status, t)}
</span>
<div className="task-row-copy">
<span className="task-kind">{taskDisplayName(task, t)}</span>
<strong title={taskHeadline(task, t)}>
{taskHeadline(task, t)}
</strong>
<TaskMetaStrip task={task} t={t} />
</div>
</div>
<div className="task-row-aside">
<div className="task-row-next">
<small>{t.tasks.next}</small>
<strong>{formatRelativeDate(task.nextRun, locale, t)}</strong>
<em>{formatTaskDate(task.nextRun, locale)}</em>
</div>
<TaskActionButtons
className="task-row-actions"
onTaskAction={onTaskAction}
task={task}
taskActionKey={taskActionKey}
t={t}
/>
</div>
</div>
<details className="task-row-details">
<summary>{t.tasks.details}</summary>
<div className="task-row-details-body">
<TaskTimeGrid locale={locale} t={t} task={task} />
<TaskSuspendedUntil locale={locale} t={t} task={task} />
<TaskResult task={task} t={t} />
<TaskPromptBlock task={task} t={t} />
<TaskEditForm
onTaskUpdate={onTaskUpdate}
task={task}
taskActionKey={taskActionKey}
t={t}
/>
</div>
</details>
</article>
);
}
function TaskGroupBody({
group,
locale,
onTaskAction,
onTaskUpdate,
taskActionKey,
t,
}: TaskGroupSectionProps) {
if (group.tasks.length === 0) {
return <div className="task-group-empty">{t.tasks.groupEmpty}</div>;
}
return (
<div className="task-list">
{group.tasks.map((task) => (
<TaskCard
groupKey={group.key}
key={task.id}
locale={locale}
onTaskAction={onTaskAction}
onTaskUpdate={onTaskUpdate}
task={task}
taskActionKey={taskActionKey}
t={t}
/>
))}
</div>
);
}
function TaskGroupHead({
countLabel,
group,
label,
}: {
countLabel: string;
group: TaskGroup;
label: string;
}) {
return (
<div className="task-group-head">
<strong>
{label} · {group.tasks.length} {countLabel}
</strong>
</div>
);
}
function TaskGroupSection({
group,
locale,
onTaskAction,
onTaskUpdate,
showHead,
taskActionKey,
t,
}: TaskGroupSectionProps & { showHead: boolean }) {
if (group.tasks.length === 0) return null;
const label = t.tasks.groups[group.key];
const body = (
<TaskGroupBody
group={group}
locale={locale}
onTaskAction={onTaskAction}
onTaskUpdate={onTaskUpdate}
taskActionKey={taskActionKey}
t={t}
/>
);
if (group.key === 'completed') {
return (
<details className="task-group task-group-completed">
<summary className="task-group-head">
<strong>{label}</strong>
<span className={`pill pill-${group.key}`}>{group.tasks.length}</span>
</summary>
{body}
</details>
);
}
if (!showHead) {
return (
<section className={`task-group task-group-${group.key}`}>{body}</section>
);
}
return (
<section className={`task-group task-group-${group.key}`}>
<TaskGroupHead countLabel={t.tasks.count} group={group} label={label} />
{body}
</section>
);
}
function buildFilterCounts(groups: TaskGroup[]): Record<TaskFilterKey, number> {
const counts: Record<TaskFilterKey, number> = {
all: 0,
watchers: 0,
scheduled: 0,
paused: 0,
completed: 0,
};
for (const group of groups) {
counts[group.key] = group.tasks.length;
counts.all += group.tasks.length;
}
return counts;
}
export function TaskPanel({
tasks,
rooms,
locale,
onTaskAction,
onTaskCreate,
onTaskUpdate,
taskActionKey,
t,
}: TaskPanelProps) {
const taskGroups = useMemo(() => buildTaskGroups(tasks), [tasks]);
const taskSummary = useMemo(() => buildTaskSummary(tasks), [tasks]);
const filterCounts = useMemo(
() => buildFilterCounts(taskGroups),
[taskGroups],
);
const [activeFilter, setActiveFilter] = useState<TaskFilterKey>('all');
const visibleGroups = useMemo(() => {
if (activeFilter === 'all') {
return taskGroups.filter((group) => group.tasks.length > 0);
}
return taskGroups.filter((group) => group.key === activeFilter);
}, [activeFilter, taskGroups]);
return (
<div className="task-board" aria-label={t.tasks.cardsAria}>
<TaskSummaryBar locale={locale} summary={taskSummary} t={t} />
<TaskFilterTabs
activeFilter={activeFilter}
counts={filterCounts}
onSelect={setActiveFilter}
t={t}
/>
<TaskCreateForm
onTaskCreate={onTaskCreate}
rooms={rooms}
taskActionKey={taskActionKey}
t={t}
/>
{tasks.length === 0 ? <EmptyState>{t.tasks.empty}</EmptyState> : null}
{tasks.length > 0 && visibleGroups.length === 0 ? (
<EmptyState>{t.tasks.groupEmpty}</EmptyState>
) : null}
<div className="task-lanes">
{visibleGroups.map((group) => (
<TaskGroupSection
group={group}
key={group.key}
locale={locale}
onTaskAction={onTaskAction}
onTaskUpdate={onTaskUpdate}
showHead={activeFilter === 'all'}
taskActionKey={taskActionKey}
t={t}
/>
))}
</div>
</div>
);
}

View File

@@ -0,0 +1,71 @@
import { createElement } from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { describe, expect, it } from 'vitest';
import type { DashboardOverview } from './api';
import { messages } from './i18n';
import { UsagePanel, type UsagePanelProps } from './UsagePanel';
const t = messages.en;
function overview(rows: DashboardOverview['usage']['rows']): DashboardOverview {
return {
generatedAt: '2026-04-28T04:00:00.000Z',
inbox: [],
operations: { serviceRestarts: [] },
rooms: { active: 0, inactive: 0, total: 0, waiting: 0 },
services: [],
tasks: {
active: 0,
completed: 0,
paused: 0,
total: 0,
watchers: { active: 0, completed: 0, paused: 0 },
},
usage: { fetchedAt: '2026-04-28T04:00:00.000Z', rows },
};
}
const baseProps: UsagePanelProps = {
overview: overview([
{
d7pct: 88,
d7reset: '2d',
h5pct: 52,
h5reset: '1h',
name: '*Claude max',
},
{
d7pct: 12,
d7reset: '',
h5pct: 45,
h5reset: '3h',
name: 'codex-pro mid',
},
]),
t,
};
describe('UsagePanel', () => {
it('renders grouped usage rows, quota remaining, and risk labels', () => {
const html = renderToStaticMarkup(createElement(UsagePanel, baseProps));
expect(html).toContain(t.usage.groupPrimary);
expect(html).toContain(t.usage.groupCodex);
expect(html).toContain('Claude');
expect(html).toContain('codex-pro');
expect(html).toContain(t.usage.inUse);
expect(html).toContain(t.usage.risk.critical);
expect(html).toContain('12%');
expect(html).toContain('48%');
expect(html).toContain('10%/h');
});
it('renders an empty state without usage rows', () => {
const html = renderToStaticMarkup(
createElement(UsagePanel, { ...baseProps, overview: overview([]) }),
);
expect(html).toContain(t.usage.empty);
});
});

View File

@@ -0,0 +1,259 @@
import { useMemo } from 'react';
import type { DashboardOverview } from './api';
import { EmptyState } from './EmptyState';
import type { Messages } from './i18n';
export type UsageRow = DashboardOverview['usage']['rows'][number];
type RiskLevel = 'ok' | 'warn' | 'critical';
type UsageGroup = 'primary' | 'codex';
type UsageLimitWindow = 'h5' | 'd7';
export interface UsagePanelProps {
overview: DashboardOverview;
t: Messages;
}
function formatPct(value: number): string {
if (value < 0) return '-';
return `${Math.round(value)}%`;
}
function usagePeak(row: UsageRow): number {
return Math.max(row.h5pct, row.d7pct);
}
function usageLimitWindow(row: UsageRow): UsageLimitWindow {
return row.d7pct >= row.h5pct ? 'd7' : 'h5';
}
function usageWindowRemaining(
row: UsageRow,
window: UsageLimitWindow,
): number | null {
const pct = window === 'h5' ? row.h5pct : row.d7pct;
if (pct < 0) return null;
return Math.max(0, 100 - pct);
}
function usageRiskLevel(row: UsageRow): RiskLevel {
const peak = usagePeak(row);
if (peak >= 85) return 'critical';
if (peak >= 65) return 'warn';
return 'ok';
}
function usageActive(row: UsageRow): boolean {
return row.name.includes('*');
}
function usageLimited(row: UsageRow): boolean {
return row.name.includes('!');
}
function usageNameParts(row: UsageRow): {
account: string;
plan: string | null;
} {
const cleaned = row.name.replace(/[*!]/g, '').replace(/\s+/g, ' ').trim();
const parts = cleaned.split(' ');
const plan = parts.at(-1) ?? null;
if (plan && ['max', 'mid', 'pro', 'team'].includes(plan.toLowerCase())) {
return { account: parts.slice(0, -1).join(' ') || cleaned, plan };
}
return { account: cleaned, plan: null };
}
function usageWindowReset(row: UsageRow, window: UsageLimitWindow): string {
return (window === 'd7' ? row.d7reset : row.h5reset).trim();
}
function usageBurnRate(row: UsageRow): number | null {
if (row.h5pct < 0) return null;
return row.h5pct / 5;
}
function usageSpeedLevel(rate: number | null): RiskLevel {
if (rate === null) return 'ok';
if (rate >= 12) return 'critical';
if (rate >= 7) return 'warn';
return 'ok';
}
function formatUsageRate(rate: number | null): string {
if (rate === null) return '-';
if (rate > 0 && rate < 1) return '<1%/h';
return `${Math.round(rate)}%/h`;
}
function usageGroup(row: UsageRow): UsageGroup {
return row.name.toLowerCase().startsWith('codex') ? 'codex' : 'primary';
}
function UsageQuotaMeter({
row,
rowName,
window,
t,
}: {
row: UsageRow;
rowName: string;
window: UsageLimitWindow;
t: Messages;
}) {
const remaining = usageWindowRemaining(row, window);
const reset = usageWindowReset(row, window);
const tightest = usageLimitWindow(row) === window;
const label = t.usage.quota[window];
return (
<div className={`usage-quota ${tightest ? 'usage-quota-tight' : ''}`}>
<div>
<span>{label}</span>
<strong>{remaining === null ? '-' : formatPct(remaining)}</strong>
</div>
<progress
aria-label={`${rowName} ${label} ${
remaining === null ? '-' : formatPct(remaining)
}`}
max={100}
value={remaining ?? 0}
/>
<small>{reset ? `${t.usage.reset} ${reset}` : t.usage.noReset}</small>
</div>
);
}
function UsageSpeed({ row, t }: { row: UsageRow; t: Messages }) {
const rate = usageBurnRate(row);
const level = usageSpeedLevel(rate);
return (
<div className={`usage-speed usage-speed-${level}`}>
<span>{t.usage.speed}</span>
<strong>{formatUsageRate(rate)}</strong>
<small>{t.usage.speedLabel[level]}</small>
</div>
);
}
export function UsagePanel({ overview, t }: UsagePanelProps) {
const rows = useMemo(
() =>
Array.from(overview.usage.rows).sort((a, b) => {
if (usageActive(a) !== usageActive(b)) return usageActive(a) ? -1 : 1;
return usagePeak(b) - usagePeak(a);
}),
[overview.usage.rows],
);
const watched = rows.filter((row) => usagePeak(row) >= 65).length;
if (rows.length === 0) {
return <EmptyState>{t.usage.empty}</EmptyState>;
}
const activeRows = rows.filter(usageActive);
const focusRows = activeRows.length > 0 ? activeRows : rows.slice(0, 1);
const focusLabel = activeRows.length > 0 ? t.usage.current : t.usage.tightest;
const focusValue = focusRows
.map((row) => {
const { account } = usageNameParts(row);
const h5Remaining = usageWindowRemaining(row, 'h5');
const d7Remaining = usageWindowRemaining(row, 'd7');
return `${account} ${t.usage.quota.h5} ${
h5Remaining === null ? '-' : formatPct(h5Remaining)
} · ${t.usage.quota.d7} ${
d7Remaining === null ? '-' : formatPct(d7Remaining)
}`;
})
.join(' · ');
const groups = [
{
key: 'primary' as const,
label: t.usage.groupPrimary,
rows: rows.filter((row) => usageGroup(row) === 'primary'),
},
{
key: 'codex' as const,
label: t.usage.groupCodex,
rows: rows.filter((row) => usageGroup(row) === 'codex'),
},
].filter((group) => group.rows.length > 0);
return (
<div className="usage-dashboard">
<div className="usage-summary">
<div>
<span>{focusLabel}</span>
<strong>{focusValue}</strong>
</div>
<div>
<span>{t.usage.watch}</span>
<strong>{watched}</strong>
</div>
</div>
<table className="usage-matrix" aria-label={t.panels.usage}>
<thead>
<tr className="usage-matrix-head">
<th scope="col">{t.usage.usage}</th>
<th scope="col">{t.usage.quota.h5}</th>
<th scope="col">{t.usage.quota.d7}</th>
<th scope="col">{t.usage.speed}</th>
</tr>
</thead>
{groups.map((group) => (
<tbody className="usage-group" key={group.key}>
<tr className="usage-group-label">
<th colSpan={4} scope="colgroup">
{group.label}
</th>
</tr>
{group.rows.map((row) => {
const risk = usageRiskLevel(row);
const { account, plan } = usageNameParts(row);
return (
<tr className={`usage-row usage-${risk}`} key={row.name}>
<th className="usage-account" scope="row">
<strong>{account}</strong>
<div>
{usageActive(row) ? (
<span className="pill pill-info">{t.usage.inUse}</span>
) : null}
{plan ? <span className="mono-chip">{plan}</span> : null}
{usageLimited(row) || risk !== 'ok' ? (
<span className={`pill pill-${risk}`}>
{t.usage.risk[risk]}
</span>
) : null}
</div>
</th>
<td>
<UsageQuotaMeter
row={row}
rowName={account}
window="h5"
t={t}
/>
</td>
<td>
<UsageQuotaMeter
row={row}
rowName={account}
window="d7"
t={t}
/>
</td>
<td>
<UsageSpeed row={row} t={t} />
</td>
</tr>
);
})}
</tbody>
))}
</table>
</div>
);
}

View File

@@ -35,6 +35,44 @@ export interface DashboardOverview {
}>;
fetchedAt: string | null;
};
operations?: {
serviceRestarts: DashboardServiceRestart[];
};
inbox: Array<{
id: string;
groupKey: string;
kind:
| 'pending-room'
| 'reviewer-request'
| 'approval'
| 'arbiter-request'
| 'ci-failure'
| 'mention';
severity: 'info' | 'warn' | 'error';
title: string;
summary: string;
occurredAt: string;
lastOccurredAt: string;
createdAt: string;
occurrences: number;
source: 'status-snapshot' | 'paired-task' | 'scheduled-task';
roomJid?: string;
roomName?: string;
groupFolder?: string;
serviceId?: string;
taskId?: string;
taskStatus?: string;
}>;
}
export interface DashboardServiceRestart {
id: string;
target: 'stack';
requestedAt: string;
completedAt: string | null;
status: 'running' | 'success' | 'failed';
services: string[];
error?: string;
}
export interface StatusSnapshot {
@@ -54,6 +92,69 @@ export interface StatusSnapshot {
}>;
}
export interface DashboardRoomActivity {
serviceId: string;
jid: string;
name: string;
folder: string;
agentType: string;
status: 'processing' | 'waiting' | 'inactive';
elapsedMs: number | null;
pendingMessages: boolean;
pendingTasks: number;
messages: Array<{
id: string;
sender: string;
senderName: string;
content: string;
attachments?: Array<{
path: string;
name?: string;
mime?: string;
}>;
timestamp: string;
isFromMe: boolean;
isBotMessage: boolean;
sourceKind: string;
}>;
pairedTask: {
id: string;
title: string | null;
status: string;
roundTripCount: number;
updatedAt: string;
currentTurn: {
turnId: string;
role: string;
intentKind: string;
state: string;
attemptNo: number;
executorServiceId: string | null;
executorAgentType: string | null;
activeRunId: string | null;
createdAt: string;
updatedAt: string;
completedAt: string | null;
lastError: string | null;
progressText: string | null;
progressUpdatedAt: string | null;
} | null;
outputs: Array<{
id: number;
turnNumber: number;
role: string;
verdict: string | null;
createdAt: string;
outputText: string;
attachments?: Array<{
path: string;
name?: string;
mime?: string;
}>;
}>;
} | null;
}
export interface DashboardTask {
id: string;
groupFolder: string;
@@ -75,6 +176,27 @@ export interface DashboardTask {
isWatcher: boolean;
}
export type DashboardTaskAction = 'pause' | 'resume' | 'cancel';
export type DashboardInboxAction = 'run' | 'decline' | 'dismiss';
export type DashboardServiceAction = 'restart';
export type DashboardTaskScheduleType = 'cron' | 'interval' | 'once';
export type DashboardTaskContextMode = 'group' | 'isolated';
export interface CreateScheduledTaskInput {
roomJid: string;
prompt: string;
scheduleType: DashboardTaskScheduleType;
scheduleValue: string;
contextMode: DashboardTaskContextMode;
requestId?: string;
}
export interface UpdateScheduledTaskInput {
prompt?: string;
scheduleType: DashboardTaskScheduleType;
scheduleValue: string;
}
async function fetchJson<T>(path: string): Promise<T> {
const response = await fetch(path, {
headers: { accept: 'application/json' },
@@ -85,6 +207,28 @@ async function fetchJson<T>(path: string): Promise<T> {
return (await response.json()) as T;
}
async function postJson<T>(path: string, body: unknown): Promise<T> {
const response = await fetch(path, {
method: 'POST',
headers: {
accept: 'application/json',
'content-type': 'application/json',
},
body: JSON.stringify(body),
});
if (!response.ok) {
let message = `${path} failed: ${response.status}`;
try {
const payload = (await response.json()) as { error?: string };
if (payload.error) message = payload.error;
} catch {
// Keep the status-based message when the server body is not JSON.
}
throw new Error(message);
}
return (await response.json()) as T;
}
export async function fetchDashboardData(): Promise<{
overview: DashboardOverview;
snapshots: StatusSnapshot[];
@@ -98,3 +242,592 @@ export async function fetchDashboardData(): Promise<{
return { overview, snapshots, tasks };
}
export async function fetchRoomTimeline(
roomJid: string,
): Promise<DashboardRoomActivity> {
return fetchJson<DashboardRoomActivity>(
`/api/rooms/${encodeURIComponent(roomJid)}/timeline`,
);
}
export async function fetchRoomsTimelineBatch(): Promise<
Record<string, DashboardRoomActivity>
> {
return fetchJson<Record<string, DashboardRoomActivity>>(
'/api/rooms-timeline',
);
}
export async function runScheduledTaskAction(
taskId: string,
action: DashboardTaskAction,
): Promise<{
ok: true;
id?: string;
deleted?: boolean;
task?: DashboardTask | null;
}> {
return postJson(`/api/tasks/${encodeURIComponent(taskId)}/actions`, {
action,
});
}
export async function createScheduledTask(
input: CreateScheduledTaskInput,
): Promise<{ ok: true; task: DashboardTask | null }> {
return postJson('/api/tasks', input);
}
export async function updateScheduledTask(
taskId: string,
input: UpdateScheduledTaskInput,
): Promise<{ ok: true; task: DashboardTask | null }> {
const response = await fetch(`/api/tasks/${encodeURIComponent(taskId)}`, {
method: 'PATCH',
headers: {
accept: 'application/json',
'content-type': 'application/json',
},
body: JSON.stringify(input),
});
if (!response.ok) {
let message = `/api/tasks/${taskId} failed: ${response.status}`;
try {
const payload = (await response.json()) as { error?: string };
if (payload.error) message = payload.error;
} catch {
// Keep the status-based message when the server body is not JSON.
}
throw new Error(message);
}
return (await response.json()) as { ok: true; task: DashboardTask | null };
}
export async function runInboxAction(
inboxId: string,
action: DashboardInboxAction,
options: { requestId?: string; lastOccurredAt?: string } = {},
): Promise<{
ok: true;
id: string;
taskId?: string;
intentKind?: string;
status?: string;
queued?: boolean;
dismissed?: boolean;
duplicate?: boolean;
}> {
return postJson(`/api/inbox/${encodeURIComponent(inboxId)}/actions`, {
action,
lastOccurredAt: options.lastOccurredAt,
requestId: options.requestId,
});
}
export async function runServiceAction(
serviceId: 'stack',
action: DashboardServiceAction,
options: { requestId?: string } = {},
): Promise<{
ok: true;
duplicate?: boolean;
restart: DashboardServiceRestart;
}> {
return postJson(`/api/services/${encodeURIComponent(serviceId)}/actions`, {
action,
requestId: options.requestId,
});
}
export async function sendRoomMessage(
roomJid: string,
text: string,
requestId: string,
nickname?: string | null,
): Promise<{ ok: true; id: string; queued: boolean }> {
return postJson(`/api/rooms/${encodeURIComponent(roomJid)}/messages`, {
requestId,
text,
nickname: nickname ?? undefined,
});
}
export interface ClaudeAccountSummary {
index: number;
expiresAt: number | null;
scopes: string[];
subscriptionType?: string;
rateLimitTier?: string;
exists: boolean;
}
export interface CodexRateLimitWindowSummary {
limitWindowSeconds: number | null;
resetAfterSeconds: number | null;
resetAt: string | null;
usedPercent: number | null;
}
export interface CodexRateLimitSummary {
allowed: boolean | null;
limitReached: boolean | null;
primaryWindow: CodexRateLimitWindowSummary | null;
secondaryWindow: CodexRateLimitWindowSummary | null;
}
export interface CodexAdditionalRateLimitSummary {
limitName: string | null;
meteredFeature: string | null;
rateLimit: CodexRateLimitSummary | null;
}
export interface CodexCreditsSummary {
hasCredits: boolean | null;
overageLimitReached: boolean | null;
unlimited: boolean | null;
}
export interface CodexSpendControlSummary {
reached: boolean | null;
}
export interface CodexLiveStatusSummary {
checkedAt: string;
source: 'wham/usage';
planType: string | null;
email: string | null;
rateLimit: CodexRateLimitSummary | null;
rateLimitReachedType: string | null;
additionalRateLimits: CodexAdditionalRateLimitSummary[];
credits: CodexCreditsSummary | null;
spendControl: CodexSpendControlSummary | null;
}
export interface CodexAccountSummary {
index: number;
accountId: string | null;
email: string | null;
planType: string | null;
subscriptionUntil: string | null;
subscriptionLastChecked: string | null;
subscriptionSource: 'jwt-cache' | null;
liveStatus: CodexLiveStatusSummary | null;
exists: boolean;
}
export interface ModelRoleConfig {
model: string;
effort: string;
}
export interface ModelAgentTypes {
owner: 'claude-code' | 'codex';
reviewer: 'claude-code' | 'codex';
arbiter: 'claude-code' | 'codex' | null;
}
export interface ModelConfigSnapshot {
owner: ModelRoleConfig;
reviewer: ModelRoleConfig;
arbiter: ModelRoleConfig;
agentTypes: ModelAgentTypes;
}
export interface FastModeSnapshot {
codex: boolean;
claude: boolean;
}
export interface CodexFeatureSnapshot {
goals: boolean;
}
export interface RuntimePathSnapshot {
label: string;
path: string;
exists: boolean;
}
export interface RuntimeSkillSummary {
name: string;
description: string | null;
path: string;
}
export interface RuntimeSkillDirSnapshot extends RuntimePathSnapshot {
count: number;
skills: RuntimeSkillSummary[];
}
export interface RuntimeMcpSnapshot {
configPath: RuntimePathSnapshot;
ejclawConfigured: boolean;
serverCount: number;
}
export interface RuntimeAgentInventory {
configFiles: RuntimePathSnapshot[];
skillDirs: RuntimeSkillDirSnapshot[];
mcp: RuntimeMcpSnapshot;
}
export interface RuntimeInventorySnapshot {
generatedAt: string;
projectRoot: string;
dataDir: string;
service: {
id: string;
sessionScope: string;
agentType: string;
};
codex: RuntimeAgentInventory;
claude: RuntimeAgentInventory;
ejclaw: {
runnerSkillDir: RuntimeSkillDirSnapshot;
mcpServer: RuntimePathSnapshot;
};
}
export type RoomSkillScope = 'codex-user' | 'claude-user' | 'runner';
export interface RoomSkillCatalogItem {
id: string;
scope: RoomSkillScope;
name: string;
displayName: string;
description: string | null;
path: string;
agentTypes: Array<'claude-code' | 'codex'>;
}
export interface RoomSkillAgentPolicy {
agentType: 'claude-code' | 'codex';
mode: 'all-enabled' | 'custom';
availableSkillIds: string[];
disabledSkillIds: string[];
explicitEnabledSkillIds: string[];
effectiveEnabledSkillIds: string[];
}
export interface RoomSkillPolicyRoom {
jid: string;
name: string;
folder: string;
roomMode?: 'single' | 'tribunal';
agents: RoomSkillAgentPolicy[];
}
export interface RoomSkillSettingsSnapshot {
generatedAt: string;
catalog: RoomSkillCatalogItem[];
rooms: RoomSkillPolicyRoom[];
}
export interface RoomSkillSettingUpdateInput {
roomJid: string;
agentType: 'claude-code' | 'codex';
skillId: string;
enabled: boolean;
}
export interface MoaReferenceStatus {
model: string;
checkedAt: string;
ok: boolean;
error: string | null;
responseLength?: number;
}
export interface MoaModelSettingsSnapshot {
name: string;
enabled: boolean;
model: string;
baseUrl: string;
apiFormat: 'openai' | 'anthropic';
apiKeyConfigured: boolean;
lastStatus: MoaReferenceStatus | null;
}
export interface MoaSettingsSnapshot {
enabled: boolean;
referenceModels: string[];
models: MoaModelSettingsSnapshot[];
}
export async function fetchAccounts(): Promise<{
claude: ClaudeAccountSummary[];
codex: CodexAccountSummary[];
codexCurrentIndex?: number;
}> {
return fetchJson('/api/settings/accounts');
}
export async function fetchModelConfig(): Promise<ModelConfigSnapshot> {
return fetchJson('/api/settings/models');
}
export async function updateModels(
input: Partial<{
owner: Partial<ModelRoleConfig>;
reviewer: Partial<ModelRoleConfig>;
arbiter: Partial<ModelRoleConfig>;
}>,
): Promise<ModelConfigSnapshot> {
const response = await fetch('/api/settings/models', {
method: 'PUT',
headers: {
accept: 'application/json',
'content-type': 'application/json',
},
body: JSON.stringify(input),
});
if (!response.ok) {
let msg = `update models failed: ${response.status}`;
try {
const payload = (await response.json()) as { error?: string };
if (payload.error) msg = payload.error;
} catch {
/* ignore */
}
throw new Error(msg);
}
return (await response.json()) as ModelConfigSnapshot;
}
export async function refreshCodexAccount(
index: number,
): Promise<CodexAccountSummary> {
const response = await fetch(
`/api/settings/accounts/codex/${index}/refresh`,
{ method: 'POST' },
);
if (!response.ok) {
let msg = `refresh failed: ${response.status}`;
try {
const payload = (await response.json()) as { error?: string };
if (payload.error) msg = payload.error;
} catch {
/* ignore */
}
throw new Error(msg);
}
const json = (await response.json()) as { account: CodexAccountSummary };
return json.account;
}
export async function refreshAllCodexAccounts(): Promise<{
refreshed: number[];
failed: Array<{ index: number; error: string }>;
}> {
const response = await fetch('/api/settings/accounts/codex/refresh-all', {
method: 'POST',
});
if (!response.ok) {
throw new Error(`refresh-all failed: ${response.status}`);
}
return (await response.json()) as {
refreshed: number[];
failed: Array<{ index: number; error: string }>;
};
}
export async function setCurrentCodexAccount(
index: number,
): Promise<{ codexCurrentIndex: number }> {
const response = await fetch('/api/settings/accounts/codex/current', {
method: 'PUT',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ index }),
});
if (!response.ok) {
let msg = `switch failed: ${response.status}`;
try {
const payload = (await response.json()) as { error?: string };
if (payload.error) msg = payload.error;
} catch {
/* ignore */
}
throw new Error(msg);
}
return (await response.json()) as { codexCurrentIndex: number };
}
export async function fetchFastMode(): Promise<FastModeSnapshot> {
return fetchJson('/api/settings/fast-mode');
}
export async function updateFastMode(
input: Partial<FastModeSnapshot>,
): Promise<FastModeSnapshot> {
const response = await fetch('/api/settings/fast-mode', {
method: 'PUT',
headers: {
accept: 'application/json',
'content-type': 'application/json',
},
body: JSON.stringify(input),
});
if (!response.ok) {
let msg = `update fast mode failed: ${response.status}`;
try {
const payload = (await response.json()) as { error?: string };
if (payload.error) msg = payload.error;
} catch {
/* ignore */
}
throw new Error(msg);
}
return (await response.json()) as FastModeSnapshot;
}
export async function fetchCodexFeatures(): Promise<CodexFeatureSnapshot> {
return fetchJson('/api/settings/codex-features');
}
export async function fetchRuntimeInventory(): Promise<RuntimeInventorySnapshot> {
return fetchJson('/api/settings/runtime-inventory');
}
export async function fetchRoomSkillSettings(): Promise<RoomSkillSettingsSnapshot> {
return fetchJson('/api/settings/room-skills');
}
export async function updateRoomSkillSetting(
input: RoomSkillSettingUpdateInput,
): Promise<RoomSkillSettingsSnapshot> {
const response = await fetch('/api/settings/room-skills', {
method: 'PATCH',
headers: {
accept: 'application/json',
'content-type': 'application/json',
},
body: JSON.stringify(input),
});
if (!response.ok) {
let msg = `update room skill failed: ${response.status}`;
try {
const payload = (await response.json()) as { error?: string };
if (payload.error) msg = payload.error;
} catch {
/* ignore */
}
throw new Error(msg);
}
return (await response.json()) as RoomSkillSettingsSnapshot;
}
export async function updateCodexFeatures(
input: Partial<CodexFeatureSnapshot>,
): Promise<CodexFeatureSnapshot> {
const response = await fetch('/api/settings/codex-features', {
method: 'PUT',
headers: {
accept: 'application/json',
'content-type': 'application/json',
},
body: JSON.stringify(input),
});
if (!response.ok) {
let msg = `update codex features failed: ${response.status}`;
try {
const payload = (await response.json()) as { error?: string };
if (payload.error) msg = payload.error;
} catch {
/* ignore */
}
throw new Error(msg);
}
return (await response.json()) as CodexFeatureSnapshot;
}
export async function fetchMoaSettings(): Promise<MoaSettingsSnapshot> {
return fetchJson('/api/settings/moa');
}
export async function updateMoaSettings(input: {
enabled?: boolean;
models?: Array<{
name: string;
enabled?: boolean;
model?: string;
baseUrl?: string;
apiFormat?: 'openai' | 'anthropic';
apiKey?: string;
}>;
}): Promise<MoaSettingsSnapshot> {
const response = await fetch('/api/settings/moa', {
method: 'PUT',
headers: {
accept: 'application/json',
'content-type': 'application/json',
},
body: JSON.stringify(input),
});
if (!response.ok) {
let msg = `update MoA settings failed: ${response.status}`;
try {
const payload = (await response.json()) as { error?: string };
if (payload.error) msg = payload.error;
} catch {
/* ignore */
}
throw new Error(msg);
}
return (await response.json()) as MoaSettingsSnapshot;
}
export async function checkMoaModel(name: string): Promise<MoaReferenceStatus> {
const response = await fetch('/api/settings/moa/check', {
method: 'POST',
headers: {
accept: 'application/json',
'content-type': 'application/json',
},
body: JSON.stringify({ name }),
});
if (!response.ok) {
let msg = `check MoA model failed: ${response.status}`;
try {
const payload = (await response.json()) as { error?: string };
if (payload.error) msg = payload.error;
} catch {
/* ignore */
}
throw new Error(msg);
}
const payload = (await response.json()) as {
status: MoaReferenceStatus;
};
return payload.status;
}
export async function deleteAccount(
provider: 'claude' | 'codex',
index: number,
): Promise<{ ok: true; provider: string; index: number }> {
const response = await fetch(`/api/settings/accounts/${provider}/${index}`, {
method: 'DELETE',
});
if (!response.ok) {
let msg = `delete account failed: ${response.status}`;
try {
const payload = (await response.json()) as { error?: string };
if (payload.error) msg = payload.error;
} catch {
/* ignore */
}
throw new Error(msg);
}
return (await response.json()) as {
ok: true;
provider: string;
index: number;
};
}
export async function addClaudeAccount(
token: string,
): Promise<{ ok: true; index: number; accountId: string | null }> {
return postJson('/api/settings/accounts/claude', { token });
}

View File

@@ -0,0 +1,120 @@
import {
type CodexLiveStatusSummary,
type CodexRateLimitSummary,
type CodexRateLimitWindowSummary,
} from './api';
export function formatDateTime(iso: string): string | null {
const dt = new Date(iso);
if (Number.isNaN(dt.getTime())) return null;
return dt.toLocaleString('ko-KR', {
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
});
}
export function formatJwtCacheExpiry(
iso: string | null,
): { label: string; cls: string; title: string } | null {
if (!iso) return null;
const dt = new Date(iso);
if (Number.isNaN(dt.getTime())) return null;
const days = (dt.getTime() - Date.now()) / 86400000;
const dateStr = dt.toLocaleDateString('ko-KR', {
year: '2-digit',
month: '2-digit',
day: '2-digit',
});
const suffix =
days < 0 ? `${Math.ceil(-days)}일 전` : `${Math.floor(days)}일 남음`;
return {
label: `JWT 캐시 ${dateStr}`,
cls: 'is-stale',
title: `OpenAI/Auth0 JWT 캐시 만료일입니다. live wham/usage 갱신값이 있으면 이 값은 표시하지 않습니다. (${suffix})`,
};
}
function formatWindowName(seconds: number | null | undefined): string {
if (!seconds || !Number.isFinite(seconds)) return 'limit';
if (seconds % 86400 === 0) return `${Math.round(seconds / 86400)}d`;
if (seconds % 3600 === 0) return `${Math.round(seconds / 3600)}h`;
if (seconds % 60 === 0) return `${Math.round(seconds / 60)}m`;
return `${seconds}s`;
}
function formatPercent(value: number | null | undefined): string {
if (typeof value !== 'number' || !Number.isFinite(value)) return '?%';
return `${Math.round(value)}%`;
}
function formatRateLimitWindow(
window: CodexRateLimitWindowSummary | null,
): string | null {
if (!window) return null;
return `${formatWindowName(window.limitWindowSeconds)} ${formatPercent(window.usedPercent)}`;
}
export function formatLiveStatusBadge(
live: CodexLiveStatusSummary | null,
): { label: string; cls: string; title: string } | null {
if (!live) return null;
const limits = [
live.rateLimit,
...live.additionalRateLimits.map((limit) => limit.rateLimit),
].filter((limit): limit is CodexRateLimitSummary => limit !== null);
const anyReached = limits.some((limit) => limit.limitReached === true);
const anyBlocked = limits.some((limit) => limit.allowed === false);
const checkedAt = formatDateTime(live.checkedAt) ?? live.checkedAt;
if (anyReached || anyBlocked || live.rateLimitReachedType) {
return {
label: 'live 제한 도달',
cls: 'is-expired',
title: `wham/usage live 확인: ${checkedAt}${
live.rateLimitReachedType ? ` · ${live.rateLimitReachedType}` : ''
}`,
};
}
if (live.spendControl?.reached || live.credits?.overageLimitReached) {
return {
label: 'live 지출 제한',
cls: 'is-expired',
title: `wham/usage live 확인: ${checkedAt}`,
};
}
if (limits.some((limit) => limit.allowed === true)) {
return {
label: 'live 사용 가능',
cls: 'is-active',
title: `wham/usage live 확인: ${checkedAt}`,
};
}
return {
label: 'live 확인됨',
cls: 'is-soon',
title: `wham/usage live 확인: ${checkedAt}`,
};
}
export function formatUsageBadge(
live: CodexLiveStatusSummary | null,
): { label: string; title: string } | null {
if (!live?.rateLimit) return null;
const primary = formatRateLimitWindow(live.rateLimit.primaryWindow);
const secondary = formatRateLimitWindow(live.rateLimit.secondaryWindow);
const label = [primary, secondary].filter(Boolean).join(' · ');
if (!label) return null;
const resetParts = [
live.rateLimit.primaryWindow?.resetAt
? `primary reset ${formatDateTime(live.rateLimit.primaryWindow.resetAt) ?? live.rateLimit.primaryWindow.resetAt}`
: null,
live.rateLimit.secondaryWindow?.resetAt
? `secondary reset ${formatDateTime(live.rateLimit.secondaryWindow.resetAt) ?? live.rateLimit.secondaryWindow.resetAt}`
: null,
].filter(Boolean);
return {
label,
title: resetParts.join(' · ') || 'wham/usage live rate_limit',
};
}

View File

@@ -0,0 +1,83 @@
import type { DashboardTask, DashboardTaskAction } from './api';
import { localeTags, type Locale, type Messages } from './i18n';
const SHORT_TIME_FORMAT_OPTIONS = {
hour: '2-digit',
hour12: false,
minute: '2-digit',
} as const;
const MONTH_DAY_TIME_FORMAT_OPTIONS = {
day: 'numeric',
hour: '2-digit',
hour12: false,
minute: '2-digit',
month: 'short',
} as const;
const SHORT_TIME_FORMATTERS: Record<Locale, Intl.DateTimeFormat> = {
en: new Intl.DateTimeFormat(localeTags.en, SHORT_TIME_FORMAT_OPTIONS),
ja: new Intl.DateTimeFormat(localeTags.ja, SHORT_TIME_FORMAT_OPTIONS),
ko: new Intl.DateTimeFormat(localeTags.ko, SHORT_TIME_FORMAT_OPTIONS),
zh: new Intl.DateTimeFormat(localeTags.zh, SHORT_TIME_FORMAT_OPTIONS),
};
const MONTH_DAY_TIME_FORMATTERS: Record<Locale, Intl.DateTimeFormat> = {
en: new Intl.DateTimeFormat(localeTags.en, MONTH_DAY_TIME_FORMAT_OPTIONS),
ja: new Intl.DateTimeFormat(localeTags.ja, MONTH_DAY_TIME_FORMAT_OPTIONS),
ko: new Intl.DateTimeFormat(localeTags.ko, MONTH_DAY_TIME_FORMAT_OPTIONS),
zh: new Intl.DateTimeFormat(localeTags.zh, MONTH_DAY_TIME_FORMAT_OPTIONS),
};
export function formatDate(
value: string | null | undefined,
locale: Locale,
): string {
if (!value) return '-';
const date = new Date(value);
if (Number.isNaN(date.getTime())) return value;
const now = Date.now();
const ageMs = now - date.getTime();
if (ageMs >= 0 && ageMs < 60_000) {
return locale === 'ko'
? '방금'
: locale === 'ja'
? 'たった今'
: locale === 'zh'
? '刚刚'
: 'just now';
}
if (ageMs >= 0 && ageMs < 3_600_000) {
const mins = Math.floor(ageMs / 60_000);
return locale === 'ko'
? `${mins}분 전`
: locale === 'ja'
? `${mins}分前`
: locale === 'zh'
? `${mins} 分钟前`
: `${mins}m ago`;
}
const sameDay = new Date().toDateString() === date.toDateString();
if (sameDay) {
return SHORT_TIME_FORMATTERS[locale].format(date);
}
const time = SHORT_TIME_FORMATTERS[locale].format(date);
if (locale === 'ko')
return `${date.getMonth() + 1}${date.getDate()}${time}`;
if (locale === 'ja')
return `${date.getMonth() + 1}${date.getDate()}${time}`;
if (locale === 'zh')
return `${date.getMonth() + 1}${date.getDate()}${time}`;
return MONTH_DAY_TIME_FORMATTERS[locale].format(date);
}
export function taskActionsFor(task: DashboardTask): DashboardTaskAction[] {
if (task.status === 'active') return ['pause', 'cancel'];
if (task.status === 'paused') return ['resume', 'cancel'];
return [];
}
export function statusLabel(status: string, t: Messages): string {
if (status in t.status) return t.status[status as keyof Messages['status']];
return status;
}

View File

@@ -0,0 +1,293 @@
body:has(.glasses-shell) {
min-width: 0;
overflow: hidden;
background: #000;
}
.glasses-shell {
display: grid;
grid-template-rows: auto auto auto minmax(0, 1fr) auto;
gap: 8px;
width: min(100vw, 600px);
height: min(100vh, 600px);
min-height: 0;
margin: 0 auto;
padding: 14px;
overflow: hidden;
background: #050706;
color: #f4f1e9;
}
.glasses-header,
.glasses-status-row,
.glasses-card-head,
.glasses-actions,
.glasses-footer,
.glasses-tabs,
.glasses-room-strip {
display: flex;
align-items: center;
}
.glasses-header {
justify-content: space-between;
gap: 12px;
}
.glasses-kicker,
.glasses-status-row,
.glasses-card-head small,
.glasses-meta dt,
.glasses-footer {
color: #a8afa6;
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
}
.glasses-header h1 {
margin: 0;
font-size: 26px;
line-height: 1;
}
.glasses-icon-button {
display: inline-flex;
align-items: center;
justify-content: center;
width: 42px;
height: 42px;
padding: 0;
border-radius: 8px;
}
.glasses-status-row {
justify-content: space-between;
gap: 8px;
}
.glasses-status-row strong {
color: #6cb087;
}
.glasses-error {
margin: 0;
padding: 8px 10px;
border: 1px solid rgba(224, 100, 75, 0.45);
border-radius: 8px;
background: rgba(224, 100, 75, 0.12);
color: #ffb4a4;
font-size: 12px;
}
.glasses-tabs {
gap: 6px;
min-width: 0;
}
.glasses-tabs button,
.glasses-actions button,
.glasses-room-strip button {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
min-height: 42px;
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 8px;
background: rgba(255, 255, 255, 0.06);
color: #f4f1e9;
white-space: nowrap;
}
.glasses-tabs button:hover:not(:disabled),
.glasses-actions button:hover:not(:disabled),
.glasses-room-strip button:hover:not(:disabled) {
background: rgba(255, 255, 255, 0.1);
}
.glasses-tabs button {
flex: 1;
}
.glasses-tabs button.is-active,
.glasses-tabs button[aria-pressed='true'],
.glasses-actions button.is-active,
.glasses-actions button[aria-pressed='true'],
.glasses-room-strip button.is-active,
.glasses-room-strip button[aria-pressed='true'] {
border-color: rgba(232, 152, 112, 0.72);
background: rgba(214, 130, 88, 0.2);
color: #fff4ec;
}
.glasses-tabs button.is-active:hover:not(:disabled),
.glasses-tabs button[aria-pressed='true']:hover:not(:disabled),
.glasses-actions button.is-active:hover:not(:disabled),
.glasses-actions button[aria-pressed='true']:hover:not(:disabled),
.glasses-room-strip button.is-active:hover:not(:disabled),
.glasses-room-strip button[aria-pressed='true']:hover:not(:disabled) {
background: rgba(214, 130, 88, 0.26);
}
.glasses-card {
display: grid;
align-content: start;
gap: 10px;
min-height: 0;
overflow: hidden;
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 8px;
background: #111512;
padding: 12px;
}
.glasses-card-head {
justify-content: space-between;
gap: 10px;
}
.glasses-pill {
display: inline-flex;
align-items: center;
max-width: 100%;
min-height: 24px;
padding: 0 8px;
overflow: hidden;
border: 1px solid rgba(255, 255, 255, 0.12);
border-radius: 999px;
background: rgba(255, 255, 255, 0.05);
color: #d7ded2;
font-size: 11px;
font-weight: 700;
text-overflow: ellipsis;
text-transform: uppercase;
white-space: nowrap;
}
.glasses-pill.sev-error {
border-color: rgba(224, 100, 75, 0.5);
color: #ffb4a4;
}
.glasses-pill.sev-warn {
border-color: rgba(212, 160, 74, 0.5);
color: #ffd38b;
}
.glasses-queue-card h2 {
margin: 0;
overflow: hidden;
font-size: 22px;
line-height: 1.08;
text-overflow: ellipsis;
white-space: nowrap;
}
.glasses-queue-card p {
display: -webkit-box;
min-height: 0;
margin: 0;
overflow: hidden;
color: #d2d5ce;
font-size: 14px;
line-height: 1.35;
-webkit-box-orient: vertical;
-webkit-line-clamp: 4;
}
.glasses-meta {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
margin: 0;
}
.glasses-meta div {
min-width: 0;
}
.glasses-meta dt,
.glasses-meta dd {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.glasses-meta dd {
margin: 2px 0 0;
color: #f4f1e9;
font-size: 13px;
}
.glasses-actions {
gap: 6px;
min-width: 0;
}
.glasses-actions button {
flex: 1;
min-width: 0;
padding: 0 10px;
}
.glasses-empty {
display: grid;
place-items: center;
gap: 6px;
min-height: 220px;
color: #a8afa6;
text-align: center;
}
.glasses-empty strong {
color: #f4f1e9;
font-size: 24px;
}
.glasses-voice-card textarea {
width: 100%;
min-height: 126px;
resize: none;
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 8px;
background: rgba(255, 255, 255, 0.04);
color: #f4f1e9;
font: inherit;
font-size: 18px;
line-height: 1.35;
padding: 10px;
}
.glasses-voice-card textarea:focus-visible {
border-color: #d68258;
outline: 2px solid rgba(214, 130, 88, 0.2);
}
.glasses-room-strip {
gap: 6px;
overflow: hidden;
}
.glasses-room-strip button {
min-width: 0;
max-width: 50%;
overflow: hidden;
padding: 0 10px;
text-overflow: ellipsis;
}
.glasses-footer {
min-height: 18px;
justify-content: center;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
@media (max-width: 640px) {
.glasses-shell {
width: 100vw;
height: 100vh;
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,205 @@
import type { Locale } from '../i18n';
export interface RoomMessages {
empty: string;
cardsAria: string;
room: string;
service: string;
agent: string;
status: string;
queue: string;
queueWaitingMessages: string;
tasks: string;
elapsed: string;
activity: string;
loadingActivity: string;
noActivity: string;
task: string;
noTask: string;
currentTurn: string;
noTurn: string;
round: string;
attempt: string;
updated: string;
output: string;
latestOutput: string;
outputHistory: string;
noOutput: string;
recentMessages: string;
messageHistory: string;
noMessages: string;
details: string;
message: string;
messagePlaceholder: string;
send: string;
sending: string;
error: string;
filterAll: string;
sortRecent: string;
sortName: string;
sortQueue: string;
sortLabel: string;
}
export const roomMessages = {
ko: {
empty: '룸 없음.',
cardsAria: '룸 상태 카드',
room: '룸',
service: '서비스',
agent: '에이전트',
status: '상태',
queue: '큐',
queueWaitingMessages: '대기 메시지',
tasks: '태스크',
elapsed: '경과',
activity: '진행',
loadingActivity: '진행 로딩 중',
noActivity: '진행 없음',
task: '태스크',
noTask: '태스크 없음',
currentTurn: '현재 턴',
noTurn: '턴 없음',
round: '라운드',
attempt: '시도',
updated: '갱신',
output: '출력',
latestOutput: '마지막 출력',
outputHistory: '출력 기록',
noOutput: '출력 없음',
recentMessages: '메시지 기록',
messageHistory: '메시지 기록',
noMessages: '메시지 없음',
details: '세부',
message: '메시지',
messagePlaceholder: '요청 입력...',
send: '전송',
sending: '전송 중...',
error: '오류',
filterAll: '전체',
sortRecent: '최근 활동',
sortName: '이름순',
sortQueue: '큐 많은 순',
sortLabel: '정렬',
},
en: {
empty: 'No rooms yet.',
cardsAria: 'Room status cards',
room: 'room',
service: 'service',
agent: 'agent',
status: 'status',
queue: 'queue',
queueWaitingMessages: 'pending messages',
tasks: 'tasks',
elapsed: 'elapsed',
activity: 'activity',
loadingActivity: 'Loading activity',
noActivity: 'No activity',
task: 'task',
noTask: 'No task',
currentTurn: 'Current turn',
noTurn: 'No turn',
round: 'round',
attempt: 'attempt',
updated: 'updated',
output: 'output',
latestOutput: 'latest output',
outputHistory: 'output log',
noOutput: 'No output',
recentMessages: 'Message log',
messageHistory: 'Message log',
noMessages: 'No messages',
details: 'details',
message: 'message',
messagePlaceholder: 'Type request...',
send: 'Send',
sending: 'Sending',
error: 'Error',
filterAll: 'All',
sortRecent: 'Recent activity',
sortName: 'Name',
sortQueue: 'Queue size',
sortLabel: 'Sort',
},
zh: {
empty: '暂无房间。',
cardsAria: '房间状态卡片',
room: '房间',
service: '服务',
agent: '代理',
status: '状态',
queue: '队列',
queueWaitingMessages: '待处理消息',
tasks: '任务',
elapsed: '耗时',
activity: '进展',
loadingActivity: '正在加载进展',
noActivity: '暂无进展',
task: '任务',
noTask: '无任务',
currentTurn: '当前回合',
noTurn: '无回合',
round: '轮次',
attempt: '尝试',
updated: '更新',
output: '输出',
latestOutput: '最新输出',
outputHistory: '输出记录',
noOutput: '暂无输出',
recentMessages: '消息记录',
messageHistory: '消息记录',
noMessages: '暂无消息',
details: '详情',
message: 'Message',
messagePlaceholder: '输入请求...',
send: '发送',
sending: '发送中...',
error: '错误',
filterAll: '全部',
sortRecent: '最近活动',
sortName: '名称',
sortQueue: '队列大小',
sortLabel: '排序',
},
ja: {
empty: 'ルームなし。',
cardsAria: 'ルーム状態カード',
room: 'ルーム',
service: 'サービス',
agent: 'エージェント',
status: '状態',
queue: 'キュー',
queueWaitingMessages: '待機メッセージ',
tasks: 'タスク',
elapsed: '経過',
activity: '進行',
loadingActivity: '進行を読み込み中',
noActivity: '進行なし',
task: 'タスク',
noTask: 'タスクなし',
currentTurn: '現在ターン',
noTurn: 'ターンなし',
round: 'ラウンド',
attempt: '試行',
updated: '更新',
output: '出力',
latestOutput: '最新出力',
outputHistory: '出力履歴',
noOutput: '出力なし',
recentMessages: 'メッセージ履歴',
messageHistory: 'メッセージ履歴',
noMessages: 'メッセージなし',
details: '詳細',
message: 'メッセージ',
messagePlaceholder: '依頼を入力...',
send: '送信',
sending: '送信中...',
error: 'エラー',
filterAll: '全て',
sortRecent: '最近の活動',
sortName: '名前順',
sortQueue: 'キュー順',
sortLabel: '並び替え',
},
} satisfies Record<Locale, RoomMessages>;

View File

@@ -2,6 +2,13 @@ import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import App from './App';
import { GlassesApp } from './GlassesApp';
function isGlassesRoute(): boolean {
const pathname = window.location.pathname.replace(/\/+$/, '') || '/';
const search = new URLSearchParams(window.location.search);
return pathname === '/glasses' || search.get('display') === 'rayban';
}
const root = document.getElementById('root');
@@ -10,7 +17,5 @@ if (!root) {
}
createRoot(root).render(
<StrictMode>
<App />
</StrictMode>,
<StrictMode>{isGlassesRoute() ? <GlassesApp /> : <App />}</StrictMode>,
);

View File

@@ -0,0 +1,26 @@
import { describe, expect, it } from 'vitest';
import { redactSecretsForMarkdown, redactSecretsForPreview } from './redaction';
describe('dashboard redaction', () => {
it('redacts named secret assignments for markdown output', () => {
expect(redactSecretsForMarkdown('OPENAI_API_KEY=sk-testvalue1234')).toBe(
'OPENAI_API_KEY=***',
);
});
it('redacts named secret assignments for compact previews', () => {
expect(redactSecretsForPreview('BOT_TOKEN=xoxb-testvalue1234')).toBe(
'BOT_TOKEN=<redacted>',
);
});
it('redacts standalone secret values consistently', () => {
const raw = 'token sk-1234567890abcdef and ghp_1234567890abcdef';
expect(redactSecretsForMarkdown(raw)).toBe('token *** and ***');
expect(redactSecretsForPreview(raw)).toBe(
'token <redacted-token> and <redacted-token>',
);
});
});

View File

@@ -0,0 +1,17 @@
export const SECRET_ASSIGNMENT_RE =
/\b([A-Z0-9_]*(?:TOKEN|SECRET|PASSWORD|API_KEY|AUTH|PRIVATE_KEY)[A-Z0-9_]*)\s*=\s*([^\s"'`]+)/gi;
export const SECRET_VALUE_RE =
/\b(?:sk-[A-Za-z0-9_-]{12,}|gh[pousr]_[A-Za-z0-9_]{12,}|xox[baprs]-[A-Za-z0-9-]{12,}|eyJ[A-Za-z0-9_-]{20,}\.[A-Za-z0-9_-]{20,}\.[A-Za-z0-9_-]{10,})\b/g;
export function redactSecretsForMarkdown(value: string): string {
return value
.replace(SECRET_ASSIGNMENT_RE, (_m, key) => `${key}=***`)
.replace(SECRET_VALUE_RE, '***');
}
export function redactSecretsForPreview(value: string): string {
return value
.replace(SECRET_ASSIGNMENT_RE, '$1=<redacted>')
.replace(SECRET_VALUE_RE, '<redacted-token>');
}

View File

@@ -0,0 +1,34 @@
import type { Locale } from './i18n';
const KO_ROLE_LABELS: Record<string, string> = {
arbiter: '중재자',
owner: '오너',
reviewer: '리뷰어',
};
const KO_VERDICT_LABELS: Record<string, string> = {
blocked: '차단',
continue: '계속',
done: '완료',
done_with_concerns: '우려 있음',
needs_context: '컨텍스트 필요',
proceed: '진행',
reset: '초기화',
revise: '수정 필요',
step_done: '단계 완료',
task_done: '작업 완료',
};
export function displayRole(
value: string | null | undefined,
locale: Locale,
): string {
const raw = value ?? '';
if (locale !== 'ko') return raw;
return KO_ROLE_LABELS[raw.trim().toLowerCase()] ?? raw;
}
export function displayVerdict(value: string, locale: Locale): string {
if (locale !== 'ko') return value;
return KO_VERDICT_LABELS[value.trim().toLowerCase()] ?? value;
}

View File

@@ -0,0 +1,213 @@
import { describe, expect, it } from 'vitest';
import type { DashboardRoomActivity } from './api';
import { buildRoomThreadEntries, isWatcherRoomMessage } from './roomThread';
type RoomMessage = DashboardRoomActivity['messages'][number];
type RoomOutput = NonNullable<
DashboardRoomActivity['pairedTask']
>['outputs'][number];
function message(overrides: Partial<RoomMessage>): RoomMessage {
return {
id: 'msg-1',
sender: 'user-1',
senderName: '눈쟁이',
content: 'hello',
timestamp: '2026-04-28T01:00:00.000Z',
isFromMe: false,
isBotMessage: false,
sourceKind: 'human',
...overrides,
};
}
function output(overrides: Partial<RoomOutput>): RoomOutput {
return {
id: 1,
turnNumber: 1,
role: 'owner',
verdict: 'task_done',
createdAt: '2026-04-28T01:02:00.000Z',
outputText: 'TASK_DONE\n\nfinal output',
...overrides,
};
}
describe('room thread entries', () => {
it('keeps regular bot messages so Discord-visible outputs appear in rooms', () => {
const entries = buildRoomThreadEntries({
messages: [
message({ id: 'human-1', content: '확인해줘' }),
message({
id: 'bot-1',
sender: 'bot-1',
senderName: '오너',
content: 'TASK_DONE\n\nprod 배포 완료',
timestamp: '2026-04-28T01:01:00.000Z',
isBotMessage: true,
sourceKind: 'bot',
}),
],
outputs: [],
});
expect(entries.map((entry) => entry.id)).toEqual(['human-1', 'bot-1']);
expect(entries.at(-1)).toMatchObject({
senderName: '오너',
content: 'TASK_DONE\n\nprod 배포 완료',
sourceKind: 'bot',
});
});
it('keeps watcher messages out of the main room thread', () => {
const watcher = message({
id: 'watcher-1',
senderName: '리뷰어',
content: 'CI 완료: PR #1 Quality Check',
isBotMessage: true,
sourceKind: 'bot',
});
expect(isWatcherRoomMessage(watcher)).toBe(true);
expect(
buildRoomThreadEntries({ messages: [watcher], outputs: [] }),
).toHaveLength(0);
});
it('deduplicates bot messages that are already present as paired outputs', () => {
const entries = buildRoomThreadEntries({
messages: [
message({
id: 'bot-duplicate',
senderName: '오너',
content: 'TASK_DONE\n\nfinal output',
timestamp: '2026-04-28T01:02:30.000Z',
isBotMessage: true,
sourceKind: 'bot',
}),
],
outputs: [output({ id: 7 })],
});
expect(entries).toHaveLength(1);
expect(entries[0]).toMatchObject({
id: 'out:7',
sourceKind: 'agent_output',
turnNumber: 1,
});
});
it('deduplicates continuation chunks that are contained in a paired output', () => {
const continuation =
'PR 설명에 TaskPanel.tsx + statusLabel 이동+테스트 2개 모두 명시되어 있고 검증도 완료됐습니다.';
const entries = buildRoomThreadEntries({
messages: [
message({
id: 'bot-continuation',
senderName: '리뷰어',
content: continuation,
timestamp: '2026-04-28T01:02:30.000Z',
isBotMessage: true,
sourceKind: 'bot',
}),
],
outputs: [
output({
id: 8,
role: '리뷰어',
outputText: `STEP_DONE\n\n파일 검증 완료\n\n${continuation}\n\n다음 슬라이스 권고`,
}),
],
});
expect(entries).toHaveLength(1);
expect(entries[0]).toMatchObject({
id: 'out:8',
senderName: '리뷰어',
sourceKind: 'agent_output',
});
});
it('merges adjacent bot chunks from the same sender', () => {
const entries = buildRoomThreadEntries({
messages: [
message({
id: 'reviewer-1',
senderName: '리뷰어',
content: 'STEP_DONE — PR #63 검증 완료\n\n파일 검증',
timestamp: '2026-04-28T01:02:00.000Z',
isBotMessage: true,
sourceKind: 'bot',
}),
message({
id: 'reviewer-2',
senderName: '리뷰어',
content: 'PR 설명에 추출 모듈 전부 명시\n\n다음 슬라이스 권고',
timestamp: '2026-04-28T01:02:03.000Z',
isBotMessage: true,
sourceKind: 'bot',
}),
],
outputs: [],
});
expect(entries).toHaveLength(1);
expect(entries[0]).toMatchObject({
id: 'reviewer-1+reviewer-2',
senderName: '리뷰어',
sourceKind: 'bot',
});
expect(entries[0].content).toContain('파일 검증');
expect(entries[0].content).toContain('다음 슬라이스 권고');
});
it('keeps adjacent human messages separate', () => {
const entries = buildRoomThreadEntries({
messages: [
message({
id: 'human-1',
content: '첫 번째 요청',
timestamp: '2026-04-28T01:02:00.000Z',
}),
message({
id: 'human-2',
content: '두 번째 요청',
timestamp: '2026-04-28T01:02:03.000Z',
}),
],
outputs: [],
});
expect(entries.map((entry) => entry.id)).toEqual(['human-1', 'human-2']);
});
it('keeps adjacent bot status outputs separate', () => {
const entries = buildRoomThreadEntries({
messages: [
message({
id: 'reviewer-status-1',
senderName: '리뷰어',
content: 'STEP_DONE — PR #63 검증 완료',
timestamp: '2026-04-28T01:02:00.000Z',
isBotMessage: true,
sourceKind: 'bot',
}),
message({
id: 'reviewer-status-2',
senderName: '리뷰어',
content: 'STEP_DONE — PR #64 검증 완료',
timestamp: '2026-04-28T01:02:03.000Z',
isBotMessage: true,
sourceKind: 'bot',
}),
],
outputs: [],
});
expect(entries.map((entry) => entry.id)).toEqual([
'reviewer-status-1',
'reviewer-status-2',
]);
});
});

View File

@@ -0,0 +1,227 @@
import type { DashboardRoomActivity } from './api';
type RoomMessage = DashboardRoomActivity['messages'][number];
type RoomOutput = NonNullable<
DashboardRoomActivity['pairedTask']
>['outputs'][number];
export type RoomThreadEntry = {
id: string;
senderName: string;
content: string;
attachments?: RoomMessage['attachments'];
timestamp: string;
isFromMe: boolean;
isBotMessage: boolean;
sourceKind: string;
verdict?: string | null;
turnNumber?: number;
};
const WATCHER_RE =
/^\s*(?:CI 완료|Build |Deploy |Lint |Release |\[CI\]|\[Watcher\]|GitHub Actions)/i;
export function isInternalProtocolPayload(
content: string | null | undefined,
): boolean {
if (!content) return false;
if (/<\/?(sub-agent[-\w]*|tool-call|internal)\b/i.test(content)) return true;
if (/"author"\s*:\s*"[^"]+"\s*,\s*"recipient"\s*:\s*"[^"]+"/.test(content)) {
return true;
}
return false;
}
export function isWatcherRoomMessage(message: RoomMessage): boolean {
return message.sourceKind === 'bot' && WATCHER_RE.test(message.content);
}
function isThreadChatMessage(message: RoomMessage): boolean {
if (isInternalProtocolPayload(message.content)) return false;
if (isWatcherRoomMessage(message)) return false;
return (
message.sourceKind === 'human' ||
message.sourceKind === 'ipc_injected_human' ||
message.sourceKind === 'trusted_external_bot' ||
message.sourceKind === 'bot'
);
}
function toMessageEntry(message: RoomMessage): RoomThreadEntry {
return {
id: message.id,
senderName: message.senderName,
content: message.content,
attachments: message.attachments,
timestamp: message.timestamp,
isFromMe: message.isFromMe,
isBotMessage: message.isBotMessage,
sourceKind: message.sourceKind,
};
}
function toOutputEntry(output: RoomOutput): RoomThreadEntry | null {
if (isInternalProtocolPayload(output.outputText)) return null;
return {
id: `out:${output.id}`,
senderName: output.role,
content: output.outputText,
attachments: output.attachments,
timestamp: output.createdAt,
isFromMe: false,
isBotMessage: true,
sourceKind: 'agent_output',
verdict: output.verdict,
turnNumber: output.turnNumber,
};
}
function messageKey(message: { content: string; senderName: string }): string {
return `${message.senderName}\u0001${message.content}`;
}
function normalizeForDedupe(value: string): string {
return value.replace(/\s+/g, ' ').trim();
}
const MIN_SUBSTRING_DEDUPE_LENGTH = 40;
const BOT_CHUNK_MERGE_WINDOW_MS = 15_000;
const STATUS_OUTPUT_PREFIX_RE =
/^(?:STEP_DONE|TASK_DONE|DONE_WITH_CONCERNS|DONE|BLOCKED|NEEDS_CONTEXT)\b/;
function isDuplicateOutputMessage(
message: RoomThreadEntry,
outputEntries: RoomThreadEntry[],
): boolean {
if (message.sourceKind !== 'bot') return false;
const messageText = normalizeForDedupe(message.content);
if (!messageText) return false;
const messageTime = new Date(message.timestamp).getTime();
return outputEntries.some((output) => {
const outputText = normalizeForDedupe(output.content);
if (!outputText) return false;
const contentMatches =
outputText === messageText ||
outputText.startsWith(messageText) ||
messageText.startsWith(outputText) ||
(messageText.length >= MIN_SUBSTRING_DEDUPE_LENGTH &&
outputText.includes(messageText)) ||
(outputText.length >= MIN_SUBSTRING_DEDUPE_LENGTH &&
messageText.includes(outputText));
if (!contentMatches) return false;
const outputTime = new Date(output.timestamp).getTime();
if (!Number.isFinite(messageTime) || !Number.isFinite(outputTime)) {
return true;
}
return Math.abs(outputTime - messageTime) <= 120_000;
});
}
function entryTimeMs(entry: RoomThreadEntry): number | null {
const time = new Date(entry.timestamp).getTime();
return Number.isFinite(time) ? time : null;
}
function shouldMergeAdjacentBotChunk(
previous: RoomThreadEntry,
next: RoomThreadEntry,
): boolean {
if (
previous.sourceKind === 'agent_output' ||
next.sourceKind === 'agent_output'
) {
return false;
}
if (previous.senderName !== next.senderName) return false;
if (previous.sourceKind !== next.sourceKind) return false;
if (!previous.isBotMessage || !next.isBotMessage) return false;
if (STATUS_OUTPUT_PREFIX_RE.test(next.content.trimStart())) return false;
const previousTime = entryTimeMs(previous);
const nextTime = entryTimeMs(next);
if (previousTime === null || nextTime === null) {
return previous.timestamp === next.timestamp;
}
return Math.abs(nextTime - previousTime) <= BOT_CHUNK_MERGE_WINDOW_MS;
}
function mergeEntryContent(left: string, right: string): string {
const trimmedLeft = left.trimEnd();
const trimmedRight = right.trimStart();
if (!trimmedLeft) return trimmedRight;
if (!trimmedRight) return trimmedLeft;
return `${trimmedLeft}\n\n${trimmedRight}`;
}
function mergeAdjacentBotChunks(entries: RoomThreadEntry[]): RoomThreadEntry[] {
const merged: RoomThreadEntry[] = [];
for (const entry of entries) {
const previous = merged.at(-1);
if (previous && shouldMergeAdjacentBotChunk(previous, entry)) {
previous.id = `${previous.id}+${entry.id}`;
previous.content = mergeEntryContent(previous.content, entry.content);
previous.verdict ??= entry.verdict;
previous.turnNumber ??= entry.turnNumber;
continue;
}
merged.push({ ...entry });
}
return merged;
}
function collectOutputEntries(outputs: RoomOutput[]): RoomThreadEntry[] {
const entries: RoomThreadEntry[] = [];
for (const output of outputs) {
const entry = toOutputEntry(output);
if (entry) entries.push(entry);
}
return entries;
}
function collectChatEntries(
messages: RoomMessage[],
outputEntries: RoomThreadEntry[],
): RoomThreadEntry[] {
const entries: RoomThreadEntry[] = [];
for (const message of messages) {
if (!isThreadChatMessage(message)) continue;
const entry = toMessageEntry(message);
if (!isDuplicateOutputMessage(entry, outputEntries)) entries.push(entry);
}
return entries;
}
function collectOptimisticPending(
pendingMessages: RoomMessage[],
confirmedSet: Set<string>,
): RoomThreadEntry[] {
const entries: RoomThreadEntry[] = [];
for (const message of pendingMessages) {
if (confirmedSet.has(messageKey(message))) continue;
if (isInternalProtocolPayload(message.content)) continue;
entries.push(toMessageEntry(message));
}
return entries;
}
export function buildRoomThreadEntries({
messages,
outputs,
pendingMessages = [],
}: {
messages: RoomMessage[];
outputs: RoomOutput[];
pendingMessages?: RoomMessage[];
}): RoomThreadEntry[] {
const confirmedSet = new Set(messages.map(messageKey));
const outputEntries = collectOutputEntries(outputs);
const chatEntries = collectChatEntries(messages, outputEntries);
const optimisticPending = collectOptimisticPending(
pendingMessages,
confirmedSet,
);
const entries = chatEntries
.concat(optimisticPending, outputEntries)
.sort((a, b) => a.timestamp.localeCompare(b.timestamp));
return mergeAdjacentBotChunks(entries);
}

View File

@@ -0,0 +1,44 @@
import { describe, expect, it } from 'vitest';
import {
PRESET_MODELS,
buildModelOptions,
effortValuesForAgent,
formatEffortOption,
isEffortSupported,
isPresetModel,
} from './settings-options';
describe('settings-options effort', () => {
it('limits Claude agents to low through max without xhigh', () => {
expect(effortValuesForAgent('claude-code')).toEqual([
'',
'low',
'medium',
'high',
'max',
]);
expect(isEffortSupported('claude-code', 'xhigh')).toBe(false);
expect(isEffortSupported('claude-code', 'high')).toBe(true);
});
it('allows xhigh for Codex agents', () => {
expect(effortValuesForAgent('codex')).toContain('xhigh');
expect(isEffortSupported('codex', 'xhigh')).toBe(true);
});
it('shows raw effort keys beside localized labels', () => {
expect(formatEffortOption('high', '높음')).toBe('높음 (high)');
expect(formatEffortOption('', '기본값')).toBe('기본값');
});
});
describe('settings-options models', () => {
it('offers Claude Opus 4.8 as the only Claude preset', () => {
expect(PRESET_MODELS.claude).toEqual(['claude-opus-4-8']);
expect(buildModelOptions('')).toContain('claude-opus-4-8');
expect(isPresetModel('claude-opus-4-8')).toBe(true);
expect(isPresetModel('claude-opus-4-7')).toBe(false);
expect(isPresetModel('claude-opus-4-6')).toBe(false);
});
});

View File

@@ -0,0 +1,70 @@
export const PRESET_MODELS = {
codex: ['gpt-5.5', 'gpt-5.5-pro', 'gpt-5.4', 'gpt-5.3-codex'],
claude: ['claude-opus-4-8'],
} as const;
export type AgentType = 'claude-code' | 'codex';
export const CODEX_EFFORT_VALUES = [
'',
'low',
'medium',
'high',
'xhigh',
'max',
] as const;
export const CLAUDE_EFFORT_VALUES = [
'',
'low',
'medium',
'high',
'max',
] as const;
/** @deprecated Use agent-specific lists via effortValuesForAgent */
export const EFFORT_VALUES = CODEX_EFFORT_VALUES;
export type EffortValue = (typeof CODEX_EFFORT_VALUES)[number];
export function effortValuesForAgent(
agentType: AgentType,
): readonly EffortValue[] {
return agentType === 'claude-code'
? CLAUDE_EFFORT_VALUES
: CODEX_EFFORT_VALUES;
}
export function isEffortSupported(
agentType: AgentType | null | undefined,
effort: string,
): boolean {
if (!effort) return true;
if (!agentType) return true;
return effortValuesForAgent(agentType).includes(effort as EffortValue);
}
export function formatEffortOption(
value: EffortValue,
localizedLabel: string,
): string {
if (value === '') return localizedLabel;
return `${localizedLabel} (${value})`;
}
export function buildModelOptions(current: string): string[] {
const presets = [...PRESET_MODELS.codex, ...PRESET_MODELS.claude];
const trimmed = current.trim();
if (!trimmed || presets.includes(trimmed as (typeof presets)[number])) {
return [...presets];
}
return [trimmed, ...presets.filter((model) => model !== trimmed)];
}
export function isPresetModel(model: string): boolean {
const trimmed = model.trim();
return (
(PRESET_MODELS.codex as readonly string[]).includes(trimmed) ||
(PRESET_MODELS.claude as readonly string[]).includes(trimmed)
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,72 @@
import { useCallback, useEffect, useState } from 'react';
import { fetchRoomTimeline, type DashboardRoomActivity } from './api';
export type RoomActivityMap = Record<string, DashboardRoomActivity>;
interface UseSelectedRoomActivityOptions {
active: boolean;
pollMs?: number;
selectedRoomJid: string | null;
}
export function useSelectedRoomActivity({
active,
pollMs = 2000,
selectedRoomJid,
}: UseSelectedRoomActivityOptions): {
refreshRoom: (roomJid: string) => Promise<DashboardRoomActivity>;
roomActivity: RoomActivityMap;
roomActivityLoading: boolean;
} {
const [roomActivity, setRoomActivity] = useState<RoomActivityMap>({});
const [roomActivityLoading, setRoomActivityLoading] = useState(false);
const refreshRoom = useCallback(async (roomJid: string) => {
const activity = await fetchRoomTimeline(roomJid);
setRoomActivity((previous) => ({
...previous,
[roomJid]: activity,
}));
return activity;
}, []);
useEffect(() => {
if (!active || !selectedRoomJid) {
return undefined;
}
let cancelled = false;
let inFlight = false;
let pollIntervalId: number | null = null;
const fetchOnce = async (initial: boolean) => {
if (cancelled || inFlight) return;
inFlight = true;
if (initial) setRoomActivityLoading(true);
try {
await refreshRoom(selectedRoomJid);
} catch {
/* Keep the last good timeline snapshot. */
} finally {
inFlight = false;
if (!cancelled && initial) setRoomActivityLoading(false);
}
};
void fetchOnce(true);
pollIntervalId = window.setInterval(() => void fetchOnce(false), pollMs);
return () => {
cancelled = true;
if (pollIntervalId !== null) window.clearInterval(pollIntervalId);
};
}, [active, pollMs, refreshRoom, selectedRoomJid]);
return {
refreshRoom,
roomActivity,
roomActivityLoading:
active && selectedRoomJid !== null ? roomActivityLoading : false,
};
}

View File

@@ -9,10 +9,10 @@ export default defineConfig({
emptyOutDir: true,
},
server: {
host: '127.0.0.1',
host: process.env.VITE_DEV_HOST ?? '127.0.0.1',
port: 5174,
proxy: {
'/api': 'http://127.0.0.1:8734',
'/api': process.env.VITE_API_TARGET ?? 'http://127.0.0.1:8734',
},
},
});

475
bun.lock
View File

@@ -8,15 +8,20 @@
"@modelcontextprotocol/sdk": "^1.27.1",
"cron-parser": "^5.5.0",
"discord.js": "^14.18.0",
"ejclaw-runners-shared": "file:./runners/shared",
"ejclaw-runners-shared": "workspace:*",
"lucide-react": "^1.11.0",
"pino": "^9.6.0",
"pino-pretty": "^13.0.0",
"react": "^19.2.5",
"react-dom": "^19.2.5",
"react-markdown": "^10.1.0",
"remark-gfm": "^4.0.1",
"yaml": "^2.8.2",
"zod": "^4.3.6",
},
"devDependencies": {
"@axe-core/playwright": "^4.11.3",
"@eslint/js": "^10.0.1",
"@types/better-sqlite3": "^7.6.13",
"@types/bun": "^1.3.11",
"@types/node": "^22.10.0",
@@ -25,21 +30,85 @@
"@vitejs/plugin-react": "^6.0.1",
"@vitest/coverage-v8": "^4.0.18",
"better-sqlite3": "^12.8.0",
"eslint": "^10.2.1",
"globals": "^17.5.0",
"husky": "^9.1.7",
"playwright": "^1.59.1",
"prettier": "^3.8.1",
"typescript": "^5.7.0",
"typescript-eslint": "^8.59.0",
"vite": "^8.0.10",
"vitest": "^4.0.18",
},
},
"runners/agent-runner": {
"name": "ejclaw-agent-runner",
"version": "1.0.0",
"dependencies": {
"@anthropic-ai/claude-agent-sdk": "0.3.153",
"@anthropic-ai/sdk": "0.100.0",
"@modelcontextprotocol/sdk": "1.29.0",
"cron-parser": "^5.0.0",
"ejclaw-runners-shared": "workspace:*",
"zod": "^4.0.0",
},
"devDependencies": {
"@types/node": "^22.10.7",
"typescript": "^5.7.3",
},
},
"runners/codex-runner": {
"name": "ejclaw-codex-runner",
"version": "1.0.0",
"dependencies": {
"@openai/codex": "0.133.0",
"ejclaw-runners-shared": "workspace:*",
},
"devDependencies": {
"@types/node": "^22.10.7",
"typescript": "^5.7.3",
},
},
"runners/shared": {
"name": "ejclaw-runners-shared",
"version": "1.0.0",
"devDependencies": {
"@types/node": "^22.10.7",
"typescript": "^5.7.3",
},
},
},
"packages": {
"@anthropic-ai/claude-agent-sdk": ["@anthropic-ai/claude-agent-sdk@0.3.153", "", { "optionalDependencies": { "@anthropic-ai/claude-agent-sdk-darwin-arm64": "0.3.153", "@anthropic-ai/claude-agent-sdk-darwin-x64": "0.3.153", "@anthropic-ai/claude-agent-sdk-linux-arm64": "0.3.153", "@anthropic-ai/claude-agent-sdk-linux-arm64-musl": "0.3.153", "@anthropic-ai/claude-agent-sdk-linux-x64": "0.3.153", "@anthropic-ai/claude-agent-sdk-linux-x64-musl": "0.3.153", "@anthropic-ai/claude-agent-sdk-win32-arm64": "0.3.153", "@anthropic-ai/claude-agent-sdk-win32-x64": "0.3.153" }, "peerDependencies": { "@anthropic-ai/sdk": ">=0.93.0", "@modelcontextprotocol/sdk": "^1.29.0", "zod": "^4.0.0" } }, "sha512-+WYWQ5ih2dOuiJY4yql5pje3w8iA+aS7eZXJSFo6LZxmEiH4nV4n/fjhJCWPcqyo7TdvhAjS2cPqwIslNw3c7A=="],
"@anthropic-ai/claude-agent-sdk-darwin-arm64": ["@anthropic-ai/claude-agent-sdk-darwin-arm64@0.3.153", "", { "os": "darwin", "cpu": "arm64" }, "sha512-4K2K/n/qikp24ufO38owSEj5RPMWjBmf83BSUxLlzOhOXt7dHXs1CitmY9ZYM58Wmn3qhJs9DaS//ptO1siskQ=="],
"@anthropic-ai/claude-agent-sdk-darwin-x64": ["@anthropic-ai/claude-agent-sdk-darwin-x64@0.3.153", "", { "os": "darwin", "cpu": "x64" }, "sha512-kY5DPMAK+ZkphoIUjOiP6sSB57Lw2H1RUNK2HVEDowagbl9T47DSbkjYE4NWYqfy22aebPj02VAIbFfFmYxnMw=="],
"@anthropic-ai/claude-agent-sdk-linux-arm64": ["@anthropic-ai/claude-agent-sdk-linux-arm64@0.3.153", "", { "os": "linux", "cpu": "arm64" }, "sha512-XoIP6xJn+QlfBta96yfxdtaeJ/BstA2S9abZJYeWs/wwNjQNWs83repg/v1VPuGn7i44teSzLtzoAv9t1w4Ybg=="],
"@anthropic-ai/claude-agent-sdk-linux-arm64-musl": ["@anthropic-ai/claude-agent-sdk-linux-arm64-musl@0.3.153", "", { "os": "linux", "cpu": "arm64" }, "sha512-5fD6MagyX+1tZdpVjZ6rN178pR7K10c+JyTsEh4HUJR3tnVO9uZYcmEcsKLX1TjWX+mz5+TW9rwDP5AMHXsjNA=="],
"@anthropic-ai/claude-agent-sdk-linux-x64": ["@anthropic-ai/claude-agent-sdk-linux-x64@0.3.153", "", { "os": "linux", "cpu": "x64" }, "sha512-U2eY8iBLF0vrvuHnh33UA3LA/aIVgSk2Pw5KY7N5lH5fYzcnDBoT8bG32lelQIoaUDY0znOWIc0vqN5/Yih51w=="],
"@anthropic-ai/claude-agent-sdk-linux-x64-musl": ["@anthropic-ai/claude-agent-sdk-linux-x64-musl@0.3.153", "", { "os": "linux", "cpu": "x64" }, "sha512-4o+1RnNAL31iZDDT46AWl/t5lUSVJ5Gq8o2IxPgNBltPHAL3aUmTTDLxjDb+gFVEyegyy9FwK0yPJGpNp4CxKA=="],
"@anthropic-ai/claude-agent-sdk-win32-arm64": ["@anthropic-ai/claude-agent-sdk-win32-arm64@0.3.153", "", { "os": "win32", "cpu": "arm64" }, "sha512-GQsZTvf4pJVVAgaXwpN/W7RpO7dtUd3VCM3zxD7RQ1X+Dc8tUNPMWr7JCHEYwrUWiS1jeSjLOsrAuRFopSP43Q=="],
"@anthropic-ai/claude-agent-sdk-win32-x64": ["@anthropic-ai/claude-agent-sdk-win32-x64@0.3.153", "", { "os": "win32", "cpu": "x64" }, "sha512-t/+IEFLsp+fY1G5EZhzVBNKI1mm28rPNcyrlJHy3wCbl1aA/Myd7mWnNCRckDGENZz4V7KyqCslLsCt5gT2iPg=="],
"@anthropic-ai/sdk": ["@anthropic-ai/sdk@0.100.0", "", { "dependencies": { "json-schema-to-ts": "^3.1.1", "standardwebhooks": "^1.0.0" }, "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" }, "optionalPeers": ["zod"], "bin": { "anthropic-ai-sdk": "bin/cli" } }, "sha512-cAm3aXm6qAiHIvHxyIIGd6tVmsD2gDqlc2h0R20ijNUzGgVnIN822bit4mKbF6CkuV7qIrLQIPoAepHEpanrQQ=="],
"@axe-core/playwright": ["@axe-core/playwright@4.11.3", "", { "dependencies": { "axe-core": "~4.11.4" }, "peerDependencies": { "playwright-core": ">= 1.0.0" } }, "sha512-h/kfksv4F0cVIDlKpT4700OehdRgpvuVskuQ2nb7/JmtWUXpe9ftHAPtwyXGvVSsa6SJ64A9ER7Zrzc/sIvC4w=="],
"@babel/helper-string-parser": ["@babel/helper-string-parser@7.27.1", "", {}, "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA=="],
"@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="],
"@babel/parser": ["@babel/parser@7.29.0", "", { "dependencies": { "@babel/types": "^7.29.0" }, "bin": { "parser": "bin/babel-parser.js" } }, "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww=="],
"@babel/runtime": ["@babel/runtime@7.29.7", "", {}, "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw=="],
"@babel/types": ["@babel/types@7.29.0", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A=="],
"@bcoe/v8-coverage": ["@bcoe/v8-coverage@1.0.2", "", {}, "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA=="],
@@ -114,8 +183,34 @@
"@esbuild/win32-x64": ["@esbuild/win32-x64@0.27.3", "", { "os": "win32", "cpu": "x64" }, "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA=="],
"@eslint-community/eslint-utils": ["@eslint-community/eslint-utils@4.9.1", "", { "dependencies": { "eslint-visitor-keys": "^3.4.3" }, "peerDependencies": { "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" } }, "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ=="],
"@eslint-community/regexpp": ["@eslint-community/regexpp@4.12.2", "", {}, "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew=="],
"@eslint/config-array": ["@eslint/config-array@0.23.5", "", { "dependencies": { "@eslint/object-schema": "^3.0.5", "debug": "^4.3.1", "minimatch": "^10.2.4" } }, "sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA=="],
"@eslint/config-helpers": ["@eslint/config-helpers@0.5.5", "", { "dependencies": { "@eslint/core": "^1.2.1" } }, "sha512-eIJYKTCECbP/nsKaaruF6LW967mtbQbsw4JTtSVkUQc9MneSkbrgPJAbKl9nWr0ZeowV8BfsarBmPpBzGelA2w=="],
"@eslint/core": ["@eslint/core@1.2.1", "", { "dependencies": { "@types/json-schema": "^7.0.15" } }, "sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ=="],
"@eslint/js": ["@eslint/js@10.0.1", "", { "peerDependencies": { "eslint": "^10.0.0" }, "optionalPeers": ["eslint"] }, "sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA=="],
"@eslint/object-schema": ["@eslint/object-schema@3.0.5", "", {}, "sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw=="],
"@eslint/plugin-kit": ["@eslint/plugin-kit@0.7.1", "", { "dependencies": { "@eslint/core": "^1.2.1", "levn": "^0.4.1" } }, "sha512-rZAP3aVgB9ds9KOeUSL+zZ21hPmo8dh6fnIFwRQj5EAZl9gzR7wxYbYXYysAM8CTqGmUGyp2S4kUdV17MnGuWQ=="],
"@hono/node-server": ["@hono/node-server@1.19.11", "", { "peerDependencies": { "hono": "^4" } }, "sha512-dr8/3zEaB+p0D2n/IUrlPF1HZm586qgJNXK1a9fhg/PzdtkK7Ksd5l312tJX2yBuALqDYBlG20QEbayqPyxn+g=="],
"@humanfs/core": ["@humanfs/core@0.19.2", "", { "dependencies": { "@humanfs/types": "^0.15.0" } }, "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA=="],
"@humanfs/node": ["@humanfs/node@0.16.8", "", { "dependencies": { "@humanfs/core": "^0.19.2", "@humanfs/types": "^0.15.0", "@humanwhocodes/retry": "^0.4.0" } }, "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ=="],
"@humanfs/types": ["@humanfs/types@0.15.0", "", {}, "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q=="],
"@humanwhocodes/module-importer": ["@humanwhocodes/module-importer@1.0.1", "", {}, "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA=="],
"@humanwhocodes/retry": ["@humanwhocodes/retry@0.4.3", "", {}, "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ=="],
"@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="],
"@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="],
@@ -126,6 +221,20 @@
"@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.4", "", { "dependencies": { "@tybys/wasm-util": "^0.10.1" }, "peerDependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1" } }, "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow=="],
"@openai/codex": ["@openai/codex@0.133.0", "", { "optionalDependencies": { "@openai/codex-darwin-arm64": "npm:@openai/codex@0.133.0-darwin-arm64", "@openai/codex-darwin-x64": "npm:@openai/codex@0.133.0-darwin-x64", "@openai/codex-linux-arm64": "npm:@openai/codex@0.133.0-linux-arm64", "@openai/codex-linux-x64": "npm:@openai/codex@0.133.0-linux-x64", "@openai/codex-win32-arm64": "npm:@openai/codex@0.133.0-win32-arm64", "@openai/codex-win32-x64": "npm:@openai/codex@0.133.0-win32-x64" }, "bin": { "codex": "bin/codex.js" } }, "sha512-Gh42kLLBo/6gpnHmDzUWDVvyS57ekCB1+1Dz0RG2oIl3Lhk1uwrjSj/PwaJWWh4Rw/rUp1RqkwrMugFfFEOlqQ=="],
"@openai/codex-darwin-arm64": ["@openai/codex@0.133.0-darwin-arm64", "", { "os": "darwin", "cpu": "arm64" }, "sha512-W7f8+DckLujnqGlptKCzgJU+ooeHKMuk6KYgMFP6A9asn7YUsGUgJqjiBaX8oNcXO6w/pTbKGRARx1kCNS8lIg=="],
"@openai/codex-darwin-x64": ["@openai/codex@0.133.0-darwin-x64", "", { "os": "darwin", "cpu": "x64" }, "sha512-Ek8ikvLOiXZ8emcIJVBXxK6fm8ratBy0kaEt3JNisTNszxGshUHf/R4xxDxIyKNcUkYYXjW7A/rMwW3iu3OFlg=="],
"@openai/codex-linux-arm64": ["@openai/codex@0.133.0-linux-arm64", "", { "os": "linux", "cpu": "arm64" }, "sha512-uKXYYSJ3mY16sp4hcG/4BMNRjva/ZS4oARiI1+7k8+NiuoAhdCGWNe5u4KJ3sMuL3tp/IXcmc6B56EFX1+WDBQ=="],
"@openai/codex-linux-x64": ["@openai/codex@0.133.0-linux-x64", "", { "os": "linux", "cpu": "x64" }, "sha512-9YfyqrfUj/UZ2+aXE4zBz47t6RXbVni95ZorGsNh857vxYK/asVpUtR2cymo9lB3JaI4mQaKFfV/t7IRItqkuA=="],
"@openai/codex-win32-arm64": ["@openai/codex@0.133.0-win32-arm64", "", { "os": "win32", "cpu": "arm64" }, "sha512-mRzND0PSGHRoLk0X41GTSoc3tFjZSF4HgDlfjU5fiQcWVi0/kLb7Ku6/tPFT/X2hOLa3YdJkbIcHC0Hc9ni80g=="],
"@openai/codex-win32-x64": ["@openai/codex@0.133.0-win32-x64", "", { "os": "win32", "cpu": "x64" }, "sha512-u3ji78DIPZCGJeELuovsAnaZH+vK9gsA4F6M1y+Uy2s80Sz7/i1S0KL81qGReYji3urSjgBpkQuNP47GXOqxrQ=="],
"@oxc-project/types": ["@oxc-project/types@0.127.0", "", {}, "sha512-aIYXQBo4lCbO4z0R3FHeucQHpF46l2LbMdxRvqvuRuW2OxdnSkcng5B8+K12spgLDj93rtN3+J2Vac/TIO+ciQ=="],
"@pinojs/redact": ["@pinojs/redact@0.4.0", "", {}, "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg=="],
@@ -218,6 +327,8 @@
"@sapphire/snowflake": ["@sapphire/snowflake@3.5.3", "", {}, "sha512-jjmJywLAFoWeBi1W7994zZyiNWPIiqRRNAmSERxyg93xRGzNYvGjlZ0gR6x0F4gPRi2+0O6S71kOZYyr3cxaIQ=="],
"@stablelib/base64": ["@stablelib/base64@1.0.1", "", {}, "sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ=="],
"@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
"@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="],
@@ -228,18 +339,56 @@
"@types/chai": ["@types/chai@5.2.3", "", { "dependencies": { "@types/deep-eql": "*", "assertion-error": "^2.0.1" } }, "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA=="],
"@types/debug": ["@types/debug@4.1.13", "", { "dependencies": { "@types/ms": "*" } }, "sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw=="],
"@types/deep-eql": ["@types/deep-eql@4.0.2", "", {}, "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw=="],
"@types/esrecurse": ["@types/esrecurse@4.3.1", "", {}, "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw=="],
"@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="],
"@types/estree-jsx": ["@types/estree-jsx@1.0.5", "", { "dependencies": { "@types/estree": "*" } }, "sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg=="],
"@types/hast": ["@types/hast@3.0.4", "", { "dependencies": { "@types/unist": "*" } }, "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ=="],
"@types/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="],
"@types/mdast": ["@types/mdast@4.0.4", "", { "dependencies": { "@types/unist": "*" } }, "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA=="],
"@types/ms": ["@types/ms@2.1.0", "", {}, "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA=="],
"@types/node": ["@types/node@22.19.11", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-BH7YwL6rA93ReqeQS1c4bsPpcfOmJasG+Fkr6Y59q83f9M1WcBRHR2vM+P9eOisYRcN3ujQoiZY8uk5W+1WL8w=="],
"@types/react": ["@types/react@19.2.14", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w=="],
"@types/react-dom": ["@types/react-dom@19.2.3", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="],
"@types/unist": ["@types/unist@3.0.3", "", {}, "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q=="],
"@types/ws": ["@types/ws@8.18.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg=="],
"@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.59.0", "", { "dependencies": { "@eslint-community/regexpp": "^4.12.2", "@typescript-eslint/scope-manager": "8.59.0", "@typescript-eslint/type-utils": "8.59.0", "@typescript-eslint/utils": "8.59.0", "@typescript-eslint/visitor-keys": "8.59.0", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.59.0", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-HyAZtpdkgZwpq8Sz3FSUvCR4c+ScbuWa9AksK2Jweub7w4M3yTz4O11AqVJzLYjy/B9ZWPyc81I+mOdJU/bDQw=="],
"@typescript-eslint/parser": ["@typescript-eslint/parser@8.59.0", "", { "dependencies": { "@typescript-eslint/scope-manager": "8.59.0", "@typescript-eslint/types": "8.59.0", "@typescript-eslint/typescript-estree": "8.59.0", "@typescript-eslint/visitor-keys": "8.59.0", "debug": "^4.4.3" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-TI1XGwKbDpo9tRW8UDIXCOeLk55qe9ZFGs8MTKU6/M08HWTw52DD/IYhfQtOEhEdPhLMT26Ka/x7p70nd3dzDg=="],
"@typescript-eslint/project-service": ["@typescript-eslint/project-service@8.59.0", "", { "dependencies": { "@typescript-eslint/tsconfig-utils": "^8.59.0", "@typescript-eslint/types": "^8.59.0", "debug": "^4.4.3" }, "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-Lw5ITrR5s5TbC19YSvlr63ZfLaJoU6vtKTHyB0GQOpX0W7d5/Ir6vUahWi/8Sps/nOukZQ0IB3SmlxZnjaKVnw=="],
"@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@8.59.0", "", { "dependencies": { "@typescript-eslint/types": "8.59.0", "@typescript-eslint/visitor-keys": "8.59.0" } }, "sha512-UzR16Ut8IpA3Mc4DbgAShlPPkVm8xXMWafXxB0BocaVRHs8ZGakAxGRskF7FId3sdk9lgGD73GSFaWmWFDE4dg=="],
"@typescript-eslint/tsconfig-utils": ["@typescript-eslint/tsconfig-utils@8.59.0", "", { "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-91Sbl3s4Kb3SybliIY6muFBmHVv+pYXfybC4Oolp3dvk8BvIE3wOPc+403CWIT7mJNkfQRGtdqghzs2+Z91Tqg=="],
"@typescript-eslint/type-utils": ["@typescript-eslint/type-utils@8.59.0", "", { "dependencies": { "@typescript-eslint/types": "8.59.0", "@typescript-eslint/typescript-estree": "8.59.0", "@typescript-eslint/utils": "8.59.0", "debug": "^4.4.3", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-3TRiZaQSltGqGeNrJzzr1+8YcEobKH9rHnqIp/1psfKFmhRQDNMGP5hBufanYTGznwShzVLs3Mz+gDN7HkWfXg=="],
"@typescript-eslint/types": ["@typescript-eslint/types@8.59.0", "", {}, "sha512-nLzdsT1gdOgFxxxwrlNVUBzSNBEEHJ86bblmk4QAS6stfig7rcJzWKqCyxFy3YRRHXDWEkb2NralA1nOYkkm/A=="],
"@typescript-eslint/typescript-estree": ["@typescript-eslint/typescript-estree@8.59.0", "", { "dependencies": { "@typescript-eslint/project-service": "8.59.0", "@typescript-eslint/tsconfig-utils": "8.59.0", "@typescript-eslint/types": "8.59.0", "@typescript-eslint/visitor-keys": "8.59.0", "debug": "^4.4.3", "minimatch": "^10.2.2", "semver": "^7.7.3", "tinyglobby": "^0.2.15", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-O9Re9P1BmBLFJyikRbQpLku/QA3/AueZNO9WePLBwQrvkixTmDe8u76B6CYUAITRl/rHawggEqUGn5QIkVRLMw=="],
"@typescript-eslint/utils": ["@typescript-eslint/utils@8.59.0", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", "@typescript-eslint/scope-manager": "8.59.0", "@typescript-eslint/types": "8.59.0", "@typescript-eslint/typescript-estree": "8.59.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-I1R/K7V07XsMJ12Oaxg/O9GfrysGTmCRhvZJBv0RE0NcULMzjqVpR5kRRQjHsz3J/bElU7HwCO7zkqL+MSUz+g=="],
"@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.59.0", "", { "dependencies": { "@typescript-eslint/types": "8.59.0", "eslint-visitor-keys": "^5.0.0" } }, "sha512-/uejZt4dSere1bx12WLlPfv8GktzcaDtuJ7s42/HEZ5zGj9oxRaD4bj7qwSunXkf+pbAhFt2zjpHYUiT5lHf0Q=="],
"@ungap/structured-clone": ["@ungap/structured-clone@1.3.0", "", {}, "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g=="],
"@vitejs/plugin-react": ["@vitejs/plugin-react@6.0.1", "", { "dependencies": { "@rolldown/pluginutils": "1.0.0-rc.7" }, "peerDependencies": { "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", "babel-plugin-react-compiler": "^1.0.0", "vite": "^8.0.0" }, "optionalPeers": ["@rolldown/plugin-babel", "babel-plugin-react-compiler"] }, "sha512-l9X/E3cDb+xY3SWzlG1MOGt2usfEHGMNIaegaUGFsLkb3RCn/k8/TOXBcab+OndDI4TBtktT8/9BwwW8Vi9KUQ=="],
"@vitest/coverage-v8": ["@vitest/coverage-v8@4.0.18", "", { "dependencies": { "@bcoe/v8-coverage": "^1.0.2", "@vitest/utils": "4.0.18", "ast-v8-to-istanbul": "^0.3.10", "istanbul-lib-coverage": "^3.2.2", "istanbul-lib-report": "^3.0.1", "istanbul-reports": "^3.2.0", "magicast": "^0.5.1", "obug": "^2.1.1", "std-env": "^3.10.0", "tinyrainbow": "^3.0.3" }, "peerDependencies": { "@vitest/browser": "4.0.18", "vitest": "4.0.18" }, "optionalPeers": ["@vitest/browser"] }, "sha512-7i+N2i0+ME+2JFZhfuz7Tg/FqKtilHjGyGvoHYQ6iLV0zahbsJ9sljC9OcFcPDbhYKCet+sG8SsVqlyGvPflZg=="],
@@ -262,7 +411,11 @@
"accepts": ["accepts@2.0.0", "", { "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="],
"ajv": ["ajv@8.18.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A=="],
"acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="],
"acorn-jsx": ["acorn-jsx@5.3.2", "", { "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ=="],
"ajv": ["ajv@6.15.0", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw=="],
"ajv-formats": ["ajv-formats@3.0.1", "", { "dependencies": { "ajv": "^8.0.0" }, "peerDependencies": { "ajv": "^8.0.0" } }, "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ=="],
@@ -272,6 +425,12 @@
"atomic-sleep": ["atomic-sleep@1.0.0", "", {}, "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ=="],
"axe-core": ["axe-core@4.11.4", "", {}, "sha512-KunSNx+TVpkAw/6ULfhnx+HWRecjqZGTOyquAoWHYLRSdK1tB5Ihce1ZW+UY3fj33bYAFWPu7W/GRSmmrCGuxA=="],
"bail": ["bail@2.0.2", "", {}, "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw=="],
"balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="],
"base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="],
"better-sqlite3": ["better-sqlite3@12.8.0", "", { "dependencies": { "bindings": "^1.5.0", "prebuild-install": "^7.1.1" } }, "sha512-RxD2Vd96sQDjQr20kdP+F+dK/1OUNiVOl200vKBZY8u0vTwysfolF6Hq+3ZK2+h8My9YvZhHsF+RSGZW2VYrPQ=="],
@@ -282,6 +441,8 @@
"body-parser": ["body-parser@2.2.2", "", { "dependencies": { "bytes": "^3.1.2", "content-type": "^1.0.5", "debug": "^4.4.3", "http-errors": "^2.0.0", "iconv-lite": "^0.7.0", "on-finished": "^2.4.1", "qs": "^6.14.1", "raw-body": "^3.0.1", "type-is": "^2.0.1" } }, "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA=="],
"brace-expansion": ["brace-expansion@5.0.5", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ=="],
"buffer": ["buffer@5.7.1", "", { "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.1.13" } }, "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ=="],
"bun-types": ["bun-types@1.3.11", "", { "dependencies": { "@types/node": "*" } }, "sha512-1KGPpoxQWl9f6wcZh57LvrPIInQMn2TQ7jsgxqpRzg+l0QPOFvJVH7HmvHo/AiPgwXy+/Thf6Ov3EdVn1vOabg=="],
@@ -292,12 +453,24 @@
"call-bound": ["call-bound@1.0.4", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "get-intrinsic": "^1.3.0" } }, "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg=="],
"ccount": ["ccount@2.0.1", "", {}, "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg=="],
"chai": ["chai@6.2.2", "", {}, "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg=="],
"character-entities": ["character-entities@2.0.2", "", {}, "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ=="],
"character-entities-html4": ["character-entities-html4@2.1.0", "", {}, "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA=="],
"character-entities-legacy": ["character-entities-legacy@3.0.0", "", {}, "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ=="],
"character-reference-invalid": ["character-reference-invalid@2.0.1", "", {}, "sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw=="],
"chownr": ["chownr@1.1.4", "", {}, "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg=="],
"colorette": ["colorette@2.0.20", "", {}, "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w=="],
"comma-separated-tokens": ["comma-separated-tokens@2.0.3", "", {}, "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg=="],
"content-disposition": ["content-disposition@1.0.1", "", {}, "sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q=="],
"content-type": ["content-type@1.0.5", "", {}, "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA=="],
@@ -318,14 +491,22 @@
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
"decode-named-character-reference": ["decode-named-character-reference@1.3.0", "", { "dependencies": { "character-entities": "^2.0.0" } }, "sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q=="],
"decompress-response": ["decompress-response@6.0.0", "", { "dependencies": { "mimic-response": "^3.1.0" } }, "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ=="],
"deep-extend": ["deep-extend@0.6.0", "", {}, "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA=="],
"deep-is": ["deep-is@0.1.4", "", {}, "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ=="],
"depd": ["depd@2.0.0", "", {}, "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw=="],
"dequal": ["dequal@2.0.3", "", {}, "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA=="],
"detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
"devlop": ["devlop@1.1.0", "", { "dependencies": { "dequal": "^2.0.0" } }, "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA=="],
"discord-api-types": ["discord-api-types@0.38.41", "", {}, "sha512-yMECyR8j9c2fVTvCQ+Qc24pweYFIZk/XoxDOmt1UvPeSw5tK6gXBd/2hhP+FEAe9Y6ny8pRMaf618XDK4U53OQ=="],
"discord.js": ["discord.js@14.25.1", "", { "dependencies": { "@discordjs/builders": "^1.13.0", "@discordjs/collection": "1.5.3", "@discordjs/formatters": "^0.6.2", "@discordjs/rest": "^2.6.0", "@discordjs/util": "^1.2.0", "@discordjs/ws": "^1.2.3", "@sapphire/snowflake": "3.5.3", "discord-api-types": "^0.38.33", "fast-deep-equal": "3.1.3", "lodash.snakecase": "4.1.1", "magic-bytes.js": "^1.10.0", "tslib": "^2.6.3", "undici": "6.21.3" } }, "sha512-2l0gsPOLPs5t6GFZfQZKnL1OJNYFcuC/ETWsW4VtKVD/tg4ICa9x+jb9bkPffkMdRpRpuUaO/fKkHCBeiCKh8g=="],
@@ -334,7 +515,11 @@
"ee-first": ["ee-first@1.1.1", "", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="],
"ejclaw-runners-shared": ["ejclaw-runners-shared@file:runners/shared", { "devDependencies": { "@types/node": "^22.10.7", "typescript": "^5.7.3" } }],
"ejclaw-agent-runner": ["ejclaw-agent-runner@workspace:runners/agent-runner"],
"ejclaw-codex-runner": ["ejclaw-codex-runner@workspace:runners/codex-runner"],
"ejclaw-runners-shared": ["ejclaw-runners-shared@workspace:runners/shared"],
"encodeurl": ["encodeurl@2.0.0", "", {}, "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg=="],
@@ -352,8 +537,28 @@
"escape-html": ["escape-html@1.0.3", "", {}, "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow=="],
"escape-string-regexp": ["escape-string-regexp@4.0.0", "", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="],
"eslint": ["eslint@10.2.1", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.2", "@eslint/config-array": "^0.23.5", "@eslint/config-helpers": "^0.5.5", "@eslint/core": "^1.2.1", "@eslint/plugin-kit": "^0.7.1", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", "ajv": "^6.14.0", "cross-spawn": "^7.0.6", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", "eslint-scope": "^9.1.2", "eslint-visitor-keys": "^5.0.1", "espree": "^11.2.0", "esquery": "^1.7.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", "file-entry-cache": "^8.0.0", "find-up": "^5.0.0", "glob-parent": "^6.0.2", "ignore": "^5.2.0", "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", "minimatch": "^10.2.4", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, "peerDependencies": { "jiti": "*" }, "optionalPeers": ["jiti"], "bin": { "eslint": "bin/eslint.js" } }, "sha512-wiyGaKsDgqXvF40P8mDwiUp/KQjE1FdrIEJsM8PZ3XCiniTMXS3OHWWUe5FI5agoCnr8x4xPrTDZuxsBlNHl+Q=="],
"eslint-scope": ["eslint-scope@9.1.2", "", { "dependencies": { "@types/esrecurse": "^4.3.1", "@types/estree": "^1.0.8", "esrecurse": "^4.3.0", "estraverse": "^5.2.0" } }, "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ=="],
"eslint-visitor-keys": ["eslint-visitor-keys@5.0.1", "", {}, "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA=="],
"espree": ["espree@11.2.0", "", { "dependencies": { "acorn": "^8.16.0", "acorn-jsx": "^5.3.2", "eslint-visitor-keys": "^5.0.1" } }, "sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw=="],
"esquery": ["esquery@1.7.0", "", { "dependencies": { "estraverse": "^5.1.0" } }, "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g=="],
"esrecurse": ["esrecurse@4.3.0", "", { "dependencies": { "estraverse": "^5.2.0" } }, "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag=="],
"estraverse": ["estraverse@5.3.0", "", {}, "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA=="],
"estree-util-is-identifier-name": ["estree-util-is-identifier-name@3.0.0", "", {}, "sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg=="],
"estree-walker": ["estree-walker@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.0" } }, "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g=="],
"esutils": ["esutils@2.0.3", "", {}, "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g=="],
"etag": ["etag@1.8.1", "", {}, "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg=="],
"eventsource": ["eventsource@3.0.7", "", { "dependencies": { "eventsource-parser": "^3.0.1" } }, "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA=="],
@@ -368,27 +573,43 @@
"express-rate-limit": ["express-rate-limit@8.3.1", "", { "dependencies": { "ip-address": "10.1.0" }, "peerDependencies": { "express": ">= 4.11" } }, "sha512-D1dKN+cmyPWuvB+G2SREQDzPY1agpBIcTa9sJxOPMCNeH3gwzhqJRDWCXW3gg0y//+LQ/8j52JbMROWyrKdMdw=="],
"extend": ["extend@3.0.2", "", {}, "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g=="],
"fast-copy": ["fast-copy@4.0.2", "", {}, "sha512-ybA6PDXIXOXivLJK/z9e+Otk7ve13I4ckBvGO5I2RRmBU1gMHLVDJYEuJYhGwez7YNlYji2M2DvVU+a9mSFDlw=="],
"fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="],
"fast-json-stable-stringify": ["fast-json-stable-stringify@2.1.0", "", {}, "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw=="],
"fast-levenshtein": ["fast-levenshtein@2.0.6", "", {}, "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw=="],
"fast-safe-stringify": ["fast-safe-stringify@2.1.1", "", {}, "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA=="],
"fast-sha256": ["fast-sha256@1.3.0", "", {}, "sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ=="],
"fast-uri": ["fast-uri@3.1.0", "", {}, "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA=="],
"fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" } }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
"file-entry-cache": ["file-entry-cache@8.0.0", "", { "dependencies": { "flat-cache": "^4.0.0" } }, "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ=="],
"file-uri-to-path": ["file-uri-to-path@1.0.0", "", {}, "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw=="],
"finalhandler": ["finalhandler@2.1.1", "", { "dependencies": { "debug": "^4.4.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "on-finished": "^2.4.1", "parseurl": "^1.3.3", "statuses": "^2.0.1" } }, "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA=="],
"find-up": ["find-up@5.0.0", "", { "dependencies": { "locate-path": "^6.0.0", "path-exists": "^4.0.0" } }, "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng=="],
"flat-cache": ["flat-cache@4.0.1", "", { "dependencies": { "flatted": "^3.2.9", "keyv": "^4.5.4" } }, "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw=="],
"flatted": ["flatted@3.4.2", "", {}, "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA=="],
"forwarded": ["forwarded@0.2.0", "", {}, "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow=="],
"fresh": ["fresh@2.0.0", "", {}, "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A=="],
"fs-constants": ["fs-constants@1.0.0", "", {}, "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow=="],
"fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
"fsevents": ["fsevents@2.3.2", "", { "os": "darwin" }, "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA=="],
"function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="],
@@ -400,6 +621,10 @@
"github-from-package": ["github-from-package@0.0.0", "", {}, "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw=="],
"glob-parent": ["glob-parent@6.0.2", "", { "dependencies": { "is-glob": "^4.0.3" } }, "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A=="],
"globals": ["globals@17.5.0", "", {}, "sha512-qoV+HK2yFl/366t2/Cb3+xxPUo5BuMynomoDmiaZBIdbs+0pYbjfZU+twLhGKp4uCZ/+NbtpVepH5bGCxRyy2g=="],
"gopd": ["gopd@1.2.0", "", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="],
"has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="],
@@ -408,12 +633,18 @@
"hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="],
"hast-util-to-jsx-runtime": ["hast-util-to-jsx-runtime@2.3.6", "", { "dependencies": { "@types/estree": "^1.0.0", "@types/hast": "^3.0.0", "@types/unist": "^3.0.0", "comma-separated-tokens": "^2.0.0", "devlop": "^1.0.0", "estree-util-is-identifier-name": "^3.0.0", "hast-util-whitespace": "^3.0.0", "mdast-util-mdx-expression": "^2.0.0", "mdast-util-mdx-jsx": "^3.0.0", "mdast-util-mdxjs-esm": "^2.0.0", "property-information": "^7.0.0", "space-separated-tokens": "^2.0.0", "style-to-js": "^1.0.0", "unist-util-position": "^5.0.0", "vfile-message": "^4.0.0" } }, "sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg=="],
"hast-util-whitespace": ["hast-util-whitespace@3.0.0", "", { "dependencies": { "@types/hast": "^3.0.0" } }, "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw=="],
"help-me": ["help-me@5.0.0", "", {}, "sha512-7xgomUX6ADmcYzFik0HzAxh/73YlKR9bmFzf51CZwR+b6YtzU2m0u49hQCqV6SvlqIqsaxovfwdvbnsw3b/zpg=="],
"hono": ["hono@4.12.9", "", {}, "sha512-wy3T8Zm2bsEvxKZM5w21VdHDDcwVS1yUFFY6i8UobSsKfFceT7TOwhbhfKsDyx7tYQlmRM5FLpIuYvNFyjctiA=="],
"html-escaper": ["html-escaper@2.0.2", "", {}, "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg=="],
"html-url-attributes": ["html-url-attributes@3.0.1", "", {}, "sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ=="],
"http-errors": ["http-errors@2.0.1", "", { "dependencies": { "depd": "~2.0.0", "inherits": "~2.0.4", "setprototypeof": "~1.2.0", "statuses": "~2.0.2", "toidentifier": "~1.0.1" } }, "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ=="],
"husky": ["husky@9.1.7", "", { "bin": "bin.js" }, "sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA=="],
@@ -422,14 +653,34 @@
"ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="],
"ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="],
"imurmurhash": ["imurmurhash@0.1.4", "", {}, "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA=="],
"inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="],
"ini": ["ini@1.3.8", "", {}, "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew=="],
"inline-style-parser": ["inline-style-parser@0.2.7", "", {}, "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA=="],
"ip-address": ["ip-address@10.1.0", "", {}, "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q=="],
"ipaddr.js": ["ipaddr.js@1.9.1", "", {}, "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g=="],
"is-alphabetical": ["is-alphabetical@2.0.1", "", {}, "sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ=="],
"is-alphanumerical": ["is-alphanumerical@2.0.1", "", { "dependencies": { "is-alphabetical": "^2.0.0", "is-decimal": "^2.0.0" } }, "sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw=="],
"is-decimal": ["is-decimal@2.0.1", "", {}, "sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A=="],
"is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="],
"is-glob": ["is-glob@4.0.3", "", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="],
"is-hexadecimal": ["is-hexadecimal@2.0.1", "", {}, "sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg=="],
"is-plain-obj": ["is-plain-obj@4.1.0", "", {}, "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg=="],
"is-promise": ["is-promise@4.0.0", "", {}, "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ=="],
"isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="],
@@ -446,10 +697,20 @@
"js-tokens": ["js-tokens@10.0.0", "", {}, "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q=="],
"json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="],
"json-buffer": ["json-buffer@3.0.1", "", {}, "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ=="],
"json-schema-to-ts": ["json-schema-to-ts@3.1.1", "", { "dependencies": { "@babel/runtime": "^7.18.3", "ts-algebra": "^2.0.0" } }, "sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g=="],
"json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="],
"json-schema-typed": ["json-schema-typed@8.0.2", "", {}, "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA=="],
"json-stable-stringify-without-jsonify": ["json-stable-stringify-without-jsonify@1.0.1", "", {}, "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw=="],
"keyv": ["keyv@4.5.4", "", { "dependencies": { "json-buffer": "3.0.1" } }, "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw=="],
"levn": ["levn@0.4.1", "", { "dependencies": { "prelude-ls": "^1.2.1", "type-check": "~0.4.0" } }, "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ=="],
"lightningcss": ["lightningcss@1.32.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.32.0", "lightningcss-darwin-arm64": "1.32.0", "lightningcss-darwin-x64": "1.32.0", "lightningcss-freebsd-x64": "1.32.0", "lightningcss-linux-arm-gnueabihf": "1.32.0", "lightningcss-linux-arm64-gnu": "1.32.0", "lightningcss-linux-arm64-musl": "1.32.0", "lightningcss-linux-x64-gnu": "1.32.0", "lightningcss-linux-x64-musl": "1.32.0", "lightningcss-win32-arm64-msvc": "1.32.0", "lightningcss-win32-x64-msvc": "1.32.0" } }, "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ=="],
"lightningcss-android-arm64": ["lightningcss-android-arm64@1.32.0", "", { "os": "android", "cpu": "arm64" }, "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg=="],
@@ -474,10 +735,16 @@
"lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.32.0", "", { "os": "win32", "cpu": "x64" }, "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q=="],
"locate-path": ["locate-path@6.0.0", "", { "dependencies": { "p-locate": "^5.0.0" } }, "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw=="],
"lodash": ["lodash@4.17.23", "", {}, "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w=="],
"lodash.snakecase": ["lodash.snakecase@4.1.1", "", {}, "sha512-QZ1d4xoBHYUeuouhEq3lk3Uq7ldgyFXGBhg04+oRLnIz8o9T65Eh+8YdroUwn846zchkA9yDsDl5CVVaV2nqYw=="],
"longest-streak": ["longest-streak@3.1.0", "", {}, "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g=="],
"lucide-react": ["lucide-react@1.11.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-UOhjdztXCgdBReRcIhsvz2siIBogfv/lhJEIViCpLt924dO+GDms9T7DNoucI23s6kEPpe988m5N0D2ajnzb2g=="],
"luxon": ["luxon@3.7.2", "", {}, "sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew=="],
"magic-bytes.js": ["magic-bytes.js@1.13.0", "", {}, "sha512-afO2mnxW7GDTXMm5/AoN1WuOcdoKhtgXjIvHmobqTD1grNplhGdv3PFOyjCVmrnOZBIT/gD/koDKpYG+0mvHcg=="],
@@ -488,18 +755,108 @@
"make-dir": ["make-dir@4.0.0", "", { "dependencies": { "semver": "^7.5.3" } }, "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw=="],
"markdown-table": ["markdown-table@3.0.4", "", {}, "sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw=="],
"math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="],
"mdast-util-find-and-replace": ["mdast-util-find-and-replace@3.0.2", "", { "dependencies": { "@types/mdast": "^4.0.0", "escape-string-regexp": "^5.0.0", "unist-util-is": "^6.0.0", "unist-util-visit-parents": "^6.0.0" } }, "sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg=="],
"mdast-util-from-markdown": ["mdast-util-from-markdown@2.0.3", "", { "dependencies": { "@types/mdast": "^4.0.0", "@types/unist": "^3.0.0", "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "mdast-util-to-string": "^4.0.0", "micromark": "^4.0.0", "micromark-util-decode-numeric-character-reference": "^2.0.0", "micromark-util-decode-string": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0", "unist-util-stringify-position": "^4.0.0" } }, "sha512-W4mAWTvSlKvf8L6J+VN9yLSqQ9AOAAvHuoDAmPkz4dHf553m5gVj2ejadHJhoJmcmxEnOv6Pa8XJhpxE93kb8Q=="],
"mdast-util-gfm": ["mdast-util-gfm@3.1.0", "", { "dependencies": { "mdast-util-from-markdown": "^2.0.0", "mdast-util-gfm-autolink-literal": "^2.0.0", "mdast-util-gfm-footnote": "^2.0.0", "mdast-util-gfm-strikethrough": "^2.0.0", "mdast-util-gfm-table": "^2.0.0", "mdast-util-gfm-task-list-item": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ=="],
"mdast-util-gfm-autolink-literal": ["mdast-util-gfm-autolink-literal@2.0.1", "", { "dependencies": { "@types/mdast": "^4.0.0", "ccount": "^2.0.0", "devlop": "^1.0.0", "mdast-util-find-and-replace": "^3.0.0", "micromark-util-character": "^2.0.0" } }, "sha512-5HVP2MKaP6L+G6YaxPNjuL0BPrq9orG3TsrZ9YXbA3vDw/ACI4MEsnoDpn6ZNm7GnZgtAcONJyPhOP8tNJQavQ=="],
"mdast-util-gfm-footnote": ["mdast-util-gfm-footnote@2.1.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "devlop": "^1.1.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0" } }, "sha512-sqpDWlsHn7Ac9GNZQMeUzPQSMzR6Wv0WKRNvQRg0KqHh02fpTz69Qc1QSseNX29bhz1ROIyNyxExfawVKTm1GQ=="],
"mdast-util-gfm-strikethrough": ["mdast-util-gfm-strikethrough@2.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-mKKb915TF+OC5ptj5bJ7WFRPdYtuHv0yTRxK2tJvi+BDqbkiG7h7u/9SI89nRAYcmap2xHQL9D+QG/6wSrTtXg=="],
"mdast-util-gfm-table": ["mdast-util-gfm-table@2.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "markdown-table": "^3.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-78UEvebzz/rJIxLvE7ZtDd/vIQ0RHv+3Mh5DR96p7cS7HsBhYIICDBCu8csTNWNO6tBWfqXPWekRuj2FNOGOZg=="],
"mdast-util-gfm-task-list-item": ["mdast-util-gfm-task-list-item@2.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-IrtvNvjxC1o06taBAVJznEnkiHxLFTzgonUdy8hzFVeDun0uTjxxrRGVaNFqkU1wJR3RBPEfsxmU6jDWPofrTQ=="],
"mdast-util-mdx-expression": ["mdast-util-mdx-expression@2.0.1", "", { "dependencies": { "@types/estree-jsx": "^1.0.0", "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-J6f+9hUp+ldTZqKRSg7Vw5V6MqjATc+3E4gf3CFNcuZNWD8XdyI6zQ8GqH7f8169MM6P7hMBRDVGnn7oHB9kXQ=="],
"mdast-util-mdx-jsx": ["mdast-util-mdx-jsx@3.2.0", "", { "dependencies": { "@types/estree-jsx": "^1.0.0", "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "@types/unist": "^3.0.0", "ccount": "^2.0.0", "devlop": "^1.1.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0", "parse-entities": "^4.0.0", "stringify-entities": "^4.0.0", "unist-util-stringify-position": "^4.0.0", "vfile-message": "^4.0.0" } }, "sha512-lj/z8v0r6ZtsN/cGNNtemmmfoLAFZnjMbNyLzBafjzikOM+glrjNHPlf6lQDOTccj9n5b0PPihEBbhneMyGs1Q=="],
"mdast-util-mdxjs-esm": ["mdast-util-mdxjs-esm@2.0.1", "", { "dependencies": { "@types/estree-jsx": "^1.0.0", "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "mdast-util-from-markdown": "^2.0.0", "mdast-util-to-markdown": "^2.0.0" } }, "sha512-EcmOpxsZ96CvlP03NghtH1EsLtr0n9Tm4lPUJUBccV9RwUOneqSycg19n5HGzCf+10LozMRSObtVr3ee1WoHtg=="],
"mdast-util-phrasing": ["mdast-util-phrasing@4.1.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "unist-util-is": "^6.0.0" } }, "sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w=="],
"mdast-util-to-hast": ["mdast-util-to-hast@13.2.1", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "@ungap/structured-clone": "^1.0.0", "devlop": "^1.0.0", "micromark-util-sanitize-uri": "^2.0.0", "trim-lines": "^3.0.0", "unist-util-position": "^5.0.0", "unist-util-visit": "^5.0.0", "vfile": "^6.0.0" } }, "sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA=="],
"mdast-util-to-markdown": ["mdast-util-to-markdown@2.1.2", "", { "dependencies": { "@types/mdast": "^4.0.0", "@types/unist": "^3.0.0", "longest-streak": "^3.0.0", "mdast-util-phrasing": "^4.0.0", "mdast-util-to-string": "^4.0.0", "micromark-util-classify-character": "^2.0.0", "micromark-util-decode-string": "^2.0.0", "unist-util-visit": "^5.0.0", "zwitch": "^2.0.0" } }, "sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA=="],
"mdast-util-to-string": ["mdast-util-to-string@4.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0" } }, "sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg=="],
"media-typer": ["media-typer@1.1.0", "", {}, "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw=="],
"merge-descriptors": ["merge-descriptors@2.0.0", "", {}, "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g=="],
"micromark": ["micromark@4.0.2", "", { "dependencies": { "@types/debug": "^4.0.0", "debug": "^4.0.0", "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "micromark-core-commonmark": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-combine-extensions": "^2.0.0", "micromark-util-decode-numeric-character-reference": "^2.0.0", "micromark-util-encode": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-resolve-all": "^2.0.0", "micromark-util-sanitize-uri": "^2.0.0", "micromark-util-subtokenize": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA=="],
"micromark-core-commonmark": ["micromark-core-commonmark@2.0.3", "", { "dependencies": { "decode-named-character-reference": "^1.0.0", "devlop": "^1.0.0", "micromark-factory-destination": "^2.0.0", "micromark-factory-label": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-factory-title": "^2.0.0", "micromark-factory-whitespace": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-classify-character": "^2.0.0", "micromark-util-html-tag-name": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-resolve-all": "^2.0.0", "micromark-util-subtokenize": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg=="],
"micromark-extension-gfm": ["micromark-extension-gfm@3.0.0", "", { "dependencies": { "micromark-extension-gfm-autolink-literal": "^2.0.0", "micromark-extension-gfm-footnote": "^2.0.0", "micromark-extension-gfm-strikethrough": "^2.0.0", "micromark-extension-gfm-table": "^2.0.0", "micromark-extension-gfm-tagfilter": "^2.0.0", "micromark-extension-gfm-task-list-item": "^2.0.0", "micromark-util-combine-extensions": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w=="],
"micromark-extension-gfm-autolink-literal": ["micromark-extension-gfm-autolink-literal@2.1.0", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-sanitize-uri": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw=="],
"micromark-extension-gfm-footnote": ["micromark-extension-gfm-footnote@2.1.0", "", { "dependencies": { "devlop": "^1.0.0", "micromark-core-commonmark": "^2.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-normalize-identifier": "^2.0.0", "micromark-util-sanitize-uri": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-/yPhxI1ntnDNsiHtzLKYnE3vf9JZ6cAisqVDauhp4CEHxlb4uoOTxOCJ+9s51bIB8U1N1FJ1RXOKTIlD5B/gqw=="],
"micromark-extension-gfm-strikethrough": ["micromark-extension-gfm-strikethrough@2.1.0", "", { "dependencies": { "devlop": "^1.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-classify-character": "^2.0.0", "micromark-util-resolve-all": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-ADVjpOOkjz1hhkZLlBiYA9cR2Anf8F4HqZUO6e5eDcPQd0Txw5fxLzzxnEkSkfnD0wziSGiv7sYhk/ktvbf1uw=="],
"micromark-extension-gfm-table": ["micromark-extension-gfm-table@2.1.1", "", { "dependencies": { "devlop": "^1.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-t2OU/dXXioARrC6yWfJ4hqB7rct14e8f7m0cbI5hUmDyyIlwv5vEtooptH8INkbLzOatzKuVbQmAYcbWoyz6Dg=="],
"micromark-extension-gfm-tagfilter": ["micromark-extension-gfm-tagfilter@2.0.0", "", { "dependencies": { "micromark-util-types": "^2.0.0" } }, "sha512-xHlTOmuCSotIA8TW1mDIM6X2O1SiX5P9IuDtqGonFhEK0qgRI4yeC6vMxEV2dgyr2TiD+2PQ10o+cOhdVAcwfg=="],
"micromark-extension-gfm-task-list-item": ["micromark-extension-gfm-task-list-item@2.1.0", "", { "dependencies": { "devlop": "^1.0.0", "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-qIBZhqxqI6fjLDYFTBIa4eivDMnP+OZqsNwmQ3xNLE4Cxwc+zfQEfbs6tzAo2Hjq+bh6q5F+Z8/cksrLFYWQQw=="],
"micromark-factory-destination": ["micromark-factory-destination@2.0.1", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA=="],
"micromark-factory-label": ["micromark-factory-label@2.0.1", "", { "dependencies": { "devlop": "^1.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg=="],
"micromark-factory-space": ["micromark-factory-space@2.0.1", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg=="],
"micromark-factory-title": ["micromark-factory-title@2.0.1", "", { "dependencies": { "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw=="],
"micromark-factory-whitespace": ["micromark-factory-whitespace@2.0.1", "", { "dependencies": { "micromark-factory-space": "^2.0.0", "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ=="],
"micromark-util-character": ["micromark-util-character@2.1.1", "", { "dependencies": { "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q=="],
"micromark-util-chunked": ["micromark-util-chunked@2.0.1", "", { "dependencies": { "micromark-util-symbol": "^2.0.0" } }, "sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA=="],
"micromark-util-classify-character": ["micromark-util-classify-character@2.0.1", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q=="],
"micromark-util-combine-extensions": ["micromark-util-combine-extensions@2.0.1", "", { "dependencies": { "micromark-util-chunked": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg=="],
"micromark-util-decode-numeric-character-reference": ["micromark-util-decode-numeric-character-reference@2.0.2", "", { "dependencies": { "micromark-util-symbol": "^2.0.0" } }, "sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw=="],
"micromark-util-decode-string": ["micromark-util-decode-string@2.0.1", "", { "dependencies": { "decode-named-character-reference": "^1.0.0", "micromark-util-character": "^2.0.0", "micromark-util-decode-numeric-character-reference": "^2.0.0", "micromark-util-symbol": "^2.0.0" } }, "sha512-nDV/77Fj6eH1ynwscYTOsbK7rR//Uj0bZXBwJZRfaLEJ1iGBR6kIfNmlNqaqJf649EP0F3NWNdeJi03elllNUQ=="],
"micromark-util-encode": ["micromark-util-encode@2.0.1", "", {}, "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw=="],
"micromark-util-html-tag-name": ["micromark-util-html-tag-name@2.0.1", "", {}, "sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA=="],
"micromark-util-normalize-identifier": ["micromark-util-normalize-identifier@2.0.1", "", { "dependencies": { "micromark-util-symbol": "^2.0.0" } }, "sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q=="],
"micromark-util-resolve-all": ["micromark-util-resolve-all@2.0.1", "", { "dependencies": { "micromark-util-types": "^2.0.0" } }, "sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg=="],
"micromark-util-sanitize-uri": ["micromark-util-sanitize-uri@2.0.1", "", { "dependencies": { "micromark-util-character": "^2.0.0", "micromark-util-encode": "^2.0.0", "micromark-util-symbol": "^2.0.0" } }, "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ=="],
"micromark-util-subtokenize": ["micromark-util-subtokenize@2.1.0", "", { "dependencies": { "devlop": "^1.0.0", "micromark-util-chunked": "^2.0.0", "micromark-util-symbol": "^2.0.0", "micromark-util-types": "^2.0.0" } }, "sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA=="],
"micromark-util-symbol": ["micromark-util-symbol@2.0.1", "", {}, "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q=="],
"micromark-util-types": ["micromark-util-types@2.0.2", "", {}, "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA=="],
"mime-db": ["mime-db@1.54.0", "", {}, "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ=="],
"mime-types": ["mime-types@3.0.2", "", { "dependencies": { "mime-db": "^1.54.0" } }, "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A=="],
"mimic-response": ["mimic-response@3.1.0", "", {}, "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ=="],
"minimatch": ["minimatch@10.2.5", "", { "dependencies": { "brace-expansion": "^5.0.5" } }, "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg=="],
"minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="],
"mkdirp-classic": ["mkdirp-classic@0.5.3", "", {}, "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A=="],
@@ -510,6 +867,8 @@
"napi-build-utils": ["napi-build-utils@2.0.0", "", {}, "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA=="],
"natural-compare": ["natural-compare@1.4.0", "", {}, "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw=="],
"negotiator": ["negotiator@1.0.0", "", {}, "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg=="],
"node-abi": ["node-abi@3.89.0", "", { "dependencies": { "semver": "^7.3.5" } }, "sha512-6u9UwL0HlAl21+agMN3YAMXcKByMqwGx+pq+P76vii5f7hTPtKDp08/H9py6DY+cfDw7kQNTGEj/rly3IgbNQA=="],
@@ -526,8 +885,18 @@
"once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="],
"optionator": ["optionator@0.9.4", "", { "dependencies": { "deep-is": "^0.1.3", "fast-levenshtein": "^2.0.6", "levn": "^0.4.1", "prelude-ls": "^1.2.1", "type-check": "^0.4.0", "word-wrap": "^1.2.5" } }, "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g=="],
"p-limit": ["p-limit@3.1.0", "", { "dependencies": { "yocto-queue": "^0.1.0" } }, "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ=="],
"p-locate": ["p-locate@5.0.0", "", { "dependencies": { "p-limit": "^3.0.2" } }, "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw=="],
"parse-entities": ["parse-entities@4.0.2", "", { "dependencies": { "@types/unist": "^2.0.0", "character-entities-legacy": "^3.0.0", "character-reference-invalid": "^2.0.0", "decode-named-character-reference": "^1.0.0", "is-alphanumerical": "^2.0.0", "is-decimal": "^2.0.0", "is-hexadecimal": "^2.0.0" } }, "sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw=="],
"parseurl": ["parseurl@1.3.3", "", {}, "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ=="],
"path-exists": ["path-exists@4.0.0", "", {}, "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w=="],
"path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="],
"path-to-regexp": ["path-to-regexp@8.3.0", "", {}, "sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA=="],
@@ -548,18 +917,28 @@
"pkce-challenge": ["pkce-challenge@5.0.1", "", {}, "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ=="],
"playwright": ["playwright@1.59.1", "", { "dependencies": { "playwright-core": "1.59.1" }, "optionalDependencies": { "fsevents": "2.3.2" }, "bin": { "playwright": "cli.js" } }, "sha512-C8oWjPR3F81yljW9o5OxcWzfh6avkVwDD2VYdwIGqTkl+OGFISgypqzfu7dOe4QNLL2aqcWBmI3PMtLIK233lw=="],
"playwright-core": ["playwright-core@1.59.1", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg=="],
"postcss": ["postcss@8.5.10", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ=="],
"prebuild-install": ["prebuild-install@7.1.3", "", { "dependencies": { "detect-libc": "^2.0.0", "expand-template": "^2.0.3", "github-from-package": "0.0.0", "minimist": "^1.2.3", "mkdirp-classic": "^0.5.3", "napi-build-utils": "^2.0.0", "node-abi": "^3.3.0", "pump": "^3.0.0", "rc": "^1.2.7", "simple-get": "^4.0.0", "tar-fs": "^2.0.0", "tunnel-agent": "^0.6.0" }, "bin": { "prebuild-install": "bin.js" } }, "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug=="],
"prelude-ls": ["prelude-ls@1.2.1", "", {}, "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g=="],
"prettier": ["prettier@3.8.1", "", { "bin": "bin/prettier.cjs" }, "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg=="],
"process-warning": ["process-warning@5.0.0", "", {}, "sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA=="],
"property-information": ["property-information@7.1.0", "", {}, "sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ=="],
"proxy-addr": ["proxy-addr@2.0.7", "", { "dependencies": { "forwarded": "0.2.0", "ipaddr.js": "1.9.1" } }, "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg=="],
"pump": ["pump@3.0.3", "", { "dependencies": { "end-of-stream": "^1.1.0", "once": "^1.3.1" } }, "sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA=="],
"punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="],
"qs": ["qs@6.15.0", "", { "dependencies": { "side-channel": "^1.1.0" } }, "sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ=="],
"quick-format-unescaped": ["quick-format-unescaped@4.0.4", "", {}, "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg=="],
@@ -574,10 +953,20 @@
"react-dom": ["react-dom@19.2.5", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.5" } }, "sha512-J5bAZz+DXMMwW/wV3xzKke59Af6CHY7G4uYLN1OvBcKEsWOs4pQExj86BBKamxl/Ik5bx9whOrvBlSDfWzgSag=="],
"react-markdown": ["react-markdown@10.1.0", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "devlop": "^1.0.0", "hast-util-to-jsx-runtime": "^2.0.0", "html-url-attributes": "^3.0.0", "mdast-util-to-hast": "^13.0.0", "remark-parse": "^11.0.0", "remark-rehype": "^11.0.0", "unified": "^11.0.0", "unist-util-visit": "^5.0.0", "vfile": "^6.0.0" }, "peerDependencies": { "@types/react": ">=18", "react": ">=18" } }, "sha512-qKxVopLT/TyA6BX3Ue5NwabOsAzm0Q7kAPwq6L+wWDwisYs7R8vZ0nRXqq6rkueboxpkjvLGU9fWifiX/ZZFxQ=="],
"readable-stream": ["readable-stream@3.6.2", "", { "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", "util-deprecate": "^1.0.1" } }, "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA=="],
"real-require": ["real-require@0.2.0", "", {}, "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg=="],
"remark-gfm": ["remark-gfm@4.0.1", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-gfm": "^3.0.0", "micromark-extension-gfm": "^3.0.0", "remark-parse": "^11.0.0", "remark-stringify": "^11.0.0", "unified": "^11.0.0" } }, "sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg=="],
"remark-parse": ["remark-parse@11.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-from-markdown": "^2.0.0", "micromark-util-types": "^2.0.0", "unified": "^11.0.0" } }, "sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA=="],
"remark-rehype": ["remark-rehype@11.1.2", "", { "dependencies": { "@types/hast": "^3.0.0", "@types/mdast": "^4.0.0", "mdast-util-to-hast": "^13.0.0", "unified": "^11.0.0", "vfile": "^6.0.0" } }, "sha512-Dh7l57ianaEoIpzbp0PC9UKAdCSVklD8E5Rpw7ETfbTl3FqcOOgq5q2LVDhgGCkaBv7p24JXikPdvhhmHvKMsw=="],
"remark-stringify": ["remark-stringify@11.0.0", "", { "dependencies": { "@types/mdast": "^4.0.0", "mdast-util-to-markdown": "^2.0.0", "unified": "^11.0.0" } }, "sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw=="],
"require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="],
"resolve-pkg-maps": ["resolve-pkg-maps@1.0.0", "", {}, "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw=="],
@@ -628,18 +1017,28 @@
"source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
"space-separated-tokens": ["space-separated-tokens@2.0.2", "", {}, "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q=="],
"split2": ["split2@4.2.0", "", {}, "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg=="],
"stackback": ["stackback@0.0.2", "", {}, "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw=="],
"standardwebhooks": ["standardwebhooks@1.0.0", "", { "dependencies": { "@stablelib/base64": "^1.0.0", "fast-sha256": "^1.3.0" } }, "sha512-BbHGOQK9olHPMvQNHWul6MYlrRTAOKn03rOe4A8O3CLWhNf4YHBqq2HJKKC+sfqpxiBY52pNeesD6jIiLDz8jg=="],
"statuses": ["statuses@2.0.2", "", {}, "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw=="],
"std-env": ["std-env@3.10.0", "", {}, "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg=="],
"string_decoder": ["string_decoder@1.3.0", "", { "dependencies": { "safe-buffer": "~5.2.0" } }, "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA=="],
"stringify-entities": ["stringify-entities@4.0.4", "", { "dependencies": { "character-entities-html4": "^2.0.0", "character-entities-legacy": "^3.0.0" } }, "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg=="],
"strip-json-comments": ["strip-json-comments@5.0.3", "", {}, "sha512-1tB5mhVo7U+ETBKNf92xT4hrQa3pm0MZ0PQvuDnWgAAGHDsfp4lPSpiS6psrSiet87wyGPh9ft6wmhOMQ0hDiw=="],
"style-to-js": ["style-to-js@1.1.21", "", { "dependencies": { "style-to-object": "1.0.14" } }, "sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ=="],
"style-to-object": ["style-to-object@1.0.14", "", { "dependencies": { "inline-style-parser": "0.2.7" } }, "sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw=="],
"supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="],
"tar-fs": ["tar-fs@2.1.4", "", { "dependencies": { "chownr": "^1.1.1", "mkdirp-classic": "^0.5.2", "pump": "^3.0.0", "tar-stream": "^2.1.4" } }, "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ=="],
@@ -658,6 +1057,14 @@
"toidentifier": ["toidentifier@1.0.1", "", {}, "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA=="],
"trim-lines": ["trim-lines@3.0.1", "", {}, "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg=="],
"trough": ["trough@2.2.0", "", {}, "sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw=="],
"ts-algebra": ["ts-algebra@2.0.0", "", {}, "sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw=="],
"ts-api-utils": ["ts-api-utils@2.5.0", "", { "peerDependencies": { "typescript": ">=4.8.4" } }, "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA=="],
"ts-mixer": ["ts-mixer@6.0.4", "", {}, "sha512-ufKpbmrugz5Aou4wcr5Wc1UUFWOLhq+Fm6qa6P0w0K5Qw2yhaUoiWszhCVuNQyNwrlGiscHOmqYoAox1PtvgjA=="],
"tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
@@ -666,20 +1073,42 @@
"tunnel-agent": ["tunnel-agent@0.6.0", "", { "dependencies": { "safe-buffer": "^5.0.1" } }, "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w=="],
"type-check": ["type-check@0.4.0", "", { "dependencies": { "prelude-ls": "^1.2.1" } }, "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew=="],
"type-is": ["type-is@2.0.1", "", { "dependencies": { "content-type": "^1.0.5", "media-typer": "^1.1.0", "mime-types": "^3.0.0" } }, "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw=="],
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
"typescript-eslint": ["typescript-eslint@8.59.0", "", { "dependencies": { "@typescript-eslint/eslint-plugin": "8.59.0", "@typescript-eslint/parser": "8.59.0", "@typescript-eslint/typescript-estree": "8.59.0", "@typescript-eslint/utils": "8.59.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-BU3ONW9X+v90EcCH9ZS6LMackcVtxRLlI3XrYyqZIwVSHIk7Qf7bFw1z0M9Q0IUxhTMZCf8piY9hTYaNEIASrw=="],
"undici": ["undici@6.21.3", "", {}, "sha512-gBLkYIlEnSp8pFbT64yFgGE6UIB9tAkhukC23PmMDCe5Nd+cRqKxSjw5y54MK2AZMgZfJWMaNE4nYUHgi1XEOw=="],
"undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="],
"unified": ["unified@11.0.5", "", { "dependencies": { "@types/unist": "^3.0.0", "bail": "^2.0.0", "devlop": "^1.0.0", "extend": "^3.0.0", "is-plain-obj": "^4.0.0", "trough": "^2.0.0", "vfile": "^6.0.0" } }, "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA=="],
"unist-util-is": ["unist-util-is@6.0.1", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g=="],
"unist-util-position": ["unist-util-position@5.0.0", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA=="],
"unist-util-stringify-position": ["unist-util-stringify-position@4.0.0", "", { "dependencies": { "@types/unist": "^3.0.0" } }, "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ=="],
"unist-util-visit": ["unist-util-visit@5.1.0", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0", "unist-util-visit-parents": "^6.0.0" } }, "sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg=="],
"unist-util-visit-parents": ["unist-util-visit-parents@6.0.2", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-is": "^6.0.0" } }, "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ=="],
"unpipe": ["unpipe@1.0.0", "", {}, "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ=="],
"uri-js": ["uri-js@4.4.1", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="],
"util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="],
"vary": ["vary@1.1.2", "", {}, "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg=="],
"vfile": ["vfile@6.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "vfile-message": "^4.0.0" } }, "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q=="],
"vfile-message": ["vfile-message@4.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-stringify-position": "^4.0.0" } }, "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw=="],
"vite": ["vite@8.0.10", "", { "dependencies": { "lightningcss": "^1.32.0", "picomatch": "^4.0.4", "postcss": "^8.5.10", "rolldown": "1.0.0-rc.17", "tinyglobby": "^0.2.16" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "@vitejs/devtools": "^0.1.0", "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "@vitejs/devtools", "esbuild", "jiti", "less", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-rZuUu9j6J5uotLDs+cAA4O5H4K1SfPliUlQwqa6YEwSrWDZzP4rhm00oJR5snMewjxF5V/K3D4kctsUTsIU9Mw=="],
"vitest": ["vitest@4.0.18", "", { "dependencies": { "@vitest/expect": "4.0.18", "@vitest/mocker": "4.0.18", "@vitest/pretty-format": "4.0.18", "@vitest/runner": "4.0.18", "@vitest/snapshot": "4.0.18", "@vitest/spy": "4.0.18", "@vitest/utils": "4.0.18", "es-module-lexer": "^1.7.0", "expect-type": "^1.2.2", "magic-string": "^0.30.21", "obug": "^2.1.1", "pathe": "^2.0.3", "picomatch": "^4.0.3", "std-env": "^3.10.0", "tinybench": "^2.9.0", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", "tinyrainbow": "^3.0.3", "vite": "^6.0.0 || ^7.0.0", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", "@vitest/browser-playwright": "4.0.18", "@vitest/browser-preview": "4.0.18", "@vitest/browser-webdriverio": "4.0.18", "@vitest/ui": "4.0.18", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@opentelemetry/api", "@vitest/browser-playwright", "@vitest/browser-preview", "@vitest/browser-webdriverio", "@vitest/ui", "happy-dom", "jsdom"], "bin": "vitest.mjs" }, "sha512-hOQuK7h0FGKgBAas7v0mSAsnvrIgAvWmRFjmzpJ7SwFHH3g1k2u37JtYwOwmEKhK6ZO3v9ggDBBm0La1LCK4uQ=="],
@@ -688,32 +1117,68 @@
"why-is-node-running": ["why-is-node-running@2.3.0", "", { "dependencies": { "siginfo": "^2.0.0", "stackback": "0.0.2" }, "bin": "cli.js" }, "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w=="],
"word-wrap": ["word-wrap@1.2.5", "", {}, "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA=="],
"wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="],
"ws": ["ws@8.19.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg=="],
"yaml": ["yaml@2.8.2", "", { "bin": "bin.mjs" }, "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A=="],
"yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="],
"zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="],
"zod-to-json-schema": ["zod-to-json-schema@3.25.1", "", { "peerDependencies": { "zod": "^3.25 || ^4" } }, "sha512-pM/SU9d3YAggzi6MtR4h7ruuQlqKtad8e9S0fmxcMi+ueAK5Korys/aWcV9LIIHTVbj01NdzxcnXSN+O74ZIVA=="],
"zwitch": ["zwitch@2.0.4", "", {}, "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A=="],
"@discordjs/rest/@discordjs/collection": ["@discordjs/collection@2.1.1", "", {}, "sha512-LiSusze9Tc7qF03sLCujF5iZp7K+vRNEDBZ86FT9aQAv3vxMLihUvKvpsCWiQ2DJq1tVckopKm1rxomgNUc9hg=="],
"@discordjs/ws/@discordjs/collection": ["@discordjs/collection@2.1.1", "", {}, "sha512-LiSusze9Tc7qF03sLCujF5iZp7K+vRNEDBZ86FT9aQAv3vxMLihUvKvpsCWiQ2DJq1tVckopKm1rxomgNUc9hg=="],
"@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="],
"@modelcontextprotocol/sdk/ajv": ["ajv@8.18.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A=="],
"@typescript-eslint/eslint-plugin/ignore": ["ignore@7.0.5", "", {}, "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg=="],
"ajv-formats/ajv": ["ajv@8.18.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A=="],
"ejclaw-agent-runner/@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.29.0", "", { "dependencies": { "@hono/node-server": "^1.19.9", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.2.1", "express-rate-limit": "^8.2.1", "hono": "^4.11.4", "jose": "^6.1.3", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", "zod-to-json-schema": "^3.25.1" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ=="],
"mdast-util-find-and-replace/escape-string-regexp": ["escape-string-regexp@5.0.0", "", {}, "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw=="],
"parse-entities/@types/unist": ["@types/unist@2.0.11", "", {}, "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA=="],
"pino-pretty/pino-abstract-transport": ["pino-abstract-transport@3.0.0", "", { "dependencies": { "split2": "^4.0.0" } }, "sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg=="],
"rc/strip-json-comments": ["strip-json-comments@2.0.1", "", {}, "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ=="],
"rolldown/@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-rc.17", "", {}, "sha512-n8iosDOt6Ig1UhJ2AYqoIhHWh/isz0xpicHTzpKBeotdVsTEcxsSA/i3EVM7gQAj0rU27OLAxCjzlj15IWY7bg=="],
"rollup/fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
"tsx/fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
"vite/fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
"vitest/picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="],
"vitest/tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="],
"vitest/vite": ["vite@7.3.1", "", { "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser"], "bin": "bin/vite.js" }, "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA=="],
"@modelcontextprotocol/sdk/ajv/json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="],
"ajv-formats/ajv/json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="],
"ejclaw-agent-runner/@modelcontextprotocol/sdk/ajv": ["ajv@8.18.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A=="],
"vitest/vite/fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
"vitest/vite/postcss": ["postcss@8.5.6", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg=="],
"ejclaw-agent-runner/@modelcontextprotocol/sdk/ajv/json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="],
}
}

View File

@@ -0,0 +1,94 @@
# Android / Meta Ray-Ban Display Plan
## Goal
Build a personal Android companion for EJClaw that reuses the existing server and dashboard APIs, then extend it to Meta Ray-Ban Display through the Meta Wearables Device Access Toolkit (DAT) when the device developer preview is available to the account.
## Current Platform Facts
- EJClaw is a Bun/Node host service. Android should be a thin client, not a port of the runtime.
- Meta DAT is in developer preview and provides Android SDK artifacts and sample app guidance through the public `facebook/meta-wearables-dat-android` repo.
- DAT preview allows SDK/documentation access and sharing builds with organization/team testers; general public publishing is limited during preview.
- Ray-Ban Display currently has two developer paths: Web Apps Dev Mode for HTML/CSS/JS standalone display apps, and DAT Display Developer Preview for extending Android/iOS apps onto the display.
Sources:
- https://developers.meta.com/blog/introducing-meta-wearables-device-access-toolkit/
- https://github.com/facebook/meta-wearables-dat-android
- https://www.levinriegner.com/news/l-r-joins-meta-to-open-ray-ban-display-to-developers/
## MVP Shape
1. Keep EJClaw running on the existing server.
2. Expose the web dashboard only through localhost, VPN, or a private tunnel.
3. Require `WEB_DASHBOARD_TOKEN` for `/api/*` before phone clients connect.
4. Build an Android app that talks to the existing dashboard API:
- `GET /api/overview`
- `GET /api/rooms-timeline`
- `GET /api/rooms/:jid/timeline`
- `POST /api/rooms/:jid/messages`
5. Add DAT only for the display surface:
- short current-room status
- latest assistant output
- progress text
- quick reply / send command entry
## Security Model
Do not expose EJClaw directly to the internet without a private transport.
Recommended personal setup:
- `WEB_DASHBOARD_HOST=127.0.0.1` for local-only use, or bind through Tailscale / VPN / SSH tunnel.
- Set `WEB_DASHBOARD_TOKEN` and send it from Android as `Authorization: Bearer <token>`.
- Keep restart/settings/account routes available only over the same protected API; do not add unauthenticated mobile-only shortcuts.
## Android App Plan
Use Kotlin for the first native client. The app can be small:
- `EJClawApi`: HTTP client with bearer token.
- `RoomListViewModel`: fetches `/api/rooms-timeline` on interval.
- `RoomThreadViewModel`: fetches room timeline and sends messages.
- `DisplayBridge`: DAT integration boundary, initially hidden behind an interface so the app can run without glasses.
The first build does not need DAT to validate EJClaw connectivity. DAT comes after the phone client can read rooms and send one message.
## DAT Integration Boundary
Keep Meta SDK code isolated under an Android module/package such as:
```text
apps/android/app/src/main/java/.../display/
```
Suggested interfaces:
```kotlin
interface DisplaySurface {
fun showStatus(roomName: String, state: String, progress: String?)
fun showMessage(roomName: String, text: String)
}
class NoopDisplaySurface : DisplaySurface { ... }
class MetaDatDisplaySurface : DisplaySurface { ... }
```
This avoids blocking normal phone testing when DAT access, glasses firmware, region support, or Developer Mode is not ready.
## Open Questions
- Is the Meta Wearables Developer Center account approved for DAT Display Developer Preview?
- Is the target phone able to pair with Ray-Ban Display in the current region/account setup?
- Should first Android build be native Kotlin UI or a minimal WebView wrapper around the existing dashboard?
- Which private transport will be used: Tailscale, SSH tunnel, Cloudflare Tunnel with access policy, or LAN only?
## Next Step
Implement the Android MVP only after the API token guard is merged and deployed. The initial Android client should prove:
1. Authenticate to EJClaw.
2. List rooms.
3. Open one room timeline.
4. Send one message.
5. Run without DAT using `NoopDisplaySurface`.

View File

@@ -45,7 +45,7 @@ There are also several signs that the code is already moving in the right direct
- `src/message-runtime-*` is already split by responsibility.
- `src/provider-retry.ts` is a good example of extracting real duplication.
- `src/paired-execution-context-shared.ts` is already acting as a central status-transition guard.
- `src/paired-task-status.ts` is already acting as a central status-transition guard.
- `setup/register.ts` is already much simpler than older compatibility assumptions would suggest.
So the right move is **targeted simplification**, not broad reorganization.
@@ -533,7 +533,7 @@ This is not a request to rewrite all domain types. It is a request to stop overl
### Status transition rule
All paired-task status writes should continue converging on `src/paired-execution-context-shared.ts`. Do not allow status mutation rules to scatter again.
All paired-task status writes should continue converging on `src/paired-task-status.ts`. Do not allow status mutation rules to scatter again.
### Acceptance criteria

View File

@@ -27,32 +27,35 @@ DISCORD_ARBITER_BOT_TOKEN=
# 역할별 provider
OWNER_AGENT_TYPE=codex
REVIEWER_AGENT_TYPE=claude-code
# ARBITER_AGENT_TYPE=claude-code
ARBITER_AGENT_TYPE=codex
# provider 기본 모델
CLAUDE_MODEL=claude-opus-4-6
# 공식 Claude API 모델 ID입니다.
CLAUDE_MODEL=claude-opus-4-8
CLAUDE_EFFORT=high
CLAUDE_THINKING=adaptive
CODEX_MODEL=gpt-5.4
CODEX_MODEL=gpt-5.5
CODEX_EFFORT=xhigh
# 역할별 override
OWNER_MODEL=gpt-5.4
OWNER_MODEL=gpt-5.5
OWNER_EFFORT=xhigh
OWNER_FALLBACK_ENABLED=true
REVIEWER_MODEL=claude-opus-4-6
REVIEWER_MODEL=claude-opus-4-8
REVIEWER_EFFORT=high
REVIEWER_FALLBACK_ENABLED=true
ARBITER_MODEL=claude-sonnet-4-6
ARBITER_EFFORT=high
ARBITER_MODEL=gpt-5.5
ARBITER_EFFORT=xhigh
ARBITER_FALLBACK_ENABLED=true
```
설명:
- reviewer / arbiter provider 선택은 **전역 설정**
- 역할별 `*_MODEL`은 해당 역할의 provider에 맞춰 주입됩니다. `codex` 역할에는 Codex 모델을, `claude-code` 역할에는 Claude 모델을 넣어야 합니다.
- 예를 들어 `ARBITER_AGENT_TYPE=claude-code`로 바꾸면 그때 `ARBITER_MODEL=claude-opus-4-8`을 사용할 수 있습니다.
- 특정 방에서만 reviewer를 Codex로 고르는 기능은 없음
- room-level `agentConfig``claudeModel`, `codexModel`은 역할별이 아니라 provider별 override
- `ARBITER_AGENT_TYPE`은 옵션이며, 설정하지 않으면 arbiter는 비활성 상태입니다
@@ -93,19 +96,21 @@ GROQ_API_KEY=
MOA_ENABLED=true
MOA_REF_MODELS=kimi,glm
MOA_KIMI_MODEL=kimi-k2.5
MOA_KIMI_MODEL=kimi-k2.6
MOA_KIMI_BASE_URL=https://api.kimi.com/coding
MOA_KIMI_API_KEY=sk-kimi-xxx
MOA_KIMI_API_FORMAT=anthropic
MOA_GLM_MODEL=glm-4-plus
MOA_GLM_BASE_URL=https://open.bigmodel.cn/api/paas/v4
MOA_GLM_MODEL=glm-5.1
MOA_GLM_BASE_URL=https://open.bigmodel.cn/api/anthropic
MOA_GLM_API_KEY=xxx
MOA_GLM_API_FORMAT=anthropic
```
MoA는 arbiter 판정 전에 외부 모델 의견을 수집해 prompt에 주입합니다.
`API_FORMAT` 같은 항목은 `.env.example`의 최소 예시에 없는 운영 확장 키입니다.
대시보드 설정 화면에서 `MOA_ENABLED`, `MOA_REF_MODELS`, 모델명, base URL,
API format, API key 교체와 연결 테스트를 관리할 수 있습니다. 저장 후에는
스택 재시작이 필요합니다.
## 운영 / 배포 관련 설정
@@ -116,24 +121,35 @@ SESSION_COMMAND_ALLOWED_SENDERS=
MAX_CONCURRENT_AGENTS=5
```
## Discord 이미지 첨부 allowlist
```bash
EJCLAW_ATTACHMENT_ALLOWED_DIRS=~/Pictures/Screenshots,~/Downloads/ejclaw-images
```
- 콤마 또는 플랫폼 path delimiter(Linux는 `:`)로 여러 폴더를 지정합니다.
- 지정한 폴더 하위의 PNG/JPEG/GIF/WebP/BMP만 Discord 첨부 후보가 됩니다.
- `realpath`, 이미지 signature, size cap 검증은 그대로 적용됩니다.
- `/home/**` 전체보다 자주 쓰는 스크린샷/이미지 출력 폴더만 추가하는 것을 권장합니다.
- `ASSISTANT_NAME`은 owner trigger 기본 이름을 만듭니다
- paired room에서도 사용자 진입점은 owner가 기준입니다
- status dashboard와 session command는 선택 설정입니다
## 디버깅 경로
| 항목 | 경로 / 명령 |
| --- | --- |
| DB | `store/messages.db` |
| 서비스 로그 | `journalctl --user -u ejclaw -f` |
| room 로그 | `groups/{folder}/logs/` |
| owner/reviewer 세션 | `data/sessions/{folder}*` |
| owner worktree | `data/workspaces/{folder}/owner` |
| Claude 플랫폼 프롬프트 | `prompts/claude-platform.md` |
| reviewer 프롬프트 | `prompts/claude-paired-room.md` |
| arbiter 프롬프트 | `prompts/arbiter-paired-room.md` |
| Codex 플랫폼 프롬프트 | `prompts/codex-platform.md` |
| 글로벌 메모리 | `groups/global/CLAUDE.md` |
| 항목 | 경로 / 명령 |
| ---------------------- | -------------------------------- |
| DB | `store/messages.db` |
| 서비스 로그 | `journalctl --user -u ejclaw -f` |
| room 로그 | `groups/{folder}/logs/` |
| owner/reviewer 세션 | `data/sessions/{folder}*` |
| owner worktree | `data/workspaces/{folder}/owner` |
| Claude 플랫폼 프롬프트 | `prompts/claude-platform.md` |
| reviewer 프롬프트 | `prompts/claude-paired-room.md` |
| arbiter 프롬프트 | `prompts/arbiter-paired-room.md` |
| Codex 플랫폼 프롬프트 | `prompts/codex-platform.md` |
| 글로벌 메모리 | `groups/global/CLAUDE.md` |
## 문서와 실제 코드의 우선순위

71
eslint.config.js Normal file
View File

@@ -0,0 +1,71 @@
import js from '@eslint/js';
import globals from 'globals';
import tseslint from 'typescript-eslint';
export default tseslint.config(
{
ignores: [
'dist/**',
'node_modules/**',
'apps/dashboard/dist/**',
'runners/*/dist/**',
'runners/*/node_modules/**',
'store/**',
'store-*/**',
'data/**',
'data-*/**',
'coverage/**',
'tmp/**',
'cache/**',
],
},
js.configs.recommended,
...tseslint.configs.recommended,
{
files: ['**/*.{ts,tsx,js,jsx}'],
languageOptions: {
globals: {
...globals.browser,
...globals.node,
...globals.es2022,
Bun: 'readonly',
},
},
rules: {
'@typescript-eslint/no-explicit-any': 'off',
'@typescript-eslint/no-unused-vars': [
'warn',
{
argsIgnorePattern: '^_',
caughtErrorsIgnorePattern: '^_',
varsIgnorePattern: '^_',
},
],
'@typescript-eslint/no-empty-object-type': 'warn',
complexity: ['warn', { max: 35 }],
'max-depth': ['warn', 5],
'max-lines-per-function': [
'warn',
{
max: 180,
skipBlankLines: true,
skipComments: true,
},
],
'max-params': ['warn', 7],
'no-case-declarations': 'warn',
'no-console': 'off',
'no-control-regex': 'off',
'no-unsafe-finally': 'warn',
'no-useless-assignment': 'warn',
'no-useless-escape': 'warn',
'preserve-caught-error': 'warn',
},
},
{
files: ['**/*.test.ts', '**/*.test.tsx', 'scripts/**/*.ts'],
rules: {
'max-lines-per-function': 'off',
},
},
);

View File

@@ -5,25 +5,37 @@
"packageManager": "bun@1.3.11",
"type": "module",
"main": "dist/index.js",
"workspaces": [
"runners/shared",
"runners/agent-runner",
"runners/codex-runner"
],
"scripts": {
"build": "tsc",
"dashboard:dev": "vite --config apps/dashboard/vite.config.ts",
"dashboard:build": "vite build --config apps/dashboard/vite.config.ts",
"dashboard:preview": "vite preview --config apps/dashboard/vite.config.ts",
"install:runners": "bun install --frozen-lockfile --cwd runners/shared && bun install --frozen-lockfile --cwd runners/agent-runner && bun install --frozen-lockfile --cwd runners/codex-runner",
"dashboard:ux": "bun scripts/dashboard-ux.ts",
"android:build": "cd apps/android && ./gradlew assembleDebug",
"android:install": "cd apps/android && ./gradlew installDebug",
"install:runners": "bun install --frozen-lockfile",
"build:runners": "bun run install:runners && bun run --cwd runners/shared build && bun run --cwd runners/agent-runner build && bun run --cwd runners/codex-runner build",
"build:all": "bun run build && bun run dashboard:build && bun run build:runners",
"build:runtime": "bun run build:all",
"verify:dist": "bash scripts/check-dist-fresh.sh",
"deploy": "git pull --ff-only && bun run build:all && bun run verify:dist && bun setup/index.ts --step migrate-room-registrations && systemctl --user restart ejclaw",
"deploy": "git pull --ff-only && bun install --frozen-lockfile && bun run build:all && bun run verify:dist && bun setup/index.ts --step migrate-room-registrations && systemctl --user restart ejclaw",
"start": "bun dist/index.js",
"dev": "bun --watch src/index.ts",
"test": "vitest run",
"typecheck": "tsc --noEmit",
"typecheck:all": "bun run install:runners && bunx tsc --noEmit -p tsconfig.check.json && bunx tsc --noEmit -p apps/dashboard/tsconfig.json && bunx tsc --noEmit -p runners/shared/tsconfig.json && bunx tsc --noEmit -p runners/agent-runner/tsconfig.json && bunx tsc --noEmit -p runners/codex-runner/tsconfig.json",
"format:fix": "prettier --write \"{src,setup,shared,test,scripts,apps}/**/*.{ts,tsx,js,jsx,css,html}\" \"runners/**/*.{ts,js}\" \"vitest*.ts\"",
"format:check": "prettier --check \"{src,setup,shared,test,scripts,apps}/**/*.{ts,tsx,js,jsx,css,html}\" \"runners/**/*.{ts,js}\" \"vitest*.ts\"",
"check": "bun run format:check && bun run typecheck:all && bun run test && bun run build:all",
"lint": "eslint \"{src,setup,scripts,apps/dashboard/src,runners}/**/*.{ts,tsx,js,jsx}\" \"vitest*.ts\" --quiet",
"lint:report": "eslint \"{src,setup,scripts,apps/dashboard/src,runners}/**/*.{ts,tsx,js,jsx}\" \"vitest*.ts\"",
"lint:complexity": "bun scripts/check-code-quality.ts",
"quality:check": "bun run lint && bun run lint:complexity",
"format:fix": "prettier --write \"{src,setup,shared,test,scripts,apps}/**/*.{ts,tsx,js,jsx,css,html}\" \"runners/**/*.{ts,js}\" \"vitest*.ts\" \"eslint.config.js\"",
"format:check": "prettier --check \"{src,setup,shared,test,scripts,apps}/**/*.{ts,tsx,js,jsx,css,html}\" \"runners/**/*.{ts,js}\" \"vitest*.ts\" \"eslint.config.js\"",
"check": "bun run format:check && bun run quality:check && bun run typecheck:all && bun run test && bun run build:all",
"prepare": "husky",
"setup": "bun setup/index.ts"
},
@@ -31,15 +43,20 @@
"@modelcontextprotocol/sdk": "^1.27.1",
"cron-parser": "^5.5.0",
"discord.js": "^14.18.0",
"ejclaw-runners-shared": "file:./runners/shared",
"ejclaw-runners-shared": "workspace:*",
"lucide-react": "^1.11.0",
"pino": "^9.6.0",
"pino-pretty": "^13.0.0",
"react": "^19.2.5",
"react-dom": "^19.2.5",
"react-markdown": "^10.1.0",
"remark-gfm": "^4.0.1",
"yaml": "^2.8.2",
"zod": "^4.3.6"
},
"devDependencies": {
"@axe-core/playwright": "^4.11.3",
"@eslint/js": "^10.0.1",
"@types/better-sqlite3": "^7.6.13",
"@types/bun": "^1.3.11",
"@types/node": "^22.10.0",
@@ -48,9 +65,13 @@
"@vitejs/plugin-react": "^6.0.1",
"@vitest/coverage-v8": "^4.0.18",
"better-sqlite3": "^12.8.0",
"eslint": "^10.2.1",
"globals": "^17.5.0",
"husky": "^9.1.7",
"playwright": "^1.59.1",
"prettier": "^3.8.1",
"typescript": "^5.7.0",
"typescript-eslint": "^8.59.0",
"vite": "^8.0.10",
"vitest": "^4.0.18"
},

View File

@@ -20,9 +20,29 @@ Before accepting any proposal, run it through:
Push back with evidence when the owner is wrong. Hold your ground when you are right. Point out logical gaps, missing edge cases, over-engineering. Agree when the owner is genuinely correct.
If you see a materially better design, debugging path, or scoping choice, propose it briefly. Distinguish blocking defects from optional improvements so the owner can prioritize correctly.
## Debugging discipline
For bugs, outages, failed checks, or unexpected behavior:
- Require root-cause evidence before accepting a fix; do not approve symptom patches
- Check the diagnosis against exact error/logs, reproduction path, recent changes, or component-boundary data
- Prefer one clear hypothesis plus the smallest targeted verification over broad rewrites
- Accept retry/reset/routing fixes only when the owner shows why they address the exact failure; flag retry bumps or hidden errors as symptoms
- If the owner repeats the same failed fix path 3 times, name the stagnation pattern and recommend a new direction or arbiter path
## Durable work notes
Accept file-backed notes as support for handoff or planning, but do not treat them as verification.
- Useful notes capture architecture choices, multi-step plans, long debugging evidence, or user-requested design decisions
- Flag notes that are stale, vague, secret-bearing, or process noise for a small hotfix
- Prefer notes that record decisions, tradeoffs, evidence, and next steps; flag pasted transcripts or obvious command lists
- Prefer concise notes in an existing docs/plans location over new workflow directories
## Completion status
**Start your first line** with one of these six statuses. This is required.
If the first visible line is not one of these statuses, the output is invalid; do not put explanations, greetings, or summaries before the status.
- **STEP_DONE** — The current step is acceptable, but the original requested task still has remaining work. Send the task back to the owner without escalating to the arbiter
- **TASK_DONE** — Approved. The owner's work satisfies the full requested task. Include the evidence
@@ -40,8 +60,12 @@ Do not start with non-status review labels like **APPROVE** or **REVISE**. Use *
- Treat `EJCLAW_WORK_DIR` as the canonical verification root for this turn. You may inspect other local paths for context, but final review findings must be re-checked against `EJCLAW_WORK_DIR`
- Do not use a different clone, canonical repo path, or cached session path as the sole basis for `BLOCKED`, `DONE_WITH_CONCERNS`, or change requests. If another path disagrees with `EJCLAW_WORK_DIR`, prefer `EJCLAW_WORK_DIR` and explicitly call out the mismatch
- When test/typecheck/build/lint evidence is needed, prefer the dedicated verification path (`run_verification`) over assuming the reviewer workspace should execute the full project locally
- If direct execution is unavailable, request `run_verification` or owner-provided evidence. Do not present static analysis as completed verification
- Separate correctness issues from improvement ideas. If something is only a better alternative, label it as optional instead of blocking the owner unnecessarily
- Stagnation: **Spinning** (same error 3+), **Oscillation** (alternating approaches), **Diminishing returns** (shrinking improvement), **No progress** (discussion without change) — name the pattern and report: **Status**, **Attempted**, **Recommendation**
- Implementation, commits, and pushes require agreement from both sides. Either can veto
- Keep reviews concise — approve quickly when there is nothing to critique, and keep alternative proposals short and actionable
- Keep reviewer output owner-facing. Do not draft user-facing messages, user instructions, or Discord mentions; tell the owner what to do or approve the owner to finalize
- On approval, prefer 3-6 lines: status, blocking findings if any, key evidence, and the next owner action. Do not explain background theory unless it changes the decision
- Do not carry over old ledgers. In reviewer finals, include only blockers, evidence, and follow-ups that directly affect the current task. Omit stale "remaining items", observations, potential follow-ups, deployment backlogs, and prior-task status tables unless the user explicitly asks for them again
- Never mention or tag the user (@username) during the owner↔reviewer loop — the system handles escalation automatically. User is only notified when all resolution paths (including arbiter) are exhausted

View File

@@ -4,3 +4,13 @@ You have a `send_message` tool that sends a message immediately while you are st
Use it to acknowledge a request before starting longer work.
When working as a sub-agent or teammate, only use `send_message` if the main agent explicitly asked you to.
## Media attachments
When a locally generated image, screenshot, video, audio, or document should appear in Discord, include a `MEDIA:` directive on its own line with an absolute local path:
```text
MEDIA:/absolute/path/preview.mp4
```
`MEDIA:` lines are hidden from the visible message and uploaded as native Discord attachments. Use absolute local paths only, do not repeat the same path in the visible text, and do not use generic markdown links or plain file paths as attachment directives. Supported formats include PNG, JPEG, GIF, WebP, BMP, MP4, MOV, WebM, MP3, WAV, OGG, M4A, FLAC, PDF, ZIP, TXT, Markdown, CSV, and JSON. SVG is not accepted.

View File

@@ -27,3 +27,28 @@ Your output is sent directly to the Discord group.
- For CI/status/watch requests that require future follow-up, schedule `watch_ci`
- Do not use generic recurring task registration from Codex
- If the user wants a reminder or other non-CI recurring task, tell them to ask Claude/클코 to schedule it
## Media attachments
When you need to show a locally generated image, screenshot, video, audio, or document in Discord, include a `MEDIA:` directive on its own line with an absolute local path:
```text
MEDIA:/absolute/path/preview.mp4
```
- `MEDIA:` lines are hidden from the visible message and uploaded as native Discord attachments
- URLs and relative paths are ignored
- Do not repeat the same file path elsewhere in the visible text
- Do not use generic markdown links or plain file paths as attachment directives
- Supported formats include PNG, JPEG, GIF, WebP, BMP, MP4, MOV, WebM, MP3, WAV, OGG, M4A, FLAC, PDF, ZIP, TXT, Markdown, CSV, and JSON. SVG is not accepted.
- Use this for generated images, e2e screenshots, previews, audio samples, and documents; the Discord channel validates and uploads the file
## CI 감시 (watch_ci)
GitHub Actions run 감시는 structured 필드를 우선 사용:
- ci_provider: "github", ci_repo: "owner/repo", ci_run_id: run ID
- 이 조합 → host-driven fast path (LLM 토큰 소모 없음, 15초 polling)
- structured 필드 없이 generic 등록 시 매 tick LLM 실행됨
- ci_pr_number는 아직 미지원
- GitHub 외 CI는 기존 generic 경로 사용

View File

@@ -17,6 +17,26 @@ Before accepting any proposal from the reviewer, run it through:
Challenge the reviewer's reasoning. Point out logical gaps, over-engineering, scope drift. Agree when the work is genuinely correct.
## Debugging discipline
For bugs, outages, failed checks, or unexpected behavior:
- Identify the root-cause before changing code; do not patch symptoms first
- Ground the diagnosis in evidence: exact error/log, reproduction path, recent changes, or component-boundary data
- State one hypothesis and verify it with the smallest targeted test or command
- Example: fixing the failed session/route/classifier is root-cause work; only increasing retries or hiding errors is a symptom patch
- If the same failed fix path repeats 3 times, name the stagnation pattern and recommend a new direction instead of stacking guesses
## Durable work notes
Use short Markdown notes when they materially help handoff or continuity across sessions.
- Good fits: broad architecture choices, multi-step plans, long debugging evidence, or user-requested design notes
- Bad fits: small hotfixes, routine review loops, transient status updates, or notes that only restate chat
- Example good note: decision, tradeoff, evidence, and next step; bad note: pasted status transcript or obvious command list
- Use an existing docs/plans location when present; ask before creating a new docs directory
- Keep notes brief: goal, decisions, evidence, next steps, and exact file/command references
## Completion status
**Start your first line** with one of these six statuses. This is required.

View File

@@ -20,32 +20,19 @@ Do not use markdown headings in chat replies. Keep messages clean and readable f
The group folder may contain a `conversations/` directory with searchable history from earlier sessions. Use it when you need prior context.
## Image attachments
## Media attachments
For locally generated images or e2e screenshots that should appear in Discord, prefer EJClaw structured attachments over prose paths:
For locally generated images, screenshots, videos, audio, or documents that should appear in Discord, include a `MEDIA:` directive on its own line with an absolute local path:
```json
{
"ejclaw": {
"visibility": "public",
"text": "스크린샷을 첨부했습니다.",
"verdict": "done",
"attachments": [
{
"path": "/absolute/path/screenshot.png",
"name": "screenshot.png",
"mime": "image/png"
}
]
}
}
```text
MEDIA:/absolute/path/preview.mp4
```
- When emitting this as your final runner output, emit the JSON envelope directly. Do not wrap it in Markdown fences or add prose outside the JSON.
- Use absolute local paths only
- Do not duplicate the same path in the visible text
- Supported attachment formats are raster image files: PNG, JPEG, GIF, WebP, and BMP. SVG is not accepted.
- The channel harness validates and uploads attachments; plain prose paths are not reliable
- `MEDIA:` lines are hidden from the visible message and uploaded as native Discord attachments
- Use absolute local paths only, and do not repeat the same path elsewhere in the visible text
- Do not rely on generic markdown links or plain file paths for attachments
- Supported formats include PNG, JPEG, GIF, WebP, BMP, MP4, MOV, WebM, MP3, WAV, OGG, M4A, FLAC, PDF, ZIP, TXT, Markdown, CSV, and JSON. SVG is not accepted.
- The channel harness validates and uploads attachments from these media directives
## CI monitoring (watch_ci)

View File

@@ -0,0 +1,195 @@
{
"defaults": {
"maxLines": 900,
"maxFunctionLines": 180,
"maxComplexity": 40,
"maxNesting": 6
},
"testDefaults": {
"maxLines": 1600,
"maxFunctionLines": 260,
"maxComplexity": 55,
"maxNesting": 7
},
"overrides": {
"apps/dashboard/src/App.tsx": {
"maxLines": 684,
"maxFunctionLines": 425,
"maxComplexity": 11,
"maxNesting": 3,
"owner": "dashboard shell hotspot"
},
"apps/dashboard/src/i18n.ts": {
"maxLines": 2156,
"owner": "dashboard i18n hotspot"
},
"apps/dashboard/src/styles.css": {
"maxLines": 4875,
"owner": "dashboard stylesheet hotspot"
},
"runners/agent-runner/test/reviewer-runtime.test.ts": {
"maxFunctionLines": 271,
"owner": "agent runner test hotspot"
},
"runners/shared/test/agent-protocol.test.ts": {
"maxFunctionLines": 279,
"owner": "runner shared test hotspot"
},
"scripts/dashboard-ux.ts": {
"maxLines": 1034,
"owner": "dashboard ux smoke hotspot"
},
"setup/migrate-room-registrations.test.ts": {
"maxFunctionLines": 393,
"owner": "setup test hotspot"
},
"setup/room-registration-state.test.ts": {
"maxFunctionLines": 292,
"owner": "setup test hotspot"
},
"setup/verify-state.test.ts": {
"maxFunctionLines": 297,
"owner": "setup test hotspot"
},
"src/agent-runner-environment.test.ts": {
"maxFunctionLines": 261,
"owner": "agent runner test hotspot"
},
"src/agent-runner-process.ts": {
"maxFunctionLines": 387,
"owner": "agent runner process hotspot"
},
"src/agent-runner.test.ts": {
"maxFunctionLines": 548,
"owner": "agent runner test hotspot"
},
"src/codex-warmup.test.ts": {
"maxFunctionLines": 277,
"owner": "codex warmup test hotspot"
},
"src/db/base-schema.ts": {
"maxFunctionLines": 436,
"owner": "db schema hotspot"
},
"src/db/paired-state.ts": {
"maxLines": 951,
"maxFunctionLines": 217,
"owner": "paired db state hotspot"
},
"src/db/paired-turn-provenance-schema.ts": {
"maxLines": 2062,
"maxFunctionLines": 343,
"owner": "paired turn provenance schema hotspot"
},
"src/db.test.ts": {
"maxLines": 9380,
"maxFunctionLines": 4990,
"maxComplexity": 29,
"maxNesting": 2,
"owner": "db test hotspot"
},
"src/duplicate-suppression.test.ts": {
"maxFunctionLines": 310,
"owner": "duplicate suppression test hotspot"
},
"src/group-queue.test.ts": {
"maxFunctionLines": 720,
"owner": "group queue test hotspot"
},
"src/group-queue.ts": {
"maxLines": 929,
"owner": "group queue hotspot"
},
"src/index.ts": {
"maxFunctionLines": 314,
"owner": "main service entrypoint hotspot"
},
"src/message-agent-executor.test.ts": {
"maxLines": 3847,
"maxFunctionLines": 2500,
"owner": "message executor test hotspot"
},
"src/message-agent-executor.ts": {
"maxFunctionLines": 239,
"owner": "message executor hotspot"
},
"src/message-runtime-queue.test.ts": {
"maxFunctionLines": 366,
"owner": "message runtime queue test hotspot"
},
"src/message-runtime-queue.ts": {
"maxFunctionLines": 228,
"owner": "message runtime queue hotspot"
},
"src/message-runtime-rules.test.ts": {
"maxFunctionLines": 447,
"owner": "message runtime rules test hotspot"
},
"src/message-runtime.test.ts": {
"maxLines": 5840,
"maxFunctionLines": 5506,
"maxComplexity": 7,
"maxNesting": 1,
"owner": "message runtime test hotspot"
},
"src/message-turn-controller.test.ts": {
"maxFunctionLines": 730,
"owner": "message turn controller test hotspot"
},
"src/message-turn-controller.ts": {
"maxLines": 948,
"maxFunctionLines": 204,
"owner": "message turn controller hotspot"
},
"src/paired-execution-context.test.ts": {
"maxFunctionLines": 1269,
"owner": "paired execution context test hotspot"
},
"src/paired-execution-context.ts": {
"maxFunctionLines": 206,
"maxComplexity": 43,
"owner": "paired execution context hotspot"
},
"src/paired-follow-up-scheduler.test.ts": {
"maxFunctionLines": 830,
"owner": "paired follow-up scheduler test hotspot"
},
"src/paired-workspace-manager.test.ts": {
"maxFunctionLines": 1199,
"owner": "paired workspace manager test hotspot"
},
"src/paired-workspace-manager.ts": {
"maxLines": 1056,
"owner": "paired workspace manager hotspot"
},
"src/service-routing.test.ts": {
"maxFunctionLines": 272,
"owner": "service routing test hotspot"
},
"src/streamed-output-evaluator.test.ts": {
"maxFunctionLines": 465,
"owner": "streamed output evaluator test hotspot"
},
"src/task-scheduler.test.ts": {
"maxLines": 1968,
"maxFunctionLines": 1790,
"owner": "task scheduler test hotspot"
},
"src/task-scheduler.ts": {
"maxFunctionLines": 367,
"owner": "task scheduler hotspot"
},
"src/verification.test.ts": {
"maxFunctionLines": 534,
"owner": "verification test hotspot"
},
"src/web-dashboard-data.test.ts": {
"maxFunctionLines": 504,
"owner": "web dashboard data test hotspot"
},
"src/web-dashboard-server.test.ts": {
"maxFunctionLines": 332,
"owner": "web dashboard server test hotspot"
}
}
}

View File

@@ -1,243 +0,0 @@
{
"lockfileVersion": 1,
"configVersion": 0,
"workspaces": {
"": {
"name": "ejclaw-agent-runner",
"dependencies": {
"@anthropic-ai/claude-agent-sdk": "0.2.114",
"@modelcontextprotocol/sdk": "^1.12.1",
"cron-parser": "^5.0.0",
"ejclaw-runners-shared": "file:../shared",
"zod": "^4.0.0",
},
"devDependencies": {
"@types/node": "^22.10.7",
"typescript": "^5.7.3",
},
},
},
"packages": {
"@anthropic-ai/claude-agent-sdk": ["@anthropic-ai/claude-agent-sdk@0.2.114", "", { "dependencies": { "@anthropic-ai/sdk": "^0.81.0", "@modelcontextprotocol/sdk": "^1.29.0" }, "optionalDependencies": { "@anthropic-ai/claude-agent-sdk-darwin-arm64": "0.2.114", "@anthropic-ai/claude-agent-sdk-darwin-x64": "0.2.114", "@anthropic-ai/claude-agent-sdk-linux-arm64": "0.2.114", "@anthropic-ai/claude-agent-sdk-linux-arm64-musl": "0.2.114", "@anthropic-ai/claude-agent-sdk-linux-x64": "0.2.114", "@anthropic-ai/claude-agent-sdk-linux-x64-musl": "0.2.114", "@anthropic-ai/claude-agent-sdk-win32-arm64": "0.2.114", "@anthropic-ai/claude-agent-sdk-win32-x64": "0.2.114" }, "peerDependencies": { "zod": "^4.0.0" } }, "sha512-plJ+j17jew9tDMHir/90hXrwoB8cZ9GrIyG19zIJcFyQ8pVhRXjZRJCtF2ElfPoiwkxMmNu1Klqyui4xP4shPg=="],
"@anthropic-ai/claude-agent-sdk-darwin-arm64": ["@anthropic-ai/claude-agent-sdk-darwin-arm64@0.2.114", "", { "os": "darwin", "cpu": "arm64" }, "sha512-0/6LWrNilWpmiX6Xrj5plsBmCrCdKGERgAlKUZQEJZplnfuweFAJu7WXZB4KBaUpGlPO91zB/yqDh6kp5aZFbA=="],
"@anthropic-ai/claude-agent-sdk-darwin-x64": ["@anthropic-ai/claude-agent-sdk-darwin-x64@0.2.114", "", { "os": "darwin", "cpu": "x64" }, "sha512-sOHxq1rEO/KZg2iEZILTPn62lMRRMPqtxKx41uGLi3xjVDrAej6Ury9dDZjYBKkK9n4kBylXV0Oom2CZ14dDYw=="],
"@anthropic-ai/claude-agent-sdk-linux-arm64": ["@anthropic-ai/claude-agent-sdk-linux-arm64@0.2.114", "", { "os": "linux", "cpu": "arm64" }, "sha512-j/SfEoN6+fyEsp8EuPe+xKcGfsZtaBmdUUH+YSRk5H/lYgy38yNsDhdt+AJMQcdMKfHsiwZ3Y9Ajoe9G9wNwHQ=="],
"@anthropic-ai/claude-agent-sdk-linux-arm64-musl": ["@anthropic-ai/claude-agent-sdk-linux-arm64-musl@0.2.114", "", { "os": "linux", "cpu": "arm64" }, "sha512-Mhd7bumTwWvkgjSJnYvCgyt8DfmLiUoK92mfvAKxHX7i5YSw+h5Kprqh2Cap+2SBbpwZvnwIoEYGCxhGwE5ddg=="],
"@anthropic-ai/claude-agent-sdk-linux-x64": ["@anthropic-ai/claude-agent-sdk-linux-x64@0.2.114", "", { "os": "linux", "cpu": "x64" }, "sha512-wbaExKDleLlm2zHEhb74GKMLVhtO0IUmFhdimQcdL6CdTkmDE8ZJi53tYWE9+jq+XWNRXoM2yEmKPzXoUmsJng=="],
"@anthropic-ai/claude-agent-sdk-linux-x64-musl": ["@anthropic-ai/claude-agent-sdk-linux-x64-musl@0.2.114", "", { "os": "linux", "cpu": "x64" }, "sha512-c1URsameGHAcghen+mY6jvr2oypiAPHXJIdP4huxR25zPdXWv2x+BCy+vcRVeajsq4VmFzAyQJwaM+BXkmXjAw=="],
"@anthropic-ai/claude-agent-sdk-win32-arm64": ["@anthropic-ai/claude-agent-sdk-win32-arm64@0.2.114", "", { "os": "win32", "cpu": "arm64" }, "sha512-qeWdUpQymcKCA92osPmffG4QogrOSvuffPvm6c2OlMDjCPYs8vKG7bSe1Vq5tP9tfBszKPVJWBDh+2ANkNissQ=="],
"@anthropic-ai/claude-agent-sdk-win32-x64": ["@anthropic-ai/claude-agent-sdk-win32-x64@0.2.114", "", { "os": "win32", "cpu": "x64" }, "sha512-nVr43WwsKvWA6rojw15qBS/f31srukdLxy1KwKzpftlpmkzQ9Lh8uhIafOmoIPzz67f8VJ8JqHE0caA5YrhX9A=="],
"@anthropic-ai/sdk": ["@anthropic-ai/sdk@0.81.0", "", { "dependencies": { "json-schema-to-ts": "^3.1.1" }, "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" }, "optionalPeers": ["zod"], "bin": { "anthropic-ai-sdk": "bin/cli" } }, "sha512-D4K5PvEV6wPiRtVlVsJHIUhHAmOZ6IT/I9rKlTf84gR7GyyAurPJK7z9BOf/AZqC5d1DhYQGJNKRmV+q8dGhgw=="],
"@babel/runtime": ["@babel/runtime@7.29.2", "", {}, "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g=="],
"@hono/node-server": ["@hono/node-server@1.19.9", "", { "peerDependencies": { "hono": "^4" } }, "sha512-vHL6w3ecZsky+8P5MD+eFfaGTyCeOHUIFYMGpQGbrBTSmNNoxv0if69rEZ5giu36weC5saFuznL411gRX7bJDw=="],
"@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.26.0", "", { "dependencies": { "@hono/node-server": "^1.19.9", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.2.1", "express-rate-limit": "^8.2.1", "hono": "^4.11.4", "jose": "^6.1.3", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", "zod-to-json-schema": "^3.25.1" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1", "zod": "^3.25 || ^4.0" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-Y5RmPncpiDtTXDbLKswIJzTqu2hyBKxTNsgKqKclDbhIgg1wgtf1fRuvxgTnRfcnxtvvgbIEcqUOzZrJ6iSReg=="],
"@types/node": ["@types/node@22.19.7", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-MciR4AKGHWl7xwxkBa6xUGxQJ4VBOmPTF7sL+iGzuahOFaO0jHCsuEfS80pan1ef4gWId1oWOweIhrDEYLuaOw=="],
"accepts": ["accepts@2.0.0", "", { "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="],
"ajv": ["ajv@8.17.1", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g=="],
"ajv-formats": ["ajv-formats@3.0.1", "", { "dependencies": { "ajv": "^8.0.0" }, "peerDependencies": { "ajv": "^8.0.0" } }, "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ=="],
"body-parser": ["body-parser@2.2.2", "", { "dependencies": { "bytes": "^3.1.2", "content-type": "^1.0.5", "debug": "^4.4.3", "http-errors": "^2.0.0", "iconv-lite": "^0.7.0", "on-finished": "^2.4.1", "qs": "^6.14.1", "raw-body": "^3.0.1", "type-is": "^2.0.1" } }, "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA=="],
"bytes": ["bytes@3.1.2", "", {}, "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg=="],
"call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="],
"call-bound": ["call-bound@1.0.4", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "get-intrinsic": "^1.3.0" } }, "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg=="],
"content-disposition": ["content-disposition@1.0.1", "", {}, "sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q=="],
"content-type": ["content-type@1.0.5", "", {}, "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA=="],
"cookie": ["cookie@0.7.2", "", {}, "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w=="],
"cookie-signature": ["cookie-signature@1.2.2", "", {}, "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg=="],
"cors": ["cors@2.8.6", "", { "dependencies": { "object-assign": "^4", "vary": "^1" } }, "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw=="],
"cron-parser": ["cron-parser@5.5.0", "", { "dependencies": { "luxon": "^3.7.1" } }, "sha512-oML4lKUXxizYswqmxuOCpgFS8BNUJpIu6k/2HVHyaL8Ynnf3wdf9tkns0yRdJLSIjkJ+b0DXHMZEHGpMwjnPww=="],
"cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="],
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
"depd": ["depd@2.0.0", "", {}, "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw=="],
"dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="],
"ee-first": ["ee-first@1.1.1", "", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="],
"ejclaw-runners-shared": ["ejclaw-runners-shared@file:../shared", { "devDependencies": { "@types/node": "^22.10.7", "typescript": "^5.7.3" } }],
"encodeurl": ["encodeurl@2.0.0", "", {}, "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg=="],
"es-define-property": ["es-define-property@1.0.1", "", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="],
"es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="],
"es-object-atoms": ["es-object-atoms@1.1.1", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA=="],
"escape-html": ["escape-html@1.0.3", "", {}, "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow=="],
"etag": ["etag@1.8.1", "", {}, "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg=="],
"eventsource": ["eventsource@3.0.7", "", { "dependencies": { "eventsource-parser": "^3.0.1" } }, "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA=="],
"eventsource-parser": ["eventsource-parser@3.0.6", "", {}, "sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg=="],
"express": ["express@5.2.1", "", { "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", "content-disposition": "^1.0.0", "content-type": "^1.0.5", "cookie": "^0.7.1", "cookie-signature": "^1.2.1", "debug": "^4.4.0", "depd": "^2.0.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "finalhandler": "^2.1.0", "fresh": "^2.0.0", "http-errors": "^2.0.0", "merge-descriptors": "^2.0.0", "mime-types": "^3.0.0", "on-finished": "^2.4.1", "once": "^1.4.0", "parseurl": "^1.3.3", "proxy-addr": "^2.0.7", "qs": "^6.14.0", "range-parser": "^1.2.1", "router": "^2.2.0", "send": "^1.1.0", "serve-static": "^2.2.0", "statuses": "^2.0.1", "type-is": "^2.0.1", "vary": "^1.1.2" } }, "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw=="],
"express-rate-limit": ["express-rate-limit@8.2.1", "", { "dependencies": { "ip-address": "10.0.1" }, "peerDependencies": { "express": ">= 4.11" } }, "sha512-PCZEIEIxqwhzw4KF0n7QF4QqruVTcF73O5kFKUnGOyjbCCgizBBiFaYpd/fnBLUMPw/BWw9OsiN7GgrNYr7j6g=="],
"fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="],
"fast-uri": ["fast-uri@3.1.0", "", {}, "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA=="],
"finalhandler": ["finalhandler@2.1.1", "", { "dependencies": { "debug": "^4.4.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "on-finished": "^2.4.1", "parseurl": "^1.3.3", "statuses": "^2.0.1" } }, "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA=="],
"forwarded": ["forwarded@0.2.0", "", {}, "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow=="],
"fresh": ["fresh@2.0.0", "", {}, "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A=="],
"function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="],
"get-intrinsic": ["get-intrinsic@1.3.0", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="],
"get-proto": ["get-proto@1.0.1", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="],
"gopd": ["gopd@1.2.0", "", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="],
"has-symbols": ["has-symbols@1.1.0", "", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="],
"hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="],
"hono": ["hono@4.11.8", "", {}, "sha512-eVkB/CYCCei7K2WElZW9yYQFWssG0DhaDhVvr7wy5jJ22K+ck8fWW0EsLpB0sITUTvPnc97+rrbQqIr5iqiy9Q=="],
"http-errors": ["http-errors@2.0.1", "", { "dependencies": { "depd": "~2.0.0", "inherits": "~2.0.4", "setprototypeof": "~1.2.0", "statuses": "~2.0.2", "toidentifier": "~1.0.1" } }, "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ=="],
"iconv-lite": ["iconv-lite@0.7.2", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw=="],
"inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="],
"ip-address": ["ip-address@10.0.1", "", {}, "sha512-NWv9YLW4PoW2B7xtzaS3NCot75m6nK7Icdv0o3lfMceJVRfSoQwqD4wEH5rLwoKJwUiZ/rfpiVBhnaF0FK4HoA=="],
"ipaddr.js": ["ipaddr.js@1.9.1", "", {}, "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g=="],
"is-promise": ["is-promise@4.0.0", "", {}, "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ=="],
"isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="],
"jose": ["jose@6.1.3", "", {}, "sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ=="],
"json-schema-to-ts": ["json-schema-to-ts@3.1.1", "", { "dependencies": { "@babel/runtime": "^7.18.3", "ts-algebra": "^2.0.0" } }, "sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g=="],
"json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="],
"json-schema-typed": ["json-schema-typed@8.0.2", "", {}, "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA=="],
"luxon": ["luxon@3.7.2", "", {}, "sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew=="],
"math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="],
"media-typer": ["media-typer@1.1.0", "", {}, "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw=="],
"merge-descriptors": ["merge-descriptors@2.0.0", "", {}, "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g=="],
"mime-db": ["mime-db@1.54.0", "", {}, "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ=="],
"mime-types": ["mime-types@3.0.2", "", { "dependencies": { "mime-db": "^1.54.0" } }, "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A=="],
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
"negotiator": ["negotiator@1.0.0", "", {}, "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg=="],
"object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="],
"object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="],
"on-finished": ["on-finished@2.4.1", "", { "dependencies": { "ee-first": "1.1.1" } }, "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg=="],
"once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="],
"parseurl": ["parseurl@1.3.3", "", {}, "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ=="],
"path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="],
"path-to-regexp": ["path-to-regexp@8.3.0", "", {}, "sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA=="],
"pkce-challenge": ["pkce-challenge@5.0.1", "", {}, "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ=="],
"proxy-addr": ["proxy-addr@2.0.7", "", { "dependencies": { "forwarded": "0.2.0", "ipaddr.js": "1.9.1" } }, "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg=="],
"qs": ["qs@6.14.1", "", { "dependencies": { "side-channel": "^1.1.0" } }, "sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ=="],
"range-parser": ["range-parser@1.2.1", "", {}, "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg=="],
"raw-body": ["raw-body@3.0.2", "", { "dependencies": { "bytes": "~3.1.2", "http-errors": "~2.0.1", "iconv-lite": "~0.7.0", "unpipe": "~1.0.0" } }, "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA=="],
"require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="],
"router": ["router@2.2.0", "", { "dependencies": { "debug": "^4.4.0", "depd": "^2.0.0", "is-promise": "^4.0.0", "parseurl": "^1.3.3", "path-to-regexp": "^8.0.0" } }, "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ=="],
"safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="],
"send": ["send@1.2.1", "", { "dependencies": { "debug": "^4.4.3", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "fresh": "^2.0.0", "http-errors": "^2.0.1", "mime-types": "^3.0.2", "ms": "^2.1.3", "on-finished": "^2.4.1", "range-parser": "^1.2.1", "statuses": "^2.0.2" } }, "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ=="],
"serve-static": ["serve-static@2.2.1", "", { "dependencies": { "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "parseurl": "^1.3.3", "send": "^1.2.0" } }, "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw=="],
"setprototypeof": ["setprototypeof@1.2.0", "", {}, "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw=="],
"shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="],
"shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="],
"side-channel": ["side-channel@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3", "side-channel-list": "^1.0.0", "side-channel-map": "^1.0.1", "side-channel-weakmap": "^1.0.2" } }, "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw=="],
"side-channel-list": ["side-channel-list@1.0.0", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3" } }, "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA=="],
"side-channel-map": ["side-channel-map@1.0.1", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3" } }, "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA=="],
"side-channel-weakmap": ["side-channel-weakmap@1.0.2", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3", "side-channel-map": "^1.0.1" } }, "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A=="],
"statuses": ["statuses@2.0.2", "", {}, "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw=="],
"toidentifier": ["toidentifier@1.0.1", "", {}, "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA=="],
"ts-algebra": ["ts-algebra@2.0.0", "", {}, "sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw=="],
"type-is": ["type-is@2.0.1", "", { "dependencies": { "content-type": "^1.0.5", "media-typer": "^1.1.0", "mime-types": "^3.0.0" } }, "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw=="],
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
"undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="],
"unpipe": ["unpipe@1.0.0", "", {}, "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ=="],
"vary": ["vary@1.1.2", "", {}, "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg=="],
"which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
"wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="],
"zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="],
"zod-to-json-schema": ["zod-to-json-schema@3.25.1", "", { "peerDependencies": { "zod": "^3.25 || ^4" } }, "sha512-pM/SU9d3YAggzi6MtR4h7ruuQlqKtad8e9S0fmxcMi+ueAK5Korys/aWcV9LIIHTVbj01NdzxcnXSN+O74ZIVA=="],
"@anthropic-ai/claude-agent-sdk/@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.29.0", "", { "dependencies": { "@hono/node-server": "^1.19.9", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.2.1", "express-rate-limit": "^8.2.1", "hono": "^4.11.4", "jose": "^6.1.3", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", "zod-to-json-schema": "^3.25.1" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ=="],
}
}

View File

@@ -10,10 +10,11 @@
"start": "bun dist/index.js"
},
"dependencies": {
"@anthropic-ai/claude-agent-sdk": "0.2.114",
"@modelcontextprotocol/sdk": "^1.12.1",
"@anthropic-ai/claude-agent-sdk": "0.3.153",
"@anthropic-ai/sdk": "0.100.0",
"@modelcontextprotocol/sdk": "1.29.0",
"cron-parser": "^5.0.0",
"ejclaw-runners-shared": "file:../shared",
"ejclaw-runners-shared": "workspace:*",
"zod": "^4.0.0"
},
"devDependencies": {

View File

@@ -0,0 +1,10 @@
import { resolveBundledClaudeCodeExecutable } from './bundled-cli-path.js';
let cachedClaudeCliPath: string | null = null;
export function getClaudeCliPath(log: (message: string) => void): string {
if (cachedClaudeCliPath) return cachedClaudeCliPath;
cachedClaudeCliPath = resolveBundledClaudeCodeExecutable();
log(`Resolved bundled Claude Code CLI: ${cachedClaudeCliPath}`);
return cachedClaudeCliPath;
}

View File

@@ -0,0 +1,596 @@
import { query } from '@anthropic-ai/claude-agent-sdk';
import { EJCLAW_ENV, IPC_POLL_MS } from 'ejclaw-runners-shared';
import { compactBoundaryFromMessage } from './compaction-boundary.js';
import { getClaudeCliPath } from './claude-cli.js';
import { drainIpcInput, shouldClose } from './ipc-input.js';
import { buildEjclawMcpServerConfig } from './mcp-config.js';
import {
MessageStream,
buildCompactionOutput,
buildMultimodalContent,
extractAssistantText,
normalizeStructuredOutput,
type RunnerCompaction,
writeOutput,
} from './output-protocol.js';
import { buildClaudeReadonlySandboxSettings } from './reviewer-runtime.js';
import {
createPreCompactHook,
createReviewerBashGuardHook,
createSanitizeBashHook,
} from './runner-hooks.js';
import type { RunnerInput } from './runner-input.js';
import {
TopLevelAgentTaskTracker,
buildTaskNotificationOutput,
buildTaskProgressOutput,
buildTaskStartedOutput,
} from './task-progress-mapping.js';
export interface ClaudeQueryPaths {
groupDir: string;
groupFolder: string;
hostTasksDir: string;
ipcInputCloseSentinel: string;
ipcInputDir: string;
workDir: string;
}
export interface RunClaudeQueryArgs {
prompt: string;
sessionId: string | undefined;
mcpServerPath: string;
runnerInput: RunnerInput;
sdkEnv: Record<string, string | undefined>;
reviewerRuntime: boolean;
claudeReadonlyReviewerRuntime: boolean;
claudeReadonlySandboxMode: 'strict' | 'best-effort' | null;
abortController: AbortController;
paths: ClaudeQueryPaths;
log: (message: string) => void;
}
export interface ClaudeQueryResult {
newSessionId?: string;
closedDuringQuery: boolean;
terminalResultObserved: boolean;
compaction?: RunnerCompaction;
}
class ClaudeQueryRunner {
private readonly stream: MessageStream;
private readonly trackedAgentTasks = new TopLevelAgentTaskTracker();
private readonly extraDirs: string[] = [];
private readonly effectiveCwd: string;
private ipcPolling = true;
private closedDuringQuery = false;
private newSessionId: string | undefined;
private messageCount = 0;
private resultCount = 0;
private terminalResultObserved = false;
private pendingProgressText: string | null = null;
private compaction: RunnerCompaction | undefined;
constructor(private readonly args: RunClaudeQueryArgs) {
this.stream = new MessageStream((text) =>
buildMultimodalContent(text, args.log),
);
this.effectiveCwd = args.paths.workDir || args.paths.groupDir;
if (args.paths.workDir && args.paths.workDir !== args.paths.groupDir) {
this.extraDirs.push(args.paths.groupDir);
args.log(
`Work directory override: ${args.paths.workDir} (group dir added to additionalDirectories)`,
);
}
if (this.extraDirs.length > 0) {
args.log(`Additional directories: ${this.extraDirs.join(', ')}`);
}
}
async run(): Promise<ClaudeQueryResult> {
this.stream.push(this.args.prompt);
this.startIpcPolling();
this.logRuntimeConfig();
for await (const message of query({
prompt: this.stream,
options: this.buildQueryOptions(),
})) {
this.messageCount++;
const msgType =
typeof message === 'object' &&
message !== null &&
(message as { type?: string }).type === 'system'
? `system/${(message as { subtype?: string }).subtype}`
: (message as { type?: string }).type;
this.args.log(`[msg #${this.messageCount}] type=${msgType}`);
if (this.handleMessage(message)) break;
}
this.flushRemainingPendingText();
this.ipcPolling = false;
this.args.log(
`Query done. Messages: ${this.messageCount}, results: ${this.resultCount}, closedDuringQuery: ${this.closedDuringQuery}`,
);
return {
newSessionId: this.newSessionId,
closedDuringQuery: this.closedDuringQuery,
terminalResultObserved: this.terminalResultObserved,
compaction: this.compaction,
};
}
private startIpcPolling(): void {
const pollIpcDuringQuery = () => {
if (!this.ipcPolling) return;
if (shouldClose(this.args.paths.ipcInputCloseSentinel)) {
this.handleCloseSentinel();
return;
}
const messages = drainIpcInput(
this.args.paths.ipcInputDir,
this.args.log,
);
for (const text of messages) {
this.args.log(
`Piping IPC message into active query (${text.length} chars)`,
);
this.stream.push(text);
}
setTimeout(pollIpcDuringQuery, IPC_POLL_MS);
};
setTimeout(pollIpcDuringQuery, IPC_POLL_MS);
}
private handleCloseSentinel(): void {
this.args.log('Close sentinel detected during query, ending stream');
if (this.pendingProgressText && !this.terminalResultObserved) {
this.args.log(
`Flushing pending text before close (${this.pendingProgressText.length} chars)`,
);
writeOutput({
status: 'success',
...normalizeStructuredOutput(this.pendingProgressText),
newSessionId: this.newSessionId,
});
this.pendingProgressText = null;
this.terminalResultObserved = true;
this.resultCount++;
}
this.closedDuringQuery = true;
this.stream.end();
this.ipcPolling = false;
}
private buildQueryOptions() {
const readonlyReviewerRuntime =
this.args.reviewerRuntime || this.args.claudeReadonlyReviewerRuntime;
const readonlyProtectedPaths = [
this.effectiveCwd,
...this.extraDirs,
].filter((value): value is string => Boolean(value));
const claudeReadonlySandboxSettings =
this.args.claudeReadonlyReviewerRuntime &&
this.args.claudeReadonlySandboxMode
? buildClaudeReadonlySandboxSettings(
readonlyProtectedPaths,
undefined,
this.args.claudeReadonlySandboxMode,
)
: undefined;
return {
pathToClaudeCodeExecutable: getClaudeCliPath(this.args.log),
cwd: this.effectiveCwd,
model: this.resolveModel(),
thinking: this.resolveThinking(),
effort: this.resolveEffort(),
additionalDirectories:
this.extraDirs.length > 0 ? this.extraDirs : undefined,
resume: this.args.sessionId,
allowedTools: this.buildAllowedTools(readonlyReviewerRuntime),
env: this.args.sdkEnv,
permissionMode: 'bypassPermissions' as const,
allowDangerouslySkipPermissions: true,
...(claudeReadonlySandboxSettings
? { sandbox: claudeReadonlySandboxSettings }
: {}),
settingSources: ['project', 'user'] as ('project' | 'user')[],
abortController: this.args.abortController,
mcpServers: {
ejclaw: buildEjclawMcpServerConfig(this.args.mcpServerPath, {
chatJid: this.args.runnerInput.chatJid,
groupFolder: this.args.runnerInput.groupFolder,
isMain: this.args.runnerInput.isMain,
agentType: process.env[EJCLAW_ENV.agentType] || 'claude-code',
roomRole: this.args.runnerInput.roomRoleContext?.role || '',
ipcDir: process.env[EJCLAW_ENV.ipcDir],
hostIpcDir: process.env[EJCLAW_ENV.hostIpcDir],
}),
},
hooks: {
PreCompact: [
{
hooks: [
createPreCompactHook({
assistantName: this.args.runnerInput.assistantName,
groupDir: this.args.paths.groupDir,
groupFolder: this.args.paths.groupFolder,
hostTasksDir: this.args.paths.hostTasksDir,
log: this.args.log,
writeOutput,
}),
],
},
],
PreToolUse: [
{
matcher: 'Bash',
hooks: readonlyReviewerRuntime
? [createReviewerBashGuardHook(), createSanitizeBashHook()]
: [createSanitizeBashHook()],
},
],
},
agentProgressSummaries: true,
};
}
private logRuntimeConfig(): void {
const model = this.resolveModel();
const thinking = this.resolveThinking();
const effort = this.resolveEffort();
if (model) this.args.log(`Using model: ${model}`);
if (thinking) this.args.log(`Thinking config: ${JSON.stringify(thinking)}`);
if (effort) this.args.log(`Effort: ${effort}`);
if (this.args.reviewerRuntime) {
this.args.log('Reviewer runtime restrictions enabled');
}
if (this.args.claudeReadonlyReviewerRuntime) {
this.args.log(
`Claude host reviewer read-only sandbox enabled (${this.args.claudeReadonlySandboxMode || 'unknown'})`,
);
}
if (this.args.claudeReadonlySandboxMode === 'best-effort') {
this.args.log(
'Claude host reviewer sandbox capability unavailable on this host, using best-effort read-only mode',
);
}
}
private resolveModel(): string | undefined {
return process.env.CLAUDE_MODEL || undefined;
}
private resolveThinking():
| { type: 'adaptive' }
| { type: 'enabled'; budgetTokens?: number }
| { type: 'disabled' }
| undefined {
const thinkingType = process.env.CLAUDE_THINKING || undefined;
const thinkingBudget = process.env.CLAUDE_THINKING_BUDGET
? parseInt(process.env.CLAUDE_THINKING_BUDGET, 10)
: undefined;
return thinkingType === 'adaptive'
? { type: 'adaptive' }
: thinkingType === 'enabled'
? { type: 'enabled', budgetTokens: thinkingBudget }
: thinkingType === 'disabled'
? { type: 'disabled' }
: undefined;
}
private resolveEffort(): 'low' | 'medium' | 'high' | 'max' | undefined {
return (
(process.env.CLAUDE_EFFORT as 'low' | 'medium' | 'high' | 'max') ||
undefined
);
}
private buildAllowedTools(readonlyRuntime: boolean): string[] {
const readonlyTools = [
'Bash',
'Read',
'Glob',
'Grep',
'WebSearch',
'WebFetch',
'Task',
'TaskOutput',
'TaskStop',
'TeamCreate',
'TeamDelete',
'SendMessage',
'TodoWrite',
'ToolSearch',
'Skill',
'mcp__ejclaw__*',
];
const writeTools = [
'Bash',
'Read',
'Write',
'Edit',
'Glob',
'Grep',
'WebSearch',
'WebFetch',
'Task',
'TaskOutput',
'TaskStop',
'TeamCreate',
'TeamDelete',
'SendMessage',
'TodoWrite',
'ToolSearch',
'Skill',
'NotebookEdit',
'mcp__ejclaw__*',
];
return readonlyRuntime ? readonlyTools : writeTools;
}
private handleMessage(message: unknown): boolean {
const type = (message as { type?: string }).type;
if (type !== 'assistant' && this.pendingProgressText) {
this.flushPendingProgressAsIntermediate();
}
if (type === 'system') this.handleSystemMessage(message);
if (type === 'tool_progress') this.handleToolProgress(message);
if (type === 'tool_use_summary') this.handleToolUseSummary(message);
if (type === 'result') return this.handleResultMessage(message);
if (type === 'assistant') return this.handleAssistantMessage(message);
return false;
}
private handleSystemMessage(message: unknown): void {
const subtype = (message as { subtype?: string }).subtype;
if (subtype === 'init') {
this.newSessionId = (message as { session_id?: string }).session_id;
this.args.log(`Session initialized: ${this.newSessionId}`);
}
this.compaction =
compactBoundaryFromMessage(message, this.args.log) ?? this.compaction;
if (subtype === 'task_notification') {
const tn = message as {
task_id: string;
tool_use_id?: string;
status: string;
summary: string;
};
this.args.log(
`Task notification: task=${tn.task_id} status=${tn.status} summary=${tn.summary}`,
);
const mapped = buildTaskNotificationOutput(
this.trackedAgentTasks,
tn,
this.newSessionId,
);
if (mapped) writeOutput(mapped);
}
if (subtype === 'task_progress') {
const tp = message as Record<string, unknown>;
const description =
typeof tp.description === 'string' ? tp.description : '';
const mapped = buildTaskProgressOutput(
this.trackedAgentTasks,
tp,
this.newSessionId,
);
if (mapped) {
writeOutput(mapped);
} else if (description) {
this.args.log(
`Skipping long task_progress description (${description.length} chars)`,
);
}
}
if (subtype === 'task_started') {
const ts = message as { task_id: string; description?: string };
const desc = ts.description || '';
this.args.log(
`Subagent started: task=${ts.task_id} desc=${desc.slice(0, 200)}`,
);
const mapped = buildTaskStartedOutput(
this.trackedAgentTasks,
ts,
this.newSessionId,
);
if (mapped) writeOutput(mapped);
}
}
private handleToolProgress(message: unknown): void {
const tp = message as {
tool_name: string;
elapsed_time_seconds: number;
};
const label = `${tp.tool_name} (${Math.round(tp.elapsed_time_seconds)}s)`;
this.args.log(`Tool progress: ${label}`);
writeOutput({
status: 'success',
phase: 'progress',
...normalizeStructuredOutput(label),
newSessionId: this.newSessionId,
});
}
private handleToolUseSummary(message: unknown): void {
const ts = message as { summary: string };
this.args.log(`Tool use summary: ${ts.summary.slice(0, 200)}`);
writeOutput({
status: 'success',
phase: 'progress',
...normalizeStructuredOutput(ts.summary),
newSessionId: this.newSessionId,
});
}
private handleResultMessage(message: unknown): boolean {
const resultMessage = message as {
subtype?: string;
result?: string;
errors?: unknown;
stop_reason?: unknown;
duration_ms?: unknown;
duration_api_ms?: unknown;
session_id?: unknown;
};
this.resultCount++;
let textResult = resultMessage.result || null;
const isError = resultMessage.subtype?.startsWith('error');
if (this.pendingProgressText && textResult === this.pendingProgressText) {
this.args.log('Discarding pending progress (matches result)');
this.pendingProgressText = null;
} else if (this.pendingProgressText) {
if (!textResult) {
this.args.log(
`Promoting pending progress text to result (${this.pendingProgressText.length} chars)`,
);
textResult = this.pendingProgressText;
} else {
writeOutput({
status: 'success',
phase: 'intermediate',
result: this.pendingProgressText,
newSessionId: this.newSessionId,
});
}
this.pendingProgressText = null;
}
this.args.log(
`Result #${this.resultCount}: subtype=${resultMessage.subtype}${textResult ? ` text=${textResult.slice(0, 200)}` : ''}`,
);
if (isError) {
this.writeErrorResult(resultMessage, textResult);
} else {
this.writeSuccessResult(textResult);
}
this.terminalResultObserved = true;
this.ipcPolling = false;
this.stream.end();
this.args.log('Terminal result observed, ending query stream');
return true;
}
private writeErrorResult(
message: {
subtype?: string;
errors?: unknown;
stop_reason?: unknown;
duration_ms?: unknown;
duration_api_ms?: unknown;
session_id?: unknown;
},
textResult: string | null,
): void {
const sdkErrors = Array.isArray(message.errors)
? (message.errors as string[])
: [];
this.args.log(
`Error result detail: ${JSON.stringify({
subtype: message.subtype,
result: textResult?.slice(0, 500),
errors: sdkErrors,
stop_reason: message.stop_reason,
duration_ms: message.duration_ms,
duration_api_ms: message.duration_api_ms,
session_id: message.session_id,
})}`,
);
writeOutput({
status: 'error',
result: textResult || null,
newSessionId: this.newSessionId,
error:
sdkErrors.length > 0
? sdkErrors.join('; ')
: `Agent error: ${message.subtype}`,
...buildCompactionOutput(this.compaction),
});
}
private writeSuccessResult(textResult: string | null): void {
writeOutput({
status: 'success',
...normalizeStructuredOutput(textResult || null),
newSessionId: this.newSessionId,
...buildCompactionOutput(this.compaction),
});
}
private handleAssistantMessage(message: unknown): boolean {
this.trackedAgentTasks.rememberAssistantMessage(message);
const stopReason = (message as { stop_reason?: string }).stop_reason;
const textResult = extractAssistantText(message);
if (textResult || stopReason === 'end_turn') {
this.args.log(
`Assistant: stop=${stopReason} text=${textResult ? textResult.length + ' chars' : 'null'}`,
);
}
if (stopReason === 'end_turn' && textResult) {
this.resultCount++;
this.args.log(
`Terminal assistant turn observed without result event (${textResult.length} chars), ending query stream`,
);
writeOutput({
status: 'success',
...normalizeStructuredOutput(textResult),
newSessionId: this.newSessionId,
...buildCompactionOutput(this.compaction),
});
this.terminalResultObserved = true;
this.ipcPolling = false;
this.stream.end();
return true;
}
if (stopReason !== 'end_turn' && textResult) {
if (this.pendingProgressText) this.flushPendingProgressAsIntermediate();
this.pendingProgressText = textResult;
this.args.log(
`Intermediate assistant text buffered (${textResult.length} chars, stop=${stopReason})`,
);
}
return false;
}
private flushPendingProgressAsIntermediate(): void {
if (!this.pendingProgressText) return;
writeOutput({
status: 'success',
phase: 'intermediate',
...normalizeStructuredOutput(this.pendingProgressText),
newSessionId: this.newSessionId,
});
this.pendingProgressText = null;
}
private flushRemainingPendingText(): void {
if (!this.pendingProgressText || this.terminalResultObserved) return;
this.args.log(
`Flushing remaining pending progress text as final output (${this.pendingProgressText.length} chars)`,
);
writeOutput({
status: 'success',
...normalizeStructuredOutput(this.pendingProgressText),
newSessionId: this.newSessionId,
...buildCompactionOutput(this.compaction),
});
this.terminalResultObserved = true;
this.resultCount++;
}
}
export function runClaudeQuery(
args: RunClaudeQueryArgs,
): Promise<ClaudeQueryResult> {
return new ClaudeQueryRunner(args).run();
}

View File

@@ -0,0 +1,29 @@
import {
compactBoundaryOutput,
type RunnerCompaction,
} from './output-protocol.js';
export function compactBoundaryFromMessage(
message: unknown,
log: (message: string) => void,
): RunnerCompaction | undefined {
if (
!(
typeof message === 'object' &&
message !== null &&
(message as { type?: string }).type === 'system' &&
(message as { subtype?: string }).subtype === 'compact_boundary'
)
) {
return undefined;
}
const meta = (
message as {
compact_metadata?: { trigger?: string; pre_tokens?: number };
}
).compact_metadata;
log(
`Compact boundary — trigger=${meta?.trigger || '?'} pre_tokens=${meta?.pre_tokens ?? '?'}`,
);
return compactBoundaryOutput(meta?.trigger);
}

View File

@@ -1,12 +1,22 @@
import fs from 'fs';
import path from 'path';
import {
ARTIFACT_EVIDENCE_KINDS,
DB_EVIDENCE_ACTIONS,
DEPLOY_EVIDENCE_ACTIONS,
GITHUB_EVIDENCE_ACTIONS,
HOST_EVIDENCE_ACTIONS,
type HostEvidenceAction,
} from 'ejclaw-runners-shared';
export const HOST_EVIDENCE_ACTIONS = [
'ejclaw_service_status',
'ejclaw_service_logs',
] as const;
export type HostEvidenceAction = (typeof HOST_EVIDENCE_ACTIONS)[number];
export {
ARTIFACT_EVIDENCE_KINDS,
DB_EVIDENCE_ACTIONS,
DEPLOY_EVIDENCE_ACTIONS,
GITHUB_EVIDENCE_ACTIONS,
HOST_EVIDENCE_ACTIONS,
type HostEvidenceAction,
};
export interface HostEvidenceResponse {
requestId: string;

View File

@@ -16,22 +16,16 @@
import fs from 'fs';
import path from 'path';
import { query } from '@anthropic-ai/claude-agent-sdk';
import {
EJCLAW_ENV,
IPC_CLOSE_SENTINEL,
IPC_INPUT_SUBDIR,
IPC_POLL_MS,
} from 'ejclaw-runners-shared';
import { fileURLToPath } from 'url';
import { resolveBundledClaudeCodeExecutable } from './bundled-cli-path.js';
import {
prependRoomRoleHeader,
type RoomRoleContext,
} from './room-role-context.js';
import { prependRoomRoleHeader } from './room-role-context.js';
import {
assertReadonlyWorkspaceRepoConnectivity,
buildClaudeReadonlySandboxSettings,
buildReviewerGitGuardEnv,
getClaudeReadonlySandboxMode,
isArbiterRuntimeEnvEnabled,
@@ -39,47 +33,24 @@ import {
isReviewerRuntime,
isReviewerRuntimeEnvEnabled,
} from './reviewer-runtime.js';
import { drainIpcInput, shouldClose } from './ipc-input.js';
import { drainIpcInput } from './ipc-input.js';
import {
MessageStream,
buildMultimodalContent,
extractAssistantText,
normalizeStructuredOutput,
buildCompactionOutput,
readStdin,
type RunnerOutput,
writeOutput,
} from './output-protocol.js';
import {
TopLevelAgentTaskTracker,
buildTaskNotificationOutput,
buildTaskProgressOutput,
buildTaskStartedOutput,
} from './task-progress-mapping.js';
import {
createPreCompactHook,
createReviewerBashGuardHook,
createSanitizeBashHook,
} from './runner-hooks.js';
interface RunnerInput {
prompt: string;
sessionId?: string;
groupFolder: string;
chatJid: string;
isMain: boolean;
isScheduledTask?: boolean;
assistantName?: string;
secrets?: Record<string, string>;
roomRoleContext?: RoomRoleContext;
}
import { buildClaudeSdkEnv } from './sdk-env.js';
import { runSessionCommand } from './session-command.js';
import { runClaudeQuery } from './claude-query-runner.js';
import type { RunnerInput } from './runner-input.js';
// Paths configurable via env vars.
const GROUP_DIR = process.env.EJCLAW_GROUP_DIR || '/workspace/group';
const IPC_DIR = process.env.EJCLAW_IPC_DIR || '/workspace/ipc';
const HOST_IPC_DIR = process.env.EJCLAW_HOST_IPC_DIR || IPC_DIR;
const GROUP_DIR = process.env[EJCLAW_ENV.groupDir] || '/workspace/group';
const IPC_DIR = process.env[EJCLAW_ENV.ipcDir] || '/workspace/ipc';
const HOST_IPC_DIR = process.env[EJCLAW_ENV.hostIpcDir] || IPC_DIR;
// Optional: override cwd (agent works in this directory instead of GROUP_DIR)
const WORK_DIR = process.env.EJCLAW_WORK_DIR || '';
const GROUP_FOLDER = process.env.EJCLAW_GROUP_FOLDER || '';
const WORK_DIR = process.env[EJCLAW_ENV.workDir] || '';
const GROUP_FOLDER = process.env[EJCLAW_ENV.groupFolder] || '';
const IPC_INPUT_DIR = path.join(IPC_DIR, IPC_INPUT_SUBDIR);
const IPC_INPUT_CLOSE_SENTINEL = path.join(IPC_INPUT_DIR, IPC_CLOSE_SENTINEL);
@@ -89,17 +60,6 @@ function log(message: string): void {
console.error(`[agent-runner] ${message}`);
}
// 번들 CLI binary를 명시해야 SDK가 musl/glibc 잘못 탐색하는 걸 우회함.
// (SDK 0.2.114의 W7() 헬퍼는 linux-x64-musl를 linux-x64보다 먼저 시도하므로
// glibc 호스트에서 musl 패키지가 빈 껍데기로 설치돼 있으면 실패한다.)
let cachedClaudeCliPath: string | null = null;
function getClaudeCliPath(): string {
if (cachedClaudeCliPath) return cachedClaudeCliPath;
cachedClaudeCliPath = resolveBundledClaudeCodeExecutable();
log(`Resolved bundled Claude Code CLI: ${cachedClaudeCliPath}`);
return cachedClaudeCliPath;
}
// Graceful shutdown: SIGTERM → abort SDK query, allowing cleanup
const agentAbortController = new AbortController();
process.on('SIGTERM', () => {
@@ -107,519 +67,6 @@ process.on('SIGTERM', () => {
agentAbortController.abort();
});
/**
* Run a single query and stream results via writeOutput.
* Uses MessageStream (AsyncIterable) to keep isSingleUserTurn=false,
* allowing agent teams subagents to run to completion.
* Also pipes IPC messages into the stream during the query.
*/
async function runQuery(
prompt: string,
sessionId: string | undefined,
mcpServerPath: string,
runnerInput: RunnerInput,
sdkEnv: Record<string, string | undefined>,
reviewerRuntime: boolean,
claudeReadonlyReviewerRuntime: boolean,
claudeReadonlySandboxMode: 'strict' | 'best-effort' | null,
): Promise<{
newSessionId?: string;
closedDuringQuery: boolean;
terminalResultObserved: boolean;
}> {
const stream = new MessageStream((text) => buildMultimodalContent(text, log));
stream.push(prompt);
// Poll IPC for follow-up messages and _close sentinel during the query
let ipcPolling = true;
let closedDuringQuery = false;
const pollIpcDuringQuery = () => {
if (!ipcPolling) return;
if (shouldClose(IPC_INPUT_CLOSE_SENTINEL)) {
log('Close sentinel detected during query, ending stream');
// Flush any buffered text before closing — the for-await loop may not
// reach the post-loop flush code after stream.end().
if (pendingProgressText && !terminalResultObserved) {
log(
`Flushing pending text before close (${pendingProgressText.length} chars)`,
);
writeOutput({
status: 'success',
...normalizeStructuredOutput(pendingProgressText),
newSessionId,
});
pendingProgressText = null;
terminalResultObserved = true;
resultCount++;
}
closedDuringQuery = true;
stream.end();
ipcPolling = false;
return;
}
const messages = drainIpcInput(IPC_INPUT_DIR, log);
for (const text of messages) {
log(`Piping IPC message into active query (${text.length} chars)`);
stream.push(text);
}
setTimeout(pollIpcDuringQuery, IPC_POLL_MS);
};
setTimeout(pollIpcDuringQuery, IPC_POLL_MS);
let newSessionId: string | undefined;
let messageCount = 0;
let resultCount = 0;
let terminalResultObserved = false;
let pendingProgressText: string | null = null;
const trackedAgentTasks = new TopLevelAgentTaskTracker();
// Discover additional directories
const extraDirs: string[] = [];
// When WORK_DIR is set, use it as cwd and include GROUP_DIR as additional directory
const effectiveCwd = WORK_DIR || GROUP_DIR;
if (WORK_DIR && WORK_DIR !== GROUP_DIR) {
extraDirs.push(GROUP_DIR);
log(
`Work directory override: ${WORK_DIR} (group dir added to additionalDirectories)`,
);
}
if (extraDirs.length > 0) {
log(`Additional directories: ${extraDirs.join(', ')}`);
}
// Model and thinking configuration from environment
const model = process.env.CLAUDE_MODEL || undefined;
const thinkingType = process.env.CLAUDE_THINKING || undefined; // 'adaptive' | 'enabled' | 'disabled'
const thinkingBudget = process.env.CLAUDE_THINKING_BUDGET
? parseInt(process.env.CLAUDE_THINKING_BUDGET, 10)
: undefined;
const effort =
(process.env.CLAUDE_EFFORT as 'low' | 'medium' | 'high' | 'max') ||
undefined;
const thinking =
thinkingType === 'adaptive'
? { type: 'adaptive' as const }
: thinkingType === 'enabled'
? { type: 'enabled' as const, budgetTokens: thinkingBudget }
: thinkingType === 'disabled'
? { type: 'disabled' as const }
: undefined;
if (model) log(`Using model: ${model}`);
if (thinking) log(`Thinking config: ${JSON.stringify(thinking)}`);
if (effort) log(`Effort: ${effort}`);
if (reviewerRuntime) log('Reviewer runtime restrictions enabled');
if (claudeReadonlyReviewerRuntime) {
log(
`Claude host reviewer read-only sandbox enabled (${claudeReadonlySandboxMode || 'unknown'})`,
);
}
if (claudeReadonlySandboxMode === 'best-effort') {
log(
'Claude host reviewer sandbox capability unavailable on this host, using best-effort read-only mode',
);
}
const readonlyReviewerRuntime =
reviewerRuntime || claudeReadonlyReviewerRuntime;
const allowedTools = readonlyReviewerRuntime
? [
'Bash',
'Read',
'Glob',
'Grep',
'WebSearch',
'WebFetch',
'Task',
'TaskOutput',
'TaskStop',
'TeamCreate',
'TeamDelete',
'SendMessage',
'TodoWrite',
'ToolSearch',
'Skill',
'mcp__ejclaw__*',
]
: [
'Bash',
'Read',
'Write',
'Edit',
'Glob',
'Grep',
'WebSearch',
'WebFetch',
'Task',
'TaskOutput',
'TaskStop',
'TeamCreate',
'TeamDelete',
'SendMessage',
'TodoWrite',
'ToolSearch',
'Skill',
'NotebookEdit',
'mcp__ejclaw__*',
];
const readonlyProtectedPaths = [effectiveCwd, ...extraDirs].filter(
(value): value is string => Boolean(value),
);
const claudeReadonlySandboxSettings =
claudeReadonlyReviewerRuntime && claudeReadonlySandboxMode
? buildClaudeReadonlySandboxSettings(
readonlyProtectedPaths,
undefined,
claudeReadonlySandboxMode,
)
: undefined;
for await (const message of query({
prompt: stream,
options: {
// 번들 CLI binary를 명시해야 SDK가 musl/glibc 잘못 탐색하는 걸 우회함.
pathToClaudeCodeExecutable: getClaudeCliPath(),
cwd: effectiveCwd,
model,
thinking,
effort,
additionalDirectories: extraDirs.length > 0 ? extraDirs : undefined,
resume: sessionId,
allowedTools,
env: sdkEnv,
permissionMode: 'bypassPermissions',
allowDangerouslySkipPermissions: true,
...(claudeReadonlySandboxSettings
? {
sandbox: claudeReadonlySandboxSettings,
}
: {}),
settingSources: ['project', 'user'],
abortController: agentAbortController,
mcpServers: {
ejclaw: {
command: 'node',
args: [mcpServerPath],
env: {
EJCLAW_CHAT_JID: runnerInput.chatJid,
EJCLAW_GROUP_FOLDER: runnerInput.groupFolder,
EJCLAW_IS_MAIN: runnerInput.isMain ? '1' : '0',
EJCLAW_AGENT_TYPE: process.env.EJCLAW_AGENT_TYPE || 'claude-code',
EJCLAW_ROOM_ROLE: runnerInput.roomRoleContext?.role || '',
...(process.env.EJCLAW_IPC_DIR && {
EJCLAW_IPC_DIR: process.env.EJCLAW_IPC_DIR,
}),
...(process.env.EJCLAW_HOST_IPC_DIR && {
EJCLAW_HOST_IPC_DIR: process.env.EJCLAW_HOST_IPC_DIR,
}),
},
},
},
hooks: {
PreCompact: [
{
hooks: [
createPreCompactHook({
assistantName: runnerInput.assistantName,
groupDir: GROUP_DIR,
groupFolder: GROUP_FOLDER,
hostTasksDir: HOST_TASKS_DIR,
log,
writeOutput,
}),
],
},
],
PreToolUse: [
{
matcher: 'Bash',
hooks: readonlyReviewerRuntime
? [createReviewerBashGuardHook(), createSanitizeBashHook()]
: [createSanitizeBashHook()],
},
],
},
agentProgressSummaries: true,
},
})) {
messageCount++;
const msgType =
message.type === 'system'
? `system/${(message as { subtype?: string }).subtype}`
: message.type;
log(`[msg #${messageCount}] type=${msgType}`);
// Flush pending intermediate text as a regular message on non-assistant events.
if (message.type !== 'assistant' && pendingProgressText) {
writeOutput({
status: 'success',
phase: 'intermediate',
...normalizeStructuredOutput(pendingProgressText),
newSessionId,
});
pendingProgressText = null;
}
if (message.type === 'system' && message.subtype === 'init') {
newSessionId = message.session_id;
log(`Session initialized: ${newSessionId}`);
}
if (
message.type === 'system' &&
(message as { subtype?: string }).subtype === 'compact_boundary'
) {
const meta = (
message as {
compact_metadata?: { trigger?: string; pre_tokens?: number };
}
).compact_metadata;
log(
`Compact boundary — trigger=${meta?.trigger || '?'} pre_tokens=${meta?.pre_tokens ?? '?'}`,
);
}
if (
message.type === 'system' &&
(message as { subtype?: string }).subtype === 'task_notification'
) {
const tn = message as {
task_id: string;
tool_use_id?: string;
status: string;
summary: string;
};
log(
`Task notification: task=${tn.task_id} status=${tn.status} summary=${tn.summary}`,
);
const mapped = buildTaskNotificationOutput(
trackedAgentTasks,
tn,
newSessionId,
);
if (mapped) {
writeOutput(mapped);
}
}
if (
message.type === 'system' &&
(message as { subtype?: string }).subtype === 'task_progress'
) {
const tp = message as Record<string, unknown>;
const summary = typeof tp.summary === 'string' ? tp.summary : '';
const description =
typeof tp.description === 'string' ? tp.description : '';
const mapped = buildTaskProgressOutput(
trackedAgentTasks,
tp,
newSessionId,
);
if (mapped) {
writeOutput(mapped);
} else if (description) {
// Long AI summary → skip (too long for progress sub-line)
log(
`Skipping long task_progress description (${description.length} chars)`,
);
}
}
if (
message.type === 'system' &&
(message as { subtype?: string }).subtype === 'task_started'
) {
const ts = message as { task_id: string; description?: string };
const desc = ts.description || '';
log(`Subagent started: task=${ts.task_id} desc=${desc.slice(0, 200)}`);
const mapped = buildTaskStartedOutput(
trackedAgentTasks,
ts,
newSessionId,
);
if (mapped) {
writeOutput(mapped);
}
}
if (message.type === 'tool_progress') {
const tp = message as {
tool_name: string;
elapsed_time_seconds: number;
};
const label = `${tp.tool_name} (${Math.round(tp.elapsed_time_seconds)}s)`;
log(`Tool progress: ${label}`);
const normalized = normalizeStructuredOutput(label);
writeOutput({
status: 'success',
phase: 'progress',
...normalized,
newSessionId,
});
}
if (message.type === 'tool_use_summary') {
const ts = message as { summary: string };
log(`Tool use summary: ${ts.summary.slice(0, 200)}`);
const normalized = normalizeStructuredOutput(ts.summary);
writeOutput({
status: 'success',
phase: 'progress',
...normalized,
newSessionId,
});
}
if (message.type === 'result') {
resultCount++;
let textResult =
'result' in message ? (message as { result?: string }).result : null;
const isError = message.subtype?.startsWith('error');
// Discard pending progress if it matches the final result (prevent duplicate)
if (
pendingProgressText &&
textResult &&
pendingProgressText === textResult
) {
log(`Discarding pending progress (matches result)`);
pendingProgressText = null;
} else if (pendingProgressText) {
// If the result has no text, promote pending progress to the result
// so it gets delivered as the final output instead of being lost.
if (!textResult) {
log(
`Promoting pending progress text to result (${pendingProgressText.length} chars)`,
);
textResult = pendingProgressText;
} else {
writeOutput({
status: 'success',
phase: 'intermediate',
result: pendingProgressText,
newSessionId,
});
}
pendingProgressText = null;
}
log(
`Result #${resultCount}: subtype=${message.subtype}${textResult ? ` text=${textResult.slice(0, 200)}` : ''}`,
);
if (isError) {
// Log full error details for debugging
const msg = message as Record<string, unknown>;
const sdkErrors = Array.isArray(msg.errors)
? (msg.errors as string[])
: [];
const errorDetail = JSON.stringify({
subtype: message.subtype,
result: textResult?.slice(0, 500),
errors: sdkErrors,
stop_reason: msg.stop_reason,
duration_ms: msg.duration_ms,
duration_api_ms: msg.duration_api_ms,
session_id: msg.session_id,
});
log(`Error result detail: ${errorDetail}`);
// Pass SDK errors through so host can detect session issues
const errorText =
sdkErrors.length > 0 ? sdkErrors.join('; ') : undefined;
writeOutput({
status: 'error',
result: textResult || null,
newSessionId,
error: errorText || `Agent error: ${message.subtype}`,
});
} else {
const normalized = normalizeStructuredOutput(textResult || null);
writeOutput({
status: 'success',
...normalized,
newSessionId,
});
}
// Single-turn runtimes must terminate the query after the first
// terminal result. Leaving the message stream open can keep the SDK
// query alive indefinitely, which pins the host queue after a reply.
terminalResultObserved = true;
ipcPolling = false;
stream.end();
log('Terminal result observed, ending query stream');
break;
}
if (message.type === 'assistant') {
trackedAgentTasks.rememberAssistantMessage(message);
const stopReason = (message as { stop_reason?: string }).stop_reason;
const textResult = extractAssistantText(message);
// Only log when there's something interesting (text or terminal)
if (textResult || stopReason === 'end_turn') {
log(
`Assistant: stop=${stopReason} text=${textResult ? textResult.length + ' chars' : 'null'}`,
);
}
if (stopReason === 'end_turn' && textResult) {
resultCount++;
log(
`Terminal assistant turn observed without result event (${textResult.length} chars), ending query stream`,
);
writeOutput({
status: 'success',
...normalizeStructuredOutput(textResult),
newSessionId,
});
terminalResultObserved = true;
ipcPolling = false;
stream.end();
break;
}
// Intermediate assistant text between tool calls → buffer as pending progress.
// Don't emit immediately — if the next message is a result with the same text,
// this would cause a duplicate. The pending text is flushed when the next
// non-result message arrives, or discarded if result matches.
if (stopReason !== 'end_turn' && textResult) {
// Flush previous pending as a regular message (not progress heading)
if (pendingProgressText) {
const normalized = normalizeStructuredOutput(pendingProgressText);
writeOutput({
status: 'success',
phase: 'intermediate',
...normalized,
newSessionId,
});
}
pendingProgressText = textResult;
log(
`Intermediate assistant text buffered (${textResult.length} chars, stop=${stopReason})`,
);
}
}
}
// Flush any remaining buffered text that was never followed by a result event.
// This happens when the agent produces a short response without a formal end_turn.
if (pendingProgressText && !terminalResultObserved) {
log(
`Flushing remaining pending progress text as final output (${pendingProgressText.length} chars)`,
);
writeOutput({
status: 'success',
...normalizeStructuredOutput(pendingProgressText),
newSessionId,
});
terminalResultObserved = true;
resultCount++;
}
ipcPolling = false;
log(
`Query done. Messages: ${messageCount}, results: ${resultCount}, closedDuringQuery: ${closedDuringQuery}`,
);
return { newSessionId, closedDuringQuery, terminalResultObserved };
}
async function main(): Promise<void> {
let runnerInput: RunnerInput;
@@ -644,10 +91,7 @@ async function main(): Promise<void> {
// Build SDK env: merge secrets into process.env for the SDK only.
// Secrets never touch process.env itself, so Bash subprocesses can't see them.
const sdkEnv: Record<string, string | undefined> = { ...process.env };
for (const [key, value] of Object.entries(runnerInput.secrets || {})) {
sdkEnv[key] = value;
}
const sdkEnv = buildClaudeSdkEnv(process.env, runnerInput.secrets || {});
const reviewerRuntime =
isReviewerRuntimeEnvEnabled(process.env) ||
isReviewerRuntime(runnerInput.roomRoleContext);
@@ -707,131 +151,18 @@ async function main(): Promise<void> {
if (isSessionSlashCommand) {
log(`Handling session command: ${trimmedPrompt}`);
let slashSessionId: string | undefined;
let compactBoundarySeen = false;
let hadError = false;
let resultEmitted = false;
try {
for await (const message of query({
prompt: trimmedPrompt,
options: {
// 번들 CLI binary를 명시해야 SDK가 musl/glibc 잘못 탐색하는 걸 우회함.
pathToClaudeCodeExecutable: getClaudeCliPath(),
cwd: mainEffectiveCwd,
resume: sessionId,
systemPrompt: undefined,
allowedTools: [],
env: guardedSdkEnv,
permissionMode: 'bypassPermissions' as const,
allowDangerouslySkipPermissions: true,
settingSources: ['project', 'user'] as const,
abortController: agentAbortController,
hooks: {
PreCompact: [
{
hooks: [
createPreCompactHook({
assistantName: runnerInput.assistantName,
groupDir: GROUP_DIR,
groupFolder: GROUP_FOLDER,
hostTasksDir: HOST_TASKS_DIR,
log,
writeOutput,
}),
],
},
],
},
},
})) {
const msgType =
message.type === 'system'
? `system/${(message as { subtype?: string }).subtype}`
: message.type;
log(`[slash-cmd] type=${msgType}`);
if (message.type === 'system' && message.subtype === 'init') {
slashSessionId = message.session_id;
log(`Session after slash command: ${slashSessionId}`);
}
// Observe compact_boundary to confirm compaction completed
if (
message.type === 'system' &&
(message as { subtype?: string }).subtype === 'compact_boundary'
) {
compactBoundarySeen = true;
const meta = (
message as {
compact_metadata?: { trigger?: string; pre_tokens?: number };
}
).compact_metadata;
log(
`Compact boundary — trigger=${meta?.trigger || '?'} pre_tokens=${meta?.pre_tokens ?? '?'}`,
);
}
if (message.type === 'result') {
const resultSubtype = (message as { subtype?: string }).subtype;
const textResult =
'result' in message
? (message as { result?: string }).result
: null;
if (resultSubtype?.startsWith('error')) {
hadError = true;
writeOutput({
status: 'error',
result: null,
error: textResult || 'Session command failed.',
newSessionId: slashSessionId,
});
} else {
writeOutput({
status: 'success',
result: textResult || 'Conversation compacted.',
newSessionId: slashSessionId,
});
}
resultEmitted = true;
}
}
} catch (err) {
hadError = true;
const errorMsg = err instanceof Error ? err.message : String(err);
log(`Slash command error: ${errorMsg}`);
writeOutput({ status: 'error', result: null, error: errorMsg });
}
log(
`Slash command done. compactBoundarySeen=${compactBoundarySeen}, hadError=${hadError}`,
);
// Warn if compact_boundary was never observed — compaction may not have occurred
if (!hadError && !compactBoundarySeen) {
log(
'WARNING: compact_boundary was not observed. Compaction may not have completed.',
);
}
// Only emit final session marker if no result was emitted yet and no error occurred
if (!resultEmitted && !hadError) {
writeOutput({
status: 'success',
result: compactBoundarySeen
? 'Conversation compacted.'
: 'Compaction requested but compact_boundary was not observed.',
newSessionId: slashSessionId,
});
} else if (!hadError) {
// Emit session-only marker so host updates session tracking
writeOutput({
status: 'success',
result: null,
newSessionId: slashSessionId,
});
}
await runSessionCommand({
prompt: trimmedPrompt,
sessionId,
cwd: mainEffectiveCwd,
sdkEnv: guardedSdkEnv,
abortController: agentAbortController,
assistantName: runnerInput.assistantName,
groupDir: GROUP_DIR,
groupFolder: GROUP_FOLDER,
hostTasksDir: HOST_TASKS_DIR,
log,
});
return;
}
// --- End slash command handling ---
@@ -839,22 +170,37 @@ async function main(): Promise<void> {
try {
log(`Starting query (session: ${sessionId || 'new'})...`);
const queryResult = await runQuery(
const queryResult = await runClaudeQuery({
prompt,
sessionId,
mcpServerPath,
runnerInput,
guardedSdkEnv,
sdkEnv: guardedSdkEnv,
reviewerRuntime,
claudeReadonlyReviewerRuntime,
claudeReadonlySandboxMode,
);
abortController: agentAbortController,
paths: {
groupDir: GROUP_DIR,
groupFolder: GROUP_FOLDER,
hostTasksDir: HOST_TASKS_DIR,
ipcInputCloseSentinel: IPC_INPUT_CLOSE_SENTINEL,
ipcInputDir: IPC_INPUT_DIR,
workDir: WORK_DIR,
},
log,
});
if (queryResult.newSessionId) {
sessionId = queryResult.newSessionId;
}
if (!queryResult.closedDuringQuery && !queryResult.terminalResultObserved) {
writeOutput({ status: 'success', result: null, newSessionId: sessionId });
writeOutput({
status: 'success',
result: null,
newSessionId: sessionId,
...buildCompactionOutput(queryResult.compaction),
});
} else if (queryResult.terminalResultObserved) {
log('Terminal result already emitted, exiting single-turn runtime');
} else {

Some files were not shown because too many files have changed in this diff Show More