symphony-code 0.0.1__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 (169) hide show
  1. coding_agent/__init__.py +20 -0
  2. coding_agent/_lazy.py +17 -0
  3. coding_agent/addons/__init__.py +35 -0
  4. coding_agent/addons/approvals.py +224 -0
  5. coding_agent/addons/compaction/__init__.py +36 -0
  6. coding_agent/addons/compaction/addon.py +88 -0
  7. coding_agent/addons/compaction/compactor.py +138 -0
  8. coding_agent/addons/compaction/prompts.py +20 -0
  9. coding_agent/addons/compaction/transcript.py +118 -0
  10. coding_agent/addons/evaluation/__init__.py +68 -0
  11. coding_agent/addons/evaluation/addon.py +199 -0
  12. coding_agent/addons/evaluation/evaluators.py +245 -0
  13. coding_agent/addons/evaluation/handlers.py +66 -0
  14. coding_agent/addons/evaluation/policy.py +277 -0
  15. coding_agent/addons/evaluation/protocol.py +192 -0
  16. coding_agent/addons/evaluation/state.py +284 -0
  17. coding_agent/addons/langfuse/__init__.py +24 -0
  18. coding_agent/addons/langfuse/addon.py +465 -0
  19. coding_agent/addons/langfuse/install.py +79 -0
  20. coding_agent/addons/langfuse/serialize.py +158 -0
  21. coding_agent/addons/learning/__init__.py +33 -0
  22. coding_agent/addons/learning/addon.py +181 -0
  23. coding_agent/addons/learning/loop.py +247 -0
  24. coding_agent/addons/learning/prompts.py +26 -0
  25. coding_agent/addons/learning/sanitize.py +64 -0
  26. coding_agent/addons/learning/store.py +522 -0
  27. coding_agent/addons/manager/__init__.py +18 -0
  28. coding_agent/addons/manager/bridge.py +74 -0
  29. coding_agent/addons/manager/discovery.py +34 -0
  30. coding_agent/addons/manager/report.py +33 -0
  31. coding_agent/addons/manager/runtime.py +76 -0
  32. coding_agent/addons/manager/tool_changes.py +16 -0
  33. coding_agent/addons/persistence/__init__.py +41 -0
  34. coding_agent/addons/persistence/active.py +132 -0
  35. coding_agent/addons/persistence/artifacts.py +110 -0
  36. coding_agent/addons/persistence/collection.py +166 -0
  37. coding_agent/addons/persistence/compatibility.py +30 -0
  38. coding_agent/addons/persistence/images.py +133 -0
  39. coding_agent/addons/persistence/jsonl.py +1195 -0
  40. coding_agent/addons/persistence/presentation.py +104 -0
  41. coding_agent/addons/plugins/__init__.py +19 -0
  42. coding_agent/addons/plugins/install.py +37 -0
  43. coding_agent/addons/plugins/manager.py +140 -0
  44. coding_agent/addons/plugins/models.py +50 -0
  45. coding_agent/addons/skills/__init__.py +28 -0
  46. coding_agent/addons/skills/addon.py +26 -0
  47. coding_agent/addons/skills/args.py +78 -0
  48. coding_agent/addons/skills/bundled_skills/addon-authoring/SKILL.md +77 -0
  49. coding_agent/addons/skills/bundled_skills/context-budget/SKILL.md +12 -0
  50. coding_agent/addons/skills/bundled_skills/orient-first/SKILL.md +16 -0
  51. coding_agent/addons/skills/bundled_skills/security-boundary/SKILL.md +16 -0
  52. coding_agent/addons/skills/bundled_skills/skill-discipline/SKILL.md +13 -0
  53. coding_agent/addons/skills/bundled_skills/surgical-edit/SKILL.md +11 -0
  54. coding_agent/addons/skills/bundled_skills/systematic-debug/SKILL.md +18 -0
  55. coding_agent/addons/skills/bundled_skills/verify-before-done/SKILL.md +11 -0
  56. coding_agent/addons/skills/frontmatter.py +57 -0
  57. coding_agent/addons/skills/models.py +57 -0
  58. coding_agent/addons/skills/registry.py +109 -0
  59. coding_agent/addons/skills/tool.py +54 -0
  60. coding_agent/addons/voice/__init__.py +5 -0
  61. coding_agent/addons/voice/addon.py +106 -0
  62. coding_agent/addons/voice/credentials.py +42 -0
  63. coding_agent/addons/voice/playback.py +42 -0
  64. coding_agent/addons/voice/speak.py +287 -0
  65. coding_agent/addons/voice/stream.py +651 -0
  66. coding_agent/addons/voice/summary.py +126 -0
  67. coding_agent/agent.py +523 -0
  68. coding_agent/bench/__init__.py +1 -0
  69. coding_agent/bench/cli.py +200 -0
  70. coding_agent/bench/patch.py +78 -0
  71. coding_agent/commands/__init__.py +5 -0
  72. coding_agent/commands/catalog.py +179 -0
  73. coding_agent/commands/jev_argument.py +40 -0
  74. coding_agent/config/__init__.py +51 -0
  75. coding_agent/config/config.py +376 -0
  76. coding_agent/config/credentials.py +153 -0
  77. coding_agent/config/personalities.json +34 -0
  78. coding_agent/config/personalities.py +170 -0
  79. coding_agent/config/prompts.py +67 -0
  80. coding_agent/protocols/__init__.py +5 -0
  81. coding_agent/protocols/acp/README.md +36 -0
  82. coding_agent/protocols/acp/__init__.py +27 -0
  83. coding_agent/protocols/acp/mapping.py +164 -0
  84. coding_agent/protocols/acp/server.py +261 -0
  85. coding_agent/protocols/acp/transport.py +72 -0
  86. coding_agent/protocols/stdio/README.md +36 -0
  87. coding_agent/protocols/stdio/__init__.py +37 -0
  88. coding_agent/protocols/stdio/catalog.py +49 -0
  89. coding_agent/protocols/stdio/commands.py +218 -0
  90. coding_agent/protocols/stdio/server.py +181 -0
  91. coding_agent/protocols/stdio/transport.py +71 -0
  92. coding_agent/py.typed +0 -0
  93. coding_agent/resources/__init__.py +5 -0
  94. coding_agent/resources/usage.py +264 -0
  95. coding_agent/run/__init__.py +1 -0
  96. coding_agent/run/cli.py +99 -0
  97. coding_agent/run/detach.py +37 -0
  98. coding_agent/run/interrupted.py +67 -0
  99. coding_agent/run/log_sink.py +46 -0
  100. coding_agent/tools/__init__.py +73 -0
  101. coding_agent/tools/ask_user.py +61 -0
  102. coding_agent/tools/base.py +136 -0
  103. coding_agent/tools/bash.py +288 -0
  104. coding_agent/tools/bash_jobs.py +116 -0
  105. coding_agent/tools/generate_image.py +123 -0
  106. coding_agent/tools/memory.py +100 -0
  107. coding_agent/tools/patch.py +218 -0
  108. coding_agent/tools/read_file.py +167 -0
  109. coding_agent/tools/search.py +433 -0
  110. coding_agent/tools/web_search.py +151 -0
  111. coding_agent/tools/write_file.py +40 -0
  112. coding_agent/tui/EFFICIENCY_PLAN.md +25 -0
  113. coding_agent/tui/__init__.py +22 -0
  114. coding_agent/tui/__main__.py +122 -0
  115. coding_agent/tui/app.py +456 -0
  116. coding_agent/tui/chrome/__init__.py +33 -0
  117. coding_agent/tui/chrome/footer.py +274 -0
  118. coding_agent/tui/chrome/topbar.py +259 -0
  119. coding_agent/tui/commands/__init__.py +45 -0
  120. coding_agent/tui/commands/catalog.py +35 -0
  121. coding_agent/tui/commands/manager.py +557 -0
  122. coding_agent/tui/composer/__init__.py +18 -0
  123. coding_agent/tui/composer/input.py +219 -0
  124. coding_agent/tui/composer/slash_menu.py +341 -0
  125. coding_agent/tui/composer/surface.py +330 -0
  126. coding_agent/tui/composer/voice.py +267 -0
  127. coding_agent/tui/composer/voice_mode.py +491 -0
  128. coding_agent/tui/runtime/__init__.py +23 -0
  129. coding_agent/tui/runtime/events.py +728 -0
  130. coding_agent/tui/runtime/sink.py +115 -0
  131. coding_agent/tui/runtime/state.py +145 -0
  132. coding_agent/tui/runtime/subagent.py +531 -0
  133. coding_agent/tui/runtime/turn.py +206 -0
  134. coding_agent/tui/screens/__init__.py +54 -0
  135. coding_agent/tui/screens/ask.py +69 -0
  136. coding_agent/tui/screens/context.py +195 -0
  137. coding_agent/tui/screens/dashboard.py +91 -0
  138. coding_agent/tui/screens/diff.py +302 -0
  139. coding_agent/tui/screens/extensions.py +68 -0
  140. coding_agent/tui/screens/file_selector.py +167 -0
  141. coding_agent/tui/screens/history.py +458 -0
  142. coding_agent/tui/screens/langfuse.py +142 -0
  143. coding_agent/tui/screens/learning.py +113 -0
  144. coding_agent/tui/screens/modal.py +131 -0
  145. coding_agent/tui/screens/onboard.py +455 -0
  146. coding_agent/tui/screens/resume.py +163 -0
  147. coding_agent/tui/theme/__init__.py +49 -0
  148. coding_agent/tui/theme/load.py +325 -0
  149. coding_agent/tui/theme/style.py +95 -0
  150. coding_agent/tui/theme/theme.toml +1687 -0
  151. coding_agent/tui/tools/__init__.py +53 -0
  152. coding_agent/tui/tools/activity.py +184 -0
  153. coding_agent/tui/tools/calls.py +560 -0
  154. coding_agent/tui/tools/code_view.py +52 -0
  155. coding_agent/tui/tools/images.py +250 -0
  156. coding_agent/tui/tools/labels.py +135 -0
  157. coding_agent/tui/tools/modal.py +137 -0
  158. coding_agent/tui/tools/partial_args.py +19 -0
  159. coding_agent/tui/tools/snapshots.py +382 -0
  160. coding_agent/tui/transcript/__init__.py +36 -0
  161. coding_agent/tui/transcript/messages.py +408 -0
  162. coding_agent/tui/transcript/process.py +504 -0
  163. coding_agent/tui/transcript/surface.py +345 -0
  164. coding_agent/tui/transcript/thinking_markdown.py +314 -0
  165. symphony_code-0.0.1.dist-info/METADATA +66 -0
  166. symphony_code-0.0.1.dist-info/RECORD +169 -0
  167. symphony_code-0.0.1.dist-info/WHEEL +4 -0
  168. symphony_code-0.0.1.dist-info/entry_points.txt +5 -0
  169. symphony_code-0.0.1.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,181 @@
