tylor-mcp 1.0.0
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.
- package/.aws-setup.sh +25 -0
- package/.claude-plugin/plugin.json +22 -0
- package/.mcp.json +12 -0
- package/AGENTS.md +93 -0
- package/CLAUDE.md +99 -0
- package/CLAUDE_PLATFORM_AWS_SETUP.md +105 -0
- package/LICENSE +21 -0
- package/README.md +146 -0
- package/assets/tylor_logo.png +0 -0
- package/assets/tylor_threads_concept.png +0 -0
- package/bin/tylor.js +23 -0
- package/hooks/kill-thread-trigger.sh +7 -0
- package/hooks/post-tool-use-code-index.sh +7 -0
- package/hooks/session-checkpoint.sh +7 -0
- package/hooks/session-start.sh +7 -0
- package/install.py +401 -0
- package/install.sh +260 -0
- package/package.json +24 -0
- package/pytest.ini +2 -0
- package/registry.json +26 -0
- package/server/.env.example +24 -0
- package/server/__init__.py +0 -0
- package/server/config.py +89 -0
- package/server/main.py +93 -0
- package/server/personas/analyst.md +15 -0
- package/server/personas/ceo.md +14 -0
- package/server/personas/code_agent.md +15 -0
- package/server/personas/cto.md +14 -0
- package/server/provision.py +260 -0
- package/server/provision_opensearch.py +154 -0
- package/server/requirements.txt +26 -0
- package/server/storage/__init__.py +0 -0
- package/server/storage/dynamo.py +399 -0
- package/server/storage/json_store.py +359 -0
- package/server/storage/opensearch.py +194 -0
- package/server/storage/s3.py +96 -0
- package/server/storage/tests/__init__.py +0 -0
- package/server/storage/tests/test_dynamo.py +452 -0
- package/server/storage/tests/test_json_store.py +226 -0
- package/server/storage/tests/test_opensearch.py +270 -0
- package/server/storage/tests/test_s3.py +125 -0
- package/server/tests/__init__.py +0 -0
- package/server/tests/test_install.py +606 -0
- package/server/tests/test_isolation.py +90 -0
- package/server/tests/test_ui_server.py +385 -0
- package/server/tests/test_ui_shader_background.py +52 -0
- package/server/tests/test_ui_story_6_3.py +105 -0
- package/server/tools/__init__.py +0 -0
- package/server/tools/_mcp.py +4 -0
- package/server/tools/agents.py +160 -0
- package/server/tools/ecc/__init__.py +1 -0
- package/server/tools/ecc/data.py +35 -0
- package/server/tools/ecc/diagrams.py +23 -0
- package/server/tools/ecc/pipeline.py +24 -0
- package/server/tools/ecc/presentation.py +24 -0
- package/server/tools/ecc/web.py +23 -0
- package/server/tools/executor.py +880 -0
- package/server/tools/harness.py +330 -0
- package/server/tools/help.py +162 -0
- package/server/tools/hooks.py +357 -0
- package/server/tools/personas.py +110 -0
- package/server/tools/registry.py +195 -0
- package/server/tools/router.py +117 -0
- package/server/tools/skill_installer.py +230 -0
- package/server/tools/summarizer.py +168 -0
- package/server/tools/tests/__init__.py +0 -0
- package/server/tools/tests/test_agents.py +246 -0
- package/server/tools/tests/test_code_index.py +108 -0
- package/server/tools/tests/test_ecc_tools.py +51 -0
- package/server/tools/tests/test_executor.py +584 -0
- package/server/tools/tests/test_help_agent101.py +149 -0
- package/server/tools/tests/test_hooks.py +124 -0
- package/server/tools/tests/test_kill_thread.py +125 -0
- package/server/tools/tests/test_new_thread_list_threads.py +293 -0
- package/server/tools/tests/test_personas.py +52 -0
- package/server/tools/tests/test_recall_memory.py +55 -0
- package/server/tools/tests/test_registry_client.py +308 -0
- package/server/tools/tests/test_router.py +263 -0
- package/server/tools/tests/test_skill_installer.py +174 -0
- package/server/tools/tests/test_switch_thread.py +163 -0
- package/server/tools/tests/test_thread_command_skills.py +54 -0
- package/server/tools/tests/test_thread_resolver.py +165 -0
- package/server/tools/tests/test_tier1_schema.py +296 -0
- package/server/tools/thread_resolver.py +75 -0
- package/server/tools/tylor.py +374 -0
- package/server/tools/ui.py +38 -0
- package/server/ui_server.py +292 -0
- package/server/validate.py +237 -0
- package/skills/add-skill/SKILL.md +37 -0
- package/skills/afk-status/SKILL.md +20 -0
- package/skills/bmad/SKILL.md +14 -0
- package/skills/help-agent101/SKILL.md +48 -0
- package/skills/kill-thread/SKILL.md +35 -0
- package/skills/list-threads/SKILL.md +35 -0
- package/skills/new-thread/SKILL.md +35 -0
- package/skills/recall/SKILL.md +39 -0
- package/skills/run/SKILL.md +33 -0
- package/skills/set-sandbox/SKILL.md +38 -0
- package/skills/switch-thread/SKILL.md +38 -0
- package/ui/claude-logo.png +0 -0
- package/ui/index.html +1314 -0
|
@@ -0,0 +1,880 @@
|
|
|
1
|
+
"""server/tools/executor.py — AFK sandbox declaration and execution guard."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
import os
|
|
4
|
+
import re
|
|
5
|
+
import shlex
|
|
6
|
+
import signal
|
|
7
|
+
import subprocess
|
|
8
|
+
import threading
|
|
9
|
+
import time
|
|
10
|
+
import uuid
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
from mcp.server.fastmcp.exceptions import ToolError
|
|
14
|
+
|
|
15
|
+
from ._mcp import mcp
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
NO_SANDBOX_MESSAGE = "No sandbox configured — run /set-sandbox <path> first"
|
|
19
|
+
DEFAULT_TIMEOUT_SECONDS = 120
|
|
20
|
+
TRANSIENT_BACKOFF_SECONDS = (5, 15, 45)
|
|
21
|
+
RECOVERY_CAP = 5
|
|
22
|
+
SAFE_MODULE_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_.-]*$")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class SandboxViolation(ToolError):
|
|
26
|
+
"""Raised when a command references a path outside configured sandbox roots."""
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def classify_execution_failure(stderr: str) -> str:
|
|
30
|
+
"""Classify command stderr for AFK recovery policy."""
|
|
31
|
+
text = (stderr or "").lower()
|
|
32
|
+
transient_markers = (
|
|
33
|
+
"connection reset",
|
|
34
|
+
"connection refused",
|
|
35
|
+
"network",
|
|
36
|
+
"timeout",
|
|
37
|
+
"timed out",
|
|
38
|
+
"temporary",
|
|
39
|
+
"temporarily unavailable",
|
|
40
|
+
"resource busy",
|
|
41
|
+
"file lock",
|
|
42
|
+
"locked",
|
|
43
|
+
)
|
|
44
|
+
if any(marker in text for marker in transient_markers):
|
|
45
|
+
return "transient"
|
|
46
|
+
return "logic"
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _missing_module(stderr: str) -> str | None:
|
|
50
|
+
match = re.search(r"No module named ['\"]([^'\"]+)['\"]", stderr or "")
|
|
51
|
+
module = match.group(1) if match else None
|
|
52
|
+
if not module or not SAFE_MODULE_RE.fullmatch(module):
|
|
53
|
+
return None
|
|
54
|
+
return module
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _get_db():
|
|
58
|
+
from server.tools.tylor import _get_db as get_thread_db
|
|
59
|
+
|
|
60
|
+
return get_thread_db()
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _resolve_existing_absolute_path(path: str) -> str:
|
|
64
|
+
expanded = Path(path).expanduser()
|
|
65
|
+
if not expanded.is_absolute() or not expanded.exists():
|
|
66
|
+
raise ToolError("Sandbox path must be absolute and exist")
|
|
67
|
+
return os.path.realpath(expanded)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _thread_meta(db, thread_id: str) -> dict:
|
|
71
|
+
meta = db.get_thread_meta(thread_id)
|
|
72
|
+
if not meta:
|
|
73
|
+
raise ToolError(f"Thread not found: {thread_id}")
|
|
74
|
+
return meta
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _now_iso() -> str:
|
|
78
|
+
from server.tools.tylor import _now_iso as thread_now
|
|
79
|
+
|
|
80
|
+
return thread_now()
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _log_thread_event(db, thread_id: str, event_type: str, attributes: dict) -> None:
|
|
84
|
+
sk = f"THREAD#{thread_id}#MSG#{_now_iso()}#{event_type.upper()}#{uuid.uuid4().hex}"
|
|
85
|
+
db.put_item(sk, {"Role": "system", "Type": event_type, **attributes})
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _write_thread_meta(db, thread_id: str, meta: dict) -> dict:
|
|
89
|
+
updated = dict(meta)
|
|
90
|
+
return db.put_item(f"THREAD#{thread_id}#META", updated)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _stdout_summary(result: dict, limit: int = 1000) -> str:
|
|
94
|
+
text = (result.get("stdout") or result.get("stderr") or "").strip()
|
|
95
|
+
if len(text) <= limit:
|
|
96
|
+
return text
|
|
97
|
+
return f"{text[:limit].rstrip()}..."
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _default_afk_steps(task: str) -> list[str]:
|
|
101
|
+
return [
|
|
102
|
+
f"printf '%s\\n' {shlex.quote('AFK task accepted: ' + task)}",
|
|
103
|
+
"pytest -q",
|
|
104
|
+
]
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _pause_message(current_step: str) -> str:
|
|
108
|
+
return (
|
|
109
|
+
f"AFK paused — here's where I am: {current_step}. "
|
|
110
|
+
"Type 'resume' to continue or give new instructions"
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _afk_session(meta: dict) -> dict | None:
|
|
115
|
+
session = meta.get("afk_session")
|
|
116
|
+
return dict(session) if isinstance(session, dict) else None
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _is_pause_requested(meta: dict) -> bool:
|
|
120
|
+
session = _afk_session(meta)
|
|
121
|
+
return bool(
|
|
122
|
+
session
|
|
123
|
+
and (
|
|
124
|
+
session.get("pause_requested") is True
|
|
125
|
+
or session.get("status") == "pause_requested"
|
|
126
|
+
)
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _files_modified_summary(cwd: str | None) -> list[dict]:
|
|
131
|
+
if not cwd:
|
|
132
|
+
return []
|
|
133
|
+
try:
|
|
134
|
+
status = subprocess.run(
|
|
135
|
+
["git", "status", "--short"],
|
|
136
|
+
cwd=cwd,
|
|
137
|
+
check=False,
|
|
138
|
+
capture_output=True,
|
|
139
|
+
text=True,
|
|
140
|
+
timeout=5,
|
|
141
|
+
)
|
|
142
|
+
except (OSError, subprocess.TimeoutExpired):
|
|
143
|
+
return []
|
|
144
|
+
if status.returncode != 0:
|
|
145
|
+
return []
|
|
146
|
+
changed = []
|
|
147
|
+
for line in status.stdout.splitlines():
|
|
148
|
+
if not line.strip():
|
|
149
|
+
continue
|
|
150
|
+
path = line[3:].strip()
|
|
151
|
+
path_for_diff = path.split(" -> ")[-1]
|
|
152
|
+
file_diff = subprocess.run(
|
|
153
|
+
["git", "diff", "--stat", "--", path_for_diff],
|
|
154
|
+
cwd=cwd,
|
|
155
|
+
check=False,
|
|
156
|
+
capture_output=True,
|
|
157
|
+
text=True,
|
|
158
|
+
timeout=5,
|
|
159
|
+
)
|
|
160
|
+
changed.append({
|
|
161
|
+
"path": path,
|
|
162
|
+
"status": line[:2].strip(),
|
|
163
|
+
"diff_summary": file_diff.stdout.strip(),
|
|
164
|
+
})
|
|
165
|
+
return changed
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _test_state(outcomes: list[dict]) -> str:
|
|
169
|
+
testish = [
|
|
170
|
+
outcome
|
|
171
|
+
for outcome in outcomes
|
|
172
|
+
if "test" in outcome.get("command", "").lower()
|
|
173
|
+
or "pytest" in outcome.get("command", "").lower()
|
|
174
|
+
]
|
|
175
|
+
if not testish:
|
|
176
|
+
return "not_run"
|
|
177
|
+
return "passing" if all(outcome.get("exit_code") == 0 for outcome in testish) else "failing"
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def _recovery_log(
|
|
181
|
+
db,
|
|
182
|
+
thread_id: str,
|
|
183
|
+
command: str,
|
|
184
|
+
result: dict,
|
|
185
|
+
classification: str,
|
|
186
|
+
attempts: list[dict],
|
|
187
|
+
content: str,
|
|
188
|
+
recommended_next_step: str | None = None,
|
|
189
|
+
) -> None:
|
|
190
|
+
attributes = {
|
|
191
|
+
"OriginalCommand": command,
|
|
192
|
+
"ExitCode": result.get("exit_code"),
|
|
193
|
+
"Classification": classification,
|
|
194
|
+
"Attempts": attempts,
|
|
195
|
+
"CommandsRun": [attempt.get("command") for attempt in attempts if attempt.get("command")],
|
|
196
|
+
"FilesModified": [],
|
|
197
|
+
"Content": content,
|
|
198
|
+
}
|
|
199
|
+
if recommended_next_step:
|
|
200
|
+
attributes["RecommendedNextStep"] = recommended_next_step
|
|
201
|
+
_log_thread_event(db, thread_id, "recovery_decision", attributes)
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def _real_roots(raw_roots: list[str]) -> list[str]:
|
|
205
|
+
return [os.path.realpath(Path(root).expanduser()) for root in raw_roots]
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def _is_inside_roots(path: str, roots: list[str]) -> bool:
|
|
209
|
+
real_path = os.path.realpath(path)
|
|
210
|
+
for root in roots:
|
|
211
|
+
try:
|
|
212
|
+
if os.path.commonpath([real_path, root]) == root:
|
|
213
|
+
return True
|
|
214
|
+
except ValueError:
|
|
215
|
+
continue
|
|
216
|
+
return False
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def _candidate_paths(command: str, cwd: str) -> list[tuple[str, str]]:
|
|
220
|
+
candidates = [(cwd, cwd)]
|
|
221
|
+
for token in shlex.split(command):
|
|
222
|
+
# Extract value from --flag=value or -f/value forms
|
|
223
|
+
raw = token
|
|
224
|
+
if "=" in token and token.startswith("-"):
|
|
225
|
+
raw = token.split("=", 1)[1]
|
|
226
|
+
elif token.startswith("-") and not token.startswith("--") and len(token) > 2 and "/" in token:
|
|
227
|
+
raw = token[2:] # -o/path/to/file
|
|
228
|
+
elif token.startswith("-") and "=" not in token and "/" not in token:
|
|
229
|
+
continue # pure flag like -v, --verbose
|
|
230
|
+
path = Path(raw).expanduser()
|
|
231
|
+
if path.is_absolute():
|
|
232
|
+
candidates.append((raw, str(path)))
|
|
233
|
+
continue
|
|
234
|
+
relative = Path(cwd) / path
|
|
235
|
+
# Include bare filenames resolved relative to cwd (even if they don't exist yet)
|
|
236
|
+
candidates.append((raw, str(relative)))
|
|
237
|
+
return candidates
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def _raise_violation(db, thread_id: str, command: str, display_path: str, resolved_path: str) -> None:
|
|
241
|
+
message = f"Path {display_path} is outside sandbox — operation rejected"
|
|
242
|
+
_log_thread_event(
|
|
243
|
+
db,
|
|
244
|
+
thread_id,
|
|
245
|
+
"sandbox_violation",
|
|
246
|
+
{
|
|
247
|
+
"Command": command,
|
|
248
|
+
"Path": display_path,
|
|
249
|
+
"ResolvedPath": resolved_path,
|
|
250
|
+
"Content": message,
|
|
251
|
+
},
|
|
252
|
+
)
|
|
253
|
+
raise SandboxViolation(message)
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def _validate_command_paths(
|
|
257
|
+
db,
|
|
258
|
+
thread_id: str,
|
|
259
|
+
command: str,
|
|
260
|
+
cwd: str,
|
|
261
|
+
roots: list[str],
|
|
262
|
+
) -> None:
|
|
263
|
+
for display_path, path in _candidate_paths(command, cwd):
|
|
264
|
+
resolved = os.path.realpath(path)
|
|
265
|
+
if not _is_inside_roots(resolved, roots):
|
|
266
|
+
_raise_violation(db, thread_id, command, display_path, resolved)
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def _terminate_process_group(process: subprocess.Popen) -> None:
|
|
270
|
+
try:
|
|
271
|
+
os.killpg(process.pid, signal.SIGTERM)
|
|
272
|
+
except ProcessLookupError:
|
|
273
|
+
return
|
|
274
|
+
try:
|
|
275
|
+
process.wait(timeout=2)
|
|
276
|
+
except subprocess.TimeoutExpired:
|
|
277
|
+
try:
|
|
278
|
+
os.killpg(process.pid, signal.SIGKILL)
|
|
279
|
+
except ProcessLookupError:
|
|
280
|
+
return
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
@mcp.tool()
|
|
284
|
+
def set_sandbox(path: str, thread_id: str | None = None) -> dict:
|
|
285
|
+
"""
|
|
286
|
+
Add or clear sandbox roots for a thread.
|
|
287
|
+
|
|
288
|
+
Args:
|
|
289
|
+
path: Absolute existing path to allow, or "clear" to remove all roots.
|
|
290
|
+
thread_id: Optional thread id. Defaults to the active current thread.
|
|
291
|
+
"""
|
|
292
|
+
db = _get_db()
|
|
293
|
+
resolved_thread_id = db.resolve_thread_id(thread_id)
|
|
294
|
+
|
|
295
|
+
if path.strip().lower() == "clear":
|
|
296
|
+
item = db.set_sandbox_roots(resolved_thread_id, [])
|
|
297
|
+
return {
|
|
298
|
+
"status": "cleared",
|
|
299
|
+
"thread_id": resolved_thread_id,
|
|
300
|
+
"sandbox_roots": list(item.get("sandbox_roots", [])),
|
|
301
|
+
"message": (
|
|
302
|
+
"Sandbox cleared — execution tools will refuse all path operations "
|
|
303
|
+
"until a new sandbox is set"
|
|
304
|
+
),
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
root = _resolve_existing_absolute_path(path)
|
|
308
|
+
meta = _thread_meta(db, resolved_thread_id)
|
|
309
|
+
roots = list(meta.get("sandbox_roots", []))
|
|
310
|
+
if root not in roots:
|
|
311
|
+
roots.append(root)
|
|
312
|
+
|
|
313
|
+
item = db.set_sandbox_roots(resolved_thread_id, roots)
|
|
314
|
+
return {
|
|
315
|
+
"status": "set",
|
|
316
|
+
"thread_id": resolved_thread_id,
|
|
317
|
+
"sandbox_roots": list(item.get("sandbox_roots", [])),
|
|
318
|
+
"message": f"Sandbox set to {path} — executor will reject any path outside this root",
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
@mcp.tool()
|
|
323
|
+
def execute_in_sandbox(
|
|
324
|
+
command: str,
|
|
325
|
+
thread_id: str | None = None,
|
|
326
|
+
cwd: str | None = None,
|
|
327
|
+
timeout_seconds: int = DEFAULT_TIMEOUT_SECONDS,
|
|
328
|
+
) -> dict:
|
|
329
|
+
"""
|
|
330
|
+
Execute a command inside declared sandbox roots.
|
|
331
|
+
Story 5.1 only implements the no-sandbox guard; Story 5.2 adds execution.
|
|
332
|
+
"""
|
|
333
|
+
db = _get_db()
|
|
334
|
+
resolved_thread_id = db.resolve_thread_id(thread_id)
|
|
335
|
+
meta = _thread_meta(db, resolved_thread_id)
|
|
336
|
+
sandbox_roots = list(meta.get("sandbox_roots", []))
|
|
337
|
+
if not sandbox_roots:
|
|
338
|
+
raise ToolError(NO_SANDBOX_MESSAGE)
|
|
339
|
+
|
|
340
|
+
roots = _real_roots(sandbox_roots)
|
|
341
|
+
workdir = os.path.realpath(Path(cwd).expanduser()) if cwd else roots[0]
|
|
342
|
+
if not _is_inside_roots(workdir, roots):
|
|
343
|
+
_raise_violation(db, resolved_thread_id, command, cwd or workdir, workdir)
|
|
344
|
+
_validate_command_paths(db, resolved_thread_id, command, workdir, roots)
|
|
345
|
+
|
|
346
|
+
start = time.monotonic()
|
|
347
|
+
try:
|
|
348
|
+
args = shlex.split(command)
|
|
349
|
+
except ValueError as exc:
|
|
350
|
+
raise ToolError(f"Invalid command syntax: {exc}") from exc
|
|
351
|
+
if not args:
|
|
352
|
+
raise ToolError("Command must not be empty")
|
|
353
|
+
|
|
354
|
+
process = subprocess.Popen(
|
|
355
|
+
args,
|
|
356
|
+
cwd=workdir,
|
|
357
|
+
shell=False,
|
|
358
|
+
stdout=subprocess.PIPE,
|
|
359
|
+
stderr=subprocess.PIPE,
|
|
360
|
+
text=True,
|
|
361
|
+
start_new_session=True,
|
|
362
|
+
)
|
|
363
|
+
try:
|
|
364
|
+
stdout, stderr = process.communicate(timeout=timeout_seconds)
|
|
365
|
+
duration_ms = int((time.monotonic() - start) * 1000)
|
|
366
|
+
result = {
|
|
367
|
+
"status": "completed",
|
|
368
|
+
"exit_code": process.returncode,
|
|
369
|
+
"stdout": stdout,
|
|
370
|
+
"stderr": stderr,
|
|
371
|
+
"duration_ms": duration_ms,
|
|
372
|
+
}
|
|
373
|
+
_log_thread_event(
|
|
374
|
+
db,
|
|
375
|
+
resolved_thread_id,
|
|
376
|
+
"sandbox_execution",
|
|
377
|
+
{
|
|
378
|
+
"Command": command,
|
|
379
|
+
"Cwd": workdir,
|
|
380
|
+
"ExitCode": process.returncode,
|
|
381
|
+
"Outcome": "success" if process.returncode == 0 else "failed",
|
|
382
|
+
"DurationMs": duration_ms,
|
|
383
|
+
"Content": f"Command `{command}` exited {process.returncode}",
|
|
384
|
+
},
|
|
385
|
+
)
|
|
386
|
+
return result
|
|
387
|
+
except subprocess.TimeoutExpired as exc:
|
|
388
|
+
_terminate_process_group(process)
|
|
389
|
+
stdout = exc.stdout or ""
|
|
390
|
+
stderr = exc.stderr or ""
|
|
391
|
+
if isinstance(stdout, bytes):
|
|
392
|
+
stdout = stdout.decode("utf-8", errors="replace")
|
|
393
|
+
if isinstance(stderr, bytes):
|
|
394
|
+
stderr = stderr.decode("utf-8", errors="replace")
|
|
395
|
+
duration_ms = int((time.monotonic() - start) * 1000)
|
|
396
|
+
message = f"Command timed out after {timeout_seconds}s — partial stdout captured"
|
|
397
|
+
_log_thread_event(
|
|
398
|
+
db,
|
|
399
|
+
resolved_thread_id,
|
|
400
|
+
"sandbox_execution",
|
|
401
|
+
{
|
|
402
|
+
"Command": command,
|
|
403
|
+
"Cwd": workdir,
|
|
404
|
+
"ExitCode": None,
|
|
405
|
+
"Outcome": "timeout",
|
|
406
|
+
"DurationMs": duration_ms,
|
|
407
|
+
"Content": message,
|
|
408
|
+
},
|
|
409
|
+
)
|
|
410
|
+
return {
|
|
411
|
+
"status": "timeout",
|
|
412
|
+
"exit_code": None,
|
|
413
|
+
"stdout": stdout,
|
|
414
|
+
"stderr": stderr,
|
|
415
|
+
"duration_ms": duration_ms,
|
|
416
|
+
"message": message,
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
|
|
420
|
+
@mcp.tool()
|
|
421
|
+
def execute_with_recovery(
|
|
422
|
+
command: str,
|
|
423
|
+
thread_id: str | None = None,
|
|
424
|
+
cwd: str | None = None,
|
|
425
|
+
timeout_seconds: int = DEFAULT_TIMEOUT_SECONDS,
|
|
426
|
+
recovery_attempts_used: int = 0,
|
|
427
|
+
) -> dict:
|
|
428
|
+
"""
|
|
429
|
+
Execute a sandboxed command and apply bounded AFK failure recovery.
|
|
430
|
+
|
|
431
|
+
Retries transient failures with 5s, 15s, 45s backoff. For fixable import
|
|
432
|
+
failures, installs the missing module once and re-runs the original command.
|
|
433
|
+
"""
|
|
434
|
+
db = _get_db()
|
|
435
|
+
resolved_thread_id = db.resolve_thread_id(thread_id)
|
|
436
|
+
if recovery_attempts_used >= RECOVERY_CAP:
|
|
437
|
+
message = "Recovery cap reached — pausing autonomous execution"
|
|
438
|
+
_log_thread_event(
|
|
439
|
+
db,
|
|
440
|
+
resolved_thread_id,
|
|
441
|
+
"recovery_decision",
|
|
442
|
+
{
|
|
443
|
+
"OriginalCommand": command,
|
|
444
|
+
"ExitCode": None,
|
|
445
|
+
"Classification": "cap_reached",
|
|
446
|
+
"Attempts": [],
|
|
447
|
+
"CommandsRun": [],
|
|
448
|
+
"FilesModified": [],
|
|
449
|
+
"RecommendedNextStep": "Developer input required before continuing AFK execution.",
|
|
450
|
+
"Content": message,
|
|
451
|
+
},
|
|
452
|
+
)
|
|
453
|
+
return {"status": "paused", "message": message}
|
|
454
|
+
|
|
455
|
+
attempts: list[dict] = []
|
|
456
|
+
result = execute_in_sandbox(
|
|
457
|
+
command=command,
|
|
458
|
+
thread_id=resolved_thread_id,
|
|
459
|
+
cwd=cwd,
|
|
460
|
+
timeout_seconds=timeout_seconds,
|
|
461
|
+
)
|
|
462
|
+
attempts.append({"command": command, "outcome": result.get("status"), "exit_code": result.get("exit_code")})
|
|
463
|
+
if result.get("exit_code") == 0:
|
|
464
|
+
return {"status": "success", "result": result, "attempts": attempts}
|
|
465
|
+
|
|
466
|
+
classification = classify_execution_failure(result.get("stderr", ""))
|
|
467
|
+
used = recovery_attempts_used
|
|
468
|
+
|
|
469
|
+
if classification == "transient":
|
|
470
|
+
current = result
|
|
471
|
+
for backoff in TRANSIENT_BACKOFF_SECONDS:
|
|
472
|
+
if used >= RECOVERY_CAP:
|
|
473
|
+
break
|
|
474
|
+
used += 1
|
|
475
|
+
time.sleep(backoff)
|
|
476
|
+
current = execute_in_sandbox(
|
|
477
|
+
command=command,
|
|
478
|
+
thread_id=resolved_thread_id,
|
|
479
|
+
cwd=cwd,
|
|
480
|
+
timeout_seconds=timeout_seconds,
|
|
481
|
+
)
|
|
482
|
+
attempts.append({
|
|
483
|
+
"command": command,
|
|
484
|
+
"strategy": f"transient_retry_after_{backoff}s",
|
|
485
|
+
"outcome": current.get("status"),
|
|
486
|
+
"exit_code": current.get("exit_code"),
|
|
487
|
+
})
|
|
488
|
+
if current.get("exit_code") == 0:
|
|
489
|
+
_recovery_log(
|
|
490
|
+
db,
|
|
491
|
+
resolved_thread_id,
|
|
492
|
+
command,
|
|
493
|
+
result,
|
|
494
|
+
classification,
|
|
495
|
+
attempts,
|
|
496
|
+
"Transient failure recovered after retry.",
|
|
497
|
+
)
|
|
498
|
+
return {
|
|
499
|
+
"status": "recovered",
|
|
500
|
+
"classification": classification,
|
|
501
|
+
"result": current,
|
|
502
|
+
"attempts": attempts,
|
|
503
|
+
}
|
|
504
|
+
result = current
|
|
505
|
+
|
|
506
|
+
module = _missing_module(result.get("stderr", ""))
|
|
507
|
+
if classification == "logic" and module and used < RECOVERY_CAP:
|
|
508
|
+
fix_command = f"python3 -m pip install {module}"
|
|
509
|
+
used += 1
|
|
510
|
+
fix = execute_in_sandbox(
|
|
511
|
+
command=fix_command,
|
|
512
|
+
thread_id=resolved_thread_id,
|
|
513
|
+
cwd=cwd,
|
|
514
|
+
timeout_seconds=timeout_seconds,
|
|
515
|
+
)
|
|
516
|
+
attempts.append({
|
|
517
|
+
"command": fix_command,
|
|
518
|
+
"strategy": "install_missing_module",
|
|
519
|
+
"outcome": fix.get("status"),
|
|
520
|
+
"exit_code": fix.get("exit_code"),
|
|
521
|
+
})
|
|
522
|
+
if fix.get("exit_code") == 0 and used < RECOVERY_CAP:
|
|
523
|
+
used += 1
|
|
524
|
+
rerun = execute_in_sandbox(
|
|
525
|
+
command=command,
|
|
526
|
+
thread_id=resolved_thread_id,
|
|
527
|
+
cwd=cwd,
|
|
528
|
+
timeout_seconds=timeout_seconds,
|
|
529
|
+
)
|
|
530
|
+
attempts.append({
|
|
531
|
+
"command": command,
|
|
532
|
+
"strategy": "rerun_after_fix",
|
|
533
|
+
"outcome": rerun.get("status"),
|
|
534
|
+
"exit_code": rerun.get("exit_code"),
|
|
535
|
+
})
|
|
536
|
+
if rerun.get("exit_code") == 0:
|
|
537
|
+
content = (
|
|
538
|
+
f"Failure: ModuleNotFoundError -> Auto-fix: pip install {module} "
|
|
539
|
+
"-> Re-run: success"
|
|
540
|
+
)
|
|
541
|
+
_recovery_log(db, resolved_thread_id, command, result, classification, attempts, content)
|
|
542
|
+
return {
|
|
543
|
+
"status": "recovered",
|
|
544
|
+
"classification": classification,
|
|
545
|
+
"result": rerun,
|
|
546
|
+
"attempts": attempts,
|
|
547
|
+
}
|
|
548
|
+
result = rerun
|
|
549
|
+
|
|
550
|
+
message = (
|
|
551
|
+
"Recovery cap reached — pausing autonomous execution"
|
|
552
|
+
if used >= RECOVERY_CAP
|
|
553
|
+
else "Recovery exhausted — pausing autonomous execution"
|
|
554
|
+
)
|
|
555
|
+
_recovery_log(
|
|
556
|
+
db,
|
|
557
|
+
resolved_thread_id,
|
|
558
|
+
command,
|
|
559
|
+
result,
|
|
560
|
+
classification,
|
|
561
|
+
attempts,
|
|
562
|
+
message,
|
|
563
|
+
recommended_next_step="Developer input required before continuing AFK execution.",
|
|
564
|
+
)
|
|
565
|
+
return {
|
|
566
|
+
"status": "paused",
|
|
567
|
+
"classification": classification,
|
|
568
|
+
"message": message,
|
|
569
|
+
"attempts": attempts,
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
|
|
573
|
+
@mcp.tool()
|
|
574
|
+
def start_afk(
|
|
575
|
+
task: str,
|
|
576
|
+
steps: list[str] | None = None,
|
|
577
|
+
thread_id: str | None = None,
|
|
578
|
+
cwd: str | None = None,
|
|
579
|
+
timeout_seconds: int = DEFAULT_TIMEOUT_SECONDS,
|
|
580
|
+
background: bool = False,
|
|
581
|
+
) -> dict:
|
|
582
|
+
"""
|
|
583
|
+
Start an AFK task: persist a plan, execute each command in the sandbox, and
|
|
584
|
+
write step and completion events to the active thread.
|
|
585
|
+
"""
|
|
586
|
+
db = _get_db()
|
|
587
|
+
resolved_thread_id = db.resolve_thread_id(thread_id)
|
|
588
|
+
meta = _thread_meta(db, resolved_thread_id)
|
|
589
|
+
sandbox_roots = list(meta.get("sandbox_roots", []))
|
|
590
|
+
if not sandbox_roots:
|
|
591
|
+
raise ToolError(NO_SANDBOX_MESSAGE)
|
|
592
|
+
|
|
593
|
+
planned_steps = list(steps) if steps else _default_afk_steps(task)
|
|
594
|
+
if not planned_steps:
|
|
595
|
+
raise ToolError("AFK task requires at least one execution step")
|
|
596
|
+
effective_cwd = cwd or sandbox_roots[0]
|
|
597
|
+
|
|
598
|
+
session = {
|
|
599
|
+
"id": uuid.uuid4().hex,
|
|
600
|
+
"status": "active",
|
|
601
|
+
"task": task,
|
|
602
|
+
"steps": planned_steps,
|
|
603
|
+
"steps_completed": 0,
|
|
604
|
+
"steps_total": len(planned_steps),
|
|
605
|
+
"current_step": planned_steps[0],
|
|
606
|
+
"last_command_output": "",
|
|
607
|
+
"started_at": _now_iso(),
|
|
608
|
+
"started_at_monotonic": _now_iso(),
|
|
609
|
+
"pause_requested": False,
|
|
610
|
+
}
|
|
611
|
+
meta["afk_session"] = session
|
|
612
|
+
_write_thread_meta(db, resolved_thread_id, meta)
|
|
613
|
+
_log_thread_event(
|
|
614
|
+
db,
|
|
615
|
+
resolved_thread_id,
|
|
616
|
+
"afk_plan",
|
|
617
|
+
{
|
|
618
|
+
"TaskDescription": task,
|
|
619
|
+
"Steps": planned_steps,
|
|
620
|
+
"Content": "AFK execution plan:\n"
|
|
621
|
+
+ "\n".join(f"{index}. {step}" for index, step in enumerate(planned_steps, start=1)),
|
|
622
|
+
},
|
|
623
|
+
)
|
|
624
|
+
|
|
625
|
+
if background:
|
|
626
|
+
worker = threading.Thread(
|
|
627
|
+
target=_run_afk_background,
|
|
628
|
+
args=(resolved_thread_id, task, planned_steps, effective_cwd, timeout_seconds),
|
|
629
|
+
daemon=True,
|
|
630
|
+
)
|
|
631
|
+
worker.start()
|
|
632
|
+
return {
|
|
633
|
+
"status": "started",
|
|
634
|
+
"thread_id": resolved_thread_id,
|
|
635
|
+
"steps_total": len(planned_steps),
|
|
636
|
+
"current_step": planned_steps[0],
|
|
637
|
+
"message": f"AFK started — see thread {resolved_thread_id} for progress",
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
return _run_afk_steps(
|
|
641
|
+
db=db,
|
|
642
|
+
resolved_thread_id=resolved_thread_id,
|
|
643
|
+
meta=meta,
|
|
644
|
+
task=task,
|
|
645
|
+
planned_steps=planned_steps,
|
|
646
|
+
effective_cwd=effective_cwd,
|
|
647
|
+
timeout_seconds=timeout_seconds,
|
|
648
|
+
session=session,
|
|
649
|
+
)
|
|
650
|
+
|
|
651
|
+
|
|
652
|
+
def _run_afk_background(
|
|
653
|
+
resolved_thread_id: str,
|
|
654
|
+
task: str,
|
|
655
|
+
planned_steps: list[str],
|
|
656
|
+
effective_cwd: str,
|
|
657
|
+
timeout_seconds: int,
|
|
658
|
+
) -> None:
|
|
659
|
+
db = _get_db()
|
|
660
|
+
meta = _thread_meta(db, resolved_thread_id)
|
|
661
|
+
session = _afk_session(meta) or {
|
|
662
|
+
"id": uuid.uuid4().hex,
|
|
663
|
+
"status": "active",
|
|
664
|
+
"task": task,
|
|
665
|
+
"steps": planned_steps,
|
|
666
|
+
"steps_completed": 0,
|
|
667
|
+
"steps_total": len(planned_steps),
|
|
668
|
+
"current_step": planned_steps[0],
|
|
669
|
+
"last_command_output": "",
|
|
670
|
+
"started_at": _now_iso(),
|
|
671
|
+
"started_at_monotonic": _now_iso(),
|
|
672
|
+
"pause_requested": False,
|
|
673
|
+
}
|
|
674
|
+
_run_afk_steps(
|
|
675
|
+
db=db,
|
|
676
|
+
resolved_thread_id=resolved_thread_id,
|
|
677
|
+
meta=meta,
|
|
678
|
+
task=task,
|
|
679
|
+
planned_steps=planned_steps,
|
|
680
|
+
effective_cwd=effective_cwd,
|
|
681
|
+
timeout_seconds=timeout_seconds,
|
|
682
|
+
session=session,
|
|
683
|
+
)
|
|
684
|
+
|
|
685
|
+
|
|
686
|
+
def _run_afk_steps(
|
|
687
|
+
db,
|
|
688
|
+
resolved_thread_id: str,
|
|
689
|
+
meta: dict,
|
|
690
|
+
task: str,
|
|
691
|
+
planned_steps: list[str],
|
|
692
|
+
effective_cwd: str,
|
|
693
|
+
timeout_seconds: int,
|
|
694
|
+
session: dict,
|
|
695
|
+
) -> dict:
|
|
696
|
+
outcomes: list[dict] = []
|
|
697
|
+
for index, command in enumerate(planned_steps, start=1):
|
|
698
|
+
checkpoint_meta = _thread_meta(db, resolved_thread_id)
|
|
699
|
+
if _is_pause_requested(checkpoint_meta):
|
|
700
|
+
session.update({
|
|
701
|
+
"status": "paused",
|
|
702
|
+
"current_step": command,
|
|
703
|
+
"pause_requested": False,
|
|
704
|
+
})
|
|
705
|
+
checkpoint_meta["afk_session"] = session
|
|
706
|
+
_write_thread_meta(db, resolved_thread_id, checkpoint_meta)
|
|
707
|
+
return {
|
|
708
|
+
"status": "paused",
|
|
709
|
+
"thread_id": resolved_thread_id,
|
|
710
|
+
"current_step": command,
|
|
711
|
+
"steps_completed": session["steps_completed"],
|
|
712
|
+
"steps_total": session["steps_total"],
|
|
713
|
+
"message": _pause_message(command),
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
session["current_step"] = command
|
|
717
|
+
meta["afk_session"] = session
|
|
718
|
+
_write_thread_meta(db, resolved_thread_id, meta)
|
|
719
|
+
result = execute_in_sandbox(
|
|
720
|
+
command=command,
|
|
721
|
+
thread_id=resolved_thread_id,
|
|
722
|
+
cwd=effective_cwd,
|
|
723
|
+
timeout_seconds=timeout_seconds,
|
|
724
|
+
)
|
|
725
|
+
outcome = {
|
|
726
|
+
"step": index,
|
|
727
|
+
"command": command,
|
|
728
|
+
"status": result.get("status"),
|
|
729
|
+
"exit_code": result.get("exit_code"),
|
|
730
|
+
"stdout_summary": _stdout_summary(result),
|
|
731
|
+
}
|
|
732
|
+
outcomes.append(outcome)
|
|
733
|
+
session.update({
|
|
734
|
+
"steps_completed": index,
|
|
735
|
+
"last_command_output": outcome["stdout_summary"],
|
|
736
|
+
})
|
|
737
|
+
meta["afk_session"] = session
|
|
738
|
+
_write_thread_meta(db, resolved_thread_id, meta)
|
|
739
|
+
_log_thread_event(
|
|
740
|
+
db,
|
|
741
|
+
resolved_thread_id,
|
|
742
|
+
"afk_step",
|
|
743
|
+
{
|
|
744
|
+
"Step": index,
|
|
745
|
+
"Command": command,
|
|
746
|
+
"Outcome": "success" if result.get("exit_code") == 0 else "failed",
|
|
747
|
+
"ExitCode": result.get("exit_code"),
|
|
748
|
+
"StdoutSummary": outcome["stdout_summary"],
|
|
749
|
+
"Content": f"AFK step {index}/{len(planned_steps)} `{command}` exited {result.get('exit_code')}",
|
|
750
|
+
},
|
|
751
|
+
)
|
|
752
|
+
|
|
753
|
+
checkpoint_meta = _thread_meta(db, resolved_thread_id)
|
|
754
|
+
if _is_pause_requested(checkpoint_meta) and index < len(planned_steps):
|
|
755
|
+
next_step = planned_steps[index]
|
|
756
|
+
session.update({
|
|
757
|
+
"status": "paused",
|
|
758
|
+
"current_step": next_step,
|
|
759
|
+
"pause_requested": False,
|
|
760
|
+
})
|
|
761
|
+
checkpoint_meta["afk_session"] = session
|
|
762
|
+
_write_thread_meta(db, resolved_thread_id, checkpoint_meta)
|
|
763
|
+
return {
|
|
764
|
+
"status": "paused",
|
|
765
|
+
"thread_id": resolved_thread_id,
|
|
766
|
+
"current_step": next_step,
|
|
767
|
+
"steps_completed": session["steps_completed"],
|
|
768
|
+
"steps_total": session["steps_total"],
|
|
769
|
+
"message": _pause_message(next_step),
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
if result.get("exit_code") != 0:
|
|
773
|
+
session["status"] = "failed"
|
|
774
|
+
meta["afk_session"] = session
|
|
775
|
+
_write_thread_meta(db, resolved_thread_id, meta)
|
|
776
|
+
_log_thread_event(
|
|
777
|
+
db,
|
|
778
|
+
resolved_thread_id,
|
|
779
|
+
"afk_completion",
|
|
780
|
+
{
|
|
781
|
+
"TaskDescription": task,
|
|
782
|
+
"StepsExecuted": outcomes,
|
|
783
|
+
"FilesModified": _files_modified_summary(effective_cwd),
|
|
784
|
+
"Tests": _test_state(outcomes),
|
|
785
|
+
"Content": f"AFK task failed at step {index}: {command}",
|
|
786
|
+
},
|
|
787
|
+
)
|
|
788
|
+
return {
|
|
789
|
+
"status": "failed",
|
|
790
|
+
"thread_id": resolved_thread_id,
|
|
791
|
+
"failed_step": command,
|
|
792
|
+
"steps_executed": outcomes,
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
files_modified = _files_modified_summary(effective_cwd)
|
|
796
|
+
tests = _test_state(outcomes)
|
|
797
|
+
session.update({
|
|
798
|
+
"status": "completed",
|
|
799
|
+
"current_step": planned_steps[-1],
|
|
800
|
+
"files_modified": files_modified,
|
|
801
|
+
"tests": tests,
|
|
802
|
+
})
|
|
803
|
+
meta["afk_session"] = session
|
|
804
|
+
_log_thread_event(
|
|
805
|
+
db,
|
|
806
|
+
resolved_thread_id,
|
|
807
|
+
"afk_completion",
|
|
808
|
+
{
|
|
809
|
+
"TaskDescription": task,
|
|
810
|
+
"StepsExecuted": outcomes,
|
|
811
|
+
"FilesModified": files_modified,
|
|
812
|
+
"Tests": tests,
|
|
813
|
+
"Content": f"AFK task complete: {task}",
|
|
814
|
+
},
|
|
815
|
+
)
|
|
816
|
+
_write_thread_meta(db, resolved_thread_id, meta)
|
|
817
|
+
return {
|
|
818
|
+
"status": "completed",
|
|
819
|
+
"thread_id": resolved_thread_id,
|
|
820
|
+
"steps_executed": outcomes,
|
|
821
|
+
"files_modified": files_modified,
|
|
822
|
+
"tests": tests,
|
|
823
|
+
"message": f"Task complete — see thread {resolved_thread_id} for full execution log",
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
|
|
827
|
+
@mcp.tool()
|
|
828
|
+
def pause_afk(thread_id: str | None = None) -> dict:
|
|
829
|
+
"""
|
|
830
|
+
Request an active AFK session to pause after the current command finishes.
|
|
831
|
+
"""
|
|
832
|
+
db = _get_db()
|
|
833
|
+
resolved_thread_id = db.resolve_thread_id(thread_id)
|
|
834
|
+
meta = _thread_meta(db, resolved_thread_id)
|
|
835
|
+
session = _afk_session(meta)
|
|
836
|
+
if not session or session.get("status") not in {"active", "pause_requested"}:
|
|
837
|
+
return {"status": "idle", "message": "No AFK session running"}
|
|
838
|
+
|
|
839
|
+
session["status"] = "pause_requested"
|
|
840
|
+
session["pause_requested"] = True
|
|
841
|
+
meta["afk_session"] = session
|
|
842
|
+
_write_thread_meta(db, resolved_thread_id, meta)
|
|
843
|
+
current_step = session.get("current_step") or "next checkpoint"
|
|
844
|
+
return {
|
|
845
|
+
"status": "pause_requested",
|
|
846
|
+
"thread_id": resolved_thread_id,
|
|
847
|
+
"current_step": current_step,
|
|
848
|
+
"message": _pause_message(current_step),
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
|
|
852
|
+
@mcp.tool()
|
|
853
|
+
def afk_status(thread_id: str | None = None) -> dict:
|
|
854
|
+
"""
|
|
855
|
+
Return current AFK progress for `/afk-status`.
|
|
856
|
+
"""
|
|
857
|
+
db = _get_db()
|
|
858
|
+
resolved_thread_id = db.resolve_thread_id(thread_id)
|
|
859
|
+
meta = _thread_meta(db, resolved_thread_id)
|
|
860
|
+
session = _afk_session(meta)
|
|
861
|
+
if not session or session.get("status") not in {"active", "pause_requested", "paused"}:
|
|
862
|
+
return {"status": "idle", "message": "No AFK session running"}
|
|
863
|
+
|
|
864
|
+
started_iso = session.get("started_at_monotonic") or session.get("started_at")
|
|
865
|
+
try:
|
|
866
|
+
from datetime import datetime, timezone
|
|
867
|
+
started_dt = datetime.fromisoformat(started_iso.replace("Z", "+00:00"))
|
|
868
|
+
elapsed = int((datetime.now(timezone.utc) - started_dt).total_seconds())
|
|
869
|
+
except Exception:
|
|
870
|
+
elapsed = None
|
|
871
|
+
return {
|
|
872
|
+
"status": session.get("status"),
|
|
873
|
+
"thread_id": resolved_thread_id,
|
|
874
|
+
"task": session.get("task"),
|
|
875
|
+
"current_step": session.get("current_step"),
|
|
876
|
+
"steps_completed": session.get("steps_completed", 0),
|
|
877
|
+
"steps_total": session.get("steps_total", 0),
|
|
878
|
+
"elapsed_seconds": elapsed,
|
|
879
|
+
"last_command_output": session.get("last_command_output", ""),
|
|
880
|
+
}
|