claude-code-tools 1.16.1__tar.gz → 1.17.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (115) hide show
  1. {claude_code_tools-1.16.1 → claude_code_tools-1.17.0}/PKG-INFO +6 -5
  2. {claude_code_tools-1.16.1 → claude_code_tools-1.17.0}/README.md +5 -4
  3. {claude_code_tools-1.16.1 → claude_code_tools-1.17.0}/claude_code_tools/__init__.py +1 -1
  4. {claude_code_tools-1.16.1 → claude_code_tools-1.17.0}/claude_code_tools/aichat.py +73 -1
  5. {claude_code_tools-1.16.1 → claude_code_tools-1.17.0}/claude_code_tools/codex_server_models.py +16 -10
  6. {claude_code_tools-1.16.1 → claude_code_tools-1.17.0}/claude_code_tools/export_session.py +101 -49
  7. {claude_code_tools-1.16.1 → claude_code_tools-1.17.0}/claude_code_tools/find_claude_session.py +116 -36
  8. {claude_code_tools-1.16.1 → claude_code_tools-1.17.0}/claude_code_tools/find_codex_session.py +163 -113
  9. claude_code_tools-1.17.0/claude_code_tools/resolve_session.py +832 -0
  10. {claude_code_tools-1.16.1 → claude_code_tools-1.17.0}/claude_code_tools/session_utils.py +76 -32
  11. {claude_code_tools-1.16.1 → claude_code_tools-1.17.0}/claude_code_tools/trim_session.py +6 -2
  12. {claude_code_tools-1.16.1 → claude_code_tools-1.17.0}/claude_code_tools/workflow_cli.py +5 -5
  13. {claude_code_tools-1.16.1 → claude_code_tools-1.17.0}/claude_code_tools/workflow_cli_contract.py +2 -0
  14. {claude_code_tools-1.16.1 → claude_code_tools-1.17.0}/claude_code_tools/workflow_cli_rendering.py +31 -6
  15. {claude_code_tools-1.16.1 → claude_code_tools-1.17.0}/claude_code_tools/workflow_cli_snapshots.py +21 -0
  16. {claude_code_tools-1.16.1 → claude_code_tools-1.17.0}/claude_code_tools/workflow_validation.py +1 -0
  17. {claude_code_tools-1.16.1 → claude_code_tools-1.17.0}/pyproject.toml +2 -2
  18. {claude_code_tools-1.16.1 → claude_code_tools-1.17.0}/.gitignore +0 -0
  19. {claude_code_tools-1.16.1 → claude_code_tools-1.17.0}/LICENSE +0 -0
  20. {claude_code_tools-1.16.1 → claude_code_tools-1.17.0}/claude_code_tools/action_rpc.py +0 -0
  21. {claude_code_tools-1.16.1 → claude_code_tools-1.17.0}/claude_code_tools/agent_tunnel/__init__.py +0 -0
  22. {claude_code_tools-1.16.1 → claude_code_tools-1.17.0}/claude_code_tools/agent_tunnel/backends.py +0 -0
  23. {claude_code_tools-1.16.1 → claude_code_tools-1.17.0}/claude_code_tools/agent_tunnel/cli.py +0 -0
  24. {claude_code_tools-1.16.1 → claude_code_tools-1.17.0}/claude_code_tools/agent_tunnel/config.py +0 -0
  25. {claude_code_tools-1.16.1 → claude_code_tools-1.17.0}/claude_code_tools/agent_tunnel/convert.py +0 -0
  26. {claude_code_tools-1.16.1 → claude_code_tools-1.17.0}/claude_code_tools/agent_tunnel/discord_bot.py +0 -0
  27. {claude_code_tools-1.16.1 → claude_code_tools-1.17.0}/claude_code_tools/agent_tunnel/locking.py +0 -0
  28. {claude_code_tools-1.16.1 → claude_code_tools-1.17.0}/claude_code_tools/agent_tunnel/paths.py +0 -0
  29. {claude_code_tools-1.16.1 → claude_code_tools-1.17.0}/claude_code_tools/agent_tunnel/registry.py +0 -0
  30. {claude_code_tools-1.16.1 → claude_code_tools-1.17.0}/claude_code_tools/agent_tunnel/session.py +0 -0
  31. {claude_code_tools-1.16.1 → claude_code_tools-1.17.0}/claude_code_tools/agent_tunnel/store.py +0 -0
  32. {claude_code_tools-1.16.1 → claude_code_tools-1.17.0}/claude_code_tools/agent_tunnel/tmux.py +0 -0
  33. {claude_code_tools-1.16.1 → claude_code_tools-1.17.0}/claude_code_tools/agent_tunnel/trust.py +0 -0
  34. {claude_code_tools-1.16.1 → claude_code_tools-1.17.0}/claude_code_tools/claude_continue.py +0 -0
  35. {claude_code_tools-1.16.1 → claude_code_tools-1.17.0}/claude_code_tools/codex_continue.py +0 -0
  36. {claude_code_tools-1.16.1 → claude_code_tools-1.17.0}/claude_code_tools/codex_server.py +0 -0
  37. {claude_code_tools-1.16.1 → claude_code_tools-1.17.0}/claude_code_tools/codex_server_cli.py +0 -0
  38. {claude_code_tools-1.16.1 → claude_code_tools-1.17.0}/claude_code_tools/codex_server_fingerprint.py +0 -0
  39. {claude_code_tools-1.16.1 → claude_code_tools-1.17.0}/claude_code_tools/codex_server_generation.py +0 -0
  40. {claude_code_tools-1.16.1 → claude_code_tools-1.17.0}/claude_code_tools/codex_server_process.py +0 -0
  41. {claude_code_tools-1.16.1 → claude_code_tools-1.17.0}/claude_code_tools/codex_server_reuse.py +0 -0
  42. {claude_code_tools-1.16.1 → claude_code_tools-1.17.0}/claude_code_tools/codex_server_supervisor.py +0 -0
  43. {claude_code_tools-1.16.1 → claude_code_tools-1.17.0}/claude_code_tools/codex_server_worker.py +0 -0
  44. {claude_code_tools-1.16.1 → claude_code_tools-1.17.0}/claude_code_tools/config.py +0 -0
  45. {claude_code_tools-1.16.1 → claude_code_tools-1.17.0}/claude_code_tools/csv2gsheet.py +0 -0
  46. {claude_code_tools-1.16.1 → claude_code_tools-1.17.0}/claude_code_tools/delete_session.py +0 -0
  47. {claude_code_tools-1.16.1 → claude_code_tools-1.17.0}/claude_code_tools/dotenv_vault.py +0 -0
  48. {claude_code_tools-1.16.1 → claude_code_tools-1.17.0}/claude_code_tools/env_safe.py +0 -0
  49. {claude_code_tools-1.16.1 → claude_code_tools-1.17.0}/claude_code_tools/export_all.py +0 -0
  50. {claude_code_tools-1.16.1 → claude_code_tools-1.17.0}/claude_code_tools/export_claude_session.py +0 -0
  51. {claude_code_tools-1.16.1 → claude_code_tools-1.17.0}/claude_code_tools/export_codex_session.py +0 -0
  52. {claude_code_tools-1.16.1 → claude_code_tools-1.17.0}/claude_code_tools/find_original_session.py +0 -0
  53. {claude_code_tools-1.16.1 → claude_code_tools-1.17.0}/claude_code_tools/find_session.py +0 -0
  54. {claude_code_tools-1.16.1 → claude_code_tools-1.17.0}/claude_code_tools/find_trimmed_sessions.py +0 -0
  55. {claude_code_tools-1.16.1 → claude_code_tools-1.17.0}/claude_code_tools/fix_session.py +0 -0
  56. {claude_code_tools-1.16.1 → claude_code_tools-1.17.0}/claude_code_tools/gdoc2docx.py +0 -0
  57. {claude_code_tools-1.16.1 → claude_code_tools-1.17.0}/claude_code_tools/gdoc2md.py +0 -0
  58. {claude_code_tools-1.16.1 → claude_code_tools-1.17.0}/claude_code_tools/gsheet2csv.py +0 -0
  59. {claude_code_tools-1.16.1 → claude_code_tools-1.17.0}/claude_code_tools/md2gdoc.py +0 -0
  60. {claude_code_tools-1.16.1 → claude_code_tools-1.17.0}/claude_code_tools/msg/__init__.py +0 -0
  61. {claude_code_tools-1.16.1 → claude_code_tools-1.17.0}/claude_code_tools/msg/cli.py +0 -0
  62. {claude_code_tools-1.16.1 → claude_code_tools-1.17.0}/claude_code_tools/msg/hooks.py +0 -0
  63. {claude_code_tools-1.16.1 → claude_code_tools-1.17.0}/claude_code_tools/msg/models.py +0 -0
  64. {claude_code_tools-1.16.1 → claude_code_tools-1.17.0}/claude_code_tools/msg/prompt_detect.py +0 -0
  65. {claude_code_tools-1.16.1 → claude_code_tools-1.17.0}/claude_code_tools/msg/store.py +0 -0
  66. {claude_code_tools-1.16.1 → claude_code_tools-1.17.0}/claude_code_tools/msg/watcher.py +0 -0
  67. {claude_code_tools-1.16.1 → claude_code_tools-1.17.0}/claude_code_tools/node_menu_ui.py +0 -0
  68. {claude_code_tools-1.16.1 → claude_code_tools-1.17.0}/claude_code_tools/search_index.py +0 -0
  69. {claude_code_tools-1.16.1 → claude_code_tools-1.17.0}/claude_code_tools/session_lineage.py +0 -0
  70. {claude_code_tools-1.16.1 → claude_code_tools-1.17.0}/claude_code_tools/session_menu.py +0 -0
  71. {claude_code_tools-1.16.1 → claude_code_tools-1.17.0}/claude_code_tools/session_menu_cli.py +0 -0
  72. {claude_code_tools-1.16.1 → claude_code_tools-1.17.0}/claude_code_tools/smart_trim.py +0 -0
  73. {claude_code_tools-1.16.1 → claude_code_tools-1.17.0}/claude_code_tools/smart_trim_core.py +0 -0
  74. {claude_code_tools-1.16.1 → claude_code_tools-1.17.0}/claude_code_tools/tmux_cli_controller.py +0 -0
  75. {claude_code_tools-1.16.1 → claude_code_tools-1.17.0}/claude_code_tools/tmux_execution_helpers.py +0 -0
  76. {claude_code_tools-1.16.1 → claude_code_tools-1.17.0}/claude_code_tools/tmux_remote_controller.py +0 -0
  77. {claude_code_tools-1.16.1 → claude_code_tools-1.17.0}/claude_code_tools/trim_in_place.py +0 -0
  78. {claude_code_tools-1.16.1 → claude_code_tools-1.17.0}/claude_code_tools/trim_session_claude.py +0 -0
  79. {claude_code_tools-1.16.1 → claude_code_tools-1.17.0}/claude_code_tools/trim_session_codex.py +0 -0
  80. {claude_code_tools-1.16.1 → claude_code_tools-1.17.0}/claude_code_tools/workflow_cli_formatting.py +0 -0
  81. {claude_code_tools-1.16.1 → claude_code_tools-1.17.0}/claude_code_tools/workflow_cli_identity_policy.py +0 -0
  82. {claude_code_tools-1.16.1 → claude_code_tools-1.17.0}/claude_code_tools/workflow_cli_manifest.py +0 -0
  83. {claude_code_tools-1.16.1 → claude_code_tools-1.17.0}/claude_code_tools/workflow_cli_projection.py +0 -0
  84. {claude_code_tools-1.16.1 → claude_code_tools-1.17.0}/claude_code_tools/workflow_cli_store_backends.py +0 -0
  85. {claude_code_tools-1.16.1 → claude_code_tools-1.17.0}/claude_code_tools/workflow_processes.py +0 -0
  86. {claude_code_tools-1.16.1 → claude_code_tools-1.17.0}/claude_code_tools/workflow_runs.py +0 -0
  87. {claude_code_tools-1.16.1 → claude_code_tools-1.17.0}/claude_code_tools/workflow_store_io.py +0 -0
  88. {claude_code_tools-1.16.1 → claude_code_tools-1.17.0}/docs/agent-tunnel-spec.md +0 -0
  89. {claude_code_tools-1.16.1 → claude_code_tools-1.17.0}/docs/ai-agent-writing-skills.md +0 -0
  90. {claude_code_tools-1.16.1 → claude_code_tools-1.17.0}/docs/cc-codex-instructions.md +0 -0
  91. {claude_code_tools-1.16.1 → claude_code_tools-1.17.0}/docs/claude-code-chutes.md +0 -0
  92. {claude_code_tools-1.16.1 → claude_code_tools-1.17.0}/docs/claude-code-tmux-tutorials.md +0 -0
  93. {claude_code_tools-1.16.1 → claude_code_tools-1.17.0}/docs/dot-zshrc.md +0 -0
  94. {claude_code_tools-1.16.1 → claude_code_tools-1.17.0}/docs/ensemble-ideas.md +0 -0
  95. {claude_code_tools-1.16.1 → claude_code_tools-1.17.0}/docs/example-with-images.md +0 -0
  96. {claude_code_tools-1.16.1 → claude_code_tools-1.17.0}/docs/find-claude-session.md +0 -0
  97. {claude_code_tools-1.16.1 → claude_code_tools-1.17.0}/docs/intercom-spec.md +0 -0
  98. {claude_code_tools-1.16.1 → claude_code_tools-1.17.0}/docs/linked-in-20260102.md +0 -0
  99. {claude_code_tools-1.16.1 → claude_code_tools-1.17.0}/docs/lmsh.md +0 -0
  100. {claude_code_tools-1.16.1 → claude_code_tools-1.17.0}/docs/local-llm-setup.md +0 -0
  101. {claude_code_tools-1.16.1 → claude_code_tools-1.17.0}/docs/msg-phase-a-plan.md +0 -0
  102. {claude_code_tools-1.16.1 → claude_code_tools-1.17.0}/docs/msg-plan-v2.md +0 -0
  103. {claude_code_tools-1.16.1 → claude_code_tools-1.17.0}/docs/paper-writing-style-transfer-tools.md +0 -0
  104. {claude_code_tools-1.16.1 → claude_code_tools-1.17.0}/docs/reddit-aichat-resume-v2.md +0 -0
  105. {claude_code_tools-1.16.1 → claude_code_tools-1.17.0}/docs/reddit-aichat-resume.md +0 -0
  106. {claude_code_tools-1.16.1 → claude_code_tools-1.17.0}/docs/reddit-aichat.md +0 -0
  107. {claude_code_tools-1.16.1 → claude_code_tools-1.17.0}/docs/reddit-post.md +0 -0
  108. {claude_code_tools-1.16.1 → claude_code_tools-1.17.0}/docs/rollover-details.md +0 -0
  109. {claude_code_tools-1.16.1 → claude_code_tools-1.17.0}/docs/tmux-cli-instructions.md +0 -0
  110. {claude_code_tools-1.16.1 → claude_code_tools-1.17.0}/docs/vault-documentation.md +0 -0
  111. {claude_code_tools-1.16.1 → claude_code_tools-1.17.0}/hatch_build.py +0 -0
  112. {claude_code_tools-1.16.1 → claude_code_tools-1.17.0}/node_ui/action_config.js +0 -0
  113. {claude_code_tools-1.16.1 → claude_code_tools-1.17.0}/node_ui/menu.js +0 -0
  114. {claude_code_tools-1.16.1 → claude_code_tools-1.17.0}/node_ui/package-lock.json +0 -0
  115. {claude_code_tools-1.16.1 → claude_code_tools-1.17.0}/node_ui/package.json +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: claude-code-tools
