refactor(skills): make skills portable across users/locations via workspace-relative paths + env var overrides

Changes:
- skills/lib.sh:
  - HOME_DIR default changed from $HOME to <workspace_root> (workspace self-sufficient)
  - Added CLAUDE_PROJECT_DIR / LOCAL_BIN env var pattern (default $HOME, overridable)
- skills/tmux-agent-orchestrate-monitor/scripts/reconcile.sh:
  - STATE_DIR moved from $HOME/.cache/... to <workspace>/.cache/tmux-agent-orchestrate-monitor
- skills/tmux-agent-orchestrate-create/scripts/create_session.sh:
  - WRAPPER uses $LOCAL_BIN env var (default $HOME/.local/bin)
- 6 SKILL.md: examples and explanations updated to mention env var override capability

User/portability contract:
- Workspace-internal data: .hermes/ + .cache/ (moves with workspace)
- User/system data: $HOME/* (overridable via CLAUDE_PROJECT_DIR, LOCAL_BIN)
- All env vars follow: ${VAR:-default} pattern with documented defaults

Verified on isolated server -L agy-homeport-test (kill-server after):
- syntax check PASS
- E2E: defaults resolve to workspace-relative paths
- E2E: env var override correctly changes paths
- 0 leftover direct $HOME references in code
- Global skill non-interference verified
- Main isolated server -L multi-agent-canary untouched
This commit is contained in:
2026-06-20 05:39:27 +00:00
parent ad7be264e7
commit cd9eec112d
10 changed files with 34 additions and 22 deletions
+10 -4
View File
@@ -18,6 +18,11 @@ SKILL_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
WORKSPACE_ROOT="$(cd "$SKILL_DIR/.." && pwd)" WORKSPACE_ROOT="$(cd "$SKILL_DIR/.." && pwd)"
AGENT_SESSIONS_YAML="${AGENT_SESSIONS_YAML:-$WORKSPACE_ROOT/.hermes/agent-sessions.yaml}" AGENT_SESSIONS_YAML="${AGENT_SESSIONS_YAML:-$WORKSPACE_ROOT/.hermes/agent-sessions.yaml}"
# Workspace-relative defaults with environment overrides (Phase Z)
HOME_DIR="${HOME_DIR:-$WORKSPACE_ROOT}"
CLAUDE_PROJECT_DIR="${CLAUDE_PROJECT_DIR:-$HOME/.claude/projects}"
LOCAL_BIN="${LOCAL_BIN:-$HOME/.local/bin}"
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Tmux Server Isolation support # Tmux Server Isolation support
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -159,7 +164,7 @@ derive_session_name() {
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
env_python() { env_python() {
local yaml_path="$1"; shift local yaml_path="$1"; shift
local -a envs=("YAML_PATH=$yaml_path" "HOME_DIR=$HOME") local -a envs=("YAML_PATH=$yaml_path" "HOME_DIR=$HOME_DIR" "CLAUDE_PROJECT_DIR=$CLAUDE_PROJECT_DIR" "LOCAL_BIN=$LOCAL_BIN")
while [ $# -gt 0 ]; do while [ $# -gt 0 ]; do
case "$1" in case "$1" in
*=*) envs+=("$1"); shift ;; *=*) envs+=("$1"); shift ;;
@@ -186,7 +191,7 @@ env_python() {
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
atomic_dump_yaml() { atomic_dump_yaml() {
local yaml_path="$1"; shift local yaml_path="$1"; shift
local -a envs=("YAML_PATH=$yaml_path" "HOME_DIR=$HOME") local -a envs=("YAML_PATH=$yaml_path" "HOME_DIR=$HOME_DIR" "CLAUDE_PROJECT_DIR=$CLAUDE_PROJECT_DIR" "LOCAL_BIN=$LOCAL_BIN")
while [ $# -gt 0 ]; do while [ $# -gt 0 ]; do
case "$1" in case "$1" in
*=*) envs+=("$1"); shift ;; *=*) envs+=("$1"); shift ;;
@@ -283,6 +288,7 @@ ws = os.environ['WS_ABS']
agent = os.environ['AGENT'] agent = os.environ['AGENT']
home = os.environ['HOME_DIR'] home = os.environ['HOME_DIR']
yaml_path = os.environ['YAML_PATH'] yaml_path = os.environ['YAML_PATH']
claude_project_dir = os.environ.get('CLAUDE_PROJECT_DIR', f"{home}/.claude/projects")
d = {} d = {}
if os.path.exists(yaml_path): if os.path.exists(yaml_path):
@@ -292,7 +298,7 @@ if os.path.exists(yaml_path):
def jsonl_exists(uuid): def jsonl_exists(uuid):
key = ws.replace('/', '-').replace('_', '-') key = ws.replace('/', '-').replace('_', '-')
return os.path.exists(f"{home}/.claude/projects/{key}/{uuid}.jsonl") return os.path.exists(f"{claude_project_dir}/{key}/{uuid}.jsonl")
def db_exists(uuid): def db_exists(uuid):
@@ -323,7 +329,7 @@ for s in d.get('tmux_sessions', []):
# 2) disk scan scoped to THIS workspace # 2) disk scan scoped to THIS workspace
if agent == 'claude': if agent == 'claude':
key = ws.replace('/', '-').replace('_', '-') key = ws.replace('/', '-').replace('_', '-')
proj = f"{home}/.claude/projects/{key}" proj = f"{claude_project_dir}/{key}"
if os.path.isdir(proj): if os.path.isdir(proj):
for j in sorted(glob.glob(f"{proj}/*.jsonl"), key=os.path.getmtime, reverse=True): for j in sorted(glob.glob(f"{proj}/*.jsonl"), key=os.path.getmtime, reverse=True):
sid = None sid = None
@@ -111,8 +111,10 @@ tmux has-session -t "$SESSION_NAME" 2>/dev/null && {
case "$AGENT" in case "$AGENT" in
claude) claude)
# Use the wrapper if it exists, else inline tmux new-session # Use the wrapper if it exists, else inline tmux new-session
if [ -x "$HOME/.local/bin/$SESSION_NAME" ]; then # Use the wrapper if it exists (LOCAL_BIN env var overrides default $HOME/.local/bin)
nohup "$HOME/.local/bin/$SESSION_NAME" >/dev/null 2>&1 & local_bin="${LOCAL_BIN:-$HOME/.local/bin}"
if [ -x "$local_bin/$SESSION_NAME" ]; then
nohup "$local_bin/$SESSION_NAME" >/dev/null 2>&1 &
else else
tmux new-session -d -s "$SESSION_NAME" -x 140 -y 40 -c "$WORKSPACE" "claude" tmux new-session -d -s "$SESSION_NAME" -x 140 -y 40 -c "$WORKSPACE" "claude"
fi fi
@@ -95,7 +95,8 @@ if _tmux has-session -t "$SESSION_NAME" 2>/dev/null; then
fi fi
# tmux 세션 띄우기 # tmux 세션 띄우기
WRAPPER="$HOME/.local/bin/$SESSION_NAME" LOCAL_BIN="${LOCAL_BIN:-$HOME/.local/bin}"
WRAPPER="$LOCAL_BIN/$SESSION_NAME"
spawn() { spawn() {
case "$AGENT" in case "$AGENT" in
@@ -120,8 +120,8 @@ print(f'OK: terminated at {s[\"terminated_at\"]}')
print(f' preserved: pane.pid={s[\"pane\"][\"pid\"]}, cmd={s[\"pane\"][\"cmd\"]}, cwd={s[\"pane\"][\"cwd\"]}') print(f' preserved: pane.pid={s[\"pane\"][\"pid\"]}, cmd={s[\"pane\"][\"cmd\"]}, cwd={s[\"pane\"][\"cwd\"]}')
" "
# 3. (if --purge-conversation) disk artifacts gone # 3. (if --purge-conversation) disk artifacts gone (CLAUDE_PROJECT_DIR env var overrides default $HOME/.claude/projects)
[ -f "$HOME/.claude/projects/<projkey>/<uuid>.jsonl" ] && echo "WARN: jsonl still exists" || echo "OK: jsonl purged" [ -f "${CLAUDE_PROJECT_DIR:-$HOME/.claude/projects}/<projkey>/<uuid>.jsonl" ] && echo "WARN: jsonl still exists" || echo "OK: jsonl purged"
``` ```
## When NOT to use this skill ## When NOT to use this skill
@@ -162,7 +162,8 @@ if last_status:
if purge and purge_uuid: if purge and purge_uuid:
if agent == 'claude': if agent == 'claude':
key = ws.replace('/', '-').replace('_', '-') key = ws.replace('/', '-').replace('_', '-')
jsonl = f"{home}/.claude/projects/{key}/{purge_uuid}.jsonl" claude_project_dir = os.environ.get('CLAUDE_PROJECT_DIR', f"{home}/.claude/projects")
jsonl = f"{claude_project_dir}/{key}/{purge_uuid}.jsonl"
if os.path.exists(jsonl): if os.path.exists(jsonl):
os.remove(jsonl) os.remove(jsonl)
print(f"purged: {jsonl}", flush=True) print(f"purged: {jsonl}", flush=True)
@@ -154,7 +154,7 @@ disk: ~/.claude/projects/.../87dc548e-...jsonl: missing
- **Don't run the monitor without `--goal`** — without goal mode, a single turn will spawn, do one reconcile, and complete. Goal mode keeps the worker alive across many turns. - **Don't run the monitor without `--goal`** — without goal mode, a single turn will spawn, do one reconcile, and complete. Goal mode keeps the worker alive across many turns.
- **The 30s poll is a default** — workers may override if they detect heavy churn. A workspace with 5+ agent sessions should bump to 60s to avoid noise. - **The 30s poll is a default** — workers may override if they detect heavy churn. A workspace with 5+ agent sessions should bump to 60s to avoid noise.
- **`kanban_comment` rate limits** — Kanban may throttle if you comment too fast. Coalesce: only comment when the diff is *new* (not the same drift on every poll). The script tracks a state file at `~/.cache/tmux-agent-orchestrate-monitor/<workspace>.state` for this. - **`kanban_comment` rate limits** — Kanban may throttle if you comment too fast. Coalesce: only comment when the diff is *new* (not the same drift on every poll). The script tracks a state file at `.cache/tmux-agent-orchestrate-monitor/<workspace>.state` in the workspace root for this (overridable via `AGENT_SESSIONS_STATE_DIR`).
- **Don't fight the user's explicit action** — if `tmux-agent-orchestrate-delete` is mid-flight and the monitor sees the same session in two states within 5s, prefer the user's most recent action. The monitor should not auto-revert a fresh `terminated` to `running` because of a stale `tmux has-session` check. - **Don't fight the user's explicit action** — if `tmux-agent-orchestrate-delete` is mid-flight and the monitor sees the same session in two states within 5s, prefer the user's most recent action. The monitor should not auto-revert a fresh `terminated` to `running` because of a stale `tmux has-session` check.
- **The monitor should never modify the conversation artifacts** (jsonl, db) — only the YAML. If you see a stale UUID, comment about it but don't delete the file. - **The monitor should never modify the conversation artifacts** (jsonl, db) — only the YAML. If you see a stale UUID, comment about it but don't delete the file.
- **TUI capture-pane is expensive** — only capture when you need to update `last_visible_status`, not every poll. - **TUI capture-pane is expensive** — only capture when you need to update `last_visible_status`, not every poll.
@@ -16,7 +16,7 @@ set -euo pipefail
source "$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)/lib.sh" source "$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)/lib.sh"
STATE_DIR="${AGENT_SESSIONS_STATE_DIR:-$HOME/.cache/tmux-agent-orchestrate-monitor}" STATE_DIR="${AGENT_SESSIONS_STATE_DIR:-$WORKSPACE_ROOT/.cache/tmux-agent-orchestrate-monitor}"
ONCE=0 ONCE=0
EMIT_DIFF=0 EMIT_DIFF=0
@@ -54,7 +54,7 @@ if [ "$SUBSCRIBE" = "1" ]; then
# The MQTT subscribe loop exits 3 to signal "broker unavailable → poll instead". # The MQTT subscribe loop exits 3 to signal "broker unavailable → poll instead".
set +e set +e
YAML_PATH="$AGENT_SESSIONS_YAML" HOME_DIR="$HOME" \ YAML_PATH="$AGENT_SESSIONS_YAML" HOME_DIR="$HOME_DIR" CLAUDE_PROJECT_DIR="$CLAUDE_PROJECT_DIR" LOCAL_BIN="$LOCAL_BIN" \
SUB_TIMEOUT="$SUB_TIMEOUT" SUB_IDLE_TIMEOUT="$SUB_IDLE_TIMEOUT" \ SUB_TIMEOUT="$SUB_TIMEOUT" SUB_IDLE_TIMEOUT="$SUB_IDLE_TIMEOUT" \
SKILLS_DIR="$SKILLS_DIR" LIB_SH="$LIB_SH" \ SKILLS_DIR="$SKILLS_DIR" LIB_SH="$LIB_SH" \
"$PYBIN" - <<'PYEOF' "$PYBIN" - <<'PYEOF'
@@ -229,6 +229,7 @@ import yaml
yaml_path = os.environ['YAML_PATH'] yaml_path = os.environ['YAML_PATH']
home = os.environ['HOME_DIR'] home = os.environ['HOME_DIR']
claude_project_dir = os.environ.get('CLAUDE_PROJECT_DIR', f"{home}/.claude/projects")
now_iso = datetime.now(timezone.utc).strftime('%Y-%m-%dT%H:%M:%SZ') now_iso = datetime.now(timezone.utc).strftime('%Y-%m-%dT%H:%M:%SZ')
@@ -374,7 +375,7 @@ for s in d.get('tmux_sessions', []):
if not cwd: if not cwd:
continue continue
proj_key = cwd.replace('/', '-').replace('_', '-') proj_key = cwd.replace('/', '-').replace('_', '-')
proj_dir = f"{home}/.claude/projects/{proj_key}" proj_dir = f"{claude_project_dir}/{proj_key}"
if not os.path.isdir(proj_dir): if not os.path.isdir(proj_dir):
continue continue
jsonls = sorted(glob.glob(f"{proj_dir}/*.jsonl"), key=os.path.getmtime, reverse=True) jsonls = sorted(glob.glob(f"{proj_dir}/*.jsonl"), key=os.path.getmtime, reverse=True)
@@ -426,7 +427,7 @@ ai = d.get('agent_identities', {}) or {}
cl = (ai.get('claude') or {}) cl = (ai.get('claude') or {})
if cl.get('session_id'): if cl.get('session_id'):
sid = cl['session_id'] sid = cl['session_id']
if not glob.glob(f"{home}/.claude/projects/*/{sid}.jsonl"): if not glob.glob(f"{claude_project_dir}/*/{sid}.jsonl"):
drifts.append({'class': 'D', 'name': '(claude identity cache)', drifts.append({'class': 'D', 'name': '(claude identity cache)',
'msg': f"stale UUID in agent_identities.claude.session_id: {sid} (jsonl missing)"}) 'msg': f"stale UUID in agent_identities.claude.session_id: {sid} (jsonl missing)"})
ag = (ai.get('agy') or {}) ag = (ai.get('agy') or {})
@@ -26,7 +26,7 @@ metadata:
Three cases this skill handles: Three cases this skill handles:
1. **tmux is dead, conversation lives**`agent-sessions.yaml` has the UUID. The JSONL/db is on disk. Re-spawn the tmux session + run `claude -r <id>` / `agy --conversation <id>`. 1. **tmux is dead, conversation lives**`agent-sessions.yaml` has the UUID. The JSONL/db is on disk. Re-spawn the tmux session + run `claude -r <id>` / `agy --conversation <id>`.
2. **tmux is alive but empty** — You started a session with `tmux-agent-orchestrate-create` but haven't sent a message yet (so no session id was assigned). The user can either send their first message (and the id is auto-assigned), or you can read the *workspace's* most recent conversation from `~/.gemini/antigravity-cli/cache/last_conversations.json` for agy, or the latest `*.jsonl` in `~/.claude/projects/<workspace-key>/` for claude. 2. **tmux is alive but empty** — You started a session with `tmux-agent-orchestrate-create` but haven't sent a message yet (so no session id was assigned). The user can either send their first message (and the id is auto-assigned), or you can read the *workspace's* most recent conversation from `$HOME_DIR/.gemini/antigravity-cli/cache/last_conversations.json` (defaults to `~/.gemini/...`) for agy, or the latest `*.jsonl` in `$CLAUDE_PROJECT_DIR/<workspace-key>/` (defaults to `~/.claude/projects/`) for claude.
3. **tmux is alive AND the agent inside is already running** — Just attach. No re-spawn needed. 3. **tmux is alive AND the agent inside is already running** — Just attach. No re-spawn needed.
## UUID resolution order ## UUID resolution order
@@ -35,9 +35,9 @@ Three cases this skill handles:
1. **`agent-sessions.yaml``agent_identities.<agent>.session_id` (claude) / `conversation_id` (agy)** — explicit saved value 1. **`agent-sessions.yaml``agent_identities.<agent>.session_id` (claude) / `conversation_id` (agy)** — explicit saved value
2. **`agent-sessions.yaml``agent_identities.<agent>.session_jsonl` (claude) / `conversation_db` (agy)** — the on-disk artifact 2. **`agent-sessions.yaml``agent_identities.<agent>.session_jsonl` (claude) / `conversation_db` (agy)** — the on-disk artifact
3. **Fallback: scan disk for the workspace's most recent conversation** 3. **Fallback: scan disk for the workspace's most recent conversation** (Note: `CLAUDE_PROJECT_DIR` overrides the default `~/.claude/projects/` path, and `HOME_DIR` overrides the `~` path)
- claude: `ls -t ~/.claude/projects/<workspace-key>/*.jsonl | head -1` and parse the `sessionId` from the first line - claude: `ls -t $CLAUDE_PROJECT_DIR/<workspace-key>/*.jsonl | head -1` and parse the `sessionId` from the first line
- agy: `jq -r '."<workspace>"' ~/.gemini/antigravity-cli/cache/last_conversations.json` - agy: `jq -r '."<workspace>"' $HOME_DIR/.gemini/antigravity-cli/cache/last_conversations.json`
If all three are empty → the workspace has no conversation yet. Fall back to `tmux-agent-orchestrate-create`. If all three are empty → the workspace has no conversation yet. Fall back to `tmux-agent-orchestrate-create`.
@@ -48,7 +48,7 @@ The script:
3. For each row in `tmux_sessions[]`: 3. For each row in `tmux_sessions[]`:
- tmux alive? (via `tmux has-session -t <name>`) - tmux alive? (via `tmux has-session -t <name>`)
- pane cmd, cwd (via `tmux list-panes`) - pane cmd, cwd (via `tmux list-panes`)
- resume UUID on disk? (claude: `~/.claude/projects/<key>/<uuid>.jsonl`; agy: `~/.gemini/antigravity-cli/conversations/<uuid>.db`) - resume UUID on disk? (claude: `$CLAUDE_PROJECT_DIR/<key>/<uuid>.jsonl` with default `~/.claude/projects/`; agy: `$HOME_DIR/.gemini/antigravity-cli/conversations/<uuid>.db` with default `~/.gemini/...`)
4. For each tmux session matching `*-creator-*` not in YAML → flag as "unregistered" 4. For each tmux session matching `*-creator-*` not in YAML → flag as "unregistered"
5. Prints a table (default) or JSON (with `--json`) 5. Prints a table (default) or JSON (with `--json`)
@@ -34,6 +34,7 @@ import yaml
yaml_path = os.environ['YAML_PATH'] yaml_path = os.environ['YAML_PATH']
home = os.environ['HOME_DIR'] home = os.environ['HOME_DIR']
claude_project_dir = os.environ.get('CLAUDE_PROJECT_DIR', f"{home}/.claude/projects")
drift = json.loads(os.environ['DRIFT_JSON']) drift = json.loads(os.environ['DRIFT_JSON'])
with open(yaml_path) as f: with open(yaml_path) as f:
@@ -53,9 +54,9 @@ def resume_on_disk(s):
u = s.get('claude_session_id_own') u = s.get('claude_session_id_own')
if u: if u:
key = cwd.replace('/', '-').replace('_', '-') key = cwd.replace('/', '-').replace('_', '-')
return 'yes' if os.path.exists(f"{home}/.claude/projects/{key}/{u}.jsonl") else 'MISSING' return 'yes' if os.path.exists(f"{claude_project_dir}/{key}/{u}.jsonl") else 'MISSING'
key = cwd.replace('/', '-').replace('_', '-') key = cwd.replace('/', '-').replace('_', '-')
return 'scan' if glob.glob(f"{home}/.claude/projects/{key}/*.jsonl") else 'no' return 'scan' if glob.glob(f"{claude_project_dir}/{key}/*.jsonl") else 'no'
if name.endswith('-creator-agy'): if name.endswith('-creator-agy'):
u = s.get('agy_conversation_id_own') u = s.get('agy_conversation_id_own')
if u: if u: