agentlink-cli 0.1.4__tar.gz → 0.1.6__tar.gz

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.
Files changed (85) hide show
  1. {agentlink_cli-0.1.4 → agentlink_cli-0.1.6}/.gitignore +1 -0
  2. {agentlink_cli-0.1.4 → agentlink_cli-0.1.6}/PKG-INFO +22 -1
  3. {agentlink_cli-0.1.4 → agentlink_cli-0.1.6}/README.md +21 -0
  4. {agentlink_cli-0.1.4 → agentlink_cli-0.1.6}/connector/capabilities.py +60 -11
  5. {agentlink_cli-0.1.4 → agentlink_cli-0.1.6}/connector/cli.py +7 -1
  6. {agentlink_cli-0.1.4 → agentlink_cli-0.1.6}/connector/codex/adapter.py +18 -0
  7. {agentlink_cli-0.1.4 → agentlink_cli-0.1.6}/connector/codex/rpc.py +25 -3
  8. {agentlink_cli-0.1.4 → agentlink_cli-0.1.6}/connector/launch.py +98 -7
  9. {agentlink_cli-0.1.4 → agentlink_cli-0.1.6}/connector/runtime.py +6 -2
  10. {agentlink_cli-0.1.4 → agentlink_cli-0.1.6}/connector/version.py +1 -1
  11. {agentlink_cli-0.1.4 → agentlink_cli-0.1.6}/pyproject.toml +1 -1
  12. {agentlink_cli-0.1.4 → agentlink_cli-0.1.6}/tests/test_codex_adapter.py +54 -1
  13. {agentlink_cli-0.1.4 → agentlink_cli-0.1.6}/tests/test_connector_capabilities.py +35 -0
  14. {agentlink_cli-0.1.4 → agentlink_cli-0.1.6}/tests/test_connector_cli.py +13 -0
  15. {agentlink_cli-0.1.4 → agentlink_cli-0.1.6}/tests/test_connector_runtime.py +1 -0
  16. agentlink_cli-0.1.6/tests/test_launch_command.py +332 -0
  17. {agentlink_cli-0.1.4 → agentlink_cli-0.1.6}/tests/test_version.py +1 -1
  18. {agentlink_cli-0.1.4 → agentlink_cli-0.1.6}/uv.lock +1 -1
  19. agentlink_cli-0.1.4/tests/test_launch_command.py +0 -82
  20. {agentlink_cli-0.1.4 → agentlink_cli-0.1.6}/connector/__init__.py +0 -0
  21. {agentlink_cli-0.1.4 → agentlink_cli-0.1.6}/connector/acp/__init__.py +0 -0
  22. {agentlink_cli-0.1.4 → agentlink_cli-0.1.6}/connector/acp/adapter.py +0 -0
  23. {agentlink_cli-0.1.4 → agentlink_cli-0.1.6}/connector/acp/config_options.py +0 -0
  24. {agentlink_cli-0.1.4 → agentlink_cli-0.1.6}/connector/acp/discovery.py +0 -0
  25. {agentlink_cli-0.1.4 → agentlink_cli-0.1.6}/connector/acp/manifest.py +0 -0
  26. {agentlink_cli-0.1.4 → agentlink_cli-0.1.6}/connector/acp/manifests/__init__.py +0 -0
  27. {agentlink_cli-0.1.4 → agentlink_cli-0.1.6}/connector/acp/manifests/codebuddy.json +0 -0
  28. {agentlink_cli-0.1.4 → agentlink_cli-0.1.6}/connector/acp/manifests/cursor.json +0 -0
  29. {agentlink_cli-0.1.4 → agentlink_cli-0.1.6}/connector/acp/manifests/gemini.json +0 -0
  30. {agentlink_cli-0.1.4 → agentlink_cli-0.1.6}/connector/acp/manifests/grok_build.json +0 -0
  31. {agentlink_cli-0.1.4 → agentlink_cli-0.1.6}/connector/acp/reducer.py +0 -0
  32. {agentlink_cli-0.1.4 → agentlink_cli-0.1.6}/connector/acp/rpc.py +0 -0
  33. {agentlink_cli-0.1.4 → agentlink_cli-0.1.6}/connector/adapter.py +0 -0
  34. {agentlink_cli-0.1.4 → agentlink_cli-0.1.6}/connector/attachments.py +0 -0
  35. {agentlink_cli-0.1.4 → agentlink_cli-0.1.6}/connector/claude/__init__.py +0 -0
  36. {agentlink_cli-0.1.4 → agentlink_cli-0.1.6}/connector/claude/history_adapter.py +0 -0
  37. {agentlink_cli-0.1.4 → agentlink_cli-0.1.6}/connector/claude/normalized.py +0 -0
  38. {agentlink_cli-0.1.4 → agentlink_cli-0.1.6}/connector/claude/normalizers.py +0 -0
  39. {agentlink_cli-0.1.4 → agentlink_cli-0.1.6}/connector/claude/path_utils.py +0 -0
  40. {agentlink_cli-0.1.4 → agentlink_cli-0.1.6}/connector/claude/preferences.py +0 -0
  41. {agentlink_cli-0.1.4 → agentlink_cli-0.1.6}/connector/claude/sdk_adapter.py +0 -0
  42. {agentlink_cli-0.1.4 → agentlink_cli-0.1.6}/connector/claude/timeline_identity.py +0 -0
  43. {agentlink_cli-0.1.4 → agentlink_cli-0.1.6}/connector/claude/timeline_reducer.py +0 -0
  44. {agentlink_cli-0.1.4 → agentlink_cli-0.1.6}/connector/claude/trust.py +0 -0
  45. {agentlink_cli-0.1.4 → agentlink_cli-0.1.6}/connector/codex/__init__.py +0 -0
  46. {agentlink_cli-0.1.4 → agentlink_cli-0.1.6}/connector/codex/collaboration.py +0 -0
  47. {agentlink_cli-0.1.4 → agentlink_cli-0.1.6}/connector/codex/history.py +0 -0
  48. {agentlink_cli-0.1.4 → agentlink_cli-0.1.6}/connector/codex/reducer.py +0 -0
  49. {agentlink_cli-0.1.4 → agentlink_cli-0.1.6}/connector/control.py +0 -0
  50. {agentlink_cli-0.1.4 → agentlink_cli-0.1.6}/connector/json_rpc.py +0 -0
  51. {agentlink_cli-0.1.4 → agentlink_cli-0.1.6}/connector/local/__init__.py +0 -0
  52. {agentlink_cli-0.1.4 → agentlink_cli-0.1.6}/connector/local/common.py +0 -0
  53. {agentlink_cli-0.1.4 → agentlink_cli-0.1.6}/connector/local/file_ops.py +0 -0
  54. {agentlink_cli-0.1.4 → agentlink_cli-0.1.6}/connector/local/ops.py +0 -0
  55. {agentlink_cli-0.1.4 → agentlink_cli-0.1.6}/connector/local/shell.py +0 -0
  56. {agentlink_cli-0.1.4 → agentlink_cli-0.1.6}/connector/local/terminal.py +0 -0
  57. {agentlink_cli-0.1.4 → agentlink_cli-0.1.6}/connector/local_ops.py +0 -0
  58. {agentlink_cli-0.1.4 → agentlink_cli-0.1.6}/connector/local_runtime.py +0 -0
  59. {agentlink_cli-0.1.4 → agentlink_cli-0.1.6}/connector/logging.py +0 -0
  60. {agentlink_cli-0.1.4 → agentlink_cli-0.1.6}/connector/perf.py +0 -0
  61. {agentlink_cli-0.1.4 → agentlink_cli-0.1.6}/connector/protocol.py +0 -0
  62. {agentlink_cli-0.1.4 → agentlink_cli-0.1.6}/connector/registry.py +0 -0
  63. {agentlink_cli-0.1.4 → agentlink_cli-0.1.6}/connector/sync_state.py +0 -0
  64. {agentlink_cli-0.1.4 → agentlink_cli-0.1.6}/connector/time.py +0 -0
  65. {agentlink_cli-0.1.4 → agentlink_cli-0.1.6}/run.sh +0 -0
  66. {agentlink_cli-0.1.4 → agentlink_cli-0.1.6}/tests/__init__.py +0 -0
  67. {agentlink_cli-0.1.4 → agentlink_cli-0.1.6}/tests/acp/__init__.py +0 -0
  68. {agentlink_cli-0.1.4 → agentlink_cli-0.1.6}/tests/acp/fake_agent/__init__.py +0 -0
  69. {agentlink_cli-0.1.4 → agentlink_cli-0.1.6}/tests/acp/fake_agent/__main__.py +0 -0
  70. {agentlink_cli-0.1.4 → agentlink_cli-0.1.6}/tests/acp/test_acp_adapter_fake.py +0 -0
  71. {agentlink_cli-0.1.4 → agentlink_cli-0.1.6}/tests/acp/test_acp_reducer_golden.py +0 -0
  72. {agentlink_cli-0.1.4 → agentlink_cli-0.1.6}/tests/acp/test_config_options.py +0 -0
  73. {agentlink_cli-0.1.4 → agentlink_cli-0.1.6}/tests/contract/__init__.py +0 -0
  74. {agentlink_cli-0.1.4 → agentlink_cli-0.1.6}/tests/contract/helpers.py +0 -0
  75. {agentlink_cli-0.1.4 → agentlink_cli-0.1.6}/tests/test_claude_history_adapter.py +0 -0
  76. {agentlink_cli-0.1.4 → agentlink_cli-0.1.6}/tests/test_claude_preferences.py +0 -0
  77. {agentlink_cli-0.1.4 → agentlink_cli-0.1.6}/tests/test_claude_sdk_adapter.py +0 -0
  78. {agentlink_cli-0.1.4 → agentlink_cli-0.1.6}/tests/test_claude_timeline_parity.py +0 -0
  79. {agentlink_cli-0.1.4 → agentlink_cli-0.1.6}/tests/test_claude_trust.py +0 -0
  80. {agentlink_cli-0.1.4 → agentlink_cli-0.1.6}/tests/test_connector_control.py +0 -0
  81. {agentlink_cli-0.1.4 → agentlink_cli-0.1.6}/tests/test_connector_json_rpc.py +0 -0
  82. {agentlink_cli-0.1.4 → agentlink_cli-0.1.6}/tests/test_connector_logging.py +0 -0
  83. {agentlink_cli-0.1.4 → agentlink_cli-0.1.6}/tests/test_local_runtime.py +0 -0
  84. {agentlink_cli-0.1.4 → agentlink_cli-0.1.6}/tests/test_perf.py +0 -0
  85. {agentlink_cli-0.1.4 → agentlink_cli-0.1.6}/tests/test_terminal_backend.py +0 -0
