arkclaw-webchat-cli 0.5.3__tar.gz → 0.6.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (25) hide show
  1. {arkclaw_webchat_cli-0.5.3 → arkclaw_webchat_cli-0.6.1}/PKG-INFO +1 -1
  2. {arkclaw_webchat_cli-0.5.3 → arkclaw_webchat_cli-0.6.1}/pyproject.toml +1 -1
  3. {arkclaw_webchat_cli-0.5.3 → arkclaw_webchat_cli-0.6.1}/src/ee_claw/cli.py +25 -7
  4. {arkclaw_webchat_cli-0.5.3 → arkclaw_webchat_cli-0.6.1}/src/ee_claw/flows.py +246 -68
  5. {arkclaw_webchat_cli-0.5.3 → arkclaw_webchat_cli-0.6.1}/src/ee_claw/identity.py +14 -0
  6. {arkclaw_webchat_cli-0.5.3 → arkclaw_webchat_cli-0.6.1}/src/ee_claw/transport/openclaw.py +32 -3
  7. {arkclaw_webchat_cli-0.5.3 → arkclaw_webchat_cli-0.6.1}/.gitignore +0 -0
  8. {arkclaw_webchat_cli-0.5.3 → arkclaw_webchat_cli-0.6.1}/README.md +0 -0
  9. {arkclaw_webchat_cli-0.5.3 → arkclaw_webchat_cli-0.6.1}/src/ee_claw/__init__.py +0 -0
  10. {arkclaw_webchat_cli-0.5.3 → arkclaw_webchat_cli-0.6.1}/src/ee_claw/attachments.py +0 -0
  11. {arkclaw_webchat_cli-0.5.3 → arkclaw_webchat_cli-0.6.1}/src/ee_claw/config.py +0 -0
  12. {arkclaw_webchat_cli-0.5.3 → arkclaw_webchat_cli-0.6.1}/src/ee_claw/control.py +0 -0
  13. {arkclaw_webchat_cli-0.5.3 → arkclaw_webchat_cli-0.6.1}/src/ee_claw/core.py +0 -0
  14. {arkclaw_webchat_cli-0.5.3 → arkclaw_webchat_cli-0.6.1}/src/ee_claw/doctor.py +0 -0
  15. {arkclaw_webchat_cli-0.5.3 → arkclaw_webchat_cli-0.6.1}/src/ee_claw/errors.py +0 -0
  16. {arkclaw_webchat_cli-0.5.3 → arkclaw_webchat_cli-0.6.1}/src/ee_claw/oauth.py +0 -0
  17. {arkclaw_webchat_cli-0.5.3 → arkclaw_webchat_cli-0.6.1}/src/ee_claw/output.py +0 -0
  18. {arkclaw_webchat_cli-0.5.3 → arkclaw_webchat_cli-0.6.1}/src/ee_claw/policy.py +0 -0
  19. {arkclaw_webchat_cli-0.5.3 → arkclaw_webchat_cli-0.6.1}/src/ee_claw/providers.py +0 -0
  20. {arkclaw_webchat_cli-0.5.3 → arkclaw_webchat_cli-0.6.1}/src/ee_claw/secrets_store.py +0 -0
  21. {arkclaw_webchat_cli-0.5.3 → arkclaw_webchat_cli-0.6.1}/src/ee_claw/sts.py +0 -0
  22. {arkclaw_webchat_cli-0.5.3 → arkclaw_webchat_cli-0.6.1}/src/ee_claw/transport/__init__.py +0 -0
  23. {arkclaw_webchat_cli-0.5.3 → arkclaw_webchat_cli-0.6.1}/src/ee_claw/transport/a2a.py +0 -0
  24. {arkclaw_webchat_cli-0.5.3 → arkclaw_webchat_cli-0.6.1}/src/ee_claw/transport/base.py +0 -0
  25. {arkclaw_webchat_cli-0.5.3 → arkclaw_webchat_cli-0.6.1}/src/ee_claw/update.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: arkclaw-webchat-cli
3
- Version: 0.5.3
3
+ Version: 0.6.1
4
4
  Summary: CLI to chat with an ArkClaw EE space's Claw over enterprise SSO — zero permanent AK/SK.
5
5
  Author: ArkClaw Team
