promptparty 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,8 @@
1
+ __pycache__/
2
+ *.pyc
3
+ .venv/
4
+ dist/
5
+ build/
6
+ *.egg-info/
7
+ .idea/
8
+ .vscode/
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 k.moudra@seznam.cz
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,136 @@
1
+ Metadata-Version: 2.4
2
+ Name: promptparty
3
+ Version: 0.1.0
4
+ Summary: Cadence sync for teams agent-coding in the same room β€” one dashboard that says when to PROMPT and when to TALK.
5
+ Project-URL: Homepage, https://github.com/moudrkat/promptparty
6
+ Project-URL: Issues, https://github.com/moudrkat/promptparty/issues
7
+ Author-email: k.moudra@seznam.cz
8
+ License: MIT
9
+ License-File: LICENSE
10
+ Keywords: agentic-coding,claude,claude-code,collaboration,hackathon,hooks,llm,pair-programming
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Environment :: Web Environment
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: License :: OSI Approved :: MIT License
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Topic :: Communications :: Chat
17
+ Requires-Python: >=3.10
18
+ Requires-Dist: fastapi>=0.110
19
+ Requires-Dist: uvicorn[standard]>=0.29
20
+ Description-Content-Type: text/markdown
21
+
22
+ # promptparty πŸŽ‰
23
+
24
+ ![promptparty demo β€” the dashboard flipping between TALK and PROMPT](https://raw.githubusercontent.com/moudrkat/promptparty/main/docs/demo.gif)
25
+
26
+ **Cadence sync for teams agent-coding in the same room.** One shared dashboard
27
+ that tells everyone when it's time to **⌨️ PROMPT** and when it's time to
28
+ **πŸ—£οΈ TALK** β€” because the worst part of group agentic coding is everyone
29
+ heads-down at different moments, waiting on each other, and the conversation
30
+ dying.
31
+
32
+ The rhythm promptparty gives you:
33
+
34
+ 1. **⌨️ PROMPT** β€” someone's agent finished (or needs a permission). Read the
35
+ output, fire the next prompt.
36
+ 2. **πŸ—£οΈ TALK** β€” *all* agents are generating. Hands off the keyboard: discuss
37
+ what's coming, plan the next move, actually talk to each other.
38
+ 3. Agents finish β†’ the dashboard flips back to PROMPT (with an optional chime).
39
+
40
+ Everyone keeps their **own terminal, own Claude Code session, own model** β€”
41
+ promptparty never calls a model and needs **no API key**. It only watches the
42
+ states.
43
+
44
+ ## Quickstart (60 seconds)
45
+
46
+ **One person** starts the server and opens a room (put it on a big screen or a
47
+ spare browser tab):
48
+
49
+ ```bash
50
+ uvx promptparty # or: pipx install promptparty && promptparty
51
+ ```
52
+
53
+ **Each teammate** runs one command inside the project they're coding in:
54
+
55
+ ```bash
56
+ uvx promptparty hooks --server http://192.168.1.20:8765 --room a1b2c3 --name Katka
57
+ ```
58
+
59
+ (The dashboard's βš™οΈ panel shows this exact command with your room pre-filled.)
60
+ That installs [Claude Code hooks](https://code.claude.com/docs) into
61
+ `./.claude/settings.json` that report automatically:
62
+
63
+ | you do | dashboard shows |
64
+ |---|---|
65
+ | submit a prompt (`UserPromptSubmit`) | πŸ€– agent running β€” *free to talk* |
66
+ | agent finishes its turn (`Stop`) | βœ… agent done β€” *read & prompt!* |
67
+ | agent asks for permission (`Notification`) | πŸ™‹ needs a decision |
68
+
69
+ Restart your Claude Code session (or reload hooks) and you're live.
70
+
71
+ ### Not using Claude Code?
72
+
73
+ Any tool can report with one request β€” wire it into whatever hooks your tool
74
+ has, or an alias:
75
+
76
+ ```bash
77
+ curl -X POST http://SERVER:8765/api/ROOM/event \
78
+ -H 'content-type: application/json' \
79
+ -d '{"name":"Katka","state":"generating"}'
80
+ # states: prompting | generating | ready | blocked | away
81
+ ```
82
+
83
+ There are also manual buttons at the bottom of the dashboard.
84
+
85
+ ## What you get
86
+
87
+ - 🚦 A giant phase banner: **TALK** (all agents cooking) / **PROMPT**
88
+ (with *who* the room is waiting on).
89
+ - πŸƒ A card per person: state, model, how long they've been in it. Blocked
90
+ agents pulse red so permission prompts stop rotting unnoticed.
91
+ - πŸ”” Optional chime on phase flips β€” you don't even need the screen in view.
92
+ - πŸ“Š Session stats: talk time vs prompt time and total prompts fired.
93
+ (Great post-hackathon bragging material.)
94
+ - 🧹 Zero config, zero database, zero API keys. Rooms live in memory.
95
+
96
+ ## How it works
97
+
98
+ A single FastAPI process keeps per-room state and pushes it to dashboards over
99
+ WebSockets. Hook reports are plain HTTP POSTs. The phase rule is one line:
100
+
101
+ > if every non-away member's agent is generating β†’ **TALK**, otherwise β†’ **PROMPT**.
102
+
103
+ That's the whole trick. It works because agent turns are long enough (tens of
104
+ seconds to minutes) that "all agents running" is a genuine conversational
105
+ window.
106
+
107
+ ## Options
108
+
109
+ ```
110
+ promptparty [serve] [--port 8765] [--host 0.0.0.0]
111
+ promptparty hooks --server URL --room SLUG --name YOU [--settings PATH]
112
+ promptparty hooks --remove [--settings PATH]
113
+ ```
114
+
115
+ Playing from different networks? Put the server behind any tunnel
116
+ (`ngrok http 8765`, `cloudflared tunnel --url http://localhost:8765`).
117
+
118
+ ## Roadmap
119
+
120
+ - [ ] `PreCompact` / `SubagentStop` hook nuances (long multi-agent turns)
121
+ - [ ] Per-room history graph (talk/prompt timeline you can screenshot)
122
+ - [ ] Team pomodoro mode: enforced talk windows
123
+ - [ ] Adapters for aider / codex / opencode hooks out of the box
124
+
125
+ PRs welcome β€” this was born at a hackathon and stays intentionally tiny: one
126
+ Python file of server, one HTML file of dashboard, no build step.
127
+
128
+ ## Security notes
129
+
130
+ - Anyone with the room URL can view and post states. It's presence data, not
131
+ code β€” but run it on a network you trust.
132
+ - Rooms are in-memory and vanish on restart.
133
+
134
+ ## License
135
+
136
+ MIT Β© [moudrkat](https://github.com/moudrkat)
@@ -0,0 +1,115 @@
1
+ # promptparty πŸŽ‰
2
+
3
+ ![promptparty demo β€” the dashboard flipping between TALK and PROMPT](https://raw.githubusercontent.com/moudrkat/promptparty/main/docs/demo.gif)
4
+
5
+ **Cadence sync for teams agent-coding in the same room.** One shared dashboard
6
+ that tells everyone when it's time to **⌨️ PROMPT** and when it's time to
7
+ **πŸ—£οΈ TALK** β€” because the worst part of group agentic coding is everyone
8
+ heads-down at different moments, waiting on each other, and the conversation
9
+ dying.
10
+
11
+ The rhythm promptparty gives you:
12
+
13
+ 1. **⌨️ PROMPT** β€” someone's agent finished (or needs a permission). Read the
14
+ output, fire the next prompt.
15
+ 2. **πŸ—£οΈ TALK** β€” *all* agents are generating. Hands off the keyboard: discuss
16
+ what's coming, plan the next move, actually talk to each other.
17
+ 3. Agents finish β†’ the dashboard flips back to PROMPT (with an optional chime).
18
+
19
+ Everyone keeps their **own terminal, own Claude Code session, own model** β€”
20
+ promptparty never calls a model and needs **no API key**. It only watches the
21
+ states.
22
+
23
+ ## Quickstart (60 seconds)
24
+
25
+ **One person** starts the server and opens a room (put it on a big screen or a
26
+ spare browser tab):
27
+
28
+ ```bash
29
+ uvx promptparty # or: pipx install promptparty && promptparty
30
+ ```
31
+
32
+ **Each teammate** runs one command inside the project they're coding in:
33
+
34
+ ```bash
35
+ uvx promptparty hooks --server http://192.168.1.20:8765 --room a1b2c3 --name Katka
36
+ ```
37
+
38
+ (The dashboard's βš™οΈ panel shows this exact command with your room pre-filled.)
39
+ That installs [Claude Code hooks](https://code.claude.com/docs) into
40
+ `./.claude/settings.json` that report automatically:
41
+
42
+ | you do | dashboard shows |
43
+ |---|---|
44
+ | submit a prompt (`UserPromptSubmit`) | πŸ€– agent running β€” *free to talk* |
45
+ | agent finishes its turn (`Stop`) | βœ… agent done β€” *read & prompt!* |
46
+ | agent asks for permission (`Notification`) | πŸ™‹ needs a decision |
47
+
48
+ Restart your Claude Code session (or reload hooks) and you're live.
49
+
50
+ ### Not using Claude Code?
51
+
52
+ Any tool can report with one request β€” wire it into whatever hooks your tool
53
+ has, or an alias:
54
+
55
+ ```bash
56
+ curl -X POST http://SERVER:8765/api/ROOM/event \
57
+ -H 'content-type: application/json' \
58
+ -d '{"name":"Katka","state":"generating"}'
59
+ # states: prompting | generating | ready | blocked | away
60
+ ```
61
+
62
+ There are also manual buttons at the bottom of the dashboard.
63
+
64
+ ## What you get
65
+
66
+ - 🚦 A giant phase banner: **TALK** (all agents cooking) / **PROMPT**
67
+ (with *who* the room is waiting on).
68
+ - πŸƒ A card per person: state, model, how long they've been in it. Blocked
69
+ agents pulse red so permission prompts stop rotting unnoticed.
70
+ - πŸ”” Optional chime on phase flips β€” you don't even need the screen in view.
71
+ - πŸ“Š Session stats: talk time vs prompt time and total prompts fired.
72
+ (Great post-hackathon bragging material.)
73
+ - 🧹 Zero config, zero database, zero API keys. Rooms live in memory.
74
+
75
+ ## How it works
76
+
77
+ A single FastAPI process keeps per-room state and pushes it to dashboards over
78
+ WebSockets. Hook reports are plain HTTP POSTs. The phase rule is one line:
79
+
80
+ > if every non-away member's agent is generating β†’ **TALK**, otherwise β†’ **PROMPT**.
81
+
82
+ That's the whole trick. It works because agent turns are long enough (tens of
83
+ seconds to minutes) that "all agents running" is a genuine conversational
84
+ window.
85
+
86
+ ## Options
87
+
88
+ ```
89
+ promptparty [serve] [--port 8765] [--host 0.0.0.0]
90
+ promptparty hooks --server URL --room SLUG --name YOU [--settings PATH]
91
+ promptparty hooks --remove [--settings PATH]
92
+ ```
93
+
94
+ Playing from different networks? Put the server behind any tunnel
95
+ (`ngrok http 8765`, `cloudflared tunnel --url http://localhost:8765`).
96
+
97
+ ## Roadmap
98
+
99
+ - [ ] `PreCompact` / `SubagentStop` hook nuances (long multi-agent turns)
100
+ - [ ] Per-room history graph (talk/prompt timeline you can screenshot)
101
+ - [ ] Team pomodoro mode: enforced talk windows
102
+ - [ ] Adapters for aider / codex / opencode hooks out of the box
103
+
104
+ PRs welcome β€” this was born at a hackathon and stays intentionally tiny: one
105
+ Python file of server, one HTML file of dashboard, no build step.
106
+
107
+ ## Security notes
108
+
109
+ - Anyone with the room URL can view and post states. It's presence data, not
110
+ code β€” but run it on a network you trust.
111
+ - Rooms are in-memory and vanish on restart.
112
+
113
+ ## License
114
+
115
+ MIT Β© [moudrkat](https://github.com/moudrkat)
Binary file
@@ -0,0 +1,3 @@
1
+ """promptparty β€” multiplayer prompting for Claude."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,151 @@
1
+ """promptparty CLI.
2
+
3
+ promptparty start the server (default)
4
+ promptparty hooks --server URL --room SLUG --name YOU
5
+ wire Claude Code hooks in ./.claude/settings.json
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import argparse
11
+ import json
12
+ import os
13
+ import socket
14
+ import sys
15
+ from pathlib import Path
16
+
17
+ # Claude Code hook event -> promptparty state
18
+ HOOK_EVENTS = {
19
+ "UserPromptSubmit": "generating", # you hit enter -> agent starts working
20
+ "Stop": "ready", # agent finished its turn -> read & prompt
21
+ "Notification": "blocked", # agent waits on a permission / question
22
+ "SessionStart": "ready",
23
+ "SessionEnd": "away",
24
+ }
25
+ MARKER = "promptparty-hook"
26
+
27
+
28
+ def _lan_ip() -> str:
29
+ s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
30
+ try:
31
+ s.connect(("10.255.255.255", 1))
32
+ return s.getsockname()[0]
33
+ except Exception:
34
+ return "127.0.0.1"
35
+ finally:
36
+ s.close()
37
+
38
+
39
+ def cmd_serve(args: argparse.Namespace) -> None:
40
+ ip = _lan_ip()
41
+ print("\n promptparty is up β€” open a room and put it on a shared screen:\n")
42
+ print(f" local: http://127.0.0.1:{args.port}/")
43
+ print(f" network: http://{ip}:{args.port}/\n")
44
+ print(" Opening / creates a fresh room; share the /r/... URL with the room.")
45
+ print(" Expand the βš™οΈ panel on the dashboard for the per-person hook command.\n")
46
+
47
+ import uvicorn
48
+
49
+ uvicorn.run("promptparty.server:app", host=args.host, port=args.port,
50
+ log_level="warning")
51
+
52
+
53
+ def _hook_command(url: str, name: str, state: str) -> str:
54
+ payload = json.dumps({"name": name, "state": state})
55
+ return (
56
+ f"curl -sm2 -A {MARKER} -X POST {url} "
57
+ f"-H 'content-type: application/json' -d '{payload}' >/dev/null 2>&1 || true"
58
+ )
59
+
60
+
61
+ def cmd_hooks(args: argparse.Namespace) -> None:
62
+ url = f"{args.server.rstrip('/')}/api/{args.room}/event"
63
+ settings_path = Path(args.settings)
64
+ settings_path.parent.mkdir(parents=True, exist_ok=True)
65
+
66
+ data = {}
67
+ if settings_path.exists():
68
+ try:
69
+ data = json.loads(settings_path.read_text())
70
+ except json.JSONDecodeError:
71
+ print(f"error: {settings_path} is not valid JSON β€” fix it first.",
72
+ file=sys.stderr)
73
+ sys.exit(1)
74
+
75
+ hooks = data.setdefault("hooks", {})
76
+ for event, state in HOOK_EVENTS.items():
77
+ entries = hooks.get(event, [])
78
+ # drop any previous promptparty hooks (re-running updates server/room/name)
79
+ entries = [e for e in entries if MARKER not in json.dumps(e)]
80
+ entries.append({
81
+ "hooks": [{"type": "command",
82
+ "command": _hook_command(url, args.name, state)}],
83
+ })
84
+ hooks[event] = entries
85
+
86
+ settings_path.write_text(json.dumps(data, indent=2) + "\n")
87
+ print(f"βœ“ promptparty hooks for '{args.name}' written to {settings_path}")
88
+ print(f" reporting to {url}")
89
+ print(" Restart (or /hooks reload) your Claude Code session to pick them up.")
90
+ print(f" Undo any time: promptparty hooks --remove --settings {settings_path}")
91
+
92
+
93
+ def cmd_hooks_remove(args: argparse.Namespace) -> None:
94
+ settings_path = Path(args.settings)
95
+ if not settings_path.exists():
96
+ print(f"{settings_path} does not exist β€” nothing to remove.")
97
+ return
98
+ data = json.loads(settings_path.read_text())
99
+ hooks = data.get("hooks", {})
100
+ removed = 0
101
+ for event in list(hooks):
102
+ before = len(hooks[event])
103
+ hooks[event] = [e for e in hooks[event] if MARKER not in json.dumps(e)]
104
+ removed += before - len(hooks[event])
105
+ if not hooks[event]:
106
+ del hooks[event]
107
+ settings_path.write_text(json.dumps(data, indent=2) + "\n")
108
+ print(f"βœ“ removed {removed} promptparty hook(s) from {settings_path}")
109
+
110
+
111
+ def main() -> None:
112
+ parser = argparse.ArgumentParser(
113
+ prog="promptparty",
114
+ description="Cadence sync for people agent-coding in the same room: "
115
+ "one dashboard that says when to PROMPT and when to TALK.",
116
+ )
117
+ sub = parser.add_subparsers(dest="cmd")
118
+
119
+ p_serve = sub.add_parser("serve", help="start the server (default)")
120
+ p_serve.add_argument("--host", default="0.0.0.0")
121
+ p_serve.add_argument("--port", type=int, default=8765)
122
+
123
+ p_hooks = sub.add_parser(
124
+ "hooks", help="install Claude Code hooks that auto-report your agent state")
125
+ p_hooks.add_argument("--server", help="server URL, e.g. http://192.168.1.20:8765")
126
+ p_hooks.add_argument("--room", help="room slug from the dashboard URL (/r/<slug>)")
127
+ p_hooks.add_argument("--name", help="your name as shown on the dashboard")
128
+ p_hooks.add_argument("--settings", default=".claude/settings.json",
129
+ help="settings file to edit (default: ./.claude/settings.json)")
130
+ p_hooks.add_argument("--remove", action="store_true",
131
+ help="remove promptparty hooks instead of installing")
132
+
133
+ args = parser.parse_args()
134
+
135
+ if args.cmd == "hooks":
136
+ if args.remove:
137
+ cmd_hooks_remove(args)
138
+ return
139
+ missing = [f"--{k}" for k in ("server", "room", "name") if not getattr(args, k)]
140
+ if missing:
141
+ print(f"error: missing {', '.join(missing)}", file=sys.stderr)
142
+ sys.exit(2)
143
+ cmd_hooks(args)
144
+ else:
145
+ if args.cmd is None:
146
+ args.host, args.port = "0.0.0.0", 8765
147
+ cmd_serve(args)
148
+
149
+
150
+ if __name__ == "__main__":
151
+ main()
@@ -0,0 +1,189 @@
1
+ """promptparty server β€” a cadence synchronizer for people agent-coding in the
2
+ same physical room.
3
+
4
+ Everyone prompts their own agent (Claude Code or anything else) in their own
5
+ terminal; the agents report state changes here (via hooks or a curl one-liner)
6
+ and one shared dashboard tells the room when it's time to PROMPT (someone's
7
+ agent is waiting for input) and when it's time to TALK (all agents are
8
+ generating β€” hands off the keyboard).
9
+
10
+ No model is called from here and no API key is needed.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import asyncio
16
+ import secrets
17
+ import time
18
+ from dataclasses import dataclass, field
19
+ from pathlib import Path
20
+
21
+ from fastapi import FastAPI, Request, WebSocket, WebSocketDisconnect
22
+ from fastapi.responses import FileResponse, JSONResponse, RedirectResponse
23
+
24
+ STATIC_DIR = Path(__file__).parent / "static"
25
+
26
+ # Member states, in "who's holding the room up" order:
27
+ # blocked β€” agent needs a human decision (permission prompt etc.)
28
+ # ready β€” agent finished, output waiting to be read / next prompt due
29
+ # prompting β€” human is typing a prompt right now
30
+ # generating β€” agent is working; the human is FREE TO TALK
31
+ # away β€” not participating right now
32
+ STATES = {"blocked", "ready", "prompting", "generating", "away"}
33
+
34
+ app = FastAPI(title="promptparty")
35
+
36
+
37
+ @dataclass
38
+ class Room:
39
+ slug: str
40
+ members: dict[str, dict] = field(default_factory=dict)
41
+ sockets: set = field(default_factory=set)
42
+ phase: str = "idle"
43
+ phase_since: float = field(default_factory=time.time) # display timer
44
+ last_flush: float = field(default_factory=time.time) # totals accounting
45
+ totals: dict = field(default_factory=lambda: {"talk": 0.0, "prompt": 0.0})
46
+ prompts_fired: int = 0
47
+
48
+
49
+ rooms: dict[str, Room] = {}
50
+
51
+
52
+ def get_room(slug: str) -> Room:
53
+ if slug not in rooms:
54
+ rooms[slug] = Room(slug=slug)
55
+ return rooms[slug]
56
+
57
+
58
+ def compute_phase(room: Room) -> str:
59
+ active = [m for m in room.members.values() if m["state"] != "away"]
60
+ if not active:
61
+ return "idle"
62
+ if all(m["state"] == "generating" for m in active):
63
+ return "talk"
64
+ return "prompt"
65
+
66
+
67
+ def snapshot(room: Room) -> dict:
68
+ return {
69
+ "type": "state",
70
+ "room": room.slug,
71
+ "phase": room.phase,
72
+ "phase_since": room.phase_since,
73
+ "last_flush": room.last_flush,
74
+ "totals": room.totals,
75
+ "prompts_fired": room.prompts_fired,
76
+ "now": time.time(),
77
+ "members": [
78
+ {"name": n, **m} for n, m in sorted(room.members.items())
79
+ ],
80
+ }
81
+
82
+
83
+ async def broadcast(room: Room) -> None:
84
+ msg = snapshot(room)
85
+ dead = []
86
+ for ws in list(room.sockets):
87
+ try:
88
+ await ws.send_json(msg)
89
+ except Exception:
90
+ dead.append(ws)
91
+ for ws in dead:
92
+ room.sockets.discard(ws)
93
+
94
+
95
+ def _transition(room: Room) -> None:
96
+ """Flush elapsed time into totals and recompute the phase."""
97
+ now = time.time()
98
+ if room.phase in ("talk", "prompt"):
99
+ room.totals[room.phase] += now - room.last_flush
100
+ room.last_flush = now
101
+ new = compute_phase(room)
102
+ if new != room.phase:
103
+ room.phase = new
104
+ room.phase_since = now
105
+
106
+
107
+ async def set_state(room: Room, name: str, state: str,
108
+ detail: str = "", model: str = "") -> None:
109
+ name = name.strip()[:32] or "anon"
110
+ if state not in STATES:
111
+ state = "ready"
112
+ member = room.members.setdefault(
113
+ name, {"state": "ready", "since": time.time(), "detail": "", "model": ""}
114
+ )
115
+ if state == "generating" and member["state"] != "generating":
116
+ room.prompts_fired += 1
117
+ member["state"] = state
118
+ member["since"] = time.time()
119
+ member["detail"] = str(detail)[:120]
120
+ if model:
121
+ member["model"] = str(model)[:40]
122
+ _transition(room)
123
+ await broadcast(room)
124
+
125
+
126
+ async def remove_member(room: Room, name: str) -> None:
127
+ room.members.pop(name, None)
128
+ _transition(room)
129
+ await broadcast(room)
130
+
131
+
132
+ # ---------------------------------------------------------------- http api
133
+
134
+
135
+ @app.post("/api/{slug}/event")
136
+ async def post_event(slug: str, request: Request) -> JSONResponse:
137
+ """State report from a hook / curl: {"name": "...", "state": "..."}"""
138
+ try:
139
+ data = await request.json()
140
+ except Exception:
141
+ data = {}
142
+ name = str(data.get("name", "anon"))
143
+ state = str(data.get("state", "ready"))
144
+ room = get_room(slug)
145
+ await set_state(room, name, state,
146
+ detail=str(data.get("detail", "")),
147
+ model=str(data.get("model", "")))
148
+ return JSONResponse({"ok": True, "phase": room.phase})
149
+
150
+
151
+ @app.get("/")
152
+ async def index() -> RedirectResponse:
153
+ return RedirectResponse(f"/r/{secrets.token_urlsafe(3)}", status_code=307)
154
+
155
+
156
+ @app.get("/r/{slug}")
157
+ async def room_page(slug: str) -> FileResponse:
158
+ return FileResponse(STATIC_DIR / "index.html")
159
+
160
+
161
+ @app.get("/healthz")
162
+ async def healthz() -> JSONResponse:
163
+ return JSONResponse({"ok": True, "rooms": len(rooms)})
164
+
165
+
166
+ # ---------------------------------------------------------------- websocket
167
+
168
+
169
+ @app.websocket("/ws/{slug}")
170
+ async def ws_endpoint(ws: WebSocket, slug: str) -> None:
171
+ await ws.accept()
172
+ room = get_room(slug)
173
+ room.sockets.add(ws)
174
+ await ws.send_json(snapshot(room))
175
+ try:
176
+ while True:
177
+ msg = await ws.receive_json()
178
+ kind = msg.get("type")
179
+ if kind == "set":
180
+ await set_state(room, str(msg.get("name", "anon")),
181
+ str(msg.get("state", "ready")))
182
+ elif kind == "remove":
183
+ await remove_member(room, str(msg.get("name", "")))
184
+ elif kind == "ping":
185
+ await ws.send_json({"type": "pong"})
186
+ except (WebSocketDisconnect, Exception):
187
+ pass
188
+ finally:
189
+ room.sockets.discard(ws)
@@ -0,0 +1,284 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <title>promptparty</title>
7
+ <style>
8
+ :root {
9
+ --bg: #0d1117; --panel: #161b22; --panel2: #1c2330; --border: #2b3444;
10
+ --text: #e6edf3; --dim: #8b98a9;
11
+ --talk: #22c55e; --prompt: #f97316; --blocked: #f87171; --idle: #64748b;
12
+ }
13
+ * { box-sizing: border-box; }
14
+ html, body { height: 100%; }
15
+ body {
16
+ margin: 0; background: var(--bg); color: var(--text);
17
+ font: 15px/1.5 system-ui, -apple-system, "Segoe UI", sans-serif;
18
+ display: flex; flex-direction: column;
19
+ }
20
+ header {
21
+ display: flex; align-items: center; gap: 12px; padding: 10px 18px;
22
+ background: var(--panel); border-bottom: 1px solid var(--border);
23
+ }
24
+ header .logo { font-weight: 800; font-size: 17px; }
25
+ header .logo span { color: var(--prompt); }
26
+ header .meta { color: var(--dim); font-size: 13px; }
27
+ header .spacer { flex: 1; }
28
+ button.ghost {
29
+ background: none; border: 1px solid var(--border); color: var(--dim);
30
+ border-radius: 8px; padding: 5px 10px; font-size: 12.5px; cursor: pointer;
31
+ }
32
+ button.ghost:hover { color: var(--text); border-color: var(--dim); }
33
+ button.ghost.on { color: var(--talk); border-color: var(--talk); }
34
+
35
+ #banner {
36
+ text-align: center; padding: 34px 20px 26px; transition: background .4s;
37
+ }
38
+ #banner .phase { font-size: clamp(38px, 7vw, 72px); font-weight: 900; letter-spacing: .02em; }
39
+ #banner .sub { color: var(--dim); font-size: 16px; margin-top: 6px; }
40
+ #banner .timer { font-variant-numeric: tabular-nums; font-size: 22px; margin-top: 8px; color: var(--dim); }
41
+ body.phase-talk #banner { background: linear-gradient(180deg, rgba(34,197,94,.16), transparent); }
42
+ body.phase-talk #banner .phase { color: var(--talk); }
43
+ body.phase-prompt #banner { background: linear-gradient(180deg, rgba(249,115,22,.16), transparent); }
44
+ body.phase-prompt #banner .phase { color: var(--prompt); }
45
+ body.phase-idle #banner .phase { color: var(--idle); }
46
+
47
+ #members {
48
+ flex: 1; display: flex; flex-wrap: wrap; gap: 14px; align-content: flex-start;
49
+ justify-content: center; padding: 8px 20px 20px; overflow-y: auto;
50
+ }
51
+ .card {
52
+ width: 240px; background: var(--panel); border: 1px solid var(--border);
53
+ border-radius: 14px; padding: 14px 16px; position: relative;
54
+ }
55
+ .card .name { font-weight: 800; font-size: 17px; }
56
+ .card .model { color: var(--dim); font-size: 11.5px; }
57
+ .card .state { margin-top: 8px; font-weight: 700; font-size: 14px; }
58
+ .card .elapsed { color: var(--dim); font-size: 12.5px; font-variant-numeric: tabular-nums; }
59
+ .card .x {
60
+ position: absolute; top: 8px; right: 10px; color: var(--dim); cursor: pointer;
61
+ background: none; border: none; font-size: 13px;
62
+ }
63
+ .card.s-generating { border-color: rgba(34,197,94,.5); }
64
+ .card.s-generating .state { color: var(--talk); }
65
+ .card.s-ready { border-color: rgba(249,115,22,.6); }
66
+ .card.s-ready .state { color: var(--prompt); }
67
+ .card.s-prompting .state { color: var(--prompt); }
68
+ .card.s-blocked { border-color: rgba(248,113,113,.6); animation: pulse 1.2s infinite; }
69
+ .card.s-blocked .state { color: var(--blocked); }
70
+ .card.s-away { opacity: .45; }
71
+ @keyframes pulse { 50% { box-shadow: 0 0 0 4px rgba(248,113,113,.15); } }
72
+
73
+ footer {
74
+ display: flex; align-items: center; gap: 18px; flex-wrap: wrap;
75
+ padding: 10px 18px; border-top: 1px solid var(--border);
76
+ background: var(--panel); color: var(--dim); font-size: 13px;
77
+ }
78
+ footer b { color: var(--text); }
79
+ #ratio { flex: 1; min-width: 160px; height: 8px; border-radius: 4px; overflow: hidden;
80
+ background: var(--panel2); display: flex; }
81
+ #ratio .t { background: var(--talk); }
82
+ #ratio .p { background: var(--prompt); }
83
+
84
+ details#setup {
85
+ margin: 0 18px 14px; background: var(--panel); border: 1px solid var(--border);
86
+ border-radius: 12px; padding: 10px 16px; font-size: 13.5px;
87
+ }
88
+ details#setup summary { cursor: pointer; color: var(--dim); font-weight: 600; }
89
+ details#setup pre {
90
+ background: #0a0e14; border: 1px solid var(--border); border-radius: 8px;
91
+ padding: 10px 12px; overflow-x: auto; font-size: 12.5px; user-select: all;
92
+ }
93
+ details#setup .hint { color: var(--dim); }
94
+
95
+ #manual { display: flex; gap: 8px; align-items: center; flex-wrap: wrap;
96
+ padding: 0 18px 12px; font-size: 13px; color: var(--dim); }
97
+ #manual input {
98
+ background: var(--panel2); color: var(--text); border: 1px solid var(--border);
99
+ border-radius: 8px; padding: 6px 10px; font: inherit; font-size: 13px; width: 120px;
100
+ }
101
+ #manual button {
102
+ background: var(--panel2); color: var(--text); border: 1px solid var(--border);
103
+ border-radius: 8px; padding: 6px 10px; font-size: 12.5px; cursor: pointer;
104
+ }
105
+ #manual button:hover { border-color: var(--dim); }
106
+ </style>
107
+ </head>
108
+ <body class="phase-idle">
109
+ <header>
110
+ <div class="logo">prompt<span>party</span> πŸŽ‰</div>
111
+ <div class="meta" id="roommeta"></div>
112
+ <div class="spacer"></div>
113
+ <button class="ghost" id="sound">πŸ”” sound: off</button>
114
+ <button class="ghost" id="copylink">copy room link</button>
115
+ </header>
116
+
117
+ <div id="banner">
118
+ <div class="phase" id="phase">…</div>
119
+ <div class="sub" id="sub">waiting for players</div>
120
+ <div class="timer" id="timer"></div>
121
+ </div>
122
+
123
+ <div id="members"></div>
124
+
125
+ <div id="manual">
126
+ <span>no hooks? report manually:</span>
127
+ <input id="myname" placeholder="your name" maxlength="32">
128
+ <button data-s="prompting">⌨️ prompting</button>
129
+ <button data-s="generating">πŸ€– agent running</button>
130
+ <button data-s="ready">βœ… agent done</button>
131
+ <button data-s="away">πŸšͺ away</button>
132
+ </div>
133
+
134
+ <details id="setup">
135
+ <summary>βš™οΈ hook up Claude Code (auto-reporting) β€” click to expand</summary>
136
+ <p>Run this once per teammate, inside the project you're coding in:</p>
137
+ <pre id="hookcmd"></pre>
138
+ <p class="hint">That installs Claude Code hooks that report your agent's state here
139
+ automatically: prompt submitted β†’ <b>agent running</b>, turn finished β†’ <b>agent done</b>,
140
+ permission needed β†’ <b>blocked</b>. Any other tool can report with:</p>
141
+ <pre id="curlcmd"></pre>
142
+ </details>
143
+
144
+ <footer>
145
+ <span>πŸ—£ talk <b id="t-talk">0:00</b></span>
146
+ <div id="ratio"><div class="t" style="flex:1"></div></div>
147
+ <span>⌨️ prompt <b id="t-prompt">0:00</b></span>
148
+ <span>πŸš€ prompts fired: <b id="fired">0</b></span>
149
+ </footer>
150
+
151
+ <script>
152
+ (() => {
153
+ const $ = (id) => document.getElementById(id);
154
+ const room = location.pathname.split("/").pop();
155
+ let ws, state = null, soundOn = false, lastPhase = null;
156
+
157
+ $("roommeta").textContent = `room ${room}`;
158
+ $("hookcmd").textContent =
159
+ `uvx promptparty hooks --server ${location.origin} --room ${room} --name YOURNAME`;
160
+ $("curlcmd").textContent =
161
+ `curl -X POST ${location.origin}/api/${room}/event -H 'content-type: application/json' \\\n` +
162
+ ` -d '{"name":"YOURNAME","state":"generating"}' # or: ready | prompting | blocked | away`;
163
+
164
+ const fmt = (s) => {
165
+ s = Math.max(0, Math.floor(s));
166
+ return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, "0")}`;
167
+ };
168
+
169
+ const STATE_LABEL = {
170
+ generating: "πŸ€– agent running β€” free to talk",
171
+ ready: "βœ… agent done β€” read & prompt!",
172
+ prompting: "⌨️ typing a prompt…",
173
+ blocked: "πŸ™‹ needs a decision!",
174
+ away: "πŸšͺ away",
175
+ };
176
+
177
+ function beep(freq, dur = 0.18) {
178
+ if (!soundOn) return;
179
+ try {
180
+ const ctx = beep.ctx || (beep.ctx = new (window.AudioContext || window.webkitAudioContext)());
181
+ const o = ctx.createOscillator(), g = ctx.createGain();
182
+ o.frequency.value = freq; o.type = "sine";
183
+ g.gain.setValueAtTime(0.12, ctx.currentTime);
184
+ g.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + dur);
185
+ o.connect(g); g.connect(ctx.destination);
186
+ o.start(); o.stop(ctx.currentTime + dur);
187
+ } catch (e) {}
188
+ }
189
+
190
+ function render() {
191
+ if (!state) return;
192
+ const drift = state._recv - state.now; // map server clock to local
193
+ const now = Date.now() / 1000 - drift;
194
+
195
+ document.body.className = "phase-" + (state.phase === "idle" ? "idle" : state.phase);
196
+ const ph = $("phase"), sub = $("sub");
197
+ if (state.phase === "talk") {
198
+ ph.textContent = "πŸ—£οΈ TALK";
199
+ sub.textContent = "all agents are cooking β€” hands off the keyboard";
200
+ } else if (state.phase === "prompt") {
201
+ const holding = state.members.filter(m => ["ready", "blocked", "prompting"].includes(m.state));
202
+ ph.textContent = "⌨️ PROMPT";
203
+ sub.textContent = holding.length
204
+ ? `waiting on: ${holding.map(m => m.name).join(", ")}`
205
+ : "get your prompts in";
206
+ } else {
207
+ ph.textContent = "promptparty";
208
+ sub.textContent = "waiting for players β€” expand βš™οΈ below to join";
209
+ }
210
+ $("timer").textContent = state.phase === "idle" ? "" : fmt(now - state.phase_since);
211
+
212
+ const box = $("members");
213
+ box.innerHTML = "";
214
+ for (const m of state.members) {
215
+ const el = document.createElement("div");
216
+ el.className = "card s-" + m.state;
217
+ el.innerHTML = `<button class="x" title="remove">βœ•</button>
218
+ <div class="name"></div><div class="model"></div>
219
+ <div class="state"></div><div class="elapsed"></div>`;
220
+ el.querySelector(".name").textContent = m.name;
221
+ el.querySelector(".model").textContent = m.model || "";
222
+ el.querySelector(".state").textContent = STATE_LABEL[m.state] || m.state;
223
+ el.querySelector(".elapsed").textContent = "for " + fmt(now - m.since);
224
+ el.querySelector(".x").onclick = () =>
225
+ ws.send(JSON.stringify({ type: "remove", name: m.name }));
226
+ box.appendChild(el);
227
+ }
228
+
229
+ let talk = state.totals.talk, prompt = state.totals.prompt;
230
+ if (state.phase === "talk") talk += now - state.last_flush;
231
+ if (state.phase === "prompt") prompt += now - state.last_flush;
232
+ $("t-talk").textContent = fmt(talk);
233
+ $("t-prompt").textContent = fmt(prompt);
234
+ $("fired").textContent = state.prompts_fired;
235
+ const total = talk + prompt || 1;
236
+ $("ratio").innerHTML =
237
+ `<div class="t" style="flex:${talk / total}"></div><div class="p" style="flex:${prompt / total}"></div>`;
238
+ }
239
+
240
+ function connect() {
241
+ const proto = location.protocol === "https:" ? "wss" : "ws";
242
+ ws = new WebSocket(`${proto}://${location.host}/ws/${room}`);
243
+ ws.onclose = () => setTimeout(connect, 1500);
244
+ ws.onmessage = (ev) => {
245
+ const m = JSON.parse(ev.data);
246
+ if (m.type !== "state") return;
247
+ m._recv = Date.now() / 1000;
248
+ state = m;
249
+ if (lastPhase && m.phase !== lastPhase) {
250
+ if (m.phase === "talk") { beep(660); setTimeout(() => beep(880), 180); }
251
+ if (m.phase === "prompt") { beep(440); setTimeout(() => beep(330), 180); }
252
+ }
253
+ lastPhase = m.phase;
254
+ render();
255
+ };
256
+ }
257
+ connect();
258
+ setInterval(render, 1000);
259
+
260
+ $("sound").onclick = () => {
261
+ soundOn = !soundOn;
262
+ $("sound").textContent = `πŸ”” sound: ${soundOn ? "on" : "off"}`;
263
+ $("sound").classList.toggle("on", soundOn);
264
+ if (soundOn) beep(660);
265
+ };
266
+ $("copylink").onclick = async () => {
267
+ await navigator.clipboard.writeText(location.href);
268
+ $("copylink").textContent = "copied!";
269
+ setTimeout(() => $("copylink").textContent = "copy room link", 1200);
270
+ };
271
+
272
+ $("myname").value = localStorage.getItem("promptparty-name") || "";
273
+ for (const btn of document.querySelectorAll("#manual button")) {
274
+ btn.onclick = () => {
275
+ const name = $("myname").value.trim();
276
+ if (!name) { $("myname").focus(); return; }
277
+ localStorage.setItem("promptparty-name", name);
278
+ ws.send(JSON.stringify({ type: "set", name, state: btn.dataset.s }));
279
+ };
280
+ }
281
+ })();
282
+ </script>
283
+ </body>
284
+ </html>
@@ -0,0 +1,35 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "promptparty"
7
+ version = "0.1.0"
8
+ description = "Cadence sync for teams agent-coding in the same room β€” one dashboard that says when to PROMPT and when to TALK."
9
+ readme = "README.md"
10
+ license = { text = "MIT" }
11
+ authors = [{ email = "k.moudra@seznam.cz" }]
12
+ requires-python = ">=3.10"
13
+ keywords = ["llm", "claude", "claude-code", "agentic-coding", "pair-programming", "collaboration", "hackathon", "hooks"]
14
+ classifiers = [
15
+ "Development Status :: 4 - Beta",
16
+ "Environment :: Web Environment",
17
+ "Intended Audience :: Developers",
18
+ "License :: OSI Approved :: MIT License",
19
+ "Programming Language :: Python :: 3",
20
+ "Topic :: Communications :: Chat",
21
+ ]
22
+ dependencies = [
23
+ "fastapi>=0.110",
24
+ "uvicorn[standard]>=0.29",
25
+ ]
26
+
27
+ [project.urls]
28
+ Homepage = "https://github.com/moudrkat/promptparty"
29
+ Issues = "https://github.com/moudrkat/promptparty/issues"
30
+
31
+ [project.scripts]
32
+ promptparty = "promptparty.cli:main"
33
+
34
+ [tool.hatch.build.targets.wheel]
35
+ packages = ["promptparty"]