claude-code-tools 1.16.2__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.2 → claude_code_tools-1.17.0}/PKG-INFO +1 -1
  2. {claude_code_tools-1.16.2 → claude_code_tools-1.17.0}/claude_code_tools/__init__.py +1 -1
  3. {claude_code_tools-1.16.2 → claude_code_tools-1.17.0}/claude_code_tools/aichat.py +73 -1
  4. {claude_code_tools-1.16.2 → claude_code_tools-1.17.0}/claude_code_tools/export_session.py +101 -49
  5. {claude_code_tools-1.16.2 → claude_code_tools-1.17.0}/claude_code_tools/find_claude_session.py +116 -36
  6. {claude_code_tools-1.16.2 → claude_code_tools-1.17.0}/claude_code_tools/find_codex_session.py +163 -113
  7. claude_code_tools-1.17.0/claude_code_tools/resolve_session.py +832 -0
  8. {claude_code_tools-1.16.2 → claude_code_tools-1.17.0}/claude_code_tools/session_utils.py +76 -32
  9. {claude_code_tools-1.16.2 → claude_code_tools-1.17.0}/claude_code_tools/trim_session.py +6 -2
  10. {claude_code_tools-1.16.2 → claude_code_tools-1.17.0}/pyproject.toml +2 -2
  11. {claude_code_tools-1.16.2 → claude_code_tools-1.17.0}/.gitignore +0 -0
  12. {claude_code_tools-1.16.2 → claude_code_tools-1.17.0}/LICENSE +0 -0
  13. {claude_code_tools-1.16.2 → claude_code_tools-1.17.0}/README.md +0 -0
  14. {claude_code_tools-1.16.2 → claude_code_tools-1.17.0}/claude_code_tools/action_rpc.py +0 -0
  15. {claude_code_tools-1.16.2 → claude_code_tools-1.17.0}/claude_code_tools/agent_tunnel/__init__.py +0 -0
  16. {claude_code_tools-1.16.2 → claude_code_tools-1.17.0}/claude_code_tools/agent_tunnel/backends.py +0 -0
  17. {claude_code_tools-1.16.2 → claude_code_tools-1.17.0}/claude_code_tools/agent_tunnel/cli.py +0 -0
  18. {claude_code_tools-1.16.2 → claude_code_tools-1.17.0}/claude_code_tools/agent_tunnel/config.py +0 -0
  19. {claude_code_tools-1.16.2 → claude_code_tools-1.17.0}/claude_code_tools/agent_tunnel/convert.py +0 -0
  20. {claude_code_tools-1.16.2 → claude_code_tools-1.17.0}/claude_code_tools/agent_tunnel/discord_bot.py +0 -0
  21. {claude_code_tools-1.16.2 → claude_code_tools-1.17.0}/claude_code_tools/agent_tunnel/locking.py +0 -0
  22. {claude_code_tools-1.16.2 → claude_code_tools-1.17.0}/claude_code_tools/agent_tunnel/paths.py +0 -0
  23. {claude_code_tools-1.16.2 → claude_code_tools-1.17.0}/claude_code_tools/agent_tunnel/registry.py +0 -0
  24. {claude_code_tools-1.16.2 → claude_code_tools-1.17.0}/claude_code_tools/agent_tunnel/session.py +0 -0
  25. {claude_code_tools-1.16.2 → claude_code_tools-1.17.0}/claude_code_tools/agent_tunnel/store.py +0 -0
  26. {claude_code_tools-1.16.2 → claude_code_tools-1.17.0}/claude_code_tools/agent_tunnel/tmux.py +0 -0
  27. {claude_code_tools-1.16.2 → claude_code_tools-1.17.0}/claude_code_tools/agent_tunnel/trust.py +0 -0
  28. {claude_code_tools-1.16.2 → claude_code_tools-1.17.0}/claude_code_tools/claude_continue.py +0 -0
  29. {claude_code_tools-1.16.2 → claude_code_tools-1.17.0}/claude_code_tools/codex_continue.py +0 -0
  30. {claude_code_tools-1.16.2 → claude_code_tools-1.17.0}/claude_code_tools/codex_server.py +0 -0
  31. {claude_code_tools-1.16.2 → claude_code_tools-1.17.0}/claude_code_tools/codex_server_cli.py +0 -0
  32. {claude_code_tools-1.16.2 → claude_code_tools-1.17.0}/claude_code_tools/codex_server_fingerprint.py +0 -0
  33. {claude_code_tools-1.16.2 → claude_code_tools-1.17.0}/claude_code_tools/codex_server_generation.py +0 -0
  34. {claude_code_tools-1.16.2 → claude_code_tools-1.17.0}/claude_code_tools/codex_server_models.py +0 -0
  35. {claude_code_tools-1.16.2 → claude_code_tools-1.17.0}/claude_code_tools/codex_server_process.py +0 -0
  36. {claude_code_tools-1.16.2 → claude_code_tools-1.17.0}/claude_code_tools/codex_server_reuse.py +0 -0
  37. {claude_code_tools-1.16.2 → claude_code_tools-1.17.0}/claude_code_tools/codex_server_supervisor.py +0 -0
  38. {claude_code_tools-1.16.2 → claude_code_tools-1.17.0}/claude_code_tools/codex_server_worker.py +0 -0
  39. {claude_code_tools-1.16.2 → claude_code_tools-1.17.0}/claude_code_tools/config.py +0 -0
  40. {claude_code_tools-1.16.2 → claude_code_tools-1.17.0}/claude_code_tools/csv2gsheet.py +0 -0
  41. {claude_code_tools-1.16.2 → claude_code_tools-1.17.0}/claude_code_tools/delete_session.py +0 -0
  42. {claude_code_tools-1.16.2 → claude_code_tools-1.17.0}/claude_code_tools/dotenv_vault.py +0 -0
  43. {claude_code_tools-1.16.2 → claude_code_tools-1.17.0}/claude_code_tools/env_safe.py +0 -0
  44. {claude_code_tools-1.16.2 → claude_code_tools-1.17.0}/claude_code_tools/export_all.py +0 -0
  45. {claude_code_tools-1.16.2 → claude_code_tools-1.17.0}/claude_code_tools/export_claude_session.py +0 -0
  46. {claude_code_tools-1.16.2 → claude_code_tools-1.17.0}/claude_code_tools/export_codex_session.py +0 -0
  47. {claude_code_tools-1.16.2 → claude_code_tools-1.17.0}/claude_code_tools/find_original_session.py +0 -0
  48. {claude_code_tools-1.16.2 → claude_code_tools-1.17.0}/claude_code_tools/find_session.py +0 -0
  49. {claude_code_tools-1.16.2 → claude_code_tools-1.17.0}/claude_code_tools/find_trimmed_sessions.py +0 -0
  50. {claude_code_tools-1.16.2 → claude_code_tools-1.17.0}/claude_code_tools/fix_session.py +0 -0
  51. {claude_code_tools-1.16.2 → claude_code_tools-1.17.0}/claude_code_tools/gdoc2docx.py +0 -0
  52. {claude_code_tools-1.16.2 → claude_code_tools-1.17.0}/claude_code_tools/gdoc2md.py +0 -0
  53. {claude_code_tools-1.16.2 → claude_code_tools-1.17.0}/claude_code_tools/gsheet2csv.py +0 -0
  54. {claude_code_tools-1.16.2 → claude_code_tools-1.17.0}/claude_code_tools/md2gdoc.py +0 -0
  55. {claude_code_tools-1.16.2 → claude_code_tools-1.17.0}/claude_code_tools/msg/__init__.py +0 -0
  56. {claude_code_tools-1.16.2 → claude_code_tools-1.17.0}/claude_code_tools/msg/cli.py +0 -0
  57. {claude_code_tools-1.16.2 → claude_code_tools-1.17.0}/claude_code_tools/msg/hooks.py +0 -0
  58. {claude_code_tools-1.16.2 → claude_code_tools-1.17.0}/claude_code_tools/msg/models.py +0 -0
  59. {claude_code_tools-1.16.2 → claude_code_tools-1.17.0}/claude_code_tools/msg/prompt_detect.py +0 -0
  60. {claude_code_tools-1.16.2 → claude_code_tools-1.17.0}/claude_code_tools/msg/store.py +0 -0
  61. {claude_code_tools-1.16.2 → claude_code_tools-1.17.0}/claude_code_tools/msg/watcher.py +0 -0
  62. {claude_code_tools-1.16.2 → claude_code_tools-1.17.0}/claude_code_tools/node_menu_ui.py +0 -0
  63. {claude_code_tools-1.16.2 → claude_code_tools-1.17.0}/claude_code_tools/search_index.py +0 -0
  64. {claude_code_tools-1.16.2 → claude_code_tools-1.17.0}/claude_code_tools/session_lineage.py +0 -0
  65. {claude_code_tools-1.16.2 → claude_code_tools-1.17.0}/claude_code_tools/session_menu.py +0 -0
  66. {claude_code_tools-1.16.2 → claude_code_tools-1.17.0}/claude_code_tools/session_menu_cli.py +0 -0
  67. {claude_code_tools-1.16.2 → claude_code_tools-1.17.0}/claude_code_tools/smart_trim.py +0 -0
  68. {claude_code_tools-1.16.2 → claude_code_tools-1.17.0}/claude_code_tools/smart_trim_core.py +0 -0
  69. {claude_code_tools-1.16.2 → claude_code_tools-1.17.0}/claude_code_tools/tmux_cli_controller.py +0 -0
  70. {claude_code_tools-1.16.2 → claude_code_tools-1.17.0}/claude_code_tools/tmux_execution_helpers.py +0 -0
  71. {claude_code_tools-1.16.2 → claude_code_tools-1.17.0}/claude_code_tools/tmux_remote_controller.py +0 -0
  72. {claude_code_tools-1.16.2 → claude_code_tools-1.17.0}/claude_code_tools/trim_in_place.py +0 -0
  73. {claude_code_tools-1.16.2 → claude_code_tools-1.17.0}/claude_code_tools/trim_session_claude.py +0 -0
  74. {claude_code_tools-1.16.2 → claude_code_tools-1.17.0}/claude_code_tools/trim_session_codex.py +0 -0
  75. {claude_code_tools-1.16.2 → claude_code_tools-1.17.0}/claude_code_tools/workflow_cli.py +0 -0
  76. {claude_code_tools-1.16.2 → claude_code_tools-1.17.0}/claude_code_tools/workflow_cli_contract.py +0 -0
  77. {claude_code_tools-1.16.2 → claude_code_tools-1.17.0}/claude_code_tools/workflow_cli_formatting.py +0 -0
  78. {claude_code_tools-1.16.2 → claude_code_tools-1.17.0}/claude_code_tools/workflow_cli_identity_policy.py +0 -0
  79. {claude_code_tools-1.16.2 → claude_code_tools-1.17.0}/claude_code_tools/workflow_cli_manifest.py +0 -0
  80. {claude_code_tools-1.16.2 → claude_code_tools-1.17.0}/claude_code_tools/workflow_cli_projection.py +0 -0
  81. {claude_code_tools-1.16.2 → claude_code_tools-1.17.0}/claude_code_tools/workflow_cli_rendering.py +0 -0
  82. {claude_code_tools-1.16.2 → claude_code_tools-1.17.0}/claude_code_tools/workflow_cli_snapshots.py +0 -0
  83. {claude_code_tools-1.16.2 → claude_code_tools-1.17.0}/claude_code_tools/workflow_cli_store_backends.py +0 -0
  84. {claude_code_tools-1.16.2 → claude_code_tools-1.17.0}/claude_code_tools/workflow_processes.py +0 -0
  85. {claude_code_tools-1.16.2 → claude_code_tools-1.17.0}/claude_code_tools/workflow_runs.py +0 -0
  86. {claude_code_tools-1.16.2 → claude_code_tools-1.17.0}/claude_code_tools/workflow_store_io.py +0 -0
  87. {claude_code_tools-1.16.2 → claude_code_tools-1.17.0}/claude_code_tools/workflow_validation.py +0 -0
  88. {claude_code_tools-1.16.2 → claude_code_tools-1.17.0}/docs/agent-tunnel-spec.md +0 -0
  89. {claude_code_tools-1.16.2 → claude_code_tools-1.17.0}/docs/ai-agent-writing-skills.md +0 -0
  90. {claude_code_tools-1.16.2 → claude_code_tools-1.17.0}/docs/cc-codex-instructions.md +0 -0
  91. {claude_code_tools-1.16.2 → claude_code_tools-1.17.0}/docs/claude-code-chutes.md +0 -0
  92. {claude_code_tools-1.16.2 → claude_code_tools-1.17.0}/docs/claude-code-tmux-tutorials.md +0 -0
  93. {claude_code_tools-1.16.2 → claude_code_tools-1.17.0}/docs/dot-zshrc.md +0 -0
  94. {claude_code_tools-1.16.2 → claude_code_tools-1.17.0}/docs/ensemble-ideas.md +0 -0
  95. {claude_code_tools-1.16.2 → claude_code_tools-1.17.0}/docs/example-with-images.md +0 -0
  96. {claude_code_tools-1.16.2 → claude_code_tools-1.17.0}/docs/find-claude-session.md +0 -0
  97. {claude_code_tools-1.16.2 → claude_code_tools-1.17.0}/docs/intercom-spec.md +0 -0
  98. {claude_code_tools-1.16.2 → claude_code_tools-1.17.0}/docs/linked-in-20260102.md +0 -0
  99. {claude_code_tools-1.16.2 → claude_code_tools-1.17.0}/docs/lmsh.md +0 -0
  100. {claude_code_tools-1.16.2 → claude_code_tools-1.17.0}/docs/local-llm-setup.md +0 -0
  101. {claude_code_tools-1.16.2 → claude_code_tools-1.17.0}/docs/msg-phase-a-plan.md +0 -0
  102. {claude_code_tools-1.16.2 → claude_code_tools-1.17.0}/docs/msg-plan-v2.md +0 -0
  103. {claude_code_tools-1.16.2 → claude_code_tools-1.17.0}/docs/paper-writing-style-transfer-tools.md +0 -0
  104. {claude_code_tools-1.16.2 → claude_code_tools-1.17.0}/docs/reddit-aichat-resume-v2.md +0 -0
  105. {claude_code_tools-1.16.2 → claude_code_tools-1.17.0}/docs/reddit-aichat-resume.md +0 -0
  106. {claude_code_tools-1.16.2 → claude_code_tools-1.17.0}/docs/reddit-aichat.md +0 -0
  107. {claude_code_tools-1.16.2 → claude_code_tools-1.17.0}/docs/reddit-post.md +0 -0
  108. {claude_code_tools-1.16.2 → claude_code_tools-1.17.0}/docs/rollover-details.md +0 -0
  109. {claude_code_tools-1.16.2 → claude_code_tools-1.17.0}/docs/tmux-cli-instructions.md +0 -0
  110. {claude_code_tools-1.16.2 → claude_code_tools-1.17.0}/docs/vault-documentation.md +0 -0
  111. {claude_code_tools-1.16.2 → claude_code_tools-1.17.0}/hatch_build.py +0 -0
  112. {claude_code_tools-1.16.2 → claude_code_tools-1.17.0}/node_ui/action_config.js +0 -0
  113. {claude_code_tools-1.16.2 → claude_code_tools-1.17.0}/node_ui/menu.js +0 -0
  114. {claude_code_tools-1.16.2 → claude_code_tools-1.17.0}/node_ui/package-lock.json +0 -0
  115. {claude_code_tools-1.16.2 → 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.2
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
@@ -1,3 +1,3 @@
1
1
  """Claude Code Tools - Collection of utilities for Claude Code."""
2
2
 
3
- __version__ = "1.16.2"
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:
@@ -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
@@ -59,15 +59,17 @@ def get_session_start_timestamp(jsonl_file: Path) -> Optional[float]:
59
59
  continue
60
60
  try:
61
61
  data = json.loads(line)
62
- if data.get("timestamp"):
63
- ts = data["timestamp"]
62
+ if not isinstance(data, dict):
63
+ continue
64
+ ts = data.get("timestamp")
65
+ if isinstance(ts, str) and ts:
64
66
  # Parse ISO format (e.g., "2025-10-22T16:05:28.707Z")
65
67
  if ts.endswith("Z"):
66
68
  ts = ts[:-1] + "+00:00"
67
69
  return datetime.fromisoformat(ts).timestamp()
68
70
  except (json.JSONDecodeError, ValueError, TypeError):
69
71
  continue
70
- except (OSError, IOError):
72
+ except (OSError, IOError, UnicodeError):
71
73
  pass
72
74
  return None
73
75
 
@@ -216,6 +218,8 @@ def extract_project_name(original_path: str) -> str:
216
218
  for line in f:
217
219
  try:
218
220
  data = json.loads(line.strip())
221
+ if not isinstance(data, dict):
222
+ continue
219
223
  # Only count user/assistant messages
220
224
  if data.get('type') in ('user', 'assistant'):
221
225
  msg_count += 1
@@ -242,6 +246,8 @@ def extract_project_name(original_path: str) -> str:
242
246
 
243
247
  try:
244
248
  data = json.loads(line.strip())
249
+ if not isinstance(data, dict):
250
+ continue
245
251
  # Only count user/assistant messages
246
252
  if data.get('type') in ('user', 'assistant'):
247
253
  msg_count += 1
@@ -288,22 +294,25 @@ def is_sidechain_session(filepath: Path) -> bool:
288
294
  Returns:
289
295
  True if session is a sidechain, False otherwise.
290
296
  """
297
+ if filepath.name.startswith("agent-"):
298
+ return True
299
+
291
300
  if not filepath.exists():
292
301
  return False
293
302
 
294
303
  try:
295
304
  with open(filepath, 'r', encoding='utf-8') as f:
296
- # Check first few lines for isSidechain field
297
- for i, line in enumerate(f):
298
- if i >= 10: # Only check first 10 lines
299
- break
305
+ for line in f:
300
306
  try:
301
307
  data = json.loads(line.strip())
302
- if "isSidechain" in data:
303
- return data["isSidechain"] is True
304
- except (json.JSONDecodeError, KeyError):
308
+ if (
309
+ isinstance(data, dict)
310
+ and data.get("isSidechain") is True
311
+ ):
312
+ return True
313
+ except (json.JSONDecodeError, TypeError):
305
314
  continue
306
- except (OSError, IOError):
315
+ except (OSError, IOError, UnicodeError):
307
316
  pass
308
317
 
309
318
  return False
@@ -332,6 +341,8 @@ def search_keywords_in_file(filepath: Path, keywords: List[str]) -> tuple[bool,
332
341
  for line in f:
333
342
  try:
334
343
  data = json.loads(line.strip())
344
+ if not isinstance(data, dict):
345
+ continue
335
346
  # Only count user/assistant messages
336
347
  if data.get('type') in ('user', 'assistant'):
337
348
  msg_count += 1
@@ -358,6 +369,8 @@ def search_keywords_in_file(filepath: Path, keywords: List[str]) -> tuple[bool,
358
369
 
359
370
  try:
360
371
  data = json.loads(line.strip())
372
+ if not isinstance(data, dict):
373
+ continue
361
374
  # Only count user/assistant messages
362
375
  if data.get('type') in ('user', 'assistant'):
363
376
  msg_count += 1
@@ -389,10 +402,17 @@ def get_session_preview(filepath: Path) -> str:
389
402
  for line in f:
390
403
  try:
391
404
  data = json.loads(line.strip())
405
+ if not isinstance(data, dict):
406
+ continue
392
407
  msg_type = data.get('type')
393
408
  # Accept user or assistant messages
394
- if msg_type in ('user', 'assistant'):
395
- message = data.get('message', {})
409
+ if isinstance(msg_type, str) and msg_type in (
410
+ 'user',
411
+ 'assistant',
412
+ ):
413
+ message = data.get('message')
414
+ if not isinstance(message, dict):
415
+ continue
396
416
  content = message.get('content', '')
397
417
  text = None
398
418
 
@@ -401,8 +421,13 @@ def get_session_preview(filepath: Path) -> str:
401
421
  elif isinstance(content, list):
402
422
  # Handle structured content
403
423
  for item in content:
404
- if isinstance(item, dict) and item.get('type') == 'text':
405
- text = item.get('text', '').strip()
424
+ if not isinstance(item, dict):
425
+ continue
426
+ if item.get('type') != 'text':
427
+ continue
428
+ item_text = item.get('text')
429
+ if isinstance(item_text, str):
430
+ text = item_text.strip()
406
431
  break
407
432
 
408
433
  # Filter out system messages and keep updating to get LAST
@@ -498,7 +523,10 @@ def find_sessions(
498
523
  continue
499
524
 
500
525
  session_id = jsonl_file.stem
501
- stat = jsonl_file.stat()
526
+ try:
527
+ stat = jsonl_file.stat()
528
+ except OSError:
529
+ continue
502
530
  mod_time = stat.st_mtime
503
531
  # Get session start timestamp from JSON metadata, fall back to file stats
504
532
  create_time = get_session_start_timestamp(jsonl_file)
@@ -506,7 +534,10 @@ def find_sessions(
506
534
  create_time = getattr(stat, 'st_birthtime', stat.st_ctime)
507
535
  preview = get_session_preview(jsonl_file)
508
536
  # Extract actual cwd from session file - MUST NOT use reconstructed path as fallback
509
- actual_cwd = extract_cwd_from_session(jsonl_file)
537
+ actual_cwd = extract_cwd_from_session(
538
+ jsonl_file,
539
+ agent="claude",
540
+ )
510
541
  if not actual_cwd:
511
542
  # Skip sessions without cwd metadata (shouldn't happen for valid Claude sessions)
512
543
  continue
@@ -547,7 +578,10 @@ def find_sessions(
547
578
  continue
548
579
 
549
580
  session_id = jsonl_file.stem
550
- stat = jsonl_file.stat()
581
+ try:
582
+ stat = jsonl_file.stat()
583
+ except OSError:
584
+ continue
551
585
  mod_time = stat.st_mtime
552
586
  # Get session start timestamp from JSON metadata, fall back to file stats
553
587
  create_time = get_session_start_timestamp(jsonl_file)
@@ -555,7 +589,10 @@ def find_sessions(
555
589
  create_time = getattr(stat, 'st_birthtime', stat.st_ctime)
556
590
  preview = get_session_preview(jsonl_file)
557
591
  # Extract actual cwd from session file - MUST NOT use reconstructed path as fallback
558
- actual_cwd = extract_cwd_from_session(jsonl_file)
592
+ actual_cwd = extract_cwd_from_session(
593
+ jsonl_file,
594
+ agent="claude",
595
+ )
559
596
  if not actual_cwd:
560
597
  # Skip sessions without cwd metadata (shouldn't happen for valid Claude sessions)
561
598
  continue
@@ -599,7 +636,10 @@ def find_sessions(
599
636
  continue
600
637
 
601
638
  session_id = jsonl_file.stem
602
- stat = jsonl_file.stat()
639
+ try:
640
+ stat = jsonl_file.stat()
641
+ except OSError:
642
+ continue
603
643
  mod_time = stat.st_mtime
604
644
  # Get session start timestamp from JSON metadata, fall back to file stats
605
645
  create_time = get_session_start_timestamp(jsonl_file)
@@ -607,7 +647,10 @@ def find_sessions(
607
647
  create_time = getattr(stat, 'st_birthtime', stat.st_ctime)
608
648
  preview = get_session_preview(jsonl_file)
609
649
  # Extract actual cwd from session file for consistency
610
- actual_cwd = extract_cwd_from_session(jsonl_file) or os.getcwd()
650
+ actual_cwd = (
651
+ extract_cwd_from_session(jsonl_file, agent="claude")
652
+ or os.getcwd()
653
+ )
611
654
  matching_sessions.append((session_id, mod_time, create_time, line_count, project_name, preview, actual_cwd, git_branch, derivation_type, is_sidechain))
612
655
 
613
656
  # Sort by modification time (newest first)
@@ -1059,6 +1102,54 @@ def get_session_file_path(
1059
1102
  )
1060
1103
 
1061
1104
 
1105
+ def get_custom_title(
1106
+ session_id: str,
1107
+ cwd: str,
1108
+ claude_home: Optional[str] = None,
1109
+ session_file: Optional[Path] = None,
1110
+ ) -> str:
1111
+ """Return the most recent user-assigned name for a Claude session.
1112
+
1113
+ Claude writes names from ``/rename`` as ``custom-title`` records. Older
1114
+ versions may mirror the value in an ``agent-name`` record. Automatic
1115
+ ``ai-title`` records are deliberately ignored.
1116
+
1117
+ Args:
1118
+ session_id: Full Claude session identifier.
1119
+ cwd: Session working directory.
1120
+ claude_home: Optional Claude home directory override.
1121
+ session_file: Optional already-discovered transcript path. This avoids
1122
+ reconstructing a stale path from the transcript's current cwd.
1123
+
1124
+ Returns:
1125
+ The latest custom session name, or an empty string if none is found.
1126
+ """
1127
+ try:
1128
+ path = session_file or Path(
1129
+ get_session_file_path(session_id, cwd, claude_home)
1130
+ )
1131
+ custom_title = ""
1132
+ with path.open("r", encoding="utf-8") as handle:
1133
+ for line in handle:
1134
+ try:
1135
+ data = json.loads(line)
1136
+ except json.JSONDecodeError:
1137
+ continue
1138
+ if not isinstance(data, dict):
1139
+ continue
1140
+ if data.get("type") == "custom-title":
1141
+ value = data.get("customTitle")
1142
+ elif data.get("type") == "agent-name":
1143
+ value = data.get("agentName")
1144
+ else:
1145
+ continue
1146
+ if isinstance(value, str) and value:
1147
+ custom_title = value
1148
+ return custom_title
1149
+ except (OSError, UnicodeError, ValueError):
1150
+ return ""
1151
+
1152
+
1062
1153
  def handle_export_session(session_file_path: str, dest_override: str | None = None, silent: bool = False) -> None:
1063
1154
  """Export session to text file."""
1064
1155
  from claude_code_tools.export_claude_session import export_session_to_markdown as do_export
@@ -1071,7 +1162,7 @@ def handle_export_session(session_file_path: str, dest_override: str | None = No
1071
1162
  today = datetime.now().strftime("%Y%m%d")
1072
1163
 
1073
1164
  # Infer project directory from session metadata
1074
- project_dir = extract_cwd_from_session(session_file)
1165
+ project_dir = extract_cwd_from_session(session_file, agent="claude")
1075
1166
  if project_dir:
1076
1167
  output_dir = Path(project_dir) / "exported-sessions"
1077
1168
  else:
@@ -1591,19 +1682,6 @@ To persist directory changes when resuming sessions:
1591
1682
  rpc_path = str(Path(__file__).parent / "action_rpc.py")
1592
1683
 
1593
1684
  if not args.simple_ui:
1594
- from claude_code_tools.export_session import extract_session_metadata
1595
-
1596
- def get_custom_title(session_id: str, cwd: str) -> str:
1597
- """Extract custom title from session file if present."""
1598
- try:
1599
- fp = get_session_file_path(session_id, cwd, args.claude_home)
1600
- if fp:
1601
- meta = extract_session_metadata(Path(fp), "claude")
1602
- return meta.get("customTitle", "")
1603
- except Exception:
1604
- pass
1605
- return ""
1606
-
1607
1685
  limited = [
1608
1686
  {
1609
1687
  "agent": "claude",
@@ -1621,7 +1699,9 @@ To persist directory changes when resuming sessions:
1621
1699
  "is_trimmed": s[8] if len(s) > 8 else False,
1622
1700
  "derivation_type": None,
1623
1701
  "is_sidechain": s[9] if len(s) > 9 else False,
1624
- "custom_title": get_custom_title(s[0], s[6]),
1702
+ "custom_title": get_custom_title(
1703
+ s[0], s[6], args.claude_home
1704
+ ),
1625
1705
  }
1626
1706
  for s in matching_sessions[: args.num_matches]
1627
1707
  ]