Skip to content

Commit e4e4c1b

Browse files
committed
Handle prompt-ready TTY completions
1 parent 0224fab commit e4e4c1b

7 files changed

Lines changed: 203 additions & 9 deletions

File tree

Cargo.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "cctty"
3-
version = "0.2.0"
3+
version = "0.2.1"
44
edition = "2024"
55
description = "Claude Agent SDK compatibility through the interactive Claude Code TTY"
66
license = "MIT"

README.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,7 @@ Download a release archive:
7777

7878
```sh
7979
curl -L -o cctty.tar.gz \
80-
https://github.com/Pyiner/cctty/releases/download/v0.2.0/cctty-0.2.0-aarch64-apple-darwin.tar.gz
80+
https://github.com/Pyiner/cctty/releases/download/v0.2.1/cctty-0.2.1-aarch64-apple-darwin.tar.gz
8181
tar -xzf cctty.tar.gz
8282
sudo install -m 0755 cctty /usr/local/bin/cctty
8383
```
@@ -442,8 +442,8 @@ CCTTY_LIVE_SDK_GAME=1 cargo test --test sdk_integration live_typescript_sdk_buil
442442
The repository includes GitHub Actions for CI and tagged releases.
443443

444444
```sh
445-
git tag v0.2.0
446-
git push origin v0.2.0
445+
git tag v0.2.1
446+
git push origin v0.2.1
447447
```
448448

449449
The release workflow builds macOS and Linux archives and publishes SHA-256

README.zh-CN.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -85,7 +85,7 @@ brew install cctty
8585

8686
```sh
8787
curl -L -o cctty.tar.gz \
88-
https://github.com/Pyiner/cctty/releases/download/v0.2.0/cctty-0.2.0-aarch64-apple-darwin.tar.gz
88+
https://github.com/Pyiner/cctty/releases/download/v0.2.1/cctty-0.2.1-aarch64-apple-darwin.tar.gz
8989
tar -xzf cctty.tar.gz
9090
sudo install -m 0755 cctty /usr/local/bin/cctty
9191
```
@@ -294,8 +294,8 @@ CCTTY_LIVE_SDK_GAME=1 cargo test --test sdk_integration live_typescript_sdk_buil
294294
仓库包含 GitHub Actions CI 和 tag release。发布新版本:
295295

296296
```sh
297-
git tag v0.2.0
298-
git push origin v0.2.0
297+
git tag v0.2.1
298+
git push origin v0.2.1
299299
```
300300

301301
Release workflow 会构建 macOS / Linux archive、生成 SHA-256,并在配置了

src/runner.rs

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ const MCP_PROXY_REQUEST_LINE_TIMEOUT: Duration = Duration::from_millis(250);
4848
const PTY_TERMINATE_TIMEOUT: Duration = Duration::from_secs(5);
4949
const TTY_SESSION_LOCK_RETRY_TIMEOUT: Duration = Duration::from_secs(8);
5050
const TTY_SESSION_LOCK_RETRY_DELAY: Duration = Duration::from_millis(300);
51+
const TTY_VISIBLE_COMPLETION_IDLE: Duration = Duration::from_secs(3);
5152
const RUN_TIMEOUT: Duration = Duration::from_secs(3600);
5253

