stravinsky 0.2.52__py3-none-any.whl → 0.2.67__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.

Potentially problematic release.


This version of stravinsky might be problematic. Click here for more details.

Files changed (41) hide show
  1. mcp_bridge/__init__.py +1 -1
  2. mcp_bridge/cli/__init__.py +6 -0
  3. mcp_bridge/cli/install_hooks.py +1265 -0
  4. mcp_bridge/cli/session_report.py +585 -0
  5. mcp_bridge/hooks/HOOKS_SETTINGS.json +175 -0
  6. mcp_bridge/hooks/README.md +215 -0
  7. mcp_bridge/hooks/__init__.py +117 -63
  8. mcp_bridge/hooks/edit_recovery.py +42 -37
  9. mcp_bridge/hooks/git_noninteractive.py +89 -0
  10. mcp_bridge/hooks/keyword_detector.py +30 -0
  11. mcp_bridge/hooks/notification_hook.py +103 -0
  12. mcp_bridge/hooks/parallel_execution.py +111 -0
  13. mcp_bridge/hooks/pre_compact.py +82 -183
  14. mcp_bridge/hooks/rules_injector.py +507 -0
  15. mcp_bridge/hooks/session_notifier.py +125 -0
  16. mcp_bridge/{native_hooks → hooks}/stravinsky_mode.py +51 -16
  17. mcp_bridge/hooks/subagent_stop.py +98 -0
  18. mcp_bridge/hooks/task_validator.py +73 -0
  19. mcp_bridge/hooks/tmux_manager.py +141 -0
  20. mcp_bridge/hooks/todo_continuation.py +90 -0
  21. mcp_bridge/hooks/todo_delegation.py +88 -0
  22. mcp_bridge/hooks/tool_messaging.py +164 -0
  23. mcp_bridge/hooks/truncator.py +21 -17
  24. mcp_bridge/prompts/multimodal.py +24 -3
  25. mcp_bridge/server.py +12 -1
  26. mcp_bridge/server_tools.py +5 -0
  27. mcp_bridge/tools/agent_manager.py +30 -11
  28. mcp_bridge/tools/code_search.py +81 -9
  29. mcp_bridge/tools/lsp/tools.py +6 -2
  30. mcp_bridge/tools/model_invoke.py +76 -1
  31. mcp_bridge/tools/templates.py +32 -18
  32. stravinsky-0.2.67.dist-info/METADATA +284 -0
  33. {stravinsky-0.2.52.dist-info → stravinsky-0.2.67.dist-info}/RECORD +36 -23
  34. stravinsky-0.2.67.dist-info/entry_points.txt +5 -0
  35. mcp_bridge/native_hooks/edit_recovery.py +0 -46
  36. mcp_bridge/native_hooks/todo_delegation.py +0 -54
  37. mcp_bridge/native_hooks/truncator.py +0 -23
  38. stravinsky-0.2.52.dist-info/METADATA +0 -204
  39. stravinsky-0.2.52.dist-info/entry_points.txt +0 -3
  40. /mcp_bridge/{native_hooks → hooks}/context.py +0 -0
  41. {stravinsky-0.2.52.dist-info → stravinsky-0.2.67.dist-info}/WHEEL +0 -0
