pocket-coding 0.2.0__tar.gz → 0.2.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 (37) hide show
  1. {pocket_coding-0.2.0 → pocket_coding-0.2.1}/PKG-INFO +1 -1
  2. {pocket_coding-0.2.0 → pocket_coding-0.2.1}/poco/__init__.py +1 -1
  3. {pocket_coding-0.2.0 → pocket_coding-0.2.1}/poco/app.py +31 -1
  4. {pocket_coding-0.2.0 → pocket_coding-0.2.1}/poco/config/__init__.py +2 -0
  5. {pocket_coding-0.2.0 → pocket_coding-0.2.1}/poco/config/store.py +18 -0
  6. {pocket_coding-0.2.0 → pocket_coding-0.2.1}/poco/providers/claude.py +6 -1
  7. {pocket_coding-0.2.0 → pocket_coding-0.2.1}/poco/providers/codex.py +1 -1
  8. {pocket_coding-0.2.0 → pocket_coding-0.2.1}/poco/relay/app.py +33 -25
  9. {pocket_coding-0.2.0 → pocket_coding-0.2.1}/poco/relay/cards.py +38 -0
  10. {pocket_coding-0.2.0 → pocket_coding-0.2.1}/poco/relay/messenger.py +28 -0
  11. {pocket_coding-0.2.0 → pocket_coding-0.2.1}/poco/relay/runtime.py +101 -15
  12. {pocket_coding-0.2.0 → pocket_coding-0.2.1}/poco/relay/stores.py +107 -46
  13. {pocket_coding-0.2.0 → pocket_coding-0.2.1}/poco/runtime.py +45 -1
  14. {pocket_coding-0.2.0 → pocket_coding-0.2.1}/poco/tui/app.py +30 -4
  15. {pocket_coding-0.2.0 → pocket_coding-0.2.1}/poco/tui/resources.py +7 -2
  16. {pocket_coding-0.2.0 → pocket_coding-0.2.1}/pyproject.toml +1 -1
  17. {pocket_coding-0.2.0 → pocket_coding-0.2.1}/.gitignore +0 -0
  18. {pocket_coding-0.2.0 → pocket_coding-0.2.1}/README.md +0 -0
  19. {pocket_coding-0.2.0 → pocket_coding-0.2.1}/README.zh-CN.md +0 -0
  20. {pocket_coding-0.2.0 → pocket_coding-0.2.1}/poco/bootstrap/__init__.py +0 -0
  21. {pocket_coding-0.2.0 → pocket_coding-0.2.1}/poco/bootstrap/feishu.py +0 -0
  22. {pocket_coding-0.2.0 → pocket_coding-0.2.1}/poco/providers/__init__.py +0 -0
  23. {pocket_coding-0.2.0 → pocket_coding-0.2.1}/poco/providers/base.py +0 -0
  24. {pocket_coding-0.2.0 → pocket_coding-0.2.1}/poco/providers/models.py +0 -0
  25. {pocket_coding-0.2.0 → pocket_coding-0.2.1}/poco/relay/__init__.py +0 -0
  26. {pocket_coding-0.2.0 → pocket_coding-0.2.1}/poco/relay/models.py +0 -0
  27. {pocket_coding-0.2.0 → pocket_coding-0.2.1}/poco/relay/utils.py +0 -0
  28. {pocket_coding-0.2.0 → pocket_coding-0.2.1}/poco/tui/__init__.py +0 -0
  29. {pocket_coding-0.2.0 → pocket_coding-0.2.1}/poco/tui/menus/__init__.py +0 -0
  30. {pocket_coding-0.2.0 → pocket_coding-0.2.1}/poco/tui/menus/config/__init__.py +0 -0
  31. {pocket_coding-0.2.0 → pocket_coding-0.2.1}/poco/tui/menus/config/claude.py +0 -0
  32. {pocket_coding-0.2.0 → pocket_coding-0.2.1}/poco/tui/menus/config/controller.py +0 -0
  33. {pocket_coding-0.2.0 → pocket_coding-0.2.1}/poco/tui/menus/config/render_base.py +0 -0
  34. {pocket_coding-0.2.0 → pocket_coding-0.2.1}/poco/tui/menus/config/render_forms.py +0 -0
  35. {pocket_coding-0.2.0 → pocket_coding-0.2.1}/poco/tui/menus/config/sections.py +0 -0
  36. {pocket_coding-0.2.0 → pocket_coding-0.2.1}/poco/tui/menus/config/types.py +0 -0
  37. {pocket_coding-0.2.0 → pocket_coding-0.2.1}/poco/tui/menus/root.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pocket-coding
3
- Version: 0.2.0
3
+ Version: 0.2.1
4
4
  Summary: PoCo: Pocket Coding for Feishu
5
5
  Author: PoCo contributors
6
6
  Keywords: bot,codex,feishu,lark,tui
@@ -1,4 +1,4 @@
1
1
  """PoCo package."""
2
2
 
3
3
  __all__ = ["__version__"]
4
- __version__ = "0.2.0"
4
+ __version__ = "0.2.1"
@@ -1,19 +1,47 @@
1
1
  #!/usr/bin/env python3
2
2
  import argparse
3
+ import fcntl
3
4
  import json
4
5
  import logging
5
6
  import os
6
7
  import sys
7
8
  from logging.handlers import RotatingFileHandler
9
+ from pathlib import Path
8
10
  from .bootstrap import run_feishu_bootstrap_cli
9
11
  from .runtime import (
10
12
  PoCoService,
11
13
  RingLogHandler,
12
14
  )
13
- from .config import CONFIG_PATH, LOG_PATH, ConfigStore, ensure_dirs, get_nested
15
+ from .config import CONFIG_PATH, LOG_PATH, STATE_DIR, ConfigStore, ensure_dirs, get_nested
14
16
  from .tui import PoCoTui
