godot-cli-control 0.2.3__tar.gz → 0.2.4__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 (53) hide show
  1. {godot_cli_control-0.2.3 → godot_cli_control-0.2.4}/.gitignore +2 -0
  2. {godot_cli_control-0.2.3 → godot_cli_control-0.2.4}/PKG-INFO +7 -5
  3. {godot_cli_control-0.2.3 → godot_cli_control-0.2.4}/addons/godot_cli_control/bridge/game_bridge.gd +71 -8
  4. {godot_cli_control-0.2.3 → godot_cli_control-0.2.4}/addons/godot_cli_control/tests/gut/test_game_bridge.gd +61 -0
  5. {godot_cli_control-0.2.3 → godot_cli_control-0.2.4}/python/README.md +6 -4
  6. godot_cli_control-0.2.4/python/godot_cli_control/_duration.py +18 -0
  7. {godot_cli_control-0.2.3 → godot_cli_control-0.2.4}/python/godot_cli_control/_version.py +2 -2
  8. {godot_cli_control-0.2.3 → godot_cli_control-0.2.4}/python/godot_cli_control/cli.py +159 -9
  9. {godot_cli_control-0.2.3 → godot_cli_control-0.2.4}/python/godot_cli_control/daemon.py +216 -13
  10. godot_cli_control-0.2.4/python/godot_cli_control/registry.py +135 -0
  11. {godot_cli_control-0.2.3 → godot_cli_control-0.2.4}/python/godot_cli_control/templates/skill/SKILL.md +2 -2
  12. {godot_cli_control-0.2.3 → godot_cli_control-0.2.4}/python/tests/test_cli.py +291 -13
  13. {godot_cli_control-0.2.3 → godot_cli_control-0.2.4}/python/tests/test_daemon.py +535 -1
  14. godot_cli_control-0.2.4/python/tests/test_duration.py +36 -0
  15. {godot_cli_control-0.2.3 → godot_cli_control-0.2.4}/python/tests/test_pytest_plugin.py +5 -1
  16. godot_cli_control-0.2.4/python/tests/test_registry.py +74 -0
  17. {godot_cli_control-0.2.3 → godot_cli_control-0.2.4}/LICENSE +0 -0
  18. {godot_cli_control-0.2.3 → godot_cli_control-0.2.4}/addons/godot_cli_control/CHANGELOG.md +0 -0
  19. {godot_cli_control-0.2.3 → godot_cli_control-0.2.4}/addons/godot_cli_control/LICENSE +0 -0
  20. {godot_cli_control-0.2.3 → godot_cli_control-0.2.4}/addons/godot_cli_control/README.md +0 -0
  21. {godot_cli_control-0.2.3 → godot_cli_control-0.2.4}/addons/godot_cli_control/bin/run_cli_control.ps1 +0 -0
  22. {godot_cli_control-0.2.3 → godot_cli_control-0.2.4}/addons/godot_cli_control/bin/run_cli_control.sh +0 -0
  23. {godot_cli_control-0.2.3 → godot_cli_control-0.2.4}/addons/godot_cli_control/bridge/game_bridge.gd.uid +0 -0
  24. {godot_cli_control-0.2.3 → godot_cli_control-0.2.4}/addons/godot_cli_control/bridge/input_simulation_api.gd +0 -0
  25. {godot_cli_control-0.2.3 → godot_cli_control-0.2.4}/addons/godot_cli_control/bridge/input_simulation_api.gd.uid +0 -0
  26. {godot_cli_control-0.2.3 → godot_cli_control-0.2.4}/addons/godot_cli_control/bridge/low_level_api.gd +0 -0
  27. {godot_cli_control-0.2.3 → godot_cli_control-0.2.4}/addons/godot_cli_control/bridge/low_level_api.gd.uid +0 -0
  28. {godot_cli_control-0.2.3 → godot_cli_control-0.2.4}/addons/godot_cli_control/plugin.cfg +0 -0
  29. {godot_cli_control-0.2.3 → godot_cli_control-0.2.4}/addons/godot_cli_control/plugin.gd +0 -0
  30. {godot_cli_control-0.2.3 → godot_cli_control-0.2.4}/addons/godot_cli_control/plugin.gd.uid +0 -0
  31. {godot_cli_control-0.2.3 → godot_cli_control-0.2.4}/addons/godot_cli_control/tests/gut/test_input_simulation_api.gd +0 -0
  32. {godot_cli_control-0.2.3 → godot_cli_control-0.2.4}/addons/godot_cli_control/tests/gut/test_low_level_api.gd +0 -0
  33. {godot_cli_control-0.2.3 → godot_cli_control-0.2.4}/addons/godot_cli_control/tests/run_gut.sh +0 -0
  34. {godot_cli_control-0.2.3 → godot_cli_control-0.2.4}/addons/godot_cli_control/tests/test_init_walkthrough.py +0 -0
  35. {godot_cli_control-0.2.3 → godot_cli_control-0.2.4}/addons/godot_cli_control/tests/test_walkthrough.sh +0 -0
  36. {godot_cli_control-0.2.3 → godot_cli_control-0.2.4}/pyproject.toml +0 -0
  37. {godot_cli_control-0.2.3 → godot_cli_control-0.2.4}/python/godot_cli_control/__init__.py +0 -0
  38. {godot_cli_control-0.2.3 → godot_cli_control-0.2.4}/python/godot_cli_control/__main__.py +0 -0
  39. {godot_cli_control-0.2.3 → godot_cli_control-0.2.4}/python/godot_cli_control/bridge.py +0 -0
  40. {godot_cli_control-0.2.3 → godot_cli_control-0.2.4}/python/godot_cli_control/client.py +0 -0
  41. {godot_cli_control-0.2.3 → godot_cli_control-0.2.4}/python/godot_cli_control/init_cmd.py +0 -0
  42. {godot_cli_control-0.2.3 → godot_cli_control-0.2.4}/python/godot_cli_control/pytest_plugin.py +0 -0
  43. {godot_cli_control-0.2.3 → godot_cli_control-0.2.4}/python/godot_cli_control/runner.py +0 -0
  44. {godot_cli_control-0.2.3 → godot_cli_control-0.2.4}/python/godot_cli_control/skills_install.py +0 -0
  45. {godot_cli_control-0.2.3 → godot_cli_control-0.2.4}/python/godot_cli_control/templates/__init__.py +0 -0
  46. {godot_cli_control-0.2.3 → godot_cli_control-0.2.4}/python/godot_cli_control/templates/skill/__init__.py +0 -0
  47. {godot_cli_control-0.2.3 → godot_cli_control-0.2.4}/python/tests/__init__.py +0 -0
  48. {godot_cli_control-0.2.3 → godot_cli_control-0.2.4}/python/tests/test_bridge.py +0 -0
  49. {godot_cli_control-0.2.3 → godot_cli_control-0.2.4}/python/tests/test_cli_helpers.py +0 -0
  50. {godot_cli_control-0.2.3 → godot_cli_control-0.2.4}/python/tests/test_client.py +0 -0
  51. {godot_cli_control-0.2.3 → godot_cli_control-0.2.4}/python/tests/test_init.py +0 -0
  52. {godot_cli_control-0.2.3 → godot_cli_control-0.2.4}/python/tests/test_runner.py +0 -0
  53. {godot_cli_control-0.2.3 → godot_cli_control-0.2.4}/python/tests/test_skills_install.py +0 -0
