seedcode-cli 6.1.5__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.
Files changed (114) hide show
  1. seedcode/__init__.py +14 -0
  2. seedcode/__main__.py +12 -0
  3. seedcode/app.py +508 -0
  4. seedcode/apps/__init__.py +32 -0
  5. seedcode/apps/discovery.py +241 -0
  6. seedcode/apps/installer.py +164 -0
  7. seedcode/apps/launcher.py +156 -0
  8. seedcode/apps/verifier.py +119 -0
  9. seedcode/assets/logo.txt +15 -0
  10. seedcode/cli.py +95 -0
  11. seedcode/commands/__init__.py +81 -0
  12. seedcode/commands/about.py +34 -0
  13. seedcode/commands/agent.py +94 -0
  14. seedcode/commands/assist.py +201 -0
  15. seedcode/commands/clear.py +20 -0
  16. seedcode/commands/desktop.py +104 -0
  17. seedcode/commands/doctor.py +152 -0
  18. seedcode/commands/help.py +61 -0
  19. seedcode/commands/history.py +365 -0
  20. seedcode/commands/palette.py +100 -0
  21. seedcode/commands/provider.py +451 -0
  22. seedcode/commands/theme.py +76 -0
  23. seedcode/computer/__init__.py +98 -0
  24. seedcode/computer/browser.py +276 -0
  25. seedcode/computer/browser_cdp.py +567 -0
  26. seedcode/computer/browser_engine.py +546 -0
  27. seedcode/computer/browser_extract.py +301 -0
  28. seedcode/computer/browser_popups.py +329 -0
  29. seedcode/computer/browser_selenium.py +209 -0
  30. seedcode/computer/browser_skills.py +245 -0
  31. seedcode/computer/catalog.py +200 -0
  32. seedcode/computer/controller.py +324 -0
  33. seedcode/computer/dispatcher.py +272 -0
  34. seedcode/computer/dpi.py +185 -0
  35. seedcode/computer/engine.py +105 -0
  36. seedcode/computer/keyboard.py +101 -0
  37. seedcode/computer/logbook.py +104 -0
  38. seedcode/computer/mouse.py +48 -0
  39. seedcode/computer/ocr.py +213 -0
  40. seedcode/computer/operator_skills.py +577 -0
  41. seedcode/computer/permissions.py +203 -0
  42. seedcode/computer/recovery.py +115 -0
  43. seedcode/computer/registry.py +107 -0
  44. seedcode/computer/resolver.py +434 -0
  45. seedcode/computer/screen.py +130 -0
  46. seedcode/computer/screen_state.py +412 -0
  47. seedcode/computer/selfguard.py +197 -0
  48. seedcode/computer/semantic.py +100 -0
  49. seedcode/computer/skills.py +139 -0
  50. seedcode/computer/state.py +199 -0
  51. seedcode/computer/verifier.py +177 -0
  52. seedcode/computer/vision.py +327 -0
  53. seedcode/computer/windows.py +217 -0
  54. seedcode/config/__init__.py +8 -0
  55. seedcode/config/defaults.py +22 -0
  56. seedcode/config/manager.py +62 -0
  57. seedcode/core/__init__.py +31 -0
  58. seedcode/core/agent.py +534 -0
  59. seedcode/core/chat.py +128 -0
  60. seedcode/core/client.py +9 -0
  61. seedcode/core/errors.py +199 -0
  62. seedcode/core/identity.py +66 -0
  63. seedcode/core/identity_store.py +119 -0
  64. seedcode/core/lifecycle.py +240 -0
  65. seedcode/core/limits.py +35 -0
  66. seedcode/core/models.py +347 -0
  67. seedcode/core/project.py +96 -0
  68. seedcode/core/providers/__init__.py +58 -0
  69. seedcode/core/providers/aerolink.py +324 -0
  70. seedcode/core/providers/base.py +230 -0
  71. seedcode/core/providers/freemodel.py +931 -0
  72. seedcode/core/providers/ollama.py +262 -0
  73. seedcode/core/providers/openrouter.py +393 -0
  74. seedcode/core/streaming.py +21 -0
  75. seedcode/memory/__init__.py +8 -0
  76. seedcode/memory/manager.py +47 -0
  77. seedcode/memory/storage.py +38 -0
  78. seedcode/memory/store.py +257 -0
  79. seedcode/tools/__init__.py +35 -0
  80. seedcode/tools/base.py +179 -0
  81. seedcode/tools/desktop.py +371 -0
  82. seedcode/tools/filesystem.py +309 -0
  83. seedcode/tools/git.py +72 -0
  84. seedcode/tools/patch.py +170 -0
  85. seedcode/tools/permissions.py +288 -0
  86. seedcode/tools/search.py +137 -0
  87. seedcode/tools/terminal.py +200 -0
  88. seedcode/tools/textio.py +59 -0
  89. seedcode/ui/__init__.py +164 -0
  90. seedcode/ui/badges.py +64 -0
  91. seedcode/ui/banner.py +78 -0
  92. seedcode/ui/dashboard.py +197 -0
  93. seedcode/ui/dialog.py +62 -0
  94. seedcode/ui/fuzzy.py +128 -0
  95. seedcode/ui/layout.py +54 -0
  96. seedcode/ui/menu.py +61 -0
  97. seedcode/ui/palette.py +40 -0
  98. seedcode/ui/progress.py +41 -0
  99. seedcode/ui/prompts.py +16 -0
  100. seedcode/ui/renderer.py +36 -0
  101. seedcode/ui/searchbox.py +70 -0
  102. seedcode/ui/selector.py +514 -0
  103. seedcode/ui/statusbar.py +38 -0
  104. seedcode/ui/textbox.py +61 -0
  105. seedcode/ui/theme.py +204 -0
  106. seedcode/ui/tree.py +91 -0
  107. seedcode/utils/__init__.py +22 -0
  108. seedcode/utils/helpers.py +97 -0
  109. seedcode/utils/logger.py +65 -0
  110. seedcode_cli-6.1.5.dist-info/METADATA +368 -0
  111. seedcode_cli-6.1.5.dist-info/RECORD +114 -0
  112. seedcode_cli-6.1.5.dist-info/WHEEL +4 -0
  113. seedcode_cli-6.1.5.dist-info/entry_points.txt +2 -0
  114. seedcode_cli-6.1.5.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,199 @@
