Skip to content

Commit fa9ce3d

Browse files
committed
feat(ask): add --session targeting
- Add --session flag and validate via session_scope\n- Route to session-scoped provider session files\n- Update README + add tests for flag/env defaulting
1 parent d60e947 commit fa9ce3d

4 files changed

Lines changed: 133 additions & 5 deletions

File tree

.beads/issues.jsonl

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
{"id":"claude_code_bridge-4.2","title":"cq: add --session and make lock per (cwd,session)","description":"Allow running multiple `cq` instances for the same repo by namespacing the per-directory lock.\n\n## Scope\n- Add `--session \u003cname\u003e` to `cq` (default `default`)\n- Change the `ProviderLock(\"cq\", cwd=...)` key to include session name (e.g. `f\"{cwd}::{session}\"`).\n- Export `CQ_SESSION=\u003cname\u003e` in managed env so child panes inherit it.\n\n## Acceptance\n- `cq --session A ...` and `cq --session B ...` can run concurrently in the same directory.\n","status":"closed","priority":1,"issue_type":"task","owner":"stefanc@allenai.org","created_at":"2026-02-03T18:50:38.852098-08:00","created_by":"stefanc-ai2","updated_at":"2026-02-03T19:27:11.073595-08:00","closed_at":"2026-02-03T19:27:11.073595-08:00","close_reason":"Added (default: or 'default'), exported CQ_SESSION into managed pane env, and namespaced launcher locking per (cwd,session) while also acquiring the legacy per-directory lock for the default session to avoid mixed-version double-launch. Added tests + README notes.","labels":["sessions","cli"],"dependencies":[{"issue_id":"claude_code_bridge-4.2","depends_on_id":"claude_code_bridge-4","type":"parent-child","created_at":"0001-01-01T00:00:00Z"},{"issue_id":"claude_code_bridge-4.2","depends_on_id":"claude_code_bridge-4.1","type":"blocks","created_at":"0001-01-01T00:00:00Z"}],"comments":[{"id":1,"issue_id":"claude_code_bridge-4.2","author":"stefanc-ai2","text":"Summary: added cq --session (default from CQ_SESSION env var, else default); launcher lock is now per (cwd,session) and default session also acquires the legacy per-directory lock to prevent mixed-version double-launch; CQ_SESSION is exported into managed env for child panes; added tests + README docs. Note: close-reason string lost the 'cq --session' snippet due to shell backtick substitution.","created_at":"2026-02-03T19:27:47.680199-08:00"}]}
55
{"id":"claude_code_bridge-4.3","title":"Session-scoped session files under .cq_config/sessions/\u003cname\u003e/","description":"Persist provider session files per session name.\n\n## Scope\n- New layout: `.cq_config/sessions/\u003cname\u003e/.codex-session` and `.cq_config/sessions/\u003cname\u003e/.claude-session`\n- Backcompat:\n - If no session specified, keep using existing `.cq_config/.codex-session` / `.cq_config/.claude-session` (default session)\n - Optionally migrate legacy root files into sessions/default/ (best-effort)\n\n## Acceptance\n- Starting a second session does not overwrite the first session's session files.\n","status":"closed","priority":1,"issue_type":"task","owner":"stefanc@allenai.org","created_at":"2026-02-03T18:50:39.013882-08:00","created_by":"stefanc-ai2","updated_at":"2026-02-03T19:44:26.180797-08:00","closed_at":"2026-02-03T19:44:26.180797-08:00","close_reason":"Implemented session-scoped provider session files for named sessions under .cq_config/sessions/\u003cname\u003e/ while keeping default session in .cq_config/. Updated AILauncher to create session dirs before writes, updated README, and added tests ensuring sessions don’t clobber each other.","labels":["sessions","state"],"dependencies":[{"issue_id":"claude_code_bridge-4.3","depends_on_id":"claude_code_bridge-4","type":"parent-child","created_at":"0001-01-01T00:00:00Z"},{"issue_id":"claude_code_bridge-4.3","depends_on_id":"claude_code_bridge-4.1","type":"blocks","created_at":"0001-01-01T00:00:00Z"},{"issue_id":"claude_code_bridge-4.3","depends_on_id":"claude_code_bridge-4.2","type":"blocks","created_at":"0001-01-01T00:00:00Z"}]}
66
{"id":"claude_code_bridge-4.4","title":"Namespace pane_title_marker per session","description":"Prevent pane marker collisions across sessions.\n\n## Scope\n- Include session name (and ideally a short run id) in `pane_title_marker` for each provider.\n- Ensure the *full marker* is stored in the session file and used for rediscovery.\n\n## Acceptance\n- Two sessions in the same project do not share markers like `CQ-Codex` / `CQ-Claude`.\n- Rediscovery finds the correct pane when a pane_id changes.\n","status":"closed","priority":1,"issue_type":"task","owner":"stefanc@allenai.org","created_at":"2026-02-03T18:50:39.180659-08:00","created_by":"stefanc-ai2","updated_at":"2026-02-03T21:05:43.438546-08:00","closed_at":"2026-02-03T21:05:43.438546-08:00","close_reason":"Namespaced pane_title_marker per session + run id (CQ-\u003csession\u003e-\u003cProvider\u003e-\u003crun\u003e) and updated all codex/claude tmux+wezterm title sites to use it so rediscovery can’t collide across sessions. Added tests for marker format/uniqueness and documented namespaced pane titles in README.","labels":["sessions","tmux","wezterm"],"dependencies":[{"issue_id":"claude_code_bridge-4.4","depends_on_id":"claude_code_bridge-4","type":"parent-child","created_at":"0001-01-01T00:00:00Z"},{"issue_id":"claude_code_bridge-4.4","depends_on_id":"claude_code_bridge-4.3","type":"blocks","created_at":"0001-01-01T00:00:00Z"}]}
7-
{"id":"claude_code_bridge-4.5","title":"ask: add --session and route using session-scoped session files","description":"Make `ask` able to target a specific session.\n\n## Scope\n- Add `--session \u003cname\u003e` to `bin/ask` (default from `CQ_SESSION`, else `default`).\n- Load the provider session for the selected session.\n\n## Acceptance\n- `ask --session A codex ...` routes to A.\n- Running `ask codex ...` inside session A routes to A (via `CQ_SESSION`).\n","status":"open","priority":1,"issue_type":"task","owner":"stefanc@allenai.org","created_at":"2026-02-03T18:50:39.371587-08:00","created_by":"stefanc-ai2","updated_at":"2026-02-03T18:50:39.371587-08:00","labels":["sessions","cli","routing"],"dependencies":[{"issue_id":"claude_code_bridge-4.5","depends_on_id":"claude_code_bridge-4","type":"parent-child","created_at":"0001-01-01T00:00:00Z"},{"issue_id":"claude_code_bridge-4.5","depends_on_id":"claude_code_bridge-4.1","type":"blocks","created_at":"0001-01-01T00:00:00Z"},{"issue_id":"claude_code_bridge-4.5","depends_on_id":"claude_code_bridge-4.6","type":"blocks","created_at":"0001-01-01T00:00:00Z"}]}
7+
{"id":"claude_code_bridge-4.5","title":"ask: add --session and route using session-scoped session files","description":"Make `ask` able to target a specific session.\n\n## Scope\n- Add `--session \u003cname\u003e` to `bin/ask` (default from `CQ_SESSION`, else `default`).\n- Load the provider session for the selected session.\n\n## Acceptance\n- `ask --session A codex ...` routes to A.\n- Running `ask codex ...` inside session A routes to A (via `CQ_SESSION`).\n","status":"closed","priority":1,"issue_type":"task","owner":"stefanc@allenai.org","created_at":"2026-02-03T18:50:39.371587-08:00","created_by":"stefanc-ai2","updated_at":"2026-02-03T21:52:53.90757-08:00","closed_at":"2026-02-03T21:52:53.90757-08:00","close_reason":"Added ask --session \u003cname\u003e to target a specific CQ session; validates names via session_scope, passes session/env through to provider session loaders, and improves error hints to include the selected session and the correct cq --session command. Updated README and added tests for explicit --session, CQ_SESSION env defaulting, and invalid session rejection.","labels":["sessions","cli","routing"],"dependencies":[{"issue_id":"claude_code_bridge-4.5","depends_on_id":"claude_code_bridge-4","type":"parent-child","created_at":"0001-01-01T00:00:00Z"},{"issue_id":"claude_code_bridge-4.5","depends_on_id":"claude_code_bridge-4.1","type":"blocks","created_at":"0001-01-01T00:00:00Z"},{"issue_id":"claude_code_bridge-4.5","depends_on_id":"claude_code_bridge-4.6","type":"blocks","created_at":"0001-01-01T00:00:00Z"}]}
88
{"id":"claude_code_bridge-4.6","title":"Make codex/claude session loaders + registry session-aware","description":"Update session resolution so multiple sessions can coexist.\n\n## Scope\n- Update `lib/codex_session.py` + `lib/claude_session.py` to accept a `session` parameter (or read `CQ_SESSION`).\n- Update `lib/claude_session_resolver.py` so it prefers session-scoped files/registry records and avoids cross-session collisions.\n- Consider adding `cq_session_name` to registry records for additional disambiguation.\n\n## Acceptance\n- With two active sessions, `ask --session A claude ...` never resolves B's pane.\n","status":"closed","priority":1,"issue_type":"task","owner":"stefanc@allenai.org","created_at":"2026-02-03T18:50:39.542717-08:00","created_by":"stefanc-ai2","updated_at":"2026-02-03T21:31:27.028941-08:00","closed_at":"2026-02-03T21:31:27.028941-08:00","close_reason":"Session-aware loaders/resolution: added cq_session_name to registry records, filtered registry lookups by session, and threaded session/env through codex/claude loaders + claude_session_resolver to avoid cross-session pane collisions. Added tests for registry+resolver session filtering (named + default).","labels":["sessions","registry"],"dependencies":[{"issue_id":"claude_code_bridge-4.6","depends_on_id":"claude_code_bridge-4","type":"parent-child","created_at":"0001-01-01T00:00:00Z"},{"issue_id":"claude_code_bridge-4.6","depends_on_id":"claude_code_bridge-4.1","type":"blocks","created_at":"0001-01-01T00:00:00Z"},{"issue_id":"claude_code_bridge-4.6","depends_on_id":"claude_code_bridge-4.3","type":"blocks","created_at":"0001-01-01T00:00:00Z"}]}
99
{"id":"claude_code_bridge-4.7","title":"cq-mounted: add --session and list all sessions","description":"Teach `cq-mounted` about multiple sessions.\n\n## Scope\n- Support `cq-mounted --session \u003cname\u003e` (or `CQ_SESSION`) to answer \"is this session mounted?\"\n- Add `cq-mounted --all-sessions` to enumerate `.cq_config/sessions/*` plus the default session.\n\n## Acceptance\n- Skills using `cq-mounted` from inside a session see the right providers.\n","status":"open","priority":2,"issue_type":"task","owner":"stefanc@allenai.org","created_at":"2026-02-03T18:50:39.728948-08:00","created_by":"stefanc-ai2","updated_at":"2026-02-03T18:50:39.728948-08:00","labels":["sessions","cli"],"dependencies":[{"issue_id":"claude_code_bridge-4.7","depends_on_id":"claude_code_bridge-4","type":"parent-child","created_at":"0001-01-01T00:00:00Z"},{"issue_id":"claude_code_bridge-4.7","depends_on_id":"claude_code_bridge-4.1","type":"blocks","created_at":"0001-01-01T00:00:00Z"},{"issue_id":"claude_code_bridge-4.7","depends_on_id":"claude_code_bridge-4.3","type":"blocks","created_at":"0001-01-01T00:00:00Z"}]}
1010
{"id":"claude_code_bridge-4.8","title":"Tests + README docs for multi-session","description":"Add coverage and documentation for multi-session workflow.\n\n## Scope\n- Unit tests for session path selection and routing precedence.\n- Update README with examples:\n - Start two sessions\n - Target via `ask --session`\n - In-pane default behavior via `CQ_SESSION`\n\n## Acceptance\n- Tests cover session selection rules and no collisions.\n- README documents multi-session usage succinctly.\n","status":"open","priority":2,"issue_type":"task","owner":"stefanc@allenai.org","created_at":"2026-02-03T18:50:39.914963-08:00","created_by":"stefanc-ai2","updated_at":"2026-02-03T18:50:39.914963-08:00","labels":["sessions","tests","docs"],"dependencies":[{"issue_id":"claude_code_bridge-4.8","depends_on_id":"claude_code_bridge-4","type":"parent-child","created_at":"0001-01-01T00:00:00Z"},{"issue_id":"claude_code_bridge-4.8","depends_on_id":"claude_code_bridge-4.5","type":"blocks","created_at":"0001-01-01T00:00:00Z"},{"issue_id":"claude_code_bridge-4.8","depends_on_id":"claude_code_bridge-4.7","type":"blocks","created_at":"0001-01-01T00:00:00Z"}]}

