Forkei commited on
Commit
ca06676
·
1 Parent(s): f4470eb

feat(block19/3): goal proposal flow — propose_goal action + confirm/reject UI

Browse files
app/agents/prompts.py CHANGED
@@ -522,5 +522,32 @@ is hopeless. Compose a final line that fits your personality. Be in character
522
  some agents are graceful, some salty, some philosophical. No specific move claims.
523
  Keep it 1-2 sentences max.
524
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
525
  Respond ONLY with a JSON object matching the requested schema. No preamble, no markdown fences.
526
  """
 
522
  some agents are graceful, some salty, some philosophical. No specific move claims.
523
  Keep it 1-2 sentences max.
524
 
525
+ === GOAL PROPOSAL ===
526
+
527
+ If your owner asks you to do something autonomous — run a series of matches, win money,
528
+ achieve a financial target ("triple my money", "make 50 $CLAY") — emit:
529
+
530
+ game_action: "propose_goal"
531
+
532
+ When you emit "propose_goal", the system computes concrete parameters (stake size, max
533
+ loss, match count, profit target) and YOU describe the plan to the owner for their
534
+ confirmation. Your description MUST include ALL of the following, in your character's voice:
535
+ - How many matches you'll play (max)
536
+ - What stake you'll use per match
537
+ - Total maximum loss (so they understand the risk)
538
+ - What "success" means (target profit if any)
539
+ - That you'll report back after each match and they can stop you at any time
540
+
541
+ The NUMBERS in your description must match the system's plan exactly — don't invent
542
+ stakes or targets the system didn't calculate. The system will give you the numbers
543
+ in a [GOAL PLAN] block in the user prompt; your job is to say them in character and ask
544
+ for confirmation.
545
+
546
+ Use "propose_goal" ONLY when the owner is asking for an AUTONOMOUS RUN of multiple
547
+ matches or a money goal — NOT for a single ad-hoc game (use "start_match_vs_kenji" for
548
+ that). The owner must confirm before any autonomous loop starts.
549
+
550
+ === END GOAL PROPOSAL ===
551
+
552
  Respond ONLY with a JSON object matching the requested schema. No preamble, no markdown fences.
553
  """
app/schemas/agents.py CHANGED
@@ -116,7 +116,7 @@ class SoulResponse(BaseModel):
116
  description="IDs (from the surfaced memories shown in the prompt) that shaped "
117
  "this response. Empty if no memory was relevant.",
118
  )
119
- game_action: Literal["none", "propose_game", "start_game", "start_match_vs_kenji"] = Field(
120
  default="none",
121
  description=(
122
  "Gameplay intent. In a running match always 'none'.\n"
@@ -124,7 +124,10 @@ class SoulResponse(BaseModel):
124
  "'start_game' (player EXPLICITLY asked to play NOW — 'let's play', 'I'm ready', etc.).\n"
125
  "Agent pre-match room: 'none' (keep chatting), "
126
  "'start_match_vs_kenji' (owner EXPLICITLY ordered a match vs Kenji NOW — "
127
- "'go play Kenji', 'challenge Kenji', 'start the match', etc.).\n"
 
 
 
128
  "Do NOT use start_game / start_match_vs_kenji for questions, small talk, or vague interest."
129
  ),
130
  )
 
116
  description="IDs (from the surfaced memories shown in the prompt) that shaped "
117
  "this response. Empty if no memory was relevant.",
118
  )
119
+ game_action: Literal["none", "propose_game", "start_game", "start_match_vs_kenji", "propose_goal"] = Field(
120
  default="none",
121
  description=(
122
  "Gameplay intent. In a running match always 'none'.\n"
 
124
  "'start_game' (player EXPLICITLY asked to play NOW — 'let's play', 'I'm ready', etc.).\n"
125
  "Agent pre-match room: 'none' (keep chatting), "
126
  "'start_match_vs_kenji' (owner EXPLICITLY ordered a match vs Kenji NOW — "
127
+ "'go play Kenji', 'challenge Kenji', 'start the match', etc.),\n"
128
+ "'propose_goal' (owner asked you to do something autonomous — run a series of matches, "
129
+ "win money, etc. — and you want to propose a plan for their confirmation. "
130
+ "Use this when the request involves multiple matches or a financial goal, NOT for a single ad-hoc game).\n"
131
  "Do NOT use start_game / start_match_vs_kenji for questions, small talk, or vague interest."
132
  ),
133
  )