@@ -6,6 +6,7 @@ __pycache__/
6
6
  .pytest_cache/
7
7
  .ruff_cache/
8
8
  .venv/
9
+ server-rust/target/
9
10
  .next/
10
11
  web-next/out/
11
12
  .env.local
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: agentlink-cli
3
- Version: 0.1.4
3
+ Version: 0.1.6
4
4
  Summary: Local runtime connector for AgentLink
5
5
  Requires-Python: >=3.12
6
6
  Requires-Dist: claude-agent-sdk
@@ -44,6 +44,13 @@ Install dependencies:
44
44
  uv sync
45
45
  ```
46
46
 
47
+ Check the installed CLI version:
48
+
49
+ ```bash
50
+ agentlink-cli -v
51
+ agentlink-cli --version
52
+ ```
53
+
47
54
  Start with explicit credentials from the web pairing flow:
48
55
 
49
56
  ```bash
@@ -84,6 +91,20 @@ CLAUDE_BIN=/path/to/claude
84
91
  The connector uses local runtime credentials and local filesystem permissions.
85
92
  AgentLink does not proxy Claude or Codex account credentials.
86
93
 
94
+ Custom Codex launch commands may name an executable or a bash/zsh alias or
95
+ function from the Connector user's interactive shell configuration. For
96
+ example, after defining this in `~/.bashrc` or `~/.zshrc`:
97
+
98
+ ```bash
99
+ alias codex-api='CODEX_HOME="$HOME/.codex-api" codex'
100
+ ```
101
+
102
+ enter `codex-api` as the custom launch command in AgentLink. Arguments are
103
+ passed without shell re-evaluation, and AgentLink still rejects pipelines,
104
+ redirects, command lists, and multiline commands. Shell aliases and functions
105
+ are supported on Linux and macOS with bash or zsh; Windows custom commands must
106
+ resolve to an executable or a supported script file.
107
+
87
108
  ## Local Operations
