Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,4 @@ legacy
.netlify
deno.lock
version.json
data
60 changes: 60 additions & 0 deletions adapters/claude-code/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
# Claude Code agent-event hook

This adapter forwards Claude Code `SessionStart`, `Notification`, and `Stop` hooks into the Lunar Pup agent harness endpoint.

## Environment

Set the same shared secret on the game server and the hook process:

```sh
export AGENT_EVENT_TOKEN="replace-with-a-long-random-token"
export AGENT_EVENT_ENDPOINT="http://localhost:3001/agent/event"
```

`AGENT_EVENT_ENDPOINT` defaults to `http://localhost:3001/agent/event` when omitted.

## Claude Code hooks

Add this to your Claude Code `settings.json` hooks block. Use an absolute path if Claude Code runs outside this repository.

```json
{
"hooks": {
"SessionStart": [
{
"matcher": "*",
"hooks": [
{
"type": "command",
"command": "bun adapters/claude-code/agent-event-hook.ts"
}
]
}
],
"Notification": [
{
"matcher": "*",
"hooks": [
{
"type": "command",
"command": "bun adapters/claude-code/agent-event-hook.ts"
}
]
}
],
"Stop": [
{
"matcher": "*",
"hooks": [
{
"type": "command",
"command": "bun adapters/claude-code/agent-event-hook.ts"
}
]
}
]
}
}
```

The hook reads Claude Code's JSON hook payload from stdin, converts it to the shared `AgentEvent` contract, and POSTs it with `Authorization: Bearer $AGENT_EVENT_TOKEN`.
91 changes: 91 additions & 0 deletions adapters/claude-code/agent-event-hook.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
#!/usr/bin/env bun
import { validateAgentEvent, type AgentEvent, type AgentEventType } from '../../src/contracts/agentEvents.ts';

interface ClaudeHookPayload {
hook_event_name?: string;
session_id?: string;
cwd?: string;
transcript_path?: string;
message?: string;
stop_hook_active?: boolean;
}

const eventTypeByHook = new Map<string, AgentEventType>([
['SessionStart', 'agent_session_start'],
['Notification', 'agent_needs_input'],
['Stop', 'agent_done'],
]);

async function readHookPayload(): Promise<ClaudeHookPayload> {
const input = await new Response(Bun.stdin.stream()).text();
if (!input.trim()) return {};
const value = JSON.parse(input) as unknown;
if (!value || typeof value !== 'object') return {};

const payload: ClaudeHookPayload = {};
if ('hook_event_name' in value && typeof value.hook_event_name === 'string') payload.hook_event_name = value.hook_event_name;
if ('session_id' in value && typeof value.session_id === 'string') payload.session_id = value.session_id;
if ('cwd' in value && typeof value.cwd === 'string') payload.cwd = value.cwd;
if ('transcript_path' in value && typeof value.transcript_path === 'string') payload.transcript_path = value.transcript_path;
if ('message' in value && typeof value.message === 'string') payload.message = value.message;
if ('stop_hook_active' in value && typeof value.stop_hook_active === 'boolean') payload.stop_hook_active = value.stop_hook_active;
return payload;
}

function eventTypeFor(payload: ClaudeHookPayload): AgentEventType {
const hookEvent = payload.hook_event_name ?? '';
return eventTypeByHook.get(hookEvent) ?? 'agent_status';
}

function projectName(cwd: string | undefined): string {
if (!cwd) return 'unknown-project';
const parts = cwd.split(/[\\/]+/).filter(Boolean);
return parts.at(-1) ?? cwd;
}

function messageFor(payload: ClaudeHookPayload, type: AgentEventType): string {
if (payload.message) return payload.message;
if (type === 'agent_session_start') return 'Claude Code session started';
if (type === 'agent_needs_input') return 'Claude Code needs input';
if (type === 'agent_done') return 'Claude Code session stopped';
return 'Claude Code status update';
}

async function main(): Promise<void> {
const endpoint = process.env.AGENT_EVENT_ENDPOINT ?? 'http://localhost:3001/agent/event';
const token = process.env.AGENT_EVENT_TOKEN;
if (!token) throw new Error('AGENT_EVENT_TOKEN is required');

const payload = await readHookPayload();
const type = eventTypeFor(payload);
const event: AgentEvent = {
type,
harness: 'claude-code',
sessionId: payload.session_id ?? crypto.randomUUID(),
project: projectName(payload.cwd),
message: messageFor(payload, type),
timestamp: new Date().toISOString(),
};

const valid = validateAgentEvent(event);
if (!valid.ok) throw new Error(valid.error);

const response = await fetch(endpoint, {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify(valid.value),
});

if (!response.ok) {
const text = await response.text();
throw new Error(`agent event POST failed: ${response.status} ${text}`);
}
}

main().catch(error => {
console.error(error instanceof Error ? error.message : String(error));
process.exit(1);
});
107 changes: 107 additions & 0 deletions docs/architecture.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
# Lunar Pup foundation contracts

This repo keeps feature seams in plain TypeScript modules so parallel work can add files without editing the game loop or the server entry point.

## Agent event protocol

`src/contracts/agentEvents.ts` defines harness-to-server-to-client status messages:

- `agent_session_start`
- `agent_status`
- `agent_needs_input`
- `agent_done`

