Compare commits

...
Author SHA1 Message Date
luccast 45e82efaf3 fix(monitor): handle clipboard copy errors in ExecNode and SessionNode
- Updated clipboard copy functionality in ExecNode and SessionNode to handle potential errors by adding a catch block to the writeText method.
- Ensured that the user experience remains smooth by maintaining the existing feedback mechanism for copy actions.
2026-01-28 12:14:00 -05:00
luccast 5fb52e6e10 refactor(monitor, graph): introduce nodeData utility for type casting
- Added a utility function `nodeData` to cast domain data to ReactFlow's Node data type for improved type safety.
- Updated the ActionGraph and graph layout components to utilize the new `nodeData` function for data handling.
2026-01-28 12:11:25 -05:00
luccast 8dac1e0bef fix(clawdbot): optimize output chunk truncation logic
The new version iterates forward, accumulating dropped chars from the front until the remaining tail fits within budget. This keeps the maximum number of recent chunks possible.

- Refactored the logic for truncating output chunks to improve performance and readability.
- Changed variable declarations for clarity and adjusted the loop to determine the starting index for slicing the capped array.
2026-01-28 12:06:39 -05:00
luccast ab395e5dfb chore: update package dependencies and remove unused packages
- Removed "@types/dagre" and "dagre" from package.json and package-lock.json
- Added "peer": true to several dependencies in package-lock.json for better compatibility
2026-01-28 12:04:31 -05:00
luccast 1b6920e94b fix: Removed the Ctrl+K shortcut. The "clear completed" button in the UI still works — no need for a global keyboard shortcut that conflicts with browser/OS defaults. 2026-01-28 12:03:54 -05:00
luccast 186c59fa82 fix(monitor): add periodic update for session node component
- Introduced useEffect to trigger a state update every 30 seconds
- Simplified relative time calculation for better readability
2026-01-28 12:02:40 -05:00
luccast 1187b35268 chore: remove non-feature files from contributor PR
Remove planning docs, security audit, vitest config, and revert
package.json/lockfile to original state.
2026-01-28 11:50:37 -05:00
Grace (Clawdbot) 068af76bb7 fix(ui): improve graph spacing and alignment
- Increase COLUMN_GAP from 300 to 400 for wider horizontal separation
- Increase ROW_GAP from 40 to 80 for more vertical breathing room
- Enlarge NODE_DIMENSIONS for better layout calculations
- Add MIN_SESSION_GAP (120px) for collision avoidance
- Implement adjustSpawnY to shift overlapping sessions down
- Sort sessions by column before positioning (parents first)
- Ensure all nodes in same depth share same X coordinate
2026-01-28 11:43:30 -05:00
Grace (Clawdbot) eca161223f feat(ui): horizontal spawn layout for action graph
- Redesigned graph layout algorithm for horizontal spawn positioning
- Sessions arranged in columns (X = spawn depth hierarchy)
- Events within a session flow DOWN vertically (Y = time progression)
- Child sessions appear to the RIGHT at the Y-level where spawned
- Added left/right handles on SessionNode for horizontal spawn edges
- Smoothstep edges connect parent → child sessions horizontally
- Collision avoidance for multiple sessions at same depth
- Orphan nodes (without session) get positioned in separate area
- Compact action nodes (180x80) vs larger session/exec nodes
2026-01-28 11:43:30 -05:00
Grace (Clawdbot) 3949991b6c feat(monitor): add copy session key button to SessionNode header
- Copy icon positioned left of status indicator
- Click copies full session key to clipboard
- Animated feedback: copy → checkmark for 1.5s
- stopPropagation prevents node selection interference
2026-01-28 11:43:30 -05:00
Grace (Clawdbot) 648539033c feat(monitor): add copy PID button to ExecNode header
- Copy icon positioned left of status indicator
- Click copies PID to clipboard
- Animated feedback: copy → checkmark for 1.5s
- stopPropagation prevents expand/collapse on click
2026-01-28 11:43:30 -05:00
Grace (Clawdbot) d2e4f3be43 feat(monitor): add clear completed execs button
- Add clearCompletedExecs() to remove completed/failed execs
- Add getCompletedExecCount() for UI badge count
- Add clearInactiveSessions() for optional inactive session cleanup
- Add "Clear Completed" button in monitor header with badge count
- Button only shows when there are clearable items
- Add keyboard shortcut: Ctrl+K / Cmd+K to clear
- Add vitest test infrastructure with 8 tests covering:
  - Clearing completed execs
  - Clearing failed execs
  - Preserving running execs
  - Counting clearable items
  - Clearing inactive sessions by threshold