88
109
 
89
110
  The server can ask an online connector to perform local work:
@@ -27,6 +27,13 @@ Install dependencies:
27
27
  uv sync
28
28
  ```
29
29
 
30
+ Check the installed CLI version:
31
+
32
+ ```bash
33
+ agentlink-cli -v
34
+ agentlink-cli --version
35
+ ```
36
+
30
37
  Start with explicit credentials from the web pairing flow:
31
38
 
32
39
  ```bash
@@ -67,6 +74,20 @@ CLAUDE_BIN=/path/to/claude
67
74
  The connector uses local runtime credentials and local filesystem permissions.
68
75
  AgentLink does not proxy Claude or Codex account credentials.
69
76
 
77
+ Custom Codex launch commands may name an executable or a bash/zsh alias or
78
+ function from the Connector user's interactive shell configuration. For
79
+ example, after defining this in `~/.bashrc` or `~/.zshrc`:
80
+
81
+ ```bash
82
+ alias codex-api='CODEX_HOME="$HOME/.codex-api" codex'
83
+ ```
84
+
85
+ enter `codex-api` as the custom launch command in AgentLink. Arguments are
86
+ passed without shell re-evaluation, and AgentLink still rejects pipelines,
87
+ redirects, command lists, and multiline commands. Shell aliases and functions
88
+ are supported on Linux and macOS with bash or zsh; Windows custom commands must
89
+ resolve to an executable or a supported script file.
90
+
70
91
  ## Local Operations
71
92
 
72
93
  The server can ask an online connector to perform local work:
@@ -4,6 +4,7 @@ import asyncio
4
4
  import importlib
5
5
  import os
6
6
  import shutil
7
+ import signal
7
8
  import sys
8
9
  import time
9
10
  from collections.abc import Iterable
