taskswarm-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.
@@ -0,0 +1,61 @@
1
+ """Fires a local OS notification. macOS uses `osascript -e 'display
2
+ notification'` (no extra dependency, ships with the OS). Any other platform
3
+ falls back to a console message plus a terminal bell -- always self-hosted,
4
+ always on by default, no third-party relay involved. Ported from
5
+ src/notify/os-notify.ts."""
6
+ from __future__ import annotations
7
+
8
+ import platform
9
+ import re
10
+ import subprocess
11
+ import sys
12
+
13
+ _BEL = chr(7)
14
+
15
+ # Strips ANSI/terminal control sequences (ESC-prefixed CSI/OSC sequences and
16
+ # raw control characters other than tab/newline) before writing
17
+ # event-derived text to a real terminal. Event fields (session_id, repo,
18
+ # blocked_reason) can originate from an authenticated but otherwise
19
+ # untrusted /events caller, so this fallback -- unlike the escaped
20
+ # AppleScript path -- must not pass them through raw.
21
+ _CONTROL_SEQUENCE_PATTERN = re.compile(
22
+ r"\x1b\[[0-9;]*[a-zA-Z]|\x1b\][^\x07]*(\x07|\x1b\\)|[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]"
23
+ )
24
+
25
+
26
+ def _sanitize_for_terminal(value: str) -> str:
27
+ return _CONTROL_SEQUENCE_PATTERN.sub("", value)
28
+
29
+
30
+ def send_os_notification(title: str, message: str) -> None:
31
+ if platform.system() == "Darwin":
32
+ script = (
33
+ f"display notification {_quote_applescript_string(message)} "
34
+ f"with title {_quote_applescript_string(title)}"
35
+ )
36
+ try:
37
+ subprocess.run(
38
+ ["osascript", "-e", script],
39
+ stdin=subprocess.DEVNULL,
40
+ stdout=subprocess.DEVNULL,
41
+ stderr=subprocess.DEVNULL,
42
+ check=False,
43
+ )
44
+ except OSError:
45
+ # osascript missing/unavailable (e.g. sandboxed CI) -- fall back
46
+ # so a notification failure never crashes the server process.
47
+ _fallback_notification(title, message)
48
+ return
49
+ _fallback_notification(title, message)
50
+
51
+
52
+ def _fallback_notification(title: str, message: str) -> None:
53
+ # BEL is the terminal bell character -- audible/visible cue in most
54
+ # terminal emulators even without a graphical OS notification.
55
+ sys.stdout.write(_BEL)
56
+ print(f"[taskswarm] {_sanitize_for_terminal(title)}: {_sanitize_for_terminal(message)}")
57
+
58
+
59
+ def _quote_applescript_string(value: str) -> str:
60
+ escaped = value.replace("\\", "\\\\").replace('"', '\\"')
61
+ return f'"{escaped}"'
taskswarm/py.typed ADDED
File without changes
@@ -0,0 +1,23 @@
1
+ from .events import (
2
+ AGENT_STATUSES,
3
+ AGENT_TYPES,
4
+ CURRENT_SCHEMA_VERSION,
5
+ NOTIFY_ON_STATUSES,
6
+ AgentEvent,
7
+ EventValidationError,
8
+ parse_agent_event,
9
+ parse_agent_event_input,
10
+ to_agent_event,
11
+ )
12
+
13
+ __all__ = [
14
+ "AGENT_STATUSES",
15
+ "AGENT_TYPES",
16
+ "CURRENT_SCHEMA_VERSION",
17
+ "NOTIFY_ON_STATUSES",
18
+ "AgentEvent",
19
+ "EventValidationError",
20
+ "parse_agent_event",
21
+ "parse_agent_event_input",
22
+ "to_agent_event",
23
+ ]
@@ -0,0 +1,221 @@
1
+ """
2
+ The event envelope every part of TaskSwarm agrees on.
3
+
4
+ Ported from src/schema/events.ts, which uses `zod` for runtime validation.
5
+ This port has zero runtime dependencies, so validation is hand-written here
6
+ instead of pulled in from a schema library -- the field set, limits, and
7
+ error shape are kept equivalent to the TypeScript version's
8
+ `agentEventSchema` / `agentEventInputSchema`.
9
+ """
10
+ from __future__ import annotations
11
+
12
+ import re
13
+ import uuid
14
+ from dataclasses import dataclass, field
15
+ from datetime import datetime, timezone
16
+ from typing import Any, Dict, List, Optional
17
+
18
+ # The current envelope version. Bump this whenever the shape of AgentEvent
19
+ # changes in a way that is not purely additive-and-optional. Consumers can
20
+ # branch on `schema_version` to stay forward compatible.
21
+ CURRENT_SCHEMA_VERSION = 1
22
+
23
+ # Agent integrations TaskSwarm ships an adapter for in v0.1.
24
+ AGENT_TYPES = ("claude-code", "codex", "cursor", "generic")
25
+
26
+ # Lifecycle states a tracked session can be in. TaskSwarm's notification
27
+ # layer fires on a transition into 'blocked' | 'needs-review' | 'failed' |
28
+ # 'done' -- the four states that mean "a human should look at this now."
29
+ AGENT_STATUSES = ("queued", "running", "blocked", "needs-review", "done", "failed")
30
+
31
+ # The subset of statuses that should trigger a notification on transition.
32
+ NOTIFY_ON_STATUSES = frozenset({"blocked", "needs-review", "failed", "done"})
33
+
34
+ _SESSION_ID_MAX = 256
35
+ _REPO_MAX = 1024
36
+ _BLOCKED_REASON_MAX = 4096
37
+
38
+ _UUID_RE = re.compile(
39
+ r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$", re.IGNORECASE
40
+ )
41
+
42
+
43
+ class EventValidationError(ValueError):
44
+ """Raised when a raw dict does not satisfy the AgentEvent(Input) contract.
45
+
46
+ `details` mirrors zod's `.flatten()` shape closely enough to be useful
47
+ for a caller building a `400` response body: a dict of field name to a
48
+ list of human-readable problem messages.
49
+ """
50
+
51
+ def __init__(self, message: str, details: Optional[Dict[str, List[str]]] = None) -> None:
52
+ super().__init__(message)
53
+ self.details: Dict[str, List[str]] = details or {}
54
+
55
+
56
+ @dataclass
57
+ class AgentEvent:
58
+ event_id: str
59
+ session_id: str
60
+ repo: str
61
+ agent_type: str
62
+ status: str
63
+ timestamp: str
64
+ schema_version: int
65
+ blocked_reason: Optional[str] = field(default=None)
66
+
67
+ def to_dict(self) -> Dict[str, Any]:
68
+ data: Dict[str, Any] = {
69
+ "event_id": self.event_id,
70
+ "session_id": self.session_id,
71
+ "repo": self.repo,
72
+ "agent_type": self.agent_type,
73
+ "status": self.status,
74
+ "timestamp": self.timestamp,
75
+ "schema_version": self.schema_version,
76
+ }
77
+ if self.blocked_reason is not None:
78
+ data["blocked_reason"] = self.blocked_reason
79
+ return data
80
+
81
+
82
+ def _is_uuid(value: Any) -> bool:
83
+ return isinstance(value, str) and bool(_UUID_RE.match(value))
84
+
85
+
86
+ def _is_offset_datetime(value: Any) -> bool:
87
+ """True for an ISO-8601 datetime string carrying an explicit UTC offset
88
+ (including a bare "Z"), matching zod's `z.string().datetime({offset:
89
+ true})`."""
90
+ if not isinstance(value, str) or len(value) == 0:
91
+ return False
92
+ normalized = value[:-1] + "+00:00" if value.endswith("Z") else value
93
+ try:
94
+ parsed = datetime.fromisoformat(normalized)
95
+ except ValueError:
96
+ return False
97
+ return parsed.tzinfo is not None
98
+
99
+
100
+ def _add_error(errors: Dict[str, List[str]], field_name: str, message: str) -> None:
101
+ errors.setdefault(field_name, []).append(message)
102
+
103
+
104
+ def _validate_common(data: Dict[str, Any], errors: Dict[str, List[str]]) -> None:
105
+ session_id = data.get("session_id")
106
+ if not isinstance(session_id, str) or not (1 <= len(session_id) <= _SESSION_ID_MAX):
107
+ _add_error(errors, "session_id", f"must be a string of 1-{_SESSION_ID_MAX} characters")
108
+
109
+ repo = data.get("repo")
110
+ if not isinstance(repo, str) or not (1 <= len(repo) <= _REPO_MAX):
111
+ _add_error(errors, "repo", f"must be a string of 1-{_REPO_MAX} characters")
112
+
113
+ agent_type = data.get("agent_type")
114
+ if agent_type not in AGENT_TYPES:
115
+ _add_error(errors, "agent_type", f"must be one of: {', '.join(AGENT_TYPES)}")
116
+
117
+ status = data.get("status")
118
+ if status not in AGENT_STATUSES:
119
+ _add_error(errors, "status", f"must be one of: {', '.join(AGENT_STATUSES)}")
120
+
121
+ blocked_reason = data.get("blocked_reason")
122
+ if blocked_reason is not None and (
123
+ not isinstance(blocked_reason, str) or len(blocked_reason) > _BLOCKED_REASON_MAX
124
+ ):
125
+ _add_error(errors, "blocked_reason", f"must be a string of at most {_BLOCKED_REASON_MAX} characters")
126
+
127
+
128
+ def parse_agent_event_input(data: Dict[str, Any]) -> Dict[str, Any]:
129
+ """Validates the input shape accepted from the CLI/adapters/HTTP layer:
130
+ event_id, timestamp, and schema_version are all optional (filled in by
131
+ `to_agent_event`); everything else is required. Raises
132
+ EventValidationError with field-level details on failure."""
133
+ if not isinstance(data, dict):
134
+ raise EventValidationError("event input must be a JSON object", {"_root": ["must be an object"]})
135
+
136
+ errors: Dict[str, List[str]] = {}
137
+ _validate_common(data, errors)
138
+
139
+ event_id = data.get("event_id")
140
+ if event_id is not None and not _is_uuid(event_id):
141
+ _add_error(errors, "event_id", "must be a valid UUID")
142
+
143
+ timestamp = data.get("timestamp")
144
+ if timestamp is not None and not _is_offset_datetime(timestamp):
145
+ _add_error(errors, "timestamp", "must be an ISO-8601 datetime with a UTC offset")
146
+
147
+ schema_version = data.get("schema_version")
148
+ if schema_version is not None and not (isinstance(schema_version, int) and schema_version > 0):
149
+ _add_error(errors, "schema_version", "must be a positive integer")
150
+
151
+ if errors:
152
+ raise EventValidationError("invalid event", errors)
153
+
154
+ result: Dict[str, Any] = {
155
+ "session_id": data["session_id"],
156
+ "repo": data["repo"],
157
+ "agent_type": data["agent_type"],
158
+ "status": data["status"],
159
+ }
160
+ if data.get("blocked_reason") is not None:
161
+ result["blocked_reason"] = data["blocked_reason"]
162
+ if event_id is not None:
163
+ result["event_id"] = event_id
164
+ if timestamp is not None:
165
+ result["timestamp"] = timestamp
166
+ if schema_version is not None:
167
+ result["schema_version"] = schema_version
168
+ return result
169
+
170
+
171
+ def parse_agent_event(data: Dict[str, Any]) -> AgentEvent:
172
+ """Validates a fully-stamped event (all fields required) -- used when
173
+ replaying persisted JSONL log lines, where every event must already
174
+ carry event_id/timestamp/schema_version."""
175
+ if not isinstance(data, dict):
176
+ raise EventValidationError("event must be a JSON object")
177
+
178
+ errors: Dict[str, List[str]] = {}
179
+ _validate_common(data, errors)
180
+
181
+ if not _is_uuid(data.get("event_id")):
182
+ _add_error(errors, "event_id", "must be a valid UUID")
183
+ if not _is_offset_datetime(data.get("timestamp")):
184
+ _add_error(errors, "timestamp", "must be an ISO-8601 datetime with a UTC offset")
185
+ schema_version = data.get("schema_version")
186
+ if not (isinstance(schema_version, int) and schema_version > 0):
187
+ _add_error(errors, "schema_version", "must be a positive integer")
188
+
189
+ if errors:
190
+ raise EventValidationError("invalid event", errors)
191
+
192
+ return AgentEvent(
193
+ event_id=data["event_id"],
194
+ session_id=data["session_id"],
195
+ repo=data["repo"],
196
+ agent_type=data["agent_type"],
197
+ status=data["status"],
198
+ timestamp=data["timestamp"],
199
+ schema_version=data["schema_version"],
200
+ blocked_reason=data.get("blocked_reason"),
201
+ )
202
+
203
+
204
+ def to_agent_event(input_data: Dict[str, Any]) -> AgentEvent:
205
+ """Fills in event_id/timestamp/schema_version defaults for a partial,
206
+ already-validated input (the output of parse_agent_event_input)."""
207
+ event_id = input_data.get("event_id") or str(uuid.uuid4())
208
+ timestamp = input_data.get("timestamp") or datetime.now(timezone.utc).isoformat().replace(
209
+ "+00:00", "Z"
210
+ )
211
+ schema_version = input_data.get("schema_version") or CURRENT_SCHEMA_VERSION
212
+ return AgentEvent(
213
+ event_id=event_id,
214
+ session_id=input_data["session_id"],
215
+ repo=input_data["repo"],
216
+ agent_type=input_data["agent_type"],
217
+ status=input_data["status"],
218
+ timestamp=timestamp,
219
+ schema_version=schema_version,
220
+ blocked_reason=input_data.get("blocked_reason"),
221
+ )
@@ -0,0 +1,33 @@
1
+ from .config import (
2
+ DEFAULT_HOST,
3
+ DEFAULT_PORT,
4
+ TaskSwarmConfig,
5
+ generate_token,
6
+ get_config_path,
7
+ get_event_log_path,
8
+ get_taskswarm_home,
9
+ load_or_create_config,
10
+ rotate_token,
11
+ save_config,
12
+ try_create_config_exclusive,
13
+ )
14
+ from .event_store import EventStore, SessionState
15
+ from .server import RunningServer, start_server
16
+
17
+ __all__ = [
18
+ "DEFAULT_HOST",
19
+ "DEFAULT_PORT",
20
+ "TaskSwarmConfig",
21
+ "generate_token",
22
+ "get_config_path",
23
+ "get_event_log_path",
24
+ "get_taskswarm_home",
25
+ "load_or_create_config",
26
+ "rotate_token",
27
+ "save_config",
28
+ "try_create_config_exclusive",
29
+ "EventStore",
30
+ "SessionState",
31
+ "RunningServer",
32
+ "start_server",
33
+ ]
@@ -0,0 +1,25 @@
1
+ """Bearer-token comparison and extraction. Ported from src/server/auth.ts."""
2
+ from __future__ import annotations
3
+
4
+ import hmac
5
+ import re
6
+ from typing import Optional
7
+
8
+ _BEARER_RE = re.compile(r"^Bearer\s+(.+)$", re.IGNORECASE)
9
+
10
+
11
+ def tokens_match(provided: str, expected: str) -> bool:
12
+ """Constant-time comparison of two bearer tokens (avoids timing
13
+ side-channels). `hmac.compare_digest` is the stdlib's constant-time
14
+ comparator -- the direct Python equivalent of Node's
15
+ `crypto.timingSafeEqual`, and (like the TS version) it tolerates
16
+ differing lengths without leaking length via early-exit timing."""
17
+ return hmac.compare_digest(provided.encode("utf-8"), expected.encode("utf-8"))
18
+
19
+
20
+ def extract_bearer_token(authorization_header: Optional[str]) -> Optional[str]:
21
+ """Extracts a bearer token from an Authorization header value, if present."""
22
+ if not authorization_header:
23
+ return None
24
+ match = _BEARER_RE.match(authorization_header)
25
+ return match.group(1) if match else None
@@ -0,0 +1,154 @@
1
+ """Local config: bearer token, host/port, and the opt-in ntfy.sh channel.
2
+ Ported from src/server/config.ts."""
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ import secrets
8
+ import stat
9
+ from dataclasses import dataclass, field
10
+ from pathlib import Path
11
+ from typing import Any, Dict, Optional
12
+
13
+ from ..util.sync_sleep import sleep_sync_ms
14
+
15
+ DEFAULT_PORT = 4173
16
+ DEFAULT_HOST = "127.0.0.1"
17
+
18
+
19
+ @dataclass
20
+ class TaskSwarmConfig:
21
+ token: str
22
+ port: int = DEFAULT_PORT
23
+ host: str = DEFAULT_HOST
24
+ ntfy: Dict[str, Any] = field(default_factory=lambda: {"enabled": False})
25
+
26
+ def to_dict(self) -> Dict[str, Any]:
27
+ return {"token": self.token, "port": self.port, "host": self.host, "ntfy": self.ntfy}
28
+
29
+
30
+ def get_taskswarm_home() -> str:
31
+ """Root directory for all TaskSwarm local state. Overridable for tests
32
+ via TASKSWARM_HOME."""
33
+ return os.environ.get("TASKSWARM_HOME") or str(Path.home() / ".taskswarm")
34
+
35
+
36
+ def get_config_path() -> str:
37
+ return str(Path(get_taskswarm_home()) / "config.json")
38
+
39
+
40
+ def get_event_log_path() -> str:
41
+ return str(Path(get_taskswarm_home()) / "events.jsonl")
42
+
43
+
44
+ def generate_token() -> str:
45
+ """Generates a cryptographically random bearer token (256 bits, URL-safe)."""
46
+ return secrets.token_urlsafe(32)
47
+
48
+
49
+ def _ensure_home_dir() -> None:
50
+ home = Path(get_taskswarm_home())
51
+ home.mkdir(parents=True, exist_ok=True, mode=0o700)
52
+ try:
53
+ os.chmod(home, 0o700)
54
+ except OSError:
55
+ # best-effort on platforms/filesystems that don't support POSIX perms
56
+ pass
57
+
58
+
59
+ def _default_config() -> TaskSwarmConfig:
60
+ return TaskSwarmConfig(token=generate_token(), port=DEFAULT_PORT, host=DEFAULT_HOST, ntfy={"enabled": False})
61
+
62
+
63
+ def _write_config(config: TaskSwarmConfig) -> None:
64
+ _ensure_home_dir()
65
+ path = get_config_path()
66
+ with open(path, "w", encoding="utf-8") as handle:
67
+ handle.write(json.dumps(config.to_dict(), indent=2) + "\n")
68
+ try:
69
+ os.chmod(path, 0o600)
70
+ except OSError:
71
+ pass
72
+
73
+
74
+ def try_create_config_exclusive(config: TaskSwarmConfig) -> bool:
75
+ """Attempts to create the config file exclusively (fails if another
76
+ process already created it). Returns True if this call won the race and
77
+ wrote the file, False if another process got there first. Exported so
78
+ the TOCTOU-loss path can be exercised directly and deterministically in
79
+ tests, without needing to fake real multi-process OS scheduling."""
80
+ _ensure_home_dir()
81
+ path = get_config_path()
82
+ payload = json.dumps(config.to_dict(), indent=2) + "\n"
83
+ try:
84
+ fd = os.open(path, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600)
85
+ except FileExistsError:
86
+ return False
87
+ try:
88
+ os.write(fd, payload.encode("utf-8"))
89
+ finally:
90
+ os.close(fd)
91
+ try:
92
+ os.chmod(path, 0o600)
93
+ except OSError:
94
+ pass
95
+ return True
96
+
97
+
98
+ def _read_config_file_with_retry(path: str) -> Dict[str, Any]:
99
+ """Reads and parses the config file, retrying briefly on a parse
100
+ failure. Guards the narrow window where we lost the exclusive-create
101
+ race and the winning process has created the file but not yet finished
102
+ flushing its contents."""
103
+ max_attempts = 25
104
+ retry_delay_ms = 4
105
+ last_error: Optional[Exception] = None
106
+ for _ in range(max_attempts):
107
+ try:
108
+ with open(path, "r", encoding="utf-8") as handle:
109
+ return json.load(handle)
110
+ except (OSError, json.JSONDecodeError) as error:
111
+ last_error = error
112
+ sleep_sync_ms(retry_delay_ms)
113
+ assert last_error is not None
114
+ raise last_error
115
+
116
+
117
+ def load_or_create_config() -> TaskSwarmConfig:
118
+ """Loads the config, creating it (with a freshly generated token) on
119
+ first run. First-boot creation is race-safe: if two processes both see
120
+ no config file and race to create one, exactly one of them wins the
121
+ exclusive create and the loser re-reads the winner's file instead of
122
+ generating and persisting a second, different token."""
123
+ _ensure_home_dir()
124
+ path = get_config_path()
125
+ if not os.path.exists(path):
126
+ config = _default_config()
127
+ if try_create_config_exclusive(config):
128
+ return config
129
+ # Lost the race: another process already created the file first.
130
+ # Fall through and read what it wrote rather than clobbering it.
131
+
132
+ parsed = _read_config_file_with_retry(path)
133
+ ntfy = parsed.get("ntfy") or {}
134
+ merged = TaskSwarmConfig(
135
+ token=parsed.get("token") or generate_token(),
136
+ port=parsed.get("port") or DEFAULT_PORT,
137
+ host=parsed.get("host") or DEFAULT_HOST,
138
+ ntfy={"enabled": bool(ntfy.get("enabled", False)), **({"topicUrl": ntfy["topicUrl"]} if ntfy.get("topicUrl") else {})},
139
+ )
140
+ if not parsed.get("token"):
141
+ _write_config(merged)
142
+ return merged
143
+
144
+
145
+ def save_config(config: TaskSwarmConfig) -> None:
146
+ _write_config(config)
147
+
148
+
149
+ def rotate_token() -> str:
150
+ """Regenerates the bearer token and persists it. Returns the new token."""
151
+ config = load_or_create_config()
152
+ config.token = generate_token()
153
+ _write_config(config)
154
+ return config.token
@@ -0,0 +1,139 @@
1
+ """In-memory event store keyed by session_id, backed by an append-only JSONL
2
+ log on disk for durability across restarts. No embedded database: this is a
3
+ deliberate choice to keep the tool lightweight, dependency-free, and
4
+ ARM-friendly (no native binary compile step). Ported from
5
+ src/server/event-store.ts.
6
+
7
+ Thread safety: the HTTP server (server/http_server.py) runs each connection
8
+ on its own thread (`http.server.ThreadingHTTPServer`), so every method here
9
+ that touches `_sessions` or the on-disk log is guarded by a single lock.
10
+ """
11
+ from __future__ import annotations
12
+
13
+ import json
14
+ import os
15
+ import stat
16
+ import threading
17
+ from dataclasses import dataclass, field
18
+ from pathlib import Path
19
+ from typing import Callable, List, Optional
20
+
21
+ from ..schema.events import AgentEvent, parse_agent_event
22
+
23
+ # Longest raw line content ever echoed back in a corrupt-line warning.
24
+ _CORRUPT_LINE_PREVIEW_LIMIT = 200
25
+
26
+
27
+ @dataclass
28
+ class SessionState:
29
+ session_id: str
30
+ latest: AgentEvent
31
+ history: List[AgentEvent] = field(default_factory=list)
32
+
33
+
34
+ class EventStore:
35
+ """Emits to registered listeners whenever a new event lands, so the HTTP
36
+ layer can fan it out to connected /live clients."""
37
+
38
+ def __init__(self, log_path: Optional[str] = None, warn: Optional[Callable[[str], None]] = None) -> None:
39
+ self._sessions: "dict[str, SessionState]" = {}
40
+ self._log_path = log_path
41
+ self._warn = warn or (lambda message: print(message, flush=True))
42
+ self._listeners: List[Callable[[AgentEvent], None]] = []
43
+ self._lock = threading.Lock()
44
+ if self._log_path:
45
+ self._ensure_log_file(self._log_path)
46
+ self._replay(self._log_path)
47
+
48
+ def _ensure_log_file(self, log_path: str) -> None:
49
+ directory = os.path.dirname(log_path)
50
+ if directory and not os.path.exists(directory):
51
+ os.makedirs(directory, mode=0o700, exist_ok=True)
52
+ if not os.path.exists(log_path):
53
+ fd = os.open(log_path, os.O_CREAT | os.O_APPEND | os.O_WRONLY, 0o600)
54
+ os.close(fd)
55
+
56
+ def _replay(self, log_path: str) -> None:
57
+ """Rebuilds in-memory state by replaying every line of the JSONL
58
+ log. Corrupt/partial lines (e.g. a torn write from a crash
59
+ mid-append) are skipped rather than failing startup entirely --
60
+ durability is best-effort, not a hard guarantee for the last
61
+ unflushed line -- but each skip is logged so a torn write or
62
+ bit-rot is visible to the operator instead of silently erasing
63
+ history."""
64
+ with open(log_path, "r", encoding="utf-8") as handle:
65
+ raw = handle.read()
66
+ for line_number, line in enumerate(raw.split("\n"), start=1):
67
+ if line.strip() == "":
68
+ continue
69
+ try:
70
+ parsed = parse_agent_event(json.loads(line))
71
+ self._apply_to_memory(parsed)
72
+ except Exception:
73
+ self._warn_corrupt_line(log_path, line_number, line)
74
+
75
+ def _warn_corrupt_line(self, log_path: str, line_number: int, line: str) -> None:
76
+ preview = (
77
+ f"{line[:_CORRUPT_LINE_PREVIEW_LIMIT]}... (truncated, {len(line)} chars total)"
78
+ if len(line) > _CORRUPT_LINE_PREVIEW_LIMIT
79
+ else line
80
+ )
81
+ self._warn(f"taskswarm: skipping unparseable event on line {line_number} of {log_path}: {preview}")
82
+
83
+ def _apply_to_memory(self, event: AgentEvent) -> None:
84
+ existing = self._sessions.get(event.session_id)
85
+ if existing:
86
+ existing.history.append(event)
87
+ existing.latest = event
88
+ else:
89
+ self._sessions[event.session_id] = SessionState(
90
+ session_id=event.session_id, latest=event, history=[event]
91
+ )
92
+
93
+ def append(self, event: AgentEvent) -> "tuple[Optional[str], Optional[str]]":
94
+ """Appends a validated event to the log (if persistence is enabled)
95
+ and updates in-memory state. Returns (previous_status,
96
+ previous_blocked_reason) for the session (both None if this is the
97
+ session's first event) so callers can decide whether a state
98
+ transition warrants a notification -- notification dedup keys on
99
+ the (status, blocked_reason) pair together, not status alone, so
100
+ both are needed."""
101
+ with self._lock:
102
+ previous = self._sessions.get(event.session_id)
103
+ previous_status = previous.latest.status if previous else None
104
+ previous_blocked_reason = previous.latest.blocked_reason if previous else None
105
+ if self._log_path:
106
+ with open(self._log_path, "a", encoding="utf-8") as handle:
107
+ handle.write(json.dumps(event.to_dict()) + "\n")
108
+ try:
109
+ os.chmod(self._log_path, 0o600)
110
+ except OSError:
111
+ pass
112
+ self._apply_to_memory(event)
113
+ listeners = list(self._listeners)
114
+ for listener in listeners:
115
+ listener(event)
116
+ return previous_status, previous_blocked_reason
117
+
118
+ def get_session(self, session_id: str) -> Optional[SessionState]:
119
+ with self._lock:
120
+ return self._sessions.get(session_id)
121
+
122
+ def list_sessions(self) -> List[SessionState]:
123
+ """All tracked sessions' latest state, sorted most-recently-updated first."""
124
+ with self._lock:
125
+ sessions = list(self._sessions.values())
126
+ return sorted(sessions, key=lambda s: s.latest.timestamp, reverse=True)
127
+
128
+ def size(self) -> int:
129
+ with self._lock:
130
+ return len(self._sessions)
131
+
132
+ def add_listener(self, listener: Callable[[AgentEvent], None]) -> None:
133
+ with self._lock:
134
+ self._listeners.append(listener)
135
+
136
+ def remove_listener(self, listener: Callable[[AgentEvent], None]) -> None:
137
+ with self._lock:
138
+ if listener in self._listeners:
139
+ self._listeners.remove(listener)