3
- Version: 1.16.1
3
+ Version: 1.17.0
4
4
  Summary: Collection of tools for working with Claude Code
5
5
  License-File: LICENSE
6
6
  Requires-Python: >=3.11
@@ -115,10 +115,11 @@ codex-workflows show RUN_ID
115
115
  codex-workflows --all
116
116
  ```
117
117
 
118
- The default list contains only active workflows. Use `--all` to include
119
- completed and diagnostic history. The command reads durable workflow state and
120
- process metadata. It never changes a run, sends a signal, repairs state, or
121
- launches an agent. See the
118
+ The default list contains only active workflows and identifies the project
119
+ folder from which each workflow was launched. Use `show RUN_ID` for its full
120
+ working directory, or `--all` to include completed and diagnostic history. The
121
+ command reads durable workflow state and process metadata. It never changes a
122
+ run, sends a signal, repairs state, or launches an agent. See the
122
123
  [codex-workflows reference][codex-workflows-reference] for filters, JSON output,
123
124
  and the complete versioned schema.
124
125
 
@@ -89,10 +89,11 @@ codex-workflows show RUN_ID
89
89
  codex-workflows --all
90
90
  ```
91
91
 
92
- The default list contains only active workflows. Use `--all` to include
93
- completed and diagnostic history. The command reads durable workflow state and
94
- process metadata. It never changes a run, sends a signal, repairs state, or
95
- launches an agent. See the
92
+ The default list contains only active workflows and identifies the project
93
+ folder from which each workflow was launched. Use `show RUN_ID` for its full
94
+ working directory, or `--all` to include completed and diagnostic history. The
95
+ command reads durable workflow state and process metadata. It never changes a
96
+ run, sends a signal, repairs state, or launches an agent. See the
96
97
  [codex-workflows reference][codex-workflows-reference] for filters, JSON output,
97
98
  and the complete versioned schema.
98
99
 
@@ -1,3 +1,3 @@
1
1
  """Claude Code Tools - Collection of utilities for Claude Code."""
2
2
 
3
- __version__ = "1.16.1"
3
+ __version__ = "1.17.0"
@@ -30,6 +30,26 @@ class SessionIDGroup(click.Group):
30
30
  return super().parse_args(ctx, args)
31
31
 
32
32
 
33
+ class ResolveCommand(click.Command):
34
+ """Click command that renders resolver usage errors without usage text."""
35
+
36
+ def parse_args(self, ctx: click.Context, args: list[str]) -> list[str]:
37
+ """Parse arguments, converting Click usage failures to resolver errors."""
38
+ import sys
39
+
40
+ from claude_code_tools.resolve_session import _render_error
41
+
42
+ raw_args = tuple(args)
43
+ try:
44
+ return super().parse_args(ctx, args)
45
+ except click.UsageError as error:
46
+ pretty = "--pretty" in raw_args or (
47
+ "--json" not in raw_args and sys.stdout.isatty()
48
+ )
49
+ _render_error("invalid_input", error.format_message(), pretty)
50
+ ctx.exit(1)
51
+
52
+
33
53
  @click.group(cls=SessionIDGroup, invoke_without_command=True)