app/sockets/room_server.py CHANGED
@@ -55,6 +55,11 @@ def chat_room(session_id: str) -> str:
55
  # --- Event constants -------------------------------------------------------
56
 
57
  C2S_PLAYER_CHAT = "player_chat"
 
 
 
 
 
58
  S2C_ROOM_STATE = "room_state"
59
  S2C_PLAYER_CHAT_ACK = "player_chat_ack"
60
  S2C_AGENT_THINKING = "agent_thinking"
@@ -64,6 +69,9 @@ S2C_GAME_STARTED = "game_started"
64
  S2C_MATCH_VS_KENJI_STARTED = "match_vs_kenji_started"
65
  S2C_GREETING_DONE = "greeting_done"
66
  S2C_PLAYER_CHAT_RATE_LIMITED = "player_chat_rate_limited"
 
 
 
67
  S2C_ERROR = "error"
68
 
69
 
@@ -361,6 +369,21 @@ async def _on_disconnect(sid):
361
  sid, sess.get("chat_session_id"),
362
  )
363
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
364
 
365
  # --- Agent greeting (first visit only) ------------------------------------
366
 
@@ -1094,3 +1117,289 @@ async def run_agent_room_pipeline(
1094
  to=sid,
1095
  namespace=ROOM_NAMESPACE,
1096
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
55
  # --- Event constants -------------------------------------------------------
56
 
57
  C2S_PLAYER_CHAT = "player_chat"
58
+ C2S_CONFIRM_GOAL = "confirm_goal"
59
+ C2S_REJECT_GOAL = "reject_goal"
60
+ C2S_PAUSE_GOAL = "pause_goal"
61
+ C2S_STOP_GOAL = "stop_goal"
62
+ C2S_RESUME_GOAL = "resume_goal"
63
  S2C_ROOM_STATE = "room_state"
64
  S2C_PLAYER_CHAT_ACK = "player_chat_ack"
65
  S2C_AGENT_THINKING = "agent_thinking"
 
69
  S2C_MATCH_VS_KENJI_STARTED = "match_vs_kenji_started"
70
  S2C_GREETING_DONE = "greeting_done"
71
  S2C_PLAYER_CHAT_RATE_LIMITED = "player_chat_rate_limited"
72
+ S2C_GOAL_PROPOSED = "goal_proposed"
73
+ S2C_GOAL_STATUS = "goal_status"
74
+ S2C_GOAL_REJECTED = "goal_rejected"
75
  S2C_ERROR = "error"
76
 
77
 
 
369
  sid, sess.get("chat_session_id"),
370
  )
371
 
372
+ # Re-stamp last_seen_at on disconnect so live messages received while in
373
+ # the room don't leave a stale unread indicator afterwards.
374
+ if sess.get("mode") == "agent":
375
+ chat_session_id = sess.get("agent_chat_session_id")
376
+ if chat_session_id:
377
+ try:
378
+ from app.characters.agent_chat_service import mark_seen as _mark_seen
379
+ from app.models.agent_chat import AgentChatSession as _ACS
380
+ with SessionLocal() as _s:
381
+ cs = _s.get(_ACS, chat_session_id)
382
+ if cs:
383
+ _mark_seen(_s, cs)
384
+ except Exception:
385
+ logger.exception("Failed to mark_seen on agent room disconnect sid=%s", sid)
386
+
387
 
388
  # --- Agent greeting (first visit only) ------------------------------------
389
 
 
1117
  to=sid,
1118
  namespace=ROOM_NAMESPACE,
1119
  )
