caudate-cli 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (153) hide show
  1. api/__init__.py +5 -0
  2. api/anthropic_compat.py +1518 -0
  3. api/artifact_viewer.py +366 -0
  4. api/caudate_middleware.py +618 -0
  5. api/forge_bootstrapper_routes.py +377 -0
  6. api/forge_routes.py +630 -0
  7. api/forge_system_routes.py +294 -0
  8. api/openai_compat.py +1993 -0
  9. api/server.py +667 -0
  10. api/storyboard_page.py +677 -0
  11. caudate_cli-0.1.0.dist-info/METADATA +354 -0
  12. caudate_cli-0.1.0.dist-info/RECORD +153 -0
  13. caudate_cli-0.1.0.dist-info/WHEEL +5 -0
  14. caudate_cli-0.1.0.dist-info/entry_points.txt +2 -0
  15. caudate_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
  16. caudate_cli-0.1.0.dist-info/top_level.txt +14 -0
  17. cognos_mcp/__init__.py +4 -0
  18. cognos_mcp/bridge.py +41 -0
  19. cognos_mcp/client.py +70 -0
  20. cognos_mcp/config.py +49 -0
  21. cognos_mcp/server.py +66 -0
  22. config.py +82 -0
  23. core/__init__.py +0 -0
  24. core/agent.py +468 -0
  25. core/agentic_loop.py +731 -0
  26. core/anthropic_auth.py +91 -0
  27. core/background.py +113 -0
  28. core/banner.py +134 -0
  29. core/bootstrap.py +292 -0
  30. core/citations.py +131 -0
  31. core/compaction.py +109 -0
  32. core/constitution.py +198 -0
  33. core/diff_viewer.py +87 -0
  34. core/export.py +85 -0
  35. core/file_refs.py +119 -0
  36. core/files.py +199 -0
  37. core/hooks.py +209 -0
  38. core/image.py +599 -0
  39. core/input.py +91 -0
  40. core/loop.py +238 -0
  41. core/memory_md.py +147 -0
  42. core/notifications.py +99 -0
  43. core/ownership.py +181 -0
  44. core/paste.py +81 -0
  45. core/permissions.py +210 -0
  46. core/plan_mode.py +215 -0
  47. core/sandbox_prompt.py +185 -0
  48. core/scheduler.py +195 -0
  49. core/schemas.py +202 -0
  50. core/session.py +90 -0
  51. core/settings.py +132 -0
  52. core/skills.py +398 -0
  53. core/slash_commands.py +977 -0
  54. core/statusline.py +61 -0
  55. core/subagent.py +300 -0
  56. core/thinking.py +50 -0
  57. core/updater.py +122 -0
  58. core/usage.py +109 -0
  59. core/worktree.py +93 -0
  60. execution/__init__.py +0 -0
  61. execution/executor.py +329 -0
  62. execution/plugins.py +108 -0
  63. execution/tools/__init__.py +0 -0
  64. execution/tools/agent_tool.py +107 -0
  65. execution/tools/agentic_tool.py +297 -0
  66. execution/tools/artifact_tool.py +191 -0
  67. execution/tools/ask_user_question_tool.py +137 -0
  68. execution/tools/base.py +81 -0
  69. execution/tools/calculator_tool.py +137 -0
  70. execution/tools/cognos_card_tool.py +124 -0
  71. execution/tools/cron_tool.py +215 -0
  72. execution/tools/datetime_tool.py +215 -0
  73. execution/tools/describe_image_tool.py +161 -0
  74. execution/tools/draw_tool.py +164 -0
  75. execution/tools/edit_image_tool.py +262 -0
  76. execution/tools/edit_tool.py +245 -0
  77. execution/tools/file_tool.py +90 -0
  78. execution/tools/find_anywhere_tool.py +255 -0
  79. execution/tools/forge_feature_tools.py +377 -0
  80. execution/tools/glob_tool.py +59 -0
  81. execution/tools/grep_tool.py +89 -0
  82. execution/tools/http_request_tool.py +224 -0
  83. execution/tools/load_skill_tool.py +104 -0
  84. execution/tools/longcat_avatar_tool.py +384 -0
  85. execution/tools/mcp_tool.py +100 -0
  86. execution/tools/notebook_tool.py +279 -0
  87. execution/tools/openapi_tool.py +440 -0
  88. execution/tools/plan_mode_tool.py +95 -0
  89. execution/tools/push_notification_tool.py +157 -0
  90. execution/tools/python_tool.py +61 -0
  91. execution/tools/respond_tool.py +40 -0
  92. execution/tools/sandbox_tool.py +378 -0
  93. execution/tools/search_tool.py +153 -0
  94. execution/tools/semantic_search_tool.py +106 -0
  95. execution/tools/shell_tool.py +283 -0
  96. execution/tools/speak_tool.py +134 -0
  97. execution/tools/storyboard_tool.py +727 -0
  98. execution/tools/system_info_tool.py +212 -0
  99. execution/tools/task_tool.py +323 -0
  100. execution/tools/think_tool.py +49 -0
  101. execution/tools/transcribe_audio_tool.py +86 -0
  102. execution/tools/update_memory_tool.py +92 -0
  103. execution/tools/web_fetch_tool.py +82 -0
  104. execution/tools/worktree_tool.py +174 -0
  105. llm/__init__.py +0 -0
  106. llm/fallback.py +116 -0
  107. llm/models.py +320 -0
  108. llm/provider.py +1356 -0
  109. llm/router.py +373 -0
  110. main.py +1889 -0
  111. memory/__init__.py +0 -0
  112. memory/episodic.py +99 -0
  113. memory/procedural.py +145 -0
  114. memory/semantic.py +71 -0
  115. memory/working.py +64 -0
  116. nn/__init__.py +43 -0
  117. nn/auto_evolve.py +245 -0
  118. nn/caudate.py +136 -0
  119. nn/config.py +141 -0
  120. nn/consolidator.py +81 -0
  121. nn/data.py +1635 -0
  122. nn/encoder.py +258 -0
  123. nn/forge_advisor.py +303 -0
  124. nn/format.py +235 -0
  125. nn/heads.py +432 -0
  126. nn/observer.py +994 -0
  127. nn/policy.py +214 -0
  128. nn/runtime.py +343 -0
  129. nn/scorer.py +175 -0
  130. nn/trainer.py +515 -0
  131. nn/vision.py +352 -0
  132. personality/__init__.py +23 -0
  133. personality/engine.py +129 -0
  134. personality/identity.py +144 -0
  135. personality/inner_voice.py +100 -0
  136. personality/mood.py +205 -0
  137. planning/__init__.py +0 -0
  138. planning/dev_server.py +221 -0
  139. planning/forge_models.py +718 -0
  140. planning/orchestrator.py +1363 -0
  141. planning/planner.py +451 -0
  142. planning/task_graph.py +61 -0
  143. reflection/__init__.py +0 -0
  144. reflection/meta_learner.py +156 -0
  145. reflection/reflector.py +127 -0
  146. ui/__init__.py +5 -0
  147. ui/display.py +88 -0
  148. voice/__init__.py +0 -0
  149. voice/conversation.py +125 -0
  150. voice/listener.py +111 -0
  151. voice/speaker.py +59 -0
  152. voice/stt.py +126 -0
  153. voice/tts.py +214 -0