1
+ """One-turn stdio command loop."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import asyncio
7
+ import json
8
+ import sys
9
+ from pathlib import Path
10
+ from collections.abc import Sequence
11
+ from typing import Any, Mapping
12
+
13
+ from core_harness import Answer, Interrupt, UserInput
14
+
15
+ from coding_agent.commands.catalog import EFFORTS
16
+ from coding_agent.config import ensure_spawn_settings
17
+ from coding_agent.protocols.stdio import commands as command_module
18
+ from coding_agent.protocols.stdio.commands import load_state
19
+ from coding_agent.protocols.stdio.transport import CAPABILITIES, PROTOCOL_VERSION, StdioSink
20
+
21
+ # Look these up on the package so tests can patch ``stdio.write_frame`` and
22
+ # ``stdio.build_agent`` without reaching into the implementation modules.
23
+ def _package() -> Any:
24
+ from coding_agent.protocols import stdio
25
+
26
+ return stdio
27
+
28
+
29
+ def _request_id(command: Mapping[str, Any], kind: str) -> str | None:
30
+ request_id = command.get("request_id")
31
+ if isinstance(request_id, str) and request_id:
32
+ return request_id
33
+ _package().write_frame({"type": "error", "message": f"{kind} requires request_id"})
34
+ return None
35
+
36
+
37
+ async def _run_turn(agent: Any, prompt: str, attachments: Sequence[str], effort: str | None) -> None:
38
+ try:
39
+ if effort is not None:
40
+ if effort not in EFFORTS:
41
+ raise ValueError(f"Unsupported reasoning effort: {effort}")
42
+ agent.harness.reasoning_effort = None if effort == "default" else effort
43
+ stripped = prompt.lstrip()
44
+ parts = stripped[1:].split(None, 1) if stripped.startswith("/") else []
45
+ name = parts[0].lower() if parts else ""
46
+ stdio = _package()
47
+ names = {item["name"] for item in command_module.catalog(extra=agent.user_addons.surface.slash_commands)}
48
+ if name in names:
49
+ if attachments:
50
+ raise ValueError("Slash commands do not accept image attachments")
51
+ result = await command_module.execute(agent, stripped)
52
+ stdio.write_frame({"type": "event", "event": "text_delta", "payload": {"delta": result}})
53
+ else:
54
+ await agent.run(stdio.user_content(prompt, attachments))
55
+ stdio.write_frame({"type": "done", "status": "completed"})
56
+ except asyncio.CancelledError:
57
+ _package().write_frame({"type": "done", "status": "interrupted"})
58
+ except Exception as exc:
59
+ stdio = _package()
60
+ stdio.write_frame({"type": "error", "message": f"{type(exc).__name__}: {exc}"})
61
+ stdio.write_frame({"type": "done", "status": "errored"})
62
+
63
+
64
+ async def serve(
65
+ workspace: Path,
66
+ model: str | None,
67
+ session_id: str | None,
68
+ *,
69
+ unattended: bool = False,
70
+ ) -> int:
71
+ stdio = _package()
72
+ stdio.load_provider_env(workspace)
73
+ sink = StdioSink()
74
+ try:
75
+ agent = stdio.build_agent(
76
+ workspace=workspace,
77
+ model_id=model,
78
+ session_id=session_id,
79
+ sink=sink,
80
+ config=ensure_spawn_settings(workspace, overrides={"unattended": True}) if unattended else None,
81
+ client="zeron",
82
+ )
83
+ load_state(agent)
84
+ except Exception as exc:
85
+ _package().write_frame({"type": "error", "message": f"{type(exc).__name__}: {exc}"})
86
+ return 1
87
+
88
+ stdio.write_frame({
89
+ "type": "ready",
90
+ "session_id": agent.session_id,
91
+ "model": getattr(agent.harness, "model_id", model or ""),
92
+ "protocol_version": PROTOCOL_VERSION,
93
+ "capabilities": CAPABILITIES,
94
+ })
95
+ run: asyncio.Task[Any] | None = None
96
+ while True:
97
+ line = await asyncio.to_thread(sys.stdin.readline)
98
+ if not line:
99
+ if run is not None and not run.done():
100
+ run.cancel()
101
+ await asyncio.gather(run, return_exceptions=True)
102
+ return 0
103
+ try:
104
+ command = json.loads(line)
105
+ except json.JSONDecodeError:
106
+ stdio.write_frame({"type": "error", "message": "invalid JSON command"})
107
+ continue
108
+ if not isinstance(command, dict):
109
+ stdio.write_frame({"type": "error", "message": "invalid command: not an object"})
110
+ continue
111
+ kind = command.get("type")
112
+ if kind == "model/list":
113
+ request_id = _request_id(command, kind)
114
+ if request_id is None:
115
+ continue
116
+ try:
117
+ stdio.write_frame(stdio.model_catalog(agent.registry, request_id))
118
+ except Exception as exc:
119
+ stdio.write_frame({
120
+ "type": "error", "request_id": request_id, "message": f"{type(exc).__name__}: {exc}",
121
+ })
122
+ elif kind == "command/list":
123
+ request_id = _request_id(command, kind)
124
+ if request_id is None:
125
+ continue
126
+ try:
127
+ stdio.write_frame({
128
+ "type": "commands",
129
+ "request_id": request_id,
130
+ "commands": command_module.catalog(workspace, agent.user_addons.surface.slash_commands),
131
+ "protocol_version": PROTOCOL_VERSION,
132
+ })
133
+ except Exception as exc:
134
+ stdio.write_frame({
135
+ "type": "error", "request_id": request_id, "message": f"{type(exc).__name__}: {exc}",
136
+ })
137
+ elif kind == "answer":
138
+ agent.harness.submit(Answer(str(command.get("request_id") or ""), str(command.get("value", ""))))
139
+ elif kind == "interrupt":
140
+ # A slash command runs outside the harness; cancel its task instead.
141
+ if run is not None and not run.done() and not agent.harness.submit(Interrupt()):
142
+ run.cancel()
143
+ elif kind == "steer":
144
+ prompt = command.get("prompt")
145
+ if not isinstance(prompt, str) or not prompt.strip():
146
+ stdio.write_frame({"type": "error", "message": "prompt is required"})
147
+ elif not agent.harness.submit(UserInput(prompt)):
148
+ stdio.write_frame({"type": "error", "message": "steer requires an active run"})
149
+ elif kind == "run" and (run is None or run.done()):
150
+ prompt = command.get("prompt")
151
+ if not isinstance(prompt, str) or not prompt.strip():
152
+ stdio.write_frame({"type": "error", "message": "prompt is required"})
153
+ continue
154
+ paths = command.get("attachments") or []
155
+ if not isinstance(paths, list) or not all(isinstance(path, str) for path in paths):
156
+ stdio.write_frame({"type": "error", "message": "attachments must be a list of paths"})
157
+ continue
158
+ effort = command.get("reasoning_effort")
159
+ run = asyncio.create_task(_run_turn(agent, prompt, paths, effort if isinstance(effort, str) else None))
160
+ else:
161
+ stdio.write_frame({"type": "error", "message": f"invalid command: {kind}"})
162
+
163
+
164
+ def main(argv: Sequence[str]) -> int:
165
+ parser = argparse.ArgumentParser(prog="symphony stdio")
166
+ parser.add_argument("--workspace", default=".")
167
+ parser.add_argument("--model")
168
+ parser.add_argument("--session-id")
169
+ parser.add_argument("--unattended", action="store_true", help="Auto-approve tools subject to Symphony deny rules")
170
+ args = parser.parse_args(argv)
171
+ # Libraries may print diagnostics; stdout belongs exclusively to JSONL.
172
+ sys.stdout = sys.stderr
173
+ return asyncio.run(serve(
174
+ Path(args.workspace).expanduser().resolve(),
175
+ args.model,
176
+ args.session_id,
177
+ unattended=args.unattended,
178
+ ))
179
+
180
+
181
+ __all__ = ["main", "serve"]
@@ -0,0 +1,71 @@
1
+ """JSONL frames and the event sink that writes them."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import sys
7
+ from pathlib import Path
8
+ from typing import Any, Dict, Mapping, Sequence
9
+
10
+ from core_ai.content import image_part_from_bytes, sniff_image_media_type
11
+ from core_ai.types import Content
12
+ from core_harness import EventSink, HumanInteraction
13
+
14
+
15
+ def _write(frame: Mapping[str, Any]) -> None:
16
+ """Write through the package so ``stdio.write_frame`` stays patchable."""
17
+ from coding_agent.protocols import stdio
18
+
19
+ stdio.write_frame(frame)
20
+
21
+ PROTOCOL_VERSION = 2
22
+ CAPABILITIES = ["runs", "resume", "interrupt", "input", "steer", "models", "images", "subagents", "commands"]
23
+
24
+
25
+ def write_frame(frame: Mapping[str, Any]) -> None:
26
+ sys.__stdout__.write(json.dumps(frame, default=str, ensure_ascii=False) + "\n")
27
+ sys.__stdout__.flush()
28
+
29
+
30
+ def user_content(prompt: str, attachments: Sequence[str]) -> Content:
31
+ """Inline staged image bytes using the same canonical parts as the TUI."""
32
+ if not attachments:
33
+ return prompt
34
+ if len(attachments) > 8:
35
+ raise ValueError("at most eight images can be attached")
36
+ parts: list[dict[str, Any]] = [{"type": "text", "text": prompt}]
37
+ for raw in attachments:
38
+ path = Path(raw).expanduser().resolve(strict=True)
39
+ if not path.is_file() or path.stat().st_size > 20 * 1024 * 1024:
40
+ raise ValueError("attachment must be a regular image under 20 MiB")
41
+ payload = path.read_bytes()
42
+ media = sniff_image_media_type(payload, filename=path.name)
43
+ if media is None:
44
+ raise ValueError(f"unsupported image: {path.name}")
45
+ parts.append(image_part_from_bytes(payload, media_type=media, filename=path.name))
46
+ return parts
47
+
48
+
49
+ class StdioSink(EventSink):
50
+ """Write each event as a frame.
51
+
52
+ A question is the ``question_asked`` event like in every other host.
53
+ Protocol 2 hosts read ``input_requested``, so that frame follows it.
54
+ """
55
+
56
+ def __init__(self) -> None:
57
+ super().__init__()
58
+ self.interaction = HumanInteraction(self)
59
+
60
+ async def deliver(self, event_type: str, payload: Dict[str, Any]) -> None:
61
+ _write({"type": "event", "event": event_type, "payload": payload})
62
+ if event_type == "question_asked":
63
+ known = {"request_id", "question", "choices", "default", "kind"}
64
+ _write({
65
+ "type": "input_requested",
66
+ **{key: payload.get(key) for key in ("request_id", "question", "choices", "default", "kind")},
67
+ "metadata": {key: value for key, value in payload.items() if key not in known},
68
+ })
69
+
70
+
71
+ __all__ = ["CAPABILITIES", "PROTOCOL_VERSION", "StdioSink", "user_content", "write_frame"]
coding_agent/py.typed ADDED
File without changes
@@ -0,0 +1,5 @@
1
+ """Process resource snapshots used by the agent dashboard."""
2
+
3
+ from coding_agent.resources.usage import AgentUsage, process_alive, sample_usage
4
+
5
+ __all__ = ["AgentUsage", "process_alive", "sample_usage"]
@@ -0,0 +1,264 @@
1
+ """CPU, memory, and disk snapshots for a live agent process.
2
+
3
+ The helpers stay in the standard library so the dashboard works on macOS,
4
+ Linux, and Windows without an extra dependency. Each OS reads the numbers it
5
+ publishes and leaves the rest unknown rather than guessing.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import os
11
+ import sys
12
+ import time
13
+ from dataclasses import dataclass
14
+ from pathlib import Path
15
+
16
+
17
+ @dataclass(frozen=True)
18
+ class AgentUsage:
19
+ """One sample of a process. ``None`` means the OS did not report it."""
20
+
21
+ pid: int
22
+ alive: bool
23
+ cpu_percent: float | None = None
24
+ rss_bytes: int | None = None
25
+ disk_bytes: int | None = None
26
+
27
+ def line(self) -> str:
28
+ """Single-row summary: ``cpu 12% ram 48.0 MB disk 1.2 KB``."""
29
+ return (
30
+ f"cpu {_percent(self.cpu_percent):>4} "
31
+ f"ram {_bytes(self.rss_bytes):>8} "
32
+ f"disk {_bytes(self.disk_bytes):>8}"
33
+ )
34
+
35
+
36
+ def _percent(value: float | None) -> str:
37
+ if value is None:
38
+ return "n/a"
39
+ return f"{value:.0f}%"
40
+
41
+
42
+ def _bytes(value: int | None) -> str:
43
+ if value is None:
44
+ return "n/a"
45
+ size = float(value)
46
+ for unit in ("B", "KB", "MB", "GB", "TB"):
47
+ if size < 1024 or unit == "TB":
48
+ return f"{size:.0f} {unit}" if unit == "B" else f"{size:.1f} {unit}"
49
+ size /= 1024
50
+ return f"{size:.1f} TB"
51
+
52
+
53
+ def process_alive(pid: int) -> bool:
54
+ """True when ``pid`` is still a process. ``pid`` 0 and 1 are never ours."""
55
+ if pid <= 1:
56
+ return False
57
+ if sys.platform == "win32":
58
+ return _windows_alive(pid)
59
+ try:
60
+ os.kill(pid, 0)
61
+ except ProcessLookupError:
62
+ return False
63
+ except PermissionError:
64
+ return True
65
+ except OSError:
66
+ return False
67
+ return True
68
+
69
+
70
+ def _windows_alive(pid: int) -> bool:
71
+ import ctypes
72
+
73
+ process = ctypes.windll.kernel32.OpenProcess(0x1000, False, pid) # PROCESS_QUERY_LIMITED_INFORMATION
74
+ if not process:
75
+ return False
76
+ ctypes.windll.kernel32.CloseHandle(process)
77
+ return True
78
+
79
+
80
+ def sample_usage(
81
+ pid: int,
82
+ *,
83
+ workspace: str | Path | None = None,
84
+ previous_cpu_seconds: float | None = None,
85
+ previous_at: float | None = None,
86
+ ) -> tuple[AgentUsage, float | None, float]:
87
+ """Sample one process and return ``(usage, cpu_seconds, sampled_at)``.
88
+
89
+ CPU percent needs two samples. The first call returns ``cpu_percent=None``
90
+ and a ``cpu_seconds`` value the caller should pass back next time.
91
+ """
92
+ sampled_at = time.monotonic()
93
+ if not process_alive(pid):
94
+ return AgentUsage(pid=pid, alive=False), None, sampled_at
95
+ cpu_seconds, rss = _process_counters(pid)
96
+ cpu_percent = _cpu_percent(cpu_seconds, previous_cpu_seconds, sampled_at, previous_at)
97
+ disk = directory_size(workspace) if workspace else None
98
+ return (
99
+ AgentUsage(pid=pid, alive=True, cpu_percent=cpu_percent, rss_bytes=rss, disk_bytes=disk),
100
+ cpu_seconds,
101
+ sampled_at,
102
+ )
103
+
104
+
105
+ def _cpu_percent(
106
+ cpu_seconds: float | None,
107
+ previous_cpu_seconds: float | None,
108
+ sampled_at: float,
109
+ previous_at: float | None,
110
+ ) -> float | None:
111
+ if (
112
+ cpu_seconds is None
113
+ or previous_cpu_seconds is None
114
+ or previous_at is None
115
+ or sampled_at <= previous_at
116
+ ):
117
+ return None
118
+ elapsed = sampled_at - previous_at
119
+ cores = os.cpu_count() or 1
120
+ used = max(0.0, cpu_seconds - previous_cpu_seconds)
121
+ return min(100.0 * cores, (used / elapsed) * 100.0)
122
+
123
+
124
+ def directory_size(path: str | Path | None) -> int | None:
125
+ """Bytes used by ``path``. A file uses its size; a directory is walked."""
126
+ if path is None:
127
+ return None
128
+ target = Path(path)
129
+ try:
130
+ if target.is_file():
131
+ return target.stat().st_size
132
+ if not target.is_dir():
133
+ return None
134
+ total = 0
135
+ for root, _dirs, files in os.walk(target):
136
+ for name in files:
137
+ try:
138
+ total += (Path(root) / name).stat().st_size
139
+ except OSError:
140
+ continue
141
+ return total
142
+ except OSError:
143
+ return None
144
+
145
+
146
+ def _process_counters(pid: int) -> tuple[float | None, int | None]:
147
+ if sys.platform == "linux":
148
+ return _linux_counters(pid)
149
+ if sys.platform == "darwin":
150
+ return _darwin_counters(pid)
151
+ if sys.platform == "win32":
152
+ return _windows_counters(pid)
153
+ return None, None
154
+
155
+
156
+ def _linux_counters(pid: int) -> tuple[float | None, int | None]:
157
+ try:
158
+ text = Path(f"/proc/{pid}/stat").read_text(encoding="utf-8")
159
+ except OSError:
160
+ return None, None
161
+ # comm can contain spaces and parentheses; fields after the last ")".
162
+ tail = text.rsplit(")", 1)[-1].split()
163
+ cpu_seconds = None
164
+ if len(tail) >= 13:
165
+ # utime and stime are fields 14 and 15 of stat, indexes 11 and 12 here.
166
+ ticks = os.sysconf("SC_CLK_TCK") or 100
167
+ cpu_seconds = (int(tail[11]) + int(tail[12])) / ticks
168
+ rss = None
169
+ if len(tail) >= 22:
170
+ rss = int(tail[21]) * (os.sysconf("SC_PAGE_SIZE") or 4096)
171
+ return cpu_seconds, rss
172
+
173
+
174
+ def _darwin_counters(pid: int) -> tuple[float | None, int | None]:
175
+ import subprocess
176
+
177
+ try:
178
+ completed = subprocess.run(
179
+ ["ps", "-o", "time=,rss=", "-p", str(pid)],
180
+ check=False,
181
+ capture_output=True,
182
+ text=True,
183
+ timeout=2,
184
+ )
185
+ except (OSError, subprocess.TimeoutExpired):
186
+ return None, None
187
+ parts = completed.stdout.split()
188
+ if len(parts) < 2:
189
+ return None, None
190
+ return _parse_ps_time(parts[0]), int(parts[1]) * 1024
191
+
192
+
193
+ def _parse_ps_time(value: str) -> float | None:
194
+ """Parse ``ps`` TIME, which is ``[[dd-]hh:]mm:ss``."""
195
+ days = 0
196
+ clock = value
197
+ if "-" in value:
198
+ day_text, clock = value.split("-", 1)
199
+ try:
200
+ days = int(day_text)
201
+ except ValueError:
202
+ return None
203
+ pieces = clock.split(":")
204
+ try:
205
+ numbers = [int(piece) for piece in pieces]
206
+ except ValueError:
207
+ return None
208
+ if len(numbers) == 2:
209
+ minutes, seconds = numbers
210
+ hours = 0
211
+ elif len(numbers) == 3:
212
+ hours, minutes, seconds = numbers
213
+ else:
214
+ return None
215
+ return ((days * 24 + hours) * 60 + minutes) * 60 + seconds
216
+
217
+
218
+ def _windows_counters(pid: int) -> tuple[float | None, int | None]:
219
+ import ctypes
220
+ from ctypes import wintypes
221
+
222
+ class ProcessMemoryCounters(ctypes.Structure):
223
+ _fields_ = [
224
+ ("cb", wintypes.DWORD),
225
+ ("PageFaultCount", wintypes.DWORD),
226
+ ("PeakWorkingSetSize", ctypes.c_size_t),
227
+ ("WorkingSetSize", ctypes.c_size_t),
228
+ ("QuotaPeakPagedPoolUsage", ctypes.c_size_t),
229
+ ("QuotaPagedPoolUsage", ctypes.c_size_t),
230
+ ("QuotaPeakNonPagedPoolUsage", ctypes.c_size_t),
231
+ ("QuotaNonPagedPoolUsage", ctypes.c_size_t),
232
+ ("PagefileUsage", ctypes.c_size_t),
233
+ ("PeakPagefileUsage", ctypes.c_size_t),
234
+ ]
235
+
236
+ kernel = ctypes.windll.kernel32
237
+ process = kernel.OpenProcess(0x1000 | 0x0400, False, pid)
238
+ if not process:
239
+ return None, None
240
+ rss = None
241
+ counters = ProcessMemoryCounters()
242
+ counters.cb = ctypes.sizeof(counters)
243
+ psapi = ctypes.windll.psapi
244
+ if psapi.GetProcessMemoryInfo(process, ctypes.byref(counters), counters.cb):
245
+ rss = int(counters.WorkingSetSize)
246
+ cpu_seconds = None
247
+ creation = wintypes.FILETIME()
248
+ exit_time = wintypes.FILETIME()
249
+ kernel_time = wintypes.FILETIME()
250
+ user_time = wintypes.FILETIME()
251
+ if kernel.GetProcessTimes(
252
+ process,
253
+ ctypes.byref(creation),
254
+ ctypes.byref(exit_time),
255
+ ctypes.byref(kernel_time),
256
+ ctypes.byref(user_time),
257
+ ):
258
+ cpu_seconds = (_filetime(kernel_time) + _filetime(user_time)) / 10_000_000
259
+ kernel.CloseHandle(process)
260
+ return cpu_seconds, rss
261
+
262
+
263
+ def _filetime(value: object) -> int:
264
+ return (int(value.dwHighDateTime) << 32) + int(value.dwLowDateTime) # type: ignore[attr-defined]
@@ -0,0 +1 @@
1
+ """Headless unattended runs: ``symphony run``, ``--detach``, and ``--resume --continue``."""
@@ -0,0 +1,99 @@
1
+ """``symphony run --unattended [--detach] "<task>"`` and ``symphony --resume --continue``."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import asyncio
7
+ import sys
8
+ from pathlib import Path
9
+ from typing import Optional, Sequence
10
+
11
+ from core_ai import has_configured_provider
12
+
13
+ from coding_agent.agent import build_agent
14
+ from coding_agent.config import ensure_spawn_settings
15
+ from coding_agent.config.credentials import load_provider_env
16
+ from coding_agent.addons.persistence import JsonlPersistence, register_active, release_active, sessions_dir
17
+ from coding_agent.resources.usage import process_alive
18
+ from coding_agent.run.detach import detach
19
+ from coding_agent.run.interrupted import detached_run_pid, interrupted_session, resume_note, stop_orphaned_jobs
20
+ from coding_agent.run.log_sink import LogLineSink
21
+
22
+
23
+ def build_parser() -> argparse.ArgumentParser:
24
+ parser = argparse.ArgumentParser(prog="symphony run", description="Run one task headless, with no human.")
25
+ parser.add_argument("task", help="Task prompt")
26
+ parser.add_argument(
27
+ "--unattended",
28
+ action="store_true",
29
+ required=True,
30
+ help="Required: auto-approve tools (approvals.deny still applies) and auto-answer ask_user",
31
+ )
32
+ parser.add_argument("--detach", action="store_true", help="Run in the background; print pid, log, session id")
33
+ parser.add_argument("--model", default=None, help="provider:model id")
34
+ parser.add_argument("--workspace", default=".", help="Working directory (default: .)")
35
+ parser.add_argument("--session-id", default=None, help="Session id to write (default: new)")
36
+ return parser
37
+
38
+
39
+ def run_task(workspace: Path, task: str, *, model: Optional[str] = None, session_id: Optional[str] = None) -> int:
40
+ """Headless unattended run with JSONL persistence and the user's ~/.symphony config and rules."""
41
+ load_provider_env(workspace)
42
+ if not has_configured_provider():
43
+ print("error: no provider credentials configured", file=sys.stderr)
44
+ return 1
45
+ agent = build_agent(
46
+ workspace=workspace,
47
+ sink=LogLineSink(),
48
+ model_id=model,
49
+ session_id=session_id,
50
+ config=ensure_spawn_settings(workspace, overrides={"unattended": True}),
51
+ client="run",
52
+ )
53
+ print(f"session {agent.session_id}", flush=True)
54
+ register_active(
55
+ agent.session_id,
56
+ workspace=workspace,
57
+ kind="run",
58
+ model_id=getattr(agent.harness, "model_id", "") or (model or ""),
59
+ label=task,
60
+ )
61
+
62
+ async def _run() -> str:
63
+ result = await agent.run(task)
64
+ await agent.wait_for_learning()
65
+ return result.output_text
66
+
67
+ try:
68
+ print(asyncio.run(_run()), flush=True)
69
+ except Exception as exc:
70
+ print(f"run failed: {type(exc).__name__}: {exc}", file=sys.stderr, flush=True)
71
+ return 1
72
+ finally:
73
+ release_active()
74
+ return 0
75
+
76
+
77
+ def continue_interrupted(workspace: Path, *, model: Optional[str] = None) -> int:
78
+ persistence = JsonlPersistence(sessions_dir(workspace))
79
+ found = asyncio.run(interrupted_session(persistence))
80
+ if found is None:
81
+ print("nothing to continue: the most recent session was not interrupted mid-run")
82
+ return 0
83
+ summary, checkpoint = found
84
+ pid = detached_run_pid(persistence, summary.session_id)
85
+ if pid is not None and process_alive(pid):
86
+ print(f"refusing to continue: detached run pid {pid} is still alive", file=sys.stderr)
87
+ return 1
88
+ stopped = stop_orphaned_jobs(checkpoint.metadata.get("background_jobs"))
89
+ print(f"continuing interrupted session {summary.session_id} unattended", flush=True)
90
+ return run_task(workspace, resume_note(summary, checkpoint, stopped), model=model, session_id=summary.session_id)
91
+
92
+
93
+ def main(argv: Sequence[str]) -> int:
94
+ argv = list(argv)
95
+ args = build_parser().parse_args(argv)
96
+ workspace = Path(args.workspace).expanduser().resolve()
97
+ if args.detach:
98
+ return detach([arg for arg in argv if arg != "--detach"], workspace)
99
+ return run_task(workspace, args.task, model=args.model, session_id=args.session_id)
@@ -0,0 +1,37 @@
1
+ """Start ``symphony run`` in its own session and return at once (no daemon manager)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import subprocess
7
+ import sys
8
+ import uuid
9
+ from datetime import datetime, timezone
10
+ from pathlib import Path
11
+
12
+ from coding_agent.addons.persistence import JsonlPersistence, sessions_dir
13
+ from coding_agent.addons.persistence.artifacts import publish
14
+
15
+
16
+ def detach(run_argv: list[str], workspace: Path) -> int:
17
+ """Re-exec ``run`` minus ``--detach`` with a pre-generated session id; write ``<sid>/run.json``."""
18
+ session_id = str(uuid.uuid4())
19
+ root = JsonlPersistence(sessions_dir(workspace)).session_dir(session_id)
20
+ root.mkdir(parents=True, exist_ok=True)
21
+ log_path = root / "run.log"
22
+ command = [sys.executable, "-m", "coding_agent.tui", "run", *run_argv, "--session-id", session_id]
23
+ with log_path.open("ab") as log:
24
+ proc = subprocess.Popen(
25
+ command, start_new_session=True, stdin=subprocess.DEVNULL, stdout=log, stderr=subprocess.STDOUT
26
+ )
27
+ info = {
28
+ "pid": proc.pid,
29
+ "session_id": session_id,
30
+ "log": str(log_path),
31
+ "workspace": str(workspace),
32
+ "argv": command,
33
+ "started_at": datetime.now(timezone.utc).isoformat(),
34
+ }
35
+ publish(root / "run.json", (json.dumps(info, indent=2) + "\n").encode("utf-8"))
36
+ print(f"pid {proc.pid}\nlog {log_path}\nsession {session_id}")
37
+ return 0