@@ -22,6 +22,8 @@ Examples:
2222from __future__ import annotations
2323
2424import os
25+ import json
26+ import shlex
2527import subprocess
2628import sys
2729import tempfile
@@ -44,6 +46,7 @@ from compat import read_stdin_text, setup_windows_encoding
4446setup_windows_encoding ()
4547
4648from 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
5986def _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+
93208def _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+
311440def _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
319455def 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"
484638Get-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=$$"
510681export CCB_REQ_ID="{ task_id } "
511682export CCB_CALLER="{ caller } "
512683export 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 }
515686ASKEOF
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