2026-01-28 11:43:30 -05:00
Grace (Clawdbot) 89177787bf feat(monitor): link subagents to parent sessions via spawnedBy, add timestamps to session nodes 2026-01-28 11:43:30 -05:00
Grace (Clawdbot) e8142d05cf fix(parser): remove incorrect sessionKey assignment 2026-01-28 11:43:30 -05:00
Grace (Clawdbot) 9c00470e44 feat(exec-events): bead-10 - final validation and hydrate exec-only data 2026-01-28 11:43:30 -05:00
Grace (Clawdbot) c9d442b5f9 feat(exec-events): bead-9 - render exec nodes under sessions 2026-01-28 11:43:30 -05:00
Grace (Clawdbot) bc4e6a0709 feat(exec-events): bead-8 - add exec node component 2026-01-28 11:43:30 -05:00
Grace (Clawdbot) 1f4209d8b7 feat(exec-events): bead-7 - wire exec events into monitor route 2026-01-28 11:43:30 -05:00
Grace (Clawdbot) f604f056e7 feat(exec-events): bead-6 - emit exec events in subscription 2026-01-28 11:43:30 -05:00
Grace (Clawdbot) 7268338264 feat(exec-events): bead-5 - hydrate exec events after actions 2026-01-28 11:43:30 -05:00
Grace (Clawdbot) 9b892e25a2 feat(exec-events): bead-4 - persist exec events 2026-01-28 11:43:30 -05:00
Grace (Clawdbot) dabd4bcadc feat(exec-events): bead-3 - add exec collection and aggregation 2026-01-28 11:43:30 -05:00
Grace (Clawdbot) 87b99713a0 feat(exec-events): bead-2 - parse exec started output completed 2026-01-28 11:42:13 -05:00
Grace (Clawdbot) b705bb1ba8 feat(exec-events): bead-1 - add exec protocol and monitor types 2026-01-28 11:42:13 -05:00
Luciano Castillo e690c847fc feat(monitor): infer spawnedBy for subagent sessions (#13)
Client-side workaround for linking subagent sessions to parent sessions
without server support. Tracks recent parent session activity and infers
spawn relationships based on timing (5-second window).

- Add spawnedBy field to SessionInfo and MonitorSession types
- Track parent session activity timestamps
- Infer parent when subagent session first appears
- Preserve spawnedBy once set (immutable)
- Handle inference during hydration
2026-01-28 11:41:13 -05:00
Luciano Castillo c6990f5d86 Merge pull request #8 from yeHHH1g/fix/streaming-content-replace
fix: replace streaming content instead of appending
2026-01-28 09:33:27 -05:00
luccast f5eb110123 docs: add comment about cumulative delta behavior 2026-01-28 09:32:07 -05:00
luccast ad75627e0f Revert "Document behavior of cumulative message content in ChatEvent interface"
This reverts commit 5e53b2a70c.
2026-01-28 09:29:57 -05:00
luccast 5e53b2a70c Document behavior of cumulative message content in ChatEvent interface 2026-01-28 09:29:18 -05:00
Luciano Castillo 78ddc4cea3 Merge pull request #9 from oxngon/add-jq-alternative 2026-01-28 07:40:13 -05:00
django 139f474076 Add jq alternative for gateway token extraction 2026-01-28 01:44:47 +07:00
Ubuntu 9181d11047 fix: replace streaming content instead of appending
Clawdbot gateway sends cumulative text with each delta event
(full message so far), not incremental characters. The previous
append logic caused duplicated/garbled text in chat nodes.
2026-01-27 17:55:28 +00:00
luccast 2155d0cffa Update references from Clawdbot to Moltbot in documentation and UI
- Changed "Clawdbot" to "Moltbot (Clawdbot)" in CLAUDE.md and README.md for consistency.
- Updated the title in the Home component to reflect the new naming convention.
2026-01-27 10:27:07 -05:00
luccast a132a33c6b 1.0.4 2026-01-27 09:43:33 -05:00
Luciano Castillo c39ae8a375 Merge pull request #6 from llirik0/fix/docker-image-runtime
fix(docker): make image runnable + document CLAWDBOT_URL
2026-01-27 06:46:25 -05:00
Kirill 6811bf7c60 fix(docker): make image runnable + document CLAWDBOT_URL 2026-01-26 23:50:17 -08:00
luccast 271d0c47b2 1.0.3 2026-01-26 23:08:08 -05:00
Luciano Castillo e403f2788a Merge pull request #4 from luccast/luccast/persistent-background-service
Add persistent background service to monitor
2026-01-26 23:03:13 -05:00
luccast aae5df728c Fix duplicate messages on hydration
Use addAction() during hydration to apply aggregation logic
2026-01-26 22:53:52 -05:00
luccast 6101bd59dd Refactor SettingsPanel to use external open state management
- Removed internal state management for the SettingsPanel component.
- Added props for open state and onOpenChange callback to manage visibility from the parent component.
- Updated MonitorPage to handle the settings panel state and trigger its visibility.
2026-01-26 22:31:25 -05:00
Luciano Castillo e848d6c25f Merge pull request #3 from luccast/luccast-patch-2
Update Buy Me a Coffee username in FUNDING.yml
2026-01-26 21:06:49 -05:00
Luciano Castillo a22ce9ab41 Update Buy Me a Coffee username in FUNDING.yml
Signed-off-by: Luciano Castillo <2213102+luccast@users.noreply.github.com>
2026-01-26 21:06:34 -05:00
luccast 6ccd394838 Add persistence feature to monitor
- Introduced a persistence service to enable data retention across sessions.
- Updated SettingsPanel to manage persistence state and actions.
- Implemented hydration from server persistence in collections.
- Enhanced TRPC router with persistence-related queries and mutations.
- Updated monitor page to handle persistence status and actions.
2026-01-26 20:25:12 -05:00
luccast 37a4db51de 1.0.2 2026-01-26 19:32:46 -05:00
luccast caf185f917 Refactor Clawdbot client initialization to use configurable WebSocket URL
- Updated the Clawdbot client to accept a WebSocket URL from the environment variable `CLAWDBOT_URL`, defaulting to 'ws://127.0.0.1:18789' if not set. This change enhances flexibility for different deployment environments.
2026-01-26 19:28:39 -05:00
luccast c1a6d38c26 Add gateway token config instructions to README 2026-01-26 17:07:03 -05:00
luccast ef1d8d2052 Fix build output path (dist not .output) 2026-01-26 16:56:05 -05:00
luccast ff50cafe34 Add Docker support and GitHub release workflow 2026-01-26 16:51:47 -05:00
luccast ad791b25af Add version 1.0.1 to package.json 2026-01-26 16:45:52 -05:00
Luciano Castillo 11d642f4e7 Merge pull request #2 from luccast/luccast-patch-1
Update copyright name in LICENSE file
2026-01-26 15:15:09 -05:00
24 changed files with 2044 additions and 132 deletions
+7
View File
@@ -0,0 +1,7 @@
node_modules
.output
.git
.github
*.md
.env*
.DS_Store
+15
View File
@@ -0,0 +1,15 @@
# These are supported funding model platforms
github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2]
patreon: # Replace with a single Patreon username
open_collective: # Replace with a single Open Collective username
ko_fi: # Replace with a single Ko-fi username
tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
liberapay: # Replace with a single Liberapay username
issuehunt: # Replace with a single IssueHunt username
lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry
polar: # Replace with a single Polar username
buy_me_a_coffee: luccasveg
thanks_dev: # Replace with a single thanks.dev username
custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2']
+66
View File
@@ -0,0 +1,66 @@
name: Release
on:
release:
types: [published]
env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
jobs:
build-and-push:
runs-on: ubuntu-latest
permissions:
contents: write
packages: write
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
- name: Install dependencies
run: npm ci
- name: Build
run: npm run build
- name: Create build artifact
run: |
tar -czvf crabwalk-${{ github.ref_name }}.tar.gz dist
- name: Upload build to release
uses: softprops/action-gh-release@v1
with:
files: crabwalk-${{ github.ref_name }}.tar.gz
- name: Log in to Container Registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata for Docker
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=raw,value=latest
- name: Build and push Docker image
uses: docker/build-push-action@v5
with:
context: .
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
+3
View File
@@ -34,3 +34,6 @@ src/routeTree.gen.ts
documents/*
.tanstack/tmp/*
# Persistence data
data/
+2 -2
View File
@@ -7,7 +7,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
```bash
npm run dev # start dev server on port 3000
npm run build # production build
npm start # run production server (.output/server/index.mjs)
npm start # run production server (dist/server/server.js)
```
## Architecture
@@ -36,7 +36,7 @@ Full-stack React app using TanStack Start (file-based routing, SSR).
**TanStack DB pattern:** Create collections, use `useLiveQuery()` for reactive reads, `createTransaction()` for writes.
## Clawdbot Monitor
## Moltbot (Clawdbot) Monitor
Real-time agent activity monitor at `/monitor`.
+30
View File
@@ -0,0 +1,30 @@
# Build stage
FROM node:22-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
# Runtime stage
FROM node:22-alpine AS runner
WORKDIR /app
# NOTE: TanStack Start server entry produced by `vite build` does not bind a port
# on its own in this repo, so we run the Vite dev server in Docker for now.
# This makes the published image functional while we figure out a proper prod server.
ENV NODE_ENV=development
COPY package*.json ./
RUN npm ci
COPY --from=builder /app/dist ./dist
EXPOSE 3000
CMD ["npm", "run", "dev", "--", "--host", "0.0.0.0", "--port", "3000"]
+46 -12
View File
@@ -1,6 +1,6 @@
# 🦀 Crabwalk
Real-time companion monitor for [Clawdbot](https://github.com/clawdbot/clawdbot) agents by [@luccasveg](https://x.com/luccasveg).
Real-time companion monitor for [Moltbot (Clawdbot)](https://github.com/moltbot/moltbot) agents by [@luccasveg](https://x.com/luccasveg).
Watch your AI agents work across WhatsApp, Telegram, Discord, and Slack in a live node graph. See thinking states, tool calls, and response chains as they happen.
@@ -16,28 +16,62 @@ Watch your AI agents work across WhatsApp, Telegram, Discord, and Slack in a liv
- **Action tracing** - Expand nodes to inspect tool args and payloads
- **Session filtering** - Filter by platform, search by recipient
## Getting Started
## Installation
### Docker (recommended)
```bash
npm install
npm run dev
docker run -d \
-p 3000:3000 \
-e CLAWDBOT_API_TOKEN=your-token \
-e CLAWDBOT_URL=ws://host.docker.internal:18789 \
ghcr.io/luccast/crabwalk:latest
```
Open `http://localhost:3000/monitor` (or `http://<server-ip>:3000/monitor` for remote access)
> Note: When running Crabwalk in Docker, the Clawdbot gateway typically runs on the *host*.
> Use `CLAWDBOT_URL=ws://host.docker.internal:18789` so the container can connect.
Or with docker-compose:
```bash
curl -O https://raw.githubusercontent.com/luccast/crabwalk/master/docker-compose.yml
CLAWDBOT_API_TOKEN=your-token CLAWDBOT_URL=ws://host.docker.internal:18789 docker-compose up -d
```
### From source
```bash
git clone https://github.com/luccast/crabwalk.git
cd crabwalk
npm install
CLAWDBOT_API_TOKEN=your-token npm run dev
```
Open `http://localhost:3000/monitor`
## Configuration
Requires clawdbot gateway running on the same machine.
## Config
### Gateway Token
Token is in `~/.clawdbot/clawdbot.json`
Find your token in the clawdbot config file:
```bash
# Option 1: command line
CLAWDBOT_API_TOKEN=your-token npm run dev
# Look for gateway.auth.token
cat ~/.clawdbot/clawdbot.json | rg "gateway\.auth\.token"
```
# Option 2: env file
echo "CLAWDBOT_API_TOKEN=your-token" > .env.local
npm run dev
Or with jq:
```bash
jq '.gateway.auth.token' ~/.clawdbot/clawdbot.json
```
Or copy it directly:
```bash
export CLAWDBOT_API_TOKEN=$(python3 -c "import json,os; print(json.load(open(os.path.expanduser('~/.clawdbot/clawdbot.json')))['gateway']['auth']['token'])")
```
## Stack
+8
View File
@@ -0,0 +1,8 @@
services:
crabwalk:
image: ghcr.io/luccast/crabwalk:latest
ports:
- "3000:3000"
environment:
- CLAWDBOT_API_TOKEN=${CLAWDBOT_API_TOKEN}
restart: unless-stopped
+3 -33
View File
@@ -1,12 +1,15 @@
{
"name": "crabwalk",
"version": "1.0.4",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "crabwalk",
"version": "1.0.4",
"dependencies": {
"@tanstack/db": "^0.5.0",
"@tanstack/history": "^1.132.0",
"@tanstack/react-db": "^0.1.0",
"@tanstack/react-query": "^5.0.0",
"@tanstack/react-router": "^1.132.0",
@@ -14,9 +17,7 @@
"@tanstack/react-start": "^1.132.0",
"@trpc/client": "^11.0.0",
"@trpc/server": "^11.0.0",
"@types/dagre": "^0.7.53",
"@xyflow/react": "^12.10.0",
"dagre": "^0.8.5",
"framer-motion": "^12.29.0",
"lucide-react": "^0.468.0",
"react": "^19.2.0",
@@ -2137,12 +2138,6 @@
"@types/d3-selection": "*"
}
},
"node_modules/@types/dagre": {
"version": "0.7.53",
"resolved": "https://registry.npmjs.org/@types/dagre/-/dagre-0.7.53.tgz",
"integrity": "sha512-f4gkWqzPZvYmKhOsDnhq/R8mO4UMcKdxZo+i5SCkOU1wvGeHJeUXGIHeE9pnwGyPMDof1Vx5ZQo4nxpeg2TTVQ==",
"license": "MIT"
},
"node_modules/@types/debug": {
"version": "4.1.12",
"resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.12.tgz",
@@ -2779,16 +2774,6 @@
"node": ">=12"
}
},
"node_modules/dagre": {
"version": "0.8.5",
"resolved": "https://registry.npmjs.org/dagre/-/dagre-0.8.5.tgz",
"integrity": "sha512-/aTqmnRta7x7MCCpExk7HQL2O4owCT2h8NT//9I1OQ9vt29Pa0BzSAkR5lwFUcQ7491yVi/3CXU9jQ5o0Mn2Sw==",
"license": "MIT",
"dependencies": {
"graphlib": "^2.1.8",
"lodash": "^4.17.15"
}
},
"node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
@@ -3162,15 +3147,6 @@
"dev": true,
"license": "ISC"
},
"node_modules/graphlib": {
"version": "2.1.8",
"resolved": "https://registry.npmjs.org/graphlib/-/graphlib-2.1.8.tgz",
"integrity": "sha512-jcLLfkpoVGmH7/InMC/1hIvOPSUh38oJtGhvrOFGzioE1DZ+0YW16RgmOJhHiuWTvGiJQ9Z1Ik43JvkRPRvE+A==",
"license": "MIT",
"dependencies": {
"lodash": "^4.17.15"
}
},
"node_modules/h3-v2": {
"name": "h3",
"version": "2.0.1-rc.11",
@@ -3713,12 +3689,6 @@
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lodash": {
"version": "4.17.23",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz",
"integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==",
"license": "MIT"
},
"node_modules/longest-streak": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/longest-streak/-/longest-streak-3.1.0.tgz",
+4 -4
View File
@@ -1,11 +1,12 @@
{
"name": "crabwalk",
"version": "1.0.4",
"private": true,
"type": "module",
"scripts": {
"dev": "vite dev --port 3000 --host",
"build": "vite build",
"start": "node .output/server/index.mjs"
"start": "node dist/server/server.js"
},
"dependencies": {
"@tanstack/db": "^0.5.0",
@@ -16,9 +17,7 @@
"@tanstack/react-start": "^1.132.0",
"@trpc/client": "^11.0.0",
"@trpc/server": "^11.0.0",
"@types/dagre": "^0.7.53",
"@xyflow/react": "^12.10.0",
"dagre": "^0.8.5",
"framer-motion": "^12.29.0",
"lucide-react": "^0.468.0",
"react": "^19.2.0",
@@ -27,7 +26,8 @@
"superjson": "^2.2.0",
"vite-tsconfig-paths": "^5.1.4",
"ws": "^8.19.0",
"zod": "^3.24.0"
"zod": "^3.24.0",
"@tanstack/history": "^1.132.0"
},
"devDependencies": {
"@tailwindcss/vite": "^4.0.0",
+130 -16
View File
@@ -15,18 +15,29 @@ import {
import '@xyflow/react/dist/style.css'
import { SessionNode } from './SessionNode'
import { ActionNode } from './ActionNode'
import { ExecNode } from './ExecNode'
import { CrabNode } from './CrabNode'
import { ChaserCrabNode, type ChaserCrabState } from './ChaserCrabNode'
import { layoutGraph } from '~/lib/graph-layout'
import type { MonitorSession, MonitorAction } from '~/integrations/clawdbot'
import type {
MonitorSession,
MonitorAction,
MonitorExecProcess,
} from '~/integrations/clawdbot'
interface ActionGraphProps {
sessions: MonitorSession[]
actions: MonitorAction[]
execs: MonitorExecProcess[]
selectedSession: string | null
onSessionSelect: (key: string | null) => void
}
/** Cast domain data to ReactFlow's Node data type */
function nodeData<T>(data: T): Record<string, unknown> {
return data as Record<string, unknown>
}
const CRAB_NODE_ID = 'crab-origin'
const CHASER_CRAB_ID = 'chaser-crab'
@@ -46,6 +57,7 @@ const SIDEWAYS_DRIFT = 0.4 // crabs scuttle sideways
const nodeTypes: NodeTypes = {
session: SessionNode as any,
action: ActionNode as any,
exec: ExecNode as any,
crab: CrabNode as any,
chaserCrab: ChaserCrabNode as any,
}
@@ -63,6 +75,7 @@ interface CrabAI {
function ActionGraphInner({
sessions,
actions,
execs,
selectedSession,
onSessionSelect,
}: ActionGraphProps) {
@@ -87,11 +100,17 @@ function ActionGraphInner({
return actions.filter((a) => a.sessionKey === selectedSession)
}, [actions, selectedSession])
const visibleExecs = useMemo(() => {
if (!selectedSession) return execs.slice(-50)
return execs.filter((exec) => exec.sessionKey === selectedSession)
}, [execs, selectedSession])
// Build nodes
const rawNodes = useMemo(() => {
const nodes: Node[] = []
const hasActivity = sessions.length > 0 || visibleActions.length > 0
const hasActivity =
sessions.length > 0 || visibleActions.length > 0 || visibleExecs.length > 0
nodes.push({
id: CRAB_NODE_ID,
type: 'crab',
@@ -108,7 +127,7 @@ function ActionGraphInner({
id: `session-${session.key}`,
type: 'session',
position: { x: 0, y: 0 },
data: session as unknown as Record<string, unknown>,
data: nodeData(session),
})
}
@@ -117,12 +136,21 @@ function ActionGraphInner({
id: `action-${action.id}`,
type: 'action',
position: { x: 0, y: 0 },
data: action as unknown as Record<string, unknown>,
data: nodeData(action),
})
}
for (const exec of visibleExecs) {
nodes.push({
id: `exec-${exec.id}`,
type: 'exec',
position: { x: 0, y: 0 },
data: nodeData(exec),
})
}
return nodes
}, [sessions, visibleActions, selectedSession])
}, [sessions, visibleActions, visibleExecs, selectedSession])
// Build edges
const rawEdges = useMemo(() => {
@@ -132,18 +160,23 @@ function ActionGraphInner({
? sessions.filter((s) => s.key === selectedSession)
: sessions
for (const session of visibleSessions) {
edges.push({
id: `e-crab-${session.key}`,
source: CRAB_NODE_ID,
target: `session-${session.key}`,
markerEnd: { type: MarkerType.ArrowClosed, color: '#ef4444' },
style: { stroke: '#ef4444', strokeWidth: 2 },
})
// Build a set of visible session keys for parent lookup
const visibleSessionKeys = new Set(visibleSessions.map((s) => s.key))
// Edge styles
const spawnEdgeStyle = {
animated: true,
style: { stroke: '#00ffd5', strokeWidth: 2, strokeDasharray: '8 4' },
markerEnd: { type: MarkerType.ArrowClosed, color: '#00ffd5' },
}
const sessionNodeIds = new Set(visibleSessions.map((s) => `session-${s.key}`))
const crabEdgeStyle = {
animated: false,
style: { stroke: '#ef4444', strokeWidth: 2 },
markerEnd: { type: MarkerType.ArrowClosed, color: '#ef4444' },
}
// Group actions by session for spawn point lookup
const sessionActions = new Map<string, MonitorAction[]>()
for (const action of visibleActions) {
const key = action.sessionKey
@@ -152,6 +185,39 @@ function ActionGraphInner({
list.push(action)
sessionActions.set(key, list)
}
// Sort each session's actions by timestamp
for (const [key, actions] of sessionActions) {
sessionActions.set(key, [...actions].sort((a, b) => a.timestamp - b.timestamp))
}
// Connect sessions to their spawn sources
for (const session of visibleSessions) {
const parentSessionKey = session.spawnedBy
if (parentSessionKey && visibleSessionKeys.has(parentSessionKey)) {
// This session was spawned by another session
// Connect from parent's right handle to child's left handle (horizontal spawn)
edges.push({
id: `e-spawn-${session.key}`,
source: `session-${parentSessionKey}`,
target: `session-${session.key}`,
sourceHandle: 'spawn-source',
targetHandle: 'spawn-target',
type: 'smoothstep',
...spawnEdgeStyle,
})
} else {
// Root session - connect from crab
edges.push({
id: `e-crab-${session.key}`,
source: CRAB_NODE_ID,
target: `session-${session.key}`,
...crabEdgeStyle,
})
}
}
const sessionNodeIds = new Set(visibleSessions.map((s) => `session-${s.key}`))
const getEdgeStyle = (action: MonitorAction) => {
switch (action.type) {
@@ -194,8 +260,9 @@ function ActionGraphInner({
}
}
// Connect actions within each session (vertical flow)
for (const [sessionKey, actions] of sessionActions) {
const sorted = [...actions].sort((a, b) => a.timestamp - b.timestamp)
const sorted = actions // Already sorted above
const sessionId = `session-${sessionKey}`
for (let i = 0; i < sorted.length; i++) {
@@ -203,6 +270,7 @@ function ActionGraphInner({
const edgeStyle = getEdgeStyle(action)
if (i === 0) {
// First action connects from session node
if (sessionNodeIds.has(sessionId)) {
edges.push({
id: `e-session-${action.id}`,
@@ -212,6 +280,7 @@ function ActionGraphInner({
})
}
} else {
// Subsequent actions connect from previous action
const prev = sorted[i - 1]!
edges.push({
id: `e-${prev.id}-${action.id}`,
@@ -223,8 +292,47 @@ function ActionGraphInner({
}
}
const getExecEdgeStyle = (exec: MonitorExecProcess) => {
switch (exec.status) {
case 'running':
return {
animated: true,
style: { stroke: '#00ffd5', strokeDasharray: '4 4' },
markerEnd: { type: MarkerType.ArrowClosed, color: '#00ffd5' },
}
case 'failed':
return {
animated: false,
style: { stroke: '#ef4444' },
markerEnd: { type: MarkerType.ArrowClosed, color: '#ef4444' },
}
case 'completed':
default:
return {
animated: false,
style: { stroke: '#98ffc8' },
markerEnd: { type: MarkerType.ArrowClosed, color: '#98ffc8' },
}
}
}
// Connect execs to their session
for (const exec of visibleExecs) {
const key = exec.sessionKey
if (!key) continue
const sessionId = `session-${key}`
if (!sessionNodeIds.has(sessionId)) continue
const edgeStyle = getExecEdgeStyle(exec)
edges.push({
id: `e-session-exec-${exec.id}`,
source: sessionId,
target: `exec-${exec.id}`,
...edgeStyle,
})
}
return edges
}, [sessions, visibleActions, selectedSession])
}, [sessions, visibleActions, visibleExecs, selectedSession])
// Apply layout
const { nodes: layoutedNodes, edges: layoutedEdges } = useMemo(() => {
@@ -608,6 +716,12 @@ function ActionGraphInner({
if (node.type === 'crab') return '#ef4444'
if (node.type === 'chaserCrab') return '#ef4444'
if (node.type === 'session') return '#98ffc8'
if (node.type === 'exec') {
const status = (node.data as unknown as MonitorExecProcess).status
if (status === 'running') return '#00ffd5'
if (status === 'failed') return '#ef4444'
return '#98ffc8'
}
return '#52526e'
}}
maskColor="rgba(10, 10, 15, 0.8)"
+250
View File
@@ -0,0 +1,250 @@
import { memo, useCallback, useMemo, useState } from 'react'
import { Handle, Position } from '@xyflow/react'
import { motion, AnimatePresence } from 'framer-motion'
import { CheckCircle, Copy, Check, Loader2, Terminal, XCircle } from 'lucide-react'
import type { MonitorExecProcess, MonitorExecOutputChunk } from '~/integrations/clawdbot'
interface ExecNodeProps {
data: MonitorExecProcess
selected?: boolean
}
function formatTime(ts: number): string {
return new Date(ts).toLocaleTimeString('en-US', {
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
})
}
function formatDuration(ms: number): string {
if (ms < 1000) return `${ms}ms`
const secs = ms / 1000
if (secs < 60) return `${secs.toFixed(1)}s`
const mins = Math.floor(secs / 60)
const remainSecs = Math.floor(secs % 60)
return `${mins}m ${remainSecs}s`
}
function tailLinesFromChunks(chunks: MonitorExecOutputChunk[], maxLines: number): string {
if (chunks.length === 0) return ''
const merged = chunks.map((c) => c.text).join('')
const lines = merged.split(/\r?\n/)
return lines.slice(-maxLines).join('\n').trim()
}
const statusConfig: Record<
MonitorExecProcess['status'],
{
icon: typeof Loader2
borderColor: string
badgeColor: string
iconColor: string
animate: boolean
label: string
}
> = {
running: {
icon: Loader2,
borderColor: 'border-neon-cyan',
badgeColor: 'bg-neon-cyan/15 text-neon-cyan',
iconColor: 'text-neon-cyan',
animate: true,
label: 'Running',
},
completed: {
icon: CheckCircle,
borderColor: 'border-neon-mint',
badgeColor: 'bg-neon-mint/15 text-neon-mint',
iconColor: 'text-neon-mint',
animate: false,
label: 'Completed',
},
failed: {
icon: XCircle,
borderColor: 'border-crab-500',
badgeColor: 'bg-crab-500/15 text-crab-300',
iconColor: 'text-crab-400',
animate: false,
label: 'Failed',
},
}
function streamStyle(stream: MonitorExecOutputChunk['stream']): string {
if (stream === 'stderr') {
return 'text-crab-200 bg-crab-950/30 border-crab-900/60'
}
return 'text-neon-cyan/90 bg-shell-950 border-shell-800'
}
export const ExecNode = memo(function ExecNode({ data, selected }: ExecNodeProps) {
const [expanded, setExpanded] = useState(false)
const [copied, setCopied] = useState(false)
const status = statusConfig[data.status]
const StatusIcon = status.icon
const preview = useMemo(() => tailLinesFromChunks(data.outputs, 3), [data.outputs])
const hasOutput = data.outputs.length > 0
const handleCopyPid = useCallback((e: React.MouseEvent) => {
e.stopPropagation()
navigator.clipboard.writeText(String(data.pid)).catch(() => {})
setCopied(true)
setTimeout(() => setCopied(false), 1500)
}, [data.pid])
const displayDuration =
data.durationMs != null
? formatDuration(data.durationMs)
: data.completedAt != null
? formatDuration(Math.max(0, data.completedAt - data.startedAt))
: null
return (
<motion.div
initial={{ opacity: 0, y: -8 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.2 }}
onClick={() => setExpanded((prev) => !prev)}
className={`
px-3 py-2.5 rounded-lg border-2 min-w-[220px] cursor-pointer
bg-shell-900 ${status.borderColor}
${selected ? 'ring-2 ring-white/30' : ''}
${expanded ? 'max-w-[680px]' : 'max-w-[360px]'}
transition-all duration-150 hover:bg-shell-800
`}
style={{
boxShadow: selected
? '0 0 15px rgba(239, 68, 68, 0.3)'
: '0 4px 12px rgba(0, 0, 0, 0.35)',
}}
>
<Handle
type="target"
position={Position.Top}
className="bg-shell-600! w-2! h-2! border-shell-800!"
/>
<div className="flex items-center gap-2 mb-1.5">
<Terminal size={13} className="text-shell-400" />
<span className="font-display text-xs font-medium text-gray-300 uppercase tracking-wide">
Exec
</span>
<span
className={`
ml-1 px-2 py-0.5 rounded-md border text-[10px] font-console truncate max-w-[220px]
border-shell-700 ${status.badgeColor}
`}
title={data.command}
>
{data.command}
</span>
<div className="ml-auto flex items-center gap-1.5">
<button
onClick={handleCopyPid}
className="p-1 rounded hover:bg-shell-700 transition-colors"
title={`Copy PID: ${data.pid}`}
>
<AnimatePresence mode="wait">
{copied ? (
<motion.div
key="check"
initial={{ scale: 0.8, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
exit={{ scale: 0.8, opacity: 0 }}
transition={{ duration: 0.15 }}
>
<Check size={12} className="text-neon-mint" />
</motion.div>
) : (
<motion.div
key="copy"
initial={{ scale: 0.8, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
exit={{ scale: 0.8, opacity: 0 }}
transition={{ duration: 0.15 }}
>
<Copy size={12} className="text-shell-400 hover:text-shell-200" />
</motion.div>
)}
</AnimatePresence>
</button>
<StatusIcon
size={14}
className={`${status.iconColor} ${status.animate ? 'animate-spin' : ''}`}
/>
</div>
</div>
<div className="font-console text-xs text-shell-500 mb-1.5">
<span className="text-crab-600">&gt;</span> {formatTime(data.lastActivityAt)}
</div>
<div className="font-console text-xs text-shell-400 mb-1.5 flex gap-2 flex-wrap">
<span>
<span className="text-shell-500">pid:</span> {data.pid}
</span>
{data.exitCode != null && (
<span>
<span className="text-shell-500">exit:</span>{' '}
<span className={data.exitCode === 0 ? 'text-neon-mint' : 'text-crab-300'}>
{data.exitCode}
</span>
</span>
)}
{displayDuration && (
<span className="text-neon-cyan">{displayDuration}</span>
)}
{data.status === 'running' && (
<span className="text-neon-peach">live</span>
)}
</div>
{data.outputTruncated && (
<div className="mb-1.5 text-[10px] font-console text-neon-peach">
output truncated
</div>
)}
{hasOutput && !expanded && (
<pre className="font-console text-[11px] text-shell-300 bg-shell-950 border border-shell-800 rounded p-2 overflow-hidden line-clamp-4 whitespace-pre-wrap">
{preview || '(no output)'}
</pre>
)}
{hasOutput && expanded && (
<div className="mt-1.5 border border-shell-800 rounded bg-shell-950/60 max-h-[320px] overflow-auto">
<div className="sticky top-0 z-10 flex items-center justify-between px-2 py-1 text-[10px] font-console text-shell-500 bg-shell-950/90 border-b border-shell-800">
<span>{status.label}</span>
<span>{data.outputs.length} chunks</span>
</div>
<div className="p-2 flex flex-col gap-1.5">
{data.outputs.map((chunk) => (
<div
key={chunk.id}
className={`border rounded px-2 py-1 ${streamStyle(chunk.stream)}`}
>
<div className="flex items-center gap-2 mb-1 text-[10px] font-console text-shell-500">
<span className={chunk.stream === 'stderr' ? 'text-crab-300' : 'text-neon-cyan'}>
{chunk.stream}
</span>
<span>{formatTime(chunk.timestamp)}</span>
</div>
<pre className="font-console text-[11px] whitespace-pre-wrap break-words">
{chunk.text}
</pre>
</div>
))}
</div>
</div>
)}
<Handle
type="source"
position={Position.Bottom}
className="bg-shell-600! w-2! h-2! border-shell-800!"
/>
</motion.div>
)
})
+86 -6
View File
@@ -1,7 +1,7 @@
import { memo } from 'react'
import { memo, useCallback, useEffect, useState } from 'react'
import { Handle, Position } from '@xyflow/react'
import { motion } from 'framer-motion'
import { Users, User } from 'lucide-react'
import { motion, AnimatePresence } from 'framer-motion'
import { Users, User, Clock, Copy, Check } from 'lucide-react'
import { StatusIndicator } from './StatusIndicator'
import type { MonitorSession } from '~/integrations/clawdbot'
@@ -15,13 +15,50 @@ const platformIcons: Record<string, string> = {
telegram: '✈️',
discord: '🎮',
slack: '💼',
subagent: '🤖',
}
function formatRelativeTime(timestamp: number): string {
const now = Date.now()
const diff = now - timestamp
const seconds = Math.floor(diff / 1000)
const minutes = Math.floor(seconds / 60)
const hours = Math.floor(minutes / 60)
const days = Math.floor(hours / 24)
if (seconds < 60) return 'just now'
if (minutes < 60) return `${minutes}m ago`
if (hours < 24) return `${hours}h ago`
return `${days}d ago`
}
export const SessionNode = memo(function SessionNode({
data,
selected,
}: SessionNodeProps) {
const platformIcon = platformIcons[data.platform] ?? '📱'
const [copied, setCopied] = useState(false)
const [, setTick] = useState(0)
useEffect(() => {
const id = setInterval(() => setTick((t) => t + 1), 30_000)
return () => clearInterval(id)
}, [])
// Detect if this is a subagent session by checking if platform is "subagent" or if key contains "subagent"
const isSubagent = data.platform === 'subagent' || data.key.includes('subagent') || Boolean(data.spawnedBy)
const platformIcon = isSubagent ? platformIcons.subagent : (platformIcons[data.platform] ?? '📱')
const displayPlatform = isSubagent ? 'subagent' : data.platform
const relativeTime = (!data.lastActivityAt || data.lastActivityAt <= 0)
? null
: formatRelativeTime(data.lastActivityAt)
const handleCopyKey = useCallback((e: React.MouseEvent) => {
e.stopPropagation()
navigator.clipboard.writeText(data.key).catch(() => {})
setCopied(true)
setTimeout(() => setCopied(false), 1500)
}, [data.key])
return (
<motion.div
@@ -32,22 +69,57 @@ export const SessionNode = memo(function SessionNode({
bg-shell-900 text-white
${selected ? 'border-crab-500' : 'border-shell-600'}
${data.status === 'thinking' ? 'border-neon-peach' : ''}
${isSubagent ? 'border-neon-cyan border-opacity-50' : ''}
transition-all duration-150 hover:bg-shell-800
`}
style={{
boxShadow: selected
? '0 0 20px rgba(239, 68, 68, 0.4), 0 4px 12px rgba(0, 0, 0, 0.3)'
: isSubagent
? '0 0 12px rgba(0, 255, 213, 0.2), 0 4px 12px rgba(0, 0, 0, 0.3)'
: '0 4px 12px rgba(0, 0, 0, 0.3)',
}}
>
<Handle type="target" position={Position.Top} className="bg-crab-500! w-3! h-3! border-2! border-shell-900!" />
<Handle type="target" id="spawn-target" position={Position.Left} className="bg-neon-cyan! w-3! h-3! border-2! border-shell-900!" />
<div className="flex items-center gap-2 mb-2">
<span className="text-xl">{platformIcon}</span>
<span className="font-display text-xs font-semibold uppercase tracking-wide text-gray-200">
{data.platform}
{displayPlatform}
</span>
<StatusIndicator status={data.status} size="sm" />
<div className="ml-auto flex items-center gap-1.5">
<button
onClick={handleCopyKey}
className="p-1 rounded hover:bg-shell-700 transition-colors"
title={`Copy key: ${data.key}`}
>
<AnimatePresence mode="wait">
{copied ? (
<motion.div
key="check"
initial={{ scale: 0.8, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
exit={{ scale: 0.8, opacity: 0 }}
transition={{ duration: 0.15 }}
>
<Check size={12} className="text-neon-mint" />
</motion.div>
) : (
<motion.div
key="copy"
initial={{ scale: 0.8, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
exit={{ scale: 0.8, opacity: 0 }}
transition={{ duration: 0.15 }}
>
<Copy size={12} className="text-shell-400 hover:text-shell-200" />
</motion.div>
)}
</AnimatePresence>
</button>
<StatusIndicator status={data.status} size="sm" />
</div>
</div>
<div className="flex items-center gap-2 mb-2">
@@ -65,7 +137,15 @@ export const SessionNode = memo(function SessionNode({
<span className="text-crab-600">&gt;</span> {data.agentId}
</div>
{relativeTime && (
<div className="flex items-center gap-1 mt-2 font-console text-[10px] text-shell-500">
<Clock size={10} className="text-shell-600" />
<span>{relativeTime}</span>
</div>
)}
<Handle type="source" position={Position.Bottom} className="bg-crab-500! w-3! h-3! border-2! border-shell-900!" />
<Handle type="source" id="spawn-source" position={Position.Right} className="bg-neon-cyan! w-3! h-3! border-2! border-shell-900!" />
</motion.div>
)
})
+80 -6
View File
@@ -1,6 +1,5 @@
import { useState } from 'react'
import { motion, AnimatePresence } from 'framer-motion'
import { Settings, X, History, Wifi, WifiOff, RefreshCw, Terminal, Download, Trash2, Database } from 'lucide-react'
import { Settings, X, History, Wifi, WifiOff, RefreshCw, Terminal, Download, Trash2, Database, HardDrive, Play, Square } from 'lucide-react'
interface SettingsPanelProps {
connected: boolean
@@ -8,6 +7,12 @@ interface SettingsPanelProps {
debugMode: boolean
logCollection: boolean
logCount: number
persistenceEnabled: boolean
persistenceStartedAt: number | null
persistenceSessionCount: number
persistenceActionCount: number
open: boolean
onOpenChange: (open: boolean) => void
onHistoricalModeChange: (enabled: boolean) => void
onDebugModeChange: (enabled: boolean) => void
onLogCollectionChange: (enabled: boolean) => void
@@ -16,6 +21,9 @@ interface SettingsPanelProps {
onConnect: () => void
onDisconnect: () => void
onRefresh: () => void
onPersistenceStart: () => void
onPersistenceStop: () => void
onPersistenceClear: () => void
}
export function SettingsPanel({
@@ -24,6 +32,12 @@ export function SettingsPanel({
debugMode,
logCollection,
logCount,
persistenceEnabled,
persistenceStartedAt,
persistenceSessionCount,
persistenceActionCount,
open,
onOpenChange,
onHistoricalModeChange,
onDebugModeChange,
onLogCollectionChange,
@@ -32,13 +46,15 @@ export function SettingsPanel({
onConnect,
onDisconnect,
onRefresh,
onPersistenceStart,
onPersistenceStop,
onPersistenceClear,
}: SettingsPanelProps) {
const [open, setOpen] = useState(false)
return (
<>
<button
onClick={() => setOpen(true)}
onClick={() => onOpenChange(true)}
className="p-2 bg-shell-800 hover:bg-shell-700 rounded-lg transition-all group"
>
<Settings size={14} className="text-gray-400 group-hover:text-crab-400 transition-colors" />
@@ -52,7 +68,7 @@ export function SettingsPanel({
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
onClick={() => setOpen(false)}
onClick={() => onOpenChange(false)}
className="fixed inset-0 bg-black/60 backdrop-blur-sm z-40"
/>
@@ -73,7 +89,7 @@ export function SettingsPanel({
SETTINGS
</h2>
<button
onClick={() => setOpen(false)}
onClick={() => onOpenChange(false)}
className="p-2 hover:bg-shell-800 rounded-lg transition-all"
>
<X size={18} className="text-gray-400" />
@@ -171,6 +187,64 @@ export function SettingsPanel({
</button>
</div>
{/* Background Service */}
<div className="panel-retro p-4">
<div className="flex items-center gap-3 mb-2">
<HardDrive size={18} className={persistenceEnabled ? 'text-neon-mint' : 'text-shell-500'} />
<span className="font-display text-sm font-medium text-gray-200 uppercase tracking-wide">
Background Service
</span>
</div>
<p className="font-console text-[10px] text-shell-500 mb-3">
<span className="text-crab-600">&gt;</span> persist data across refreshes
</p>
{persistenceEnabled && persistenceStartedAt && (
<div className="font-console text-[10px] text-neon-mint mb-2">
<span className="text-crab-600">&gt;</span> running since {new Date(persistenceStartedAt).toLocaleTimeString()}
</div>
)}
<div className="font-console text-[10px] text-shell-400 mb-3 space-y-1">
<div>
<span className="text-crab-600">&gt;</span> {persistenceSessionCount} sessions
</div>
<div>
<span className="text-crab-600">&gt;</span> {persistenceActionCount} actions
</div>
</div>
<div className="flex gap-2 mb-2">
{persistenceEnabled ? (
<button
onClick={onPersistenceStop}
className="flex-1 flex items-center justify-center gap-2 px-4 py-2 font-display text-xs uppercase tracking-wide bg-crab-600 hover:bg-crab-500 text-white rounded-lg transition-all"
>
<Square size={12} />
Stop
</button>
) : (
<button
onClick={onPersistenceStart}
className="flex-1 flex items-center justify-center gap-2 px-4 py-2 font-display text-xs uppercase tracking-wide bg-neon-mint/20 hover:bg-neon-mint/30 text-neon-mint rounded-lg transition-all"
>
<Play size={12} />
Start
</button>
)}
</div>
<button
onClick={onPersistenceClear}
disabled={persistenceSessionCount === 0 && persistenceActionCount === 0}
className="w-full flex items-center justify-center gap-2 px-3 py-2 font-display text-xs uppercase tracking-wide bg-shell-800 hover:bg-crab-900/50 rounded-lg transition-all disabled:opacity-40 disabled:cursor-not-allowed"
>
<Trash2 size={12} />
Clear Stored Data
</button>
</div>
{/* Log collection */}
<div className="panel-retro p-4">
<div className="flex items-center gap-3 mb-2">
+1
View File
@@ -2,6 +2,7 @@ export { ActionGraph } from './ActionGraph'
export { SessionList } from './SessionList'
export { SessionNode } from './SessionNode'
export { ActionNode } from './ActionNode'
export { ExecNode } from './ExecNode'
export { CrabNode } from './CrabNode'
export { StatusIndicator } from './StatusIndicator'
export { SettingsPanel } from './SettingsPanel'
+2 -1
View File
@@ -241,8 +241,9 @@ let clientInstance: ClawdbotClient | null = null
export function getClawdbotClient(): ClawdbotClient {
if (!clientInstance) {
const url = process.env.CLAWDBOT_URL || 'ws://127.0.0.1:18789'
const token = process.env.CLAWDBOT_API_TOKEN
clientInstance = new ClawdbotClient('ws://127.0.0.1:18789', token)
clientInstance = new ClawdbotClient(url, token)
}
return clientInstance
}
+387 -5
View File
@@ -1,9 +1,59 @@
import { createCollection, localOnlyCollectionOptions } from '@tanstack/db'
import type { MonitorSession, MonitorAction } from './protocol'
import {
parseSessionKey,
type MonitorSession,
type MonitorAction,
type MonitorExecEvent,
type MonitorExecProcess,
type MonitorExecOutputChunk,
type MonitorExecProcessStatus,
} from './protocol'
// Track runId → sessionKey mapping (learned from chat events)
const runSessionMap = new Map<string, string>()
// Track recent activity on parent (non-subagent) sessions for spawn inference
// Maps sessionKey → lastActivityTimestamp
const parentSessionActivity = new Map<string, number>()
// Time window for spawn inference - parent must have been active within this window
const SPAWN_INFERENCE_WINDOW_MS = 5000
function isSubagentSession(key: string): boolean {
return key.includes('subagent')
}
function isParentSession(key: string): boolean {
return !isSubagentSession(key) && !key.includes('lifecycle')
}
// Infer which parent session spawned this subagent based on recent activity
function inferSpawnedBy(subagentKey: string, timestamp?: number): string | undefined {
if (!isSubagentSession(subagentKey)) return undefined
const now = timestamp ?? Date.now()
let bestParent: string | undefined
let bestTime = 0
for (const [parentKey, activityTime] of parentSessionActivity) {
// Must be within inference window
if (now - activityTime > SPAWN_INFERENCE_WINDOW_MS) continue
// Pick most recently active parent
if (activityTime > bestTime) {
bestTime = activityTime
bestParent = parentKey
}
}
return bestParent
}
// Track activity on a parent session
function trackParentActivity(sessionKey: string, timestamp?: number) {
if (!isParentSession(sessionKey)) return
parentSessionActivity.set(sessionKey, timestamp ?? Date.now())
}
export const sessionsCollection = createCollection(
localOnlyCollectionOptions<MonitorSession>({
id: 'clawdbot-sessions',
@@ -18,15 +68,128 @@ export const actionsCollection = createCollection(
})
)
export const execsCollection = createCollection(
localOnlyCollectionOptions<MonitorExecProcess>({
id: 'clawdbot-execs',
getKey: (item) => item.id,
})
)
const EXEC_PLACEHOLDER_COMMAND = 'Exec'
const MAX_EXEC_OUTPUT_CHUNKS = 200
const MAX_EXEC_OUTPUT_CHARS = 50000
const MAX_EXEC_CHUNK_CHARS = 4000
function resolveSessionKey(event: MonitorExecEvent): string | undefined {
return event.sessionKey || runSessionMap.get(event.runId) || event.sessionId
}
function backfillExecSessionKey(runId: string, sessionKey: string) {
for (const exec of execsCollection.state.values()) {
if (exec.runId !== runId) continue
if (exec.sessionKey && exec.sessionKey !== exec.sessionId) continue
execsCollection.update(exec.id, (draft) => {
draft.sessionKey = sessionKey
})
}
}
function mapExecStatus(exitCode?: number, status?: string): MonitorExecProcessStatus {
if (typeof exitCode === 'number' && exitCode !== 0) return 'failed'
if (typeof status === 'string') {
const normalized = status.toLowerCase()
if (normalized.includes('fail') || normalized.includes('error')) {
return 'failed'
}
}
return 'completed'
}
function capExecOutputs(outputs: MonitorExecOutputChunk[]): {
outputs: MonitorExecOutputChunk[]
truncated: boolean
} {
let truncated = false
const normalized: MonitorExecOutputChunk[] = outputs.map((chunk) => {
if (chunk.text.length <= MAX_EXEC_CHUNK_CHARS) {
return chunk
}
truncated = true
return {
...chunk,
text: chunk.text.slice(0, MAX_EXEC_CHUNK_CHARS) + '\n...[truncated]',
}
})
let capped = normalized
if (capped.length > MAX_EXEC_OUTPUT_CHUNKS) {
truncated = true
capped = capped.slice(-MAX_EXEC_OUTPUT_CHUNKS)
}
const totalChars = capped.reduce((sum, chunk) => sum + chunk.text.length, 0)
if (totalChars > MAX_EXEC_OUTPUT_CHARS) {
truncated = true
let dropped = 0
let startIdx = 0
for (let i = 0; i < capped.length; i++) {
if (totalChars - dropped <= MAX_EXEC_OUTPUT_CHARS) break
dropped += capped[i]!.text.length
startIdx = i + 1
}
capped = capped.slice(startIdx)
}
return { outputs: capped, truncated }
}
function createPlaceholderExec(event: MonitorExecEvent, sessionKey?: string): MonitorExecProcess {
const startedAt = event.startedAt ?? event.timestamp
return {
id: event.execId,
runId: event.runId,
pid: event.pid,
command: event.command || EXEC_PLACEHOLDER_COMMAND,
sessionId: event.sessionId,
sessionKey,
status: event.eventType === 'completed'
? mapExecStatus(event.exitCode, event.status)
: 'running',
startedAt,
timestamp: startedAt,
outputs: [],
lastActivityAt: event.timestamp,
}
}
// Helper to update or insert session
export function upsertSession(session: MonitorSession) {
// Track activity on parent sessions
if (isParentSession(session.key)) {
trackParentActivity(session.key, session.lastActivityAt)
}
const existing = sessionsCollection.state.get(session.key)
if (existing) {
// Preserve existing spawnedBy - never overwrite once set
const preservedSpawnedBy = existing.spawnedBy
sessionsCollection.update(session.key, (draft) => {
Object.assign(draft, session)
if (preservedSpawnedBy) {
draft.spawnedBy = preservedSpawnedBy
}
})
} else {
sessionsCollection.insert(session)
// New session - infer spawnedBy for subagents if not provided
let spawnedBy = session.spawnedBy
if (!spawnedBy && isSubagentSession(session.key)) {
spawnedBy = inferSpawnedBy(session.key, session.lastActivityAt)
}
sessionsCollection.insert({
...session,
spawnedBy,
})
}
}
@@ -39,7 +202,16 @@ export function upsertSession(session: MonitorSession) {
export function addAction(action: MonitorAction) {
// Learn runId → sessionKey mapping from actions with real session keys
if (action.sessionKey && !action.sessionKey.includes('lifecycle')) {
const previous = runSessionMap.get(action.runId)
runSessionMap.set(action.runId, action.sessionKey)
if (previous !== action.sessionKey) {
backfillExecSessionKey(action.runId, action.sessionKey)
}
// Track activity on parent sessions for spawn inference
if (isParentSession(action.sessionKey)) {
trackParentActivity(action.sessionKey, action.timestamp)
}
}
// Resolve sessionKey: use mapped value if action has lifecycle/invalid key
@@ -67,10 +239,10 @@ export function addAction(action: MonitorAction) {
const streamingId = `${action.runId}-stream`
const existing = actionsCollection.state.get(streamingId)
if (existing) {
// Append content and update sessionKey if we learned it
// Replace content (gateway sends cumulative text, not incremental deltas)
actionsCollection.update(streamingId, (draft) => {
if (action.content) {
draft.content = (draft.content || '') + action.content
draft.content = action.content
}
draft.seq = action.seq
draft.timestamp = action.timestamp
@@ -125,16 +297,129 @@ export function addAction(action: MonitorAction) {
}
}
export function addExecEvent(event: MonitorExecEvent) {
const sessionKey = resolveSessionKey(event)
const existing = execsCollection.state.get(event.execId)
if (event.eventType === 'started') {
if (existing) {
execsCollection.update(event.execId, (draft) => {
draft.command = event.command || draft.command || EXEC_PLACEHOLDER_COMMAND
draft.sessionId = event.sessionId || draft.sessionId
draft.sessionKey = sessionKey || draft.sessionKey
draft.status = 'running'
draft.startedAt = event.startedAt ?? draft.startedAt ?? event.timestamp
draft.timestamp = draft.startedAt
draft.lastActivityAt = event.timestamp
})
return
}
execsCollection.insert({
...createPlaceholderExec(event, sessionKey),
command: event.command || EXEC_PLACEHOLDER_COMMAND,
startedAt: event.startedAt ?? event.timestamp,
timestamp: event.startedAt ?? event.timestamp,
})
return
}
if (event.eventType === 'output') {
const stream = event.stream || 'stdout'
const text = event.output ?? ''
const chunk: MonitorExecOutputChunk = {
id: event.id,
stream,
text,
timestamp: event.timestamp,
}
if (existing) {
execsCollection.update(event.execId, (draft) => {
draft.sessionId = event.sessionId || draft.sessionId
draft.sessionKey = sessionKey || draft.sessionKey
draft.lastActivityAt = event.timestamp
if (text) {
const capped = capExecOutputs([...draft.outputs, chunk])
draft.outputs = capped.outputs
draft.outputTruncated = draft.outputTruncated || capped.truncated
}
})
return
}
const placeholder = createPlaceholderExec(event, sessionKey)
if (text) {
const capped = capExecOutputs([chunk])
placeholder.outputs = capped.outputs
placeholder.outputTruncated = capped.truncated
}
execsCollection.insert(placeholder)
return
}
if (event.eventType === 'completed') {
const completedStatus = mapExecStatus(event.exitCode, event.status)
if (existing) {
execsCollection.update(event.execId, (draft) => {
draft.sessionId = event.sessionId || draft.sessionId
draft.sessionKey = sessionKey || draft.sessionKey
draft.command = event.command || draft.command || EXEC_PLACEHOLDER_COMMAND
draft.exitCode = event.exitCode ?? draft.exitCode
draft.durationMs = event.durationMs ?? draft.durationMs
const completedAt = draft.durationMs != null
? draft.startedAt + draft.durationMs
: event.timestamp
draft.completedAt = completedAt
draft.status = completedStatus
draft.lastActivityAt = event.timestamp
})
return
}
const placeholder = createPlaceholderExec(event, sessionKey)
placeholder.command = event.command || placeholder.command
placeholder.exitCode = event.exitCode
placeholder.durationMs = event.durationMs
placeholder.completedAt = placeholder.durationMs != null
? placeholder.startedAt + placeholder.durationMs
: event.timestamp
placeholder.status = completedStatus
execsCollection.insert(placeholder)
}
}
// Helper to update session status
export function updateSessionStatus(
key: string,
status: MonitorSession['status']
) {
const now = Date.now()
// Track activity on parent sessions
if (isParentSession(key)) {
trackParentActivity(key, now)
}
const session = sessionsCollection.state.get(key)
if (session) {
sessionsCollection.update(key, (draft) => {
draft.status = status
draft.lastActivityAt = Date.now()
draft.lastActivityAt = now
})
} else if (isSubagentSession(key)) {
// New subagent session via status update - create with inferred parent
const spawnedBy = inferSpawnedBy(key, now)
const parsed = parseSessionKey(key)
sessionsCollection.insert({
key,
agentId: parsed.agentId,
platform: parsed.platform,
recipient: parsed.recipient,
isGroup: parsed.isGroup,
lastActivityAt: now,
status,
spawnedBy,
})
}
}
@@ -151,10 +436,107 @@ export function updateSession(key: string, update: Partial<MonitorSession>) {
// Clear all data
export function clearCollections() {
runSessionMap.clear()
parentSessionActivity.clear()
for (const session of sessionsCollection.state.values()) {
sessionsCollection.delete(session.key)
}
for (const action of actionsCollection.state.values()) {
actionsCollection.delete(action.id)
}
for (const exec of execsCollection.state.values()) {
execsCollection.delete(exec.id)
}
}
// Get count of completed/failed execs (for UI badge)
export function getCompletedExecCount(): number {
let count = 0
for (const exec of execsCollection.state.values()) {
if (exec.status === 'completed' || exec.status === 'failed') {
count++
}
}
return count
}
// Clear completed and failed execs from state
// Returns number of items cleared
export function clearCompletedExecs(): number {
const toDelete: string[] = []
for (const exec of execsCollection.state.values()) {
if (exec.status === 'completed' || exec.status === 'failed') {
toDelete.push(exec.id)
}
}
for (const id of toDelete) {
execsCollection.delete(id)
}
return toDelete.length
}
// Clear inactive sessions (idle sessions with no activity for thresholdMs)
// Returns number of sessions cleared
export function clearInactiveSessions(thresholdMs: number): number {
const now = Date.now()
const toDelete: string[] = []
for (const session of sessionsCollection.state.values()) {
// Only clear idle sessions - preserve thinking/active ones
if (session.status !== 'idle') continue
const inactiveTime = now - session.lastActivityAt
if (inactiveTime >= thresholdMs) {
toDelete.push(session.key)
}
}
for (const key of toDelete) {
sessionsCollection.delete(key)
}
return toDelete.length
}
// Hydrate collections from server persistence
export function hydrateFromServer(
sessions: MonitorSession[],
actions: MonitorAction[],
execEvents: MonitorExecEvent[] = []
) {
// First clear existing data
clearCollections()
// Replay actions first to build parent activity history
const sortedActions = [...actions].sort((a, b) => a.timestamp - b.timestamp)
for (const action of sortedActions) {
// Track parent activity without inserting actions yet
if (action.sessionKey && isParentSession(action.sessionKey)) {
trackParentActivity(action.sessionKey, action.timestamp)
}
}
// Also track parent sessions by their lastActivityAt
for (const session of sessions) {
if (isParentSession(session.key)) {
trackParentActivity(session.key, session.lastActivityAt)
}
}
// Now insert all sessions - subagents will get inferred spawnedBy
for (const session of sessions) {
if (isSubagentSession(session.key)) {
const spawnedBy = session.spawnedBy || inferSpawnedBy(session.key, session.lastActivityAt)
sessionsCollection.insert({ ...session, spawnedBy })
} else {
sessionsCollection.insert(session)
}
}
// Replay actions through addAction for aggregation
for (const action of sortedActions) {
addAction(action)
}
// Replay exec events after actions to maximize sessionKey resolution
const sortedExecEvents = [...execEvents].sort((a, b) => a.timestamp - b.timestamp)
for (const event of sortedExecEvents) {
addExecEvent(event)
}
}
+100 -1
View File
@@ -2,8 +2,12 @@ import type {
EventFrame,
ChatEvent,
AgentEvent,
ExecStartedEvent,
ExecOutputEvent,
ExecCompletedEvent,
MonitorSession,
MonitorAction,
MonitorExecEvent,
SessionInfo,
} from './protocol'
import { parseSessionKey } from './protocol'
@@ -18,6 +22,7 @@ export function sessionInfoToMonitor(info: SessionInfo): MonitorSession {
isGroup: parsed.isGroup,
lastActivityAt: info.lastActivityAt,
status: 'idle',
spawnedBy: info.spawnedBy,
}
}
@@ -147,7 +152,11 @@ export function agentEventToAction(event: AgentEvent): MonitorAction {
export function parseEventFrame(
frame: EventFrame
): { session?: Partial<MonitorSession>; action?: MonitorAction } | null {
): {
session?: Partial<MonitorSession>
action?: MonitorAction
execEvent?: MonitorExecEvent
} | null {
// Skip system events
if (frame.event === 'health' || frame.event === 'tick') {
return null
@@ -188,5 +197,95 @@ export function parseEventFrame(
return null
}
if (frame.event === 'exec.started' && frame.payload) {
const exec = frame.payload as ExecStartedEvent
const execId = `exec-${exec.runId}-${exec.pid}`
const timestamp = Date.now()
const id = frame.seq != null
? `${execId}-started-${frame.seq}`
: `${execId}-started-${timestamp}`
return {
execEvent: {
id,
execId,
runId: exec.runId,
pid: exec.pid,
sessionId: exec.sessionId,
eventType: 'started',
command: exec.command,
startedAt: exec.startedAt,
timestamp,
},
session: exec.sessionId
? {
key: exec.sessionId,
status: 'thinking',
lastActivityAt: timestamp,
}
: undefined,
}
}
if (frame.event === 'exec.output' && frame.payload) {
const exec = frame.payload as ExecOutputEvent
const execId = `exec-${exec.runId}-${exec.pid}`
const timestamp = Date.now()
const id = frame.seq != null
? `${execId}-output-${frame.seq}`
: `${execId}-output-${timestamp}`
return {
execEvent: {
id,
execId,
runId: exec.runId,
pid: exec.pid,
sessionId: exec.sessionId,
eventType: 'output',
stream: exec.stream,
output: exec.output,
timestamp,
},
session: exec.sessionId
? {
key: exec.sessionId,
lastActivityAt: timestamp,
}
: undefined,
}
}
if (frame.event === 'exec.completed' && frame.payload) {
const exec = frame.payload as ExecCompletedEvent
const execId = `exec-${exec.runId}-${exec.pid}`
const timestamp = Date.now()
const id = frame.seq != null
? `${execId}-completed-${frame.seq}`
: `${execId}-completed-${timestamp}`
return {
execEvent: {
id,
execId,
runId: exec.runId,
pid: exec.pid,
sessionId: exec.sessionId,
eventType: 'completed',
durationMs: exec.durationMs,
exitCode: exec.exitCode,
status: exec.status,
timestamp,
},
session: exec.sessionId
? {
key: exec.sessionId,
status: 'active',
lastActivityAt: timestamp,
}
: undefined,
}
}
return null
}
+261
View File
@@ -0,0 +1,261 @@
import fs from 'fs'
import path from 'path'
import type { MonitorSession, MonitorAction, MonitorExecEvent } from './protocol'
const DATA_DIR = path.join(process.cwd(), 'data')
const SESSIONS_FILE = path.join(DATA_DIR, 'sessions.json')
const ACTIONS_FILE = path.join(DATA_DIR, 'actions.jsonl')
const EXEC_EVENTS_FILE = path.join(DATA_DIR, 'exec-events.jsonl')
const STATE_FILE = path.join(DATA_DIR, 'state.json')
const MAX_ACTIONS = 10000
const MAX_EXEC_EVENTS = 20000
interface PersistenceState {
enabled: boolean
startedAt: number | null
}
class PersistenceService {
private sessions: Map<string, MonitorSession> = new Map()
private actions: MonitorAction[] = []
private execEvents: MonitorExecEvent[] = []
private enabled = false
private startedAt: number | null = null
constructor() {
this.ensureDataDir()
this.loadState()
this.loadData()
// Auto-start by default if no state file exists
if (!this.enabled && !fs.existsSync(STATE_FILE)) {
this.start()
}
}
private ensureDataDir() {
if (!fs.existsSync(DATA_DIR)) {
fs.mkdirSync(DATA_DIR, { recursive: true })
}
}
private loadState() {
try {
if (fs.existsSync(STATE_FILE)) {
const data = JSON.parse(fs.readFileSync(STATE_FILE, 'utf-8')) as PersistenceState
this.enabled = data.enabled
this.startedAt = data.startedAt
}
} catch {
// ignore
}
}
private saveState() {
const state: PersistenceState = {
enabled: this.enabled,
startedAt: this.startedAt,
}
fs.writeFileSync(STATE_FILE, JSON.stringify(state, null, 2))
}
private loadData() {
// Load sessions
try {
if (fs.existsSync(SESSIONS_FILE)) {
const data = JSON.parse(fs.readFileSync(SESSIONS_FILE, 'utf-8')) as MonitorSession[]
for (const session of data) {
this.sessions.set(session.key, session)
}
}
} catch {
// ignore
}
// Load actions (JSONL)
try {
if (fs.existsSync(ACTIONS_FILE)) {
const content = fs.readFileSync(ACTIONS_FILE, 'utf-8')
const lines = content.trim().split('\n').filter(Boolean)
for (const line of lines) {
try {
const action = JSON.parse(line) as MonitorAction
this.actions.push(action)
} catch {
// skip bad lines
}
}
// Trim to max if needed
if (this.actions.length > MAX_ACTIONS) {
this.actions = this.actions.slice(-MAX_ACTIONS)
this.saveActions()
}
}
} catch {
// ignore
}
// Load exec events (JSONL)
try {
if (fs.existsSync(EXEC_EVENTS_FILE)) {
const content = fs.readFileSync(EXEC_EVENTS_FILE, 'utf-8')
const lines = content.trim().split('\n').filter(Boolean)
for (const line of lines) {
try {
const event = JSON.parse(line) as MonitorExecEvent
this.execEvents.push(event)
} catch {
// skip bad lines
}
}
if (this.execEvents.length > MAX_EXEC_EVENTS) {
this.execEvents = this.execEvents.slice(-MAX_EXEC_EVENTS)
this.saveExecEvents()
}
}
} catch {
// ignore
}
}
private saveSessions() {
const data = Array.from(this.sessions.values())
fs.writeFileSync(SESSIONS_FILE, JSON.stringify(data, null, 2))
}
private saveActions() {
const content = this.actions.map((a) => JSON.stringify(a)).join('\n')
fs.writeFileSync(ACTIONS_FILE, content)
}
private saveExecEvents() {
const content = this.execEvents.map((e) => JSON.stringify(e)).join('\n')
fs.writeFileSync(EXEC_EVENTS_FILE, content)
}
private appendAction(action: MonitorAction) {
fs.appendFileSync(ACTIONS_FILE, JSON.stringify(action) + '\n')
}
private appendExecEvent(event: MonitorExecEvent) {
fs.appendFileSync(EXEC_EVENTS_FILE, JSON.stringify(event) + '\n')
}
get isEnabled() {
return this.enabled
}
start(): { enabled: boolean; startedAt: number } {
this.enabled = true
this.startedAt = Date.now()
this.saveState()
console.log('[persistence] started')
return { enabled: true, startedAt: this.startedAt }
}
stop(): { enabled: boolean } {
this.enabled = false
this.startedAt = null
this.saveState()
console.log('[persistence] stopped')
return { enabled: false }
}
getStatus(): {
enabled: boolean
startedAt: number | null
sessionCount: number
actionCount: number
execEventCount: number
} {
return {
enabled: this.enabled,
startedAt: this.startedAt,
sessionCount: this.sessions.size,
actionCount: this.actions.length,
execEventCount: this.execEvents.length,
}
}
upsertSession(session: MonitorSession) {
if (!this.enabled) return
this.sessions.set(session.key, session)
this.saveSessions()
}
addAction(action: MonitorAction) {
if (!this.enabled) return
// Check if action already exists (by id)
const existingIdx = this.actions.findIndex((a) => a.id === action.id)
if (existingIdx >= 0) {
// Update existing action
this.actions[existingIdx] = action
this.saveActions()
} else {
// Add new action
this.actions.push(action)
this.appendAction(action)
// Rotate if over limit
if (this.actions.length > MAX_ACTIONS) {
this.actions = this.actions.slice(-MAX_ACTIONS)
this.saveActions()
}
}
}
addExecEvent(event: MonitorExecEvent) {
if (!this.enabled) return
const existingIdx = this.execEvents.findIndex((e) => e.id === event.id)
if (existingIdx >= 0) {
this.execEvents[existingIdx] = event
this.saveExecEvents()
} else {
this.execEvents.push(event)
this.appendExecEvent(event)
if (this.execEvents.length > MAX_EXEC_EVENTS) {
this.execEvents = this.execEvents.slice(-MAX_EXEC_EVENTS)
this.saveExecEvents()
}
}
}
hydrate(): {
sessions: MonitorSession[]
actions: MonitorAction[]
execEvents: MonitorExecEvent[]
} {
return {
sessions: Array.from(this.sessions.values()),
actions: [...this.actions],
execEvents: [...this.execEvents],
}
}
clear(): { cleared: boolean } {
this.sessions.clear()
this.actions = []
this.execEvents = []
try {
if (fs.existsSync(SESSIONS_FILE)) fs.unlinkSync(SESSIONS_FILE)
if (fs.existsSync(ACTIONS_FILE)) fs.unlinkSync(ACTIONS_FILE)
if (fs.existsSync(EXEC_EVENTS_FILE)) fs.unlinkSync(EXEC_EVENTS_FILE)
} catch {
// ignore
}
console.log('[persistence] cleared all data')
return { cleared: true }
}
}
// Singleton instance
let instance: PersistenceService | null = null
export function getPersistenceService(): PersistenceService {
if (!instance) {
instance = new PersistenceService()
}
return instance
}
+78
View File
@@ -60,6 +60,7 @@ export interface PresenceEntry {
}
// Chat events
// Note: gateway sends cumulative message content with each delta, not incremental chars
export interface ChatEvent {
runId: string
sessionKey: string
@@ -84,6 +85,32 @@ export interface AgentEvent {
sessionKey?: string
}
// Exec events
export interface ExecStartedEvent {
pid: number
command: string
sessionId: string
runId: string
startedAt: number
}
export interface ExecOutputEvent {
pid: number
runId: string
sessionId?: string
stream: 'stdout' | 'stderr' | string
output: string
}
export interface ExecCompletedEvent {
pid: number
runId: string
sessionId?: string
exitCode: number
durationMs: number
status: string
}
// Sessions
export interface SessionsListParams {
limit?: number
@@ -99,6 +126,8 @@ export interface SessionInfo {
lastActivityAt: number
messageCount: number
lastMessage?: unknown
/** Session key of the parent session that spawned this subagent session. */
spawnedBy?: string
}
// App-level types
@@ -110,6 +139,8 @@ export interface MonitorSession {
isGroup: boolean
lastActivityAt: number
status: 'idle' | 'active' | 'thinking'
/** Session key of the parent session that spawned this subagent session. */
spawnedBy?: string
}
export interface MonitorAction {
@@ -132,6 +163,53 @@ export interface MonitorAction {
stopReason?: string
}
export type MonitorExecEventType = 'started' | 'output' | 'completed'
export interface MonitorExecEvent {
id: string
execId: string
runId: string
pid: number
sessionId?: string
sessionKey?: string
eventType: MonitorExecEventType
command?: string
stream?: 'stdout' | 'stderr' | string
output?: string
startedAt?: number
durationMs?: number
exitCode?: number
status?: string
timestamp: number
}
export type MonitorExecProcessStatus = 'running' | 'completed' | 'failed'
export interface MonitorExecOutputChunk {
id: string
stream: 'stdout' | 'stderr' | string
text: string
timestamp: number
}
export interface MonitorExecProcess {
id: string
runId: string
pid: number
command: string
sessionId?: string
sessionKey?: string
status: MonitorExecProcessStatus
startedAt: number
completedAt?: number
durationMs?: number
exitCode?: number
outputs: MonitorExecOutputChunk[]
outputTruncated?: boolean
timestamp: number
lastActivityAt: number
}
// Utility functions
export function parseSessionKey(key: string): {
agentId: string
+46 -3
View File
@@ -3,11 +3,13 @@ import { observable } from '@trpc/server/observable'
import superjson from 'superjson'
import { z } from 'zod'
import { getClawdbotClient } from '~/integrations/clawdbot/client'
import { getPersistenceService } from '~/integrations/clawdbot/persistence'
import {
parseEventFrame,
sessionInfoToMonitor,
type MonitorSession,
type MonitorAction,
type MonitorExecEvent,
} from '~/integrations/clawdbot'
// Server-side debug mode state
@@ -114,14 +116,18 @@ const clawdbotRouter = router({
)
.query(async ({ input }) => {
const client = getClawdbotClient()
const persistence = getPersistenceService()
if (!client.connected) {
return { sessions: [], error: 'Not connected' }
}
try {
const sessions = await client.listSessions(input)
return {
sessions: sessions.map(sessionInfoToMonitor),
const monitorSessions = sessions.map(sessionInfoToMonitor)
// Persist sessions if service is enabled
for (const session of monitorSessions) {
persistence.upsertSession(session)
}
return { sessions: monitorSessions }
} catch (error) {
return {
sessions: [],
@@ -132,11 +138,13 @@ const clawdbotRouter = router({
events: publicProcedure.subscription(() => {
return observable<{
type: 'session' | 'action'
type: 'session' | 'action' | 'exec'
session?: Partial<MonitorSession>
action?: MonitorAction
execEvent?: MonitorExecEvent
}>((emit) => {
const client = getClawdbotClient()
const persistence = getPersistenceService()
const unsubscribe = client.onEvent((event) => {
// Collect raw event when log collection is enabled
@@ -157,12 +165,21 @@ const clawdbotRouter = router({
if (debugMode && parsed.action) {
console.log('[DEBUG] Parsed action:', parsed.action.type, parsed.action.eventType, 'sessionKey:', parsed.action.sessionKey)
}
if (debugMode && parsed.execEvent) {
console.log('[DEBUG] Parsed exec:', parsed.execEvent.eventType, 'runId:', parsed.execEvent.runId, 'pid:', parsed.execEvent.pid)
}
if (parsed.session) {
emit.next({ type: 'session', session: parsed.session })
}
if (parsed.action) {
// Persist action if service is enabled
persistence.addAction(parsed.action)
emit.next({ type: 'action', action: parsed.action })
}
if (parsed.execEvent) {
persistence.addExecEvent(parsed.execEvent)
emit.next({ type: 'exec', execEvent: parsed.execEvent })
}
}
})
@@ -171,6 +188,32 @@ const clawdbotRouter = router({
}
})
}),
// Persistence service
persistenceStatus: publicProcedure.query(() => {
const persistence = getPersistenceService()
return persistence.getStatus()
}),
persistenceStart: publicProcedure.mutation(() => {
const persistence = getPersistenceService()
return persistence.start()
}),
persistenceStop: publicProcedure.mutation(() => {
const persistence = getPersistenceService()
return persistence.stop()
}),
persistenceHydrate: publicProcedure.query(() => {
const persistence = getPersistenceService()
return persistence.hydrate()
}),
persistenceClear: publicProcedure.mutation(() => {
const persistence = getPersistenceService()
return persistence.clear()
}),
})
export const appRouter = router({
+279 -39
View File
@@ -1,5 +1,14 @@
import dagre from 'dagre'
import type { Node, Edge } from '@xyflow/react'
import type {
MonitorSession,
MonitorAction,
MonitorExecProcess,
} from '~/integrations/clawdbot'
/** Cast domain data to ReactFlow's Node data type */
function nodeData<T>(data: T): Record<string, unknown> {
return data as Record<string, unknown>
}
export interface LayoutOptions {
direction?: 'TB' | 'LR' | 'BT' | 'RL'
@@ -9,60 +18,291 @@ export interface LayoutOptions {
nodeSep?: number
}
// Node sizing configuration - sized generously for layout calculations
const NODE_DIMENSIONS = {
session: { width: 280, height: 140 }, // Wider for session cards
exec: { width: 300, height: 120 }, // Exec processes need room
action: { width: 220, height: 100 }, // Chat events with padding
crab: { width: 64, height: 64 },
}
// Layout constants - generous spacing for clarity
const COLUMN_GAP = 400 // Horizontal gap between session columns
const ROW_GAP = 80 // Vertical gap between items in a column
const SPAWN_OFFSET = 60 // Extra Y offset when spawning to right
const CRAB_OFFSET = { x: -120, y: -100 }
const MIN_SESSION_GAP = 120 // Minimum vertical gap between sessions in same column
interface SessionColumn {
sessionKey: string
columnIndex: number
spawnY: number // Y position where this session was spawned from parent
items: Array<{
nodeId: string
type: 'session' | 'action' | 'exec'
timestamp: number
data: unknown
}>
}
/**
* Horizontal spawn layout algorithm:
* - Sessions arranged in columns (X = spawn depth)
* - Events within a session flow DOWN (Y = time progression)
* - Child sessions appear to the RIGHT at the Y-level where they were spawned
*/
export function layoutGraph(
nodes: Node[],
edges: Edge[],
options: LayoutOptions = {}
_options: LayoutOptions = {}
): { nodes: Node[]; edges: Edge[] } {
const {
direction = 'TB',
nodeWidth = 200,
nodeHeight = 80,
rankSep = 80,
nodeSep = 40,
} = options
// Build session hierarchy and columns
const sessions = nodes
.filter((n) => n.type === 'session')
.map((n) => n.data as unknown as MonitorSession)
const g = new dagre.graphlib.Graph()
g.setDefaultEdgeLabel(() => ({}))
g.setGraph({ rankdir: direction, ranksep: rankSep, nodesep: nodeSep })
const actions = nodes
.filter((n) => n.type === 'action')
.map((n) => ({ id: n.id.replace('action-', ''), data: n.data as unknown as MonitorAction }))
// Add nodes
for (const node of nodes) {
const width = node.measured?.width ?? nodeWidth
const height = node.measured?.height ?? nodeHeight
g.setNode(node.id, { width, height })
}
const execs = nodes
.filter((n) => n.type === 'exec')
.map((n) => ({ id: n.id.replace('exec-', ''), data: n.data as unknown as MonitorExecProcess }))
// Add edges
for (const edge of edges) {
g.setEdge(edge.source, edge.target)
}
const crabNode = nodes.find((n) => n.type === 'crab')
// Run layout
dagre.layout(g)
// Build session column map - which column is each session in?
const sessionColumns = new Map<string, SessionColumn>()
const columnOccupancy = new Map<number, number>() // columnIndex -> maxY used
// Apply positions
const layoutedNodes = nodes.map((node) => {
const nodeWithPosition = g.node(node.id)
const width = node.measured?.width ?? nodeWidth
const height = node.measured?.height ?? nodeHeight
// First pass: determine column for each session based on spawn hierarchy
const getSessionColumn = (sessionKey: string, visited = new Set<string>()): number => {
if (visited.has(sessionKey)) return 0
visited.add(sessionKey)
return {
...node,
position: {
x: nodeWithPosition.x - width / 2,
y: nodeWithPosition.y - height / 2,
},
const session = sessions.find((s) => s.key === sessionKey)
if (!session) return 0
if (session.spawnedBy) {
return getSessionColumn(session.spawnedBy, visited) + 1
}
return 0
}
// Assign columns to all sessions
for (const session of sessions) {
const columnIndex = getSessionColumn(session.key)
sessionColumns.set(session.key, {
sessionKey: session.key,
columnIndex,
spawnY: 0,
items: [],
})
}
// Group actions by session and sort by timestamp
const actionsBySession = new Map<string, typeof actions>()
for (const action of actions) {
const sessionKey = action.data.sessionKey
if (!sessionKey) continue
const list = actionsBySession.get(sessionKey) ?? []
list.push(action)
actionsBySession.set(sessionKey, list)
}
for (const [key, list] of actionsBySession) {
list.sort((a, b) => a.data.timestamp - b.data.timestamp)
actionsBySession.set(key, list)
}
// Group execs by session
const execsBySession = new Map<string, typeof execs>()
for (const exec of execs) {
const sessionKey = exec.data.sessionKey
if (!sessionKey) continue
const list = execsBySession.get(sessionKey) ?? []
list.push(exec)
execsBySession.set(sessionKey, list)
}
for (const [key, list] of execsBySession) {
list.sort((a, b) => a.data.startedAt - b.data.startedAt)
execsBySession.set(key, list)
}
// Build items list for each session (session node + actions + execs)
for (const session of sessions) {
const col = sessionColumns.get(session.key)
if (!col) continue
// Add session node itself
col.items.push({
nodeId: `session-${session.key}`,
type: 'session',
timestamp: session.lastActivityAt ?? 0,
data: session,
})
// Add actions
const sessionActions = actionsBySession.get(session.key) ?? []
for (const action of sessionActions) {
col.items.push({
nodeId: `action-${action.id}`,
type: 'action',
timestamp: action.data.timestamp,
data: action.data,
})
}
// Add execs
const sessionExecs = execsBySession.get(session.key) ?? []
for (const exec of sessionExecs) {
col.items.push({
nodeId: `exec-${exec.id}`,
type: 'exec',
timestamp: exec.data.startedAt,
data: exec.data,
})
}
// Sort all items by timestamp (session node first since it's the start)
col.items.sort((a, b) => {
if (a.type === 'session') return -1
if (b.type === 'session') return 1
return a.timestamp - b.timestamp
})
}
// Calculate spawn Y positions for child sessions
// When a session is spawned, find the Y position of the parent at that time
for (const session of sessions) {
if (!session.spawnedBy) continue
const parentCol = sessionColumns.get(session.spawnedBy)
const childCol = sessionColumns.get(session.key)
if (!parentCol || !childCol) continue
// Find the approximate position in parent where spawn happened
// Use the child's creation time (approximated by first action time or session activity)
const childActions = actionsBySession.get(session.key) ?? []
const childCreationTime = childActions[0]?.data.timestamp ?? session.lastActivityAt ?? Date.now()
// Count how many items in parent were before this spawn
let parentItemsBeforeSpawn = 0
for (const item of parentCol.items) {
if (item.type === 'session') {
parentItemsBeforeSpawn++
continue
}
if (item.timestamp <= childCreationTime) {
parentItemsBeforeSpawn++
}
}
// Calculate Y based on parent's item count
childCol.spawnY = parentItemsBeforeSpawn * (NODE_DIMENSIONS.action.height + ROW_GAP) + SPAWN_OFFSET
}
// Position all nodes
const positionedNodes: Node[] = []
const positionedNodeIds = new Set<string>()
// Position crab node
if (crabNode) {
positionedNodes.push({
...crabNode,
position: { x: CRAB_OFFSET.x, y: CRAB_OFFSET.y },
})
positionedNodeIds.add(crabNode.id)
}
// Track column usage for collision avoidance: columnIndex -> list of {startY, endY} ranges
const columnRanges = new Map<number, Array<{ startY: number; endY: number }>>()
// Get X position for a column (all nodes in same column share same X)
const getColumnX = (columnIndex: number): number => {
return columnIndex * COLUMN_GAP
}
// Adjust spawn Y to avoid collisions with existing sessions in same column
const adjustSpawnY = (columnIndex: number, desiredY: number, itemCount: number): number => {
const ranges = columnRanges.get(columnIndex) ?? []
const estimatedHeight = itemCount * (NODE_DIMENSIONS.action.height + ROW_GAP) + MIN_SESSION_GAP
let adjustedY = desiredY
// Check for overlaps and shift down if needed
for (const range of ranges) {
// If our desired position overlaps with an existing range
if (adjustedY < range.endY && (adjustedY + estimatedHeight) > range.startY) {
// Shift below this range with minimum gap
adjustedY = range.endY + MIN_SESSION_GAP
}
}
// Record our range
ranges.push({ startY: adjustedY, endY: adjustedY + estimatedHeight })
columnRanges.set(columnIndex, ranges)
return adjustedY
}
// Sort sessions by column index (process column 0 first, then 1, etc.)
// This ensures parent sessions are positioned before children
const sortedSessionKeys = Array.from(sessionColumns.keys()).sort((a, b) => {
const colA = sessionColumns.get(a)!.columnIndex
const colB = sessionColumns.get(b)!.columnIndex
if (colA !== colB) return colA - colB
// Within same column, sort by spawn Y (earlier spawns first)
return sessionColumns.get(a)!.spawnY - sessionColumns.get(b)!.spawnY
})
return { nodes: layoutedNodes, edges }
// Position each session's column
for (const sessionKey of sortedSessionKeys) {
const col = sessionColumns.get(sessionKey)!
const columnX = getColumnX(col.columnIndex)
// Adjust Y position to avoid collisions with other sessions in same column
const adjustedY = adjustSpawnY(col.columnIndex, col.spawnY, col.items.length)
let currentY = adjustedY
for (const item of col.items) {
const dims = NODE_DIMENSIONS[item.type]
positionedNodes.push({
id: item.nodeId,
type: item.type,
position: { x: columnX, y: currentY },
data: nodeData(item.data),
})
positionedNodeIds.add(item.nodeId)
currentY += dims.height + ROW_GAP
}
// Track max Y for this column
columnOccupancy.set(col.columnIndex, Math.max(
columnOccupancy.get(col.columnIndex) ?? 0,
currentY
))
}
// Handle orphan nodes (actions/execs without a session)
let orphanY = Math.max(...Array.from(columnOccupancy.values()), 0) + 100
for (const node of nodes) {
if (!positionedNodeIds.has(node.id)) {
const dims = NODE_DIMENSIONS[node.type as keyof typeof NODE_DIMENSIONS] ?? { width: 180, height: 80 }
positionedNodes.push({
...node,
position: { x: -200, y: orphanY },
})
orphanY += dims.height + ROW_GAP
}
}
return { nodes: positionedNodes, edges }
}
// Group nodes by session for better visual organization
export function groupNodesBySession(
nodes: Node[]
): Map<string, Node[]> {
export function groupNodesBySession(nodes: Node[]): Map<string, Node[]> {
const groups = new Map<string, Node[]>()
for (const node of nodes) {
+1 -1
View File
@@ -122,7 +122,7 @@ function Home() {
transition={{ duration: 0.5, delay: 0.3 }}
className="font-console font-bold text-lg text-gray-400 mb-4 tracking-wide uppercase"
>
Open-Source Clawdbot Companion
Open-Source Moltbot (Clawdbot) Companion
</motion.p>
{/* Console-style description */}
+159 -3
View File
@@ -1,16 +1,20 @@
import { useState, useEffect, useCallback } from 'react'
import { useState, useEffect, useCallback, useMemo } from 'react'
import { createFileRoute, Link } from '@tanstack/react-router'
import { useLiveQuery } from '@tanstack/react-db'
import { motion } from 'framer-motion'
import { ArrowLeft, Loader2 } from 'lucide-react'
import { ArrowLeft, Loader2, HardDrive, Trash2 } from 'lucide-react'
import { trpc } from '~/integrations/trpc/client'
import {
sessionsCollection,
actionsCollection,
execsCollection,
upsertSession,
addAction,
addExecEvent,
updateSessionStatus,
clearCollections,
hydrateFromServer,
clearCompletedExecs,
} from '~/integrations/clawdbot'
import {
ActionGraph,
@@ -67,22 +71,57 @@ function MonitorPage() {
const [logCount, setLogCount] = useState(0)
const [selectedSession, setSelectedSession] = useState<string | null>(null)
// Persistence service state
const [persistenceEnabled, setPersistenceEnabled] = useState(false)
const [persistenceStartedAt, setPersistenceStartedAt] = useState<number | null>(null)
const [persistenceSessionCount, setPersistenceSessionCount] = useState(0)
const [persistenceActionCount, setPersistenceActionCount] = useState(0)
// Sidebar collapse state - default to collapsed
const [sidebarCollapsed, setSidebarCollapsed] = useState(true)
// Settings panel state
const [settingsOpen, setSettingsOpen] = useState(false)
// Live queries from TanStack DB collections
const sessionsQuery = useLiveQuery(sessionsCollection)
const actionsQuery = useLiveQuery(actionsCollection)
const execsQuery = useLiveQuery(execsCollection)
const sessions = sessionsQuery.data ?? []
const actions = actionsQuery.data ?? []
const execs = execsQuery.data ?? []
// Count clearable items (completed/failed execs)
const completedCount = useMemo(() => {
return execs.filter(e => e.status === 'completed' || e.status === 'failed').length
}, [execs])
// Handler for clearing completed execs
const handleClearCompleted = useCallback(() => {
const count = clearCompletedExecs()
console.log(`[monitor] cleared ${count} completed execs`)
}, [])
// Check connection status on mount
// Check connection status and persistence on mount
useEffect(() => {
checkStatus()
checkPersistenceStatus()
}, [])
const checkPersistenceStatus = async () => {
try {
const status = await trpc.clawdbot.persistenceStatus.query()
setPersistenceEnabled(status.enabled)
setPersistenceStartedAt(status.startedAt)
setPersistenceSessionCount(status.sessionCount)
setPersistenceActionCount(status.actionCount)
} catch {
// ignore
}
}
const checkStatus = async () => {
try {
const status = await trpc.clawdbot.status.query()
@@ -101,6 +140,8 @@ function MonitorPage() {
setConnected(true)
setRetryCount(0)
setConnecting(false)
// Hydrate from persistence if enabled
await hydrateFromPersistence()
await loadSessions()
return
}
@@ -115,6 +156,25 @@ function MonitorPage() {
}
}
const hydrateFromPersistence = async () => {
try {
const status = await trpc.clawdbot.persistenceStatus.query()
if (status.sessionCount > 0 || status.actionCount > 0 || status.execEventCount > 0) {
const data = await trpc.clawdbot.persistenceHydrate.query()
hydrateFromServer(data.sessions, data.actions, data.execEvents ?? [])
console.log(
`[monitor] hydrated ${data.sessions.length} sessions, ${data.actions.length} actions, ${(data.execEvents ?? []).length} exec events`
)
}
setPersistenceEnabled(status.enabled)
setPersistenceStartedAt(status.startedAt)
setPersistenceSessionCount(status.sessionCount)
setPersistenceActionCount(status.actionCount)
} catch (e) {
console.error('Failed to hydrate:', e)
}
}
const handleDisconnect = async () => {
try {
await trpc.clawdbot.disconnect.mutate()
@@ -196,6 +256,37 @@ function MonitorPage() {
}
}
const handlePersistenceStart = async () => {
try {
const result = await trpc.clawdbot.persistenceStart.mutate()
setPersistenceEnabled(result.enabled)
setPersistenceStartedAt(result.startedAt)
} catch (e) {
console.error('Failed to start persistence:', e)
}
}
const handlePersistenceStop = async () => {
try {
const result = await trpc.clawdbot.persistenceStop.mutate()
setPersistenceEnabled(result.enabled)
setPersistenceStartedAt(null)
} catch (e) {
console.error('Failed to stop persistence:', e)
}
}
const handlePersistenceClear = async () => {
try {
await trpc.clawdbot.persistenceClear.mutate()
setPersistenceSessionCount(0)
setPersistenceActionCount(0)
clearCollections()
} catch (e) {
console.error('Failed to clear persistence:', e)
}
}
// Poll log count while collecting
useEffect(() => {
if (!logCollection) return
@@ -210,6 +301,22 @@ function MonitorPage() {
return () => clearInterval(interval)
}, [logCollection])
// Poll persistence status
useEffect(() => {
const interval = setInterval(async () => {
try {
const status = await trpc.clawdbot.persistenceStatus.query()
setPersistenceEnabled(status.enabled)
setPersistenceStartedAt(status.startedAt)
setPersistenceSessionCount(status.sessionCount)
setPersistenceActionCount(status.actionCount)
} catch {
// ignore
}
}, 5000)
return () => clearInterval(interval)
}, [])
const handleToggleSidebar = useCallback(() => {
setSidebarCollapsed((prev) => !prev)
}, [])
@@ -242,6 +349,9 @@ function MonitorPage() {
if (data.type === 'action' && data.action) {
addAction(data.action)
}
if (data.type === 'exec' && data.execEvent) {
addExecEvent(data.execEvent)
}
},
onError: (err) => {
console.error('[monitor] subscription error:', err)
@@ -294,6 +404,42 @@ function MonitorPage() {
</motion.div>
)}
{/* Clear Completed button */}
{completedCount > 0 && (
<button
onClick={handleClearCompleted}
className="flex items-center gap-2 px-3 py-1.5 rounded-lg transition-all bg-shell-800/50 hover:bg-crab-900/50 hover:border-crab-700/50 border border-transparent group"
title={`Clear ${completedCount} completed item${completedCount !== 1 ? 's' : ''}`}
>
<Trash2
size={14}
className="text-shell-400 group-hover:text-crab-400 transition-colors"
/>
<span className="font-console text-xs text-shell-400 group-hover:text-crab-400 transition-colors">
{completedCount}
</span>
</button>
)}
{/* Persistence indicator */}
<button
onClick={() => setSettingsOpen(true)}
className={`flex items-center gap-2 px-3 py-1.5 rounded-lg transition-all ${
persistenceEnabled
? 'bg-neon-mint/10 hover:bg-neon-mint/20'
: 'bg-shell-800/50 hover:bg-shell-700'
}`}
title={persistenceEnabled ? 'Background service running' : 'Background service stopped'}
>
<HardDrive
size={14}
className={persistenceEnabled ? 'text-neon-mint' : 'text-shell-500'}
/>
{persistenceEnabled && (
<span className="w-1.5 h-1.5 rounded-full bg-neon-mint animate-pulse" />
)}
</button>
{/* Stats display */}
<div className="hidden sm:flex items-center gap-3 px-3 py-1.5 bg-shell-800/50 rounded-lg">
<div className="flex items-center gap-2">
@@ -313,6 +459,12 @@ function MonitorPage() {
debugMode={debugMode}
logCollection={logCollection}
logCount={logCount}
persistenceEnabled={persistenceEnabled}
persistenceStartedAt={persistenceStartedAt}
persistenceSessionCount={persistenceSessionCount}
persistenceActionCount={persistenceActionCount}
open={settingsOpen}
onOpenChange={setSettingsOpen}
onHistoricalModeChange={handleHistoricalModeChange}
onDebugModeChange={handleDebugModeChange}
onLogCollectionChange={handleLogCollectionChange}
@@ -321,6 +473,9 @@ function MonitorPage() {
onConnect={handleConnect}
onDisconnect={handleDisconnect}
onRefresh={handleRefresh}
onPersistenceStart={handlePersistenceStart}
onPersistenceStop={handlePersistenceStop}
onPersistenceClear={handlePersistenceClear}
/>
</div>
</header>
@@ -341,6 +496,7 @@ function MonitorPage() {
<ActionGraph
sessions={sessions}
actions={actions}
execs={execs}
selectedSession={selectedSession}
onSessionSelect={setSelectedSession}
/>