34
54
  @click.version_option()
35
55
  @click.option(
@@ -96,7 +116,11 @@ def main(ctx, claude_home, codex_home):
96
116
  # Skip for build-index/clear-index to avoid double-indexing or state conflicts
97
117
  # In JSON mode (-j/--json), suppress all output for clean parsing
98
118
  skip_auto_index_cmds = ['build-index', 'clear-index', 'index-stats']
99
- should_skip = any(cmd in sys.argv for cmd in skip_auto_index_cmds)
119
+ should_skip = (
120
+ ctx.invoked_subcommand == 'resolve'
121
+ or ctx.invoked_subcommand in skip_auto_index_cmds
122
+ or any(cmd in sys.argv for cmd in skip_auto_index_cmds)
123
+ )
100
124
  json_mode = any(arg in sys.argv for arg in ['-j', '--json'])
101
125
  if not should_skip:
102
126
  try:
@@ -128,6 +152,54 @@ def main(ctx, claude_home, codex_home):
128
152
  )
129
153
 
130
154
 
155
+ @main.command("resolve", cls=ResolveCommand)
156
+ @click.argument("query")
157
+ @click.option(
158
+ "--agent",
159
+ type=str,
160
+ metavar="claude|codex",
161
+ default="claude",
162
+ show_default=True,
163
+ )
164
+ @click.option(
165
+ "--home",
166
+ type=click.Path(readable=False),
167
+ help="Claude or Codex home directory to search.",
168
+ )
169
+ @click.option("--json", "json_output", is_flag=True, help="Force JSON output.")
170
+ @click.option(
171
+ "--pretty",
172
+ "pretty_output",
173
+ is_flag=True,
174
+ help="Force human-readable output.",
175
+ )
176
+ @click.pass_context
177
+ def resolve_session_cmd(
178
+ ctx: click.Context,
179
+ query: str,
180
+ agent: str,
181
+ home: str | None,
182
+ json_output: bool,
183
+ pretty_output: bool,
184
+ ) -> None:
185
+ """Resolve a name, full session ID, or partial session ID."""
186
+ import sys
187
+
188
+ from claude_code_tools.resolve_session import run
189
+
190
+ if json_output and pretty_output:
191
+ click.echo(
192
+ '{"error":"invalid_format",'
193
+ '"detail":"Choose only one of --json or --pretty."}'
194
+ )
195
+ sys.exit(1)
196
+ if home is None:
197
+ home_key = "codex_home" if agent.casefold() == "codex" else "claude_home"
198
+ home = ctx.ensure_object(dict).get(home_key)
199
+ fmt = "json" if json_output else "pretty" if pretty_output else "auto"
200
+ sys.exit(run(query, agent.lower(), home, fmt))
201
+
202
+
131
203
  # Shared help text for find commands