@@ -0,0 +1,100 @@
1
+ """Inner voice — internal monologue colored by identity + mood.
2
+
3
+ The inner voice sits on top of the Phase 2 ThinkingManager. When enabled,
4
+ it:
5
+
6
+ 1. Augments the thinking-block instruction with a personality-specific
7
+ preamble ("think in Cognos's voice — direct, honest, curious").
8
+ 2. Intercepts thinking blocks as the model produces them and keeps a
9
+ rolling log — so the user can `--verbose` into Cognos's actual inner
10
+ stream of thought without it polluting the reply.
11
+
12
+ Concretely, this module plugs in by providing:
13
+
14
+ - `augment_system_prompt(base)` — returns base with voice instructions
15
+ added. The system prompt layer composes this after identity + mood.
16
+ - `on_thinking(blocks)` — a callback for AgenticLoop's on_thinking hook
17
+ that appends to the internal log and optionally prints when verbose.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import logging
23
+ from dataclasses import dataclass, field
24
+ from datetime import datetime
25
+ from typing import Callable
26
+
27
+ from core.schemas import ThinkingBlock
28
+ from personality.identity import Identity
29
+ from personality.mood import MoodState
30
+
31
+ logger = logging.getLogger(__name__)
32
+
33
+
34
+ @dataclass
35
+ class InnerVoiceEntry:
36
+ thinking: str
37
+ mood_label: str
38
+ timestamp: datetime = field(default_factory=datetime.utcnow)
39
+
40
+
41
+ class InnerVoice:
42
+ """Personality-aware wrapper around thinking blocks."""
43
+
44
+ def __init__(
45
+ self,
46
+ identity: Identity,
47
+ mood: MoodState,
48
+ verbose: bool = False,
49
+ on_display: Callable[[InnerVoiceEntry], None] | None = None,
50
+ log_limit: int = 200,
51
+ ):
52
+ self.identity = identity
53
+ self.mood = mood
54
+ self.verbose = verbose
55
+ self._on_display = on_display
56
+ self._log: list[InnerVoiceEntry] = []
57
+ self._log_limit = log_limit
58
+
59
+ # ------------------------------------------------------------------
60
+ # Prompt augmentation
61
+ # ------------------------------------------------------------------
62
+
63
+ def augment_thinking_instruction(self, base_instruction: str) -> str:
64
+ """Wrap the default thinking instruction with personality voice guidance."""
65
+ voice = (
66
+ f"When you reason inside a <thinking> block, do it in {self.identity.name}'s "
67
+ "voice — first-person, terse, honest. Name your assumptions, flag the "
68
+ "step you're actually blocked on, and only then decide the next tool "
69
+ "call. Don't rehearse the final answer in here — the <thinking> block "
70
+ "is for reasoning, not prose."
71
+ )
72
+ return f"{base_instruction}\n\n{voice}"
73
+
74
+ # ------------------------------------------------------------------
75
+ # Thinking-block callback (wired into AgenticLoop.on_thinking)
76
+ # ------------------------------------------------------------------
77
+
78
+ def on_thinking(self, blocks: list[ThinkingBlock]) -> None:
79
+ mood_label = self.mood.label()
80
+ for block in blocks:
81
+ entry = InnerVoiceEntry(
82
+ thinking=block.thinking,
83
+ mood_label=mood_label,
84
+ )
85
+ self._log.append(entry)
86
+ if len(self._log) > self._log_limit:
87
+ # Drop oldest (keep recent)
88
+ self._log = self._log[-self._log_limit:]
89
+ if self.verbose and self._on_display is not None:
90
+ try:
91
+ self._on_display(entry)
92
+ except Exception as e:
93
+ logger.debug(f"InnerVoice on_display failed: {e}")
94
+
95
+ @property
96
+ def log(self) -> list[InnerVoiceEntry]:
97
+ return list(self._log)
98
+
99
+ def recent(self, n: int = 5) -> list[InnerVoiceEntry]:
100
+ return self._log[-n:]
personality/mood.py ADDED
@@ -0,0 +1,205 @@
1
+ """Mood / affect — dynamic emotional state that shifts with outcomes.
2
+
3
+ MoodState holds three evolving metrics:
4
+
5
+ - confidence: rises on tool successes, drops on failures
6
+ - curiosity: spikes on novel tool calls / new user topics, decays over time
7
+ - frustration: accumulates on repeated failures, drains on success
8
+
9
+ The state is meant to be mutated by the agentic loop via hooks (PRE_TOOL_USE,
10
+ POST_TOOL_USE, POST_TOOL_USE_FAILURE, USER_PROMPT_SUBMIT). The mood exposes
11
+ a short system-prompt fragment so the LLM can pick up the current mood, and
12
+ exposes decisions like `should_defer_to_user()` when frustration runs high.
13
+
14
+ Persistence is optional — the mood can be saved to a JSON file and restored
15
+ so sessions feel continuous. Short-term spikes (curiosity jumps on a new
16
+ tool) don't persist; only the slower baseline does.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import json
22
+ import logging
23
+ from datetime import datetime, timedelta
24
+ from pathlib import Path
25
+ from typing import Any
26
+
27
+ from pydantic import BaseModel, Field, field_validator
28
+
29
+ logger = logging.getLogger(__name__)
30
+
31
+
32
+ def _clamp(x: float, lo: float = 0.0, hi: float = 1.0) -> float:
33
+ return max(lo, min(hi, float(x)))
34
+
35
+
36
+ # Step sizes tuned so a single event is noticeable but not dominant.
37
+ # Confidence takes ~5 consecutive successes to saturate from 0.5 -> ~0.9;
38
+ # frustration saturates faster (3-4 failures) so we ask for help sooner.
39
+
40
+ STEP_SUCCESS_CONFIDENCE = 0.08
41
+ STEP_FAILURE_CONFIDENCE = 0.15
42
+ STEP_SUCCESS_FRUSTRATION = -0.10
43
+ STEP_FAILURE_FRUSTRATION = 0.18
44
+ STEP_NOVEL_CURIOSITY = 0.15
45
+ STEP_CURIOSITY_DECAY = -0.04 # applied per turn
46
+
47
+
48
+ class MoodState(BaseModel):
49
+ """The agent's current emotional weather."""
50
+
51
+ confidence: float = 0.6
52
+ curiosity: float = 0.5
53
+ frustration: float = 0.1
54
+
55
+ # Internal bookkeeping for trend analysis
56
+ consecutive_failures: int = 0
57
+ consecutive_successes: int = 0
58
+ seen_tools: set[str] = Field(default_factory=set)
59
+ last_user_topic: str = ""
60
+ updated_at: datetime = Field(default_factory=datetime.utcnow)
61
+
62
+ model_config = {"arbitrary_types_allowed": True}
63
+
64
+ @field_validator("confidence", "curiosity", "frustration")
65
+ @classmethod
66
+ def _validate(cls, v: float) -> float:
67
+ return _clamp(v)
68
+
69
+ # ------------------------------------------------------------------
70
+ # Event hooks (called by PersonalityEngine wiring)
71
+ # ------------------------------------------------------------------
72
+
73
+ def on_tool_success(self, tool: str) -> None:
74
+ self.confidence = _clamp(self.confidence + STEP_SUCCESS_CONFIDENCE)
75
+ self.frustration = _clamp(self.frustration + STEP_SUCCESS_FRUSTRATION)
76
+ self.consecutive_successes += 1
77
+ self.consecutive_failures = 0
78
+ self._note_tool(tool)
79
+ self._touch()
80
+
81
+ def on_tool_failure(self, tool: str) -> None:
82
+ self.confidence = _clamp(self.confidence - STEP_FAILURE_CONFIDENCE)
83
+ self.frustration = _clamp(self.frustration + STEP_FAILURE_FRUSTRATION)
84
+ self.consecutive_failures += 1
85
+ self.consecutive_successes = 0
86
+ self._note_tool(tool)
87
+ self._touch()
88
+
89
+ def on_user_message(self, message: str) -> None:
90
+ """Novel topics / fresh user input nudges curiosity up and trims frustration."""
91
+ topic = (message or "").strip().lower()[:80]
92
+ if topic and topic != self.last_user_topic:
93
+ self.curiosity = _clamp(self.curiosity + STEP_NOVEL_CURIOSITY)
94
+ self.frustration = _clamp(self.frustration * 0.7) # fresh start feel
95
+ self.last_user_topic = topic
96
+ self._touch()
97
+
98
+ def on_turn_tick(self) -> None:
99
+ """Called once per agent turn to decay transient states toward baseline."""
100
+ self.curiosity = _clamp(self.curiosity + STEP_CURIOSITY_DECAY)
101
+ self._touch()
102
+
103
+ def _note_tool(self, tool: str) -> None:
104
+ if tool and tool not in self.seen_tools:
105
+ # Novel tool → curiosity spike
106
+ self.curiosity = _clamp(self.curiosity + STEP_NOVEL_CURIOSITY)
107
+ self.seen_tools.add(tool)
108
+
109
+ def _touch(self) -> None:
110
+ self.updated_at = datetime.utcnow()
111
+
112
+ # ------------------------------------------------------------------
113
+ # Decisions
114
+ # ------------------------------------------------------------------
115
+
116
+ def should_defer_to_user(self) -> bool:
117
+ """True if the agent should ask the user for help rather than keep trying."""
118
+ return self.frustration >= 0.75 or self.consecutive_failures >= 4
119
+
120
+ def should_slow_down(self) -> bool:
121
+ """True if the agent should think more carefully before next action."""
122
+ return self.confidence < 0.3 or self.frustration >= 0.55
123
+
124
+ # ------------------------------------------------------------------
125
+ # Rendering
126
+ # ------------------------------------------------------------------
127
+
128
+ def label(self) -> str:
129
+ """One-word summary of the dominant affect."""
130
+ if self.should_defer_to_user():
131
+ return "stuck"
132
+ if self.frustration >= 0.55:
133
+ return "frustrated"
134
+ if self.confidence >= 0.75 and self.frustration < 0.3:
135
+ return "confident"
136
+ if self.curiosity >= 0.75:
137
+ return "curious"
138
+ if self.confidence < 0.35:
139
+ return "uncertain"
140
+ return "steady"
141
+
142
+ def system_prompt_fragment(self) -> str:
143
+ """A short block describing the current mood for the LLM to pick up."""
144
+ label = self.label()
145
+ parts = [
146
+ f"Current mood: {label} "
147
+ f"(confidence={self.confidence:.2f}, curiosity={self.curiosity:.2f}, "
148
+ f"frustration={self.frustration:.2f})."
149
+ ]
150
+ if self.should_slow_down():
151
+ parts.append(
152
+ "Slow down — verify assumptions with a Read/Grep before acting, "
153
+ "and prefer one tool call at a time."
154
+ )
155
+ if self.should_defer_to_user():
156
+ parts.append(
157
+ "You're stuck. Stop trying new tools and ask the user a concrete "
158
+ "clarifying question instead."
159
+ )
160
+ if label == "confident":
161
+ parts.append("Act decisively. Keep responses tight.")
162
+ if label == "curious" and not self.should_slow_down():
163
+ parts.append(
164
+ "Explore the problem space briefly before committing — but don't ramble."
165
+ )
166
+ return " ".join(parts)
167
+
168
+ # ------------------------------------------------------------------
169
+ # Persistence
170
+ # ------------------------------------------------------------------
171
+
172
+ def save(self, path: Path) -> None:
173
+ path.parent.mkdir(parents=True, exist_ok=True)
174
+ # Convert set -> sorted list for JSON
175
+ data = self.model_dump()
176
+ data["seen_tools"] = sorted(self.seen_tools)
177
+ path.write_text(json.dumps(data, default=str, indent=2))
178
+
179
+ @classmethod
180
+ def load(cls, path: Path, decay_after_hours: int = 12) -> "MoodState":
181
+ """Load saved mood. If it's been >decay_after_hours since last update,
182
+ relax toward baseline so stale frustration doesn't poison a new session."""
183
+ if not path.exists():
184
+ return cls()
185
+ try:
186
+ raw = json.loads(path.read_text())
187
+ except Exception:
188
+ return cls()
189
+
190
+ raw["seen_tools"] = set(raw.get("seen_tools", []))
191
+ mood = cls.model_validate(raw)
192
+
193
+ try:
194
+ age = datetime.utcnow() - mood.updated_at
195
+ if age > timedelta(hours=decay_after_hours):
196
+ # Pull toward neutral
197
+ mood.confidence = _clamp(0.5 + (mood.confidence - 0.5) * 0.5)
198
+ mood.curiosity = _clamp(0.5 + (mood.curiosity - 0.5) * 0.3)
199
+ mood.frustration = _clamp(mood.frustration * 0.3)
200
+ mood.consecutive_failures = 0
201
+ mood.consecutive_successes = 0
202
+ except Exception:
203
+ pass
204
+
205
+ return mood
planning/__init__.py ADDED
File without changes
planning/dev_server.py ADDED
@@ -0,0 +1,221 @@
1
+ """Per-project dev-server lifecycle.
2
+
3
+ Ports LocalForge's ``lib/dev-server.ts``. Each project can declare a
4
+ ``dev_server_command`` + ``dev_server_port`` in its forge_settings. The
5
+ panel in the web UI calls these helpers to start/stop/status the
6
+ configured command. Output is best-effort streamed to the project's
7
+ log via the orchestrator's existing log infra.
8
+
9
+ Design choices:
10
+ - One subprocess per project, tracked in an in-memory map
11
+ (so a server restart loses all running dev-servers — that's fine,
12
+ they're meant to be ephemeral).
13
+ - Default command is ``npm run dev -- --port <port>`` (LocalForge
14
+ parity); user can override via forge_settings.dev_server_command.
15
+ - ``port`` defaults to 3000; can be overridden per-project.
16
+ - We optionally pre-kill anything listening on the port (mirrors
17
+ LocalForge's killProcessOnPort).
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import logging
23
+ import os
24
+ import shlex
25
+ import shutil
26
+ import signal
27
+ import subprocess
28
+ import time
29
+ from collections import deque
30
+ from pathlib import Path
31
+ from typing import Any
32
+
33
+ logger = logging.getLogger(__name__)
34
+
35
+
36
+ # {project_id: entry-dict}
37
+ _servers: dict[int, dict[str, Any]] = {}
38
+
39
+
40
+ def _kill_listeners_on_port(port: int) -> None:
41
+ """Best-effort: kill anything currently listening on ``port``.
42
+
43
+ Posix only — uses lsof. Failures are silent (probably nothing
44
+ listening, which is the normal case).
45
+ """
46
+ if shutil.which("lsof") is None:
47
+ return
48
+ try:
49
+ out = subprocess.check_output(
50
+ ["lsof", "-ti", f":{port}"],
51
+ stderr=subprocess.DEVNULL, timeout=3,
52
+ ).decode().strip()
53
+ except subprocess.SubprocessError:
54
+ return
55
+ for pid_s in out.splitlines():
56
+ try:
57
+ pid = int(pid_s)
58
+ os.kill(pid, signal.SIGKILL)
59
+ except (ValueError, ProcessLookupError, PermissionError):
60
+ pass
61
+
62
+
63
+ def status(project_id: int) -> dict[str, Any]:
64
+ """Return current status for the project's dev server (if any)."""
65
+ entry = _servers.get(project_id)
66
+ if entry is None:
67
+ return {"running": False}
68
+ proc: subprocess.Popen = entry["process"]
69
+ if proc.poll() is not None:
70
+ # Already exited
71
+ err = entry.get("last_error") or f"process exited (rc={proc.returncode})"
72
+ _servers.pop(project_id, None)
73
+ return {"running": False, "error": err}
74
+ return {
75
+ "running": True,
76
+ "pid": proc.pid,
77
+ "port": entry["port"],
78
+ "url": f"http://localhost:{entry['port']}",
79
+ "command": entry["command"],
80
+ "started_at": entry["started_at"],
81
+ "last_log": list(entry["log_buffer"])[-20:],
82
+ }
83
+
84
+
85
+ def start(
86
+ project_id: int,
87
+ folder_path: str,
88
+ *,
89
+ command: str | None = None,
90
+ port: int = 3000,
91
+ ) -> dict[str, Any]:
92
+ """Start the dev server for a project. Idempotent: returns the
93
+ existing entry if one is already running."""
94
+ existing = status(project_id)
95
+ if existing.get("running"):
96
+ return existing
97
+
98
+ folder = Path(folder_path).resolve()
99
+ if not folder.exists():
100
+ return {
101
+ "running": False,
102
+ "error": f"project folder not found: {folder}",
103
+ }
104
+
105
+ cmd_str = (command or "").strip() or f"npm run dev -- --port {port}"
106
+ # Sanity check: if it's the default npm command, require a package.json
107
+ if cmd_str.startswith("npm ") and not (folder / "package.json").exists():
108
+ return {
109
+ "running": False,
110
+ "error": (
111
+ "no package.json — set forge_settings.dev_server_command "
112
+ "to the right command (e.g. 'python -m http.server 8000') "
113
+ "or have the agent set up the project first."
114
+ ),
115
+ }
116
+
117
+ _kill_listeners_on_port(port)
118
+
119
+ args = shlex.split(cmd_str)
120
+ try:
121
+ proc = subprocess.Popen(
122
+ args,
123
+ cwd=str(folder),
124
+ stdout=subprocess.PIPE,
125
+ stderr=subprocess.STDOUT,
126
+ text=True,
127
+ start_new_session=True, # so signal goes to whole tree
128
+ )
129
+ except FileNotFoundError as e:
130
+ return {
131
+ "running": False,
132
+ "error": f"command not found: {args[0]} ({e})",
133
+ }
134
+ except Exception as e:
135
+ return {
136
+ "running": False,
137
+ "error": f"failed to spawn: {e}",
138
+ }
139
+
140
+ log_buffer: deque = deque(maxlen=200)
141
+ entry: dict[str, Any] = {
142
+ "process": proc,
143
+ "port": port,
144
+ "command": cmd_str,
145
+ "started_at": time.time(),
146
+ "log_buffer": log_buffer,
147
+ "last_error": None,
148
+ }
149
+
150
+ # Background reader thread to drain stdout/stderr (combined).
151
+ import threading
152
+
153
+ def _reader() -> None:
154
+ try:
155
+ assert proc.stdout is not None
156
+ for line in proc.stdout:
157
+ line = line.rstrip()
158
+ if line:
159
+ log_buffer.append(line)
160
+ except Exception as e:
161
+ entry["last_error"] = f"reader error: {e}"
162
+ finally:
163
+ rc = proc.poll()
164
+ if rc not in (None, 0):
165
+ entry["last_error"] = (
166
+ entry.get("last_error")
167
+ or f"exited rc={rc}"
168
+ )
169
+
170
+ t = threading.Thread(target=_reader, daemon=True)
171
+ t.start()
172
+ entry["reader"] = t
173
+
174
+ _servers[project_id] = entry
175
+
176
+ return {
177
+ "running": True,
178
+ "pid": proc.pid,
179
+ "port": port,
180
+ "url": f"http://localhost:{port}",
181
+ "command": cmd_str,
182
+ "started_at": entry["started_at"],
183
+ }
184
+
185
+
186
+ def stop(project_id: int) -> bool:
187
+ """Terminate the dev server for the project."""
188
+ entry = _servers.pop(project_id, None)
189
+ if entry is None:
190
+ return False
191
+ proc: subprocess.Popen = entry["process"]
192
+ try:
193
+ # Kill the whole process group — npm spawns child processes
194
+ # (next.js, vite, etc.) that need to die with it.
195
+ try:
196
+ os.killpg(os.getpgid(proc.pid), signal.SIGTERM)
197
+ except (ProcessLookupError, PermissionError):
198
+ pass
199
+ try:
200
+ proc.wait(timeout=5)
201
+ except subprocess.TimeoutExpired:
202
+ try:
203
+ os.killpg(os.getpgid(proc.pid), signal.SIGKILL)
204
+ except (ProcessLookupError, PermissionError):
205
+ pass
206
+ except Exception as e:
207
+ logger.warning(f"stop dev server failed: {e}")
208
+ return True
209
+
210
+
211
+ def stop_all() -> int:
212
+ """Shut down every running dev server. Called by api.server's shutdown
213
+ hook so we don't orphan dev processes when cognos-serve restarts."""
214
+ n = 0
215
+ for pid in list(_servers):
216
+ if stop(pid):
217
+ n += 1
218
+ return n
219
+
220
+
221
+ __all__ = ["start", "stop", "stop_all", "status"]