ltcai 3.4.0 → 3.5.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.
Files changed (56) hide show
  1. package/README.md +175 -225
  2. package/docs/RUNTIME_HOOK_COVERAGE_v3.5.0.md +56 -0
  3. package/docs/assets/v3.4.1/e2e_runtime_log.txt +42 -0
  4. package/docs/assets/v3.4.1/hooks-dispatch.png +0 -0
  5. package/docs/assets/v3.4.1/local-agent.png +0 -0
  6. package/latticeai/__init__.py +1 -1
  7. package/latticeai/api/auth.py +37 -9
  8. package/latticeai/api/chat.py +6 -1
  9. package/latticeai/api/computer_use.py +21 -8
  10. package/latticeai/api/local_files.py +76 -10
  11. package/latticeai/api/tools.py +35 -35
  12. package/latticeai/core/agent.py +13 -2
  13. package/latticeai/core/builtin_hooks.py +106 -0
  14. package/latticeai/core/config.py +3 -0
  15. package/latticeai/core/hooks.py +76 -2
  16. package/latticeai/core/marketplace.py +1 -1
  17. package/latticeai/core/multi_agent.py +1 -1
  18. package/latticeai/core/oidc.py +205 -0
  19. package/latticeai/core/security.py +59 -5
  20. package/latticeai/core/workflow_engine.py +3 -3
  21. package/latticeai/core/workspace_os.py +1 -1
  22. package/latticeai/server_app.py +22 -34
  23. package/latticeai/services/platform_runtime.py +18 -6
  24. package/latticeai/services/tool_dispatch.py +2 -0
  25. package/latticeai/services/upload_service.py +24 -4
  26. package/local_knowledge_api.py +27 -1
  27. package/package.json +3 -3
  28. package/requirements.txt +1 -0
  29. package/scripts/check_python.py +87 -0
  30. package/static/css/reference/account.css +1 -1
  31. package/static/css/reference/admin.css +1 -1
  32. package/static/css/reference/base.css +8 -5
  33. package/static/css/reference/chat.css +8 -8
  34. package/static/css/reference/graph.css +2 -2
  35. package/static/css/responsive.css +2 -2
  36. package/static/v3/asset-manifest.json +9 -9
  37. package/static/v3/css/{lattice.shell.6ceea7c8.css → lattice.shell.8fcc9d33.css} +2 -1
  38. package/static/v3/css/lattice.shell.css +2 -1
  39. package/static/v3/js/{app.c4acfdd8.js → app.d086489d.js} +1 -1
  40. package/static/v3/js/core/{components.35f02e4c.js → components.f25b3b93.js} +1 -1
  41. package/static/v3/js/core/components.js +1 -1
  42. package/static/v3/js/core/{shell.80a6ad82.js → shell.d05266f5.js} +1 -1
  43. package/static/v3/js/views/{hooks.13845954.js → hooks.37895880.js} +12 -7
  44. package/static/v3/js/views/hooks.js +12 -7
  45. package/static/v3/js/views/{my-computer.c3ef5283.js → my-computer.d9d9ae1c.js} +7 -4
  46. package/static/v3/js/views/my-computer.js +7 -4
  47. package/static/workspace.css +1 -1
  48. package/tools/__init__.py +276 -0
  49. package/tools/commands.py +188 -0
  50. package/tools/computer.py +185 -0
  51. package/tools/documents.py +243 -0
  52. package/tools/filesystem.py +560 -0
  53. package/tools/knowledge.py +97 -0
  54. package/tools/local_files.py +69 -0
  55. package/tools/network.py +66 -0
  56. package/tools.py +0 -1525
