bamboo-coding 0.1.7__tar.gz → 0.2.0__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.7 → bamboo_coding-0.2.0}/PKG-INFO +8 -2
- {bamboo_coding-0.1.7 → bamboo_coding-0.2.0}/pyproject.toml +13 -2
- bamboo_coding-0.2.0/src/bamboo_coding/agents/__init__.py +20 -0
- bamboo_coding-0.2.0/src/bamboo_coding/agents/builtin_skills/__init__.py +3 -0
- bamboo_coding-0.2.0/src/bamboo_coding/agents/builtin_skills/skill-create/SKILL.md +55 -0
- bamboo_coding-0.2.0/src/bamboo_coding/agents/builtin_skills/skill-optimize/SKILL.md +40 -0
- bamboo_coding-0.2.0/src/bamboo_coding/agents/compose.py +55 -0
- bamboo_coding-0.2.0/src/bamboo_coding/agents/defs.py +168 -0
- bamboo_coding-0.2.0/src/bamboo_coding/agents/detection.py +154 -0
- bamboo_coding-0.2.0/src/bamboo_coding/agents/runner.py +172 -0
- bamboo_coding-0.2.0/src/bamboo_coding/agents/runtime.py +370 -0
- bamboo_coding-0.2.0/src/bamboo_coding/agents/sessions.py +218 -0
- bamboo_coding-0.2.0/src/bamboo_coding/agents/skills.py +110 -0
- bamboo_coding-0.2.0/src/bamboo_coding/agents/skills_install.py +215 -0
- bamboo_coding-0.2.0/src/bamboo_coding/agents/store.py +91 -0
- bamboo_coding-0.2.0/src/bamboo_coding/agents/streams.py +225 -0
- bamboo_coding-0.2.0/src/bamboo_coding/agents/tasks.py +105 -0
- bamboo_coding-0.2.0/src/bamboo_coding/agents/types.py +112 -0
- {bamboo_coding-0.1.7 → bamboo_coding-0.2.0}/src/bamboo_coding/capabilities/repo.py +10 -0
- bamboo_coding-0.2.0/src/bamboo_coding/chat/__init__.py +35 -0
- bamboo_coding-0.2.0/src/bamboo_coding/chat/__main__.py +231 -0
- bamboo_coding-0.2.0/src/bamboo_coding/chat/adapter.py +89 -0
- bamboo_coding-0.2.0/src/bamboo_coding/chat/pty_session.py +15 -0
- bamboo_coding-0.2.0/src/bamboo_coding/chat/pty_session_unix.py +89 -0
- bamboo_coding-0.2.0/src/bamboo_coding/chat/pty_session_win.py +125 -0
- bamboo_coding-0.2.0/src/bamboo_coding/chat/runtime.py +84 -0
- bamboo_coding-0.2.0/src/bamboo_coding/chat/session.py +236 -0
- bamboo_coding-0.2.0/src/bamboo_coding/chat/types.py +62 -0
- bamboo_coding-0.2.0/src/bamboo_coding/chat/virtual_screen.py +81 -0
- {bamboo_coding-0.1.7 → bamboo_coding-0.2.0}/src/bamboo_coding/config.py +30 -0
- {bamboo_coding-0.1.7 → bamboo_coding-0.2.0}/src/bamboo_coding/controller_client.py +43 -3
- {bamboo_coding-0.1.7 → bamboo_coding-0.2.0}/src/bamboo_coding/journal.py +113 -24
- {bamboo_coding-0.1.7 → bamboo_coding-0.2.0}/src/bamboo_coding/main.py +341 -158
- {bamboo_coding-0.1.7 → bamboo_coding-0.2.0}/src/bamboo_coding/protocol.py +46 -0
- {bamboo_coding-0.1.7 → bamboo_coding-0.2.0}/src/bamboo_coding/terminal_runtime.py +21 -0
- {bamboo_coding-0.1.7 → bamboo_coding-0.2.0}/src/bamboo_coding.egg-info/PKG-INFO +8 -2
- bamboo_coding-0.2.0/src/bamboo_coding.egg-info/SOURCES.txt +46 -0
- bamboo_coding-0.2.0/src/bamboo_coding.egg-info/requires.txt +9 -0
- bamboo_coding-0.1.7/src/bamboo_coding.egg-info/SOURCES.txt +0 -20
- bamboo_coding-0.1.7/src/bamboo_coding.egg-info/requires.txt +0 -4
- {bamboo_coding-0.1.7 → bamboo_coding-0.2.0}/README.md +0 -0
- {bamboo_coding-0.1.7 → bamboo_coding-0.2.0}/setup.cfg +0 -0
- {bamboo_coding-0.1.7 → bamboo_coding-0.2.0}/src/bamboo_coding/__init__.py +0 -0
- {bamboo_coding-0.1.7 → bamboo_coding-0.2.0}/src/bamboo_coding/capabilities/__init__.py +0 -0
- {bamboo_coding-0.1.7 → bamboo_coding-0.2.0}/src/bamboo_coding/git_utils.py +0 -0
- {bamboo_coding-0.1.7 → bamboo_coding-0.2.0}/src/bamboo_coding/scrollback_buffer.py +0 -0
- {bamboo_coding-0.1.7 → bamboo_coding-0.2.0}/src/bamboo_coding/status.py +0 -0
- {bamboo_coding-0.1.7 → bamboo_coding-0.2.0}/src/bamboo_coding.egg-info/dependency_links.txt +0 -0
- {bamboo_coding-0.1.7 → bamboo_coding-0.2.0}/src/bamboo_coding.egg-info/entry_points.txt +0 -0
- {bamboo_coding-0.1.7 → bamboo_coding-0.2.0}/src/bamboo_coding.egg-info/top_level.txt +0 -0
|
@@ -1,13 +1,19 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: bamboo-coding
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.2.0
|
|
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
|
|
14
|
+
Requires-Dist: pyte>=0.8.2
|
|
15
|
+
Requires-Dist: psutil>=5.9
|
|
16
|
+
Requires-Dist: pywinpty>=2.0; sys_platform == "win32"
|
|
11
17
|
|
|
12
18
|
# bamboo-coding
|
|
13
19
|
|
|
@@ -4,15 +4,23 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "bamboo-coding"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.2.0"
|
|
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",
|
|
21
|
+
"pyte>=0.8.2",
|
|
22
|
+
"psutil>=5.9",
|
|
23
|
+
"pywinpty>=2.0; sys_platform == 'win32'",
|
|
16
24
|
]
|
|
17
25
|
|
|
18
26
|
[project.scripts]
|
|
@@ -21,3 +29,6 @@ bamboo-coding = "bamboo_coding.main:main"
|
|
|
21
29
|
[tool.setuptools.packages.find]
|
|
22
30
|
where = ["src"]
|
|
23
31
|
include = ["bamboo_coding*"]
|
|
32
|
+
|
|
33
|
+
[tool.setuptools.package-data]
|
|
34
|
+
"bamboo_coding.agents.builtin_skills" = ["*/SKILL.md", "*/assets/*"]
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"""Headless Claude Code / Codex CLI integration.
|
|
2
|
+
|
|
3
|
+
This package lets the client detect coding-agent CLIs installed on this
|
|
4
|
+
machine, run them headlessly (no PTY, structured JSONL output), inject
|
|
5
|
+
client-owned skills, and list/resume the CLIs' native history sessions.
|
|
6
|
+
|
|
7
|
+
The agents never know Bamboo exists: prompts arrive on stdin, skills ride in
|
|
8
|
+
the prompt plus an ``--add-dir`` grant, and nothing is written into the
|
|
9
|
+
user's ``~/.claude`` / ``~/.codex`` configuration.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from .types import AgentDef, DetectedAgent, ModelOption, RuntimeContext, Skill
|
|
13
|
+
|
|
14
|
+
__all__ = [
|
|
15
|
+
"AgentDef",
|
|
16
|
+
"DetectedAgent",
|
|
17
|
+
"ModelOption",
|
|
18
|
+
"RuntimeContext",
|
|
19
|
+
"Skill",
|
|
20
|
+
]
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: skill-create
|
|
3
|
+
description: Scaffold a new Bamboo skill folder (SKILL.md + assets) from a description of the workflow it should capture
|
|
4
|
+
version: 1.0.0
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
# Create a new skill
|
|
8
|
+
|
|
9
|
+
You are helping the user turn a workflow into a reusable Bamboo skill. A skill
|
|
10
|
+
is a folder named after the skill containing a `SKILL.md` (frontmatter +
|
|
11
|
+
instructions) and optional support files the instructions reference.
|
|
12
|
+
|
|
13
|
+
## Steps
|
|
14
|
+
|
|
15
|
+
1. From the user's request, identify: the skill's purpose, the steps an agent
|
|
16
|
+
should follow when the skill is active, and any reference material worth
|
|
17
|
+
shipping as support files. If the request is too vague to write concrete
|
|
18
|
+
steps, ask for the missing specifics before writing anything.
|
|
19
|
+
2. Choose a short kebab-case name (lowercase letters, digits, and dashes only,
|
|
20
|
+
e.g. `api-review`). The folder and the frontmatter `name` must match.
|
|
21
|
+
3. Create the skill folder **inside the current working directory**:
|
|
22
|
+
|
|
23
|
+
```
|
|
24
|
+
<name>/
|
|
25
|
+
├── SKILL.md
|
|
26
|
+
└── assets/ # only if the body references support files
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
4. Write `SKILL.md` with exactly this frontmatter shape, then the body:
|
|
30
|
+
|
|
31
|
+
```
|
|
32
|
+
---
|
|
33
|
+
name: <name>
|
|
34
|
+
description: <one line: when an agent should use this skill>
|
|
35
|
+
version: 0.1.0
|
|
36
|
+
---
|
|
37
|
+
|
|
38
|
+
# <Title>
|
|
39
|
+
|
|
40
|
+
<numbered, imperative steps an agent can follow without asking questions>
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
5. Keep the body tight: every line costs prompt space on every run that
|
|
44
|
+
activates the skill. Move long reference material into `assets/` files and
|
|
45
|
+
reference them by relative path from the body.
|
|
46
|
+
6. Finish by telling the user the folder is ready to install: zip it and
|
|
47
|
+
upload it from the Bamboo skill panel (or publish it to the team's skill
|
|
48
|
+
hub).
|
|
49
|
+
|
|
50
|
+
## Rules
|
|
51
|
+
|
|
52
|
+
- Never write into `~/.bamboo-coding`, `~/.claude`, or `~/.codex` — the skill
|
|
53
|
+
is installed through Bamboo's upload flow, which validates it first.
|
|
54
|
+
- The `description` must say when to use the skill, not what it is.
|
|
55
|
+
- Do not invent support files the body never references.
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: skill-optimize
|
|
3
|
+
description: Review an installed Bamboo skill and write an improved version of its SKILL.md
|
|
4
|
+
version: 1.0.0
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
# Optimize an existing skill
|
|
8
|
+
|
|
9
|
+
You are improving another skill that is active in this same run (its body
|
|
10
|
+
appears above as another "## Active skill" section, and its folder is listed
|
|
11
|
+
as readable). If no other skill is active, ask the user which skill to
|
|
12
|
+
optimize and stop.
|
|
13
|
+
|
|
14
|
+
## Steps
|
|
15
|
+
|
|
16
|
+
1. Read the target skill's `SKILL.md` from its support directory, plus any
|
|
17
|
+
asset files its body references.
|
|
18
|
+
2. Judge it against these criteria, in order:
|
|
19
|
+
- **Actionable**: steps are imperative and concrete enough for an agent to
|
|
20
|
+
follow without asking questions.
|
|
21
|
+
- **Lean**: the body carries no prose an agent doesn't need; long
|
|
22
|
+
reference material belongs in `assets/` files, referenced by relative
|
|
23
|
+
path.
|
|
24
|
+
- **Scoped**: the `description` says when to use the skill; the body does
|
|
25
|
+
one job and doesn't drift into adjacent workflows.
|
|
26
|
+
- **Self-contained**: no references to files, tools, or conventions that
|
|
27
|
+
don't exist in the skill folder or a standard environment.
|
|
28
|
+
3. Write the improved skill **into the current working directory** as
|
|
29
|
+
`<name>/SKILL.md` (same folder layout as the original; copy forward any
|
|
30
|
+
assets that survive the review, dropping unreferenced ones). Keep the
|
|
31
|
+
frontmatter `name` unchanged and bump the patch part of `version`.
|
|
32
|
+
4. Summarize what changed and why in a short list, then tell the user to
|
|
33
|
+
reinstall the folder from the Bamboo skill panel to apply it.
|
|
34
|
+
|
|
35
|
+
## Rules
|
|
36
|
+
|
|
37
|
+
- Never edit the installed skill in place — its directory is read-only input;
|
|
38
|
+
the improved copy goes to the working directory for reinstall.
|
|
39
|
+
- Preserve behavior the user didn't ask to change; this is a rewrite for
|
|
40
|
+
clarity and economy, not a redesign.
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
from typing import Sequence
|
|
5
|
+
|
|
6
|
+
from .types import Skill
|
|
7
|
+
|
|
8
|
+
# Some models start their reply by echoing the top of the user message
|
|
9
|
+
# verbatim, so the rendered output begins with a spurious "# Instructions"
|
|
10
|
+
# block. One explicit guard line cuts that without changing the turn shape.
|
|
11
|
+
ECHO_GUARD = (
|
|
12
|
+
"\n\n(Do not quote, restate, or echo the # Instructions block above in "
|
|
13
|
+
"your reply. Begin your response with the answer to the # User request below.)"
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def compose_stable_block(*, skills: Sequence[Skill] = (), cwd: str | None,
|
|
18
|
+
extra_instructions: str = "") -> str:
|
|
19
|
+
"""The turn-invariant instruction prefix (skill bodies, cwd contract).
|
|
20
|
+
|
|
21
|
+
Kept separate from the user request so resumed sessions can skip
|
|
22
|
+
re-sending it when unchanged (see ``stable_block_hash``) — changing the
|
|
23
|
+
skill selection mid-conversation changes the hash, so the next turn
|
|
24
|
+
re-sends the updated block automatically.
|
|
25
|
+
"""
|
|
26
|
+
instructions: list[str] = []
|
|
27
|
+
if extra_instructions:
|
|
28
|
+
instructions.append(extra_instructions)
|
|
29
|
+
for skill in skills:
|
|
30
|
+
instructions.append(
|
|
31
|
+
f"## Active skill: {skill.name}\n\n{skill.body}\n\n"
|
|
32
|
+
f"(This skill's support files live at `{skill.path}` — you have "
|
|
33
|
+
f"read access to that directory.)"
|
|
34
|
+
)
|
|
35
|
+
if cwd:
|
|
36
|
+
instructions.append(
|
|
37
|
+
f"Your working directory is `{cwd}`. Create all output files inside it."
|
|
38
|
+
)
|
|
39
|
+
return "\n\n".join(instructions)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def compose_prompt(*, user_request: str, stable_block: str = "") -> str:
|
|
43
|
+
"""There is no system-prompt API for a spawned CLI, so everything rides
|
|
44
|
+
in one composed user message."""
|
|
45
|
+
if stable_block:
|
|
46
|
+
head = ("# Instructions (read first)\n\n"
|
|
47
|
+
+ stable_block
|
|
48
|
+
+ ECHO_GUARD + "\n\n---\n")
|
|
49
|
+
else:
|
|
50
|
+
head = ""
|
|
51
|
+
return f"{head}# User request\n\n{user_request}"
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def stable_block_hash(stable_block: str) -> str:
|
|
55
|
+
return hashlib.sha256(stable_block.encode("utf-8")).hexdigest()
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import platform
|
|
6
|
+
|
|
7
|
+
from .types import AgentDef, ModelOption, RuntimeContext
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def _build_claude_args(model, extra_dirs, caps, ctx: RuntimeContext) -> list[str]:
|
|
11
|
+
# -p (print mode) with the prompt on stdin: argv delivery hits the Linux
|
|
12
|
+
# per-entry ~128 KB limit (E2BIG) and Windows CreateProcess limits long
|
|
13
|
+
# before a skill-carrying prompt does. `claude -p` with no positional
|
|
14
|
+
# prompt reads stdin.
|
|
15
|
+
args = ["-p", "--output-format", "stream-json", "--verbose"]
|
|
16
|
+
|
|
17
|
+
# Richer streaming deltas, but the flag only exists on newer builds; old
|
|
18
|
+
# installs exit 1 on "unknown option", so gate on the --help probe.
|
|
19
|
+
if caps.get("partial_messages"):
|
|
20
|
+
args.append("--include-partial-messages")
|
|
21
|
+
|
|
22
|
+
if model and model != "default":
|
|
23
|
+
args += ["--model", model]
|
|
24
|
+
|
|
25
|
+
# Grant read access to skill dirs living outside the workspace cwd.
|
|
26
|
+
dirs = [d for d in (extra_dirs or []) if d]
|
|
27
|
+
if dirs and caps.get("add_dir", True):
|
|
28
|
+
args.append("--add-dir")
|
|
29
|
+
args += dirs
|
|
30
|
+
|
|
31
|
+
# Continue Claude's own CLI session across turns so it keeps working
|
|
32
|
+
# memory instead of re-deriving everything from a re-packed transcript.
|
|
33
|
+
if ctx.resume_session_id:
|
|
34
|
+
args += ["--resume", ctx.resume_session_id]
|
|
35
|
+
elif ctx.new_session_id:
|
|
36
|
+
args += ["--session-id", ctx.new_session_id]
|
|
37
|
+
|
|
38
|
+
# No TTY means no interactive approval prompt is possible; an approval
|
|
39
|
+
# request would hang the run forever.
|
|
40
|
+
args += ["--permission-mode", "bypassPermissions"]
|
|
41
|
+
return args
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _codex_needs_danger_full_access() -> bool:
|
|
45
|
+
"""Codex's workspace-write sandbox blocks shell invocations on Windows
|
|
46
|
+
(no OS-level sandbox there) and under WSL, which reports linux but hits
|
|
47
|
+
the same path. macOS (Seatbelt) and Linux (Landlock+seccomp) keep
|
|
48
|
+
workspace-write."""
|
|
49
|
+
override = os.environ.get("BAMBOO_CODING_CODEX_SANDBOX", "").strip()
|
|
50
|
+
if override:
|
|
51
|
+
return override == "danger-full-access"
|
|
52
|
+
if platform.system() == "Windows":
|
|
53
|
+
return True
|
|
54
|
+
return bool(os.environ.get("WSL_DISTRO_NAME", "").strip())
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _build_codex_args(model, extra_dirs, caps, ctx: RuntimeContext) -> list[str]:
|
|
58
|
+
if ctx.resume_session_id:
|
|
59
|
+
# `codex exec resume` accepts only a subset of exec's flags — no
|
|
60
|
+
# --sandbox / -C / --add-dir (it exits 2 on them). Sandbox selection
|
|
61
|
+
# rides through -c config overrides instead; the workspace cwd comes
|
|
62
|
+
# from the spawn cwd, and the resumed thread keeps its prior context.
|
|
63
|
+
args = ["exec", "resume", ctx.resume_session_id,
|
|
64
|
+
"--json", "--skip-git-repo-check"]
|
|
65
|
+
if _codex_needs_danger_full_access():
|
|
66
|
+
args += ["-c", 'sandbox_mode="danger-full-access"']
|
|
67
|
+
else:
|
|
68
|
+
args += ["-c", 'sandbox_mode="workspace-write"',
|
|
69
|
+
"-c", "sandbox_workspace_write.network_access=true"]
|
|
70
|
+
args += ["-c", 'default_permissions=":workspace"']
|
|
71
|
+
if model and model != "default":
|
|
72
|
+
args += ["--model", model]
|
|
73
|
+
return args
|
|
74
|
+
|
|
75
|
+
if _codex_needs_danger_full_access():
|
|
76
|
+
args = ["exec", "--json", "--skip-git-repo-check",
|
|
77
|
+
"--sandbox", "danger-full-access"]
|
|
78
|
+
else:
|
|
79
|
+
args = ["exec", "--json", "--skip-git-repo-check",
|
|
80
|
+
"--sandbox", "workspace-write",
|
|
81
|
+
"-c", "sandbox_workspace_write.network_access=true"]
|
|
82
|
+
args += ["-c", 'default_permissions=":workspace"']
|
|
83
|
+
|
|
84
|
+
if ctx.cwd:
|
|
85
|
+
args += ["-C", ctx.cwd]
|
|
86
|
+
for d in (extra_dirs or []):
|
|
87
|
+
if d:
|
|
88
|
+
args += ["--add-dir", d]
|
|
89
|
+
if model and model != "default":
|
|
90
|
+
args += ["--model", model]
|
|
91
|
+
return args
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _parse_codex_debug_models(stdout: str) -> list[ModelOption] | None:
|
|
95
|
+
"""`codex debug models` prints JSON {models:[{slug,display_name,visibility}]}
|
|
96
|
+
on recent CLIs."""
|
|
97
|
+
try:
|
|
98
|
+
parsed = json.loads(stdout or "")
|
|
99
|
+
except (ValueError, TypeError):
|
|
100
|
+
return None
|
|
101
|
+
models = parsed.get("models") if isinstance(parsed, dict) else None
|
|
102
|
+
if not isinstance(models, list):
|
|
103
|
+
return None
|
|
104
|
+
out, seen = [ModelOption("default", "Default")], {"default"}
|
|
105
|
+
for m in models:
|
|
106
|
+
if not isinstance(m, dict) or m.get("visibility") == "hidden":
|
|
107
|
+
continue
|
|
108
|
+
mid = str(m.get("slug") or m.get("id") or "").strip()
|
|
109
|
+
if not mid or mid in seen:
|
|
110
|
+
continue
|
|
111
|
+
seen.add(mid)
|
|
112
|
+
out.append(ModelOption(mid, str(m.get("display_name") or m.get("name") or mid)))
|
|
113
|
+
return out if len(out) > 1 else None
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
CLAUDE_DEF = AgentDef(
|
|
117
|
+
id="claude",
|
|
118
|
+
name="Claude Code",
|
|
119
|
+
bin="claude",
|
|
120
|
+
version_args=("--version",),
|
|
121
|
+
# --add-dir / --include-partial-messages only appear under `claude -p
|
|
122
|
+
# --help`, not the global help.
|
|
123
|
+
help_args=("-p", "--help"),
|
|
124
|
+
capability_flags={
|
|
125
|
+
"--include-partial-messages": "partial_messages",
|
|
126
|
+
"--add-dir": "add_dir",
|
|
127
|
+
},
|
|
128
|
+
build_args=_build_claude_args,
|
|
129
|
+
stream_format="claude-stream-json",
|
|
130
|
+
fallback_models=(
|
|
131
|
+
ModelOption("default", "Default"),
|
|
132
|
+
ModelOption("sonnet", "Sonnet (alias)"),
|
|
133
|
+
ModelOption("opus", "Opus (alias)"),
|
|
134
|
+
ModelOption("haiku", "Haiku (alias)"),
|
|
135
|
+
),
|
|
136
|
+
resumes_session_via_cli=True,
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
CODEX_DEF = AgentDef(
|
|
141
|
+
id="codex",
|
|
142
|
+
name="Codex CLI",
|
|
143
|
+
bin="codex",
|
|
144
|
+
version_args=("--version",),
|
|
145
|
+
build_args=_build_codex_args,
|
|
146
|
+
stream_format="codex-json",
|
|
147
|
+
list_models_args=("debug", "models"),
|
|
148
|
+
parse_models=_parse_codex_debug_models,
|
|
149
|
+
fallback_models=(
|
|
150
|
+
ModelOption("default", "Default"),
|
|
151
|
+
ModelOption("gpt-5-codex", "gpt-5-codex"),
|
|
152
|
+
ModelOption("gpt-5", "gpt-5"),
|
|
153
|
+
),
|
|
154
|
+
resumes_session_via_cli=True,
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
AGENT_DEFS: tuple[AgentDef, ...] = (CLAUDE_DEF, CODEX_DEF)
|
|
159
|
+
|
|
160
|
+
_ids: set[str] = set()
|
|
161
|
+
for _d in AGENT_DEFS:
|
|
162
|
+
if _d.id in _ids:
|
|
163
|
+
raise RuntimeError(f"Duplicate agent definition id: {_d.id}")
|
|
164
|
+
_ids.add(_d.id)
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def get_agent_def(agent_id: str) -> AgentDef | None:
|
|
168
|
+
return next((d for d in AGENT_DEFS if d.id == agent_id), None)
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import os
|
|
5
|
+
import shutil
|
|
6
|
+
import subprocess
|
|
7
|
+
import sys
|
|
8
|
+
import threading
|
|
9
|
+
import time
|
|
10
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
11
|
+
|
|
12
|
+
from .defs import AGENT_DEFS
|
|
13
|
+
from .types import AgentDef, DetectedAgent, ModelOption
|
|
14
|
+
|
|
15
|
+
LOGGER = logging.getLogger(__name__)
|
|
16
|
+
|
|
17
|
+
VERSION_PROBE_TIMEOUT = 3.0
|
|
18
|
+
HELP_PROBE_TIMEOUT = 5.0
|
|
19
|
+
MODELS_PROBE_TIMEOUT = 5.0
|
|
20
|
+
CACHE_TTL_SECONDS = 60.0
|
|
21
|
+
|
|
22
|
+
# Synchronous on purpose: detection runs inside asyncio.to_thread from the
|
|
23
|
+
# task executor, where blocking subprocess calls are fine and an event loop
|
|
24
|
+
# is not available.
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def resolve_bin(def_: AgentDef) -> str | None:
|
|
28
|
+
"""Resolve the exact path that will later be spawned.
|
|
29
|
+
|
|
30
|
+
Honors a per-agent override (BAMBOO_CODING_CLAUDE_BIN=...) first, then
|
|
31
|
+
walks the declared binary names on PATH.
|
|
32
|
+
"""
|
|
33
|
+
override = os.environ.get(f"BAMBOO_CODING_{def_.id.upper()}_BIN")
|
|
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)
|
|
39
|
+
return override if os.access(override, os.X_OK) else None
|
|
40
|
+
for candidate in (def_.bin, *def_.fallback_bins):
|
|
41
|
+
path = shutil.which(candidate)
|
|
42
|
+
if path:
|
|
43
|
+
return path
|
|
44
|
+
return None
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def probe_version(path: str, def_: AgentDef) -> tuple[bool, str | None, str | None]:
|
|
48
|
+
"""Returns (invocable, version, diagnostic).
|
|
49
|
+
|
|
50
|
+
Two failure modes are discriminated: the OS rejecting the spawn (binary
|
|
51
|
+
missing/not executable -> unavailable) versus the CLI running but
|
|
52
|
+
--version misbehaving (timeout/nonzero -> still usable, version unknown).
|
|
53
|
+
"""
|
|
54
|
+
try:
|
|
55
|
+
proc = subprocess.run(
|
|
56
|
+
[path, *def_.version_args],
|
|
57
|
+
capture_output=True,
|
|
58
|
+
timeout=VERSION_PROBE_TIMEOUT,
|
|
59
|
+
)
|
|
60
|
+
except subprocess.TimeoutExpired:
|
|
61
|
+
return True, None, None # ran, just slow — still usable
|
|
62
|
+
except PermissionError:
|
|
63
|
+
return False, None, "not-executable"
|
|
64
|
+
except FileNotFoundError:
|
|
65
|
+
return False, None, "missing-target"
|
|
66
|
+
except OSError as exc:
|
|
67
|
+
return False, None, f"spawn-failed: {exc}"
|
|
68
|
+
if proc.returncode in (126, 127):
|
|
69
|
+
return False, None, "not-executable" if proc.returncode == 126 else "missing-target"
|
|
70
|
+
first_line = proc.stdout.decode(errors="replace").strip().splitlines()
|
|
71
|
+
return True, (first_line[0] if first_line else None), None
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def probe_capabilities(path: str, def_: AgentDef) -> dict[str, bool]:
|
|
75
|
+
if not def_.help_args or not def_.capability_flags:
|
|
76
|
+
return {}
|
|
77
|
+
try:
|
|
78
|
+
proc = subprocess.run(
|
|
79
|
+
[path, *def_.help_args],
|
|
80
|
+
capture_output=True,
|
|
81
|
+
timeout=HELP_PROBE_TIMEOUT,
|
|
82
|
+
)
|
|
83
|
+
text = proc.stdout.decode(errors="replace")
|
|
84
|
+
return {key: flag in text for flag, key in def_.capability_flags.items()}
|
|
85
|
+
except Exception:
|
|
86
|
+
return {} # --help failed -> safe baseline: no optional flags
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def fetch_models(path: str, def_: AgentDef) -> list[ModelOption]:
|
|
90
|
+
if def_.list_models_args and def_.parse_models:
|
|
91
|
+
try:
|
|
92
|
+
proc = subprocess.run(
|
|
93
|
+
[path, *def_.list_models_args],
|
|
94
|
+
capture_output=True,
|
|
95
|
+
timeout=MODELS_PROBE_TIMEOUT,
|
|
96
|
+
)
|
|
97
|
+
parsed = def_.parse_models(proc.stdout.decode(errors="replace"))
|
|
98
|
+
# Empty parse means fallback, otherwise the picker gets stuck.
|
|
99
|
+
if parsed:
|
|
100
|
+
return parsed
|
|
101
|
+
except Exception:
|
|
102
|
+
pass
|
|
103
|
+
return list(def_.fallback_models)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def probe(def_: AgentDef) -> DetectedAgent:
|
|
107
|
+
path = resolve_bin(def_)
|
|
108
|
+
if not path:
|
|
109
|
+
return DetectedAgent(def_, available=False, diagnostic="not on PATH")
|
|
110
|
+
ok, version, diag = probe_version(path, def_)
|
|
111
|
+
if not ok:
|
|
112
|
+
return DetectedAgent(def_, available=False, path=path, diagnostic=diag)
|
|
113
|
+
with ThreadPoolExecutor(max_workers=2) as pool:
|
|
114
|
+
caps_future = pool.submit(probe_capabilities, path, def_)
|
|
115
|
+
models_future = pool.submit(fetch_models, path, def_)
|
|
116
|
+
caps, models = caps_future.result(), models_future.result()
|
|
117
|
+
return DetectedAgent(def_, available=True, path=path, version=version, caps=caps, models=models)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def detect_agents() -> list[DetectedAgent]:
|
|
121
|
+
"""Probe every defined agent; one probe blowing up must not collapse the list."""
|
|
122
|
+
with ThreadPoolExecutor(max_workers=len(AGENT_DEFS)) as pool:
|
|
123
|
+
futures = [(d, pool.submit(probe, d)) for d in AGENT_DEFS]
|
|
124
|
+
results = []
|
|
125
|
+
for def_, future in futures:
|
|
126
|
+
try:
|
|
127
|
+
results.append(future.result())
|
|
128
|
+
except Exception as exc: # noqa: BLE001 - fault isolation per agent
|
|
129
|
+
LOGGER.exception("Detection probe failed for %s", def_.id)
|
|
130
|
+
results.append(DetectedAgent(def_, available=False, diagnostic=str(exc)))
|
|
131
|
+
return results
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
_cache_lock = threading.Lock()
|
|
135
|
+
_cache: tuple[float, list[DetectedAgent]] | None = None
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def detect_agents_cached(refresh: bool = False) -> list[DetectedAgent]:
|
|
139
|
+
"""TTL-cached detection so UI polls don't spawn probe subprocesses each time."""
|
|
140
|
+
global _cache
|
|
141
|
+
with _cache_lock:
|
|
142
|
+
if not refresh and _cache is not None and time.monotonic() - _cache[0] < CACHE_TTL_SECONDS:
|
|
143
|
+
return _cache[1]
|
|
144
|
+
results = detect_agents()
|
|
145
|
+
with _cache_lock:
|
|
146
|
+
_cache = (time.monotonic(), results)
|
|
147
|
+
return results
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def get_detected(agent_id: str, refresh: bool = False) -> DetectedAgent | None:
|
|
151
|
+
for detected in detect_agents_cached(refresh=refresh):
|
|
152
|
+
if detected.def_.id == agent_id:
|
|
153
|
+
return detected
|
|
154
|
+
return None
|