diff --git a/README.md b/README.md index 30cc1f54..45a95d88 100644 --- a/README.md +++ b/README.md @@ -161,8 +161,39 @@ explicit `--reply-to` or configured reply host remains unchanged, and actual remote sends continue to advertise an SSH route. Repeat `--host` to operate on several SSH destinations. `--json` is available on -`list`, `send`, and `update`; v0.6 retains command-specific response shapes rather -than a uniform envelope. See [#29](https://github.com/abruption/session-peer/issues/29). +`list`, `send`, and `update`. + +### JSON response contract + +Every JSON result object starts with the same schema-versioned envelope: + +```json +{ + "schemaVersion": 1, + "ok": true, + "host": "mac-mini.example.ts.net", + "command": "list", + "sessions": [], + "version": "0.6.2" +} +``` + +- `schemaVersion` versions the common envelope. Command-specific nested schemas + such as `clientUpdate` and `codexHomeResolution` carry their own versions. +- `ok` is present on every success and failure. A nonzero process exit can still + contain successful results for other hosts. +- `host` identifies the destination to which that result applies. Local results + use the OS hostname. Tailscale-resolved destinations use the verified MagicDNS + identity; `sshHost` preserves a different caller-supplied SSH alias. +- `command` is `list`, `send`, or `update`. Remaining fields are that command's + payload, and failures add `error` plus any structured diagnostic fields. + +A local or one-host invocation emits one object. Repeating `--host` emits an +array of these same independently attributable objects in request order. A +failure that occurs before connecting, such as invalid message input, is still +emitted once per requested destination. This cardinality is shared by all three +commands, so consumers can branch only on object versus array and then use the +same envelope fields. Normal commands read a dedicated 24-hour update cache. A missing, expired, or invalid cache starts one detached best-effort GitHub refresh and never delays or @@ -187,7 +218,7 @@ Human output gets the same short guidance on stderr. The field is omitted when the client is current, the cache is unavailable or stale, the host is offline, or notices are disabled, so absence alone does not prove the client is current. For multi-host commands the fact remains scoped to the one invoking CLI and is -copied into each existing result object; destination `remoteVersion` fields keep +copied into each result object; destination `remoteVersion` fields keep their separate meaning. Remote subprocesses do not perform their own refresh. When local `tailscale status --json` identifies a `--host` by device hostname, @@ -377,15 +408,17 @@ session-peer portability policy rather than a measured Codex server limit. NUL characters cannot be passed as CLI arguments. `--dry-run` verifies the executable and saved target without queueing but cannot guarantee a later submission will succeed. -Local Codex list JSON retains `sessions` and `version`, and adds the resolved -absolute `codexHome` at the top level. Each entry has +Local Codex list JSON uses the common response envelope and includes `sessions`, +`version`, and the resolved absolute `codexHome`. Each session entry has `agent`, `id`, `name` (first line, at most 120 characters), `cwd`, `updatedAt` -(Unix seconds), and `archived`. Remote list JSON is an array of per-host results, -even for one host; each successful result includes `host`, `sessions`, `version`, `codexHome` -and an optional `remoteVersion` for an installed standalone copy. +(Unix seconds), and `archived`. Each successful remote result includes +`sessions`, `version`, `codexHome`, and an optional `remoteVersion` for an +installed standalone copy. One remote host returns an object; repeated hosts +return an array. -Codex send JSON retains `target: {agent, id}`, `status: queued` (or `validated` -under dry-run), `ok`, `chars`, `dryRun`, and optional `queueId`. It adds: +Within the common envelope, Codex send JSON includes `target: {agent, id}`, +`status: queued` (or `validated` under dry-run), `chars`, `dryRun`, and optional +`queueId`. It also includes: - `codexHome`: the resolved absolute destination home, not a sender-side guess. - `codexHomeResolution`: schema-versioned `status`, `selected`, `reason`, and @@ -395,15 +428,14 @@ under dry-run), `ok`, `chars`, `dryRun`, and optional `queueId`. It adds: - `submitted`: `true` only after successful queue CLI completion, `false` for dry-run. - `consumptionConfirmed`: always `false`; neither queued nor validated establishes consumption. -Errors keep the existing `{ok: false, error}` fields (plus host on remote results) -and add `codexHomeResolution` when home evidence caused the failure. +Errors set the common envelope's `ok` to `false`, add `error`, and include +`codexHomeResolution` when home evidence caused the failure. A timeout has an unknown submission outcome; missing `submitted` on an error must not be interpreted as proof that nothing was queued. Listing results do -not describe a submission and have no submission/consumption fields. The changes -are additive, not the cross-command envelope redesign tracked in [#29](https://github.com/abruption/session-peer/issues/29). +not describe a submission and have no submission/consumption fields. -A remote send to one host returns a flat object with `host`; multiple hosts return an array. -Claude output stays compatible. When `CODEX_THREAD_ID` (or the compatibility +For every command, one remote host returns a flat object and multiple hosts +return an array. When `CODEX_THREAD_ID` (or the compatibility fallback `CODEX_SESSION_ID`) is present, the message envelope and reply command identify the originating Codex thread. diff --git a/session_peer.py b/session_peer.py index 48cb686f..ab887dd5 100644 --- a/session_peer.py +++ b/session_peer.py @@ -1309,8 +1309,38 @@ def render_sessions(sessions: list[dict], where: str) -> str: return f"Sessions on {where}:\n" + "\n".join(lines) +JSON_RESPONSE_SCHEMA_VERSION = 1 + + +def local_host() -> str: + """Stable, network-independent identity for results produced locally.""" + return socket.gethostname() + + +def json_result(command: str, payload: dict | None = None, *, + host: str | None = None, ok: bool | None = None) -> dict: + """Build one command result with the common machine-readable envelope.""" + detail = dict(payload or {}) + payload_ok = detail.pop("ok", True) + payload_host = detail.pop("host", None) + for reserved in ("schemaVersion", "command"): + detail.pop(reserved, None) + return { + "schemaVersion": JSON_RESPONSE_SCHEMA_VERSION, + "ok": bool(payload_ok if ok is None else ok), + "host": host or payload_host or local_host(), + "command": command, + **detail, + } + + +def one_or_many(results: list[dict]) -> dict | list[dict]: + """Keep one destination flat; use an array only for repeated --host.""" + return results[0] if len(results) == 1 else results + + def with_client_update(payload: dict | list[dict]) -> dict | list[dict]: - """Attach the invoking client's cached update fact without changing shapes.""" + """Attach the invoking client's cached update fact to each result object.""" if _CLIENT_UPDATE_NOTICE is None: return payload if isinstance(payload, list): @@ -1322,8 +1352,14 @@ def with_client_update(payload: dict | list[dict]) -> dict | list[dict]: return {**payload, "clientUpdate": dict(_CLIENT_UPDATE_NOTICE)} -def emit(as_json: bool, payload: dict, human: str) -> None: - print(json.dumps(with_client_update(payload), ensure_ascii=False) if as_json else human) +def emit(as_json: bool, payload: dict, human: str, *, command: str, + host: str | None = None, ok: bool | None = None) -> None: + result = json_result(command, payload, host=host, ok=ok) + print(json.dumps(with_client_update(result), ensure_ascii=False) if as_json else human) + + +def emit_json_results(results: list[dict]) -> None: + print(json.dumps(with_client_update(one_or_many(results)), ensure_ascii=False)) def emit_human_update_notice() -> None: @@ -1383,7 +1419,12 @@ def cmd_list(args: argparse.Namespace) -> int: home_info = {"codexHome": str(codex_home(args))} if is_codex else {} if is_codex: human += f"\nCodex home: {home_info['codexHome']} (selected home only)." - emit(args.json, {"sessions": sessions, "version": __version__, **home_info}, human) + emit( + args.json, + {"sessions": sessions, "version": __version__, **home_info}, + human, + command="list", + ) return 0 exit_code = 0 @@ -1410,13 +1451,13 @@ def cmd_list(args: argparse.Namespace) -> int: f"{shown_host} runs session-peer {remote_version}; this machine has {__version__}." f"\nUpdate it with: session-peer update --host {requested_host}\n\n{human}" ) - host_result = { + host_result = json_result("list", { **host_metadata(requested_host, host), **ssh_info, "sessions": sessions, "version": __version__, **({"remoteVersion": remote_version} if remote_version else {}), **({"codexHome": result["codexHome"]} if is_codex and "codexHome" in result else {}), - } + }) all_results.append(host_result) if not args.json: if len(all_results) > 1: @@ -1424,13 +1465,16 @@ def cmd_list(args: argparse.Namespace) -> int: print(human) except CcPeerError as exc: exit_code = EXIT_ERROR - all_results.append({**host_metadata(requested_host, host), - "ok": False, "error": str(exc), **exc.details}) + all_results.append(json_result( + "list", + {**host_metadata(requested_host, host), "error": str(exc), **exc.details}, + ok=False, + )) if not args.json: print(f"session-peer: {requested_host}: {exc}", file=sys.stderr) if args.json: - print(json.dumps(with_client_update(all_results), ensure_ascii=False)) + emit_json_results(all_results) return exit_code @@ -1772,6 +1816,7 @@ def cmd_update(args: argparse.Namespace) -> int: {"current": __version__, "latest": tag, "outdated": outdated, "managedBy": "package-manager", "updateCommand": command}, f"session-peer {__version__} — {state}. Upgrade with: {command}", + command="update", ) return 0 emit( @@ -1779,6 +1824,7 @@ def cmd_update(args: argparse.Namespace) -> int: {"current": __version__, "updated": False, "managedBy": "package-manager", "updateCommand": command}, f"This installation is package-managed. Upgrade with: {command}", + command="update", ) return 0 if args.host: @@ -1801,36 +1847,45 @@ def cmd_update(args: argparse.Namespace) -> int: state = "up to date" else: state = f"{there} → {__version__} available" - all_results.append({**host_metadata(requested_host, host), **ssh_info, - "remoteVersion": there, "current": __version__, - "outdated": there != __version__}) + all_results.append(json_result("update", { + **host_metadata(requested_host, host), **ssh_info, + "remoteVersion": there, "current": __version__, + "outdated": there != __version__, + })) if not args.json: print(f"{shown_host}: session-peer {there or '(none)'} — {state}") continue if there == __version__: - all_results.append({**host_metadata(requested_host, host), **ssh_info, - "remoteVersion": there, "current": __version__, - "updated": False}) + all_results.append(json_result("update", { + **host_metadata(requested_host, host), **ssh_info, + "remoteVersion": there, "current": __version__, + "updated": False, + })) if not args.json: print(f"{shown_host} runs session-peer {there} — already current.") continue new_version = push_to_remote(requested_host, ssh_opts, ssh_info) - all_results.append({**host_metadata(requested_host, host), **ssh_info, "ok": True, - "previous": there, "current": __version__, "updated": True}) + all_results.append(json_result("update", { + **host_metadata(requested_host, host), **ssh_info, + "previous": there, "current": __version__, "updated": True, + })) if not args.json: prev = there or "(none)" print(f"{shown_host}: session-peer {prev} → {new_version}") except CcPeerError as exc: exit_code = EXIT_ERROR - all_results.append({**host_metadata(requested_host, host), - "ok": False, "error": str(exc), **exc.details}) + all_results.append(json_result( + "update", + {**host_metadata(requested_host, host), "error": str(exc), **exc.details}, + ok=False, + )) if not args.json: print(f"session-peer: {requested_host}: {exc}", file=sys.stderr) if args.json: - print(json.dumps(with_client_update(all_results), ensure_ascii=False)) + emit_json_results(all_results) return exit_code tag, url = latest_release() @@ -1846,12 +1901,17 @@ def cmd_update(args: argparse.Namespace) -> int: args.json, {"current": __version__, "latest": tag, "outdated": current < latest}, f"session-peer {__version__} — {state}", + command="update", ) return 0 if current >= latest: - emit(args.json, {"current": __version__, "latest": tag, "updated": False}, - f"session-peer {__version__} is already current ({tag}).") + emit( + args.json, + {"current": __version__, "latest": tag, "updated": False}, + f"session-peer {__version__} is already current ({tag}).", + command="update", + ) return 0 target = Path(__file__).resolve() @@ -1878,6 +1938,7 @@ def cmd_update(args: argparse.Namespace) -> int: args.json, {"current": __version__, "latest": tag, "updated": True, "path": str(target)}, f"session-peer {__version__} → {tag} ({target})", + command="update", ) return 0 @@ -1924,7 +1985,12 @@ def cmd_send(args: argparse.Namespace) -> int: if not args.host and is_codex: result = queue_codex(args, text) - emit(args.json, result, codex_submission_text(result, "this machine")) + emit( + args.json, + result, + codex_submission_text(result, "this machine"), + command="send", + ) return 0 if not args.host: @@ -1938,6 +2004,7 @@ def cmd_send(args: argparse.Namespace) -> int: args.json, {"ok": True, "target": target, "chars": len(text), "dryRun": args.dry_run}, f"{verb} {name}'s inbox on this machine ({len(text)} chars).", + command="send", ) return 0 @@ -1959,31 +2026,33 @@ def cmd_send(args: argparse.Namespace) -> int: result = run_remote(requested_host, remote_argv, ssh_opts) if is_codex: result.update(host_metadata(requested_host, host)) - all_results.append(result) + all_results.append(json_result("send", result)) if not args.json: print(codex_submission_text(result, shown_host)) continue target = result.get("target", {}) name = target.get("name") or target.get("pid") verb = "Would post to" if args.dry_run else "Posted to" - host_result = {"ok": True, **host_metadata(requested_host, host), "target": target, - **ssh_metadata_from(result), "chars": len(text), - "dryRun": args.dry_run} + host_result = json_result("send", { + **host_metadata(requested_host, host), "target": target, + **ssh_metadata_from(result), "chars": len(text), + "dryRun": args.dry_run, + }) all_results.append(host_result) if not args.json: print(f"{verb} {name}'s inbox on {shown_host} ({len(text)} chars).") except CcPeerError as exc: exit_code = EXIT_ERROR - all_results.append({"ok": False, **host_metadata(requested_host, host), - "error": str(exc), **exc.details}) + all_results.append(json_result( + "send", + {**host_metadata(requested_host, host), "error": str(exc), **exc.details}, + ok=False, + )) if not args.json: print(f"session-peer: {requested_host}: {exc}", file=sys.stderr) if args.json: - if len(all_results) == 1: - print(json.dumps(with_client_update(all_results[0]), ensure_ascii=False)) - else: - print(json.dumps(with_client_update(all_results), ensure_ascii=False)) + emit_json_results(all_results) return exit_code @@ -2061,6 +2130,18 @@ def add_common(sub: argparse.ArgumentParser) -> None: return parser +def json_error_result(args: argparse.Namespace, payload: dict) -> dict | list[dict]: + """Attribute command-wide failures to every requested destination.""" + command = getattr(args, "command", None) or "unknown" + hosts = list(getattr(args, "host", None) or []) + if not hosts: + return json_result(command, payload, ok=False) + return one_or_many([ + json_result(command, payload, host=host, ok=False) + for host in hosts + ]) + + def main(argv: list[str] | None = None) -> int: global _CLIENT_UPDATE_NOTICE cli_invocation = argv is None @@ -2086,7 +2167,7 @@ def main(argv: list[str] | None = None) -> int: except CcPeerError as exc: message = str(exc) if args.json: - payload = {"ok": False, "error": message, **exc.details} + payload = json_error_result(args, {"error": message, **exc.details}) print(json.dumps(with_client_update(payload), ensure_ascii=False)) else: print(f"session-peer: {message}", file=sys.stderr) @@ -2101,7 +2182,8 @@ def main(argv: list[str] | None = None) -> int: except Exception as exc: message = f"{type(exc).__name__}: {exc}" if getattr(args, "json", False): - print(json.dumps(with_client_update({"ok": False, "error": message}), ensure_ascii=False)) + payload = json_error_result(args, {"error": message}) + print(json.dumps(with_client_update(payload), ensure_ascii=False)) else: print(f"session-peer: {message}", file=sys.stderr) exit_code = EXIT_ERROR diff --git a/skills/session-peer/SKILL.md b/skills/session-peer/SKILL.md index 4173049b..1cb87c10 100644 --- a/skills/session-peer/SKILL.md +++ b/skills/session-peer/SKILL.md @@ -35,6 +35,11 @@ Omit `--host` for local delivery. `--dry-run` resolves without sending. Use `--codex-home` and `--codex-bin` when the destination's default environment does not identify its installation; remote paths are interpreted on that host. +With `--json`, every result object contains `schemaVersion`, `ok`, `host`, and +`command`. Local and one-host commands return one object; repeated `--host` +returns an ordered array of those objects. Inspect each `ok` independently +because one destination may fail while another succeeds. + Report `posted` (Claude socket write) and `queued` (Codex queue registration) accurately: neither confirms the receiving agent consumed the message or replied. Do not automatically resume sessions, retry an ambiguous timeout, change inbound diff --git a/tests/test_codex.py b/tests/test_codex.py index b0d65c14..f87b76d3 100644 --- a/tests/test_codex.py +++ b/tests/test_codex.py @@ -199,8 +199,11 @@ def test_remote_list_forwards_agent_and_home(self): mock.patch.object(peer, "remote_installed_version", return_value=None): code, result = self.invoke("list", "--agent", "codex", "--host", "worker", "--all", "--codex-home", "/remote", "--json") self.assertEqual(code, 0) - self.assertEqual(result[0]["host"], "worker") - self.assertEqual(result[0]["codexHome"], "/resolved remote home") + self.assertEqual(result["host"], "worker") + self.assertEqual(result["codexHome"], "/resolved remote home") + self.assertTrue(result["ok"]) + self.assertEqual(result["command"], "list") + self.assertEqual(result["schemaVersion"], 1) self.assertEqual(remote.call_args.args[1], [ "list", "--no-update-notice", "--all", "--agent", "codex", "--codex-home", "/remote", @@ -223,11 +226,18 @@ def remote(host, argv, ssh_opts): self.assertEqual([(r["host"], r["codexHome"]) for r in result], [("first", "/first/codex"), ("second", "/second/codex")]) - def test_claude_listing_envelope_is_unchanged(self): + def test_claude_listing_uses_common_json_envelope(self): with mock.patch.object(peer, "discover", return_value=[]): code, result = self.invoke("list", "--json") self.assertEqual(code, 0) - self.assertEqual(result, {"sessions": [], "version": peer.__version__}) + self.assertEqual(result, { + "schemaVersion": 1, + "ok": True, + "host": peer.local_host(), + "command": "list", + "sessions": [], + "version": peer.__version__, + }) def test_help_documents_explicit_home_and_bounded_guard(self): for command in ("list", "send"): diff --git a/tests/test_session_peer.py b/tests/test_session_peer.py index 3a11e3bc..d4bed148 100644 --- a/tests/test_session_peer.py +++ b/tests/test_session_peer.py @@ -585,9 +585,12 @@ def test_remote_list_and_update_check_use_the_same_verified_identity(self): with self.subTest(command=command), contextlib.redirect_stdout(io.StringIO()) as output: code = session_peer.main([*command, "--host", "100.122.73.69", "--json"]) self.assertEqual(code, 0) - result = json.loads(output.getvalue())[0] + result = json.loads(output.getvalue()) self.assertEqual(result["host"], expected) self.assertEqual(result["sshHost"], "100.122.73.69") + self.assertTrue(result["ok"]) + self.assertEqual(result["command"], command[0]) + self.assertEqual(result["schemaVersion"], 1) route = [ "-o", "HostName=macbook-pro-m4-pro.tailnet.ts.net", "-o", "HostKeyAlias=100.122.73.69", @@ -619,12 +622,20 @@ def test_main_catches_unexpected_exception_json(self): import io buf = io.StringIO() with mock.patch.object(session_peer, "build_parser") as bp: - ns = argparse.Namespace(func=mock.Mock(side_effect=RuntimeError("boom")), json=True) + ns = argparse.Namespace( + func=mock.Mock(side_effect=RuntimeError("boom")), + json=True, + command="list", + host=[], + ) bp.return_value.parse_args.return_value = ns with mock.patch("builtins.print", side_effect=lambda *a, **kw: buf.write(a[0])): code = session_peer.main(["list", "--json"]) result = json.loads(buf.getvalue()) self.assertFalse(result["ok"]) + self.assertEqual(result["command"], "list") + self.assertEqual(result["host"], session_peer.local_host()) + self.assertEqual(result["schemaVersion"], 1) self.assertIn("boom", result["error"]) self.assertEqual(code, session_peer.EXIT_ERROR) @@ -632,7 +643,12 @@ def test_main_catches_unexpected_exception_human(self): import io buf = io.StringIO() with mock.patch.object(session_peer, "build_parser") as bp: - ns = argparse.Namespace(func=mock.Mock(side_effect=RuntimeError("boom")), json=False) + ns = argparse.Namespace( + func=mock.Mock(side_effect=RuntimeError("boom")), + json=False, + command="list", + host=[], + ) bp.return_value.parse_args.return_value = ns with mock.patch("builtins.print", side_effect=lambda *a, **kw: buf.write(a[0])): code = session_peer.main(["list"]) @@ -661,6 +677,59 @@ def test_remote_error_keeps_structured_codex_home_resolution(self): self.assertEqual(caught.exception.details["codexHomeResolution"], resolution) +class JsonResponseContract(unittest.TestCase): + def assert_envelope(self, result, command, ok=True, host="local-node"): + self.assertEqual(result["schemaVersion"], 1) + self.assertIs(result["ok"], ok) + self.assertEqual(result["host"], host) + self.assertEqual(result["command"], command) + + def test_local_list_send_and_update_share_the_envelope(self): + with mock.patch.object(session_peer.socket, "gethostname", return_value="local-node"): + output = io.StringIO() + with mock.patch.object(session_peer, "discover", return_value=[]), \ + contextlib.redirect_stdout(output): + self.assertEqual(session_peer.main(["list", "--json"]), 0) + listing = json.loads(output.getvalue()) + self.assert_envelope(listing, "list") + self.assertEqual(listing["sessions"], []) + + output = io.StringIO() + session = {"pid": 7, "name": "worker", "reachable": True, + "socket": "/tmp/7.sock", "alive": True} + with mock.patch.object(session_peer, "discover", return_value=[session]), \ + contextlib.redirect_stdout(output): + self.assertEqual(session_peer.main([ + "send", "--to", "worker", "--dry-run", "--no-from", + "--no-reply-to", "--json", "hello", + ]), 0) + sending = json.loads(output.getvalue()) + self.assert_envelope(sending, "send") + self.assertTrue(sending["dryRun"]) + + output = io.StringIO() + with mock.patch.object(session_peer, "installed_as_distribution", return_value=True), \ + contextlib.redirect_stdout(output): + self.assertEqual(session_peer.main(["update", "--json"]), 0) + updating = json.loads(output.getvalue()) + self.assert_envelope(updating, "update") + self.assertEqual(updating["managedBy"], "package-manager") + + def test_command_wide_remote_failure_is_attributed_to_each_destination(self): + output = io.StringIO() + with contextlib.redirect_stdout(output): + code = session_peer.main([ + "send", "--host", "first", "--host", "second", "--to", "worker", + "--no-from", "--no-reply-to", "--json", " ", + ]) + results = json.loads(output.getvalue()) + self.assertEqual(code, session_peer.EXIT_ERROR) + self.assertEqual([item["host"] for item in results], ["first", "second"]) + for item in results: + self.assert_envelope(item, "send", ok=False, host=item["host"]) + self.assertIn("empty", item["error"]) + + class ReadMessage(unittest.TestCase): def test_rejects_tty_stdin(self): args = argparse.Namespace(b64=None, message=None) @@ -741,6 +810,9 @@ def test_ssh_failure_details_survive_multi_host_results(self): self.assertEqual(results[0]["sshFailure"], "authentication_failed") self.assertEqual(results[0]["sshUser"], "deploy") self.assertEqual(results[1]["sshUser"], "release") + for result in results: + self.assertEqual(result["schemaVersion"], 1) + self.assertEqual(result["command"], "send") def test_single_host_send_json_is_flat(self): import io @@ -756,6 +828,9 @@ def test_single_host_send_json_is_flat(self): self.assertIsInstance(result, dict) self.assertNotIsInstance(result, list) self.assertTrue(result["ok"]) + self.assertEqual(result["host"], "hostA") + self.assertEqual(result["command"], "send") + self.assertEqual(result["schemaVersion"], 1) class PushToRemote(unittest.TestCase):