@@ -211,12 +211,15 @@ function buildAgent({ h, icon, c }, res) {
211
211
  const folders = d.folders || {};
212
212
 
213
213
  const online = !!agent.online;
214
+ const mode = d.mode || (online ? "online" : "offline");
214
215
 
215
216
  const rows = [
217
+ { k: "Mode", v: mode, icon: "activity" },
218
+ { k: "Version", v: d.version || "—", mono: true, icon: "tag" },
219
+ { k: "PID", v: d.pid != null ? String(d.pid) : "—", mono: true, icon: "hash" },
216
220
  { k: "Platform", v: agent.platform || "—", icon: "device-desktop" },
217
221
  { k: "Machine", v: agent.machine || "—", mono: true, icon: "cpu" },
218
222
  { k: "Python", v: agent.python || "—", mono: true, icon: "code" },
219
- { k: "Kind", v: agent.kind || "on-device runtime", icon: "robot" },
220
223
  ];
221
224
 
222
225
  return h("div.lt3-stack-3",
@@ -231,8 +234,8 @@ function buildAgent({ h, icon, c }, res) {
231
234
  h("span.lt3-mono", agent.id)),
232
235
  ),
233
236
  ),
234
- // online is reported by the live endpoint — never fabricated.
235
- c.statePill(online ? "active" : "idle"),
237
+ // mode is probed by the live endpoint (online/degraded/error) — never faked.
238
+ c.statePill(mode),
236
239
  ),
237
240
 
