8000 fix(collector): reveal Paseo main sessions that are still open for 0.12.6 by InfinityPacer · Pull Request #55 · InfinityPacer/meterleaf · GitHub
[go: up one dir, main page]

Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

## [未发布]

## [0.12.5] - 2026-10-25
## [0.12.6] - 2026-10-25

- `scripts/claude-resume-visibility.ts` 只列出你在 Paseo 里开的主会话。其他代理派出的子代理不再列出,即使之后你直接和它对话过;从未收到你消息的代理也不列出。用 `paseo run` 派出代理时加上 `--label paseo.parent-agent-id=<派出者>`,它也会被识别为子代理。
- `scripts/claude-resume-visibility.ts` 不再等 Paseo 代理结束才处理,在 Paseo 里仍开着的主会话也会出现在 `claude --resume` 的列表里。想在终端里接着用时,先在 Paseo 里归档它,避免两边的对话分叉。
- 只需更新这个脚本,采集器和服务端都不用替换。数据库结构未变,升级无需先备份。
9 changes: 5 additions & 4 deletions docs/collector.md
Original file line number Diff line number Diff line change
Expand Up @@ -281,13 +281,14 @@ meterleaf-collector tasks remove <名称>

Paseo 以无界面模式启动 Claude Code,这样产生的会话不会出现在 `claude --resume` 和 `/resume` 的会话列表里,只能用会话 ID 打开。Claude Code 是按会话记录开头标着的启动来源决定是否列出的,与会话内容无关。

仓库里的 `scripts/claude-resume-visibility.ts` 把你在 Paseo 里开的主会话改成按普通终端会话列出。它只处理同时满足下面三点的会话。
仓库里的 `scripts/claude-resume-visibility.ts` 把你在 Paseo 里开的主会话改成按普通终端会话列出。它只处理同时满足下面两点的会话。

- 属于某个 Paseo 代理,也就是 Paseo 恢复这个代理时用的那个 Claude 会话。
- 这个代理是你开的,不是其他代理派出的。Paseo 内派出的代理带有父代理标签,第一条消息也是上级代理转来的任务,这样的子代理即使之后你直接和它对话过,也不会被列出。代理之外用 `paseo run` 派出的代理,Paseo 不记录派出者,派出时加上 `--label paseo.parent-agent-id=<派出者>` 就会被识别为子代理。
- 没有正在运行的 Claude Code 进程在使用它。还在运行的代理等它结束后,下一轮再处理。这也避免了 Paseo 和终端同时往一个会话里写。
- 这个代理是你开的,不是其他代理派出的。Paseo 内派出的代理带有父代理标签,第一条消息也是上级代理转来的任务,这样的子代理即使之后你直接和它对话过,也不会被列出。在 Paseo 之外,例如普通终端里的 Claude Code 用 `paseo run` 派出的代理,Paseo 不记录派出者,派出时加上 `--label paseo.parent-agent-id=<派出者>` 就会被识别为子代理。

改动只涉及会话记录开头那一段的启动来源字段,在原位置写入同样长度的内容,文件大小不变,对话内容不动。Claude Code 只在文件末尾追加,不会和这里的写入冲突,采集器也不会因此重新读取这些文件。
在 Paseo 里仍开着的代理也会列出。想在终端里接着用时,先在 Paseo 里归档它。Paseo 那边的进程不知道终端里新增的对话,之后在 Paseo 里再发消息,会从它自己的最后一条接着写,两边的对话就分叉了。

改动只涉及会话记录开头那一段的启动来源字段,在原位置写入同样长度的内容,文件大小不变,对话内容不动。Claude Code 只在文件末尾追加,即使会话正在进行也不会和这里的写入冲突,采集器也不会因此重新读取这些文件。