132
204
  _FIND_OPTIONS_COMMON = """
133
205
  Options:
@@ -444,7 +444,7 @@ def clear_current_generation(base: ServerPaths) -> None:
444
444
 
445
445
 
446
446
  def all_server_paths(base: ServerPaths) -> list[ServerPaths]:
447
- """Return bounded helper generations eligible for explicit cleanup."""
447
+ """Return bounded helper generations retaining state or a socket."""
448
448
  info = _lstat(base.runtime_dir)
449
449
  if info is None:
450
450
  return [base]
@@ -474,12 +474,12 @@ def all_server_paths(base: ServerPaths) -> list[ServerPaths]:
474
474
  raise CodexServerError("app-server runtime changed during inspection")
475
475
  with os.scandir(directory_fd) as entries:
476
476
  for entry in entries:
477
- scanned += 1
478
- if scanned > MAX_SERVER_DIRECTORY_ENTRIES:
479
- raise CodexServerError("too many app-server runtime entries")
480
477
  try:
481
478
  generation = validate_generation(entry.name)
482
479
  except ValueError:
480
+ scanned += 1
481
+ if scanned > MAX_SERVER_DIRECTORY_ENTRIES:
482
+ raise CodexServerError("too many app-server runtime entries")
483
483
  continue
484
484
  try:
485
485
  entry_info = entry.stat(follow_symlinks=False)
@@ -497,6 +497,15 @@ def all_server_paths(base: ServerPaths) -> list[ServerPaths]:
497
497
  raise CodexServerError(
498
498
  f"app-server generation is owned by another user: {entry.name}"
499
499
  )
500
+ paths = paths_for_generation(base, generation)
501
+ if (
502
+ _lstat(paths.state_path) is None
503
+ and _lstat(paths.socket_path) is None
504
+ ):
505
+ continue
506
+ scanned += 1
507
+ if scanned > MAX_SERVER_DIRECTORY_ENTRIES:
508
+ raise CodexServerError("too many app-server runtime entries")
500
509
  generations.append(generation)
501
510
  if len(generations) > MAX_SERVER_GENERATION_DIRECTORIES:
502
511
  raise CodexServerError(
@@ -510,13 +519,10 @@ def all_server_paths(base: ServerPaths) -> list[ServerPaths]:
510
519
  def require_generation_capacity(base: ServerPaths, generation: str) -> None:
511
520
  """Bound retained servers before creating another generation."""
512
521
  target = paths_for_generation(base, generation)
513
- if _lstat(target.runtime_dir) is not None:
522
+ retained = all_server_paths(base)[1:]
523
+ if any(paths.generation == target.generation for paths in retained):
514
524
  return
515
- retained = sum(
516
- _lstat(paths.state_path) is not None or _lstat(paths.socket_path) is not None
517
- for paths in all_server_paths(base)[1:]
518
- )
519
- if retained >= MAX_SERVER_GENERATIONS:
525
+ if len(retained) >= MAX_SERVER_GENERATIONS:
520
526
  raise CodexServerError(
521
527
  "app-server generation limit reached; exit callback-ready sessions "
522
528
  "and run `codex-server stop --force` before launching another"
@@ -97,7 +97,10 @@ def _get_last_line_timestamp(file_path: Path) -> Optional[str]:
97
97
 
98
98
  # Parse JSON and extract timestamp
99
99
  data = json.loads(last_line)
100
- return data.get("timestamp")
100
+ if not isinstance(data, dict):
101
+ return None
102
+ timestamp = data.get("timestamp")
103
+ return timestamp if isinstance(timestamp, str) and timestamp else None
101
104
 
102
105
  except (OSError, IOError, json.JSONDecodeError, UnicodeDecodeError):
103
106
  return None
@@ -114,6 +117,8 @@ def _extract_claude_message_text(data: dict) -> Optional[str]:
114
117
  Extracted text or None if not a text message
115
118
  """
