cdx-manager 0.18.5 → 0.18.7

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.
package/README.md CHANGED
@@ -4,7 +4,7 @@
4
4
 
5
5
  <br clear="left"/>
6
6
 
7
- [![License](https://img.shields.io/badge/license-MIT-4C8BF5)](LICENSE) ![Version](https://img.shields.io/badge/version-v0.18.5-4C8BF5) ![Python](https://img.shields.io/badge/python-3.9%2B-3776AB?logo=python&logoColor=white)
7
+ [![License](https://img.shields.io/badge/license-MIT-4C8BF5)](LICENSE) ![Version](https://img.shields.io/badge/version-v0.18.7-4C8BF5) ![Python](https://img.shields.io/badge/python-3.9%2B-3776AB?logo=python&logoColor=white)
8
8
 
9
9
  **Stop guessing which AI account still has quota.** `cdx` tracks the rate-limit window of every Codex and Claude account you own, tells you which one is usable right now, and launches it with isolated auth — in one command.
10
10
 
@@ -377,12 +377,13 @@ It is off by default because turning it on writes into the provider's own
377
377
  configuration and, on Codex, puts an approval prompt in front of you. That is
378
378
  not something to do to sessions you already own without being asked.
379
379
 
380
- Response previews are separately off by default. When enabled, a completion
381
- notification includes at most 180 characters of the agent's final response, and
382
- a permission request may quote the provider's own one-line description of what
383
- it wants to do. That text can be visible on a lock screen, so enable it only for
384
- sessions whose output is safe to expose. Previews never read transcripts, and
385
- the command a permission request is about is never included.
380
+ New sessions inherit response previews when agent alerts were accepted during
381
+ tray installation. A completion notification includes at most 180 characters
382
+ of the agent's final response, and a permission request may quote the
383
+ provider's own one-line description of what it wants to do. That text can be
384
+ visible on a lock screen; disable it per session with
385
+ `cdx set <name> --notify-preview off`. Previews never read transcripts, and the
386
+ command a permission request is about is never included.
386
387
 
387
388
  Both providers are subscribed to the same two hooks: `Stop` when a turn ends and
388
389
  `PermissionRequest` when one blocks on your approval. Claude Code is also
@@ -465,6 +466,8 @@ Interactive launches and resumes set the terminal window title to `session — f
465
466
 
466
467
  The title is only written when stdout is a terminal. Nothing is emitted for `--json` launches, redirected or piped output, `cdx run` and other headless runs, login flows, or Ollama sessions. Session names and directories are stripped of escape and control characters before the title is emitted, and cdx does not restore the previous title when the session ends.
467
468
 
469
+ Claude Code writes its own animated title while an action runs, and it redraws far more often than cdx re-asserts the shared title. So interactive Claude launches and resumes started by cdx set the Claude-specific `CLAUDE_CODE_DISABLE_TERMINAL_TITLE=1` in the provider environment, which stops Claude from setting and clearing the title and leaves `session — folder` stable while Claude is busy. Claude's in-TUI activity indicator is unaffected, the value is forced so an ambient setting in your shell cannot hand the title back, and headless `cdx run`, Claude authentication flows, and every non-Claude provider keep their previous environment.
470
+
468
471
  ### Launch History
469
472
 
470
473
  Every interactive `cdx <name>` launch is recorded under `CDX_HOME`, including success/failure, duration, cwd, launch settings, and transcript path.
@@ -1046,6 +1049,10 @@ Without `--yes`, an interactive confirmation is required. `cdx` sends an idempot
1046
1049
 
1047
1050
  Contribution guidelines are available in [`CONTRIBUTING.md`](CONTRIBUTING.md).
1048
1051
 
1052
+ Working on the tray companion? [`docs/tray-local-testing.md`](docs/tray-local-testing.md)
1053
+ covers the local loops, from rendering the menu to stdout to running a real icon
1054
+ in the menu bar.
1055
+
1049
1056
  ---
1050
1057
 
1051
1058
  ## License
package/VERSION CHANGED
@@ -1 +1 @@
1
- 0.18.5
1
+ 0.18.7
@@ -0,0 +1,8 @@
1
+ # CDX Manager 0.18.6
2
+
3
+ ## Features
4
+
5
+ - Launches can use an explicit `--dir`; each run records its directory for status and tray surfaces.
6
+ - The Logics tray card derives repositories from active runs and shows basename-only rows.
7
+ - `cdx update` names its install prefix, while `cdx tray doctor` reports the companion CDX command and hook-store alignment.
8
+ - Windows Terminal, GNOME Terminal, and Konsole open new tabs where their documented interfaces support it; other terminals retain the working window fallback.
@@ -0,0 +1,5 @@
1
+ # CDX Manager 0.18.7
2
+
3
+ ## Fixes
4
+
5
+ - Interactive Claude sessions keep the cdx `session — folder` terminal title while Claude is running an action, instead of losing it to Claude's own animated title. Headless runs, Claude authentication flows, and other providers are unchanged.
@@ -246,6 +246,28 @@
246
246
  "x86_64-pc-windows-msvc": "cc33c0f5a53472c6c1c72f458f33052abc09b6fe4ff5dfb5edae497c234bb2d0",
247
247
  "x86_64-unknown-linux-musl": "d8fb9958112329487bd91be7f7505f18f886676f4309e30aa3877340e42c6c15"
248
248
  }
249
+ },
250
+ "v0.18.5": {
251
+ "github_tarball_sha256": "d4251f1bdeb5aba31308cf749bf51733435bf2559f50a923ce090f0940c59876",
252
+ "github_zip_sha256": "36bc9d4e0a3cad306ccd405d7b4e4091418e21fe897b5ebcad4e275133615837",
253
+ "tray_assets": {
254
+ "aarch64-apple-darwin": "b0eb6653c3bbe1aba93d8b1966377e379eecea063ad01a10a11cdfd52d48e5ac",
255
+ "aarch64-unknown-linux-musl": "0cbca29099f1d320e917cb60e463fe0eb8fef9b78396db32520b8574ae4bb4a3",
256
+ "x86_64-apple-darwin": "a3e3d485552191f2456573dfbfee4259e430e7432b6d307eea00d5c527c86011",
257
+ "x86_64-pc-windows-msvc": "31a1743b0b6ec420a85c1679e93fcbd32c982a36e080a59d563276204682f152",
258
+ "x86_64-unknown-linux-musl": "49fba26d3deeab1e46af542528a5c71262e6c4d82898fcf989d7222a09a11d59"
259
+ }
260
+ },
261
+ "v0.18.6": {
262
+ "github_tarball_sha256": "31111eda2c31399b3fea14d80d10c0a2d10f15aab93a77a60939c86c564971ca",
263
+ "github_zip_sha256": "a3d78348416b3494645fa9f0467144c7e76b40d8862be959d125eb3d13805b15",
264
+ "tray_assets": {
265
+ "aarch64-apple-darwin": "aeb9267f72c3ee7426672d2e1fe98c0e39e0df795bbad6832299d4176c21022e",
266
+ "aarch64-unknown-linux-musl": "846969612d9a0e82f5f6cacc05aedc33153c1c325b8af95f63c8a2a5c2217261",
267
+ "x86_64-apple-darwin": "da5b4d3994fff9e616401e5a4cd01d68d216ddbeb6d41d96d67bbc789ef85118",
268
+ "x86_64-pc-windows-msvc": "296f760a7e10b7ef05936157b9de2b9d3b5e13f7ea2f57b7616705352ea97bd2",
269
+ "x86_64-unknown-linux-musl": "b360ea3ec14ab3aad0ccffcf477dee4bdadf6c1d4f150ef1cd13c7d293ae6829"
270
+ }
249
271
  }
250
272
  }
251
273
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cdx-manager",
3
- "version": "0.18.5",
3
+ "version": "0.18.7",
4
4
  "description": "Terminal session manager for Codex and Claude accounts.",
5
5
  "license": "MIT",
6
6
  "author": "Alexandre Agostini",
package/pyproject.toml CHANGED
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "cdx-manager"
7
- version = "0.18.5"
7
+ version = "0.18.7"
8
8
  description = "Terminal session manager for Codex and Claude accounts."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.9"
package/src/cli.py CHANGED
@@ -619,8 +619,10 @@ def main(argv, options=None):
619
619
  if handler:
620
620
  return handler(rest, ctx)
621
621
 
622
- if all(arg in ("--json", "-r", "--resume") for arg in rest):
623
- return handle_launch(command, ctx, resume=("-r" in rest or "--resume" in rest))
622
+ from .commands.launch import _parse_launch_args
623
+ directory, launch_args = _parse_launch_args(rest)
624
+ if all(arg in ("--json", "-r", "--resume") for arg in launch_args):
625
+ return handle_launch(command, ctx, resume=("-r" in launch_args or "--resume" in launch_args), directory=directory)
624
626
 
625
627
  raise CdxError(f"Unknown command: {command}. Use cdx --help.")
626
628
 
package/src/cli_args.py CHANGED
@@ -43,7 +43,7 @@ RESUME_USAGE = "Usage: cdx resume <name> [--json]"
43
43
  CAN_RESUME_USAGE = "Usage: cdx can-resume <name> [--json]"
44
44
  SELECT_USAGE = "Usage: cdx select --provider PROVIDER [--min-reasoning-effort minimal|low|medium|high|xhigh] [--min-power minimal|low|medium|high|xhigh] [--require-ready] [--refresh] --json"
45
45
  NEXT_USAGE = "Usage: cdx next [--json] [--refresh]"
46
- RUN_USAGE = "Usage: cdx run [session] --cwd PATH (--prompt-file PATH|--prompt TEXT|--prompt-file -) [--provider PROVIDER] [--model MODEL] [--kind assistant|code-review] [--reasoning-effort minimal|low|medium|high|xhigh] [--power minimal|low|medium|high|xhigh] [--permission review|default|auto|full|workspace-write|read-only|danger-full-access] [--timeout-seconds N] [--detach] [--failover] [--refresh] --json"
46
+ RUN_USAGE = "Usage: cdx run [session] (--cwd|--dir) PATH (--prompt-file PATH|--prompt TEXT|--prompt-file -) [--provider PROVIDER] [--model MODEL] [--kind assistant|code-review] [--reasoning-effort minimal|low|medium|high|xhigh] [--power minimal|low|medium|high|xhigh] [--permission review|default|auto|full|workspace-write|read-only|danger-full-access] [--timeout-seconds N] [--detach] [--failover] [--refresh] --json"
47
47
  RUN_JSON_REQUIRED = "cdx run: --json is required."
48
48
  RUN_TARGET_REQUIRED = "cdx run: specify a session name or --provider PROVIDER."
49
49
  RUN_SESSION_PROVIDER_CONFLICT = "cdx run: cannot specify both a session name and --provider."
@@ -585,6 +585,7 @@ def _parse_run_effort(reasoning_effort, power):
585
585
  def _parse_run_args(args):
586
586
  parsed = _parse_run_flag_args(args, {
587
587
  "--cwd": {"key": "cwd", "type": "str", "default": None},
588
+ "--dir": {"key": "cwd", "type": "str", "default": None},
588
589
  "--prompt-file": {"key": "prompt_file", "type": "str", "default": None},
589
590
  "--prompt": {"key": "prompt", "type": "str", "default": None},
590
591
  "--provider": {"key": "provider", "type": "str", "default": None, "transform": _parse_run_provider},
@@ -28,6 +28,54 @@ from ..errors import CdxError
28
28
  from ..provider_runtime import _ensure_session_authentication, _get_auth_home, _run_interactive_provider_command
29
29
 
30
30
 
31
+ def _parse_launch_args(args):
32
+ """Return the per-launch directory and ordinary launch flags."""
33
+ directory = None
34
+ cleaned = []
35
+ index = 0
36
+ while index < len(args):
37
+ arg = args[index]
38
+ if arg == "--dir":
39
+ if directory is not None or index + 1 >= len(args):
40
+ raise CdxError("Usage: cdx <name> [--dir PATH] [--resume] [--json]")
41
+ directory = args[index + 1]
42
+ index += 2
43
+ continue
44
+ if arg.startswith("--dir="):
45
+ if directory is not None or not arg.split("=", 1)[1]:
46
+ raise CdxError("Usage: cdx <name> [--dir PATH] [--resume] [--json]")
47
+ directory = arg.split("=", 1)[1]
48
+ index += 1
49
+ continue
50
+ cleaned.append(arg)
51
+ index += 1
52
+ return directory, cleaned
53
+
54
+
55
+ def _launch_directory(session, ctx, explicit, json_flag):
56
+ cwd = os.path.realpath(explicit) if explicit else (ctx.get("cwd") or os.getcwd())
57
+ if explicit and not os.path.isdir(cwd):
58
+ raise CdxError(f"Invalid directory: {explicit or cwd}")
59
+ if explicit or json_flag or not ctx["stdin_is_tty"] or cwd not in {os.path.realpath(os.path.expanduser("~")), os.path.realpath(os.path.sep)}:
60
+ return cwd
61
+ recent = []
62
+ for entry in ctx["service"]["get_launch_history"](session["name"], limit=20):
63
+ path = entry.get("cwd")
64
+ if path and os.path.isdir(path) and path not in recent:
65
+ recent.append(path)
66
+ choices = recent + [cwd]
67
+ if len(choices) == 1:
68
+ return cwd
69
+ ctx["out"]("Choose launch directory:\n" + "".join(f" {index + 1}. {path}\n" for index, path in enumerate(choices)))
70
+ answer = (ctx["options"].get("input") or input)(f"Directory [1-{len(choices)}] (default {len(choices)}): ").strip()
71
+ if not answer:
72
+ return cwd
73
+ try:
74
+ return choices[int(answer) - 1]
75
+ except (ValueError, IndexError):
76
+ raise CdxError("Launch cancelled: choose a listed directory.") from None
77
+
78
+
31
79
  def _format_resume_capability(capability, use_color=False):
32
80
  name = capability["session"]
33
81
  provider = capability["provider"]
@@ -74,12 +122,13 @@ def handle_can_resume(rest, ctx):
74
122
  return 0
75
123
 
76
124
  def handle_resume(rest, ctx):
125
+ directory, rest = _parse_launch_args(rest)
77
126
  json_flag, args = _parse_json_flag(rest)
78
127
  if len(args) != 1:
79
128
  raise CdxError(RESUME_USAGE)
80
- return handle_launch(args[0], ctx, resume=True, force_json=json_flag)
129
+ return handle_launch(args[0], ctx, resume=True, force_json=json_flag, directory=directory)
81
130
 
82
- def handle_launch(command, ctx, initial_prompt=None, resume=False, force_json=None):
131
+ def handle_launch(command, ctx, initial_prompt=None, resume=False, force_json=None, directory=None):
83
132
  json_flag = "--json" in ctx.get("raw_args", ctx["options"].get("raw_args", []))
84
133
  if force_json is not None:
85
134
  json_flag = force_json
@@ -91,6 +140,9 @@ def handle_launch(command, ctx, initial_prompt=None, resume=False, force_json=No
91
140
  raise CdxError(
92
141
  f"Provider {session['provider']} does not support native resume through cdx."
93
142
  )
143
+ cwd = _launch_directory(session, ctx, directory, json_flag)
144
+ if not json_flag and not ctx["stdin_is_tty"]:
145
+ ctx["out"](f"Using directory: {cwd}\n")
94
146
  _ensure_session_authentication(
95
147
  session,
96
148
  ctx["service"],
@@ -127,13 +179,12 @@ def handle_launch(command, ctx, initial_prompt=None, resume=False, force_json=No
127
179
  # a real turn — so it gets no instruction it does not need.
128
180
  notice += " — approve them once in Codex to enable"
129
181
  ctx["out"](f"{_info(notice, ctx['use_color'])}\n")
130
- cwd = ctx.get("cwd") or os.getcwd()
131
182
  runtime_run_id = None
132
183
 
133
184
  def runtime_lifecycle(event, info):
134
185
  nonlocal runtime_run_id
135
186
  if event == "started":
136
- runtime = ctx["service"]["start_session_runtime"](session["name"], info)
187
+ runtime = ctx["service"]["start_session_runtime"](session["name"], {**info, "cwd": cwd})
137
188
  runtime_run_id = runtime.get("runId")
138
189
  elif event == "finished" and runtime_run_id:
139
190
  ctx["service"]["finish_session_runtime"](
@@ -176,7 +227,7 @@ def handle_launch(command, ctx, initial_prompt=None, resume=False, force_json=No
176
227
  **run_info,
177
228
  })
178
229
  if json_flag:
179
- extra = {"session": ctx["service"]["get_session"](session["name"])}
230
+ extra = {"session": ctx["service"]["get_session"](session["name"]), "cwd": cwd}
180
231
  if capability:
181
232
  extra["resume"] = capability
182
233
  _write_json(ctx, _json_success("resume" if resume else "launch", message, warnings=warnings, **extra))
@@ -774,10 +774,12 @@ def handle_update(rest, ctx):
774
774
  current_version=current_version,
775
775
  target_version=target_version,
776
776
  mode=plan["mode"],
777
+ prefix=plan["prefix"],
777
778
  steps=results,
778
779
  ))
779
780
  return 0
780
781
  ctx["out"](f"{_success(message, ctx['use_color'])}\n")
782
+ ctx["out"](f"Install prefix: {plan['prefix']}\n")
781
783
  for warning in warnings:
782
784
  ctx["out"](f"{_warn(warning['message'], ctx['use_color'])}\n")
783
785
  return 0
@@ -58,6 +58,7 @@ def _notification_setting_notice(settings, sessions, action):
58
58
  notices.append(f"Agent alerts updated for {names} — this provider does not support notification hooks.")
59
59
  return "\n".join(notices)
60
60
  if "notify_preview" in settings:
61
+ names = _format_bulk_launch_summary(sessions)
61
62
  if action == "set" and settings["notify_preview"]:
62
63
  return f"Response previews enabled for {names} — completion text may be visible on the lock screen."
63
64
  return f"Response previews disabled for {names}."
@@ -5,12 +5,14 @@ and `autostart` act on recorded state and say plainly when there is none, and
5
5
  `doctor` reads all of it without changing any of it.
6
6
  """
7
7
  import os
8
+ import shutil
8
9
  import subprocess
9
10
  from datetime import datetime, timezone
10
11
 
11
12
  from ..cli_args import TRAY_USAGE, _parse_flag_args
12
13
  from ..cli_helpers import _json_success, _write_json
13
14
  from ..cli_render import _dim, _pad_table, _style, _warn
15
+ from ..config import get_cdx_home
14
16
  from ..errors import CdxError
15
17
  from ..tray_alerts import alerts_enabled, set_alerts
16
18
  from ..tray_autostart import disable as autostart_disable
@@ -127,6 +129,7 @@ def _tray_status(args, ctx):
127
129
  ctx["service"]["base_dir"],
128
130
  env=ctx.get("env"),
129
131
  cache=None if parsed["refresh"] else _plugin_cache(ctx),
132
+ directories=[row.get("cwd") for row in rows if row.get("active") and row.get("cwd")],
130
133
  )
131
134
  snapshot = build_snapshot(
132
135
  rows,
@@ -591,6 +594,8 @@ def _tray_doctor(args, ctx):
591
594
  instance = companion_instance(env=env)
592
595
  desktop = desktop_capability(env=env)
593
596
  toasts = toast_capability(env=env)
597
+ companion_cdx = (env or {}).get("CDX_TRAY_CDX") or shutil.which("cdx", path=(env or {}).get("PATH")) or "cdx"
598
+ hook_store = get_cdx_home(env)
594
599
 
595
600
  checks = [
596
601
  ("companion", "installed" if state else ("override" if executable else "absent"),
@@ -608,6 +613,8 @@ def _tray_doctor(args, ctx):
608
613
  ("autostart", "on" if autostart["enabled"] else "off", autostart["artifact"] or "unsupported"),
609
614
  ("alerts", "on" if alerts_enabled(base_dir) else "muted",
610
615
  "cdx tray alerts on|off"),
616
+ ("companion_cdx", "override" if (env or {}).get("CDX_TRAY_CDX") else "path", companion_cdx),
617
+ ("hook_store", "aligned" if os.path.realpath(hook_store) == os.path.realpath(base_dir) else "mismatched", hook_store),
611
618
  ("update", "interrupted" if interrupted_update(base_dir) else "clean",
612
619
  "a staged companion was never promoted; run: cdx tray install"
613
620
  if interrupted_update(base_dir) else "-"),
@@ -108,6 +108,9 @@ def headless_permission_disables_network(provider, permission):
108
108
 
109
109
 
110
110
  REDACTED_PROMPT_ARG = "[prompt redacted]"
111
+ # Documented Claude Code switch that stops it from setting and clearing the
112
+ # terminal title, leaving cdx's shared title keeper as the only writer.
113
+ CLAUDE_DISABLE_TERMINAL_TITLE_ENV = "CLAUDE_CODE_DISABLE_TERMINAL_TITLE"
111
114
  CLAUDE_CLI_MODEL_ALIASES = {
112
115
  "claude-sonnet": "sonnet",
113
116
  "claude-opus": "opus",
@@ -148,11 +151,23 @@ def _anthropic_credentials_path(auth_home):
148
151
  return os.path.join(auth_home, "credentials", f"{_anthropic_profile_name()}.json")
149
152
 
150
153
 
151
- def _claude_env(base_env, auth_home):
154
+ def _claude_env(base_env, auth_home, own_terminal_title=False):
155
+ """Build the Claude provider environment for one cdx spawn.
156
+
157
+ `own_terminal_title` is set by the interactive launch/resume paths, where
158
+ cdx's title keeper owns the `session — folder` title. Claude Code redraws an
159
+ animated busy title far more often than the keeper re-emits, so we tell it
160
+ to leave the title alone; its in-TUI activity indicator is unaffected. The
161
+ value is forced rather than defaulted so an ambient `0` in the operator's
162
+ shell cannot hand the title back to Claude. Headless and auth spawns do not
163
+ own a title and keep their previous environment.
164
+ """
152
165
  env = {**base_env, **_home_env_overrides(auth_home)}
153
166
  env.pop("CLAUDE_CONFIG_DIR", None)
154
167
  env.pop("CODEX_HOME", None)
155
168
  env.setdefault("ANTHROPIC_PROFILE", _anthropic_profile_name())
169
+ if own_terminal_title:
170
+ env[CLAUDE_DISABLE_TERMINAL_TITLE_ENV] = "1"
156
171
  return env
157
172
 
158
173
 
@@ -547,7 +562,7 @@ def _build_launch_spec(session, cwd=None, env_override=None, initial_prompt=None
547
562
  if initial_prompt:
548
563
  args.append(initial_prompt)
549
564
  auth_home = _get_auth_home(session)
550
- claude_env = _claude_env(env, auth_home)
565
+ claude_env = _claude_env(env, auth_home, own_terminal_title=True)
551
566
  oauth_token = _read_claude_launch_oauth_token(auth_home)
552
567
  if oauth_token:
553
568
  claude_env["CLAUDE_CODE_OAUTH_TOKEN"] = oauth_token
@@ -693,7 +708,7 @@ def _build_resume_spec(session, cwd=None, env_override=None, capture_transcript=
693
708
  if resume_prompt:
694
709
  args.append(resume_prompt)
695
710
  auth_home = _get_auth_home(session)
696
- claude_env = _claude_env(env, auth_home)
711
+ claude_env = _claude_env(env, auth_home, own_terminal_title=True)
697
712
  oauth_token = _read_claude_launch_oauth_token(auth_home)
698
713
  if oauth_token:
699
714
  claude_env["CLAUDE_CODE_OAUTH_TOKEN"] = oauth_token
@@ -629,6 +629,7 @@ def _launch_defaults_for(base_dir, provider):
629
629
  try:
630
630
  if supports_agent_alerts(provider) and alerts_default(base_dir):
631
631
  launch["notify"] = True
632
+ launch["notify_preview"] = True
632
633
  except OSError:
633
634
  # An unreadable default is no default. Creating the session matters
634
635
  # more than inheriting a preference.
@@ -739,6 +740,7 @@ def start_session_runtime(store, name, payload=None):
739
740
  "pid": payload.get("pid") or os.getpid(),
740
741
  "command": payload.get("command"),
741
742
  "label": payload.get("label"),
743
+ "cwd": payload.get("cwd"),
742
744
  "transcriptPath": payload.get("transcript_path") or payload.get("transcriptPath"),
743
745
  }
744
746
 
@@ -268,6 +268,7 @@ def _status_row_from_session(store, s):
268
268
  status = s.get("lastStatus")
269
269
  enabled = s.get("enabled", True) is not False
270
270
  row_status = status if enabled else None
271
+ runtime = _session_runtime(store, s["name"])
271
272
  return {
272
273
  "session_name": s["name"],
273
274
  "label": s.get("label"),
@@ -279,7 +280,8 @@ def _status_row_from_session(store, s):
279
280
  # ignored `--priority` entirely.
280
281
  "priority": _row_priority(s),
281
282
  "reasoning_effort": _row_reasoning_effort(s),
282
- "active": bool(_session_runtime(store, s["name"])) if enabled else False,
283
+ "active": bool(runtime) if enabled else False,
284
+ "cwd": runtime.get("cwd") if runtime else None,
283
285
  "status": "enabled" if enabled else "disabled",
284
286
  "auth_status": (s.get("auth") or {}).get("status") or "unknown",
285
287
  "auth_checked_at": _to_local_iso((s.get("auth") or {}).get("lastCheckedAt")),
@@ -313,6 +315,7 @@ def format_list_rows(store):
313
315
  "provider": s["provider"] if has_multiple else None,
314
316
  "enabled": s.get("enabled", True) is not False,
315
317
  "active": bool(_session_runtime(store, s["name"])) if s.get("enabled", True) is not False else False,
318
+ "cwd": (_session_runtime(store, s["name"]) or {}).get("cwd"),
316
319
  "enabled_status": "disabled" if s.get("enabled", True) is False else "enabled",
317
320
  "status": s.get("lastStatus"),
318
321
  "launch": s.get("launch") or {},
@@ -12,6 +12,7 @@ task, because it is what to do instead. A tray menu that listed everything would
12
12
  be a worse `logics-manager status`, run more often.
13
13
  """
14
14
  import json
15
+ import os
15
16
  import subprocess
16
17
  import time
17
18
 
@@ -21,7 +22,7 @@ from .tray_plugins import ADAPTER_TIMEOUT_SECONDS, CARD_TTL_SECONDS
21
22
  _PRIORITY_ORDER = {"high": 0, "medium": 1, "low": 2}
22
23
 
23
24
 
24
- def logics_card(env=None, now=None, cache=None, runner=None, executable=None):
25
+ def logics_card(env=None, now=None, cache=None, runner=None, executable=None, directories=None):
25
26
  """One card, or None when there is nothing truthful to show.
26
27
 
27
28
  None covers every unhappy case on purpose — not installed, not a Logics
@@ -33,7 +34,7 @@ def logics_card(env=None, now=None, cache=None, runner=None, executable=None):
33
34
  cached = _from_cache(cache, now)
34
35
  if cached is not None:
35
36
  return cached
36
- status = _status(env=env, runner=runner, executable=executable)
37
+ status = _status(env=env, runner=runner, executable=executable, directories=directories)
37
38
  card = _card_from_status(status)
38
39
  _to_cache(cache, now, card)
39
40
  return card
@@ -58,7 +59,16 @@ def _to_cache(cache, now, card):
58
59
  cache["logics"] = {"at": now, "card": card}
59
60
 
60
61
 
61
- def _status(env=None, runner=None, executable=None):
62
+ def _repository_root(directory):
63
+ directory = os.path.realpath(directory or "")
64
+ while directory and directory != os.path.dirname(directory):
65
+ if os.path.exists(os.path.join(directory, ".git")):
66
+ return directory
67
+ directory = os.path.dirname(directory)
68
+ return directory if os.path.exists(os.path.join(directory, ".git")) else None
69
+
70
+
71
+ def _status(env=None, runner=None, executable=None, directories=None):
62
72
  """`logics-manager status --format json`, or None however it failed.
63
73
 
64
74
  Resolved through CDX's own lookup rather than a path the tray knows: on a
@@ -68,26 +78,38 @@ def _status(env=None, runner=None, executable=None):
68
78
  executable = executable or resolve_logics_manager(env or {})
69
79
  if not executable:
70
80
  return None
71
- try:
72
- completed = (runner or subprocess.run)(
73
- [executable, "status", "--format", "json"],
74
- capture_output=True,
75
- text=True,
76
- timeout=ADAPTER_TIMEOUT_SECONDS,
77
- check=False,
78
- )
79
- except (OSError, subprocess.SubprocessError):
80
- return None
81
- if getattr(completed, "returncode", 1) != 0:
82
- return None
83
- try:
84
- payload = json.loads(completed.stdout)
85
- except (TypeError, ValueError):
81
+ roots = sorted({root for root in (_repository_root(path) for path in (directories or [])) if root})
82
+ if directories is not None and not roots:
86
83
  return None
87
- return payload if isinstance(payload, dict) else None
84
+ payloads = []
85
+ for root in roots or [None]:
86
+ try:
87
+ completed = (runner or subprocess.run)(
88
+ [executable, "status", "--format", "json"], capture_output=True,
89
+ text=True, timeout=ADAPTER_TIMEOUT_SECONDS, check=False, **({"cwd": root} if root else {}),
90
+ )
91
+ payload = json.loads(completed.stdout) if getattr(completed, "returncode", 1) == 0 else None
92
+ except (OSError, subprocess.SubprocessError, TypeError, ValueError):
93
+ return None if not payloads else payloads
94
+ if isinstance(payload, dict):
95
+ payloads.append((root, payload))
96
+ return payloads if roots else (payloads[0][1] if payloads else None)
88
97
 
89
98
 
90
99
  def _card_from_status(status):
100
+ if isinstance(status, list):
101
+ cards = [(root, _card_from_status(payload), len(payload.get("blocked_docs") or [])) for root, payload in status]
102
+ cards = [(root, card, blocked) for root, card, blocked in cards if card]
103
+ if not cards:
104
+ return None
105
+ root, card, _ = sorted(cards, key=lambda item: (-item[2], -len(item[1]["rows"]), item[0] or ""))[0]
106
+ repository_count = len(cards)
107
+ blocked_count = sum(item[2] for item in cards)
108
+ card["summary"] = f"{repository_count} repositories · {blocked_count} blocked"
109
+ prefix = os.path.basename(root or "")
110
+ for row in card["rows"]:
111
+ row["label"] = f"{prefix}: {row['label']}"
112
+ return card
91
113
  if not isinstance(status, dict):
92
114
  return None
93
115
  blocked = [doc for doc in (status.get("blocked_docs") or []) if isinstance(doc, dict)]
@@ -128,7 +128,7 @@ def _valid_action(action, plugin):
128
128
  )
129
129
 
130
130
 
131
- def collect_cards(base_dir, env=None, now=None, adapters=None, cache=None):
131
+ def collect_cards(base_dir, env=None, now=None, adapters=None, cache=None, directories=None):
132
132
  """Every enabled plugin's card, bounded, with failures dropped.
133
133
 
134
134
  Never raises. An adapter that throws, hangs past its timeout, returns
@@ -143,7 +143,7 @@ def collect_cards(base_dir, env=None, now=None, adapters=None, cache=None):
143
143
  if not adapter:
144
144
  continue
145
145
  try:
146
- raw = adapter(env=env, now=now, cache=cache)
146
+ raw = adapter(env=env, now=now, cache=cache, directories=directories) if name == "logics" else adapter(env=env, now=now, cache=cache)
147
147
  except Exception: # noqa: BLE001 - an integration cannot break the tray
148
148
  continue
149
149
  card = bounded_card(raw, name)
@@ -152,10 +152,10 @@ def collect_cards(base_dir, env=None, now=None, adapters=None, cache=None):
152
152
  return cards
153
153
 
154
154
 
155
- def _logics_adapter(env=None, now=None, cache=None):
155
+ def _logics_adapter(env=None, now=None, cache=None, directories=None):
156
156
  from .tray_logics import logics_card
157
157
 
158
- return logics_card(env=env, now=now, cache=cache)
158
+ return logics_card(env=env, now=now, cache=cache, directories=directories)
159
159
 
160
160
 
161
161
  # The registry. A name here is the only way an adapter can run, and adding one
@@ -171,6 +171,7 @@ def build_update_plan(target_version=None, package_root=None, env=None, prefix=N
171
171
  return {
172
172
  "mode": mode,
173
173
  "package_root": str(root),
174
+ "prefix": str(prefix or sys.prefix),
174
175
  "target_version": version,
175
176
  "steps": steps,
176
177
  }