@@ -12,6 +12,8 @@ venv/
12
12
  .coverage.*
13
13
  coverage.xml
14
14
  htmlcov/
15
+ # uv 本地 dev 锁;本项目作为库发布,不入库(避免给下游消费者制造噪音)
16
+ uv.lock
15
17
 
16
18
  # hatch-vcs generated
17
19
  python/godot_cli_control/_version.py
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: godot-cli-control
3
- Version: 0.2.3
3
+ Version: 0.2.4
4
4
  Summary: WebSocket bridge for headless / scripted control of Godot scenes.
5
5
  Author: kesar
6
6
  License: MIT
@@ -53,7 +53,8 @@ import asyncio
53
53
  from godot_cli_control import GameClient
54
54
 
55
55
  async def main():
56
- async with GameClient(port=9877) as client:
56
+ # Omitting port lets GameClient auto-discover from .cli_control/port (written by daemon start)
57
+ async with GameClient() as client:
57
58
  tree = await client.get_scene_tree(depth=3)
58
59
  await client.click("/root/MyScene/Button")
59
60
  await client.action_press("jump")
@@ -101,7 +102,7 @@ def test_jump(godot_daemon, bridge):
101
102
  CLI options:
102
103
 
103
104
  ```
104
- --godot-cli-port=N # GameBridge port (default 9877)
105
+ --godot-cli-port=N # GameBridge port (default: read from .cli_control/port)
105
106
  --godot-cli-no-headless # open a real Godot window
106
107
  --godot-cli-project-root=DIR # default: pytest rootdir
107
108
  ```