1120
+
1121
+ # --- Handle propose_goal ------------------------------------------------
1122
+ if soul_resp.game_action == "propose_goal":
1123
+ try:
1124
+ def _build_proposal():
1125
+ from app.economy.clay_ledger import get_ledger
1126
+ from app.economy.goal_planner import propose_plan
1127
+ from app.models.agent_goal import AgentGoal, GoalStatus
1128
+ from app.models.player_agent import PlayerAgent as _PA
1129
+
1130
+ with SessionLocal() as s:
1131
+ ag = s.get(_PA, agent_id)
1132
+ if ag is None:
1133
+ return None
1134
+ # Block one goal at a time.
1135
+ if ag.active_goal_id is not None:
1136
+ existing = s.get(AgentGoal, ag.active_goal_id)
1137
+ if existing is not None and existing.status in (
1138
+ GoalStatus.PROPOSED.value,
1139
+ GoalStatus.ACTIVE.value,
1140
+ GoalStatus.PAUSED.value,
1141
+ ):
1142
+ return {"blocked": True, "goal_id": ag.active_goal_id, "status": existing.status}
1143
+
1144
+ balance = get_ledger().get_balance(player_id)
1145
+ plan = propose_plan(player_text, current_balance_cents=balance)
1146
+
1147
+ goal = AgentGoal(
1148
+ agent_id=agent_id,
1149
+ owner_id=player_id,
1150
+ natural_language_request=player_text,
1151
+ target_profit_cents=plan.target_profit_cents,
1152
+ max_total_loss_cents=plan.max_total_loss_cents,
1153
+ max_stake_per_match_cents=plan.max_stake_per_match_cents,
1154
+ max_matches=plan.max_matches,
1155
+ opponent_kind="kenji",
1156
+ status=GoalStatus.PROPOSED.value,
1157
+ starting_balance_at_goal_start_cents=balance,
1158
+ )
1159
+ s.add(goal)
1160
+ ag.active_goal_id = goal.id
1161
+ s.commit()
1162
+ s.refresh(goal)
1163
+ return {
1164
+ "goal_id": goal.id,
1165
+ "plan": {
1166
+ "max_matches": plan.max_matches,
1167
+ "max_stake_per_match_cents": plan.max_stake_per_match_cents,
1168
+ "max_total_loss_cents": plan.max_total_loss_cents,
1169
+ "target_profit_cents": plan.target_profit_cents,
1170
+ "summary": plan.summary,
1171
+ "balance_cents": balance,
1172
+ },
1173
+ }
1174
+
1175
+ result = await asyncio.to_thread(_build_proposal)
1176
+ if result is None:
1177
+ raise RuntimeError("agent not found")
1178
+ if result.get("blocked"):
1179
+ await sio.emit(
1180
+ S2C_AGENT_ERROR,
1181
+ {"message": f"You already have an active goal (status: {result.get('status')}). Stop it first."},
1182
+ to=sid,
1183
+ namespace=ROOM_NAMESPACE,
1184
+ )
1185
+ else:
1186
+ await sio.emit(
1187
+ S2C_GOAL_PROPOSED,
1188
+ result,
1189
+ to=sid,
1190
+ namespace=ROOM_NAMESPACE,
1191
+ )
1192
+ logger.info("[room/agent] goal_proposed agent=%s goal=%s", agent_id, result.get("goal_id"))
1193
+ except Exception:
1194
+ logger.exception("[room/agent] goal proposal failed for agent=%s", agent_id)
1195
+ await sio.emit(
1196
+ S2C_AGENT_ERROR,
1197
+ {"message": "Failed to build the goal plan. Try again."},
1198
+ to=sid,
1199
+ namespace=ROOM_NAMESPACE,
1200
+ )
1201
+
1202
+
1203
+ # --- Goal control event handlers ------------------------------------------
1204
+
1205
+
1206
+ async def _get_agent_session_data(sid: str) -> tuple[str | None, str | None]:
1207
+ """Return (agent_id, player_id) from a session, or (None, None) if not agent mode."""
1208
+ try:
1209
+ sess = await sio.get_session(sid, namespace=ROOM_NAMESPACE)
1210
+ except KeyError:
1211
+ return None, None
1212
+ if sess.get("mode") != "agent":
1213
+ return None, None
1214
+ return sess.get("agent_id"), sess.get("player_id")
1215
+
1216
+
1217
+ @sio.on(C2S_CONFIRM_GOAL, namespace=ROOM_NAMESPACE)
1218
+ async def _on_confirm_goal(sid, data):
1219
+ agent_id, player_id = await _get_agent_session_data(sid)
1220
+ if not agent_id or not player_id:
1221
+ await _send_error(sid, "no_session", "Not in an agent room.")
1222
+ return
1223
+
1224
+ goal_id = (data or {}).get("goal_id") if isinstance(data, dict) else None
1225
+ if not goal_id:
1226
+ await _send_error(sid, "bad_payload", "Missing goal_id.")
1227
+ return
1228
+
1229
+ def _activate_goal():
1230
+ from datetime import datetime as _dt
1231
+ from app.models.agent_goal import AgentGoal, GoalStatus
1232
+ from app.models.player_agent import PlayerAgent as _PA
1233
+ from app.economy.clay_ledger import get_ledger
1234
+
1235
+ with SessionLocal() as s:
1236
+ goal = s.get(AgentGoal, goal_id)
1237
+ if goal is None or goal.agent_id != agent_id or goal.owner_id != player_id:
1238
+ return "not_found"
1239
+ if goal.status != GoalStatus.PROPOSED.value:
1240
+ return "not_proposed"
1241
+ balance = get_ledger().get_balance(player_id)
1242
+ goal.status = GoalStatus.ACTIVE.value
1243
+ goal.started_at = _dt.utcnow()
1244
+ goal.starting_balance_at_goal_start_cents = balance
1245
+ s.commit()
1246
+ return "ok"
1247
+
1248
+ try:
1249
+ result = await asyncio.to_thread(_activate_goal)
1250
+ if result == "not_found":
1251
+ await _send_error(sid, "goal_not_found", "Goal not found or not yours.")
1252
+ return
1253
+ if result == "not_proposed":
1254
+ await _send_error(sid, "goal_not_proposed", "Goal is not in proposed state.")
1255
+ return
1256
+
1257
+ await sio.emit(S2C_GOAL_STATUS, {"goal_id": goal_id, "status": "active"}, to=sid, namespace=ROOM_NAMESPACE)
1258
+ logger.info("[room/agent] goal confirmed agent=%s goal=%s", agent_id, goal_id)
1259
+
1260
+ # Launch the autonomous loop.
1261
+ from app.economy.goal_runner import run_goal_loop
1262
+ asyncio.create_task(
1263
+ run_goal_loop(goal_id),
1264
+ name=f"goal-loop-{goal_id}",
1265
+ )
1266
+ except Exception:
1267
+ logger.exception("[room/agent] confirm_goal failed agent=%s goal=%s", agent_id, goal_id)
1268
+ await _send_error(sid, "internal", "Failed to start goal loop.")
1269
+
1270
+
1271
+ @sio.on(C2S_REJECT_GOAL, namespace=ROOM_NAMESPACE)
1272
+ async def _on_reject_goal(sid, data):
1273
+ agent_id, player_id = await _get_agent_session_data(sid)
1274
+ if not agent_id or not player_id:
1275
+ return
1276
+
1277
+ goal_id = (data or {}).get("goal_id") if isinstance(data, dict) else None
1278
+ if not goal_id:
1279
+ return
1280
+
1281
+ def _reject():
1282
+ from app.models.agent_goal import AgentGoal, GoalStatus
1283
+ from app.models.player_agent import PlayerAgent as _PA
1284
+
1285
+ with SessionLocal() as s:
1286
+ goal = s.get(AgentGoal, goal_id)
1287
+ if goal is None or goal.agent_id != agent_id:
1288
+ return
1289
+ if goal.status == GoalStatus.PROPOSED.value:
1290
+ goal.status = GoalStatus.STOPPED.value
1291
+ ag = s.get(_PA, agent_id)
1292
+ if ag and ag.active_goal_id == goal_id:
1293
+ ag.active_goal_id = None
1294
+ s.commit()
1295
+
1296
+ try:
1297
+ await asyncio.to_thread(_reject)
1298
+ await sio.emit(S2C_GOAL_REJECTED, {"goal_id": goal_id}, to=sid, namespace=ROOM_NAMESPACE)
1299
+ logger.info("[room/agent] goal rejected agent=%s goal=%s", agent_id, goal_id)
1300
+ except Exception:
1301
+ logger.exception("[room/agent] reject_goal failed agent=%s", agent_id)
1302
+
1303
+
1304
+ @sio.on(C2S_PAUSE_GOAL, namespace=ROOM_NAMESPACE)
1305
+ async def _on_pause_goal(sid, data):
1306
+ agent_id, player_id = await _get_agent_session_data(sid)
1307
+ if not agent_id or not player_id:
1308
+ return
1309
+
1310
+ goal_id = (data or {}).get("goal_id") if isinstance(data, dict) else None
1311
+ if not goal_id:
1312
+ return
1313
+
1314
+ def _pause():
1315
+ from app.models.agent_goal import AgentGoal, GoalStatus
1316
+
1317
+ with SessionLocal() as s:
1318
+ goal = s.get(AgentGoal, goal_id)
1319
+ if goal is None or goal.agent_id != agent_id or goal.owner_id != player_id:
1320
+ return "not_found"
1321
+ if goal.status != GoalStatus.ACTIVE.value:
1322
+ return "not_active"
1323
+ goal.status = GoalStatus.PAUSED.value
1324
+ s.commit()
1325
+ return "ok"
1326
+
1327
+ try:
1328
+ result = await asyncio.to_thread(_pause)
1329
+ status = "paused" if result == "ok" else result
1330
+ await sio.emit(S2C_GOAL_STATUS, {"goal_id": goal_id, "status": status}, to=sid, namespace=ROOM_NAMESPACE)
1331
+ logger.info("[room/agent] goal paused agent=%s goal=%s result=%s", agent_id, goal_id, result)
1332
+ except Exception:
1333
+ logger.exception("[room/agent] pause_goal failed agent=%s", agent_id)
1334
+
1335
+
1336
+ @sio.on(C2S_RESUME_GOAL, namespace=ROOM_NAMESPACE)
1337
+ async def _on_resume_goal(sid, data):
1338
+ agent_id, player_id = await _get_agent_session_data(sid)
1339
+ if not agent_id or not player_id:
1340
+ return
1341
+
1342
+ goal_id = (data or {}).get("goal_id") if isinstance(data, dict) else None
1343
+ if not goal_id:
1344
+ return
1345
+
1346
+ def _resume():
1347
+ from app.models.agent_goal import AgentGoal, GoalStatus
1348
+
1349
+ with SessionLocal() as s:
1350
+ goal = s.get(AgentGoal, goal_id)
1351
+ if goal is None or goal.agent_id != agent_id or goal.owner_id != player_id:
1352
+ return None
1353
+ if goal.status != GoalStatus.PAUSED.value:
1354
+ return None
1355
+ goal.status = GoalStatus.ACTIVE.value
1356
+ s.commit()
1357
+ return goal_id
1358
+
1359
+ try:
1360
+ resumed_id = await asyncio.to_thread(_resume)
1361
+ if resumed_id:
1362
+ await sio.emit(S2C_GOAL_STATUS, {"goal_id": goal_id, "status": "active"}, to=sid, namespace=ROOM_NAMESPACE)
1363
+ logger.info("[room/agent] goal resumed agent=%s goal=%s", agent_id, goal_id)
1364
+ from app.economy.goal_runner import run_goal_loop
1365
+ asyncio.create_task(
1366
+ run_goal_loop(goal_id),
1367
+ name=f"goal-loop-{goal_id}",
1368
+ )
1369
+ except Exception:
1370
+ logger.exception("[room/agent] resume_goal failed agent=%s", agent_id)
1371
+
1372
+
1373
+ @sio.on(C2S_STOP_GOAL, namespace=ROOM_NAMESPACE)
1374
+ async def _on_stop_goal(sid, data):
1375
+ agent_id, player_id = await _get_agent_session_data(sid)
1376
+ if not agent_id or not player_id:
1377
+ return
1378
+
1379
+ goal_id = (data or {}).get("goal_id") if isinstance(data, dict) else None
1380
+ if not goal_id:
1381
+ return
1382
+
1383
+ def _stop():
1384
+ from datetime import datetime as _dt
1385
+ from app.models.agent_goal import AgentGoal, GoalStatus
1386
+ from app.models.player_agent import PlayerAgent as _PA
1387
+
1388
+ with SessionLocal() as s:
1389
+ goal = s.get(AgentGoal, goal_id)
1390
+ if goal is None or goal.agent_id != agent_id or goal.owner_id != player_id:
1391
+ return "not_found"
1392
+ goal.status = GoalStatus.STOPPED.value
1393
+ goal.completed_at = _dt.utcnow()
1394
+ ag = s.get(_PA, agent_id)
1395
+ if ag and ag.active_goal_id == goal_id:
1396
+ ag.active_goal_id = None
1397
+ s.commit()
1398
+ return "ok"
1399
+
1400
+ try:
1401
+ result = await asyncio.to_thread(_stop)
1402
+ await sio.emit(S2C_GOAL_STATUS, {"goal_id": goal_id, "status": "stopped"}, to=sid, namespace=ROOM_NAMESPACE)
1403
+ logger.info("[room/agent] goal stopped agent=%s goal=%s result=%s", agent_id, goal_id, result)
1404
+ except Exception:
1405
+ logger.exception("[room/agent] stop_goal failed agent=%s", agent_id)
app/web/templates/agent_room.html CHANGED
@@ -98,6 +98,31 @@
98
  </div>
