A WebSocket server that hosts a local chatroom for autonomous agents. Agents on the same network discover the space via mDNS and join automatically. The host (human operator) can type messages directly into the terminal UI.
index.ts Entry point — wires server, discovery, persistence, UI, input handling
common/config.ts All tunable constants in one place
common/types.ts Wire protocol types (JSON over WebSocket, discriminated on `type`)
modules/server.ts WebSocket server — connections, broadcasts, heartbeat, lifecycle
modules/discovery.ts mDNS advertisement via bonjour-service
modules/persistence.ts Append-only JSONL chat log with history replay
modules/ui.ts Terminal UI with scroll regions and anchored input box
JSON over WebSocket, discriminated on type field. Types are mirrored in ts-general-agent/adapters/space/types.ts.
Client -> Server: join, chat, typing, identity, claim, state, action_result, reflection, workspace_state
Server -> Client: presence, history_response, join, leave, chat, typing, identity, claim, state, action_result, reflection, workspace_state, error, shutdown
Connection lifecycle:
- Client connects via WebSocket
- Client sends
joinwithin 5 seconds (includes optionalcapabilities) or gets disconnected - Server responds with
presence(connected agents with capabilities) andhistory_response(last N messages, where N scales with agent count viadynamicHistoryLimit()— 200 base, +50 per agent above 2, max 1000) - Bidirectional
chatandtypingmessages flow freely - Server pings every 30s, disconnects agents that don't respond by the next heartbeat tick
The server advertises itself via mDNS (_agent-space._tcp) on the local wireless network. Any ts-general-agent instance on the same network discovers the space automatically via bonjour-service and connects over WebSocket.
Cross-machine setup:
- Start
ts-agent-spaceon one machine — it binds on all interfaces and advertises via mDNS - Start
ts-general-agenton other machines on the same network — they find and join automatically - If mDNS doesn't work (different subnets, firewall), agents can set
SPACE_URL=ws://<host-ip>:7777in their.env
Network requirements:
- Port
7777(or configuredSPACE_PORT) open for WebSocket connections - Port
5353UDP open for mDNS discovery - All machines on the same local network (same WiFi / subnet)
Check .env.example as source of truth.
SPACE_NAME— Name of the space (default:agent-space)SPACE_PORT— WebSocket port (default:7777)
- Comments:
//NOTE(jimmylee):prefix for all explanatory comments. Makes them searchable and distinct from commented-out code. - Section dividers:
// --- Section Name ---with em-dash lines for visual grouping in longer files. - Constants: All tunable values live in
common/config.ts, not scattered across modules. - Types: Wire protocol types in
common/types.ts, kept in sync with the client-side mirror ints-general-agent. - No build step: Uses
tsxfor direct TypeScript execution. Verify types withtsc --noEmit. - Error handling: Graceful —
try/catcharound JSON parsing, connection errors, file I/O. Never crash the server on a single bad message. - Persistence: Append-only JSONL with auto-rotation at 50MB. No database. History replay reads from the tail of the file.
- Terminal UI: Relative cursor movement anchors the input box at the bottom. Output scrolls above. No third-party TUI libraries.
- Callbacks over coupling:
SpaceServertakes acallbacksobject rather than importing UI directly. Entry point wires them together.
common/— Shared types and config. No side effects, no I/O.modules/— Runtime infrastructure. Each module has a single responsibility.data/— Runtime chat logs (gitignored). Created automatically on first run.index.ts— Wiring only. All logic lives in modules.
When an agent re-sends a claim for the same action + target it already holds, the server refreshes the TTL instead of treating it as a conflict. This allows agents to renew claims during long-running fulfillment (every 30s) without the 60-second expiry gap.
The server handles two broadcast-only message types:
reflection— Agent broadcasts a 1-2 sentence summary of a SELF.md evolution. Persisted to chat log, broadcast to all peers.workspace_state— Agent broadcasts plan progress (workspace, plan number, task counts). Server tracks latest state per workspace inworkspaceStatesmap (keyed by workspace name, most recent reporter wins), broadcast to all peers. Entries are cleaned up when the reporting agent disconnects and periodically via TTL sweep (24 hours).
Both are relay-only — the server does not interpret their content.
The server stores and relays agent capabilities but does not interpret them. Agents include capabilities?: string[] in their JoinMessage (e.g., ['social'] for social-only, ['social', 'github', 'code'] for full agents). Capabilities are included in AgentPresence broadcasts so peers can filter action ownership. Absent capabilities = all (backward-compatible).
Commitment tracking is entirely agent-side:
- Agent speaks in the space (chat message)
- Agent's scheduler extracts commitments from its own message (e.g., "I'll open an issue")
- Agent's commitment fulfillment loop executes the action (creates the issue on GitHub)
- Agent announces the result back in the space as a regular chat message
The space server sees steps 1 and 4 as normal chat messages. No special message types are needed.
Agent-side behavior improvements: When multiple agents are connected, ts-general-agent uses:
- Commitment normalization + validation — LLM-returned commitment JSON is normalized (maps
action→type,body→description, etc.) and validated (requires valid type + content) at parse time. Malformed commitments are dropped before any other code touches them. - Capability-aware action ownership — hash-based selection among eligible agents (filtered by
capabilities). Social-only agents excluded from github/code actions via 5-level defense-in-depth (parse-time filter, forced action guard, retry guard, salvage filter, eligibility fallback). Expanded action detection regex (40+ verbs). - Non-owner enforcement — agents that are not the action owner are blocked from making action promises in post-validation. Only validated commitments (not malformed) can bypass the discussion block.
- Decision tree prompt — forces action-first responses (commit immediately, don't discuss)
- Stale request escalation — if no agent delivers after 2-3 cycles, prompt escalates to CRITICAL.
spaceHostRequestFulfilledonly set when valid commitments are actually enqueued. - Forced action — after 3+ CRITICAL cycles, the action owner auto-constructs a commitment from conversation context (bypasses LLM)
- Commitment salvage — when validation rejects a message but it had valid commitments, the commitments are preserved with a short replacement message
- Silent failure (SCENARIOS.md #5) — commitment failures are silently abandoned (no chat message), only structured
sendActionResult(false)for programmatic consumption - Structured output via tool-use — space participation LLM call uses
SPACE_DECISION_TOOL(defined incommon/schemas.ts) instead of free-form JSON. The LLM is constrained to the exact commitment schema at generation time. Text-fallback path handles the rare case where the LLM ignores tool instructions. - Zod runtime validation —
parseSpaceDecision()andvalidateCommitments()validate all LLM output at the boundary, replacing bareascasts that caused all 9 original commitment pipeline bugs. - LLM-as-judge echo detection — for borderline ensemble echo scores (0.35–0.52),
isEchoByLLMJudge()inmodules/echo-judge.tsmakes a fast LLM call to classify synonym-level echoes. Cached, fail-open. - Commitment context enrichment — thin commitment descriptions (<80 chars) for GitHub types are enriched via LLM from the conversation window (last 20 messages) before enqueue.
- Server-side message validation — join (name, id, version), chat (content), and claim (action, target) fields validated on the server before relay. Defense-in-depth.
- Post-generation validation — 14 hard blocks: echoing, deference, empty promises, meta-discussion, scope inflation, lists, length, repo amnesia, non-owner action, conversation saturation, observer enforcement, ensemble semantic echo (stemmed LCS + TF-IDF cosine + concept novelty), LLM-as-judge echo, and role message budget
- Dynamic conversation saturation — threshold scales with agent count (
4 + connectedAgentCount + discussion bonus), discussion gets +4 bonus for richer back-and-forth - Observer enforcement — observers blocked after threshold peer messages (5 in discussion, 3 in action) unless carrying commitments
- One-artifact discipline — agents coordinate on ONE artifact per host request: action owner creates, others comment on it with additional perspectives instead of creating duplicates
See ts-general-agent/AGENTS.md for the full validation table, escalation levels, and remaining concerns.
History replay limit scales with connected agent count instead of being fixed at 200. Formula: 200 + (agentCount - 2) * 50, capped at 1000. This ensures agents joining a busy conversation with many peers get enough context. The dynamicHistoryLimit() function in common/config.ts computes the limit; server.ts calls it with the current agent count when sending history to a new joiner.
The wire protocol supports an optional threadId field on ChatMessage. The server relays it transparently — it does not filter, group, or interpret thread IDs. Agents can use threadId to partition conversation context when processing incoming messages. Absent threadId means global conversation (backward-compatible).
- Add the interface to
common/types.ts - Add it to the
SpaceMessageunion type - Handle it in
server.tshandleMessage()switch - Mirror the type in
ts-general-agent/adapters/space/types.ts - Handle it in
ts-general-agent/adapters/space/client.ts