@@ -113,9 +114,10 @@ The CLI is the canonical surface — every `GameClient` method has a one-line eq
113
114
  ```bash
114
115
  # Lifecycle
115
116
  godot-cli-control init [--path DIR] [--force]
116
- godot-cli-control daemon start [--headless --record --movie-path X --fps N --port N]
117
- godot-cli-control daemon stop
117
+ godot-cli-control daemon start [--headless --record --movie-path X --fps N --port N --idle-timeout 30m]
118
+ godot-cli-control daemon stop [--all | --project PATH]
118
119
  godot-cli-control daemon status
120
+ godot-cli-control daemon ls # list running daemons across all projects
119
121
  godot-cli-control run <script.py> [--headless ...]
120
122
 
121
123
  # Read
@@ -11,6 +11,14 @@ var _tcp_server: TCPServer = TCPServer.new()
11
11
  var _active_peer: WebSocketPeer = null
12
12
  var _active_stream: StreamPeerTCP = null
13
13
  var _port: int = DEFAULT_PORT
14
+ var _idle_timeout_secs: int = 0
15
+ var _last_activity_ms: int = 0
16
+ # 正在处理中的请求数。> 0 表示 daemon 没闲着,idle 检查必须放过 —— 否则
17
+ # 一条 wait_game_time(3600) 会被 30m idle-timeout 半路打断,客户端拿不到响应。
18
+ # 入:消息通过参数校验、即将派发到 handler 时 +1。
19
+ # 出:_dispatch_result(sync/async/async_with_id 三条路径的最终响应点)-1。
20
+ # 校验失败的 _send_error 不计数(没进过 handler)。
21
+ var _in_flight: int = 0
14
22
  var _outbound_buffer_size: int = DEFAULT_OUTBOUND_BUFFER_MB * 1024 * 1024
15
23
  var _low_level_api: LowLevelApi = null
16
24
  var _input_sim_api: InputSimulationApi = null
@@ -23,7 +31,12 @@ var _methods: Dictionary = {}
23
31
 
24
32
  func _ready() -> void:
25
33
  if not _should_activate():
26
- print("[Godot CLI Control] inactive — pass --cli-control, set GODOT_CLI_CONTROL=1, or enable %s in Project Settings (debug build only)" % SETTING_AUTO_ENABLE)
34
+ print(
35
+ (
36
+ "[Godot CLI Control] inactive — pass --cli-control, set GODOT_CLI_CONTROL=1, or enable %s in Project Settings (debug build only)"
37
+ % SETTING_AUTO_ENABLE
38
+ )
39
+ )
27
40
  queue_free()
28
41
  return
29
42
  # 即使 SceneTree 暂停也要继续运行
@@ -39,7 +52,9 @@ func _ready() -> void:
39
52
  # 构建统一方法注册表
40
53
  _register_methods()
41
54
  # 缓存 outbound buffer 大小(ProjectSettings 可覆盖默认 10MB,至少 1MB)
42
- var mb: int = int(ProjectSettings.get_setting(SETTING_OUTBOUND_BUFFER_MB, DEFAULT_OUTBOUND_BUFFER_MB))
55
+ var mb: int = int(
56
+ ProjectSettings.get_setting(SETTING_OUTBOUND_BUFFER_MB, DEFAULT_OUTBOUND_BUFFER_MB)
57
+ )
43
58
  _outbound_buffer_size = max(1, mb) * 1024 * 1024
44
59
  # 启动 TCP 服务器
45
60
  _port = _parse_port_from_args()
@@ -54,6 +69,16 @@ func _ready() -> void:
54
69
  printerr(msg)
55
70
  return
56
71
  print("GameBridge: Listening on ws://127.0.0.1:%d" % _port)
72
+ _idle_timeout_secs = _parse_idle_timeout_from_args()
73
+ _last_activity_ms = Time.get_ticks_msec()
74
+ if _idle_timeout_secs > 0:
75
+ var t: Timer = Timer.new()
76
+ t.wait_time = 1.0
77
+ t.autostart = true
78
+ t.process_mode = Node.PROCESS_MODE_ALWAYS
79
+ t.timeout.connect(_check_idle)
80
+ add_child(t)
81
+ print("GameBridge: idle-timeout %ds enabled" % _idle_timeout_secs)
57
82
 
58
83
 
59
84
  func _process(_delta: float) -> void:
@@ -118,14 +143,22 @@ func _register_methods() -> void:
118
143
  _methods["wait_game_time"] = {"callable": _low_level_api.wait_game_time_async, "kind": "async"}
119
144
  _methods["screenshot"] = {"callable": _wrap_screenshot, "kind": "async"}
120
145
  # 输入模拟(同步)
121
- _methods["input_action_press"] = {"callable": _input_sim_api.handle_action_press, "kind": "sync"}
122
- _methods["input_action_release"] = {"callable": _input_sim_api.handle_action_release, "kind": "sync"}
146
+ _methods["input_action_press"] = {
147
+ "callable": _input_sim_api.handle_action_press, "kind": "sync"
148
+ }
149
+ _methods["input_action_release"] = {
150
+ "callable": _input_sim_api.handle_action_release, "kind": "sync"
151
+ }
123
152
  _methods["input_action_tap"] = {"callable": _input_sim_api.handle_action_tap, "kind": "sync"}
124
153
  _methods["input_get_pressed"] = {"callable": _input_sim_api.handle_get_pressed, "kind": "sync"}
125
- _methods["list_input_actions"] = {"callable": _input_sim_api.handle_list_input_actions, "kind": "sync"}
154
+ _methods["list_input_actions"] = {
155
+ "callable": _input_sim_api.handle_list_input_actions, "kind": "sync"
156
+ }
126
157
  _methods["input_hold"] = {"callable": _input_sim_api.handle_hold, "kind": "sync"}
127
158
  _methods["input_release_all"] = {"callable": _input_sim_api.handle_release_all, "kind": "sync"}
128
- _methods["input_combo_cancel"] = {"callable": _input_sim_api.handle_combo_cancel, "kind": "sync"}
159
+ _methods["input_combo_cancel"] = {
160
+ "callable": _input_sim_api.handle_combo_cancel, "kind": "sync"
161
+ }
129
162
  # 输入模拟(async_with_id:handler 自行通过 _on_async_response 回响)
130
163
  _methods["input_combo"] = {"callable": _input_sim_api.handle_combo, "kind": "async_with_id"}
131
164
 
@@ -136,6 +169,7 @@ func _wrap_screenshot(_params: Dictionary) -> Dictionary:
136
169
 
137
170
 
138
171
  func _handle_message(raw: String) -> void:
172
+ _last_activity_ms = Time.get_ticks_msec()
139
173
  var parsed: Variant = JSON.parse_string(raw)
140
174
  if parsed == null or not parsed is Dictionary:
141
175
  _send_error("", -32600, "Invalid JSON")
@@ -170,6 +204,7 @@ func _handle_message(raw: String) -> void:
170
204
  var entry: Dictionary = _methods[method] as Dictionary
171
205
  var handler: Callable = entry["callable"] as Callable
172
206
  var kind: String = entry["kind"] as String
207
+ _in_flight += 1
173
208
  match kind:
174
209
  "sync":
175
210
  var result: Dictionary = handler.call(params)
@@ -186,12 +221,14 @@ func _run_async(id: String, handler: Callable, params: Dictionary) -> void:
186
221
  # 客户端 await 挂死到 timeout。先收原 Variant 自己 type-check。
187
222
  var raw: Variant = await handler.call(params)
188
223
  if not raw is Dictionary:
224
+ _in_flight = max(0, _in_flight - 1)
189
225
  _send_error(id, -32603, "internal: async handler returned non-dict")
190
226
  return
191
227
  _dispatch_result(id, raw as Dictionary)
192
228
 
193
229
 
194
230
  func _dispatch_result(id: String, result: Dictionary) -> void:
231
+ _in_flight = max(0, _in_flight - 1)
195
232
  if result.has("error"):
196
233
  var err: Dictionary = result["error"] as Dictionary
197
234
  _send_error(id, err["code"] as int, err["message"] as String)
@@ -253,9 +290,35 @@ func _parse_port_from_args() -> int:
253
290
  var port: int = parts[1].to_int()
254
291
  if port < 1 or port > 65535:
255
292
  push_warning(
256
- "GameBridge: Port %d out of range [1, 65535], falling back to %d"
257
- % [port, DEFAULT_PORT]
293
+ (
294
+ "GameBridge: Port %d out of range [1, 65535], falling back to %d"
295
+ % [port, DEFAULT_PORT]
296
+ )
258
297
  )
259
298
  return DEFAULT_PORT
260
299
  return port
261
300
  return DEFAULT_PORT
301
+
302
+
303
+ func _parse_idle_timeout_from_args() -> int:
304
+ for arg: String in OS.get_cmdline_args():
305
+ if arg.begins_with("--game-bridge-idle-timeout="):
306
+ var parts: PackedStringArray = arg.split("=", false, 1)
307
+ if parts.size() != 2 or not parts[1].is_valid_int():
308
+ push_warning("GameBridge: Invalid idle-timeout %s, disabling" % arg)
309
+ return 0
310
+ var secs: int = parts[1].to_int()
311
+ return secs if secs > 0 else 0
312
+ return 0
313
+
314
+
315
+ func _check_idle() -> void:
316
+ # 有正在处理的请求 → daemon 没闲着;把活动戳推到现在,等所有请求落地后
317
+ # 再开始计时。否则一个 wait_game_time(idle_timeout+1) 就会被半路 quit。
318
+ if _in_flight > 0:
319
+ _last_activity_ms = Time.get_ticks_msec()
320
+ return
321
+ var idle_ms: int = Time.get_ticks_msec() - _last_activity_ms
322
+ if idle_ms / 1000 >= _idle_timeout_secs:
323
+ print("GameBridge: idle for %ds, shutting down" % (idle_ms / 1000))
324
+ get_tree().quit()
@@ -344,3 +344,64 @@ func test_sync_input_action_press_routes_to_input_sim_api() -> void:
344
344
  assert_does_not_have(f, "error")
345
345
  assert_eq(_input.press_calls.size(), 1)
346
346
  assert_eq(str(_input.press_calls[0].action), "jump")
347
+
348
+
349
+ # ── idle-timeout / in-flight 计数 ─────────────────────────────────
350
+ # 防止回归:长操作(>idle_timeout 的 wait_game_time / combo)期间 _check_idle
351
+ # 不能 quit,否则客户端拿不到响应。
352
+
353
+ func test_in_flight_starts_at_zero() -> void:
354
+ assert_eq(_bridge._in_flight, 0)
355
+
356
+
357
+ func test_sync_dispatch_returns_in_flight_to_zero() -> void:
358
+ _send('{"id": "s1", "method": "click", "params": {}}')
359
+ assert_eq(_bridge._in_flight, 0, "sync 派发完成后 _in_flight 必须归零")
360
+
361
+
362
+ func test_async_dispatch_returns_in_flight_to_zero() -> void:
363
+ _send('{"id": "a1", "method": "wait_for_node", "params": {"path": "/X", "timeout": 1.0}}')
364
+ # stub 在 await get_tree().process_frame 期间 _in_flight 应保持 1
365
+ assert_eq(_bridge._in_flight, 1, "async 等待期间 _in_flight 应为 1")
366
+ await get_tree().process_frame
367
+ await get_tree().process_frame
368
+ assert_eq(_bridge._in_flight, 0, "async 派发完成后 _in_flight 必须归零")
369
+
370
+
371
+ func test_async_with_id_keeps_in_flight_until_callback() -> void:
372
+ # 关键场景:input_combo 长动作(实际可能 5s+)期间,daemon 不能因 idle quit
373
+ _send('{"id": "c1", "method": "input_combo", "params": {"steps": []}}')
374
+ assert_eq(_bridge._in_flight, 1, "async_with_id handler 未回响前 _in_flight 应为 1")
375
+ _input.finish_combo(0, {"success": true})
376
+ assert_eq(_bridge._in_flight, 0, "回调后 _in_flight 必须归零")
377
+
378
+
379
+ func test_async_handler_returning_non_dict_decrements_in_flight() -> void:
380
+ # type-guard 路径也必须减计数,否则 handler bug 会让 daemon 永远不 idle
381
+ _bridge._methods["wait_for_node"] = {
382
+ "callable": _async_returning_null,
383
+ "kind": "async",
384
+ }
385
+ _send('{"id": "wn", "method": "wait_for_node", "params": {}}')
386
+ await get_tree().process_frame
387
+ await get_tree().process_frame
388
+ assert_eq(_bridge._in_flight, 0, "非 dict 错误路径也必须把 _in_flight 减回 0")
389
+
390
+
391
+ func test_validation_failure_does_not_increment_in_flight() -> void:
392
+ # Invalid JSON / 协议错没进过 handler,不应碰计数
393
+ _send("not json")
394
+ _send('{"id": 42, "method": "click"}') # id 非串
395
+ _send('{"id": "x", "method": "no_such"}') # 未知方法
396
+ assert_eq(_bridge._in_flight, 0, "校验失败路径不应增 _in_flight")
397
+
398
+
399
+ func test_check_idle_resets_activity_when_busy() -> void:
400
+ # _check_idle 在 _in_flight > 0 时必须把活动戳推到现在 —— 否则一个跨越
401
+ # 整个 idle_timeout 的长操作会被 quit。
402
+ _bridge._idle_timeout_secs = 1
403
+ _bridge._in_flight = 1
404
+ _bridge._last_activity_ms = Time.get_ticks_msec() - 60_000 # 装作 60s 前
405
+ _bridge._check_idle()
406
+ var now: int = Time.get_ticks_msec()
407
+ assert_almost_eq(_bridge._last_activity_ms, now, 200, "busy 时 _check_idle 必须把活动戳推到 ~now")
@@ -36,7 +36,8 @@ import asyncio
36
36
  from godot_cli_control import GameClient
37
37
 
38
38
  async def main():
39
- async with GameClient(port=9877) as client:
39
+ # Omitting port lets GameClient auto-discover from .cli_control/port (written by daemon start)
40
+ async with GameClient() as client:
40
41
  tree = await client.get_scene_tree(depth=3)
41
42
  await client.click("/root/MyScene/Button")
42
43
  await client.action_press("jump")
@@ -84,7 +85,7 @@ def test_jump(godot_daemon, bridge):
84
85
  CLI options:
85
86
 
86
87
  ```
