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.
- {bamboo_coding-0.1.8 → bamboo_coding-0.2.1}/PKG-INFO +7 -2
- {bamboo_coding-0.1.8 → bamboo_coding-0.2.1}/pyproject.toml +9 -2
- {bamboo_coding-0.1.8 → bamboo_coding-0.2.1}/src/bamboo_coding/agents/detection.py +5 -0
- {bamboo_coding-0.1.8 → bamboo_coding-0.2.1}/src/bamboo_coding/agents/runner.py +6 -1
- {bamboo_coding-0.1.8 → bamboo_coding-0.2.1}/src/bamboo_coding/capabilities/repo.py +3 -0
- bamboo_coding-0.2.1/src/bamboo_coding/chat/pty_session.py +15 -0
- bamboo_coding-0.2.1/src/bamboo_coding/chat/pty_session_win.py +125 -0
- {bamboo_coding-0.1.8 → bamboo_coding-0.2.1}/src/bamboo_coding/config.py +5 -0
- {bamboo_coding-0.1.8 → bamboo_coding-0.2.1}/src/bamboo_coding/controller_client.py +8 -1
- {bamboo_coding-0.1.8 → bamboo_coding-0.2.1}/src/bamboo_coding/main.py +54 -42
- {bamboo_coding-0.1.8 → bamboo_coding-0.2.1}/src/bamboo_coding/protocol.py +3 -0
- {bamboo_coding-0.1.8 → bamboo_coding-0.2.1}/src/bamboo_coding/status.py +5 -1
- {bamboo_coding-0.1.8 → bamboo_coding-0.2.1}/src/bamboo_coding/terminal_runtime.py +21 -0
- {bamboo_coding-0.1.8 → bamboo_coding-0.2.1}/src/bamboo_coding.egg-info/PKG-INFO +7 -2
- {bamboo_coding-0.1.8 → bamboo_coding-0.2.1}/src/bamboo_coding.egg-info/SOURCES.txt +2 -0
- bamboo_coding-0.2.1/src/bamboo_coding.egg-info/requires.txt +9 -0
- bamboo_coding-0.1.8/src/bamboo_coding.egg-info/requires.txt +0 -5
- {bamboo_coding-0.1.8 → bamboo_coding-0.2.1}/README.md +0 -0
- {bamboo_coding-0.1.8 → bamboo_coding-0.2.1}/setup.cfg +0 -0
- {bamboo_coding-0.1.8 → bamboo_coding-0.2.1}/src/bamboo_coding/__init__.py +0 -0
- {bamboo_coding-0.1.8 → bamboo_coding-0.2.1}/src/bamboo_coding/agents/__init__.py +0 -0
- {bamboo_coding-0.1.8 → bamboo_coding-0.2.1}/src/bamboo_coding/agents/builtin_skills/__init__.py +0 -0
- {bamboo_coding-0.1.8 → bamboo_coding-0.2.1}/src/bamboo_coding/agents/builtin_skills/skill-create/SKILL.md +0 -0
- {bamboo_coding-0.1.8 → bamboo_coding-0.2.1}/src/bamboo_coding/agents/builtin_skills/skill-optimize/SKILL.md +0 -0
- {bamboo_coding-0.1.8 → bamboo_coding-0.2.1}/src/bamboo_coding/agents/compose.py +0 -0
- {bamboo_coding-0.1.8 → bamboo_coding-0.2.1}/src/bamboo_coding/agents/defs.py +0 -0
- {bamboo_coding-0.1.8 → bamboo_coding-0.2.1}/src/bamboo_coding/agents/runtime.py +0 -0
- {bamboo_coding-0.1.8 → bamboo_coding-0.2.1}/src/bamboo_coding/agents/sessions.py +0 -0
- {bamboo_coding-0.1.8 → bamboo_coding-0.2.1}/src/bamboo_coding/agents/skills.py +0 -0
- {bamboo_coding-0.1.8 → bamboo_coding-0.2.1}/src/bamboo_coding/agents/skills_install.py +0 -0
- {bamboo_coding-0.1.8 → bamboo_coding-0.2.1}/src/bamboo_coding/agents/store.py +0 -0
- {bamboo_coding-0.1.8 → bamboo_coding-0.2.1}/src/bamboo_coding/agents/streams.py +0 -0
- {bamboo_coding-0.1.8 → bamboo_coding-0.2.1}/src/bamboo_coding/agents/tasks.py +0 -0
- {bamboo_coding-0.1.8 → bamboo_coding-0.2.1}/src/bamboo_coding/agents/types.py +0 -0
- {bamboo_coding-0.1.8 → bamboo_coding-0.2.1}/src/bamboo_coding/capabilities/__init__.py +0 -0
- {bamboo_coding-0.1.8 → bamboo_coding-0.2.1}/src/bamboo_coding/chat/__init__.py +0 -0
- {bamboo_coding-0.1.8 → bamboo_coding-0.2.1}/src/bamboo_coding/chat/__main__.py +0 -0
- {bamboo_coding-0.1.8 → bamboo_coding-0.2.1}/src/bamboo_coding/chat/adapter.py +0 -0
- /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
- {bamboo_coding-0.1.8 → bamboo_coding-0.2.1}/src/bamboo_coding/chat/runtime.py +0 -0
- {bamboo_coding-0.1.8 → bamboo_coding-0.2.1}/src/bamboo_coding/chat/session.py +0 -0
- {bamboo_coding-0.1.8 → bamboo_coding-0.2.1}/src/bamboo_coding/chat/types.py +0 -0
- {bamboo_coding-0.1.8 → bamboo_coding-0.2.1}/src/bamboo_coding/chat/virtual_screen.py +0 -0
- {bamboo_coding-0.1.8 → bamboo_coding-0.2.1}/src/bamboo_coding/git_utils.py +0 -0
- {bamboo_coding-0.1.8 → bamboo_coding-0.2.1}/src/bamboo_coding/journal.py +0 -0
- {bamboo_coding-0.1.8 → bamboo_coding-0.2.1}/src/bamboo_coding/scrollback_buffer.py +0 -0
- {bamboo_coding-0.1.8 → bamboo_coding-0.2.1}/src/bamboo_coding.egg-info/dependency_links.txt +0 -0
- {bamboo_coding-0.1.8 → bamboo_coding-0.2.1}/src/bamboo_coding.egg-info/entry_points.txt +0 -0
- {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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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="
|
|
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="
|
|
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
|
-
|
|
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
|
-
"""
|
|
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
|
|
982
|
-
|
|
983
|
-
|
|
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 =
|
|
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
|
-
|
|
1002
|
-
|
|
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
|
|
1020
|
+
except psutil.AccessDenied:
|
|
1005
1021
|
return True
|
|
1006
|
-
|
|
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
|
-
|
|
1013
|
-
except (
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
1094
|
+
except psutil.NoSuchProcess:
|
|
1083
1095
|
print(f"PID {pid} already exited")
|
|
1084
|
-
except
|
|
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
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{bamboo_coding-0.1.8 → bamboo_coding-0.2.1}/src/bamboo_coding/agents/builtin_skills/__init__.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|