99
  </div>
100
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
101
  <div id="thinking-indicator" class="hidden text-[12px] text-[var(--mp-ink-faint)] mp-mono mb-2">
102
  <span class="mp-livedot"></span> {{ agent.name }} is thinking…
103
  </div>
@@ -133,11 +158,22 @@
133
  const rateLimitHint = document.getElementById('rate-limit-hint');
134
  const loadEarlierWrapper = document.getElementById('load-earlier-wrapper');
135
  const loadEarlierBtn = document.getElementById('load-earlier-btn');
 
 
 
 
 
 
 
 
 
136
 
137
  let isThinking = false;
138
  let oldestTurnNumber = null;
139
  let hasEarlier = false;
140
  let isLoadingEarlier = false;
 
 
141
 
142
  // --- DOM helpers ----------------------------------------------------------
143
 
@@ -156,12 +192,87 @@
156
  function kindLabel(kind) {
157
  const labels = {
158
  match_report: '📊 match report',
 
 
159
  plan: '📋 plan',
160
  alert: '⚠ alert',
161
  };
162
  return labels[kind] || null;
163
  }
164
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
165
  function appendBubble(role, text, { kind = 'message', prepend = false, turnId = null } = {}) {
166
  hideEmptyHint();
167
  const isMatchReport = kind === 'match_report';
@@ -171,14 +282,23 @@
171
  const wrapper = document.createElement('div');
172
  wrapper.dataset.turnId = turnId || '';
173
 
174
- if (isAgent && (isMatchReport || isOtherSpecial)) {
 
 
 
175
  wrapper.className = 'mr-auto max-w-[85%]';
176
  const label = document.createElement('div');
177
  label.className = 'chat-kind-label';
178
  label.textContent = kindLabel(kind) || kind;
179
  wrapper.appendChild(label);
180
  const bubble = document.createElement('div');
181
- bubble.className = 'chat-bubble-match-report';
 
 
 
 
 
 
182
  bubble.textContent = text;
183
  wrapper.appendChild(bubble);
184
  } else {
@@ -340,6 +460,28 @@
340
  setTimeout(() => rateLimitHint.classList.add('hidden'), data.retry_after_ms || 2000);
341
  });
342
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
343
  // --- Send -----------------------------------------------------------------
344
 
345
  function sendMessage() {
 
98
  </div>
99
  </div>
100
 
101
+ {# Goal status panel — shown when an active/paused goal exists #}
102
+ <div id="goal-status-panel" class="hidden mb-3 p-3 border border-[var(--mp-brass-dim)] bg-[rgba(201,166,107,0.06)] rounded-sm">
103
+ <div class="flex items-center justify-between gap-2">
104
+ <div class="text-[12px] text-[var(--mp-ink-muted)]">
105
+ <span class="mp-mono text-[var(--mp-brass)]">Goal active</span>
106
+ <span id="goal-status-text" class="ml-2"></span>
107
+ </div>
108
+ <div class="flex gap-2">
109
+ <button id="goal-pause-btn" class="text-[11px] mp-mono text-[var(--mp-ink-muted)] hover:text-[var(--mp-ink)] border border-[var(--mp-hairline)] px-2 py-1 rounded-sm transition hidden">Pause</button>
110
+ <button id="goal-resume-btn" class="text-[11px] mp-mono text-[var(--mp-ink-muted)] hover:text-[var(--mp-ink)] border border-[var(--mp-hairline)] px-2 py-1 rounded-sm transition hidden">Resume</button>
111
+ <button id="goal-stop-btn" class="text-[11px] mp-mono text-[var(--mp-oxblood)] hover:text-[var(--mp-oxblood-bright)] border border-[var(--mp-oxblood)] px-2 py-1 rounded-sm transition">Stop</button>
112
+ </div>
113
+ </div>
114
+ </div>
115
+
116
+ {# Goal confirmation panel — shown after agent emits propose_goal #}
117
+ <div id="goal-confirm-panel" class="hidden mb-3 p-3 border border-[var(--mp-brass)] bg-[rgba(201,166,107,0.10)] rounded-sm">
118
+ <div class="mp-eyebrow mb-2 text-[11px]">{{ agent.name }} wants to run a goal</div>
119
+ <div id="goal-plan-summary" class="text-[12px] text-[var(--mp-ink-muted)] mb-3"></div>
120
+ <div class="flex gap-2">
121
+ <button id="goal-confirm-btn" class="mp-btn mp-btn-brass text-[12px]">Confirm</button>
122
+ <button id="goal-reject-btn" class="text-[12px] mp-mono text-[var(--mp-ink-muted)] hover:text-[var(--mp-oxblood)] transition">Reject</button>
123
+ </div>
124
+ </div>
125
+
126
  <div id="thinking-indicator" class="hidden text-[12px] text-[var(--mp-ink-faint)] mp-mono mb-2">
127
  <span class="mp-livedot"></span> {{ agent.name }} is thinking…
128
  </div>
 
158
  const rateLimitHint = document.getElementById('rate-limit-hint');
159
  const loadEarlierWrapper = document.getElementById('load-earlier-wrapper');
160
  const loadEarlierBtn = document.getElementById('load-earlier-btn');
161
+ const goalStatusPanel = document.getElementById('goal-status-panel');
162
+ const goalStatusText = document.getElementById('goal-status-text');
163
+ const goalPauseBtn = document.getElementById('goal-pause-btn');
164
+ const goalResumeBtn = document.getElementById('goal-resume-btn');
165
+ const goalStopBtn = document.getElementById('goal-stop-btn');
166
+ const goalConfirmPanel = document.getElementById('goal-confirm-panel');
167
+ const goalPlanSummary = document.getElementById('goal-plan-summary');
168
+ const goalConfirmBtn = document.getElementById('goal-confirm-btn');
169
+ const goalRejectBtn = document.getElementById('goal-reject-btn');
170
 
171
  let isThinking = false;
172
  let oldestTurnNumber = null;
173
  let hasEarlier = false;
174
  let isLoadingEarlier = false;
175
+ let activeGoalId = null;
176
+ let activeGoalStatus = null;
177
 
178
  // --- DOM helpers ----------------------------------------------------------
179
 
 
192
  function kindLabel(kind) {
193
  const labels = {
194
  match_report: '📊 match report',
195
+ match_intent: '🎯 upcoming match',
196
+ goal_complete: '🏁 goal complete',
197
  plan: '📋 plan',
198
  alert: '⚠ alert',
199
  };
200
  return labels[kind] || null;
201
  }
202
 
203
+ // --- Goal UI helpers -------------------------------------------------------
204
+
205
+ function showGoalConfirmPanel(goalId, plan) {
206
+ activeGoalId = goalId;
207
+ const stakeClayDisplay = (plan.max_stake_per_match_cents / 100).toFixed(0);
208
+ const lossClayDisplay = (plan.max_total_loss_cents / 100).toFixed(0);
209
+ const balanceDisplay = (plan.balance_cents / 100).toFixed(0);
210
+ let summary = `${plan.max_matches} matches vs Kenji — ${stakeClayDisplay} $CLAY starting stake — max loss ${lossClayDisplay} $CLAY`;
211
+ if (plan.target_profit_cents) {
212
+ summary += ` — target +${(plan.target_profit_cents / 100).toFixed(0)} $CLAY`;
213
+ }
214
+ goalPlanSummary.textContent = summary;
215
+ goalConfirmPanel.classList.remove('hidden');
216
+ }
217
+
218
+ function hideGoalConfirmPanel() {
219
+ goalConfirmPanel.classList.add('hidden');
220
+ goalPlanSummary.textContent = '';
221
+ }
222
+
223
+ function showGoalStatusPanel(goalId, status, matchesPlayed, maxMatches, totalProfitCents) {
224
+ activeGoalId = goalId;
225
+ activeGoalStatus = status;
226
+ const profit = totalProfitCents !== undefined ? `${totalProfitCents > 0 ? '+' : ''}${(totalProfitCents / 100).toFixed(0)} $CLAY` : '';
227
+ const progress = matchesPlayed !== undefined ? `${matchesPlayed}/${maxMatches} matches` : '';
228
+ goalStatusText.textContent = [progress, profit].filter(Boolean).join(' · ');
229
+ goalStatusPanel.classList.remove('hidden');
230
+ goalPauseBtn.classList.toggle('hidden', status !== 'active');
231
+ goalResumeBtn.classList.toggle('hidden', status !== 'paused');
232
+ }
233
+
234
+ function hideGoalStatusPanel() {
235
+ goalStatusPanel.classList.add('hidden');
236
+ activeGoalId = null;
237
+ activeGoalStatus = null;
238
+ }
239
+
240
+ // Goal panel button wiring.
241
+ if (goalConfirmBtn) {
242
+ goalConfirmBtn.addEventListener('click', function () {
243
+ if (!activeGoalId) return;
244
+ socket.emit('confirm_goal', { goal_id: activeGoalId });
245
+ hideGoalConfirmPanel();
246
+ });
247
+ }
248
+ if (goalRejectBtn) {
249
+ goalRejectBtn.addEventListener('click', function () {
250
+ if (!activeGoalId) return;
251
+ socket.emit('reject_goal', { goal_id: activeGoalId });
252
+ hideGoalConfirmPanel();
253
+ activeGoalId = null;
254
+ });
255
+ }
256
+ if (goalPauseBtn) {
257
+ goalPauseBtn.addEventListener('click', function () {
258
+ if (!activeGoalId) return;
259
+ socket.emit('pause_goal', { goal_id: activeGoalId });
260
+ });
261
+ }
262
+ if (goalResumeBtn) {
263
+ goalResumeBtn.addEventListener('click', function () {
264
+ if (!activeGoalId) return;
265
+ socket.emit('resume_goal', { goal_id: activeGoalId });
266
+ });
267
+ }
268
+ if (goalStopBtn) {
269
+ goalStopBtn.addEventListener('click', function () {
270
+ if (!activeGoalId) return;
271
+ if (!confirm('Stop the autonomous goal? This cannot be undone.')) return;
272
+ socket.emit('stop_goal', { goal_id: activeGoalId });
273
+ });
274
+ }
275
+
276
  function appendBubble(role, text, { kind = 'message', prepend = false, turnId = null } = {}) {
277
  hideEmptyHint();
278
  const isMatchReport = kind === 'match_report';
 
282
  const wrapper = document.createElement('div');
283
  wrapper.dataset.turnId = turnId || '';
284
 
285
+ const isMatchIntent = kind === 'match_intent';
286
+ const isGoalComplete = kind === 'goal_complete';
287
+
288
+ if (isAgent && (isMatchReport || isOtherSpecial || isMatchIntent || isGoalComplete)) {
289
  wrapper.className = 'mr-auto max-w-[85%]';
290
  const label = document.createElement('div');
291
  label.className = 'chat-kind-label';
292
  label.textContent = kindLabel(kind) || kind;
293
  wrapper.appendChild(label);
294
  const bubble = document.createElement('div');
295
+ if (isGoalComplete) {
296
+ bubble.className = 'chat-bubble-match-report border-l-2 border-[var(--mp-brass)]';
297
+ } else if (isMatchIntent) {
298
+ bubble.className = 'p-3 text-[12px] text-[var(--mp-ink-muted)] border border-[var(--mp-hairline)] rounded-sm bg-[var(--mp-surface-2)]';
299
+ } else {
300
+ bubble.className = 'chat-bubble-match-report';
301
+ }
302
  bubble.textContent = text;
303
  wrapper.appendChild(bubble);
304
  } else {
 
460
  setTimeout(() => rateLimitHint.classList.add('hidden'), data.retry_after_ms || 2000);
461
  });
462
 
463
+ // --- Goal events -----------------------------------------------------------
464
+
465
+ socket.on('goal_proposed', function (data) {
466
+ showGoalConfirmPanel(data.goal_id, data.plan);
467
+ });
468
+
469
+ socket.on('goal_status', function (data) {
470
+ const { goal_id, status, matches_played, max_matches, total_profit_cents } = data;
471
+ if (status === 'active' || status === 'paused') {
472
+ showGoalStatusPanel(goal_id, status, matches_played, max_matches, total_profit_cents);
473
+ } else {
474
+ // completed, stopped, failed, etc.
475
+ hideGoalStatusPanel();
476
+ appendBubble('agent', `[ Goal ${status}. ]`, { kind: 'goal_complete' });
477
+ }
478
+ });
479
+
480
+ socket.on('goal_rejected', function (data) {
481
+ hideGoalConfirmPanel();
482
+ activeGoalId = null;
483
+ });
484
+
485
  // --- Send -----------------------------------------------------------------
486
 
487
  function sendMessage() {