87
- --godot-cli-port=N # GameBridge port (default 9877)
88
+ --godot-cli-port=N # GameBridge port (default: read from .cli_control/port)
88
89
  --godot-cli-no-headless # open a real Godot window
89
90
  --godot-cli-project-root=DIR # default: pytest rootdir
90
91
  ```
@@ -96,9 +97,10 @@ The CLI is the canonical surface — every `GameClient` method has a one-line eq
96
97
  ```bash
97
98
  # Lifecycle
98
99
  godot-cli-control init [--path DIR] [--force]
99
- godot-cli-control daemon start [--headless --record --movie-path X --fps N --port N]
100
- godot-cli-control daemon stop
100
+ godot-cli-control daemon start [--headless --record --movie-path X --fps N --port N --idle-timeout 30m]
101
+ godot-cli-control daemon stop [--all | --project PATH]
101
102
  godot-cli-control daemon status
103
+ godot-cli-control daemon ls # list running daemons across all projects
102
104
  godot-cli-control run <script.py> [--headless ...]
103
105
 
104
106
  # Read
@@ -0,0 +1,18 @@
1
+ """Parse human-friendly duration strings (e.g. "30m", "2h", "90s") to seconds."""
2
+ from __future__ import annotations
3
+
4
+ import re
5
+
6
+ # 不允许数字与单位之间留空格 —— 与帮助文本「30m / 2h / 90s」保持一致,避免
7
+ # 既文档教 30m 又静默接受 "30 m" 这种半通不通的输入。两端 \s* 仍保留,方便
8
+ # 用户从 shell 复制带尾换行的字串。
9
+ _PATTERN = re.compile(r"^\s*(\d+)([smh]?)\s*$")
10
+ _UNITS = {"": 1, "s": 1, "m": 60, "h": 3600}
11
+
12
+
13
+ def parse_duration(text: str) -> int:
14
+ """Return total seconds. Bare integer = seconds. 0 means disabled."""
15
+ m = _PATTERN.match(text)
16
+ if not m:
17
+ raise ValueError(f"invalid duration {text!r} (use 30s / 30m / 2h / 0)")
18
+ return int(m.group(1)) * _UNITS[m.group(2)]
@@ -18,7 +18,7 @@ version_tuple: tuple[int | str, ...]
18
18
  commit_id: str | None