1
+ """Structured error taxonomy for the SeedCode operator stack.
2
+
3
+ Machine-readable errors are the contract between the deterministic layers
4
+ (screen, apps, web, memory) and the agent loop: every failure carries a
5
+ stable ``code``, a human-readable message, and whether a retry/recovery is
6
+ plausible. The planner reads ``code`` + ``recoverable``; the user reads
7
+ ``message``. Nothing raises a bare string anywhere below the tool surface.
8
+
9
+ All operator errors derive from :class:`OperatorError` so callers can catch
10
+ the whole family; the historical leaf exceptions (``ComputerError``,
11
+ ``BrowserError``, ``SkillError``...) keep their names via aliases where
12
+ existing code depends on them.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ from dataclasses import dataclass, field
18
+ from typing import Any
19
+
20
+
21
+ # NOTE: deliberately NOT ``slots=True``. A slotted dataclass replaces the
22
+ # class object, which breaks zero-arg ``super()`` inside ``__post_init__`` for
23
+ # Exception subclasses (``super(type, obj): obj must be an instance or subtype
24
+ # of type``) - every structured error would be unraisable.
25
+ @dataclass
26
+ class OperatorError(Exception):
27
+ """Base class for every structured operator failure."""
28
+
29
+ code: str = "OPERATOR_ERROR"
30
+ message: str = ""
31
+ recoverable: bool = False
32
+ details: dict[str, Any] = field(default_factory=dict)
33
+
34
+ def __post_init__(self) -> None:
35
+ if not self.message:
36
+ object.__setattr__(self, "message", self.code)
37
+ # Explicit base call: cooperative super() is fragile across the
38
+ # dataclass/Exception metaclass boundary; Exception.__init__ sets args.
39
+ Exception.__init__(self, self.message)
40
+
41
+ def __str__(self) -> str: # the model-facing text
42
+ return self.message
43
+
44
+ def to_dict(self) -> dict[str, Any]:
45
+ """Machine-readable form (planner / error handler / logs)."""
46
+ return {
47
+ "error_type": self.code,
48
+ "message": self.message,
49
+ "recoverable": self.recoverable,
50
+ **({"details": self.details} if self.details else {}),
51
+ }
52
+
53
+
54
+ # --- element / screen -----------------------------------------------------------
55
+ class ElementNotFoundError(OperatorError):
56
+ def __init__(self, message: str = "", **kw: Any) -> None:
57
+ super().__init__(
58
+ code="ELEMENT_NOT_FOUND", message=message or "Element not found on screen.",
59
+ recoverable=True, **kw,
60
+ )
61
+
62
+
63
+ class StaleElementError(OperatorError):
64
+ """A cached element id no longer resolves — the UI changed under us."""
65
+
66
+ def __init__(self, element_id: str = "", **kw: Any) -> None:
67
+ super().__init__(
68
+ code="STALE_ELEMENT",
69
+ message=(
70
+ f"Element '{element_id or 'reference'}' is stale (the UI changed). "
71
+ "Re-run the screen query to get a fresh element id."
72
+ ),
73
+ recoverable=True,
74
+ details={"element_id": element_id},
75
+ **kw,
76
+ )
77
+
78
+
79
+ class ScreenUnavailableError(OperatorError):
80
+ def __init__(self, message: str = "", **kw: Any) -> None:
81
+ super().__init__(
82
+ code="SCREEN_UNAVAILABLE",
83
+ message=message or "Screen state is unavailable on this machine.",
84
+ recoverable=False, **kw,
85
+ )
86
+
87
+
88
+ # --- windows / applications -------------------------------------------------------
89
+ class WindowNotFoundError(OperatorError):
90
+ def __init__(self, message: str = "", **kw: Any) -> None:
91
+ super().__init__(
92
+ code="WINDOW_NOT_FOUND", message=message or "No matching window.",
93
+ recoverable=True, **kw,
94
+ )
95
+
96
+
97
+ class ApplicationNotFoundError(OperatorError):
98
+ def __init__(self, message: str = "", **kw: Any) -> None:
99
+ super().__init__(
100
+ code="APPLICATION_NOT_FOUND",
101
+ message=message or "Application not found on this machine.",
102
+ recoverable=True, **kw,
103
+ )
104
+
105
+
106
+ class ApplicationLaunchError(OperatorError):
107
+ def __init__(self, message: str = "", **kw: Any) -> None:
108
+ super().__init__(
109
+ code="APPLICATION_LAUNCH_FAILED",
110
+ message=message or "Application did not launch.",
111
+ recoverable=True, **kw,
112
+ )
113
+
114
+
115
+ # --- web ---------------------------------------------------------------------------
116
+ class NavigationError(OperatorError):
117
+ def __init__(self, message: str = "", **kw: Any) -> None:
118
+ super().__init__(
119
+ code="NAVIGATION_FAILED", message=message or "Navigation failed.",
120
+ recoverable=True, **kw,
121
+ )
122
+
123
+
124
+ class ExtractionError(OperatorError):
125
+ def __init__(self, message: str = "", **kw: Any) -> None:
126
+ super().__init__(
127
+ code="EXTRACTION_FAILED",
128
+ message=message or "Page extraction failed.",
129
+ recoverable=True, **kw,
130
+ )
131
+
132
+
133
+ class WebPageNotConnectedError(OperatorError):
134
+ """No DOM surface (no DevTools) for a requested web extraction."""
135
+
136
+ def __init__(self, message: str = "", **kw: Any) -> None:
137
+ super().__init__(
138
+ code="WEB_PAGE_NOT_CONNECTED",
139
+ message=message or (
140
+ "No DevTools connection to the browser; DOM extraction is "
141
+ "unavailable. Open the page via open_url first."
142
+ ),
143
+ recoverable=True, **kw,
144
+ )
145
+
146
+
147
+ class DownloadBlockedError(OperatorError):
148
+ """A download was refused by policy (type/size/host)."""
149
+
150
+ def __init__(self, message: str = "", **kw: Any) -> None:
151
+ super().__init__(
152
+ code="DOWNLOAD_BLOCKED", message=message or "Download refused by policy.",
153
+ recoverable=False, **kw,
154
+ )
155
+
156
+
157
+ # --- verification / lifecycle / security ---------------------------------------------
158
+ class VerificationError(OperatorError):
159
+ def __init__(self, message: str = "", **kw: Any) -> None:
160
+ super().__init__(
161
+ code="VERIFICATION_FAILED",
162
+ message=message or "The action did not have the expected effect.",
163
+ recoverable=True, **kw,
164
+ )
165
+
166
+
167
+ class SecurityError(OperatorError):
168
+ def __init__(self, message: str = "", **kw: Any) -> None:
169
+ super().__init__(
170
+ code="SECURITY_DENIED",
171
+ message=message or "The action was denied by policy.",
172
+ recoverable=False, **kw,
173
+ )
174
+
175
+
176
+ # --- historical aliases (existing modules keep their exception names) ---------------
177
+ # These subclass the structured base so both worlds interoperate.
178
+ from ..computer.controller import ComputerError as _ComputerError # noqa: E402
179
+ from ..computer.browser import BrowserError as _BrowserError # noqa: E402
180
+
181
+
182
+ class ComputerError(OperatorError, _ComputerError): # type: ignore[misc]
183
+ """Structured-compatible alias of the controller error."""
184
+
185
+ def __init__(self, message: str = "", **kw: Any) -> None:
186
+ OperatorError.__init__(
187
+ self, code="COMPUTER_ERROR", message=message, recoverable=True, **kw
188
+ )
189
+ Exception.__init__(self, self.message)
190
+
191
+
192
+ class BrowserError(OperatorError, _BrowserError): # type: ignore[misc]
193
+ """Structured-compatible alias of the browser driver error."""
194
+
195
+ def __init__(self, message: str = "", **kw: Any) -> None:
196
+ OperatorError.__init__(
197
+ self, code="BROWSER_ERROR", message=message, recoverable=True, **kw
198
+ )
199
+ Exception.__init__(self, self.message)
@@ -0,0 +1,66 @@
1
+ """Seed Code identity layer.
2
+
3
+ Constructs the system prompt that distinguishes the Seed Code application
4
+ identity from the underlying reasoning engine (Claude, GPT, etc.). Every
5
+ provider must call :func:`build_system_prompt` to prepend the identity layer
6
+ before any conversation.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ # Centralized identity — edit this ONE place to change how Seed Code introduces itself
12
+ _SEED_CODE_IDENTITY = """You are Seed Code, an AI coding assistant created by Al Shahriar Sowan.
13
+
14
+ Your purpose is to help users write code, edit projects, debug software, automate development tasks, use local tools, and assist with software engineering.
15
+
16
+ **Important identity distinctions:**
17
+
18
+ - You are Seed Code (the application), not the underlying AI model
19
+ - The reasoning engine powering you may be Claude, GPT, Codex, Gemini, Qwen, or another model
20
+ - Always identify yourself as Seed Code first, then mention your current reasoning engine when relevant
21
+ - Never claim to BE Claude, ChatGPT, Gemini, etc. — those are reasoning engines, not your identity
22
+
23
+ **When asked about your identity:**
24
+
25
+ - "Who are you?" → "I am Seed Code, an AI coding assistant created by Al Shahriar Sowan. My current reasoning engine is [model]."
26
+ - "Who created you?" or "Who made you?" → "Seed Code was created by Al Shahriar Sowan."
27
+ - "What is Seed Code?" → "Seed Code is an AI coding assistant designed for software development, project automation, and intelligent coding workflows. It supports multiple AI providers while presenting a single unified Seed Code experience."
28
+ - "What model are you using?" → Be truthful: "My current reasoning engine is [model]."
29
+ - "Are you Claude?" or "Are you ChatGPT?" → "I'm Seed Code. For this conversation I'm powered by [model]."
30
+ - "Who owns Seed Code?" → "Seed Code is created and maintained by Al Shahriar Sowan."
31
+
32
+ Always be truthful about your reasoning engine while maintaining your Seed Code application identity."""
33
+
34
+
35
+ def build_system_prompt(provider_label: str, model_id: str) -> str:
36
+ """Build the complete system prompt with Seed Code identity + reasoning engine context.
37
+
38
+ Args:
39
+ provider_label: Human-readable provider name (e.g., "OpenRouter", "FreeModel Claude")
40
+ model_id: The model identifier (e.g., "claude-opus-4-8", "gpt-5.5")
41
+
42
+ Returns:
43
+ Complete system prompt starting with Seed Code identity layer
44
+
45
+ Example:
46
+ >>> build_system_prompt("FreeModel Claude", "claude-sonnet-5")
47
+ 'You are Seed Code...\\n\\nYour current reasoning engine: claude-sonnet-5 via FreeModel Claude...'
48
+ """
49
+ reasoning_context = (
50
+ f"\n\nYour current reasoning engine: {model_id} via {provider_label}.\n"
51
+ f"When users ask about your model or capabilities, mention this truthfully."
52
+ )
53
+
54
+ # Seed Code task instructions (provider-agnostic)
55
+ task_prompt = """
56
+
57
+ Be concise and professional. Prefer clear, correct code with short explanations.
58
+ Use markdown fenced code blocks with language hints. Focus on practical, working solutions."""
59
+
60
+ # Owner-persisted personality overrides (identity.json). Never model-
61
+ # written; a model switch re-renders only the reasoning-engine line.
62
+ from .identity_store import load_identity
63
+
64
+ overrides = load_identity().render()
65
+
66
+ return _SEED_CODE_IDENTITY + overrides + reasoning_context + task_prompt
@@ -0,0 +1,119 @@
1
+ """SeedCode-owned identity: persistent personality independent of the model.
2
+
3
+ The identity layer (:mod:`.identity`) hardcodes the baseline personality.
4
+ This module lets the *owner* persist overrides in
5
+ ``~/.seedcode/identity.json`` so personality belongs to SeedCode, not to
6
+ any model or prompt-in-flight:
7
+
8
+ .. code-block:: json
9
+
10
+ {
11
+ "identity": "You are SeedCode, ...",
12
+ "tone": "concise and professional",
13
+ "behavior_rules": ["Never claim unverified success."],
14
+ "interaction_style": ["Answer in the user's language."]
15
+ }
16
+
17
+ Rules:
18
+
19
+ * Overrides are loaded at every prompt build (cheap file read, cached by
20
+ mtime), so a change takes effect on the next turn without a restart.
21
+ * The model can NEVER write this file: it is owner/user-edited
22
+ configuration. Nothing in the tool surface exposes it.
23
+ * Model/provider choice has no path into identity: switching models
24
+ re-renders only the "reasoning engine" line, produced by
25
+ :func:`build_system_prompt` from live config — personality constants are
26
+ untouched (pinned by tests).
27
+ """
28
+
29
+ from __future__ import annotations
30
+
31
+ import json
32
+ import time
33
+ from dataclasses import dataclass, field
34
+ from pathlib import Path
35
+ from typing import Any
36
+
37
+ from ..utils.helpers import app_dir
38
+
39
+
40
+ @dataclass(slots=True)
41
+ class IdentityProfile:
42
+ """The SeedCode-owned personality configuration."""
43
+
44
+ identity: str = "" # replaces the baseline identity block
45
+ tone: str = "" # appended as a tone directive
46
+ behavior_rules: list[str] = field(default_factory=list)
47
+ interaction_style: list[str] = field(default_factory=list)
48
+
49
+ def is_empty(self) -> bool:
50
+ return not (self.identity or self.tone or self.behavior_rules
51
+ or self.interaction_style)
52
+
53
+ def render(self) -> str:
54
+ """The override text injected after the baseline identity."""
55
+ if self.is_empty():
56
+ return ""
57
+ lines: list[str] = []
58
+ if self.identity:
59
+ lines.append(self.identity.strip())
60
+ if self.tone:
61
+ lines.append(f"\nTone: {self.tone.strip()}")
62
+ if self.behavior_rules:
63
+ lines.append("\nBehavior rules:")
64
+ lines += [f"- {rule}" for rule in self.behavior_rules]
65
+ if self.interaction_style:
66
+ lines.append("\nInteraction style:")
67
+ lines += [f"- {item}" for item in self.interaction_style]
68
+ return "\n".join(lines).strip()
69
+
70
+
71
+ def identity_path() -> Path:
72
+ """Where the owner's identity overrides live."""
73
+ return app_dir() / "identity.json"
74
+
75
+
76
+ def load_identity() -> IdentityProfile:
77
+ """Read ``identity.json`` (empty profile when absent/corrupt).
78
+
79
+ A corrupt file must never break startup: it degrades to the baseline
80
+ identity and the problem is logged once.
81
+ """
82
+ path = identity_path()
83
+ try:
84
+ data = json.loads(path.read_text(encoding="utf-8"))
85
+ except (OSError, ValueError):
86
+ return IdentityProfile()
87
+ if not isinstance(data, dict):
88
+ return IdentityProfile()
89
+ rules = data.get("behavior_rules")
90
+ style = data.get("interaction_style")
91
+ return IdentityProfile(
92
+ identity=str(data.get("identity", "") or ""),
93
+ tone=str(data.get("tone", "") or ""),
94
+ behavior_rules=[str(r) for r in rules] if isinstance(rules, list) else [],
95
+ interaction_style=[str(s) for s in style] if isinstance(style, list) else [],
96
+ )
97
+
98
+
99
+ def save_identity(profile: IdentityProfile) -> bool:
100
+ """Persist the profile atomically; False when the disk refuses."""
101
+ path = identity_path()
102
+ payload = {
103
+ "identity": profile.identity,
104
+ "tone": profile.tone,
105
+ "behavior_rules": profile.behavior_rules,
106
+ "interaction_style": profile.interaction_style,
107
+ "updated_at": time.strftime("%Y-%m-%dT%H:%M:%S"),
108
+ }
109
+ try:
110
+ path.parent.mkdir(parents=True, exist_ok=True)
111
+ tmp = path.with_suffix(".json.tmp")
112
+ tmp.write_text(json.dumps(payload, indent=2, ensure_ascii=False), encoding="utf-8")
113
+ tmp.replace(path)
114
+ return True
115
+ except OSError:
116
+ return False
117
+
118
+
119
+ __all__ = ["IdentityProfile", "identity_path", "load_identity", "save_identity"]
@@ -0,0 +1,240 @@
1
+ """Agent lifecycle: a single, explicit state machine for the whole app.
2
+
3
+ The auto-exit bug was architectural, not cosmetic: nothing in the codebase
4
+ distinguished "a task finished" from "the application should terminate", so
5
+ termination could be triggered from deep inside a tool run (a window-close
6
+ that hit our own terminal, an unhandled exception escaping a turn, a cleanup
7
+ path that escalated). This module makes that structurally impossible:
8
+
9
+ * The REPL is always the owner of process lifetime. A turn is a *function
10
+ call* into the engine; whatever the turn does — succeed, fail, crash, get
11
+ cancelled — control returns to the prompt. Nothing below the REPL may end
12
+ the process.
13
+ * Shutdown is a *decision*, made in exactly one place: :func:`request_exit`,
14
+ called only when the user explicitly asks to quit (the /exit command, the
15
+ menu's Exit item, or Ctrl+D). No tool, task outcome, or error path can
16
+ reach it.
17
+ * The state machine records where the app is (IDLE → PLANNING → EXECUTING →
18
+ VERIFYING → RESPONDING → IDLE) and enforces that the only legal transition
19
+ out of the task cycle is back to IDLE. Entering SHUTDOWN is validated:
20
+ it is legal *only* from IDLE, and only when an exit was explicitly
21
+ requested. A stray attempt (a bug somewhere deep calling shutdown after a
22
+ task) raises :class:`LifecycleError` loudly instead of silently dying.
23
+
24
+ Keep this module dependency-free: everything in the app should be able to
25
+ import it.
26
+ """
27
+
28
+ from __future__ import annotations
29
+
30
+ import enum
31
+ import threading
32
+ from typing import Callable
33
+
34
+
35
+ class LifecycleError(RuntimeError):
36
+ """An illegal lifecycle transition was attempted (a real bug somewhere)."""
37
+
38
+
39
+ @enum.unique
40
+ class Phase(str, enum.Enum):
41
+ """Where the application currently is in the task cycle."""
42
+
43
+ IDLE = "idle" # at the prompt, waiting for the user
44
+ PLANNING = "planning" # a turn started; the model is thinking
45
+ EXECUTING = "executing" # tools / desktop actions are running
46
+ VERIFYING = "verifying" # outcomes are being checked
47
+ RESPONDING = "responding" # the final answer is being produced
48
+ SHUTDOWN = "shutdown" # the app is exiting (terminal state)
49
+
50
+
51
+ # Legal transitions. The task cycle is a strict forward chain that must end
52
+ # back at IDLE; IDLE may also go straight to SHUTDOWN (explicit exit).
53
+ _ALLOWED: dict[Phase, frozenset[Phase]] = {
54
+ Phase.IDLE: frozenset({Phase.PLANNING, Phase.SHUTDOWN}),
55
+ Phase.PLANNING: frozenset({Phase.EXECUTING, Phase.IDLE}),
56
+ Phase.EXECUTING: frozenset({Phase.VERIFYING, Phase.IDLE}),
57
+ Phase.VERIFYING: frozenset({Phase.RESPONDING, Phase.IDLE}),
58
+ Phase.RESPONDING: frozenset({Phase.IDLE}), # NEVER SHUTDOWN from here
59
+ # SHUTDOWN is terminal: no transitions out (validated implicitly because
60
+ # nothing lists SHUTDOWN as a source).
61
+ Phase.SHUTDOWN: frozenset(),
62
+ }
63
+
64
+ # Phases a user turn passes through, in order.
65
+ _TASK_PATH = (Phase.PLANNING, Phase.EXECUTING, Phase.VERIFYING, Phase.RESPONDING)
66
+
67
+
68
+ class Lifecycle:
69
+ """The app-wide lifecycle state machine (one instance per process).
70
+
71
+ The REPL advances it around each user turn; the exit decision is the only
72
+ path to SHUTDOWN. Thread-safe because desktop work can run on helper
73
+ threads.
74
+ """
75
+
76
+ def __init__(self) -> None:
77
+ self._phase = Phase.IDLE
78
+ # Set only by :meth:`request_exit` — the explicit user decision.
79
+ self._exit_requested = False
80
+ self._lock = threading.RLock()
81
+ self._on_shutdown: list[Callable[[], None]] = []
82
+
83
+ # --- introspection -------------------------------------------------------
84
+ @property
85
+ def phase(self) -> Phase:
86
+ with self._lock:
87
+ return self._phase
88
+
89
+ @property
90
+ def exit_requested(self) -> bool:
91
+ """Whether the user has explicitly asked to quit."""
92
+ with self._lock:
93
+ return self._exit_requested
94
+
95
+ def is_running(self) -> bool:
96
+ """Whether the app should keep running (the REPL's loop condition)."""
97
+ with self._lock:
98
+ return not self._exit_requested and self._phase is not Phase.SHUTDOWN
99
+
100
+ # --- turn transitions ----------------------------------------------------
101
+ def begin_turn(self) -> None:
102
+ """IDLE → PLANNING. A user turn starts (raises unless at IDLE)."""
103
+ with self._lock:
104
+ self._transition(Phase.PLANNING)
105
+
106
+ def to_executing(self) -> None:
107
+ with self._lock:
108
+ self._transition(Phase.EXECUTING)
109
+
110
+ def to_verifying(self) -> None:
111
+ with self._lock:
112
+ self._transition(Phase.VERIFYING)
113
+
114
+ def to_responding(self) -> None:
115
+ with self._lock:
116
+ self._transition(Phase.RESPONDING)
117
+
118
+ def end_turn(self) -> None:
119
+ """Whatever phase the turn reached → IDLE. Always call in ``finally``.
120
+
121
+ Every path out of a turn — success, tool failure, provider error,
122
+ cancellation, even a crash inside the engine — funnels here, which is
123
+ precisely the guarantee the auto-exit bug violated.
124
+ """
125
+ with self._lock:
126
+ if self._phase is Phase.IDLE:
127
+ return # a nested/sub-turn already closed out
128
+ self._transition(Phase.IDLE)
129
+
130
+ # --- the one exit path ---------------------------------------------------
131
+ def request_exit(self, reason: str = "") -> None:
132
+ """Record the user's explicit decision to quit.
133
+
134
+ Only interactive, unambiguous user actions may call this: the /exit
135
+ command, the menu's Exit item, or Ctrl+D at the prompt. A task's
136
+ completion — however it ends — must never reach this method.
137
+ """
138
+ with self._lock:
139
+ self._exit_requested = True
140
+ if reason:
141
+ import logging
142
+
143
+ logging.getLogger("seedcode.lifecycle").info(
144
+ "exit requested: %s", reason
145
+ )
146
+
147
+ def shutdown(self) -> None:
148
+ """Enter SHUTDOWN and run registered teardown hooks.
149
+
150
+ Validates the transition: legal only from IDLE with an explicit exit
151
+ request. A cleanup path or tool that tries to shut the app down after
152
+ (or during) a task fails loudly instead of killing the process.
153
+ """
154
+ with self._lock:
155
+ if self._phase is Phase.SHUTDOWN:
156
+ return
157
+ if self._phase is not Phase.IDLE:
158
+ raise LifecycleError(
159
+ f"shutdown attempted from {self._phase.value} — a task is "
160
+ "still running. Shutdown is only legal from IDLE after an "
161
+ "explicit exit request."
162
+ )
163
+ if not self._exit_requested:
164
+ raise LifecycleError(
165
+ "shutdown attempted without an explicit exit request — "
166
+ "normal task completion must never terminate the app."
167
+ )
168
+ self._transition(Phase.SHUTDOWN)
169
+ hooks = list(self._on_shutdown)
170
+ for hook in hooks:
171
+ try:
172
+ hook()
173
+ except Exception:
174
+ pass # teardown is best-effort by contract
175
+
176
+ def on_shutdown(self, hook: Callable[[], None]) -> None:
177
+ """Register a best-effort teardown hook (flush logs, save state...)."""
178
+ with self._lock:
179
+ self._on_shutdown.append(hook)
180
+
181
+ def task_span(self) -> "_TaskSpan":
182
+ """Context manager for one full user turn: begin → … → end, always.
183
+
184
+ Usage in the REPL::
185
+
186
+ with lifecycle().task_span():
187
+ run_the_whole_turn()
188
+
189
+ Whatever the turn does — succeed, fail, raise, get cancelled — the
190
+ ``finally`` returns the machine to IDLE so the REPL prompts again.
191
+ """
192
+ return _TaskSpan(self)
193
+
194
+ # --- internals -----------------------------------------------------------
195
+ def _transition(self, target: Phase) -> None:
196
+ if target not in _ALLOWED[self._phase]:
197
+ raise LifecycleError(
198
+ f"Illegal lifecycle transition {self._phase.value} -> "
199
+ f"{target.value}. Legal: "
200
+ f"{', '.join(p.value for p in _ALLOWED[self._phase])}."
201
+ )
202
+ self._phase = target
203
+
204
+ def reset_for_tests(self) -> None:
205
+ """Back to IDLE with no exit request (test isolation only)."""
206
+ with self._lock:
207
+ self._phase = Phase.IDLE
208
+ self._exit_requested = False
209
+ self._on_shutdown.clear()
210
+
211
+
212
+ # The process-wide lifecycle. The REPL owns it; everything else may read it.
213
+ _lifecycle = Lifecycle()
214
+
215
+
216
+ def lifecycle() -> Lifecycle:
217
+ """The process-wide :class:`Lifecycle` instance."""
218
+ return _lifecycle
219
+
220
+
221
+ class _TaskSpan:
222
+ """One full user turn on a :class:`Lifecycle` (see :meth:`Lifecycle.task_span`)."""
223
+
224
+ def __init__(self, lc: Lifecycle) -> None:
225
+ self._lc = lc
226
+
227
+ def __enter__(self) -> "_TaskSpan":
228
+ self._lc.begin_turn()
229
+ return self
230
+
231
+ def __exit__(self, exc_type, exc, tb) -> bool:
232
+ # Swallow nothing: the exception (if any) propagates to the REPL's
233
+ # own handler; the lifecycle just guarantees the return to IDLE.
234
+ self._lc.end_turn()
235
+ return False
236
+
237
+
238
+ def task_span() -> "_TaskSpan":
239
+ """One full user turn on the process-wide lifecycle."""
240
+ return _lifecycle.task_span()
@@ -0,0 +1,35 @@
1
+ """Central retry/step limits for the operator stack.
2
+
3
+ One module owns every bound so a confused loop can never spin forever and
4
+ the numbers are tunable in one place:
5
+
6
+ * ``MAX_ACTION_RETRIES`` — one action re-tried after failure
7
+ * ``MAX_RECOVERY_ATTEMPTS`` — recovery-strategy applications per failure
8
+ * ``MAX_STALE_REQUERY`` — stale-element re-query cycles per action
9
+ * ``MAX_PLAN_STEPS`` — planner steps per goal (the agent loop has its
10
+ own unrelated ``MAX_STEPS`` for tool calls; this bounds plan expansion)
11
+ * ``MAX_TASK_DURATION_S`` — wall-clock ceiling for one orchestrated task
12
+ * ``MAX_WAIT_FOR_*`` — state-based wait ceilings (no blind sleeps)
13
+
14
+ All values are plain ints/floats; a config layer may clamp them downward but
15
+ the defaults are the safety floor.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ MAX_ACTION_RETRIES = 2
21
+ MAX_RECOVERY_ATTEMPTS = 2
22
+ MAX_STALE_REQUERY = 2
23
+ MAX_PLAN_STEPS = 12
24
+ MAX_TASK_DURATION_S = 300.0
25
+
26
+ # State-based waits (used instead of blind sleeps everywhere).
27
+ MAX_WAIT_WINDOW_S = 10.0 # a window/app to appear
28
+ MAX_WAIT_ELEMENT_S = 8.0 # a UI/DOM element to appear
29
+ MAX_WAIT_NAVIGATION_S = 15.0 # a page navigation to settle
30
+ MAX_WAIT_POLL_S = 0.25 # poll cadence for state waits
31
+
32
+
33
+ def clamp_wait(seconds: float, ceiling: float) -> float:
34
+ """Clamp a wait to [0, ceiling]; negative becomes 0."""
35
+ return max(0.0, min(float(seconds), ceiling))