15
17
 
16
18
 
19
+ _APP_LOCK = None
20
+
21
+
22
+ def _acquire_app_lock() -> bool:
23
+ """Acquires a process-wide PoCo instance lock.
24
+
25
+ Returns:
26
+ True when this process successfully acquired the lock, or False when
27
+ another PoCo process is already holding it.
28
+ """
29
+ global _APP_LOCK
30
+ lock_path = Path(STATE_DIR) / "poco.lock"
31
+ handle = lock_path.open("w", encoding="utf-8")
32
+ try:
33
+ fcntl.flock(handle.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
34
+ except BlockingIOError:
35
+ handle.close()
36
+ return False
37
+ handle.seek(0)
38
+ handle.truncate()
39
+ handle.write(str(os.getpid()))
40
+ handle.flush()
41
+ _APP_LOCK = handle
42
+ return True
43
+
44
+
17
45
  def parse_args() -> argparse.Namespace:
18
46
  parser = argparse.ArgumentParser(description="PoCo")
19
47
  parser.add_argument("--log-level", default="INFO")
@@ -33,6 +61,8 @@ def main() -> None:
33
61
  format="%(asctime)s %(levelname)s %(name)s: %(message)s",
34
62
  )
35
63
  ensure_dirs()
64
+ if not _acquire_app_lock():
65
+ raise SystemExit("Another PoCo process is already running. Kill the existing process first.")
36
66
  root_logger = logging.getLogger()
37
67
  ring = RingLogHandler()
38
68
  ring.setFormatter(logging.Formatter("%(asctime)s %(levelname)s %(name)s: %(message)s"))
