axion-code 1.0.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.
- axion/__init__.py +3 -0
- axion/api/__init__.py +0 -0
- axion/api/anthropic.py +460 -0
- axion/api/client.py +259 -0
- axion/api/error.py +161 -0
- axion/api/ollama.py +597 -0
- axion/api/openai_compat.py +805 -0
- axion/api/openai_responses.py +627 -0
- axion/api/prompt_cache.py +31 -0
- axion/api/sse.py +98 -0
- axion/api/types.py +451 -0
- axion/cli/__init__.py +0 -0
- axion/cli/init_cmd.py +50 -0
- axion/cli/input.py +290 -0
- axion/cli/main.py +2953 -0
- axion/cli/render.py +489 -0
- axion/cli/tui.py +766 -0
- axion/commands/__init__.py +0 -0
- axion/commands/handlers/__init__.py +0 -0
- axion/commands/handlers/agents.py +51 -0
- axion/commands/handlers/builtin_commands.py +367 -0
- axion/commands/handlers/mcp.py +59 -0
- axion/commands/handlers/models.py +75 -0
- axion/commands/handlers/plugins.py +55 -0
- axion/commands/handlers/skills.py +61 -0
- axion/commands/parsing.py +317 -0
- axion/commands/registry.py +166 -0
- axion/compat_harness/__init__.py +0 -0
- axion/compat_harness/extractor.py +145 -0
- axion/plugins/__init__.py +0 -0
- axion/plugins/hooks.py +22 -0
- axion/plugins/manager.py +391 -0
- axion/plugins/manifest.py +270 -0
- axion/runtime/__init__.py +0 -0
- axion/runtime/bash.py +388 -0
- axion/runtime/bootstrap.py +39 -0
- axion/runtime/claude_subscription.py +300 -0
- axion/runtime/compact.py +233 -0
- axion/runtime/config.py +397 -0
- axion/runtime/conversation.py +1073 -0
- axion/runtime/file_ops.py +613 -0
- axion/runtime/git.py +213 -0
- axion/runtime/hooks.py +235 -0
- axion/runtime/image.py +212 -0
- axion/runtime/lanes.py +282 -0
- axion/runtime/lsp.py +425 -0
- axion/runtime/mcp/__init__.py +0 -0
- axion/runtime/mcp/client.py +76 -0
- axion/runtime/mcp/lifecycle.py +96 -0
- axion/runtime/mcp/stdio.py +318 -0
- axion/runtime/mcp/tool_bridge.py +79 -0
- axion/runtime/memory.py +196 -0
- axion/runtime/oauth.py +329 -0
- axion/runtime/openai_subscription.py +346 -0
- axion/runtime/permissions.py +247 -0
- axion/runtime/plan_mode.py +96 -0
- axion/runtime/policy_engine.py +259 -0
- axion/runtime/prompt.py +586 -0
- axion/runtime/recovery.py +261 -0
- axion/runtime/remote.py +28 -0
- axion/runtime/sandbox.py +68 -0
- axion/runtime/scheduler.py +231 -0
- axion/runtime/session.py +365 -0
- axion/runtime/sharing.py +159 -0
- axion/runtime/skills.py +124 -0
- axion/runtime/tasks.py +258 -0
- axion/runtime/usage.py +241 -0
- axion/runtime/workers.py +186 -0
- axion/telemetry/__init__.py +0 -0
- axion/telemetry/events.py +67 -0
- axion/telemetry/profile.py +49 -0
- axion/telemetry/sink.py +60 -0
- axion/telemetry/tracer.py +95 -0
- axion/tools/__init__.py +0 -0
- axion/tools/lane_completion.py +33 -0
- axion/tools/registry.py +853 -0
- axion/tools/tool_search.py +226 -0
- axion_code-1.0.0.dist-info/METADATA +709 -0
- axion_code-1.0.0.dist-info/RECORD +82 -0
- axion_code-1.0.0.dist-info/WHEEL +4 -0
- axion_code-1.0.0.dist-info/entry_points.txt +2 -0
- axion_code-1.0.0.dist-info/licenses/LICENSE +21 -0
axion/runtime/bash.py
ADDED
|
@@ -0,0 +1,388 @@
|
|
|
1
|
+
"""Bash command execution with timeout, sandbox, and background support.
|
|
2
|
+
|
|
3
|
+
Maps to: rust/crates/runtime/src/bash.rs
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import asyncio
|
|
9
|
+
import enum
|
|
10
|
+
import logging
|
|
11
|
+
import os
|
|
12
|
+
import sys
|
|
13
|
+
import uuid
|
|
14
|
+
from dataclasses import dataclass
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
logger = logging.getLogger(__name__)
|
|
18
|
+
|
|
19
|
+
DEFAULT_TIMEOUT_MS = 120_000 # 2 minutes
|
|
20
|
+
MAX_TIMEOUT_MS = 600_000 # 10 minutes
|
|
21
|
+
MAX_OUTPUT_BYTES = 16_384 # 16 KiB (matching Rust)
|
|
22
|
+
TRUNCATION_NOTICE = "\n\n[output truncated — exceeded 16384 bytes]"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class FilesystemIsolationMode(enum.Enum):
|
|
26
|
+
NONE = "none"
|
|
27
|
+
WORKSPACE_ONLY = "workspace_only"
|
|
28
|
+
READ_ONLY = "read_only"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class SandboxStatus(enum.Enum):
|
|
32
|
+
DISABLED = "disabled"
|
|
33
|
+
ENABLED = "enabled"
|
|
34
|
+
UNAVAILABLE = "unavailable"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass
|
|
38
|
+
class BashCommandInput:
|
|
39
|
+
"""Input for a bash command execution."""
|
|
40
|
+
|
|
41
|
+
command: str
|
|
42
|
+
timeout_ms: int = DEFAULT_TIMEOUT_MS
|
|
43
|
+
description: str = ""
|
|
44
|
+
run_in_background: bool = False
|
|
45
|
+
cwd: Path | None = None
|
|
46
|
+
dangerously_disable_sandbox: bool = False
|
|
47
|
+
namespace_restrictions: bool | None = None
|
|
48
|
+
isolate_network: bool | None = None
|
|
49
|
+
filesystem_mode: FilesystemIsolationMode | None = None
|
|
50
|
+
allowed_mounts: list[str] | None = None
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@dataclass
|
|
54
|
+
class BashCommandOutput:
|
|
55
|
+
"""Output from a bash command execution."""
|
|
56
|
+
|
|
57
|
+
stdout: str = ""
|
|
58
|
+
stderr: str = ""
|
|
59
|
+
exit_code: int | None = None
|
|
60
|
+
interrupted: bool = False
|
|
61
|
+
timed_out: bool = False
|
|
62
|
+
background_task_id: str | None = None
|
|
63
|
+
is_image: bool = False
|
|
64
|
+
no_output_expected: bool = False
|
|
65
|
+
sandbox_status: SandboxStatus | None = None
|
|
66
|
+
return_code_interpretation: str | None = None
|
|
67
|
+
raw_output_path: str | None = None
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
# ---------------------------------------------------------------------------
|
|
71
|
+
# Background task tracking
|
|
72
|
+
# ---------------------------------------------------------------------------
|
|
73
|
+
|
|
74
|
+
_background_tasks: dict[str, asyncio.subprocess.Process] = {}
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def get_background_task(task_id: str) -> asyncio.subprocess.Process | None:
|
|
78
|
+
"""Get a background task by ID."""
|
|
79
|
+
return _background_tasks.get(task_id)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
# ---------------------------------------------------------------------------
|
|
83
|
+
# Output truncation (matching Rust)
|
|
84
|
+
# ---------------------------------------------------------------------------
|
|
85
|
+
|
|
86
|
+
def truncate_output(text: str, max_bytes: int = MAX_OUTPUT_BYTES) -> str:
|
|
87
|
+
"""Truncate output to max_bytes, respecting UTF-8 boundaries."""
|
|
88
|
+
encoded = text.encode("utf-8")
|
|
89
|
+
if len(encoded) <= max_bytes:
|
|
90
|
+
return text
|
|
91
|
+
# Find last valid UTF-8 boundary
|
|
92
|
+
truncated = encoded[:max_bytes]
|
|
93
|
+
result = truncated.decode("utf-8", errors="ignore")
|
|
94
|
+
return result + TRUNCATION_NOTICE
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
# ---------------------------------------------------------------------------
|
|
98
|
+
# Sandbox support
|
|
99
|
+
# ---------------------------------------------------------------------------
|
|
100
|
+
|
|
101
|
+
def _build_sandbox_command(
|
|
102
|
+
command: str,
|
|
103
|
+
cwd: Path | None,
|
|
104
|
+
input_cfg: BashCommandInput,
|
|
105
|
+
) -> list[str]:
|
|
106
|
+
"""Build the shell command, potentially wrapped in sandbox."""
|
|
107
|
+
# Check for Linux sandbox tools
|
|
108
|
+
if sys.platform == "linux" and not input_cfg.dangerously_disable_sandbox:
|
|
109
|
+
bwrap = Path("/usr/bin/bwrap")
|
|
110
|
+
if bwrap.exists() and input_cfg.filesystem_mode:
|
|
111
|
+
# Build bubblewrap command
|
|
112
|
+
args = [
|
|
113
|
+
str(bwrap),
|
|
114
|
+
"--unshare-all",
|
|
115
|
+
"--die-with-parent",
|
|
116
|
+
]
|
|
117
|
+
# Add filesystem mounts
|
|
118
|
+
if input_cfg.filesystem_mode == FilesystemIsolationMode.WORKSPACE_ONLY:
|
|
119
|
+
workspace = str(cwd) if cwd else os.getcwd()
|
|
120
|
+
args.extend(["--bind", workspace, workspace])
|
|
121
|
+
args.extend(["--ro-bind", "/usr", "/usr"])
|
|
122
|
+
args.extend(["--ro-bind", "/bin", "/bin"])
|
|
123
|
+
args.extend(["--ro-bind", "/lib", "/lib"])
|
|
124
|
+
if Path("/lib64").exists():
|
|
125
|
+
args.extend(["--ro-bind", "/lib64", "/lib64"])
|
|
126
|
+
args.extend(["--proc", "/proc"])
|
|
127
|
+
args.extend(["--dev", "/dev"])
|
|
128
|
+
args.extend(["--tmpfs", "/tmp"])
|
|
129
|
+
elif input_cfg.filesystem_mode == FilesystemIsolationMode.READ_ONLY:
|
|
130
|
+
args.extend(["--ro-bind", "/", "/"])
|
|
131
|
+
|
|
132
|
+
# Network isolation
|
|
133
|
+
if input_cfg.isolate_network:
|
|
134
|
+
args.extend(["--unshare-net"])
|
|
135
|
+
|
|
136
|
+
# Additional mounts
|
|
137
|
+
if input_cfg.allowed_mounts:
|
|
138
|
+
for mount in input_cfg.allowed_mounts:
|
|
139
|
+
args.extend(["--bind", mount, mount])
|
|
140
|
+
|
|
141
|
+
args.extend(["--", "sh", "-c", command])
|
|
142
|
+
return args
|
|
143
|
+
|
|
144
|
+
# Default: use available shell
|
|
145
|
+
if sys.platform == "win32":
|
|
146
|
+
# On Windows, try Git Bash first, then fall back to cmd
|
|
147
|
+
git_bash = Path(os.environ.get("PROGRAMFILES", r"C:\Program Files")) / "Git" / "bin" / "bash.exe"
|
|
148
|
+
if git_bash.exists():
|
|
149
|
+
return [str(git_bash), "-c", command]
|
|
150
|
+
# Fallback to cmd.exe
|
|
151
|
+
return ["cmd", "/C", command]
|
|
152
|
+
return ["/bin/bash", "-lc", command]
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
# ---------------------------------------------------------------------------
|
|
156
|
+
# Sandbox home directory setup
|
|
157
|
+
# ---------------------------------------------------------------------------
|
|
158
|
+
|
|
159
|
+
def _prepare_sandbox_dirs(cwd: Path | None) -> dict[str, str]:
|
|
160
|
+
"""Create sandbox home/tmp directories and return env overrides."""
|
|
161
|
+
workspace = cwd or Path.cwd()
|
|
162
|
+
sandbox_home = workspace / ".sandbox-home"
|
|
163
|
+
sandbox_tmp = workspace / ".sandbox-tmp"
|
|
164
|
+
sandbox_home.mkdir(exist_ok=True)
|
|
165
|
+
sandbox_tmp.mkdir(exist_ok=True)
|
|
166
|
+
return {
|
|
167
|
+
"HOME": str(sandbox_home),
|
|
168
|
+
"TMPDIR": str(sandbox_tmp),
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
# ---------------------------------------------------------------------------
|
|
173
|
+
# Process helpers
|
|
174
|
+
# ---------------------------------------------------------------------------
|
|
175
|
+
|
|
176
|
+
def _kill_process(process: asyncio.subprocess.Process) -> None:
|
|
177
|
+
"""Kill a subprocess safely."""
|
|
178
|
+
try:
|
|
179
|
+
process.kill()
|
|
180
|
+
except (ProcessLookupError, OSError):
|
|
181
|
+
pass
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
async def _stream_process(
|
|
185
|
+
process: asyncio.subprocess.Process,
|
|
186
|
+
description: str = "",
|
|
187
|
+
) -> tuple[bytes, str]:
|
|
188
|
+
"""Read stdout fully while showing a live updating status line on stderr.
|
|
189
|
+
|
|
190
|
+
Shows a single line that keeps updating with the latest stderr output,
|
|
191
|
+
like Claude Code's "Running...", "Installing packages...", etc.
|
|
192
|
+
|
|
193
|
+
Returns (stdout_bytes, stderr_text).
|
|
194
|
+
"""
|
|
195
|
+
assert process.stdout is not None
|
|
196
|
+
assert process.stderr is not None
|
|
197
|
+
|
|
198
|
+
stderr_lines: list[str] = []
|
|
199
|
+
_spinner_frames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]
|
|
200
|
+
_frame_idx = [0]
|
|
201
|
+
|
|
202
|
+
def _clean_status(line: str) -> str:
|
|
203
|
+
"""Extract a clean status message from a stderr line."""
|
|
204
|
+
line = line.strip()
|
|
205
|
+
if not line:
|
|
206
|
+
return ""
|
|
207
|
+
# Skip noisy lines (progress bars, ANSI escapes, blank/repeated)
|
|
208
|
+
if line.startswith(("\x1b", "\033")) or set(line) <= set("-=>#. "):
|
|
209
|
+
return ""
|
|
210
|
+
# Truncate to fit one line
|
|
211
|
+
return line[:80]
|
|
212
|
+
|
|
213
|
+
async def _read_stderr() -> None:
|
|
214
|
+
"""Read stderr line-by-line and update the status line."""
|
|
215
|
+
assert process.stderr is not None
|
|
216
|
+
while True:
|
|
217
|
+
line_bytes = await process.stderr.readline()
|
|
218
|
+
if not line_bytes:
|
|
219
|
+
break
|
|
220
|
+
line = line_bytes.decode("utf-8", errors="replace").rstrip("\n\r")
|
|
221
|
+
stderr_lines.append(line)
|
|
222
|
+
|
|
223
|
+
status = _clean_status(line)
|
|
224
|
+
if status:
|
|
225
|
+
frame = _spinner_frames[_frame_idx[0] % len(_spinner_frames)]
|
|
226
|
+
_frame_idx[0] += 1
|
|
227
|
+
# Overwrite the same line — \r returns to start, \033[K clears to end
|
|
228
|
+
sys.stderr.write(f"\r\033[K {frame} {status}")
|
|
229
|
+
sys.stderr.flush()
|
|
230
|
+
|
|
231
|
+
async def _read_stdout() -> bytes:
|
|
232
|
+
"""Also show status updates from stdout for commands that log there."""
|
|
233
|
+
assert process.stdout is not None
|
|
234
|
+
chunks: list[bytes] = []
|
|
235
|
+
while True:
|
|
236
|
+
chunk = await process.stdout.read(4096)
|
|
237
|
+
if not chunk:
|
|
238
|
+
break
|
|
239
|
+
chunks.append(chunk)
|
|
240
|
+
# Extract last line for status
|
|
241
|
+
text = chunk.decode("utf-8", errors="replace")
|
|
242
|
+
last_line = text.rstrip().rsplit("\n", 1)[-1]
|
|
243
|
+
status = _clean_status(last_line)
|
|
244
|
+
if status:
|
|
245
|
+
frame = _spinner_frames[_frame_idx[0] % len(_spinner_frames)]
|
|
246
|
+
_frame_idx[0] += 1
|
|
247
|
+
sys.stderr.write(f"\r\033[K {frame} {status}")
|
|
248
|
+
sys.stderr.flush()
|
|
249
|
+
return b"".join(chunks)
|
|
250
|
+
|
|
251
|
+
# Show initial status
|
|
252
|
+
label = description or "Running"
|
|
253
|
+
sys.stderr.write(f"\r\033[K {_spinner_frames[0]} {label}...")
|
|
254
|
+
sys.stderr.flush()
|
|
255
|
+
|
|
256
|
+
# Run stderr reader concurrently with stdout collection
|
|
257
|
+
stderr_task = asyncio.create_task(_read_stderr())
|
|
258
|
+
stdout_bytes = await _read_stdout()
|
|
259
|
+
await stderr_task
|
|
260
|
+
await process.wait()
|
|
261
|
+
|
|
262
|
+
# Clear the status line
|
|
263
|
+
sys.stderr.write("\r\033[K")
|
|
264
|
+
sys.stderr.flush()
|
|
265
|
+
|
|
266
|
+
return stdout_bytes, "\n".join(stderr_lines)
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
# ---------------------------------------------------------------------------
|
|
270
|
+
# Main execution
|
|
271
|
+
# ---------------------------------------------------------------------------
|
|
272
|
+
|
|
273
|
+
async def execute_bash(input_cfg: BashCommandInput) -> BashCommandOutput:
|
|
274
|
+
"""Execute a bash command asynchronously.
|
|
275
|
+
|
|
276
|
+
Streams stderr to the terminal in real-time so the user can see progress
|
|
277
|
+
from long-running commands (builds, installs, copies). Ctrl+C during
|
|
278
|
+
execution kills only this command, not the whole session.
|
|
279
|
+
|
|
280
|
+
Maps to: rust/crates/runtime/src/bash.rs::execute_bash
|
|
281
|
+
"""
|
|
282
|
+
timeout_secs = min(input_cfg.timeout_ms, MAX_TIMEOUT_MS) / 1000.0
|
|
283
|
+
cwd = str(input_cfg.cwd) if input_cfg.cwd else None
|
|
284
|
+
|
|
285
|
+
# Background execution
|
|
286
|
+
if input_cfg.run_in_background:
|
|
287
|
+
return await _execute_background(input_cfg, cwd)
|
|
288
|
+
|
|
289
|
+
# Build command
|
|
290
|
+
shell_cmd = _build_sandbox_command(input_cfg.command, input_cfg.cwd, input_cfg)
|
|
291
|
+
|
|
292
|
+
# Build environment
|
|
293
|
+
env = dict(os.environ)
|
|
294
|
+
if input_cfg.filesystem_mode and input_cfg.filesystem_mode != FilesystemIsolationMode.NONE:
|
|
295
|
+
sandbox_env = _prepare_sandbox_dirs(input_cfg.cwd)
|
|
296
|
+
env.update(sandbox_env)
|
|
297
|
+
|
|
298
|
+
try:
|
|
299
|
+
process = await asyncio.create_subprocess_exec(
|
|
300
|
+
*shell_cmd,
|
|
301
|
+
stdout=asyncio.subprocess.PIPE,
|
|
302
|
+
stderr=asyncio.subprocess.PIPE,
|
|
303
|
+
cwd=cwd,
|
|
304
|
+
env=env,
|
|
305
|
+
)
|
|
306
|
+
|
|
307
|
+
try:
|
|
308
|
+
# Stream stderr in real-time while collecting stdout
|
|
309
|
+
stdout_bytes, stderr_text = await asyncio.wait_for(
|
|
310
|
+
_stream_process(process, description=input_cfg.description),
|
|
311
|
+
timeout=timeout_secs,
|
|
312
|
+
)
|
|
313
|
+
except asyncio.CancelledError:
|
|
314
|
+
# Ctrl+C hit — kill just this command
|
|
315
|
+
_kill_process(process)
|
|
316
|
+
return BashCommandOutput(
|
|
317
|
+
stdout="",
|
|
318
|
+
stderr="Command cancelled by user (Ctrl+C)",
|
|
319
|
+
exit_code=None,
|
|
320
|
+
interrupted=True,
|
|
321
|
+
)
|
|
322
|
+
except asyncio.TimeoutError:
|
|
323
|
+
_kill_process(process)
|
|
324
|
+
return BashCommandOutput(
|
|
325
|
+
stdout="",
|
|
326
|
+
stderr=f"Command exceeded timeout of {input_cfg.timeout_ms}ms",
|
|
327
|
+
exit_code=None,
|
|
328
|
+
timed_out=True,
|
|
329
|
+
interrupted=True,
|
|
330
|
+
)
|
|
331
|
+
|
|
332
|
+
stdout = truncate_output(stdout_bytes.decode("utf-8", errors="replace"))
|
|
333
|
+
stderr = truncate_output(stderr_text)
|
|
334
|
+
|
|
335
|
+
exit_code = process.returncode
|
|
336
|
+
return_code_interp = None
|
|
337
|
+
if exit_code is not None and exit_code != 0:
|
|
338
|
+
return_code_interp = f"exit_code:{exit_code}"
|
|
339
|
+
|
|
340
|
+
return BashCommandOutput(
|
|
341
|
+
stdout=stdout,
|
|
342
|
+
stderr=stderr,
|
|
343
|
+
exit_code=exit_code,
|
|
344
|
+
no_output_expected=(not stdout.strip() and not stderr.strip()),
|
|
345
|
+
return_code_interpretation=return_code_interp,
|
|
346
|
+
)
|
|
347
|
+
|
|
348
|
+
except FileNotFoundError:
|
|
349
|
+
return BashCommandOutput(
|
|
350
|
+
stdout="",
|
|
351
|
+
stderr="bash: command not found. Is bash installed?",
|
|
352
|
+
exit_code=127,
|
|
353
|
+
)
|
|
354
|
+
except Exception as exc:
|
|
355
|
+
logger.error("Failed to execute bash command: %s", exc)
|
|
356
|
+
return BashCommandOutput(
|
|
357
|
+
stdout="",
|
|
358
|
+
stderr=f"Execution error: {exc}",
|
|
359
|
+
exit_code=1,
|
|
360
|
+
)
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
async def _execute_background(
|
|
364
|
+
input_cfg: BashCommandInput, cwd: str | None
|
|
365
|
+
) -> BashCommandOutput:
|
|
366
|
+
"""Execute a command in the background, returning immediately."""
|
|
367
|
+
shell_cmd = _build_sandbox_command(input_cfg.command, input_cfg.cwd, input_cfg)
|
|
368
|
+
|
|
369
|
+
try:
|
|
370
|
+
process = await asyncio.create_subprocess_exec(
|
|
371
|
+
*shell_cmd,
|
|
372
|
+
stdout=asyncio.subprocess.DEVNULL,
|
|
373
|
+
stderr=asyncio.subprocess.DEVNULL,
|
|
374
|
+
cwd=cwd,
|
|
375
|
+
)
|
|
376
|
+
|
|
377
|
+
task_id = f"bg-{uuid.uuid4().hex[:8]}"
|
|
378
|
+
_background_tasks[task_id] = process
|
|
379
|
+
|
|
380
|
+
return BashCommandOutput(
|
|
381
|
+
background_task_id=task_id,
|
|
382
|
+
stdout=f"Background task started: {task_id} (PID: {process.pid})",
|
|
383
|
+
)
|
|
384
|
+
except Exception as exc:
|
|
385
|
+
return BashCommandOutput(
|
|
386
|
+
stderr=f"Failed to start background task: {exc}",
|
|
387
|
+
exit_code=1,
|
|
388
|
+
)
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""Bootstrap phases for CLI startup.
|
|
2
|
+
|
|
3
|
+
Maps to: rust/crates/runtime/src/bootstrap.rs
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import enum
|
|
9
|
+
from dataclasses import dataclass, field
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class BootstrapPhase(enum.Enum):
|
|
13
|
+
VERSION_CHECK = "version_check"
|
|
14
|
+
PROFILER = "profiler"
|
|
15
|
+
SYSTEM_PROMPT = "system_prompt"
|
|
16
|
+
DAEMON = "daemon"
|
|
17
|
+
AUTH = "auth"
|
|
18
|
+
CONFIG = "config"
|
|
19
|
+
PLUGINS = "plugins"
|
|
20
|
+
MCP = "mcp"
|
|
21
|
+
READY = "ready"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass
|
|
25
|
+
class BootstrapPlan:
|
|
26
|
+
"""Ordered list of bootstrap phases to execute."""
|
|
27
|
+
|
|
28
|
+
phases: list[BootstrapPhase] = field(default_factory=lambda: [
|
|
29
|
+
BootstrapPhase.VERSION_CHECK,
|
|
30
|
+
BootstrapPhase.CONFIG,
|
|
31
|
+
BootstrapPhase.AUTH,
|
|
32
|
+
BootstrapPhase.PLUGINS,
|
|
33
|
+
BootstrapPhase.MCP,
|
|
34
|
+
BootstrapPhase.SYSTEM_PROMPT,
|
|
35
|
+
BootstrapPhase.READY,
|
|
36
|
+
])
|
|
37
|
+
|
|
38
|
+
def includes(self, phase: BootstrapPhase) -> bool:
|
|
39
|
+
return phase in self.phases
|