workstreams-cli 0.5.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.
@@ -0,0 +1,32 @@
1
+ """Terminal multiplexer implementations."""
2
+ """Terminal multiplexer implementations."""
3
+
4
+ from .base import MultiplexerBase
5
+ from .tmux import TmuxMultiplexer
6
+ from .zellij import ZellijMultiplexer
7
+ from .tmux_compatible import TmuxCompatibleMultiplexer
8
+
9
+ __all__ = ["MultiplexerBase", "TmuxMultiplexer", "ZellijMultiplexer", "get_multiplexer"]
10
+
11
+ _TMUX_COMPATIBLE = {
12
+ "nami": "nami",
13
+ "lmux": "lmux",
14
+ "wmux": "wmux",
15
+ "herdr": "herdr",
16
+ }
17
+
18
+ def get_multiplexer(name: str, config) -> MultiplexerBase:
19
+ """Get a multiplexer instance by name."""
20
+ multiplexers = {
21
+ "tmux": TmuxMultiplexer,
22
+ "zellij": ZellijMultiplexer,
23
+ }
24
+ if name in _TMUX_COMPATIBLE:
25
+ binary = _TMUX_COMPATIBLE[name]
26
+ return TmuxCompatibleMultiplexer(config, binary)
27
+ cls = multiplexers.get(name)
28
+ if not cls:
29
+ raise ValueError(
30
+ f"Unknown multiplexer: {name}. Supported: {', '.join(multiplexers)} plus tmux-compatible: {', '.join(_TMUX_COMPATIBLE)}"
31
+ )
32
+ return cls(config)
@@ -0,0 +1,44 @@
1
+ """Base multiplexer interface."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from abc import ABC, abstractmethod
6
+ from typing import Any, Dict, List, Optional
7
+
8
+
9
+ class MultiplexerBase(ABC):
10
+ """Base class for terminal multiplexers."""
11
+
12
+ #: Multiplexer name (used in logs / status)
13
+ name = "base"
14
+
15
+ def __init__(self, config: Any):
16
+ self.config = config
17
+
18
+ @abstractmethod
19
+ def start(self, workstreams: List[Dict[str, Any]], command: Optional[str] = None) -> None:
20
+ """Start workstreams in multiplexer."""
21
+
22
+ @abstractmethod
23
+ def attach(self, session: str) -> None:
24
+ """Attach to existing session (blocks until user exits)."""
25
+
26
+ @abstractmethod
27
+ def send_command(self, workstream_id: int, command: str) -> bool:
28
+ """Send a command to the workstream's pane/tab."""
29
+
30
+ @abstractmethod
31
+ def session_name(self) -> str:
32
+ """Return the session name used by start()."""
33
+
34
+ @abstractmethod
35
+ def pane_target(self, workstream_id: int) -> str:
36
+ """Return the pane target for a workstream (used for send_command)."""
37
+
38
+ def is_available(self) -> bool:
39
+ """Check if the multiplexer binary exists on PATH."""
40
+ import shutil
41
+ return shutil.which(self.binary_name()) is not None
42
+
43
+ def binary_name(self) -> str:
44
+ return self.name
@@ -0,0 +1,202 @@
1
+ """Tmux multiplexer implementation.
2
+
3
+ Addressing model (verified against tmux 3.4):
4
+ - Each workstream gets its own tmux WINDOW named after the workstream
5
+ (`new-window -n <name>`). Windows can be targeted by NAME:
6
+ `<session>:<window-name>`.
7
+ - Each window hosts a persistent `bash -i` so the detached session never
8
+ dies when its startup command exits.
9
+ - In the "tiled" layout all workstreams live in one window as panes and
10
+ are targeted by index: `<session>:1.<pane-index>`.
11
+
12
+ NOTE: `select-pane -T` only sets a *display title* in tmux; it does NOT
13
+ make a pane addressable by that title. Window names (via `new-window -n`
14
+ / `rename-window`) are the reliable target mechanism.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import subprocess
20
+ from typing import Any, Dict, List, Optional
21
+
22
+ from .base import MultiplexerBase
23
+
24
+ # Start a persistent interactive shell in every window/pane so detached
25
+ # sessions stay alive after their first command finishes.
26
+ _PERSIST_SHELL = "exec bash -i"
27
+
28
+
29
+ class TmuxMultiplexer(MultiplexerBase):
30
+ """Tmux-based workstream management."""
31
+
32
+ name = "tmux"
33
+
34
+ def __init__(self, config: Any):
35
+ super().__init__(config)
36
+ self.session = f"workstreams-{config.project}"
37
+
38
+ # -- helpers -----------------------------------------------------------
39
+
40
+ def _tmux(self, *args: str, check: bool = False, capture: bool = True) -> subprocess.CompletedProcess:
41
+ cmd = ["tmux"] + list(args)
42
+ if capture:
43
+ return subprocess.run(cmd, check=check, capture_output=True, text=True)
44
+ return subprocess.run(cmd, check=check, text=True)
45
+
46
+ def _run_quiet(self, *args: str) -> None:
47
+ """Run a tmux command, swallowing non-zero exits (best-effort layout)."""
48
+ self._tmux(*args)
49
+
50
+ def _ensure_session(self, base_dir: str) -> None:
51
+ """Create the session if it does not exist yet."""
52
+ result = self._tmux("has-session", "-t", self.session)
53
+ if result.returncode != 0:
54
+ create = self._tmux(
55
+ "new-session", "-d", "-s", self.session, "-c", base_dir,
56
+ _PERSIST_SHELL,
57
+ )
58
+ if create.returncode != 0:
59
+ raise RuntimeError(
60
+ f"failed to create tmux session '{self.session}': "
61
+ f"{(create.stderr or '').strip() or create.stdout.strip()}"
62
+ )
63
+ # Warm the server / confirm session is up.
64
+ self._tmux("list-windows", "-t", self.session)
65
+
66
+ def _session_alive(self) -> bool:
67
+ return self._tmux("has-session", "-t", self.session).returncode == 0
68
+
69
+ def _window_target(self, window_name: str) -> str:
70
+ """Target a window by its name."""
71
+ return f"{self.session}:{window_name}"
72
+
73
+ def session_name(self) -> str:
74
+ return self.session
75
+
76
+ def pane_target(self, workstream_id: int) -> str:
77
+ """Return the target for a workstream.
78
+
79
+ Tab/window layout: target by window NAME -> `<session>:<ws-name>`.
80
+ Tiled layout: target by pane index -> `<session>:0.<pane-index>`.
81
+ """
82
+ ws = self.config.workstream(workstream_id)
83
+ name = ws.name if ws else f"ws{workstream_id}"
84
+ if self.config.layout in ("tiled", "grid"):
85
+ idx = workstream_id - 1
86
+ return f"{self.session}:0.{idx}"
87
+ # Window-per-workstream: each workstream is a window named after it.
88
+ return self._window_target(name)
89
+
90
+ # -- required interface --------------------------------------------------
91
+
92
+ def start(self, workstreams: List[Dict[str, Any]], command: Optional[str] = None) -> None:
93
+ base_dir = self.config.base_path
94
+ self._ensure_session(base_dir)
95
+
96
+ layout = self.config.layout
97
+ if layout in ("tiled", "grid"):
98
+ self._layout_single_window_panes(workstreams, base_dir)
99
+ else:
100
+ self._layout_tab_windows(workstreams, base_dir)
101
+
102
+ # Send each command to its window/pane.
103
+ for i, ws in enumerate(workstreams):
104
+ target = self._pane_target_for_index(i, layout)
105
+ ws_command = command or ws.get("command") or ""
106
+ if ws_command:
107
+ ws_path = ws.get("path", "")
108
+ pane_env = ws.get("env") or {}
109
+ env_prefix = " ".join(f"{k}={v}" for k, v in pane_env.items())
110
+ cd_part = f"cd {base_dir}/{ws_path} && " if ws_path else ""
111
+ full = f"{cd_part}{env_prefix} {ws_command}".strip()
112
+ self._run_quiet("send-keys", "-t", target, full, "Enter")
113
+
114
+ print(f"Started {len(workstreams)} workstream(s) in tmux session '{self.session}'")
115
+ print(f" Attach: tmux attach -t {self.session}")
116
+ print(f" Or: workstreams attach --multiplexer tmux")
117
+
118
+ # -- layouts --------------------------------------------------------------
119
+
120
+ def _layout_tab_windows(self, workstreams: List[Dict[str, Any]], base_dir: str) -> None:
121
+ """One window per workstream - cleanest for coding agents."""
122
+ first = True
123
+ for i, ws in enumerate(workstreams):
124
+ ws_name = ws.get("name", f"ws{ws.get('id', i + 1)}")
125
+ ws_path = f"{base_dir}/{ws.get('path', '')}"
126
+ if first:
127
+ # Reuse the initial window: rename it to the first workstream.
128
+ # Window indices start at 0. The session was created with a
129
+ # single initial window at index 0, so target ":0".
130
+ self._run_quiet("rename-window", "-t", f"{self.session}:0", ws_name)
131
+ self._run_quiet("send-keys", "-t", f"{self.session}:0", f"cd {ws_path}", "Enter")
132
+ first = False
133
+ else:
134
+ self._run_quiet(
135
+ "new-window", "-t", self.session,
136
+ "-n", ws_name, "-c", ws_path, _PERSIST_SHELL,
137
+ )
138
+
139
+ def _layout_single_window_panes(self, workstreams: List[Dict[str, Any]], base_dir: str) -> None:
140
+ """All workstreams in one window as tiled panes."""
141
+ for i, ws in enumerate(workstreams):
142
+ ws_name = ws.get("name", f"ws{ws.get('id', i + 1)}")
143
+ ws_path = f"{base_dir}/{ws.get('path', '')}"
144
+ if i == 0:
145
+ # First pane already exists; just cd it.
146
+ self._run_quiet("send-keys", "-t", f"{self.session}:0.0", f"cd {ws_path}", "Enter")
147
+ _ = ws_name
148
+ else:
149
+ split = "-h" if i % 2 == 1 else "-v"
150
+ self._run_quiet(
151
+ "split-window", "-t", f"{self.session}:0", split,
152
+ "-c", ws_path, _PERSIST_SHELL,
153
+ )
154
+ self._run_quiet("select-layout", "-t", f"{self.session}:0", "tiled")
155
+
156
+ def _pane_target_for_index(self, i: int, layout: str) -> str:
157
+ """Target the i-th workstream given the layout."""
158
+ if layout in ("tiled", "grid"):
159
+ return f"{self.session}:0.{i}"
160
+ # Window-per-workstream: window NAMES equal the workstream name.
161
+ # Resolve the name from the config so we always hit the right window.
162
+ ws = self.config.workstreams[i] if i < len(self.config.workstreams) else None
163
+ name = ws.name if ws else f"ws{i + 1}"
164
+ return self._window_target(name)
165
+
166
+ # -- lifecycle -------------------------------------------------------------
167
+
168
+ def attach(self, session: Optional[str] = None) -> None:
169
+ target = session or self.session
170
+ result = self._tmux("attach", "-t", target, capture=False, check=False)
171
+ # attach blocks until the user detaches.
172
+ _ = result
173
+
174
+ def send_command(self, workstream_id: int, command: str) -> bool:
175
+ target = self.pane_target(workstream_id)
176
+ if not self._session_alive():
177
+ print(
178
+ f"[workstreams] tmux session '{self.session}' is not running. "
179
+ "Run `workstreams start` first.",
180
+ flush=True,
181
+ )
182
+ return False
183
+ result = self._tmux("send-keys", "-t", target, command, "Enter")
184
+ return result.returncode == 0
185
+
186
+ def capture(self, workstream_id: int, lines: int = 20) -> str:
187
+ """Return the last `lines` of a workstream pane's output."""
188
+ target = self.pane_target(workstream_id)
189
+ result = self._tmux("capture-pane", "-t", target, "-p", "-S", f"-{lines}")
190
+ return result.stdout.strip()
191
+
192
+ def is_running(self) -> bool:
193
+ return self._session_alive()
194
+
195
+ def list_windows(self) -> List[str]:
196
+ result = self._tmux("list-windows", "-t", self.session)
197
+ if result.returncode != 0:
198
+ return []
199
+ return [w for w in result.stdout.strip().split("\n") if w]
200
+
201
+ def kill(self) -> None:
202
+ self._run_quiet("kill-session", "-t", self.session)
@@ -0,0 +1,22 @@
1
+ """Tmux-compatible multiplexer wrappers (nami, lmux, wmux, herdr)."""
2
+
3
+ from __future__ import annotations
4
+ from typing import Any
5
+ from .tmux import TmuxMultiplexer
6
+
7
+ class TmuxCompatibleMultiplexer(TmuxMultiplexer):
8
+ """Wrapper for tmux-compatible multiplexers that use the same CLI semantics."""
9
+
10
+ def __init__(self, config: Any, binary: str):
11
+ super().__init__(config)
12
+ self._binary = binary
13
+
14
+ def binary_name(self) -> str:
15
+ return self._binary
16
+
17
+ def _tmux(self, *args: str, check: bool = False, capture: bool = True):
18
+ import subprocess
19
+ cmd = [self._binary] + list(args)
20
+ if capture:
21
+ return subprocess.run(cmd, check=check, capture_output=True, text=True)
22
+ return subprocess.run(cmd, check=check, text=True)
@@ -0,0 +1,109 @@
1
+ """Zellij multiplexer implementation.
2
+
3
+ Zellij uses tabs; each workstream gets its own tab. Zellij's scripting
4
+ surface is less mature than tmux's, so we use `zellij action` and
5
+ `zellij run` where available.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import shutil
11
+ import subprocess
12
+ from typing import Any, Dict, List, Optional
13
+
14
+ from .base import MultiplexerBase
15
+
16
+
17
+ class ZellijMultiplexer(MultiplexerBase):
18
+ """Zellij-based workstream management."""
19
+
20
+ name = "zellij"
21
+
22
+ def __init__(self, config: Any):
23
+ super().__init__(config)
24
+ self.session = f"workstreams-{config.project}"
25
+
26
+ def _zj(self, *args: str, check: bool = False, capture: bool = True) -> subprocess.CompletedProcess:
27
+ cmd = ["zellij"] + list(args)
28
+ if capture:
29
+ return subprocess.run(cmd, check=check, capture_output=True, text=True)
30
+ return subprocess.run(cmd, check=check, text=True)
31
+
32
+ def session_name(self) -> str:
33
+ return self.session
34
+
35
+ def pane_target(self, workstream_id: int) -> str:
36
+ ws = self.config.workstream(workstream_id)
37
+ return ws.name if ws else f"ws{workstream_id}"
38
+
39
+ def start(self, workstreams: List[Dict[str, Any]], command: Optional[str] = None) -> None:
40
+ base_dir = self.config.base_path
41
+
42
+ # Create (or attach to) the session in the background
43
+ result = self._zj(
44
+ "attach", self.session, "--create",
45
+ "--", "sleep", "999999",
46
+ check=False,
47
+ )
48
+ _ = result
49
+
50
+ for i, ws in enumerate(workstreams):
51
+ ws_name = ws.get("name", f"ws{ws.get('id', i + 1)}")
52
+ # Create a tab per workstream
53
+ self._zj("action", "new-tab", "--name", ws_name, check=False)
54
+ ws_path = f"{base_dir}/{ws.get('path', '')}"
55
+ ws_command = command or ws.get("command") or ""
56
+ if ws_command:
57
+ pane_env = ws.get("env") or {}
58
+ env_prefix = " ".join(f"{k}={v}" for k, v in pane_env.items())
59
+ full = f"cd {ws_path} && {env_prefix} {ws_command}".strip()
60
+ self._zj("run", "--", "bash", "-lc", full, check=False)
61
+
62
+ print(f"Started {len(workstreams)} workstream(s) in zellij session '{self.session}'")
63
+ print(f" Attach: zellij attach {self.session}")
64
+
65
+ def attach(self, session: Optional[str] = None) -> None:
66
+ self._zj("attach", session or self.session, check=False, capture=False)
67
+
68
+ def send_command(self, workstream_id: int, command: str) -> bool:
69
+ """Zellij has no simple `send-keys` equivalent; use `zellij run`.
70
+
71
+ This runs the command in the *current* tab of the session. For
72
+ per-tab targeting you'd need the Zellij RPC API; for now we log
73
+ a notice and run in the foreground tab.
74
+ """
75
+ import sys
76
+ print(
77
+ f"[workstreams] zellij: send_command targets current tab only; "
78
+ f"run: {command}",
79
+ file=sys.stderr,
80
+ )
81
+ self._zj("run", "--", "bash", "-lc", command, check=False)
82
+ return True
83
+
84
+ def capture(self, lines: int = 20) -> List[str]:
85
+ """Read the last N lines from the workstream's log file.
86
+
87
+ Zellij has no built-in pane-capture CLI like `tmux capture-pane`,
88
+ so we read from the per-workstream log file instead. This is the
89
+ most reliable cross-platform source and works even when the
90
+ zellij session is not attached.
91
+ """
92
+ import os
93
+ log_file = os.environ.get("WORKSTREAMS_ZELLIJ_LOG")
94
+ if not log_file:
95
+ return []
96
+ try:
97
+ with open(log_file, "r", encoding="utf-8", errors="replace") as f:
98
+ all_lines = f.readlines()
99
+ return [ln.rstrip("\n") for ln in all_lines[-lines:]]
100
+ except OSError:
101
+ return []
102
+
103
+ def is_available(self) -> bool:
104
+ import shutil
105
+ return shutil.which("zellij") is not None
106
+
107
+ def is_running(self) -> bool:
108
+ result = self._zj("list-sessions", check=False)
109
+ return self.session in (result.stdout or "")
@@ -0,0 +1,147 @@
1
+ """Cross-terminal notifications (desktop + file-based for other workstreams)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ import platform
8
+ import subprocess
9
+ import sys
10
+ from datetime import datetime, UTC
11
+ from pathlib import Path
12
+ from typing import Any, Dict, List, Optional
13
+
14
+ from .event_log import default_log_dir
15
+
16
+ NOTIFY_SENDERS = [
17
+ "notify-send",
18
+ "terminal-notify",
19
+ ]
20
+
21
+
22
+ class Notifier:
23
+ """Send notifications to other terminals / desktops."""
24
+
25
+ def __init__(self, project: str, log_dir: Optional[Path] = None):
26
+ self.project = project
27
+ self.notify_dir = Path(log_dir) if log_dir else default_log_dir(project)
28
+ self.notify_dir.mkdir(parents=True, exist_ok=True)
29
+ self.notify_file = self.notify_dir / "notifications.jsonl"
30
+
31
+ def send(self, title: str, message: str, urgency: str = "normal") -> bool:
32
+ """Send notification via desktop notification and file."""
33
+ sent = False
34
+ system = platform.system()
35
+
36
+ if system == "Darwin":
37
+ script = f'display notification {json.dumps(message)} with title {json.dumps(title)}'
38
+ try:
39
+ result = subprocess.run(
40
+ ["osascript", "-e", script],
41
+ capture_output=True,
42
+ text=True,
43
+ timeout=5,
44
+ )
45
+ sent = result.returncode == 0
46
+ except (OSError, subprocess.TimeoutExpired):
47
+ pass
48
+ elif system == "Windows":
49
+ # Windows: use PowerShell for desktop notification
50
+ ps_script = (
51
+ f"[System.Reflection.Assembly]::LoadWithPartialName('System.Windows.Forms') | Out-Null; "
52
+ f"$n = New-Object System.Windows.Forms.NotifyIcon; "
53
+ f"$n.Icon = [System.Drawing.SystemIcons]::Information; "
54
+ f"$n.Visible = $true; "
55
+ f"$n.ShowBalloonTip(5000, {json.dumps(title)}, {json.dumps(message)}, 'Info')"
56
+ )
57
+ try:
58
+ subprocess.run(
59
+ ["powershell", "-Command", ps_script],
60
+ capture_output=True,
61
+ text=True,
62
+ timeout=5,
63
+ )
64
+ sent = True
65
+ except (OSError, subprocess.TimeoutExpired):
66
+ pass
67
+ else:
68
+ # Linux: notify-send. Use --transient + --expire-time so the
69
+ # notification auto-dismisses instead of staying open. Critical
70
+ # urgency notifications are modal/blocking by spec — downgrade
71
+ # them and rely on expire-time so they don't pin forever.
72
+ effective_urgency = "normal" if urgency == "critical" else urgency
73
+ expire_ms = 15000 if urgency == "critical" else 8000
74
+ cmd = [
75
+ "notify-send",
76
+ "--transient",
77
+ "--expire-time", str(expire_ms),
78
+ "-u", effective_urgency,
79
+ title,
80
+ message,
81
+ ]
82
+ try:
83
+ result = subprocess.run(
84
+ cmd,
85
+ capture_output=True,
86
+ text=True,
87
+ timeout=5,
88
+ )
89
+ sent = result.returncode == 0
90
+ except (OSError, subprocess.TimeoutExpired):
91
+ # Fallback: terminal-notify writes to stdout of the terminal
92
+ try:
93
+ result = subprocess.run(
94
+ ["terminal-notify", title, message],
95
+ capture_output=True,
96
+ text=True,
97
+ timeout=5,
98
+ )
99
+ sent = result.returncode == 0
100
+ except (OSError, subprocess.TimeoutExpired):
101
+ pass
102
+
103
+ # Always write to notification file for other instances to pick up
104
+ notif = {
105
+ "title": title,
106
+ "message": message,
107
+ "urgency": urgency,
108
+ "timestamp": datetime.now(UTC).isoformat(),
109
+ }
110
+ try:
111
+ with open(self.notify_file, "a", encoding="utf-8") as f:
112
+ f.write(json.dumps(notif) + "\n")
113
+ sent = True
114
+ except OSError:
115
+ pass
116
+ return sent
117
+
118
+ def get_notifications(self, since: Optional[datetime] = None) -> List[Dict[str, Any]]:
119
+ """Get pending notifications."""
120
+ if not self.notify_file.exists():
121
+ return []
122
+ notifs: List[Dict[str, Any]] = []
123
+ with open(self.notify_file, encoding="utf-8") as f:
124
+ for line in f:
125
+ line = line.strip()
126
+ if not line:
127
+ continue
128
+ try:
129
+ notif = json.loads(line)
130
+ if since is not None:
131
+ ts_raw = notif.get("timestamp", "")
132
+ try:
133
+ ts = datetime.fromisoformat(ts_raw)
134
+ if ts.tzinfo is None:
135
+ ts = ts.replace(tzinfo=UTC)
136
+ if ts < since:
137
+ continue
138
+ except ValueError:
139
+ pass
140
+ notifs.append(notif)
141
+ except json.JSONDecodeError:
142
+ continue
143
+ return notifs
144
+
145
+ def clear_notifications(self) -> None:
146
+ if self.notify_file.exists():
147
+ self.notify_file.unlink()
workstreams/py.typed ADDED
File without changes
@@ -0,0 +1,125 @@
1
+ """Subagent client helpers - report events from any process.
2
+
3
+ Import these from your subagent (a Claude Code session, a Codex session,
4
+ a script, a cron job...) so it can report progress to the main terminal:
5
+
6
+ from workstreams import subagent_progress
7
+ subagent_progress("myproject", 1, "claude-code", 42, "Fixed auth", {"files": 3})
8
+
9
+ Or use the one-liner CLI that needs no Python import at all:
10
+
11
+ workstreams event started --project myproject --workstream 1 \
12
+ --subagent claude-code --issue 42 --message "Fix auth"
13
+
14
+ All events land in ~/.workstreams/<project>/events.jsonl and show up in
15
+ `workstreams monitor`, `workstreams events`, and any other terminal.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ from typing import Any, Dict, Optional
21
+
22
+ from .models import SubagentEvent
23
+ from .event_log import get_event_log
24
+
25
+ # Subagent identifiers are free-form (claude-code, codex, opencode, qwen-code,
26
+ # mimocode, hermes, kilo-code, cline, ...). We do NOT restrict them: the whole
27
+ # point of workstreams is agent-agnostic dispatch.
28
+
29
+
30
+ def subagent_report(
31
+ project: str,
32
+ workstream_id: int,
33
+ subagent: str,
34
+ issue: int,
35
+ event_type: str,
36
+ message: str,
37
+ data: Optional[Dict[str, Any]] = None,
38
+ ) -> bool:
39
+ """Report an arbitrary event (cross-process)."""
40
+ event_log = get_event_log(project)
41
+ event = SubagentEvent(
42
+ workstream_id=workstream_id,
43
+ subagent=subagent,
44
+ issue=issue,
45
+ event_type=event_type,
46
+ message=message,
47
+ data=data or {},
48
+ )
49
+ return event_log.append(event)
50
+
51
+
52
+ def subagent_started(
53
+ project: str,
54
+ workstream_id: int,
55
+ subagent: str,
56
+ issue: int,
57
+ prompt: str = "",
58
+ data: Optional[Dict[str, Any]] = None,
59
+ ) -> bool:
60
+ """Report subagent started."""
61
+ return subagent_report(
62
+ project, workstream_id, subagent, issue, "started",
63
+ f"{subagent} started on issue #{issue}" + (f": {prompt}" if prompt else ""),
64
+ {"prompt": prompt, **(data or {})},
65
+ )
66
+
67
+
68
+ def subagent_progress(
69
+ project: str,
70
+ workstream_id: int,
71
+ subagent: str,
72
+ issue: int,
73
+ message: str,
74
+ data: Optional[Dict[str, Any]] = None,
75
+ ) -> bool:
76
+ """Report subagent progress."""
77
+ return subagent_report(project, workstream_id, subagent, issue, "progress", message, data)
78
+
79
+
80
+ def subagent_completed(
81
+ project: str,
82
+ workstream_id: int,
83
+ subagent: str,
84
+ issue: int,
85
+ message: str,
86
+ data: Optional[Dict[str, Any]] = None,
87
+ ) -> bool:
88
+ """Report subagent completed."""
89
+ return subagent_report(project, workstream_id, subagent, issue, "completed", message, data)
90
+
91
+
92
+ def subagent_failed(
93
+ project: str,
94
+ workstream_id: int,
95
+ subagent: str,
96
+ issue: int,
97
+ message: str,
98
+ data: Optional[Dict[str, Any]] = None,
99
+ ) -> bool:
100
+ """Report subagent failed."""
101
+ return subagent_report(project, workstream_id, subagent, issue, "failed", message, data)
102
+
103
+
104
+ def subagent_error(
105
+ project: str,
106
+ workstream_id: int,
107
+ subagent: str,
108
+ issue: int,
109
+ message: str,
110
+ data: Optional[Dict[str, Any]] = None,
111
+ ) -> bool:
112
+ """Report a subagent error."""
113
+ return subagent_report(project, workstream_id, subagent, issue, "error", message, data)
114
+
115
+
116
+ def subagent_done(
117
+ project: str,
118
+ workstream_id: int,
119
+ subagent: str,
120
+ issue: int,
121
+ message: str,
122
+ data: Optional[Dict[str, Any]] = None,
123
+ ) -> bool:
124
+ """Report a subagent finished (used by workstreams work --wait)."""
125
+ return subagent_report(project, workstream_id, subagent, issue, "done", message, data)