@@ -12,6 +12,7 @@ from .store import (
12
12
  ensure_dirs,
13
13
  get_nested,
14
14
  mask_secret,
15
+ missing_required_config_paths,
15
16
  normalize_config_key,
16
17
  parse_config_value,
17
18
  set_nested,
@@ -31,6 +32,7 @@ __all__ = [
31
32
  "ensure_dirs",
32
33
  "get_nested",
33
34
  "mask_secret",
35
+ "missing_required_config_paths",
34
36
  "normalize_config_key",
35
37
  "parse_config_value",
36
38
  "set_nested",
@@ -197,6 +197,24 @@ def config_ready(config: Dict[str, Any]) -> bool:
197
197
  return bool(feishu["app_id"] and feishu["app_secret"])
198
198
 
199
199
 
200
+ def missing_required_config_paths(config: Dict[str, Any]) -> list[str]:
201
+ """Returns the required config paths that are still missing.
202
+
203
+ Args:
204
+ config: Current merged config payload.
205
+
206
+ Returns:
207
+ A list of dotted config paths that must be filled before PoCo can run.
208
+ """
209
+ missing: list[str] = []
210
+ feishu = config.get("feishu", {})
211
+ if not str(feishu.get("app_id", "")).strip():
212
+ missing.append("feishu.app_id")
213
+ if not str(feishu.get("app_secret", "")).strip():
214
+ missing.append("feishu.app_secret")
215
+ return missing
216
+
217
+
200
218
  def ensure_dirs() -> None:
201
219
  CONFIG_DIR.mkdir(parents=True, exist_ok=True)
202
220
  STATE_DIR.mkdir(parents=True, exist_ok=True)
@@ -185,7 +185,10 @@ class ClaudeProviderClient:
185
185
  cmd.extend(["--session-id", thread_id])
186
186
  if self._provider.model:
187
187
  cmd.extend(["--model", self._provider.model])
188
- if self._provider.approval_policy:
188
+ approval_policy = self._provider.approval_policy.strip()
189
+ if approval_policy in {"bypassPermissions", "dangerously-skip-permissions"}:
190
+ cmd.append("--dangerously-skip-permissions")
191
+ elif approval_policy:
189
192
  cmd.extend(["--permission-mode", self._provider.approval_policy])
190
193
  image_paths = [path for path in (local_image_paths or []) if path]
191
194
  for parent in sorted({str(Path(path).expanduser().resolve().parent) for path in image_paths}):
@@ -203,6 +206,8 @@ class ClaudeProviderClient:
203
206
  cmd.append(text)
204
207
  env = os.environ.copy()
205
208
  env.update(self._provider.env)
209
+ if str(self._provider.sandbox).strip() in {"1", "true", "True", "sandbox", "is_sandbox"}:
210
+ env["IS_SANDBOX"] = "1"
206
211
  process = subprocess.Popen(
207
212
  cmd,
208
213
  stdin=subprocess.DEVNULL,
@@ -216,7 +216,7 @@ class CodexProviderClient:
216
216
  self._request(
217
217
  "initialize",
218
218
  {
219
- "clientInfo": {"name": "poco-provider-bridge", "version": "0.2.0"},
219
+ "clientInfo": {"name": "poco-provider-bridge", "version": "0.2.1"},
220
220
  "capabilities": {"experimentalApi": True},
221
221
  },
222
222
  )
@@ -435,14 +435,9 @@ class RelayApp:
435
435
  self._safe_send(chat_id, "[poco] 单聊是管理控制台,暂不处理图片。请在项目群里发送图片。")
436
436
  return
437
437
  worker_id = chat_id
438
- self._worker_store.ensure_worker(worker_id)
439
- if not self._worker_store.enabled_for(worker_id):
440
- self._safe_send(
441
- chat_id,
442
- "[poco] This group is not active yet.\n"
443
- "Create and manage project groups from the DM console instead of configuring them here.\n"
444
- "DM the bot with `poco`.",
445
- )
438
+ state_error = self._group_access_error_text(worker_id)
439
+ if state_error:
440
+ self._safe_send(chat_id, state_error)
446
441
  return
447
442
  self._safe_send(
448
443
  chat_id,
@@ -468,14 +463,9 @@ class RelayApp:
468
463
  return
469
464
 
470
465
  worker_id = chat_id
471
- self._worker_store.ensure_worker(worker_id)
472
- if not self._worker_store.enabled_for(worker_id):
473
- self._safe_send(
474
- chat_id,
475
- "[poco] This group is not active yet.\n"
476
- "Create and manage project groups from the DM console instead.\n"
477
- "DM the bot with `poco`.",
478
- )
466
+ state_error = self._group_access_error_text(worker_id)
467
+ if state_error:
468
+ self._safe_send(chat_id, state_error)
479
469
  return
480
470
 
481
471
  local_image_paths = [
@@ -510,7 +500,6 @@ class RelayApp:
510
500
  open_id: str,
511
501
  ) -> None:
512
502
  worker_id = chat_id
513
- self._worker_store.ensure_worker(worker_id)
514
503
  command, arg = self._parse_poco_command(text)
515
504
  stripped = text.strip().lower()
516
505
  if command == "cardtest":
@@ -525,13 +514,9 @@ class RelayApp:
525
514
  "请去单聊发送 `poco`,这个群里直接聊天即可。",
526
515
  )
527
516
  return
528
- if not self._worker_store.enabled_for(worker_id):
529
- self._safe_send(
530
- chat_id,
531
- "[poco] This group is not active yet.\n"
532
- "Create and manage project groups from the DM console.\n"
533
- "DM the bot with `poco`.",
534
- )
517
+ state_error = self._group_access_error_text(worker_id)
518
+ if state_error:
519
+ self._safe_send(chat_id, state_error)
535
520
  return
536
521
 
537
522
  self._handle_group_turn_input(worker_id, chat_id, chat_type, text, mentions, open_id)
@@ -720,8 +705,31 @@ class RelayApp:
720
705
  cleaned = cleaned.replace(f"@{name}", " ")
721
706
  return " ".join(cleaned.split())
722
707
 
708
+ def _group_access_error_text(self, worker_id: str) -> Optional[str]:
709
+ if not self._worker_store.has_worker(worker_id):
710
+ if self._store.get(worker_id):
711
+ return (
712
+ "[poco] This group's local project state is missing or corrupted.\n"
713
+ "Please go back to the DM console and recreate or repair this project group."
714
+ )
715
+ return (
716
+ "[poco] This group is not active yet.\n"
717
+ "Create and manage project groups from the DM console.\n"
718
+ "DM the bot with `poco`."
719
+ )
720
+ if not self._worker_store.enabled_for(worker_id):
721
+ return (
722
+ "[poco] This group is not active yet.\n"
723
+ "Create and manage project groups from the DM console.\n"
724
+ "DM the bot with `poco`."
725
+ )
726
+ return None
727
+
723
728
  def _ensure_worker(self, worker_id: str, chat_id: str, chat_type: str) -> WorkerRuntime:
724
- self._worker_store.ensure_worker(worker_id)
729
+ if not self._worker_store.has_worker(worker_id):
730
+ raise RuntimeError(
731
+ "当前群的本地项目状态缺失或损坏。请回到 DM 控制台重新创建或修复这个项目群。"
732
+ )
725
733
  cwd = self._worker_store.cwd_for(worker_id)
726
734
  provider_name = self._provider_name_for_worker(worker_id)
727
735
  if not provider_name:
@@ -861,6 +861,17 @@ class SetupCardController:
861
861
  payload["toast"] = {"type": toast_type, "content": notice}
862
862
  return P2CardActionTriggerResponse(payload)
863
863
 
864
+ @staticmethod
865
+ def response_toast(notice: str, toast_type: str = "info") -> P2CardActionTriggerResponse:
866
+ return P2CardActionTriggerResponse(
867
+ {
868
+ "toast": {
869
+ "type": toast_type,
870
+ "content": notice,
871
+ }
872
+ }
873
+ )
874
+
864
875
  @staticmethod
865
876
  def coerce_form_value(value: object) -> str:
866
877
  if value is None:
@@ -1086,6 +1097,33 @@ class SetupCardController:
1086
1097
  "success",
1087
1098
  )
1088
1099
 
1100
+ if action_name == "stop_turn":
1101
+ with self.app._lock:
1102
+ session = self.app._active_by_worker.get(worker_id)
1103
+ worker = self.app._workers.get(worker_id)
1104
+ if session is None or worker is None:
1105
+ return self.response_toast("Nothing to stop.", "info")
1106
+ try:
1107
+ worker.client.interrupt_turn(session.thread_id, session.turn_id)
1108
+ except Exception as exc:
1109
+ return self.response_toast(f"Stop failed: {exc}", "error")
1110
+ with session.lock:
1111
+ rendered = (session.final_text or session.accumulated_text).rstrip() or "_Stopped._"
1112
+ session.done = True
1113
+ session.final_text = rendered
1114
+ session.error_text = ""
1115
+ session.live.card_broken = True
1116
+ session.notify()
1117
+ return self.response_card(
1118
+ self.app._turns.answer_card(
1119
+ worker.provider_name,
1120
+ rendered,
1121
+ state="stopped",
1122
+ stop_enabled=False,
1123
+ streaming=False,
1124
+ ),
1125
+ )
1126
+
1089
1127
  if action_name in {"launch_project", "reset_project_launch"}:
1090
1128
  def project_card_response(
1091
1129
  notice: str = "",
@@ -11,10 +11,13 @@ from typing import Dict, Optional
11
11
  import lark_oapi as lark
12
12
  from lark_oapi.api.cardkit.v1.model.content_card_element_request import ContentCardElementRequest
13
13
  from lark_oapi.api.cardkit.v1.model.content_card_element_request_body import ContentCardElementRequestBody
14
+ from lark_oapi.api.cardkit.v1.model.card import Card
14
15
  from lark_oapi.api.cardkit.v1.model.create_card_request import CreateCardRequest
15
16
  from lark_oapi.api.cardkit.v1.model.create_card_request_body import CreateCardRequestBody
16
17
  from lark_oapi.api.cardkit.v1.model.settings_card_request import SettingsCardRequest
17
18
  from lark_oapi.api.cardkit.v1.model.settings_card_request_body import SettingsCardRequestBody
19
+ from lark_oapi.api.cardkit.v1.model.update_card_request import UpdateCardRequest
20
+ from lark_oapi.api.cardkit.v1.model.update_card_request_body import UpdateCardRequestBody
18
21
  from lark_oapi.api.im.v1 import (
19
22
  CreateChatMembersRequest,
20
23
  CreateChatMembersRequestBody,
@@ -383,6 +386,31 @@ class FeishuMessenger:
383
386
  f"log_id={response.get_log_id()}"
384
387
  )
385
388
 
389
+ def update_card_entity(self, card_id: str, card: dict, sequence: int) -> None:
390
+ request = (
391
+ UpdateCardRequest.builder()
392
+ .card_id(card_id)
393
+ .request_body(
394
+ UpdateCardRequestBody.builder()
395
+ .uuid(str(uuid.uuid4()))
396
+ .sequence(sequence)
397
+ .card(
398
+ Card.builder()
399
+ .type("card_json")
400
+ .data(json.dumps(card, ensure_ascii=False, separators=(",", ":")))
401
+ .build()
402
+ )
403
+ .build()
404
+ )
405
+ .build()
406
+ )
407
+ response = self._client.cardkit.v1.card.update(request)
408
+ if response.code != 0:
409
+ raise RuntimeError(
410
+ f"Feishu update card entity failed: code={response.code}, msg={response.msg}, "
411
+ f"log_id={response.get_log_id()}"
412
+ )
413
+
386
414
  def update_text(self, message_id: str, text: str) -> None:
387
415
  request = (
388
416
  UpdateMessageRequest.builder()
@@ -175,6 +175,19 @@ class TurnController:
175
175
  )
176
176
  live.stream_sequence += 1
177
177
  live.streaming_mode = False
178
+ self.app._messenger.update_card_entity(
179
+ live.card_id,
180
+ self.answer_card(
181
+ self.app._provider_name_for_worker(session.worker_id),
182
+ rendered,
183
+ state="completed",
184
+ stop_enabled=False,
185
+ streaming=False,
186
+ element_id=live.element_id or "answer_stream",
187
+ ),
188
+ live.stream_sequence + 1,
189
+ )
190
+ live.stream_sequence += 1
178
191
  except Exception:
179
192
  LOG.exception("Failed to close streaming mode for card_id=%s", live.card_id)
180
193
  return
@@ -301,38 +314,111 @@ class TurnController:
301
314
  streaming_mode=True,
302
315
  )
303
316
 
304
- def create_streaming_answer_card(self, provider_name: str) -> Tuple[str, str]:
305
- """Create a reusable streaming card entity for one answer."""
306
- element_id = "answer_stream"
307
- card = {
317
+ def answer_card(
318
+ self,
319
+ provider_name: str,
320
+ text: str,
321
+ *,
322
+ state: str,
323
+ stop_enabled: bool,
324
+ streaming: bool,
325
+ element_id: str = "answer_stream",
326
+ ) -> dict:
327
+ """Build one reply card for streaming, completed, or stopped states."""
328
+ normalized = state.strip().lower()
329
+ if normalized == "stopped":
330
+ template = "orange"
331
+ tag_text = "Stopped"
332
+ tag_color = "orange"
333
+ elif normalized == "completed":
334
+ template = "green"
335
+ tag_text = "Completed"
336
+ tag_color = "green"
337
+ else:
338
+ template = "orange"
339
+ tag_text = "Running"
340
+ tag_color = "blue"
341
+ elements = [
342
+ {
343
+ "tag": "markdown",
344
+ "element_id": element_id,
345
+ "content": text,
346
+ }
347
+ ]
348
+ if stop_enabled:
349
+ elements.append(
350
+ {
351
+ "tag": "column_set",
352
+ "horizontal_spacing": "8px",
353
+ "columns": [
354
+ {
355
+ "tag": "column",
356
+ "width": "auto",
357
+ "elements": [
358
+ self.app._card_button("Stop", "stop_turn", "default")
359
+ ],
360
+ }
361
+ ],
362
+ }
363
+ )
364
+ elif normalized == "stopped":
365
+ elements.append(
366
+ {
367
+ "tag": "column_set",
368
+ "horizontal_spacing": "8px",
369
+ "columns": [
370
+ {
371
+ "tag": "column",
372
+ "width": "auto",
373
+ "elements": [
374
+ self.app._card_button("Stopped", "stopped", "default", enable_callback=False)
375
+ ],
376
+ }
377
+ ],
378
+ }
379
+ )
380
+ return {
308
381
  "schema": "2.0",
309
382
  "config": {
310
383
  "update_multi": True,
311
384
  "enable_forward": True,
312
- "streaming_mode": True,
313
- "summary": {"content": "Generating..."},
385
+ "streaming_mode": streaming,
386
+ "summary": {"content": "Generating..." if streaming else shorten(text, 80)},
314
387
  "streaming_config": {
315
388
  "print_frequency_ms": {"default": 70},
316
389
  "print_step": {"default": 1},
317
390
  "print_strategy": "fast",
318
- },
391
+ } if streaming else {},
319
392
  },
320
393
  "header": {
321
- "template": "orange",
394
+ "template": template,
322
395
  "title": {"tag": "plain_text", "content": f"{provider_name.capitalize()} Reply"},
396
+ "text_tag_list": [
397
+ {
398
+ "tag": "text_tag",
399
+ "text": {"tag": "plain_text", "content": tag_text},
400
+ "color": tag_color,
401
+ }
402
+ ],
323
403
  },
324
404
  "body": {
325
405
  "padding": "14px",
326
406
  "vertical_spacing": "8px",
327
- "elements": [
328
- {
329
- "tag": "markdown",
330
- "element_id": element_id,
331
- "content": f"PoCo is thinking with {provider_name}...",
332
- }
333
- ],
407
+ "elements": elements,
334
408
  },
335
409
  }
410
+
411
+ def create_streaming_answer_card(self, provider_name: str) -> Tuple[str, str]:
412
+ """Create a reusable streaming card entity for one answer."""
413
+ element_id = "answer_stream"
414
+ card = self.answer_card(
415
+ provider_name,
416
+ f"PoCo is thinking with {provider_name}...",
417
+ state="running",
418
+ stop_enabled=True,
419
+ streaming=True,
420
+ element_id=element_id,
421
+ )
336
422
  return self.app._messenger.create_card_entity(card), element_id
337
423
 
338
424
  def cleanup_session(self, session: TurnSession) -> None:
@@ -2,6 +2,7 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
+ import fcntl
5
6
  import json
6
7
  import logging
7
8
  import threading
@@ -13,111 +14,155 @@ LOG = logging.getLogger("poco.relay")
13
14
 
14
15
 
15
16
  class ThreadStore:
16
- """Persists worker-to-thread bindings."""
17
+ """Persists worker-to-thread bindings with cross-process safety."""
17
18
 
18
19
  def __init__(self, path: Path) -> None:
19
20
  self._path = path
20
21
  self._lock = threading.Lock()
21
22
  self._data: Dict[str, str] = {}
22
- self._load()
23
+ self._refresh()
23
24
 
24
- def _load(self) -> None:
25
+ def _load_disk(self) -> Dict[str, str]:
25
26
  if not self._path.exists():
26
- return
27
+ return {}
27
28
  try:
28
29
  raw = json.loads(self._path.read_text(encoding="utf-8"))
29
30
  if isinstance(raw, dict):
30
- self._data = {str(k): str(v) for k, v in raw.items()}
31
+ return {str(k): str(v) for k, v in raw.items()}
31
32
  except Exception:
32
33
  LOG.warning("Failed to load thread state from %s", self._path)
34
+ return {}
35
+
36
+ def _refresh(self) -> None:
37
+ self._data = self._load_disk()
33
38
 
34
39
  def _save(self) -> None:
35
- self._path.write_text(
36
- json.dumps(self._data, ensure_ascii=False, indent=2),
37
- encoding="utf-8",
38
- )
40
+ self._path.parent.mkdir(parents=True, exist_ok=True)
41
+ with self._path.open("a+", encoding="utf-8") as handle:
42
+ fcntl.flock(handle.fileno(), fcntl.LOCK_EX)
43
+ disk_data = self._load_disk()
44
+ disk_data.update(self._data)
45
+ self._data = disk_data
46
+ handle.seek(0)
47
+ handle.truncate()
48
+ handle.write(json.dumps(self._data, ensure_ascii=False, indent=2))
49
+ handle.flush()
50
+ fcntl.flock(handle.fileno(), fcntl.LOCK_UN)
39
51
 
40
52
  def get(self, chat_id: str) -> Optional[str]:
41
53
  with self._lock:
54
+ self._refresh()
42
55
  return self._data.get(chat_id)
43
56
 
44
57
  def set(self, chat_id: str, thread_id: str) -> None:
45
58
  with self._lock:
59
+ self._refresh()
46
60
  self._data[chat_id] = thread_id
47
61
  self._save()
48
62
 
49
63
  def clear(self, chat_id: str) -> None:
50
64
  with self._lock:
65
+ self._refresh()
51
66
  if chat_id in self._data:
52
67
  del self._data[chat_id]
53
68
  self._save()
54
69
 
55
70
 
56
71
  class WorkerStore:
57
- """Persists per-group worker configuration."""
72
+ """Persists per-group worker configuration with cross-process safety."""
58
73
 
59
74
  def __init__(self, path: Path) -> None:
60
75
  self._path = path
61
76
  self._lock = threading.Lock()
62
77
  self._data: Dict[str, Dict[str, object]] = {}
63
- self._load()
78
+ self._refresh()
64
79
 
65
- def _load(self) -> None:
80
+ @staticmethod
81
+ def _normalize_payload(payload: object) -> Optional[Dict[str, object]]:
82
+ if not isinstance(payload, dict):
83
+ return None
84
+ alias = str(payload.get("alias", "")).strip()
85
+ provider = str(payload.get("provider", "")).strip().lower() or "codex"
86
+ backend = str(payload.get("backend", "")).strip().lower()
87
+ if not backend:
88
+ backend = "openai" if provider == "codex" else ""
89
+ mode = str(payload.get("mode", "auto")).strip() or "auto"
90
+ enabled = bool(payload.get("enabled", False))
91
+ cwd = str(payload.get("cwd", "")).strip()
92
+ model = str(payload.get("model", "")).strip()
93
+ return {
94
+ "alias": alias,
95
+ "provider": provider,
96
+ "backend": backend,
97
+ "mode": mode,
98
+ "model": model,
99
+ "enabled": enabled,
100
+ "cwd": cwd,
101
+ }
102
+
103
+ def _load_disk(self) -> Dict[str, Dict[str, object]]:
66
104
  if not self._path.exists():
67
- return
105
+ return {}
68
106
  try:
69
107
  raw = json.loads(self._path.read_text(encoding="utf-8"))
70
108
  if isinstance(raw, dict):
71
109
  cleaned: Dict[str, Dict[str, object]] = {}
72
110
  for worker_id, payload in raw.items():
73
- if not isinstance(payload, dict):
74
- continue
75
- alias = str(payload.get("alias", "")).strip()
76
- provider = str(payload.get("provider", "")).strip().lower() or "codex"
77
- backend = str(payload.get("backend", "")).strip().lower()
78
- if not backend:
79
- backend = "openai" if provider == "codex" else ""
80
- mode = str(payload.get("mode", "auto")).strip() or "auto"
81
- enabled = bool(payload.get("enabled", False))
82
- cwd = str(payload.get("cwd", "")).strip()
83
- model = str(payload.get("model", "")).strip()
84
- cleaned[str(worker_id)] = {
85
- "alias": alias,
86
- "provider": provider,
87
- "backend": backend,
88
- "mode": mode,
89
- "model": model,
90
- "enabled": enabled,
91
- "cwd": cwd,
92
- }
93
- self._data = cleaned
111
+ normalized = self._normalize_payload(payload)
112
+ if normalized is not None:
113
+ cleaned[str(worker_id)] = normalized
114
+ return cleaned
94
115
  except Exception:
95
116
  LOG.warning("Failed to load worker state from %s", self._path)
117
+ return {}
118
+
119
+ def _refresh(self) -> None:
120
+ self._data = self._load_disk()
96
121
 
97
122
  def _save(self) -> None:
98
- self._path.write_text(
99
- json.dumps(self._data, ensure_ascii=False, indent=2),
100
- encoding="utf-8",
101
- )
123
+ self._path.parent.mkdir(parents=True, exist_ok=True)
124
+ with self._path.open("a+", encoding="utf-8") as handle:
125
+ fcntl.flock(handle.fileno(), fcntl.LOCK_EX)
126
+ disk_data = self._load_disk()
127
+ disk_data.update(self._data)
128
+ self._data = disk_data
129
+ handle.seek(0)
130
+ handle.truncate()
131
+ handle.write(json.dumps(self._data, ensure_ascii=False, indent=2))
132
+ handle.flush()
133
+ fcntl.flock(handle.fileno(), fcntl.LOCK_UN)
134
+
135
+ def has_worker(self, worker_id: str) -> bool:
136
+ with self._lock:
137
+ self._refresh()
138
+ return worker_id in self._data
102
139
 
103
140
  def alias_for(self, worker_id: str) -> str:
104
141
  with self._lock:
105
- payload = self._ensure_record_locked(worker_id)
142
+ self._refresh()
143
+ payload = self._data.get(worker_id) or {}
106
144
  return str(payload.get("alias", "")).strip()
107
145
 
108
146
  def provider_for(self, worker_id: str) -> str:
109
147
  with self._lock:
110
- payload = self._ensure_record_locked(worker_id)
148
+ self._refresh()
149
+ payload = self._data.get(worker_id) or {}
111
150
  return str(payload.get("provider", "")).strip().lower()
112
151
 
113
152
  def backend_for(self, worker_id: str) -> str:
114
153
  with self._lock:
115
- payload = self._ensure_record_locked(worker_id)
116
- return str(payload.get("backend", "")).strip().lower()
154
+ self._refresh()
155
+ payload = self._data.get(worker_id) or {}
156
+ provider = str(payload.get("provider", "")).strip().lower()
157
+ backend = str(payload.get("backend", "")).strip().lower()
158
+ if not backend and provider == "codex":
159
+ return "openai"
160
+ return backend
117
161
 
118
162
  def set_provider(self, worker_id: str, provider: str) -> None:
119
163
  provider_value = provider.strip().lower()
120
164
  with self._lock:
165
+ self._refresh()
121
166
  payload = self._ensure_record_locked(worker_id)
122
167
  payload["provider"] = provider_value
123
168
  payload["backend"] = ""
@@ -127,6 +172,7 @@ class WorkerStore:
127
172
  def set_backend(self, worker_id: str, backend: str) -> None:
128
173
  backend_value = backend.strip().lower()
129
174
  with self._lock:
175
+ self._refresh()
130
176
  payload = self._ensure_record_locked(worker_id)
131
177
  payload["backend"] = backend_value
132
178
  payload["model"] = ""
@@ -135,6 +181,7 @@ class WorkerStore:
135
181
  def set_alias(self, worker_id: str, alias: str) -> None:
136
182
  alias = alias.strip()
137
183
  with self._lock:
184
+ self._refresh()
138
185
  for existing_worker_id, payload in list(self._data.items()):
139
186
  if existing_worker_id != worker_id and payload.get("alias", "") == alias:
140
187
  raise ValueError(f"别名 {alias} 已被 worker {existing_worker_id} 使用。")
@@ -147,6 +194,7 @@ class WorkerStore:
147
194
  if not alias_value:
148
195
  return False
149
196
  with self._lock:
197
+ self._refresh()
150
198
  for worker_id, payload in self._data.items():
151
199
  if except_worker_id and worker_id == except_worker_id:
152
200
  continue
@@ -156,55 +204,65 @@ class WorkerStore:
156
204
 
157
205
  def clear_alias(self, worker_id: str) -> None:
158
206
  with self._lock:
207
+ self._refresh()
159
208
  payload = self._ensure_record_locked(worker_id)
160
209
  payload["alias"] = ""
161
210
  self._save()
162
211
 
163
212
  def mode_for(self, worker_id: str) -> str:
164
213
  with self._lock:
165
- payload = self._ensure_record_locked(worker_id)
214
+ self._refresh()
215
+ payload = self._data.get(worker_id) or {}
166
216
  return str(payload.get("mode", "auto")).strip() or "auto"
167
217
 
168
218
  def set_mode(self, worker_id: str, mode: str) -> None:
169
219
  with self._lock:
220
+ self._refresh()
170
221
  payload = self._ensure_record_locked(worker_id)
171
222
  payload["mode"] = mode
172
223
  self._save()
173
224
 
174
225
  def enabled_for(self, worker_id: str) -> bool:
175
226
  with self._lock:
176
- payload = self._ensure_record_locked(worker_id)
227
+ self._refresh()
228
+ payload = self._data.get(worker_id) or {}
177
229
  return bool(payload.get("enabled", False))
178
230
 
179
231
  def set_enabled(self, worker_id: str, enabled: bool) -> None:
180
232
  with self._lock:
233
+ self._refresh()
181
234
  payload = self._ensure_record_locked(worker_id)
182
235
  payload["enabled"] = enabled
183
236
  self._save()
184
237
 
185
238
  def ensure_worker(self, worker_id: str) -> None:
186
239
  with self._lock:
240
+ self._refresh()
187
241
  self._ensure_record_locked(worker_id)
188
242
  self._save()
189
243
 
190
244
  def cwd_for(self, worker_id: str) -> str:
191
245
  with self._lock:
192
- payload = self._ensure_record_locked(worker_id)
246
+ self._refresh()
247
+ payload = self._data.get(worker_id) or {}
193
248
  return str(payload.get("cwd", "")).strip()
194
249
 
195
250
  def set_cwd(self, worker_id: str, cwd: str) -> None:
196
251
  with self._lock:
252
+ self._refresh()
197
253
  payload = self._ensure_record_locked(worker_id)
198
254
  payload["cwd"] = cwd.strip()
199
255
  self._save()
200
256
 
201
257
  def model_for(self, worker_id: str) -> str:
202
258
  with self._lock:
203
- payload = self._ensure_record_locked(worker_id)
259
+ self._refresh()
260
+ payload = self._data.get(worker_id) or {}
204
261
  return str(payload.get("model", "")).strip()
205
262
 
206
263
  def set_model(self, worker_id: str, model: str) -> None:
207
264
  with self._lock:
265
+ self._refresh()
208
266
  payload = self._ensure_record_locked(worker_id)
209
267
  payload["model"] = model.strip()
210
268
  self._save()
@@ -214,6 +272,7 @@ class WorkerStore:
214
272
  if not key:
215
273
  return None
216
274
  with self._lock:
275
+ self._refresh()
217
276
  if key in self._data:
218
277
  return key
219
278
  for worker_id, payload in self._data.items():
@@ -223,10 +282,12 @@ class WorkerStore:
223
282
 
224
283
  def items(self) -> list[tuple[str, str]]:
225
284
  with self._lock:
285
+ self._refresh()
226
286
  return sorted((worker_id, payload.get("alias", "")) for worker_id, payload in self._data.items())
227
287
 
228
288
  def remove(self, worker_id: str) -> None:
229
289
  with self._lock:
290
+ self._refresh()
230
291
  if worker_id in self._data:
231
292
  del self._data[worker_id]
232
293
  self._save()
@@ -1,16 +1,22 @@
1
1
  import logging
2
+ import os
2
3
  import threading
3
4
  import time
4
5
  from collections import deque
6
+ from pathlib import Path
5
7
  from typing import Any, Dict, Optional
6
8
 
9
+ import fcntl
10
+
7
11
  from .relay import AppConfig, ProviderConfig, RelayApp
8
12
  from .config import (
9
13
  INPUT_IDS,
14
+ STATE_DIR,
10
15
  THREAD_STATE_PATH,
11
16
  WORKER_STATE_PATH,
12
17
  ConfigStore,
13
18
  config_ready,
19
+ missing_required_config_paths,
14
20
  normalize_config_key,
15
21
  parse_config_value,
16
22
  set_nested,
@@ -47,6 +53,7 @@ class RelayRunner:
47
53
  self._thread: Optional[threading.Thread] = None
48
54
  self._last_error: str = ""
49
55
  self._started_at: Optional[float] = None
56
+ self._lock_file = None
50
57
  self._lock = threading.Lock()
51
58
 
52
59
  def status(self) -> Dict[str, Any]:
@@ -61,6 +68,11 @@ class RelayRunner:
61
68
  with self._lock:
62
69
  if self._thread is not None and self._thread.is_alive():
63
70
  return False
71
+ lock_file = self._acquire_single_instance_lock()
72
+ if lock_file is None:
73
+ raise RuntimeError(
74
+ "Another PoCo relay process is already running. Stop the other `poco` process first."
75
+ )
64
76
  self._last_error = ""
65
77
  app_config = build_app_config(config)
66
78
  thread = threading.Thread(
@@ -71,6 +83,7 @@ class RelayRunner:
71
83
  )
72
84
  self._thread = thread
73
85
  self._started_at = time.time()
86
+ self._lock_file = lock_file
74
87
  thread.start()
75
88
  return True
76
89
 
@@ -82,6 +95,36 @@ class RelayRunner:
82
95
  with self._lock:
83
96
  self._last_error = str(exc)
84
97
  LOG.exception("Relay runtime crashed")
98
+ finally:
99
+ self._release_single_instance_lock()
100
+
101
+ def _acquire_single_instance_lock(self):
102
+ lock_path = Path(STATE_DIR) / "relay.lock"
103
+ handle = lock_path.open("w", encoding="utf-8")
104
+ try:
105
+ fcntl.flock(handle.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
106
+ except BlockingIOError:
107
+ handle.close()
108
+ return None
109
+ handle.seek(0)
110
+ handle.truncate()
111
+ handle.write(str(os.getpid()))
112
+ handle.flush()
113
+ return handle
114
+
115
+ def _release_single_instance_lock(self) -> None:
116
+ with self._lock:
117
+ handle = self._lock_file
118
+ self._lock_file = None
119
+ self._thread = None
120
+ self._started_at = None
121
+ if handle is None:
122
+ return
123
+ try:
124
+ fcntl.flock(handle.fileno(), fcntl.LOCK_UN)
125
+ except OSError:
126
+ pass
127
+ handle.close()
85
128
 
86
129
 
87
130
  def build_app_config(config: Dict[str, Any]) -> AppConfig:
@@ -162,5 +205,6 @@ class PoCoService:
162
205
  def start_relay(self) -> bool:
163
206
  config = self.load_config()
164
207
  if not config_ready(config):
165
- raise ValueError("配置还不完整,至少需要 Feishu App ID 和 App Secret。")
208
+ missing = ", ".join(missing_required_config_paths(config))
209
+ raise ValueError(f"配置还不完整,缺少:{missing}")
166
210
  return self.relay.start(config)
@@ -8,7 +8,7 @@ from textual.containers import Container, Horizontal
8
8
  from textual.widgets import Input, Static
9
9
 
10
10
  from .. import __version__
11
- from ..config import config_ready
11
+ from ..config import config_ready, missing_required_config_paths
12
12
  from ..providers import model_choices
13
13
  from .resources import POCO_ICON, STRINGS, TUI_CSS
14
14
  from .menus.config import (
@@ -291,6 +291,22 @@ class PoCoTui(App[None]):
291
291
  value = value.get(part, "")
292
292
  return str(value or "")
293
293
 
294
+ @staticmethod
295
+ def _missing_config_field_name(path: str) -> str:
296
+ """Returns a short human-readable field name for one required path."""
297
+ mapping = {
298
+ "feishu.app_id": "app_id",
299
+ "feishu.app_secret": "app_secret",
300
+ }
301
+ return mapping.get(path, path)
302
+
303
+ def _missing_config_fields_text(self, config: dict) -> str:
304
+ """Returns a compact comma-separated summary of missing required fields."""
305
+ return ", ".join(
306
+ self._missing_config_field_name(path)
307
+ for path in missing_required_config_paths(config)
308
+ )
309
+
294
310
  def compose(self) -> ComposeResult:
295
311
  """Builds the Textual widget tree."""
296
312
  with Horizontal(id="main_panel"):
@@ -315,7 +331,11 @@ class PoCoTui(App[None]):
315
331
  config = self._service.load_config()
316
332
  if self._focus_config or not config_ready(config):
317
333
  self._enter_config_mode()
318
- self._set_message(self._t("config_required"))
334
+ missing = self._missing_config_fields_text(config)
335
+ if missing:
336
+ self._set_message(self._t("config_missing", fields=missing))
337
+ else:
338
+ self._set_message(self._t("config_required"))
319
339
  return
320
340
  self._service.start_relay()
321
341
  self._set_message(self._t("relay_started"), transient=True)
@@ -337,13 +357,19 @@ class PoCoTui(App[None]):
337
357
  relay_style = "#3fb950" if relay["running"] else "#f85149"
338
358
  config_status = self._t("ready") if config_ready(config) else self._t("needs_config")
339
359
  config_style = "#3fb950" if config_ready(config) else "#f2cc60"
340
- return (
360
+ lines = [
341
361
  f"[bold #fb923c]{POCO_ICON}[/]\n"
342
362
  f"[bold #fb923c]PoCo v{__version__}[/]\n"
343
363
  "\n"
344
364
  f"{self._t('relay')}: [bold {relay_style}]{relay_status}[/]\n"
345
365
  f"{self._t('settings')}: [bold {config_style}]{config_status}[/]"
346
- )
366
+ ]
367
+ if not config_ready(config):
368
+ missing = self._missing_config_fields_text(config)
369
+ if missing:
370
+ lines.append("")
371
+ lines.append(f"[bold #f2cc60]{self._t('missing_label')}[/]: [#f2cc60]{missing}[/]")
372
+ return "\n".join(lines)
347
373
 
348
374
  def _right_panel_text(self, config: dict, relay: dict) -> str:
349
375
  """Builds the right panel text for the current view."""
@@ -103,9 +103,14 @@ STRINGS = {
103
103
  "current_secret": {"en": "current secret", "zh": "当前密钥"},
104
104
  "empty": {"en": "empty", "zh": "空"},
105
105
  "config_required": {
106
- "en": "Config is incomplete. Open Config in the menu and finish the required fields.",
107
- "zh": "配置还不完整。请在菜单中打开 Config 并完成必填项。",
106
+ "en": "Config is incomplete. Open Bot (feishu) and finish the required fields.",
107
+ "zh": "配置还不完整。请打开 Bot (feishu) 并完成必填项。",
108
108
  },
109
+ "config_missing": {
110
+ "en": "Missing in Bot (feishu): {fields}",
111
+ "zh": "Bot (feishu) 缺少:{fields}",
112
+ },
113
+ "missing_label": {"en": "Missing", "zh": "缺少"},
109
114
  "relay_started": {"en": "PoCo relay is running.", "zh": "PoCo relay 已启动。"},
110
115
  "relay_already_running": {"en": "PoCo relay is already running.", "zh": "PoCo relay 已经在运行。"},
111
116
  "left_config": {"en": "Left config mode.", "zh": "已退出配置模式。"},
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "pocket-coding"
3
- version = "0.2.0"
3
+ version = "0.2.1"
4
4
  description = "PoCo: Pocket Coding for Feishu"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.11"
File without changes
File without changes