@@ -133,7 +134,7 @@ async def discover_codex_capability(
133
134
  """
134
135
  if launch_command:
135
136
  try:
136
- parsed = parse_launch_command(launch_command)
137
+ parsed = await parse_launch_command(launch_command)
137
138
  except ValueError as exc:
138
139
  return (
139
140
  {
@@ -238,16 +239,17 @@ async def _check_codex_candidate(candidate: dict[str, str] | LaunchTarget | Laun
238
239
  path = target.path
239
240
  source = target.source
240
241
  base = {"source": source, "path": path}
241
- if not Path(path).is_file():
242
- return {**base, "status": "missing", "reason": "file not found"}
243
- if not path_exists_for_launch(path):
244
- return {**base, "status": "failed", "reason": "not executable"}
242
+ if not target.is_shell_command:
243
+ if not Path(path).is_file():
244
+ return {**base, "status": "missing", "reason": "file not found"}
245
+ if not path_exists_for_launch(path):
246
+ return {**base, "status": "failed", "reason": "not executable"}
245
247
 
246
- version = await _run_version(command.command(["--version"]))
248
+ version = await _run_launch_version(command)
247
249
  if version["status"] != "ok":
248
250
  return {**base, "status": "failed", "stage": "version", **version}
249
251
 
250
- client = JsonRpcStdioClient(command=command.command(["app-server", "--listen", "stdio://"]))
252
+ client = _codex_stdio_client(command)
251
253
  try:
252
254
  await asyncio.wait_for(client.start(lambda _payload: _noop()), timeout=_CODEX_CHECK_TIMEOUT_S)
253
255
  list_result = await asyncio.wait_for(
@@ -294,12 +296,14 @@ async def prepare_codex_launch(
294
296
  """Validate a command while keeping the initialized candidate alive."""
295
297
 
296
298
  target = command.target
297
- if not Path(target.path).is_file() or not path_exists_for_launch(target.path):
299
+ if not target.is_shell_command and (
300
+ not Path(target.path).is_file() or not path_exists_for_launch(target.path)
301
+ ):
298
302
  raise ValueError(f"executable is unavailable: {target.path}")
299
- version = await _run_version(command.command(["--version"]))
303
+ version = await _run_launch_version(command)
300
304
  if version["status"] != "ok":
301
305
  raise RuntimeError(str(version.get("reason") or "Codex version check failed"))
302
- client = JsonRpcStdioClient(command=command.command(["app-server", "--listen", "stdio://"]))
306
+ client = _codex_stdio_client(command)
303
307
  try:
304
308
  await asyncio.wait_for(client.start(handler), timeout=_CODEX_CHECK_TIMEOUT_S)
305
309
  list_result = await asyncio.wait_for(
@@ -515,15 +519,36 @@ def _list_claude_sdk_sessions() -> list[Any]:
515
519
  return list(list_sessions())
516
520
 
517
521
 
518
- async def _run_version(command: list[str]) -> dict[str, Any]:
522
+ async def _run_launch_version(command: LaunchCommand) -> dict[str, Any]:
523
+ kwargs = {"start_new_session": True} if command.target.starts_process_group else {}
524
+ return await _run_version(command.command(["--version"]), **kwargs)
525
+
526
+
527
+ def _codex_stdio_client(command: LaunchCommand) -> JsonRpcStdioClient:
528
+ kwargs = {"start_new_session": True} if command.target.starts_process_group else {}
529
+ return JsonRpcStdioClient(
530
+ command=command.command(["app-server", "--listen", "stdio://"]),
531
+ **kwargs,
532
+ )
533
+
534
+
535
+ async def _run_version(command: list[str], *, start_new_session: bool = False) -> dict[str, Any]:
536
+ proc: asyncio.subprocess.Process | None = None
519
537
  try:
520
538
  proc = await asyncio.create_subprocess_exec(
521
539
  *command,
522
540
  stdout=asyncio.subprocess.PIPE,
523
541
  stderr=asyncio.subprocess.PIPE,
542
+ start_new_session=start_new_session,
524
543
  )
525
544
  stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=_COMMAND_CHECK_TIMEOUT_S)
545
+ except asyncio.CancelledError:
546
+ if proc is not None and proc.returncode is None:
547
+ await _terminate_process(proc, process_group=start_new_session)
548
+ raise
526
549
  except Exception as exc:
550
+ if proc is not None and proc.returncode is None:
551
+ await _terminate_process(proc, process_group=start_new_session)
527
552
  return {"status": "failed", "reason": _exception_reason(exc)}
528
553
  out = stdout.decode(errors="replace").strip()
529
554
  err = stderr.decode(errors="replace").strip()
@@ -537,6 +562,30 @@ async def _run_version(command: list[str]) -> dict[str, Any]:
537
562
  return {"status": "ok", "stdout": out[:500], "stderr": err[:500]}
538
563
 
539
564
 
565
+ async def _terminate_process(
566
+ process: asyncio.subprocess.Process,
567
+ *,
568
+ process_group: bool,
569
+ ) -> None:
570
+ try:
571
+ if process_group:
572
+ os.killpg(process.pid, signal.SIGTERM)
573
+ else:
574
+ process.terminate()
575
+ await asyncio.wait_for(process.wait(), timeout=5)
576
+ except ProcessLookupError:
577
+ return
578
+ except TimeoutError:
579
+ try:
580
+ if process_group:
581
+ os.killpg(process.pid, signal.SIGKILL)
582
+ else:
583
+ process.kill()
584
+ except ProcessLookupError:
585
+ return
586
+ await process.wait()
587
+
588
+
540
589
  def _claude_candidate_paths() -> list[dict[str, str]]:
541
590
  if sys.platform == "win32":
542
591
  home = Path.home()
@@ -20,7 +20,7 @@ from connector.local_runtime import (
20
20
  )
21
21
  from connector.logging import install_rpc_log_sink
22
22
  from connector.runtime import BackendRpcClient, ConnectorConfig
23
-
23
+ from connector.version import connector_version
24
24
 
25
25
  SHELL_LIFETIME_WARNING = (
26
26
  "Note: this connector runs in the current shell session. "
@@ -69,6 +69,12 @@ class CliError(RuntimeError):
69
69
 
70
70
  def _build_parser() -> argparse.ArgumentParser:
71
71
  parser = argparse.ArgumentParser(prog="agentlink-cli", description="Agent Server Codex connector CLI")
72
+ parser.add_argument(
73
+ "-v",
74
+ "--version",
75
+ action="version",
76
+ version=f"%(prog)s {connector_version()}",
77
+ )
72
78
  subparsers = parser.add_subparsers(dest="command", metavar="{start,pair,configure,rpc}")
73
79
 
74
80
  start = subparsers.add_parser("start", help="start the connector")
@@ -590,6 +590,24 @@ class CodexAdapter:
590
590
  "target": target,
591
591
  }
592
592
  else:
593
+ # A connector restart clears the app-server's in-memory
594
+ # thread set. Resume the requested thread before reading its
595
+ # status so Codex resolves workspace-sensitive information
596
+ # (including Git state) against the thread's workspace.
597
+ try:
598
+ await self._ensure_thread_loaded(thread_id)
599
+ except Exception as exc:
600
+ # Archived/unresumable threads remain readable and status
601
+ # is intentionally a read-only command. Preserve that
602
+ # behavior when app-server refuses to resume them.
603
+ reason = _unresumable_thread_failure_reason(str(exc))
604
+ if reason not in {"archived", "unresumable"}:
605
+ raise
606
+ logger.info(
607
+ "codex status using read-only archived thread thread_id={} reason={}",
608
+ thread_id,
609
+ reason,
610
+ )
593
611
  thread = await self.rpc.request(
594
612
  "thread/read", {"threadId": thread_id, "includeTurns": False}
595
613
  )
@@ -4,6 +4,7 @@ import asyncio
4
4
  import json
5
5
  import os
6
6
  import shutil
7
+ import signal
7
8
  import sys
8
9
  import time
9
10
  from collections.abc import Awaitable, Callable
@@ -21,8 +22,14 @@ APP_SERVER_STREAM_LIMIT = 64 * 1024 * 1024
21
22
  class JsonRpcStdioClient:
22
23
  """Line-delimited JSON-RPC client for `codex app-server --listen stdio://`."""
23
24
 
24
- def __init__(self, command: list[str] | None = None) -> None:
25
+ def __init__(
26
+ self,
27
+ command: list[str] | None = None,
28
+ *,
29
+ start_new_session: bool = False,
30
+ ) -> None:
25
31
  self.command = command or _resolve_codex_command()
32
+ self.start_new_session = start_new_session
26
33
  self.process: asyncio.subprocess.Process | None = None
27
34
  self._start_lock = asyncio.Lock()
28
35
  self._next_id = 1
@@ -46,6 +53,7 @@ class JsonRpcStdioClient:
46
53
  stdout=asyncio.subprocess.PIPE,
47
54
  stderr=asyncio.subprocess.PIPE,
48
55
  limit=APP_SERVER_STREAM_LIMIT,
56
+ start_new_session=self.start_new_session,
49
57
  )
50
58
  self._track_reader(asyncio.create_task(self._read_stdout(self.process)), "stdout")
51
59
  self._track_reader(asyncio.create_task(self._read_stderr(self.process)), "stderr")
@@ -112,11 +120,25 @@ class JsonRpcStdioClient:
112
120
  async def close(self) -> None:
113
121
  if self.process is None:
114
122
  return
115
- self.process.terminate()
123
+ try:
124
+ if self.start_new_session:
125
+ os.killpg(self.process.pid, signal.SIGTERM)
126
+ else:
127
+ self.process.terminate()
128
+ except ProcessLookupError:
129
+ self.process = None
130
+ self._initialized = False
131
+ return
116
132
  try:
117
133
  await asyncio.wait_for(self.process.wait(), timeout=5)
118
134
  except TimeoutError:
119
- self.process.kill()
135
+ try:
136
+ if self.start_new_session:
137
+ os.killpg(self.process.pid, signal.SIGKILL)
138
+ else:
139
+ self.process.kill()
140
+ except ProcessLookupError:
141
+ pass
120
142
  await self.process.wait()
121
143
  finally:
122
144
  self.process = None
@@ -1,17 +1,19 @@
1
1
  from __future__ import annotations
2
2
 
3
+ import asyncio
3
4
  import hashlib
4
5
  import json
5
6
  import os
6
7
  import re
7
8
  import shlex
8
9
  import shutil
10
+ import signal
9
11
  import sys
10
12
  from dataclasses import dataclass
11
13
  from pathlib import Path
12
14
  from typing import Literal
13
15
 
14
- Launcher = Literal["direct", "powershell", "cmd"]
16
+ Launcher = Literal["direct", "powershell", "cmd", "posix-shell"]
15
17
 
16
18
 
17
19
  @dataclass(frozen=True, slots=True)
@@ -52,6 +54,14 @@ class LaunchTarget:
52
54
  def report_path(self) -> str:
53
55
  return self.path
54
56
 
57
+ @property
58
+ def is_shell_command(self) -> bool:
59
+ return self.launcher == "posix-shell"
60
+
61
+ @property
62
+ def starts_process_group(self) -> bool:
63
+ return self.is_shell_command
64
+
55
65
 
56
66
  @dataclass(frozen=True, slots=True)
57
67
  class LaunchCommand:
@@ -84,9 +94,10 @@ class LaunchCommand:
84
94
 
85
95
  _UNSUPPORTED_SHELL_TOKENS = {"|", "||", "&", "&&", ";", ">", ">>", "<", "<<"}
86
96
  _CMD_METACHARACTERS = frozenset('&|<>^()%!"')
97
+ _SHELL_PROBE_TIMEOUT_S = 5
87
98
 
88
99
 
89
- def parse_launch_command(value: str) -> LaunchCommand:
100
+ async def parse_launch_command(value: str) -> LaunchCommand:
90
101
  """Parse a frontend command as argv and resolve its executable locally.
91
102
 
92
103
  Shell operators are deliberately rejected. The resulting command is
@@ -108,12 +119,14 @@ def parse_launch_command(value: str) -> LaunchCommand:
108
119
  raise ValueError("shell operators are not supported; enter an executable and arguments only")
109
120
 
110
121
  executable = expand_vars(argv[0])
111
- resolved = shutil.which(executable)
112
- if resolved is None and Path(executable).is_file():
113
- resolved = executable
114
- if resolved is None:
122
+ target = await _resolve_posix_shell_command(executable)
123
+ if target is None:
124
+ resolved = shutil.which(executable)
125
+ if resolved is None and Path(executable).is_file():
126
+ resolved = executable
127
+ target = launch_target("custom", resolved) if resolved is not None else None
128
+ if target is None:
115
129
  raise ValueError(f"executable not found: {argv[0]}")
116
- target = launch_target("custom", resolved)
117
130
  if target.launcher == "cmd" and any(
118
131
  character in _CMD_METACHARACTERS
119
132
  for token in argv[1:]
@@ -125,6 +138,84 @@ def parse_launch_command(value: str) -> LaunchCommand:
125
138
  return LaunchCommand(raw=raw, target=target, args=tuple(argv[1:]))
126
139
 
127
140
 
141
+ async def _resolve_posix_shell_command(name: str) -> LaunchTarget | None:
142
+ if sys.platform == "win32" or not re.fullmatch(r"[A-Za-z0-9_][A-Za-z0-9_.+-]*", name):
143
+ return None
144
+
145
+ shell = _user_shell()
146
+ if shell is None:
147
+ return None
148
+ shell_name = Path(shell).name.lower()
149
+ if shell_name == "bash":
150
+ probe = (
151
+ 'kind=$(type -t -- "$1" 2>/dev/null); '
152
+ '[ "$kind" = alias ] || [ "$kind" = function ]'
153
+ )
154
+ elif shell_name == "zsh":
155
+ probe = (
156
+ 'kind=$(whence -w -- "$1" 2>/dev/null); kind=${kind##*: }; '
157
+ '[ "$kind" = alias ] || [ "$kind" = function ]'
158
+ )
159
+ else:
160
+ return None
161
+
162
+ process: asyncio.subprocess.Process | None = None
163
+ try:
164
+ process = await asyncio.create_subprocess_exec(
165
+ shell,
166
+ "-ic",
167
+ probe,
168
+ "agentlink-shell-probe",
169
+ name,
170
+ stdin=asyncio.subprocess.DEVNULL,
171
+ stdout=asyncio.subprocess.DEVNULL,
172
+ stderr=asyncio.subprocess.DEVNULL,
173
+ start_new_session=True,
174
+ )
175
+ await asyncio.wait_for(process.wait(), timeout=_SHELL_PROBE_TIMEOUT_S)
176
+ except asyncio.CancelledError:
177
+ if process is not None and process.returncode is None:
178
+ await _kill_shell_probe_group(process)
179
+ raise
180
+ except (OSError, TimeoutError):
181
+ if process is not None and process.returncode is None:
182
+ await _kill_shell_probe_group(process)
183
+ return None
184
+ if process.returncode != 0:
185
+ return None
186
+
187
+ script = f'{shlex.quote(name)} "$@"'
188
+ return LaunchTarget(
189
+ source="shell",
190
+ path=name,
191
+ launcher="posix-shell",
192
+ exec_argv=(shell, "-ic", script, "agentlink-codex"),
193
+ )
194
+
195
+
196
+ async def _kill_shell_probe_group(process: asyncio.subprocess.Process) -> None:
197
+ try:
198
+ os.killpg(process.pid, signal.SIGKILL)
199
+ except ProcessLookupError:
200
+ pass
201
+ await process.wait()
202
+
203
+
204
+ def _user_shell() -> str | None:
205
+ configured = expand_vars(os.environ.get("SHELL", ""))
206
+ if not configured and sys.platform != "win32":
207
+ try:
208
+ import pwd
209
+
210
+ configured = pwd.getpwuid(os.getuid()).pw_shell
211
+ except (ImportError, KeyError, OSError):
212
+ return None
213
+ resolved = shutil.which(configured) if configured else None
214
+ if resolved is None and configured and Path(configured).is_file():
215
+ resolved = configured
216
+ return resolved
217
+
218
+
128
219
  def launch_command_from_target(target: LaunchTarget, *, raw: str | None = None) -> LaunchCommand:
129
220
  return LaunchCommand(raw=raw or target.report_path(), target=target)
130
221
 
@@ -735,6 +735,7 @@ class BackendRpcClient:
735
735
  if (
736
736
  codex.rpc is not None
737
737
  and codex.rpc.command == command
738
+ and codex.rpc.start_new_session == launch.target.starts_process_group
738
739
  and getattr(codex, "_started", False)
739
740
  ):
740
741
  self._blocked_runtimes.discard("codex")
@@ -744,7 +745,10 @@ class BackendRpcClient:
744
745
  await codex.rpc.close()
745
746
  except Exception:
746
747
  logger.exception("closing previous codex app-server failed")
747
- codex.rpc = JsonRpcStdioClient(command=command)
748
+ codex.rpc = JsonRpcStdioClient(
749
+ command=command,
750
+ start_new_session=launch.target.starts_process_group,
751
+ )
748
752
  codex._started = False
749
753
  self._blocked_runtimes.discard("codex")
750
754
 
@@ -756,7 +760,7 @@ class BackendRpcClient:
756
760
  if mode == "command":
757
761
  if not isinstance(raw, str):
758
762
  raise ValueError("missing Codex launch command")
759
- launch = parse_launch_command(raw)
763
+ launch = await parse_launch_command(raw)
760
764
  else:
761
765
  report, target = await discover_codex_capability()
762
766
  if target is None:
@@ -2,7 +2,7 @@ from __future__ import annotations
2
2
 
3
3
  from importlib.metadata import PackageNotFoundError, version
4
4
 
5
- FALLBACK_VERSION = "0.1.4"
5
+ FALLBACK_VERSION = "0.1.6"
6
6
 
7
7
 
8
8
  def connector_version() -> str:
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "agentlink-cli"
3
- version = "0.1.4"
3
+ version = "0.1.6"
4
4
  description = "Local runtime connector for AgentLink"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.12"
@@ -2,6 +2,7 @@ from __future__ import annotations
2
2
 
3
3
  import asyncio
4
4
  import json
5
+ import signal
5
6
  from collections.abc import Awaitable, Callable
6
7
  from typing import Any
7
8
 
@@ -11,7 +12,10 @@ from connector.codex.adapter import (
11
12
  CodexAdapter,
12
13
  stable_session_id,
13
14
  )
14
- from connector.codex.collaboration import collaboration_mode_payload, normalize_collaboration_modes
15
+ from connector.codex.collaboration import (
16
+ collaboration_mode_payload,
17
+ normalize_collaboration_modes,
18
+ )
15
19
  from connector.codex.history import read_timeline_history, read_tool_history
16
20
  from connector.codex.reducer import TimelineReducer
17
21
  from connector.codex.rpc import APP_SERVER_STREAM_LIMIT, JsonRpcStdioClient
@@ -121,6 +125,7 @@ class ArchivedThreadListRpc(FakeCodexRpc):
121
125
  class ArchivedResumeRpc(FakeCodexRpc):
122
126
  async def request(self, method: str, params: dict[str, Any] | None = None) -> dict[str, Any]:
123
127
  if method == "thread/resume":
128
+ self.requests.append((method, params))
124
129
  raise RuntimeError(json.dumps({"code": -32600, "message": "thread is archived"}))
125
130
  return await super().request(method, params)
126
131
 
@@ -386,6 +391,29 @@ def test_stdio_client_start_is_serialized(monkeypatch) -> None:
386
391
  asyncio.run(_exercise_stdio_client_start_is_serialized(monkeypatch))
387
392
 
388
393
 
394
+ def test_stdio_client_closes_shell_process_group(monkeypatch) -> None:
395
+ class GroupProcess:
396
+ pid = 4321
397
+ returncode: int | None = None
398
+
399
+ async def wait(self) -> int:
400
+ self.returncode = 0
401
+ return 0
402
+
403
+ signals: list[tuple[int, signal.Signals]] = []
404
+ monkeypatch.setattr(
405
+ "connector.codex.rpc.os.killpg",
406
+ lambda pid, sent_signal: signals.append((pid, sent_signal)),
407
+ )
408
+ client = JsonRpcStdioClient(command=["bash"], start_new_session=True)
409
+ client.process = GroupProcess() # type: ignore[assignment]
410
+
411
+ asyncio.run(client.close())
412
+
413
+ assert signals == [(4321, signal.SIGTERM)]
414
+ assert client.process is None
415
+
416
+
389
417
  def test_stdio_client_ignores_response_for_cancelled_request() -> None:
390
418
  client = JsonRpcStdioClient(command=["codex"])
391
419
  loop = asyncio.new_event_loop()
@@ -1138,12 +1166,37 @@ def test_status_command_returns_thread_and_cached_token_usage() -> None:
1138
1166
  assert result["status"] == "completed"
1139
1167
  assert result["data"]["thread"]["id"] == "thr_1"
1140
1168
  assert result["data"]["tokenUsage"]["total"]["totalTokens"] == 42
1169
+ assert ("thread/resume", {"threadId": "thr_1"}) in rpc.requests
1141
1170
  assert ("thread/read", {"threadId": "thr_1", "includeTurns": False}) in rpc.requests
1142
1171
  assert all(method != "turn/start" for method, _params in rpc.requests)
1143
1172
 
1144
1173
  asyncio.run(exercise())
1145
1174
 
1146
1175
 
1176
+ def test_status_command_reads_archived_thread_when_resume_is_rejected() -> None:
1177
+ async def exercise() -> None:
1178
+ rpc = ArchivedResumeRpc()
1179
+ adapter = CodexAdapter(rpc=rpc) # type: ignore[arg-type]
1180
+ await adapter.start()
1181
+ adapter._loaded_thread_ids.clear() # noqa: SLF001
1182
+
1183
+ result = await adapter.execute_command(
1184
+ {
1185
+ "sessionId": "sess_1",
1186
+ "externalSessionId": "thr_1",
1187
+ "command": "status",
1188
+ "clientCommandId": "cmd_status_archived",
1189
+ }
1190
+ )
1191
+
1192
+ assert result["status"] == "completed"
1193
+ assert result["data"]["thread"]["id"] == "thr_1"
1194
+ assert ("thread/resume", {"threadId": "thr_1"}) in rpc.requests
1195
+ assert ("thread/read", {"threadId": "thr_1", "includeTurns": False}) in rpc.requests
1196
+
1197
+ asyncio.run(exercise())
1198
+
1199
+
1147
1200
  def test_reducer_normalizes_review_and_compaction_items() -> None:
1148
1201
  reducer = TimelineReducer()
1149
1202
  reducer.bind_session("sess_1", "thr_1")
@@ -1,6 +1,7 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import asyncio
4
+ import signal
4
5
  from pathlib import Path
5
6
  from typing import Any
6
7
 
@@ -14,6 +15,40 @@ def _write_executable(path: Path, content: str) -> None:
14
15
  path.chmod(0o755)
15
16
 
16
17
 
18
+ def test_version_timeout_terminates_shell_process_group(monkeypatch) -> None:
19
+ class HangingProcess:
20
+ pid = 7654
21
+ returncode: int | None = None
22
+
23
+ async def communicate(self) -> tuple[bytes, bytes]:
24
+ await asyncio.Event().wait()
25
+ raise AssertionError("unreachable")
26
+
27
+ async def wait(self) -> int:
28
+ self.returncode = 0
29
+ return 0
30
+
31
+ process = HangingProcess()
32
+ signals: list[tuple[int, signal.Signals]] = []
33
+
34
+ async def fake_create_subprocess_exec(*_command: str, **_kwargs: object) -> HangingProcess:
35
+ return process
36
+
37
+ monkeypatch.setattr(capabilities, "_COMMAND_CHECK_TIMEOUT_S", 0.01)
38
+ monkeypatch.setattr(asyncio, "create_subprocess_exec", fake_create_subprocess_exec)
39
+ monkeypatch.setattr(
40
+ "connector.capabilities.os.killpg",
41
+ lambda pid, sent_signal: signals.append((pid, sent_signal)),
42
+ )
43
+
44
+ result = asyncio.run(
45
+ capabilities._run_version(["bash", "-ic", "codex-api"], start_new_session=True)
46
+ )
47
+
48
+ assert result == {"status": "failed", "reason": "timeout"}
49
+ assert signals == [(7654, signal.SIGTERM)]
50
+
51
+
17
52
  def test_claude_capability_checks_version_and_help_only(monkeypatch, tmp_path: Path) -> None:
18
53
  claude_bin = tmp_path / "claude"
19
54
  _write_executable(