Every event carries `harness`, `sessionId`, `project`, `message`, and ISO-compatible `timestamp`. `validateAgentEvent` is the runtime gate for untrusted JSON.

## Package manifests

`src/contracts/packageManifest.ts` defines shareable mod-like content manifests. A manifest has:

- `kind`: `cosmetic` or `gamemode`
- `version`
- `author`
- `displayName`
- `assetRefs`: named URI/media-type references with their own SHA-256
- `id`: SHA-256 of the canonical JSON for the manifest without `id`

`canonicalManifestJson` sorts object keys recursively. `packageManifestId` hashes that canonical JSON. `validatePackageManifest` rejects manifests whose `id` does not match the content.

## Cosmetics

`src/contracts/cosmetic.ts` defines cosmetic packages only; there is no shop or inventory UI here.

- `slot`: `board`, `body`, `trail`, or `aura`
- `rarity`: `common`, `rare`, `epic`, or `legendary`
- `visual.colors`: `#RRGGBB` or `#RRGGBBAA`
- optional mesh parameters: `shape`, `scale`, `roughness`, `metalness`
- optional particle parameters: `count`, `size`, `lifetime`, `emissionRate`

`validateCosmeticDefinition` is the runtime validator.

## Gamemodes

`src/contracts/gamemode.ts` defines the gamemode interface, not concrete game content. A gamemode provides:

- `id`
- lifecycle: `init`, `start`, `tick`, `end`
- player hooks: `onPlayerJoin`, `onPlayerLeave`
- scoring: `score`
- win condition: `isWinConditionMet`
- checkpoint definitions with position, radius, and optional order

`src/game/loop.ts` exposes `setCurrentGamemode(gamemode, state)`. When set, the loop increments `state.elapsedMs` and calls `gamemode.tick(dt, state)` each frame.

## Room/lobby protocol

`src/contracts/roomProtocol.ts` defines lobby messages:

- client: `create_room`, `join_room`, `leave_room`, `list_rooms`
- server: `room_state`, `room_list`

Room state includes `roomId`, `gamemodeId`, and player IDs. This is only the protocol contract; no lobby feature is built in this unit.

## Currency, inventory, and ledger storage

`src/contracts/services.ts` defines storage interfaces that can be implemented by SQLite now and Postgres/Timescale later.

`CurrencyInventoryService`:

- `getBalance(accountId)`
- `grant(accountId, amount, reason)`
- `spend(accountId, amount, reason)`; throws `InsufficientFundsError` when balance is too low
- `listOwnedItems(accountId)`
- `grantOwnedItem(accountId, cosmeticId, reason)`

`EventLedgerStorage`:

- `append({ type, entityId, timestamp, payload })`
- `query({ type, entityId, from, to })`

The default backend is Bun SQLite at `data/lunarpup.db`. The `data` directory is gitignored. The ledger is append-only at the interface level: callers append typed events and query by time range/type/entity.

## Server routing

`src/server.ts` now owns only process wiring:

- create a `ModularRouter`
- register server modules
- start `Bun.serve`
- pass WebSocket messages to the router

`src/server/router.ts` supports:

- HTTP routes registered by method/path
- WebSocket handlers registered by channel
- a default `multiplayer` channel when a legacy message has no `channel` field

`src/server/multiplayer.ts` registers the existing join/state/leave flow on the `multiplayer` channel. Existing `src/net/client.ts` messages are unchanged, so current multiplayer clients continue sending `{ type: 'join' }` and `{ type: 'state' }` without a channel field.

## Game loop extension hooks

`src/game/loop.ts` exposes:

- `registerUpdateHook(fn)` returns an unregister function and calls `fn(dt, state)` once per frame
- `setCurrentGamemode(gamemode, state)` attaches or clears a gamemode tick target
- `getCurrentGamemode()` reports the currently attached gamemode

Hooks receive `playerGroup`, `physics`, `scene`, and `skateboard`. This lets cosmetics and gamemodes attach behavior without editing `loop.ts` again.
31 changes: 31 additions & 0 deletions src/contracts/agentEvents.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { fail, isRecord, ok, readEnum, readString, type ValidationResult } from './validators.ts';

export const agentEventTypes = ['agent_session_start', 'agent_status', 'agent_needs_input', 'agent_done'] as const;
export type AgentEventType = (typeof agentEventTypes)[number];

export interface AgentEvent {
type: AgentEventType;
harness: string;
sessionId: string;
project: string;
message: string;
timestamp: string;
}

export function validateAgentEvent(value: unknown): ValidationResult<AgentEvent> {
if (!isRecord(value)) return fail('agent event must be an object');
const type = readEnum(value, 'type', agentEventTypes);
if (!type.ok) return type;
const harness = readString(value, 'harness');
if (!harness.ok) return harness;
const sessionId = readString(value, 'sessionId');
if (!sessionId.ok) return sessionId;
const project = readString(value, 'project');
if (!project.ok) return project;
const message = readString(value, 'message');
if (!message.ok) return message;
const timestamp = readString(value, 'timestamp');
if (!timestamp.ok) return timestamp;
if (Number.isNaN(Date.parse(timestamp.value))) return fail('timestamp must be an ISO-compatible date string');
return ok({ type: type.value, harness: harness.value, sessionId: sessionId.value, project: project.value, message: message.value, timestamp: timestamp.value });
}
Loading