Files
EJClaw/src/web-dashboard-server.ts
Eyejoker ea323cc787 Overhaul dashboard UX (two-pane rooms, live turn persistence, react-markdown) (#42)
* Overhaul dashboard UX with two-pane rooms and live turn persistence

- Two-pane room board (Discord-style list + detail) with sender role
  colors, inbox attention badges, and per-role left-border sectioning
- Settings page: nickname + language extracted to /#/settings; sidebar
  freed up for mini stats (rooms, queue, inbox) and nav badges
- react-markdown + remark-gfm for message body (tables, code, links,
  proper line breaks); unified timeline merges outputs and human messages
- Live turn elapsed counter ticks every second; paused state preserved
  across service restarts via new progress_text column on paired_turns
  (migration 015) so in-flight context survives reboots
- Batched /api/rooms-timeline endpoint with server-side stale-while-
  revalidate cache + gzip; cold ~3s -> warm ~10ms
- Optimistic UI for outgoing messages with rollback on failure;
  nickname propagated to sender_name on send
- Density sweep: tighter paddings, radii, font sizes; tokens align
  across panel/card/sidebar/timeline

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* Strip redundant LIVE/intent/state labels from turn header

The left dot already signals LIVE and the role chip already shows the
turn role; the standalone LIVE label, intentKind em, and state pill
were repeating that information. Keep only role chip, time, elapsed,
and a "중단됨" label when paused.

Also picks up Prettier-formatted long lines in i18n and backend files
left over from the previous commit.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* Convert sidebar to lucide-react icon rail and fix optimistic UI

- Replace 168px text-label sidebar with 56px flush-left icon rail
  (lucide-react icons: Gauge/Inbox/Activity/MessageSquare/Clock/Settings)
  Discord/Slack pattern: rail against left edge, no centered shell margin
- Optimistic UI now survives polling: pending messages tracked in a
  separate state and merged into the timeline render; an effect prunes
  pending entries once the server snapshot confirms them by content match
- LIVE elapsed counter shows seconds at all granularities (formatLiveElapsed
  uses m+s/h+m+s instead of dropping seconds after 60s)
- Refresh button shows spin animation while busy

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* Make Rooms the default landing view

Rooms is the primary surface (chat-style live agent flow), so reorder
nav (Rooms first) and switch DEFAULT_VIEW from inbox to rooms. The
topbar fallback label also changes from 'usage' to 'rooms'.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* Submit room message on Enter, allow Shift+Enter for newline

IME composition (event.nativeEvent.isComposing) is excluded so that
mid-Hangul Enter does not trigger send.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* Viewport-lock rooms view to remove double scroll

The rooms view now fills the viewport (height: 100vh - topbar) with
list and detail each scrolling in their own column. The page itself
no longer scrolls when the detail thread grows long, so the wheel
scrolls whichever column the cursor is over.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* Flatten rooms view, single-line compose with icon send button

Drop nested card chrome on rooms view: outer panel now has no padding,
border, or background so the rail / list / detail sit flush against
the page. Internal panes (rooms-list, rooms-detail) lose their own
borders and rely on a single divider line for separation.

Compose textarea is single-row by default (rows=1) and grows up to
~5 lines; the send button is icon-only (Send from lucide-react),
fixed 36x36, pinned to the bottom of the detail pane. The redundant
'메시지' h4 and the 세부 fold are removed — the room title in the
header already establishes context.

Detail pane is now a flex column where only the timeline scrolls;
the head sticks at top and the compose pins at bottom (messenger
pattern), so the input is always reachable.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* Fix rooms layout: viewport lock, restore panel tone, ensure thread scrolls

- Lock body to viewport height (overflow:hidden) only when rooms view is
  active, via body:has(.view-stack.view-rooms). Section nav becomes
  static so the layout fits exactly in the viewport instead of being
  clipped under the sticky header.
- Restore subtle panel background and border on .rooms-list and
  .rooms-detail so chat content has tonal contrast against the page bg
  (pure black was unreadable). Outer panel chrome stays flat.
- Make the thread section the only flex:1 child of the pinned card so
  it actually scrolls when content overflows; everything else (head,
  meta, current turn, watcher fold, compose) stays flex:none.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* Make rooms-detail itself scroll with sticky head and compose

Drop the brittle flex-column-with-1fr-child setup; just let .rooms-detail
be overflow-y:auto and pin .room-card-head to top and
.room-compose-section to bottom via position:sticky. The head and
compose stay visible while the thread between them scrolls naturally.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* Match compose-section background to panel

The sticky compose bar was using var(--bg) (near-black) which jarred
against the surrounding panel tone. Use var(--panel) so it sits
continuously with the room detail.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* Fix compose full-width and hide internal IPC payloads

- The compose section's full-width grid was being clobbered by the
  later .room-section { display: grid } rule (same specificity, source
  order won). Bump specificity to .room-section.room-compose-section
  with display: block so the inner form/textarea fills the panel.
- Filter out internal protocol payloads from the timeline:
  isInternalProtocolPayload() detects {"author":..."recipient":...}
  shaped agent IPC messages and excludes them from humanMessages and
  outputEntries. These are routing metadata, not human-readable chat.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* Tighten rooms width: explicit 100% on detail/card, responsive list

- Make rooms-list responsive (clamp(180px, 16vw, 240px)) so on smaller
  screens the chat area gets more room
- Add explicit width: 100% to rooms-twopane, rooms-detail, and the
  pinned room-card-v2 so nothing collapses to content-size

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* Remove timeline left indent and broaden IPC payload filter

- Drop the 3px colored left border + 12px left padding on each
  timeline item; messages were starting 29px from the panel edge
  (3 border + 12 item padding + 14 thread padding). Role colors are
  already conveyed by the role chip, so the border is redundant.
- Tighten thread-section padding from 14px to 12px to match compose.
- Broaden isInternalProtocolPayload to also catch <sub-agent-prompt>,
  <tool-call>, <internal> wrapped payloads — these were leaking into
  the visible thread when the agent emitted them as message content.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* Restore role color borders and tighten left padding

- Bring back the role-colored left border on timeline items (2px now,
  was 3px) so owner/reviewer/arbiter/human are visually distinct
- Tighten left-side spacing across the chat panel: thread section
  padding-left 12 -> 4, item padding-left 12 -> 8, card head
  padding-left 14 -> 8, compose-section padding-left 12 -> 4. Content
  now starts ~10px from the detail panel's inner edge instead of ~30px

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* Unclamp output text in unified timeline + add measure debug toggle

- The legacy .room-output-item p / .room-message-item p / .room-latest-output p
  rules used -webkit-line-clamp:2/4 to truncate previews. The unified
  timeline doesn't use those classes so it shouldn't be clamped, but
  add a defensive override on .room-timeline-body .parsed-body that
  unclamps and clears max-height in case any sibling rule leaks in.
- Add ?debug=1 (outline panels) and ?measure=1 (overlay container x/w
  in viewport coords) toggles via App.tsx so we can diagnose layout
  issues without npm-installing devtools.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* Tighten rooms layout left/right margins

Drop view-panel and section-nav left margin from 12px to 8px. Removes
the visible empty band between icon-rail and rooms-list (was 12px,
now 8px).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* Remove internal left padding in rooms-detail panel

The detail panel had 4-8px of left padding inside the thread and
compose sections, leaving an empty band on the left. Drop padding-left
to 0 on .room-thread-section and .room-compose-section. Timeline items
keep their own 8px padding-left (for the role border + text gap), and
the textarea inherits the same flush-left starting position as
messages — they align vertically now.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* Fix 26px left empty band: zero out .room-expanded padding in pinned mode

Root cause located via pixel scan + DOM dump: the legacy
.room-expanded wrapper (used by the click-to-expand layout) has
padding: 14px 0 4px 26px to align expanded content with the head's
pulse-dot column. In the pinned twopane layout it was leaving a
26px empty band between the panel border and the chat content.

Override .room-expanded inside .room-card-v2.is-pinned to zero
padding/margin/border-top/gap, so thread-section and compose-section
align flush at the panel's inner left edge.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* Add Playwright + dash-inspect helper script

Playwright (chromium) is installed so future layout debugging can use
real DOM/computed-style queries instead of pixel scans on screenshots.

Helper commands:
  bun scripts/dash-inspect.ts shot [route]
  bun scripts/dash-inspect.ts measure '<selector>'
  bun scripts/dash-inspect.ts eval '<expr>'
  bun scripts/dash-inspect.ts dom '<selector>'
  bun scripts/dash-inspect.ts click '<selector>'

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* Auto-scroll chat to bottom on send and on new messages

- When the user sends a message, scroll the rooms-detail panel to the
  very bottom over 4 frames (covers the post-render layout settle).
- Add a near-bottom tracker: a ref flips false when the user scrolls
  more than 80px from the bottom, true otherwise. The auto-follow
  effect (triggered when messages/outputs/pending counts change) only
  scrolls if the user was already near the bottom — so reading scrollback
  doesn't get yanked.
- Initial room selection and any new content added while at-bottom
  always shows the latest message.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* Add Settings page: model role config + multi-account management

Backend:
- src/settings-store.ts: filesystem reader for Claude (~/.claude,
  ~/.claude-accounts/N) and Codex (~/.codex, ~/.codex-accounts/N)
  account directories. Returns safe metadata only (expiresAt, scopes,
  subscriptionType, planType, accountId from JWT) — never raw tokens.
  Atomic .env writer for OWNER/REVIEWER/ARBITER MODEL/EFFORT pairs.
  Account directory deletion (refuses index 0). Claude account add via
  pasted OAuth JWT (decodes exp/sub from payload, writes new
  ~/.claude-accounts/N/.credentials.json).
- src/web-dashboard-server.ts: new endpoints
  GET    /api/settings/accounts
  GET    /api/settings/models
  PUT    /api/settings/models             (PATCH alias)
  POST   /api/settings/accounts/claude    (body: { token })
  DELETE /api/settings/accounts/{provider}/{index}

Frontend:
- apps/dashboard/src/api.ts: typed clients for the new endpoints.
- apps/dashboard/src/App.tsx: SettingsPanel split into 일반 / 모델 / 계정.
  Model section: editable owner/reviewer/arbiter model+effort fields
  with dirty tracking and a save button. Account section: per-provider
  list with delete buttons (default index 0 protected) and a token-paste
  field for adding a Claude account. Both sections expose a "스택 재시작"
  button that calls the existing /api/services/stack/actions endpoint
  with a confirmation prompt — changes only take effect after restart.

UX scope: phase B (model edit + account delete) + phase D (account add
via paste) per the recommendation; full OAuth flow not included.
Restart is gated behind a confirm() since it kills running agents.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* Format: Prettier follow-up on settings PR

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* Show cron / external-trigger messages inline (separate from CI watcher)

trusted_external_bot messages (Sentry Cron, GitHub webhook triggers,
etc.) were lumped into the CI-watcher fold and hidden by default.
Split them: WATCHER_RE now only matches actual CI status messages
(Build/Deploy/Lint/Release/[CI]/[Watcher]/GitHub Actions on bot kind),
so the watcher fold stays focused on CI noise.

trusted_external_bot messages now get rendered inline in the unified
timeline as cronMessages (sourceKind preserved). senderRoleClass
returns 'role-cron' for senders matching cron/sentry/webhook so the
chip and left border show in muted gray to visually distinguish from
agent and human messages.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* Live progress fallback: scan TASK_STATUS messages for current turn only

When paired_turns.progress_text is empty (codex runner doesn't always
populate it in time), fall back to scanning the room's messages table
for ones starting with the TASK_STATUS_MESSAGE_PREFIX (the invisible
3-char prefix used on Discord live-edit messages). Strip the prefix
and the trailing elapsed indicator (e.g. "\n\n12초"), then render in
the LIVE timeline entry.

Filters:
- timestamp must be >= turn.createdAt (don't show pre-turn history)
- sender role must match the current turn's role (don't show a stale
  reviewer status when owner is now speaking)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* Server fallback: derive live progressText from messages when column empty

Keep paired_turns.progress_text as the primary source (populated by
recordTurnProgress in MessageTurnController). When the column is empty
on an active turn — happens for codex turns where the controller path
isn't fully wired, or any path where ingestion lags — scan the messages
table for the most recent TASK_STATUS_MESSAGE_PREFIX-prefixed message
that belongs to the current turn (sender role match + timestamp >=
turn.createdAt) and use that as progressText.

This makes the dashboard converge with Discord without requiring the
column to be reliably written: if Discord shows a live edit, the
ingest pipeline writes it to messages, and we surface it.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* Poll room timelines every 1.5s on rooms view (was every 15s)

The rooms-detail thread was reusing the 15s overview refresh tick,
which made the chat lag noticeably behind Discord. Add a dedicated
setInterval(1500ms) fetch on the rooms view (cleared when leaving),
with an in-flight guard so overlapping ticks don't pile up. Backend
already has a 2s stale-while-revalidate cache so this stays cheap.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* Real-time rooms updates via Server-Sent Events

Backend: GET /api/stream emits text/event-stream with 'rooms-timeline'
events whenever the server's roomsTimelineCache rebuilds (key change).
A 1.5s tick checks the cache; if builtAt advanced, push the new JSON,
otherwise send a SSE comment heartbeat ': ping ts\n\n' to keep proxies
from idle-closing the connection. AbortSignal handler closes the
ReadableStream when the client disconnects.

Frontend: replace setInterval(1500ms) on rooms view with EventSource.
Initial fetch primes the data so first paint isn't blank, then SSE
takes over for instant updates. EventSource auto-reconnects on
disconnect; while it's flapping, a 2s polling fallback runs so data
keeps flowing. When SSE recovers (next 'rooms-timeline' event) the
polling fallback is cleared.

Latency: previously 0–1.5s polling lag; now ~tick latency (50–150ms)
once cache rebuilds.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* Add breathing room between timeline header and body

Increase room-timeline-item gap from 2px to 6px so the role chip /
timestamp line has visible breathing room above the parsed body —
header → body relationship reads more clearly. Bump vertical padding
from 6px to 8px and item separator from 1px to 2px to match.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* Hide the 2px processing bar in pinned twopane to free role-color borders

.room-card-v2.status-processing::before draws a 2px green bar at the
card's left edge. In pinned twopane mode it sits right on top of each
timeline item's role-colored left border (owner blue / reviewer
orange / arbiter green / human yellow), masking them. The header's
pulse dot already conveys "processing", so suppress the bar in pinned
mode and let the role colors show through.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* Polish room compose: tighter pill input + override global button min-height