先复制到不随仓库分支变化的位置,用 `--dry-run` 看看会列出哪些会话,再登记为额外任务。

Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "meterleaf",
"version": "0.12.5",
"version": "0.12.6",
"private": true,
"type": "module",
"license": "Apache-2.0",
Expand Down
53 changes: 4 additions & 49 deletions scripts/claude-resume-visibility.ts
< 8000 td id="diff-4903cf8a5817a642497f3a4b6042016d9cc530bd8cb404595f4d18a2b8b0b23bL18" data-line-number="18" class="blob-num blob-num-context js-linkable-line-number">
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,11 @@
* 这些值改成 "cli",并在值后补空格保持字节数不变。
*
* 只处理同时满足下列条件的会话:被某个 Paseo 代理记录为它恢复的 Claude 会话;
* 这个代理由你开启,而不是由其他代理派出(子代理之后你再和它对话,也仍是子代理);
* 没有正在运行的 Claude Code 进程登记这个会话。
* 这个代理由你开启,而不是由其他代理派出(子代理之后你再和它对话,也仍是子代理)。
* 在 Paseo 里仍开着的代理同样处理:列出不会改变它的运行,是否在终端里接着用由你决定。
*
* 写入方式是原地覆盖等长字节:文件身份与长度不变,Claude Code 只在文件末尾追加,
* 不会与这里的写入重叠;Meterleaf 采集器按文件身份与已读位置之前的字节续读,
* 正在运行的进程也不会与这里的写入重叠;Meterleaf 采集器按文件身份与已读位置之前的字节续读,
* 也不会因此重读。每处修改记在任务状态目录的 changes.jsonl,`--revert` 按它改回。
*
* 用法: bun scripts/claude-resume-visibility.ts [--dry-run | --revert]
Expand Down Expand Up @@ -55,7 +55,6 @@ export interface Change {

export interface Report {
revealed: string[];
skippedLive: string[];
notEligible: number;
changes: Change[];
}
Expand All @@ -71,7 +70,7 @@ function configDirs(env: Env): string[] {
.map((line) => line.trim())
.filter(Boolean);
if (listed.length) return listed;
// 单独运行时当前 shell 可能指向某个额外账户,默认目录里的运行登记也要算上。
// 单独运行时当前 shell 可能指向某个额外账户,默认目录的会话记录也要算上。
const fallback = join(env.HOME || homedir(), ".claude");
return [...new Set([env.CLAUDE_CONFIG_DIR || fallback, fallback])];
}
Expand All @@ -98,40 +97,6 @@ function readJson(path: string): unknown {
}
}

function alive(pid: number): boolean {
try {
process.kill(pid, 0);
return true;
} catch (error) {
return (error as NodeJS.ErrnoException).code === "EPERM";
}
}

/** Claude Code 在每个配置目录的 sessions 下登记正在运行的进程与它的会话。 */
function liveSessions(dirs: string[]): Set<string> {
const live = new Set<string>();
for (const dir of dirs) {
const sessions = join(dir, "sessions");
if (!existsSync(sessions)) continue;
for (const name of readdirSync(sessions)) {
if (!name.endsWith(".json")) continue;
const record = readJson(join(sessions, name)) as {
pid?: unknown;
sessionId?: unknown;
} | null;
const pid = Number(record?.pid);
if (
typeof record?.sessionId === "string" &&
Number.isInteger(pid) &&
pid > 0 &&
alive(pid)
)
live.add(record.sessionId);
}
}
return live;
}

