multi-claude-cli 0.11.0__tar.gz → 0.12.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.
- {multi_claude_cli-0.11.0 → multi_claude_cli-0.12.0}/PKG-INFO +29 -4
- {multi_claude_cli-0.11.0 → multi_claude_cli-0.12.0}/README.md +28 -3
- {multi_claude_cli-0.11.0 → multi_claude_cli-0.12.0}/src/multi_claude/__init__.py +1 -1
- multi_claude_cli-0.12.0/src/multi_claude/artifacts.py +466 -0
- {multi_claude_cli-0.11.0 → multi_claude_cli-0.12.0}/src/multi_claude/cli.py +159 -2
- {multi_claude_cli-0.11.0 → multi_claude_cli-0.12.0}/src/multi_claude/completion.py +1 -1
- {multi_claude_cli-0.11.0 → multi_claude_cli-0.12.0}/src/multi_claude_cli.egg-info/PKG-INFO +29 -4
- {multi_claude_cli-0.11.0 → multi_claude_cli-0.12.0}/src/multi_claude_cli.egg-info/SOURCES.txt +2 -0
- multi_claude_cli-0.12.0/tests/test_artifacts.py +431 -0
- {multi_claude_cli-0.11.0 → multi_claude_cli-0.12.0}/tests/test_completion.py +1 -1
- {multi_claude_cli-0.11.0 → multi_claude_cli-0.12.0}/tests/test_help.py +2 -0
- {multi_claude_cli-0.11.0 → multi_claude_cli-0.12.0}/tests/test_sessions.py +7 -6
- {multi_claude_cli-0.11.0 → multi_claude_cli-0.12.0}/LICENSE +0 -0
- {multi_claude_cli-0.11.0 → multi_claude_cli-0.12.0}/pyproject.toml +0 -0
- {multi_claude_cli-0.11.0 → multi_claude_cli-0.12.0}/setup.cfg +0 -0
- {multi_claude_cli-0.11.0 → multi_claude_cli-0.12.0}/src/multi_claude/__main__.py +0 -0
- {multi_claude_cli-0.11.0 → multi_claude_cli-0.12.0}/src/multi_claude/accounts.py +0 -0
- {multi_claude_cli-0.11.0 → multi_claude_cli-0.12.0}/src/multi_claude/actions.py +0 -0
- {multi_claude_cli-0.11.0 → multi_claude_cli-0.12.0}/src/multi_claude/bundle.py +0 -0
- {multi_claude_cli-0.11.0 → multi_claude_cli-0.12.0}/src/multi_claude/config.py +0 -0
- {multi_claude_cli-0.11.0 → multi_claude_cli-0.12.0}/src/multi_claude/doctor.py +0 -0
- {multi_claude_cli-0.11.0 → multi_claude_cli-0.12.0}/src/multi_claude/env.py +0 -0
- {multi_claude_cli-0.11.0 → multi_claude_cli-0.12.0}/src/multi_claude/fsutil.py +0 -0
- {multi_claude_cli-0.11.0 → multi_claude_cli-0.12.0}/src/multi_claude/history.py +0 -0
- {multi_claude_cli-0.11.0 → multi_claude_cli-0.12.0}/src/multi_claude/hook.py +0 -0
- {multi_claude_cli-0.11.0 → multi_claude_cli-0.12.0}/src/multi_claude/identity.py +0 -0
- {multi_claude_cli-0.11.0 → multi_claude_cli-0.12.0}/src/multi_claude/launcher.py +0 -0
- {multi_claude_cli-0.11.0 → multi_claude_cli-0.12.0}/src/multi_claude/lock.py +0 -0
- {multi_claude_cli-0.11.0 → multi_claude_cli-0.12.0}/src/multi_claude/migrate.py +0 -0
- {multi_claude_cli-0.11.0 → multi_claude_cli-0.12.0}/src/multi_claude/plan.py +0 -0
- {multi_claude_cli-0.11.0 → multi_claude_cli-0.12.0}/src/multi_claude/platform.py +0 -0
- {multi_claude_cli-0.11.0 → multi_claude_cli-0.12.0}/src/multi_claude/refresh.py +0 -0
- {multi_claude_cli-0.11.0 → multi_claude_cli-0.12.0}/src/multi_claude/restore.py +0 -0
- {multi_claude_cli-0.11.0 → multi_claude_cli-0.12.0}/src/multi_claude/routes.py +0 -0
- {multi_claude_cli-0.11.0 → multi_claude_cli-0.12.0}/src/multi_claude/sessions.py +0 -0
- {multi_claude_cli-0.11.0 → multi_claude_cli-0.12.0}/src/multi_claude/shared.py +0 -0
- {multi_claude_cli-0.11.0 → multi_claude_cli-0.12.0}/src/multi_claude/shellpath.py +0 -0
- {multi_claude_cli-0.11.0 → multi_claude_cli-0.12.0}/src/multi_claude/statusline.py +0 -0
- {multi_claude_cli-0.11.0 → multi_claude_cli-0.12.0}/src/multi_claude/usage.py +0 -0
- {multi_claude_cli-0.11.0 → multi_claude_cli-0.12.0}/src/multi_claude_cli.egg-info/dependency_links.txt +0 -0
- {multi_claude_cli-0.11.0 → multi_claude_cli-0.12.0}/src/multi_claude_cli.egg-info/entry_points.txt +0 -0
- {multi_claude_cli-0.11.0 → multi_claude_cli-0.12.0}/src/multi_claude_cli.egg-info/top_level.txt +0 -0
- {multi_claude_cli-0.11.0 → multi_claude_cli-0.12.0}/tests/test_accounts.py +0 -0
- {multi_claude_cli-0.11.0 → multi_claude_cli-0.12.0}/tests/test_bundle.py +0 -0
- {multi_claude_cli-0.11.0 → multi_claude_cli-0.12.0}/tests/test_config.py +0 -0
- {multi_claude_cli-0.11.0 → multi_claude_cli-0.12.0}/tests/test_doctor.py +0 -0
- {multi_claude_cli-0.11.0 → multi_claude_cli-0.12.0}/tests/test_everyday.py +0 -0
- {multi_claude_cli-0.11.0 → multi_claude_cli-0.12.0}/tests/test_fsutil.py +0 -0
- {multi_claude_cli-0.11.0 → multi_claude_cli-0.12.0}/tests/test_handoff.py +0 -0
- {multi_claude_cli-0.11.0 → multi_claude_cli-0.12.0}/tests/test_history.py +0 -0
- {multi_claude_cli-0.11.0 → multi_claude_cli-0.12.0}/tests/test_identity.py +0 -0
- {multi_claude_cli-0.11.0 → multi_claude_cli-0.12.0}/tests/test_install.py +0 -0
- {multi_claude_cli-0.11.0 → multi_claude_cli-0.12.0}/tests/test_mcp.py +0 -0
- {multi_claude_cli-0.11.0 → multi_claude_cli-0.12.0}/tests/test_migrate.py +0 -0
- {multi_claude_cli-0.11.0 → multi_claude_cli-0.12.0}/tests/test_onboarding.py +0 -0
- {multi_claude_cli-0.11.0 → multi_claude_cli-0.12.0}/tests/test_packaging.py +0 -0
- {multi_claude_cli-0.11.0 → multi_claude_cli-0.12.0}/tests/test_plan.py +0 -0
- {multi_claude_cli-0.11.0 → multi_claude_cli-0.12.0}/tests/test_platform.py +0 -0
- {multi_claude_cli-0.11.0 → multi_claude_cli-0.12.0}/tests/test_refresh.py +0 -0
- {multi_claude_cli-0.11.0 → multi_claude_cli-0.12.0}/tests/test_rename.py +0 -0
- {multi_claude_cli-0.11.0 → multi_claude_cli-0.12.0}/tests/test_routes.py +0 -0
- {multi_claude_cli-0.11.0 → multi_claude_cli-0.12.0}/tests/test_set.py +0 -0
- {multi_claude_cli-0.11.0 → multi_claude_cli-0.12.0}/tests/test_shared_exclude.py +0 -0
- {multi_claude_cli-0.11.0 → multi_claude_cli-0.12.0}/tests/test_statusline.py +0 -0
- {multi_claude_cli-0.11.0 → multi_claude_cli-0.12.0}/tests/test_usability.py +0 -0
- {multi_claude_cli-0.11.0 → multi_claude_cli-0.12.0}/tests/test_usage.py +0 -0
- {multi_claude_cli-0.11.0 → multi_claude_cli-0.12.0}/tests/test_vscode.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: multi-claude-cli
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.12.0
|
|
4
4
|
Summary: Run several Claude Code accounts side by side: separate CLAUDE_CONFIG_DIR directories, launchers, proxies, environment variables and arguments.
|
|
5
5
|
License: MIT
|
|
6
6
|
Project-URL: Homepage, https://github.com/jakoes-wu/multi-claude
|
|
@@ -342,11 +342,11 @@ multi-claude sessions
|
|
|
342
342
|
multi-claude sessions work # one account
|
|
343
343
|
multi-claude sessions --grep importer # title, directory or session id contains the text
|
|
344
344
|
multi-claude sessions --grep importer --cmd
|
|
345
|
-
# TITLE ACCOUNT CREATED UPDATED
|
|
346
|
-
# Fix the importer work 2026-10-11 16:05 2026-10-11 17:42
|
|
345
|
+
# TITLE ACCOUNT CREATED UPDATED CMD
|
|
346
|
+
# Fix the importer work 2026-10-11 16:05 2026-10-11 17:42 cd ~/project && claude-work --resume 3c588340-1219-48df-a231-509866789487
|
|
347
347
|
```
|
|
348
348
|
|
|
349
|
-
- `--cmd` puts the resume command in a `CMD` column when the whole table fits the width of the terminal; otherwise each command goes on its own line below its session. The home directory is written as `~`. When the output is piped, the width counts as 80 columns unless `COLUMNS` is set.
|
|
349
|
+
- `--cmd` puts the resume command in a `CMD` column when the whole table fits the width of the terminal; otherwise each command goes on its own line below its session. The `SESSION ID` column is left out, since each command ends with the session id. The home directory is written as `~`. When the output is piped, the width counts as 80 columns unless `COLUMNS` is set.
|
|
350
350
|
- It shows the 30 most recent sessions; `--limit N` changes that and `--limit 0` shows all. `--json` prints every match with the full fields (`directory`, `created`, `updated`, `title_source`, `file`, `resume`). `conversations` is another name for the same command.
|
|
351
351
|
- The title is the session's name in Claude Code; a session without one shows the beginning of its first prompt, and `-` if it has no prompt at all. Times are local. `UPDATED` is the time of the last message, or the file's modification time when the session has no timestamps.
|
|
352
352
|
- A `~/.claude` that has not been turned into an account is listed as `default`.
|
|
@@ -354,6 +354,29 @@ multi-claude sessions --grep importer --cmd
|
|
|
354
354
|
- The command is read-only and stays on this machine: it reads the start and the end of each session file, prints the title (a piece of your conversation) to the terminal, and saves or sends nothing. It does not list subagent transcripts.
|
|
355
355
|
- If the directory a session was started in no longer exists, the `cd` in the resume command fails; `claude --resume` only finds a session from the directory it was started in.
|
|
356
356
|
|
|
357
|
+
### Finding an artifact
|
|
358
|
+
|
|
359
|
+
Published an artifact from one of the accounts and cannot remember which one, or where its source file was? `artifacts` lists the artifacts published from the Claude sessions of every account on this machine, newest first:
|
|
360
|
+
|
|
361
|
+
```sh
|
|
362
|
+
multi-claude artifacts
|
|
363
|
+
# TITLE ACCOUNT UPDATED URL
|
|
364
|
+
# Release notes work 2026-10-15 09:12 https://claude.ai/artifact/9GwmwTUZsiSYKj4fLD7XdB
|
|
365
|
+
multi-claude artifacts work # only the artifacts owned by one account
|
|
366
|
+
multi-claude artifacts --grep release # title, description, URL, directory, source file or session id
|
|
367
|
+
multi-claude artifacts --grep release --cmd # adds the command that resumes the session below each one
|
|
368
|
+
multi-claude artifacts --path # adds the local file each one was published from
|
|
369
|
+
# Release notes work 2026-10-15 09:12 https://claude.ai/artifact/9GwmwTUZsiSYKj4fLD7XdB
|
|
370
|
+
# /private/tmp/claude-501/…/scratchpad/notes.html (missing)
|
|
371
|
+
multi-claude artifacts --json # every match: source file (path, path_exists), session, publishes…
|
|
372
|
+
```
|
|
373
|
+
|
|
374
|
+
- The account is the one whose session published the artifact. A session copied with `handoff` holds the publishes made before the copy in both accounts; `handoff` therefore records each copy in `~/.config/multi-claude/handoffs.jsonl` (file names, sizes and SHA-256 hashes, no content), and `artifacts` uses it to give those publishes back to the source account.
|
|
375
|
+
- `a,b ?` means the owner is unknown: the session was copied before multi-claude recorded handoffs (before 0.12.0, or before the first `handoff` after upgrading), or the record could not be written, or the copy no longer starts with the bytes that were copied. The artifact is listed under every account that has a copy.
|
|
376
|
+
- Only artifacts published from Claude Code sessions on this machine are found: not those made on claude.ai or in the desktop app, and not those whose session was deleted. Backups made by `handoff --force` are not read. A handoff recorded for `~/.claude` before `migrate-default` no longer matches the account it became.
|
|
377
|
+
- The source file is usually in Claude's temporary scratchpad and is often gone: `--path` marks it `(missing)`, and `path_exists` in `--json` tells the same. An artifact published without a local file shows `(no source file)`. With both `--path` and `--cmd`, the path comes first.
|
|
378
|
+
- It shows the 30 most recent artifacts; `--limit N` changes that and `--limit 0` shows all. The command is read-only and stays on this machine: it reads the session files, prints titles, descriptions and paths taken from your sessions, and saves or sends nothing.
|
|
379
|
+
|
|
357
380
|
### Handing a session over to another account
|
|
358
381
|
|
|
359
382
|
Each account keeps its own sessions, so `claude --resume` in one account cannot see the sessions of another. `handoff` copies one session (its `.jsonl` file and the folder next to it with subagent transcripts and saved tool output) into the same project folder of another account:
|
|
@@ -368,6 +391,7 @@ Each account keeps its own sessions, so `claude --resume` in one account cannot
|
|
|
368
391
|
- Nothing is moved or deleted, and the source account is only read. A line that is still being written is left out. Later messages in the source account are not synchronised.
|
|
369
392
|
- If the target already has the same session with different content, `handoff` stops with exit code 3. `--force` renames the existing copy to `*.multi-claude-bak.*` and copies again; close that session in the target account first.
|
|
370
393
|
- `/rewind` snapshots (`file-history`) and project memory are not copied.
|
|
394
|
+
- Each copy is recorded in `~/.config/multi-claude/handoffs.jsonl` so that `artifacts` can tell which account published what. If the record cannot be written, `handoff` still succeeds and prints a warning.
|
|
371
395
|
|
|
372
396
|
### Renaming accounts and managing MCP servers
|
|
373
397
|
|
|
@@ -526,6 +550,7 @@ It never copies, exports, uploads or backs up a login. One command touches one:
|
|
|
526
550
|
| `multi-claude code NAME [PATH] [-- ARGS ...]` | Open a separate VS Code instance with the account's environment (experimental). |
|
|
527
551
|
| `multi-claude handoff TARGET [--from NAME] [--session ID] [--force]` | Copy a session to another account and print the command that resumes it there. |
|
|
528
552
|
| `multi-claude sessions [NAME] [--grep TEXT] [--limit N] [--cmd] [--json]` | List the sessions of every account and directory, newest first; `--cmd` adds the command that resumes each. Also `conversations`. |
|
|
553
|
+
| `multi-claude artifacts [NAME] [--grep TEXT] [--limit N] [--path] [--cmd] [--json]` | List the artifacts published from the sessions of every account, newest first, with the account that owns each; `--path` adds the local source file, `--cmd` the command that resumes the session. |
|
|
529
554
|
| `multi-claude statusline install\|uninstall FILE` | Wrap the statusLine command in a settings file so that `usage` gets fresh numbers, or restore it. |
|
|
530
555
|
| `multi-claude completion bash\|zsh\|fish` | Print a shell completion script. |
|
|
531
556
|
|
|
@@ -315,11 +315,11 @@ multi-claude sessions
|
|
|
315
315
|
multi-claude sessions work # one account
|
|
316
316
|
multi-claude sessions --grep importer # title, directory or session id contains the text
|
|
317
317
|
multi-claude sessions --grep importer --cmd
|
|
318
|
-
# TITLE ACCOUNT CREATED UPDATED
|
|
319
|
-
# Fix the importer work 2026-10-11 16:05 2026-10-11 17:42
|
|
318
|
+
# TITLE ACCOUNT CREATED UPDATED CMD
|
|
319
|
+
# Fix the importer work 2026-10-11 16:05 2026-10-11 17:42 cd ~/project && claude-work --resume 3c588340-1219-48df-a231-509866789487
|
|
320
320
|
```
|
|
321
321
|
|
|
322
|
-
- `--cmd` puts the resume command in a `CMD` column when the whole table fits the width of the terminal; otherwise each command goes on its own line below its session. The home directory is written as `~`. When the output is piped, the width counts as 80 columns unless `COLUMNS` is set.
|
|
322
|
+
- `--cmd` puts the resume command in a `CMD` column when the whole table fits the width of the terminal; otherwise each command goes on its own line below its session. The `SESSION ID` column is left out, since each command ends with the session id. The home directory is written as `~`. When the output is piped, the width counts as 80 columns unless `COLUMNS` is set.
|
|
323
323
|
- It shows the 30 most recent sessions; `--limit N` changes that and `--limit 0` shows all. `--json` prints every match with the full fields (`directory`, `created`, `updated`, `title_source`, `file`, `resume`). `conversations` is another name for the same command.
|
|
324
324
|
- The title is the session's name in Claude Code; a session without one shows the beginning of its first prompt, and `-` if it has no prompt at all. Times are local. `UPDATED` is the time of the last message, or the file's modification time when the session has no timestamps.
|
|
325
325
|
- A `~/.claude` that has not been turned into an account is listed as `default`.
|
|
@@ -327,6 +327,29 @@ multi-claude sessions --grep importer --cmd
|
|
|
327
327
|
- The command is read-only and stays on this machine: it reads the start and the end of each session file, prints the title (a piece of your conversation) to the terminal, and saves or sends nothing. It does not list subagent transcripts.
|
|
328
328
|
- If the directory a session was started in no longer exists, the `cd` in the resume command fails; `claude --resume` only finds a session from the directory it was started in.
|
|
329
329
|
|
|
330
|
+
### Finding an artifact
|
|
331
|
+
|
|
332
|
+
Published an artifact from one of the accounts and cannot remember which one, or where its source file was? `artifacts` lists the artifacts published from the Claude sessions of every account on this machine, newest first:
|
|
333
|
+
|
|
334
|
+
```sh
|
|
335
|
+
multi-claude artifacts
|
|
336
|
+
# TITLE ACCOUNT UPDATED URL
|
|
337
|
+
# Release notes work 2026-10-15 09:12 https://claude.ai/artifact/9GwmwTUZsiSYKj4fLD7XdB
|
|
338
|
+
multi-claude artifacts work # only the artifacts owned by one account
|
|
339
|
+
multi-claude artifacts --grep release # title, description, URL, directory, source file or session id
|
|
340
|
+
multi-claude artifacts --grep release --cmd # adds the command that resumes the session below each one
|
|
341
|
+
multi-claude artifacts --path # adds the local file each one was published from
|
|
342
|
+
# Release notes work 2026-10-15 09:12 https://claude.ai/artifact/9GwmwTUZsiSYKj4fLD7XdB
|
|
343
|
+
# /private/tmp/claude-501/…/scratchpad/notes.html (missing)
|
|
344
|
+
multi-claude artifacts --json # every match: source file (path, path_exists), session, publishes…
|
|
345
|
+
```
|
|
346
|
+
|
|
347
|
+
- The account is the one whose session published the artifact. A session copied with `handoff` holds the publishes made before the copy in both accounts; `handoff` therefore records each copy in `~/.config/multi-claude/handoffs.jsonl` (file names, sizes and SHA-256 hashes, no content), and `artifacts` uses it to give those publishes back to the source account.
|
|
348
|
+
- `a,b ?` means the owner is unknown: the session was copied before multi-claude recorded handoffs (before 0.12.0, or before the first `handoff` after upgrading), or the record could not be written, or the copy no longer starts with the bytes that were copied. The artifact is listed under every account that has a copy.
|
|
349
|
+
- Only artifacts published from Claude Code sessions on this machine are found: not those made on claude.ai or in the desktop app, and not those whose session was deleted. Backups made by `handoff --force` are not read. A handoff recorded for `~/.claude` before `migrate-default` no longer matches the account it became.
|
|
350
|
+
- The source file is usually in Claude's temporary scratchpad and is often gone: `--path` marks it `(missing)`, and `path_exists` in `--json` tells the same. An artifact published without a local file shows `(no source file)`. With both `--path` and `--cmd`, the path comes first.
|
|
351
|
+
- It shows the 30 most recent artifacts; `--limit N` changes that and `--limit 0` shows all. The command is read-only and stays on this machine: it reads the session files, prints titles, descriptions and paths taken from your sessions, and saves or sends nothing.
|
|
352
|
+
|
|
330
353
|
### Handing a session over to another account
|
|
331
354
|
|
|
332
355
|
Each account keeps its own sessions, so `claude --resume` in one account cannot see the sessions of another. `handoff` copies one session (its `.jsonl` file and the folder next to it with subagent transcripts and saved tool output) into the same project folder of another account:
|
|
@@ -341,6 +364,7 @@ Each account keeps its own sessions, so `claude --resume` in one account cannot
|
|
|
341
364
|
- Nothing is moved or deleted, and the source account is only read. A line that is still being written is left out. Later messages in the source account are not synchronised.
|
|
342
365
|
- If the target already has the same session with different content, `handoff` stops with exit code 3. `--force` renames the existing copy to `*.multi-claude-bak.*` and copies again; close that session in the target account first.
|
|
343
366
|
- `/rewind` snapshots (`file-history`) and project memory are not copied.
|
|
367
|
+
- Each copy is recorded in `~/.config/multi-claude/handoffs.jsonl` so that `artifacts` can tell which account published what. If the record cannot be written, `handoff` still succeeds and prints a warning.
|
|
344
368
|
|
|
345
369
|
### Renaming accounts and managing MCP servers
|
|
346
370
|
|
|
@@ -499,6 +523,7 @@ It never copies, exports, uploads or backs up a login. One command touches one:
|
|
|
499
523
|
| `multi-claude code NAME [PATH] [-- ARGS ...]` | Open a separate VS Code instance with the account's environment (experimental). |
|
|
500
524
|
| `multi-claude handoff TARGET [--from NAME] [--session ID] [--force]` | Copy a session to another account and print the command that resumes it there. |
|
|
501
525
|
| `multi-claude sessions [NAME] [--grep TEXT] [--limit N] [--cmd] [--json]` | List the sessions of every account and directory, newest first; `--cmd` adds the command that resumes each. Also `conversations`. |
|
|
526
|
+
| `multi-claude artifacts [NAME] [--grep TEXT] [--limit N] [--path] [--cmd] [--json]` | List the artifacts published from the sessions of every account, newest first, with the account that owns each; `--path` adds the local source file, `--cmd` the command that resumes the session. |
|
|
502
527
|
| `multi-claude statusline install\|uninstall FILE` | Wrap the statusLine command in a settings file so that `usage` gets fresh numbers, or restore it. |
|
|
503
528
|
| `multi-claude completion bash\|zsh\|fish` | Print a shell completion script. |
|
|
504
529
|
|
|
@@ -0,0 +1,466 @@
|
|
|
1
|
+
"""已发布 artifact 的跨账号查询与 handoff 交接记录(方案 feature-artifact-list)。
|
|
2
|
+
|
|
3
|
+
数据来源是 Claude Code 写在账号目录里的会话记录:Artifact 工具每次调用写一行带 `tool_use` 块的
|
|
4
|
+
assistant 记录,结果写一行带 `tool_result` 块的 user 记录,发布成功时后者顶层的 `toolUseResult`
|
|
5
|
+
是带 `url`、`title`、`path` 等键的对象。记录格式未写入官方文档,任何一行解析失败都跳过,不报错。
|
|
6
|
+
本模块只读会话记录,不联网,不读凭据。
|
|
7
|
+
|
|
8
|
+
会话记录里没有账号字段,而 handoff 会把整份会话复制进另一个账号,交接前的发布记录在两个账号里
|
|
9
|
+
逐字节相同。所以 handoff 每次复制后在工具状态目录追加一条交接记录(复制了哪些文件、各多少字节、
|
|
10
|
+
这段字节的 SHA-256),查询时据此把副本里交接前的记录归回来源账号(§5.1.3)。
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
import fcntl
|
|
14
|
+
import hashlib
|
|
15
|
+
import json
|
|
16
|
+
import os
|
|
17
|
+
import re
|
|
18
|
+
from datetime import datetime, timezone
|
|
19
|
+
from typing import Dict, Iterator, List, NamedTuple, Optional, Set, Tuple
|
|
20
|
+
|
|
21
|
+
from . import history, platform, sessions
|
|
22
|
+
|
|
23
|
+
HANDOFF_LOG = "handoffs.jsonl"
|
|
24
|
+
HANDOFF_LOG_VERSION = 1
|
|
25
|
+
# fullmatch 用:match 加 $ 会放过结尾带换行的 URL
|
|
26
|
+
URL_PATTERN = re.compile(r"https://claude\.ai/(?:code/)?artifact/[A-Za-z0-9_-]+")
|
|
27
|
+
# 只解析含这两种字节的行:tool_use 行带 "Artifact",发布结果行带 URL。其余行(绝大多数)不做 JSON 解析
|
|
28
|
+
_CANDIDATE_MARKS = (b'"Artifact"', b"claude.ai/")
|
|
29
|
+
_HASH_CHUNK = 1024 * 1024
|
|
30
|
+
|
|
31
|
+
OWNERSHIP_SINGLE = "single"
|
|
32
|
+
OWNERSHIP_RECORDED = "recorded"
|
|
33
|
+
OWNERSHIP_UNCERTAIN = "uncertain"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class Publish(NamedTuple):
|
|
37
|
+
"""会话记录里的一次发布成功。来自会话内容的文字字段输出前必须过 sessions.clean_title。"""
|
|
38
|
+
url: str
|
|
39
|
+
title: str
|
|
40
|
+
# 发布时的本地源文件;多在 Claude 的临时 scratchpad 里,事后常已不存在
|
|
41
|
+
path: Optional[str]
|
|
42
|
+
audience: Optional[str]
|
|
43
|
+
description: str
|
|
44
|
+
timestamp: Optional[datetime] # UTC
|
|
45
|
+
cwd: Optional[str]
|
|
46
|
+
project: str
|
|
47
|
+
# 相对项目目录:主会话 <SID>.jsonl,子代理 <SID>/subagents/<名>.jsonl
|
|
48
|
+
relpath: str
|
|
49
|
+
# 续聊用的会话 ID:子代理里发布的也是父会话
|
|
50
|
+
session_id: str
|
|
51
|
+
# 结果行在文件里的起始字节偏移;handoff 逐字节复制,同一行在来源与副本里偏移相同,追链靠它
|
|
52
|
+
offset: int
|
|
53
|
+
# 同一条记录在多个账号的副本共用这个 ID,用来合并副本
|
|
54
|
+
record_id: str
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class Handoff(NamedTuple):
|
|
58
|
+
# 在 handoffs.jsonl 里的行号(从 0 起)。交接先后只认它,不比 time:同一秒内两次交接、时钟回拨都会排错
|
|
59
|
+
seq: int
|
|
60
|
+
# 写记录时的 UTC 时间;只用来求开始记录时刻 T0,不用来排先后
|
|
61
|
+
time: Optional[datetime]
|
|
62
|
+
session_id: str
|
|
63
|
+
project: str
|
|
64
|
+
# 来源 / 目标账号目录的真实路径
|
|
65
|
+
source_dir: str
|
|
66
|
+
target_dir: str
|
|
67
|
+
# relpath -> (复制进目标的字节数, 这段字节的 SHA-256)
|
|
68
|
+
files: Dict[str, Tuple[int, str]]
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class Copy(NamedTuple):
|
|
72
|
+
"""某个账号目录里的一条发布记录。"""
|
|
73
|
+
account: str
|
|
74
|
+
publish: Publish
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class Artifact(NamedTuple):
|
|
78
|
+
url: str
|
|
79
|
+
title: str
|
|
80
|
+
description: str
|
|
81
|
+
# 所有者;ownership 为 uncertain 时是所有有副本的账号
|
|
82
|
+
accounts: List[str]
|
|
83
|
+
ownership: str
|
|
84
|
+
first_published: Optional[datetime]
|
|
85
|
+
last_published: Optional[datetime]
|
|
86
|
+
# 去掉副本后的发布次数
|
|
87
|
+
publishes: int
|
|
88
|
+
# 代表副本:目录、会话、续聊命令都取它(所有者账号里最晚的一条;所有者不确定时取全局最晚)
|
|
89
|
+
representative: Copy
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def valid_session_id(session_id: str) -> bool:
|
|
93
|
+
# 以 - 开头的 ID 拼进续聊命令会被 claude 当成选项(与 sessions.list_sessions 同一道校验)
|
|
94
|
+
return bool(sessions.SESSION_ID_PATTERN.fullmatch(session_id)) and not session_id.startswith("-")
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def session_files(account_dir: str) -> Iterator[Tuple[str, str, str]]:
|
|
98
|
+
"""产出账号目录下要扫的会话文件 (项目目录名, relpath, 完整路径)。
|
|
99
|
+
|
|
100
|
+
只认 <SID>.jsonl 与 <SID>/subagents/<名>.jsonl:handoff --force 的备份(<SID>.jsonl.multi-claude-bak.*、
|
|
101
|
+
<SID>.multi-claude-bak.*/)与中途被杀留下的 .multi-claude-<SID>.tmp/ 都过不了 SID 校验,不是能续聊的会话。
|
|
102
|
+
读不了的目录直接跳过。
|
|
103
|
+
"""
|
|
104
|
+
projects = os.path.join(account_dir, "projects")
|
|
105
|
+
try:
|
|
106
|
+
project_names = sorted(os.listdir(projects))
|
|
107
|
+
except OSError:
|
|
108
|
+
return
|
|
109
|
+
for project in project_names:
|
|
110
|
+
directory = os.path.join(projects, project)
|
|
111
|
+
try:
|
|
112
|
+
names = sorted(os.listdir(directory))
|
|
113
|
+
except OSError:
|
|
114
|
+
continue
|
|
115
|
+
for name in names:
|
|
116
|
+
path = os.path.join(directory, name)
|
|
117
|
+
if name.endswith(".jsonl"):
|
|
118
|
+
if valid_session_id(name[:-len(".jsonl")]) and os.path.isfile(path):
|
|
119
|
+
yield project, name, path
|
|
120
|
+
continue
|
|
121
|
+
if not valid_session_id(name):
|
|
122
|
+
continue
|
|
123
|
+
for relpath, sub_path in _subagent_files(directory, name):
|
|
124
|
+
yield project, relpath, sub_path
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _subagent_files(project_dir: str, session_id: str) -> Iterator[Tuple[str, str]]:
|
|
128
|
+
subagents = os.path.join(project_dir, session_id, "subagents")
|
|
129
|
+
try:
|
|
130
|
+
names = sorted(os.listdir(subagents))
|
|
131
|
+
except OSError:
|
|
132
|
+
return
|
|
133
|
+
for name in names:
|
|
134
|
+
path = os.path.join(subagents, name)
|
|
135
|
+
if name.endswith(".jsonl") and os.path.isfile(path):
|
|
136
|
+
yield "{}/subagents/{}".format(session_id, name), path
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def scan_file(path: str, project: str, relpath: str,
|
|
140
|
+
expected_prefix: Optional[Tuple[int, str]] = None) -> Tuple[List[Publish], Optional[bool]]:
|
|
141
|
+
"""读一个会话文件,返回 (发布记录, 前缀是否与交接记录一致)。读不了时抛 OSError。
|
|
142
|
+
|
|
143
|
+
expected_prefix 是该文件最新一条交接记录的 (字节数, SHA-256);给了就边读边算这段前缀的哈希,
|
|
144
|
+
不另外读一遍。文件比记录的字节数短,或哈希不同,都算不一致;没给时第二项为 None。
|
|
145
|
+
"""
|
|
146
|
+
session_id = relpath.split("/", 1)[0]
|
|
147
|
+
if session_id.endswith(".jsonl"):
|
|
148
|
+
session_id = session_id[:-len(".jsonl")]
|
|
149
|
+
uses: Dict[str, dict] = {}
|
|
150
|
+
found: List[Publish] = []
|
|
151
|
+
digest = hashlib.sha256() if expected_prefix is not None else None
|
|
152
|
+
limit = expected_prefix[0] if expected_prefix is not None else 0
|
|
153
|
+
hashed = 0
|
|
154
|
+
offset = 0
|
|
155
|
+
with open(path, "rb") as handle:
|
|
156
|
+
for line in handle:
|
|
157
|
+
if digest is not None and hashed < limit:
|
|
158
|
+
part = line[:limit - hashed]
|
|
159
|
+
digest.update(part)
|
|
160
|
+
hashed += len(part)
|
|
161
|
+
if any(mark in line for mark in _CANDIDATE_MARKS):
|
|
162
|
+
publish = _parse_line(line, uses, project, relpath, session_id, offset)
|
|
163
|
+
if publish is not None:
|
|
164
|
+
found.append(publish)
|
|
165
|
+
offset += len(line)
|
|
166
|
+
if expected_prefix is None:
|
|
167
|
+
return found, None
|
|
168
|
+
return found, hashed == limit and digest.hexdigest() == expected_prefix[1]
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def _parse_line(line: bytes, uses: Dict[str, dict], project: str, relpath: str, session_id: str,
|
|
172
|
+
offset: int) -> Optional[Publish]:
|
|
173
|
+
"""登记本行的 Artifact tool_use;本行是它的成功结果时返回 Publish。"""
|
|
174
|
+
try:
|
|
175
|
+
record = json.loads(line)
|
|
176
|
+
except ValueError:
|
|
177
|
+
return None
|
|
178
|
+
if not isinstance(record, dict):
|
|
179
|
+
return None
|
|
180
|
+
message = record.get("message")
|
|
181
|
+
content = message.get("content") if isinstance(message, dict) else None
|
|
182
|
+
if not isinstance(content, list):
|
|
183
|
+
return None
|
|
184
|
+
for block in content:
|
|
185
|
+
if not isinstance(block, dict):
|
|
186
|
+
continue
|
|
187
|
+
if block.get("type") == "tool_use" and block.get("name") == "Artifact" and isinstance(block.get("id"), str):
|
|
188
|
+
tool_input = block.get("input")
|
|
189
|
+
uses[block["id"]] = tool_input if isinstance(tool_input, dict) else {}
|
|
190
|
+
elif block.get("type") == "tool_result" and block.get("tool_use_id") in uses:
|
|
191
|
+
# 失败的发布(is_error)没有 URL;别的工具的结果里出现 claude.ai 链接也不算,必须对得上 Artifact 调用
|
|
192
|
+
if block.get("is_error") is True:
|
|
193
|
+
return None
|
|
194
|
+
return _publish(record, uses[block["tool_use_id"]], project, relpath, session_id, offset)
|
|
195
|
+
return None
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def _publish(record: dict, tool_input: dict, project: str, relpath: str, session_id: str,
|
|
199
|
+
offset: int) -> Optional[Publish]:
|
|
200
|
+
result = record.get("toolUseResult")
|
|
201
|
+
if not isinstance(result, dict):
|
|
202
|
+
return None
|
|
203
|
+
url = result.get("url")
|
|
204
|
+
# quickstart / read / list 的结果没有单个 url;只认完整匹配的 artifact 链接
|
|
205
|
+
if not isinstance(url, str) or not URL_PATTERN.fullmatch(url):
|
|
206
|
+
return None
|
|
207
|
+
timestamp = history._parse_time(record.get("timestamp"))
|
|
208
|
+
cwd = record.get("cwd")
|
|
209
|
+
uuid = record.get("uuid")
|
|
210
|
+
return Publish(url, _text(result.get("title")), _text(result.get("path")) or None,
|
|
211
|
+
_text(result.get("audience")) or None, _text(tool_input.get("description")),
|
|
212
|
+
timestamp, cwd if sessions._usable_cwd(cwd) else None, project, relpath, session_id, offset,
|
|
213
|
+
uuid if isinstance(uuid, str) and uuid else "{}|{}".format(url, record.get("timestamp")))
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def _text(value) -> str:
|
|
217
|
+
return value if isinstance(value, str) else ""
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
# ---- 交接记录 ----
|
|
221
|
+
|
|
222
|
+
def log_path() -> str:
|
|
223
|
+
return os.path.join(platform.state_dir(), HANDOFF_LOG)
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def record_handoff(handoff: "sessions.Plan", source_account_dir: str, target_account_dir: str) -> None:
|
|
227
|
+
"""handoff 复制完成后追加一条交接记录。失败时抛 OSError,由调用方只警告:会话已经复制成功。
|
|
228
|
+
|
|
229
|
+
主会话文件的大小与哈希取 handoff.content(就是写进目标的字节);子代理文件读刚落位的目标副本,
|
|
230
|
+
来源可能仍在被 Claude 追加,取来源会与目标对不上。
|
|
231
|
+
单行可能有几十 KB(一个会话可有上百个子代理文件),一次 write 不保证原子:
|
|
232
|
+
加本文件的排他锁后循环写满整行。被杀留下的半行在读取时按坏行跳过;
|
|
233
|
+
下一次写入先补一个换行,新记录不会接在半行后面一起变成坏行。
|
|
234
|
+
"""
|
|
235
|
+
session_id = handoff.session_id
|
|
236
|
+
files = {session_id + ".jsonl": [len(handoff.content), hashlib.sha256(handoff.content).hexdigest()]}
|
|
237
|
+
for relpath, path in _subagent_files(handoff.target_dir, session_id):
|
|
238
|
+
files[relpath] = list(_file_digest(path))
|
|
239
|
+
entry = {"v": HANDOFF_LOG_VERSION,
|
|
240
|
+
"time": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%S.%fZ"),
|
|
241
|
+
"session_id": session_id, "project": os.path.basename(handoff.target_dir),
|
|
242
|
+
"source_dir": os.path.realpath(source_account_dir), "target_dir": os.path.realpath(target_account_dir),
|
|
243
|
+
"files": files}
|
|
244
|
+
data = (json.dumps(entry, ensure_ascii=False, sort_keys=True) + "\n").encode("utf-8")
|
|
245
|
+
path = log_path()
|
|
246
|
+
os.makedirs(os.path.dirname(path), exist_ok=True)
|
|
247
|
+
fd = os.open(path, os.O_RDWR | os.O_APPEND | os.O_CREAT, 0o600)
|
|
248
|
+
try:
|
|
249
|
+
# 阻塞等待:两个 handoff 同时追加时排队,不交错。锁的是本文件,不是工具的写锁(handoff 不拿写锁)
|
|
250
|
+
fcntl.flock(fd, fcntl.LOCK_EX)
|
|
251
|
+
# 只写描述符读不了末字节,所以上面用 O_RDWR 打开
|
|
252
|
+
size = os.fstat(fd).st_size
|
|
253
|
+
if size and os.pread(fd, 1, size - 1) != b"\n":
|
|
254
|
+
data = b"\n" + data
|
|
255
|
+
view = memoryview(data)
|
|
256
|
+
while view:
|
|
257
|
+
written = os.write(fd, view)
|
|
258
|
+
view = view[written:]
|
|
259
|
+
os.fsync(fd)
|
|
260
|
+
finally:
|
|
261
|
+
# 关闭即释放 flock
|
|
262
|
+
os.close(fd)
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def _file_digest(path: str) -> Tuple[int, str]:
|
|
266
|
+
digest = hashlib.sha256()
|
|
267
|
+
size = 0
|
|
268
|
+
with open(path, "rb") as handle:
|
|
269
|
+
while True:
|
|
270
|
+
chunk = handle.read(_HASH_CHUNK)
|
|
271
|
+
if not chunk:
|
|
272
|
+
break
|
|
273
|
+
digest.update(chunk)
|
|
274
|
+
size += len(chunk)
|
|
275
|
+
return size, digest.hexdigest()
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def load_handoffs() -> Tuple[List[Handoff], int]:
|
|
279
|
+
"""按行序读交接记录,返回 (记录, 跳过的坏行数)。文件不存在视为没有记录;读不了时抛 OSError。
|
|
280
|
+
|
|
281
|
+
读取时以字节切行:补进来的换行会多出一个空行,跳过即可,行号仍单调递增。
|
|
282
|
+
"""
|
|
283
|
+
try:
|
|
284
|
+
with open(log_path(), "rb") as handle:
|
|
285
|
+
raw_lines = handle.read().split(b"\n")
|
|
286
|
+
except FileNotFoundError:
|
|
287
|
+
return [], 0
|
|
288
|
+
found: List[Handoff] = []
|
|
289
|
+
bad = 0
|
|
290
|
+
for seq, raw in enumerate(raw_lines):
|
|
291
|
+
if not raw.strip():
|
|
292
|
+
continue
|
|
293
|
+
handoff = _handoff(seq, raw)
|
|
294
|
+
if handoff is None:
|
|
295
|
+
bad += 1
|
|
296
|
+
else:
|
|
297
|
+
found.append(handoff)
|
|
298
|
+
return found, bad
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def _handoff(seq: int, raw: bytes) -> Optional[Handoff]:
|
|
302
|
+
try:
|
|
303
|
+
entry = json.loads(raw)
|
|
304
|
+
except ValueError:
|
|
305
|
+
return None
|
|
306
|
+
if not isinstance(entry, dict) or entry.get("v") != HANDOFF_LOG_VERSION:
|
|
307
|
+
return None
|
|
308
|
+
strings = [entry.get(key) for key in ("session_id", "project", "source_dir", "target_dir")]
|
|
309
|
+
files = entry.get("files")
|
|
310
|
+
if not all(isinstance(value, str) and value for value in strings) or not isinstance(files, dict):
|
|
311
|
+
return None
|
|
312
|
+
parsed: Dict[str, Tuple[int, str]] = {}
|
|
313
|
+
for relpath, value in files.items():
|
|
314
|
+
if not (isinstance(value, list) and len(value) == 2 and isinstance(value[0], int)
|
|
315
|
+
and not isinstance(value[0], bool) and value[0] >= 0 and isinstance(value[1], str)):
|
|
316
|
+
return None
|
|
317
|
+
parsed[relpath] = (value[0], value[1])
|
|
318
|
+
return Handoff(seq, history._parse_time(entry.get("time")), strings[0], strings[1], strings[2], strings[3],
|
|
319
|
+
parsed)
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
def recording_started(handoffs: List[Handoff]) -> Optional[datetime]:
|
|
323
|
+
"""开始记录时刻 T0:有效记录里最早的写入时间;没有记录时为 None(视为还没开始记录)。
|
|
324
|
+
|
|
325
|
+
第一条记录就是本功能上线后的第一次 handoff,所以不需要另写启动标记。
|
|
326
|
+
"""
|
|
327
|
+
times = [handoff.time for handoff in handoffs if handoff.time is not None]
|
|
328
|
+
return min(times) if times else None
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
# ---- 归属判定与汇总 ----
|
|
332
|
+
|
|
333
|
+
class _Index(object):
|
|
334
|
+
"""按 (目标账号目录, 项目, relpath) 索引交接记录,供追链查“某次交接之前最近的一次”。"""
|
|
335
|
+
|
|
336
|
+
def __init__(self, handoffs: List[Handoff]) -> None:
|
|
337
|
+
self._by_key: Dict[Tuple[str, str, str], List[Handoff]] = {}
|
|
338
|
+
for handoff in handoffs:
|
|
339
|
+
for relpath in handoff.files:
|
|
340
|
+
self._by_key.setdefault((handoff.target_dir, handoff.project, relpath), []).append(handoff)
|
|
341
|
+
|
|
342
|
+
def latest(self, target_dir: str, project: str, relpath: str, before: Optional[int] = None) -> Optional[Handoff]:
|
|
343
|
+
best = None
|
|
344
|
+
for handoff in self._by_key.get((target_dir, project, relpath), ()):
|
|
345
|
+
if (before is None or handoff.seq < before) and (best is None or handoff.seq > best.seq):
|
|
346
|
+
best = handoff
|
|
347
|
+
return best
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
def _owner_dir(index: _Index, account_dir: str, publish: Publish, prefix_ok: Optional[bool]) -> Optional[str]:
|
|
351
|
+
"""这条副本最早是在哪个账号目录里写下的(真实路径);判不出时为 None。
|
|
352
|
+
|
|
353
|
+
副本前 N 字节是第 N 字节交接复制来的:偏移落在里面就到来源账号、在那次交接之前继续追;
|
|
354
|
+
偏移不在里面,说明是交接后在本账号实时写下的。只校验当前文件的前缀,链上更早的环节按记录可信。
|
|
355
|
+
"""
|
|
356
|
+
current = account_dir
|
|
357
|
+
before: Optional[int] = None
|
|
358
|
+
while True:
|
|
359
|
+
handoff = index.latest(current, publish.project, publish.relpath, before)
|
|
360
|
+
if handoff is None or publish.offset >= handoff.files[publish.relpath][0]:
|
|
361
|
+
return current
|
|
362
|
+
if before is None and prefix_ok is not True:
|
|
363
|
+
# 当前文件开头已不是当时复制进来的字节,偏移对应关系不成立
|
|
364
|
+
return None
|
|
365
|
+
current, before = handoff.source_dir, handoff.seq
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
def collect(sources: List[Tuple[str, str]]) -> Tuple[List[Artifact], int, int]:
|
|
369
|
+
"""扫描各账号目录,返回 (artifact 列表, 读不了而跳过的会话文件数, 交接记录坏行数)。
|
|
370
|
+
|
|
371
|
+
sources 是 (账号名, 账号目录),要扫全部账号:归属判定要看到所有副本。顺序决定平手时的取舍。
|
|
372
|
+
"""
|
|
373
|
+
handoffs, bad_lines = load_handoffs()
|
|
374
|
+
index = _Index(handoffs)
|
|
375
|
+
started = recording_started(handoffs)
|
|
376
|
+
names_by_dir = {os.path.realpath(directory): name for name, directory in sources}
|
|
377
|
+
copies: Dict[str, List[Tuple[Copy, Optional[str]]]] = {}
|
|
378
|
+
skipped = 0
|
|
379
|
+
for name, directory in sources:
|
|
380
|
+
real = os.path.realpath(directory)
|
|
381
|
+
for project, relpath, path in session_files(directory):
|
|
382
|
+
latest = index.latest(real, project, relpath)
|
|
383
|
+
expected = latest.files[relpath] if latest is not None else None
|
|
384
|
+
try:
|
|
385
|
+
found, prefix_ok = scan_file(path, project, relpath, expected)
|
|
386
|
+
except OSError:
|
|
387
|
+
skipped += 1
|
|
388
|
+
continue
|
|
389
|
+
for publish in found:
|
|
390
|
+
owner_dir = _owner_dir(index, real, publish, prefix_ok)
|
|
391
|
+
# 来源目录对不上任何当前账号(账号已删除,或 migrate-default 前的 ~/.claude):判不出
|
|
392
|
+
owner = names_by_dir.get(owner_dir) if owner_dir is not None else None
|
|
393
|
+
copies.setdefault(publish.record_id, []).append((Copy(name, publish), owner))
|
|
394
|
+
order = {name: position for position, (name, _directory) in enumerate(sources)}
|
|
395
|
+
return _group(_gate(copies, started), order), skipped, bad_lines
|
|
396
|
+
|
|
397
|
+
|
|
398
|
+
def _gate(copies: Dict[str, List[Tuple[Copy, Optional[str]]]],
|
|
399
|
+
started: Optional[datetime]) -> Dict[str, List[Tuple[Copy, Optional[str]]]]:
|
|
400
|
+
"""时间闸:开始记录前的发布、又出现在多个账号里的,追链结果不可信,全部改判不确定。
|
|
401
|
+
|
|
402
|
+
T0 之后能把记录复制进别的账号的交接一定有记录;T0 之前的交接可能没有记录,追链会停在错误的账号上
|
|
403
|
+
(上线前 A→B、上线后 B→A --force 时,A 的原件成了不扫的备份,两个副本都追到 B)。
|
|
404
|
+
只在一个账号里出现的早期记录没有被复制过的迹象,照常判该账号。
|
|
405
|
+
"""
|
|
406
|
+
gated = {}
|
|
407
|
+
for record_id, record_copies in copies.items():
|
|
408
|
+
moment = record_copies[0][0].publish.timestamp
|
|
409
|
+
early = moment is None or started is None or moment < started
|
|
410
|
+
if early and len({copy.account for copy, _owner in record_copies}) > 1:
|
|
411
|
+
record_copies = [(copy, None) for copy, _owner in record_copies]
|
|
412
|
+
gated[record_id] = record_copies
|
|
413
|
+
return gated
|
|
414
|
+
|
|
415
|
+
|
|
416
|
+
def _group(copies: Dict[str, List[Tuple[Copy, Optional[str]]]], order: Dict[str, int]) -> List[Artifact]:
|
|
417
|
+
by_url: Dict[str, List[Tuple[List[Copy], Set[str]]]] = {}
|
|
418
|
+
for record_copies in copies.values():
|
|
419
|
+
# 同一条记录的各副本:所有者取各副本判出的账号的并集,只有全部判不出才算不确定
|
|
420
|
+
owners = {owner for _copy, owner in record_copies if owner is not None}
|
|
421
|
+
url = record_copies[0][0].publish.url
|
|
422
|
+
by_url.setdefault(url, []).append(([copy for copy, _owner in record_copies], owners))
|
|
423
|
+
artifacts = []
|
|
424
|
+
for url, records in by_url.items():
|
|
425
|
+
all_copies = [copy for record_copies, _owners in records for copy in record_copies]
|
|
426
|
+
owners: Set[str] = set()
|
|
427
|
+
certain = True
|
|
428
|
+
for _record_copies, record_owners in records:
|
|
429
|
+
certain = certain and bool(record_owners)
|
|
430
|
+
owners |= record_owners
|
|
431
|
+
seen = sorted({copy.account for copy in all_copies}, key=lambda name: order.get(name, len(order)))
|
|
432
|
+
if certain and len(owners) == 1:
|
|
433
|
+
accounts = sorted(owners)
|
|
434
|
+
# single:副本全在所有者账号里,没靠交接记录;否则(副本在别的账号,或所有者账号里已没有副本)是 recorded
|
|
435
|
+
ownership = OWNERSHIP_SINGLE if seen == accounts else OWNERSHIP_RECORDED
|
|
436
|
+
# 所有者账号里可能已没有副本(交接后删掉了原会话):退回全部副本,续聊命令指向还在的那份
|
|
437
|
+
pool = [copy for copy in all_copies if copy.account in owners] or all_copies
|
|
438
|
+
else:
|
|
439
|
+
ownership = OWNERSHIP_UNCERTAIN
|
|
440
|
+
accounts = seen
|
|
441
|
+
pool = all_copies
|
|
442
|
+
times = [copy.publish.timestamp for copy in all_copies if copy.publish.timestamp is not None]
|
|
443
|
+
ordered = sorted(all_copies, key=lambda copy: _time_key(copy.publish.timestamp))
|
|
444
|
+
artifacts.append(Artifact(
|
|
445
|
+
url, _last_text(ordered, "title"), _last_text(ordered, "description"), accounts, ownership,
|
|
446
|
+
min(times) if times else None, max(times) if times else None, len(records),
|
|
447
|
+
_representative(pool, order)))
|
|
448
|
+
return artifacts
|
|
449
|
+
|
|
450
|
+
|
|
451
|
+
def _time_key(moment: Optional[datetime]) -> float:
|
|
452
|
+
return moment.timestamp() if moment is not None else float("-inf")
|
|
453
|
+
|
|
454
|
+
|
|
455
|
+
def _last_text(ordered: List[Copy], field: str) -> str:
|
|
456
|
+
for copy in reversed(ordered):
|
|
457
|
+
value = getattr(copy.publish, field)
|
|
458
|
+
if value:
|
|
459
|
+
return value
|
|
460
|
+
return ""
|
|
461
|
+
|
|
462
|
+
|
|
463
|
+
def _representative(pool: List[Copy], order: Dict[str, int]) -> Copy:
|
|
464
|
+
# 时间最晚的优先;时间相同按账号顺序取靠前的,结果稳定
|
|
465
|
+
return min(pool, key=lambda copy: (-_time_key(copy.publish.timestamp), order.get(copy.account, len(order)),
|
|
466
|
+
copy.publish.relpath, copy.publish.offset))
|