Skip to content

Commit 1e58557

Browse files
authored
Merge pull request SeemSeam#111 from daniellee2015/clean/upstream-routing-session-v2
fix(core): improve ask caller routing and cross-dir session resolution
2 parents a7c1ebc + 46a1486 commit 1e58557

4 files changed

Lines changed: 455 additions & 100 deletions

File tree

bin/ask

Lines changed: 221 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,8 @@ Examples:
2222
from __future__ import annotations
2323

2424
import os
25+
import json
26+
import shlex
2527
import subprocess
2628
import sys
2729
import tempfile
@@ -44,6 +46,7 @@ from compat import read_stdin_text, setup_windows_encoding
4446
setup_windows_encoding()
4547

4648
from cli_output import EXIT_ERROR, EXIT_OK
49+
from session_utils import find_project_session_file
4750

4851

4952
# Provider to daemon command mapping
@@ -55,6 +58,30 @@ PROVIDER_DAEMONS = {
5558
"claude": "lask",
5659
}
5760

61+
CALLER_SESSION_FILES = {
62+
"claude": ".claude-session",
63+
"codex": ".codex-session",
64+
"gemini": ".gemini-session",
65+
"opencode": ".opencode-session",
66+
"droid": ".droid-session",
67+
}
68+
69+
CALLER_PANE_ENV_HINTS = {
70+
"codex": ("CODEX_TMUX_SESSION", "CODEX_WEZTERM_PANE"),
71+
"gemini": ("GEMINI_TMUX_SESSION", "GEMINI_WEZTERM_PANE"),
72+
"opencode": ("OPENCODE_TMUX_SESSION", "OPENCODE_WEZTERM_PANE"),
73+
"droid": ("DROID_TMUX_SESSION", "DROID_WEZTERM_PANE"),
74+
}
75+
76+
CALLER_ENV_HINTS = {
77+
"codex": ("CODEX_SESSION_ID", "CODEX_RUNTIME_DIR"),
78+
"gemini": ("GEMINI_SESSION_ID", "GEMINI_RUNTIME_DIR"),
79+
"opencode": ("OPENCODE_SESSION_ID", "OPENCODE_RUNTIME_DIR"),
80+
"droid": ("DROID_SESSION_ID", "DROID_RUNTIME_DIR"),
81+
}
82+
83+
VALID_CALLERS = set(CALLER_SESSION_FILES.keys()) | {"email", "manual"}
84+
5885

5986
def _env_int(name: str, default: int) -> int:
6087
raw = (os.environ.get(name) or "").strip()
@@ -83,13 +110,101 @@ def _cleanup_task_logs(log_dir: Path) -> None:
83110
logs.sort(key=lambda p: p.name, reverse=True)
84111
for path in logs[max_files:]:
85112
prefix = path.name[:-4] # strip .log
86-
for ext in (".log", ".sh", ".ps1", ".msg"):
113+
for ext in (".log", ".sh", ".ps1", ".msg", ".status"):
87114
try:
88115
(log_dir / f"{prefix}{ext}").unlink(missing_ok=True)
89116
except Exception:
90117
pass
91118

92119

