omnish 1.6.6 → 2.0.0

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 (38) hide show
  1. package/CHANGELOG.md +32 -0
  2. package/README.md +26 -18
  3. package/config.example.json +1 -0
  4. package/dist/downloads/omnish-claude/install.sh +174 -0
  5. package/dist/downloads/omnish-claude/uninstall.sh +114 -0
  6. package/dist/downloads/omnish-claude.tar.gz +0 -0
  7. package/dist/downloads/omnish-cursor/install.sh +162 -0
  8. package/dist/downloads/omnish-cursor/uninstall.sh +107 -0
  9. package/dist/downloads/omnish-cursor.tar.gz +0 -0
  10. package/dist/index.js +424 -392
  11. package/dist/omnish-claude/README.md +80 -0
  12. package/dist/omnish-claude/hooks/omnish-notify.py +213 -0
  13. package/dist/omnish-claude/hooks/omnish-notify.sh +13 -0
  14. package/dist/omnish-claude/install.sh +174 -0
  15. package/dist/omnish-claude/omnish-notify.json.example +8 -0
  16. package/dist/omnish-claude/scripts/doctor.sh +99 -0
  17. package/dist/omnish-claude/skill/SKILL.md +39 -0
  18. package/dist/omnish-claude/skill/files-and-sharing.md +94 -0
  19. package/dist/omnish-claude/skill/notifications.md +113 -0
  20. package/dist/omnish-claude/skill/setup-paths.md +81 -0
  21. package/dist/omnish-claude/uninstall.sh +114 -0
  22. package/dist/omnish-cursor/README.md +176 -0
  23. package/dist/omnish-cursor/hooks/__pycache__/omnish-notify.cpython-313.pyc +0 -0
  24. package/dist/omnish-cursor/hooks/omnish-notify.py +563 -0
  25. package/dist/omnish-cursor/hooks/omnish-notify.sh +13 -0
  26. package/dist/omnish-cursor/hooks/omnish-session-start.sh +48 -0
  27. package/dist/omnish-cursor/install.sh +162 -0
  28. package/dist/omnish-cursor/omnish-notify.json.example +13 -0
  29. package/dist/omnish-cursor/rules/omnish-notify.mdc +45 -0
  30. package/dist/omnish-cursor/scripts/doctor.sh +126 -0
  31. package/dist/omnish-cursor/skill/SKILL.md +129 -0
  32. package/dist/omnish-cursor/skill/files-and-sharing.md +94 -0
  33. package/dist/omnish-cursor/skill/notifications.md +155 -0
  34. package/dist/omnish-cursor/skill/setup-paths.md +81 -0
  35. package/dist/omnish-cursor/uninstall.sh +107 -0
  36. package/dist/ui/assets/{index-aUJGrxrr.js → index-BwG51a2I.js} +10 -10
  37. package/dist/ui/index.html +16 -1
  38. package/package.json +12 -4