5354
pub async fn run(invocation: Invocation) -> Result<i32> {
@@ -1108,6 +1109,7 @@ async fn tail_until_complete(
11081109
let mut tty_debug = TtyDebugLogger::new("text");
11091110
let mut tail_progress = TailProgressLogger::new("text");
11101111
let mut questions = TtyQuestionBridge::new(false);
1112+
let mut visible_progress = TtyVisibleProgress::new(process);
11111113

11121114
loop {
11131115
if started.elapsed() > RUN_TIMEOUT {
@@ -1156,6 +1158,7 @@ async fn tail_until_complete(
11561158
{
11571159
last_activity = Instant::now();
11581160
}
1161+
visible_progress.observe(process);
11591162
if !state.assistant_text.is_empty() && last_activity.elapsed() >= COMPLETION_IDLE {
11601163
if output_format == OutputFormat::StreamJson && !state.saw_result {
11611164
let value = synthetic_result(&state, started.elapsed());
@@ -1167,6 +1170,20 @@ async fn tail_until_complete(
11671170
emit_idle_session_state_if_requested(&mut state, output_format)?;
11681171
return Ok(state);
11691172
}
1173+
if state.assistant_text.is_empty()
1174+
&& visible_progress.completed_without_transcript(process)
1175+
&& last_activity.elapsed() >= COMPLETION_IDLE
1176+
{
1177+
if output_format == OutputFormat::StreamJson && !state.saw_result {
1178+
let value = synthetic_prompt_ready_result(&state, started.elapsed());
1179+
logging::event("tail_result source=synthetic_prompt_ready");
1180+
println!("{}", serde_json::to_string(&value)?);
1181+
std::io::stdout().flush()?;
1182+
state.apply(&value);
1183+
}
1184+
emit_idle_session_state_if_requested(&mut state, output_format)?;
1185+
return Ok(state);
1186+
}
11701187
tail_progress.maybe_log(process, tail, &state, started.elapsed());
11711188
tty_debug.maybe_log(process, started.elapsed());
11721189
tokio::time::sleep(TRANSCRIPT_POLL).await;
@@ -1189,6 +1206,7 @@ async fn tail_until_complete_stream(
11891206
let mut questions = TtyQuestionBridge::new(permission_prompt_tool_stdio);
11901207
let mut tty_debug = TtyDebugLogger::new("stream");
11911208
let mut tail_progress = TailProgressLogger::new("stream");
1209+
let mut visible_progress = TtyVisibleProgress::new(process);
11921210

11931211
loop {
11941212
if started.elapsed() > RUN_TIMEOUT {
@@ -1243,6 +1261,7 @@ async fn tail_until_complete_stream(
12431261
permission.mark_ask_user_question_handled();
12441262
last_activity = Instant::now();
12451263
}
1264+
visible_progress.observe(process);
12461265

12471266
if state.saw_result {
12481267
logging::event("tail_result source=transcript stream=true");
@@ -1271,6 +1290,21 @@ async fn tail_until_complete_stream(
12711290
emit_idle_session_state_if_requested(&mut state, output_format)?;
12721291
return Ok(state);
12731292
}
1293+
if state.assistant_text.is_empty()
1294+
&& !permission.denied_current_turn()
1295+
&& visible_progress.completed_without_transcript(process)
1296+
&& last_activity.elapsed() >= COMPLETION_IDLE
1297+
{
1298+
if output_format == OutputFormat::StreamJson && !state.saw_result {
1299+
let value = synthetic_prompt_ready_result(&state, started.elapsed());
1300+
logging::event("tail_result source=synthetic_prompt_ready stream=true");
1301+
println!("{}", serde_json::to_string(&value)?);
1302+
std::io::stdout().flush()?;
1303+
state.apply(&value);
1304+
}
1305+
emit_idle_session_state_if_requested(&mut state, output_format)?;
1306+
return Ok(state);
1307+
}
12741308
tail_progress.maybe_log(process, tail, &state, started.elapsed());
12751309
tty_debug.maybe_log(process, started.elapsed());
12761310
tokio::time::sleep(TRANSCRIPT_POLL).await;
@@ -1496,6 +1530,39 @@ impl TailProgressLogger {
14961530
}
14971531
}
14981532

1533+
struct TtyVisibleProgress {
1534+
last_snapshot: String,
1535+
last_change: Instant,
1536+
saw_model_activity: bool,
1537+
}
1538+
1539+
impl TtyVisibleProgress {
1540+
fn new(process: &PtyProcess) -> Self {
1541+
Self {
1542+
last_snapshot: tty_progress_snapshot(process),
1543+
last_change: Instant::now(),
1544+
saw_model_activity: false,
1545+
}
1546+
}
1547+
1548+
fn observe(&mut self, process: &PtyProcess) {
1549+
let snapshot = tty_progress_snapshot(process);
1550+
if snapshot != self.last_snapshot {
1551+
self.last_snapshot = snapshot;
1552+
self.last_change = Instant::now();
1553+
}
1554+
if tty_output_has_visible_model_activity(&process.recent_output()) {
1555+
self.saw_model_activity = true;
1556+
}
1557+
}
1558+
1559+
fn completed_without_transcript(&self, process: &PtyProcess) -> bool {
1560+
self.saw_model_activity
1561+
&& tty_wait_class(&process.recent_output()) == "prompt_ready"
1562+
&& self.last_change.elapsed() >= TTY_VISIBLE_COMPLETION_IDLE
1563+
}
1564+
}
1565+
14991566
struct PermissionBridge {
15001567
enabled: bool,
15011568
requested_tool_use_ids: HashSet<String>,
@@ -3428,6 +3495,29 @@ fn tty_wait_class(output: &str) -> &'static str {
34283495
"other"
34293496
}
34303497

3498+
fn tty_progress_snapshot(process: &PtyProcess) -> String {
3499+
compact_tty_output(&plain_tty_output(&process.recent_output()))
3500+
}
3501+
3502+
fn tty_output_has_visible_model_activity(output: &str) -> bool {
3503+
let output = plain_tty_output(output);
3504+
let compact = compact_tty_output(&output);
3505+
output.contains("⏺")
3506+
|| output.contains("⎿")
3507+
|| output.contains("Bash (")
3508+
|| output.contains("Fetch (")
3509+
|| output.contains("Edit (")
3510+
|| output.contains("Write (")
3511+
|| output.contains("Read (")
3512+
|| output.contains("Wrote ")
3513+
|| output.contains("thinking with")
3514+
|| output.contains("thought for")
3515+
|| output.contains("running stop hook")
3516+
|| compact.contains("thinkingwith")
3517+
|| compact.contains("thoughtfor")
3518+
|| compact.contains("runningstophook")
3519+
}
3520+
34313521
fn tty_plan_approval_prompt_has_feedback_choice(output: &str) -> bool {
34323522
let output = plain_tty_output(output);
34333523
let compact = compact_tty_output(&output);
@@ -3507,6 +3597,26 @@ fn synthetic_result(state: &TranscriptState, duration: Duration) -> Value {
35073597
})
35083598
}
35093599

3600+
fn synthetic_prompt_ready_result(state: &TranscriptState, duration: Duration) -> Value {
3601+
json!({
3602+
"type": "result",
3603+
"subtype": "success",
3604+
"duration_ms": duration.as_millis() as i64,
3605+
"duration_api_ms": 0,
3606+
"is_error": false,
3607+
"num_turns": 1,
3608+
"session_id": state.session_id.clone().unwrap_or_default(),
3609+
"result": "Claude returned to the terminal prompt without writing a transcript result.",
3610+
"stop_reason": "end_turn",
3611+
"usage": zero_usage(),
3612+
"total_cost_usd": 0.0,
3613+
"modelUsage": {},
3614+
"permission_denials": [],
3615+
"terminal_reason": "prompt_ready_without_transcript",
3616+
"fast_mode_state": "off",
3617+
})
3618+
}
3619+
35103620
fn synthetic_permission_denied_result(state: &TranscriptState, duration: Duration) -> Value {
35113621
json!({
35123622
"type": "result",

tests/cctty_cli.rs

Lines changed: 75 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
use assert_cmd::Command;
22
use serde_json::Value;
3-
use std::io::{BufRead, BufReader, Write};
3+
use std::io::{BufRead, BufReader, Read, Write};
44
use std::process::Stdio;
55
use std::time::{Duration, Instant};
66
use wait_timeout::ChildExt;
@@ -1630,6 +1630,80 @@ fn stream_json_synthetic_result_emits_idle_and_accepts_followup() {
16301630
assert!(status.success());
16311631
}
16321632

1633+
#[test]
1634+
fn stream_json_synthesizes_result_when_tty_finishes_without_transcript() {
1635+
let fixture = FakeClaude::new();
1636+
let workspace = tempfile::tempdir().unwrap();
1637+
let config_dir = tempfile::tempdir().unwrap();
1638+
let session_id = "00000000-0000-0000-0000-000000000024";
1639+
let mut child = std::process::Command::new(env!("CARGO_BIN_EXE_cctty"))
1640+
.env("CCTTY_CLAUDE_PATH", fixture.path())
1641+
.env("CLAUDE_CONFIG_DIR", config_dir.path())
1642+
.current_dir(workspace.path())
1643+
.args([
1644+
"--output-format",
1645+
"stream-json",
1646+
"--input-format",
1647+
"stream-json",
1648+
"--session-id",
1649+
session_id,
1650+
])
1651+
.stdin(Stdio::piped())
1652+
.stdout(Stdio::piped())
1653+
.stderr(Stdio::piped())
1654+
.spawn()
1655+
.unwrap();
1656+
let mut stdin = child.stdin.take().unwrap();
1657+
1658+
writeln!(
1659+
stdin,
1660+
r#"{{"type":"user","message":{{"role":"user","content":"STDOUT_ONLY_FAKE_RESULT"}}}}"#
1661+
)
1662+
.unwrap();
1663+
drop(stdin);
1664+
1665+
let status = child
1666+
.wait_timeout(Duration::from_secs(10))
1667+
.unwrap()
1668+
.unwrap_or_else(|| {
1669+
let _ = child.kill();
1670+
panic!("cctty did not synthesize a prompt-ready result");
1671+
});
1672+
let mut stdout = String::new();
1673+
child
1674+
.stdout
1675+
.take()
1676+
.unwrap()
1677+
.read_to_string(&mut stdout)
1678+
.unwrap();
1679+
let mut stderr = String::new();
1680+
child
1681+
.stderr
1682+
.take()
1683+
.unwrap()
1684+
.read_to_string(&mut stderr)
1685+
.unwrap();
1686+
1687+
assert!(status.success(), "stderr:\n{stderr}");
1688+
let values = stdout
1689+
.lines()
1690+
.map(|line| serde_json::from_str::<Value>(line).unwrap())
1691+
.collect::<Vec<_>>();
1692+
let result = values
1693+
.iter()
1694+
.find(|value| value.get("type").and_then(Value::as_str) == Some("result"))
1695+
.expect("expected synthetic result");
1696+
assert_eq!(result["terminal_reason"], "prompt_ready_without_transcript");
1697+
assert!(
1698+
values.iter().any(|value| {
1699+
value["type"] == "system"
1700+
&& value["subtype"] == "session_state_changed"
1701+
&& value["state"] == "idle"
1702+
}),
1703+
"stdout:\n{stdout}"
1704+
);
1705+
}
1706+
16331707
#[test]
16341708
fn stream_json_permission_prompt_stdio_bridges_can_use_tool_request() {
16351709
let fixture = FakeClaude::new();

tests/support/mod.rs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -627,6 +627,16 @@ while True:
627627
after += 1
628628
buf = buf[after:]
629629
continue
630+
if "STDOUT_ONLY_FAKE_RESULT" in prompt:
631+
sys.stdout.write("⏺ I inspected the repo and wrote conductor.json\n")
632+
sys.stdout.write("⎿ Wrote 5 lines to conductor.json\n")
633+
sys.stdout.write("Context permissions /mcp\n")
634+
sys.stdout.flush()
635+
after = end + len(b"\x1b[201~")
636+
while after < len(buf) and buf[after:after + 1] in (b"\r", b"\n"):
637+
after += 1
638+
buf = buf[after:]
639+
continue
630640
with transcript.open("a", encoding="utf-8") as f:
631641
f.write(json.dumps({"type":"system","subtype":"init","session_id":session_id}) + "\n")
632642
f.write(json.dumps({"type":"user","message":{"role":"user","content":prompt}}) + "\n")

0 commit comments

Comments
 (0)