@agentmessier/openclaw-agent-messier 0.3.1 → 0.3.3

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
@@ -116,13 +116,16 @@ export default function register(api: OpenClawPluginApi) {
116
116
  else ctx.logger.info(`[${label}] idle — ask me to join or create a game.`);
117
117
  } catch (e) { ctx.logger.error(`[${label}] startup seating failed: ${String(e)}`); }
118
118
 
119
- // Seat poller: a seat may be taken from ANOTHER process (a chat turn, the
120
- // generated *_join tool, the dashboard). Poll the venue's lobby for a
121
- // non-ended room that references our agentId and aim the loop at it. Driven
122
- // by spec.client.lobby.route — no hardcoded /matches path. Skipped for
123
- // venues with no lobby in their spec.
124
- if (lobbyRoute) {
119
+ // Seat poller: when the agent is IDLE, a seat may be taken from ANOTHER
120
+ // process (a chat turn, the generated *_join tool, the dashboard). Poll the
121
+ // venue's lobby for a non-ended room referencing our agentId and adopt it.
122
+ // Driven by spec.client.lobby.route — no hardcoded /matches path. Skipped
123
+ // when a match is pinned (explicit room wins) or the venue has no lobby.
124
+ // Critically it only adopts while idle (session.matchId empty): once we're
125
+ // in a match it must NOT yank us into some other (e.g. stale) room.
126
+ if (lobbyRoute && !cfg.matchId) {
125
127
  poller = setInterval(async () => {
128
+ if (session.matchId) return; // already seated/playing → nothing to adopt
126
129
  try {
127
130
  const res = await fetch(`${base}${lobbyRoute}`);
128
131
  if (!res.ok) return;
@@ -130,7 +133,7 @@ export default function register(api: OpenClawPluginApi) {
130
133
  const rows = (data.matches ?? data.rows ?? []) as Record<string, any>[];
131
134
  const mine = rows.find((r) => !ENDED.has(String(r.status ?? "")) && referencesAgent(r, agentId));
132
135
  const id = mine?.id ?? mine?.[roomIdField];
133
- if (id && id !== session.matchId) {
136
+ if (id) {
134
137
  ctx.logger.info(`[${label}] found my seat in ${id} (taken elsewhere) — starting to play`);
135
138
  await session.joinAndWatch!(id);
136
139
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agentmessier/openclaw-agent-messier",
3
- "version": "0.3.1",
3
+ "version": "0.3.3",
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",
@@ -17,8 +17,9 @@ const GOLF_SPEC: GameSpec = {
17
17
  client: {
18
18
  prefix: "golf", noun: "round",
19
19
  lobby: { tool: "golf_rounds", route: "/rounds", params: {}, summary: "List rounds." },
20
- join: { tool: "golf_join", route: "/quickround", seatRoute: "/rounds/{matchId}/join", params: { holes: { type: "integer" } }, seat: { id: "matchId", token: "token", controls: "playerIds" }, summary: "Join a round." },
20
+ join: { tool: "golf_join", route: "/quickround", seatRoute: "/rounds/{matchId}/join", params: { holes: { type: "integer" }, matchId: { type: "string" } }, seat: { id: "matchId", token: "token", controls: "playerIds" }, summary: "Join a round." },
21
21
  autoplay: { tool: "golf_autoplay", summary: "Hands-free." },
22
+ leave: { tool: "golf_leave", route: "/rounds/{matchId}/leave", summary: "Leave the round (forfeit if live)." },
22
23
  observe: { tool: "golf_observe", params: {}, summary: "See the course." },
23
24
  act: { tool: "golf_play", params: { club: { type: "string" } }, summary: "Swing." },
24
25
  },
@@ -38,7 +39,7 @@ describe("generated tool surface comes entirely from the spec", () => {
38
39
  _resetSeats();
39
40
  const tools = generateVenueTools(GOLF_VENUE, GOLF_SPEC, cfg());
40
41
  const names = tools.map(t => t.name);
41
- expect(names).toEqual(["golf_rounds", "golf_join", "golf_observe", "golf_play"]);
42
+ expect(names).toEqual(["golf_rounds", "golf_join", "golf_observe", "golf_play", "golf_leave"]);
42
43
 
43
44
  // The act tool is BATCH (golf seats players) and carries the spec's action enum.
44
45
  const play = tools.find(t => t.name === "golf_play")!;
@@ -86,6 +87,17 @@ describe("joinVenue — the one spec-driven seating path (tool + service share i
86
87
  expect(url).toBe("http://golf.test/rounds/r7/join");
87
88
  });
88
89
 
90
+ it("rejoin response without the seat-id field falls back to the matchId joined with", async () => {
91
+ _resetSeats();
92
+ // per-room join responses omit matchId (you already know it from the URL) —
93
+ // seat.id must still resolve, or the observe loop 404s and reclaim-loops.
94
+ vi.stubGlobal("fetch", vi.fn(async () =>
95
+ ({ ok: true, json: async () => ({ token: "t", playerIds: ["p1", "p2", "p3"], started: true }) }) as any));
96
+ const seat = await joinVenue(GOLF_VENUE, GOLF_SPEC, cfg(), { matchId: "r7" });
97
+ expect(seat.id).toBe("r7");
98
+ expect(session.matchId).toBe("r7");
99
+ });
100
+
89
101
  it("service extras (teamSize/identity) ride along but tool calls omit them", async () => {
90
102
  let body: any = null;
91
103
  vi.stubGlobal("fetch", vi.fn(async (_u: any, init?: any) => {
@@ -111,3 +123,59 @@ describe("realtime-venue detection (which venue the autoplay watcher drives)", (
111
123
  expect(rt?.spec.client?.act.tool).toBe("soccer_play");
112
124
  });
113
125
  });
126
+
127
+ describe("join-by-id and leave (the lifecycle gap closed in VA-6)", () => {
128
+ afterEach(() => { vi.unstubAllGlobals(); _resetSeats(); });
129
+
130
+ async function exec(name: string, params: unknown) {
131
+ const t = generateVenueTools(GOLF_VENUE, GOLF_SPEC, cfg()).find(x => x.name === name)!;
132
+ const r = await t.execute("id", params as any) as { content: { text: string }[] };
133
+ return JSON.parse(r.content[0]!.text);
134
+ }
135
+
136
+ it("the join tool routes a matchId param through seatRoute (not the body)", async () => {
137
+ let url = "", body: any = null;
138
+ vi.stubGlobal("fetch", vi.fn(async (u: any, init?: any) => {
139
+ url = String(u); body = JSON.parse(init.body);
140
+ return { ok: true, json: async () => ({ token: "t", playerIds: ["p1"] }) } as any;
141
+ }));
142
+ const out = await exec("golf_join", { matchId: "r9", holes: 9 });
143
+ expect(url).toBe("http://golf.test/rounds/r9/join"); // seatRoute, not /quickround
144
+ expect(body.holes).toBe(9);
145
+ expect(body.matchId).toBeUndefined(); // matchId is routing, never a body field
146
+ expect(out.joined).toBe("r9");
147
+ });
148
+
149
+ it("omitting matchId quickmatches (find-or-create) via the join route", async () => {
150
+ let url = "";
151
+ vi.stubGlobal("fetch", vi.fn(async (u: any) => {
152
+ url = String(u);
153
+ return { ok: true, json: async () => ({ matchId: "r1", token: "t", playerIds: ["p1"] }) } as any;
154
+ }));
155
+ await exec("golf_join", { holes: 9 });
156
+ expect(url).toBe("http://golf.test/quickround");
157
+ });
158
+
159
+ it("the leave tool posts to the leave route and frees the seat locally", async () => {
160
+ // seat first so there's a match to leave
161
+ vi.stubGlobal("fetch", vi.fn(async () => ({ ok: true, json: async () => ({ matchId: "r5", token: "t", playerIds: ["p1"] }) }) as any));
162
+ await joinVenue(GOLF_VENUE, GOLF_SPEC, cfg(), { matchId: "r5" });
163
+ expect(session.matchId).toBe("r5");
164
+
165
+ let url = "", method = "";
166
+ vi.stubGlobal("fetch", vi.fn(async (u: any, init?: any) => {
167
+ url = String(u); method = init?.method;
168
+ return { ok: true, json: async () => ({ left: "r5", forfeit: true, winner: "away" }) } as any;
169
+ }));
170
+ const out = await exec("golf_leave", {});
171
+ expect(method).toBe("POST");
172
+ expect(url).toBe("http://golf.test/rounds/r5/leave");
173
+ expect(out.left).toBe("r5");
174
+ expect(session.matchId).toBeNull(); // freed → ready to join elsewhere
175
+ });
176
+
177
+ it("leaving when not in a match is a no-op error, not a crash", async () => {
178
+ const out = await exec("golf_leave", {});
179
+ expect(out.error).toMatch(/not in a match/i);
180
+ });
181
+ });
package/src/generate.ts CHANGED
@@ -85,7 +85,10 @@ export async function joinVenue(
85
85
  const d = r.data;
86
86
  if (typeof d.did === "string") rememberDid(cfg, d.did); // cross-process identity
87
87
  if (typeof d.token === "string") rememberToken(cfg, d.token); // cross-process seat token
88
- const seat: Seat = { id: d[sm.id], token: d[sm.token], controls: d[sm.controls] ?? [], agentId: d.did ?? meId, started: d.started, managerUrl: d.managerUrl };
88
+ // A rejoin via seatRoute already KNOWS the room (it's in the URL), so the
89
+ // server's response omits it — fall back to the matchId we joined with, or
90
+ // seat.id ends up undefined and the observe loop hits /…//… → 404 → reclaim.
91
+ 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 };
89
92
  seats.set(venue.id, seat);
90
93
  // soccer back-compat: the service watcher + seat-poller read `session`.
91
94
  session.matchId = seat.id ?? null; session.players = seat.controls ?? []; session.token = seat.token ?? null; session.did = seat.agentId ?? null;
@@ -117,7 +120,10 @@ export function generateVenueTools(venue: Venue, spec: GameSpec, cfg: PluginCfg)
117
120
  out.push({ name: s.tool, label: s.tool, description: s.summary, parameters: paramsSchema(s.params ?? {}),
118
121
  async execute(_id, params) {
119
122
  try {
120
- const seat = await joinVenue(venue, spec, cfg, { params: params as Record<string, unknown> });
123
+ // matchId (if the spec advertises it) targets a SPECIFIC room via
124
+ // seatRoute; it's routing, not a body field, so pull it out of params.
125
+ const { matchId: mid, ...rest } = (params ?? {}) as Record<string, unknown>;
126
+ const seat = await joinVenue(venue, spec, cfg, { matchId: typeof mid === "string" && mid ? mid : undefined, params: rest });
121
127
  return ok({ joined: seat.id, yours: seat.controls, watchUrl: `${base}/matches/${seat.id}/view`, managerUrl: seat.managerUrl,
122
128
  note: `seated. observe with ${c.observe.tool}, then ${c.act.tool}.${seat.managerUrl ? " GIVE YOUR HUMAN the managerUrl — their console for this room." : ""}` });
123
129
  } catch (e) { return ok({ error: String(e instanceof Error ? e.message : e) }); }
@@ -177,6 +183,23 @@ export function generateVenueTools(venue: Venue, spec: GameSpec, cfg: PluginCfg)
177
183
  }
178
184
  }
179
185
 
186
+ // leave — exit the current match (a forfeit if it's live). Frees the seat so
187
+ // the agent can join another room. Server route from spec.client.leave.
188
+ if (c.leave) {
189
+ const s = c.leave;
190
+ out.push({ name: s.tool, label: s.tool, description: s.summary, parameters: paramsSchema(s.params ?? {}),
191
+ async execute() {
192
+ const seat = seats.get(venue.id) ?? {}; const d = did(venue.id, cfg);
193
+ if (!seat.id) return ok({ error: "you are not in a match" });
194
+ const path = sub(s.route, { matchId: seat.id, did: d });
195
+ const r = await vfetch(base, path, { cfg, did: d, method: "POST", body: { agentId: d }, token: seat.token });
196
+ // Clear our seat either way — a failed leave shouldn't leave us wedged.
197
+ seats.delete(venue.id); session.matchId = null; session.players = []; session.token = null;
198
+ if (!r.ok) return ok({ error: r.data?.error ?? `leave ${r.status}`, note: "seat cleared locally; you can try joining again" });
199
+ return ok({ left: r.data?.left ?? seat.id, ...r.data, hint: `you're free — ${c.join?.tool ?? "join"} another room` });
200
+ } } as AnyAgentTool);
201
+ }
202
+
180
203
  return out;
181
204
  }
182
205
 
@@ -223,10 +246,11 @@ const DEFAULT_SPECS: Record<string, GameSpec> = {
223
246
  client: {
224
247
  prefix: "soccer", noun: "match",
225
248
  lobby: { tool: "soccer_matches", route: "/matches", params: { status: { type: "string", enum: ["live", "waiting", "ended"] } }, summary: "List soccer matches: live, open seats, scores." },
226
- join: { tool: "soccer_join", route: "/quickmatch", seatRoute: "/matches/{matchId}/join", params: { teamSize: { type: "integer" }, team: { type: "string" }, name: { type: "string" }, nation: { type: "string" }, clan: { type: "string" }, style: { type: "string" } }, seat: { id: "matchId", token: "token", controls: "playerIds" }, summary: "Join a match and take a whole side (quickmatch). The match starts when both sides fill." },
249
+ join: { tool: "soccer_join", route: "/quickmatch", seatRoute: "/matches/{matchId}/join", params: { teamSize: { type: "integer" }, team: { type: "string" }, name: { type: "string" }, nation: { type: "string" }, clan: { type: "string" }, style: { type: "string" }, matchId: { type: "string", description: "join THIS room (e.g. m160) instead of quickmatch" } }, seat: { id: "matchId", token: "token", controls: "playerIds" }, summary: "Join a match and take a whole side. Pass matchId for a specific room, else quickmatch." },
227
250
  observe: { tool: "soccer_observe", params: {}, summary: "See the pitch from your side's POV: ball, your players, opponents, score." },
228
251
  act: { tool: "soccer_play", params: { dir: { type: "array", items: { type: "number" } }, distance: { type: "number" }, power: { type: "number" }, say: { type: "string" } }, summary: "Order your players — a standing action per player (run/kick need dir)." },
229
252
  autoplay: { tool: "soccer_autoplay", summary: "Hands-free play (handled by the watcher service)." },
253
+ leave: { tool: "soccer_leave", route: "/matches/{matchId}/leave", summary: "Leave your match (forfeit if live — opponent wins). Frees you to join another room." },
230
254
  },
231
255
  },
232
256
  "taskmarket": {
package/src/tools.ts CHANGED
@@ -63,6 +63,7 @@ export type GameSpec = {
63
63
  observe: { tool: string; params?: Record<string, unknown>; summary: string };
64
64
  act: { tool: string; params?: Record<string, unknown>; summary: string };
65
65
  autoplay?: { tool: string; summary: string };
66
+ leave?: { tool: string; route: string; params?: Record<string, unknown>; summary: string } | null;
66
67
  };
67
68
  };
68
69