nexo-brain 7.9.9 → 7.9.11

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.9",
3
+ "version": "7.9.11",
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.9` is the current packaged-runtime line. Hotfix release over `7.9.8`: after the packaged updater fix, the installer now also repairs installs whose metadata already says the new version but whose active runtime still points to an older `~/.nexo/core/current -> versions/<old>`. Running `nexo-brain --yes` or the postinstall path now recopies the packaged runtime, re-activates `core/current`, and leaves installed users on the real new code instead of a stale snapshot. Coordinated Desktop v0.28.11 bundles the matching quit-path fix.
21
+ Version `7.9.11` is the current packaged-runtime line. Patch release over `7.9.10`: canonical Desktop lifecycle completion now requires both diary evidence and real stop evidence before Brain marks archive/delete/app-exit done, canonical plans add `wait_for_stop`, and Brain exposes the exact stop-wait primitives Desktop uses to avoid leaving duplicate live NEXO sessions behind a reopened conversation. Coordinated Desktop release: v0.28.12.
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
@@ -252,6 +252,24 @@ function readRuntimeCalibration(nexoHome = NEXO_HOME) {
252
252
  return { path: null, payload: null };
253
253
  }
254
254
 
255
+ function profilePathCandidates(nexoHome = NEXO_HOME) {
256
+ return [
257
+ path.join(nexoHome, "personal", "brain", "profile.json"),
258
+ path.join(nexoHome, "brain", "profile.json"),
259
+ ];
260
+ }
261
+
262
+ function readRuntimeProfile(nexoHome = NEXO_HOME) {
263
+ for (const filePath of profilePathCandidates(nexoHome)) {
264
+ if (!fs.existsSync(filePath)) continue;
265
+ const payload = readJsonFile(filePath);
266
+ if (payload && typeof payload === "object") {
267
+ return { path: filePath, payload };
268
+ }
269
+ }
270
+ return { path: null, payload: null };
271
+ }
272
+
255
273
  function nonEmptyString(value) {
256
274
  return typeof value === "string" && value.trim().length > 0;
257
275
  }
@@ -288,6 +306,55 @@ function hasPartialPlaceholderCalibration(calibration) {
288
306
  return isPlaceholderUserName(name) || !nonEmptyString(language);
289
307
  }
290
308
 
309
+ function firstMeaningfulString(...values) {
310
+ for (const value of values) {
311
+ if (typeof value !== "string") continue;
312
+ const clean = value.trim();
313
+ if (clean) return clean;
314
+ }
315
+ return "";
316
+ }
317
+
318
+ function normalizeLanguageCode(value) {
319
+ const clean = String(value || "").trim().toLowerCase().replace("_", "-");
320
+ if (!clean) return "";
321
+ return clean.split("-")[0];
322
+ }
323
+
324
+ function resolveExistingIdentityDefaults(nexoHome = NEXO_HOME) {
325
+ const calibration = readRuntimeCalibration(nexoHome).payload || {};
326
+ const user = calibration.user && typeof calibration.user === "object" ? calibration.user : {};
327
+ const profile = readRuntimeProfile(nexoHome).payload || {};
328
+ const version = readJsonFile(path.join(nexoHome, "version.json")) || {};
329
+
330
+ const userName = firstMeaningfulString(
331
+ user.name,
332
+ calibration.user_name,
333
+ profile.user_name,
334
+ version.user_name,
335
+ );
336
+ const language = normalizeLanguageCode(firstMeaningfulString(
337
+ user.language,
338
+ calibration.language,
339
+ profile.language,
340
+ version.language,
341
+ ));
342
+ const operatorName = firstMeaningfulString(
343
+ user.assistant_name,
344
+ calibration.assistant_name,
345
+ calibration.operator_name,
346
+ profile.assistant_name,
347
+ profile.operator_name,
348
+ version.operator_name,
349
+ );
350
+
351
+ return {
352
+ userName: !isPlaceholderUserName(userName) ? userName : "",
353
+ language,
354
+ operatorName: !isReservedAssistantName(operatorName) ? operatorName : "",
355
+ };
356
+ }
357
+
291
358
  function ensureOnboardingCompletionMarker(nexoHome = NEXO_HOME) {
292
359
  const record = readRuntimeCalibration(nexoHome);
293
360
  if (!record.path || !isOnboardingComplete(record.payload)) {
@@ -2966,9 +3033,11 @@ async function runSetup() {
2966
3033
  },
2967
3034
  };
2968
3035
 
3036
+ const existingIdentity = resolveExistingIdentityDefaults(NEXO_HOME);
3037
+
2969
3038
  // Detect language from input or use default
2970
- let lang = "en";
2971
- let t = i18n.en;
3039
+ let lang = existingIdentity.language || "en";
3040
+ let t = i18n[lang] || i18n.en;
2972
3041
  if (!useDefaults) {
2973
3042
  const langInput = await ask(" What's your preferred language? / ¿En qué idioma prefieres hablar?\n > ");
2974
3043
  const langLower = langInput.trim().toLowerCase();
@@ -3009,7 +3078,7 @@ async function runSetup() {
3009
3078
  // Step 2: User's name (P2) — v6.0.0 empty input falls through to "Usuario"
3010
3079
  // instead of keeping an empty string. The calibration file always ships
3011
3080
  // with a concrete user.name so downstream tooling does not need guards.
3012
- let userName = "Usuario";
3081
+ let userName = existingIdentity.userName || "Usuario";
3013
3082
  if (!useDefaults) {
3014
3083
  const nameInput = await ask(t.askUserName);
3015
3084
  const trimmedName = nameInput.trim();
@@ -3021,7 +3090,7 @@ async function runSetup() {
3021
3090
  }
3022
3091
 
3023
3092
  // Step 3: Agent name (P3)
3024
- let operatorName = DEFAULT_ASSISTANT_NAME;
3093
+ let operatorName = existingIdentity.operatorName || DEFAULT_ASSISTANT_NAME;
3025
3094
  if (!useDefaults) {
3026
3095
  while (true) {
3027
3096
  const name = await ask(t.askAgentName);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nexo-brain",
3
- "version": "7.9.9",
3
+ "version": "7.9.11",
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
  ]:
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"):
@@ -28,6 +28,7 @@ VALID_DIAGNOSTIC_PLANES = {
28
28
  }
29
29
 
30
30
  DOCTOR_COMPATIBLE_PLANES = {"runtime_personal", "installation_live", "database_real"}
31
+ DEFAULT_DOCTOR_PLANE = "runtime_personal"
31
32
 
32
33
 
33
34
  def normalize_diagnostic_plane(plane: str = "") -> str:
@@ -40,15 +41,19 @@ def diagnostic_plane_choices() -> list[str]:
40
41
 
41
42
 
42
43
  def diagnostic_plane_preflight(plane: str = "") -> tuple[str, DoctorCheck | None]:
43
- clean_plane = normalize_diagnostic_plane(plane)
44
+ raw_plane = str(plane or "").strip()
45
+ if not raw_plane:
46
+ return DEFAULT_DOCTOR_PLANE, None
47
+
48
+ clean_plane = normalize_diagnostic_plane(raw_plane)
44
49
  if not clean_plane:
45
50
  options = ", ".join(diagnostic_plane_choices())
46
51
  return "", DoctorCheck(
47
- id="orchestrator.diagnostic_plane_required",
52
+ id="orchestrator.diagnostic_plane_invalid",
48
53
  tier="orchestrator",
49
54
  status="critical",
50
55
  severity="error",
51
- summary="El diagnóstico está bloqueado hasta fijar explícitamente el plano",
56
+ summary=f"Plano diagnóstico desconocido: {raw_plane}",
52
57
  evidence=[
53
58
  f"planes válidos: {options}",
54
59
  "Usa `runtime_personal` para ~/.nexo y hábitos del runtime; `installation_live` para hooks/clientes/instalación; `database_real` para filas y schema reales.",
@@ -58,8 +63,7 @@ def diagnostic_plane_preflight(plane: str = "") -> tuple[str, DoctorCheck | None
58
63
  "Si el problema pertenece a producto público o al co-operador, usa el surface correcto en vez de NEXO Doctor.",
59
64
  ],
60
65
  escalation_prompt=(
61
- "NEXO mezcló planos en diagnósticos anteriores. El doctor no debe correr hasta que se elija "
62
- "explícitamente si el problema está en producto público, runtime personal, instalación viva, BD real o co-operador."
66
+ "El plano elegido no existe. Repite el diagnóstico con un plano válido para evitar mezclar runtime, instalación, BD real o surfaces ajenas al doctor."
63
67
  ),
64
68
  )
65
69
 
@@ -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),
@@ -22,26 +22,11 @@ def handle_doctor(tier: str = "boot", fix: bool = False, output: str = "text", p
22
22
  """
23
23
  from doctor.orchestrator import run_doctor
24
24
  from doctor.formatters import format_report
25
- from doctor.planes import diagnostic_plane_choices
26
25
 
27
26
  if tier not in ("boot", "runtime", "deep", "all"):
28
27
  return f"Invalid tier '{tier}'. Use: boot, runtime, deep, all"
29
28
  if output not in ("text", "json"):
30
29
  return f"Invalid output '{output}'. Use: text, json"
31
- if not (plane or "").strip():
32
- valid_planes = diagnostic_plane_choices()
33
- if output == "json":
34
- return (
35
- "{"
36
- f"\"ok\": false, \"error\": \"Missing required argument: plane\", "
37
- "\"missing_argument\": \"plane\", "
38
- f"\"valid_planes\": {valid_planes!r}"
39
- "}"
40
- ).replace("'", '"')
41
- return (
42
- "Missing required argument: plane. "
43
- f"Use one of: {', '.join(valid_planes)}."
44
- )
45
30
 
46
31
  report = run_doctor(tier=tier, fix=fix, plane=plane)
47
32
  return format_report(report, fmt=output)
@@ -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
  ]