Compare commits

...
Author SHA1 Message Date
luccast 3c29e63d44 chore: bump version to 1.0.5 2026-01-28 18:04:22 -05:00
Jamie Taylor 2ce840b957 docs(readme) - added info on host networking for users who have gateway exposed to loopback only (common tailscale setup) and how to pass allowed hosts as env vars in docker/source setups (#16) 2026-01-28 18:02:25 -05:00
luccast 1e52901b81 feat: update version display in SettingsPanel and Home components to use dynamic version from package.json 2026-01-28 18:01:22 -05:00
Luciano Castillo 1c0388c586 feat(monitor): nest subagent sessions, reorganizing feature (#15)
* feat(monitor): enhance session list with subagent grouping and improved filtering

- Introduced SubagentItem component for rendering subagent sessions.
- Grouped subagents under their parent sessions based on the spawnedBy relationship.
- Updated filtering logic to separate parent sessions from subagents.
- Enhanced sorting and display of sessions, including orphan subagents.
- Improved UI for session selection and status indication.

This update improves the organization and usability of the session list in the monitor component.

* fix(monitor): update session display to show recipient instead of agentId

- Changed the displayed identifier in the SessionList component from agentId to recipient for improved clarity in session representation.

* feat(monitor): add node position pinning and reorganize layout functionality

- Introduced functionality to pin node positions upon drag-end, enhancing user experience in the ActionGraph component.
- Added a button to reorganize the layout, clearing pinned positions and reapplying the layout for better visual management.
- Updated the node state handling to incorporate pinned positions during layout updates, ensuring a smoother interaction with the graph.

* fix(monitor): update styling for ExecNode and SessionNode components

- Changed the CSS class for text wrapping in ExecNode to improve readability.
- Modified the border and box shadow logic in SessionNode to enhance visual feedback based on session status, particularly for 'thinking' states.

* feat(monitor): enhance session node representation with thinking state

- Added logic to determine which sessions are in a "thinking" state based on the latest action type in the ActionGraph component.
- Updated SessionNode to accept a new `thinking` property, modifying the visual feedback (border and box shadow) to reflect this state, improving user experience and clarity in session status.

* revert: undo session thinking state and exec/session styling changes

* fix(monitor): update SubagentItem emoji representation

* feat(monitor): implement collapsible subagent groups in SessionList

- Added functionality to collapse and expand subagent groups within the SessionList component.
- Introduced a new state to manage collapsed groups and updated the UI to reflect the expanded/collapsed state with a ChevronDown icon.
- Enhanced user experience by allowing users to easily navigate through subagent sessions.

* style(monitor): update SessionList component styling

- Adjusted padding and alignment for improved layout in the SessionList component.
- Increased ChevronDown icon size for better visibility.
- Enhanced text styling for consistency and readability.

* refactor(monitor): optimize animation handling in SessionList component

- Updated SubagentItem animations to improve performance and visual feedback.
- Replaced AnimatePresence with motion.div for better control over height and opacity transitions during group collapse/expand.
- Simplified initial animation states for a smoother user experience.
2026-01-28 17:56:01 -05:00
Luciano Castillo eabebfd761 support ALLOWED_HOSTS env var, fix Dockerfile missing source files (#12)
- parse ALLOWED_HOSTS into vite server.allowedHosts for LAN/tailscale access
- copy src/, public/, vite.config.ts, tsconfig.json into Docker runtime stage
  (dev server needs these, only dist/ was copied before)
- pass ALLOWED_HOSTS through docker-compose

Ref #10
2026-01-28 12:21:06 -05:00
Luciano Castillo 80c28485f5 feat(monitor): exec events visualization + hierarchical spawn layout (#14)
Merging select features from https://github.com/luccast/crabwalk/pull/11 

* feat(exec-events): bead-1 - add exec protocol and monitor types

* feat(exec-events): bead-2 - parse exec started output completed

* feat(exec-events): bead-3 - add exec collection and aggregation

* feat(exec-events): bead-4 - persist exec events

* feat(exec-events): bead-5 - hydrate exec events after actions

* feat(exec-events): bead-6 - emit exec events in subscription

* feat(exec-events): bead-7 - wire exec events into monitor route

* feat(exec-events): bead-8 - add exec node component

* feat(exec-events): bead-9 - render exec nodes under sessions

* feat(exec-events): bead-10 - final validation and hydrate exec-only data

* fix(parser): remove incorrect sessionKey assignment

* feat(monitor): link subagents to parent sessions via spawnedBy, add timestamps to session nodes

* 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

* 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

* 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

* 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

* 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

* chore: remove non-feature files from contributor PR

Remove planning docs, security audit, vitest config, and revert
package.json/lockfile to original state.

* 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

* 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.

* 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

* 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.

* 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.

* 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:16:23 -05:00
21 changed files with 1603 additions and 174 deletions
+4
View File
@@ -3,3 +3,7 @@
# Clawdbot gateway auth token
CLAWDBOT_API_TOKEN=
# Comma-separated list of allowed hosts for the dev server
# Example: ALLOWED_HOSTS=myhost.local,192.168.1.100,.mydomain.com
ALLOWED_HOSTS=
+4
View File
@@ -24,6 +24,10 @@ COPY package*.json ./
RUN npm ci
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/src ./src
COPY --from=builder /app/public ./public
COPY --from=builder /app/vite.config.ts ./
COPY --from=builder /app/tsconfig.json ./
EXPOSE 3000
+13 -1
View File
@@ -28,8 +28,10 @@ docker run -d \
ghcr.io/luccast/crabwalk:latest
```
> Note: When running Crabwalk in Docker, the Clawdbot gateway typically runs on the *host*.
> Note: When running Crabwalk in Docker, the Moltbot gateway typically runs on the *host*.
> Use `CLAWDBOT_URL=ws://host.docker.internal:18789` so the container can connect.
>If you're running Moltbot with `bind: loopback` and `tailscale serve` for secure tailnet-only access, you'll need to run the crabwalk container with host networking - replace `p:3000:3000` with `--network host`
>This allows the container to reach 127.0.0.1:18789 while maintaining the security benefits of loopback-only binding.
Or with docker-compose:
@@ -38,6 +40,8 @@ curl -O https://raw.githubusercontent.com/luccast/crabwalk/master/docker-compose
CLAWDBOT_API_TOKEN=your-token CLAWDBOT_URL=ws://host.docker.internal:18789 docker-compose up -d
```
> If gateway is `bind: loopback` only, you will need to edit the `docker-compose.yml` to add `network_mode: host`
### From source
```bash
@@ -74,6 +78,14 @@ Or copy it directly:
export CLAWDBOT_API_TOKEN=$(python3 -c "import json,os; print(json.load(open(os.path.expanduser('~/.clawdbot/clawdbot.json')))['gateway']['auth']['token'])")
```
## Accessing from a remote host
If you are running this on a remote server and accessing it through a non-local browser, the default allowedHosts behaviour will prevent access to the web UI.
If running in docker, you can pass the environment variable `ALLOWED_HOSTS` with a comma-separated list of hosts you wish to allow access to the crabwalk UI.
If running from source, you can either pass `ALLOWED_HOSTS` as an env var at the command line, or use a .env file.
## Stack
TanStack Start, ReactFlow, Framer Motion, tRPC, TanStack DB
+1
View File
@@ -5,4 +5,5 @@ services:
- "3000:3000"
environment:
- CLAWDBOT_API_TOKEN=${CLAWDBOT_API_TOKEN}
- ALLOWED_HOSTS=${ALLOWED_HOSTS:-}
restart: unless-stopped
+19 -36
View File
@@ -1,12 +1,12 @@
{
"name": "crabwalk",
"version": "1.0.4",
"version": "1.0.5",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "crabwalk",
"version": "1.0.4",
"version": "1.0.5",
"dependencies": {
"@tanstack/db": "^0.5.0",
"@tanstack/history": "^1.132.0",
@@ -17,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",
@@ -70,6 +68,7 @@
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.6.tgz",
"integrity": "sha512-H3mcG6ZDLTlYfaSNi0iOKkigqMFvkTKlGUYlD8GW7nNOYRrevuA46iTypPyv+06V3fEmvvazfntkBU34L0azAw==",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/code-frame": "^7.28.6",
"@babel/generator": "^7.28.6",
@@ -1547,6 +1546,7 @@
"resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.90.20.tgz",
"integrity": "sha512-vXBxa+qeyveVO7OA0jX1z+DeyCA4JKnThKv411jd5SORpBKgkcVnYKCiBgECvADvniBX7tobwBmg01qq9JmMJw==",
"license": "MIT",
"peer": true,
"dependencies": {
"@tanstack/query-core": "5.90.20"
},
@@ -1581,6 +1581,7 @@
"resolved": "https://registry.npmjs.org/@tanstack/react-router/-/react-router-1.157.13.tgz",
"integrity": "sha512-rLr6swdDhLjadAHbukg/XYurNi4q8AmmjP99QiWUnhRPKfrfmQ/O1UCvaowQ9GcaiyOG2COiJ9OlDAQU3k9MFQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"@tanstack/history": "1.154.14",
"@tanstack/react-store": "^0.8.0",
@@ -1727,6 +1728,7 @@
"resolved": "https://registry.npmjs.org/@tanstack/router-core/-/router-core-1.157.13.tgz",
"integrity": "sha512-dl1avpRH2Pi1tYhxDzGEqI6DU9VQUf5zBeW1JJFs3LfJuVkGd2lG93dSUzH9seJNp77hm8JWhoX+k4QYMEB6KA==",
"license": "MIT",
"peer": true,
"dependencies": {
"@tanstack/history": "1.154.14",
"@tanstack/store": "^0.8.0",
@@ -2037,6 +2039,7 @@
"https://trpc.io/sponsor"
],
"license": "MIT",
"peer": true,
"peerDependencies": {
"typescript": ">=5.7.2"
}
@@ -2135,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",
@@ -2204,6 +2201,7 @@
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.9.tgz",
"integrity": "sha512-Lpo8kgb/igvMIPeNV2rsYKTgaORYdO1XGVZ4Qz3akwOj0ySGYMPlQWa8BaLn0G63D1aSaAQ5ldR06wCpChQCjA==",
"license": "MIT",
"peer": true,
"dependencies": {
"csstype": "^3.2.2"
}
@@ -2432,6 +2430,7 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.9.0",
"caniuse-lite": "^1.0.30001759",
@@ -2666,7 +2665,8 @@
"version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
"license": "MIT"
"license": "MIT",
"peer": true
},
"node_modules/d3-color": {
"version": "3.1.0",
@@ -2725,6 +2725,7 @@
"resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz",
"integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==",
"license": "ISC",
"peer": true,
"engines": {
"node": ">=12"
}
@@ -2773,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",
@@ -3156,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",
@@ -3707,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",
@@ -4568,6 +4544,7 @@
"resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz",
"integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=0.10.0"
}
@@ -4577,6 +4554,7 @@
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.3.tgz",
"integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==",
"license": "MIT",
"peer": true,
"dependencies": {
"scheduler": "^0.27.0"
},
@@ -4776,6 +4754,7 @@
"resolved": "https://registry.npmjs.org/seroval/-/seroval-1.5.0.tgz",
"integrity": "sha512-OE4cvmJ1uSPrKorFIH9/w/Qwuvi/IMcGbv5RKgcJ/zjA/IohDLU6SVaxFN9FwajbP7nsX0dQqMDes1whk3y+yw==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=10"
}
@@ -4953,6 +4932,7 @@
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@@ -5042,6 +5022,7 @@
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"license": "Apache-2.0",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -5258,6 +5239,7 @@
"resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz",
"integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==",
"license": "MIT",
"peer": true,
"dependencies": {
"esbuild": "^0.27.0",
"fdir": "^6.5.0",
@@ -5368,6 +5350,7 @@
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
+1 -3
View File
@@ -1,6 +1,6 @@
{
"name": "crabwalk",
"version": "1.0.4",
"version": "1.0.5",
"private": true,
"type": "module",
"scripts": {
@@ -17,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",
+182 -20
View File
@@ -9,24 +9,37 @@ import {
type Node,
type Edge,
type NodeTypes,
type NodeChange,
MarkerType,
ReactFlowProvider,
} from '@xyflow/react'
import { LayoutGrid } from 'lucide-react'
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 +59,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 +77,7 @@ interface CrabAI {
function ActionGraphInner({
sessions,
actions,
execs,
selectedSession,
onSessionSelect,
}: ActionGraphProps) {
@@ -78,6 +93,7 @@ function ActionGraphInner({
const prevNodeIdsRef = useRef<Set<string>>(new Set())
const nodePositionsRef = useRef<Map<string, { x: number; y: number }>>(new Map())
const pinnedPositions = useRef<Map<string, { x: number; y: number }>>(new Map())
const animationFrameRef = useRef<number>(undefined)
const timeoutRef = useRef<NodeJS.Timeout>(undefined)
@@ -87,11 +103,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 +130,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 +139,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 +163,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 +188,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 +263,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 +273,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 +283,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 +295,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(() => {
@@ -262,7 +373,25 @@ function ActionGraphInner({
return [...layoutedNodes, chaserNode]
}, [])
const [nodes, setNodes, onNodesChange] = useNodesState(initialNodes)
const [nodes, setNodes, rawOnNodesChange] = useNodesState(initialNodes)
// Intercept node changes to detect drag-end and pin positions
const onNodesChange = useCallback(
(changes: NodeChange[]) => {
for (const change of changes) {
if (
change.type === 'position' &&
'dragging' in change &&
change.dragging === false &&
change.position
) {
pinnedPositions.current.set(change.id, { ...change.position })
}
}
rawOnNodesChange(changes)
},
[rawOnNodesChange]
)
// Handle crab click - jump animation (defined after setNodes)
const handleCrabClick = useCallback(() => {
@@ -534,16 +663,22 @@ function ActionGraphInner({
}
}, [layoutedNodes, setNodes, handleCrabClick])
// Update layout nodes when they change (preserve chaser)
// Update layout nodes when they change (preserve chaser + pinned positions)
useEffect(() => {
setNodes((nds) => {
const pinned = pinnedPositions.current
const mergedNodes = layoutedNodes.map((n) => {
const pin = pinned.get(n.id)
return pin ? { ...n, position: pin } : n
})
const chaserNode = nds.find((n) => n.id === CHASER_CRAB_ID)
if (chaserNode) {
return [...layoutedNodes, chaserNode]
return [...mergedNodes, chaserNode]
}
const crab = crabRef.current
return [
...layoutedNodes,
...mergedNodes,
{
id: CHASER_CRAB_ID,
type: 'chaserCrab',
@@ -562,6 +697,18 @@ function ActionGraphInner({
setEdges(layoutedEdges)
}, [layoutedNodes, layoutedEdges, setNodes, setEdges, handleCrabClick])
// Re-organize: clear pinned positions and re-apply layout
const handleReorganize = useCallback(() => {
pinnedPositions.current.clear()
setNodes((nds) => {
const chaserNode = nds.find((n) => n.id === CHASER_CRAB_ID)
if (chaserNode) {
return [...layoutedNodes, chaserNode]
}
return [...layoutedNodes]
})
}, [layoutedNodes, setNodes])
// Cleanup
useEffect(() => {
return () => {
@@ -603,11 +750,26 @@ function ActionGraphInner({
<Controls
className="bg-shell-900! border-shell-700! shadow-lg! [&>button]:bg-shell-800! [&>button]:border-shell-700! [&>button]:text-gray-300! [&>button:hover]:bg-shell-700! [&>button>svg]:fill-gray-300!"
/>
<div className="absolute top-2 right-2 z-10">
<button
onClick={handleReorganize}
title="Re-organize layout"
className="p-1.5 rounded bg-shell-800 border border-shell-700 text-gray-300 hover:bg-shell-700 shadow-lg cursor-pointer"
>
<LayoutGrid className="w-4 h-4" />
</button>
</div>
<MiniMap
nodeColor={(node) => {
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>
)
})
+208 -56
View File
@@ -1,9 +1,13 @@
import { useState } from "react";
import { useMemo, useState } from "react";
import { motion, AnimatePresence } from "framer-motion";
import { Users, ChevronLeft, ChevronRight, Github } from "lucide-react";
import { Users, ChevronLeft, ChevronRight, ChevronDown, Github } from "lucide-react";
import { StatusIndicator } from "./StatusIndicator";
import type { MonitorSession } from "~/integrations/clawdbot";
function isSubagent(session: MonitorSession): boolean {
return Boolean(session.spawnedBy) || session.platform === "subagent" || session.key.includes("subagent");
}
function XIcon({
size = 14,
className,
@@ -41,6 +45,54 @@ const platformEmoji: Record<string, string> = {
slack: "💼",
};
function SubagentItem({
session,
selected,
collapsed,
onSelect,
}: {
session: MonitorSession;
selected: boolean;
collapsed: boolean;
onSelect: (key: string) => void;
}) {
return (
<motion.button
initial={false}
animate={{ opacity: 1 }}
onClick={() => onSelect(session.key)}
className={`w-full text-left border-b border-shell-800/50 transition-all duration-150 group ${
collapsed ? "p-2" : "py-2 pr-3 pl-6"
} ${
selected
? "bg-neon-cyan/5 border-l-2 border-l-neon-cyan"
: "hover:bg-shell-800/30 border-l-2 border-l-transparent"
}`}
title={collapsed ? "subagent" : undefined}
>
{collapsed ? (
<div className="flex flex-col items-center gap-1">
<span className="text-sm">🤖</span>
<StatusIndicator status={session.status} size="sm" />
</div>
) : (
<>
<div className="font-display text-[9px] font-medium text-neon-cyan/60 uppercase tracking-widest mb-1">
subagent
</div>
<div className="flex items-center gap-2">
<span className="text-sm">🤖</span>
<span className="font-console text-[11px] text-shell-400 truncate flex-1 group-hover:text-shell-200">
{session.recipient}
</span>
<StatusIndicator status={session.status} size="sm" />
</div>
</>
)}
</motion.button>
);
}
export function SessionList({
sessions,
selectedKey,
@@ -50,10 +102,12 @@ export function SessionList({
}: SessionListProps) {
const [filter, setFilter] = useState("");
const [platformFilter, setPlatformFilter] = useState<string | null>(null);
const [collapsedGroups, setCollapsedGroups] = useState<Set<string>>(new Set());
const platforms = [...new Set(sessions.map((s) => s.platform))];
const parentSessions = sessions.filter((s) => !isSubagent(s));
const platforms = [...new Set(parentSessions.map((s) => s.platform))];
const filteredSessions = sessions.filter((session) => {
const filteredParents = parentSessions.filter((session) => {
const matchesText =
!filter ||
session.recipient.toLowerCase().includes(filter.toLowerCase()) ||
@@ -64,12 +118,45 @@ export function SessionList({
});
// Sort: active first, then by lastActivityAt
const sortedSessions = [...filteredSessions].sort((a, b) => {
const sortedParents = [...filteredParents].sort((a, b) => {
if (a.status !== "idle" && b.status === "idle") return -1;
if (a.status === "idle" && b.status !== "idle") return 1;
return b.lastActivityAt - a.lastActivityAt;
});
// Group subagents by parent key
const { subagentsByParent, orphanSubagents } = useMemo(() => {
const byParent = new Map<string, MonitorSession[]>();
const orphans: MonitorSession[] = [];
const parentKeys = new Set(parentSessions.map((s) => s.key));
for (const session of sessions) {
if (!isSubagent(session)) continue;
const matchesFilter =
!filter ||
session.agentId.toLowerCase().includes(filter.toLowerCase()) ||
"subagent".includes(filter.toLowerCase());
if (!matchesFilter) continue;
if (session.spawnedBy && parentKeys.has(session.spawnedBy)) {
const list = byParent.get(session.spawnedBy) ?? [];
list.push(session);
byParent.set(session.spawnedBy, list);
} else {
orphans.push(session);
}
}
// Sort subagents within each group by activity
for (const [key, list] of byParent) {
list.sort((a, b) => b.lastActivityAt - a.lastActivityAt);
byParent.set(key, list);
}
orphans.sort((a, b) => b.lastActivityAt - a.lastActivityAt);
return { subagentsByParent: byParent, orphanSubagents: orphans };
}, [sessions, parentSessions, filter]);
return (
<motion.div
className="flex flex-col h-full bg-shell-900 relative"
@@ -119,7 +206,7 @@ export function SessionList({
<div className="flex gap-1.5 mt-3 flex-wrap">
<button
onClick={() => setPlatformFilter(null)}
className={`px-2.5 py-1 text-[10px] font-display uppercase tracking-wide rounded border transition-all ${
className={`px-2.5 py-1 text-[11px] font-display uppercase tracking-wide rounded border transition-all ${
!platformFilter
? "bg-crab-600 border-crab-500 text-white box-glow-red"
: "bg-shell-800 border-shell-700 text-gray-400 hover:border-shell-600 hover:text-gray-300"
@@ -131,7 +218,7 @@ export function SessionList({
<button
key={p}
onClick={() => setPlatformFilter(p)}
className={`px-2.5 py-1 text-[10px] font-display uppercase tracking-wide rounded border transition-all ${
className={`px-2.5 py-1 text-[11px] font-display uppercase tracking-wide rounded border transition-all ${
platformFilter === p
? "bg-crab-600 border-crab-500 text-white box-glow-red"
: "bg-shell-800 border-shell-700 text-gray-400 hover:border-shell-600 hover:text-gray-300"
@@ -149,64 +236,129 @@ export function SessionList({
{/* Session list */}
<div className="relative flex-1 overflow-y-auto">
<AnimatePresence mode="popLayout">
{sortedSessions.map((session) => (
<motion.button
key={session.key}
layout
initial={{ opacity: 0, x: -20 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: -20 }}
onClick={() => onSelect(session.key)}
className={`w-full text-left p-3 border-b border-shell-800 transition-all duration-150 group ${
selectedKey === session.key
? "bg-crab-900/20 border-l-2 border-l-crab-500"
: "hover:bg-shell-800/50 border-l-2 border-l-transparent"
}`}
title={
collapsed
? `${session.recipient} (${session.platform})`
: undefined
}
>
{collapsed ? (
// Collapsed view: just icon and status
<div className="flex flex-col items-center gap-1">
<span className="text-lg">
{platformEmoji[session.platform] || "📱"}
</span>
<StatusIndicator status={session.status} size="sm" />
</div>
) : (
// Expanded view
<>
<div className="flex items-center gap-2 mb-1.5">
{sortedParents.map((session) => (
<div key={session.key}>
<motion.button
layout
initial={{ opacity: 0, x: -20 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: -20 }}
onClick={() => onSelect(session.key)}
className={`w-full text-left p-3 border-b border-shell-800 transition-all duration-150 group ${
selectedKey === session.key
? "bg-crab-900/20 border-l-2 border-l-crab-500"
: "hover:bg-shell-800/50 border-l-2 border-l-transparent"
}`}
title={
collapsed
? `${session.recipient} (${session.platform})`
: undefined
}
>
{collapsed ? (
<div className="flex flex-col items-center gap-1">
<span className="text-lg">
{platformEmoji[session.platform] || "📱"}
</span>
<span className="font-display text-xs font-medium text-gray-200 truncate flex-1 uppercase tracking-wide group-hover:text-white">
{session.recipient}
</span>
<StatusIndicator status={session.status} size="sm" />
</div>
<div className="flex items-center gap-2">
<span className="font-console text-[10px] text-shell-500 truncate flex-1">
{session.agentId}
</span>
{session.isGroup && (
<span className="flex items-center gap-1 px-1.5 py-0.5 bg-shell-800 border border-shell-700 rounded text-[11px] text-shell-400">
<Users size={10} />
group
) : (
<>
<div className="font-display text-[9px] font-medium text-shell-500 uppercase tracking-widest mb-1">
main
</div>
<div className="flex items-center gap-2 mb-1.5">
<span className="text-lg">
{platformEmoji[session.platform] || "📱"}
</span>
)}
</div>
</>
)}
</motion.button>
<span className="font-display text-xs font-medium text-gray-200 truncate flex-1 uppercase tracking-wide group-hover:text-white">
{session.recipient}
</span>
<StatusIndicator status={session.status} size="sm" />
</div>
<div className="flex items-center gap-2">
<span className="font-console text-[11px] text-shell-500 truncate flex-1">
{session.agentId}
</span>
{session.isGroup && (
<span className="flex items-center gap-1 px-1.5 py-0.5 bg-shell-800 border border-shell-700 rounded text-[11px] text-shell-400">
<Users size={10} />
group
</span>
)}
</div>
</>
)}
</motion.button>
{/* Nested subagents */}
{(() => {
const subs = subagentsByParent.get(session.key);
if (!subs?.length) return null;
const isGroupCollapsed = collapsedGroups.has(session.key);
return (
<>
<button
onClick={(e) => {
e.stopPropagation();
setCollapsedGroups((prev) => {
const next = new Set(prev);
if (next.has(session.key)) next.delete(session.key);
else next.add(session.key);
return next;
});
}}
className={`w-full border-b border-shell-800/50 transition-all ${
collapsed ? "p-2 justify-center" : "px-4 py-1.5 text-left"
} flex items-center gap-1.5 text-xs font-display uppercase tracking-widest text-shell-500 hover:text-shell-300 hover:bg-shell-800/30`}
>
<ChevronDown
size={14}
className={`transition-transform ${isGroupCollapsed ? "-rotate-90" : ""}`}
/>
{!collapsed && (
<span>{subs.length} subagent{subs.length > 1 ? "s" : ""}</span>
)}
</button>
<motion.div
initial={false}
animate={{
height: isGroupCollapsed ? 0 : "auto",
opacity: isGroupCollapsed ? 0 : 1,
}}
transition={{ duration: 0.15, ease: "easeInOut" }}
className="overflow-hidden"
>
{subs.map((sub) => (
<SubagentItem
key={sub.key}
session={sub}
selected={selectedKey === sub.key}
collapsed={collapsed}
onSelect={onSelect}
/>
))}
</motion.div>
</>
);
})()}
</div>
))}
{/* Orphan subagents */}
{orphanSubagents.map((sub) => (
<SubagentItem
key={sub.key}
session={sub}
selected={selectedKey === sub.key}
collapsed={collapsed}
onSelect={onSelect}
/>
))}
</AnimatePresence>
{sortedSessions.length === 0 && !collapsed && (
{sortedParents.length === 0 && orphanSubagents.length === 0 && !collapsed && (
<div className="p-6 text-center">
<div className="font-console text-xs text-shell-500">
<span className="text-crab-600">&gt;</span> no sessions found
+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>
)
})
+2 -1
View File
@@ -1,5 +1,6 @@
import { motion, AnimatePresence } from 'framer-motion'
import { Settings, X, History, Wifi, WifiOff, RefreshCw, Terminal, Download, Trash2, Database, HardDrive, Play, Square } from 'lucide-react'
import { version } from '../../../package.json'
interface SettingsPanelProps {
connected: boolean
@@ -322,7 +323,7 @@ export function SettingsPanel({
<div className="flex items-center justify-center gap-2 pt-4">
<span className="w-2 h-2 rounded-full bg-neon-mint animate-pulse" />
<span className="font-console text-[10px] text-shell-500">
crabwalk v1.0.1
crabwalk v{version}
</span>
</div>
</div>
+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'
+255 -2
View File
@@ -1,5 +1,13 @@
import { createCollection, localOnlyCollectionOptions } from '@tanstack/db'
import { parseSessionKey, type MonitorSession, type 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>()
@@ -60,6 +68,100 @@ 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
@@ -100,7 +202,11 @@ 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)) {
@@ -191,6 +297,98 @@ 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,
@@ -246,12 +444,61 @@ export function clearCollections() {
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[]
actions: MonitorAction[],
execEvents: MonitorExecEvent[] = []
) {
// First clear existing data
clearCollections()
@@ -286,4 +533,10 @@ export function hydrateFromServer(
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)
}
}
+99 -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'
@@ -148,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
@@ -189,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
}
+63 -2
View File
@@ -1,12 +1,14 @@
import fs from 'fs'
import path from 'path'
import type { MonitorSession, MonitorAction } from './protocol'
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
@@ -16,6 +18,7 @@ interface PersistenceState {
class PersistenceService {
private sessions: Map<string, MonitorSession> = new Map()
private actions: MonitorAction[] = []
private execEvents: MonitorExecEvent[] = []
private enabled = false
private startedAt: number | null = null
@@ -90,6 +93,28 @@ class PersistenceService {
} 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() {
@@ -102,10 +127,19 @@ class PersistenceService {
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
}
@@ -131,12 +165,14 @@ class PersistenceService {
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,
}
}
@@ -168,19 +204,44 @@ class PersistenceService {
}
}
hydrate(): { sessions: MonitorSession[]; actions: MonitorAction[] } {
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
}
+73
View File
@@ -85,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
@@ -137,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
+10 -1
View File
@@ -9,6 +9,7 @@ import {
sessionInfoToMonitor,
type MonitorSession,
type MonitorAction,
type MonitorExecEvent,
} from '~/integrations/clawdbot'
// Server-side debug mode state
@@ -137,9 +138,10 @@ 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()
@@ -163,6 +165,9 @@ 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 })
}
@@ -171,6 +176,10 @@ const clawdbotRouter = router({
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 })
}
}
})
+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) {
+2 -1
View File
@@ -2,6 +2,7 @@ import { useState, useCallback, useEffect } from 'react'
import { createFileRoute, Link } from '@tanstack/react-router'
import { motion } from 'framer-motion'
import { Github } from 'lucide-react'
import { version } from '../../package.json'
import { CrabIdleAnimation, CrabJumpAnimation, CrabAttackAnimation } from '~/components/ani'
function XIcon({ size = 14, className }: { size?: number; className?: string }) {
@@ -165,7 +166,7 @@ function Home() {
>
<span className="w-2 h-2 rounded-full bg-neon-mint animate-pulse" />
<span className="font-console font-bold text-[11px] uppercase text-shell-500">
system online v1.0.1
system online v{version}
</span>
</motion.div>
+44 -5
View File
@@ -1,17 +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, HardDrive } 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,
@@ -83,9 +86,22 @@ function MonitorPage() {
// 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 and persistence on mount
@@ -143,10 +159,12 @@ function MonitorPage() {
const hydrateFromPersistence = async () => {
try {
const status = await trpc.clawdbot.persistenceStatus.query()
if (status.sessionCount > 0 || status.actionCount > 0) {
if (status.sessionCount > 0 || status.actionCount > 0 || status.execEventCount > 0) {
const data = await trpc.clawdbot.persistenceHydrate.query()
hydrateFromServer(data.sessions, data.actions)
console.log(`[monitor] hydrated ${data.sessions.length} sessions, ${data.actions.length} actions`)
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)
@@ -331,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)
@@ -383,6 +404,23 @@ 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)}
@@ -458,6 +496,7 @@ function MonitorPage() {
<ActionGraph
sessions={sessions}
actions={actions}
execs={execs}
selectedSession={selectedSession}
onSessionSelect={setSelectedSession}
/>
+7
View File
@@ -4,7 +4,14 @@ import viteReact from '@vitejs/plugin-react'
import viteTsConfigPaths from 'vite-tsconfig-paths'
import tailwindcss from '@tailwindcss/vite'
const allowedHosts = process.env.ALLOWED_HOSTS
? process.env.ALLOWED_HOSTS.split(',').map(host => host.trim()).filter(Boolean)
: undefined
export default defineConfig({
server: {
allowedHosts,
},
plugins: [
viteTsConfigPaths({
projects: ['./tsconfig.json'],