FFFF fix(collector): reveal only Paseo sessions you started, never dispatched agents, for 0.12.5 by InfinityPacer · Pull Request #54 · 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
7 changes: 3 additions & 4 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,7 @@

## [未发布]

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

- 本机采集器可以登记额外任务:`meterleaf-collector tasks add` 登记一条本机命令,由采集器的后台任务按间隔运行,有限时,失败只记日志,不影响同步,`status` 和 `tasks` 显示最近一次结果。用法见[本机采集器](docs/collector.md#额外任务可选)。
- 新增可选脚本 `scripts/claude-resume-visibility.ts`,登记为额外任务后,你在 Paseo 里对话过的 Claude Code 会话会出现在 `claude --resume` 的会话列表里。子代理和仍在运行的会话不处理,改动可以按记录改回。
- 需要重新构建并替换本机采集器,服务端不用升级。数据库结构未变,升级无需先备份。
- `scripts/claude-resume-visibility.ts` 只列出你在 Paseo 里开的主会话。其他代理派出的子代理不再列出,即使之后你直接和它对话过;从未收到你消息的代理也不列出。用 `paseo run` 派出代理时加上 `--label paseo.parent-agent-id=<派出者>`,它也会被识别为子代理。
- 只需更新这个脚本,采集器和服务端都不用替换。数据库结构未变,升级无需先备份。
4 changes: 2 additions & 2 deletions docs/collector.md
Original file line number Diff line number Diff line change
Expand Up @@ -281,10 +281,10 @@ meterleaf-collector tasks remove <名称>

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

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

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

改动只涉及会话记录开头那一段的启动来源字段,在原位置写入同样长度的内容,文件大小不变,对话内容不动。Claude Code 只在文件末尾追加,不会和这里的写入冲突,采集器也不会因此重新读取这些文件。
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.4",
"version": "0.12.5",
"private": true,
"type": "module",
"license": "Apache-2.0",
Expand Down
58 changes: 37 additions & 21 deletions scripts/claude-resume-visibility.ts
8000
Original file line number Diff line number Diff line change
@@ -1,14 +1,14 @@
#!/usr/bin/env bun
/**
* 让 Paseo 里与你对话过的 Claude Code 会话出现在 `claude --resume` 的会话列表里。
* 让你在 Paseo 里开的 Claude Code 主会话出现在 `claude --resume` 的会话列表里。
*
* Paseo 以无界面模式启动 Claude Code,会话记录的每一行都标着 "entrypoint":"sdk-cli"。
* Claude Code 的会话列表只看文件前 64 KiB 里第一次出现的 entrypoint,是 sdk-cli、
* sdk-ts 或 sdk-py 就不列出,但仍可以用会话 ID 直接恢复。本脚本把开头这一段里的
* 这些值改成 "cli",并在值后补空格保持字节数不变。
*
* 只处理同时满足下列条件的会话:被某个 Paseo 代理记录为它恢复的 Claude 会话;
* 记录里至少有一条你直接发出的消息(子代理只收到其他代理转来的任务,不处理);
* 这个代理由你开启,而不是由其他代理派出(子代理之后你再和它对话,也仍是子代理);
* 没有正在运行的 Claude Code 进程登记这个会话。
*
* 写入方式是原地覆盖等长字节:文件身份与长度不变,Claude Code 只在文件末尾追加,
Expand Down Expand Up @@ -40,6 +40,8 @@ import { join } from "node:path";
/** Claude Code 读取会话列表摘要时读入的文件开头字节数。 */
const HEAD_BYTES = 65536;
const PROGRAMMATIC = ["sdk-cli", "sdk-ts", "sdk-py"];
/** Paseo 给由其他代理派出的代理加的标签;`paseo run --label` 也可以手动带上。 */
const PARENT_LABEL = "paseo.parent-agent-id";

type Env = Record<string, string | undefined>;

Expand Down Expand Up @@ -130,29 +132,40 @@ function liveSessions(dirs: string[]): Set<string> {
return live;
}

/** Paseo 每个代理一个 JSON;persistence.sessionId 是它恢复的会话。 */
function paseoClaudeSessions(paseoHome: string): Set<string> {
const ids = new Set<string>();
/**
* Paseo 每个代理一个 JSON;persistence.sessionId 是它恢复的会话。值为 true 表示
* 记录显示这个代理由其他代理派出:Paseo 内派出的代理带 paseo.parent-agent-id
* 标签;从未收到过你在 Paseo 里发的消息时 lastUserMessageAt 为 null。
* 同一会话被多个代理引用时,任何一个显示是派出的就按派出处理。
*/
function paseoClaudeSessions(paseoHome: string): Map<string, boolean> {
const sessions = new Map<string, boolean>();
const agents = join(paseoHome, "agents");
if (!existsSync(agents)) return ids;
if (!existsSync(agents)) return sessions;
for (const workspace of readdirSync(agents, { withFileTypes: true })) {
if (!workspace.isDirectory()) continue;
const dir = join(agents, workspace.name);
for (const name of readdirSync(dir)) {
if (!name.endsWith(".json")) continue;
const record = readJson(join(dir, name)) as {
provider?: unknown;
labels?: Record<string, unknown> | null;
lastUserMessageAt?: unknown;
persistence?: { provider?: unknown; sessionId?: unknown };
} | null;
const provider = String(
record?.persistence?.provider ?? record?.provider ?? "",
);
const sessionId = record?.persistence?.sessionId;
if (provider.startsWith("claude") && typeof sessionId === "string")
ids.add(sessionId);
if (!provider.startsWith("claude") || typeof sessionId !== "string")
continue;
const dispatched =
Boolean(record?.labels?.[PARENT_LABEL]) ||
record?.lastUserMessageAt === null;
sessions.set(sessionId, (sessions.get(sessionId) ?? false) || dispatched);
}
}
return ids;
return sessions;
}

/** 各配置目录的 projects 常软链接到同一处,按真实路径去重。 */
Expand Down Expand Up @@ -229,11 +242,12 @@ function messageText(content: unknown): string {
}

/**
* 是否有你直接发出的消息。其他代理转来的任务、命令输出和注入的上下文都以
* 尖括号标签开头,不算;侧链是子代理自己的记录,上下文压缩摘要和中断提示
* 由 Claude Code 生成,也不算。
* 会话的第一条消息是不是你发的。其他代理派任务时,Paseo 把任务包在
* <paseo-system kind="agent-message"> 里作为第一条消息,这样的会话是子代理,
* 之后你再和它对话也不改变。注入的上下文、命令输出等其他尖括号标签开头的内容,
* 侧链(子代理自己的记录)、上下文压缩摘要和中断提示都不是消息本身,跳过。
*/
export function hasHumanMessage(path: string): boolean {
export function startedByYou(path: string): boolean {
const text = readFileSync(path, "utf8");
let start = 0;
while (start < text.length) {
Expand Down Expand Up @@ -264,12 +278,10 @@ export function hasHumanMessage(path: string): boolean {
)
continue;
const body = messageText(record.message?.content).trim();
if (
body &&
!body.startsWith("<") &&
!body.startsWith("[Request interrupted")
)
return true;
if (!body || body.startsWith("[Request interrupted")) continue;
if (body.startsWith("<paseo-system")) return false;
if (body.startsWith("<")) continue;
return true;
}
return false;
}
Expand Down Expand Up @@ -317,7 +329,7 @@ export function reveal(
notEligible: 0,
changes: [],
};
for (const sessionId of paseoClaudeSessions(
for (const [sessionId, dispatched] of paseoClaudeSessions(
env.PASEO_HOME || join(env.HOME || homedir(), ".paseo"),
)) {
const path = index.get(sessionId);
Expand All @@ -334,6 +346,10 @@ export function reveal(
readHead(fd, Math.min(stat.size, HEAD_BYTES + 32)),
);
if (targets.length === 0) continue;
if (dispatched) {
report.notEligible += 1;
continue;
}
if (live.has(sessionId)) {
report.skippedLive.push(sessionId);
continue;
Expand All @@ -348,7 +364,7 @@ export function reveal(
report.notEligible += 1;
continue;
}
if (!hasHumanMessage(path)) {
if (!startedByYou(path)) {
checked[path] = { size: stat.size, mtimeMs: stat.mtimeMs };
report.notEligible += 1;
continue;
Expand Down
32 changes: 25 additions & 7 deletions tests/claude-resume-visibility.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ const ids = {
child: "22222222-2222-4222-8222-222222222222",
solo: "33333333-3333-4333-8333-333333333333",
live: "44444444-4444-4444-8444-444444444444",
labeled: "55555555-5555-4555-8555-555555555555",
silent: "66666666-6666-4666-8666-666666666666",
};

function line(record: Record<string, unknown>): string {
Expand Down Expand Up @@ -73,6 +75,8 @@ function setup() {
child: join(projects, `${ids.child}.jsonl`),
solo: join(projects, `${ids.solo}.jsonl`),
live: join(projects, `${ids.live}.jsonl`),
labeled: join(projects, `${ids.labeled}.jsonl`),
silent: join(projects, `${ids.silent}.jsonl`),
};

// 主会话:开头是注入的上下文和一段转义文本,随后是你的提问;后半部分超过 64 KiB。
Expand Down Expand Up @@ -108,21 +112,35 @@ function setup() {
) +
user(ids.child, [
{ type: "text", text: "[Request interrupted by user for tool use]" },
]),
]) +
// 之后你在 Paseo 里直接和子代理对话,它仍是子代理。
user(ids.child, "顺便把测试也补上"),
);
writeFileSync(files.solo, user(ids.solo, "一次性提问"));
writeFileSync(files.live, user(ids.live, "还在进行的对话"));
// 这两个会话的第一条消息看起来像人写的,但代理记录显示它们是派出的。
writeFileSync(files.labeled, user(ids.labeled, "按计划实现第 2 项"));
writeFileSync(files.silent, user(ids.silent, "Read and carry out the plan"));

for (const id of [ids.main, ids.child, ids.live]) {
const agent = (id: string, extra: Record<string, unknown> = {}) =>
writeFileSync(
join(paseo, `${id.slice(0, 8)}.json`),
JSON.stringify({
id: `agent-${id.slice(0, 8)}`,
provider: "claude-bo",
labels: {},
lastUserMessageAt: "2026-10-25T10:00:00.000Z",
persistence: { provider: "claude-bo", sessionId: id },
...extra,
}),
);
}
agent(ids.main);
agent(ids.child);
agent(ids.live);
agent(ids.labeled, {
labels: { "paseo.parent-agent-id": "agent-11111111" },
});
agent(ids.silent, { lastUserMessageAt: null });
writeFileSync(
join(paseo, "codex.json"),
JSON.stringify({
Expand All @@ -148,13 +166,13 @@ 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.notEligible).toBe(1);
expect(report.notEligible).toBe(3);

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

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

Expand All @@ -186,7 +204,7 @@ describe("让 Paseo 会话出现在 resume 列表", () => {
reveal(env);
const second = reveal(env);
expect(second.revealed).toEqual([]);
expect(second.notEligible).toBe(1);
expect(second.notEligible).toBe(3);
const checked = JSON.parse(
readFileSync(join(env.METERLEAF_TASK_STATE_DIR, "checked.json"), "utf8"),
) as Record<string, unknown>;
Expand Down
Loading
0