agentosity 0.1.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/bin/agentosity.js +7 -0
- package/package.json +19 -0
- package/src/api.js +32 -0
- package/src/config.js +31 -0
- package/src/index.js +135 -0
- package/src/mcp.js +161 -0
- package/src/probes.js +145 -0
package/package.json
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "agentosity",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Agentosity — AI-native is a number now. Automatic attendance for your AI agents (stdio MCP lifecycle + activity probes) + human clock-out CLI.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"agentosity": "./bin/agentosity.js"
|
|
8
|
+
},
|
|
9
|
+
"files": ["bin", "src"],
|
|
10
|
+
"engines": { "node": ">=18" },
|
|
11
|
+
"keywords": ["mcp", "agent", "agent-hours", "claude-code", "codex", "xiabanbang"],
|
|
12
|
+
"homepage": "https://agentosity.com",
|
|
13
|
+
"repository": {
|
|
14
|
+
"type": "git",
|
|
15
|
+
"url": "git+https://github.com/realethanyang/agentosity.git",
|
|
16
|
+
"directory": "packages/cli"
|
|
17
|
+
},
|
|
18
|
+
"license": "MIT"
|
|
19
|
+
}
|
package/src/api.js
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { apiBase, loadConfig } from "./config.js";
|
|
2
|
+
|
|
3
|
+
function authHeaders() {
|
|
4
|
+
const t = loadConfig().accessToken;
|
|
5
|
+
return t ? { Authorization: `Bearer ${t}` } : {};
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
/** 所有请求 best-effort:考勤进程绝不能因为网络问题影响宿主 harness */
|
|
9
|
+
export async function post(path, body, { timeoutMs = 8000 } = {}) {
|
|
10
|
+
try {
|
|
11
|
+
const res = await fetch(`${apiBase()}${path}`, {
|
|
12
|
+
method: "POST",
|
|
13
|
+
headers: { "Content-Type": "application/json", ...authHeaders() },
|
|
14
|
+
body: JSON.stringify(body),
|
|
15
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
16
|
+
});
|
|
17
|
+
return await res.json();
|
|
18
|
+
} catch {
|
|
19
|
+
return null;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export async function get(path, { timeoutMs = 8000 } = {}) {
|
|
24
|
+
try {
|
|
25
|
+
const res = await fetch(`${apiBase()}${path}`, {
|
|
26
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
27
|
+
});
|
|
28
|
+
return await res.json();
|
|
29
|
+
} catch {
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
}
|
package/src/config.js
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { readFileSync, writeFileSync, mkdirSync } from "node:fs";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { randomUUID } from "node:crypto";
|
|
5
|
+
|
|
6
|
+
const DIR = join(homedir(), ".agentosity");
|
|
7
|
+
const FILE = join(DIR, "config.json");
|
|
8
|
+
|
|
9
|
+
export function loadConfig() {
|
|
10
|
+
try {
|
|
11
|
+
return JSON.parse(readFileSync(FILE, "utf8"));
|
|
12
|
+
} catch {
|
|
13
|
+
return {};
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function saveConfig(patch) {
|
|
18
|
+
const cfg = { ...loadConfig(), ...patch };
|
|
19
|
+
if (!cfg.deviceId) cfg.deviceId = randomUUID();
|
|
20
|
+
mkdirSync(DIR, { recursive: true });
|
|
21
|
+
writeFileSync(FILE, JSON.stringify(cfg, null, 2));
|
|
22
|
+
return cfg;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function apiBase() {
|
|
26
|
+
return (
|
|
27
|
+
process.env.AGENTOSITY_API ||
|
|
28
|
+
loadConfig().apiBase ||
|
|
29
|
+
"https://agentosity.com"
|
|
30
|
+
);
|
|
31
|
+
}
|
package/src/index.js
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import { execFileSync } from "node:child_process";
|
|
2
|
+
import { loadConfig, saveConfig, apiBase } from "./config.js";
|
|
3
|
+
import { post, get } from "./api.js";
|
|
4
|
+
import { serve } from "./mcp.js";
|
|
5
|
+
|
|
6
|
+
export async function main(argv) {
|
|
7
|
+
const cmd = argv[0] ?? "help";
|
|
8
|
+
switch (cmd) {
|
|
9
|
+
case "serve": {
|
|
10
|
+
const cfg = loadConfig();
|
|
11
|
+
serve({ company: argv[1] || cfg.company });
|
|
12
|
+
return; // 常驻,直到 harness 关闭
|
|
13
|
+
}
|
|
14
|
+
case "init":
|
|
15
|
+
return init(argv.slice(1).join(" ").trim());
|
|
16
|
+
case "clockout":
|
|
17
|
+
return clockout();
|
|
18
|
+
case "status":
|
|
19
|
+
return status();
|
|
20
|
+
case "login":
|
|
21
|
+
return login(argv[1], argv[2]);
|
|
22
|
+
default:
|
|
23
|
+
console.log(`agentosity — AI-native is a number now.
|
|
24
|
+
|
|
25
|
+
用法:
|
|
26
|
+
npx agentosity init <公司名> 绑定公司 + 给 harness 装上自动考勤
|
|
27
|
+
npx agentosity clockout 人类下班打卡
|
|
28
|
+
npx agentosity status 看榜:在岗 Agent / Agent 加班榜
|
|
29
|
+
npx agentosity login <邮箱> 发验证码;再跑 login <邮箱> <验证码> 完成登录
|
|
30
|
+
npx agentosity serve (由 harness 自动拉起)stdio MCP 考勤进程
|
|
31
|
+
`);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async function init(company) {
|
|
36
|
+
if (!company) {
|
|
37
|
+
console.error("用法:npx agentosity init <公司名>");
|
|
38
|
+
process.exit(1);
|
|
39
|
+
}
|
|
40
|
+
const cfg = saveConfig({ company });
|
|
41
|
+
console.log(`✅ 已绑定公司:${company}`);
|
|
42
|
+
|
|
43
|
+
// Claude Code:有 claude CLI 就直接装
|
|
44
|
+
let claudeOk = false;
|
|
45
|
+
try {
|
|
46
|
+
execFileSync("claude", ["mcp", "add", "--scope", "user", "agentosity", "--", "npx", "-y", "agentosity", "serve"], {
|
|
47
|
+
stdio: "pipe",
|
|
48
|
+
timeout: 15000,
|
|
49
|
+
});
|
|
50
|
+
claudeOk = true;
|
|
51
|
+
console.log("✅ Claude Code:已注册 MCP 考勤(全局)");
|
|
52
|
+
} catch {
|
|
53
|
+
/* 没装 claude CLI,走手动 */
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
console.log(`
|
|
57
|
+
从现在起,你的 Agent 会话会自动打卡考勤——模型零参与,只上报时长,不读任何内容。
|
|
58
|
+
|
|
59
|
+
${claudeOk ? "" : `Claude Code 手动配置:
|
|
60
|
+
claude mcp add --scope user agentosity -- npx -y agentosity serve
|
|
61
|
+
`}Codex CLI(~/.codex/config.toml 追加):
|
|
62
|
+
[mcp_servers.agentosity]
|
|
63
|
+
command = "npx"
|
|
64
|
+
args = ["-y", "agentosity", "serve"]
|
|
65
|
+
|
|
66
|
+
其他支持 stdio MCP 的 harness 同理:命令 npx,参数 -y agentosity serve
|
|
67
|
+
|
|
68
|
+
看榜:${apiBase()}/agents
|
|
69
|
+
设备 ID:${cfg.deviceId}
|
|
70
|
+
`);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
async function login(email, code) {
|
|
74
|
+
if (!email) {
|
|
75
|
+
console.error("用法:npx agentosity login <邮箱>,收到验证码后再跑 login <邮箱> <验证码>");
|
|
76
|
+
process.exit(1);
|
|
77
|
+
}
|
|
78
|
+
if (!code) {
|
|
79
|
+
const r = await post("/api/auth/send", { email });
|
|
80
|
+
if (r?.ok) console.log(`✅ 验证码已发到 ${email},收到后跑:npx agentosity login ${email} <验证码>`);
|
|
81
|
+
else {
|
|
82
|
+
console.error(`发送失败:${r?.error ?? "网络不可达"}`);
|
|
83
|
+
process.exit(1);
|
|
84
|
+
}
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
const cfg = saveConfig({}); // 确保 deviceId 存在
|
|
88
|
+
const r = await post("/api/auth/verify", { email, code, deviceId: cfg.deviceId });
|
|
89
|
+
if (r?.ok) {
|
|
90
|
+
saveConfig({ email: r.email, accessToken: r.access_token });
|
|
91
|
+
console.log(`✅ 已登录 ${r.email},这台设备的历史记录已并入账号`);
|
|
92
|
+
} else {
|
|
93
|
+
console.error(`登录失败:${r?.error ?? "验证码不对或已过期"}`);
|
|
94
|
+
process.exit(1);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
async function clockout() {
|
|
99
|
+
const cfg = loadConfig();
|
|
100
|
+
if (!cfg.company) {
|
|
101
|
+
console.error("还没绑定公司,先跑:npx agentosity init <公司名>");
|
|
102
|
+
process.exit(1);
|
|
103
|
+
}
|
|
104
|
+
const list = await get(`/api/companies?q=${encodeURIComponent(cfg.company)}`);
|
|
105
|
+
const match = list?.find?.((c) => c.name === cfg.company) ?? list?.[0];
|
|
106
|
+
if (!match) {
|
|
107
|
+
console.error(`找不到公司「${cfg.company}」,检查网络或重新 init`);
|
|
108
|
+
process.exit(1);
|
|
109
|
+
}
|
|
110
|
+
const r = await post("/api/checkin", { companyId: match.id, deviceId: cfg.deviceId });
|
|
111
|
+
if (r?.ok) {
|
|
112
|
+
console.log(`✅ 下班快乐!${cfg.company} · ${r.clocked_local}`);
|
|
113
|
+
if (r.note) console.log(` ${r.note}`);
|
|
114
|
+
console.log(` 明早 10:00 揭榜:${apiBase()}/me`);
|
|
115
|
+
} else {
|
|
116
|
+
console.error(`打卡失败:${r?.error ?? "网络不可达"}`);
|
|
117
|
+
process.exit(1);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
async function status() {
|
|
122
|
+
const d = await get("/api/agents");
|
|
123
|
+
if (!d?.live) {
|
|
124
|
+
console.error("拿不到数据,检查网络");
|
|
125
|
+
process.exit(1);
|
|
126
|
+
}
|
|
127
|
+
console.log(`🤖 此刻全网 ${d.live.total} 个 Agent 在岗\n`);
|
|
128
|
+
console.log("Agent 加班榜(近 7 天,Active Agent-Hours):");
|
|
129
|
+
(d.board ?? []).slice(0, 10).forEach((r, i) => {
|
|
130
|
+
const live = r.live_now > 0 ? ` · ● 在岗 ${r.live_now}` : "";
|
|
131
|
+
console.log(
|
|
132
|
+
`${String(i + 1).padStart(2)}. ${r.name} — ${r.active_hours}h · 会话 ${r.sessions} · 加班 ${r.overtime_hours}h · Leverage ${r.leverage ?? "—"}${live}`
|
|
133
|
+
);
|
|
134
|
+
});
|
|
135
|
+
}
|
package/src/mcp.js
ADDED
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
import { createInterface } from "node:readline";
|
|
2
|
+
import { post, get } from "./api.js";
|
|
3
|
+
import { detectHarness, createProbe } from "./probes.js";
|
|
4
|
+
|
|
5
|
+
const TICK_MS = parseInt(process.env.AGENTOSITY_TICK_MS ?? "", 10) || 15_000; // 活跃度采样间隔
|
|
6
|
+
const HEARTBEAT_MS = parseInt(process.env.AGENTOSITY_HB_MS ?? "", 10) || 60_000; // 上报间隔;服务端以最后心跳结算,误差 ≤1min
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* stdio MCP 考勤服务:harness 拉起本进程即上班,杀掉即下班。
|
|
10
|
+
* 模型零参与:initialize 握手 = start,stdin EOF / SIGTERM = end(遗言),心跳为准。
|
|
11
|
+
*/
|
|
12
|
+
export function serve({ company }) {
|
|
13
|
+
const startMs = Date.now();
|
|
14
|
+
let sessionId = null;
|
|
15
|
+
let startPromise = null;
|
|
16
|
+
let harness = "unknown";
|
|
17
|
+
let probe = null;
|
|
18
|
+
let probeLabel = "none";
|
|
19
|
+
let activeSeconds = 0;
|
|
20
|
+
let lastTick = Date.now();
|
|
21
|
+
let ended = false;
|
|
22
|
+
|
|
23
|
+
const write = (msg) => process.stdout.write(JSON.stringify(msg) + "\n");
|
|
24
|
+
|
|
25
|
+
function startSession() {
|
|
26
|
+
if (!company || startPromise) return;
|
|
27
|
+
startPromise = post("/api/agent/start", { company, harness, probe: probeLabel }).then((r) => {
|
|
28
|
+
if (r?.session_id) sessionId = r.session_id;
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const tickTimer = setInterval(() => {
|
|
33
|
+
const now = Date.now();
|
|
34
|
+
if (probe) {
|
|
35
|
+
try {
|
|
36
|
+
const s = probe.sample();
|
|
37
|
+
probeLabel = s.probe;
|
|
38
|
+
if (s.active) {
|
|
39
|
+
activeSeconds = Math.min(
|
|
40
|
+
activeSeconds + Math.round((now - lastTick) / 1000),
|
|
41
|
+
Math.round((now - startMs) / 1000) // 活跃时长不可能超过在岗墙钟
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
} catch {
|
|
45
|
+
/* 探针永不致命 */
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
lastTick = now;
|
|
49
|
+
}, TICK_MS);
|
|
50
|
+
|
|
51
|
+
const hbTimer = setInterval(() => {
|
|
52
|
+
if (sessionId) {
|
|
53
|
+
post("/api/agent/heartbeat", {
|
|
54
|
+
session_id: sessionId,
|
|
55
|
+
active_seconds: activeSeconds,
|
|
56
|
+
probe: probeLabel,
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
}, HEARTBEAT_MS);
|
|
60
|
+
|
|
61
|
+
async function endSession(code = 0) {
|
|
62
|
+
if (ended) return;
|
|
63
|
+
ended = true;
|
|
64
|
+
clearInterval(tickTimer);
|
|
65
|
+
clearInterval(hbTimer);
|
|
66
|
+
// start 可能还在途(会话开得快关得也快时):先等它落地,才能报 end
|
|
67
|
+
if (startPromise) {
|
|
68
|
+
await Promise.race([startPromise, new Promise((r) => setTimeout(r, 3000))]);
|
|
69
|
+
}
|
|
70
|
+
if (sessionId) {
|
|
71
|
+
// 遗言:harness 关闭时抢发,超时就放弃(服务端有心跳兜底)
|
|
72
|
+
await post(
|
|
73
|
+
"/api/agent/end",
|
|
74
|
+
{ session_id: sessionId, active_seconds: activeSeconds },
|
|
75
|
+
{ timeoutMs: 2500 }
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
process.exit(code);
|
|
79
|
+
}
|
|
80
|
+
process.on("SIGTERM", () => endSession());
|
|
81
|
+
process.on("SIGINT", () => endSession());
|
|
82
|
+
process.on("SIGHUP", () => endSession());
|
|
83
|
+
|
|
84
|
+
const rl = createInterface({ input: process.stdin, terminal: false });
|
|
85
|
+
rl.on("close", () => endSession());
|
|
86
|
+
|
|
87
|
+
rl.on("line", async (line) => {
|
|
88
|
+
let msg;
|
|
89
|
+
try {
|
|
90
|
+
msg = JSON.parse(line);
|
|
91
|
+
} catch {
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
try {
|
|
95
|
+
await handle(msg);
|
|
96
|
+
} catch {
|
|
97
|
+
if (msg?.id !== undefined) {
|
|
98
|
+
write({ jsonrpc: "2.0", id: msg.id, error: { code: -32603, message: "Internal error" } });
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
async function handle(msg) {
|
|
104
|
+
switch (msg.method) {
|
|
105
|
+
case "initialize": {
|
|
106
|
+
harness = detectHarness(msg.params?.clientInfo);
|
|
107
|
+
probe = createProbe(harness, startMs);
|
|
108
|
+
write({
|
|
109
|
+
jsonrpc: "2.0",
|
|
110
|
+
id: msg.id,
|
|
111
|
+
result: {
|
|
112
|
+
protocolVersion: msg.params?.protocolVersion ?? "2024-11-05",
|
|
113
|
+
capabilities: { tools: {} },
|
|
114
|
+
serverInfo: { name: "agentosity", version: "0.1.0" },
|
|
115
|
+
},
|
|
116
|
+
});
|
|
117
|
+
startSession();
|
|
118
|
+
break;
|
|
119
|
+
}
|
|
120
|
+
case "notifications/initialized":
|
|
121
|
+
case "notifications/cancelled":
|
|
122
|
+
break;
|
|
123
|
+
case "ping":
|
|
124
|
+
write({ jsonrpc: "2.0", id: msg.id, result: {} });
|
|
125
|
+
break;
|
|
126
|
+
case "tools/list":
|
|
127
|
+
write({
|
|
128
|
+
jsonrpc: "2.0",
|
|
129
|
+
id: msg.id,
|
|
130
|
+
result: {
|
|
131
|
+
tools: [
|
|
132
|
+
{
|
|
133
|
+
name: "agentosity_status",
|
|
134
|
+
description:
|
|
135
|
+
"查看下班榜 / Agentosity 实时榜单:当前在岗 Agent 数、Agent 加班榜前五。打卡是自动的,此工具仅供查询。",
|
|
136
|
+
inputSchema: { type: "object", properties: {} },
|
|
137
|
+
},
|
|
138
|
+
],
|
|
139
|
+
},
|
|
140
|
+
});
|
|
141
|
+
break;
|
|
142
|
+
case "tools/call": {
|
|
143
|
+
let text = "暂时拿不到榜单数据(网络不可达)。考勤不受影响,打卡是自动的。";
|
|
144
|
+
const d = await get("/api/agents");
|
|
145
|
+
if (d?.live) {
|
|
146
|
+
const rows = (d.board ?? [])
|
|
147
|
+
.slice(0, 5)
|
|
148
|
+
.map((r, i) => `${i + 1}. ${r.name} — Active ${r.active_hours}h · 在岗 ${r.live_now}`)
|
|
149
|
+
.join("\n");
|
|
150
|
+
text = `此刻全网 ${d.live.total} 个 Agent 在岗。\n\nAgent 加班榜(近 7 天):\n${rows}`;
|
|
151
|
+
}
|
|
152
|
+
write({ jsonrpc: "2.0", id: msg.id, result: { content: [{ type: "text", text }] } });
|
|
153
|
+
break;
|
|
154
|
+
}
|
|
155
|
+
default:
|
|
156
|
+
if (msg.id !== undefined) {
|
|
157
|
+
write({ jsonrpc: "2.0", id: msg.id, error: { code: -32601, message: "Method not found" } });
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
}
|
package/src/probes.js
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import { readdirSync, statSync, openSync, readSync, closeSync, fstatSync } from "node:fs";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { execFileSync } from "node:child_process";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* 活跃度探针:回答"此刻 Agent 是否在干活"。
|
|
8
|
+
* 三信号取并集:会话文件最近有写入 / 尾巴是在途工具调用 / harness 有活跃子进程。
|
|
9
|
+
* 只 stat 文件、只解析最后一行的事件类型字段,绝不读取对话内容。
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
const RECENT_WRITE_MS = 90_000; // 距上次写盘 90s 内视为活跃(覆盖流式输出间隙)
|
|
13
|
+
|
|
14
|
+
export function detectHarness(clientInfo) {
|
|
15
|
+
const n = (clientInfo?.name ?? "").toLowerCase();
|
|
16
|
+
if (n.includes("claude")) return "claude-code";
|
|
17
|
+
if (n.includes("codex")) return "codex";
|
|
18
|
+
if (n.includes("gemini")) return "gemini-cli";
|
|
19
|
+
if (n.includes("cursor")) return "cursor";
|
|
20
|
+
if (n.includes("cline")) return "cline";
|
|
21
|
+
return n || "unknown";
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Claude Code:~/.claude/projects/<cwd-slug>/<session>.jsonl */
|
|
25
|
+
function claudeSessionDir() {
|
|
26
|
+
const slug = process.cwd().replace(/[/.\s_]/g, "-");
|
|
27
|
+
return join(homedir(), ".claude", "projects", slug);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Codex:~/.codex/sessions/YYYY/MM/DD/rollout-*.jsonl */
|
|
31
|
+
function codexSessionDir() {
|
|
32
|
+
const d = new Date();
|
|
33
|
+
return join(
|
|
34
|
+
homedir(), ".codex", "sessions",
|
|
35
|
+
String(d.getFullYear()),
|
|
36
|
+
String(d.getMonth() + 1).padStart(2, "0"),
|
|
37
|
+
String(d.getDate()).padStart(2, "0")
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function createProbe(harness, processStartMs) {
|
|
42
|
+
const state = {
|
|
43
|
+
kind: "none",
|
|
44
|
+
file: null,
|
|
45
|
+
lastMtimeMs: 0,
|
|
46
|
+
lastWriteAt: 0,
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
const dirOf = { "claude-code": claudeSessionDir, codex: codexSessionDir }[harness];
|
|
50
|
+
|
|
51
|
+
function bindFile() {
|
|
52
|
+
if (state.file || !dirOf) return;
|
|
53
|
+
try {
|
|
54
|
+
const dir = dirOf();
|
|
55
|
+
const candidates = readdirSync(dir)
|
|
56
|
+
.filter((f) => f.endsWith(".jsonl"))
|
|
57
|
+
.map((f) => {
|
|
58
|
+
const p = join(dir, f);
|
|
59
|
+
const st = statSync(p);
|
|
60
|
+
return { p, birth: st.birthtimeMs || st.ctimeMs, mtime: st.mtimeMs };
|
|
61
|
+
})
|
|
62
|
+
// 会话文件在首条消息时才创建,允许比进程启动早 2 分钟(时钟偏差)之后的任何时间
|
|
63
|
+
.filter((c) => c.birth >= processStartMs - 120_000)
|
|
64
|
+
.sort((a, b) => b.birth - a.birth);
|
|
65
|
+
if (candidates.length > 0) {
|
|
66
|
+
state.file = candidates[0].p;
|
|
67
|
+
state.kind = "file-mtime";
|
|
68
|
+
state.lastMtimeMs = candidates[0].mtime;
|
|
69
|
+
state.lastWriteAt = Date.now();
|
|
70
|
+
}
|
|
71
|
+
} catch {
|
|
72
|
+
/* 目录不存在/无权限 → 保持降级 */
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** 信号 1:文件最近有写入 */
|
|
77
|
+
function recentWrite() {
|
|
78
|
+
if (!state.file) return false;
|
|
79
|
+
try {
|
|
80
|
+
const st = statSync(state.file);
|
|
81
|
+
if (st.mtimeMs > state.lastMtimeMs) {
|
|
82
|
+
state.lastMtimeMs = st.mtimeMs;
|
|
83
|
+
state.lastWriteAt = Date.now();
|
|
84
|
+
}
|
|
85
|
+
return Date.now() - state.lastWriteAt < RECENT_WRITE_MS;
|
|
86
|
+
} catch {
|
|
87
|
+
state.file = null; // 文件消失 → 解绑重找
|
|
88
|
+
state.kind = "none";
|
|
89
|
+
return false;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** 信号 2:尾巴状态 — 最后一个事件是发起工具调用且尚无结果 → 工具在途 */
|
|
94
|
+
function toolInFlight() {
|
|
95
|
+
if (!state.file) return false;
|
|
96
|
+
try {
|
|
97
|
+
const fd = openSync(state.file, "r");
|
|
98
|
+
const size = fstatSync(fd).size;
|
|
99
|
+
const len = Math.min(size, 64 * 1024);
|
|
100
|
+
const buf = Buffer.alloc(len);
|
|
101
|
+
readSync(fd, buf, 0, len, size - len);
|
|
102
|
+
closeSync(fd);
|
|
103
|
+
const lines = buf.toString("utf8").split("\n").filter((l) => l.trim());
|
|
104
|
+
const last = lines[lines.length - 1];
|
|
105
|
+
if (!last) return false;
|
|
106
|
+
// 只判断事件形态,不读内容:tool_use 出现且其后无 tool_result
|
|
107
|
+
const lastToolUse = last.lastIndexOf('"tool_use"');
|
|
108
|
+
const lastToolResult = last.lastIndexOf('"tool_result"');
|
|
109
|
+
return lastToolUse > -1 && lastToolUse > lastToolResult;
|
|
110
|
+
} catch {
|
|
111
|
+
return false;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** 信号 3:harness(父进程)有除本进程外的子进程在跑(长工具调用) */
|
|
116
|
+
function childActive() {
|
|
117
|
+
try {
|
|
118
|
+
const out = execFileSync("pgrep", ["-P", String(process.ppid)], {
|
|
119
|
+
encoding: "utf8",
|
|
120
|
+
timeout: 3000,
|
|
121
|
+
});
|
|
122
|
+
const others = out
|
|
123
|
+
.split("\n")
|
|
124
|
+
.map((s) => parseInt(s, 10))
|
|
125
|
+
.filter((pid) => pid && pid !== process.pid);
|
|
126
|
+
return others.length > 0;
|
|
127
|
+
} catch {
|
|
128
|
+
return false; // pgrep 无匹配时 exit 1,也走这里 → 视为无子进程
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
return {
|
|
133
|
+
/** 每个 tick 调用:返回 { active, probe } */
|
|
134
|
+
sample() {
|
|
135
|
+
bindFile();
|
|
136
|
+
const signals = {
|
|
137
|
+
write: recentWrite(),
|
|
138
|
+
inflight: toolInFlight(),
|
|
139
|
+
child: childActive(),
|
|
140
|
+
};
|
|
141
|
+
const probe = state.kind === "file-mtime" ? "file-mtime" : "proc-tree";
|
|
142
|
+
return { active: signals.write || signals.inflight || signals.child, probe, signals };
|
|
143
|
+
},
|
|
144
|
+
};
|
|
145
|
+
}
|