@ellieyujin/pi-reopen 0.0.0-stage → 0.2.0-dev.0
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.
- package/LICENSE +21 -0
- package/README.md +121 -2
- package/bin/pi-workspace.js +4 -0
- package/docs/PUBLISHING.md +97 -0
- package/docs/REQUIREMENTS.md +110 -0
- package/docs/STATUS.md +134 -0
- package/extensions/open-all.ts +232 -0
- package/package.json +50 -4
- package/src/binfmt.js +51 -0
- package/src/claims.js +67 -0
- package/src/cli.js +69 -0
- package/src/discover.js +65 -0
- package/src/launch.js +15 -0
- package/src/liveness.js +37 -0
- package/src/members.js +84 -0
- package/src/paths.js +21 -0
- package/src/plan.js +79 -0
- package/src/preflight.js +91 -0
- package/src/restore.js +132 -0
- package/src/spawn.js +53 -0
- package/src/verify.js +24 -0
package/src/paths.js
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { homedir } from "node:os";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
|
|
4
|
+
/** Pi agent 目录,尊重 PI_CODING_AGENT_DIR(与 pi 本体一致)。 */
|
|
5
|
+
export function agentDir(env = process.env) {
|
|
6
|
+
return env.PI_CODING_AGENT_DIR || join(env.HOME || homedir(), ".pi", "agent");
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/** pi-workspace 全部持久化路径。 */
|
|
10
|
+
export function workspacePaths(env = process.env) {
|
|
11
|
+
const agent = agentDir(env);
|
|
12
|
+
return {
|
|
13
|
+
agent,
|
|
14
|
+
workspace: join(agent, "pi-workspace"),
|
|
15
|
+
members: join(agent, "pi-workspace", "members"),
|
|
16
|
+
claims: join(agent, "pi-workspace", "starting"),
|
|
17
|
+
sessions: join(agent, "sessions"),
|
|
18
|
+
legacyRegistry: join(agent, "pi-terminals.json"),
|
|
19
|
+
legacyRecords: join(agent, "pi-terminal-records"),
|
|
20
|
+
};
|
|
21
|
+
}
|
package/src/plan.js
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { existsSync, readdirSync, realpathSync, statSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { isLive } from "./liveness.js";
|
|
4
|
+
import { workspacePaths } from "./paths.js";
|
|
5
|
+
|
|
6
|
+
/** 由记录定位会话文件:优先记录内绝对路径,否则按 sessionId 扫描会话目录。 */
|
|
7
|
+
export function resolveSessionFile(entry, paths = workspacePaths()) {
|
|
8
|
+
if (entry.sessionFile && existsSync(entry.sessionFile)) return entry.sessionFile;
|
|
9
|
+
try {
|
|
10
|
+
for (const dir of readdirSync(paths.sessions)) {
|
|
11
|
+
const p = join(paths.sessions, dir);
|
|
12
|
+
if (!statSync(p).isDirectory()) continue;
|
|
13
|
+
const file = readdirSync(p).find((f) => f.endsWith(`_${entry.sessionId}.jsonl`));
|
|
14
|
+
if (file) return join(p, file);
|
|
15
|
+
}
|
|
16
|
+
} catch {
|
|
17
|
+
/* 无会话目录 */
|
|
18
|
+
}
|
|
19
|
+
return undefined;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function dedupeKey(sessionFile) {
|
|
23
|
+
try {
|
|
24
|
+
return realpathSync(sessionFile);
|
|
25
|
+
} catch {
|
|
26
|
+
return sessionFile;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* 生成恢复计划。状态语义:
|
|
32
|
+
* live 进程仍存活,跳过(也自然排除了发起恢复的当前会话)
|
|
33
|
+
* claimed 其他恢复进程正在启动它,跳过
|
|
34
|
+
* missing-cwd 工作目录不存在,跳过并报告
|
|
35
|
+
* missing-file 会话文件不存在(未落盘/已删除),跳过并报告
|
|
36
|
+
* duplicate 与更早条目指向同一会话文件(含别名),跳过
|
|
37
|
+
* restorable 待恢复
|
|
38
|
+
*/
|
|
39
|
+
export function buildRestorePlan({
|
|
40
|
+
members,
|
|
41
|
+
paths = workspacePaths(),
|
|
42
|
+
liveFn = isLive,
|
|
43
|
+
claimFn,
|
|
44
|
+
bootId,
|
|
45
|
+
}) {
|
|
46
|
+
const seen = new Set();
|
|
47
|
+
const items = [];
|
|
48
|
+
const sorted = [...members].sort((a, b) => a.ts - b.ts);
|
|
49
|
+
for (const entry of sorted) {
|
|
50
|
+
let state = "restorable";
|
|
51
|
+
let sessionFile;
|
|
52
|
+
let detail;
|
|
53
|
+
if (liveFn(entry, { bootId })) {
|
|
54
|
+
state = "live";
|
|
55
|
+
} else if (claimFn?.(entry.sessionId)) {
|
|
56
|
+
state = "claimed";
|
|
57
|
+
} else if (!existsSync(entry.cwd)) {
|
|
58
|
+
state = "missing-cwd";
|
|
59
|
+
} else {
|
|
60
|
+
sessionFile = resolveSessionFile(entry, paths);
|
|
61
|
+
if (!sessionFile) {
|
|
62
|
+
state = "missing-file";
|
|
63
|
+
} else {
|
|
64
|
+
const key = dedupeKey(sessionFile);
|
|
65
|
+
if (seen.has(key)) {
|
|
66
|
+
state = "duplicate";
|
|
67
|
+
} else {
|
|
68
|
+
seen.add(key);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
items.push({ entry, sessionFile, state });
|
|
73
|
+
}
|
|
74
|
+
return {
|
|
75
|
+
items,
|
|
76
|
+
restorable: items.filter((i) => i.state === "restorable"),
|
|
77
|
+
count: (state) => items.filter((i) => i.state === state).length,
|
|
78
|
+
};
|
|
79
|
+
}
|
package/src/preflight.js
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
import { existsSync, statSync } from "node:fs";
|
|
3
|
+
import { discoverWezterm } from "./discover.js";
|
|
4
|
+
import { launchSetup, shellQuote } from "./launch.js";
|
|
5
|
+
|
|
6
|
+
const BINFMT_FIX_HINT =
|
|
7
|
+
"WSLInterop 未注册,Windows 程序无法从 WSL 启动。" +
|
|
8
|
+
"请在普通 Shell 执行(需 sudo 密码,工具不会自动执行):\n" +
|
|
9
|
+
"printf '%s\\n' ':WSLInterop:M::MZ::/init:P' | sudo tee /proc/sys/fs/binfmt_misc/register >/dev/null";
|
|
10
|
+
|
|
11
|
+
function isFile(path) {
|
|
12
|
+
try {
|
|
13
|
+
return statSync(path).isFile();
|
|
14
|
+
} catch {
|
|
15
|
+
return false;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** 启动前环境检查;任何一项不通过都不得批量开标签页。 */
|
|
20
|
+
export function preflight({ env = process.env, wezterm, prober, piProber } = {}) {
|
|
21
|
+
const checks = [];
|
|
22
|
+
const add = (name, ok, detail, hint) => checks.push({ name, ok, detail, hint });
|
|
23
|
+
|
|
24
|
+
const distro = env.WSL_DISTRO_NAME;
|
|
25
|
+
add("WSL 发行版", !!distro, distro || "未设置", distro ? undefined : "恢复仅支持 WSL + Windows WezTerm;缺少 WSL_DISTRO_NAME,无法定向启动标签页");
|
|
26
|
+
|
|
27
|
+
add("WSL 互操作套接字", existsSync("/run/WSL"), "/run/WSL", existsSync("/run/WSL") ? undefined : "未找到 /run/WSL,WSL 互操作未启用(检查 wsl.conf 的 interop 设置)");
|
|
28
|
+
|
|
29
|
+
const explicit = wezterm ?? env.PI_WORKSPACE_WEZTERM;
|
|
30
|
+
let found;
|
|
31
|
+
if (explicit) {
|
|
32
|
+
if (isFile(explicit)) {
|
|
33
|
+
found = explicit;
|
|
34
|
+
add("WezTerm 程序", true, found);
|
|
35
|
+
} else {
|
|
36
|
+
add(
|
|
37
|
+
"WezTerm 程序",
|
|
38
|
+
false,
|
|
39
|
+
`指定的文件不存在:${explicit}`,
|
|
40
|
+
"检查 PI_WORKSPACE_WEZTERM 环境变量或传入的路径",
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
} else {
|
|
44
|
+
found = discoverWezterm(env);
|
|
45
|
+
add(
|
|
46
|
+
"WezTerm 程序",
|
|
47
|
+
!!found,
|
|
48
|
+
found || "未找到",
|
|
49
|
+
found ? undefined : "未找到 wezterm.exe;可用环境变量 PI_WORKSPACE_WEZTERM 指定完整路径",
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
if (found) {
|
|
54
|
+
const probe = prober
|
|
55
|
+
? prober(found)
|
|
56
|
+
: spawnSync(found, ["--version"], { timeout: 8000, encoding: "utf8" });
|
|
57
|
+
if (probe.status === 0) {
|
|
58
|
+
add("Windows 程序执行", true, String(probe.stdout ?? "").trim() || "ok");
|
|
59
|
+
} else {
|
|
60
|
+
const enoexec = probe.error?.code === "ENOEXEC";
|
|
61
|
+
add(
|
|
62
|
+
"Windows 程序执行",
|
|
63
|
+
false,
|
|
64
|
+
enoexec ? "Exec format error (ENOEXEC)" : String(probe.stderr ?? probe.error ?? "").trim(),
|
|
65
|
+
enoexec ? BINFMT_FIX_HINT : "wezterm.exe 无法执行;确认安装完整且为 Windows 版本",
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const explicitPi = env.PI_WORKSPACE_PI_BIN;
|
|
71
|
+
const candidate = explicitPi ? shellQuote(explicitPi) : '"$pi_bin"';
|
|
72
|
+
const probeScript = [
|
|
73
|
+
launchSetup(env, explicitPi),
|
|
74
|
+
...(explicitPi ? [] : ['pi_bin=$(type -P pi) || exit 1']),
|
|
75
|
+
`[[ ${candidate} = /* && -f ${candidate} && -x ${candidate} ]] || exit 1`,
|
|
76
|
+
`printf '%s\\n' ${candidate}`,
|
|
77
|
+
].join("; ");
|
|
78
|
+
const piProbe = piProber
|
|
79
|
+
? piProber()
|
|
80
|
+
: spawnSync("bash", ["-lc", probeScript], { env, timeout: 5000, encoding: "utf8" });
|
|
81
|
+
const piPath = String(piProbe.stdout ?? "").trim().split(/\r?\n/).at(-1);
|
|
82
|
+
const piOk = piProbe.status === 0 && !!piPath;
|
|
83
|
+
add(
|
|
84
|
+
"pi 命令",
|
|
85
|
+
piOk,
|
|
86
|
+
piOk ? piPath : explicitPi || "未找到",
|
|
87
|
+
piOk ? undefined : "请先安装 pi 并加入 PATH,或设置 PI_WORKSPACE_PI_BIN 为 WSL 内可执行文件的绝对路径(不含参数、别名或 Shell 函数)",
|
|
88
|
+
);
|
|
89
|
+
|
|
90
|
+
return { ok: checks.every((c) => c.ok), wezterm: found, checks, distro, piBin: piOk ? piPath : undefined };
|
|
91
|
+
}
|
package/src/restore.js
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import { claimSession, sweepClaims } from "./claims.js";
|
|
2
|
+
import { listMembers } from "./members.js";
|
|
3
|
+
import { buildRestorePlan } from "./plan.js";
|
|
4
|
+
import { preflight } from "./preflight.js";
|
|
5
|
+
import { openTab } from "./spawn.js";
|
|
6
|
+
import { workspacePaths } from "./paths.js";
|
|
7
|
+
import { currentBootId } from "./liveness.js";
|
|
8
|
+
import { waitForMembers } from "./verify.js";
|
|
9
|
+
|
|
10
|
+
const STATE_LABEL = {
|
|
11
|
+
live: "运行中",
|
|
12
|
+
claimed: "启动占用中",
|
|
13
|
+
"missing-cwd": "工作目录缺失",
|
|
14
|
+
"missing-file": "会话文件缺失",
|
|
15
|
+
duplicate: "重复条目",
|
|
16
|
+
restorable: "待恢复",
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export function label(state) {
|
|
20
|
+
return STATE_LABEL[state] ?? state;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** 只读:列出全部成员与状态(list / dry-run 共用)。 */
|
|
24
|
+
export function inspect({ paths = workspacePaths() } = {}) {
|
|
25
|
+
const members = listMembers(paths);
|
|
26
|
+
const plan = buildRestorePlan({
|
|
27
|
+
members,
|
|
28
|
+
paths,
|
|
29
|
+
claimFn: () => false,
|
|
30
|
+
bootId: currentBootId(),
|
|
31
|
+
});
|
|
32
|
+
return { members, plan };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* 恢复编排:预检 → 计划 → 声明启动占用 → 逐个开标签页 → 等待登记确认。
|
|
37
|
+
* 结果如实区分:requested(WezTerm 接受请求)与 verified(Pi 已启动并登记)。
|
|
38
|
+
*/
|
|
39
|
+
export async function runRestore({
|
|
40
|
+
env = process.env,
|
|
41
|
+
paths = workspacePaths(env),
|
|
42
|
+
dryRun = false,
|
|
43
|
+
noWait = false,
|
|
44
|
+
waitTimeoutMs = 15_000,
|
|
45
|
+
spawner,
|
|
46
|
+
prober,
|
|
47
|
+
piProber,
|
|
48
|
+
} = {}) {
|
|
49
|
+
const pre = preflight({ env, prober, piProber });
|
|
50
|
+
if (!pre.ok) {
|
|
51
|
+
return { ok: false, stage: "preflight", checks: pre.checks };
|
|
52
|
+
}
|
|
53
|
+
sweepClaims(paths);
|
|
54
|
+
const { plan } = inspect({ paths });
|
|
55
|
+
if (dryRun) {
|
|
56
|
+
return { ok: true, stage: "dry-run", plan };
|
|
57
|
+
}
|
|
58
|
+
const baseline = Date.now();
|
|
59
|
+
const results = [];
|
|
60
|
+
for (const item of plan.restorable) {
|
|
61
|
+
const claim = claimSession(item.entry.sessionId, process.pid, paths);
|
|
62
|
+
if (!claim.claimed) {
|
|
63
|
+
results.push({ ...item, state: "claimed" });
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
const spawnResult = await openTab({
|
|
67
|
+
wezterm: pre.wezterm,
|
|
68
|
+
distro: pre.distro,
|
|
69
|
+
piBin: pre.piBin,
|
|
70
|
+
env,
|
|
71
|
+
cwd: item.entry.cwd,
|
|
72
|
+
sessionFile: item.sessionFile,
|
|
73
|
+
spawner,
|
|
74
|
+
});
|
|
75
|
+
results.push({
|
|
76
|
+
...item,
|
|
77
|
+
requested: spawnResult.ok,
|
|
78
|
+
requestError: spawnResult.ok ? undefined : spawnResult.error,
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
const requestedIds = results.filter((r) => r.requested).map((r) => r.entry.sessionId);
|
|
82
|
+
const verification = noWait || requestedIds.length === 0
|
|
83
|
+
? null
|
|
84
|
+
: await waitForMembers(requestedIds, { paths, timeoutMs: waitTimeoutMs, baseline });
|
|
85
|
+
const ok = results.every((r) => r.requested);
|
|
86
|
+
return { ok, stage: "done", plan, results, verification };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** 生成给人看的中文报告行。 */
|
|
90
|
+
export function summarize(result) {
|
|
91
|
+
const lines = [];
|
|
92
|
+
if (result.stage === "preflight") {
|
|
93
|
+
lines.push("环境预检未通过,未打开任何标签页:");
|
|
94
|
+
for (const check of result.checks.filter((c) => !c.ok)) {
|
|
95
|
+
lines.push(` ✖ ${check.name}:${check.detail}`);
|
|
96
|
+
if (check.hint) lines.push(` → ${check.hint}`);
|
|
97
|
+
}
|
|
98
|
+
return lines;
|
|
99
|
+
}
|
|
100
|
+
const counts = {};
|
|
101
|
+
for (const item of result.plan.items) counts[item.state] = (counts[item.state] ?? 0) + 1;
|
|
102
|
+
const skipped = Object.entries(counts)
|
|
103
|
+
.filter(([state]) => state !== "restorable")
|
|
104
|
+
.map(([state, n]) => `${label(state)} ${n}`)
|
|
105
|
+
.join(" · ");
|
|
106
|
+
if (result.stage === "dry-run") {
|
|
107
|
+
lines.push(`[dry-run] 待恢复 ${result.plan.restorable.length} 个${skipped ? `;跳过:${skipped}` : ""}`);
|
|
108
|
+
for (const item of result.plan.restorable) {
|
|
109
|
+
lines.push(` → ${item.entry.name || item.entry.sessionId} | ${item.entry.cwd}`);
|
|
110
|
+
}
|
|
111
|
+
return lines;
|
|
112
|
+
}
|
|
113
|
+
for (const r of result.results) {
|
|
114
|
+
const title = r.entry.name || r.entry.sessionId;
|
|
115
|
+
if (r.state === "claimed") {
|
|
116
|
+
lines.push(` ⏭ ${title}:其他恢复进程正在启动,跳过`);
|
|
117
|
+
} else if (!r.requested) {
|
|
118
|
+
lines.push(` ✖ ${title}:WezTerm 请求失败 — ${r.requestError}`);
|
|
119
|
+
} else if (result.verification?.verified.includes(r.entry.sessionId)) {
|
|
120
|
+
lines.push(` ✔ ${title}:已启动并确认(${r.entry.cwd})`);
|
|
121
|
+
} else {
|
|
122
|
+
lines.push(` ⚠ ${title}:已创建标签页,超时未确认 pi 启动(${r.entry.cwd})`);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
const skippedText = skipped ? `;跳过:${skipped}` : "";
|
|
126
|
+
if (!result.results.length) {
|
|
127
|
+
lines.push(`没有待恢复的会话${skippedText}`);
|
|
128
|
+
} else {
|
|
129
|
+
lines.push(`已处理 ${result.results.length} 个${skippedText}`);
|
|
130
|
+
}
|
|
131
|
+
return lines;
|
|
132
|
+
}
|
package/src/spawn.js
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
|
|
3
|
+
import { launchSetup, shellQuote } from "./launch.js";
|
|
4
|
+
export { shellQuote } from "./launch.js";
|
|
5
|
+
|
|
6
|
+
/** 登录 shell 初始化后恢复调用方 PATH 和 agent 目录,再启动已预检的 Pi。 */
|
|
7
|
+
export function buildTabScript({ cwd, sessionFile, piBin, env = process.env }) {
|
|
8
|
+
return [
|
|
9
|
+
launchSetup(env, piBin),
|
|
10
|
+
`cd -- ${shellQuote(cwd)} && exec ${piBin ? shellQuote(piBin) : "pi"} --session ${shellQuote(sessionFile)}`,
|
|
11
|
+
].join("; ");
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function defaultSpawner(program, args) {
|
|
15
|
+
return new Promise((resolve) => {
|
|
16
|
+
try {
|
|
17
|
+
const child = spawn(program, args, { detached: true, stdio: ["ignore", "pipe", "pipe"] });
|
|
18
|
+
let stdout = "";
|
|
19
|
+
let stderr = "";
|
|
20
|
+
child.stdout?.on("data", (d) => (stdout += d));
|
|
21
|
+
child.stderr?.on("data", (d) => (stderr += d));
|
|
22
|
+
const fail = (error) => resolve({ ok: false, error: String(error) });
|
|
23
|
+
child.on("error", (error) => fail(error));
|
|
24
|
+
child.on("close", (code) => {
|
|
25
|
+
if (code === 0) resolve({ ok: true, stdout: stdout.trim() });
|
|
26
|
+
else resolve({ ok: false, error: stderr.trim() || `wezterm cli 退出码 ${code}` });
|
|
27
|
+
});
|
|
28
|
+
} catch (error) {
|
|
29
|
+
resolve({ ok: false, error: String(error) });
|
|
30
|
+
}
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* 请求 WezTerm 打开一个标签页。返回 ok 只代表“请求被 WezTerm 接受”,
|
|
36
|
+
* 不代表 pi 已成功启动——后者由 verify 阶段确认。
|
|
37
|
+
*/
|
|
38
|
+
export async function openTab({ wezterm, distro, cwd, sessionFile, piBin, env = process.env, spawner } = {}) {
|
|
39
|
+
const script = buildTabScript({ cwd, sessionFile, piBin, env });
|
|
40
|
+
const args = [
|
|
41
|
+
"cli",
|
|
42
|
+
"spawn",
|
|
43
|
+
"--",
|
|
44
|
+
"wsl.exe",
|
|
45
|
+
...(distro ? ["-d", distro] : []),
|
|
46
|
+
"-e",
|
|
47
|
+
"bash",
|
|
48
|
+
"-lc",
|
|
49
|
+
script,
|
|
50
|
+
];
|
|
51
|
+
const run = spawner ?? defaultSpawner;
|
|
52
|
+
return run(wezterm, args);
|
|
53
|
+
}
|
package/src/verify.js
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { readMember } from "./members.js";
|
|
2
|
+
|
|
3
|
+
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* 等待被恢复的 Pi 完成登记(成员记录被新进程以新时间戳覆盖)。
|
|
7
|
+
* 只确认“pi 已启动并登记”,超时未确认的如实报告为未确认。
|
|
8
|
+
*/
|
|
9
|
+
export async function waitForMembers(sessionIds, { paths, timeoutMs = 15_000, pollMs = 500, baseline = Date.now(), sleeper = sleep } = {}) {
|
|
10
|
+
const pending = new Set(sessionIds);
|
|
11
|
+
const verified = new Set();
|
|
12
|
+
const deadline = Date.now() + timeoutMs;
|
|
13
|
+
while (pending.size > 0 && Date.now() < deadline) {
|
|
14
|
+
for (const id of [...pending]) {
|
|
15
|
+
const member = readMember(id, paths);
|
|
16
|
+
if (member && member.ts > baseline) {
|
|
17
|
+
verified.add(id);
|
|
18
|
+
pending.delete(id);
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
if (pending.size > 0) await sleeper(pollMs);
|
|
22
|
+
}
|
|
23
|
+
return { verified: [...verified], unverified: [...pending] };
|
|
24
|
+
}
|