@@ -0,0 +1,507 @@
1
+ """
2
+ Rules Injector Hook - Automatic Coding Standards Injection
3
+
4
+ ARCHITECTURE:
5
+ - Tier 2 Pre-Model-Invoke Hook
6
+ - Session-scoped caching with deduplication
7
+ - File path pattern matching via glob
8
+ - Priority-based rule ordering and truncation
9
+
10
+ DISCOVERY:
11
+ 1. Searches .claude/rules/ (project-local and user-global)
12
+ 2. Parses YAML frontmatter for globs and metadata
13
+ 3. Caches discovered rules for session duration
14
+
15
+ MATCHING:
16
+ 1. Extracts file paths from prompt context
17
+ 2. Matches files against rule glob patterns
18
+ 3. Sorts by priority (lower = higher)
19
+ 4. Deduplicates via session cache
20
+
21
+ INJECTION:
22
+ 1. Formats matched rules as markdown
23
+ 2. Prepends to model prompt
24
+ 3. Respects token budget (max 4k tokens)
25
+ 4. Truncates low-priority rules if needed
26
+
27
+ ERROR HANDLING:
28
+ - Graceful degradation on all errors
29
+ - Never blocks model invocation
30
+ - Logs warnings for malformed rules
31
+ """
32
+
33
+ import logging
34
+ import re
35
+ from dataclasses import dataclass
36
+ from fnmatch import fnmatch
37
+ from pathlib import Path
38
+ from typing import Any, Dict, Optional, Set
39
+
40
+ logger = logging.getLogger(__name__)
41
+
42
+ # Constants
43
+ MAX_RULES_TOKENS = 4000 # Reserve max 4k tokens for rules
44
+ TOKEN_ESTIMATE_RATIO = 4 # ~4 chars per token (conservative)
45
+
46
+ # Session-scoped caches
47
+ _rules_injection_cache: Dict[str, Set[str]] = {}
48
+ _session_rules_cache: Dict[str, list] = {}
49
+
50
+
51
+ @dataclass(frozen=True)
52
+ class RuleFile:
53
+ """Represents a discovered rule file with metadata."""
54
+ name: str
55
+ path: str
56
+ scope: str # "project" or "user"
57
+ globs: tuple[str, ...] # Use tuple instead of list for hashability
58
+ description: str
59
+ priority: int
60
+ body: str
61
+ enabled: bool = True
62
+
63
+
64
+ def parse_frontmatter(content: str) -> tuple[dict[str, Any], str]:
65
+ """
66
+ Parse YAML frontmatter from markdown content.
67
+
68
+ Supports:
69
+ - Simple key: value
70
+ - Arrays: globs: ["*.py", "*.js"]
71
+ - Multi-line arrays:
72
+ globs:
73
+ - "*.py"
74
+ - "*.js"
75
+ """
76
+ if not content.startswith("---"):
77
+ return {}, content
78
+
79
+ end_match = content.find("---", 3)
80
+ if end_match == -1:
81
+ return {}, content
82
+
83
+ frontmatter_block = content[3:end_match].strip()
84
+ body = content[end_match + 3:].strip()
85
+
86
+ metadata = {}
87
+ current_key = None
88
+ array_buffer = []
89
+
90
+ for line in frontmatter_block.split("\n"):
91
+ line = line.rstrip()
92
+
93
+ # Array item: " - value"
94
+ if line.strip().startswith("-") and current_key:
95
+ value = line.strip()[1:].strip().strip('"').strip("'")
96
+ array_buffer.append(value)
97
+ continue
98
+
99
+ # Key-value pair
100
+ if ":" in line:
101
+ # Flush previous array
102
+ if current_key and array_buffer:
103
+ metadata[current_key] = array_buffer
104
+ array_buffer = []
105
+
106
+ key, _, value = line.partition(":")
107
+ key = key.strip()
108
+ value = value.strip()
109
+
110
+ # Inline array: globs: ["*.py", "*.js"]
111
+ if value.startswith("[") and value.endswith("]"):
112
+ values = value[1:-1].split(",")
113
+ metadata[key] = [v.strip().strip('"').strip("'") for v in values if v.strip()]
114
+ current_key = None
115
+ # Empty value (likely multi-line array follows)
116
+ elif not value:
117
+ current_key = key
118
+ array_buffer = []
119
+ # Simple value
120
+ else:
121
+ metadata[key] = value.strip('"').strip("'")
122
+ current_key = None
123
+
124
+ # Flush final array
125
+ if current_key and array_buffer:
126
+ metadata[current_key] = array_buffer
127
+
128
+ return metadata, body
129
+
130
+
131
+ def discover_rules(project_path: Optional[str] = None) -> list[RuleFile]:
132
+ """
133
+ Discover all rule files from .claude/rules/ directories.
134
+
135
+ Search order:
136
+ 1. Project-local: {project}/.claude/rules/**/*.md (highest priority)
137
+ 2. User-global: ~/.claude/rules/**/*.md (fallback)
138
+
139
+ Returns:
140
+ List of RuleFile objects sorted by priority (lower = higher)
141
+ """
142
+ rules = []
143
+ search_paths = []
144
+
145
+ # Project-local rules (highest priority)
146
+ if project_path:
147
+ project = Path(project_path)
148
+ else:
149
+ project = Path.cwd()
150
+
151
+ project_rules = project / ".claude" / "rules"
152
+ if project_rules.exists() and project_rules.is_dir():
153
+ search_paths.append(("project", project_rules))
154
+
155
+ # User-global rules (fallback)
156
+ user_rules = Path.home() / ".claude" / "rules"
157
+ if user_rules.exists() and user_rules.is_dir():
158
+ search_paths.append(("user", user_rules))
159
+
160
+ for scope, rules_dir in search_paths:
161
+ try:
162
+ for md_file in rules_dir.glob("**/*.md"):
163
+ try:
164
+ content = md_file.read_text(encoding='utf-8')
165
+ metadata, body = parse_frontmatter(content)
166
+
167
+ # Parse globs from frontmatter
168
+ globs_raw = metadata.get("globs", [])
169
+
170
+ # Handle both string and list formats
171
+ if isinstance(globs_raw, str):
172
+ globs = [g.strip() for g in globs_raw.split(",") if g.strip()]
173
+ elif isinstance(globs_raw, list):
174
+ globs = [str(g).strip() for g in globs_raw if g]
175
+ else:
176
+ globs = []
177
+
178
+ if not globs:
179
+ logger.warning(f"[RulesInjector] Skipping {md_file.name}: no globs defined")
180
+ continue
181
+
182
+ # Parse enabled flag (default: true)
183
+ enabled = metadata.get("enabled", "true")
184
+ if isinstance(enabled, str):
185
+ enabled = enabled.lower() in ("true", "yes", "1")
186
+
187
+ rules.append(RuleFile(
188
+ name=md_file.stem,
189
+ path=str(md_file),
190
+ scope=scope,
191
+ globs=tuple(globs), # Convert to tuple for hashability
192
+ description=metadata.get("description", ""),
193
+ priority=int(metadata.get("priority", "100")),
194
+ body=body.strip(),
195
+ enabled=enabled
196
+ ))
197
+
198
+ logger.debug(f"[RulesInjector] Discovered rule: {md_file.stem} ({len(globs)} patterns)")
199
+
200
+ except Exception as e:
201
+ logger.warning(f"[RulesInjector] Failed to parse {md_file}: {e}")
202
+ continue
203
+ except Exception as e:
204
+ logger.error(f"[RulesInjector] Failed to scan {rules_dir}: {e}")
205
+ continue
206
+
207
+ # Sort by priority (lower numbers = higher priority)
208
+ rules.sort(key=lambda r: r.priority)
209
+
210
+ logger.info(f"[RulesInjector] Discovered {len(rules)} rules from {len(search_paths)} locations")
211
+
212
+ return rules
213
+
214
+
215
+ def extract_file_paths_from_context(params: Dict[str, Any]) -> Set[str]:
216
+ """
217
+ Extract file paths from prompt context.
218
+
219
+ Looks for:
220
+ 1. Tool references: "Read /path/to/file.py"
221
+ 2. File path patterns: file_path: "/path/to/file.py"
222
+ 3. Explicit file mentions
223
+ """
224
+ paths = set()
225
+ prompt = params.get("prompt", "")
226
+
227
+ # Common tool reference patterns
228
+ file_patterns = [
229
+ r'(?:Read|Edit|Write|MultiEdit)[\s:]+([^\s]+\.(?:py|ts|js|tsx|jsx|md|json|yaml|yml|toml|rs|go|java|cpp|c|h|hpp))',
230
+ r'file_path["\']?\s*[:=]\s*["\']?([^"\'}\s]+)',
231
+ r'path["\']?\s*[:=]\s*["\']?([^"\'}\s]+\.(?:py|ts|js|tsx|jsx|rs|go))',
232
+ r'([/~][^\s]+\.(?:py|ts|js|tsx|jsx|md|json|yaml|yml|toml|rs|go|java|cpp|c|h|hpp))',
233
+ ]
234
+
235
+ for pattern in file_patterns:
236
+ for match in re.finditer(pattern, prompt):
237
+ path_str = match.group(1).strip()
238
+ path = Path(path_str)
239
+
240
+ # Expand home directory
241
+ if path_str.startswith("~"):
242
+ path = path.expanduser()
243
+
244
+ # Check if file exists
245
+ if path.exists() and path.is_file():
246
+ paths.add(str(path.absolute()))
247
+
248
+ return paths
249
+
250
+
251
+ def match_rules_to_files(rules: list[RuleFile], file_paths: Set[str], project_path: str) -> list[RuleFile]:
252
+ """
253
+ Match discovered rules to active file paths using glob patterns.
254
+
255
+ Args:
256
+ rules: List of discovered RuleFile objects
257
+ file_paths: Set of absolute file paths from context
258
+ project_path: Project root for relative path resolution
259
+
260
+ Returns:
261
+ List of matched RuleFile objects (deduplicated, priority-sorted)
262
+ """
263
+ matched = set()
264
+ project = Path(project_path)
265
+
266
+ for file_path in file_paths:
267
+ path = Path(file_path)
268
+
269
+ # Try both absolute and relative matching
270
+ try:
271
+ relative_path = str(path.relative_to(project)) if path.is_relative_to(project) else None
272
+ except (ValueError, TypeError):
273
+ relative_path = None
274
+
275
+ for rule in rules:
276
+ # Skip disabled rules
277
+ if not rule.enabled:
278
+ continue
279
+
280
+ # Check each glob pattern
281
+ for glob_pattern in rule.globs:
282
+ matched_this_pattern = False
283
+
284
+ # Match absolute path
285
+ if fnmatch(str(path), glob_pattern):
286
+ matched_this_pattern = True
287
+
288
+ # Match relative path
289
+ elif relative_path and fnmatch(relative_path, glob_pattern):
290
+ matched_this_pattern = True
291
+
292
+ # Match filename only (for patterns like "*.py")
293
+ elif fnmatch(path.name, glob_pattern):
294
+ matched_this_pattern = True
295
+
296
+ if matched_this_pattern:
297
+ matched.add(rule)
298
+ logger.debug(f"[RulesInjector] Matched rule '{rule.name}' to {path.name}")
299
+ break # One match per rule is enough
300
+
301
+ # Return sorted by priority
302
+ return sorted(matched, key=lambda r: r.priority)
303
+
304
+
305
+ def truncate_rules_by_priority(matched_rules: list[RuleFile], max_tokens: int = MAX_RULES_TOKENS) -> list[RuleFile]:
306
+ """
307
+ Truncate rules to fit within token budget, preserving high-priority rules.
308
+
309
+ Strategy:
310
+ 1. Always include highest priority rules
311
+ 2. Truncate individual rule bodies if needed
312
+ 3. Drop lowest priority rules if still over budget
313
+ """
314
+ max_chars = max_tokens * TOKEN_ESTIMATE_RATIO
315
+
316
+ included = []
317
+ total_chars = 0
318
+
319
+ for rule in sorted(matched_rules, key=lambda r: r.priority):
320
+ rule_chars = len(rule.body) + 100 # +100 for formatting overhead
321
+
322
+ if total_chars + rule_chars <= max_chars:
323
+ # Fits completely
324
+ included.append(rule)
325
+ total_chars += rule_chars
326
+ else:
327
+ # Try truncating the rule body
328
+ remaining_budget = max_chars - total_chars
329
+ if remaining_budget > 500: # Minimum useful rule size
330
+ truncated_body = rule.body[:remaining_budget - 200] + "\n...[TRUNCATED]"
331
+ included.append(RuleFile(
332
+ name=rule.name,
333
+ path=rule.path,
334
+ scope=rule.scope,
335
+ globs=rule.globs, # Already a tuple
336
+ description=rule.description,
337
+ priority=rule.priority,
338
+ body=truncated_body,
339
+ enabled=rule.enabled
340
+ ))
341
+ total_chars += remaining_budget
342
+ # No more budget - stop including rules
343
+ break
344
+
345
+ if len(included) < len(matched_rules):
346
+ logger.warning(f"[RulesInjector] Truncated {len(matched_rules) - len(included)} rules due to token budget")
347
+
348
+ return included
349
+
350
+
351
+ def format_rules_injection(rules: list[RuleFile]) -> str:
352
+ """
353
+ Format matched rules for injection into prompt.
354
+
355
+ Returns:
356
+ Formatted markdown block with all applicable rules
357
+ """
358
+ if not rules:
359
+ return ""
360
+
361
+ header = """
362
+ > **[AUTO-RULES INJECTION]**
363
+ > The following coding standards/rules apply to files in this context:
364
+ """
365
+
366
+ rules_blocks = []
367
+ for rule in rules:
368
+ globs_display = ', '.join(rule.globs[:3])
369
+ if len(rule.globs) > 3:
370
+ globs_display += "..."
371
+
372
+ block = f"""
373
+ ---
374
+ ### Rule: {rule.name}
375
+ **Scope**: {rule.scope} | **Files**: {globs_display}
376
+ **Description**: {rule.description}
377
+
378
+ {rule.body}
379
+ ---
380
+ """
381
+ rules_blocks.append(block)
382
+
383
+ return header + "\n".join(rules_blocks)
384
+
385
+
386
+ def get_session_cache_key(session_id: str, file_paths: Set[str]) -> str:
387
+ """Generate cache key for session + file combination."""
388
+ sorted_paths = "|".join(sorted(file_paths))
389
+ return f"{session_id}:{sorted_paths}"
390
+
391
+
392
+ def is_already_injected(session_id: str, file_paths: Set[str], rule_names: list[str]) -> bool:
393
+ """
394
+ Check if rules have already been injected for this session + file combination.
395
+
396
+ Returns:
397
+ True if ALL rules have been injected before
398
+ """
399
+ cache_key = get_session_cache_key(session_id, file_paths)
400
+
401
+ if cache_key not in _rules_injection_cache:
402
+ _rules_injection_cache[cache_key] = set()
403
+
404
+ injected = _rules_injection_cache[cache_key]
405
+ new_rules = set(rule_names) - injected
406
+
407
+ if new_rules:
408
+ # Mark as injected
409
+ _rules_injection_cache[cache_key].update(new_rules)
410
+ return False
411
+
412
+ return True # All rules already injected
413
+
414
+
415
+ def clear_session_cache(session_id: str):
416
+ """Clear all cached injections for a session (call on session_compact or session_end)."""
417
+ keys_to_remove = [k for k in _rules_injection_cache if k.startswith(f"{session_id}:")]
418
+ for key in keys_to_remove:
419
+ del _rules_injection_cache[key]
420
+
421
+ # Also clear rules cache
422
+ keys_to_remove = [k for k in _session_rules_cache if k.startswith(f"{session_id}:")]
423
+ for key in keys_to_remove:
424
+ del _session_rules_cache[key]
425
+
426
+
427
+ def get_cached_rules(session_id: str, project_path: str) -> list[RuleFile]:
428
+ """Get or discover rules for session (cached)."""
429
+ cache_key = f"{session_id}:{project_path}"
430
+
431
+ if cache_key not in _session_rules_cache:
432
+ _session_rules_cache[cache_key] = discover_rules(project_path)
433
+
434
+ return _session_rules_cache[cache_key]
435
+
436
+
437
+ def get_project_path_from_prompt(prompt: str) -> Optional[str]:
438
+ """Extract project path from prompt if available."""
439
+ # Look for common working directory indicators
440
+ patterns = [
441
+ r'Working directory:\s*([^\n]+)',
442
+ r'Project path:\s*([^\n]+)',
443
+ r'cwd:\s*([^\n]+)',
444
+ ]
445
+
446
+ for pattern in patterns:
447
+ match = re.search(pattern, prompt)
448
+ if match:
449
+ return match.group(1).strip()
450
+
451
+ # Default to current working directory
452
+ return str(Path.cwd())
453
+
454
+
455
+ async def rules_injector_hook(params: Dict[str, Any]) -> Optional[Dict[str, Any]]:
456
+ """
457
+ Pre-model-invoke hook for automatic rules injection.
458
+
459
+ Gracefully degrades on errors - never blocks model invocation.
460
+ """
461
+ try:
462
+ # 1. Extract session ID
463
+ session_id = params.get("session_id", "unknown")
464
+
465
+ # 2. Extract file paths from context
466
+ file_paths = extract_file_paths_from_context(params)
467
+
468
+ if not file_paths:
469
+ # No file context - skip injection
470
+ return None
471
+
472
+ # 3. Get project path and discover rules
473
+ project_path = get_project_path_from_prompt(params.get("prompt", ""))
474
+ rules = get_cached_rules(session_id, project_path)
475
+
476
+ if not rules:
477
+ # No rules defined - skip
478
+ return None
479
+
480
+ # 4. Match rules to files
481
+ matched = match_rules_to_files(rules, file_paths, project_path)
482
+
483
+ if not matched:
484
+ # No matching rules - skip
485
+ return None
486
+
487
+ # 5. Check deduplication cache
488
+ rule_names = [r.name for r in matched]
489
+ if is_already_injected(session_id, file_paths, rule_names):
490
+ logger.debug(f"[RulesInjector] Rules already injected for session {session_id}")
491
+ return None
492
+
493
+ # 6. Truncate if needed
494
+ truncated = truncate_rules_by_priority(matched)
495
+
496
+ # 7. Format and inject
497
+ injection = format_rules_injection(truncated)
498
+ modified_params = params.copy()
499
+ modified_params["prompt"] = injection + "\n\n" + params.get("prompt", "")
500
+
501
+ logger.info(f"[RulesInjector] Injected {len(truncated)} rules for {len(file_paths)} files")
502
+ return modified_params
503
+
504
+ except Exception as e:
505
+ # Log error but DON'T block the model invocation
506
+ logger.error(f"[RulesInjector] Hook failed: {e}", exc_info=True)
507
+ return None # Continue without rule injection
@@ -0,0 +1,125 @@
1
+ """
2
+ Session Notification Hook.
3
+
4
+ Provides OS-level desktop notifications when sessions are idle.
5
+ """
6
+
7
+ import logging
8
+ import platform
9
+ import subprocess
10
+ from typing import Any, Dict, Optional, Set
11
+
12
+ logger = logging.getLogger(__name__)
13
+
14
+ # Track which sessions have been notified (avoid duplicates)
15
+ _notified_sessions: Set[str] = set()
16
+
17
+
18
+ def get_notification_command(title: str, message: str, sound: bool = True) -> Optional[list]:
19
+ """
20
+ Get platform-specific notification command.
21
+
22
+ Returns command as list of args, or None if platform not supported.
23
+ """
24
+ system = platform.system()
25
+
26
+ if system == "Darwin": # macOS
27
+ # Use osascript for macOS notifications
28
+ script = f'display notification "{message}" with title "{title}"'
29
+ if sound:
30
+ script += ' sound name "Glass"'
31
+ return ["osascript", "-e", script]
32
+
33
+ elif system == "Linux":
34
+ # Use notify-send for Linux
35
+ cmd = ["notify-send", title, message]
36
+ if sound:
37
+ cmd.extend(["--urgency=normal"])
38
+ return cmd
39
+
40
+ elif system == "Windows":
41
+ # Use PowerShell for Windows notifications
42
+ ps_script = f"""
43
+ [Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, ContentType = WindowsRuntime] | Out-Null
44
+ [Windows.UI.Notifications.ToastNotification, Windows.UI.Notifications, ContentType = WindowsRuntime] | Out-Null
45
+ [Windows.Data.Xml.Dom.XmlDocument, Windows.Data.Xml.Dom.XmlDocument, ContentType = WindowsRuntime] | Out-Null
46
+
47
+ $template = @"
48
+ <toast>
49
+ <visual>
50
+ <binding template="ToastGeneric">
51
+ <text>{title}</text>
52
+ <text>{message}</text>
53
+ </binding>
54
+ </visual>
55
+ </toast>
56
+ "@
57
+
58
+ $xml = New-Object Windows.Data.Xml.Dom.XmlDocument
59
+ $xml.LoadXml($template)
60
+ $toast = New-Object Windows.UI.Notifications.ToastNotification $xml
61
+ [Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier("Stravinsky").Show($toast)
62
+ """
63
+ return ["powershell", "-Command", ps_script]
64
+
65
+ return None
66
+
67
+
68
+ async def session_notifier_hook(
69
+ session_id: str,
70
+ has_pending_todos: bool,
71
+ idle_seconds: float,
72
+ params: Dict[str, Any]
73
+ ) -> None:
74
+ """
75
+ Session idle hook that sends desktop notification.
76
+
77
+ Called when session becomes idle with pending work.
78
+ """
79
+ # Skip if already notified for this session
80
+ if session_id in _notified_sessions:
81
+ return
82
+
83
+ # Skip if no pending work
84
+ if not has_pending_todos:
85
+ return
86
+
87
+ # Skip if idle time is too short (< 5 seconds)
88
+ if idle_seconds < 5.0:
89
+ return
90
+
91
+ # Prepare notification
92
+ title = "Stravinsky Session Idle"
93
+ message = f"Session has pending todos and has been idle for {int(idle_seconds)}s"
94
+
95
+ # Get platform-specific command
96
+ cmd = get_notification_command(title, message, sound=True)
97
+
98
+ if not cmd:
99
+ logger.warning(f"[SessionNotifier] Desktop notifications not supported on {platform.system()}")
100
+ return
101
+
102
+ try:
103
+ # Send notification (non-blocking)
104
+ subprocess.Popen(
105
+ cmd,
106
+ stdout=subprocess.DEVNULL,
107
+ stderr=subprocess.DEVNULL,
108
+ start_new_session=True
109
+ )
110
+
111
+ # Mark as notified
112
+ _notified_sessions.add(session_id)
113
+ logger.info(f"[SessionNotifier] Sent desktop notification for session {session_id}")
114
+
115
+ except FileNotFoundError:
116
+ logger.warning(f"[SessionNotifier] Notification command not found: {cmd[0]}")
117
+ except Exception as e:
118
+ logger.error(f"[SessionNotifier] Failed to send notification: {e}")
119
+
120
+
121
+ def clear_notification_state(session_id: str) -> None:
122
+ """
123
+ Clear notification state for a session (called when session resumes activity).
124
+ """
125
+ _notified_sessions.discard(session_id)