120+
def _normalize_caller(raw: str) -> str:
121+
caller = (raw or "").strip().lower()
122+
if caller in VALID_CALLERS:
123+
return caller
124+
return ""
125+
126+
127+
def _load_json_dict(path: Path) -> dict:
128+
try:
129+
with path.open("r", encoding="utf-8") as handle:
130+
data = json.load(handle)
131+
if isinstance(data, dict):
132+
return data
133+
except Exception:
134+
pass
135+
return {}
136+
137+
138+
def _infer_caller_from_pane() -> str:
139+
pane_id = (os.environ.get("TMUX_PANE") or os.environ.get("WEZTERM_PANE") or "").strip()
140+
if not pane_id:
141+
return ""
142+
143+
# Fast-path: CCB may export known pane ids in env for cross-pane routing.
144+
for caller, keys in CALLER_PANE_ENV_HINTS.items():
145+
for key in keys:
146+
value = (os.environ.get(key) or "").strip()
147+
if value and value == pane_id:
148+
return caller
149+
150+
# Fallback: resolve by local session files for the current project/cwd.
151+
cwd = Path.cwd()
152+
for caller, session_filename in CALLER_SESSION_FILES.items():
153+
try:
154+
session_file = find_project_session_file(cwd, session_filename)
155+
except Exception:
156+
session_file = None
157+
if not session_file:
158+
continue
159+
data = _load_json_dict(session_file)
160+
session_pane = str(data.get("pane_id") or data.get("tmux_session") or "").strip()
161+
if session_pane and session_pane == pane_id:
162+
return caller
163+
164+
return ""
165+
166+
167+
def _infer_caller_from_env_hints() -> str:
168+
matches: list[str] = []
169+
for caller, keys in CALLER_ENV_HINTS.items():
170+
if any((os.environ.get(key) or "").strip() for key in keys):
171+
matches.append(caller)
172+
if len(matches) == 1:
173+
return matches[0]
174+
return ""
175+
176+
177+
def _detect_caller() -> str:
178+
direct = _normalize_caller(os.environ.get("CCB_CALLER", ""))
179+
if direct:
180+
return direct
181+
182+
# Email worker may carry email metadata even when CCB_CALLER isn't exported.
183+
if (os.environ.get("CCB_EMAIL_REQ_ID") or "").strip():
184+
return "email"
185+
186+
pane = _infer_caller_from_pane()
187+
if pane:
188+
return pane
189+
190+
hinted = _infer_caller_from_env_hints()
191+
if hinted:
192+
return hinted
193+
194+
return ""
195+
196+
197+
def _append_task_status_line(status_file: Path, line: str) -> None:
198+
"""Append one timestamped lifecycle line for async task observability."""
199+
ts = datetime.now().strftime("%Y-%m-%dT%H:%M:%S%z")
200+
try:
201+
status_file.parent.mkdir(parents=True, exist_ok=True)
202+
with status_file.open("a", encoding="utf-8") as handle:
203+
handle.write(f"{ts} {line}\n")
204+
except Exception:
205+
pass
206+
207+
93208
def _use_unified_daemon() -> bool:
94209
"""Check if unified askd daemon should be used (default: True)."""
95210
val = (os.environ.get("CCB_UNIFIED_ASKD") or "").strip().lower()
@@ -301,19 +416,40 @@ def _default_foreground() -> bool:
301416
return False
302417
if _env_bool("CCB_ASK_FOREGROUND", False):
303418
return True
304-
# If CCB_CALLER is set, use background (nohup) mode
305-
if os.environ.get("CCB_CALLER"):
419+
# Default async only for Claude caller to preserve historical guardrail behavior.
420+
# Other callers (codex/gemini/opencode/droid/manual) default to foreground.
421+
caller = _detect_caller()
422+
if caller == "claude":
306423
return False
307-
# No caller set, use foreground mode
308424
return True
309425

310426

427+
def _should_emit_async_guardrail(caller: str) -> bool:
428+
"""
429+
Decide whether to print strict async guardrail text.
430+
431+
Override with CCB_ASK_EMIT_GUARDRAIL=0/1.
432+
Default: enabled only when caller is Claude.
433+
"""
434+
raw = (os.environ.get("CCB_ASK_EMIT_GUARDRAIL") or "").strip().lower()
435+
if raw:
436+
return raw not in ("0", "false", "no", "off")
437+
return (caller or "").strip().lower() == "claude"
438+
439+
311440
def _require_caller() -> str:
312-
caller = (os.environ.get("CCB_CALLER") or "").strip()
441+
caller = _detect_caller()
313442
if caller:
314443
return caller
315-
print("[ERROR] CCB_CALLER is required. Set CCB_CALLER=<provider> (e.g. CCB_CALLER=claude).", file=sys.stderr)
316-
sys.exit(1)
444+
default_caller = _normalize_caller(os.environ.get("CCB_CALLER_DEFAULT", ""))
445+
if not default_caller:
446+
default_caller = "manual"
447+
print(
448+
f"[WARN] CCB_CALLER not set; using '{default_caller}'. "
449+
"Set CCB_CALLER explicitly to override.",
450+
file=sys.stderr,
451+
)
452+
return default_caller
317453

318454