README.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -134,6 +134,12 @@ cq --session feature-b codex,claude
134134

135135
This namespaces the launcher lock per (cwd,session) and exports `CQ_SESSION` in managed panes.
136136

137+
To send to a specific session from outside a managed pane:
138+
139+
```bash
140+
ask --session feature-a codex "Review this diff"
141+
```
142+
137143
Provider session files for named sessions live under:
138144
- `.cq_config/sessions/<name>/.codex-session`
139145
- `.cq_config/sessions/<name>/.claude-session`

bin/ask

Lines changed: 26 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,9 @@ Examples:
1616
# Send a one-liner
1717
ask codex "Review this diff"
1818
19+
# Target a specific CQ session (for multiple independent sessions in one repo)
20+
ask --session feature-x codex "Review this diff"
21+
1922
# Send a multi-line message
2023
ask claude <<'EOF'
2124
Please review these changes:
@@ -51,6 +54,7 @@ from cq_protocol import make_req_id, wrap_reply_payload, wrap_request_prompt
5154
from claude_session import load_project_session as load_claude_session
5255
from cli_output import EXIT_ERROR, EXIT_OK
5356
from codex_session import load_project_session as load_codex_session
57+
from session_scope import SESSION_ENV_VAR, DEFAULT_SESSION, resolve_session_name
5458

