bamboo-coding 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. agent/__init__.py +0 -0
  2. agent/bamboo_git_agent/__init__.py +0 -0
  3. agent/bamboo_git_agent/capabilities/__init__.py +0 -0
  4. agent/bamboo_git_agent/capabilities/repo.py +189 -0
  5. agent/bamboo_git_agent/config.py +76 -0
  6. agent/bamboo_git_agent/controller_client.py +73 -0
  7. agent/bamboo_git_agent/journal.py +209 -0
  8. agent/bamboo_git_agent/main.py +164 -0
  9. agent/bamboo_git_agent/protocol.py +21 -0
  10. agent/bamboo_git_agent/status.py +59 -0
  11. api/__init__.py +2 -0
  12. api/commit.py +82 -0
  13. api/repository.py +592 -0
  14. bamboo_coding/__init__.py +3 -0
  15. bamboo_coding/client/__init__.py +3 -0
  16. bamboo_coding/client/capabilities/__init__.py +1 -0
  17. bamboo_coding/client/capabilities/repo.py +279 -0
  18. bamboo_coding/client/config.py +179 -0
  19. bamboo_coding/client/controller_client.py +112 -0
  20. bamboo_coding/client/journal.py +1 -0
  21. bamboo_coding/client/main.py +425 -0
  22. bamboo_coding/client/status.py +59 -0
  23. bamboo_coding/client/terminal_runtime.py +251 -0
  24. bamboo_coding/server/__init__.py +3 -0
  25. bamboo_coding/server/core/__init__.py +1 -0
  26. bamboo_coding/server/core/config.py +1 -0
  27. bamboo_coding/server/main.py +39 -0
  28. bamboo_coding/shared/__init__.py +39 -0
  29. bamboo_coding/shared/protocol.py +1 -0
  30. bamboo_coding-0.1.0.dist-info/METADATA +284 -0
  31. bamboo_coding-0.1.0.dist-info/RECORD +62 -0
  32. bamboo_coding-0.1.0.dist-info/WHEEL +5 -0
  33. bamboo_coding-0.1.0.dist-info/entry_points.txt +3 -0
  34. bamboo_coding-0.1.0.dist-info/top_level.txt +6 -0
  35. controller/__init__.py +0 -0
  36. controller/app/__init__.py +0 -0
  37. controller/app/api/__init__.py +0 -0
  38. controller/app/api/agents/__init__.py +3 -0
  39. controller/app/api/agents/ws.py +133 -0
  40. controller/app/api/public/__init__.py +11 -0
  41. controller/app/api/public/repos.py +207 -0
  42. controller/app/api/public/terminals.py +64 -0
  43. controller/app/core/__init__.py +0 -0
  44. controller/app/core/config.py +20 -0
  45. controller/app/core/errors.py +14 -0
  46. controller/app/db/__init__.py +0 -0
  47. controller/app/db/models.py +42 -0
  48. controller/app/db/session.py +74 -0
  49. controller/app/main.py +71 -0
  50. controller/app/schemas/__init__.py +23 -0
  51. controller/app/schemas/agent_messages.py +21 -0
  52. controller/app/schemas/public.py +3 -0
  53. controller/app/services/__init__.py +0 -0
  54. controller/app/services/agents.py +108 -0
  55. controller/app/services/registrations.py +17 -0
  56. controller/app/services/repositories.py +85 -0
  57. controller/app/services/router.py +140 -0
  58. controller/app/services/tasks.py +104 -0
  59. controller/app/services/terminals.py +148 -0
  60. git_utils.py +2238 -0
  61. shared/__init__.py +39 -0
  62. shared/protocol.py +253 -0