116
119
  message = data.get("message", {})
120
+ if not isinstance(message, dict):
121
+ return None
117
122
  content = message.get("content")
118
123
 
119
124
  if not content:
@@ -129,8 +134,9 @@ def _extract_claude_message_text(data: dict) -> Optional[str]:
129
134
  if isinstance(block, str) and block.strip():
130
135
  return block.strip()
131
136
  if isinstance(block, dict) and block.get("type") == "text":
132
- text = block.get("text", "").strip()
133
- if text:
137
+ text = block.get("text")
138
+ if isinstance(text, str) and text.strip():
139
+ text = text.strip()
134
140
  return text
135
141
 
136
142
  return None
@@ -147,6 +153,8 @@ def _extract_codex_message_text(data: dict) -> Optional[str]:
147
153
  Extracted text or None if not a text message
148
154
  """
149
155
  payload = data.get("payload", {})
156
+ if not isinstance(payload, dict):
157
+ return None
150
158
  if payload.get("type") != "message":
151
159
  return None
152
160
 
@@ -160,9 +168,9 @@ def _extract_codex_message_text(data: dict) -> Optional[str]:
160
168
  block_type = block.get("type")
161
169
  # Both input_text and output_text have text field
162
170
  if block_type in ("input_text", "output_text"):
163
- text = block.get("text", "").strip()
164
- if text:
165
- return text
171
+ text = block.get("text")
172
+ if isinstance(text, str) and text.strip():
173
+ return text.strip()
166
174
 
167
175
  return None
168
176
 
@@ -236,6 +244,8 @@ def extract_first_last_messages(
236
244
  data = json.loads(line)
237
245
  except json.JSONDecodeError:
238
246
  continue
247
+ if not isinstance(data, dict):
248
+ continue
239
249
 
240
250
  role: Optional[str] = None
241
251
  text: Optional[str] = None
@@ -249,10 +259,17 @@ def extract_first_last_messages(
249
259
  elif agent == "codex":
250
260
  if data.get("type") == "response_item":
251
261
  payload = data.get("payload", {})
252
- if payload.get("type") == "message":
253
- role = payload.get("role")
262
+ if (
263
+ isinstance(payload, dict)
264
+ and payload.get("type") == "message"
265
+ ):
266
+ payload_role = payload.get("role")
267
+ if isinstance(payload_role, str):
268
+ role = payload_role
254
269
  text = _extract_codex_message_text(data)
255
- timestamp = data.get("timestamp")
270
+ raw_timestamp = data.get("timestamp")
271
+ if isinstance(raw_timestamp, str):
272
+ timestamp = raw_timestamp
256
273
 
257
274
  if role and text:
258
275
  msg_dict = {
@@ -334,7 +351,7 @@ def extract_session_metadata(session_file: Path, agent: str) -> dict[str, Any]:
334
351
 
335
352
  try:
336
353
  with open(session_file, "r", encoding="utf-8") as f:
337
- for line_num, line in enumerate(f, 1):
354
+ for line in f:
338
355
  line = line.strip()
339
356
  if not line:
340
357
  continue
@@ -343,40 +360,61 @@ def extract_session_metadata(session_file: Path, agent: str) -> dict[str, Any]:
343
360
  data = json.loads(line)
344
361
  except json.JSONDecodeError:
345
362
  continue
363
+ if not isinstance(data, dict):
364
+ continue
346
365
 
347
- # Extract cwd (first line that has it)
348
- if metadata["cwd"] is None and data.get("cwd"):
349
- metadata["cwd"] = data["cwd"]
366
+ # Extract cwd (first line with a non-empty string value)
367
+ cwd = data.get("cwd")
368
+ if (
369
+ metadata["cwd"] is None
370
+ and isinstance(cwd, str)
371
+ and bool(cwd.strip())
372
+ ):
373
+ metadata["cwd"] = cwd
350
374
 
351
375
  # Extract git branch (first line that has it)
352
- if metadata["branch"] is None and data.get("gitBranch"):
353
- metadata["branch"] = data["gitBranch"]
376
+ git_branch = data.get("gitBranch")
377
+ if (
378
+ metadata["branch"] is None
379
+ and isinstance(git_branch, str)
380
+ and bool(git_branch.strip())
381
+ ):
382
+ metadata["branch"] = git_branch
354
383
 
355
384
  # Extract session ID from sessionId field if available
356
- if data.get("sessionId"):
357
- metadata["session_id"] = data["sessionId"]
385
+ session_id = data.get("sessionId")
386
+ if isinstance(session_id, str) and session_id.strip():
387
+ metadata["session_id"] = session_id
358
388
 
359
389
  # Extract trim_metadata (for trimmed sessions)
360
- if "trim_metadata" in data:
361
- tm = data["trim_metadata"]
390
+ tm = data.get("trim_metadata")
391
+ if isinstance(tm, dict):
362
392
  metadata["derivation_type"] = "trimmed"
363
- metadata["parent_session_file"] = tm.get("parent_file")
364
- if tm.get("parent_file"):
365
- parent_path = Path(tm["parent_file"])
393
+ parent_file = tm.get("parent_file")
394
+ if isinstance(parent_file, str) and parent_file.strip():
395
+ metadata["parent_session_file"] = parent_file
396
+ parent_path = Path(parent_file)
366
397
  metadata["parent_session_id"] = parent_path.stem
367
- if tm.get("stats"):
368
- metadata["trim_stats"] = tm["stats"]
398
+ stats = tm.get("stats")
399
+ if isinstance(stats, dict):
400
+ metadata["trim_stats"] = stats
369
401
 
370
402
  # Extract continue_metadata (for continued sessions)
371
- if "continue_metadata" in data:
372
- cm = data["continue_metadata"]
403
+ cm = data.get("continue_metadata")
404
+ if isinstance(cm, dict):
373
405
  metadata["derivation_type"] = "continued"
374
- metadata["parent_session_id"] = cm.get("parent_session_id")
375
- metadata["parent_session_file"] = cm.get("parent_session_file")
406
+ parent_id = cm.get("parent_session_id")
407
+ if isinstance(parent_id, str) and parent_id.strip():
408
+ metadata["parent_session_id"] = parent_id
409
+ parent_file = cm.get("parent_session_file")
410
+ if isinstance(parent_file, str) and parent_file.strip():
411
+ metadata["parent_session_file"] = parent_file
376
412
 
377
413
  # Extract sessionType (e.g., "helper" for SDK/headless sessions)
378
414
  if "sessionType" in data and metadata["session_type"] is None:
379
- metadata["session_type"] = data["sessionType"]
415
+ session_type = data.get("sessionType")
416
+ if isinstance(session_type, str) and session_type.strip():
417
+ metadata["session_type"] = session_type
380
418
 
381
419
  # Extract git branch for Claude from file-history-snapshot metadata
382
420
  if (
@@ -384,33 +422,47 @@ def extract_session_metadata(session_file: Path, agent: str) -> dict[str, Any]:
384
422
  and metadata["branch"] is None
385
423
  and data.get("type") == "file-history-snapshot"
386
424
  ):
387
- git_info = data.get("metadata", {}).get("git", {})
388
- if git_info.get("branch"):
389
- metadata["branch"] = git_info["branch"]
425
+ nested_metadata = data.get("metadata")
426
+ if isinstance(nested_metadata, dict):
427
+ git_info = nested_metadata.get("git")
428
+ if isinstance(git_info, dict):
429
+ branch = git_info.get("branch")
430
+ if isinstance(branch, str) and branch.strip():
431
+ metadata["branch"] = branch
390
432
 
391
433
  # Extract git branch for Codex sessions from session_meta
392
434
  if agent == "codex" and data.get("type") == "session_meta":
393
- payload = data.get("payload", {})
394
- git_info = payload.get("git", {})
395
- if git_info.get("branch"):
396
- metadata["branch"] = git_info["branch"]
397
- if payload.get("cwd"):
398
- metadata["cwd"] = payload["cwd"]
399
- if payload.get("id"):
400
- metadata["session_id"] = payload["id"]
401
- if session_start_timestamp is None and data.get("timestamp"):
402
- session_start_timestamp = data["timestamp"]
435
+ payload = data.get("payload")
436
+ if isinstance(payload, dict):
437
+ git_info = payload.get("git")
438
+ if isinstance(git_info, dict):
439
+ branch = git_info.get("branch")
440
+ if isinstance(branch, str) and branch.strip():
441
+ metadata["branch"] = branch
442
+ payload_cwd = payload.get("cwd")
443
+ if (
444
+ isinstance(payload_cwd, str)
445
+ and payload_cwd.strip()
446
+ ):
447
+ metadata["cwd"] = payload_cwd
448
+ payload_id = payload.get("id")
449
+ if isinstance(payload_id, str) and payload_id.strip():
450
+ metadata["session_id"] = payload_id
403
451
 
404
452
  # Extract session start timestamp from first entry with timestamp
405
- if session_start_timestamp is None and data.get("timestamp"):
406
- session_start_timestamp = data["timestamp"]
453
+ timestamp = data.get("timestamp")
454
+ if (
455
+ session_start_timestamp is None
456
+ and isinstance(timestamp, str)
457
+ and timestamp.strip()
458
+ ):
459
+ session_start_timestamp = timestamp
407
460
 
408
- # Stop once we have the essential metadata (cwd and branch)
409
- # or after 500 lines as a safety limit
410
- if (metadata["cwd"] and metadata["branch"]) or line_num >= 500:
461
+ # Stop once we have the essential metadata (cwd and branch).
462
+ if metadata["cwd"] and metadata["branch"]:
411
463
  break
412
464
 
413
- except (OSError, IOError):
465
+ except (OSError, IOError, UnicodeError):
414
466
  pass
415
467
 
416
468
  # Note: customTitle extraction is done in search_index.py's _extract_session_content
@@ -454,7 +506,7 @@ def extract_session_metadata(session_file: Path, agent: str) -> dict[str, Any]:
454
506
  try:
455
507
  with open(session_file, "r", encoding="utf-8") as f:
456
508
  metadata["lines"] = sum(1 for _ in f)
457
- except (OSError, IOError):
509
+ except (OSError, IOError, UnicodeError):
458
510
  metadata["lines"] = 0
459
511
 
460
512
  # Derive project name from cwd