tandem-cli 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
tandem/memory_sync.py ADDED
@@ -0,0 +1,220 @@
1
+ """Instruction/memory file sync: CLAUDE.md <-> AGENTS.md, plus opt-in MCP
2
+ server copying.
3
+
4
+ v1 mapping: shared content lives in a marker-delimited common block; text
5
+ outside the markers is tool-specific and never touched.
6
+
7
+ <!-- tandem:shared:begin -->
8
+ ...shared instructions...
9
+ <!-- tandem:shared:end -->
10
+
11
+ Rules (documented in README):
12
+ - A file without markers contributes its ENTIRE content as the shared block
13
+ but is itself never modified (we don't rewrite user files to add markers).
14
+ - The counterpart file is created/refreshed with the shared block wrapped in
15
+ markers; anything it keeps outside the markers survives.
16
+ - When both files carry markers, the newer mtime wins the shared block.
17
+ - When both exist and neither has markers we refuse to guess and report it.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import json
23
+ import re
24
+ from dataclasses import dataclass, field
25
+ from pathlib import Path
26
+
27
+ from . import paths
28
+ from .util import write_file_atomic
29
+
30
+ BEGIN = "<!-- tandem:shared:begin -->"
31
+ END = "<!-- tandem:shared:end -->"
32
+
33
+ _BLOCK_RE = re.compile(
34
+ re.escape(BEGIN) + r"\n?(.*?)\n?" + re.escape(END), re.S
35
+ )
36
+
37
+ FILES = {"claude": "CLAUDE.md", "codex": "AGENTS.md"}
38
+
39
+
40
+ @dataclass
41
+ class MemorySyncReport:
42
+ actions: list[str] = field(default_factory=list)
43
+ warnings: list[str] = field(default_factory=list)
44
+
45
+
46
+ def _shared_block(text: str) -> str | None:
47
+ m = _BLOCK_RE.search(text)
48
+ return m.group(1).strip() if m else None
49
+
50
+
51
+ def _replace_block(text: str, shared: str) -> str:
52
+ new_block = f"{BEGIN}\n{shared}\n{END}"
53
+ if _BLOCK_RE.search(text):
54
+ # replacement via callable: content must be literal, never treated
55
+ # as backreference syntax
56
+ return _BLOCK_RE.sub(lambda _m: new_block, text, count=1)
57
+ prefix = text.rstrip() + "\n\n" if text.strip() else ""
58
+ return prefix + new_block + "\n"
59
+
60
+
61
+ def sync_memory_files(cwd: str) -> MemorySyncReport:
62
+ """Reflect shared instructions between CLAUDE.md and AGENTS.md in cwd."""
63
+ report = MemorySyncReport()
64
+ root = Path(cwd)
65
+ paths_ = {k: root / v for k, v in FILES.items()}
66
+ texts = {}
67
+ for k, p in paths_.items():
68
+ try:
69
+ texts[k] = p.read_text() if p.exists() else None
70
+ except OSError as exc:
71
+ report.warnings.append(f"cannot read {p.name}: {exc}")
72
+ return report
73
+
74
+ existing = [k for k, t in texts.items() if t is not None]
75
+ if not existing:
76
+ return report
77
+
78
+ if len(existing) == 1:
79
+ src = existing[0]
80
+ dst = "codex" if src == "claude" else "claude"
81
+ shared = _shared_block(texts[src])
82
+ if shared is None:
83
+ shared = texts[src].strip()
84
+ if not shared:
85
+ return report
86
+ write_file_atomic(paths_[dst], _replace_block("", shared))
87
+ report.actions.append(
88
+ f"created {FILES[dst]} from {FILES[src]} (shared block)"
89
+ )
90
+ return report
91
+
92
+ blocks = {k: _shared_block(texts[k]) for k in ("claude", "codex")}
93
+ if blocks["claude"] is None and blocks["codex"] is None:
94
+ report.warnings.append(
95
+ f"both {FILES['claude']} and {FILES['codex']} exist without tandem "
96
+ f"markers; not merging. Wrap the shared part of one of them in "
97
+ f"'{BEGIN}' / '{END}' to enable sync."
98
+ )
99
+ return report
100
+
101
+ # Effective shared content per side: the marker block, or (for a
102
+ # marker-less file) its whole content.
103
+ effective = {
104
+ k: blocks[k] if blocks[k] is not None else texts[k].strip()
105
+ for k in ("claude", "codex")
106
+ }
107
+ if effective["claude"] == effective["codex"]:
108
+ return report
109
+
110
+ newer = max(("claude", "codex"), key=lambda k: paths_[k].stat().st_mtime)
111
+ older = "codex" if newer == "claude" else "claude"
112
+ if blocks[older] is None:
113
+ # never rewrite a user file that has no markers
114
+ report.warnings.append(
115
+ f"{FILES[older]} has no tandem markers; not updating it from "
116
+ f"{FILES[newer]}."
117
+ )
118
+ return report
119
+ write_file_atomic(
120
+ paths_[older], _replace_block(texts[older], effective[newer])
121
+ )
122
+ report.actions.append(
123
+ f"updated shared block in {FILES[older]} from {FILES[newer]}"
124
+ )
125
+ return report
126
+
127
+
128
+ # -- MCP config copy (opt-in via `tandem sync-mcp`) --------------------------
129
+
130
+
131
+ def claude_mcp_servers() -> dict[str, dict]:
132
+ """Global stdio MCP servers from ~/.claude.json."""
133
+ cfg = Path.home() / ".claude.json"
134
+ try:
135
+ data = json.loads(cfg.read_text())
136
+ except (OSError, json.JSONDecodeError):
137
+ return {}
138
+ servers = data.get("mcpServers") or {}
139
+ return {
140
+ name: s
141
+ for name, s in servers.items()
142
+ if isinstance(s, dict) and s.get("command")
143
+ }
144
+
145
+
146
+ def codex_mcp_servers() -> dict[str, dict]:
147
+ """[mcp_servers.*] from ~/.codex/config.toml."""
148
+ import tomllib
149
+
150
+ cfg = paths.codex_home() / "config.toml"
151
+ try:
152
+ with open(cfg, "rb") as f:
153
+ data = tomllib.load(f)
154
+ except (OSError, ValueError):
155
+ return {}
156
+ servers = data.get("mcp_servers") or {}
157
+ return {
158
+ name: s
159
+ for name, s in servers.items()
160
+ if isinstance(s, dict) and s.get("command")
161
+ }
162
+
163
+
164
+ def _toml_str(value: str) -> str:
165
+ return json.dumps(value) # JSON string escaping is valid TOML
166
+
167
+
168
+ def copy_mcp(report: MemorySyncReport | None = None) -> MemorySyncReport:
169
+ """Bidirectional additive copy of stdio MCP servers: each side gains the
170
+ servers it is missing; existing definitions are never modified."""
171
+ report = report or MemorySyncReport()
172
+ claude_servers = claude_mcp_servers()
173
+ codex_servers = codex_mcp_servers()
174
+
175
+ # claude -> codex: append TOML sections for missing servers
176
+ missing_in_codex = {
177
+ n: s for n, s in claude_servers.items() if n not in codex_servers
178
+ }
179
+ if missing_in_codex:
180
+ cfg = paths.codex_home() / "config.toml"
181
+ text = cfg.read_text() if cfg.exists() else ""
182
+ chunks = [text.rstrip() + "\n" if text.strip() else ""]
183
+ for name, s in missing_in_codex.items():
184
+ chunks.append(f"\n# added by tandem sync-mcp\n[mcp_servers.{name}]")
185
+ chunks.append(f"command = {_toml_str(s['command'])}")
186
+ args = s.get("args") or []
187
+ chunks.append(
188
+ "args = [" + ", ".join(_toml_str(a) for a in args) + "]"
189
+ )
190
+ env = s.get("env") or {}
191
+ if env:
192
+ chunks.append(f"\n[mcp_servers.{name}.env]")
193
+ for k, v in env.items():
194
+ chunks.append(f"{k} = {_toml_str(str(v))}")
195
+ report.actions.append(f"added MCP server {name!r} to codex config.toml")
196
+ write_file_atomic(cfg, "\n".join(chunks) + "\n")
197
+
198
+ # codex -> claude: merge into ~/.claude.json mcpServers
199
+ missing_in_claude = {
200
+ n: s for n, s in codex_servers.items() if n not in claude_servers
201
+ }
202
+ if missing_in_claude:
203
+ cfg = Path.home() / ".claude.json"
204
+ try:
205
+ data = json.loads(cfg.read_text()) if cfg.exists() else {}
206
+ except json.JSONDecodeError:
207
+ report.warnings.append("~/.claude.json is not valid JSON; skipping")
208
+ return report
209
+ servers = data.setdefault("mcpServers", {})
210
+ for name, s in missing_in_claude.items():
211
+ entry: dict = {"command": s["command"], "args": list(s.get("args") or [])}
212
+ if s.get("env"):
213
+ entry["env"] = {k: str(v) for k, v in s["env"].items()}
214
+ servers[name] = entry
215
+ report.actions.append(f"added MCP server {name!r} to ~/.claude.json")
216
+ write_file_atomic(cfg, json.dumps(data, indent=2) + "\n")
217
+
218
+ if not report.actions:
219
+ report.actions.append("MCP configs already in sync (nothing copied)")
220
+ return report
tandem/ops.py ADDED
@@ -0,0 +1,207 @@
1
+ """Session operations shared by the CLI: catch-up drain, cursor
2
+ fast-forward, switch, and one-off routing.
3
+
4
+ Echo suppression: every entry tandem appends to a shadow is, by
5
+ construction, already represented in the other file. So whenever a harness
6
+ changes role (switch, or a one-off turn routed to it), we first drain the
7
+ outgoing source's unsynced tail, then fast-forward the incoming source's
8
+ cursor to end-of-file — from that point only genuinely new turns flow, and
9
+ nothing synced ever bounces back.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import subprocess
15
+ import time
16
+ from pathlib import Path
17
+
18
+ from . import paths
19
+ from .harness import get_adapter, other
20
+ from .runner import TailLoop, await_codex_rollout
21
+ from .state import PairedSession, StateStore
22
+ from .sync import SyncEngine, SyncSetupError
23
+
24
+ # seam for tests (patching subprocess.run itself would also intercept the
25
+ # CLI version probes)
26
+ _run = subprocess.run
27
+
28
+
29
+ def source_transcript(session: PairedSession, source: str) -> Path | None:
30
+ sid = getattr(session, f"{source}_session_id")
31
+ if not sid:
32
+ return None
33
+ adapter = get_adapter(source)
34
+ return adapter.transcript_path(session.cwd, sid)
35
+
36
+
37
+ def drain_source(
38
+ store: StateStore, session: PairedSession, source: str,
39
+ *, flush_dangling: bool = False,
40
+ ) -> int:
41
+ """Translate any unsynced tail of `source`'s file into the other file.
42
+ Pure local file I/O. Returns lines consumed. With flush_dangling=True,
43
+ close any still-unpaired tool calls with placeholder results afterwards
44
+ (required when the source is being handed off: both replay APIs reject
45
+ a dangling call)."""
46
+ transcript = source_transcript(session, source)
47
+ if transcript is None:
48
+ return 0
49
+ engine = SyncEngine(store, session, source)
50
+ loop = TailLoop(store, session, source, transcript, engine)
51
+ total = 0
52
+ while True:
53
+ n = loop.drain()
54
+ total += n
55
+ if n == 0:
56
+ break
57
+ if loop.errors:
58
+ raise SyncSetupError("; ".join(loop.errors))
59
+ if flush_dangling:
60
+ engine.flush_dangling(loop.ctx, loop.cursor)
61
+ return total
62
+
63
+
64
+ def fast_forward(store: StateStore, session: PairedSession, source: str) -> None:
65
+ """Mark everything currently in `source`'s file as already-synced."""
66
+ transcript = source_transcript(session, source)
67
+ cursor = store.get_cursor(session.tandem_id, source)
68
+ if transcript is None:
69
+ cursor.byte_offset = 0
70
+ cursor.line_index = 0
71
+ else:
72
+ data = transcript.read_bytes()
73
+ cursor.byte_offset = len(data)
74
+ cursor.line_index = data.count(b"\n")
75
+ cursor.pending.pop("intent", None)
76
+ store.save_cursor(cursor)
77
+
78
+
79
+ def unsynced_lines(session: PairedSession, store: StateStore, source: str) -> int:
80
+ transcript = source_transcript(session, source)
81
+ if transcript is None:
82
+ return 0
83
+ cursor = store.get_cursor(session.tandem_id, source)
84
+ try:
85
+ data = transcript.read_bytes()
86
+ except OSError:
87
+ return 0
88
+ if len(data) <= cursor.byte_offset:
89
+ return 0
90
+ return data[cursor.byte_offset :].count(b"\n")
91
+
92
+
93
+ def switch_session(store: StateStore, session: PairedSession):
94
+ """Flip active/shadow. Returns (new_active, problems-with-new-active-file,
95
+ memory-sync report). Instant: catch-up drain of the old source + cursor
96
+ fast-forward, no bulk re-conversion."""
97
+ from .doctor import validate_transcript
98
+
99
+ old_active, new_active = session.active, session.shadow
100
+
101
+ # If codex never ran (id pending), its shadow file does not exist yet;
102
+ # create it now so the flip has something to resume.
103
+ if new_active == "codex" and not session.codex_session_id:
104
+ _create_codex_shadow_late(store, session)
105
+ session = store.get_session(session.tandem_id) or session
106
+
107
+ drain_source(store, session, old_active, flush_dangling=True)
108
+ fast_forward(store, session, new_active)
109
+ store.set_active(session.tandem_id, new_active)
110
+
111
+ from .memory_sync import sync_memory_files
112
+
113
+ memory_report = sync_memory_files(session.cwd)
114
+
115
+ problems: list[str] = []
116
+ transcript = source_transcript(session, new_active)
117
+ if transcript is None:
118
+ if new_active == "claude" and session.claude_session_id:
119
+ # claude never launched; it will be created fresh on next run
120
+ problems = []
121
+ else:
122
+ problems = ["transcript for newly active harness does not exist yet"]
123
+ else:
124
+ problems = validate_transcript(new_active, transcript,
125
+ getattr(session, f"{new_active}_session_id"))
126
+ return new_active, problems, memory_report
127
+
128
+
129
+ def _create_codex_shadow_late(store: StateStore, session: PairedSession) -> None:
130
+ from .constants import SEED_NOTE
131
+ from .runner import ctx_from_cursor
132
+
133
+ adapter = get_adapter("codex")
134
+ sid = adapter.mint_session_id()
135
+ cursor = store.get_cursor(session.tandem_id, session.active)
136
+ ctx = ctx_from_cursor(session, session.active, cursor)
137
+ ctx.codex_session_id = sid
138
+ note = SEED_NOTE.format(
139
+ tandem_id=session.tandem_id, other=get_adapter(session.active).display_name
140
+ )
141
+ adapter.create_shadow_transcript(session.cwd, sid, ctx, note)
142
+ store.set_native_session_id(session.tandem_id, "codex", sid)
143
+
144
+
145
+ def run_oneoff(
146
+ store: StateStore, session: PairedSession, target: str, prompt: str
147
+ ) -> int:
148
+ """Route one prompt to `target` as a single native non-interactive turn,
149
+ then sync that turn into the other file. Exactly one model (target's) is
150
+ invoked."""
151
+ adapter = get_adapter(target)
152
+ sid = getattr(session, f"{target}_session_id")
153
+
154
+ # Catch up the active side first, then mark the target's whole file as
155
+ # known so only the new turn flows back afterwards. (When target IS the
156
+ # active side there is nothing to fast-forward — its cursor is live.)
157
+ drain_source(store, session, session.active, flush_dangling=True)
158
+ if target != session.active and sid and source_transcript(session, target) is not None:
159
+ fast_forward(store, session, target)
160
+
161
+ started = time.time()
162
+ if target == "codex" and not sid:
163
+ # codex never ran: no session to resume; a fresh exec creates one.
164
+ argv = [adapter.binary, "exec", "--skip-git-repo-check", prompt]
165
+ else:
166
+ argv = adapter.oneoff_argv(sid, prompt)
167
+ code = _run(argv, cwd=session.cwd).returncode
168
+
169
+ if target == "codex" and not sid:
170
+ rollout = await_codex_rollout(session.cwd, started, timeout=10)
171
+ if rollout:
172
+ new_sid = paths.codex_rollout_session_id(rollout)
173
+ if new_sid:
174
+ store.set_native_session_id(session.tandem_id, "codex", new_sid)
175
+ session = store.get_session(session.tandem_id) or session
176
+ fast_forward_to_zero = store.get_cursor(session.tandem_id, "codex")
177
+ fast_forward_to_zero.byte_offset = 0
178
+ fast_forward_to_zero.line_index = 0
179
+ store.save_cursor(fast_forward_to_zero)
180
+
181
+ # Echo suppression (see the module docstring): this drain appends
182
+ # tandem's translation of the target's turn to the OTHER harness's file.
183
+ # Those appends are by construction already represented in the target's
184
+ # file, so the echo side's cursor has to move past them — otherwise its
185
+ # next drain translates them straight back, duplicating call ids and text.
186
+ echo_side = other(target)
187
+ echo_pre_size = _file_size(source_transcript(session, echo_side))
188
+ echo_pre_offset = store.get_cursor(session.tandem_id, echo_side).byte_offset
189
+
190
+ drain_source(store, session, target, flush_dangling=True)
191
+
192
+ # Only when the echo side was fully synced before the drain is everything
193
+ # now in its file known to be ours. If it had an unsynced tail (a
194
+ # concurrent writer), fast-forwarding would swallow a live turn: leave the
195
+ # cursor alone and let the normal drain pick both up.
196
+ if echo_pre_size is not None and echo_pre_offset == echo_pre_size:
197
+ fast_forward(store, session, echo_side)
198
+ return code
199
+
200
+
201
+ def _file_size(path: Path | None) -> int | None:
202
+ if path is None:
203
+ return None
204
+ try:
205
+ return path.stat().st_size
206
+ except OSError:
207
+ return None
tandem/paths.py ADDED
@@ -0,0 +1,92 @@
1
+ """Filesystem locations for tandem and both wrapped harnesses.
2
+
3
+ Everything here was derived by inspecting the files written by the installed
4
+ CLIs (claude 2.1.220, codex-cli 0.145.0) — see docs/formats.md.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import os
10
+ import re
11
+ from pathlib import Path
12
+
13
+
14
+ def tandem_home() -> Path:
15
+ return Path(os.environ.get("TANDEM_HOME", Path.home() / ".tandem"))
16
+
17
+
18
+ def state_db_path() -> Path:
19
+ return tandem_home() / "state.db"
20
+
21
+
22
+ def quarantine_dir(tandem_id: str) -> Path:
23
+ return tandem_home() / "quarantine" / tandem_id
24
+
25
+
26
+ def log_dir() -> Path:
27
+ return tandem_home() / "logs"
28
+
29
+
30
+ # --- Claude Code ------------------------------------------------------------
31
+
32
+ def claude_home() -> Path:
33
+ return Path(os.environ.get("CLAUDE_CONFIG_DIR", Path.home() / ".claude"))
34
+
35
+
36
+ def claude_munge_cwd(cwd: str | Path) -> str:
37
+ """Claude Code names each project dir after the cwd with every character
38
+ outside [A-Za-z0-9] replaced by '-'. Observed: /private/tmp/claude-501/x
39
+ -> -private-tmp-claude-501-x (leading '/' becomes a leading '-',
40
+ pre-existing '-' survive unchanged)."""
41
+ return re.sub(r"[^A-Za-z0-9]", "-", str(cwd))
42
+
43
+
44
+ def claude_project_dir(cwd: str | Path) -> Path:
45
+ return claude_home() / "projects" / claude_munge_cwd(cwd)
46
+
47
+
48
+ def claude_transcript_path(cwd: str | Path, session_id: str) -> Path:
49
+ return claude_project_dir(cwd) / f"{session_id}.jsonl"
50
+
51
+
52
+ # --- Codex ------------------------------------------------------------------
53
+
54
+ def codex_home() -> Path:
55
+ return Path(os.environ.get("CODEX_HOME", Path.home() / ".codex"))
56
+
57
+
58
+ def codex_sessions_dir() -> Path:
59
+ return codex_home() / "sessions"
60
+
61
+
62
+ _ROLLOUT_RE = re.compile(
63
+ r"rollout-\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}-([0-9a-f-]{36})\.jsonl$"
64
+ )
65
+
66
+
67
+ def codex_rollout_session_id(path: Path) -> str | None:
68
+ """The session UUID is embedded in the rollout filename."""
69
+ m = _ROLLOUT_RE.search(path.name)
70
+ return m.group(1) if m else None
71
+
72
+
73
+ def find_codex_rollout(session_id: str) -> Path | None:
74
+ """Locate a rollout file by session id. Filenames embed the id, so this
75
+ never needs to open files."""
76
+ root = codex_sessions_dir()
77
+ if not root.is_dir():
78
+ return None
79
+ for p in root.glob(f"**/rollout-*-{session_id}.jsonl"):
80
+ return p
81
+ return None
82
+
83
+
84
+ def iter_codex_rollouts_newest_first() -> list[Path]:
85
+ root = codex_sessions_dir()
86
+ if not root.is_dir():
87
+ return []
88
+ return sorted(
89
+ root.glob("**/rollout-*.jsonl"),
90
+ key=lambda p: p.stat().st_mtime,
91
+ reverse=True,
92
+ )
tandem/ptyrun.py ADDED
@@ -0,0 +1,99 @@
1
+ """PTY passthrough: run the real harness CLI with its untouched native UX.
2
+
3
+ The child gets a real pty (raw-mode stdin, terminal resize via SIGWINCH
4
+ forwarding, control bytes delivered through the pty line discipline). Tandem
5
+ never reads meaning from the terminal stream — transcript files are the
6
+ source of truth — so this module just pumps bytes.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import io
12
+ import os
13
+ import select
14
+ import signal
15
+ import struct
16
+ import subprocess
17
+ import sys
18
+ import termios
19
+ import tty
20
+
21
+ from ptyprocess import PtyProcess
22
+
23
+
24
+ def _winsize(fd: int) -> tuple[int, int]:
25
+ try:
26
+ import fcntl
27
+
28
+ packed = fcntl.ioctl(fd, termios.TIOCGWINSZ, struct.pack("HHHH", 0, 0, 0, 0))
29
+ rows, cols = struct.unpack("HHHH", packed)[:2]
30
+ return (rows or 24, cols or 80)
31
+ except OSError:
32
+ return (24, 80)
33
+
34
+
35
+ def run_in_pty(argv: list[str], cwd: str | None = None, env: dict | None = None) -> int:
36
+ """Run argv on a pty, mirroring the controlling terminal. Returns the
37
+ child's exit status. Falls back to a plain subprocess when stdin is not a
38
+ tty (tests, pipes)."""
39
+ try:
40
+ stdin_fd = sys.stdin.fileno()
41
+ is_tty = os.isatty(stdin_fd)
42
+ except (ValueError, OSError, io.UnsupportedOperation):
43
+ is_tty = False
44
+ if not is_tty:
45
+ return subprocess.run(argv, cwd=cwd, env=env).returncode
46
+
47
+ child = PtyProcess.spawn(
48
+ argv, cwd=cwd, env=env or dict(os.environ), dimensions=_winsize(stdin_fd)
49
+ )
50
+
51
+ def on_winch(signum, frame):
52
+ rows, cols = _winsize(stdin_fd)
53
+ try:
54
+ child.setwinsize(rows, cols)
55
+ except Exception:
56
+ pass
57
+
58
+ def on_term(signum, frame):
59
+ try:
60
+ child.kill(signal.SIGTERM)
61
+ except Exception:
62
+ pass
63
+
64
+ old_winch = signal.signal(signal.SIGWINCH, on_winch)
65
+ old_term = signal.signal(signal.SIGTERM, on_term)
66
+ old_attrs = termios.tcgetattr(stdin_fd)
67
+ try:
68
+ tty.setraw(stdin_fd)
69
+ stdin_open = True
70
+ while child.isalive():
71
+ rlist = [child.fd] + ([stdin_fd] if stdin_open else [])
72
+ try:
73
+ ready, _, _ = select.select(rlist, [], [], 0.2)
74
+ except InterruptedError:
75
+ continue # signal (e.g. SIGWINCH) — loop again
76
+ if child.fd in ready:
77
+ try:
78
+ data = child.read(65536)
79
+ except EOFError:
80
+ break
81
+ if not data:
82
+ break
83
+ os.write(sys.stdout.fileno(), data)
84
+ if stdin_open and stdin_fd in ready:
85
+ data = os.read(stdin_fd, 65536)
86
+ if data:
87
+ child.write(data)
88
+ else:
89
+ child.sendeof()
90
+ stdin_open = False
91
+ finally:
92
+ termios.tcsetattr(stdin_fd, termios.TCSADRAIN, old_attrs)
93
+ signal.signal(signal.SIGWINCH, old_winch)
94
+ signal.signal(signal.SIGTERM, old_term)
95
+ try:
96
+ child.wait()
97
+ except Exception:
98
+ pass
99
+ return child.exitstatus if child.exitstatus is not None else 1