cdx-manager 0.20.9 → 0.20.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.
@@ -12,15 +12,11 @@ import re
12
12
  from datetime import datetime
13
13
 
14
14
  from .cli_render import _dim, _info, _pad_table, _style, _warn
15
- from .config import PROVIDER_CODEX
16
15
  from .errors import CdxError
17
16
  from .fs_utils import atomic_write
18
- from .provider_runtime import _list_launch_transcript_paths, _run_interactive_provider_command, get_resume_capability
19
- from .status_source import _normalize_terminal_transcript
17
+ from .provider_runtime import _run_interactive_provider_command, get_resume_capability
20
18
 
21
19
  API_SCHEMA_VERSION = 1
22
- HANDOFF_TRANSCRIPT_CHARS = 120000
23
- HANDOFF_NATIVE_TRANSCRIPT_CANDIDATES = 64
24
20
 
25
21
 
26
22
  def _local_now_iso():
@@ -201,213 +197,6 @@ def _make_notify_progress(ctx):
201
197
  )
202
198
 
203
199
 
204
- def _latest_launch_transcript_path(session):
205
- paths = _list_launch_transcript_paths(session)
206
- if not paths:
207
- return None
208
- return max(paths, key=lambda path: (os.path.getmtime(path), path))
209
-
210
-
211
- def _get_session_home(session):
212
- return session.get("authHome") or session.get("sessionRoot") or session.get("codexHome", "")
213
-
214
-
215
- def _safe_stat(path):
216
- try:
217
- return os.stat(path)
218
- except OSError:
219
- return None
220
-
221
-
222
- def _sort_recent_paths(paths):
223
- stats = {path: stat for path, stat in ((_path, _safe_stat(_path)) for _path in set(paths)) if stat}
224
- return sorted(stats, key=lambda path: (stats[path].st_mtime, path), reverse=True)
225
-
226
-
227
- def _collect_native_handoff_transcript_paths(session):
228
- root = _get_session_home(session)
229
- if not root:
230
- return []
231
- candidates = []
232
- direct = [
233
- os.path.join(root, "history.jsonl"),
234
- os.path.join(root, "session_index.jsonl"),
235
- ]
236
- candidates.extend(path for path in direct if _safe_stat(path))
237
-
238
- scan_roots = [
239
- os.path.join(root, "sessions"),
240
- os.path.join(root, ".claude", "projects"),
241
- os.path.join(root, "projects"),
242
- ]
243
- skip_dirs = {"cache", "plugins", "skills", "memories", "sqlite", "shell_snapshots", "tmp", "__pycache__"}
244
- for scan_root in scan_roots:
245
- if not os.path.isdir(scan_root):
246
- continue
247
- for dirpath, dirnames, filenames in os.walk(scan_root):
248
- dirnames[:] = [name for name in dirnames if not name.startswith(".") and name not in skip_dirs]
249
- for filename in filenames:
250
- if filename.endswith(".jsonl") or filename.endswith(".log"):
251
- candidates.append(os.path.join(dirpath, filename))
252
- return _sort_recent_paths(candidates)[:HANDOFF_NATIVE_TRANSCRIPT_CANDIDATES]
253
-
254
-
255
- def _handoff_transcript_workspace(path):
256
- if not path.endswith(".jsonl"):
257
- return None
258
- try:
259
- with open(path, encoding="utf-8", errors="replace") as handle:
260
- record = json.loads(handle.readline() or "{}")
261
- except (OSError, json.JSONDecodeError):
262
- return None
263
- if record.get("type") != "session_meta":
264
- return None
265
- payload = record.get("payload") if isinstance(record.get("payload"), dict) else {}
266
- cwd = payload.get("cwd")
267
- return os.path.realpath(cwd) if isinstance(cwd, str) and cwd else None
268
-
269
-
270
- def _latest_handoff_transcript_path(session, cwd=None):
271
- native_paths = _collect_native_handoff_transcript_paths(session)
272
- if cwd:
273
- workspace = os.path.realpath(cwd)
274
- for path in native_paths:
275
- if _handoff_transcript_workspace(path) == workspace:
276
- return path
277
- if native_paths:
278
- return native_paths[0]
279
- return _latest_launch_transcript_path(session)
280
-
281
-
282
- def _collect_handoff_text_fragments(value):
283
- fragments = []
284
- if isinstance(value, str):
285
- text = value.strip()
286
- if text:
287
- fragments.append(text)
288
- elif isinstance(value, list):
289
- for item in value:
290
- fragments.extend(_collect_handoff_text_fragments(item))
291
- elif isinstance(value, dict):
292
- if isinstance(value.get("text"), str):
293
- fragments.extend(_collect_handoff_text_fragments(value.get("text")))
294
- if isinstance(value.get("content"), (str, list, dict)):
295
- fragments.extend(_collect_handoff_text_fragments(value.get("content")))
296
- if isinstance(value.get("message"), dict):
297
- fragments.extend(_collect_handoff_text_fragments(value.get("message")))
298
- if isinstance(value.get("payload"), dict):
299
- fragments.extend(_collect_handoff_text_fragments(value.get("payload")))
300
- return fragments
301
-
302
-
303
- def _jsonl_record_to_handoff_text(record):
304
- if not isinstance(record, dict):
305
- return None
306
- if record.get("type") == "response_item":
307
- record = record.get("payload")
308
- if not isinstance(record, dict):
309
- return None
310
- message = record.get("message") if isinstance(record.get("message"), dict) else {}
311
- role = (
312
- message.get("role")
313
- or record.get("role")
314
- or record.get("type")
315
- or record.get("sender")
316
- or "entry"
317
- )
318
- if role not in ("user", "assistant"):
319
- return None
320
- text_sources = []
321
- for key in ("content", "text", "payload"):
322
- if key in record:
323
- text_sources.append(record.get(key))
324
- for key in ("content", "text"):
325
- if key in message:
326
- text_sources.append(message.get(key))
327
- fragments = []
328
- for value in text_sources:
329
- fragments.extend(_collect_handoff_text_fragments(value))
330
- text = "\n".join(fragment for fragment in fragments if fragment).strip()
331
- if not text:
332
- return None
333
- role = re.sub(r"[^A-Za-z0-9_-]+", "-", str(role).strip()).strip("-") or "entry"
334
- return f"[{role}]\n{text}"
335
-
336
-
337
- def _read_jsonl_handoff_transcript(path):
338
- entries = []
339
- with open(path, encoding="utf-8", errors="replace") as handle:
340
- for line in handle:
341
- line = line.strip()
342
- if not line:
343
- continue
344
- try:
345
- text = _jsonl_record_to_handoff_text(json.loads(line))
346
- except json.JSONDecodeError:
347
- text = None
348
- if text:
349
- entries.append(text)
350
- if entries:
351
- return "\n\n".join(entries)
352
- with open(path, encoding="utf-8", errors="replace") as handle:
353
- return handle.read()
354
-
355
-
356
- def _read_handoff_transcript(path):
357
- if path.endswith(".jsonl"):
358
- content = _read_jsonl_handoff_transcript(path)
359
- else:
360
- with open(path, encoding="utf-8", errors="replace") as handle:
361
- # `script`-captured PTY transcript: strip ANSI/control noise before
362
- # the char-budget truncation so the tail holds real text, not escapes.
363
- content = _normalize_terminal_transcript(handle.read())
364
- if len(content) <= HANDOFF_TRANSCRIPT_CHARS:
365
- return content, False
366
- return content[-HANDOFF_TRANSCRIPT_CHARS:], True
367
-
368
-
369
- def _build_handoff_context(source, target, transcript_path, transcript, truncated=False):
370
- truncation_note = (
371
- "Only the tail of the previous transcript is included because the log was large."
372
- if truncated else
373
- "The previous transcript is included below."
374
- )
375
- return "\n".join([
376
- "# Shared Context",
377
- "",
378
- "## Goal",
379
- f"Resume the work from `{source['name']}` in `{target['name']}` because the source account has no usage left.",
380
- "",
381
- "## Current State",
382
- f"- Source session: `{source['name']}` ({source['provider']})",
383
- f"- Target session: `{target['name']}` ({target['provider']})",
384
- f"- Transcript source: `{transcript_path}`",
385
- f"- {truncation_note}",
386
- "",
387
- "## Previous Session Transcript",
388
- transcript.rstrip(),
389
- "",
390
- "## Next Steps",
391
- "- Read the transcript above.",
392
- "- Continue the task from the latest actionable state.",
393
- "- Preserve the target account authentication; do not copy source credentials.",
394
- ])
395
-
396
-
397
- def _handoff_launch_prompt(session, install=None):
398
- if session.get("provider") == PROVIDER_CODEX:
399
- context_ref = "$CODEX_HOME/shared-context.md"
400
- else:
401
- context_ref = (install or {}).get("target_path") or os.path.join(
402
- session.get("authHome") or session.get("sessionRoot") or "~",
403
- "shared-context.md",
404
- )
405
- return (
406
- f"Read {context_ref} first, then resume the previous session "
407
- "from the latest actionable state. Do not ask me to paste the context again."
408
- )
409
-
410
-
411
200
  def _resolve_confirmation(confirm_fn, name):
