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.
- {claude_code_tools-1.16.2 → claude_code_tools-1.17.0}/PKG-INFO +1 -1
- {claude_code_tools-1.16.2 → claude_code_tools-1.17.0}/claude_code_tools/__init__.py +1 -1
- {claude_code_tools-1.16.2 → claude_code_tools-1.17.0}/claude_code_tools/aichat.py +73 -1
- {claude_code_tools-1.16.2 → claude_code_tools-1.17.0}/claude_code_tools/export_session.py +101 -49
- {claude_code_tools-1.16.2 → claude_code_tools-1.17.0}/claude_code_tools/find_claude_session.py +116 -36
- {claude_code_tools-1.16.2 → claude_code_tools-1.17.0}/claude_code_tools/find_codex_session.py +163 -113
- claude_code_tools-1.17.0/claude_code_tools/resolve_session.py +832 -0
- {claude_code_tools-1.16.2 → claude_code_tools-1.17.0}/claude_code_tools/session_utils.py +76 -32
- {claude_code_tools-1.16.2 → claude_code_tools-1.17.0}/claude_code_tools/trim_session.py +6 -2
- {claude_code_tools-1.16.2 → claude_code_tools-1.17.0}/pyproject.toml +2 -2
- {claude_code_tools-1.16.2 → claude_code_tools-1.17.0}/.gitignore +0 -0
- {claude_code_tools-1.16.2 → claude_code_tools-1.17.0}/LICENSE +0 -0
- {claude_code_tools-1.16.2 → claude_code_tools-1.17.0}/README.md +0 -0
- {claude_code_tools-1.16.2 → claude_code_tools-1.17.0}/claude_code_tools/action_rpc.py +0 -0
- {claude_code_tools-1.16.2 → claude_code_tools-1.17.0}/claude_code_tools/agent_tunnel/__init__.py +0 -0
- {claude_code_tools-1.16.2 → claude_code_tools-1.17.0}/claude_code_tools/agent_tunnel/backends.py +0 -0
- {claude_code_tools-1.16.2 → claude_code_tools-1.17.0}/claude_code_tools/agent_tunnel/cli.py +0 -0
- {claude_code_tools-1.16.2 → claude_code_tools-1.17.0}/claude_code_tools/agent_tunnel/config.py +0 -0
- {claude_code_tools-1.16.2 → claude_code_tools-1.17.0}/claude_code_tools/agent_tunnel/convert.py +0 -0
- {claude_code_tools-1.16.2 → claude_code_tools-1.17.0}/claude_code_tools/agent_tunnel/discord_bot.py +0 -0
- {claude_code_tools-1.16.2 → claude_code_tools-1.17.0}/claude_code_tools/agent_tunnel/locking.py +0 -0
- {claude_code_tools-1.16.2 → claude_code_tools-1.17.0}/claude_code_tools/agent_tunnel/paths.py +0 -0
- {claude_code_tools-1.16.2 → claude_code_tools-1.17.0}/claude_code_tools/agent_tunnel/registry.py +0 -0
- {claude_code_tools-1.16.2 → claude_code_tools-1.17.0}/claude_code_tools/agent_tunnel/session.py +0 -0
- {claude_code_tools-1.16.2 → claude_code_tools-1.17.0}/claude_code_tools/agent_tunnel/store.py +0 -0
- {claude_code_tools-1.16.2 → claude_code_tools-1.17.0}/claude_code_tools/agent_tunnel/tmux.py +0 -0
- {claude_code_tools-1.16.2 → claude_code_tools-1.17.0}/claude_code_tools/agent_tunnel/trust.py +0 -0
- {claude_code_tools-1.16.2 → claude_code_tools-1.17.0}/claude_code_tools/claude_continue.py +0 -0
- {claude_code_tools-1.16.2 → claude_code_tools-1.17.0}/claude_code_tools/codex_continue.py +0 -0
- {claude_code_tools-1.16.2 → claude_code_tools-1.17.0}/claude_code_tools/codex_server.py +0 -0
- {claude_code_tools-1.16.2 → claude_code_tools-1.17.0}/claude_code_tools/codex_server_cli.py +0 -0
- {claude_code_tools-1.16.2 → claude_code_tools-1.17.0}/claude_code_tools/codex_server_fingerprint.py +0 -0
- {claude_code_tools-1.16.2 → claude_code_tools-1.17.0}/claude_code_tools/codex_server_generation.py +0 -0
- {claude_code_tools-1.16.2 → claude_code_tools-1.17.0}/claude_code_tools/codex_server_models.py +0 -0
- {claude_code_tools-1.16.2 → claude_code_tools-1.17.0}/claude_code_tools/codex_server_process.py +0 -0
- {claude_code_tools-1.16.2 → claude_code_tools-1.17.0}/claude_code_tools/codex_server_reuse.py +0 -0
- {claude_code_tools-1.16.2 → claude_code_tools-1.17.0}/claude_code_tools/codex_server_supervisor.py +0 -0
- {claude_code_tools-1.16.2 → claude_code_tools-1.17.0}/claude_code_tools/codex_server_worker.py +0 -0
- {claude_code_tools-1.16.2 → claude_code_tools-1.17.0}/claude_code_tools/config.py +0 -0
- {claude_code_tools-1.16.2 → claude_code_tools-1.17.0}/claude_code_tools/csv2gsheet.py +0 -0
- {claude_code_tools-1.16.2 → claude_code_tools-1.17.0}/claude_code_tools/delete_session.py +0 -0
- {claude_code_tools-1.16.2 → claude_code_tools-1.17.0}/claude_code_tools/dotenv_vault.py +0 -0
- {claude_code_tools-1.16.2 → claude_code_tools-1.17.0}/claude_code_tools/env_safe.py +0 -0
- {claude_code_tools-1.16.2 → claude_code_tools-1.17.0}/claude_code_tools/export_all.py +0 -0
- {claude_code_tools-1.16.2 → claude_code_tools-1.17.0}/claude_code_tools/export_claude_session.py +0 -0
- {claude_code_tools-1.16.2 → claude_code_tools-1.17.0}/claude_code_tools/export_codex_session.py +0 -0
- {claude_code_tools-1.16.2 → claude_code_tools-1.17.0}/claude_code_tools/find_original_session.py +0 -0
- {claude_code_tools-1.16.2 → claude_code_tools-1.17.0}/claude_code_tools/find_session.py +0 -0
- {claude_code_tools-1.16.2 → claude_code_tools-1.17.0}/claude_code_tools/find_trimmed_sessions.py +0 -0
- {claude_code_tools-1.16.2 → claude_code_tools-1.17.0}/claude_code_tools/fix_session.py +0 -0
- {claude_code_tools-1.16.2 → claude_code_tools-1.17.0}/claude_code_tools/gdoc2docx.py +0 -0
- {claude_code_tools-1.16.2 → claude_code_tools-1.17.0}/claude_code_tools/gdoc2md.py +0 -0
- {claude_code_tools-1.16.2 → claude_code_tools-1.17.0}/claude_code_tools/gsheet2csv.py +0 -0
- {claude_code_tools-1.16.2 → claude_code_tools-1.17.0}/claude_code_tools/md2gdoc.py +0 -0
- {claude_code_tools-1.16.2 → claude_code_tools-1.17.0}/claude_code_tools/msg/__init__.py +0 -0
- {claude_code_tools-1.16.2 → claude_code_tools-1.17.0}/claude_code_tools/msg/cli.py +0 -0
- {claude_code_tools-1.16.2 → claude_code_tools-1.17.0}/claude_code_tools/msg/hooks.py +0 -0
- {claude_code_tools-1.16.2 → claude_code_tools-1.17.0}/claude_code_tools/msg/models.py +0 -0
- {claude_code_tools-1.16.2 → claude_code_tools-1.17.0}/claude_code_tools/msg/prompt_detect.py +0 -0
- {claude_code_tools-1.16.2 → claude_code_tools-1.17.0}/claude_code_tools/msg/store.py +0 -0
- {claude_code_tools-1.16.2 → claude_code_tools-1.17.0}/claude_code_tools/msg/watcher.py +0 -0
- {claude_code_tools-1.16.2 → claude_code_tools-1.17.0}/claude_code_tools/node_menu_ui.py +0 -0
- {claude_code_tools-1.16.2 → claude_code_tools-1.17.0}/claude_code_tools/search_index.py +0 -0
- {claude_code_tools-1.16.2 → claude_code_tools-1.17.0}/claude_code_tools/session_lineage.py +0 -0
- {claude_code_tools-1.16.2 → claude_code_tools-1.17.0}/claude_code_tools/session_menu.py +0 -0
- {claude_code_tools-1.16.2 → claude_code_tools-1.17.0}/claude_code_tools/session_menu_cli.py +0 -0
- {claude_code_tools-1.16.2 → claude_code_tools-1.17.0}/claude_code_tools/smart_trim.py +0 -0
- {claude_code_tools-1.16.2 → claude_code_tools-1.17.0}/claude_code_tools/smart_trim_core.py +0 -0
- {claude_code_tools-1.16.2 → claude_code_tools-1.17.0}/claude_code_tools/tmux_cli_controller.py +0 -0
- {claude_code_tools-1.16.2 → claude_code_tools-1.17.0}/claude_code_tools/tmux_execution_helpers.py +0 -0
- {claude_code_tools-1.16.2 → claude_code_tools-1.17.0}/claude_code_tools/tmux_remote_controller.py +0 -0
- {claude_code_tools-1.16.2 → claude_code_tools-1.17.0}/claude_code_tools/trim_in_place.py +0 -0
- {claude_code_tools-1.16.2 → claude_code_tools-1.17.0}/claude_code_tools/trim_session_claude.py +0 -0
- {claude_code_tools-1.16.2 → claude_code_tools-1.17.0}/claude_code_tools/trim_session_codex.py +0 -0
- {claude_code_tools-1.16.2 → claude_code_tools-1.17.0}/claude_code_tools/workflow_cli.py +0 -0
- {claude_code_tools-1.16.2 → claude_code_tools-1.17.0}/claude_code_tools/workflow_cli_contract.py +0 -0
- {claude_code_tools-1.16.2 → claude_code_tools-1.17.0}/claude_code_tools/workflow_cli_formatting.py +0 -0
- {claude_code_tools-1.16.2 → claude_code_tools-1.17.0}/claude_code_tools/workflow_cli_identity_policy.py +0 -0
- {claude_code_tools-1.16.2 → claude_code_tools-1.17.0}/claude_code_tools/workflow_cli_manifest.py +0 -0
- {claude_code_tools-1.16.2 → claude_code_tools-1.17.0}/claude_code_tools/workflow_cli_projection.py +0 -0
- {claude_code_tools-1.16.2 → claude_code_tools-1.17.0}/claude_code_tools/workflow_cli_rendering.py +0 -0
- {claude_code_tools-1.16.2 → claude_code_tools-1.17.0}/claude_code_tools/workflow_cli_snapshots.py +0 -0
- {claude_code_tools-1.16.2 → claude_code_tools-1.17.0}/claude_code_tools/workflow_cli_store_backends.py +0 -0
- {claude_code_tools-1.16.2 → claude_code_tools-1.17.0}/claude_code_tools/workflow_processes.py +0 -0
- {claude_code_tools-1.16.2 → claude_code_tools-1.17.0}/claude_code_tools/workflow_runs.py +0 -0
- {claude_code_tools-1.16.2 → claude_code_tools-1.17.0}/claude_code_tools/workflow_store_io.py +0 -0
- {claude_code_tools-1.16.2 → claude_code_tools-1.17.0}/claude_code_tools/workflow_validation.py +0 -0
- {claude_code_tools-1.16.2 → claude_code_tools-1.17.0}/docs/agent-tunnel-spec.md +0 -0
- {claude_code_tools-1.16.2 → claude_code_tools-1.17.0}/docs/ai-agent-writing-skills.md +0 -0
- {claude_code_tools-1.16.2 → claude_code_tools-1.17.0}/docs/cc-codex-instructions.md +0 -0
- {claude_code_tools-1.16.2 → claude_code_tools-1.17.0}/docs/claude-code-chutes.md +0 -0
- {claude_code_tools-1.16.2 → claude_code_tools-1.17.0}/docs/claude-code-tmux-tutorials.md +0 -0
- {claude_code_tools-1.16.2 → claude_code_tools-1.17.0}/docs/dot-zshrc.md +0 -0
- {claude_code_tools-1.16.2 → claude_code_tools-1.17.0}/docs/ensemble-ideas.md +0 -0
- {claude_code_tools-1.16.2 → claude_code_tools-1.17.0}/docs/example-with-images.md +0 -0
- {claude_code_tools-1.16.2 → claude_code_tools-1.17.0}/docs/find-claude-session.md +0 -0
- {claude_code_tools-1.16.2 → claude_code_tools-1.17.0}/docs/intercom-spec.md +0 -0
- {claude_code_tools-1.16.2 → claude_code_tools-1.17.0}/docs/linked-in-20260102.md +0 -0
- {claude_code_tools-1.16.2 → claude_code_tools-1.17.0}/docs/lmsh.md +0 -0
- {claude_code_tools-1.16.2 → claude_code_tools-1.17.0}/docs/local-llm-setup.md +0 -0
- {claude_code_tools-1.16.2 → claude_code_tools-1.17.0}/docs/msg-phase-a-plan.md +0 -0
- {claude_code_tools-1.16.2 → claude_code_tools-1.17.0}/docs/msg-plan-v2.md +0 -0
- {claude_code_tools-1.16.2 → claude_code_tools-1.17.0}/docs/paper-writing-style-transfer-tools.md +0 -0
- {claude_code_tools-1.16.2 → claude_code_tools-1.17.0}/docs/reddit-aichat-resume-v2.md +0 -0
- {claude_code_tools-1.16.2 → claude_code_tools-1.17.0}/docs/reddit-aichat-resume.md +0 -0
- {claude_code_tools-1.16.2 → claude_code_tools-1.17.0}/docs/reddit-aichat.md +0 -0
- {claude_code_tools-1.16.2 → claude_code_tools-1.17.0}/docs/reddit-post.md +0 -0
- {claude_code_tools-1.16.2 → claude_code_tools-1.17.0}/docs/rollover-details.md +0 -0
- {claude_code_tools-1.16.2 → claude_code_tools-1.17.0}/docs/tmux-cli-instructions.md +0 -0
- {claude_code_tools-1.16.2 → claude_code_tools-1.17.0}/docs/vault-documentation.md +0 -0
- {claude_code_tools-1.16.2 → claude_code_tools-1.17.0}/hatch_build.py +0 -0
- {claude_code_tools-1.16.2 → claude_code_tools-1.17.0}/node_ui/action_config.js +0 -0
- {claude_code_tools-1.16.2 → claude_code_tools-1.17.0}/node_ui/menu.js +0 -0
- {claude_code_tools-1.16.2 → claude_code_tools-1.17.0}/node_ui/package-lock.json +0 -0
- {claude_code_tools-1.16.2 → claude_code_tools-1.17.0}/node_ui/package.json +0 -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 =
|
|
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
|
-
|
|
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"
|
|
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"
|
|
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
|
|
253
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
348
|
-
|
|
349
|
-
|
|
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
|
-
|
|
353
|
-
|
|
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
|
-
|
|
357
|
-
|
|
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
|
-
|
|
361
|
-
|
|
390
|
+
tm = data.get("trim_metadata")
|
|
391
|
+
if isinstance(tm, dict):
|
|
362
392
|
metadata["derivation_type"] = "trimmed"
|
|
363
|
-
|
|
364
|
-
if
|
|
365
|
-
|
|
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
|
-
|
|
368
|
-
|
|
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
|
-
|
|
372
|
-
|
|
403
|
+
cm = data.get("continue_metadata")
|
|
404
|
+
if isinstance(cm, dict):
|
|
373
405
|
metadata["derivation_type"] = "continued"
|
|
374
|
-
|
|
375
|
-
|
|
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
|
-
|
|
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
|
-
|
|
388
|
-
if
|
|
389
|
-
|
|
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
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
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
|
-
|
|
406
|
-
|
|
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
|
-
|
|
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
|
{claude_code_tools-1.16.2 → claude_code_tools-1.17.0}/claude_code_tools/find_claude_session.py
RENAMED
|
@@ -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
|
|
63
|
-
|
|
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
|
-
|
|
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
|
|
303
|
-
|
|
304
|
-
|
|
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 (
|
|
395
|
-
|
|
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)
|
|
405
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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 =
|
|
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(
|
|
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
|
]
|