238
241
  h("dl.lt3-keyval",
@@ -247,7 +250,7 @@ function buildAgent({ h, icon, c }, res) {
247
250
  h("div.lt3-row", { style: { "justify-content": "space-between", "align-items": "center" } },
248
251
  h("span.lt3-row-2", icon("plug-connected"),
249
252
  handshake.ok
250
- ? `Handshake OK · ${handshake.transport || "local"}`
253
+ ? `Handshake OK · ${handshake.transport || "local"}${handshake.latency_ms != null ? " · " + handshake.latency_ms + "ms" : ""}`
251
254
  : "Handshake not established"),
252
255
  c.statePill(handshake.ok ? "active" : "idle"),
253
256
  ),
@@ -211,12 +211,15 @@ function buildAgent({ h, icon, c }, res) {
211
211
  const folders = d.folders || {};
212
212
 
213
213
  const online = !!agent.online;
214
+ const mode = d.mode || (online ? "online" : "offline");
214
215
 
215
216
  const rows = [
217
+ { k: "Mode", v: mode, icon: "activity" },
218
+ { k: "Version", v: d.version || "—", mono: true, icon: "tag" },
219
+ { k: "PID", v: d.pid != null ? String(d.pid) : "—", mono: true, icon: "hash" },
216
220
  { k: "Platform", v: agent.platform || "—", icon: "device-desktop" },
217
221
  { k: "Machine", v: agent.machine || "—", mono: true, icon: "cpu" },
218
222
  { k: "Python", v: agent.python || "—", mono: true, icon: "code" },
219
- { k: "Kind", v: agent.kind || "on-device runtime", icon: "robot" },
220
223
  ];
221
224
 
222
225
  return h("div.lt3-stack-3",
@@ -231,8 +234,8 @@ function buildAgent({ h, icon, c }, res) {
231
234
  h("span.lt3-mono", agent.id)),
232
235
  ),
233
236
  ),
234
- // online is reported by the live endpoint — never fabricated.
235
- c.statePill(online ? "active" : "idle"),
237
+ // mode is probed by the live endpoint (online/degraded/error) — never faked.
238
+ c.statePill(mode),
236
239
  ),
237
240
 
238
241
  h("dl.lt3-keyval",
@@ -247,7 +250,7 @@ function buildAgent({ h, icon, c }, res) {
247
250
  h("div.lt3-row", { style: { "justify-content": "space-between", "align-items": "center" } },
248
251
  h("span.lt3-row-2", icon("plug-connected"),
249
252
  handshake.ok
250
- ? `Handshake OK · ${handshake.transport || "local"}`
253
+ ? `Handshake OK · ${handshake.transport || "local"}${handshake.latency_ms != null ? " · " + handshake.latency_ms + "ms" : ""}`
251
254
  : "Handshake not established"),
252
255
  c.statePill(handshake.ok ? "active" : "idle"),
253
256
  ),
@@ -873,7 +873,7 @@ main {
873
873
  padding: 16px clamp(16px, 2vw, 28px);
874
874
  background: color-mix(in srgb, var(--surface-elevated) 94%, transparent);
875
875
  border-bottom: 1px solid var(--line);
876
- backdrop-filter: blur(18px);
876
+ backdrop-filter: none; /* glass removed v3.5.0 */
877
877
  }
878
878
 
879
879
  .topbar-subtitle {
@@ -0,0 +1,276 @@
1
+ """Safe local tools for Lattice AI agent mode.
2
+
3
+ All filesystem operations are confined to LATTICEAI_AGENT_ROOT, defaulting to
4
+ ./agent_workspace. Command execution runs without a shell and from inside that
5
+ workspace.
6
+
7
+ v3.5.0 splits the historical flat ``tools.py`` into focused submodules
8
+ (computer, filesystem, documents, local_files, knowledge, network, commands)
9
+ while keeping the exact import surface: this package re-exports every public name
10
+ and ``execute_tool``/``DEFAULT_TOOL_REGISTRY``, so ``import tools`` and
11
+ ``from tools import X`` behave identically to before. ``AGENT_ROOT`` and the path
12
+ helpers live here as the single source of truth (tests monkeypatch
13
+ ``tools.AGENT_ROOT``); submodules read it dynamically.
14
+ """
15
+
16
+ import base64
17
+ import json
18
+ import os
19
+ import platform
20
+ import re
21
+ import shlex
22
+ import socket
23
+ import subprocess
24
+ import tempfile
25
+ from html.parser import HTMLParser
26
+ from pathlib import Path
27
+ from typing import Any, Callable, Dict, List, Optional
28
+
29
+ from latticeai.core.tool_registry import ToolRegistry
30
+ from p_reinforce import BRAIN_DIR, STRUCTURE
31
+
32
+ _PLATFORM = platform.system() # "Darwin" | "Windows" | "Linux"
33
+
34
+
35
+ # ── base: agent-root sandbox, shared constants, path helpers ──────────────────
36
+ AGENT_ROOT = Path(os.getenv("LATTICEAI_AGENT_ROOT") or "agent_workspace").resolve()
37
+ MAX_FILE_BYTES = 512_000
38
+ MAX_COMMAND_SECONDS = 30
39
+ MAX_BUILD_SECONDS = 180
40
+ MAX_DEPLOY_SECONDS = 300
41
+ MAX_COMMAND_OUTPUT = 12_000
42
+
43
+ BLOCKED_COMMANDS = {
44
+ "rm",
45
+ "rmdir",
46
+ "sudo",
47
+ "su",
48
+ "chmod",
49
+ "chown",
50
+ "curl",
51
+ "wget",
52
+ "ssh",
53
+ "scp",
54
+ "rsync",
55
+ "dd",
56
+ "mkfs",
57
+ "diskutil",
58
+ "launchctl",
59
+ }
60
+
61
+ ALLOWED_COMMANDS = {
62
+ "pwd",
63
+ "ls",
64
+ "find",
65
+ "cat",
66
+ "sed",
67
+ "head",
68
+ "tail",
69
+ "wc",
70
+ "rg",
71
+ "python",
72
+ "python3",
73
+ "node",
74
+ "npm",
75
+ "npx",
76
+ "git",
77
+ }
78
+
79
+ BUILD_SCRIPT_NAMES = {"build", "compile", "typecheck", "test"}
80
+ DEPLOY_SCRIPT_NAMES = {
81
+ "deploy",
82
+ "preview",
83
+ "release",
84
+ "package",
85
+ "dist",
86
+ "make",
87
+ "build:installer",
88
+ "build:pkg",
89
+ "build:exe",
90
+ "package:mac",
91
+ "package:win",
92
+ }
93
+
94
+ ALLOWED_GIT_SUBCOMMANDS = {"status", "diff", "log", "show"}
95
+
96
+ TEXT_EXTENSIONS = {
97
+ ".css",
98
+ ".csv",
99
+ ".html",
100
+ ".js",
101
+ ".json",
102
+ ".jsx",
103
+ ".md",
104
+ ".py",
105
+ ".ts",
106
+ ".tsx",
107
+ ".txt",
108
+ ".xml",
109
+ ".yaml",
110
+ ".yml",
111
+ }
112
+
113
+ DOCUMENT_OUTPUT_DIR = "generated_documents"
114
+ PRESENTATION_OUTPUT_DIR = "generated_presentations"
115
+ SPREADSHEET_OUTPUT_DIR = "generated_spreadsheets"
116
+
117
+
118
+ class ToolError(ValueError):
119
+ pass
120
+
121
+
122
+ def ensure_agent_root() -> Path:
123
+ AGENT_ROOT.mkdir(parents=True, exist_ok=True)
124
+ return AGENT_ROOT
125
+
126
+
127
+ def _resolve_path(path: str = "") -> Path:
128
+ ensure_agent_root()
129
+ if not path:
130
+ return AGENT_ROOT
131
+ candidate = (AGENT_ROOT / path).resolve()
132
+ if candidate != AGENT_ROOT and AGENT_ROOT not in candidate.parents:
133
+ raise ToolError("Path escapes the agent workspace.")
134
+ return candidate
135
+
136
+
137
+ def _relative(path: Path) -> str:
138
+ return str(path.relative_to(AGENT_ROOT))
139
+
140
+
141
+ # ── document / local / read constants (shared by submodules) ──────────────────
142
+ PDF_OUTPUT_DIR = "generated_pdfs"
143
+ LOCAL_MAX_FILE_BYTES = 2_000_000 # 2 MB cap for local reads
144
+
145
+
146
+ # CJK-capable fonts (Korean + Chinese + Japanese)
147
+ _CJK_FONT_CANDIDATES = [
148
+ "/System/Library/Fonts/AppleSDGothicNeo.ttc", # Korean (macOS)
149
+ "/System/Library/Fonts/STHeiti Light.ttc", # Chinese (macOS)
150
+ "/System/Library/Fonts/PingFang.ttc", # Chinese (macOS)
151
+ "/Library/Fonts/NanumGothic.ttf", # Korean
152
+ "/usr/share/fonts/truetype/nanum/NanumGothic.ttf",
153
+ ]
154
+
155
+ _SUPPORTED_READ_EXTENSIONS = {".pdf", ".docx", ".xlsx", ".pptx", ".txt", ".md", ".csv"}
156
+ DOCUMENT_MAX_READ_BYTES = 10_000_000 # 10 MB
157
+
158
+
159
+ # ── focused tool submodules (re-exported flat for import compatibility) ───────
160
+ from tools.computer import * # noqa: E402,F401,F403
161
+ from tools.filesystem import * # noqa: E402,F401,F403
162
+ from tools.documents import * # noqa: E402,F401,F403
163
+ from tools.local_files import * # noqa: E402,F401,F403
164
+ from tools.knowledge import * # noqa: E402,F401,F403
165
+ from tools.network import * # noqa: E402,F401,F403
166
+ from tools.commands import * # noqa: E402,F401,F403
167
+
168
+
169
+ # ── tool registry: the single name → invocation source of truth ───────────────
170
+ def _h_create_xlsx(args: Dict[str, Any]) -> Dict[str, Any]:
171
+ rows = args.get("rows", [])
172
+ if isinstance(rows, str):
173
+ rows = json.loads(rows)
174
+ return create_xlsx(rows, args.get("filename", "spreadsheet.xlsx"), args.get("sheet_name", "Sheet1"))
175
+
176
+
177
+ def _h_create_pptx(args: Dict[str, Any]) -> Dict[str, Any]:
178
+ slides = args.get("slides", [])
179
+ if isinstance(slides, str):
180
+ slides = json.loads(slides)
181
+ return create_pptx(args.get("title", ""), slides, args.get("filename", "presentation.pptx"))
182
+
183
+
184
+ # ── Tool registry: the single source of truth for name → invocation ───────────
185
+ # Each entry binds the args dict to a tool function. ``execute_tool`` is a
186
+ # lookup over this table — adding a tool means adding one entry here, not
187
+ # editing an if/elif chain. server.py's governance map and catalog brief are
188
+ # checked against ``registered_tools()`` so the three never silently drift.
189
+ TOOL_HANDLERS: Dict[str, Callable[[Dict[str, Any]], Dict[str, Any]]] = {
190
+ # filesystem
191
+ "list_dir": lambda a: list_dir(a.get("path", ".")),
192
+ "workspace_tree": lambda a: workspace_tree(a.get("path", "."), a.get("max_depth", 3)),
193
+ "read_file": lambda a: read_file(a["path"], offset=a.get("offset", 0), limit=a.get("limit", 0), line_numbers=a.get("line_numbers", True)),
194
+ "write_file": lambda a: write_file(a["path"], a.get("content", "")),
195
+ "edit_file": lambda a: edit_file(a["path"], a["old_string"], a["new_string"], replace_all=bool(a.get("replace_all", False))),
196
+ "grep": lambda a: grep(a["pattern"], path=a.get("path", "."), glob=a.get("glob"), max_results=a.get("max_results", 50), case_insensitive=bool(a.get("case_insensitive", False)), context_lines=a.get("context_lines", 0)),
197
+ "search_files": lambda a: search_files(a["query"], a.get("path", "."), a.get("max_results", 20)),
198
+ "inspect_html": lambda a: inspect_html(a["path"]),
199
+ "preview_url": lambda a: preview_url(a.get("path", "index.html")),
200
+ # planning
201
+ "todo_read": lambda a: todo_read(),
202
+ "todo_write": lambda a: todo_write(a.get("todos") or []),
203
+ # documents
204
+ "create_docx": lambda a: create_docx(a.get("title", ""), a.get("body", ""), a.get("filename", "document.docx")),
205
+ "create_xlsx": _h_create_xlsx,
206
+ "create_pptx": _h_create_pptx,
207
+ "create_pdf": lambda a: create_pdf(a.get("title", ""), a.get("body", ""), a.get("filename", "document.pdf")),
208
+ "create_web_project": lambda a: create_web_project(a.get("path", ""), a.get("framework", "react"), a.get("template", "vite")),
209
+ # local filesystem
210
+ "local_list": lambda a: local_list(a["path"]),
211
+ "local_read": lambda a: local_read(a["path"]),
212
+ "local_write": lambda a: local_write(a["path"], a.get("content", "")),
213
+ "read_document": lambda a: read_document(a["path"]),
214
+ "network_status": lambda a: network_status(),
215
+ # computer use
216
+ "computer_screenshot": lambda a: computer_screenshot(),
217
+ "computer_open_app": lambda a: computer_open_app(a.get("app", "Google Chrome")),
218
+ "computer_open_url": lambda a: computer_open_url(a["url"], a.get("app", "Google Chrome")),
219
+ "computer_click": lambda a: computer_click(a.get("x", 0), a.get("y", 0), a.get("button", "left"), a.get("double", False)),
220
+ "computer_type": lambda a: computer_type(a["text"], a.get("interval", 0.04)),
221
+ "computer_key": lambda a: computer_key(a["key"]),
222
+ "computer_scroll": lambda a: computer_scroll(a.get("x", 0), a.get("y", 0), a.get("direction", "down"), a.get("clicks", 3)),
223
+ "computer_move": lambda a: computer_move(a.get("x", 0), a.get("y", 0)),
224
+ "computer_drag": lambda a: computer_drag(a.get("x1", 0), a.get("y1", 0), a.get("x2", 0), a.get("y2", 0)),
225
+ "computer_status": lambda a: computer_status(),
226
+ "chrome_status": lambda a: desktop_bridge_status(),
227
+ "computer_use_status": lambda a: desktop_bridge_status(),
228
+ # knowledge / obsidian
229
+ "knowledge_save": lambda a: knowledge_save(a["content"], a.get("folder", "00_Raw"), a.get("title")),
230
+ "knowledge_search": lambda a: knowledge_search(a["query"], a.get("max_results", 5)),
231
+ "knowledge_tree": lambda a: knowledge_tree(),
232
+ "obsidian_save": lambda a: obsidian_save(a["content"], a.get("folder", "00_Raw"), a.get("title")),
233
+ "obsidian_search": lambda a: obsidian_search(a["query"], a.get("max_results", 5)),
234
+ "obsidian_tree": lambda a: obsidian_tree(),
235
+ # git (read-only)
236
+ "git_status": lambda a: git_status(a.get("cwd")),
237
+ "git_diff": lambda a: git_diff(a.get("path"), a.get("cwd")),
238
+ "git_log": lambda a: git_log(a.get("max_count", 5), a.get("cwd")),
239
+ "git_show": lambda a: git_show(a.get("revision", "HEAD"), a.get("cwd")),
240
+ # exec
241
+ "run_command": lambda a: run_command(a["command"], a.get("cwd")),
242
+ "build_project": lambda a: build_project(a.get("cwd"), a.get("script", "build")),
243
+ "deploy_project": lambda a: deploy_project(a.get("cwd"), a.get("script", "deploy")),
244
+ }
245
+
246
+
247
+ DEFAULT_TOOL_REGISTRY = ToolRegistry(TOOL_HANDLERS)
248
+
249
+
250
+ def registered_tools() -> frozenset:
251
+ """Names dispatchable through ``execute_tool`` — the seam other modules verify against."""
252
+ return DEFAULT_TOOL_REGISTRY.registered_tools()
253
+
254
+
255
+ def execute_tool(action: str, args: Dict[str, Any]) -> Dict[str, Any]:
256
+ return DEFAULT_TOOL_REGISTRY.execute(action, args, error_cls=ToolError)
257
+
258
+
259
+ __all__ = [
260
+ "AGENT_ROOT", "ToolError", "ensure_agent_root",
261
+ "list_dir", "workspace_tree", "read_file", "write_file", "edit_file", "grep",
262
+ "search_files", "inspect_html", "preview_url", "create_web_project",
263
+ "todo_read", "todo_write",
264
+ "create_docx", "create_xlsx", "create_pptx", "create_pdf", "read_document",
265
+ "local_list", "local_read", "local_write", "desktop_bridge_status",
266
+ "knowledge_save", "knowledge_search", "knowledge_tree",
267
+ "obsidian_save", "obsidian_search", "obsidian_tree",
268
+ "network_status",
269
+ "computer_screenshot", "computer_open_app", "computer_open_url",
270
+ "computer_click", "computer_type", "computer_key", "computer_scroll",
271
+ "computer_move", "computer_drag", "computer_status",
272
+ "run_command", "build_project", "deploy_project",
273
+ "git_status", "git_diff", "git_log", "git_show",
274
+ "TOOL_HANDLERS", "DEFAULT_TOOL_REGISTRY", "registered_tools", "execute_tool",
275
+ "BRAIN_DIR", "STRUCTURE",
276
+ ]
@@ -0,0 +1,188 @@
1
+ """Command/build/deploy and read-only git tools (no shell, allow-listed)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import shlex
7
+ import subprocess
8
+ from pathlib import Path
9
+ from typing import Any, Dict, Optional
10
+
11
+ import tools
12
+ from tools import (
13
+ ToolError,
14
+ ensure_agent_root,
15
+ _resolve_path,
16
+ _relative,
17
+ ALLOWED_COMMANDS,
18
+ BLOCKED_COMMANDS,
19
+ BUILD_SCRIPT_NAMES,
20
+ DEPLOY_SCRIPT_NAMES,
21
+ ALLOWED_GIT_SUBCOMMANDS,
22
+ MAX_COMMAND_SECONDS,
23
+ MAX_BUILD_SECONDS,
24
+ MAX_DEPLOY_SECONDS,
25
+ MAX_COMMAND_OUTPUT,
26
+ )
27
+
28
+
29
+ def run_command(command: str, cwd: Optional[str] = None) -> Dict[str, Any]:
30
+ ensure_agent_root()
31
+ parts = shlex.split(command)
32
+ if not parts:
33
+ raise ToolError("Command is empty.")
34
+
35
+ executable = Path(parts[0]).name
36
+ if executable in BLOCKED_COMMANDS or executable not in ALLOWED_COMMANDS:
37
+ raise ToolError(f"Command is not allowed: {executable}")
38
+ if executable == "git":
39
+ raise ToolError("Use the read-only git_status, git_diff, git_log, or git_show tools.")
40
+ if any(token in command for token in ["|", "&&", "||", ";", ">", "<", "$(", "`"]):
41
+ raise ToolError("Shell operators are not allowed.")
42
+ if executable == "find":
43
+ blocked = [f for f in parts[1:] if f in _BLOCKED_FIND_FLAGS]
44
+ if blocked:
45
+ raise ToolError(f"find flags are not allowed: {', '.join(blocked)}")
46
+ abs_args = [a for a in parts[1:] if a.startswith("/") and a not in ("/dev/null",)]
47
+ if abs_args:
48
+ raise ToolError(f"Absolute paths in command arguments are not allowed: {abs_args[0]}")
49
+
50
+ workdir = _resolve_path(cwd or ".")
51
+ if not workdir.exists() or not workdir.is_dir():
52
+ raise ToolError("Working directory does not exist.")
53
+
54
+ try:
55
+ completed = subprocess.run(
56
+ parts,
57
+ cwd=workdir,
58
+ capture_output=True,
59
+ text=True,
60
+ timeout=MAX_COMMAND_SECONDS,
61
+ check=False,
62
+ )
63
+ except subprocess.TimeoutExpired:
64
+ raise ToolError(f"Command timed out after {MAX_COMMAND_SECONDS} seconds.")
65
+
66
+ stdout = completed.stdout[-MAX_COMMAND_OUTPUT:]
67
+ stderr = completed.stderr[-MAX_COMMAND_OUTPUT:]
68
+ return {
69
+ "command": command,
70
+ "cwd": _relative(workdir) if workdir != tools.AGENT_ROOT else ".",
71
+ "returncode": completed.returncode,
72
+ "stdout": stdout,
73
+ "stderr": stderr,
74
+ }
75
+
76
+
77
+ def _load_package_scripts(workdir: Path) -> Dict[str, str]:
78
+ package_json = workdir / "package.json"
79
+ if not package_json.exists():
80
+ return {}
81
+ try:
82
+ import json
83
+ data = json.loads(package_json.read_text(encoding="utf-8"))
84
+ except Exception as exc:
85
+ raise ToolError(f"Could not parse package.json: {exc}") from exc
86
+ scripts = data.get("scripts") or {}
87
+ if not isinstance(scripts, dict):
88
+ return {}
89
+ return {str(key): str(value) for key, value in scripts.items()}
90
+
91
+
92
+ def _run_script(script: str, cwd: Optional[str], allowed: set[str], timeout: int) -> Dict[str, Any]:
93
+ ensure_agent_root()
94
+ if script not in allowed:
95
+ raise ToolError(f"Script is not allowed here: {script}")
96
+ workdir = _resolve_path(cwd or ".")
97
+ if not workdir.exists() or not workdir.is_dir():
98
+ raise ToolError("Working directory does not exist.")
99
+
100
+ scripts = _load_package_scripts(workdir)
101
+ if script not in scripts:
102
+ raise ToolError(f"package.json does not define a '{script}' script.")
103
+
104
+ try:
105
+ completed = subprocess.run(
106
+ ["npm", "run", script],
107
+ cwd=workdir,
108
+ capture_output=True,
109
+ text=True,
110
+ timeout=timeout,
111
+ check=False,
112
+ )
113
+ except subprocess.TimeoutExpired:
114
+ raise ToolError(f"npm run {script} timed out after {timeout} seconds.")
115
+
116
+ return {
117
+ "command": f"npm run {script}",
118
+ "cwd": _relative(workdir) if workdir != tools.AGENT_ROOT else ".",
119
+ "script_body": scripts[script],
120
+ "returncode": completed.returncode,
121
+ "stdout": completed.stdout[-MAX_COMMAND_OUTPUT:],
122
+ "stderr": completed.stderr[-MAX_COMMAND_OUTPUT:],
123
+ }
124
+
125
+
126
+ def build_project(cwd: Optional[str] = None, script: str = "build") -> Dict[str, Any]:
127
+ return _run_script(script, cwd, BUILD_SCRIPT_NAMES, MAX_BUILD_SECONDS)
128
+
129
+
130
+ def deploy_project(cwd: Optional[str] = None, script: str = "deploy") -> Dict[str, Any]:
131
+ return _run_script(script, cwd, DEPLOY_SCRIPT_NAMES, MAX_DEPLOY_SECONDS)
132
+
133
+
134
+ def _run_git(args: List[str], cwd: Optional[str] = None) -> Dict[str, Any]:
135
+ if not args:
136
+ raise ToolError("Git subcommand is required.")
137
+ subcommand = args[0]
138
+ if subcommand not in ALLOWED_GIT_SUBCOMMANDS:
139
+ raise ToolError(f"Git subcommand is not allowed: {subcommand}")
140
+ if any(arg.startswith(("git@", "http://", "https://", "ssh://")) for arg in args):
141
+ raise ToolError("Remote git targets are not allowed.")
142
+
143
+ workdir = _resolve_path(cwd or ".")
144
+ if not workdir.exists() or not workdir.is_dir():
145
+ raise ToolError("Working directory does not exist.")
146
+
147
+ try:
148
+ completed = subprocess.run(
149
+ ["git", *args],
150
+ cwd=workdir,
151
+ capture_output=True,
152
+ text=True,
153
+ timeout=MAX_COMMAND_SECONDS,
154
+ check=False,
155
+ )
156
+ except subprocess.TimeoutExpired:
157
+ raise ToolError(f"Git command timed out after {MAX_COMMAND_SECONDS} seconds.")
158
+
159
+ return {
160
+ "command": "git " + " ".join(args),
161
+ "cwd": _relative(workdir) if workdir != tools.AGENT_ROOT else ".",
162
+ "returncode": completed.returncode,
163
+ "stdout": completed.stdout[-MAX_COMMAND_OUTPUT:],
164
+ "stderr": completed.stderr[-MAX_COMMAND_OUTPUT:],
165
+ }
166
+
167
+
168
+ def git_status(cwd: Optional[str] = None) -> Dict[str, Any]:
169
+ return _run_git(["status", "--short"], cwd)
170
+
171
+
172
+ def git_diff(path: Optional[str] = None, cwd: Optional[str] = None) -> Dict[str, Any]:
173
+ args = ["diff", "--"]
174
+ if path:
175
+ target = _resolve_path(path)
176
+ args.append(_relative(target))
177
+ return _run_git(args, cwd)
178
+
179
+
180
+ def git_log(max_count: int = 5, cwd: Optional[str] = None) -> Dict[str, Any]:
181
+ max_count = max(1, min(int(max_count), 20))
182
+ return _run_git(["log", f"--max-count={max_count}", "--oneline", "--decorate"], cwd)
183
+
184
+
185
+ def git_show(revision: str = "HEAD", cwd: Optional[str] = None) -> Dict[str, Any]:
186
+ if revision.startswith("-") or any(token in revision for token in ["..", ":", "/", "\\"]):
187
+ raise ToolError("Revision is not allowed.")
188
+ return _run_git(["show", "--stat", "--oneline", "--decorate", revision], cwd)