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.
Files changed (50) hide show
  1. {bamboo_coding-0.1.7 → bamboo_coding-0.2.0}/PKG-INFO +8 -2
  2. {bamboo_coding-0.1.7 → bamboo_coding-0.2.0}/pyproject.toml +13 -2
  3. bamboo_coding-0.2.0/src/bamboo_coding/agents/__init__.py +20 -0
  4. bamboo_coding-0.2.0/src/bamboo_coding/agents/builtin_skills/__init__.py +3 -0
  5. bamboo_coding-0.2.0/src/bamboo_coding/agents/builtin_skills/skill-create/SKILL.md +55 -0
  6. bamboo_coding-0.2.0/src/bamboo_coding/agents/builtin_skills/skill-optimize/SKILL.md +40 -0
  7. bamboo_coding-0.2.0/src/bamboo_coding/agents/compose.py +55 -0
  8. bamboo_coding-0.2.0/src/bamboo_coding/agents/defs.py +168 -0
  9. bamboo_coding-0.2.0/src/bamboo_coding/agents/detection.py +154 -0
  10. bamboo_coding-0.2.0/src/bamboo_coding/agents/runner.py +172 -0
  11. bamboo_coding-0.2.0/src/bamboo_coding/agents/runtime.py +370 -0
  12. bamboo_coding-0.2.0/src/bamboo_coding/agents/sessions.py +218 -0
  13. bamboo_coding-0.2.0/src/bamboo_coding/agents/skills.py +110 -0
  14. bamboo_coding-0.2.0/src/bamboo_coding/agents/skills_install.py +215 -0
  15. bamboo_coding-0.2.0/src/bamboo_coding/agents/store.py +91 -0
  16. bamboo_coding-0.2.0/src/bamboo_coding/agents/streams.py +225 -0
  17. bamboo_coding-0.2.0/src/bamboo_coding/agents/tasks.py +105 -0
  18. bamboo_coding-0.2.0/src/bamboo_coding/agents/types.py +112 -0
  19. {bamboo_coding-0.1.7 → bamboo_coding-0.2.0}/src/bamboo_coding/capabilities/repo.py +10 -0
  20. bamboo_coding-0.2.0/src/bamboo_coding/chat/__init__.py +35 -0
  21. bamboo_coding-0.2.0/src/bamboo_coding/chat/__main__.py +231 -0
  22. bamboo_coding-0.2.0/src/bamboo_coding/chat/adapter.py +89 -0
  23. bamboo_coding-0.2.0/src/bamboo_coding/chat/pty_session.py +15 -0
  24. bamboo_coding-0.2.0/src/bamboo_coding/chat/pty_session_unix.py +89 -0
  25. bamboo_coding-0.2.0/src/bamboo_coding/chat/pty_session_win.py +125 -0
  26. bamboo_coding-0.2.0/src/bamboo_coding/chat/runtime.py +84 -0
  27. bamboo_coding-0.2.0/src/bamboo_coding/chat/session.py +236 -0
  28. bamboo_coding-0.2.0/src/bamboo_coding/chat/types.py +62 -0
  29. bamboo_coding-0.2.0/src/bamboo_coding/chat/virtual_screen.py +81 -0
  30. {bamboo_coding-0.1.7 → bamboo_coding-0.2.0}/src/bamboo_coding/config.py +30 -0
  31. {bamboo_coding-0.1.7 → bamboo_coding-0.2.0}/src/bamboo_coding/controller_client.py +43 -3
  32. {bamboo_coding-0.1.7 → bamboo_coding-0.2.0}/src/bamboo_coding/journal.py +113 -24
  33. {bamboo_coding-0.1.7 → bamboo_coding-0.2.0}/src/bamboo_coding/main.py +341 -158
  34. {bamboo_coding-0.1.7 → bamboo_coding-0.2.0}/src/bamboo_coding/protocol.py +46 -0
  35. {bamboo_coding-0.1.7 → bamboo_coding-0.2.0}/src/bamboo_coding/terminal_runtime.py +21 -0
  36. {bamboo_coding-0.1.7 → bamboo_coding-0.2.0}/src/bamboo_coding.egg-info/PKG-INFO +8 -2
  37. bamboo_coding-0.2.0/src/bamboo_coding.egg-info/SOURCES.txt +46 -0
  38. bamboo_coding-0.2.0/src/bamboo_coding.egg-info/requires.txt +9 -0
  39. bamboo_coding-0.1.7/src/bamboo_coding.egg-info/SOURCES.txt +0 -20
  40. bamboo_coding-0.1.7/src/bamboo_coding.egg-info/requires.txt +0 -4
  41. {bamboo_coding-0.1.7 → bamboo_coding-0.2.0}/README.md +0 -0
  42. {bamboo_coding-0.1.7 → bamboo_coding-0.2.0}/setup.cfg +0 -0
  43. {bamboo_coding-0.1.7 → bamboo_coding-0.2.0}/src/bamboo_coding/__init__.py +0 -0
  44. {bamboo_coding-0.1.7 → bamboo_coding-0.2.0}/src/bamboo_coding/capabilities/__init__.py +0 -0
  45. {bamboo_coding-0.1.7 → bamboo_coding-0.2.0}/src/bamboo_coding/git_utils.py +0 -0
  46. {bamboo_coding-0.1.7 → bamboo_coding-0.2.0}/src/bamboo_coding/scrollback_buffer.py +0 -0
  47. {bamboo_coding-0.1.7 → bamboo_coding-0.2.0}/src/bamboo_coding/status.py +0 -0
  48. {bamboo_coding-0.1.7 → bamboo_coding-0.2.0}/src/bamboo_coding.egg-info/dependency_links.txt +0 -0
  49. {bamboo_coding-0.1.7 → bamboo_coding-0.2.0}/src/bamboo_coding.egg-info/entry_points.txt +0 -0
  50. {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.1.7
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.1.7"
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,3 @@
1
+ # Marker so setuptools packages this directory; the skill folders inside are
2
+ # package data (see [tool.setuptools.package-data]), discovered at runtime by
3
+ # skills.builtin_skills_dir().
@@ -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