@@ -0,0 +1,563 @@
1
+ #!/usr/bin/env python3
2
+ """Cursor stop hook: send curated Omnish briefs when agent work is worth notifying."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import hashlib
7
+ import json
8
+ import os
9
+ import re
10
+ import subprocess
11
+ import sys
12
+ import time
13
+ from pathlib import Path
14
+ from typing import Any
15
+
16
+ CONFIG_PATH = Path.home() / ".cursor" / "omnish-notify.json"
17
+ LOG_PATH = Path.home() / ".cursor" / "hooks" / "omnish-notify.log"
18
+ STATE_PATH = Path.home() / ".cursor" / "hooks" / "omnish-notify-state.json"
19
+ CHATS_ROOT = Path.home() / ".cursor" / "chats"
20
+
21
+ # Only edits/deliverables count as substantive — Shell/Grep/Read alone should not ping.
22
+ WRITE_TOOLS = {
23
+ "Write",
24
+ "StrReplace",
25
+ "Delete",
26
+ "EditNotebook",
27
+ "ApplyPatch",
28
+ "GenerateImage",
29
+ }
30
+
31
+ ACTION_TOOLS = WRITE_TOOLS | {
32
+ "Shell",
33
+ "Task",
34
+ }
35
+
36
+ SENTENCE_END = re.compile(r"(?<=[.!?])\s+")
37
+
38
+
39
+ def log(message: str) -> None:
40
+ try:
41
+ LOG_PATH.parent.mkdir(parents=True, exist_ok=True)
42
+ with LOG_PATH.open("a", encoding="utf-8") as handle:
43
+ handle.write(message.rstrip() + "\n")
44
+ except OSError:
45
+ pass
46
+
47
+
48
+ def load_config() -> dict[str, Any]:
49
+ defaults: dict[str, Any] = {
50
+ "enabled": True,
51
+ "peer": os.environ.get("OMNISH_CURSOR_NOTIFY_PEER", "*"),
52
+ "min_assistant_chars": 120,
53
+ "notify_on_error": True,
54
+ "skip_aborted": True,
55
+ "include_full_chat_id": True,
56
+ "summary_max_chars": 300,
57
+ "followup_max_chars": 1000,
58
+ "max_followup_messages": 0,
59
+ "send_followups": False,
60
+ "dedupe_minutes": 45,
61
+ }
62
+ if CONFIG_PATH.exists():
63
+ try:
64
+ defaults.update(json.loads(CONFIG_PATH.read_text(encoding="utf-8")))
65
+ except (OSError, json.JSONDecodeError) as exc:
66
+ log(f"config read failed: {exc}")
67
+ return defaults
68
+
69
+
70
+ def strip_user_query(text: str) -> str:
71
+ cleaned = re.sub(r"</?user_query>", "", text, flags=re.IGNORECASE)
72
+ lines = [re.sub(r"\s+", " ", line).strip() for line in cleaned.splitlines()]
73
+ return "\n".join(line for line in lines if line).strip()
74
+
75
+
76
+ def normalize_notify_text(text: str) -> str:
77
+ text = re.sub(r"\[REDACTED\]", "", text)
78
+ lines = [re.sub(r"[ \t]+", " ", line).strip() for line in text.splitlines()]
79
+ compact = "\n".join(line for line in lines if line).strip()
80
+ return re.sub(r"\n{3,}", "\n\n", compact)
81
+
82
+
83
+ def truncate_at_sentence(text: str, max_chars: int) -> tuple[str, bool]:
84
+ compact = normalize_notify_text(text)
85
+ if len(compact) <= max_chars:
86
+ return compact, False
87
+
88
+ window = compact[: max_chars + 1]
89
+ last_break = -1
90
+ for match in SENTENCE_END.finditer(window):
91
+ if match.start() <= max_chars:
92
+ last_break = match.start()
93
+
94
+ if last_break > max_chars // 2:
95
+ return compact[:last_break].rstrip(), True
96
+
97
+ trimmed = compact[:max_chars].rstrip()
98
+ if " " in trimmed:
99
+ trimmed = trimmed.rsplit(" ", 1)[0].rstrip()
100
+ return trimmed + "…", True
101
+
102
+
103
+ def extract_summary(text: str, max_chars: int) -> str:
104
+ compact = normalize_notify_text(text)
105
+ if not compact:
106
+ return ""
107
+
108
+ sentences: list[str] = []
109
+ for part in SENTENCE_END.split(compact):
110
+ piece = part.strip()
111
+ if not piece:
112
+ continue
113
+ if not piece.endswith((".", "!", "?")):
114
+ piece += "."
115
+ sentences.append(piece)
116
+ joined = " ".join(sentences)
117
+ if len(joined) > max_chars:
118
+ sentences.pop()
119
+ break
120
+
121
+ if sentences:
122
+ summary = " ".join(sentences).strip()
123
+ if len(summary) <= max_chars:
124
+ return summary
125
+
126
+ return truncate_at_sentence(compact, max_chars)[0]
127
+
128
+
129
+ def chunk_text(text: str, max_chars: int) -> list[str]:
130
+ compact = normalize_notify_text(text)
131
+ if not compact:
132
+ return []
133
+ if len(compact) <= max_chars:
134
+ return [compact]
135
+
136
+ chunks: list[str] = []
137
+ remaining = compact
138
+ while remaining:
139
+ if len(remaining) <= max_chars:
140
+ chunks.append(remaining)
141
+ break
142
+ piece, truncated = truncate_at_sentence(remaining, max_chars)
143
+ if not piece:
144
+ chunks.append(remaining[:max_chars].rstrip() + "…")
145
+ break
146
+ chunks.append(piece)
147
+ if not truncated:
148
+ break
149
+ core = piece[:-1].rstrip() if piece.endswith("…") else piece
150
+ idx = remaining.find(core)
151
+ if idx == -1:
152
+ remaining = remaining[max_chars:].lstrip()
153
+ else:
154
+ remaining = remaining[idx + len(core) :].lstrip()
155
+ return chunks
156
+
157
+
158
+ def flatten_text(content: Any) -> str:
159
+ if isinstance(content, str):
160
+ return content.strip()
161
+ if isinstance(content, list):
162
+ parts: list[str] = []
163
+ for item in content:
164
+ if isinstance(item, dict):
165
+ if item.get("type") == "text" and isinstance(item.get("text"), str):
166
+ parts.append(item["text"])
167
+ elif isinstance(item.get("text"), str):
168
+ parts.append(item["text"])
169
+ elif isinstance(item, str):
170
+ parts.append(item)
171
+ return "\n".join(part.strip() for part in parts if part.strip()).strip()
172
+ return ""
173
+
174
+
175
+ def parse_transcript(path: str | None) -> dict[str, Any]:
176
+ result: dict[str, Any] = {
177
+ "user_query": "",
178
+ "assistant_summary": "",
179
+ "tool_count": 0,
180
+ "action_tool_count": 0,
181
+ "write_tool_count": 0,
182
+ "assistant_chars": 0,
183
+ "turns": 0,
184
+ }
185
+ if not path:
186
+ return result
187
+
188
+ transcript_path = Path(path)
189
+ if not transcript_path.is_file():
190
+ return result
191
+
192
+ try:
193
+ lines = transcript_path.read_text(encoding="utf-8", errors="replace").splitlines()
194
+ except OSError as exc:
195
+ log(f"transcript read failed: {exc}")
196
+ return result
197
+
198
+ last_user = ""
199
+ last_assistant = ""
200
+ last_assistant_nonempty = ""
201
+
202
+ for line in lines:
203
+ line = line.strip()
204
+ if not line:
205
+ continue
206
+ try:
207
+ entry = json.loads(line)
208
+ except json.JSONDecodeError:
209
+ continue
210
+
211
+ role = entry.get("role")
212
+ message = entry.get("message") or {}
213
+ content = message.get("content")
214
+
215
+ if role == "user":
216
+ text = flatten_text(content)
217
+ query = strip_user_query(text)
218
+ if query:
219
+ last_user = query
220
+ result["turns"] += 1
221
+ elif role == "assistant":
222
+ if isinstance(content, list):
223
+ for item in content:
224
+ if not isinstance(item, dict):
225
+ continue
226
+ if item.get("type") == "tool_use":
227
+ result["tool_count"] += 1
228
+ tool_name = item.get("name") or item.get("tool_name")
229
+ if tool_name in ACTION_TOOLS:
230
+ result["action_tool_count"] += 1
231
+ if tool_name in WRITE_TOOLS:
232
+ result["write_tool_count"] += 1
233
+ elif item.get("type") == "text":
234
+ text = item.get("text", "")
235
+ if text.strip():
236
+ last_assistant = text.strip()
237
+ cleaned = re.sub(r"\[REDACTED\]", "", text).strip()
238
+ if cleaned:
239
+ last_assistant_nonempty = cleaned
240
+ elif isinstance(content, str) and content.strip():
241
+ last_assistant = content.strip()
242
+ cleaned = re.sub(r"\[REDACTED\]", "", content).strip()
243
+ if cleaned:
244
+ last_assistant_nonempty = cleaned
245
+
246
+ result["user_query"] = last_user
247
+ result["assistant_summary"] = last_assistant_nonempty or last_assistant
248
+ result["assistant_chars"] = len(last_assistant)
249
+ return result
250
+
251
+
252
+ def find_chat_title(conversation_id: str) -> str:
253
+ if not conversation_id or not CHATS_ROOT.is_dir():
254
+ return ""
255
+
256
+ try:
257
+ for workspace_dir in CHATS_ROOT.iterdir():
258
+ meta_path = workspace_dir / conversation_id / "meta.json"
259
+ if not meta_path.is_file():
260
+ continue
261
+ meta = json.loads(meta_path.read_text(encoding="utf-8"))
262
+ title = str(meta.get("title") or "").strip()
263
+ if title:
264
+ return title
265
+ except (OSError, json.JSONDecodeError):
266
+ pass
267
+ return ""
268
+
269
+
270
+ def should_notify(payload: dict[str, Any], transcript: dict[str, Any], cfg: dict[str, Any]) -> bool:
271
+ status = payload.get("status", "completed")
272
+ if cfg.get("skip_aborted") and status == "aborted":
273
+ return False
274
+
275
+ if status == "error" and cfg.get("notify_on_error"):
276
+ return True
277
+
278
+ # Substantive = repo/file changes. Read-only Shell/Grep turns stay silent.
279
+ if transcript.get("write_tool_count", 0) > 0:
280
+ return True
281
+
282
+ return False
283
+
284
+
285
+ def notify_fingerprint(conversation_id: str, user_query: str) -> str:
286
+ normalized = re.sub(r"\s+", " ", (user_query or "").lower().strip())[:500]
287
+ raw = f"{conversation_id}|{normalized}".encode("utf-8")
288
+ return hashlib.sha256(raw).hexdigest()[:20]
289
+
290
+
291
+ def load_notify_state() -> dict[str, Any]:
292
+ if not STATE_PATH.is_file():
293
+ return {"entries": {}}
294
+ try:
295
+ data = json.loads(STATE_PATH.read_text(encoding="utf-8"))
296
+ if isinstance(data, dict) and isinstance(data.get("entries"), dict):
297
+ return data
298
+ except (OSError, json.JSONDecodeError):
299
+ pass
300
+ return {"entries": {}}
301
+
302
+
303
+ def save_notify_state(state: dict[str, Any]) -> None:
304
+ try:
305
+ STATE_PATH.parent.mkdir(parents=True, exist_ok=True)
306
+ entries = state.get("entries") or {}
307
+ if len(entries) > 200:
308
+ sorted_keys = sorted(
309
+ entries.keys(),
310
+ key=lambda k: float((entries[k] or {}).get("ts") or 0),
311
+ )
312
+ for key in sorted_keys[: len(entries) - 200]:
313
+ entries.pop(key, None)
314
+ STATE_PATH.write_text(json.dumps(state, indent=2) + "\n", encoding="utf-8")
315
+ except OSError as exc:
316
+ log(f"state write failed: {exc}")
317
+
318
+
319
+ def is_duplicate_notify(
320
+ conversation_id: str,
321
+ user_query: str,
322
+ cfg: dict[str, Any],
323
+ ) -> bool:
324
+ cooldown_min = int(cfg.get("dedupe_minutes") or 45)
325
+ if cooldown_min <= 0 or not conversation_id:
326
+ return False
327
+
328
+ fp = notify_fingerprint(conversation_id, user_query)
329
+ state = load_notify_state()
330
+ entries = state.setdefault("entries", {})
331
+ prev = entries.get(fp)
332
+ now = time.time()
333
+ if prev and now - float(prev.get("ts") or 0) < cooldown_min * 60:
334
+ return True
335
+
336
+ entries[fp] = {"ts": now}
337
+ save_notify_state(state)
338
+ return False
339
+
340
+
341
+ def build_title(payload: dict[str, Any], cfg: dict[str, Any]) -> str:
342
+ conversation_id = str(payload.get("conversation_id") or "").strip()
343
+ status = str(payload.get("status") or "completed")
344
+ chat_title = find_chat_title(conversation_id)
345
+
346
+ if status == "error":
347
+ base = "Cursor Error"
348
+ elif status == "aborted":
349
+ base = "Cursor Aborted"
350
+ else:
351
+ base = "Cursor Done"
352
+
353
+ if chat_title:
354
+ short_title, _ = truncate_at_sentence(chat_title, 48)
355
+ return f"{base}: {short_title}"
356
+ return base
357
+
358
+
359
+ def build_main_brief(
360
+ payload: dict[str, Any],
361
+ transcript: dict[str, Any],
362
+ cfg: dict[str, Any],
363
+ ) -> str:
364
+ conversation_id = str(payload.get("conversation_id") or "").strip()
365
+ short_id = conversation_id[:8] if conversation_id else "unknown"
366
+ status = str(payload.get("status") or "completed")
367
+ workspace_roots = payload.get("workspace_roots") or []
368
+ workspace = workspace_roots[0] if workspace_roots else ""
369
+
370
+ summary_limit = int(cfg.get("summary_max_chars") or 300)
371
+ user_query = normalize_notify_text(transcript.get("user_query") or "")
372
+ assistant_summary = normalize_notify_text(transcript.get("assistant_summary") or "")
373
+
374
+ lines: list[str] = []
375
+
376
+ if status == "error":
377
+ lines.append("Status: Error")
378
+ elif status == "aborted":
379
+ lines.append("Status: Aborted")
380
+ else:
381
+ lines.append("Status: Done")
382
+
383
+ if cfg.get("include_full_chat_id", True) and conversation_id:
384
+ lines.append(f"Chat: {conversation_id}")
385
+ else:
386
+ lines.append(f"Chat: {short_id}")
387
+
388
+ if workspace:
389
+ lines.append(f"Workspace: {Path(workspace).name}")
390
+
391
+ if user_query:
392
+ preview, truncated = truncate_at_sentence(user_query, min(summary_limit, 220))
393
+ lines.append("")
394
+ lines.append("You asked:")
395
+ lines.append(preview)
396
+ if truncated:
397
+ lines.append("(see follow-up for full request)")
398
+
399
+ lines.append("")
400
+ lines.append("Outcome:")
401
+ if assistant_summary:
402
+ lines.append(extract_summary(assistant_summary, summary_limit))
403
+ elif status == "error":
404
+ lines.append("Agent ended with an error.")
405
+ else:
406
+ lines.append("Agent finished.")
407
+
408
+ if transcript["action_tool_count"] > 0:
409
+ lines.append("")
410
+ lines.append(f"Tools: {transcript['action_tool_count']} action(s)")
411
+
412
+ return "\n".join(lines).strip()
413
+
414
+
415
+ def build_followups(
416
+ payload: dict[str, Any],
417
+ transcript: dict[str, Any],
418
+ cfg: dict[str, Any],
419
+ ) -> list[tuple[str, str]]:
420
+ if not cfg.get("send_followups", True):
421
+ return []
422
+
423
+ max_followups = max(0, int(cfg.get("max_followup_messages") or 2))
424
+ if max_followups == 0:
425
+ return []
426
+
427
+ followup_limit = int(cfg.get("followup_max_chars") or 1000)
428
+ summary_limit = int(cfg.get("summary_max_chars") or 300)
429
+ conversation_id = str(payload.get("conversation_id") or "").strip()
430
+ short_id = conversation_id[:8] if conversation_id else "unknown"
431
+ chat_ref = conversation_id if cfg.get("include_full_chat_id", True) and conversation_id else short_id
432
+
433
+ user_query = normalize_notify_text(transcript.get("user_query") or "")
434
+ assistant_summary = normalize_notify_text(transcript.get("assistant_summary") or "")
435
+
436
+ followups: list[tuple[str, str]] = []
437
+
438
+ if user_query:
439
+ preview, was_truncated = truncate_at_sentence(user_query, min(summary_limit, 220))
440
+ if was_truncated or len(user_query) > len(preview) + 20:
441
+ for index, chunk in enumerate(chunk_text(user_query, followup_limit), start=1):
442
+ if len(followups) >= max_followups:
443
+ break
444
+ suffix = f" ({index})" if len(chunk_text(user_query, followup_limit)) > 1 else ""
445
+ body = f"Chat: {chat_ref}\n\nYou asked:\n{chunk}"
446
+ followups.append((f"Cursor · Request{suffix}", body))
447
+
448
+ if assistant_summary and len(followups) < max_followups:
449
+ summary = extract_summary(assistant_summary, summary_limit)
450
+ summary_core = summary.rstrip("…")
451
+ tail = assistant_summary
452
+ if summary_core and tail.startswith(summary_core):
453
+ tail = tail[len(summary_core) :].lstrip(" .,;:-")
454
+ if len(tail) >= 40:
455
+ parts = chunk_text(tail, followup_limit)
456
+ for index, chunk in enumerate(parts, start=1):
457
+ if len(followups) >= max_followups:
458
+ break
459
+ suffix = f" ({index})" if len(parts) > 1 else ""
460
+ body = f"Chat: {chat_ref}\n\nDetails:\n{chunk}"
461
+ followups.append((f"Cursor · Outcome{suffix}", body))
462
+
463
+ return followups[:max_followups]
464
+
465
+
466
+ def build_messages(
467
+ payload: dict[str, Any],
468
+ transcript: dict[str, Any],
469
+ cfg: dict[str, Any],
470
+ ) -> list[tuple[str, str]]:
471
+ title = build_title(payload, cfg)
472
+ main_brief = build_main_brief(payload, transcript, cfg)
473
+ if not main_brief.strip():
474
+ return []
475
+
476
+ messages = [(title, main_brief)]
477
+ messages.extend(build_followups(payload, transcript, cfg))
478
+ return messages
479
+
480
+
481
+ def send_notification(title: str, brief: str, peer: str) -> None:
482
+ destination = (peer or "*").strip() or "*"
483
+ env = os.environ.copy()
484
+ if destination != "*":
485
+ env["OMNISH_PEER_KEY"] = destination
486
+
487
+ inner = f"/sendto {destination} -t {title} -- {brief}"
488
+ subprocess.run(
489
+ ["omnish", "i", "-c", inner],
490
+ check=False,
491
+ env=env,
492
+ stdout=subprocess.DEVNULL,
493
+ stderr=subprocess.DEVNULL,
494
+ timeout=30,
495
+ )
496
+
497
+
498
+ def which(name: str) -> str | None:
499
+ for directory in os.environ.get("PATH", "").split(os.pathsep):
500
+ candidate = Path(directory) / name
501
+ if candidate.is_file() and os.access(candidate, os.X_OK):
502
+ return str(candidate)
503
+ return None
504
+
505
+
506
+ def main() -> int:
507
+ try:
508
+ payload = json.load(sys.stdin)
509
+ except json.JSONDecodeError as exc:
510
+ log(f"invalid hook payload: {exc}")
511
+ print("{}")
512
+ return 0
513
+
514
+ cfg = load_config()
515
+ if not cfg.get("enabled", True):
516
+ print("{}")
517
+ return 0
518
+
519
+ transcript = parse_transcript(payload.get("transcript_path"))
520
+ if not should_notify(payload, transcript, cfg):
521
+ log(f"skip notify chat={payload.get('conversation_id')} status={payload.get('status')}")
522
+ print("{}")
523
+ return 0
524
+
525
+ conversation_id = str(payload.get("conversation_id") or "").strip()
526
+ user_query = transcript.get("user_query") or ""
527
+ if is_duplicate_notify(conversation_id, user_query, cfg):
528
+ log(f"skip duplicate chat={conversation_id}")
529
+ print("{}")
530
+ return 0
531
+
532
+ messages = build_messages(payload, transcript, cfg)
533
+ if not messages:
534
+ print("{}")
535
+ return 0
536
+
537
+ peer = str(cfg.get("peer") or "*").strip() or "*"
538
+
539
+ if not which("omnish"):
540
+ log("omnish not found on PATH")
541
+ print("{}")
542
+ return 0
543
+
544
+ try:
545
+ for index, (title, brief) in enumerate(messages):
546
+ if index > 0:
547
+ time.sleep(0.4)
548
+ send_notification(title, brief, peer)
549
+ log(
550
+ f"sent chat={payload.get('conversation_id')} dest={peer!r} "
551
+ f"messages={len(messages)} title={messages[0][0]!r}"
552
+ )
553
+ except subprocess.TimeoutExpired:
554
+ log(f"notify timeout chat={payload.get('conversation_id')}")
555
+ except OSError as exc:
556
+ log(f"notify failed: {exc}")
557
+
558
+ print("{}")
559
+ return 0
560
+
561
+
562
+ if __name__ == "__main__":
563
+ raise SystemExit(main())
@@ -0,0 +1,13 @@
1
+ #!/usr/bin/env bash
2
+ # stop hook: send curated Omnish brief when Cursor agent work is worth notifying.
3
+ set -euo pipefail
4
+
5
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
6
+ PYTHON="${OMNISH_NOTIFY_PYTHON:-python3}"
7
+
8
+ if ! command -v "$PYTHON" >/dev/null 2>&1; then
9
+ echo '{}'
10
+ exit 0
11
+ fi
12
+
13
+ exec "$PYTHON" "$SCRIPT_DIR/omnish-notify.py"
@@ -0,0 +1,48 @@
1
+ #!/usr/bin/env bash
2
+ # sessionStart hook: inject Omnish notification guidance into every agent session.
3
+ set -euo pipefail
4
+
5
+ read -r _input
6
+
7
+ python3 - <<'PY'
8
+ import json
9
+ import os
10
+ from pathlib import Path
11
+
12
+ cfg_path = Path.home() / ".cursor" / "omnish-notify.json"
13
+ enabled = True
14
+ peer = os.environ.get("OMNISH_CURSOR_NOTIFY_PEER", "*")
15
+ if cfg_path.is_file():
16
+ try:
17
+ cfg = json.loads(cfg_path.read_text(encoding="utf-8"))
18
+ enabled = bool(cfg.get("enabled", True))
19
+ peer = str(cfg.get("peer") or peer)
20
+ except Exception:
21
+ pass
22
+
23
+ if not enabled:
24
+ print("{}")
25
+ raise SystemExit(0)
26
+
27
+ dest = peer if peer and peer != "peer" else "*"
28
+ notify_cmd = f"omnish i -c '/sendto {dest} -t <title> -- <brief>'"
29
+
30
+ context = f"""Omnish mobile notifications (global user policy):
31
+ - When you finish useful work, or hit a blocker that needs human input, send a concise status via Omnish.
32
+ - Command: {notify_cmd}
33
+ - Always include the Cursor chat id (conversation_id) in the brief when notifying.
34
+ - Send only when the update is actionable: Done, Progress, Question, or Blocked. Skip trivial Q&A, pure exploration with no outcome, or duplicate "still working" pings.
35
+ - Prefer one Done notification at completion; use Progress sparingly on major milestones.
36
+ - Default destination is {dest!r} (all allowlisted peers on this gateway). Requires omnish run with platform token or local WhatsApp/Telegram."""
37
+
38
+ out = {
39
+ "additional_context": context,
40
+ "env": {
41
+ "OMNISH_NOTIFY_CMD": f"omnish i -c '/sendto {dest} -t <title> -- <brief>'",
42
+ },
43
+ }
44
+ if dest != "*":
45
+ out["env"]["OMNISH_PEER_KEY"] = dest
46
+
47
+ print(json.dumps(out))
48
+ PY