19
19
  __commit_id__: str | None
20
20
 
21
- __version__ = version = '0.2.3'
22
- __version_tuple__ = version_tuple = (0, 2, 3)
21
+ __version__ = version = '0.2.4'
22
+ __version_tuple__ = version_tuple = (0, 2, 4)
23
23
 
24
24
  __commit_id__ = commit_id = None
@@ -42,6 +42,10 @@ OUTPUT_TEXT = "text"
42
42
  EXIT_OK = 0
43
43
  EXIT_RPC_ERROR = 1
44
44
  EXIT_INFRA_ERROR = 2 # 连接 / 超时 / 用户输入解析失败
45
+ # `daemon stop --all` 专用:至少一条 stop 失败。不复用 EXIT_INFRA_ERROR(=2) —— 单个项目
46
+ # stop rc=2 是「daemon 已停但 ffmpeg 转码失败」的合法成功旁路;--all 聚合若也用 2,
47
+ # 调用方分不清「全停成功只是某个 transcode 失败」与「真有 daemon 没停掉」。
48
+ EXIT_PARTIAL = 3
45
49
  EXIT_USAGE = 64 # 命令组合无效(如 combo 既无文件又无 --steps-json)
46
50
 
47
51
  # RPC 错误统一信封(无论 --json 还是 --text)的连接/超时占位 code。GD 端
