bamboo-coding 0.1.8__tar.gz → 0.2.1__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 (49) hide show
  1. {bamboo_coding-0.1.8 → bamboo_coding-0.2.1}/PKG-INFO +7 -2
  2. {bamboo_coding-0.1.8 → bamboo_coding-0.2.1}/pyproject.toml +9 -2
  3. {bamboo_coding-0.1.8 → bamboo_coding-0.2.1}/src/bamboo_coding/agents/detection.py +5 -0
  4. {bamboo_coding-0.1.8 → bamboo_coding-0.2.1}/src/bamboo_coding/agents/runner.py +6 -1
  5. {bamboo_coding-0.1.8 → bamboo_coding-0.2.1}/src/bamboo_coding/capabilities/repo.py +3 -0
  6. bamboo_coding-0.2.1/src/bamboo_coding/chat/pty_session.py +15 -0
  7. bamboo_coding-0.2.1/src/bamboo_coding/chat/pty_session_win.py +125 -0
  8. {bamboo_coding-0.1.8 → bamboo_coding-0.2.1}/src/bamboo_coding/config.py +5 -0
  9. {bamboo_coding-0.1.8 → bamboo_coding-0.2.1}/src/bamboo_coding/controller_client.py +8 -1
  10. {bamboo_coding-0.1.8 → bamboo_coding-0.2.1}/src/bamboo_coding/main.py +54 -42
  11. {bamboo_coding-0.1.8 → bamboo_coding-0.2.1}/src/bamboo_coding/protocol.py +3 -0
  12. {bamboo_coding-0.1.8 → bamboo_coding-0.2.1}/src/bamboo_coding/status.py +5 -1
  13. {bamboo_coding-0.1.8 → bamboo_coding-0.2.1}/src/bamboo_coding/terminal_runtime.py +21 -0
  14. {bamboo_coding-0.1.8 → bamboo_coding-0.2.1}/src/bamboo_coding.egg-info/PKG-INFO +7 -2
  15. {bamboo_coding-0.1.8 → bamboo_coding-0.2.1}/src/bamboo_coding.egg-info/SOURCES.txt +2 -0
  16. bamboo_coding-0.2.1/src/bamboo_coding.egg-info/requires.txt +9 -0
  17. bamboo_coding-0.1.8/src/bamboo_coding.egg-info/requires.txt +0 -5
  18. {bamboo_coding-0.1.8 → bamboo_coding-0.2.1}/README.md +0 -0
  19. {bamboo_coding-0.1.8 → bamboo_coding-0.2.1}/setup.cfg +0 -0
  20. {bamboo_coding-0.1.8 → bamboo_coding-0.2.1}/src/bamboo_coding/__init__.py +0 -0
  21. {bamboo_coding-0.1.8 → bamboo_coding-0.2.1}/src/bamboo_coding/agents/__init__.py +0 -0
  22. {bamboo_coding-0.1.8 → bamboo_coding-0.2.1}/src/bamboo_coding/agents/builtin_skills/__init__.py +0 -0
  23. {bamboo_coding-0.1.8 → bamboo_coding-0.2.1}/src/bamboo_coding/agents/builtin_skills/skill-create/SKILL.md +0 -0
  24. {bamboo_coding-0.1.8 → bamboo_coding-0.2.1}/src/bamboo_coding/agents/builtin_skills/skill-optimize/SKILL.md +0 -0
  25. {bamboo_coding-0.1.8 → bamboo_coding-0.2.1}/src/bamboo_coding/agents/compose.py +0 -0
  26. {bamboo_coding-0.1.8 → bamboo_coding-0.2.1}/src/bamboo_coding/agents/defs.py +0 -0
  27. {bamboo_coding-0.1.8 → bamboo_coding-0.2.1}/src/bamboo_coding/agents/runtime.py +0 -0
  28. {bamboo_coding-0.1.8 → bamboo_coding-0.2.1}/src/bamboo_coding/agents/sessions.py +0 -0
  29. {bamboo_coding-0.1.8 → bamboo_coding-0.2.1}/src/bamboo_coding/agents/skills.py +0 -0
  30. {bamboo_coding-0.1.8 → bamboo_coding-0.2.1}/src/bamboo_coding/agents/skills_install.py +0 -0
  31. {bamboo_coding-0.1.8 → bamboo_coding-0.2.1}/src/bamboo_coding/agents/store.py +0 -0
  32. {bamboo_coding-0.1.8 → bamboo_coding-0.2.1}/src/bamboo_coding/agents/streams.py +0 -0
  33. {bamboo_coding-0.1.8 → bamboo_coding-0.2.1}/src/bamboo_coding/agents/tasks.py +0 -0
  34. {bamboo_coding-0.1.8 → bamboo_coding-0.2.1}/src/bamboo_coding/agents/types.py +0 -0
  35. {bamboo_coding-0.1.8 → bamboo_coding-0.2.1}/src/bamboo_coding/capabilities/__init__.py +0 -0
  36. {bamboo_coding-0.1.8 → bamboo_coding-0.2.1}/src/bamboo_coding/chat/__init__.py +0 -0
  37. {bamboo_coding-0.1.8 → bamboo_coding-0.2.1}/src/bamboo_coding/chat/__main__.py +0 -0
  38. {bamboo_coding-0.1.8 → bamboo_coding-0.2.1}/src/bamboo_coding/chat/adapter.py +0 -0
  39. /bamboo_coding-0.1.8/src/bamboo_coding/chat/pty_session.py → /bamboo_coding-0.2.1/src/bamboo_coding/chat/pty_session_unix.py +0 -0
  40. {bamboo_coding-0.1.8 → bamboo_coding-0.2.1}/src/bamboo_coding/chat/runtime.py +0 -0
  41. {bamboo_coding-0.1.8 → bamboo_coding-0.2.1}/src/bamboo_coding/chat/session.py +0 -0
  42. {bamboo_coding-0.1.8 → bamboo_coding-0.2.1}/src/bamboo_coding/chat/types.py +0 -0
  43. {bamboo_coding-0.1.8 → bamboo_coding-0.2.1}/src/bamboo_coding/chat/virtual_screen.py +0 -0
  44. {bamboo_coding-0.1.8 → bamboo_coding-0.2.1}/src/bamboo_coding/git_utils.py +0 -0
  45. {bamboo_coding-0.1.8 → bamboo_coding-0.2.1}/src/bamboo_coding/journal.py +0 -0
  46. {bamboo_coding-0.1.8 → bamboo_coding-0.2.1}/src/bamboo_coding/scrollback_buffer.py +0 -0
  47. {bamboo_coding-0.1.8 → bamboo_coding-0.2.1}/src/bamboo_coding.egg-info/dependency_links.txt +0 -0
  48. {bamboo_coding-0.1.8 → bamboo_coding-0.2.1}/src/bamboo_coding.egg-info/entry_points.txt +0 -0
  49. {bamboo_coding-0.1.8 → bamboo_coding-0.2.1}/src/bamboo_coding.egg-info/top_level.txt +0 -0
@@ -1,14 +1,19 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: bamboo-coding
3
- Version: 0.1.8
3
+ Version: 0.2.1
4
4
  Summary: Bamboo Coding outbound client
5
+ Classifier: Operating System :: POSIX :: Linux
6
+ Classifier: Operating System :: Microsoft :: Windows
7
+ Classifier: Operating System :: MacOS
5
8
  Requires-Python: >=3.12
6
9
  Description-Content-Type: text/markdown
7
10
  Requires-Dist: gitpython==3.1.40
8
11
  Requires-Dist: websockets==13.1
9
12
  Requires-Dist: pydantic>=2.0
10
- Requires-Dist: bamboo-ssh
13
+ Requires-Dist: bamboo-ssh>=0.2.0
11
14
  Requires-Dist: pyte>=0.8.2
15
+ Requires-Dist: psutil>=5.9
16
+ Requires-Dist: pywinpty>=2.0; sys_platform == "win32"
12
17
 
13
18
  # bamboo-coding
14
19
 
@@ -4,16 +4,23 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "bamboo-coding"
7
- version = "0.1.8"
7
+ version = "0.2.1"
8
8
  description = "Bamboo Coding outbound client"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.12"
11
+ classifiers = [
12
+ "Operating System :: POSIX :: Linux",
13
+ "Operating System :: Microsoft :: Windows",
14
+ "Operating System :: MacOS",
15
+ ]
11
16
  dependencies = [
12
17
  "gitpython==3.1.40",
13
18
  "websockets==13.1",
14
19
  "pydantic>=2.0",
15
- "bamboo-ssh",
20
+ "bamboo-ssh>=0.2.0",
16
21
  "pyte>=0.8.2",
22
+ "psutil>=5.9",
23
+ "pywinpty>=2.0; sys_platform == 'win32'",
17
24
  ]
18
25
 
19
26
  [project.scripts]
@@ -4,6 +4,7 @@ import logging
4
4
  import os
5
5
  import shutil
6
6
  import subprocess
7
+ import sys
7
8
  import threading
8
9
  import time
9
10
  from concurrent.futures import ThreadPoolExecutor
@@ -31,6 +32,10 @@ def resolve_bin(def_: AgentDef) -> str | None:
31
32
  """
32
33
  override = os.environ.get(f"BAMBOO_CODING_{def_.id.upper()}_BIN")
33
34
  if override:
35
+ if sys.platform == "win32":
36
+ # CreateProcess won't resolve `.cmd`/`.exe`-less names; which()
37
+ # honors PATHEXT and passes absolute paths through.
38
+ return shutil.which(override)
34
39
  return override if os.access(override, os.X_OK) else None
35
40
  for candidate in (def_.bin, *def_.fallback_bins):
36
41
  path = shutil.which(candidate)
@@ -3,6 +3,7 @@ from __future__ import annotations
3
3
  import asyncio
4
4
  import logging
5
5
  import os
6
+ import shutil
6
7
 
7
8
  from .streams import get_parser
8
9
  from .types import DetectedAgent, RuntimeContext
@@ -62,7 +63,11 @@ class AgentProcess:
62
63
  def build_argv(self) -> list[str]:
63
64
  def_ = self.detected.def_
64
65
  args = def_.build_args(self.model, self.extra_dirs, self.detected.caps, self.ctx)
65
- return [self.detected.path, *args]
66
+ # Resolve right before the spawn: on Windows, CreateProcess does not
67
+ # resolve npm `.cmd` shims by itself; which() honors PATHEXT. On
68
+ # POSIX, which() on the already-absolute detected path is a no-op.
69
+ binary = shutil.which(self.detected.path) or self.detected.path
70
+ return [binary, *args]
66
71
 
67
72
  async def cancel(self) -> None:
68
73
  """SIGTERM the CLI (it flushes and exits); escalate to SIGKILL after a
@@ -13,6 +13,9 @@ def _allowed_roots(repositories: dict[str, str] | list[str] | tuple[str, ...]) -
13
13
  values = list(repositories.values()) or list(repositories.keys())
14
14
  else:
15
15
  values = list(repositories)
16
+ # Guarantee: paths are resolved here, on the client that owns the
17
+ # filesystem. The server treats client paths as opaque, platform-tagged
18
+ # strings and never resolves them itself.
16
19
  return [str(Path(item).expanduser().resolve()) for item in values]
17
20
 
18
21
 
