nexo-brain 7.9.10 → 7.9.12

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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nexo-brain",
3
- "version": "7.9.10",
3
+ "version": "7.9.12",
4
4
  "description": "Local cognitive runtime for Claude Code \u2014 persistent memory, overnight learning, doctor diagnostics, personal scripts, recovery-aware jobs, startup preflight, and optional dashboard/power helper.",
5
5
  "author": {
6
6
  "name": "NEXO Brain",
package/README.md CHANGED
@@ -18,7 +18,7 @@
18
18
 
19
19
  [Watch the overview video](https://nexo-brain.com/watch/) · [Watch on YouTube](https://www.youtube.com/watch?v=i2lkGhKyVqI) · [Open the infographic](https://nexo-brain.com/assets/nexo-brain-infographic-v5.png)
20
20
 
21
- Version `7.9.10` is the current packaged-runtime line. Hotfix release over `7.9.9`: `nexo_doctor` now defaults blank calls to `runtime_personal` instead of failing on missing `plane`, and non-interactive installer/update paths preserve existing identity defaults from runtime profile/calibration metadata instead of blindly rewriting operators to generic onboarding defaults. Desktop remains at v0.28.11 because the extra fixes are Brain-only.
21
+ Version `7.9.12` is the current packaged-runtime line. Patch release over `7.9.11`: managed `~/.nexo/bin/nexo` wrappers now self-repair `core/current` when it lags behind `~/.nexo/core`, so installed users stop executing stale snapshots and `nexo update` no longer gets stuck claiming “Already up to date” from the old runtime. Coordinated Desktop release: v0.28.13.
22
22
 
23
23
  Previously in `7.9.5`: patch release that fixes canonical diary confirmation for Desktop: Brain resolves the Desktop/Claude session UUID through NEXO SID aliases before checking `session_diary`, so archive/delete/app-exit can confirm diaries written by `nexo_session_diary_write` under the active `nexo-...` SID. Verification: `pytest tests/test_lifecycle_events.py` (28 passing) plus coordinated Desktop v0.28.6 shutdown/archive/delete/app-exit checks.
24
24
 
package/bin/nexo-brain.js CHANGED
@@ -3442,6 +3442,31 @@ async function runSetup() {
3442
3442
  ' echo "NEXO runtime Python not found. Run nexo-brain or nexo update to repair the installation." >&2',
3443
3443
  ' exit 1',
3444
3444
  'fi',
3445
+ 'read_runtime_version() {',
3446
+ ' local base="${1:-}"',
3447
+ ' [ -n "$base" ] || return 0',
3448
+ ' local candidate=""',
3449
+ ' for candidate in "$base/version.json" "$base/package.json"; do',
3450
+ ' [ -f "$candidate" ] || continue',
3451
+ ' "$PYTHON" -c "import json, sys; from pathlib import Path; payload=json.loads(Path(sys.argv[1]).read_text(encoding=\\"utf-8\\")); version=str(payload.get(\\"version\\", \\"\\")).strip(); sys.stdout.write(version); sys.exit(0 if version else 1)" "$candidate" 2>/dev/null || continue',
3452
+ ' return 0',
3453
+ ' done',
3454
+ ' return 0',
3455
+ '}',
3456
+ 'repair_stale_current_runtime() {',
3457
+ ' local core_root="$NEXO_HOME/core"',
3458
+ ' local current_root="$NEXO_HOME/core/current"',
3459
+ ' [ -d "$core_root" ] || return 0',
3460
+ ' [ -e "$current_root" ] || return 0',
3461
+ ' local core_version=""',
3462
+ ' local current_version=""',
3463
+ ' core_version="$(read_runtime_version "$core_root")"',
3464
+ ' current_version="$(read_runtime_version "$current_root")"',
3465
+ ' [ -n "$core_version" ] || return 0',
3466
+ ' [ "$core_version" = "$current_version" ] && return 0',
3467
+ ' NEXO_HOME="$NEXO_HOME" NEXO_CODE="$core_root" "$PYTHON" -c "import os, sys; from pathlib import Path; home=Path(os.environ[\\"NEXO_HOME\\"]); core=home / \\"core\\"; sys.path.insert(0, str(core)); from runtime_versioning import activate_versioned_runtime_snapshot, read_version_for_path; version=read_version_for_path(core); result=activate_versioned_runtime_snapshot(source_root=core, version=str(version or \\"\\").strip()); sys.exit(0 if result.get(\\"ok\\") else 1)" >/dev/null 2>&1 || return 0',
3468
+ '}',
3469
+ 'repair_stale_current_runtime',
3445
3470
  'CLI_PY="$NEXO_CODE/cli.py"',
3446
3471
  'if [ ! -f "$CLI_PY" ] && [ -f "$NEXO_HOME/core/current/cli.py" ]; then',
3447
3472
  ' NEXO_CODE="$NEXO_HOME/core/current"',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nexo-brain",
3
- "version": "7.9.10",
3
+ "version": "7.9.12",
4
4
  "mcpName": "io.github.wazionapps/nexo",
5
5
  "description": "NEXO Brain — Shared brain for AI agents. Persistent memory, semantic RAG, natural forgetting, metacognitive guard, trust scoring, 150+ MCP tools. Works with Claude Code, Codex, Claude Desktop & any MCP client. 100% local, free.",
6
6
  "homepage": "https://nexo-brain.com",
@@ -792,6 +792,8 @@ def _build_enforcement_system_prompt() -> str:
792
792
  map_path = NEXO_HOME / "tool-enforcement-map.json"
793
793
  if not map_path.exists():
794
794
  for candidate in [
795
+ # .resolve() handles the $NEXO_HOME symlink -> core/ case.
796
+ Path(__file__).resolve().parent / "tool-enforcement-map.json",
795
797
  Path(__file__).parent / "tool-enforcement-map.json",
796
798
  Path(__file__).parent.parent / "tool-enforcement-map.json",
797
799
  ]:
@@ -593,6 +593,31 @@ def _runtime_cli_wrapper_text() -> str:
593
593
  ' echo "NEXO runtime Python not found. Run nexo-brain or nexo update to repair the installation." >&2\n'
594
594
  ' exit 1\n'
595
595
  'fi\n'
596
+ 'read_runtime_version() {\n'
597
+ ' local base="${1:-}"\n'
598
+ ' [ -n "$base" ] || return 0\n'
599
+ ' local candidate=""\n'
600
+ ' for candidate in "$base/version.json" "$base/package.json"; do\n'
601
+ ' [ -f "$candidate" ] || continue\n'
602
+ ' "$PYTHON" -c "import json, sys; from pathlib import Path; payload=json.loads(Path(sys.argv[1]).read_text(encoding=\\"utf-8\\")); version=str(payload.get(\\"version\\", \\"\\")).strip(); sys.stdout.write(version); sys.exit(0 if version else 1)" "$candidate" 2>/dev/null || continue\n'
603
+ ' return 0\n'
604
+ ' done\n'
605
+ ' return 0\n'
606
+ '}\n'
607
+ 'repair_stale_current_runtime() {\n'
608
+ ' local core_root="$NEXO_HOME/core"\n'
609
+ ' local current_root="$NEXO_HOME/core/current"\n'
610
+ ' [ -d "$core_root" ] || return 0\n'
611
+ ' [ -e "$current_root" ] || return 0\n'
612
+ ' local core_version=""\n'
613
+ ' local current_version=""\n'
614
+ ' core_version="$(read_runtime_version "$core_root")"\n'
615
+ ' current_version="$(read_runtime_version "$current_root")"\n'
616
+ ' [ -n "$core_version" ] || return 0\n'
617
+ ' [ "$core_version" = "$current_version" ] && return 0\n'
618
+ ' NEXO_HOME="$NEXO_HOME" NEXO_CODE="$core_root" "$PYTHON" -c "import os, sys; from pathlib import Path; home=Path(os.environ[\\"NEXO_HOME\\"]); core=home / \\"core\\"; sys.path.insert(0, str(core)); from runtime_versioning import activate_versioned_runtime_snapshot, read_version_for_path; version=read_version_for_path(core); result=activate_versioned_runtime_snapshot(source_root=core, version=str(version or \\"\\").strip()); sys.exit(0 if result.get(\\"ok\\") else 1)" >/dev/null 2>&1 || return 0\n'
619
+ '}\n'
620
+ 'repair_stale_current_runtime\n'
596
621
  'CLI_PY="$NEXO_CODE/cli.py"\n'
597
622
  'if [ ! -f "$CLI_PY" ] && [ -f "$NEXO_HOME/core/current/cli.py" ]; then\n'
598
623
  ' NEXO_CODE="$NEXO_HOME/core/current"\n'
package/src/cli.py CHANGED
@@ -3232,6 +3232,20 @@ def main():
3232
3232
  lwait_p.add_argument("--timeout-ms", type=int, default=45_000)
3233
3233
  lwait_p.add_argument("--poll-ms", type=int, default=500)
3234
3234
 
3235
+ lwait_stop_p = lifecycle_sub.add_parser(
3236
+ "wait-for-stop",
3237
+ help="v7.9.10: wait until the linked NEXO session is no longer active",
3238
+ )
3239
+ lwait_stop_p.add_argument("--event-id", required=True)
3240
+ lwait_stop_p.add_argument("--timeout-ms", type=int, default=10_000)
3241
+ lwait_stop_p.add_argument("--poll-ms", type=int, default=500)
3242
+
3243
+ lstop_sid_p = lifecycle_sub.add_parser(
3244
+ "stop-nexo-session",
3245
+ help="v7.9.10: explicit best-effort stop of a NEXO SID for Desktop lifecycle cleanup",
3246
+ )
3247
+ lstop_sid_p.add_argument("--sid", required=True)
3248
+
3235
3249
  # Fase E.5 — quarantine ops surfaced via Desktop Guardian Proposals panel.
3236
3250
  quarantine_parser = sub.add_parser("quarantine", help="Quarantine proposals (Fase E.5 Desktop UI)")
3237
3251
  quarantine_sub = quarantine_parser.add_subparsers(dest="quarantine_command")
@@ -3533,6 +3547,38 @@ def main():
3533
3547
  if status == "retryable_error":
3534
3548
  return 2
3535
3549
  return 3
3550
+ if args.lifecycle_command == "wait-for-stop":
3551
+ out = _lifecycle_plugin.handle_nexo_lifecycle_wait_for_stop(
3552
+ event_id=args.event_id,
3553
+ timeout_ms=args.timeout_ms,
3554
+ poll_ms=args.poll_ms,
3555
+ )
3556
+ print(out)
3557
+ try:
3558
+ parsed = _json.loads(out)
3559
+ status = str(parsed.get("status", ""))
3560
+ except Exception:
3561
+ status = ""
3562
+ if status == "ok":
3563
+ return 0
3564
+ if status == "retryable_error":
3565
+ return 2
3566
+ return 3
3567
+ if args.lifecycle_command == "stop-nexo-session":
3568
+ out = _lifecycle_plugin.handle_nexo_lifecycle_stop_nexo_session(
3569
+ sid=args.sid,
3570
+ )
3571
+ print(out)
3572
+ try:
3573
+ parsed = _json.loads(out)
3574
+ status = str(parsed.get("status", ""))
3575
+ except Exception:
3576
+ status = ""
3577
+ if status == "ok":
3578
+ return 0
3579
+ if status == "retryable_error":
3580
+ return 2
3581
+ return 3
3536
3582
  lifecycle_parser.print_help()
3537
3583
  return 1
3538
3584
  elif args.command in ("schema", "identity", "onboard", "scan-profile"):
@@ -321,7 +321,13 @@ def _redact_for_log(text: str, max_len: int = 200) -> str:
321
321
 
322
322
 
323
323
  def _load_map() -> dict | None:
324
+ # .resolve() is required: at runtime this module is usually imported via
325
+ # the symlink $NEXO_HOME/enforcement_engine.py -> core/enforcement_engine.py,
326
+ # so the non-resolved Path(__file__).parent points at NEXO_HOME instead
327
+ # of the core/ dir where the map actually sits. Keeping the non-resolved
328
+ # variant too covers in-repo test imports.
324
329
  for candidate in [
330
+ Path(__file__).resolve().parent / MAP_FILENAME,
325
331
  Path(__file__).parent / MAP_FILENAME,
326
332
  paths.home() / MAP_FILENAME,
327
333
  paths.brain_dir() / MAP_FILENAME,
@@ -55,6 +55,7 @@ import time
55
55
  from typing import Any, Dict, List, Optional
56
56
 
57
57
  from db import get_db
58
+ from db._core import SESSION_STALE_SECONDS
58
59
  import lifecycle_prompts
59
60
 
60
61
 
@@ -259,6 +260,103 @@ def _session_diary_since(conn, session_id: str, dispatched_at: Optional[str], ac
259
260
  return _session_diary_evidence(conn, session_id, dispatched_at, actions_json) is not None
260
261
 
261
262
 
263
+ def _session_stop_state(conn, session_id: str) -> Dict[str, Any]:
264
+ """Return whether the lifecycle session can be verified as fully stopped."""
265
+ raw = str(session_id or "").strip()
266
+ if not raw:
267
+ return {
268
+ "verifiable": False,
269
+ "session_registered": False,
270
+ "stop_confirmed": False,
271
+ "active_session_ids": [],
272
+ }
273
+
274
+ candidate_ids: List[str] = []
275
+ verifiable = False
276
+
277
+ if raw.startswith("nexo-"):
278
+ candidate_ids.append(raw)
279
+ verifiable = True
280
+
281
+ try:
282
+ rows = conn.execute(
283
+ "SELECT sid FROM session_claude_aliases "
284
+ "WHERE claude_session_id = ? ORDER BY last_seen DESC",
285
+ (raw,),
286
+ ).fetchall()
287
+ alias_ids = [str(row[0]) for row in rows if row and row[0]]
288
+ if alias_ids:
289
+ candidate_ids.extend(alias_ids)
290
+ verifiable = True
291
+ except Exception:
292
+ pass
293
+
294
+ try:
295
+ rows = conn.execute(
296
+ "SELECT sid FROM sessions "
297
+ "WHERE external_session_id = ? OR claude_session_id = ? OR sid = ? "
298
+ "ORDER BY last_update_epoch DESC",
299
+ (raw, raw, raw),
300
+ ).fetchall()
301
+ live_ids = [str(row[0]) for row in rows if row and row[0]]
302
+ if live_ids:
303
+ candidate_ids.extend(live_ids)
304
+ verifiable = True
305
+ except Exception:
306
+ pass
307
+
308
+ deduped_candidates: List[str] = []
309
+ seen = set()
310
+ for sid in candidate_ids:
311
+ if sid and sid not in seen:
312
+ seen.add(sid)
313
+ deduped_candidates.append(sid)
314
+
315
+ if not verifiable:
316
+ return {
317
+ "verifiable": False,
318
+ "session_registered": False,
319
+ "stop_confirmed": False,
320
+ "active_session_ids": [],
321
+ }
322
+
323
+ cutoff = time.time() - float(SESSION_STALE_SECONDS)
324
+ active_ids: List[str] = []
325
+ try:
326
+ if deduped_candidates:
327
+ placeholders = ",".join("?" for _ in deduped_candidates)
328
+ rows = conn.execute(
329
+ "SELECT sid FROM sessions "
330
+ f"WHERE last_update_epoch > ? AND (sid IN ({placeholders}) OR external_session_id = ? OR claude_session_id = ?) "
331
+ "ORDER BY last_update_epoch DESC",
332
+ (cutoff, *deduped_candidates, raw, raw),
333
+ ).fetchall()
334
+ else:
335
+ rows = conn.execute(
336
+ "SELECT sid FROM sessions "
337
+ "WHERE last_update_epoch > ? AND (external_session_id = ? OR claude_session_id = ?) "
338
+ "ORDER BY last_update_epoch DESC",
339
+ (cutoff, raw, raw),
340
+ ).fetchall()
341
+ active_ids = [str(row[0]) for row in rows if row and row[0]]
342
+ except Exception:
343
+ active_ids = []
344
+
345
+ deduped_active: List[str] = []
346
+ seen.clear()
347
+ for sid in active_ids:
348
+ if sid and sid not in seen:
349
+ seen.add(sid)
350
+ deduped_active.append(sid)
351
+
352
+ return {
353
+ "verifiable": True,
354
+ "session_registered": True,
355
+ "stop_confirmed": len(deduped_active) == 0,
356
+ "active_session_ids": deduped_active,
357
+ }
358
+
359
+
262
360
  def record_lifecycle_event(
263
361
  event_id: str,
264
362
  action: str,
@@ -324,17 +422,18 @@ def record_lifecycle_event(
324
422
  "prior_status": status,
325
423
  }
326
424
 
327
- # Case B: canonical was dispatched but never confirmed. Check
328
- # whether the live session wrote a diary after dispatch; if so
329
- # the intent has already been satisfied by the model and we
330
- # must NOT ask Desktop to re-run the plan.
425
+ # Case B: canonical was dispatched but never confirmed. Only
426
+ # short-circuit if the session already wrote the diary AND no
427
+ # linked NEXO session remains active.
331
428
  if prior_plan_id and prior_dispatched_at and not prior_done_at:
429
+ stop_state = _session_stop_state(conn, str(session_id)) if session_id else None
430
+ stop_confirmed = bool(stop_state and stop_state.get("verifiable") and stop_state.get("stop_confirmed"))
332
431
  if session_id and _session_diary_since(
333
432
  conn,
334
433
  str(session_id),
335
434
  str(prior_dispatched_at),
336
435
  str(prior_actions_json or ""),
337
- ):
436
+ ) and stop_confirmed:
338
437
  conn.execute(
339
438
  "UPDATE lifecycle_events "
340
439
  "SET delivery_status = 'already_processed', "
@@ -349,7 +448,7 @@ def record_lifecycle_event(
349
448
  "event_id": event_id,
350
449
  "duplicate": True,
351
450
  "prior_status": status,
352
- "reason": "session_diary-already-written",
451
+ "reason": "session_diary-and-stop-already-written",
353
452
  }
354
453
  # Re-hand the exact same plan so Desktop can resume / finish
355
454
  # any actions it didn't complete before the crash.
@@ -538,11 +637,18 @@ def record_complete_canonical(
538
637
  for r in results_list
539
638
  )
540
639
  diary_evidence = _session_diary_evidence(conn, session_id, dispatched_at, actions_json)
640
+ stop_state = _session_stop_state(conn, session_id) if session_id else {
641
+ "verifiable": False,
642
+ "session_registered": False,
643
+ "stop_confirmed": True,
644
+ "active_session_ids": [],
645
+ }
541
646
  diary_required = action in _DIARY_TRIGGERING and bool(session_id)
542
647
  session_registered = _session_is_linked_to_nexo(conn, session_id) if diary_required else bool(session_id)
543
648
  session_unregistered = diary_required and not session_registered
544
649
  diary_missing = diary_required and diary_evidence is None
545
- effective = "retryable_error" if (any_failure or diary_missing) else "canonical_done"
650
+ stop_missing = diary_required and bool(stop_state.get("verifiable")) and not bool(stop_state.get("stop_confirmed"))
651
+ effective = "retryable_error" if (any_failure or diary_missing or stop_missing) else "canonical_done"
546
652
  last_error = None
547
653
  if session_unregistered:
548
654
  last_error = SESSION_NOT_LINKED_REASON
@@ -550,6 +656,8 @@ def record_complete_canonical(
550
656
  last_error = "one-or-more-actions-failed"
551
657
  elif diary_missing:
552
658
  last_error = "canonical-diary-not-confirmed"
659
+ elif stop_missing:
660
+ last_error = "canonical-stop-not-confirmed"
553
661
  conn.execute(
554
662
  "UPDATE lifecycle_events "
555
663
  "SET delivery_status = ?, "
@@ -573,8 +681,11 @@ def record_complete_canonical(
573
681
  "failed_actions": any_failure,
574
682
  "diary_confirmed": diary_evidence is not None,
575
683
  "diary_required": diary_required,
684
+ "stop_confirmed": not stop_missing,
685
+ "stop_required": diary_required,
576
686
  "session_registered": session_registered,
577
687
  "session_diary_id": diary_evidence.get("session_diary_id") if diary_evidence else None,
688
+ "active_session_ids": list(stop_state.get("active_session_ids") or []),
578
689
  "reason": last_error if effective == "retryable_error" else None,
579
690
  }
580
691
 
@@ -635,6 +746,74 @@ def wait_for_canonical_diary(
635
746
  time.sleep(min(poll_s, max(0.0, deadline - time.monotonic())))
636
747
 
637
748
 
749
+ def wait_for_canonical_stop(
750
+ event_id: str,
751
+ timeout_ms: int = 10_000,
752
+ poll_ms: int = 500,
753
+ ) -> Dict[str, Any]:
754
+ """Poll until the canonical lifecycle session no longer has active linked SIDs."""
755
+ if not event_id:
756
+ return {"status": "rejected", "reason": "missing-event-id"}
757
+
758
+ timeout_s = max(0.0, float(timeout_ms or 0) / 1000.0)
759
+ poll_s = max(0.05, float(poll_ms or 500) / 1000.0)
760
+ deadline = time.monotonic() + timeout_s
761
+
762
+ while True:
763
+ conn = get_db()
764
+ row = conn.execute(
765
+ "SELECT session_id, action FROM lifecycle_events WHERE event_id = ?",
766
+ (str(event_id),),
767
+ ).fetchone()
768
+ if row is None:
769
+ return {"status": "rejected", "reason": "unknown-event-id", "event_id": event_id}
770
+
771
+ session_id = str(row[0] or "")
772
+ action = str(row[1] or "")
773
+ stop_required = action in _DIARY_TRIGGERING and bool(session_id)
774
+ if not stop_required:
775
+ return {
776
+ "status": "ok",
777
+ "event_id": event_id,
778
+ "stop_required": False,
779
+ "stop_confirmed": True,
780
+ "session_registered": bool(session_id),
781
+ "active_session_ids": [],
782
+ }
783
+
784
+ stop_state = _session_stop_state(conn, session_id)
785
+ if not stop_state.get("verifiable"):
786
+ return {
787
+ "status": "retryable_error",
788
+ "event_id": event_id,
789
+ "stop_required": True,
790
+ "stop_confirmed": False,
791
+ "session_registered": False,
792
+ "active_session_ids": [],
793
+ "reason": SESSION_NOT_LINKED_REASON,
794
+ }
795
+ if stop_state.get("stop_confirmed"):
796
+ return {
797
+ "status": "ok",
798
+ "event_id": event_id,
799
+ "stop_required": True,
800
+ "stop_confirmed": True,
801
+ "session_registered": True,
802
+ "active_session_ids": [],
803
+ }
804
+ if time.monotonic() >= deadline:
805
+ return {
806
+ "status": "retryable_error",
807
+ "event_id": event_id,
808
+ "stop_required": True,
809
+ "stop_confirmed": False,
810
+ "session_registered": True,
811
+ "active_session_ids": list(stop_state.get("active_session_ids") or []),
812
+ "reason": "canonical-stop-not-confirmed",
813
+ }
814
+ time.sleep(min(poll_s, max(0.0, deadline - time.monotonic())))
815
+
816
+
638
817
  def get_lifecycle_event(event_id: str) -> Optional[Dict[str, Any]]:
639
818
  if not event_id:
640
819
  return None
@@ -19,7 +19,7 @@ import json
19
19
  from typing import Any, Dict, List, Optional
20
20
 
21
21
 
22
- PLAN_VERSION = 3
22
+ PLAN_VERSION = 4
23
23
 
24
24
 
25
25
  # Actions that trigger a canonical diary+stop plan. `switch` and
@@ -37,6 +37,7 @@ DEFAULT_RESUME_TIMEOUT_MS = 2_000
37
37
  DEFAULT_INJECT_TIMEOUT_MS = 30_000
38
38
  DEFAULT_DIARY_WAIT_TIMEOUT_MS = 45_000
39
39
  DEFAULT_STOP_TIMEOUT_MS = 3_000
40
+ DEFAULT_STOP_WAIT_TIMEOUT_MS = 10_000
40
41
 
41
42
 
42
43
  def canonical_plan_id(event_id: str, plan_version: int = PLAN_VERSION) -> str:
@@ -150,6 +151,15 @@ def build_canonical_plan(
150
151
  evidence="session_diary",
151
152
  ),
152
153
  _canonical_action("a4", "stop_session", str(session_id), DEFAULT_STOP_TIMEOUT_MS),
154
+ _canonical_action(
155
+ "a5",
156
+ "wait_for_stop",
157
+ str(session_id),
158
+ DEFAULT_STOP_WAIT_TIMEOUT_MS,
159
+ event_id=str(event_id),
160
+ expected_tool_call="nexo_stop",
161
+ evidence="session_stop",
162
+ ),
153
163
  ]
154
164
  return {
155
165
  "canonical_plan_id": canonical_plan_id(event_id, PLAN_VERSION),
@@ -168,6 +168,48 @@ def handle_nexo_lifecycle_wait_for_diary(
168
168
  return json.dumps(ack, ensure_ascii=False)
169
169
 
170
170
 
171
+ def handle_nexo_lifecycle_wait_for_stop(
172
+ event_id: str,
173
+ timeout_ms: int = 10_000,
174
+ poll_ms: int = 500,
175
+ ) -> str:
176
+ """Wait until a canonical lifecycle event no longer has an active NEXO session."""
177
+ try:
178
+ ack = lifecycle_events.wait_for_canonical_stop(
179
+ event_id=str(event_id or ""),
180
+ timeout_ms=int(timeout_ms or 0),
181
+ poll_ms=int(poll_ms or 500),
182
+ )
183
+ except Exception as exc:
184
+ return json.dumps({
185
+ "status": "retryable_error",
186
+ "reason": f"{type(exc).__name__}: {exc}",
187
+ "handler_threw": True,
188
+ }, ensure_ascii=False)
189
+ return json.dumps(ack, ensure_ascii=False)
190
+
191
+
192
+ def handle_nexo_lifecycle_stop_nexo_session(
193
+ sid: str,
194
+ ) -> str:
195
+ """Best-effort explicit stop of a NEXO SID for Desktop lifecycle cleanup."""
196
+ try:
197
+ from tools_sessions import handle_stop
198
+
199
+ message = handle_stop(str(sid or ""))
200
+ return json.dumps({
201
+ "status": "ok",
202
+ "sid": str(sid or ""),
203
+ "message": message,
204
+ }, ensure_ascii=False)
205
+ except Exception as exc:
206
+ return json.dumps({
207
+ "status": "retryable_error",
208
+ "sid": str(sid or ""),
209
+ "reason": f"{type(exc).__name__}: {exc}",
210
+ }, ensure_ascii=False)
211
+
212
+
171
213
  TOOLS = [
172
214
  (
173
215
  handle_nexo_lifecycle_event,
@@ -189,4 +231,14 @@ TOOLS = [
189
231
  "nexo_lifecycle_wait_for_diary",
190
232
  "Wait for concrete session_diary evidence for a canonical lifecycle event before Desktop stops the session.",
191
233
  ),
234
+ (
235
+ handle_nexo_lifecycle_wait_for_stop,
236
+ "nexo_lifecycle_wait_for_stop",
237
+ "Wait until the linked NEXO session is no longer active for a canonical lifecycle event.",
238
+ ),
239
+ (
240
+ handle_nexo_lifecycle_stop_nexo_session,
241
+ "nexo_lifecycle_stop_nexo_session",
242
+ "Best-effort explicit nexo_stop by SID for Desktop lifecycle cleanup.",
243
+ ),
192
244
  ]