Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -247,6 +247,23 @@ collaboration.
| `/inter-session:inter-session disconnect` | Stop the monitor. |
| `/inter-session:inter-session auto-start [on\|off\|status]` | Toggle auto-start. `on` = start at every session; `off` = lazy (default). Apply with `/reload-plugins`. |

## Session labels

Alongside its `name` (the ASCII handle used for addressing), a session can
carry an optional **label** — a short Unicode display string (up to 60
characters, e.g. `Payments 🐛 refund bug`) shown in the `list` table. Labels
are display-only; you always address a session by its `name`.

Set one when the monitor starts with the client's `--label` flag. A label set
this way is **remembered per project** — persisted in the data dir, keyed by
the git repo root (falling back to the working directory outside a repo) — so
it is reused automatically on the next restart without re-passing the flag:

- `--label "…"` — set and persist for this project.
- `--label ""` — clear the persisted label.
- `INTER_SESSION_LABEL` — a one-off runtime override; used but **not**
persisted.

## Plugin configuration

The WebSocket port and idle-shutdown timeout are configurable via
Expand Down
14 changes: 14 additions & 0 deletions README.zh.md
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,20 @@ blue → "tracked in PATCH_LOG.md (24 entries). all classes hardened.
| `/inter-session:inter-session disconnect` | 停止 monitor。 |
| `/inter-session:inter-session auto-start [on\|off\|status]` | 切换 auto-start。`on` = 每次会话启动时连接;`off` = 懒启动(默认)。改动后用 `/reload-plugins` 应用。 |

## 会话标签(label)

除了用于寻址的 `name`(ASCII 句柄)之外,会话还可以带一个可选的 **label** ——
一个简短的 Unicode 显示字符串(最多 60 个字符,例如 `Payments 🐛 refund bug`),
显示在 `list` 表格中。label 仅用于显示;寻址始终使用 `name`。

在 monitor 启动时通过客户端的 `--label` 参数设置。以这种方式设置的 label 会
**按项目记住** —— 持久化到数据目录,以 git 仓库根目录为键(不在仓库中时回退到
当前工作目录)—— 因此下次重启会自动复用,无需再次传入该参数:

- `--label "…"` —— 为当前项目设置并持久化。
- `--label ""` —— 清除已持久化的 label。
- `INTER_SESSION_LABEL` —— 一次性的运行时覆盖;会被使用但**不会**持久化。

## 插件配置

通过 `/plugin config` 可配置 WebSocket 端口与空闲关闭超时:
Expand Down
7 changes: 7 additions & 0 deletions skills/inter-session/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,13 @@ Works the same whether the skill is installed as part of the plugin
so leave them off. Use plain `python3` — `client.py` re-execs under
the project venv automatically once `install-deps` has created it.

**Optional `--label`**: to give the session a human-friendly display
string (shown in `list`; addressing still uses `name`), add
`--label "<text>"` to the command. A label set this way **persists per
project** — it's remembered (keyed by the git repo root) and reused on
the next connect without re-passing it, so only pass `--label` when the
user asks to set or change it. `--label ""` clears the persisted label.

Each stdout line is a peer message — apply the Reaction policy above.

3. **If the spawn returns
Expand Down
67 changes: 36 additions & 31 deletions skills/inter-session/bin/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@
if str(_REPO_ROOT) not in sys.path:
sys.path.insert(0, str(_REPO_ROOT))

from bin import shared, spawn
from bin import shared, spawn, profile

log = logging.getLogger("inter-session.client")

Expand Down Expand Up @@ -68,33 +68,10 @@ def _format_truncation_pointer(msg_id: str, full_len: int) -> str:


def _write_session_state(ppid: int, state: dict) -> None:
"""Atomic write: create a sibling tempfile, fchmod 0600, fsync, then
os.replace. This prevents helpers from observing a partially-written
state file (the previous direct `write_text` was non-atomic).
"""
import tempfile
"""Atomically write the listener state file (0600), so helpers never
observe a partially-written file."""
shared.secure_dir(shared.clients_dir())
path = shared.client_session_path(ppid)
parent = path.parent
fd, tmp_path = tempfile.mkstemp(
prefix=path.name + ".", suffix=".tmp", dir=str(parent),
)
try:
os.fchmod(fd, 0o600)
os.write(fd, (json.dumps(state) + "\n").encode("utf-8"))
os.fsync(fd)
os.close(fd)
os.replace(tmp_path, str(path))
except OSError:
try:
os.close(fd)
except OSError:
pass
try:
os.unlink(tmp_path)
except OSError:
pass
raise
shared.atomic_write_text(shared.client_session_path(ppid), json.dumps(state) + "\n")


def _delete_session_state(ppid: int) -> None:
Expand Down Expand Up @@ -355,6 +332,29 @@ async def _ping_loop(self, ws) -> None:
return