319455
def make_task_id() -> str:
@@ -446,22 +582,32 @@ def main(argv: list[str]) -> int:
446582
print(f"[ERROR] {e}", file=sys.stderr)
447583
return EXIT_ERROR
448584

449-
# Default async mode: background task via nohup
585+
# Default async mode: background task
450586
task_id = make_task_id()
451587
log_dir = Path(tempfile.gettempdir()) / "ccb-tasks"
452588
log_dir.mkdir(parents=True, exist_ok=True)
453589
log_file = log_dir / f"ask-{provider}-{task_id}.log"
590+
status_file = log_dir / f"ask-{provider}-{task_id}.status"
454591
try:
455592
log_file.touch(exist_ok=True)
456593
except Exception:
457594
pass
595+
try:
596+
status_file.touch(exist_ok=True)
597+
except Exception:
598+
pass
458599
_cleanup_task_logs(log_dir)
459600

460601
# Detect caller from environment or default to "claude"
461602
caller = _require_caller()
603+
_append_task_status_line(
604+
status_file,
605+
f"submitted task={task_id} provider={provider} caller={caller} work_dir={os.getcwd()}",
606+
)
462607

463608
# Get the path to this script for recursive call with --foreground
464609
ask_cmd = str(Path(__file__).resolve())
610+
bg_pid: int | None = None
465611

466612
# Platform-specific background execution
467613
if os.name == "nt":
@@ -476,24 +622,40 @@ def main(argv: list[str]) -> int:
476622

477623
# Write PowerShell script - call ask --foreground to use unified daemon
478624
script_file = log_dir / f"ask-{provider}-{task_id}.ps1"
625+
status_file_win = str(status_file).replace('"', '`"')
626+
log_file_win = str(log_file).replace('"', '`"')
479627
script_content = f'''$ErrorActionPreference = "SilentlyContinue"
480628
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
481629
$env:CCB_REQ_ID = "{task_id}"
482630
$env:CCB_CALLER = "{caller}"
483631
$env:CCB_WORK_DIR = "{os.getcwd()}"
632+
$statusFile = "{status_file_win}"
633+
$logFile = "{log_file_win}"
634+
function Write-CcbStatus([string]$line) {{
635+
Add-Content -Path $statusFile -Value ("{{0}} {{1}}" -f (Get-Date -Format "yyyy-MM-ddTHH:mm:sszzz"), $line) -Encoding UTF8
636+
}}
637+
Write-CcbStatus "running pid=$PID"
484638
Get-Content -Path "{msg_file}" -Encoding UTF8 | python "{ask_cmd}" {provider} --foreground --timeout {timeout}
639+
$rc = $LASTEXITCODE
640+
Write-CcbStatus "finished exit_code=$rc"
641+
if ($rc -ne 0) {{
642+
Write-CcbStatus "failed exit_code=$rc"
643+
}}
644+
exit $rc
485645
'''
486646
script_file.write_text(script_content, encoding="utf-8")
487647

488-
subprocess.Popen(
489-
["powershell", "-ExecutionPolicy", "Bypass", "-NoProfile", "-File", str(script_file)],
490-
stdin=subprocess.DEVNULL,
491-
stdout=open(log_file, "w"),
492-
stderr=subprocess.STDOUT,
493-
creationflags=DETACHED_PROCESS | CREATE_NO_WINDOW | CREATE_NEW_PROCESS_GROUP,
494-
)
648+
with log_file.open("a", encoding="utf-8") as log_handle:
649+
proc = subprocess.Popen(
650+
["powershell", "-ExecutionPolicy", "Bypass", "-NoProfile", "-File", str(script_file)],
651+
stdin=subprocess.DEVNULL,
652+
stdout=log_handle,
653+
stderr=subprocess.STDOUT,
654+
creationflags=DETACHED_PROCESS | CREATE_NO_WINDOW | CREATE_NEW_PROCESS_GROUP,
655+
)
656+
bg_pid = proc.pid
495657
else:
496-
# Unix: use nohup, call ask --foreground to use unified daemon
658+
# Unix: run detached shell script, call ask --foreground to use unified daemon
497659
# Collect CCB_EMAIL_* env vars for email caller
498660
email_env_lines = ""
499661
if caller == "email":
@@ -506,31 +668,62 @@ Get-Content -Path "{msg_file}" -Encoding UTF8 | python "{ask_cmd}" {provider} --
506668
ccb_run_dir = os.environ.get("CCB_RUN_DIR", "")
507669
run_dir_line = f'export CCB_RUN_DIR="{ccb_run_dir}"\n' if ccb_run_dir else ""
508670