The 48px button min-height global rule was forcing the compose grid track to
48px, pushing the textarea content visibly down inside the pill. Adding
min-height: 0 on the compose button lets the track collapse to ~29px so the
placeholder lines up with the send button vertically.

Also slimmed paddings (form 3px vert, textarea 4px vert) and added bottom
breathing room on the compose section (8px → 14px) at user request.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* Format styles.css to multi-line transition values

Prettier 3.8.1 prefers multi-line transition shorthand. CI was failing
format:check on these single-line transitions in the room-compose rules.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-27 19:45:51 +09:00

1660 lines
49 KiB
TypeScript

import fs from 'fs';
import path from 'path';
import { execFileSync } from 'child_process';
import { CronExpressionParser } from 'cron-parser';
import { TIMEZONE, WEB_DASHBOARD } from './config.js';
import {
createTask,
deleteTask,
getAllOpenPairedTasks,
getAllTasks,
getLatestPairedTaskForChat,
getPairedTaskById,
getPairedTurnAttempts,
getPairedTurnOutputs,
getRecentPairedTurnOutputsForChat,
getPairedTurnsForTask,
getLatestPairedTurnForTask,
getRecentChatMessages,
getRecentChatMessagesBatch,
getTaskById,
hasMessage,
storeChatMetadata,
storeMessage,
updatePairedTaskIfUnchanged,
updateTask,
} from './db.js';
import { logger } from './logger.js';
import { schedulePairedFollowUpIntent } from './message-runtime-follow-up.js';
import { type ScheduledPairedFollowUpIntentKind } from './paired-follow-up-scheduler.js';
import {
readStatusSnapshots,
type StatusSnapshot,
} from './status-dashboard.js';
import type {
AgentType,
NewMessage,
PairedTask,
RegisteredGroup,
ScheduledTask,
} from './types.js';
import { isWatchCiTask } from './task-watch-status.js';
import {
buildWebDashboardRoomActivity,
buildWebDashboardOverview,
sanitizeScheduledTask,
} from './web-dashboard-data.js';
import {
addClaudeAccountFromToken,
getModelConfig,
listClaudeAccounts,
listCodexAccounts,
removeAccountDirectory,
updateModelConfig,
} from './settings-store.js';
const DEFAULT_STATUS_MAX_AGE_MS = 10 * 60 * 1000;
const ROOM_MESSAGE_ID_CACHE_LIMIT = 500;
const SERVICE_RESTART_LOG_LIMIT = 20;
const STACK_RESTART_UNIT_NAME = 'ejclaw-stack-restart.service';
const WEB_TASK_PROMPT_MAX_LENGTH = 8000;
const MANAGED_SERVICE_CALLER_FALLBACK_MESSAGE =
'Stack restart unit is not installed yet. Run setup service from an external shell before retrying from a managed EJClaw service.';
const UNIT_NOT_FOUND_PATTERN =
/(Unit .* not found|Could not find the requested service|not-found)/i;
type PairedFollowUpTask = Pick<
PairedTask,
'id' | 'status' | 'round_trip_count' | 'updated_at'
>;
type WebPairedFollowUpScheduler = (args: {
chatJid: string;
runId: string;
task: PairedFollowUpTask;
intentKind: ScheduledPairedFollowUpIntentKind;
enqueue: () => void;
}) => boolean;
export interface ServiceRestartRecord {
id: string;
target: 'stack';
requestedAt: string;
completedAt: string | null;
status: 'running' | 'success' | 'failed';
services: string[];
error?: string;
}
export interface WebDashboardHandlerOptions {
staticDir?: string;
statusMaxAgeMs?: number;
startBackgroundCacheRefresh?: boolean;
readStatusSnapshots?: (maxAgeMs: number) => StatusSnapshot[];
getTasks?: () => ScheduledTask[];
getTaskById?: (id: string) => ScheduledTask | undefined;
updateTask?: (
id: string,
updates: Partial<
Pick<
ScheduledTask,
| 'prompt'
| 'schedule_type'
| 'schedule_value'
| 'next_run'
| 'status'
| 'suspended_until'
>
>,
) => void;
createTask?: (task: {
id: string;
group_folder: string;
chat_jid: string;
agent_type?: AgentType | null;
prompt: string;
schedule_type: ScheduledTask['schedule_type'];
schedule_value: string;
context_mode: ScheduledTask['context_mode'];
next_run: string | null;
status: ScheduledTask['status'];
created_at: string;
}) => void;
deleteTask?: (id: string) => void;
getPairedTasks?: () => PairedTask[];
getLatestPairedTaskForChat?: (chatJid: string) => PairedTask | undefined;
getPairedTurnsForTask?: typeof getPairedTurnsForTask;
getLatestPairedTurnForTask?: typeof getLatestPairedTurnForTask;
getPairedTurnAttempts?: typeof getPairedTurnAttempts;
getPairedTurnOutputs?: typeof getPairedTurnOutputs;
getRecentPairedTurnOutputsForChat?: typeof getRecentPairedTurnOutputsForChat;
getRecentChatMessages?: typeof getRecentChatMessages;
getPairedTaskById?: (id: string) => PairedTask | undefined;
updatePairedTaskIfUnchanged?: (
id: string,
expectedUpdatedAt: string,
updates: Partial<
Pick<
PairedTask,
'status' | 'updated_at' | 'arbiter_requested_at' | 'completion_reason'
>
>,
) => boolean;
schedulePairedFollowUp?: WebPairedFollowUpScheduler;
getRoomBindings?: () => Record<string, RegisteredGroup>;
storeChatMetadata?: (
chatJid: string,
timestamp: string,
name?: string,
channel?: string,
isGroup?: boolean,
) => void;
storeMessage?: (message: NewMessage) => void;
hasMessage?: (chatJid: string, id: string) => boolean;
enqueueMessageCheck?: (chatJid: string, groupFolder: string) => void;
nudgeScheduler?: () => void;
restartServiceStack?: () => string[];
now?: () => string;
}
export interface StartedWebDashboardServer {
url: string;
stop: () => void;
}
const ROOMS_TIMELINE_BG_INTERVAL_MS = 2000;
let roomsTimelineCache: {
key: string;
builtAt: number;
rawJson: string;
gzipBuffer: Uint8Array;
} | null = null;
function jsonResponse(
value: unknown,
init?: ResponseInit,
request?: Request,
): Response {
const body = JSON.stringify(value);
const acceptsGzip =
request?.headers.get('accept-encoding')?.includes('gzip') ?? false;
const baseHeaders: Record<string, string> = {
'content-type': 'application/json; charset=utf-8',
...(init?.headers as Record<string, string> | undefined),
};
if (acceptsGzip && body.length > 1024) {
const compressed = Bun.gzipSync(new TextEncoder().encode(body));
return new Response(compressed, {
...init,
headers: {
...baseHeaders,
'content-encoding': 'gzip',
vary: 'accept-encoding',
},
});
}
return new Response(body, {
...init,
headers: baseHeaders,
});
}
function getContentType(filePath: string): string {
const ext = path.extname(filePath).toLowerCase();
switch (ext) {
case '.html':
return 'text/html; charset=utf-8';
case '.js':
case '.mjs':
return 'text/javascript; charset=utf-8';
case '.css':
return 'text/css; charset=utf-8';
case '.json':
return 'application/json; charset=utf-8';
case '.svg':
return 'image/svg+xml';
case '.png':
return 'image/png';
case '.jpg':
case '.jpeg':
return 'image/jpeg';
case '.webp':
return 'image/webp';
case '.ico':
return 'image/x-icon';
default:
return 'application/octet-stream';
}
}
function resolveStaticFile(staticDir: string, pathname: string): string | null {
const normalizedPath = decodeURIComponent(pathname).replace(/^\/+/, '');
const candidate = path.resolve(staticDir, normalizedPath || 'index.html');
const root = path.resolve(staticDir);
if (candidate !== root && !candidate.startsWith(`${root}${path.sep}`)) {
return null;
}
if (fs.existsSync(candidate) && fs.statSync(candidate).isFile()) {
return candidate;
}
const indexPath = path.join(root, 'index.html');
if (fs.existsSync(indexPath) && fs.statSync(indexPath).isFile()) {
return indexPath;
}
return null;
}
function serveStaticFile(staticDir: string, pathname: string): Response {
const filePath = resolveStaticFile(staticDir, pathname);
if (!filePath) {
return new Response('Not found', { status: 404 });
}
return new Response(fs.readFileSync(filePath), {
headers: {
'content-type': getContentType(filePath),
},
});
}
type TaskAction = 'pause' | 'resume' | 'cancel';
type InboxAction = 'run' | 'decline' | 'dismiss';
type ServiceAction = 'restart';
interface InboxActionRequest {
action: InboxAction;
requestId: string | null;
lastOccurredAt: string | null;
}
interface ServiceActionRequest {
action: ServiceAction;
requestId: string | null;
}
function parseTaskPath(pathname: string): string | null {
const match = pathname.match(/^\/api\/tasks\/([^/]+)$/);
if (!match) return null;
try {
return decodeURIComponent(match[1]);
} catch {
return null;
}
}
function parseTaskActionPath(pathname: string): string | null {
const match = pathname.match(/^\/api\/tasks\/([^/]+)\/actions$/);
if (!match) return null;
try {
return decodeURIComponent(match[1]);
} catch {
return null;
}
}
function parseInboxActionPath(pathname: string): string | null {
const match = pathname.match(/^\/api\/inbox\/([^/]+)\/actions$/);
if (!match) return null;
try {
return decodeURIComponent(match[1]);
} catch {
return null;
}
}
function parseRoomMessagePath(pathname: string): string | null {
const match = pathname.match(/^\/api\/rooms\/([^/]+)\/messages$/);
if (!match) return null;
try {
return decodeURIComponent(match[1]);
} catch {
return null;
}
}
function parseRoomTimelinePath(pathname: string): string | null {
const match = pathname.match(/^\/api\/rooms\/([^/]+)\/timeline$/);
if (!match) return null;
try {
return decodeURIComponent(match[1]);
} catch {
return null;
}
}
function parseServiceActionPath(pathname: string): string | null {
const match = pathname.match(/^\/api\/services\/([^/]+)\/actions$/);
if (!match) return null;
try {
return decodeURIComponent(match[1]);
} catch {
return null;
}
}
function parsePairedInboxTarget(
inboxId: string,
): { taskId: string; status: PairedTask['status'] } | null {
if (!inboxId.startsWith('paired:')) return null;
const rest = inboxId.slice('paired:'.length);
const separatorIndex = rest.lastIndexOf(':');
if (separatorIndex <= 0 || separatorIndex === rest.length - 1) return null;
const status = rest.slice(separatorIndex + 1);
if (!isPairedTaskStatus(status)) return null;
return {
taskId: rest.slice(0, separatorIndex),
status,
};
}
function isTaskAction(value: unknown): value is TaskAction {
return value === 'pause' || value === 'resume' || value === 'cancel';
}
function isScheduleType(
value: unknown,
): value is ScheduledTask['schedule_type'] {
return value === 'cron' || value === 'interval' || value === 'once';
}
function isContextMode(value: unknown): value is ScheduledTask['context_mode'] {
return value === 'group' || value === 'isolated';
}
function isAgentType(value: unknown): value is AgentType {
return value === 'claude-code' || value === 'codex';
}
function isInboxAction(value: unknown): value is InboxAction {
return value === 'run' || value === 'decline' || value === 'dismiss';
}
function isServiceAction(value: unknown): value is ServiceAction {
return value === 'restart';
}
function isPairedTaskStatus(value: unknown): value is PairedTask['status'] {
return (
value === 'active' ||
value === 'review_ready' ||
value === 'in_review' ||
value === 'merge_ready' ||
value === 'completed' ||
value === 'arbiter_requested' ||
value === 'in_arbitration'
);
}
async function readTaskAction(request: Request): Promise<TaskAction | null> {
try {
const body = (await request.json()) as { action?: unknown };
return isTaskAction(body.action) ? body.action : null;
} catch {
return null;
}
}
async function readInboxAction(
request: Request,
): Promise<InboxActionRequest | null> {
try {
const body = (await request.json()) as {
action?: unknown;
requestId?: unknown;
lastOccurredAt?: unknown;
};
if (!isInboxAction(body.action)) return null;
return {
action: body.action,
requestId: sanitizeInboxActionRequestId(body.requestId),
lastOccurredAt:
typeof body.lastOccurredAt === 'string' && body.lastOccurredAt.trim()
? body.lastOccurredAt.trim()
: null,
};
} catch {
return null;
}
}
async function readServiceAction(
request: Request,
): Promise<ServiceActionRequest | null> {
try {
const body = (await request.json()) as {
action?: unknown;
requestId?: unknown;
};
if (!isServiceAction(body.action)) return null;
return {
action: body.action,
requestId: sanitizeServiceActionRequestId(body.requestId),
};
} catch {
return null;
}
}
interface ScheduledTaskMutationBody {
roomJid?: unknown;
groupFolder?: unknown;
prompt?: unknown;
scheduleType?: unknown;
scheduleValue?: unknown;
contextMode?: unknown;
agentType?: unknown;
requestId?: unknown;
}
async function readScheduledTaskMutationBody(
request: Request,
): Promise<ScheduledTaskMutationBody | null> {
try {
return (await request.json()) as ScheduledTaskMutationBody;
} catch {
return null;
}
}
function normalizeNonEmptyString(value: unknown): string | null {
if (typeof value !== 'string') return null;
const trimmed = value.trim();
return trimmed ? trimmed : null;
}
function normalizeTaskPrompt(value: unknown): string | null {
const prompt = normalizeNonEmptyString(value);
if (!prompt) return null;
return prompt.length > WEB_TASK_PROMPT_MAX_LENGTH
? prompt.slice(0, WEB_TASK_PROMPT_MAX_LENGTH)
: prompt;
}
function computeInitialNextRun(
scheduleType: ScheduledTask['schedule_type'],
scheduleValue: string,
nowIso: string,
): { nextRun: string | null; error?: string } {
if (scheduleType === 'cron') {
try {
const interval = CronExpressionParser.parse(scheduleValue, {
tz: TIMEZONE,
});
return { nextRun: interval.next().toISOString() };
} catch {
return { nextRun: null, error: 'Invalid cron schedule' };
}
}
if (scheduleType === 'interval') {
const ms = Number.parseInt(scheduleValue, 10);
if (!Number.isFinite(ms) || ms <= 0) {
return { nextRun: null, error: 'Invalid interval schedule' };
}
return {
nextRun: new Date(new Date(nowIso).getTime() + ms).toISOString(),
};
}
const date = new Date(scheduleValue);
if (Number.isNaN(date.getTime())) {
return { nextRun: null, error: 'Invalid timestamp schedule' };
}
return { nextRun: date.toISOString() };
}
function resolveTaskRoom(
body: ScheduledTaskMutationBody,
roomBindings: Record<string, RegisteredGroup>,
): { chatJid: string; group: RegisteredGroup } | null {
const roomJid = normalizeNonEmptyString(body.roomJid);
const groupFolder = normalizeNonEmptyString(body.groupFolder);
if (roomJid && roomBindings[roomJid]) {
return { chatJid: roomJid, group: roomBindings[roomJid] };
}
if (groupFolder) {
const match = Object.entries(roomBindings).find(
([, group]) => group.folder === groupFolder,
);
if (match) return { chatJid: match[0], group: match[1] };
}
return null;
}
function makeWebTaskId(): string {
return `web-task-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
}
function sanitizeScheduledTaskRequestId(value: unknown): string | null {
if (typeof value !== 'string') return null;
const trimmed = value.trim();
if (!trimmed) return null;
const safe = trimmed.replace(/[^A-Za-z0-9._:-]/g, '-').slice(0, 120);
return safe || null;
}
function makeWebTaskIdFromRequest(requestId: string | null): string {
return requestId ? `web-task-${requestId}` : makeWebTaskId();
}
function sanitizeRoomMessageRequestId(value: unknown): string | null {
if (typeof value !== 'string') return null;
const trimmed = value.trim();
if (!trimmed) return null;
const safe = trimmed.replace(/[^A-Za-z0-9._:-]/g, '-').slice(0, 120);
return safe || null;
}
async function readRoomMessageBody(request: Request): Promise<{
text: string;
requestId: string | null;
nickname: string | null;
} | null> {
try {
const body = (await request.json()) as {
text?: unknown;
requestId?: unknown;
nickname?: unknown;
};
if (typeof body.text !== 'string') return null;
const text = body.text.trim();
if (!text) return null;
let nickname: string | null = null;
if (typeof body.nickname === 'string') {
const trimmed = body.nickname.trim().slice(0, 32);
if (trimmed) nickname = trimmed;
}
return {
text: text.length > 8000 ? text.slice(0, 8000) : text,
requestId: sanitizeRoomMessageRequestId(body.requestId),
nickname,
};
} catch {
return null;
}
}
function makeWebMessageId(): string {
return `web-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
}
function makeWebMessageIdFromRequest(requestId: string | null): string {
return requestId ? `web-${requestId}` : makeWebMessageId();
}
function makeWebRunId(prefix: string): string {
return `web-${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
}
function sanitizeServiceActionRequestId(value: unknown): string | null {
if (typeof value !== 'string') return null;
const trimmed = value.trim();
if (!trimmed) return null;
const safe = trimmed.replace(/[^A-Za-z0-9._:-]/g, '-').slice(0, 120);
return safe || null;
}
function makeServiceRestartId(requestId: string | null): string {
return requestId
? `web-restart-${requestId}`
: `web-restart-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
}
function sanitizeInboxActionRequestId(value: unknown): string | null {
if (typeof value !== 'string') return null;
const trimmed = value.trim();
if (!trimmed) return null;
const safe = trimmed.replace(/[^A-Za-z0-9._:-]/g, '-').slice(0, 120);
return safe || null;
}
function makeInboxDismissKey(
inboxId: string,
lastOccurredAt: string | null,
): string {
return lastOccurredAt ? `${inboxId}\0${lastOccurredAt}` : inboxId;
}
function makeWebInboxMessageId(requestId: string | null): string {
return requestId
? `web-inbox-${requestId}`
: `web-inbox-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
}
function declinedInboxMessage(status: PairedTask['status']): string {
if (status === 'review_ready' || status === 'in_review') {
return 'Dashboard declined review. Continue with the owner turn.';
}
if (status === 'merge_ready') {
return 'Dashboard declined finalization. Continue with the owner turn.';
}
if (status === 'arbiter_requested' || status === 'in_arbitration') {
return 'Dashboard declined arbiter escalation. Continue with the owner turn.';
}
return 'Dashboard declined this inbox item. Continue with the owner turn.';
}
function pairedFollowUpIntentForStatus(
status: PairedTask['status'],
): ScheduledPairedFollowUpIntentKind | null {
if (status === 'review_ready' || status === 'in_review') {
return 'reviewer-turn';
}
if (status === 'merge_ready') {
return 'finalize-owner-turn';
}
if (status === 'arbiter_requested' || status === 'in_arbitration') {
return 'arbiter-turn';
}
return null;
}
function isRoot(): boolean {
return typeof process.getuid === 'function' && process.getuid() === 0;
}
function restartEjclawStackServices(): string[] {
if (process.platform !== 'linux') {
throw new Error('Service restart only supports Linux systemd services');
}
const services = ['ejclaw'];
const systemctlArgs = isRoot() ? [] : ['--user'];
try {
execFileSync(
'systemctl',
[...systemctlArgs, 'start', '--wait', STACK_RESTART_UNIT_NAME],
{ stdio: 'ignore' },
);
} catch (error) {
const stderr =
error && typeof error === 'object' && 'stderr' in error
? String((error as { stderr?: unknown }).stderr || '')
: '';
const stdout =
error && typeof error === 'object' && 'stdout' in error
? String((error as { stdout?: unknown }).stdout || '')
: '';
const message =
error instanceof Error ? error.message : `${stdout}\n${stderr}`.trim();
const combined = `${message}\n${stdout}\n${stderr}`;
if (!UNIT_NOT_FOUND_PATTERN.test(combined)) {
throw error;
}
if (process.env.SERVICE_ID) {
throw new Error(MANAGED_SERVICE_CALLER_FALLBACK_MESSAGE);
}
execFileSync('systemctl', [...systemctlArgs, 'restart', ...services], {
stdio: 'ignore',
});
for (const service of services) {
execFileSync(
'systemctl',
[...systemctlArgs, 'is-active', '--quiet', service],
{ stdio: 'ignore' },
);
}
}
return services;
}
export function createWebDashboardHandler(
opts: WebDashboardHandlerOptions = {},
): (request: Request) => Response | Promise<Response> {
const readSnapshots = opts.readStatusSnapshots ?? readStatusSnapshots;
const loadTasks = opts.getTasks ?? getAllTasks;
const loadTaskById = opts.getTaskById ?? getTaskById;
const mutateTask = opts.updateTask ?? updateTask;
const createScheduledTask = opts.createTask ?? createTask;
const removeTask = opts.deleteTask ?? deleteTask;
const loadPairedTasks = opts.getPairedTasks ?? getAllOpenPairedTasks;
const loadPairedTaskById = opts.getPairedTaskById ?? getPairedTaskById;
const mutatePairedTaskIfUnchanged =
opts.updatePairedTaskIfUnchanged ?? updatePairedTaskIfUnchanged;
const loadLatestPairedTaskForChat =
opts.getLatestPairedTaskForChat ?? getLatestPairedTaskForChat;
const loadPairedTurnsForTask =
opts.getPairedTurnsForTask ?? getPairedTurnsForTask;
const loadLatestPairedTurnForTask =
opts.getLatestPairedTurnForTask ?? getLatestPairedTurnForTask;
const loadPairedTurnOutputs =
opts.getPairedTurnOutputs ?? getPairedTurnOutputs;
const loadRecentPairedTurnOutputsForChat =
opts.getRecentPairedTurnOutputsForChat ?? getRecentPairedTurnOutputsForChat;
const loadPairedTurnAttempts =
opts.getPairedTurnAttempts ?? getPairedTurnAttempts;
const loadRecentChatMessages =
opts.getRecentChatMessages ?? getRecentChatMessages;
const schedulePairedFollowUp =
opts.schedulePairedFollowUp ?? schedulePairedFollowUpIntent;
const loadRoomBindings = opts.getRoomBindings;
const writeChatMetadata = opts.storeChatMetadata ?? storeChatMetadata;
const writeMessage = opts.storeMessage ?? storeMessage;
const messageExists = opts.hasMessage ?? hasMessage;
const enqueueMessageCheck = opts.enqueueMessageCheck;
const nudgeScheduler = opts.nudgeScheduler;
const restartServiceStack =
opts.restartServiceStack ?? restartEjclawStackServices;
const statusMaxAgeMs = opts.statusMaxAgeMs ?? DEFAULT_STATUS_MAX_AGE_MS;
function buildRoomsTimelineResult(): Record<
string,
ReturnType<typeof buildWebDashboardRoomActivity>
> {
const snapshots = readSnapshots(statusMaxAgeMs);
const uniqueByJid = new Map<
string,
{
snapshot: (typeof snapshots)[number];
entry: (typeof snapshots)[number]['entries'][number];
}
>();
for (const snapshot of snapshots) {
for (const entry of snapshot.entries) {
const existing = uniqueByJid.get(entry.jid);
if (
!existing ||
existing.snapshot.updatedAt.localeCompare(snapshot.updatedAt) < 0
) {
uniqueByJid.set(entry.jid, { snapshot, entry });
}
}
}
const result: Record<
string,
ReturnType<typeof buildWebDashboardRoomActivity>
> = {};
for (const [jid, { snapshot, entry }] of uniqueByJid) {
const pairedTask = loadLatestPairedTaskForChat(jid) ?? null;
const messages = loadRecentChatMessages(jid, 8);
const outputs = loadRecentPairedTurnOutputsForChat(jid, 8);
if (!pairedTask && messages.length === 0 && outputs.length === 0)
continue;
const latestTurn = pairedTask
? loadLatestPairedTurnForTask(pairedTask.id)
: null;
const turns = latestTurn ? [latestTurn] : [];
result[jid] = buildWebDashboardRoomActivity({
serviceId: snapshot.serviceId,
entry,
pairedTask,
turns,
attempts: [],
outputs,
messages,
outputLimit: 8,
});
}
return result;
}
function computeRoomsCacheKey(): string {
return readSnapshots(statusMaxAgeMs)
.map((s) => s.updatedAt)
.sort()
.join('|');
}
function ensureRoomsTimelineCache(): NonNullable<typeof roomsTimelineCache> {
const key = computeRoomsCacheKey();
if (roomsTimelineCache && roomsTimelineCache.key === key) {
return roomsTimelineCache;
}
const result = buildRoomsTimelineResult();
const rawJson = JSON.stringify(result);
const gzipBuffer = Bun.gzipSync(new TextEncoder().encode(rawJson));
roomsTimelineCache = {
key,
builtAt: Date.now(),
rawJson,
gzipBuffer,
};
return roomsTimelineCache;
}
if (opts.startBackgroundCacheRefresh !== false) {
setTimeout(() => {
try {
ensureRoomsTimelineCache();
} catch {
/* warm-up failure is non-fatal */
}
}, 0);
setInterval(() => {
try {
ensureRoomsTimelineCache();
} catch {
/* refresh failure is non-fatal */
}
}, ROOMS_TIMELINE_BG_INTERVAL_MS).unref();
}
const seenRoomMessageIds: string[] = [];
const seenRoomMessageIdSet = new Set<string>();
const dismissedInboxKeys = new Set<string>();
const recentServiceRestarts: ServiceRestartRecord[] = [];
const activeServiceRestartTargets = new Set<string>();
function rememberRoomMessageId(id: string): boolean {
if (seenRoomMessageIdSet.has(id)) return false;
seenRoomMessageIdSet.add(id);
seenRoomMessageIds.push(id);
if (seenRoomMessageIds.length > ROOM_MESSAGE_ID_CACHE_LIMIT) {
const oldest = seenRoomMessageIds.shift();
if (oldest) seenRoomMessageIdSet.delete(oldest);
}
return true;
}
function isInboxItemDismissed(item: {
id: string;
lastOccurredAt: string;
}): boolean {
return (
dismissedInboxKeys.has(item.id) ||
dismissedInboxKeys.has(makeInboxDismissKey(item.id, item.lastOccurredAt))
);
}
function rememberServiceRestart(record: ServiceRestartRecord): void {
recentServiceRestarts.unshift(record);
if (recentServiceRestarts.length > SERVICE_RESTART_LOG_LIMIT) {
recentServiceRestarts.length = SERVICE_RESTART_LOG_LIMIT;
}
}
return async (request: Request): Promise<Response> => {
const url = new URL(request.url);
const actionTaskId = parseTaskActionPath(url.pathname);
const taskId = parseTaskPath(url.pathname);
const actionInboxId = parseInboxActionPath(url.pathname);
const messageRoomJid = parseRoomMessagePath(url.pathname);
const timelineRoomJid = parseRoomTimelinePath(url.pathname);
const actionServiceId = parseServiceActionPath(url.pathname);
if (actionTaskId) {
if (request.method !== 'POST') {
return jsonResponse({ error: 'Method not allowed' }, { status: 405 });
}
const action = await readTaskAction(request);
if (!action) {
return jsonResponse({ error: 'Invalid task action' }, { status: 400 });
}
const task = loadTaskById(actionTaskId);
if (!task) {
return jsonResponse({ error: 'Task not found' }, { status: 404 });
}
if (task.status === 'completed' && action !== 'cancel') {
return jsonResponse(
{ error: 'Completed tasks cannot be changed' },
{ status: 409 },
);
}
if (action === 'cancel') {
removeTask(actionTaskId);
return jsonResponse({ ok: true, id: actionTaskId, deleted: true });
}
mutateTask(actionTaskId, {
status: action === 'pause' ? 'paused' : 'active',
suspended_until: null,
});
const updatedTask = loadTaskById(actionTaskId);
return jsonResponse({
ok: true,
task: updatedTask ? sanitizeScheduledTask(updatedTask) : null,
});
}
if (actionInboxId) {
if (request.method !== 'POST') {
return jsonResponse({ error: 'Method not allowed' }, { status: 405 });
}
const inboxRequest = await readInboxAction(request);
if (!inboxRequest) {
return jsonResponse({ error: 'Invalid inbox action' }, { status: 400 });
}
if (inboxRequest.action === 'dismiss') {
dismissedInboxKeys.add(
makeInboxDismissKey(actionInboxId, inboxRequest.lastOccurredAt),
);
return jsonResponse({ ok: true, id: actionInboxId, dismissed: true });
}
const pairedTarget = parsePairedInboxTarget(actionInboxId);
if (!pairedTarget) {
return jsonResponse(
{ error: 'Unsupported inbox action target' },
{ status: 400 },
);
}
if (!enqueueMessageCheck) {
return jsonResponse(
{ error: 'Paired follow-up queue is not configured' },
{ status: 503 },
);
}
const task = loadPairedTaskById(pairedTarget.taskId);
if (!task) {
return jsonResponse(
{ error: 'Paired task not found' },
{ status: 404 },
);
}
if (inboxRequest.action === 'decline') {
const messageId = makeWebInboxMessageId(inboxRequest.requestId);
if (inboxRequest.requestId && messageExists(task.chat_jid, messageId)) {
return jsonResponse({
ok: true,
id: actionInboxId,
taskId: task.id,
queued: false,
duplicate: true,
});
}
}
if (task.status !== pairedTarget.status) {
return jsonResponse(
{
error: 'Inbox item is stale',
status: task.status,
},
{ status: 409 },
);
}
if (inboxRequest.action === 'decline') {
const timestamp = opts.now?.() ?? new Date().toISOString();
const messageId = makeWebInboxMessageId(inboxRequest.requestId);
const updates: Partial<
Pick<
PairedTask,
| 'status'
| 'updated_at'
| 'arbiter_requested_at'
| 'completion_reason'
>
> = {
status: 'active',
updated_at: timestamp,
};
if (
task.status === 'arbiter_requested' ||
task.status === 'in_arbitration'
) {
updates.arbiter_requested_at = null;
}
const updated = mutatePairedTaskIfUnchanged(
task.id,
task.updated_at,
updates,
);
if (!updated) {
return jsonResponse(
{ error: 'Inbox item is stale', status: task.status },
{ status: 409 },
);
}
writeChatMetadata(
task.chat_jid,
timestamp,
undefined,
'web-dashboard',
true,
);
writeMessage({
id: messageId,
chat_jid: task.chat_jid,
sender: 'web-dashboard',
sender_name: 'Web Dashboard',
content: declinedInboxMessage(task.status),
timestamp,
is_from_me: false,
is_bot_message: false,
message_source_kind: 'ipc_injected_human',
});
enqueueMessageCheck(task.chat_jid, task.group_folder);
return jsonResponse({
ok: true,
id: actionInboxId,
taskId: task.id,
status: 'active',
queued: true,
});
}
const intentKind = pairedFollowUpIntentForStatus(task.status);
if (!intentKind) {
return jsonResponse(
{ error: 'Paired task is not actionable' },
{ status: 409 },
);
}
const queued = schedulePairedFollowUp({
chatJid: task.chat_jid,
runId: makeWebRunId('inbox'),
task,
intentKind,
enqueue: () => enqueueMessageCheck(task.chat_jid, task.group_folder),
});
return jsonResponse({
ok: true,
id: actionInboxId,
taskId: task.id,
intentKind,
queued,
});
}
if (messageRoomJid) {
if (request.method !== 'POST') {
return jsonResponse({ error: 'Method not allowed' }, { status: 405 });
}
if (!loadRoomBindings || !enqueueMessageCheck) {
return jsonResponse(
{ error: 'Room message injection is not configured' },
{ status: 503 },
);
}
const body = await readRoomMessageBody(request);
if (!body) {
return jsonResponse(
{ error: 'Message text is required' },
{ status: 400 },
);
}
const binding = loadRoomBindings()[messageRoomJid];
if (!binding) {
return jsonResponse({ error: 'Room not found' }, { status: 404 });
}
const timestamp = opts.now?.() ?? new Date().toISOString();
const id = makeWebMessageIdFromRequest(body.requestId);
if (body.requestId && messageExists(messageRoomJid, id)) {
return jsonResponse({ ok: true, id, queued: false, duplicate: true });
}
if (!rememberRoomMessageId(`${messageRoomJid}:${id}`)) {
return jsonResponse({ ok: true, id, queued: false, duplicate: true });
}
writeChatMetadata(
messageRoomJid,
timestamp,
binding.name,
'web-dashboard',
true,
);
writeMessage({
id,
chat_jid: messageRoomJid,
sender: 'web-dashboard',
sender_name: body.nickname ?? 'Web Dashboard',
content: body.text,
timestamp,
is_from_me: false,
is_bot_message: false,
message_source_kind: 'ipc_injected_human',
});
enqueueMessageCheck(messageRoomJid, binding.folder);
return jsonResponse({ ok: true, id, queued: true });
}
if (timelineRoomJid) {
if (request.method !== 'GET' && request.method !== 'HEAD') {
return jsonResponse({ error: 'Method not allowed' }, { status: 405 });
}
const snapshots = readSnapshots(statusMaxAgeMs);
const matched = snapshots
.flatMap((snapshot) =>
snapshot.entries
.filter((entry) => entry.jid === timelineRoomJid)
.map((entry) => ({ snapshot, entry })),
)
.sort((a, b) =>
b.snapshot.updatedAt.localeCompare(a.snapshot.updatedAt),
)
.at(0);
if (!matched) {
return jsonResponse(
{ error: 'Room timeline not found' },
{ status: 404 },
);
}
const pairedTask = loadLatestPairedTaskForChat(timelineRoomJid) ?? null;
const turns = pairedTask ? loadPairedTurnsForTask(pairedTask.id) : [];
const attempts = turns.flatMap((turn) =>
loadPairedTurnAttempts(turn.turn_id),
);
return jsonResponse(
buildWebDashboardRoomActivity({
serviceId: matched.snapshot.serviceId,
entry: matched.entry,
pairedTask,
turns,
attempts,
outputs: pairedTask ? loadPairedTurnOutputs(pairedTask.id) : [],
messages: loadRecentChatMessages(timelineRoomJid, 8),
}),
);
}
if (actionServiceId) {
if (request.method !== 'POST') {
return jsonResponse({ error: 'Method not allowed' }, { status: 405 });
}
if (actionServiceId !== 'stack') {
return jsonResponse(
{ error: 'Unsupported service restart target' },
{ status: 400 },
);
}
const serviceRequest = await readServiceAction(request);
if (!serviceRequest) {
return jsonResponse(
{ error: 'Invalid service action' },
{ status: 400 },
);
}
const id = makeServiceRestartId(serviceRequest.requestId);
const previous = recentServiceRestarts.find((record) => record.id === id);
if (serviceRequest.requestId && previous) {
if (previous.status === 'failed') {
return jsonResponse(
{
error: previous.error ?? 'Service restart failed',
duplicate: true,
restart: previous,
},
{ status: 500 },
);
}
return jsonResponse({
ok: true,
duplicate: true,
restart: previous,
});
}
if (activeServiceRestartTargets.has(actionServiceId)) {
return jsonResponse(
{ error: 'Service restart is already running' },
{ status: 409 },
);
}
const requestedAt = opts.now?.() ?? new Date().toISOString();
const record: ServiceRestartRecord = {
id,
target: 'stack',
requestedAt,
completedAt: null,
status: 'running',
services: [],
};
rememberServiceRestart(record);
activeServiceRestartTargets.add(actionServiceId);
try {
const services = restartServiceStack();
record.completedAt = opts.now?.() ?? new Date().toISOString();
record.status = 'success';
record.services = services;
return jsonResponse({ ok: true, restart: record });
} catch (error) {
record.completedAt = opts.now?.() ?? new Date().toISOString();
record.status = 'failed';
record.error = error instanceof Error ? error.message : String(error);
return jsonResponse(
{ error: record.error, restart: record },
{ status: 500 },
);
} finally {
activeServiceRestartTargets.delete(actionServiceId);
}
}
if (
url.pathname === '/api/settings/models' &&
(request.method === 'PUT' || request.method === 'PATCH')
) {
let body: unknown = null;
try {
body = await request.json();
} catch {
return jsonResponse({ error: 'Invalid JSON body' }, { status: 400 });
}
if (!body || typeof body !== 'object') {
return jsonResponse(
{ error: 'Body must be a JSON object' },
{ status: 400 },
);
}
try {
const next = updateModelConfig(body as Record<string, unknown>);
return jsonResponse(next);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
return jsonResponse({ error: message }, { status: 500 });
}
}
{
const accountAddMatch = url.pathname.match(
/^\/api\/settings\/accounts\/(claude)$/,
);
if (accountAddMatch && request.method === 'POST') {
let body: { token?: unknown } | null = null;
try {
body = (await request.json()) as { token?: unknown };
} catch {
return jsonResponse({ error: 'Invalid JSON body' }, { status: 400 });
}
const token = typeof body?.token === 'string' ? body.token.trim() : '';
if (!token) {
return jsonResponse({ error: 'token is required' }, { status: 400 });
}
try {
const result = addClaudeAccountFromToken(token);
return jsonResponse({ ok: true, ...result });
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
return jsonResponse({ error: message }, { status: 400 });
}
}
}
{
const accountDelMatch = url.pathname.match(
/^\/api\/settings\/accounts\/(claude|codex)\/(\d+)$/,
);
if (accountDelMatch && request.method === 'DELETE') {
const provider = accountDelMatch[1] as 'claude' | 'codex';
const index = Number.parseInt(accountDelMatch[2], 10);
try {
removeAccountDirectory(provider, index);
return jsonResponse({ ok: true, provider, index });
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
return jsonResponse({ error: message }, { status: 400 });
}
}
}
if (url.pathname === '/api/tasks' && request.method === 'POST') {
if (!loadRoomBindings) {
return jsonResponse(
{ error: 'Task creation is not configured' },
{ status: 503 },
);
}
const body = await readScheduledTaskMutationBody(request);
const prompt = normalizeTaskPrompt(body?.prompt);
const scheduleType =
body && isScheduleType(body.scheduleType) ? body.scheduleType : null;
const scheduleValue = normalizeNonEmptyString(body?.scheduleValue);
if (!body || !prompt || !scheduleType || !scheduleValue) {
return jsonResponse(
{
error:
'Task prompt, schedule type, and schedule value are required',
},
{ status: 400 },
);
}
const room = resolveTaskRoom(body, loadRoomBindings());
if (!room) {
return jsonResponse({ error: 'Room not found' }, { status: 404 });
}
const nowIso = opts.now?.() ?? new Date().toISOString();
const next = computeInitialNextRun(scheduleType, scheduleValue, nowIso);
if (next.error) {
return jsonResponse({ error: next.error }, { status: 400 });
}
const contextMode = isContextMode(body.contextMode)
? body.contextMode
: 'isolated';
const agentType = isAgentType(body.agentType)
? body.agentType
: (room.group.agentType ?? 'claude-code');
const requestId = sanitizeScheduledTaskRequestId(body.requestId);
const id = makeWebTaskIdFromRequest(requestId);
const existing = requestId ? loadTaskById(id) : undefined;
if (existing) {
return jsonResponse({
ok: true,
duplicate: true,
task: sanitizeScheduledTask(existing),
});
}
createScheduledTask({
id,
group_folder: room.group.folder,
chat_jid: room.chatJid,
agent_type: agentType,
prompt,
schedule_type: scheduleType,
schedule_value: scheduleValue,
context_mode: contextMode,
next_run: next.nextRun,
status: 'active',
created_at: nowIso,
});
const created = loadTaskById(id);
if (
next.nextRun &&
new Date(next.nextRun).getTime() <= new Date(nowIso).getTime()
) {
nudgeScheduler?.();
}
return jsonResponse({
ok: true,
task: created ? sanitizeScheduledTask(created) : null,
});
}
if (taskId && request.method === 'PATCH') {
const task = loadTaskById(taskId);
if (!task) {
return jsonResponse({ error: 'Task not found' }, { status: 404 });
}
if (task.status === 'completed') {
return jsonResponse(
{ error: 'Completed tasks cannot be edited' },
{ status: 409 },
);
}
if (isWatchCiTask(task)) {
return jsonResponse(
{ error: 'CI watchers cannot be edited here' },
{ status: 409 },
);
}
const body = await readScheduledTaskMutationBody(request);
if (!body) {
return jsonResponse({ error: 'Invalid task update' }, { status: 400 });
}
if (
body.roomJid !== undefined ||
body.groupFolder !== undefined ||
body.contextMode !== undefined ||
body.agentType !== undefined
) {
return jsonResponse(
{ error: 'Task room, context, and agent cannot be edited here' },
{ status: 400 },
);
}
const prompt =
body.prompt === undefined
? task.prompt
: normalizeTaskPrompt(body.prompt);
const scheduleChanged =
body.scheduleType !== undefined || body.scheduleValue !== undefined;
const scheduleType =
body.scheduleType === undefined
? task.schedule_type
: isScheduleType(body.scheduleType)
? body.scheduleType
: null;
const scheduleValue =
body.scheduleValue === undefined
? task.schedule_value
: normalizeNonEmptyString(body.scheduleValue);
if (!prompt || !scheduleType || !scheduleValue) {
return jsonResponse({ error: 'Invalid task update' }, { status: 400 });
}
const updates: Parameters<typeof mutateTask>[1] = {
prompt,
schedule_type: scheduleType,
schedule_value: scheduleValue,
};
if (scheduleChanged) {
const next = computeInitialNextRun(
scheduleType,
scheduleValue,
opts.now?.() ?? new Date().toISOString(),
);
if (next.error) {
return jsonResponse({ error: next.error }, { status: 400 });
}
updates.next_run = next.nextRun;
updates.suspended_until = null;
}
mutateTask(taskId, updates);
const updatedTask = loadTaskById(taskId);
return jsonResponse({
ok: true,
task: updatedTask ? sanitizeScheduledTask(updatedTask) : null,
});
}
if (request.method !== 'GET' && request.method !== 'HEAD') {
return jsonResponse({ error: 'Method not allowed' }, { status: 405 });
}
if (url.pathname === '/api/health') {
return jsonResponse({ ok: true });
}
if (url.pathname === '/api/overview') {
const snapshots = readSnapshots(statusMaxAgeMs);
const tasks = loadTasks();
const pairedTasks = loadPairedTasks();
const overview = buildWebDashboardOverview({
now: opts.now?.(),
snapshots,
tasks,
pairedTasks,
});
return jsonResponse({
...overview,
operations: {
serviceRestarts: recentServiceRestarts,
},
inbox: overview.inbox.filter((item) => !isInboxItemDismissed(item)),
});
}
if (url.pathname === '/api/status-snapshots') {
return jsonResponse(readSnapshots(statusMaxAgeMs));
}
if (url.pathname === '/api/tasks') {
return jsonResponse(loadTasks().map(sanitizeScheduledTask));
}
if (url.pathname === '/api/settings/accounts') {
return jsonResponse({
claude: listClaudeAccounts(),
codex: listCodexAccounts(),
});
}
if (url.pathname === '/api/settings/models') {
return jsonResponse(getModelConfig());
}
if (url.pathname === '/api/stream') {
const encoder = new TextEncoder();
const stream = new ReadableStream({
start(controller) {
let lastBuiltAt = 0;
let closed = false;
const enqueue = (chunk: string) => {
if (closed) return;
try {
controller.enqueue(encoder.encode(chunk));
} catch {
closed = true;
}
};
// Initial: send retry hint and seed event
enqueue(`retry: 3000\n\n`);
try {
const cache = ensureRoomsTimelineCache();
lastBuiltAt = cache.builtAt;
enqueue(`event: rooms-timeline\ndata: ${cache.rawJson}\n\n`);
} catch {
/* warm-up failure is non-fatal */
}
const tick = () => {
if (closed) return;
try {
const cache = ensureRoomsTimelineCache();
if (cache.builtAt !== lastBuiltAt) {
lastBuiltAt = cache.builtAt;
enqueue(`event: rooms-timeline\ndata: ${cache.rawJson}\n\n`);
} else {
// heartbeat to keep connection alive through proxies
enqueue(`: ping ${Date.now()}\n\n`);
}
} catch {
/* skip this tick */
}
};
const interval = setInterval(tick, 1500);
const close = () => {
if (closed) return;
closed = true;
clearInterval(interval);
try {
controller.close();
} catch {
/* already closed */
}
};
request.signal.addEventListener('abort', close);
},
});
return new Response(stream, {
headers: {
'content-type': 'text/event-stream; charset=utf-8',
'cache-control': 'no-cache, no-transform',
connection: 'keep-alive',
'x-accel-buffering': 'no',
},
});
}
if (url.pathname === '/api/rooms-timeline') {
const cache = ensureRoomsTimelineCache();
const acceptsGzip =
request.headers.get('accept-encoding')?.includes('gzip') ?? false;
if (acceptsGzip) {
return new Response(cache.gzipBuffer, {
headers: {
'content-type': 'application/json; charset=utf-8',
'content-encoding': 'gzip',
vary: 'accept-encoding',
'x-cache-age': String(Date.now() - cache.builtAt),
},
});
}
return new Response(cache.rawJson, {
headers: {
'content-type': 'application/json; charset=utf-8',
'x-cache-age': String(Date.now() - cache.builtAt),
},
});
}
if (url.pathname.startsWith('/api/')) {
return jsonResponse({ error: 'Not found' }, { status: 404 });
}
if (!opts.staticDir) {
return new Response('Dashboard static directory is not configured', {
status: 404,
});
}
return serveStaticFile(opts.staticDir, url.pathname);
};
}
export function startWebDashboardServer(
opts: {
enabled?: boolean;
host?: string;
port?: number;
staticDir?: string;
getRoomBindings?: () => Record<string, RegisteredGroup>;
enqueueMessageCheck?: (chatJid: string, groupFolder: string) => void;
nudgeScheduler?: () => void;
} = {},
): StartedWebDashboardServer | null {
const enabled = opts.enabled ?? WEB_DASHBOARD.enabled;
if (!enabled) return null;
const host = opts.host ?? WEB_DASHBOARD.host;
const port = opts.port ?? WEB_DASHBOARD.port;
const staticDir = opts.staticDir ?? WEB_DASHBOARD.staticDir;
const server = Bun.serve({
hostname: host,
port,
fetch: createWebDashboardHandler({
staticDir,
getRoomBindings: opts.getRoomBindings,
enqueueMessageCheck: opts.enqueueMessageCheck,
nudgeScheduler: opts.nudgeScheduler,
}),
});
const url = `http://${host}:${server.port}`;
logger.info({ url, staticDir }, 'Web dashboard started');
return {
url,
stop: () => server.stop(true),
};
}