@@ -641,6 +645,13 @@ RPC_BY_NAME: dict[str, RpcSpec] = {s.name: s for s in RPC_SPECS}
641
645
 
642
646
  def cmd_daemon_start(ns: argparse.Namespace) -> int:
643
647
  from .daemon import Daemon, DaemonError
648
+ from ._duration import parse_duration
649
+
650
+ try:
651
+ idle_seconds = parse_duration(getattr(ns, "idle_timeout", "0"))
652
+ except ValueError as e:
653
+ _emit_top_error(ns, code=CLIENT_CODE_USAGE, message=str(e))
654
+ return EXIT_USAGE
644
655
 
645
656
  daemon = Daemon(Path.cwd())
646
657
  try:
@@ -650,6 +661,7 @@ def cmd_daemon_start(ns: argparse.Namespace) -> int:
650
661
  headless=ns.headless,
651
662
  fps=ns.fps,
652
663
  port=ns.port,
664
+ idle_timeout=idle_seconds,
653
665
  )
654
666
  except DaemonError as e:
655
667
  _emit_top_error(ns, code=CLIENT_CODE_USAGE, message=str(e))
@@ -670,17 +682,64 @@ def cmd_daemon_start(ns: argparse.Namespace) -> int:
670
682
 
671
683
  def cmd_daemon_stop(ns: argparse.Namespace) -> int:
672
684
  from .daemon import Daemon, DaemonError
685
+ from . import registry
673
686
 