509-
bg_script = f'''
671+
quoted_status = shlex.quote(str(status_file))
672+
quoted_ask_cmd = shlex.quote(ask_cmd)
673+
quoted_provider = shlex.quote(provider)
674+
bg_script = f'''#!/bin/sh
675+
set +e
676+
_now() {{
677+
date '+%Y-%m-%dT%H:%M:%S%z'
678+
}}
679+
echo "$(_now) running pid=$$" >> {quoted_status}
680+
echo "[CCB_TASK_START] task={task_id} provider={provider} caller={caller} pid=$$"
510681
export CCB_REQ_ID="{task_id}"
511682
export CCB_CALLER="{caller}"
512683
export CCB_WORK_DIR="{os.getcwd()}"
513-
{run_dir_line}{email_env_lines}python3 "{ask_cmd}" {provider} --foreground --timeout {timeout} <<'ASKEOF'
684+
{run_dir_line}{email_env_lines}python3 {quoted_ask_cmd} {quoted_provider} --foreground --timeout {timeout} <<'ASKEOF'
514685
{message}
515686
ASKEOF
687+
rc=$?
688+
echo "[CCB_TASK_END] task={task_id} provider={provider} exit_code=$rc"
689+
echo "$(_now) finished exit_code=$rc" >> {quoted_status}
690+
if [ "$rc" -ne 0 ]; then
691+
echo "$(_now) failed exit_code=$rc" >> {quoted_status}
692+
fi
693+
exit "$rc"
516694
'''
517-
# Write script to temp file for nohup execution
695+
# Write script to temp file for detached execution
518696
script_file = log_dir / f"ask-{provider}-{task_id}.sh"
519697
script_file.write_text(bg_script, encoding="utf-8")
520698
script_file.chmod(0o755)
521699

522-
# Use nohup to run in background, independent of terminal
523-
subprocess.Popen(
524-
f'nohup sh "{script_file}" > "{log_file}" 2>&1 &',
525-
shell=True,
526-
stdin=subprocess.DEVNULL,
527-
stdout=subprocess.DEVNULL,
528-
stderr=subprocess.DEVNULL,
529-
)
700+
# Run detached in its own session and stream stdout/stderr to task log.
701+
with log_file.open("a", encoding="utf-8") as log_handle:
702+
proc = subprocess.Popen(
703+
["sh", str(script_file)],
704+
stdin=subprocess.DEVNULL,
705+
stdout=log_handle,
706+
stderr=subprocess.STDOUT,
707+
start_new_session=True,
708+
close_fds=True,
709+
)
710+
bg_pid = proc.pid
711+
712+
if bg_pid is not None:
713+
_append_task_status_line(status_file, f"spawned pid={bg_pid}")
714+
else:
715+
_append_task_status_line(status_file, "spawned pid=unknown")
530716

531717
print(f"[CCB_ASYNC_SUBMITTED provider={provider}]")
532718
print(f"{display_name(provider)} processing (task: {task_id})")
533-
print(f"MANDATORY: END YOUR TURN NOW. Reply ONLY '{display_name(provider)} processing...', then stop. See 'Async Guardrail' in CLAUDE.md.")
719+
if bg_pid is not None:
720+
print(f"[CCB_ASYNC_PID task={task_id} pid={bg_pid}]")
721+
print(f"[CCB_ASYNC_STATUS_FILE task={task_id}] {status_file}")
722+
print(f"[CCB_ASYNC_LOG_FILE task={task_id}] {log_file}")
723+
if _should_emit_async_guardrail(caller):
724+
print(
725+
f"MANDATORY: END YOUR TURN NOW. Reply ONLY '{display_name(provider)} processing...', then stop. See 'Async Guardrail' in CLAUDE.md."
726+
)
534727
return EXIT_OK
535728

536729

0 commit comments

Comments
 (0)