trip-moments-skill 1.2.0 → 1.3.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.
@@ -355,7 +355,9 @@ def _claw_module():
355
355
 
356
356
 
357
357
  class OpenClawTransport:
358
- """Persistent-session transport over ClawHive's chat-completions endpoint.
358
+ """Persistent-session transport over an OpenClaw gateway's chat-completions
359
+ endpoint — either ClawHive's embedded gateway or the OpenClaw desktop app's
360
+ WSL-backed one (both are `openclaw`, discovered by clawbridge).
359
361
 
360
362
  The gateway serves ONE turn per session lane (#235): `start()` is a full
361
363
  blocking agent turn, not a run-id handle. We still model it as
@@ -366,6 +368,13 @@ class OpenClawTransport:
366
368
  """
367
369
 
368
370
  provider = "openclaw"
371
+ # The WSL gateway's session book is SQLite inside the distro and the wsl$
372
+ # UNC paths are unreachable from Windows, so there is no "the conversation
373
+ # the user is looking at" to bind to there. This explicit key gives the
374
+ # workbench one stable thread instead: the gateway routes it to a session of
375
+ # its own (only `subagent:`/`cron:`/`acp:` are reserved) and repeated turns
376
+ # continue that conversation rather than starting fresh each time.
377
+ WORKBENCH_SESSION = "studio-workbench"
369
378
 
370
379
  def __init__(self, cwd=""):
371
380
  self.claw = _claw_module()
@@ -378,28 +387,45 @@ class OpenClawTransport:
378
387
  def probe(self):
379
388
  info = self.claw.discover(force=True)
380
389
  if not info.get("base"):
381
- raise AgentError("未找到运行中的 ClawHive gateway:" + (info.get("why") or ""))
390
+ # A gateway that answered but has the opt-in endpoint disabled is NOT
391
+ # the same as no gateway: say which one it was and what to turn on,
392
+ # otherwise the only message the user gets is 「未找到」 while their
393
+ # OpenClaw is sitting right there on 18789.
394
+ why = info.get("why") or ""
395
+ if info.get("via"):
396
+ raise AgentError(f"{info.get('name') or 'OpenClaw'} 通道不可用:{why}")
397
+ raise AgentError("未找到运行中的 OpenClaw gateway:" + why)
382
398
  if not info.get("enabled"):
383
- raise AgentError("ClawHive gateway 的 OpenAI 兼容端点未启用:" + (info.get("why") or ""))
399
+ raise AgentError("OpenClaw gateway 的 OpenAI 兼容端点未启用:" + (info.get("why") or ""))
384
400
  return {"ready": True, "provider": self.provider, "native_sessions": True,
385
401
  "streaming": False, "steer": False, "approvals": False,
386
- "port": info.get("port"), "agents": info.get("agents") or []}
402
+ "port": info.get("port"), "agents": info.get("agents") or [],
403
+ "gateway": info.get("name") or "", "via": info.get("via") or ""}
387
404
 
388
405
  def sessions(self):
389
- # The desktop conversation list belongs to ClawHive's own UI; offering
406
+ # The desktop conversation list belongs to that app's own UI; offering
390
407
  # it here would invite binding to a session nobody is looking at.
391
408
  return []
392
409
 
393
410
  def create(self, title, cwd):
394
- # There is no session-creation endpoint: we bind to the conversation
395
- # the user has open right now (see bind).
411
+ # There is no session-creation endpoint on this API surface; the bind
412
+ # below decides which thread turns will land in.
396
413
  return ""
397
414
 
398
415
  def bind(self, sid):
399
- key = sid or self.claw.active_session()
400
- if not key:
401
- raise AgentError("没有找到正在使用的 ClawHive 会话;先在 ClawHive 里打开一个对话")
402
- return key
416
+ if sid:
417
+ return sid
418
+ key = self.claw.active_session()
419
+ if key:
420
+ return key # ClawHive: the open conversation
421
+ info = self.claw.discover()
422
+ if info.get("via"):
423
+ # Keyed off `via`, not `base`: a gateway that ANSWERED but has the
424
+ # endpoint disabled is still a gateway (that is the default state),
425
+ # and probe() already refused with the exact config key before we get
426
+ # here. Binding then succeeds and the first turn reports the truth.
427
+ return self.WORKBENCH_SESSION # WSL gateway: our own thread
428
+ raise AgentError("没有找到正在使用的 ClawHive 会话;先在 ClawHive 里打开一个对话")
403
429
 
404
430
  # -- turns ---------------------------------------------------------------
405
431
  def start(self, sid, text, key):
@@ -83,7 +83,7 @@ AGENT_SHELL = r"""
83
83
  <div class="ap-body" id="ap-body">
84
84
  <details class="ap-setup" id="ap-setup" open><summary>连接与会话绑定</summary>
85
85
  <label for="ap-kind">Agent 通道</label><select id="ap-kind" onchange="agentProviderHint()">
86
- <option value="hermes">Hermes · 原生持续会话</option><option value="codex">Codex App Server · 工作台专用会话</option><option value="openclaw">OpenClaw (ClawHive) · 活跃会话直连</option></select>
86
+ <option value="hermes">Hermes · 原生持续会话</option><option value="codex">Codex App Server · 工作台专用会话</option><option value="openclaw">OpenClaw gateway · 整轮直连(ClawHive 或 WSL)</option></select>
87
87
  <div id="ap-hermes-fields"><label for="ap-url">本机 Hermes API 地址</label><input id="ap-url" value="http://127.0.0.1:8642">
88
88
  <label for="ap-key">API 密钥(留空读取本机 Hermes 配置)</label><input id="ap-key" type="password" autocomplete="off"></div>
89
89
  <p class="note" id="ap-hint" style="margin-top:8px"></p>
@@ -130,6 +130,7 @@ function deckControlsHtml() {
130
130
  <p class="note" id="wf-mode-note">${agent
131
131
  ? "用已绑定 Agent 的模型、工具与会话上下文;检查、处理、追问都在本卡完成。"
132
132
  : "用配置的模型网关;AI 分析会发送照片预览,不进入 Agent 会话。"}</p>
133
+ <p class="note" id="claw-note" style="display:none"></p>
133
134
  </div>`;
134
135
  }
135
136
  /* One input box serves both 「本步要求」 and 「继续追问」, so the session buttons
@@ -275,7 +276,7 @@ function agentProviderHint() {
275
276
  ? "绑定 Hermes 原生会话,后续回复与步骤按钮都延续该会话。连接密钥仅由本机服务使用。"
276
277
  : kind === "codex"
277
278
  ? "使用本机已登录的 Codex CLI,创建工作台专用的持续任务;不接管你当前 Codex 桌面聊天。工具批准仍需你确认。"
278
- : "直连 ClawHive 内嵌的 OpenClaw gateway,绑定你正在看的那个会话;整轮执行,无中间批准。需在 openclaw.json 启用 chatCompletions 端点。";
279
+ : "直连 OpenClaw gateway —— ClawHive 内嵌的那个,或 OpenClaw 桌面版在 WSL 里跑的那个。整轮执行,无中间批准;需在该 gateway 的 openclaw.json 里启用 chatCompletions 端点。ClawHive 上会绑定你正在看的会话;WSL gateway 读不到会话簿,改用工作台专用会话。";
279
280
  }
280
281
  async function pollAgent() {
281
282
  if (AP.polling) return;
@@ -10,20 +10,33 @@ So a click inside Studio can turn into a real agent turn, and the agent's own
10
10
  words can be shown back in the workbench — which is the whole point of the demo:
11
11
  showing curation *driven by* an agent, not a silent local script.
12
12
 
13
- Discovery is by filesystem, not hardcoded ports: ClawHive writes the live port
14
- to `…/openclaw/state/gateway-port.json` and the bearer token next to it as
15
- `gateway-token`. Account/tenant ids vary per install, so we glob.
16
-
17
- Everything degrades gracefully: if ClawHive is not running or the endpoint is
18
- disabled, `discover()` returns None and Studio simply hides the panel.
19
- """
20
- from __future__ import annotations
21
-
22
- import glob
23
- import json
24
- import os
25
- import sys
26
- import time
13
+ Discovery is by filesystem, not hardcoded ports — and there are TWO families of
14
+ gateway to find, because "OpenClaw" ships in two very different shapes:
15
+
16
+ 1. ClawHive's embedded gateway. ClawHive writes the live port to
17
+ `…/openclaw/state/gateway-port.json` and the bearer token next to it as
18
+ `gateway-token`. Account/tenant ids vary per install, so we glob.
19
+ 2. The OpenClaw desktop app (`OpenClawTray`), which provisions its own WSL distro
20
+ (default `OpenClawGateway`) and runs `openclaw gateway --port 18789` inside it.
21
+ Nothing lands in a Windows-visible `~/.openclaw/state`; the app publishes the
22
+ gateway URL and shared token in `%APPDATA%/OpenClawTray/gateways.json`, and
23
+ Windows reaches the WSL listener through WSL2 localhost forwarding. Both
24
+ families multiplex WS + HTTP on the SAME port, and both serve the same
25
+ OpenAI-compatible surface — but only when it is enabled in that gateway's
26
+ `openclaw.json` (`gateway.http.endpoints.chatCompletions.enabled`).
27
+
28
+ Everything degrades gracefully: if no gateway is running, or the endpoint is
29
+ disabled, `discover()` returns a not-found info carrying an honest `why` and
30
+ Studio simply hides the panel.
31
+ """
32
+ from __future__ import annotations
33
+
34
+ import glob
35
+ import json
36
+ import os
37
+ import re
38
+ import sys
39
+ import time
27
40
  from pathlib import Path
28
41
 
29
42
  if sys.platform == "win32":
@@ -33,20 +46,106 @@ if sys.platform == "win32":
33
46
  except Exception:
34
47
  pass
35
48
 
36
- _APPDATA = os.environ.get("APPDATA") or str(Path.home() / "AppData" / "Roaming")
37
- _STATE_GLOBS = [
38
- # ClawHive desktop (per account / per tenant)
39
- os.path.join(_APPDATA, "ClawHiveApp", "accounts", "*", "tenants", "*",
40
- "openclaw", "state"),
41
- # standalone OpenClaw installs
42
- os.path.join(Path.home(), ".openclaw", "state"),
43
- os.path.join(Path.home(), ".config", "openclaw", "state"),
44
- ]
45
-
46
- _CACHE: dict = {"at": 0.0, "info": None}
47
- _CACHE_TTL = 20.0
48
-
49
-
49
+ _APPDATA = os.environ.get("APPDATA") or str(Path.home() / "AppData" / "Roaming")
50
+ _STATE_GLOBS = [
51
+ # ClawHive desktop (per account / per tenant)
52
+ os.path.join(_APPDATA, "ClawHiveApp", "accounts", "*", "tenants", "*",
53
+ "openclaw", "state"),
54
+ # standalone OpenClaw installs
55
+ os.path.join(Path.home(), ".openclaw", "state"),
56
+ os.path.join(Path.home(), ".config", "openclaw", "state"),
57
+ ]
58
+ _OPENCLAW_TRAY = os.path.join(_APPDATA, "OpenClawTray", "gateways.json")
59
+ _LOCAL_HOST = re.compile(r"^(?:127\.0\.0\.1|localhost|\[::1\]):(\d{2,5})$")
60
+
61
+ _CACHE: dict = {"at": 0.0, "info": None}
62
+ _CACHE_TTL = 20.0
63
+
64
+
65
+ def tray_gateways() -> list[dict]:
66
+ """Gateways published by the OpenClaw desktop app (the WSL-backed one).
67
+
68
+ `openclaw` 2026.x installs as a Windows tray app that creates its own WSL
69
+ distro and runs the gateway inside it (verified on this machine 2026-10-04:
70
+ distro `OpenClawGateway`, `node …/openclaw/dist/index.js gateway --port
71
+ 18789`, `gateway.mode: local`, `bind: loopback`, `auth.mode: token`).
72
+ Windows reaches it because WSL2 forwards localhost; the app records the URL
73
+ and the shared token here, which happens to be the same value as
74
+ `gateway.auth.token` inside the distro. We never read the token out of WSL.
75
+
76
+ Only loopback URLs are accepted: a remote `sshTunnel`/LAN gateway in this
77
+ file is not something the workbench should silently send photos to.
78
+ """
79
+ try:
80
+ raw = json.loads(Path(_OPENCLAW_TRAY).read_text(encoding="utf-8"))
81
+ except (OSError, ValueError):
82
+ return []
83
+ out = []
84
+ for gw in (raw.get("gateways") or []):
85
+ if not isinstance(gw, dict):
86
+ continue
87
+ token = str(gw.get("sharedGatewayToken") or "").strip()
88
+ url = str(gw.get("url") or "").strip()
89
+ m = re.match(r"^wss?://([^/]+)/?$", url)
90
+ if not token or not m:
91
+ continue
92
+ host = m.group(1)
93
+ port = _LOCAL_HOST.match(host)
94
+ if not port:
95
+ continue # not loopback -> never used
96
+ out.append({"base": f"http://{host}", "token": token,
97
+ "port": int(port.group(1)),
98
+ "name": str(gw.get("friendlyName") or "OpenClaw"),
99
+ "distro": str(gw.get("setupManagedDistroName") or "")})
100
+ return out
101
+
102
+
103
+ def _probe_endpoint(entry: dict) -> dict:
104
+ """Fill `agents`/`endpoint`/`enabled` for an entry that already has base+token.
105
+
106
+ Both gateway families answer `/health` and multiplex HTTP on the WS port, so
107
+ one probe serves both. `/v1/models` is the cheapest possible check for the
108
+ opt-in OpenAI surface: it costs zero agent tokens when it is on, and returns
109
+ 404 when it is off — which is NOT a failure to report as "no gateway found".
110
+ """
111
+ token = entry["token"]
112
+ try:
113
+ import requests
114
+
115
+ r = requests.get(f"{entry['base']}/health", timeout=2,
116
+ proxies={"http": None, "https": None})
117
+ if r.status_code != 200:
118
+ entry["why"] = f"gateway /health -> HTTP {r.status_code}"
119
+ return entry
120
+ except Exception as exc:
121
+ entry["why"] = f"gateway 无响应: {type(exc).__name__}"
122
+ return entry
123
+
124
+ try:
125
+ import requests
126
+
127
+ r = requests.get(f"{entry['base']}/v1/models",
128
+ headers={"Authorization": f"Bearer {token}"},
129
+ timeout=4,
130
+ proxies={"http": None, "https": None})
131
+ ct = (r.headers.get("Content-Type") or "")
132
+ if r.status_code == 200 and "json" in ct:
133
+ data = r.json().get("data") or []
134
+ entry["agents"] = [m.get("id") for m in data if isinstance(m, dict) and m.get("id")]
135
+ entry["endpoint"] = bool(entry["agents"])
136
+ if not entry["endpoint"]:
137
+ entry["why"] = "gateway 已启用端点,但 /v1/models 没列出任何 agent"
138
+ else:
139
+ entry["why"] = ("已找到 gateway(" + (entry.get("name") or entry["base"])
140
+ + "),但 OpenAI 兼容端点未启用"
141
+ ":在该 gateway 的 openclaw.json 里设置 "
142
+ "gateway.http.endpoints.chatCompletions.enabled = true")
143
+ except Exception as exc:
144
+ entry["why"] = f"端点探测失败: {type(exc).__name__}"
145
+ entry["enabled"] = bool(entry["endpoint"])
146
+ return entry
147
+
148
+
50
149
  def _state_dirs() -> list[Path]:
51
150
  dirs = []
52
151
  for pat in _STATE_GLOBS:
@@ -60,8 +159,9 @@ def _state_dirs() -> list[Path]:
60
159
  def _probe_state(state: Path) -> dict:
61
160
  """Probe ONE ClawHive state dir. Returns an entry with `enabled` plus an
62
161
  honest `why` when it is not usable — the caller keeps trying the others."""
63
- entry = {"base": "", "token": "", "port": 0, "state_dir": str(state),
64
- "agents": [], "endpoint": False, "enabled": False, "why": ""}
162
+ entry = {"base": "", "token": "", "port": 0, "state_dir": str(state),
163
+ "agents": [], "endpoint": False, "enabled": False, "why": "",
164
+ "via": "clawhive", "name": "ClawHive"}
65
165
  try:
66
166
  port = int(json.loads((state / "gateway-port.json").read_text(
67
167
  encoding="utf-8"))["port"])
@@ -80,82 +180,83 @@ def _probe_state(state: Path) -> dict:
80
180
  if not token:
81
181
  entry["why"] = "gateway-token 为空"
82
182
  return entry
83
- entry.update({"base": f"http://127.0.0.1:{port}", "token": token, "port": port})
84
-
85
- try:
86
- import requests
87
-
88
- r = requests.get(f"{entry['base']}/health", timeout=2,
89
- proxies={"http": None, "https": None})
90
- if r.status_code != 200:
91
- entry["why"] = f"gateway /health -> HTTP {r.status_code}"
92
- return entry
93
- except Exception as exc:
94
- entry["why"] = f"gateway 无响应: {type(exc).__name__}"
95
- return entry
96
-
97
- # the OpenAI-compatible surface is opt-in; /v1/models tells us whether
98
- # it is on without spending a single agent token
99
- try:
100
- import requests
101
-
102
- r = requests.get(f"{entry['base']}/v1/models",
103
- headers={"Authorization": f"Bearer {token}"},
104
- timeout=4,
105
- proxies={"http": None, "https": None})
106
- ct = (r.headers.get("Content-Type") or "")
107
- if r.status_code == 200 and "json" in ct:
108
- data = r.json().get("data") or []
109
- entry["agents"] = [m.get("id") for m in data if m.get("id")]
110
- entry["endpoint"] = bool(entry["agents"])
111
- else:
112
- entry["why"] = ("已找到 gateway,但 OpenAI 兼容端点未启用"
113
- "(gateway.http.endpoints.chatCompletions.enabled)")
114
- except Exception as exc:
115
- entry["why"] = f"端点探测失败: {type(exc).__name__}"
116
- entry["enabled"] = bool(entry["endpoint"])
117
- return entry
183
+ entry.update({"base": f"http://127.0.0.1:{port}", "token": token, "port": port})
184
+ return _probe_endpoint(entry)
118
185
 
119
186
 
120
187
  def discover(force: bool = False) -> dict | None:
121
- """Locate a live ClawHive gateway across ALL candidate tenants.
122
-
123
- The first state dir that has a port+token used to win the loop outright —
124
- a dead first tenant permanently shadowed a healthy second one. Now every
125
- candidate is probed (cheap: /health + /v1/models) until one is actually
126
- healthy, and the failures are carried in `tried` for diagnosis.
127
-
128
- Returns {"base", "token", "port", "agents", "endpoint": bool, "why": str,
129
- "tried": [...]} or a not-found info when nothing is reachable. Cached
130
- briefly: this is called on every state refresh and probing each time would
131
- be wasteful. `force=True` bypasses the TTL — that is for the user's
132
- explicit refresh, not for background polling.
188
+ """Locate a live gateway across ALL candidate installs.
189
+
190
+ Two families are searched: ClawHive's embedded gateway (state dirs with
191
+ gateway-port.json) and the OpenClaw desktop app's WSL-backed gateway
192
+ (gateways.json). ClawHive is tried first — on it we can also target the
193
+ conversation the user is looking at, which the standalone gateway cannot
194
+ offer through this endpoint.
195
+
196
+ The first state dir that has a port+token used to win the loop outright —
197
+ a dead first tenant permanently shadowed a healthy second one. Now every
198
+ candidate is probed (cheap: /health + /v1/models) until one is actually
199
+ healthy, and the failures are carried in `tried` for diagnosis.
200
+
201
+ Returns {"base", "token", "port", "agents", "endpoint": bool, "why": str,
202
+ "tried": [...]} or a not-found info when nothing is reachable. Cached
203
+ briefly: this is called on every state refresh and probing each time would
204
+ be wasteful. `force=True` bypasses the TTL — that is for the user's
205
+ explicit refresh, not for background polling.
133
206
  """
134
207
  now = time.time()
135
208
  if not force and _CACHE["info"] is not None and now - _CACHE["at"] < _CACHE_TTL:
136
209
  return _CACHE["info"]
137
210
 
138
- healthy = None
139
- tried = []
140
- for state in _state_dirs():
141
- entry = _probe_state(state)
142
- tried.append({"state_dir": entry["state_dir"], "port": entry["port"],
143
- "enabled": entry["enabled"], "why": entry["why"]})
144
- if entry["enabled"]:
145
- healthy = entry
146
- break
147
-
148
- if healthy is not None:
149
- info = dict(healthy)
150
- info["tried"] = tried
151
- else:
152
- whys = [t["why"] for t in tried if t.get("why")]
153
- info = {"base": "", "token": "", "port": 0, "agents": [], "endpoint": False,
154
- "enabled": False, "state_dir": "",
155
- "why": (";".join(whys)[:300] if whys
156
- else "未找到运行中的 ClawHive gateway"),
157
- "tried": tried}
158
- _CACHE.update({"at": now, "info": info})
211
+ healthy = None
212
+ tried = []
213
+ for state in _state_dirs():
214
+ entry = _probe_state(state)
215
+ tried.append({"state_dir": entry["state_dir"], "port": entry["port"],
216
+ "enabled": entry["enabled"], "why": entry["why"],
217
+ "via": entry.get("via") or "clawhive",
218
+ "name": entry.get("name") or "ClawHive"})
219
+ if entry["enabled"]:
220
+ healthy = entry
221
+ break
222
+
223
+ # The OpenClaw desktop app's own gateway. Its OpenAI surface has to be
224
+ # enabled in ITS config, so a disabled one is reported with the exact key
225
+ # rather than being mistaken for "no gateway found" — the two look the same
226
+ # from the page otherwise, and the user has to know which knob to turn.
227
+ if healthy is None:
228
+ for gw in tray_gateways():
229
+ entry = _probe_endpoint(dict(gw, state_dir="", agents=[], endpoint=False,
230
+ enabled=False, why="", via="openclaw-tray"))
231
+ tried.append({"state_dir": entry["base"], "port": entry["port"],
232
+ "enabled": entry["enabled"], "why": entry["why"],
233
+ "via": "openclaw-tray",
234
+ "name": entry.get("name") or "OpenClaw"})
235
+ if entry["enabled"]:
236
+ healthy = entry
237
+ break
238
+
239
+ if healthy is not None:
240
+ info = dict(healthy)
241
+ info["tried"] = tried
242
+ else:
243
+ whys = [t["why"] for t in tried if t.get("why")]
244
+ info = {"base": "", "token": "", "port": 0, "agents": [], "endpoint": False,
245
+ "enabled": False, "state_dir": "", "via": "", "name": "",
246
+ "why": (";".join(whys)[:400] if whys
247
+ else "未找到运行中的 ClawHive / OpenClaw gateway"),
248
+ "tried": tried}
249
+ # A gateway that ANSWERED but has the endpoint off is the common case (it
250
+ # is off by default) and it is not "nothing found". Carry its identity
251
+ # through so the page can name it: 「Local (OpenClawGateway) 通道未就绪
252
+ # (…chatCompletions.enabled…)」 is actionable, 「未找到 gateway」 is not.
253
+ for t in reversed(tried):
254
+ if t.get("port"):
255
+ info.update({"port": t["port"], "via": t.get("via") or "",
256
+ "state_dir": t.get("state_dir") or "",
257
+ "name": (t.get("name") or "")})
258
+ break
259
+ _CACHE.update({"at": now, "info": info})
159
260
  return info
160
261
 
161
262
 
@@ -172,6 +273,8 @@ def status(force: bool = False) -> dict:
172
273
  "agents": info.get("agents") or [],
173
274
  "why": info.get("why") or "",
174
275
  "state_dir": info.get("state_dir") or "",
276
+ "via": info.get("via") or "",
277
+ "name": info.get("name") or "",
175
278
  "tried": info.get("tried") or [],
176
279
  # the page needs to know this *before* the click: delegating into a
177
280
  # conversation that is mid-turn cannot be served (see session_busy)
@@ -320,11 +423,11 @@ def ask(message: str, agent: str = "", session: str = "trip-moments-studio",
320
423
  resolving sessions against "main" while talking to another agent used to
321
424
  post into a conversation nobody was looking at.
322
425
  """
323
- info = discover()
324
- if not info.get("base"):
325
- raise RuntimeError(info.get("why") or "ClawHive gateway 不可用")
326
- if not info.get("enabled"):
327
- raise RuntimeError(info.get("why") or "ClawHive 的 OpenAI 兼容端点未启用")
426
+ info = discover()
427
+ if not info.get("base"):
428
+ raise RuntimeError(info.get("why") or "OpenClaw gateway 不可用")
429
+ if not info.get("enabled"):
430
+ raise RuntimeError(info.get("why") or "OpenClaw 的 OpenAI 兼容端点未启用")
328
431
 
329
432
  target = pick_agent(agent)
330
433
  payload = {
@@ -364,8 +467,8 @@ def ask(message: str, agent: str = "", session: str = "trip-moments-studio",
364
467
  headers=headers, json=payload, timeout=timeout,
365
468
  proxies={"http": None, "https": None})
366
469
  took = time.time() - t0
367
- if r.status_code != 200:
368
- raise RuntimeError(f"ClawHive 返回 HTTP {r.status_code}: {r.text[:200]}")
470
+ if r.status_code != 200:
471
+ raise RuntimeError(f"gateway 返回 HTTP {r.status_code}: {r.text[:200]}")
369
472
  data = r.json()
370
473
  try:
371
474
  text = data["choices"][0]["message"]["content"]
@@ -2952,6 +2952,12 @@ def t_claw_grade_candidates_and_output_visibility():
2952
2952
  assert "inline=1" in html and "function saveUrl" in html, \
2953
2953
  "页面没有 fetch+blob 的保存路径(attachment 被浏览器拦时会静默失败)"
2954
2954
  assert "data.build" in html, "页面没有随服务端构建号自刷新的逻辑"
2955
+ # 通道状态必须能被看见:旧的 #exec-bar 从六步重构起就一直是 display:none,
2956
+ # 于是「ClawHive/OpenClaw 通道未就绪(为什么)」从来没显示过。现在同一行也
2957
+ # 写进操作台(#claw-note),否则 OpenClaw WSL gateway 的“端点没开”提示
2958
+ # 等于没写。
2959
+ assert 'id="claw-note"' in html, "操作台没有通道状态行(未就绪/忙碌说明无处可显)"
2960
+ assert '$("claw-note")' in html, "通道状态行没有被写入"
2955
2961
 
2956
2962
 
2957
2963
  def t_agent_model_grade_candidates():
@@ -3160,10 +3166,126 @@ def t_agent_model_grade_candidates():
3160
3166
  "agent 只把文案写在会话里时页面不说明"
3161
3167
 
3162
3168
 
3169
+ def t_tray_gateway_discovery():
3170
+ """OpenClaw 桌面版(WSL)自己的 gateway 也要能被发现,而且要能区分
3171
+ 「没找到 gateway」与「找到了但端点没开」—— 后者才是用户实际踩到的状态。
3172
+
3173
+ 2026-10-04 实机:用户的 OpenClaw 不是 ClawHive 内嵌的那个,而是 OpenClaw Tray
3174
+ 装的 WSL 发行版(OpenClawGateway),`openclaw gateway --port 18789`,
3175
+ `auth.mode=token`,gateway URL 与共享令牌写在
3176
+ %APPDATA%/OpenClawTray/gateways.json;WS 与 HTTP 同端口多路复用,
3177
+ /health 活着但 /v1/models -> 404,因为 OpenAI 兼容端点默认关闭。
3178
+ 旧实现只看 ClawHive 的 gateway-port.json,于是把这种情况误报成“没找到”。
3179
+
3180
+ 这里起真实 HTTP 假 gateway,把 gateways.json 指向它,走真实 discover()。
3181
+ """
3182
+ import http.server
3183
+ import json as _json
3184
+ import tempfile as _tf
3185
+ import threading as _th
3186
+ from pathlib import Path as _P
3187
+
3188
+ import clawbridge
3189
+
3190
+ state = {"models": 404, "hits": []}
3191
+
3192
+ class Fake(http.server.BaseHTTPRequestHandler):
3193
+ def log_message(self, *a):
3194
+ pass
3195
+
3196
+ def _send(self, code, payload):
3197
+ body = _json.dumps(payload).encode("utf-8")
3198
+ self.send_response(code)
3199
+ self.send_header("Content-Type", "application/json")
3200
+ self.send_header("Content-Length", str(len(body)))
3201
+ self.end_headers()
3202
+ self.wfile.write(body)
3203
+
3204
+ def do_GET(self):
3205
+ state["hits"].append((self.path,
3206
+ (self.headers.get("Authorization") or "")))
3207
+ if self.path == "/health":
3208
+ return self._send(200, {"ok": True, "status": "live"})
3209
+ if self.path == "/v1/models":
3210
+ if state["models"] == 404:
3211
+ self.send_response(404)
3212
+ self.send_header("Content-Type", "text/plain")
3213
+ self.end_headers()
3214
+ self.wfile.write(b"Not Found")
3215
+ return
3216
+ return self._send(200, {"data": [{"id": "openclaw/default"}]})
3217
+ self._send(404, {"error": "nope"})
3218
+
3219
+ srv = http.server.ThreadingHTTPServer(("127.0.0.1", 0), Fake)
3220
+ port = srv.server_address[1]
3221
+ _th.Thread(target=srv.serve_forever, daemon=True).start()
3222
+
3223
+ old_path = clawbridge._OPENCLAW_TRAY
3224
+ old_cache = dict(clawbridge._CACHE)
3225
+ with _tf.TemporaryDirectory() as tmp:
3226
+ cfg = _P(tmp) / "gateways.json"
3227
+ cfg.write_text(_json.dumps({"gateways": [
3228
+ {"friendlyName": "Local (OpenClawGateway)",
3229
+ "url": f"ws://127.0.0.1:{port}",
3230
+ "sharedGatewayToken": "tok-abc",
3231
+ "setupManagedDistroName": "OpenClawGateway"},
3232
+ # 局域网/远程 gateway 不得被静默使用(照片只会发到本机 gateway)
3233
+ {"friendlyName": "Office box", "url": "ws://192.168.1.5:18789",
3234
+ "sharedGatewayToken": "tok-lan"},
3235
+ ]}), encoding="utf-8")
3236
+ clawbridge._OPENCLAW_TRAY = str(cfg)
3237
+ clawbridge._CACHE.update({"at": 0.0, "info": None})
3238
+ try:
3239
+ found = clawbridge.tray_gateways()
3240
+ assert [g["port"] for g in found] == [port], \
3241
+ f"只应接受 loopback gateway,实际 {[g.get('base') for g in found]}"
3242
+ assert found[0]["name"] == "Local (OpenClawGateway)", found[0]
3243
+
3244
+ # ① 端点没开:必须报「找到了但端点未启用」+ 具体的配置键,
3245
+ # 而不是含糊的「没找到 gateway」;同时要带上是谁没就绪
3246
+ clawbridge._CACHE.update({"at": 0.0, "info": None})
3247
+ info = clawbridge.discover(force=True)
3248
+ assert info["enabled"] is False, info
3249
+ assert info["via"] == "openclaw-tray", info
3250
+ assert info["port"] == port, info
3251
+ assert "OpenClawGateway" in (info["name"] or ""), info
3252
+ assert "chatCompletions" in info["why"], \
3253
+ f"没说清要开哪个开关:{info['why']}"
3254
+ assert "未找到" not in info["why"], info["why"]
3255
+ # 探测必须带 Bearer 令牌:无鉴权探测在 token 模式下会被拒
3256
+ assert any(h[0] == "/v1/models" and h[1] == "Bearer tok-abc"
3257
+ for h in state["hits"]), state["hits"]
3258
+
3259
+ # ② 端点开了:发现成功并列出 agent 目标
3260
+ state["models"] = 200
3261
+ clawbridge._CACHE.update({"at": 0.0, "info": None})
3262
+ info2 = clawbridge.discover(force=True)
3263
+ assert info2["enabled"] is True, info2
3264
+ assert info2["agents"] == ["openclaw/default"], info2
3265
+ assert clawbridge.pick_agent("") == "openclaw/default", clawbridge.pick_agent("")
3266
+ st = clawbridge.status(True)
3267
+ assert st["ready"] and st["via"] == "openclaw-tray" and st["name"], st
3268
+ assert "tok-abc" not in _json.dumps(st), "status() 不能泄露令牌"
3269
+
3270
+ # ③ 端点没开时,status() 也要能报出「是谁没就绪」
3271
+ state["models"] = 404
3272
+ clawbridge._CACHE.update({"at": 0.0, "info": None})
3273
+ st2 = clawbridge.status(True)
3274
+ assert st2["ready"] is False and st2["via"] == "openclaw-tray", st2
3275
+ assert st2["name"] and "OpenClawGateway" in st2["name"], st2
3276
+ assert "chatCompletions" in st2["why"], st2["why"]
3277
+ finally:
3278
+ clawbridge._OPENCLAW_TRAY = old_path
3279
+ clawbridge._CACHE.update(old_cache)
3280
+ srv.shutdown()
3281
+ srv.server_close()
3282
+
3283
+
3163
3284
  CASES = {
3164
3285
  "打包交付与下载(zip/预览/越权拦截)": t_finish_deliverables_and_download,
3165
3286
  "AI 结果可见性+三套候选(每步输出框/agent轨道)": t_claw_grade_candidates_and_output_visibility,
3166
3287
  "Agent自带模型轨道也给三档候选(预览/采用)": t_agent_model_grade_candidates,
3288
+ "OpenClaw WSL gateway 发现(端点未启用的诚实报告)": t_tray_gateway_discovery,
3167
3289
  "存活探测不误杀冷实例/代理环境": t_liveness_probe_is_cheap_and_proxy_safe,
3168
3290
  "审片页 STUDIO 链接跟随在跑实例": t_review_studio_link_follows_instance,
3169
3291
  "canon 命名归一化": t_canon,
@@ -3098,21 +3098,52 @@ function syncExecutor() {
3098
3098
  b.classList.toggle("on", b.dataset.exec === cur);
3099
3099
  b.classList.toggle("agent", b.dataset.exec === "clawhive" && b.classList.contains("on"));
3100
3100
  });
3101
- const n = $("exec-note");
3102
- if (!n) return;
3103
- if (!st.ready) {
3104
- n.innerHTML = `ClawHive 通道未就绪(${H(st.why || "")}),现在只能用本地网关`;
3105
- } else if (cur === "local") {
3106
- n.innerHTML = "用工作台自己的视觉 API 看片出参数:快,但过程不会进入 ClawHive 会话";
3107
- } else if (st.busy) {
3108
- // A session serves one turn at a time: delegating into a conversation that
3109
- // is mid-turn cannot come back (it queues until a timeout). Say so BEFORE
3110
- // the click instead of letting the button look dead for seven minutes.
3111
- n.innerHTML = `<b>ClawHive 正在跑上一轮</b>,一个会话一次只做一件事:` +
3112
- `现在派回去只会排队到超时。等它空下来,或切到「本地」用本地网关做这一步。`;
3113
- } else {
3114
- const agent = (st.agents || []).find((a) => a.includes("main")) || (st.agents || [])[0] || "agent";
3115
- n.innerHTML = `<b>${H(agent)}</b> 会自己看照片、定参数、直接改工作台;它的回答会出现在你的 ClawHive 会话里`;
3101
+ const n = $("exec-note");
3102
+ if (!n) return;
3103
+ // Two gateway families are discoverable now: ClawHive's embedded one and the
3104
+ // OpenClaw desktop app's WSL-backed one. They differ in what the workbench can
3105
+ // promise, so the note names the family instead of always saying "ClawHive".
3106
+ const tray = st.via === "openclaw-tray";
3107
+ const gwName = H(st.name || (tray ? "OpenClaw" : "ClawHive"));
3108
+ let msg = "";
3109
+ let warn = false;
3110
+ if (st.hidden) {
3111
+ // 「连接已隐藏」 is a user preference, not a fault: stay quiet instead of
3112
+ // printing a not-ready warning at the top of every step.
3113
+ msg = "";
3114
+ } else if (!st.ready) {
3115
+ msg = `${gwName} 通道未就绪(${H(st.why || "")})`;
3116
+ warn = true;
3117
+ } else if (cur === "local") {
3118
+ msg = `用工作台自己的视觉 API 看片出参数:快,但过程不会进入 ${tray ? "OpenClaw" : "ClawHive"} 会话`;
3119
+ } else if (st.busy) {
3120
+ // A session serves one turn at a time: delegating into a conversation that
3121
+ // is mid-turn cannot come back (it queues until a timeout). Say so BEFORE
3122
+ // the click instead of letting the button look dead for seven minutes.
3123
+ msg = `<b>${gwName} 正在跑上一轮</b>,一个会话一次只做一件事:` +
3124
+ `现在派回去只会排队到超时。等它空下来,或切到「本地」用本地网关做这一步。`;
3125
+ warn = true;
3126
+ } else if (tray) {
3127
+ // The WSL gateway keeps its session book in SQLite inside the distro, which
3128
+ // Windows cannot read (the wsl$ UNC paths are not reachable from here), so
3129
+ // the workbench cannot target the conversation the user is looking at — and
3130
+ // it cannot pre-detect a mid-turn session either. Promise less, be honest.
3131
+ const agent = (st.agents || [])[0] || "openclaw/default";
3132
+ msg = `<b>${H(agent)}</b> 会自己看照片、定参数、直接改工作台(OpenClaw WSL gateway);` +
3133
+ `会话由工作台自己建,无法绑定你正在看的那个。`;
3134
+ } else {
3135
+ const agent = (st.agents || []).find((a) => a.includes("main")) || (st.agents || [])[0] || "agent";
3136
+ msg = `<b>${H(agent)}</b> 会自己看照片、定参数、直接改工作台;它的回答会出现在你的 ClawHive 会话里`;
3137
+ }
3138
+ n.innerHTML = msg;
3139
+ // The bar holding #exec-note has been display:none since the six-step rewrite,
3140
+ // so everything above was invisible in practice — including 「通道未就绪」.
3141
+ // The deck is where the user actually looks, so the same line goes there.
3142
+ const deckNote = $("claw-note");
3143
+ if (deckNote) {
3144
+ deckNote.innerHTML = msg;
3145
+ deckNote.style.display = msg ? "" : "none";
3146
+ deckNote.style.color = warn ? "#8a3b2f" : "";
3116
3147
  }
3117
3148
  const btnSel = $("btn-reselect9");
3118
3149
  const hintSel = $("reselect9-hint");
package/bin/install.js CHANGED
@@ -16,7 +16,7 @@ const path = require('path');
16
16
  const os = require('os');
17
17
  const { execSync, spawnSync } = require('child_process');
18
18
 
19
- const VERSION = '1.2.0';
19
+ const VERSION = '1.3.0';
20
20
  const SKILL_NAME = 'trip-moments';
21
21
 
22
22
  const c = {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "trip-moments-skill",
3
- "version": "1.2.0",
3
+ "version": "1.3.0",
4
4
  "description": "AI 朋友圈旅行出图助手 (trip-moments skill) — 工业级全流程旅行摄影智能出图与朋友圈九宫格套件",
5
5
  "main": "bin/install.js",
6
6
  "bin": {