6
6
  Keywords: arkclaw,cli,ee,openclaw,sso,sts
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "arkclaw-webchat-cli"
7
- version = "0.5.3"
7
+ version = "0.6.1"
8
8
  description = "CLI to chat with an ArkClaw EE space's Claw over enterprise SSO — zero permanent AK/SK."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -161,11 +161,20 @@ def chat(
161
161
  message_opt: str | None = typer.Option(
162
162
  None, "-m", "--message", help="One-shot message (same as the positional argument)."
163
163
  ),
164
+ agent: str = typer.Option(
165
+ "main",
166
+ "--agent",
167
+ help="Which agent in the claw to talk to (its agentId; see `arkclaw agents`). "
168
+ "Defaults to the claw's main agent.",
169
+ ),
164
170
  session: str | None = typer.Option(
165
171
  None,
166
172
  "--session",
167
- help="Named server-side session (conversation thread). Defaults to the "
168
- "claw's main session; sessions survive CLI restarts.",
173
+ help="Resume a specific conversation by its 会话ID (see `arkclaw sessions`). "
174
+ "Omit to continue the agent's main session.",
175
+ ),
176
+ new: bool = typer.Option(
177
+ False, "--new", help="Start a fresh conversation instead of continuing the last one."
169
178
  ),
170
179
  approve_all: bool = typer.Option(
171
180
  False,
@@ -192,6 +201,8 @@ def chat(
192
201
  target=target,
193
202
  message=message_opt or message,
194
203
  session=session,
204
+ agent=agent,
205
+ new=new,
195
206
  approve_all=approve_all,
196
207
  files=list(file) or None,
197
208
  output=output,
@@ -200,10 +211,17 @@ def chat(
200
211
 
201
212
 
202
213
  @app.command()
203
- def sessions(json_mode: bool = _json_opt()) -> None:
204
- """List chat sessions this machine has used (most recent first)."""
214
+ def sessions(
215
+ claw: str | None = typer.Argument(
216
+ None, metavar="[CLAW]", help="Claw id (ci-...). Omit to use your default claw."
217
+ ),
218
+ agent: str = typer.Option("main", "--agent", help="Which agent's conversations to list."),
219
+ json_mode: bool = _json_opt(),
220
+ ) -> None:
221
+ """List an agent's conversations from the server (newest first). Resume one
222
+ with `arkclaw chat <claw> --session <会话ID>`, or start fresh with `--new`."""
205
223
  emitter = Emitter(json_mode=json_mode)
206
- _run(emitter, lambda: flows.do_sessions(emitter))
224
+ _run(emitter, lambda: flows.do_sessions(emitter, clawid=claw, agent=agent))
207
225
 
208
226
 
209
227
  @app.command(name="ls")
@@ -243,8 +261,8 @@ def push(
243
261
 
244
262
  @app.command()
245
263
  def agents(json_mode: bool = _json_opt()) -> None:
246
- """List the agents/claws you can chat with (openclaw: your claws, space
247
- auto-discovered; a2a: the agent at the endpoint). Then `chat <id-or-name>`."""
264
+ """List the agents you can chat with (openclaw: the agents in your accessible
265
+ claws; a2a: the agent at the endpoint). Then `chat <claw-or-name>`."""
248
266
  emitter = Emitter(json_mode=json_mode)
249
267
  _run(emitter, lambda: flows.do_agents(emitter))
250
268
 
@@ -20,6 +20,7 @@ import signal
20
20
  import sys
21
21
  import time
22
22
  import urllib.parse
23
+ import uuid
23
24
  from typing import Any
24
25
 
25
26
  from ee_claw import config as config_mod
@@ -116,6 +117,79 @@ def _user_identity(id_token: str | None) -> dict[str, str]:
116
117
  return ident
117
118
 
118
119
 
120
+ def _agent_display_name(agent: dict[str, Any]) -> str:
121
+ """Friendly name for an agent. The main agent often comes back named "main";
122
+ show a friendly label instead of leaking the internal id (mirrors the web)."""
123
+ name = str(agent.get("name") or "").strip()
124
+ agent_id = str(agent.get("agentId") or "")
125
+ is_main = bool(agent.get("default")) or agent_id == "main"
126
+ if is_main and (not name or name == "main"):
127
+ return "ArkClaw 智能助手"
128
+ return name or agent_id or "(未命名)"
129
+
130
+
131
+ def _list_agents_for_claws(
132
+ cfg: SessionConfig, creds: dict[str, str], identity: dict[str, str], claw_ids: list[str]
133
+ ) -> dict[str, tuple[str, list[dict[str, Any]]]]:
134
+ """For each claw, list its agents over the ws (``arkclaw.team.agent.list``) —
135
+ which also proves access (the ChatToken mint denies non-owners). Returns
136
+ ``clawid → (status, agents)``, status ∈ {"ok","denied","error"}. Parallel."""
137
+ from concurrent.futures import ThreadPoolExecutor
138
+
139
+ region = str(cfg.region)
140
+
141
+ def one(clawid: str) -> tuple[str, str, list[dict[str, Any]]]:
142
+ t = OpenClawTransport(clawid=clawid, region=region, creds=creds, identity=identity)
143
+ try:
144
+ return clawid, "ok", asyncio.run(t.list_agents())
145
+ except ClawAccessDeniedError:
146
+ return clawid, "denied", []
147
+ except ArkclawError:
148
+ return clawid, "error", [] # transient — keep the claw, mark unknown
149
+
150
+ out: dict[str, tuple[str, list[dict[str, Any]]]] = {}
151
+ with ThreadPoolExecutor(max_workers=min(8, max(1, len(claw_ids)))) as ex:
152
+ for clawid, status, agents in ex.map(one, claw_ids):
153
+ out[clawid] = (status, agents)
154
+ return out
155
+
156
+
157
+ def _resolve_session_key(
158
+ cfg: SessionConfig, id_token: str, clawid: str, agent: str, session: str | None, new: bool
159
+ ) -> str | None:
160
+ """Compute the OpenClaw sessionKey ``agent:<agentId>:<label>`` for a chat:
161
+
162
+ * ``--new`` → a fresh ``agent:<agent>:cli-<uuid>`` (new conversation)
163
+ * ``--session <id>`` → resume: a full ``agent:…`` key passes through; a short
164
+ sessionId is resolved to its key via ``sessions.list``
165
+ * neither, agent=main → ``None`` (the default ``agent:main:main`` session)
166
+ * neither, other agent→ ``agent:<agent>:main``
167
+ """
168
+ agent = agent or "main"
169
+ if new:
170
+ return f"agent:{agent}:cli-{uuid.uuid4()}"
171
+ if not session:
172
+ return None if agent == "main" else f"agent:{agent}:main"
173
+ if session.startswith("agent:"):
174
+ return session # already a full sessionKey
175
+ # A hex token is a 会话ID copied from `arkclaw sessions` → resolve to its key.
176
+ if re.fullmatch(r"[0-9a-f]{6,}", session):
177
+ creds = _openclaw_creds(cfg, id_token)
178
+ t = OpenClawTransport(
179
+ clawid=clawid, region=str(cfg.region), creds=creds, identity=_user_identity(id_token)
180
+ )
181
+ for s in asyncio.run(t.list_sessions(agent)):
182
+ sid = str(s.get("sessionId") or "")
183
+ if sid and (sid == session or sid.startswith(session)):
184
+ return str(s.get("key") or f"agent:{agent}:{session}")
185
+ raise ValidationError(
186
+ f"会话 {session!r} 不存在。",
187
+ hint="用 `arkclaw sessions` 查看现有会话,或加 `--new` 开新会话。",
188
+ )
189
+ # Otherwise a human-named session label (created/resumed by this key).
190
+ return f"agent:{agent}:{session}"
191
+
192
+
119
193
  def _normalize(url: str) -> tuple[str, str]:
120
194
  base = (url if "//" in url else "https://" + url).rstrip("/")
121
195
  host = urllib.parse.urlparse(base).hostname or ""
@@ -131,55 +205,72 @@ def _is_userpool_address(host: str) -> bool:
131
205
 
132
206
 
133
207
  def do_init(emitter: Emitter, address: str | None = None) -> dict[str, Any]:
134
- """``arkclaw init``: one-time interactive setup. Captures the login address
135
- plus the bits not yet auto-discoverable (the CLI's OAuth client, the STS
136
- role) so that afterwards ``arkclaw login`` needs no env vars or flags.
137
- Anything the space already self-describes (issuer, region, and once the
138
- platform publishes it client_id/role) is auto-filled and not asked."""
139
- if emitter.json or not sys.stdin.isatty():
140
- raise ValidationError(
141
- "arkclaw init 是交互式的,需要终端。",
142
- hint="在终端直接运行 `arkclaw init`;脚本里改用 env(ARKCLAW_CLIENT_ID/ROLE_TRN)+ flags。",
143
- )
208
+ """``arkclaw init``: one-time setup. The GOAL is **address-only** — when the
209
+ space publishes its CLI config (issuer/region/client_id/role…) via
210
+ ``/.well-known/arkclaw-cli`` or GetAuthConfig, the user gives ONLY the
211
+ address and nothing is asked (works non-interactively too). Until then, it
212
+ prompts for the few bits discovery can't provide (the CLI's OAuth client,
213
+ the STS role)."""
214
+ interactive = not emitter.json and sys.stdin.isatty()
215
+
216
+ def need_tty() -> None:
217
+ if not interactive:
218
+ raise ValidationError(
219
+ "该空间尚未发布 CLI 配置,需要补充 client_id/role,而当前非交互终端。",
220
+ hint="在终端运行 `arkclaw init <地址>`;或脚本里用 env(ARKCLAW_CLIENT_ID/ROLE_TRN)+ flags。",
221
+ )
144
222
 
145
- def ask(label: str, default: str | None = None, *, required: bool = False) -> str | None:
223
+ def ask(label: str, default: str | None = None, *, required: bool = False) -> str:
146
224
  suffix = f" [{default}]" if default else ""
147
225
  while True:
148
226
  ans = input(f" {label}{suffix}: ").strip() or (default or "")
149
227
  if ans or not required:
150
- return ans or None
228
+ return ans
151
229
  emitter.line(" (必填)")
152
230
 
153
- emitter.line("arkclaw init —— 一次性配置(能自动发现的不会问你)\n")
154
- address = address or ask("空间登录地址 (https://…)", required=True)
231
+ if not address:
232
+ need_tty()
233
+ emitter.line("arkclaw init —— 配置一次(能自动发现的不问你)\n")
234
+ address = ask("空间登录地址 (https://…)", required=True)
155
235
  base, host = _normalize(str(address))
156
236
  disc = discover_cli_config(base) or {}
157
237
  issuer = disc.get("issuer") or (f"https://{host}" if _is_userpool_address(host) else None)
158
238
  region = disc.get("region") or derive_region(base)
159
- if issuer:
160
- emitter.line(" ✓ 自动发现身份池 issuer")
161
- if region:
162
- emitter.line(f" ✓ 区域: {region}")
163
-
164
- # User-facing name is "webchat" (the ChatToken/wss path); internally it's
165
- # the "openclaw" transport. "a2a" is unchanged.
166
- disc_t = str(disc.get("transport") or "")
167
- transport_in = ask("传输方式 (webchat/a2a)", default=("a2a" if disc_t == "a2a" else "webchat")) or "webchat"
168
- transport = "a2a" if transport_in.lower() == "a2a" else "openclaw"
169
- client_id = disc.get("client_id") or ask(
170
- "CLI client_id(public OAuth 客户端,问管理员;平台发布发现后免此项)", required=True
171
- )
172
- role_trn: object = None
173
- endpoint: object = None
174
- if transport == "openclaw":
175
- role_trn = disc.get("role_trn") or ask("STS role TRN (trn:iam::<账号>:role/<名字>)", required=True)
239
+ transport = "a2a" if str(disc.get("transport") or "").lower() == "a2a" else "openclaw"
240
+ client_id = disc.get("client_id")
241
+ role_trn = disc.get("role_trn")
242
+ provider_trn = disc.get("provider_trn")
243
+ endpoint = disc.get("endpoint")
244
+ clawid = disc.get("clawid")
245
+
246
+ # Fully discovered = the space published this CLI's client + target plane →
247
+ # nothing to ask (address-only). Otherwise prompt ONLY for what's missing.
248
+ complete = bool(client_id) and (bool(role_trn) if transport == "openclaw" else bool(endpoint))
249
+ if complete:
250
+ emitter.line("✓ 该空间已发布 CLI 配置 —— 全部自动解析,无需手动输入。")
176
251
  else:
177
- endpoint = disc.get("endpoint") or ask("A2A endpoint (https://…)", required=True)
178
- clawid = disc.get("clawid") or ask("默认 claw id (ci-…,可留空,登录后用 arkclaw agents 选)")
252
+ need_tty()
253
+ if issuer:
254
+ emitter.line(" ✓ 自动发现身份池 issuer")
255
+ if region:
256
+ emitter.line(f" ✓ 区域: {region}")
257
+ # "webchat" = the ChatToken/wss path (internal name "openclaw"); "a2a" unchanged.
258
+ t_in = ask("传输方式 (webchat/a2a)", default=("a2a" if transport == "a2a" else "webchat"))
259
+ transport = "a2a" if t_in.lower() == "a2a" else "openclaw"
260
+ if not client_id:
261
+ client_id = ask(
262
+ "CLI client_id(public OAuth 客户端,问管理员;平台发布发现后免此项)", required=True
263
+ )
264
+ if transport == "openclaw" and not role_trn:
265
+ role_trn = ask("STS role TRN (trn:iam::<账号>:role/<名字>)", required=True)
266
+ elif transport == "a2a" and not endpoint:
267
+ endpoint = ask("A2A endpoint (https://…)", required=True)
268
+ if not clawid:
269
+ clawid = ask("默认 claw id (ci-…,可留空,登录后用 arkclaw agents 选)") or None
179
270
 
180
271
  saved = {
181
272
  "address": base, "issuer": issuer, "client_id": client_id, "transport": transport,
182
- "region": region, "role_trn": role_trn, "provider_trn": disc.get("provider_trn"),
273
+ "region": region, "role_trn": role_trn, "provider_trn": provider_trn,
183
274
  "endpoint": endpoint, "space_id": disc.get("space_id"), "clawid": clawid,
184
275
  }
185
276
  config_mod.save_defaults(saved)
@@ -735,6 +826,8 @@ def do_chat(
735
826
  target: str | None = None,
736
827
  message: str | None = None,
737
828
  session: str | None = None,
829
+ agent: str = "main",
830
+ new: bool = False,
738
831
  approve_all: bool = False,
739
832
  files: list[str] | None = None,
740
833
  output: str | None = None,
@@ -790,6 +883,12 @@ def do_chat(
790
883
  )
791
884
 
792
885
  approver = _build_approver(emitter, approve_all=approve_all, interactive=interactive)
886
+ # Resolve agent + session → a concrete sessionKey (openclaw only; a2a uses
887
+ # the session name as the context label).
888
+ if cfg.transport == "openclaw":
889
+ effective_claw = clawid or cfg.claw
890
+ if effective_claw:
891
+ session = _resolve_session_key(cfg, id_token, effective_claw, agent, session, new)
793
892
  target, transport = _target_transport(
794
893
  cfg, clawid, id_token, session=session, approver=approver
795
894
  )
@@ -831,8 +930,9 @@ def do_chat(
831
930
  pass
832
931
  emitter.line(
833
932
  f"ArkClaw chat · {target}"
933
+ + (f" · agent {agent}" if agent and agent != "main" else "")
934
+ + (f" · 会话 {session.split(':')[-1][:16]}" if session else "")
834
935
  + (f" · 空间 {cfg.space}" if cfg.space and cfg.space != target else "")
835
- + (f" · 会话 {session}" if session else "")
836
936
  + " (Ctrl+C 退出)"
837
937
  )
838
938
  while True:
@@ -863,11 +963,54 @@ def do_chat(
863
963
  emitter.line("")
864
964
 
865
965
 
866
- def do_sessions(emitter: Emitter) -> dict[str, Any]:
867
- """``arkclaw sessions``: the local view of server-side sessions (which
868
- space/claw/session tuples this machine has talked to). Server-side
869
- listing + replay land with gate G2."""
870
- rows = config_mod.list_sessions()
966
+ def do_sessions(
967
+ emitter: Emitter, clawid: str | None = None, agent: str = "main"
968
+ ) -> dict[str, Any]:
969
+ """``arkclaw sessions [<claw>] [--agent <id>]``: the conversations of an agent
970
+ in a claw, from the server (ws ``sessions.list``) — newest first. Resume one
971
+ with ``arkclaw chat <claw> --session <会话ID>``. Falls back to the local
972
+ record for a2a / when the claw can't be reached."""
973
+ cfg = config_mod.load_config()
974
+ target = (clawid or (cfg.claw if cfg else None)) if cfg else None
975
+ if cfg and cfg.space and cfg.transport == "openclaw" and target:
976
+ try:
977
+ _, id_token = _load_session(cfg)
978
+ creds = _openclaw_creds(cfg, id_token)
979
+ t = OpenClawTransport(
980
+ clawid=target, region=str(cfg.region), creds=creds, identity=_user_identity(id_token)
981
+ )
982
+ sessions = asyncio.run(t.list_sessions(agent))
983
+ sessions.sort(key=lambda s: float(s.get("updatedAt") or 0), reverse=True)
984
+ emitter.line(f"★ claw {target} · agent {agent} · 空间 {cfg.space}")
985
+ if sessions:
986
+ emitter.table(
987
+ ["标题", "上次活动", "会话ID", "状态"],
988
+ [
989
+ [
990
+ (str(s.get("derivedTitle") or "(无标题)"))[:48],
991
+ _ago_ms(s.get("updatedAt")),
992
+ str(s.get("sessionId") or "")[:8],
993
+ str(s.get("status") or "—"),
994
+ ]
995
+ for s in sessions
996
+ ],
997
+ )
998
+ emitter.line(" 续聊: arkclaw chat <claw> --session <会话ID>;新开: --new")
999
+ else:
1000
+ emitter.line(" 该 agent 还没有会话。`arkclaw chat` 开始第一段对话。")
1001
+ return {
1002
+ "claw": target, "agent": agent,
1003
+ "sessions": [
1004
+ {"sessionId": s.get("sessionId"), "key": s.get("key"),
1005
+ "title": s.get("derivedTitle"), "updatedAt": s.get("updatedAt"),
1006
+ "status": s.get("status")}
1007
+ for s in sessions
1008
+ ],
1009
+ }
1010
+ except ArkclawError as e:
1011
+ emitter.line(f" ⚠ 无法从服务端列会话({e.code}),回退本地记录:")
1012
+
1013
+ rows = config_mod.list_sessions() # a2a / not-logged-in / no-claw / server-error fallback
871
1014
  if rows:
872
1015
  emitter.table(
873
1016
  ["会话", "目标", "空间", "最近使用"],
@@ -916,13 +1059,15 @@ def _ago(ts: object) -> str:
916
1059
 
917
1060
 
918
1061
  def do_agents(emitter: Emitter) -> dict[str, Any]:
919
- """``arkclaw agents``: the agents/claws you can chat with.
1062
+ """``arkclaw agents``: the agents you can chat with.
920
1063
 
921
- openclaw → the claws YOU have used from this machine (kept locally, newest
922
- first), plus your default claw. This is deliberately NOT a server-side
923
- enumeration of the space that API returns every member's claws to anyone
924
- (no per-user scoping is reachable by the CLI's role), so we don't call it.
925
- You add a claw to this list by chatting it once: ``arkclaw chat ci-...``.
1064
+ openclaw → the AGENTS hosted in your accessible claws. The candidate claws
1065
+ are the ones YOU have used from this machine (kept locally, newest first)
1066
+ plus your default; for each we list its agents over the ws
1067
+ (``arkclaw.team.agent.list``), which also verifies access — inaccessible
1068
+ claws are pruned. This is NOT a space-wide server enumeration (not per-user
1069
+ scoped for the CLI's role). You add a claw by chatting it once: ``arkclaw
1070
+ chat ci-...``.
926
1071
 
927
1072
  a2a → the agent at the endpoint (its card). Profiles live under ``arkclaw
928
1073
  profile``, full conversation history under ``arkclaw sessions``."""
@@ -982,12 +1127,11 @@ def do_agents(emitter: Emitter) -> dict[str, Any]:
982
1127
  )
983
1128
  elif cfg.transport == "openclaw":
984
1129
  emitter.line(f"★ 当前登录(openclaw)· 空间 {cfg.space}")
985
- # The claws you can chat = the ones YOU have used from this machine,
986
- # remembered locally NOT a server enumeration of the space. The
987
- # space-wide ListClawInstances returns every member's claws to anyone
988
- # (no per-user scoping reachable by the CLI's role), so we don't call it.
989
- # New claws enter via `arkclaw chat ci-...`; each chat is recorded
990
- # (record_session) and shows up here next time.
1130
+ # The chat-able units are AGENTS, and one claw hosts several. Candidates
1131
+ # are the claws YOU have used from this machine (local history) + your
1132
+ # default; for each we list its agents over the ws (which also proves
1133
+ # access). Inaccessible claws are pruned from history (+ cleared as
1134
+ # default). NOT a space-wide server enumeration (not per-user scoped).
991
1135
  seen: dict[str, float] = {}
992
1136
  for s in config_mod.list_sessions():
993
1137
  cid = s.get("claw")
@@ -996,23 +1140,57 @@ def do_agents(emitter: Emitter) -> dict[str, Any]:
996
1140
  seen[str(cid)] = max(seen.get(str(cid), 0.0), float(ts) if isinstance(ts, (int, float)) else 0.0)
997
1141
  if cfg.claw and str(cfg.claw) not in seen:
998
1142
  seen[str(cfg.claw)] = 0.0 # the default claw, even if not chatted yet
999
- claws = [
1000
- {"ClawInstanceId": cid, "last_used": ts}
1001
- for cid, ts in sorted(seen.items(), key=lambda kv: kv[1], reverse=True)
1002
- ]
1003
- current["claws"] = claws
1004
- current["source"] = "local-history"
1005
- if claws:
1143
+
1144
+ claw_ids = sorted(seen, key=lambda c: seen[c], reverse=True)
1145
+ rows: list[list[str]] = []
1146
+ agents_data: list[dict[str, Any]] = []
1147
+ checked = False
1148
+ if claw_ids:
1149
+ if not emitter.json:
1150
+ emitter.line(" 列出 agents…")
1151
+ try:
1152
+ _, id_token = _load_session(cfg)
1153
+ creds = _openclaw_creds(cfg, id_token)
1154
+ identity = _user_identity(id_token)
1155
+ results = _list_agents_for_claws(cfg, creds, identity, claw_ids)
1156
+ checked = True
1157
+ for clawid in claw_ids:
1158
+ status, agents = results[clawid]
1159
+ if status == "denied":
1160
+ config_mod.forget_claw(str(cfg.space), clawid) # stop listing it
1161
+ if cfg.claw == clawid:
1162
+ config_mod.save_config(dataclasses.replace(cfg, claw=None))
1163
+ cfg = config_mod.load_config() or cfg
1164
+ current["default_claw"] = cfg.claw
1165
+ continue
1166
+ if status == "error" or not agents:
1167
+ rows.append(["(无法列出 agent)", "—", clawid, "★" if clawid == cfg.claw else ""])
1168
+ continue
1169
+ for a in agents:
1170
+ aid = str(a.get("agentId") or "")
1171
+ is_main = bool(a.get("default")) or aid == "main"
1172
+ rows.append([
1173
+ _agent_display_name(a), aid or "—", clawid,
1174
+ "★" if (clawid == cfg.claw and is_main) else "",
1175
+ ])
1176
+ agents_data.append({
1177
+ "clawId": clawid, "agentId": aid, "name": _agent_display_name(a),
1178
+ "description": a.get("description"), "default": is_main,
1179
+ })
1180
+ except ArkclawError:
1181
+ checked = False # offline / expired / no creds → can't list agents
1182
+
1183
+ current["agents"] = agents_data
1184
+ current["source"] = "verified" if checked else "local-history-unchecked"
1185
+ if checked and rows:
1186
+ emitter.table(["Agent", "agentId", "所属 Claw", "默认"], rows)
1187
+ elif checked:
1188
+ emitter.line(" 本空间没有你可访问的 claw / agent(历史里的都已无权或不存在)。")
1189
+ elif claw_ids:
1190
+ emitter.line(" ⚠ 未能连接核对(离线/会话过期),无法列出 agent;以下为本地历史的 claw:")
1006
1191
  emitter.table(
1007
- ["Claw(可 chat)", "上次使用", "默认"],
1008
- [
1009
- [
1010
- str(c["ClawInstanceId"]),
1011
- _ago(c["last_used"]) if c["last_used"] else "—",
1012
- "★" if c["ClawInstanceId"] == cfg.claw else "",
1013
- ]
1014
- for c in claws
1015
- ],
1192
+ ["Claw", "上次使用"],
1193
+ [[c, _ago(seen[c]) if seen[c] else "—"] for c in claw_ids],
1016
1194
  )
1017
1195
  else:
1018
1196
  emitter.line(" 还没用过任何 claw。用 `arkclaw chat ci-xxxx` 开始,之后这里会记住它。")
@@ -118,6 +118,20 @@ def discover_cli_config(url: str) -> dict[str, object] | None:
118
118
  if result.get(k):
119
119
  out["client_id"] = result[k]
120
120
  break
121
+ # The remaining CLI-plane fields (platform-added). Once the BFF surfaces
122
+ # these on GetAuthConfig, the user types ONLY the address — login/init
123
+ # resolve everything. (camelCase from the BFF → snake_case the CLI uses.)
124
+ for src, dst in (
125
+ ("roleTrn", "role_trn"),
126
+ ("providerTrn", "provider_trn"),
127
+ ("region", "region"),
128
+ ("spaceId", "space_id"),
129
+ ("transport", "transport"),
130
+ ("endpoint", "endpoint"),
131
+ ("defaultClawId", "clawid"),
132
+ ):
133
+ if result.get(src):
134
+ out[dst] = result[src]
121
135
  return out or None
122
136
 
123
137
 
@@ -451,9 +451,10 @@ class OpenClawTransport:
451
451
  # agents.files.get {agentId, name} → {file:{name,path,size,content}}
452
452
  # agents.files.set {agentId, name, content} → write
453
453
 
454
- async def _request(self, method: str, params: dict[str, Any]) -> dict[str, Any]:
455
- """connect (hello-ok) → one control request → its res payload. Errors
456
- are redacted; the token-bearing URL is never echoed."""
454
+ async def _request(self, method: str, params: dict[str, Any]) -> Any:
455
+ """connect (hello-ok) → one control request → its res payload (dict or
456
+ list, depending on the method). Errors are redacted; the token-bearing
457
+ URL is never echoed."""
457
458
  chat_token, endpoint = get_chat_token(self.clawid, self.region, self.creds, identity=self.identity)
458
459
  url = (
459
460
  f"wss://{endpoint}/?chatToken={urllib.parse.quote(chat_token)}"
@@ -510,3 +511,31 @@ class OpenClawTransport:
510
511
  return await self._request(
511
512
  "agents.files.set", {"agentId": agent_id, "name": name, "content": content}
512
513
  )
514
+
515
+ async def list_agents(self) -> list[dict[str, Any]]:
516
+ """The agents living in this claw (ws RPC ``arkclaw.team.agent.list``).
517
+ Each item: ``{agentId, name, description?, default?}``. Raises
518
+ ClawAccessDeniedError (via the ChatToken mint) if the user can't access
519
+ this claw."""
520
+ payload = await self._request("arkclaw.team.agent.list", {})
521
+ if isinstance(payload, list):
522
+ return [a for a in payload if isinstance(a, dict)]
523
+ if isinstance(payload, dict): # some gateways wrap the array
524
+ for k in ("data", "agents", "list", "items"):
525
+ v = payload.get(k)
526
+ if isinstance(v, list):
527
+ return [a for a in v if isinstance(a, dict)]
528
+ return []
529
+
530
+ async def list_sessions(self, agent_id: str = "main") -> list[dict[str, Any]]:
531
+ """An agent's conversations (ws RPC ``sessions.list``). Each item:
532
+ ``{key, sessionId, derivedTitle?, updatedAt, status, ...}`` — ``key`` is
533
+ the sessionKey to resume with."""
534
+ payload = await self._request(
535
+ "sessions.list", {"agentId": agent_id, "includeDerivedTitles": True}
536
+ )
537
+ if isinstance(payload, dict):
538
+ sessions = payload.get("sessions")
539
+ if isinstance(sessions, list):
540
+ return [s for s in sessions if isinstance(s, dict)]
541
+ return []