674
- daemon = Daemon(Path.cwd())
687
+ fmt = _output_format(ns)
688
+
689
+ if getattr(ns, "all", False):
690
+ records = registry.list_all()
691
+ if not records:
692
+ if fmt == OUTPUT_JSON:
693
+ _emit_success_payload({"stopped": [], "rc": 0})
694
+ else:
695
+ print("(no running daemons)")
696
+ return EXIT_OK
697
+ results: list[dict[str, Any]] = []
698
+ had_failure = False
699
+ for r in records:
700
+ entry: dict[str, Any] = {
701
+ "project_root": r.project_root,
702
+ "pid": r.pid,
703
+ "port": r.port,
704
+ }
705
+ try:
706
+ rc = Daemon(Path(r.project_root)).stop()
707
+ entry["rc"] = rc
708
+ if fmt != OUTPUT_JSON:
709
+ suffix = f" (rc={rc})" if rc != 0 else ""
710
+ print(f"stopped pid={r.pid} port={r.port} {r.project_root}{suffix}")
711
+ except DaemonError as e:
712
+ entry["rc"] = EXIT_INFRA_ERROR
713
+ entry["error"] = str(e)
714
+ had_failure = True
715
+ # 单条失败不能阻止其余 daemon 收尾
716
+ print(f"[{r.project_root}] {e}", file=sys.stderr)
717
+ results.append(entry)
718
+ # rc 含义:0 = 全部成功;EXIT_PARTIAL = 至少一条 DaemonError。注意单条 stop
719
+ # 返回 2(ffmpeg 转码失败但 daemon 已停)不算"失败",按成功汇总;调用方
720
+ # 想要逐条状态请看 JSON 输出 / 文本里的每行 rc=N 标记。
721
+ rc_total = EXIT_PARTIAL if had_failure else EXIT_OK
722
+ if fmt == OUTPUT_JSON:
723
+ _emit_success_payload({"stopped": results, "rc": rc_total})
724
+ else:
725
+ failed = sum(1 for x in results if "error" in x)
726
+ print(
727
+ f"summary: {len(results) - failed}/{len(results)} stopped"
728
+ + (f", {failed} failed" if failed else "")
729
+ )
730
+ return rc_total
731
+
732
+ target = (ns.project.resolve() if getattr(ns, "project", None) else Path.cwd())
733
+ daemon = Daemon(target)
675
734
  try:
676
735
  rc = daemon.stop()
677
736
  except DaemonError as e:
678
737
  _emit_top_error(ns, code=CLIENT_CODE_USAGE, message=str(e))
679
738
  return EXIT_INFRA_ERROR
680
- if _output_format(ns) == OUTPUT_JSON:
739
+ if fmt == OUTPUT_JSON:
681
740
  # rc 0=正常停 / 2=ffmpeg 转码失败但 daemon 已停。两种都算"stopped",
682
741
  # 把 rc 透出让 agent 决定要不要 retry transcode。
683
- _emit_success_payload({"stopped": True, "rc": rc})
742
+ _emit_success_payload({"stopped": True, "rc": rc, "project_root": str(target)})
684
743
  return rc
685
744
 
686
745
 
@@ -712,22 +771,82 @@ def cmd_daemon_status(ns: argparse.Namespace) -> int:
712
771
  f"running pid={pid} port={port if port is not None else '?'}"
713
772
  )
714
773
  return EXIT_OK
774
+ # Stopped:若上一轮启动留下了 godot.log / last_exit_code,把诊断信息透出来。
775
+ # issue #38 要求 daemon 已退出时直接告诉用户「last exit: <code>, see ...log」,
776
+ # 不让用户再手摸 .cli_control/ 翻文件。
777
+ stopped_payload: dict[str, Any] = {"state": "stopped"}
778
+ if daemon.log_file.exists():
779
+ stopped_payload["last_log"] = str(daemon.log_file)
780
+ last_rc = daemon.read_last_exit_code()
781
+ if last_rc is not None:
782
+ stopped_payload["last_exit_code"] = last_rc
715
783
  if fmt == OUTPUT_JSON:
716
- _emit_success_payload({"state": "stopped"})
784
+ _emit_success_payload(stopped_payload)
717
785
  else:
718
- print("stopped")
786
+ hints: list[str] = []
787
+ if "last_exit_code" in stopped_payload:
788
+ hints.append(f"last exit: {stopped_payload['last_exit_code']}")
789
+ if "last_log" in stopped_payload:
790
+ hints.append(f"see {stopped_payload['last_log']}")
791
+ if hints:
792
+ print(f"stopped ({', '.join(hints)})")
793
+ else:
794
+ print("stopped")
719
795
  return EXIT_RPC_ERROR
720
796
 
721
797
 
798
+ def cmd_daemon_ls(ns: argparse.Namespace) -> int:
799
+ """跨项目列出运行中的 daemon。
800
+
801
+ 扫全局注册表 ~/.local/state/godot-cli-control/daemons/,对每条记录探活。
802
+ 死记录会被 list_all 自动清理(连同对应项目的 .cli_control/godot.pid 与 port)。
803
+ JSON 模式:{"ok": true, "result": {"daemons": [...]}}(信封一致)。
804
+ Text 模式:每条一行 `<pid>\t<port>\t<project_root>\t<started_at>`;空时 (no running daemons)。
805
+ """
806
+ from . import registry
807
+
808
+ fmt = _output_format(ns)
809
+ records = registry.list_all()
810
+ payload = {
811
+ "daemons": [
812
+ {
813
+ "project_root": r.project_root,
814
+ "pid": r.pid,
815
+ "port": r.port,
816
+ "started_at": r.started_at,
817
+ "godot_bin": r.godot_bin,
818
+ "log_path": r.log_path,
819
+ }
820
+ for r in records
821
+ ]
822
+ }
823
+ if fmt == OUTPUT_JSON:
824
+ _emit_success_payload(payload)
825
+ else:
826
+ if not records:
827
+ print("(no running daemons)")
828
+ else:
829
+ for r in records:
830
+ print(f"{r.pid}\t{r.port}\t{r.project_root}\t{r.started_at}")
831
+ return EXIT_OK
832
+
833
+
722
834
  def cmd_run(ns: argparse.Namespace) -> int:
723
835
  """加载用户脚本(要求定义 ``run(bridge)``),自动启停 daemon。"""
724
836
  from .daemon import Daemon, DaemonError
837
+ from ._duration import parse_duration
725
838
 
726
839
  script_path = Path(ns.script)
727
840
  if not script_path.exists():
728
841
  print(f"错误:找不到脚本: {script_path}", file=sys.stderr)
729
842
  return 1
730
843
 
844
+ try:
845
+ idle_seconds = parse_duration(getattr(ns, "idle_timeout", "0"))
846
+ except ValueError as e:
847
+ print(f"错误:{e}", file=sys.stderr)
848
+ return EXIT_USAGE
849
+
731
850
  daemon = Daemon(Path.cwd())
732
851
  auto_started = False
733
852
  if not daemon.is_running():
@@ -738,6 +857,7 @@ def cmd_run(ns: argparse.Namespace) -> int:
738
857
  headless=ns.headless,
739
858
  fps=ns.fps,
740
859
  port=ns.port,
860
+ idle_timeout=idle_seconds,
741
861
  )
742
862
  except DaemonError as e:
743
863
  print(f"错误:{e}", file=sys.stderr)
@@ -935,8 +1055,14 @@ def _add_daemon_flags(p: argparse.ArgumentParser) -> None:
935
1055
  p.add_argument(
936
1056
  "--port",
937
1057
  type=int,
938
- default=DEFAULT_PORT,
939
- help=f"GameBridge 监听端口(默认 {DEFAULT_PORT})",
1058
+ default=0,
1059
+ help="GameBridge 监听端口(默认 0 = OS 自动分配;写入 .cli_control/port)",
1060
+ )
1061
+ p.add_argument(
1062
+ "--idle-timeout",
1063
+ type=str,
1064
+ default="0",
1065
+ help="空闲超时(如 30m / 2h / 90s / 0=关闭,默认关)。开启后 Godot 端 Timer 自动 quit。",
940
1066
  )
941
1067
 
942
1068
 
@@ -1015,10 +1141,22 @@ def build_parser() -> argparse.ArgumentParser:
1015
1141
  )
1016
1142
  _add_daemon_flags(start_p)
1017
1143
 
1018
- daemon_subs.add_parser(
1144
+ stop_p = daemon_subs.add_parser(
1019
1145
  "stop",
1020
1146
  help="停止 daemon",
1021
- description="停止 .cli_control/godot.pid 记录的 daemon。",
1147
+ description=(
1148
+ "停止 daemon。无 flag 时停 cwd 项目;--all 停所有注册的 daemon;"
1149
+ "--project <path> 停指定项目。"
1150
+ ),
1151
+ )
1152
+ stop_grp = stop_p.add_mutually_exclusive_group()
1153
+ stop_grp.add_argument(
1154
+ "--all", action="store_true",
1155
+ help="停止注册表中所有运行中的 daemon"
1156
+ )
1157
+ stop_grp.add_argument(
1158
+ "--project", type=Path, default=None,
1159
+ help="停止指定项目根的 daemon(绝对/相对路径均可)"
1022
1160
  )
1023
1161
 
1024
1162
  daemon_subs.add_parser(
@@ -1032,6 +1170,16 @@ def build_parser() -> argparse.ArgumentParser:
1032
1170
  ),
1033
1171
  )
1034
1172
 
1173
+ ls_p = daemon_subs.add_parser(
1174
+ "ls",
1175
+ help="列出所有正在运行的 daemon(跨项目)",
1176
+ description=(
1177
+ "扫描全局注册表 ~/.local/state/godot-cli-control/daemons/,"
1178
+ "列出所有探活通过的 daemon。死记录会被自动清理。"
1179
+ ),
1180
+ )
1181
+ _add_output_format_flags(ls_p)
1182
+
1035
1183
  # run:自动启停 + 跑用户脚本
1036
1184
  run_p = subs.add_parser(
1037
1185
  "run",
@@ -1261,6 +1409,8 @@ def main() -> None:
1261
1409
  sys.exit(cmd_daemon_stop(ns))
1262
1410
  if ns.action == "status":
1263
1411
  sys.exit(cmd_daemon_status(ns))
1412
+ if ns.action == "ls":
1413
+ sys.exit(cmd_daemon_ls(ns))
1264
1414
  parser.error(f"unknown daemon action: {ns.action}")
1265
1415
  if ns.cmd == "run":
1266
1416
  sys.exit(cmd_run(ns))