@@ -0,0 +1,251 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import inspect
5
+ import shlex
6
+ from dataclasses import dataclass, field
7
+ from pathlib import Path
8
+ from typing import Any, Awaitable, Callable
9
+
10
+ from bamboo_ssh import TerminalSession, stream_terminal_output
11
+
12
+ from bamboo_coding.shared.protocol import (
13
+ TerminalClosedMessage,
14
+ TerminalHeartbeatMessage,
15
+ TerminalInputMessage,
16
+ TerminalOpenMessage,
17
+ TerminalOutputMessage,
18
+ TerminalStatusMessage,
19
+ )
20
+
21
+
22
+ MessageCallback = Callable[[Any], Awaitable[None] | None]
23
+ DEFAULT_OUTPUT_FLUSH_INTERVAL = 0.05
24
+ DEFAULT_HEARTBEAT_INTERVAL = 10.0
25
+ MAX_PENDING_OUTPUT_CHARS = 262144
26
+ TRUNCATED_OUTPUT_NOTICE = "\r\n[terminal output truncated while relay caught up]\r\n"
27
+
28
+
29
+ def build_initial_cwd_command(path: str) -> str:
30
+ if not path:
31
+ return ''
32
+ target = Path(path).expanduser()
33
+ resolved = target.resolve(strict=False)
34
+ working_dir = resolved if resolved.is_dir() else resolved.parent
35
+ return f"cd {shlex.quote(str(working_dir))}\n"
36
+
37
+
38
+ @dataclass
39
+ class ActiveTerminalSession:
40
+ terminal_id: str
41
+ session: TerminalSession
42
+ reader_task: asyncio.Task | None = None
43
+ output_task: asyncio.Task | None = None
44
+ heartbeat_task: asyncio.Task | None = None
45
+ output_event: asyncio.Event = field(default_factory=asyncio.Event)
46
+ pending_output: list[str] = field(default_factory=list)
47
+ pending_output_chars: int = 0
48
+ output_truncated: bool = False
49
+
50
+
51
+ class ClientTerminalRuntime:
52
+ def __init__(
53
+ self,
54
+ *,
55
+ send_status: MessageCallback,
56
+ send_output: MessageCallback,
57
+ send_closed: MessageCallback,
58
+ send_heartbeat: MessageCallback | None = None,
59
+ output_flush_interval: float = DEFAULT_OUTPUT_FLUSH_INTERVAL,
60
+ heartbeat_interval: float = DEFAULT_HEARTBEAT_INTERVAL,
61
+ max_pending_output_chars: int = MAX_PENDING_OUTPUT_CHARS,
62
+ ):
63
+ self.send_status = send_status
64
+ self.send_output = send_output
65
+ self.send_closed = send_closed
66
+ self.send_heartbeat = send_heartbeat or (lambda _message: None)
67
+ self.output_flush_interval = output_flush_interval
68
+ self.heartbeat_interval = heartbeat_interval
69
+ self.max_pending_output_chars = max_pending_output_chars
70
+ self.sessions: dict[str, ActiveTerminalSession] = {}
71
+
72
+ async def open_terminal(self, message: TerminalOpenMessage) -> None:
73
+ await self.close_terminal(message.terminal_id, reason='replaced', emit_closed=False)
74
+ await self._emit(
75
+ self.send_status,
76
+ TerminalStatusMessage(terminal_id=message.terminal_id, status='starting'),
77
+ )
78
+ session = TerminalSession.start(cols=message.cols, rows=message.rows, command=build_initial_cwd_command(message.path))
79
+ active = ActiveTerminalSession(
80
+ terminal_id=message.terminal_id,
81
+ session=session,
82
+ )
83
+ self.sessions[message.terminal_id] = active
84
+ active.output_task = asyncio.create_task(
85
+ self._flush_output_loop(message.terminal_id),
86
+ name=f'terminal-output:{message.terminal_id}',
87
+ )
88
+ active.heartbeat_task = asyncio.create_task(
89
+ self._heartbeat_loop(message.terminal_id),
90
+ name=f'terminal-heartbeat:{message.terminal_id}',
91
+ )
92
+ active.reader_task = asyncio.create_task(
93
+ self._stream_terminal(message.terminal_id, session),
94
+ name=f'terminal:{message.terminal_id}',
95
+ )
96
+
97
+ async def input_terminal(self, message: TerminalInputMessage) -> None:
98
+ active = self.sessions.get(message.terminal_id)
99
+ if active is None:
100
+ return
101
+ try:
102
+ active.session.write(message.data)
103
+ except Exception as exc:
104
+ await self._emit(
105
+ self.send_status,
106
+ TerminalStatusMessage(terminal_id=message.terminal_id, status='error', message=str(exc)),
107
+ )
108
+ await self.close_terminal(message.terminal_id, reason='write_failed')
109
+
110
+ async def resize_terminal(self, terminal_id: str, *, cols: int, rows: int) -> None:
111
+ active = self.sessions.get(terminal_id)
112
+ if active is None:
113
+ return
114
+ try:
115
+ active.session.resize(cols, rows)
116
+ except Exception as exc:
117
+ await self._emit(
118
+ self.send_status,
119
+ TerminalStatusMessage(terminal_id=terminal_id, status='error', message=str(exc)),
120
+ )
121
+ await self.close_terminal(terminal_id, reason='resize_failed')
122
+
123
+ async def close_terminal(
124
+ self,
125
+ terminal_id: str,
126
+ *,
127
+ reason: str = 'closed',
128
+ emit_closed: bool = True,
129
+ exit_code: int | None = None,
130
+ ) -> None:
131
+ active = self.sessions.pop(terminal_id, None)
132
+ if active is None:
133
+ return
134
+ active.session.close()
135
+ current_task = asyncio.current_task()
136
+ for task in (active.reader_task, active.output_task, active.heartbeat_task):
137
+ if task is None or task is current_task:
138
+ continue
139
+ task.cancel()
140
+ try:
141
+ await task
142
+ except asyncio.CancelledError:
143
+ pass
144
+ await self._flush_pending_output(active)
145
+ if emit_closed:
146
+ if exit_code is None:
147
+ exit_code = getattr(getattr(active.session, 'process', None), 'returncode', None)
148
+ await self._emit(
149
+ self.send_closed,
150
+ TerminalClosedMessage(terminal_id=terminal_id, reason=reason, exit_code=exit_code),
151
+ )
152
+
153
+ async def close_all(self, reason: str = 'shutdown') -> None:
154
+ for terminal_id in list(self.sessions):
155
+ await self.close_terminal(terminal_id, reason=reason)
156
+
157
+ async def _stream_terminal(self, terminal_id: str, session: TerminalSession) -> None:
158
+ async def send_json(payload: dict) -> None:
159
+ message_type = payload.get('type')
160
+ if message_type == 'ready':
161
+ await self._emit(self.send_status, TerminalStatusMessage(terminal_id=terminal_id, status='ready'))
162
+ elif message_type == 'cmd':
163
+ self._queue_output(terminal_id, str(payload.get('data') or ''))
164
+
165
+ try:
166
+ await stream_terminal_output(session, send_json)
167
+ except asyncio.CancelledError:
168
+ raise
169
+ except Exception as exc:
170
+ if terminal_id in self.sessions:
171
+ await self._emit(
172
+ self.send_status,
173
+ TerminalStatusMessage(terminal_id=terminal_id, status='error', message=str(exc)),
174
+ )
175
+ finally:
176
+ active = self.sessions.get(terminal_id)
177
+ if active is not None:
178
+ exit_code = getattr(getattr(active.session, 'process', None), 'returncode', None)
179
+ await self.close_terminal(terminal_id, reason='session_ended', exit_code=exit_code)
180
+
181
+ def _queue_output(self, terminal_id: str, data: str) -> None:
182
+ if not data:
183
+ return
184
+ active = self.sessions.get(terminal_id)
185
+ if active is None:
186
+ return
187
+ active.pending_output.append(data)
188
+ active.pending_output_chars += len(data)
189
+ while active.pending_output_chars > self.max_pending_output_chars and active.pending_output:
190
+ removed = active.pending_output.pop(0)
191
+ active.pending_output_chars -= len(removed)
192
+ active.output_truncated = True
193
+ active.output_event.set()
194
+
195
+ async def _flush_output_loop(self, terminal_id: str) -> None:
196
+ try:
197
+ while True:
198
+ active = self.sessions.get(terminal_id)
199
+ if active is None:
200
+ return
201
+ await active.output_event.wait()
202
+ await asyncio.sleep(self.output_flush_interval)
203
+ active = self.sessions.get(terminal_id)
204
+ if active is None:
205
+ return
206
+ await self._flush_pending_output(active)
207
+ except asyncio.CancelledError:
208
+ raise
209
+
210
+ async def _flush_pending_output(self, active: ActiveTerminalSession) -> None:
211
+ if not active.pending_output and not active.output_truncated:
212
+ active.output_event.clear()
213
+ return
214
+ payload = ''.join(active.pending_output)
215
+ active.pending_output.clear()
216
+ active.pending_output_chars = 0
217
+ if active.output_truncated:
218
+ payload = f'{TRUNCATED_OUTPUT_NOTICE}{payload}'
219
+ active.output_truncated = False
220
+ active.output_event.clear()
221
+ if payload:
222
+ await self._emit(
223
+ self.send_output,
224
+ TerminalOutputMessage(terminal_id=active.terminal_id, data=payload),
225
+ )
226
+
227
+ async def _heartbeat_loop(self, terminal_id: str) -> None:
228
+ try:
229
+ while True:
230
+ await asyncio.sleep(self.heartbeat_interval)
231
+ active = self.sessions.get(terminal_id)
232
+ if active is None:
233
+ return
234
+ process = getattr(active.session, 'process', None)
235
+ poll = getattr(process, 'poll', None)
236
+ exit_code = poll() if callable(poll) else getattr(process, 'returncode', None)
237
+ process_alive = exit_code is None
238
+ await self._emit(
239
+ self.send_heartbeat,
240
+ TerminalHeartbeatMessage(terminal_id=terminal_id, process_alive=process_alive),
241
+ )
242
+ if not process_alive:
243
+ await self.close_terminal(terminal_id, reason='session_ended', exit_code=exit_code)
244
+ return
245
+ except asyncio.CancelledError:
246
+ raise
247
+
248
+ async def _emit(self, callback: MessageCallback, message: Any) -> None:
249
+ result = callback(message)
250
+ if inspect.isawaitable(result):
251
+ await result
@@ -0,0 +1,3 @@
1
+ from .main import app, create_app, main
2
+
3
+ __all__ = ["app", "create_app", "main"]
@@ -0,0 +1 @@
1
+ """Server configuration helpers."""
@@ -0,0 +1 @@
1
+ from controller.app.core.config import * # noqa: F401,F403
@@ -0,0 +1,39 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import os
5
+
6
+ import uvicorn
7
+ from dotenv import load_dotenv
8
+
9
+ from controller.app.main import app, create_app
10
+
11
+
12
+ load_dotenv()
13
+
14
+
15
+ def main() -> None:
16
+ parser = argparse.ArgumentParser(description="Bamboo Coding")
17
+ parser.add_argument("--dev", action="store_true", help="Run in development mode with auto-reload")
18
+ parser.add_argument("--port", type=int, help="Port to run on")
19
+ parser.add_argument("--host", help="Host to bind to")
20
+ args = parser.parse_args()
21
+
22
+ host = args.host or os.getenv("HOST", "0.0.0.0")
23
+ port = args.port or int(os.getenv("PORT", 8100))
24
+ reload_enabled = bool(args.dev)
25
+
26
+ uvicorn.run(
27
+ "bamboo_coding.server.main:app",
28
+ host=host,
29
+ port=port,
30
+ reload=reload_enabled,
31
+ factory=False,
32
+ )
33
+
34
+
35
+ __all__ = ["app", "create_app", "main"]
36
+
37
+
38
+ if __name__ == "__main__":
39
+ main()
@@ -0,0 +1,39 @@
1
+ from .protocol import (
2
+ AgentRegisterMessage,
3
+ CapabilityDescriptor,
4
+ ClientInfo,
5
+ ClientStatusMessage,
6
+ PublicRepositorySummary,
7
+ PublicRootSummary,
8
+ RepositoryDescriptor,
9
+ RootDescriptor,
10
+ RuntimeHealth,
11
+ TaskMessage,
12
+ TerminalCloseMessage,
13
+ TerminalClosedMessage,
14
+ TerminalInputMessage,
15
+ TerminalOpenMessage,
16
+ TerminalOutputMessage,
17
+ TerminalResizeMessage,
18
+ TerminalStatusMessage,
19
+ )
20
+
21
+ __all__ = [
22
+ "AgentRegisterMessage",
23
+ "CapabilityDescriptor",
24
+ "ClientInfo",
25
+ "ClientStatusMessage",
26
+ "PublicRepositorySummary",
27
+ "PublicRootSummary",
28
+ "RepositoryDescriptor",
29
+ "RootDescriptor",
30
+ "RuntimeHealth",
31
+ "TaskMessage",
32
+ "TerminalCloseMessage",
33
+ "TerminalClosedMessage",
34
+ "TerminalInputMessage",
35
+ "TerminalOpenMessage",
36
+ "TerminalOutputMessage",
37
+ "TerminalResizeMessage",
38
+ "TerminalStatusMessage",
39
+ ]
@@ -0,0 +1 @@
1
+ from shared.protocol import * # noqa: F401,F403
@@ -0,0 +1,284 @@
1
+ Metadata-Version: 2.4
2
+ Name: bamboo-coding
3
+ Version: 0.1.0
4
+ Summary: Bamboo Coding controller and client
5
+ Requires-Python: >=3.12
6
+ Description-Content-Type: text/markdown
7
+ Requires-Dist: fastapi==0.115.0
8
+ Requires-Dist: uvicorn==0.30.6
9
+ Requires-Dist: gitpython==3.1.40
10
+ Requires-Dist: python-dotenv==1.0.0
11
+ Requires-Dist: websockets==13.1
12
+ Requires-Dist: httpx==0.27.2
13
+ Requires-Dist: bamboo-ssh
14
+
15
+ # Bamboo Coding
16
+
17
+ Bamboo Coding is a FastAPI-based workspace and Git review system with an outbound Python client and a browser UI.
18
+
19
+ ## Architecture
20
+
21
+ - `bamboo_coding/server/`: packaged server entrypoint and public app surface
22
+ - `bamboo_coding/client/`: packaged outbound client runtime
23
+ - `controller/app/`: controller internals used by the packaged server entrypoint
24
+ - `shared/`: legacy shared protocol implementation backing the new namespace
25
+ - `static/`: browser UI assets
26
+
27
+ The controller never reads arbitrary local paths itself. Clients connect outward, advertise allowed roots, wait for approval, and then handle tasks for paths inside those roots.
28
+
29
+ ## Features
30
+
31
+ - Web approval for pending clients
32
+ - Client-first workspace selection in the UI
33
+ - Direct folder browsing, including non-Git folders
34
+ - Automatic Git mode when the selected path is inside a repository
35
+ - Changed-files-first tree, diff viewer, commit history, branches, stage/unstage, and commit
36
+ - Durable client token storage and task journal
37
+ - Multi-terminal browser dialogs bound to the selected client and path
38
+
39
+ ## Requirements
40
+
41
+ - Python 3.12+
42
+ - Git 2.x+
43
+
44
+ ## Install
45
+
46
+ ```bash
47
+ python3 -m venv .venv
48
+ . .venv/bin/activate
49
+ pip install -e .
50
+ ```
51
+
52
+ ## Client Config
53
+
54
+ The client reads `~/.bamboo-coding/bamboo-coding.toml` by default.
55
+
56
+ Quick start:
57
+
58
+ ```bash
59
+ bamboo-coding setup
60
+ bamboo-coding run
61
+ ```
62
+
63
+ `setup` creates the config file if it is missing. By default it uses the current working directory as the first allowed root. You can override the generated values:
64
+
65
+ ```bash
66
+ bamboo-coding setup --root /work/project-a --root /work/project-b --client-id worker-1 --hostname devbox
67
+ ```
68
+
69
+ Example generated config:
70
+
71
+ ```toml
72
+ [client]
73
+ controller_url = "ws://127.0.0.1:8100/ws/agents"
74
+ client_id = "worker-1"
75
+ hostname = "devbox"
76
+ allowed_roots = ["/work/project-a", "/work/project-b"]
77
+ max_concurrent_tasks = 1
78
+
79
+ [storage]
80
+ token_path = "~/.bamboo-coding/token.json"
81
+ journal_path = "~/.bamboo-coding/journal.db"
82
+ ```
83
+
84
+ Environment variables still override TOML values. Existing `AGENT_*` overrides remain supported during the transition.
85
+
86
+ ## Makefile Commands
87
+
88
+ The repository now includes a root `Makefile` as the quick command hub.
89
+
90
+ Common targets:
91
+
92
+ ```bash
93
+ make help # list available commands
94
+ make dev # run the server locally
95
+ make start # start the server with docker compose
96
+ make stop # stop the docker compose stack
97
+ make restart # restart the docker compose stack
98
+ make logs # follow server logs
99
+ make ps # show compose service state
100
+ make test # run the full pytest suite
101
+ make client-install # install editable package plus build/upload tooling
102
+ make client-clean # remove build artifacts and Python cache files
103
+ make client-build # build wheel and sdist into dist/
104
+ make client-reinstall # clean and reinstall the editable package
105
+ make client-deploy # publish dist/* to real PyPI
106
+ ```
107
+
108
+ `client-deploy` publishes to the real PyPI index. It prompts for a PyPI deploy
109
+ token when one is missing:
110
+
111
+ ```bash
112
+ make client-deploy
113
+ ```
114
+
115
+ You can also provide the token through the environment for non-interactive runs:
116
+
117
+ ```bash
118
+ export PYPI_TOKEN=<pypi-token>
119
+ make client-deploy
120
+ ```
121
+
122
+ The Makefile maps `PYPI_TOKEN` to Twine's token auth internally by setting
123
+ `TWINE_USERNAME=__token__` and `TWINE_PASSWORD=<pypi-token>`.
124
+
125
+ ## Docker Folder
126
+
127
+ The server Docker assets live under `docker/`:
128
+
129
+ ```text
130
+ docker/
131
+ compose.server.yml
132
+ server.Dockerfile
133
+ ```
134
+
135
+ ## Run Locally
136
+
137
+ ### 1. Start the server
138
+
139
+ ```bash
140
+ bamboo-coding-server --dev --host 127.0.0.1 --port 8100
141
+ ```
142
+
143
+ The legacy launcher also still works:
144
+
145
+ ```bash
146
+ .venv/bin/python app.py --dev --host 127.0.0.1 --port 8100
147
+ ```
148
+
149
+ ### 2. Setup and run a client
150
+
151
+ First write the default config:
152
+
153
+ ```bash
154
+ bamboo-coding setup
155
+ ```
156
+
157
+ Then start the long-running client:
158
+
159
+ ```bash
160
+ bamboo-coding run
161
+ ```
162
+
163
+ Bare `bamboo-coding` is an alias for `bamboo-coding run`.
164
+
165
+ For one-off value overrides you can still use environment variables, but `run` still expects a config file to exist:
166
+
167
+ ```bash
168
+ CONTROLLER_URL=ws://127.0.0.1:8100/ws/agents AGENT_CLIENT_ID=worker-1 AGENT_ALLOWED_ROOTS=/work/project-a:/work/project-b bamboo-coding run
169
+ ```
170
+
171
+ The module entrypoint is also available:
172
+
173
+ ```bash
174
+ .venv/bin/python -m bamboo_coding.client.main run
175
+ ```
176
+
177
+ ### 3. Approve the client in the web UI
178
+
179
+ Open:
180
+
181
+ ```text
182
+ http://127.0.0.1:8100/
183
+ ```
184
+
185
+ The start page shows:
186
+
187
+ - approved clients first, with live root counts
188
+ - pending clients with an `Approve` button
189
+ - roots and immediate children only after you pick a specific client
190
+
191
+ Once approved, the client stores its token under `~/.bamboo-coding/` by default and reconnects automatically on the next start.
192
+
193
+ ### 4. Open terminals from the web UI
194
+
195
+ After you pick a client and workspace, use the `Terminal` button in the header.
196
+
197
+ Terminal behavior:
198
+
199
+ - each terminal attaches to the currently selected client
200
+ - the initial shell directory uses the current selected path
201
+ - you can open multiple terminals in the same page
202
+ - windows can be dragged, resized, and maximized inside the page
203
+ - `Hide` moves a terminal into the bottom-right dock; click the dock item to restore it
204
+ - the header `Layout` control supports `Hidden`, `1`, `1|2`, `1|2|3`, and `2x2` presets
205
+ - applying `Hidden` docks every open terminal without closing the sessions
206
+ - applying a layout restores all open terminals, fills the preset slots by open order, and docks overflow terminals
207
+ - with an active layout, new terminals join the preset only while free slots remain; after that they open as floating windows
208
+ - the main page `Shortcuts` button opens browser-local terminal shortcut settings
209
+ - default terminal shortcuts are `Ctrl+Down` for `Hidden` and `Ctrl+Up` for `2x2`
210
+ - leaving or refreshing the page while any terminal windows remain open triggers the browser unload warning
211
+
212
+ There is also a standalone terminal page that reuses the same component:
213
+
214
+ ```text
215
+ http://127.0.0.1:8100/terminal?agent_id=<agent-id>&path=/absolute/path
216
+ ```
217
+
218
+ ### 5. Open a specific workspace directly
219
+
220
+ When multiple clients may advertise the same path, include both the client and the path:
221
+
222
+ ```text
223
+ http://127.0.0.1:8100/?agent_id=<agent-id>&path=/absolute/path/inside/an/approved/root
224
+ ```
225
+
226
+ Path-only links still work when the owner is unambiguous:
227
+
228
+ ```text
229
+ http://127.0.0.1:8100/?path=/absolute/path/inside/an/approved/root
230
+ ```
231
+
232
+ If the path is inside a Git repository, Git actions are enabled. If it is only a normal folder, the UI still shows the file tree and editor.
233
+
234
+ ## Public API
235
+
236
+ ### Discovery and approval
237
+
238
+ - `GET /api/health`
239
+ - `GET /api/agents` (includes live `roots` for connected approved clients)
240
+ - `POST /api/agents/{agent_id}/approve`
241
+ - `GET /api/roots`
242
+
243
+ ### Filesystem
244
+
245
+ - `GET /api/fs/tree?path=...&agent_id=...`
246
+ - `GET /api/fs/browse?path=...&agent_id=...`
247
+ - `GET /api/fs/file?path=...&agent_id=...`
248
+ - `PUT /api/fs/file?path=...&agent_id=...`
249
+
250
+ ### Git
251
+
252
+ - `GET /api/git/status?path=...&agent_id=...`
253
+ - `GET /api/git/diff?path=...&file=...&agent_id=...`
254
+ - `GET /api/git/commits?path=...&agent_id=...`
255
+ - `GET /api/git/commit/{commit_hash}?path=...&agent_id=...`
256
+ - `POST /api/git/stage?path=...&agent_id=...`
257
+ - `POST /api/git/unstage?path=...&agent_id=...`
258
+ - `GET /api/git/branches?path=...&agent_id=...`
259
+ - `POST /api/git/branches?path=...&agent_id=...`
260
+ - `PUT /api/git/branches/checkout?path=...&agent_id=...`
261
+ - `DELETE /api/git/branches/{name}?path=...&agent_id=...`
262
+ - `POST /api/git/branches/merge?path=...&agent_id=...`
263
+ - `POST /api/git/commit?path=...&agent_id=...`
264
+
265
+ ### Client transport
266
+
267
+ - `WS /ws/agents`
268
+ - `WS /ws/terminal?agent_id=...&path=...&terminal_id=...&cols=...&rows=...`
269
+
270
+ ## Testing
271
+
272
+ ```bash
273
+ .venv/bin/python -m pytest -q
274
+ ```
275
+
276
+ ## Repository Layout
277
+
278
+ - `app.py`: legacy server launcher pointing at `bamboo_coding.server.main`
279
+ - `bamboo_coding/server/`: packaged server entrypoint
280
+ - `bamboo_coding/client/`: packaged client runtime
281
+ - `controller/app/`: controller internals
282
+ - `static/`: SPA assets
283
+ - `docker/`: server Docker Compose and Dockerfile assets
284
+ - `tests/`: controller, client, protocol, and Git utility tests
@@ -0,0 +1,62 @@
1
+ git_utils.py,sha256=y1NsxtvsY7gOXjym-sVld6b4Qw5CQ-OdqzlqhBVY9eY,82025
2
+ agent/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
3
+ agent/bamboo_git_agent/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
4
+ agent/bamboo_git_agent/config.py,sha256=cDzCRi2Tz8H71-cK6gW8f4m6BlqOWT2Du-le0pDcE08,2454
5
+ agent/bamboo_git_agent/controller_client.py,sha256=qPMp-JEoCbSxMR5OH114QQ9ZjIi134jWU47n5ro7M3w,2467
6
+ agent/bamboo_git_agent/journal.py,sha256=xIK4aBYrYwqLddZ3LAX4tuY6ugjUPFxb5TP_Fkrqog8,8504
7
+ agent/bamboo_git_agent/main.py,sha256=svI0KmXWOBtybc7Gk1UviECg4XCEPbFF0MQgUwIx88o,7338
8
+ agent/bamboo_git_agent/protocol.py,sha256=MPSxQVhkw__IIqsqeS-j46OsUHjvWT_WJH4qnE42lcA,445
9
+ agent/bamboo_git_agent/status.py,sha256=QhCgmhDx6sLpsWHD516LGirBJMxDDFFfHjLdg5BHbJk,2532
10
+ agent/bamboo_git_agent/capabilities/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
11
+ agent/bamboo_git_agent/capabilities/repo.py,sha256=HJrkmcEGgnAZ7uXW2Yky3INno4DG-Vhesa9qO5JPM2w,7271
12
+ api/__init__.py,sha256=UMv3OaonM4UouYy8NRtLWNj5Hh1IE-SEdV0jMTMya10,35
13
+ api/commit.py,sha256=IGS9O5_A22cZ6dBWR0mYqOUgT4HQl3lt2mC5jWiE8po,2343
14
+ api/repository.py,sha256=HiGwG-Bjz_Toy4sUGgKLopkAexYD2QbvinPOgRNowvo,17763
15
+ bamboo_coding/__init__.py,sha256=46h_Uj5gdx8oYtLJDZMgSyA-APe5YE7vLfgyQaCb32o,71
16
+ bamboo_coding/client/__init__.py,sha256=3wip8Z6F-mf1YUXavSPFM1jBu3i3NXU1Yp1ymaHrpq8,125
17
+ bamboo_coding/client/config.py,sha256=3NQ7HzmOy1Y-rjgDAUbyKJ1RCxpOylWR8ZY5boxUFFU,5953
18
+ bamboo_coding/client/controller_client.py,sha256=UvhbOZpszNy8soOkoCevCuSbDDpwjDNzZTdVAVbQ5qo,4262
19
+ bamboo_coding/client/journal.py,sha256=uZJesILPzOYKTJJaJAWN5_e_h3ONp1Nl7EVbYtfhHuA,64
20
+ bamboo_coding/client/main.py,sha256=osxHMu3UJGAvmrJmhS1Jd4yKuggUiqn-iCc3RjvBfXs,17697
21
+ bamboo_coding/client/status.py,sha256=xX2Ofjmuf5B9y7NsdG9djxNoH1DOw_NWr0gjK_GPw6c,2546
22
+ bamboo_coding/client/terminal_runtime.py,sha256=_mWWKOUcizqgwLXYhZAxI_LPW0bzTXcd03b58ZYi6yA,9916
23
+ bamboo_coding/client/capabilities/__init__.py,sha256=B7qxGhwcbKpRuE2jiZDa85BzYRCFyTEGWK_TWfRvFWQ,34
24
+ bamboo_coding/client/capabilities/repo.py,sha256=02FhJGdnT2Jpd9whfKfxzrQvNt9xwty1hm42hwmzLYw,10768
25
+ bamboo_coding/server/__init__.py,sha256=T-PUHHEg8K4GY9IkpxyN4-28eD01KU2W1JfgNqMHT8U,81
26
+ bamboo_coding/server/main.py,sha256=85P-AAzjiGKfSHBtTlMfDYq9-rUTuV0xf7TGBhITSGQ,903
27
+ bamboo_coding/server/core/__init__.py,sha256=Utes-Fcxa2vnzgU3ZCimYF7Y78PrzswLkPpNtQpUHF0,36
28
+ bamboo_coding/server/core/config.py,sha256=RhQF4gYFtfC6k_fm33nKQgwJgE0GZYn-OV81u9oOJHY,60
29
+ bamboo_coding/shared/__init__.py,sha256=2hU3m0yWwYevIoOEJqkoumqVGW4CGrQIYHE_q1ftpbk,899
30
+ bamboo_coding/shared/protocol.py,sha256=qnbT6fMKp-d7-e_sFq5QPfG0gPTrxuAAuczLFZuTenc,49
31
+ controller/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
32
+ controller/app/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
33
+ controller/app/main.py,sha256=2TAQewTF65lBxKHUimmBT6mnIE0ZRdl9kuHkVUM6fGM,2841
34
+ controller/app/api/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
35
+ controller/app/api/agents/__init__.py,sha256=-GnMriR-KQnvpEZYjyDNFEtMHV5yXpfJmBQ4bAXWn-w,45
36
+ controller/app/api/agents/ws.py,sha256=VQN_1D5QezbjIaMXiCXv1UxoCeEHTz3QIKuCfhtV4hA,5411
37
+ controller/app/api/public/__init__.py,sha256=jLNHGmfLCF4sFk01LQ0yUCYyPDiSiyyjtkmRu8Qysmc,244
38
+ controller/app/api/public/repos.py,sha256=lsRQFNFEo5koiezskzLalTuv19YQO_EdWVG9pLJg-Wg,9087
39
+ controller/app/api/public/terminals.py,sha256=n0QOHw2WT1ozqKh_LqUHysDVZ9VeHG3zMvoZA20w0wY,2365
40
+ controller/app/core/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
41
+ controller/app/core/config.py,sha256=cWBLY43Y49eBuFRWSBvjueXBpTkMDIUyBEqyl1-FHFc,560
42
+ controller/app/core/errors.py,sha256=D6KFR3llcvaSDEdJTZtRZh7ONREEfUlGDLVt1q4YW0M,404
43
+ controller/app/db/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
44
+ controller/app/db/models.py,sha256=woNGECTOCIwrAT9HSLLHXNZVTejc3Cd_hBg_-6keZtw,722
45
+ controller/app/db/session.py,sha256=DHGrcYmrCLb7b_0IBmxuaJUAA03yRnWrvHl_V0K_H3o,2759
46
+ controller/app/schemas/__init__.py,sha256=-G-eagWihlL-tJxVe6hKt_v94dAx3swVKd91DLxyXIQ,520
47
+ controller/app/schemas/agent_messages.py,sha256=MPSxQVhkw__IIqsqeS-j46OsUHjvWT_WJH4qnE42lcA,445
48
+ controller/app/schemas/public.py,sha256=etpMQUTTaLIw33f2rI_XAVhRfl398P7GyUlwLBzUyiU,131
49
+ controller/app/services/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
50
+ controller/app/services/agents.py,sha256=ocPa2Bcv5WatyTPTiYAjWGDJfIC52C9y3S3i643wfA4,4841
51
+ controller/app/services/registrations.py,sha256=rzc_Wv-YczoNSRE3XpDCLLQLhfP9Hyb9c_npublhcWA,742
52
+ controller/app/services/repositories.py,sha256=soQkGdkXQz0r2ZcutJ3SNAlty1XBJhsP-nqfL4rodQs,3659
53
+ controller/app/services/router.py,sha256=_Nx48mvusDiQm9Fj_a8hrC0BZevkMn6rUNoLw3ga2SQ,5406
54
+ controller/app/services/tasks.py,sha256=mevkv9ofQWARuSres9PLgP6jKX0_-maW9tS_4C4iyUk,4673
55
+ controller/app/services/terminals.py,sha256=jYDqF9Eo9gQaI8teBdUziBKPYzyl5iwDq0K6LaRWxHY,5890
56
+ shared/__init__.py,sha256=2hU3m0yWwYevIoOEJqkoumqVGW4CGrQIYHE_q1ftpbk,899
57
+ shared/protocol.py,sha256=7sTnV0tHGoRND6SjbTrASE1HEuLG3TJ-CF4l5QqRxm0,6925
58
+ bamboo_coding-0.1.0.dist-info/METADATA,sha256=2y_tje1EBwqmtLfoazs4SvaA78q2_5-YzPPTnR4EI7M,8493
59
+ bamboo_coding-0.1.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
60
+ bamboo_coding-0.1.0.dist-info/entry_points.txt,sha256=LYwgfzzCTbCjBo3iImfUbBYSjXf2NkscFv6FA4IxHvQ,119
61
+ bamboo_coding-0.1.0.dist-info/top_level.txt,sha256=oWwVpBlYKI-qdAPqf9XWLBCjLfKxN2W_D3gNJo8_pVo,52
62
+ bamboo_coding-0.1.0.dist-info/RECORD,,