412
201
  confirmed = (
413
202
  asyncio.get_event_loop().run_until_complete(confirm_fn(name))
@@ -109,4 +109,9 @@ def handle_import(rest, ctx):
109
109
  _write_json(ctx, payload)
110
110
  return 0
111
111
  ctx["out"](f"{_success(message, ctx['use_color'])}\n")
112
+ retained = result.get("retained_local_credentials") or []
113
+ if retained:
114
+ # Merge keeps local values, so say which sessions kept their own login
115
+ # rather than leaving the operator to guess which credential is live.
116
+ ctx["out"](f"Kept the existing local Claude authentication for: {', '.join(retained)}\n")
112
117
  return 0
@@ -10,11 +10,8 @@ from ..agent_notify import notifications_enabled, provision, supports_agent_aler
10
10
  from ..cli_args import CAN_RESUME_USAGE, HANDOFF_USAGE, RESUME_USAGE, _parse_json_flag
11
11
  from ..cli_helpers import (
12
12
  API_SCHEMA_VERSION,
13
- _build_handoff_context,
14
- _handoff_launch_prompt,
13
+ _json_failure,
15
14
  _json_success,
16
- _latest_handoff_transcript_path,
17
- _read_handoff_transcript,
18
15
  _resume_capability_for_session,
19
16
  _update_notice_warnings,
20
17
  _warn_if_session_already_running,
@@ -23,8 +20,8 @@ from ..cli_helpers import (
23
20
  )
24
21
  from ..cli_render import _dim, _info, _success, _warn
25
22
  from ..config import PROVIDER_CODEX
26
- from ..context_store import install_context_for_session, write_context
27
23
  from ..errors import CdxError
24
+ from ..handoff import HandoffSourceError, launch_prompt, load_entry, prepare_entry, resolve_source
28
25
  from ..interactive_usage import extract_interactive_usage, transcript_predates_run, usage_delta
29
26
  from ..provider_runtime import (
30
27
  INTERRUPT_EXIT_CODES,
@@ -397,59 +394,92 @@ def _previous_cumulative(history, transcript_path):
397
394
  return cumulative
398
395
  return None
399
396
 
400
- def handle_handoff(rest, ctx):
397
+ def _parse_handoff_args(rest):
401
398
  json_flag, args = _parse_json_flag(rest)
402
- if len(args) not in (1, 2):
399
+ names, flags = [], {}
400
+ index = 0
401
+ while index < len(args):
402
+ arg = args[index]
403
+ if arg.startswith("--"):
404
+ key, sep, value = arg.partition("=")
405
+ if key not in ("--source-conversation", "--source-transcript") or key in flags:
406
+ raise CdxError(HANDOFF_USAGE)
407
+ if not sep:
408
+ index += 1
409
+ value = args[index] if index < len(args) else ""
410
+ if not value or value.startswith("--"):
411
+ raise CdxError(HANDOFF_USAGE)
412
+ flags[key] = value
413
+ else:
414
+ names.append(arg)
415
+ index += 1
416
+ if len(names) not in (1, 2) or (flags and len(names) != 2) or len(flags) > 1:
403
417
  raise CdxError(HANDOFF_USAGE)
404
- if len(args) == 1:
405
- name = args[0]
406
- session = ctx["service"]["get_session"](name)
407
- if not session:
408
- raise CdxError(f"Unknown session: {name}")
409
- install = install_context_for_session(ctx["service"]["base_dir"], session, ctx.get("cwd"))
410
- launch_prompt = _handoff_launch_prompt(session, install)
411
- if json_flag:
412
- _write_json(ctx, _json_success(
413
- "handoff",
414
- f"Installed shared context for {name}",
415
- context=install,
416
- launch_prompt=launch_prompt,
417
- session=session,
418
- ))
419
- return 0
420
- text = f"Shared context installed for {name}: {install['target_path']}"
421
- ctx["out"](f"{_info(text, ctx['use_color'])}\n")
422
- return handle_launch(name, ctx, initial_prompt=launch_prompt)
423
-
424
- source_name, target_name = args
425
- if source_name == target_name:
426
- raise CdxError("Source and target sessions must be different")
427
- source = ctx["service"]["get_session"](source_name)
428
- if not source:
429
- raise CdxError(f"Unknown session: {source_name}")
430
- target = ctx["service"]["get_session"](target_name)
418
+ return json_flag, names, flags
419
+
420
+
421
+ def _handoff_source(source, workspace, flags, ctx, json_flag):
422
+ try:
423
+ return resolve_source(source, workspace, flags.get("--source-conversation"), flags.get("--source-transcript"))
424
+ except HandoffSourceError as error:
425
+ candidates = error.candidates
426
+ # JSON and non-interactive callers always make an explicit second call.
427
+ if json_flag or not ctx["stdin_is_tty"] or not candidates or flags:
428
+ raise
429
+ ctx["out"](str(error) + "\n" + "".join(
430
+ f" {i + 1}. {c['conversation_id']} | {c['workspace']} | last event {c['last_event_at'] or 'unknown'}\n"
431
+ for i, c in enumerate(candidates)
432
+ ))
433
+ ask = ctx["options"].get("input") or input
434
+ try:
435
+ answer = ask("Source conversation number (blank to cancel): ").strip()
436
+ except (KeyboardInterrupt, EOFError):
437
+ raise CdxError("Handoff cancelled.", exit_code=130) from None
438
+ if not answer:
439
+ raise CdxError("Handoff cancelled.", exit_code=130) from None
440
+ if not answer.isdigit() or not 1 <= int(answer) <= len(candidates):
441
+ raise CdxError("Handoff cancelled: choose a listed source.") from None
442
+ return resolve_source(source, workspace, candidates[int(answer) - 1]["conversation_id"])
443
+
444
+
445
+ def handle_handoff(rest, ctx):
446
+ json_flag, names, flags = _parse_handoff_args(rest)
447
+ target = ctx["service"]["get_session"](names[-1])
431
448
  if not target:
432
- raise CdxError(f"Unknown session: {target_name}")
433
- transcript_path = _latest_handoff_transcript_path(source, ctx.get("cwd"))
434
- if not transcript_path:
435
- raise CdxError(f"No transcript found for session: {source_name}")
436
- transcript, truncated = _read_handoff_transcript(transcript_path)
437
- context = _build_handoff_context(source, target, transcript_path, transcript, truncated=truncated)
438
- write_result = write_context(ctx["service"]["base_dir"], context, ctx.get("cwd"))
439
- install = install_context_for_session(ctx["service"]["base_dir"], target, ctx.get("cwd"))
440
- launch_prompt = _handoff_launch_prompt(target, install)
449
+ raise CdxError(f"Unknown session: {names[-1]}")
450
+ if not target.get("enabled", True):
451
+ raise CdxError(f"Session is disabled: {target['name']}")
452
+ workspace = os.path.realpath(ctx.get("cwd") or os.getcwd())
453
+ if not os.path.isdir(workspace):
454
+ raise CdxError(f"Invalid directory: {workspace}")
455
+ base_dir = ctx["service"]["base_dir"]
456
+ source = None
457
+ if len(names) == 2:
458
+ if names[0] == names[1]:
459
+ raise CdxError("Source and target sessions must be different")
460
+ source = ctx["service"]["get_session"](names[0])
461
+ if not source:
462
+ raise CdxError(f"Unknown session: {names[0]}")
463
+ try:
464
+ transcript = _handoff_source(source, workspace, flags, ctx, json_flag)
465
+ except HandoffSourceError as error:
466
+ if not json_flag:
467
+ choices = "\n".join(f" {c['conversation_id']} | {c['workspace']} | {c['last_event_at'] or 'unknown'}" for c in error.candidates)
468
+ raise CdxError(str(error) + ("\nCandidates:\n" + choices if choices else "")) from error
469
+ _write_json(ctx, _json_failure("handoff", "handoff_source_unresolved", str(error), candidates=error.candidates))
470
+ return error.exit_code
471
+ path, entry = prepare_entry(base_dir, target, workspace, source, transcript)
472
+ else:
473
+ prepared = load_entry(base_dir, target, workspace)
474
+ path, entry = prepared if prepared else prepare_entry(base_dir, target, workspace)
475
+ prompt = launch_prompt(path, entry)
441
476
  if json_flag:
442
477
  _write_json(ctx, _json_success(
443
- "handoff",
444
- f"Prepared handoff from {source_name} to {target_name}",
445
- context=install,
446
- source_session=source,
447
- target_session=target,
448
- source_transcript=transcript_path,
449
- shared_context=write_result,
450
- launch_prompt=launch_prompt,
478
+ "handoff", f"Prepared {entry['mode']} handoff for {target['name']}",
479
+ context={"target_path": path}, handoff=entry,
480
+ source_session=entry['source'], target_session={"name": target['name'], "provider": target['provider']},
481
+ source_transcript=(entry['transcript'] or {}).get('path'), launch_prompt=prompt,
451
482
  ))
452
483
  return 0
453
- text = f"Handoff prepared from {source_name} to {target_name}: {install['target_path']}"
454
- ctx["out"](f"{_info(text, ctx['use_color'])}\n")
455
- return handle_launch(target_name, ctx, initial_prompt=launch_prompt)
484
+ ctx["out"](f"Handoff ready ({entry['mode']}): {path}\n")
485
+ return handle_launch(target['name'], ctx, initial_prompt=prompt, directory=workspace)
@@ -3,6 +3,7 @@
3
3
  Split out of cli_commands.py. Moved verbatim; re-exported by cli_commands.
4
4
  """
5
5
 
6
+ import importlib.util
6
7
  import os
7
8
  import subprocess
8
9
  import sys
@@ -147,9 +148,22 @@ def _cdx_self_command():
147
148
  script has no reliable executable bit on Windows (`os.access(X_OK)` is true
148
149
  for any existing file there) and a shebang script handed to CreateProcess
149
150
  does not start at all.
151
+
152
+ Named from the top-level package, not this module's own package: `cli` lives
153
+ beside `commands`, so `{__package__}.cli` resolves to a module that does not
154
+ exist. The import root is the parent of every package component, which for
155
+ src/commands/runs.py is the repository root rather than src/.
150
156
  """
151
- package_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
152
- return [sys.executable, "-m", f"{__package__}.cli"], package_root
157
+ package = (__package__ or "src.commands").split(".")[0]
158
+ module = f"{package}.cli"
159
+ package_root = os.path.abspath(__file__)
160
+ for _ in range((__package__ or "src.commands").count(".") + 2):
161
+ package_root = os.path.dirname(package_root)
162
+ if importlib.util.find_spec(module) is None:
163
+ # A wrong module name reports a launched run that never runs, so fail
164
+ # here where the caller still gets the error.
165
+ raise CdxError(f"Cannot launch a detached run: the cdx CLI module {module} was not found.")
166
+ return [sys.executable, "-m", module], package_root
153
167
 
154
168
  def _detached_spawn_options():
155
169
  """Platform flags that keep the child alive after this process exits.
@@ -751,6 +765,7 @@ def handle_run(rest, ctx):
751
765
  return error.exit_code or 1
752
766
  except CdxError as error:
753
767
  run_info = getattr(error, "run_info", None)
768
+ cancelled = bool(getattr(error, "cancelled", False))
754
769
  final_payload = run_result_payload(
755
770
  API_SCHEMA_VERSION,
756
771
  False,
@@ -758,14 +773,16 @@ def handle_run(rest, ctx):
758
773
  locals().get("session"),
759
774
  run_info=run_info,
760
775
  error=error,
761
- error_source="cdx",
762
- error_code=run_cdx_error_code(error),
776
+ error_source="provider" if cancelled else "cdx",
777
+ error_code="run_cancelled" if cancelled else run_cdx_error_code(error),
763
778
  selection_row=selection_row,
764
779
  )
765
780
  if registry and run_id:
766
781
  registry.finish(
767
782
  run_id,
768
- status="failed",
783
+ # A cancelled run is neither a task failure nor a timeout, and a
784
+ # caller has to be able to tell the three apart.
785
+ status="cancelled" if cancelled else "failed",
769
786
  final_payload=final_payload,
770
787
  run_info=run_info,
771
788
  error=final_payload.get("error"),
@@ -2,6 +2,7 @@ import hashlib
2
2
  import os
3
3
  import shlex
4
4
  import subprocess
5
+ import threading
5
6
  from datetime import datetime
6
7
  from urllib.parse import quote, unquote
7
8
 
@@ -105,13 +106,31 @@ def append_context(base_dir, note, cwd=None):
105
106
  return append_context_path(get_context_path(base_dir, cwd), note)
106
107
 
107
108
 
109
+ # An append is a read-modify-write, and the write is atomic per attempt, so two
110
+ # concurrent appends that both read the pre-existing text produce one file that
111
+ # is missing a note the caller was told had been accepted. Parallel agents append
112
+ # to the same scope by design, so the sequence is serialized here.
113
+ _APPEND_LOCKS = {}
114
+ _APPEND_LOCKS_GUARD = threading.Lock()
115
+
116
+
117
+ def _append_lock(path):
118
+ key = os.path.abspath(path)
119
+ with _APPEND_LOCKS_GUARD:
120
+ lock = _APPEND_LOCKS.get(key)
121
+ if lock is None:
122
+ lock = _APPEND_LOCKS[key] = threading.Lock()
123
+ return lock
124
+
125
+
108
126
  def append_context_path(path, note):
109
127
  note = str(note or "").strip()
110
128
  if not note:
111
129
  raise CdxError("Memory append requires text.")
112
- current = read_context_path(path).rstrip()
113
- content = f"{current}\n{note}" if current else note
114
- return write_context_path(path, content)
130
+ with _append_lock(path):
131
+ current = read_context_path(path).rstrip()
132
+ content = f"{current}\n{note}" if current else note
133
+ return write_context_path(path, content)
115
134
 
116
135
 
117
136
  def init_context(base_dir, cwd=None):