/**
* Paseo 每个代理一个 JSON;persistence.sessionId 是它恢复的会话。值为 true 表示
* 记录显示这个代理由其他代理派出:Paseo 内派出的代理带 paseo.parent-agent-id
Expand Down Expand Up @@ -321,11 +286,9 @@ export function reveal(
mkdirSync(state, { recursive: true, mode: 0o700 });
const checkedPath = join(state, "checked.json");
const checked = loadChecked(checkedPath);
const live = liveSessions(dirs);
const index = transcriptIndex(dirs);
const report: Report = {
revealed: [],
skippedLive: [],
notEligible: 0,
changes: [],
};
Expand All @@ -350,10 +313,6 @@ export function reveal(
report.notEligible += 1;
continue;
}
if (live.has(sessionId)) {
report.skippedLive.push(sessionId);
continue;
}
// 不符合条件的会话只在文件变化后重新判断,避免每轮都通读大文件。
const previous = checked[path];
if (
Expand Down Expand Up @@ -460,9 +419,5 @@ if (import.meta.main) {
console.log(
`${dryRun ? "将显示" : "已显示"} ${report.revealed.length} 个会话: ${short(report.revealed)}`,
);
if (dryRun && report.skippedLive.length)
console.log(
`正在运行,暂不处理 ${report.skippedLive.length} 个: ${short(report.skippedLive)}`,
);
if (dryRun) console.log(`不符合条件 ${report.notEligible} 个。`);
}
23 changes: 7 additions & 16 deletions tests/claude-resume-visibility.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -166,12 +166,11 @@ function setup() {
}

describe("让 Paseo 会话出现在 resume 列表", () => {
test("只改你开启、未在运行的 Paseo 会话,原地等长改写开头一段", () => {
test("只改你开启的 Paseo 会话,仍开着的也改,原地等长改写开头一段", () => {
const { env, files, original } = setup();
const before = statSync(files.main);
const report = reveal(env);
expect(report.revealed).toEqual([ids.main]);
expect(report.skippedLive).toEqual([ids.live]);
expect(report.revealed.sort()).toEqual([ids.main, ids.live].sort());
expect(report.notEligible).toBe(3);

const after = statSync(files.main);
Expand All @@ -195,7 +194,8 @@ describe("让 Paseo 会话出现在 resume 列表", () => {
// 正文里的同名文字被转义,不是记录字段,不改。
expect(text).toContain('\\"entrypoint\\":\\"sdk-cli\\"');

for (const key of ["child", "solo", "live", "labeled", "silent"] as const)
expect(listedEntrypoint(files.live)).toBe("cli");
for (const key of ["child", "solo", "labeled", "silent"] as const)
expect(readFileSync(files[key]).equals(original[key])).toBe(true);
});

Expand All @@ -214,7 +214,7 @@ describe("让 Paseo 会话出现在 resume 列表", () => {
test("预演不写文件也不留状态", () => {
const { env, files, original } = setup();
const report = reveal(env, { dryRun: true });
expect(report.revealed).toEqual([ids.main]);
expect(report.revealed.sort()).toEqual([ids.main, ids.live].sort());
expect(readFileSync(files.main).equals(original.main)).toBe(true);
expect(() =>
readFileSync(join(env.METERLEAF_TASK_STATE_DIR, "changes.jsonl")),
Expand All @@ -224,21 +224,12 @@ describe("让 Paseo 会话出现在 resume 列表", () => {
test("按修改记录逐字节改回", () => {
const { env, files, original } = setup();
reveal(env);
expect(revert(env)).toEqual({ reverted: 3, skipped: 0 });
expect(revert(env)).toEqual({ reverted: 4, skipped: 0 });
expect(readFileSync(files.main).equals(original.main)).toBe(true);
expect(readFileSync(files.live).equals(original.live)).toBe(true);
expect(revert(env)).toEqual({ reverted: 0, skipped: 0 });
});

test("会话结束后的下一轮再处理", () => {
const { env, files } = setup();
reveal(env);
rmSync(join(env.METERLEAF_CLAUDE_CONFIG_DIRS, "sessions"), {
recursive: true,
});
expect(reveal(env).revealed).toEqual([ids.live]);
expect(listedEntrypoint(files.live)).toBe("cli");
});

test("识别 SDK 的三种来源和冒号后带空格的写法,替换后长度不变", () => {
const head = Buffer.from(
'{"entrypoint":"sdk-ts"}\n{"entrypoint": "sdk-py"}\n{"entrypoint":"cli"}\n',
Expand Down
Loading
0