agent-session-relay 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.
@@ -0,0 +1,3 @@
1
+ """Agent-Session-Relay: human review sessions built on Git."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,3 @@
1
+ from .cli import main
2
+
3
+ raise SystemExit(main())
@@ -0,0 +1,211 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import json
5
+ import sys
6
+
7
+ from . import __version__
8
+ from .core.errors import RelayError
9
+ from .core.git import Git
10
+ from .core.session import Relay
11
+ from .integrations.kiro.adapter import install, run_hook
12
+
13
+
14
+ def parser() -> argparse.ArgumentParser:
15
+ root = argparse.ArgumentParser(
16
+ prog="relay", description="Agent-Session-Relay: Git staging is human review."
17
+ )
18
+ root.add_argument("--version", action="version", version=f"Agent-Session-Relay {__version__}")
19
+ commands = root.add_subparsers(dest="command", required=True)
20
+ commands.add_parser("start", help="start from a clean Git workspace")
21
+ status = commands.add_parser("status", help="show session and review state")
22
+ status.add_argument("--json", action="store_true", help="output structured state")
23
+ commands.add_parser("suspend", help="save the full review state and return to normal Git")
24
+ resume = commands.add_parser("resume", help="restore a suspended session")
25
+ resume.add_argument("session", nargs="?", help="session ID or unambiguous prefix")
26
+ finish = commands.add_parser(
27
+ "finish", help="create one public result commit from reviewed code"
28
+ )
29
+ finish.add_argument("-m", "--message", help="result commit message")
30
+ commands.add_parser(
31
+ "abort", help="terminate after TWO confirmations, preserving a recovery branch"
32
+ )
33
+ listing = commands.add_parser(
34
+ "list", help="list active and suspended sessions in this worktree"
35
+ )
36
+ listing.add_argument("--json", action="store_true")
37
+ commands.add_parser(
38
+ "recover", help="recover an interrupted Relay operation, preserving current code"
39
+ )
40
+ agent = commands.add_parser("agent", help="agent-facing semantic inspection")
41
+ agent_commands = agent.add_subparsers(dest="agent_command", required=True)
42
+ agent_commands.add_parser("status", help="output agent-readable session state as JSON")
43
+ diff = agent_commands.add_parser("diff", help="inspect review/provenance patches")
44
+ diff.add_argument("kind", choices=("reviewed", "human", "pending"))
45
+ diff.add_argument("--name-only", action="store_true", help="only output involved file names")
46
+ diff.add_argument("-z", "--null", action="store_true", help="NUL delimit --name-only output")
47
+ kiro = commands.add_parser("kiro", help="Kiro integration")
48
+ kiro_commands = kiro.add_subparsers(dest="kiro_command", required=True)
49
+ installer = kiro_commands.add_parser("install", help="install standalone Kiro lifecycle hooks")
50
+ scope = installer.add_mutually_exclusive_group(required=True)
51
+ scope.add_argument("--global", dest="global_scope", action="store_true")
52
+ scope.add_argument("--project", action="store_true")
53
+ installer.add_argument(
54
+ "--force", action="store_true", help="replace customized Relay hook configuration"
55
+ )
56
+ hook = kiro_commands.add_parser("hook", help="adapter entry point called by Kiro")
57
+ hook.add_argument("event", choices=("prompt-submit", "agent-stop", "pre-tool-use"))
58
+ return root
59
+
60
+
61
+ def describe_origin(origin: dict) -> str:
62
+ return (
63
+ origin["ref"].removeprefix("refs/heads/")
64
+ if origin["ref"]
65
+ else "detached " + origin["commit"][:12]
66
+ )
67
+
68
+
69
+ def confirm_abort(session_id: str) -> bool:
70
+ print(
71
+ "WARNING 1/2: Aborting ends this Relay session permanently; it cannot be resumed.\n"
72
+ "Relay will delete its intermediate checkpoints, snapshots, and review/provenance state.\n"
73
+ "Your current code will be preserved first in a recovery branch, including staged,\n"
74
+ "unstaged, and non-ignored untracked project files."
75
+ )
76
+ try:
77
+ if input("Type 'abort' to acknowledge this warning: ").strip() != "abort":
78
+ return False
79
+ print(
80
+ "\nWARNING 2/2: This removes the session's ability to resume and its intermediate\n"
81
+ "review history. Current workspace code will be saved in a single recovery commit\n"
82
+ f"at relay/aborted/{session_id} before cleanup; the staging distinction will be lost."
83
+ )
84
+ return (
85
+ input("Type 'preserve and abort' to confirm the cleanup: ").strip()
86
+ == "preserve and abort"
87
+ )
88
+ except EOFError:
89
+ return False
90
+
91
+
92
+ def execute(args, paths: list[str]) -> int:
93
+ if args.command == "kiro":
94
+ if args.kiro_command == "hook":
95
+ return run_hook(args.event)
96
+ path = install(global_scope=args.global_scope, force=args.force)
97
+ print(
98
+ f"Kiro integration installed at {path}\n"
99
+ "Ensure `relay` is on Kiro's PATH and open a new Kiro session.\n"
100
+ "Without an active Relay session, these hooks are silent and inert."
101
+ )
102
+ if args.project:
103
+ print("Commit the project hook file before `relay start`, or install globally instead.")
104
+ return 0
105
+ relay = Relay(Git())
106
+ if args.command == "start":
107
+ session = relay.start()
108
+ print(
109
+ f"Relay session started: {session['id']}\nBase: {session['base_commit']}\n"
110
+ "Review with normal Git staging; send a prompt in Kiro to hand off to the agent."
111
+ )
112
+ elif args.command in ("status", "agent"):
113
+ if args.command == "agent" and args.agent_command == "diff":
114
+ if args.null and not args.name_only:
115
+ raise RelayError("--null requires --name-only.")
116
+ sys.stdout.buffer.write(
117
+ relay.diff(args.kind, paths, name_only=args.name_only, null=args.null)
118
+ )
119
+ else:
120
+ status = relay.status()
121
+ if args.command == "agent" or args.json:
122
+ print(json.dumps(status, indent=2))
123
+ elif not status["active"]:
124
+ print(f"Relay is {status['lifecycle']}.")
125
+ if status["suspended_sessions"]:
126
+ print("Suspended: " + ", ".join(status["suspended_sessions"]))
127
+ print("Use `relay resume` to continue.")
128
+ else:
129
+ print(
130
+ f"Session: {status['session']} "
131
+ f"({status['lifecycle']}, {status['phase']} turn)\n"
132
+ f"Base: {status['base_commit']}\nReviewed: {status['reviewed_checkpoint']}\n"
133
+ f"Staged approvals: {'yes' if status['staged_approvals'] else 'none'}\n"
134
+ "Unstaged / untracked proposals: "
135
+ f"{'yes' if status['unstaged_changes'] else 'none'}\n"
136
+ "Turn provenance: "
137
+ f"{'available' if status['provenance']['available'] else 'none yet'}"
138
+ )
139
+ elif args.command == "list":
140
+ relay.store.assert_ready()
141
+ sessions = list(relay.store.load()["sessions"].values())
142
+ if args.json:
143
+ print(json.dumps(sessions, indent=2))
144
+ elif not sessions:
145
+ print("No Relay sessions in this worktree.")
146
+ else:
147
+ for session in sorted(sessions, key=lambda s: s["id"]):
148
+ print(
149
+ f"{session['id']} {session['state']} base={session['base_commit'][:12]} "
150
+ f"turns={session['turn']}"
151
+ )
152
+ elif args.command == "suspend":
153
+ session, origin = relay.suspend()
154
+ print(
155
+ f"Relay session suspended: {session['id']}\nReturned to {describe_origin(origin)}.\n"
156
+ "Review state is saved. Hooks are now inert. Use `relay resume` to continue."
157
+ )
158
+ elif args.command == "resume":
159
+ session = relay.resume(args.session)
160
+ print(f"Relay session resumed: {session['id']}\nStaged and unstaged review state restored.")
161
+ elif args.command == "finish":
162
+ branch = relay.finish(args.message)
163
+ print(
164
+ f"Relay session finished. Switched to {branch}.\n"
165
+ "One result commit; its only parent is the immutable session base.\n"
166
+ "Use normal Git to rebase, merge, or cherry-pick the result when ready."
167
+ )
168
+ elif args.command == "abort":
169
+ relay.store.assert_ready()
170
+ session = relay.active(relay.store.load())
171
+ if not confirm_abort(session["id"]):
172
+ print("Abort cancelled. Relay session and code were not changed.")
173
+ return 1
174
+ branch, origin = relay.abort(session["id"])
175
+ print(
176
+ "Relay session aborted.\n\nYour workspace at the time of abort was preserved at:\n\n"
177
+ f" {branch}\n\nRelay's intermediate review/provenance state has been removed.\n"
178
+ f"Returned to {describe_origin(origin)}.\n\n"
179
+ "If you are certain you no longer need the recovery snapshot, delete it\n"
180
+ f"later with normal Git, for example:\n\n git branch -D {branch}"
181
+ )
182
+ elif args.command == "recover":
183
+ with relay.store.lock():
184
+ branch = relay.store.recover()
185
+ print(
186
+ f"Previous Relay state restored. Code from before recovery was preserved at {branch}."
187
+ )
188
+ return 0
189
+
190
+
191
+ def main(argv: list[str] | None = None) -> int:
192
+ argv = list(sys.argv[1:] if argv is None else argv)
193
+ paths = []
194
+ # Everything after -- is a literal path, even a name such as --name-only.
195
+ if "--" in argv and argv[:2] == ["agent", "diff"]:
196
+ boundary = argv.index("--")
197
+ paths, argv = argv[boundary + 1 :], argv[:boundary]
198
+ args = parser().parse_args(argv)
199
+ try:
200
+ return execute(args, paths)
201
+ except BrokenPipeError:
202
+ return 0
203
+ except (RelayError, OSError) as exc:
204
+ print(f"relay: {exc}", file=sys.stderr)
205
+ return 2 if args.command == "kiro" and args.kiro_command == "hook" else 1
206
+ except KeyboardInterrupt:
207
+ print(
208
+ "\nrelay: interrupted; any incomplete transition can be restored with `relay recover`.",
209
+ file=sys.stderr,
210
+ )
211
+ return 130
@@ -0,0 +1 @@
1
+ """Harness-independent Git, session, and provenance implementation."""
@@ -0,0 +1,2 @@
1
+ class RelayError(Exception):
2
+ """An actionable error that can be displayed without a traceback."""
@@ -0,0 +1,345 @@
1
+ """Git plumbing. Never invokes a shell or edits a user's branch in place."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import stat
7
+ import subprocess
8
+ import tempfile
9
+ from contextlib import contextmanager
10
+ from pathlib import Path
11
+
12
+ from .errors import RelayError
13
+
14
+
15
+ class Git:
16
+ def __init__(self, cwd: Path | str = "."):
17
+ self.cwd = Path(cwd).absolute()
18
+ self.root = self.cwd
19
+ try:
20
+ self.root = Path(self.text("rev-parse", "--show-toplevel"))
21
+ self.git_dir = Path(self.text("rev-parse", "--absolute-git-dir"))
22
+ except RelayError as exc:
23
+ raise RelayError("Run Relay inside a non-bare Git working tree.") from exc
24
+
25
+ def run(
26
+ self, *args: str, data: bytes | None = None, env: dict | None = None, check: bool = True
27
+ ) -> subprocess.CompletedProcess:
28
+ process_env = os.environ.copy()
29
+ # A hook's inherited routing must not redirect operations into another index/repository.
30
+ for key in (
31
+ "GIT_DIR",
32
+ "GIT_WORK_TREE",
33
+ "GIT_INDEX_FILE",
34
+ "GIT_COMMON_DIR",
35
+ "GIT_NAMESPACE",
36
+ "GIT_PREFIX",
37
+ "GIT_EXTERNAL_DIFF",
38
+ "GIT_DIFF_OPTS",
39
+ "GIT_LITERAL_PATHSPECS",
40
+ "GIT_GLOB_PATHSPECS",
41
+ "GIT_NOGLOB_PATHSPECS",
42
+ "GIT_ICASE_PATHSPECS",
43
+ ):
44
+ process_env.pop(key, None)
45
+ process_env.update(
46
+ {
47
+ "GIT_OPTIONAL_LOCKS": "0",
48
+ "GIT_TERMINAL_PROMPT": "0",
49
+ "GIT_PAGER": "cat",
50
+ "LC_ALL": "C",
51
+ }
52
+ )
53
+ if env:
54
+ process_env.update(env)
55
+ try:
56
+ result = subprocess.run(
57
+ [
58
+ "git",
59
+ "--no-pager",
60
+ "-c",
61
+ "core.hooksPath=" + os.devnull,
62
+ "-c",
63
+ "gc.auto=0",
64
+ "-c",
65
+ "maintenance.auto=false",
66
+ *args,
67
+ ],
68
+ cwd=self.root,
69
+ input=data,
70
+ capture_output=True,
71
+ env=process_env,
72
+ check=False,
73
+ )
74
+ except FileNotFoundError as exc:
75
+ raise RelayError("Git is required and must be on PATH.") from exc
76
+ if check and result.returncode:
77
+ detail = result.stderr.decode("utf-8", "replace").strip()
78
+ raise RelayError(f"Git {args[0]} failed: {detail}")
79
+ return result
80
+
81
+ def text(self, *args: str, **kwargs) -> str:
82
+ return os.fsdecode(self.run(*args, **kwargs).stdout).removesuffix("\n")
83
+
84
+ def resolve(self, ref: str) -> str:
85
+ return self.text("rev-parse", "--verify", ref)
86
+
87
+ def head(self) -> dict:
88
+ return {
89
+ "commit": self.resolve("HEAD^{commit}"),
90
+ "ref": self.text("symbolic-ref", "-q", "HEAD", check=False) or None,
91
+ }
92
+
93
+ def set_head(self, target: dict) -> None:
94
+ if target["ref"]:
95
+ self.run("symbolic-ref", "HEAD", target["ref"])
96
+ else:
97
+ self.run("update-ref", "--no-deref", "HEAD", target["commit"])
98
+
99
+ def index_path(self) -> Path:
100
+ path = Path(self.text("rev-parse", "--git-path", "index"))
101
+ return path if path.is_absolute() else self.root / path
102
+
103
+ def index_bytes(self) -> bytes:
104
+ path = self.index_path()
105
+ return path.read_bytes() if path.exists() else b""
106
+
107
+ def restore_index(self, contents: bytes) -> None:
108
+ path = self.index_path()
109
+ lock = path.with_name(path.name + ".lock")
110
+ try:
111
+ with lock.open("xb") as stream:
112
+ stream.write(contents)
113
+ stream.flush()
114
+ os.fsync(stream.fileno())
115
+ if contents:
116
+ os.replace(lock, path)
117
+ else:
118
+ path.unlink(missing_ok=True)
119
+ lock.unlink()
120
+ except FileExistsError as exc:
121
+ raise RelayError(
122
+ "Git index is locked by another process; retry after it exits."
123
+ ) from exc
124
+
125
+ def assert_stable(self) -> None:
126
+ markers = (
127
+ "MERGE_HEAD",
128
+ "CHERRY_PICK_HEAD",
129
+ "REVERT_HEAD",
130
+ "rebase-merge",
131
+ "rebase-apply",
132
+ "sequencer",
133
+ "BISECT_START",
134
+ "index.lock",
135
+ "HEAD.lock",
136
+ )
137
+ for marker in markers:
138
+ path = Path(self.text("rev-parse", "--git-path", marker))
139
+ if not path.is_absolute():
140
+ path = self.root / path
141
+ if path.exists():
142
+ raise RelayError(f"Git operation or lock in progress ({marker}); finish it first.")
143
+ if self.run("ls-files", "--unmerged", "-z").stdout:
144
+ raise RelayError("Resolve the unmerged Git index before using Relay.")
145
+ if self.text("config", "--bool", "core.sparseCheckout", check=False) == "true":
146
+ raise RelayError("Sparse checkouts are not supported; use a full working tree.")
147
+ for entry in self.run("ls-files", "-v", "-z").stdout.split(b"\0"):
148
+ if entry and (entry[:1].islower() or entry[:1] == b"S"):
149
+ raise RelayError(
150
+ "Clear assume-unchanged/skip-worktree index flags before using Relay."
151
+ )
152
+ self.assert_no_gitlinks(self.index_tree())
153
+
154
+ def assert_clean(self) -> None:
155
+ self.assert_stable()
156
+ if self.run(
157
+ "status", "--porcelain=v1", "-z", "--untracked-files=all", "--ignore-submodules=none"
158
+ ).stdout:
159
+ raise RelayError(
160
+ "A clean workspace is required: commit, stash, or discard staged, "
161
+ "unstaged, and non-ignored untracked changes first."
162
+ )
163
+
164
+ def assert_no_gitlinks(self, tree: str) -> None:
165
+ if any(
166
+ entry.startswith(b"160000 ")
167
+ for entry in self.run("ls-tree", "-r", "-z", tree).stdout.split(b"\0")
168
+ ):
169
+ raise RelayError(
170
+ "Submodules and embedded repositories cannot be fully snapshotted; "
171
+ "use a working tree without them."
172
+ )
173
+
174
+ @contextmanager
175
+ def temporary_index(self):
176
+ with tempfile.TemporaryDirectory(prefix="relay-index-") as directory:
177
+ path = Path(directory) / "index"
178
+ contents = self.index_bytes()
179
+ if contents:
180
+ path.write_bytes(contents)
181
+ env = {"GIT_INDEX_FILE": str(path)}
182
+ # Expand split indexes so saved index blobs don't depend on sharedindex.* lifetimes.
183
+ self.run("update-index", "--no-split-index", env=env)
184
+ yield path, env
185
+
186
+ def index_tree(self) -> str:
187
+ # write-tree may update the cache-tree extension. Keep even inspection off the real index.
188
+ with self.temporary_index() as (_, env):
189
+ return self.text("write-tree", env=env)
190
+
191
+ def normalized_index(self) -> bytes:
192
+ with self.temporary_index() as (path, _):
193
+ return path.read_bytes()
194
+
195
+ def workspace_tree(self, baseline: str | None = None) -> str:
196
+ """Snapshot tracked + non-ignored untracked files without changing the staging UI."""
197
+ baseline = baseline or self.resolve("HEAD")
198
+ with self.temporary_index() as (_, env):
199
+ indexed = {
200
+ entry.split(b"\t", 1)[1]
201
+ for entry in self.run("ls-files", "--stage", "-z", env=env).stdout.split(b"\0")
202
+ if entry
203
+ }
204
+ # A staged deletion can leave a now-ignored file on disk. It is still session code.
205
+ for entry in self.run("ls-tree", "-r", "-z", baseline).stdout.split(b"\0"):
206
+ if not entry:
207
+ continue
208
+ meta, name = entry.split(b"\t", 1)
209
+ mode, kind, oid = meta.split()
210
+ if name in indexed or kind != b"blob":
211
+ continue
212
+ path = self.root / os.fsdecode(name)
213
+ try:
214
+ file_mode = path.lstat().st_mode
215
+ except (FileNotFoundError, NotADirectoryError):
216
+ continue
217
+ if stat.S_ISREG(file_mode) or stat.S_ISLNK(file_mode):
218
+ self.run(
219
+ "update-index",
220
+ "--add",
221
+ "--replace",
222
+ "--cacheinfo",
223
+ os.fsdecode(mode),
224
+ os.fsdecode(oid),
225
+ os.fsdecode(name),
226
+ env=env,
227
+ )
228
+ self.run("add", "--all", "--", ".", env=env)
229
+ tree = self.text("write-tree", env=env)
230
+ self.assert_no_gitlinks(tree)
231
+ return tree
232
+
233
+ def tree(self, commit: str) -> str:
234
+ return self.resolve(commit + "^{tree}")
235
+
236
+ def commit(self, tree: str, parent: str, message: str, *, internal: bool = True) -> str:
237
+ env = None
238
+ if internal:
239
+ env = {
240
+ "GIT_AUTHOR_NAME": "Agent-Session-Relay",
241
+ "GIT_AUTHOR_EMAIL": "relay@localhost",
242
+ "GIT_COMMITTER_NAME": "Agent-Session-Relay",
243
+ "GIT_COMMITTER_EMAIL": "relay@localhost",
244
+ }
245
+ return self.text(
246
+ "commit-tree", tree, "-p", parent, data=(message.rstrip() + "\n").encode(), env=env
247
+ )
248
+
249
+ def pin(self, ref: str, oid: str, *, create: bool = False) -> None:
250
+ args = (ref, oid, "") if create else (ref, oid)
251
+ self.run("update-ref", *args)
252
+
253
+ def refs(self, prefix: str) -> dict:
254
+ lines = self.text("for-each-ref", "--format=%(refname) %(objectname)", prefix)
255
+ return dict(line.split(" ", 1) for line in lines.splitlines())
256
+
257
+ def delete_refs(self, refs: dict) -> None:
258
+ if refs:
259
+ data = "".join(f"delete {ref} {oid}\n" for ref, oid in refs.items()).encode()
260
+ self.run("update-ref", "--stdin", data=data)
261
+
262
+ def paths(self, tree: str) -> set[bytes]:
263
+ return set(self.run("ls-tree", "-r", "--name-only", "-z", tree).stdout.split(b"\0")) - {b""}
264
+
265
+ def materialize(self, source: str, target: str) -> None:
266
+ """Replace captured project files, preserving unrelated ignored files."""
267
+ target_paths = self.paths(target)
268
+ source_paths = self.paths(source)
269
+ target_parents = {
270
+ b"/".join(parts[:i])
271
+ for path in target_paths
272
+ for parts in [path.split(b"/")]
273
+ for i in range(1, len(parts))
274
+ }
275
+ ignored = self.run("ls-files", "--others", "--ignored", "--exclude-standard", "-z").stdout
276
+ for name in ignored.split(b"\0"):
277
+ name = name.rstrip(b"/")
278
+ if not name or name in source_paths:
279
+ continue
280
+ parts = name.split(b"/")
281
+ if (
282
+ name in target_paths
283
+ or name in target_parents
284
+ or any(b"/".join(parts[:i]) in target_paths for i in range(1, len(parts)))
285
+ ):
286
+ raise RelayError(
287
+ f"An ignored file would be overwritten: {os.fsdecode(name)}. "
288
+ "Move it out of the way and retry."
289
+ )
290
+ saved_index = self.index_bytes()
291
+ try:
292
+ # Make captured new files tracked so read-tree removes them when leaving the session.
293
+ self.run("read-tree", source)
294
+ self.run("update-index", "--refresh")
295
+ self.run("read-tree", "-m", "-u", source, target)
296
+ except BaseException:
297
+ self.restore_index(saved_index)
298
+ raise
299
+
300
+ def available_origin(self, origin: dict) -> dict:
301
+ ref = origin["ref"]
302
+ if ref:
303
+ result = self.run("rev-parse", "--verify", ref + "^{commit}", check=False)
304
+ occupied = False
305
+ records = self.run("worktree", "list", "--porcelain", "-z").stdout.split(b"\0\0")
306
+ for record in records:
307
+ fields = record.split(b"\0")
308
+ if b"branch " + os.fsencode(ref) in fields:
309
+ location = next((f[9:] for f in fields if f.startswith(b"worktree ")), b"")
310
+ if Path(os.fsdecode(location)).resolve() != self.root.resolve():
311
+ occupied = True
312
+ if result.returncode == 0 and not occupied:
313
+ return {"ref": ref, "commit": os.fsdecode(result.stdout).strip()}
314
+ return {"ref": None, "commit": origin["commit"]}
315
+
316
+ def diff(
317
+ self,
318
+ left: str,
319
+ right: str,
320
+ paths: list[str],
321
+ *,
322
+ name_only: bool = False,
323
+ null: bool = False,
324
+ ) -> bytes:
325
+ filters = []
326
+ for name in paths:
327
+ path = Path(os.path.abspath(self.cwd / name))
328
+ try:
329
+ filters.append(str(path.relative_to(self.root)))
330
+ except ValueError as exc:
331
+ raise RelayError(f"Diff path is outside the repository: {name}") from exc
332
+ args = [
333
+ "--literal-pathspecs",
334
+ "diff",
335
+ "--no-ext-diff",
336
+ "--no-textconv",
337
+ "--no-color",
338
+ "--no-renames",
339
+ "--src-prefix=a/",
340
+ "--dst-prefix=b/",
341
+ ]
342
+ args += ["--name-only"] if name_only else ["--binary", "--full-index"]
343
+ if null:
344
+ args.append("-z")
345
+ return self.run(*args, left, right, "--", *filters).stdout