ring-cli 0.2.0__py3-none-any.whl
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.
- ring/__init__.py +12 -0
- ring/__main__.py +6 -0
- ring/cli.py +685 -0
- ring/config.py +276 -0
- ring/focus/__init__.py +65 -0
- ring/focus/applescript.py +34 -0
- ring/focus/base.py +19 -0
- ring/focus/iterm2.py +28 -0
- ring/focus/terminal.py +25 -0
- ring/focus/tmux.py +38 -0
- ring/hook.py +482 -0
- ring/hook_protocol.py +264 -0
- ring/i18n.py +52 -0
- ring/ipc.py +220 -0
- ring/labels.py +50 -0
- ring/locale/en/LC_MESSAGES/ring.mo +0 -0
- ring/locale/en/LC_MESSAGES/ring.po +395 -0
- ring/locale/ring.pot +484 -0
- ring/notify/__init__.py +120 -0
- ring/notify/base.py +37 -0
- ring/notify/command.py +18 -0
- ring/notify/notify_send.py +30 -0
- ring/notify/osascript_notifier.py +32 -0
- ring/notify/terminal_notifier.py +64 -0
- ring/osascript.py +17 -0
- ring/registry.py +851 -0
- ring/sources/__init__.py +94 -0
- ring/sources/base.py +17 -0
- ring/sources/claude_code.py +23 -0
- ring/sources/codex.py +16 -0
- ring/sources/hook_registry.py +16 -0
- ring/tui.py +360 -0
- ring/watcher.py +107 -0
- ring_cli-0.2.0.dist-info/METADATA +334 -0
- ring_cli-0.2.0.dist-info/RECORD +38 -0
- ring_cli-0.2.0.dist-info/WHEEL +4 -0
- ring_cli-0.2.0.dist-info/entry_points.txt +3 -0
- ring_cli-0.2.0.dist-info/licenses/LICENSE +21 -0
ring/hook_protocol.py
ADDED
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
"""Provider-neutral hook event normalization for RiNG.
|
|
2
|
+
|
|
3
|
+
外部工具只要能把事件 JSON 餵給 ``ring hook``,就能寫入同一份 registry。
|
|
4
|
+
provider adapter 負責把各工具的事件名稱與欄位正規化,registry writer 不需要知道
|
|
5
|
+
Claude Code、Codex 或未來工具的細節。
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from collections.abc import Mapping
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from typing import Any, Protocol
|
|
13
|
+
|
|
14
|
+
from ring.registry import Status
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class HookAdapter(Protocol):
|
|
18
|
+
"""把某個 provider 的 hook payload 正規化成 RiNG event。"""
|
|
19
|
+
|
|
20
|
+
provider: str
|
|
21
|
+
process_names: tuple[str, ...]
|
|
22
|
+
|
|
23
|
+
def normalize(self, data: Mapping[str, Any]) -> NormalizedHookEvent | None:
|
|
24
|
+
"""回傳正規化事件;不支援或 payload 不足時回 ``None``。"""
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass(frozen=True)
|
|
28
|
+
class NormalizedHookEvent:
|
|
29
|
+
provider: str
|
|
30
|
+
event: str
|
|
31
|
+
session_id: str
|
|
32
|
+
cwd: str
|
|
33
|
+
status: Status
|
|
34
|
+
transcript_path: str = ""
|
|
35
|
+
tty: str = ""
|
|
36
|
+
last_action: str = ""
|
|
37
|
+
waiting_for: str = ""
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
_ALWAYS_STATUS = {
|
|
41
|
+
"SessionStart": Status.WORKING,
|
|
42
|
+
"UserPromptSubmit": Status.WORKING,
|
|
43
|
+
"Stop": Status.IDLE,
|
|
44
|
+
"SessionEnd": Status.ENDED,
|
|
45
|
+
"PermissionRequest": Status.WAITING,
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
_ACTION_REQUIRED_NOTIFICATION_TYPES = {
|
|
49
|
+
"permission_prompt",
|
|
50
|
+
"elicitation_dialog",
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
_ACTION_REQUIRED_WAITING_FOR = {
|
|
54
|
+
"approval",
|
|
55
|
+
"choice",
|
|
56
|
+
"choices",
|
|
57
|
+
"elicitation",
|
|
58
|
+
"input",
|
|
59
|
+
"option",
|
|
60
|
+
"options",
|
|
61
|
+
"permission",
|
|
62
|
+
"question",
|
|
63
|
+
"questions",
|
|
64
|
+
"selection",
|
|
65
|
+
"user",
|
|
66
|
+
"user_input",
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
_IDLE_WAITING_FOR = {
|
|
70
|
+
"",
|
|
71
|
+
"complete",
|
|
72
|
+
"done",
|
|
73
|
+
"idle",
|
|
74
|
+
"instruction",
|
|
75
|
+
"next_step",
|
|
76
|
+
"none",
|
|
77
|
+
"prompt",
|
|
78
|
+
"turn_complete",
|
|
79
|
+
"user_prompt",
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
class CommonHookAdapter:
|
|
84
|
+
"""共用事件語意 adapter。
|
|
85
|
+
|
|
86
|
+
適合 Claude Code、Codex,以及未來沿用類似 lifecycle event 的 agent CLI。
|
|
87
|
+
"""
|
|
88
|
+
|
|
89
|
+
def __init__(self, provider: str, process_names: tuple[str, ...]) -> None:
|
|
90
|
+
self.provider = provider
|
|
91
|
+
self.process_names = process_names
|
|
92
|
+
|
|
93
|
+
def normalize(self, data: Mapping[str, Any]) -> NormalizedHookEvent | None:
|
|
94
|
+
event = _first_str(data, "event", "event_name", "hook_event_name", "hookEventName")
|
|
95
|
+
sid = _first_str(data, "session_id", "sessionId", "thread_id", "threadId", "id")
|
|
96
|
+
if not event or not sid:
|
|
97
|
+
return None
|
|
98
|
+
status = _ALWAYS_STATUS.get(event)
|
|
99
|
+
explicit_requires_action = _explicit_requires_action(data)
|
|
100
|
+
if event == "SessionEnd":
|
|
101
|
+
status = Status.ENDED
|
|
102
|
+
elif event in {"SessionStart", "UserPromptSubmit"}:
|
|
103
|
+
status = Status.WORKING
|
|
104
|
+
elif explicit_requires_action is not None:
|
|
105
|
+
status = Status.WAITING if explicit_requires_action else Status.IDLE
|
|
106
|
+
elif event == "Notification":
|
|
107
|
+
status = Status.WAITING if _is_action_required_notification(data) else Status.IDLE
|
|
108
|
+
elif event == "PreToolUse":
|
|
109
|
+
# 工具要動了:權限 / 選項類 → 🔴 WAITING;其餘 → 🟢 WORKING。
|
|
110
|
+
# 非 action 的 PreToolUse 也明確收斂成 WORKING(而非不寫),這樣
|
|
111
|
+
# 上一個 WAITING(例如剛答完的權限)會被下一個工具動作清掉。
|
|
112
|
+
status = Status.WAITING if _is_action_required_payload(data) else Status.WORKING
|
|
113
|
+
elif event == "PostToolUse":
|
|
114
|
+
# 工具跑完代表使用者已放行、agent 又在動 → 🟢 WORKING。
|
|
115
|
+
# 這是「回應完」最乾淨的訊號,用來清掉卡住的 WAITING、止住重複通知。
|
|
116
|
+
status = Status.WORKING
|
|
117
|
+
if status is None:
|
|
118
|
+
return None
|
|
119
|
+
return NormalizedHookEvent(
|
|
120
|
+
provider=self.provider,
|
|
121
|
+
event=event,
|
|
122
|
+
session_id=_qualified_session_id(self.provider, sid),
|
|
123
|
+
cwd=_first_str(data, "cwd", "current_working_directory", "currentWorkingDirectory"),
|
|
124
|
+
status=status,
|
|
125
|
+
transcript_path=_first_str(data, "transcript_path", "transcriptPath", "rollout_path", "rolloutPath"),
|
|
126
|
+
tty=_first_str(data, "tty"),
|
|
127
|
+
last_action=_first_str(data, "last_action", "lastAction", "message", "title"),
|
|
128
|
+
waiting_for=_waiting_for(data),
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def adapter_for(provider: str) -> HookAdapter:
|
|
133
|
+
normalized = normalize_provider(provider)
|
|
134
|
+
if normalized in {"claude", "claude-code"}:
|
|
135
|
+
return CommonHookAdapter("claude-code", ("claude",))
|
|
136
|
+
if normalized == "codex":
|
|
137
|
+
return CommonHookAdapter("codex", ("codex",))
|
|
138
|
+
return CommonHookAdapter(normalized or "generic", ())
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def provider_from_payload(data: Mapping[str, Any], fallback: str = "claude-code") -> str:
|
|
142
|
+
# 只認明確的 "provider" 欄位。不要拿 "source"——Claude Code 的 SessionStart payload
|
|
143
|
+
# 帶 source="startup"/"resume"/"clear"/"compact"(觸發來源,不是 provider),
|
|
144
|
+
# 誤當 provider 會生出 "startup:<id>" 幽靈 session、且永遠不會被標離場。
|
|
145
|
+
return normalize_provider(_first_str(data, "provider") or fallback)
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def normalize_provider(provider: str) -> str:
|
|
149
|
+
return provider.strip().lower().replace("_", "-")
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _first_str(data: Mapping[str, Any], *keys: str) -> str:
|
|
153
|
+
for key in keys:
|
|
154
|
+
value = data.get(key)
|
|
155
|
+
if isinstance(value, str) and value:
|
|
156
|
+
return value
|
|
157
|
+
return ""
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _is_action_required_notification(data: Mapping[str, Any]) -> bool:
|
|
161
|
+
notification_type = _normalize_token(
|
|
162
|
+
_first_str(data, "notification_type", "notificationType", "raw_notification_type", "rawNotificationType")
|
|
163
|
+
)
|
|
164
|
+
if notification_type in _ACTION_REQUIRED_NOTIFICATION_TYPES:
|
|
165
|
+
return True
|
|
166
|
+
return _is_action_required_payload(data)
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def _explicit_requires_action(data: Mapping[str, Any]) -> bool | None:
|
|
170
|
+
for key in (
|
|
171
|
+
"requires_action",
|
|
172
|
+
"requiresAction",
|
|
173
|
+
"action_required",
|
|
174
|
+
"actionRequired",
|
|
175
|
+
"needs_user_action",
|
|
176
|
+
"needsUserAction",
|
|
177
|
+
"requires_input",
|
|
178
|
+
"requiresInput",
|
|
179
|
+
"interactive",
|
|
180
|
+
):
|
|
181
|
+
parsed = _parse_bool(data.get(key))
|
|
182
|
+
if parsed is not None:
|
|
183
|
+
return parsed
|
|
184
|
+
|
|
185
|
+
waiting_for = _waiting_for(data)
|
|
186
|
+
if not waiting_for:
|
|
187
|
+
return None
|
|
188
|
+
if waiting_for in _ACTION_REQUIRED_WAITING_FOR:
|
|
189
|
+
return True
|
|
190
|
+
if waiting_for in _IDLE_WAITING_FOR:
|
|
191
|
+
return False
|
|
192
|
+
return None
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def _is_action_required_payload(data: Mapping[str, Any]) -> bool:
|
|
196
|
+
tool_name = _first_str(data, "tool_name", "toolName", "tool")
|
|
197
|
+
if tool_name == "AskUserQuestion":
|
|
198
|
+
return True
|
|
199
|
+
|
|
200
|
+
tool_input = data.get("tool_input") or data.get("toolInput") or data.get("input")
|
|
201
|
+
if isinstance(tool_input, Mapping):
|
|
202
|
+
questions = tool_input.get("questions")
|
|
203
|
+
if isinstance(questions, list) and questions:
|
|
204
|
+
return True
|
|
205
|
+
options = tool_input.get("options")
|
|
206
|
+
if isinstance(options, list) and options:
|
|
207
|
+
return True
|
|
208
|
+
|
|
209
|
+
for key in ("questions", "options", "choices"):
|
|
210
|
+
value = data.get(key)
|
|
211
|
+
if isinstance(value, list) and value:
|
|
212
|
+
return True
|
|
213
|
+
return False
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def _waiting_for(data: Mapping[str, Any]) -> str:
|
|
217
|
+
return _normalize_token(
|
|
218
|
+
_first_str(
|
|
219
|
+
data,
|
|
220
|
+
"waiting_for",
|
|
221
|
+
"waitingFor",
|
|
222
|
+
"requires",
|
|
223
|
+
"reason",
|
|
224
|
+
"interaction",
|
|
225
|
+
"interaction_type",
|
|
226
|
+
"interactionType",
|
|
227
|
+
)
|
|
228
|
+
).replace("-", "_")
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def _parse_bool(value: object) -> bool | None:
|
|
232
|
+
if isinstance(value, bool):
|
|
233
|
+
return value
|
|
234
|
+
if isinstance(value, str):
|
|
235
|
+
normalized = value.strip().lower()
|
|
236
|
+
if normalized in {"1", "true", "yes", "y", "on"}:
|
|
237
|
+
return True
|
|
238
|
+
if normalized in {"0", "false", "no", "n", "off"}:
|
|
239
|
+
return False
|
|
240
|
+
return None
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def _normalize_token(value: str) -> str:
|
|
244
|
+
return value.strip().lower()
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def _qualified_session_id(provider: str, sid: str) -> str:
|
|
248
|
+
if ":" in sid:
|
|
249
|
+
return sid
|
|
250
|
+
if provider in {"claude", "claude-code"}:
|
|
251
|
+
return sid
|
|
252
|
+
return f"{provider}:{sid}"
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
HOOK_EVENTS = (
|
|
256
|
+
"SessionStart",
|
|
257
|
+
"UserPromptSubmit",
|
|
258
|
+
"Notification",
|
|
259
|
+
"PermissionRequest",
|
|
260
|
+
"PreToolUse",
|
|
261
|
+
"PostToolUse",
|
|
262
|
+
"Stop",
|
|
263
|
+
"SessionEnd",
|
|
264
|
+
)
|
ring/i18n.py
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""RiNG i18n — gettext。
|
|
2
|
+
|
|
3
|
+
msgid 用**台灣漢語**:原始碼即預設語言,所以 zh-Hant 不需要任何 ``.mo``。
|
|
4
|
+
其他語言放 ``locale/<lang>/LC_MESSAGES/ring.mo``(由 ``.po`` 編譯,見 README / ``poe i18n-compile``)。
|
|
5
|
+
|
|
6
|
+
切語言:process 啟動時 ``set_lang()`` 裝上對應 translation;之後 ``_()`` / ``ngettext()`` 就用它。
|
|
7
|
+
在 ``import ring.tui`` 之前先 set_lang,class-level 的 Footer 按鍵說明也會跟著走。
|
|
8
|
+
|
|
9
|
+
``_()`` 與 ``ngettext()`` 都吃 kwargs:``_("…{x}…", x=1)`` 等同 gettext 後再 ``.format(x=1)``。
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import gettext as _gettext
|
|
15
|
+
import os
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
_LOCALE_DIR = Path(__file__).parent / "locale"
|
|
19
|
+
_DOMAIN = "ring"
|
|
20
|
+
|
|
21
|
+
_current: _gettext.NullTranslations = _gettext.NullTranslations()
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def resolve_lang(explicit: str | None = None) -> str:
|
|
25
|
+
raw = (explicit or os.environ.get("RING_LANG") or os.environ.get("LANG") or "").lower()
|
|
26
|
+
return "en" if raw.startswith("en") else "zh-Hant"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def set_lang(lang: str | None) -> None:
|
|
30
|
+
"""裝上對應語言的 translation。zh-Hant=msgid 本身(不需 .mo)。"""
|
|
31
|
+
global _current
|
|
32
|
+
if resolve_lang(lang) == "zh-Hant":
|
|
33
|
+
_current = _gettext.NullTranslations()
|
|
34
|
+
return
|
|
35
|
+
try:
|
|
36
|
+
_current = _gettext.translation(_DOMAIN, _LOCALE_DIR, languages=[resolve_lang(lang)])
|
|
37
|
+
except OSError: # .mo 不在 → 安靜退回 msgid(中文)
|
|
38
|
+
_current = _gettext.NullTranslations()
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def gettext(msgid: str, /, **kw: object) -> str:
|
|
42
|
+
# msgid 設 positional-only,這樣 format kwarg 取任何名字(msg / n …)都不會撞參數。
|
|
43
|
+
out = _current.gettext(msgid)
|
|
44
|
+
return out.format(**kw) if kw else out
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def ngettext(singular: str, plural: str, count: int, /, **kw: object) -> str:
|
|
48
|
+
out = _current.ngettext(singular, plural, count)
|
|
49
|
+
return out.format(**kw) if kw else out
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
_ = gettext
|
ring/ipc.py
ADDED
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
"""檔案式 IPC 輔助——RiNG TUI 與 ``ring focus`` subprocess 之間的溝通橋梁。
|
|
2
|
+
|
|
3
|
+
機制概述
|
|
4
|
+
---------
|
|
5
|
+
- ``~/.config/ring/focus-request``:由 ``ring focus <id>`` 寫入、TUI poll 後讀取並刪除(消費即焚)。
|
|
6
|
+
- ``~/.config/ring/tui-presence``:由 RiNG TUI 啟動時寫入、結束時刪除,同時做「TUI 是否在跑」的判斷依據。
|
|
7
|
+
|
|
8
|
+
設計原則
|
|
9
|
+
---------
|
|
10
|
+
- 純 stdlib,零新依賴。
|
|
11
|
+
- 所有跨 process 檔案操作包 try/except,失敗安靜吞掉,不打斷 TUI / 通知主流程。
|
|
12
|
+
- ``_config_dir``、``_focus_request_path``、``_presence_path`` 為可在測試中注入的私有常數,
|
|
13
|
+
使用者透過 ``tmp_path`` 覆寫即可隔離測試環境。
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import json
|
|
19
|
+
import os
|
|
20
|
+
import sys
|
|
21
|
+
import time
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
from typing import TypedDict
|
|
24
|
+
|
|
25
|
+
# --------------------------------------------------------------------------- 路徑常數(可在測試中覆寫)
|
|
26
|
+
|
|
27
|
+
_CONFIG_DIR: Path = Path.home() / ".config" / "ring"
|
|
28
|
+
_FOCUS_REQUEST_PATH: Path = _CONFIG_DIR / "focus-request"
|
|
29
|
+
_PRESENCE_PATH: Path = _CONFIG_DIR / "tui-presence"
|
|
30
|
+
|
|
31
|
+
# focus-request 的有效時效(秒):超過視為 stale,忽略。
|
|
32
|
+
_REQUEST_TTL: float = 30.0
|
|
33
|
+
|
|
34
|
+
# presence 的有效時效(秒):超過視為 stale,即使 pid 看起來還活著也不信任。
|
|
35
|
+
_PRESENCE_TTL: float = 300.0
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
# --------------------------------------------------------------------------- 型別
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class _FocusRequest(TypedDict):
|
|
42
|
+
session_id: str
|
|
43
|
+
ts: float
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class _TuiPresence(TypedDict):
|
|
47
|
+
tty: str
|
|
48
|
+
pid: int
|
|
49
|
+
ts: float
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
# --------------------------------------------------------------------------- focus-request 相關
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def write_focus_request(session_id: str, *, request_path: Path | None = None) -> None:
|
|
56
|
+
"""把 focus-request 寫入磁碟,供 TUI poll 讀取。
|
|
57
|
+
|
|
58
|
+
:param session_id: 要聚焦的 session ID。
|
|
59
|
+
:param request_path: 可注入路徑(測試用);省略時用預設路徑。
|
|
60
|
+
"""
|
|
61
|
+
path = request_path or _FOCUS_REQUEST_PATH
|
|
62
|
+
payload: _FocusRequest = {"session_id": session_id, "ts": time.time()}
|
|
63
|
+
try:
|
|
64
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
65
|
+
path.write_text(json.dumps(payload), encoding="utf-8")
|
|
66
|
+
except Exception:
|
|
67
|
+
pass
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def read_focus_request(
|
|
71
|
+
*,
|
|
72
|
+
request_path: Path | None = None,
|
|
73
|
+
ttl: float = _REQUEST_TTL,
|
|
74
|
+
) -> str | None:
|
|
75
|
+
"""讀取 focus-request 並消費即焚。
|
|
76
|
+
|
|
77
|
+
- 無檔案 → 回傳 ``None``,不刪(無檔可刪)。
|
|
78
|
+
- 無效 JSON → 刪掉爛檔,回傳 ``None``。
|
|
79
|
+
- 過期(超過 ttl 秒)→ 刪檔,回傳 ``None``。
|
|
80
|
+
- 有效 request → 刪檔,回傳 ``session_id``。
|
|
81
|
+
|
|
82
|
+
:param request_path: 可注入路徑(測試用)。
|
|
83
|
+
:param ttl: 有效時效秒數,超過視為 stale。
|
|
84
|
+
:returns: session_id 或 None。
|
|
85
|
+
"""
|
|
86
|
+
path = request_path or _FOCUS_REQUEST_PATH
|
|
87
|
+
try:
|
|
88
|
+
raw = path.read_text(encoding="utf-8")
|
|
89
|
+
except Exception:
|
|
90
|
+
return None
|
|
91
|
+
|
|
92
|
+
try:
|
|
93
|
+
data: _FocusRequest = json.loads(raw)
|
|
94
|
+
session_id = str(data["session_id"])
|
|
95
|
+
ts = float(data["ts"])
|
|
96
|
+
except Exception:
|
|
97
|
+
# 解析失敗:刪掉爛檔
|
|
98
|
+
try:
|
|
99
|
+
path.unlink()
|
|
100
|
+
except Exception:
|
|
101
|
+
pass
|
|
102
|
+
return None
|
|
103
|
+
|
|
104
|
+
age = time.time() - ts
|
|
105
|
+
# 不論有效或過期,都刪掉(消費即焚;過期的也不留)
|
|
106
|
+
try:
|
|
107
|
+
path.unlink()
|
|
108
|
+
except Exception:
|
|
109
|
+
pass
|
|
110
|
+
|
|
111
|
+
if age > ttl:
|
|
112
|
+
return None
|
|
113
|
+
|
|
114
|
+
return session_id
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
# --------------------------------------------------------------------------- tui-presence 相關
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _pid_alive(pid: int) -> bool:
|
|
121
|
+
"""檢查 pid 是否仍存活(送 signal 0)。"""
|
|
122
|
+
try:
|
|
123
|
+
os.kill(pid, 0)
|
|
124
|
+
return True
|
|
125
|
+
except (ProcessLookupError, PermissionError):
|
|
126
|
+
return False
|
|
127
|
+
except Exception:
|
|
128
|
+
return False
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def write_tui_presence(*, presence_path: Path | None = None) -> None:
|
|
132
|
+
"""將目前 process 的 tty / pid / ts 寫入 presence 檔。
|
|
133
|
+
|
|
134
|
+
tty 取自 controlling terminal(``os.ttyname(sys.stdout.fileno())`` 或類似)。
|
|
135
|
+
若無法取得 tty(非互動終端)則寫空字串,讓 activate 步驟跳過而不崩。
|
|
136
|
+
|
|
137
|
+
:param presence_path: 可注入路徑(測試用)。
|
|
138
|
+
"""
|
|
139
|
+
path = presence_path or _PRESENCE_PATH
|
|
140
|
+
tty = ""
|
|
141
|
+
try:
|
|
142
|
+
# 優先用 stdout;若 stdout 非 tty,嘗試直接開 /dev/tty
|
|
143
|
+
if sys.stdout.isatty():
|
|
144
|
+
tty = os.ttyname(sys.stdout.fileno())
|
|
145
|
+
else:
|
|
146
|
+
fd = os.open("/dev/tty", os.O_RDONLY | os.O_NOCTTY)
|
|
147
|
+
try:
|
|
148
|
+
tty = os.ttyname(fd)
|
|
149
|
+
finally:
|
|
150
|
+
os.close(fd)
|
|
151
|
+
except Exception:
|
|
152
|
+
pass # headless / no controlling terminal,tty 留空字串
|
|
153
|
+
|
|
154
|
+
payload: _TuiPresence = {"tty": tty, "pid": os.getpid(), "ts": time.time()}
|
|
155
|
+
try:
|
|
156
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
157
|
+
path.write_text(json.dumps(payload), encoding="utf-8")
|
|
158
|
+
except Exception:
|
|
159
|
+
pass
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def read_tui_presence(
|
|
163
|
+
*,
|
|
164
|
+
presence_path: Path | None = None,
|
|
165
|
+
ttl: float = _PRESENCE_TTL,
|
|
166
|
+
) -> _TuiPresence | None:
|
|
167
|
+
"""讀取 presence 檔,判斷 TUI 是否確實在跑。
|
|
168
|
+
|
|
169
|
+
判定規則:
|
|
170
|
+
- 無檔案、無效 JSON → ``None``。
|
|
171
|
+
- ts 超過 ttl → stale,刪檔並回傳 ``None``。
|
|
172
|
+
- pid 已死 → stale,刪檔並回傳 ``None``。
|
|
173
|
+
- 以上均通過 → 回傳 presence dict。
|
|
174
|
+
|
|
175
|
+
:param presence_path: 可注入路徑(測試用)。
|
|
176
|
+
:param ttl: 有效時效秒數。
|
|
177
|
+
:returns: ``_TuiPresence`` 或 ``None``。
|
|
178
|
+
"""
|
|
179
|
+
path = presence_path or _PRESENCE_PATH
|
|
180
|
+
try:
|
|
181
|
+
raw = path.read_text(encoding="utf-8")
|
|
182
|
+
except Exception:
|
|
183
|
+
return None
|
|
184
|
+
|
|
185
|
+
try:
|
|
186
|
+
data: _TuiPresence = json.loads(raw)
|
|
187
|
+
tty = str(data.get("tty", ""))
|
|
188
|
+
pid = int(data["pid"])
|
|
189
|
+
ts = float(data["ts"])
|
|
190
|
+
except Exception:
|
|
191
|
+
return None
|
|
192
|
+
|
|
193
|
+
age = time.time() - ts
|
|
194
|
+
if age > ttl:
|
|
195
|
+
try:
|
|
196
|
+
path.unlink()
|
|
197
|
+
except Exception:
|
|
198
|
+
pass
|
|
199
|
+
return None
|
|
200
|
+
|
|
201
|
+
if not _pid_alive(pid):
|
|
202
|
+
try:
|
|
203
|
+
path.unlink()
|
|
204
|
+
except Exception:
|
|
205
|
+
pass
|
|
206
|
+
return None
|
|
207
|
+
|
|
208
|
+
return _TuiPresence(tty=tty, pid=pid, ts=ts)
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def clear_tui_presence(*, presence_path: Path | None = None) -> None:
|
|
212
|
+
"""刪除 presence 檔(TUI 離場時呼叫)。
|
|
213
|
+
|
|
214
|
+
:param presence_path: 可注入路徑(測試用)。
|
|
215
|
+
"""
|
|
216
|
+
path = presence_path or _PRESENCE_PATH
|
|
217
|
+
try:
|
|
218
|
+
path.unlink()
|
|
219
|
+
except Exception:
|
|
220
|
+
pass
|
ring/labels.py
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""Session 自訂標籤——本機持久化「這個 session 是什麼」。
|
|
2
|
+
|
|
3
|
+
存在 ``~/.config/ring/labels.json``:``{session_id: label}``。純 stdlib,所有檔案操作
|
|
4
|
+
失敗一律安靜吞掉(標籤是錦上添花,不該打斷 TUI / 快照主流程)。``_LABELS_PATH`` 可在
|
|
5
|
+
測試中以 ``path=`` 參數覆寫隔離。
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
_LABELS_PATH: Path = Path.home() / ".config" / "ring" / "labels.json"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def load_labels(*, path: Path | None = None) -> dict[str, str]:
|
|
17
|
+
"""讀整份標籤表;缺檔 / 壞檔 / 型別不符一律回空 dict。"""
|
|
18
|
+
p = path or _LABELS_PATH
|
|
19
|
+
try:
|
|
20
|
+
data = json.loads(p.read_text(encoding="utf-8"))
|
|
21
|
+
except Exception:
|
|
22
|
+
return {}
|
|
23
|
+
if not isinstance(data, dict):
|
|
24
|
+
return {}
|
|
25
|
+
return {str(k): v for k, v in data.items() if isinstance(v, str) and v}
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def get_label(session_id: str, *, path: Path | None = None) -> str:
|
|
29
|
+
"""取單一 session 的標籤;沒有就回空字串。"""
|
|
30
|
+
return load_labels(path=path).get(session_id, "")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def set_label(session_id: str, label: str, *, path: Path | None = None) -> None:
|
|
34
|
+
"""設定 / 更新某 session 的標籤;傳空字串(或全空白)則移除該標籤。"""
|
|
35
|
+
p = path or _LABELS_PATH
|
|
36
|
+
labels = load_labels(path=path)
|
|
37
|
+
label = label.strip()
|
|
38
|
+
if label:
|
|
39
|
+
labels[session_id] = label
|
|
40
|
+
elif session_id not in labels:
|
|
41
|
+
return # 本來就沒有,且要清空 → 不必寫檔
|
|
42
|
+
else:
|
|
43
|
+
labels.pop(session_id, None)
|
|
44
|
+
try:
|
|
45
|
+
p.parent.mkdir(parents=True, exist_ok=True)
|
|
46
|
+
tmp = p.with_suffix(".json.tmp")
|
|
47
|
+
tmp.write_text(json.dumps(labels, ensure_ascii=False), encoding="utf-8")
|
|
48
|
+
tmp.replace(p) # atomic
|
|
49
|
+
except Exception:
|
|
50
|
+
pass
|
|
Binary file
|