def _resolve_label(cli_label, env_label, cwd=None) -> str:
"""Resolve (and persist) the session's display label. Precedence:

1. `--label` given (cli_label is not None): validate, persist it for this
project ('' clears the persisted label), and use it.
2. else `$INTER_SESSION_LABEL` (env_label truthy): validate and use as a
one-off runtime override — NOT persisted.
3. else: load the persisted per-project label (or '').

Raises ValueError(bad_label) if an explicitly-supplied label is invalid, so
the caller can surface it and exit.
"""
if cli_label is not None:
if not shared.validate_label(cli_label):
raise ValueError(cli_label)
return profile.resolve_label(cli_label, cwd)
if env_label:
if not shared.validate_label(env_label):
raise ValueError(env_label)
return env_label
return profile.resolve_label(None, cwd)


def _env_int(*keys, default: int) -> int:
for k in keys:
v = os.environ.get(k)
Expand Down Expand Up @@ -389,7 +389,9 @@ def main() -> int:
"CLAUDE_PLUGIN_OPTION_PORT", "INTER_SESSION_PORT", default=shared.DEFAULT_PORT,
))
parser.add_argument("--name", default=os.environ.get("INTER_SESSION_NAME", ""))
parser.add_argument("--label", default=os.environ.get("INTER_SESSION_LABEL", ""))
# Default None (not "") so we can tell "flag absent" (→ load the persisted
# per-project label) apart from an explicit "--label ''" (→ clear it).
parser.add_argument("--label", default=None)
parser.add_argument("--idle-shutdown-minutes", type=float, default=_env_float(
"CLAUDE_PLUGIN_OPTION_IDLE_SHUTDOWN_MINUTES", "INTER_SESSION_IDLE_MINUTES",
default=10,
Expand All @@ -404,8 +406,11 @@ def main() -> int:
if args.name and not shared.validate_name(args.name):
_print_line(f"[inter-session] invalid name {args.name!r}")
return 1
if not shared.validate_label(args.label):
_print_line(f"[inter-session] invalid label {args.label!r}")

try:
final_label = _resolve_label(args.label, os.environ.get("INTER_SESSION_LABEL"))
except ValueError as e:
_print_line(f"[inter-session] invalid label {e.args[0]!r}")
return 1

# Plugin auto-start path: monitors.json doesn't pass --name (so the user
Expand All @@ -423,7 +428,7 @@ def main() -> int:
)

client = Client(
host=args.host, port=args.port, name=final_name, label=args.label,
host=args.host, port=args.port, name=final_name, label=final_label,
idle_shutdown_minutes=args.idle_shutdown_minutes,
verbose=args.verbose,
)
Expand Down
99 changes: 99 additions & 0 deletions skills/inter-session/bin/profile.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
"""Per-project identity profile: persists a session's display label so it is
reused across restarts without re-passing --label.

Scope (this feature): label only. The profile is a small JSON file in the data
dir, keyed by the *project root* — the nearest ancestor directory containing a
`.git` entry, else the working directory. Keying by repo root (rather than raw
cwd) means `cd`-ing into a subdirectory of the same repo resolves the same
profile. The stored dict carries a `path`/`updated_at` and room for future
fields, so adding e.g. `team` later needs no migration.

Values are re-validated on load, so a hand-edited or older-format file can
never surface a label the live validation path would reject.
"""

from __future__ import annotations

import hashlib
import json
import os
from datetime import datetime, timezone
from pathlib import Path
from typing import Optional

from bin import shared


def profiles_dir() -> Path:
return shared.data_dir() / "profiles"


def project_root(cwd: Optional[str] = None) -> str:
"""Stable per-project key: the nearest ancestor directory containing a
`.git` entry (a dir for a normal repo, a file for a worktree/submodule),
else the starting directory. Symlink-resolved so equivalent paths collapse
to one profile."""
start = Path(cwd) if cwd else Path.cwd()
try:
start = start.resolve()
except OSError:
start = start.absolute()
for d in (start, *start.parents):
if (d / ".git").exists():
return str(d)
return str(start)


def profile_path(cwd: Optional[str] = None) -> Path:
"""Data-dir path of the profile for the current project. The filename is a
hash we generate from the project root, so no part of it is caller-supplied
(no path-traversal surface)."""
digest = hashlib.sha256(project_root(cwd).encode("utf-8")).hexdigest()[:32]
return profiles_dir() / f"{digest}.json"


def _read(cwd: Optional[str] = None) -> dict:
try:
data = json.loads(profile_path(cwd).read_text())
except (OSError, json.JSONDecodeError):
return {}
return data if isinstance(data, dict) else {}


def load_label(cwd: Optional[str] = None) -> str:
"""The persisted label for this project, or '' if none/invalid/corrupt."""
label = _read(cwd).get("label", "")
if isinstance(label, str) and shared.validate_label(label):
return label
return ""


def save_label(label: str, cwd: Optional[str] = None) -> None:
"""Persist `label` for this project; an empty string clears it. Other keys
in the profile are preserved (forward-compat). An invalid non-empty label
is a no-op (the live path rejects it separately)."""
if label and not shared.validate_label(label):
return
shared.secure_dir(profiles_dir())
data = _read(cwd)
data["path"] = project_root(cwd)
if label:
data["label"] = label
else:
data.pop("label", None)
data["updated_at"] = datetime.now(tz=timezone.utc).isoformat()
shared.atomic_write_text(profile_path(cwd), json.dumps(data))


def resolve_label(explicit: Optional[str], cwd: Optional[str] = None) -> str:
"""Resolve the label to connect with.

`explicit` is None when the caller passed no explicit label (→ load and
return the persisted per-project label, or ''), or a string (possibly '')
when the caller passed one explicitly (→ persist it, '' clearing, and
return it). The caller is responsible for validating a non-empty explicit
label first; save_label is a defensive no-op on an invalid one."""
if explicit is None:
return load_label(cwd)
save_label(explicit, cwd)
return explicit
27 changes: 27 additions & 0 deletions skills/inter-session/bin/shared.py
Original file line number Diff line number Diff line change
Expand Up @@ -232,6 +232,33 @@ def rotate_log_if_needed(path: Path, max_bytes: int, backups: int) -> None:
pass


def atomic_write_text(path: Path, text: str, mode: int = 0o600) -> None:
"""Atomically write `text` to `path` with `mode` perms: write a sibling
tempfile (fchmod + fsync), then os.replace so readers never observe a
partial file. The parent directory must already exist (callers create it
with the right perms via secure_dir)."""
import tempfile
path = Path(path)
fd, tmp = tempfile.mkstemp(prefix=path.name + ".", suffix=".tmp",
dir=str(path.parent))
try:
os.fchmod(fd, mode)
os.write(fd, text.encode("utf-8"))
os.fsync(fd)
os.close(fd)
os.replace(tmp, str(path))
except OSError:
try:
os.close(fd)
except OSError:
pass
try:
os.unlink(tmp)
except OSError:
pass
raise


def safe_pid_alive(pid: int) -> bool:
"""True if a process with `pid` is alive (or we can signal 0 to it)."""
if pid <= 0:
Expand Down
51 changes: 51 additions & 0 deletions tests/test_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,57 @@ def _read_until_nonempty(proc, timeout=5.0):
return ""


class TestResolveLabel:
"""client._resolve_label: --label vs $INTER_SESSION_LABEL vs persisted."""

def test_flag_persists_and_is_used(self, tmp_data_dir, tmp_path):
cwd = str(tmp_path)
assert client_mod._resolve_label("Payments 🐛", None, cwd) == "Payments 🐛"
assert client_mod.profile.load_label(cwd) == "Payments 🐛" # persisted

def test_flag_empty_clears(self, tmp_data_dir, tmp_path):
cwd = str(tmp_path)
client_mod._resolve_label("old", None, cwd)
assert client_mod._resolve_label("", None, cwd) == ""
assert client_mod.profile.load_label(cwd) == ""

def test_env_used_but_not_persisted(self, tmp_data_dir, tmp_path):
cwd = str(tmp_path)
assert client_mod._resolve_label(None, "env-label", cwd) == "env-label"
assert client_mod.profile.load_label(cwd) == "" # NOT persisted

def test_flag_takes_precedence_over_env(self, tmp_data_dir, tmp_path):
cwd = str(tmp_path)
assert client_mod._resolve_label("flag", "env", cwd) == "flag"

def test_absent_loads_persisted(self, tmp_data_dir, tmp_path):
cwd = str(tmp_path)
client_mod.profile.save_label("stored", cwd)
assert client_mod._resolve_label(None, None, cwd) == "stored"

def test_empty_env_falls_back_to_persisted(self, tmp_data_dir, tmp_path):
cwd = str(tmp_path)
client_mod.profile.save_label("stored", cwd)
assert client_mod._resolve_label(None, "", cwd) == "stored"

def test_absent_with_nothing_is_empty(self, tmp_data_dir, tmp_path):
assert client_mod._resolve_label(None, None, str(tmp_path)) == ""

def test_invalid_flag_raises(self, tmp_data_dir, tmp_path):
with pytest.raises(ValueError):
client_mod._resolve_label("a\nb", None, str(tmp_path))
# and nothing was persisted
assert client_mod.profile.load_label(str(tmp_path)) == ""

def test_invalid_env_raises(self, tmp_data_dir, tmp_path):
with pytest.raises(ValueError):
client_mod._resolve_label(None, "a\nb", str(tmp_path))

def test_over_max_length_flag_raises(self, tmp_data_dir, tmp_path):
with pytest.raises(ValueError):
client_mod._resolve_label("a" * (shared.LABEL_MAX_CP + 1), None, str(tmp_path))


class TestFormatMsg:
def test_basic_msg(self):
msg = {"op": "msg", "msg_id": "ab12", "from": "x", "from_name": "alpha",
Expand Down
Loading