@@ -0,0 +1,15 @@
1
+ """Platform switch for ChatPtySession.
2
+
3
+ On Windows the ConPTY-backed implementation is used; everywhere else the
4
+ original Unix PTY implementation is imported unchanged (``pty_session_unix``
5
+ is the old ``pty_session.py``, renamed without edits).
6
+ """
7
+
8
+ import sys
9
+
10
+ if sys.platform == "win32":
11
+ from .pty_session_win import ChatPtySession
12
+ else:
13
+ from .pty_session_unix import ChatPtySession
14
+
15
+ __all__ = ["ChatPtySession"]
@@ -0,0 +1,125 @@
1
+ """ConPTY-backed ChatPtySession for native Windows.
2
+
3
+ Mirrors the shape of the Unix implementation (``master_fd``, ``process``,
4
+ ``closed``, ``start``/``write``/``resize``/``close``) so it composes with
5
+ ``bamboo_ssh.stream_terminal_output`` (whose Windows backend reads from the
6
+ pywinpty handle stored in ``master_fd``).
7
+
8
+ Unlike the shell-oriented ``bamboo_ssh`` session, this spawns an arbitrary
9
+ argv directly. Windows specifics:
10
+
11
+ - npm-installed CLIs are ``.cmd`` shims that ``CreateProcess`` does not
12
+ resolve by itself, so argv[0] is resolved with ``shutil.which`` (which
13
+ honors ``PATHEXT``) before spawning.
14
+ - There are no process groups; ``close()`` terminates the process and then
15
+ uses ``taskkill /T /F`` to reap the whole child tree — the Windows
16
+ replacement for ``os.killpg``.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import contextlib
22
+ import os
23
+ import shutil
24
+ import subprocess
25
+ import time
26
+ from dataclasses import dataclass
27
+ from typing import Any
28
+
29
+ _CLOSE_WAIT_SECONDS = 1.0
30
+
31
+
32
+ class _ProcessAdapter:
33
+ """subprocess.Popen-ish view over a pywinpty PtyProcess."""
34
+
35
+ def __init__(self, pty_process: Any):
36
+ self._pty = pty_process
37
+
38
+ @property
39
+ def pid(self) -> int:
40
+ return self._pty.pid
41
+
42
+ @property
43
+ def returncode(self) -> int | None:
44
+ if self._pty.isalive():
45
+ return None
46
+ return self._pty.exitstatus
47
+
48
+ def poll(self) -> int | None:
49
+ return self.returncode
50
+
51
+ def wait(self, timeout: float | None = None) -> int | None:
52
+ deadline = None if timeout is None else time.monotonic() + timeout
53
+ while self._pty.isalive():
54
+ if deadline is not None and time.monotonic() >= deadline:
55
+ return None
56
+ time.sleep(0.05)
57
+ return self._pty.exitstatus
58
+
59
+
60
+ @dataclass
61
+ class ChatPtySession:
62
+ # Named ``master_fd`` for interface compatibility with the unix backend;
63
+ # holds the pywinpty PtyProcess handle rather than a file descriptor.
64
+ master_fd: Any
65
+ process: _ProcessAdapter
66
+ closed: bool = False
67
+
68
+ @classmethod
69
+ def start(
70
+ cls,
71
+ *,
72
+ argv: list[str],
73
+ cwd: str | None = None,
74
+ cols: int = 120,
75
+ rows: int = 32,
76
+ env: dict[str, str] | None = None,
77
+ ) -> "ChatPtySession":
78
+ from winpty import PtyProcess # pywinpty; Windows-only
79
+
80
+ if not argv:
81
+ raise ValueError("argv must not be empty")
82
+ merged_env = os.environ.copy()
83
+ merged_env.setdefault("TERM", "xterm-256color")
84
+ if env:
85
+ merged_env.update(env)
86
+ resolved = shutil.which(argv[0]) or argv[0]
87
+ pty_process = PtyProcess.spawn(
88
+ [resolved, *argv[1:]],
89
+ cwd=cwd or None,
90
+ env=merged_env,
91
+ dimensions=(rows, cols),
92
+ )
93
+ return cls(master_fd=pty_process, process=_ProcessAdapter(pty_process))
94
+
95
+ def write(self, data: str | bytes) -> None:
96
+ if self.closed:
97
+ return
98
+ payload = data.decode("utf-8", errors="replace") if isinstance(data, bytes) else data
99
+ self.master_fd.write(payload)
100
+
101
+ def resize(self, cols: int, rows: int) -> None:
102
+ if self.closed:
103
+ return
104
+ self.master_fd.setwinsize(rows, cols)
105
+
106
+ def close(self) -> None:
107
+ if self.closed:
108
+ return
109
+ self.closed = True
110
+ pid = self.master_fd.pid
111
+ with contextlib.suppress(Exception):
112
+ self.master_fd.terminate()
113
+ deadline = time.monotonic() + _CLOSE_WAIT_SECONDS
114
+ while self.master_fd.isalive() and time.monotonic() < deadline:
115
+ time.sleep(0.05)
116
+ if self.master_fd.isalive():
117
+ # Kill the whole child tree; grandchildren survive terminate().
118
+ with contextlib.suppress(Exception):
119
+ subprocess.run(
120
+ ["taskkill", "/T", "/F", "/PID", str(pid)],
121
+ capture_output=True,
122
+ check=False,
123
+ )
124
+ with contextlib.suppress(Exception):
125
+ del self.master_fd.pty
@@ -34,6 +34,9 @@ class AgentConfig:
34
34
  token_path: Path
35
35
  journal_path: Path
36
36
  config_path: Path = field(default_factory=Path)
37
+ # Server-side login account that owns this client. Empty string = public:
38
+ # every logged-in user sees (and may approve) the pending registration.
39
+ user: str = ""
37
40
  profile: str = DEFAULT_PROFILE
38
41
  profile_exists: bool = True
39
42
  legacy_client_section: bool = False
@@ -208,6 +211,7 @@ def load_config(profile: str | None = None) -> AgentConfig:
208
211
  hostname = _first_env("BAMBOO_CODING_HOSTNAME", "AGENT_HOSTNAME") or str(
209
212
  section.get("hostname") or platform.node() or client_id
210
213
  )
214
+ user = _first_env("BAMBOO_CODING_USER") or str(section.get("user") or "")
211
215
  version = _first_env("BAMBOO_CODING_VERSION", "AGENT_VERSION") or str(section.get("version") or _default_version())
212
216
  allowed_roots = _coerce_allowed_roots(
213
217
  _first_env("BAMBOO_CODING_ALLOWED_ROOTS", "AGENT_ALLOWED_ROOTS", "AGENT_ROOT", "AGENT_REPO_PATH")
@@ -259,6 +263,7 @@ def load_config(profile: str | None = None) -> AgentConfig:
259
263
  controller_url=controller_url,
260
264
  client_id=client_id,
261
265
  hostname=hostname,
266
+ user=user,
262
267
  version=version,
263
268
  token=token,
264
269
  token_path=token_path,
@@ -74,7 +74,14 @@ class ControllerConnection:
74
74
  return self.websocket
75
75
 
76
76
  async def send_register(self, message: AgentRegisterMessage) -> dict:
77
- await self._send(message.model_dump())
77
+ payload = message.model_dump()
78
+ # Old servers forbid unknown keys (extra: "forbid") and model_dump()
79
+ # emits every field, including None defaults; only send owner_user
80
+ # when it is actually set, so a new client with no owner keeps
81
+ # working against an old server.
82
+ if payload.get("owner_user") is None:
83
+ payload.pop("owner_user", None)
84
+ await self._send(payload)
78
85
  return await self._receive()
79
86
 
80
87
  async def send_status(self, message: ClientStatusMessage) -> bool:
@@ -5,11 +5,14 @@ import asyncio
5
5
  import json
6
6
  import logging
7
7
  import os
8
+ import platform as _platform
8
9
  import random
9
10
  import re
10
11
  import signal
11
12
  import sys
12
13
  import time
14
+
15
+ import psutil
13
16
  from dataclasses import asdict
14
17
  from pathlib import Path
15
18
  from typing import Awaitable, Callable
@@ -222,13 +225,14 @@ class AgentRuntime:
222
225
  client_id=self.config.client_id,
223
226
  hostname=self.config.hostname,
224
227
  version=self.config.version,
225
- platform_name="python",
228
+ platform_name=_platform.system().lower(), # "linux" / "windows" / "darwin"
226
229
  capabilities=self.capabilities(),
227
230
  roots=self.roots(),
228
231
  active_tasks=active,
229
232
  queued_tasks=queued,
230
233
  max_concurrent_tasks=max_concurrent,
231
234
  connected=True,
235
+ owner_user=self.config.user or None,
232
236
  )
233
237
  registration = await self.connection.send_register(register)
234
238
  self.last_registration_message = str(registration.get("message") or "")
@@ -567,7 +571,7 @@ class AgentRuntime:
567
571
  client_id=self.config.client_id,
568
572
  hostname=self.config.hostname,
569
573
  version=self.config.version,
570
- platform_name="python",
574
+ platform_name=_platform.system().lower(), # "linux" / "windows" / "darwin"
571
575
  reason="task_update",
572
576
  capabilities=self.capabilities(),
573
577
  roots=self.roots(),
@@ -681,6 +685,11 @@ def build_parser() -> argparse.ArgumentParser:
681
685
  setup_parser.add_argument("--root", action="append", dest="roots", help="Allowed root path; pass more than once for multiple roots")
682
686
  setup_parser.add_argument("--client-id", help="Client identifier advertised to the controller")
683
687
  setup_parser.add_argument("--hostname", help="Hostname advertised to the controller")
688
+ setup_parser.add_argument(
689
+ "--user",
690
+ help="Server login account that owns this client; only that user sees "
691
+ "and approves it. Blank = every user.",
692
+ )
684
693
  setup_parser.add_argument("--force", action="store_true", help="Overwrite the profile if it already exists")
685
694
 
686
695
  run_parser = subparsers.add_parser("run", help="Run the Bamboo Coding client")
@@ -721,6 +730,9 @@ def _prompt(label: str, default: str) -> str:
721
730
  def _prompt_roots(default_roots: list[str]) -> list[str]:
722
731
  default_text = ", ".join(default_roots)
723
732
  raw = _prompt("Allowed roots (comma-separated)", default_text)
733
+ # Guarantee: roots leave the client absolute and resolved. The server
734
+ # treats client paths as opaque, platform-tagged strings and never
735
+ # resolves them itself.
724
736
  return [
725
737
  str(Path(item.strip()).expanduser().resolve())
726
738
  for item in raw.split(",")
@@ -775,12 +787,16 @@ def setup_command(args: argparse.Namespace) -> int:
775
787
  )
776
788
  return 1
777
789
 
790
+ # Guarantee: roots leave the client absolute and resolved. The server
791
+ # treats client paths as opaque, platform-tagged strings and never
792
+ # resolves them itself.
778
793
  default_roots = [
779
794
  str(Path(root).expanduser().resolve())
780
795
  for root in (args.roots or list(config.allowed_roots) or [str(Path.cwd().resolve())])
781
796
  ]
782
797
  default_client_id = args.client_id or config.client_id
783
798
  default_controller_url = args.controller_url or config.controller_url
799
+ default_user = args.user if args.user is not None else config.user
784
800
 
785
801
  if interactive:
786
802
  if profile_in_new_layout:
@@ -796,6 +812,7 @@ def setup_command(args: argparse.Namespace) -> int:
796
812
  client_id = _prompt("Client ID", default_client_id)
797
813
  default_hostname = args.hostname or config.hostname or client_id
798
814
  hostname = _prompt("Hostname", default_hostname)
815
+ owner_user = _prompt("Owner user (optional, blank = everyone)", default_user)
799
816
  allowed_roots = _prompt_roots(default_roots)
800
817
 
801
818
  roots_text = ", ".join(allowed_roots) if allowed_roots else "(none)"
@@ -804,6 +821,7 @@ def setup_command(args: argparse.Namespace) -> int:
804
821
  print(f" Controller URL : {controller_url}")
805
822
  print(f" Client ID : {client_id}")
806
823
  print(f" Hostname : {hostname}")
824
+ print(f" Owner user : {owner_user or '(everyone)'}")
807
825
  print(f" Allowed roots : {roots_text}")
808
826
  print()
809
827
  if not _confirm(f"Write profile '{profile}' to {config_path}?", default_yes=True):
@@ -813,6 +831,7 @@ def setup_command(args: argparse.Namespace) -> int:
813
831
  controller_url = default_controller_url
814
832
  client_id = default_client_id
815
833
  hostname = args.hostname or config.hostname or client_id
834
+ owner_user = default_user
816
835
  allowed_roots = default_roots
817
836
 
818
837
  raw_config = _load_toml(config_path) if config_path.exists() else {}
@@ -829,6 +848,9 @@ def setup_command(args: argparse.Namespace) -> int:
829
848
  "allowed_roots": list(allowed_roots),
830
849
  "max_concurrent_tasks": config.max_concurrent_tasks,
831
850
  }
851
+ # Written only when set, keeping TOML files of non-users unchanged.
852
+ if owner_user:
853
+ servers[profile]["user"] = owner_user
832
854
 
833
855
  migrated_files: list[Path] = []
834
856
  creating_default_in_new_layout = (
@@ -866,7 +888,10 @@ async def _run_runtime(runtime: AgentRuntime, stop_event: asyncio.Event | None =
866
888
  registered_signals: list[signal.Signals] = []
867
889
  if owns_signal_handlers:
868
890
  loop = asyncio.get_running_loop()
869
- for signum in (signal.SIGINT, signal.SIGTERM, signal.SIGHUP):
891
+ _signals = [signal.SIGINT, signal.SIGTERM]
892
+ if hasattr(signal, "SIGHUP"): # not on Windows
893
+ _signals.append(signal.SIGHUP)
894
+ for signum in _signals:
870
895
  try:
871
896
  loop.add_signal_handler(signum, stop_event.set)
872
897
  registered_signals.append(signum)
@@ -972,60 +997,45 @@ def _is_bamboo_client(argv: list[str]) -> bool:
972
997
 
973
998
 
974
999
  def _find_running_clients() -> list[tuple[int, list[str]]]:
975
- """Walk /proc to find running bamboo-coding client processes (Linux only)."""
1000
+ """Find running bamboo-coding client processes via psutil (cross-platform)."""
976
1001
  results: list[tuple[int, list[str]]] = []
977
- proc_dir = Path("/proc")
978
- if not proc_dir.exists():
979
- return results
980
1002
  self_pid = os.getpid()
981
- for entry in proc_dir.iterdir():
982
- if not entry.name.isdigit():
983
- continue
984
- pid = int(entry.name)
985
- if pid == self_pid:
986
- continue
987
- try:
988
- raw = (entry / "cmdline").read_bytes()
989
- except (FileNotFoundError, PermissionError, OSError):
990
- continue
991
- if not raw:
1003
+ for proc in psutil.process_iter(["pid", "cmdline"]):
1004
+ pid = proc.info.get("pid")
1005
+ if pid is None or pid == self_pid:
992
1006
  continue
993
- argv = [part.decode("utf-8", errors="replace") for part in raw.split(b"\x00") if part]
994
- if _is_bamboo_client(argv):
1007
+ argv = proc.info.get("cmdline") or []
1008
+ if argv and _is_bamboo_client(argv):
995
1009
  results.append((pid, argv))
996
1010
  return results
997
1011
 
998
1012
 
999
1013
  def _pid_alive(pid: int) -> bool:
1000
1014
  try:
1001
- os.kill(pid, 0)
1002
- except ProcessLookupError:
1015
+ # A zombie has already exited (its parent just hasn't reaped it) —
1016
+ # counting it alive would make `stop` report a false failure.
1017
+ return psutil.Process(pid).status() != psutil.STATUS_ZOMBIE
1018
+ except psutil.NoSuchProcess:
1003
1019
  return False
1004
- except PermissionError:
1020
+ except psutil.AccessDenied:
1005
1021
  return True
1006
- return True
1022
+ except Exception:
1023
+ return False
1007
1024
 
1008
1025
 
1009
1026
  def _lookup_client_argv(pid: int) -> list[str] | None:
1010
1027
  """Return argv for `pid` if it is a bamboo-coding client process, else None."""
1011
1028
  try:
1012
- raw = Path(f"/proc/{pid}/cmdline").read_bytes()
1013
- except (FileNotFoundError, PermissionError, OSError):
1014
- return None
1015
- if not raw:
1029
+ argv = psutil.Process(pid).cmdline()
1030
+ except (psutil.NoSuchProcess, psutil.AccessDenied, psutil.ZombieProcess, OSError):
1016
1031
  return None
1017
- argv = [part.decode("utf-8", errors="replace") for part in raw.split(b"\x00") if part]
1018
- if not _is_bamboo_client(argv):
1032
+ if not argv or not _is_bamboo_client(argv):
1019
1033
  return None
1020
1034
  return argv
1021
1035
 
1022
1036
 
1023
1037
  def status_command(args: argparse.Namespace) -> int:
1024
1038
  logging.basicConfig(level=logging.INFO, format="%(levelname)s %(message)s")
1025
- if sys.platform != "linux":
1026
- print("'bamboo-coding status' is only supported on Linux (it reads /proc).", file=sys.stderr)
1027
- return 2
1028
-
1029
1039
  clients = _find_running_clients()
1030
1040
  if not clients:
1031
1041
  print("No running bamboo-coding clients found.")
@@ -1039,10 +1049,6 @@ def status_command(args: argparse.Namespace) -> int:
1039
1049
 
1040
1050
  def stop_command(args: argparse.Namespace) -> int:
1041
1051
  logging.basicConfig(level=logging.INFO, format="%(levelname)s %(message)s")
1042
- if sys.platform != "linux":
1043
- print("'bamboo-coding stop' is only supported on Linux (it reads /proc).", file=sys.stderr)
1044
- return 2
1045
-
1046
1052
  requested_pids: list[int] = list(getattr(args, "pids", []) or [])
1047
1053
 
1048
1054
  if requested_pids:
@@ -1071,17 +1077,23 @@ def stop_command(args: argparse.Namespace) -> int:
1071
1077
  for pid, argv in clients:
1072
1078
  print(f" PID {pid}: {' '.join(argv)}")
1073
1079
 
1074
- sig = signal.SIGKILL if args.force else signal.SIGTERM
1080
+ # psutil kill()/terminate() replace SIGKILL/SIGTERM: SIGKILL does not
1081
+ # exist on Windows, and psutil maps both calls to the right primitive
1082
+ # on every OS.
1075
1083
  sig_name = "SIGKILL" if args.force else "SIGTERM"
1076
1084
  sent: list[int] = []
1077
1085
  for pid, _ in clients:
1078
1086
  try:
1079
- os.kill(pid, sig)
1087
+ proc = psutil.Process(pid)
1088
+ if args.force:
1089
+ proc.kill()
1090
+ else:
1091
+ proc.terminate()
1080
1092
  sent.append(pid)
1081
1093
  print(f"Sent {sig_name} to PID {pid}")
1082
- except ProcessLookupError:
1094
+ except psutil.NoSuchProcess:
1083
1095
  print(f"PID {pid} already exited")
1084
- except PermissionError:
1096
+ except psutil.AccessDenied:
1085
1097
  print(f"Permission denied for PID {pid}; rerun with sudo")
1086
1098
 
1087
1099
  if not sent or args.force:
@@ -99,6 +99,9 @@ class AgentRegisterMessage(ProtocolModel):
99
99
  protocol_version: int
100
100
  client_id: str | None = None
101
101
  client_info: ClientInfo
102
+ # Server-side login account that owns this client. Optional; None/empty
103
+ # means public: every user sees (and may approve) it.
104
+ owner_user: str | None = None
102
105
  capabilities: list[CapabilityDescriptor] = Field(default_factory=list)
103
106
  roots: list[RootDescriptor] = Field(default_factory=list)
104
107
  connectivity: ConnectivityState
@@ -24,7 +24,7 @@ def _build_common_payload(*, client_id: str, hostname: str, version: str, platfo
24
24
  }
25
25
 
26
26
 
27
- def build_register_message(*, client_id: str, hostname: str, version: str, platform_name: str, capabilities: list[dict], roots: list[dict] | None = None, repositories: list[dict] | None = None, active_tasks: int, queued_tasks: int, max_concurrent_tasks: int, connected: bool = True) -> AgentRegisterMessage:
27
+ def build_register_message(*, client_id: str, hostname: str, version: str, platform_name: str, capabilities: list[dict], roots: list[dict] | None = None, repositories: list[dict] | None = None, active_tasks: int, queued_tasks: int, max_concurrent_tasks: int, connected: bool = True, owner_user: str | None = None) -> AgentRegisterMessage:
28
28
  payload = _build_common_payload(
29
29
  client_id=client_id,
30
30
  hostname=hostname,
@@ -38,6 +38,10 @@ def build_register_message(*, client_id: str, hostname: str, version: str, platf
38
38
  max_concurrent_tasks=max_concurrent_tasks,
39
39
  connected=connected,
40
40
  )
41
+ # Added only when non-empty: status messages never carry ownership, and
42
+ # an unset owner must not appear in the payload at all (see send_register).
43
+ if owner_user:
44
+ payload["owner_user"] = owner_user
41
45
  return AgentRegisterMessage(**payload)
42
46
 
43
47
 
@@ -5,6 +5,7 @@ import inspect
5
5
  import os
6
6
  import shlex
7
7
  import signal as _signal
8
+ import sys
8
9
  import time
9
10
  from dataclasses import dataclass, field
10
11
  from pathlib import Path
@@ -43,6 +44,11 @@ def build_initial_cwd_command(path: str) -> str:
43
44
  target = Path(path).expanduser()
44
45
  resolved = target.resolve(strict=False)
45
46
  working_dir = resolved if resolved.is_dir() else resolved.parent
47
+ if sys.platform == "win32":
48
+ # A `cd "..."` both PowerShell and cmd accept; embedded quotes are
49
+ # doubled, and the line ending is \r\n.
50
+ escaped = str(working_dir).replace('"', '""')
51
+ return f'cd "{escaped}"\r\n'
46
52
  return f"cd {shlex.quote(str(working_dir))}\n"
47
53
 
48
54
 
@@ -210,6 +216,21 @@ class ClientTerminalRuntime:
210
216
  active = self.sessions.get(message.terminal_id)
211
217
  if active is None:
212
218
  return
219
+ if sys.platform == "win32":
220
+ # No process groups or tcgetpgrp on Windows. Ctrl+C travels
221
+ # through the ConPTY input stream; SIGTERM closes the session
222
+ # (the win backend tears down the console tree itself).
223
+ if message.signal == "SIGINT":
224
+ try:
225
+ active.session.write("\x03")
226
+ except Exception as exc:
227
+ await self._emit(
228
+ self.send_status,
229
+ TerminalStatusMessage(terminal_id=message.terminal_id, status='error', message=str(exc)),
230
+ )
231
+ else:
232
+ await self.close_terminal(message.terminal_id, reason='signalled')
233
+ return
213
234
  master_fd = active.session.master_fd
214
235
  try:
215
236
  pgid = os.tcgetpgrp(master_fd)
@@ -1,14 +1,19 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: bamboo-coding
3
- Version: 0.1.8
3
+ Version: 0.2.1
4
4
  Summary: Bamboo Coding outbound client
5
+ Classifier: Operating System :: POSIX :: Linux
6
+ Classifier: Operating System :: Microsoft :: Windows
7
+ Classifier: Operating System :: MacOS
5
8
  Requires-Python: >=3.12
6
9
  Description-Content-Type: text/markdown
7
10
  Requires-Dist: gitpython==3.1.40
8
11
  Requires-Dist: websockets==13.1
9
12
  Requires-Dist: pydantic>=2.0
10
- Requires-Dist: bamboo-ssh
13
+ Requires-Dist: bamboo-ssh>=0.2.0
11
14
  Requires-Dist: pyte>=0.8.2
15
+ Requires-Dist: psutil>=5.9
16
+ Requires-Dist: pywinpty>=2.0; sys_platform == "win32"
12
17
 
13
18
  # bamboo-coding
14
19
 
@@ -38,6 +38,8 @@ src/bamboo_coding/chat/__init__.py
38
38
  src/bamboo_coding/chat/__main__.py
39
39
  src/bamboo_coding/chat/adapter.py
40
40
  src/bamboo_coding/chat/pty_session.py
41
+ src/bamboo_coding/chat/pty_session_unix.py
42
+ src/bamboo_coding/chat/pty_session_win.py
41
43
  src/bamboo_coding/chat/runtime.py
42
44
  src/bamboo_coding/chat/session.py
43
45
  src/bamboo_coding/chat/types.py
@@ -0,0 +1,9 @@
1
+ gitpython==3.1.40
2
+ websockets==13.1
3
+ pydantic>=2.0
4
+ bamboo-ssh>=0.2.0
5
+ pyte>=0.8.2
6
+ psutil>=5.9
7
+
8
+ [:sys_platform == "win32"]
9
+ pywinpty>=2.0
@@ -1,5 +0,0 @@
1
- gitpython==3.1.40
2
- websockets==13.1
3
- pydantic>=2.0
4
- bamboo-ssh
5
- pyte>=0.8.2
File without changes
File without changes