5559

5660
def _env_bool(name: str, default: bool = False) -> bool:
@@ -86,6 +90,7 @@ def _parser() -> argparse.ArgumentParser:
8690
epilog=(
8791
"Examples:\n"
8892
' ask codex "Review this diff"\n'
93+
' ask --session feature-x codex "Review this diff"\n'
8994
" ask claude <<'EOF'\n"
9095
" Multi-line message...\n"
9196
" EOF\n"
@@ -114,6 +119,12 @@ def main(argv: list[str]) -> int:
114119
parser.add_argument(
115120
"provider", choices=("codex", "claude"), help="Target provider pane to send to."
116121
)
122+
parser.add_argument(
123+
"--session",
124+
dest="session",
125+
default=None,
126+
help=f"Target CQ session name (default: ${SESSION_ENV_VAR}, else {DEFAULT_SESSION}).",
127+
)
117128
parser.add_argument(
118129
"--reply-to",
119130
dest="reply_to_req_id",
@@ -149,6 +160,13 @@ def main(argv: list[str]) -> int:
149160
return EXIT_OK if getattr(exc, "code", 1) == 0 else EXIT_ERROR
150161
provider = str(args.provider).lower()
151162

163+
session_arg = (args.session or "").strip() or None
164+
try:
165+
effective_session = resolve_session_name(session_arg, env=os.environ)
166+
except ValueError as exc:
167+
print(f"[ERROR] Invalid --session: {exc}", file=sys.stderr)
168+
return EXIT_ERROR
169+
152170
message = " ".join(args.message).strip()
153171
if not message and not sys.stdin.isatty():
154172
message = read_stdin_text().strip()
@@ -175,16 +193,20 @@ def main(argv: list[str]) -> int:
175193

176194
work_dir = Path.cwd()
177195
if provider == "codex":
178-
session = load_codex_session(work_dir)
196+
session = load_codex_session(work_dir, session=session_arg, env=os.environ)
179197
else:
180-
session = load_claude_session(work_dir)
198+
session = load_claude_session(work_dir, session=session_arg, env=os.environ)
181199

182200
if not session:
201+
session_hint = f" (session: {effective_session})" if effective_session != DEFAULT_SESSION else ""
183202
print(
184-
f"[ERROR] No active {provider} session found for this directory.",
203+
f"[ERROR] No active {provider} session found for this directory{session_hint}.",
185204
file=sys.stderr,
186205
)
187-
print(f"[ERROR] Run `cq {provider}` in this project first.", file=sys.stderr)
206+
cmd = f"cq {provider}"
207+
if effective_session != DEFAULT_SESSION:
208+
cmd = f"cq --session {effective_session} {provider}"
209+
print(f"[ERROR] Run `{cmd}` in this project first.", file=sys.stderr)
188210
return EXIT_ERROR
189211

190212
ok, pane_or_err = session.ensure_pane()

test/test_ask_session_flag.py

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
from __future__ import annotations
2+
3+
import importlib.util
4+
from importlib.machinery import SourceFileLoader
5+
from pathlib import Path
6+
7+
import pytest
8+
9+
10+
def _load_ask_module(repo_root: Path):
11+
loader = SourceFileLoader("ask_bin", str(repo_root / "bin" / "ask"))
12+
spec = importlib.util.spec_from_loader("ask_bin", loader)
13+
assert spec and spec.loader
14+
module = importlib.util.module_from_spec(spec)
15+
spec.loader.exec_module(module)
16+
return module
17+
18+
19+
def test_ask_passes_session_flag_to_loader(tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys) -> None:
20+
repo_root = Path(__file__).resolve().parents[1]
21+
ask = _load_ask_module(repo_root)
22+
23+
monkeypatch.chdir(tmp_path)
24+
25+
sent: dict[str, str] = {}
26+
27+
class _Backend:
28+
def send_text(self, pane_id: str, text: str) -> None:
29+
sent["pane_id"] = pane_id
30+
sent["text"] = text
31+
32+
class _Session:
33+
def ensure_pane(self):
34+
return True, "pane-1"
35+
36+
def backend(self):
37+
return _Backend()
38+
39+
def _fake_load_codex_session(work_dir: Path, *, session=None, env=None):
40+
assert work_dir.resolve() == tmp_path.resolve()
41+
assert session == "feature-x"
42+
assert env is not None
43+
return _Session()
44+
45+
monkeypatch.setattr(ask, "load_codex_session", _fake_load_codex_session)
46+
47+
rc = ask.main(["ask", "--session", "feature-x", "codex", "--req-id", "abc", "hello"])
48+
assert rc == ask.EXIT_OK
49+
assert capsys.readouterr().out.strip() == "abc"
50+
assert sent["pane_id"] == "pane-1"
51+
assert "abc" in sent["text"]
52+
53+
54+
def test_ask_uses_cq_session_env_when_no_flag(tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys) -> None:
55+
repo_root = Path(__file__).resolve().parents[1]
56+
ask = _load_ask_module(repo_root)
57+
58+
monkeypatch.chdir(tmp_path)
59+
monkeypatch.setenv("CQ_SESSION", "env-session")
60+
61+
sent: dict[str, str] = {}
62+
63+
class _Backend:
64+
def send_text(self, pane_id: str, text: str) -> None:
65+
sent["pane_id"] = pane_id
66+
sent["text"] = text
67+
68+
class _Session:
69+
def ensure_pane(self):
70+
return True, "pane-1"
71+
72+
def backend(self):
73+
return _Backend()
74+
75+
def _fake_load_codex_session(work_dir: Path, *, session=None, env=None):
76+
assert work_dir.resolve() == tmp_path.resolve()
77+
assert session is None
78+
assert env is not None
79+
assert env.get("CQ_SESSION") == "env-session"
80+
return _Session()
81+
82+
monkeypatch.setattr(ask, "load_codex_session", _fake_load_codex_session)
83+
84+
rc = ask.main(["ask", "codex", "--req-id", "abc", "hello"])
85+
assert rc == ask.EXIT_OK
86+
assert capsys.readouterr().out.strip() == "abc"
87+
assert sent["pane_id"] == "pane-1"
88+
assert "abc" in sent["text"]
89+
90+
91+
def test_ask_rejects_invalid_session_flag(tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys) -> None:
92+
repo_root = Path(__file__).resolve().parents[1]
93+
ask = _load_ask_module(repo_root)
94+
95+
monkeypatch.chdir(tmp_path)
96+
97+
rc = ask.main(["ask", "--session", "../oops", "codex", "hello"])
98+
assert rc == ask.EXIT_ERROR
99+
err = capsys.readouterr().err
100+
assert "Invalid --session" in err

0 commit comments

Comments
 (0)