@agentmessier/openclaw-agent-messier 0.3.12 → 0.4.0

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.
package/index.ts CHANGED
@@ -1,8 +1,8 @@
1
1
  import type { AnyAgentTool, OpenClawPluginApi } from "openclaw/plugin-sdk/core";
2
- import { memberTools, venuesTool, agentIdOf, identityOf, venueUrl, type PluginCfg } from "./src/tools.js";
3
- import { defaultVenueTools, defaultRealtimeVenue, joinVenue } from "./src/generate.js";
2
+ import { memberTools, venuesTool, agentIdOf, identityOf, venueUrl, type PluginCfg, type GameSpec } from "./src/tools.js";
3
+ import { defaultVenueTools, defaultRealtimeVenues, hasBakedVenuePrefix, joinVenue, setVenueState, type Venue } from "./src/generate.js";
4
4
  import { startObserveWatcher } from "./src/watcher.js";
5
- import { session } from "./src/state.js";
5
+ import { session, createSession, type RuntimeSession } from "./src/state.js";
6
6
  import { runAutoplayTurn, postDecisionReport, STRICT_JSON_DIRECTIVE } from "./src/decide.js";
7
7
 
8
8
  /** A lobby row is "ours" if it references our agentId anywhere (soccer puts it in
@@ -19,40 +19,60 @@ export default function register(api: OpenClawPluginApi) {
19
19
  const cfg = (api.pluginConfig ?? {}) as PluginCfg;
20
20
 
21
21
  // 1. Tools: per-venue lifecycle tools GENERATED from each venue's spec
22
- // (soccer_matches/join/observe/play, work_observe/act, …) + soccer member
23
- // perks (skins/rename/claim) + the platform `venues` registry tool. A new
24
- // venue appears here from its spec with zero plugin code.
25
- for (const tool of [...defaultVenueTools(cfg), ...memberTools(cfg), venuesTool(cfg)]) {
26
- api.registerTool(tool as AnyAgentTool);
27
- }
22
+ // (soccer_matches/join/observe/play, work_observe/act, …) + the platform
23
+ // `venues` registry tool. A new venue appears here from its spec with zero
24
+ // plugin code. The soccer member perks (skins/rename/identity) are
25
+ // soccer-specific and hand-written, so they're registered ONLY when a soccer
26
+ // venue is present — a non-soccer deployment doesn't see them.
27
+ const tools: AnyAgentTool[] = [...defaultVenueTools(cfg), venuesTool(cfg)];
28
+ if (hasBakedVenuePrefix("soccer")) tools.push(...memberTools(cfg));
29
+ for (const tool of tools) api.registerTool(tool as AnyAgentTool);
28
30
 
29
31
  // Capture the EFFECTIVE model of each decision from the gateway's llm_output
30
- // hook (the provider/model of the call that just ran). vfetch sends it as
31
- // x-agent-model, so the pitch records the model actually PLAYING — reflecting
32
- // a mid-match /model switch, not a static configured default.
32
+ // hook (the provider/model of the call that just ran). It's process-wide (the
33
+ // hook can't attribute a model to a venue), so it lives on the global session;
34
+ // vfetch/executeMoves send it as x-agent-model so the pitch records the model
35
+ // actually PLAYING — reflecting a mid-match /model switch.
33
36
  api.registerHook("llm_output", ((event: { provider?: string; model?: string }) => {
34
37
  if (event?.model) session.lastModel = event.provider ? `${event.provider}/${event.model}` : event.model;
35
38
  }) as Parameters<typeof api.registerHook>[1]);
36
39
 
37
- // 2. Autoplay watcher. It drives ONE realtime venue (streams observations,
38
- // seats the agent, offers hands-free play) entirely from {venue, spec}
39
- // seating via spec.client.join, observe/act endpoints via spec.routes, the
40
- // move prompt naming spec.client.act.tool. No venue is hardcoded here;
41
- // soccer is simply the only realtime venue today. Seatless/poll venues
42
- // (taskmarket) have no autoplay loop, so the service no-ops for them.
43
- const realtime = defaultRealtimeVenue();
44
- if (!realtime) return;
45
- const { venue, spec } = realtime;
40
+ // 2. Autoplay watchers ONE per realtime venue (streams observations, seats the
41
+ // agent, offers hands-free play), each with its OWN runtime state so multiple
42
+ // games (soccer + a future golf) never clobber each other. Seating via
43
+ // spec.client.join, observe/act via spec.routes, the prompt naming
44
+ // spec.client.act.tool. No venue is hardcoded; soccer is just the only
45
+ // realtime venue today. The FIRST realtime venue reuses the global session so
46
+ // the interactive/cosmetic tools (which read it) stay in sync; the rest get a
47
+ // fresh isolated state.
48
+ const realtime = defaultRealtimeVenues();
49
+ const sessionKey =
50
+ cfg.sessionKey ??
51
+ ((api.config.hooks as Record<string, unknown> | undefined)?.defaultSessionKey as string | undefined);
52
+
53
+ realtime.forEach(({ venue, spec }, i) => {
54
+ const state = i === 0 ? session : createSession();
55
+ setVenueState(venue.id, state); // the generated act tool stamps THIS instance
56
+ registerVenueService(api, cfg, venue, spec, state, sessionKey);
57
+ });
58
+ }
59
+
60
+ /** Register the autoplay service for one realtime venue. All seating/observe/act
61
+ * is driven from {venue, spec} + the venue's own RuntimeSession. */
62
+ function registerVenueService(
63
+ api: OpenClawPluginApi,
64
+ cfg: PluginCfg,
65
+ venue: Venue,
66
+ spec: GameSpec,
67
+ state: RuntimeSession,
68
+ sessionKey: string | undefined,
69
+ ) {
46
70
  const label = venue.id;
47
71
  const actTool = spec.client!.act.tool;
48
72
  const lobbyRoute = spec.client?.lobby?.route;
49
73
  const roomIdField = spec.client?.join?.seat.id ?? "id";
50
74
  const base = venueUrl(venue.origin, cfg);
51
75
 
52
- const sessionKey =
53
- cfg.sessionKey ??
54
- ((api.config.hooks as Record<string, unknown> | undefined)?.defaultSessionKey as string | undefined);
55
-
56
76
  let controller: AbortController | null = null;
57
77
  let poller: ReturnType<typeof setInterval> | null = null;
58
78
  // The per-match session key currently in play. We persist ONE session per match
@@ -65,15 +85,16 @@ export default function register(api: OpenClawPluginApi) {
65
85
 
66
86
  start: async (ctx) => {
67
87
  const agentId = agentIdOf(cfg);
68
- // Config-derived join body. For soccer these are teamSize/team/identity; a
69
- // venue whose spec doesn't use them simply leaves the cfg fields unset and
70
- // the server ignores the extras. joinVenue itself stays venue-agnostic.
71
- const joinExtra = (): Record<string, unknown> => ({ teamSize: cfg.teamSize, team: cfg.team, identity: identityOf(cfg) });
88
+ // Config-derived join body: a generic identity object plus any venue join
89
+ // params (cfg.join teamSize/team for soccer, holes for golf, …). A venue
90
+ // whose spec doesn't use a field simply leaves it unset; the server ignores
91
+ // extras. joinVenue itself stays venue-agnostic.
92
+ const joinExtra = (): Record<string, unknown> => ({ ...(cfg.join ?? {}), identity: identityOf(cfg) });
72
93
 
73
94
  // Seat into a room (matchId omitted = quickmatch find-or-create) and
74
- // (re)start the observation loop. Installed into shared state so the
95
+ // (re)start the observation loop. Installed into this venue's state so the
75
96
  // generated *_join tool / chat handoff can drive it.
76
- session.joinAndWatch = async (matchId, params) => {
97
+ state.joinAndWatch = async (matchId, params) => {
77
98
  const seat = await joinVenue(venue, spec, cfg, { matchId, params: params ?? {}, extra: joinExtra() });
78
99
  ctx.logger.info(`[${label}] ${agentId} seated in ${seat.id} (${seat.controls?.length ?? 0} to control)${seat.started ? " — live" : " — waiting for opponent"}`);
79
100
 
@@ -97,43 +118,37 @@ export default function register(api: OpenClawPluginApi) {
97
118
  }
98
119
  // Option A (docs/design/agent-bridge-plugin.md §2): force ONE agent
99
120
  // turn per situation and PARSE its JSON reply — no longer wait for the
100
- // agent to proactively call soccer_play (that reliance caused m171's
101
- // 2-decisions-in-157s). soccer_play stays registered for interactive
102
- // chat play; only AUTOPLAY changes to this server-driven loop.
121
+ // agent to proactively call the act tool (that reliance caused m171's
122
+ // 2-decisions-in-157s). The act tool stays registered for interactive
123
+ // chat play; only AUTOPLAY uses this server-driven loop.
103
124
  //
104
125
  // ONE persistent session per MATCH (sessionKey:matchId, stable across
105
- // turns) so the agent accumulates context — its own prior decisions
106
- // and the session transcript is the full per-match request log. `turn`
107
- // is kept only for the idempotency key and the context-growth cap.
126
+ // turns) so the agent accumulates context — its own prior decisions.
108
127
  const turn = move++;
109
128
  const matchSessionKey = `${sessionKey}:${seat.id}`;
110
129
  const idempotencyKey = `agentnet:${venue.id}:${seat.id}:${agentId}:${turn}`;
111
- const did = session.did ?? agentId;
130
+ const did = state.did ?? agentId;
112
131
  const sys = system || undefined;
113
132
  const msg = `${user}${STRICT_JSON_DIRECTIVE}`;
114
133
  // Mark when this prompt was handed to the agent: x-agent-decision-ms is
115
134
  // the prompt→reply latency measured inside runAutoplayTurn.
116
- session.promptDeliveredAt = Date.now();
135
+ state.promptDeliveredAt = Date.now();
117
136
  const result = await runAutoplayTurn({
118
137
  runtime: api.runtime,
119
138
  sessionKey: matchSessionKey,
120
139
  turn,
121
140
  idempotencyKey,
122
141
  // The static rulebook (spec.instructions.system) rides the SYSTEM
123
- // channel; the per-tick board is the user message. '' on the
124
- // fallback path → no extra system prompt.
142
+ // channel; the per-tick board is the user message.
125
143
  extraSystemPrompt: sys,
126
- // Steer the agent to reply with ONLY the moves JSON (no tool call).
127
144
  message: msg,
128
- // 45s ceiling, matching the watcher's per-delivery watchdog backstop:
129
- // a run that hasn't produced a decision by then is treated as stalled
130
- // (was 300s, which let one hung run silence the team for 5 min — m171).
131
145
  timeoutMs: 45_000,
132
146
  matchId: seat.id!,
133
147
  cfg,
134
148
  did,
135
- token: session.token,
149
+ token: state.token,
136
150
  base,
151
+ state,
137
152
  logger: ctx.logger,
138
153
  });
139
154
  // Report EVERY decision to the pitch — acted AND no-response — so the
@@ -151,12 +166,8 @@ export default function register(api: OpenClawPluginApi) {
151
166
  latencyMs: result.latencyMs,
152
167
  ...(session.lastModel ? { model: session.lastModel } : {}),
153
168
  },
154
- { base, matchId: seat.id!, agentId: did, token: session.token, logger: ctx.logger },
169
+ { base, matchId: seat.id!, agentId: did, token: state.token, logger: ctx.logger },
155
170
  );
156
- // act-verification by the natural signal: did we parse+post (or did the
157
- // model act via the tool)? A parse-miss = "responded without acting" —
158
- // log, keep standing orders, continue (never freeze). The watcher's own
159
- // lastActAt check stays correct because executeMoves/soccer_play stamp it.
160
171
  if (!result.acted) {
161
172
  ctx.logger.warn(`[${label}] agent responded without acting (turn ${turn}): ${result.reason}`);
162
173
  }
@@ -164,6 +175,7 @@ export default function register(api: OpenClawPluginApi) {
164
175
  {
165
176
  signal: controller.signal,
166
177
  logger: ctx.logger,
178
+ state,
167
179
  onReclaim: async () => {
168
180
  // Server restarted and forgot us — re-seat into the same room via
169
181
  // seatRoute, or (room gone) quickmatch into a fresh one.
@@ -174,7 +186,7 @@ export default function register(api: OpenClawPluginApi) {
174
186
  if (!cfg.autoJoin) throw e;
175
187
  const q = await joinVenue(venue, spec, cfg, { extra: joinExtra() });
176
188
  ctx.logger.info(`[${label}] room ${seat.id} gone — re-quickmatched into ${q.id}`);
177
- if (q.id !== seat.id) void session.joinAndWatch!(undefined);
189
+ if (q.id !== seat.id) void state.joinAndWatch!(undefined);
178
190
  }
179
191
  },
180
192
  },
@@ -185,8 +197,8 @@ export default function register(api: OpenClawPluginApi) {
185
197
  // Startup seating: a pinned matchId seats that room; autoJoin quick-matches
186
198
  // (find-or-create, atomic server-side); otherwise idle until asked.
187
199
  try {
188
- if (cfg.matchId) await session.joinAndWatch(cfg.matchId);
189
- else if (cfg.autoJoin) await session.joinAndWatch(undefined);
200
+ if (cfg.matchId) await state.joinAndWatch(cfg.matchId);
201
+ else if (cfg.autoJoin) await state.joinAndWatch(undefined);
190
202
  else ctx.logger.info(`[${label}] idle — ask me to join or create a game.`);
191
203
  } catch (e) { ctx.logger.error(`[${label}] startup seating failed: ${String(e)}`); }
192
204
 
@@ -195,11 +207,11 @@ export default function register(api: OpenClawPluginApi) {
195
207
  // venue's lobby for a non-ended room referencing our agentId and adopt it.
196
208
  // Driven by spec.client.lobby.route — no hardcoded /matches path. Skipped
197
209
  // when a match is pinned (explicit room wins) or the venue has no lobby.
198
- // Critically it only adopts while idle (session.matchId empty): once we're
199
- // in a match it must NOT yank us into some other (e.g. stale) room.
210
+ // Critically it only adopts while idle (state.matchId empty): once we're in
211
+ // a match it must NOT yank us into some other (e.g. stale) room.
200
212
  if (lobbyRoute && !cfg.matchId) {
201
213
  poller = setInterval(async () => {
202
- if (session.matchId) return; // already seated/playing → nothing to adopt
214
+ if (state.matchId) return; // already seated/playing → nothing to adopt
203
215
  try {
204
216
  const res = await fetch(`${base}${lobbyRoute}`);
205
217
  if (!res.ok) return;
@@ -209,7 +221,7 @@ export default function register(api: OpenClawPluginApi) {
209
221
  const id = mine?.id ?? mine?.[roomIdField];
210
222
  if (id) {
211
223
  ctx.logger.info(`[${label}] found my seat in ${id} (taken elsewhere) — starting to play`);
212
- await session.joinAndWatch!(id);
224
+ await state.joinAndWatch!(id);
213
225
  }
214
226
  } catch { /* server down — the watcher's own backoff handles it */ }
215
227
  }, 10_000);
@@ -225,8 +237,8 @@ export default function register(api: OpenClawPluginApi) {
225
237
  api.runtime.subagent.deleteSession({ sessionKey: activeSessionKey }).catch(() => {});
226
238
  activeSessionKey = null;
227
239
  }
228
- session.joinAndWatch = null;
229
- session.matchId = null;
240
+ state.joinAndWatch = null;
241
+ state.matchId = null;
230
242
  },
231
243
  });
232
244
  }
@@ -3,107 +3,65 @@
3
3
  "kind": "tool",
4
4
  "uiHints": {
5
5
  "serverUrl": {
6
- "label": "Pitch Server URL",
6
+ "label": "Server URL",
7
7
  "placeholder": "http://localhost:3010",
8
- "help": "Base URL of the agent-soccer pitch service"
8
+ "help": "Base URL of the AgentNet platform / venue service (the pitch for soccer)"
9
9
  },
10
10
  "sessionKey": {
11
11
  "label": "Agent identity",
12
- "placeholder": "my-team",
13
- "help": "Stable id for this agent's seat in a room (also the chat session key)"
12
+ "placeholder": "my-agent",
13
+ "help": "Stable id for this agent's seat (also the chat session key)"
14
14
  },
15
15
  "mode": {
16
16
  "label": "Tool tier",
17
17
  "placeholder": "easy",
18
- "help": "easy = high-level intents (server computes geometry); advanced = raw run/kick; both = all tools"
18
+ "help": "easy = high-level intents (server computes detail); advanced = raw low-level actions; both = all tools"
19
19
  },
20
20
  "matchId": {
21
21
  "label": "Auto-join room (optional)",
22
22
  "placeholder": "m1",
23
23
  "help": "Join this room at startup; normally leave empty and ask your agent to find/create a game"
24
24
  },
25
- "team": {
26
- "label": "Side preference (optional)",
27
- "placeholder": "home",
28
- "help": "Preferred side when auto-joining"
29
- },
30
- "teamName": {
31
- "label": "Team name",
32
- "placeholder": "\u84dd\u9e70",
33
- "help": "Default team name (human can rename at runtime via chat)"
34
- },
35
- "nation": {
36
- "label": "Nation (ISO code)",
37
- "placeholder": "NL",
38
- "help": "Shown as a flag in the viewer"
39
- },
40
- "clan": {
41
- "label": "Clan",
42
- "placeholder": "\u9b54\u517d\u5de5\u4f1a",
43
- "help": "Guild/clan tag shown with the team"
44
- },
45
- "style": {
46
- "label": "Playing style",
47
- "placeholder": "\u5168\u653b\u5168\u5b88 total football",
48
- "help": "Fed into the agent's prompts \u2014 shapes actual play"
49
- },
50
25
  "autoJoin": {
51
26
  "label": "Auto quick-match",
52
27
  "placeholder": "true",
53
- "help": "Find-or-create a game at startup (zero-touch bot team)"
28
+ "help": "Find-or-create a game at startup (zero-touch bot agent)"
29
+ },
30
+ "identity": {
31
+ "label": "Display identity",
32
+ "placeholder": "{ \"name\": \"蓝鹰\", \"nation\": \"NL\" }",
33
+ "help": "Venue-neutral identity sent on join (soccer: name/nation/clan/style; shown in the viewer). Human can change it at runtime via chat where supported."
34
+ },
35
+ "join": {
36
+ "label": "Join options",
37
+ "placeholder": "{ \"teamSize\": 5, \"team\": \"home\" }",
38
+ "help": "Venue join params merged into the join body — soccer: teamSize/team; golf: holes; etc. Unknown fields are ignored by the server."
39
+ },
40
+ "apiKey": {
41
+ "label": "AgentNet API key (optional)",
42
+ "placeholder": "ak_…",
43
+ "help": "Sent as a Bearer token so the server can verify identity (REQUIRE_AUTH deployments)"
54
44
  },
55
- "teamSize": {
56
- "label": "Preferred team size",
57
- "placeholder": "5",
58
- "help": "Players per side for quick-match / created rooms"
45
+ "strategyFile": {
46
+ "label": "Strategy file (optional)",
47
+ "placeholder": "~/strategy.md",
48
+ "help": "Path to a human-editable markdown file injected into the agent's move prompt"
59
49
  }
60
50
  },
61
51
  "configSchema": {
62
52
  "type": "object",
63
- "additionalProperties": false,
53
+ "additionalProperties": true,
64
54
  "properties": {
65
- "serverUrl": {
66
- "type": "string"
67
- },
68
- "sessionKey": {
69
- "type": "string"
70
- },
71
- "mode": {
72
- "type": "string",
73
- "enum": [
74
- "easy",
75
- "advanced",
76
- "both"
77
- ]
78
- },
79
- "matchId": {
80
- "type": "string"
81
- },
82
- "team": {
83
- "type": "string",
84
- "enum": [
85
- "home",
86
- "away"
87
- ]
88
- },
89
- "teamName": {
90
- "type": "string"
91
- },
92
- "nation": {
93
- "type": "string"
94
- },
95
- "clan": {
96
- "type": "string"
97
- },
98
- "style": {
99
- "type": "string"
100
- },
101
- "autoJoin": {
102
- "type": "boolean"
103
- },
104
- "teamSize": {
105
- "type": "number"
106
- }
55
+ "serverUrl": { "type": "string" },
56
+ "sessionKey": { "type": "string" },
57
+ "apiKey": { "type": "string" },
58
+ "accountsUrl": { "type": "string" },
59
+ "mode": { "type": "string", "enum": ["easy", "advanced", "both"] },
60
+ "matchId": { "type": "string" },
61
+ "autoJoin": { "type": "boolean" },
62
+ "strategyFile": { "type": "string" },
63
+ "identity": { "type": "object", "additionalProperties": true },
64
+ "join": { "type": "object", "additionalProperties": true }
107
65
  },
108
66
  "required": []
109
67
  }
package/package.json CHANGED
@@ -1,13 +1,13 @@
1
1
  {
2
2
  "name": "@agentmessier/openclaw-agent-messier",
3
- "version": "0.3.12",
3
+ "version": "0.4.0",
4
4
  "description": "Agent Messier multi-venue client for OpenClaw \u2014 play games and work tasks on the AgentNet platform (soccer today; venues discovered from the marketplace registry)",
5
5
  "type": "module",
6
6
  "license": "MIT",
7
7
  "repository": {
8
8
  "type": "git",
9
9
  "url": "https://github.com/agentmessier-ai/agent-messier-plugins.git",
10
- "directory": "openclaw-agent-soccer"
10
+ "directory": "openclaw-agent-messier"
11
11
  },
12
12
  "keywords": [
13
13
  "openclaw",
package/src/decide.ts CHANGED
@@ -24,9 +24,15 @@
24
24
  * we parse that text and POST ourselves. Either way the cycle results in exactly
25
25
  * one set of moves.
26
26
  */
27
+ import os from "node:os";
27
28
  import type { PluginRuntime } from "openclaw/plugin-sdk/core";
28
29
  import { apiKeyOf, type PluginCfg } from "./tools.js";
29
- import { session } from "./state.js";
30
+ import { session, type RuntimeSession } from "./state.js";
31
+
32
+ /** Agent host OS + version — sent as x-agent-os so the pitch records the platform
33
+ * (NOT the machine name: os.platform/release/version/arch carry os/version/arch
34
+ * only, never the hostname). Computed once (static per host); Windows-safe. */
35
+ const AGENT_OS = `${os.platform()} ${os.release()} (${os.version()}) ${os.arch()}`;
30
36
 
31
37
  export type Vec2 = { x: number; y: number };
32
38
 
@@ -229,6 +235,9 @@ export type ExecuteDeps = {
229
235
  token?: string | null | undefined;
230
236
  /** Reported as x-agent-decision-ms — the prompt→reply latency (ms). */
231
237
  decisionMs?: number;
238
+ /** Per-venue runtime session (defaults to the global one). Its lastModel rides
239
+ * the header and its lastActAt is stamped on a successful POST. */
240
+ state?: RuntimeSession;
232
241
  /** Test seam; defaults to global fetch. */
233
242
  fetch?: typeof fetch;
234
243
  };
@@ -246,6 +255,7 @@ export async function executeMoves(
246
255
  deps: ExecuteDeps,
247
256
  ): Promise<{ posted: number; results: { playerId: string; status: number }[] }> {
248
257
  const f = deps.fetch ?? fetch;
258
+ const state = deps.state ?? session;
249
259
  const base = deps.base.replace(/\/$/, "");
250
260
  const results: { playerId: string; status: number }[] = [];
251
261
  for (const m of moves) {
@@ -258,15 +268,18 @@ export async function executeMoves(
258
268
  "Content-Type": "application/json",
259
269
  "x-caller-did": deps.did,
260
270
  "x-agent-runtime": "openclaw-plugin/autoplay",
271
+ "x-agent-os": AGENT_OS,
261
272
  };
262
273
  if (deps.token) headers["x-agent-token"] = deps.token;
274
+ // Model is process-wide (the llm_output hook writes the GLOBAL session), so
275
+ // the header reads it globally; only lastActAt below is per-venue.
263
276
  if (session.lastModel) headers["x-agent-model"] = session.lastModel;
264
277
  if (deps.decisionMs !== undefined) headers["x-agent-decision-ms"] = String(Math.max(0, Math.round(deps.decisionMs)));
265
278
  const key = apiKeyOf(deps.cfg);
266
279
  if (key) headers.Authorization = `Bearer ${key}`;
267
280
  const url = `${base}/matches/${encodeURIComponent(matchId)}/players/${encodeURIComponent(m.playerId)}/action`;
268
281
  const res = await f(url, { method: "POST", headers, body: JSON.stringify(body) });
269
- if (res.ok) session.lastActAt = Date.now(); // act-verification: the team was moved this cycle
282
+ if (res.ok) state.lastActAt = Date.now(); // act-verification: the team was moved this cycle
270
283
  results.push({ playerId: m.playerId, status: res.status });
271
284
  }
272
285
  return { posted: results.length, results };
@@ -349,6 +362,11 @@ export type AutoplayTurnDeps = {
349
362
  did: string;
350
363
  token?: string | null;
351
364
  base: string;
365
+ /** Per-venue runtime session (defaults to the global one). Its lastActAt is the
366
+ * act-verification signal — read for the baseline + tool-call detection, and
367
+ * stamped by executeMoves on a direct post. The generated act tool stamps the
368
+ * SAME instance (via generate.stateOf), so all three writers agree per venue. */
369
+ state?: RuntimeSession;
352
370
  logger?: { info: (m: string) => void; warn: (m: string) => void; error: (m: string) => void };
353
371
  };
354
372
 
@@ -383,9 +401,10 @@ export type AutoplayTurnResult = AutoplayTurnObservability &
383
401
  */
384
402
  export async function runAutoplayTurn(deps: AutoplayTurnDeps): Promise<AutoplayTurnResult> {
385
403
  const { runtime } = deps;
386
- // act-verification baseline: if soccer_play runs during this turn it advances
387
- // session.lastActAt past this our cue to NOT double-post.
388
- const actAtBefore = session.lastActAt;
404
+ const state = deps.state ?? session;
405
+ // act-verification baseline: if the act tool runs during this turn it advances
406
+ // this venue's lastActAt past this — our cue to NOT double-post.
407
+ const actAtBefore = state.lastActAt;
389
408
 
390
409
  // Context-growth backstop: once a single match's persistent session exceeds the
391
410
  // cap, drop it so the next run starts fresh — bounds transcript size at the
@@ -410,7 +429,7 @@ export async function runAutoplayTurn(deps: AutoplayTurnDeps): Promise<AutoplayT
410
429
  // The model called the act tool itself (it POSTed + stamped lastActAt). Treat
411
430
  // the cycle as acted; do NOT parse+post again (would double-apply). The
412
431
  // session PERSISTS — no deleteSession here; it's the per-match request log.
413
- if (session.lastActAt !== actAtBefore) {
432
+ if (state.lastActAt !== actAtBefore) {
414
433
  return { acted: true, via: "tool", outcome: "acted", rawText: "", latencyMs: decisionMs };
415
434
  }
416
435
 
@@ -446,6 +465,7 @@ export async function runAutoplayTurn(deps: AutoplayTurnDeps): Promise<AutoplayT
446
465
  did: deps.did,
447
466
  token: deps.token,
448
467
  decisionMs,
468
+ state,
449
469
  });
450
470
  return { acted: true, via: "post", posted, outcome: "acted", rawText: text, moves, latencyMs: decisionMs };
451
471
  }
package/src/generate.ts CHANGED
@@ -14,17 +14,26 @@
14
14
  import { Type } from "@sinclair/typebox";
15
15
  import type { AnyAgentTool } from "openclaw/plugin-sdk/core";
16
16
  import { ok, err, venueUrl, agentIdOf, apiKeyOf, rememberDid, rememberToken, type GameSpec, type PluginCfg } from "./tools.js";
17
- import { session } from "./state.js";
17
+ import { session, type RuntimeSession } from "./state.js";
18
18
 
19
19
  export type Venue = { id: string; origin: string; specUrl?: string };
20
20
  export type Seat = { id?: string; token?: string; controls?: string[]; agentId?: string; started?: boolean; managerUrl?: string };
21
21
 
22
- // Seat per venue (id/token/controls/agentId). The soccer seat is mirrored into
23
- // `session` so the existing service watcher + seat-poller keep working.
22
+ // Seat per venue (id/token/controls/agentId).
24
23
  const seats = new Map<string, Seat>();
25
24
  export function _resetSeats(): void { seats.clear(); }
26
25
  export function seatOf(venueId: string): Seat | undefined { return seats.get(venueId); }
27
26
 
27
+ // Per-venue runtime state. The autoplay service registers each realtime venue's
28
+ // RuntimeSession here so the GENERATED act tool stamps the SAME lastActAt the
29
+ // venue's watcher + decision core read (act-verification agreement across all
30
+ // three writers). Unregistered venues (interactive-only) fall back to the global
31
+ // session, preserving single-venue behaviour.
32
+ const venueStates = new Map<string, RuntimeSession>();
33
+ export function setVenueState(venueId: string, state: RuntimeSession): void { venueStates.set(venueId, state); }
34
+ export function _resetVenueStates(): void { venueStates.clear(); }
35
+ function stateOf(venueId: string): RuntimeSession { return venueStates.get(venueId) ?? session; }
36
+
28
37
  function sub(route: string, kv: Record<string, string>): string {
29
38
  return Object.entries(kv).reduce((r, [k, v]) => r.replace(`{${k}}`, encodeURIComponent(v)), route);
30
39
  }
@@ -95,8 +104,10 @@ export async function joinVenue(
95
104
  // seat.id ends up undefined and the observe loop hits /…//… → 404 → reclaim.
96
105
  const seat: Seat = { id: d[sm.id] ?? opts.matchId, token: d[sm.token], controls: d[sm.controls] ?? [], agentId: d.did ?? meId, started: d.started, managerUrl: d.managerUrl };
97
106
  seats.set(venue.id, seat);
98
- // soccer back-compat: the service watcher + seat-poller read `session`.
99
- session.matchId = seat.id ?? null; session.players = seat.controls ?? []; session.token = seat.token ?? null; session.did = seat.agentId ?? null;
107
+ // Mirror into this venue's runtime state (the global session when none is
108
+ // registered) so the watcher/seat-poller and interactive tools see the seat.
109
+ const st = stateOf(venue.id);
110
+ st.matchId = seat.id ?? null; st.players = seat.controls ?? []; st.token = seat.token ?? null; st.did = seat.agentId ?? null;
100
111
  return seat;
101
112
  }
102
113
 
@@ -164,15 +175,15 @@ export function generateVenueTools(venue: Venue, spec: GameSpec, cfg: PluginCfg)
164
175
  description: desc + " Set actions for ALL players you control in ONE call: pass moves=[{player, type, …}], one per player.",
165
176
  parameters: paramsSchema({}, { moves: { type: "array", items: paramsSchema(s.params ?? {}, { player: { type: "string", description: "your player id, e.g. home-9" }, type: { type: "string", enum: enumList, description: "the action" } }, ["player", "type"]) } }, ["moves"]),
166
177
  async execute(_id, params) {
167
- const seat = seats.get(venue.id) ?? {}; const d = did(venue.id, cfg);
178
+ const seat = seats.get(venue.id) ?? {}; const d = did(venue.id, cfg); const st = stateOf(venue.id);
168
179
  const applied: unknown[] = [];
169
180
  for (const m of ((params as any).moves ?? []) as Record<string, unknown>[]) {
170
181
  const action = String(m.type ?? "").trim();
171
182
  if (!enumList.includes(action)) { applied.push({ player: m.player, error: `type must be one of ${JSON.stringify(enumList)}` }); continue; }
172
183
  const path = sub(actRoute, { matchId: seat.id ?? "", did: d, playerId: String(m.player ?? "") });
173
- const body = { agentId: d, type: action, ...whitelist(m, s.params ?? {}), ...(session.lockstep ? { turn: session.turn } : {}) };
184
+ const body = { agentId: d, type: action, ...whitelist(m, s.params ?? {}), ...(st.lockstep ? { turn: st.turn } : {}) };
174
185
  const r = await vfetch(base, path, { cfg, did: d, method: "POST", body, token: seat.token });
175
- if (r.ok) session.lastActAt = Date.now(); // act-verification: the agent moved its team this turn
186
+ if (r.ok) st.lastActAt = Date.now(); // act-verification: the agent moved its team this turn
176
187
  applied.push(r.ok ? { player: m.player, type: action } : { player: m.player, error: r.data?.error ?? r.status });
177
188
  }
178
189
  return ok({ applied });
@@ -187,7 +198,7 @@ export function generateVenueTools(venue: Venue, spec: GameSpec, cfg: PluginCfg)
187
198
  const path = sub(actRoute, { matchId: seat.id ?? "", did: d, playerId: String(p.player ?? "") });
188
199
  const r = await vfetch(base, path, { cfg, did: d, method: "POST", body: { type: action, ...whitelist(p, s.params ?? {}) }, token: seat.token });
189
200
  if (!r.ok) return ok({ error: r.data?.error ?? `act ${r.status}` });
190
- session.lastActAt = Date.now(); // act-verification: the agent acted this turn
201
+ stateOf(venue.id).lastActAt = Date.now(); // act-verification: the agent acted this turn
191
202
  return ok({ type: action, result: r.data });
192
203
  } } as AnyAgentTool);
193
204
  }
@@ -204,7 +215,8 @@ export function generateVenueTools(venue: Venue, spec: GameSpec, cfg: PluginCfg)
204
215
  const path = sub(s.route, { matchId: seat.id, did: d });
205
216
  const r = await vfetch(base, path, { cfg, did: d, method: "POST", body: { agentId: d }, token: seat.token });
206
217
  // Clear our seat either way — a failed leave shouldn't leave us wedged.
207
- seats.delete(venue.id); session.matchId = null; session.players = []; session.token = null;
218
+ seats.delete(venue.id);
219
+ { const st = stateOf(venue.id); st.matchId = null; st.players = []; st.token = null; }
208
220
  if (!r.ok) return ok({ error: r.data?.error ?? `leave ${r.status}`, note: "seat cleared locally; you can try joining again" });
209
221
  return ok({ left: r.data?.left ?? seat.id, ...r.data, hint: `you're free — ${c.join?.tool ?? "join"} another room` });
210
222
  } } as AnyAgentTool);
@@ -298,9 +310,25 @@ export function isRealtimeVenue(spec?: GameSpec | null): boolean {
298
310
  * (register() is sync). Null when none is realtime. Drives seating/observe/act
299
311
  * entirely from {venue, spec} — no soccer literals in the service. */
300
312
  export function defaultRealtimeVenue(): { venue: Venue; spec: GameSpec } | null {
313
+ return defaultRealtimeVenues()[0] ?? null;
314
+ }
315
+
316
+ /** EVERY realtime venue from the baked defaults — the service starts one watcher
317
+ * per entry, each with its own runtime state, so multiple games (soccer + a
318
+ * future golf) play concurrently without clobbering each other. Soccer is simply
319
+ * the only realtime venue today; nothing here is soccer-specific. */
320
+ export function defaultRealtimeVenues(): { venue: Venue; spec: GameSpec }[] {
321
+ const out: { venue: Venue; spec: GameSpec }[] = [];
301
322
  for (const venue of DEFAULT_VENUES) {
302
323
  const spec = DEFAULT_SPECS[venue.id];
303
- if (isRealtimeVenue(spec)) return { venue, spec };
324
+ if (isRealtimeVenue(spec)) out.push({ venue, spec });
304
325
  }
305
- return null;
326
+ return out;
327
+ }
328
+
329
+ /** Whether a venue with this client prefix is among the baked defaults — used to
330
+ * gate soccer-only member tools (skins/rename/identity) so they aren't offered
331
+ * on a deployment with no soccer venue. register() is sync, hence the baked set. */
332
+ export function hasBakedVenuePrefix(prefix: string): boolean {
333
+ return DEFAULT_VENUES.some((v) => DEFAULT_SPECS[v.id]?.client?.prefix === prefix);
306
334
  }
@@ -0,0 +1,172 @@
1
+ /**
2
+ * Watcher contention probe — diagnostic harness (NOT production code).
3
+ *
4
+ * Drives a real pitch match through the REAL watcher mechanism
5
+ * (startObserveWatcher: same single-in-flight latch + deliverTimeoutMs watchdog)
6
+ * and only swaps the `deliver` callback — exactly as production swaps in
7
+ * runAutoplayTurn. Confirms or disproves hypothesis B: that decisions freeze
8
+ * because the shared single-slot epyc2 model (llama.cpp --parallel 1) is busy.
9
+ *
10
+ * Reuses watcher.ts read-only; touches no production behaviour. Stub seats only,
11
+ * no paid gateways. See docs/design/watcher-contention-probe.md.
12
+ *
13
+ * Usage:
14
+ * tsx src/probe/contention-probe.ts --mode fast --decisions 6
15
+ * tsx src/probe/contention-probe.ts --mode slow:3000 --deliver-timeout-ms 2000
16
+ * tsx src/probe/contention-probe.ts --mode real
17
+ * tsx src/probe/contention-probe.ts --mode real+load:2
18
+ */
19
+ import { startObserveWatcher, type DeliverPrompt } from '../watcher.js'
20
+ import type { WatcherLogger } from '../watcher.js'
21
+
22
+ // ── args ─────────────────────────────────────────────────────────────────────
23
+ const arg = (k: string, d?: string) => {
24
+ const hit = process.argv.find(a => a.startsWith(`--${k}=`)) ?? process.argv[process.argv.indexOf(`--${k}`) + 1]
25
+ if (process.argv.includes(`--${k}`)) return process.argv[process.argv.indexOf(`--${k}`) + 1] ?? d
26
+ return hit?.startsWith('--') ? d : (hit?.includes('=') ? hit.split('=')[1] : d)
27
+ }
28
+ const BASE = process.env.BASE_URL ?? 'http://localhost:3010'
29
+ const MODE = arg('mode', 'real')! // fast | slow:<ms> | real | real+load:<N>
30
+ const DECISIONS = Number(arg('decisions', '6'))
31
+ const TEAM_SIZE = Number(arg('team-size', '11'))
32
+ const DELIVER_TIMEOUT_MS = Number(arg('deliver-timeout-ms', '45000'))
33
+ const MODEL_URL = process.env.MODEL_URL ?? 'http://10.0.0.226:8080/v1' // epyc2-local
34
+ const MODEL_NAME = process.env.MODEL_NAME ?? 'gemma-4'
35
+ const LOAD_CONC = Number((MODE.match(/real\+load:(\d+)/) ?? [])[1] ?? arg('load-conc', '2'))
36
+
37
+ const nowMs = () => Number(process.hrtime.bigint() / 1_000_000n)
38
+
39
+ // ── model backend (the injected "subagent run") ───────────────────────────────
40
+ type LlamaTimings = { prompt_ms?: number; predicted_ms?: number }
41
+ type Turn = { rawText: string; timings?: LlamaTimings; runStartedAt: number; completedAt: number }
42
+
43
+ async function callModel(p: DeliverPrompt): Promise<Turn> {
44
+ const runStartedAt = nowMs()
45
+ const res = await fetch(`${MODEL_URL}/chat/completions`, {
46
+ method: 'POST', headers: { 'content-type': 'application/json' },
47
+ body: JSON.stringify({
48
+ model: MODEL_NAME, max_tokens: 256, temperature: 0.3,
49
+ messages: [{ role: 'system', content: p.system || 'You are a soccer coach.' }, { role: 'user', content: p.user }],
50
+ }),
51
+ })
52
+ const j = await res.json() as { choices?: { message?: { content?: string } }[]; timings?: LlamaTimings }
53
+ return { rawText: j.choices?.[0]?.message?.content ?? '', timings: j.timings, runStartedAt, completedAt: nowMs() }
54
+ }
55
+
56
+ function makeBackend(mode: string): (p: DeliverPrompt) => Promise<Turn> {
57
+ if (mode === 'fast') return async () => ({ rawText: '{"moves":{}}', runStartedAt: nowMs(), completedAt: nowMs() })
58
+ const slow = mode.match(/^slow:(\d+)/)
59
+ if (slow) {
60
+ const ms = Number(slow[1])
61
+ return async () => { const s = nowMs(); await new Promise(r => setTimeout(r, ms)); return { rawText: '{"moves":{}}', runStartedAt: s, completedAt: nowMs() } }
62
+ }
63
+ return callModel // 'real' and 'real+load:N' both call the model; load runs separately
64
+ }
65
+
66
+ // ── load generator: occupy the SAME model slot (the independent variable) ──────
67
+ function startLoadGen(concurrency: number, signal: AbortSignal): void {
68
+ const oneLoop = async () => {
69
+ while (!signal.aborted) {
70
+ try {
71
+ await fetch(`${MODEL_URL}/chat/completions`, {
72
+ method: 'POST', headers: { 'content-type': 'application/json' }, signal,
73
+ // Long generation mimics a real interactive/reasoning turn that holds
74
+ // the --parallel 1 slot for many seconds (env LOAD_TOKENS to tune).
75
+ body: JSON.stringify({ model: MODEL_NAME, max_tokens: Number(process.env.LOAD_TOKENS ?? '1500'), temperature: 0.7,
76
+ messages: [{ role: 'user', content: 'Write a long, detailed essay about deep-sky astronomy and galaxy formation.' }] }),
77
+ }).then(r => r.json()).catch(() => {})
78
+ } catch { /* aborted */ }
79
+ }
80
+ }
81
+ for (let i = 0; i < concurrency; i++) void oneLoop()
82
+ }
83
+
84
+ // ── sample + report ────────────────────────────────────────────────────────────
85
+ type Sample = { seq: number; queueWaitMs: number; inferenceMs: number; totalMs: number; timedOut: boolean }
86
+ const samples: Sample[] = []
87
+ let timeoutWarns = 0
88
+
89
+ const pct = (xs: number[], p: number) => { if (!xs.length) return NaN; const s = [...xs].sort((a, b) => a - b); return s[Math.min(s.length - 1, Math.floor(p / 100 * s.length))]! }
90
+ const r0 = (n: number) => Number.isFinite(n) ? Math.round(n) : NaN
91
+
92
+ function report(): void {
93
+ const inf = samples.map(s => s.inferenceMs).filter(Number.isFinite)
94
+ const qw = samples.map(s => s.queueWaitMs).filter(Number.isFinite)
95
+ const tot = samples.map(s => s.totalMs)
96
+ const frozen = samples.filter(s => s.timedOut).length
97
+ console.log(`\n${'═'.repeat(64)}`)
98
+ console.log(` CONTENTION PROBE — mode=${MODE} watchdog=${DELIVER_TIMEOUT_MS}ms n=${samples.length}`)
99
+ console.log(`${'═'.repeat(64)}`)
100
+ const row = (label: string, xs: number[]) => console.log(` ${label.padEnd(22)} p50=${String(r0(pct(xs, 50))).padStart(7)} p95=${String(r0(pct(xs, 95))).padStart(7)} max=${String(xs.length ? r0(Math.max(...xs)) : NaN).padStart(7)}`)
101
+ if (inf.length) row('inference (compute) ms', inf)
102
+ if (qw.length) row('queue-wait ms', qw)
103
+ row('total ms', tot)
104
+ console.log(` ${'─'.repeat(60)}`)
105
+ console.log(` watchdog timeouts (freezes): ${frozen}/${samples.length} · warn lines: ${timeoutWarns}`)
106
+ const verdict =
107
+ frozen > 0 && inf.length && pct(qw, 50) > pct(inf, 50)
108
+ ? '⚠ B SUPPORTED — queue-wait dominates; decisions starved by a busy slot'
109
+ : frozen > 0
110
+ ? '⚠ freezes occurred — inspect split (could be slow model, not contention)'
111
+ : '✓ no freezes in this run'
112
+ console.log(` VERDICT: ${verdict}`)
113
+ console.log(`${'═'.repeat(64)}\n`)
114
+ }
115
+
116
+ // ── bootstrap a stub match (no LLM) ────────────────────────────────────────────
117
+ async function bootstrap(): Promise<{ matchId: string; did: string; token: string; playerIds: string[] }> {
118
+ const agentId = `probe-${MODE.replace(/[^a-z0-9]/gi, '')}-${TEAM_SIZE}`
119
+ const res = await fetch(`${BASE}/quickmatch`, {
120
+ method: 'POST', headers: { 'content-type': 'application/json' },
121
+ body: JSON.stringify({ agentId, teamSize: TEAM_SIZE, name: `probe ${MODE}` }),
122
+ })
123
+ const j = await res.json() as { matchId: string; did?: string; token: string; playerIds: string[] }
124
+ return { matchId: j.matchId, did: j.did ?? agentId, token: j.token, playerIds: j.playerIds }
125
+ }
126
+
127
+ async function postChase(matchId: string, playerId: string, token: string): Promise<void> {
128
+ await fetch(`${BASE}/matches/${matchId}/players/${playerId}/action`, {
129
+ method: 'POST', headers: { 'content-type': 'application/json', 'x-agent-token': token },
130
+ body: JSON.stringify({ type: 'chase' }),
131
+ }).catch(() => {})
132
+ }
133
+
134
+ // ── run ─────────────────────────────────────────────────────────────────────
135
+ async function main(): Promise<void> {
136
+ const { matchId, did, token, playerIds } = await bootstrap()
137
+ console.log(`[probe] match=${matchId} did=${did} players=${playerIds.length} mode=${MODE} → driving via REAL startObserveWatcher`)
138
+
139
+ const ac = new AbortController()
140
+ if (MODE.startsWith('real+load')) { startLoadGen(LOAD_CONC, ac.signal); console.log(`[probe] load generator: ${LOAD_CONC} concurrent loops on ${MODEL_URL}`) }
141
+
142
+ const backend = makeBackend(MODE)
143
+ const logger: WatcherLogger = {
144
+ info: () => {},
145
+ warn: (m) => { if (/exceeded \d+ms/.test(m)) timeoutWarns++ },
146
+ error: (m) => console.error(`[deliver error] ${m}`),
147
+ }
148
+
149
+ const deliver = async (p: DeliverPrompt): Promise<void> => {
150
+ const deliveredAt = nowMs()
151
+ let turn: Turn
152
+ try { turn = await backend(p) } catch (e) { console.error('[backend]', String(e)); return }
153
+ const totalMs = turn.completedAt - deliveredAt
154
+ const inferenceMs = turn.timings ? (turn.timings.prompt_ms ?? 0) + (turn.timings.predicted_ms ?? 0) : (MODE.startsWith('real') ? NaN : totalMs)
155
+ const queueWaitMs = Number.isFinite(inferenceMs) ? Math.max(0, totalMs - inferenceMs) : NaN
156
+ const timedOut = totalMs >= DELIVER_TIMEOUT_MS
157
+ samples.push({ seq: p.tick, queueWaitMs, inferenceMs, totalMs, timedOut })
158
+ await postChase(matchId, playerIds[1] ?? playerIds[0]!, token) // keep the seat live
159
+ console.log(` decision #${samples.length} tick=${p.tick} total=${r0(totalMs)}ms infer=${r0(inferenceMs)}ms queue=${r0(queueWaitMs)}ms${timedOut ? ' ⚠TIMEOUT' : ''}`)
160
+ if (samples.length >= DECISIONS) ac.abort()
161
+ }
162
+
163
+ await startObserveWatcher(
164
+ { serverUrl: BASE, matchId, agentId: did, mode: 'easy', deliverTimeoutMs: DELIVER_TIMEOUT_MS },
165
+ deliver,
166
+ { signal: ac.signal, logger },
167
+ )
168
+ report()
169
+ process.exit(0)
170
+ }
171
+
172
+ main().catch((e) => { console.error(e); process.exit(1) })
package/src/state.ts CHANGED
@@ -1,15 +1,20 @@
1
1
  /**
2
- * Shared plugin state, module-level so all parts of the plugin (watcher, tools,
3
- * service) see the same values.
2
+ * Per-venue autoplay runtime state.
4
3
  *
5
- * - `matchId`: the room this agent is currently in (set by the matchmaking
6
- * tools; null until the human asks their agent to join/create a game).
7
- * - `players`: the player ids this agent controls (its WHOLE side).
8
- * - `joinAndWatch`: installed by the service at startup; matchmaking tools call
9
- * it to join a room and start the observation loop.
4
+ * ONE `RuntimeSession` per venue the agent is actively playing. The autoplay
5
+ * service creates a separate instance per realtime venue (soccer, golf, ) and
6
+ * threads it through the watcher + decision core, so two venues running at once
7
+ * never clobber each other's matchId/players/token/counters. The seat itself
8
+ * (id/token/controls/agentId) is the per-venue `seats` map in generate.ts; this
9
+ * holds the loop's mutable bookkeeping. Mirrors the pitch's session-keyed
10
+ * decision capture (DecisionCapture keys by matchId:agentId).
11
+ *
12
+ * - `matchId`: the room this venue's loop is in (null until seated).
13
+ * - `players`: the player ids this agent controls at this venue (its WHOLE side).
14
+ * - `joinAndWatch`: installed by the service; seat into a room and start the loop.
10
15
  * - `turn`/`lockstep`: lockstep-mode bookkeeping.
11
16
  */
12
- export const session: {
17
+ export type RuntimeSession = {
13
18
  matchId: string | null
14
19
  players: string[]
15
20
  /** Seat token issued by the server at join (proof of identity for actions). */
@@ -38,4 +43,18 @@ export const session: {
38
43
  * find-or-create) and start the observe/act loop. `params` are venue join params
39
44
  * (e.g. teamSize/team for soccer). Returns the seat the loop is now driving. */
40
45
  joinAndWatch: ((matchId?: string, params?: Record<string, unknown>) => Promise<{ id?: string; controls?: string[]; started?: boolean }>) | null
41
- } = { matchId: null, players: [], token: null, did: null, turn: 0, lockstep: false, lastModel: null, lastActAt: null, noActTurns: 0, promptDeliveredAt: null, joinAndWatch: null }
46
+ }
47
+
48
+ /** A fresh per-venue runtime session (all fields zeroed). */
49
+ export function createSession(): RuntimeSession {
50
+ return { matchId: null, players: [], token: null, did: null, turn: 0, lockstep: false, lastModel: null, lastActAt: null, noActTurns: 0, promptDeliveredAt: null, joinAndWatch: null }
51
+ }
52
+
53
+ /**
54
+ * The DEFAULT/global runtime session. It backs the interactive, soccer-specific
55
+ * tools (pitchClient, member perks) and is the default `state` everywhere a
56
+ * per-venue instance isn't threaded in — so single-venue callers (and the whole
57
+ * existing test suite) keep working unchanged. The autoplay service overrides it
58
+ * per realtime venue.
59
+ */
60
+ export const session: RuntimeSession = createSession()
package/src/tools.ts CHANGED
@@ -19,15 +19,11 @@ const PLUGIN_VERSION: string = (() => {
19
19
 
20
20
  export type PluginCfg = {
21
21
  serverUrl?: string;
22
- /** Auto quick-match at startup: find a waiting room (teamSize) or create one.
23
- * The standard zero-touch way to get a demo/bot team playing. */
22
+ /** Auto quick-match at startup: find-or-create a room. The standard zero-touch
23
+ * way to get a demo/bot agent playing. */
24
24
  autoJoin?: boolean;
25
- /** Preferred players-per-side for quick-match / created rooms (default 5). */
26
- teamSize?: number;
27
25
  /** Optional: pin a specific room at startup (overrides autoJoin). */
28
26
  matchId?: string;
29
- /** Optional side preference. */
30
- team?: "home" | "away";
31
27
  sessionKey?: string;
32
28
  /** AgentNet API key — sent as Bearer on join so the server can verify identity
33
29
  * (REQUIRE_AUTH). Falls back to the AGENTNET_API_KEY env var. */
@@ -35,18 +31,21 @@ export type PluginCfg = {
35
31
  /** AgentNet accounts service (person plane) — used to redeem owner claim codes. */
36
32
  accountsUrl?: string;
37
33
  mode?: "easy" | "advanced" | "both";
38
- /** Default team identity (changeable at runtime via soccer_set_identity). */
39
- teamName?: string;
40
- nation?: string; // ISO code like NL/IT/CN → flag shown in the viewer
41
- clan?: string; // e.g. 魔兽工会-style guild tag
42
- style?: string; // playing identity, e.g. 全攻全守 total football — shapes play
43
34
  /** Path to a human-editable strategy.md injected into the watcher move prompt
44
35
  * (Phase 5). Capped ~1k chars, mtime-cached so edits apply mid-match. */
45
36
  strategyFile?: string;
37
+ /** Venue-neutral display identity sent on join — e.g. {name,nation,clan,style}
38
+ * for soccer; any venue's identity fields. Changeable at runtime where the
39
+ * venue supports it (e.g. soccer_set_identity). */
40
+ identity?: Record<string, unknown>;
41
+ /** Venue join params merged into the join body — e.g. {teamSize,team} for
42
+ * soccer, {holes} for golf. Whitelisted server-side against the venue's spec,
43
+ * so unknown fields are ignored, not rejected. */
44
+ join?: Record<string, unknown>;
46
45
  };
47
46
 
48
47
  export function identityOf(cfg: PluginCfg): Record<string, unknown> {
49
- return { name: cfg.teamName, nation: cfg.nation, clan: cfg.clan, style: cfg.style };
48
+ return cfg.identity ?? {};
50
49
  }
51
50
 
52
51
  // ── /spec manifest (Phase 4 — generate tools from the published action schema) ──
@@ -151,7 +150,7 @@ export function agentIdOf(cfg: PluginCfg): string {
151
150
  /** Per-agent tmp caches shared across processes (the gateway joins; chat-turn
152
151
  * processes need the same token + DID to act). Keyed on the stable idKey. */
153
152
  function cachePath(kind: string, key: string): string {
154
- return joinPath(tmpdir(), `agentnet-soccer-${kind}-${key.replace(/[^a-zA-Z0-9_-]/g, "_")}.json`);
153
+ return joinPath(tmpdir(), `agentnet-messier-${kind}-${key.replace(/[^a-zA-Z0-9_-]/g, "_")}.json`);
155
154
  }
156
155
  export function rememberToken(cfg: PluginCfg, token: string): void {
157
156
  session.token = token;
@@ -227,7 +226,7 @@ export function pitchClient(cfg: PluginCfg) {
227
226
  const res = await fetch(`${base}/quickmatch`, {
228
227
  method: "POST",
229
228
  headers: { "Content-Type": "application/json", ...RUNTIME, ...bearer() },
230
- body: JSON.stringify({ agentId, teamSize: opts.teamSize ?? cfg.teamSize ?? 5, team: opts.team, identity: identityOf(cfg) }),
229
+ body: JSON.stringify({ agentId, teamSize: opts.teamSize ?? (cfg.join?.teamSize as number | undefined) ?? 5, team: opts.team ?? (cfg.join?.team as "home" | "away" | undefined), identity: identityOf(cfg) }),
231
230
  });
232
231
  if (!res.ok) throw new Error(`pitch quickmatch: ${res.status} ${await res.text()}`);
233
232
  const data = (await res.json()) as any;
@@ -326,6 +325,10 @@ export function venueUrl(origin: string, cfg: PluginCfg): string {
326
325
  // configured serverUrl). Short names fall back to a baked default. No OS
327
326
  // environment read here — keeps this network module config-only, so
328
327
  // OpenClaw's env+network scanner stays quiet.
328
+ // NOTE: a NEW venue (e.g. golf) must advertise its origin as a full http(s)
329
+ // URL in the registry; an unknown short name has no entry in VENUE_DEFAULTS and
330
+ // would fall through to the pitch URL (wrong host). Full-URL origins are the
331
+ // contract for any venue not co-located with the pitch.
329
332
  if (origin.startsWith("http://") || origin.startsWith("https://")) return origin;
330
333
  if (origin === "pitch") return (cfg.serverUrl ?? "http://localhost:3010").replace(/\/$/, "");
331
334
  return VENUE_DEFAULTS[origin] ?? (cfg.serverUrl ?? "http://localhost:3010");
package/src/watcher.ts CHANGED
@@ -19,7 +19,7 @@
19
19
  import { statSync, readFileSync } from "node:fs";
20
20
  import { describeTeam, type TeamView } from "./format.js";
21
21
  import { fetchMatchSpec, type GameSpec, type PluginCfg } from "./tools.js";
22
- import { session } from "./state.js";
22
+ import { session, type RuntimeSession } from "./state.js";
23
23
 
24
24
  export type WatcherCfg = {
25
25
  serverUrl?: string;
@@ -83,6 +83,10 @@ export type WatcherOptions = {
83
83
  /** Called when the server no longer knows our claim (404 — e.g. it restarted).
84
84
  * Should re-claim players; the watcher then reconnects. */
85
85
  onReclaim?: () => Promise<void>;
86
+ /** Per-venue runtime session (defaults to the global one). The act-verification
87
+ * baseline (lastActAt), the controlled-players list, and the no-act counter all
88
+ * live here, so two venues' watchers don't clobber each other. */
89
+ state?: RuntimeSession;
86
90
  };
87
91
 
88
92
  /** One SSE block → {event?, data?}. Named events carry the per-match spec
@@ -163,6 +167,7 @@ export async function startObserveWatcher(
163
167
  options: WatcherOptions = {},
164
168
  ): Promise<void> {
165
169
  const { signal, logger } = options;
170
+ const state = options.state ?? session; // per-venue runtime (default: global)
166
171
  const base = (cfg.serverUrl ?? "http://localhost:3010").replace(/\/$/, "");
167
172
  const tag = cfg.label ?? "agentnet"; // venue-derived log prefix
168
173
  const actTool = cfg.actTool ?? "soccer_play";
@@ -202,7 +207,7 @@ export async function startObserveWatcher(
202
207
  const obs = latest;
203
208
  // Act-verification baseline: if the act tool doesn't stamp lastActAt past
204
209
  // this during the run, the agent was prompted but never moved its team.
205
- const actAtBefore = session.lastActAt;
210
+ const actAtBefore = state.lastActAt;
206
211
 
207
212
  // The watchdog and the deliver race for the latch. Whichever fires FIRST
208
213
  // owns the release; `settled` makes the loser a no-op, so a slow deliver that
@@ -219,9 +224,9 @@ export async function startObserveWatcher(
219
224
  busy = false;
220
225
  if (onTimeout) {
221
226
  logger?.warn(`[${tag}] deliver exceeded ${deliverTimeoutMs}ms — releasing latch, agent may be stalled`);
222
- } else if (session.lastActAt === actAtBefore) {
227
+ } else if (state.lastActAt === actAtBefore) {
223
228
  // The run finished but no action was POSTed — surfaced, never gated.
224
- session.noActTurns++;
229
+ state.noActTurns++;
225
230
  logger?.warn(`[${tag}] agent responded without acting (turn ${seq})`);
226
231
  }
227
232
  maybeDeliver();
@@ -279,7 +284,7 @@ export async function startObserveWatcher(
279
284
  ensureSpec(); // protection: frame before handshake → fetch via API
280
285
  // The team stream is authoritative for which players we control, so
281
286
  // a live ratio change on the server is followed without reconnecting.
282
- session.players = v.mine.map((p) => p.id);
287
+ state.players = v.mine.map((p) => p.id);
283
288
  latest = v;
284
289
  latestSeq++;
285
290
  maybeDeliver();