agentosity 0.1.4 → 0.2.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/package.json +3 -3
- package/src/api.js +48 -3
- package/src/harness-config.js +148 -0
- package/src/index.js +63 -44
- package/src/radar.js +210 -0
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agentosity",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Agentosity
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "Agentosity \u2014 AI-native is a number now. Automatic attendance for your AI agents (stdio MCP lifecycle + activity probes) + human clock-out CLI.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
7
7
|
"agentosity": "./bin/agentosity.js"
|
|
@@ -28,4 +28,4 @@
|
|
|
28
28
|
"directory": "packages/cli"
|
|
29
29
|
},
|
|
30
30
|
"license": "MIT"
|
|
31
|
-
}
|
|
31
|
+
}
|
package/src/api.js
CHANGED
|
@@ -1,4 +1,46 @@
|
|
|
1
|
-
import { apiBase, loadConfig } from "./config.js";
|
|
1
|
+
import { apiBase, loadConfig, saveConfig } from "./config.js";
|
|
2
|
+
|
|
3
|
+
function jwtExpMs(token) {
|
|
4
|
+
try {
|
|
5
|
+
return JSON.parse(Buffer.from(token.split(".")[1], "base64url").toString()).exp * 1000;
|
|
6
|
+
} catch {
|
|
7
|
+
return 0;
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
let refreshing = null;
|
|
12
|
+
|
|
13
|
+
/** access token 快过期时用 refresh token 换新(轮换制);失败则清登录态 */
|
|
14
|
+
async function ensureFresh() {
|
|
15
|
+
const cfg = loadConfig();
|
|
16
|
+
if (!cfg.accessToken) return;
|
|
17
|
+
if (jwtExpMs(cfg.accessToken) - Date.now() > 5 * 60_000) return;
|
|
18
|
+
if (!cfg.refreshToken) {
|
|
19
|
+
saveConfig({ accessToken: undefined, email: undefined });
|
|
20
|
+
return;
|
|
21
|
+
}
|
|
22
|
+
refreshing ??= (async () => {
|
|
23
|
+
try {
|
|
24
|
+
const res = await fetch(`${apiBase()}/api/auth/refresh`, {
|
|
25
|
+
method: "POST",
|
|
26
|
+
headers: { "Content-Type": "application/json" },
|
|
27
|
+
body: JSON.stringify({ refresh_token: cfg.refreshToken }),
|
|
28
|
+
signal: AbortSignal.timeout(8000),
|
|
29
|
+
});
|
|
30
|
+
const d = await res.json();
|
|
31
|
+
if (d?.ok) {
|
|
32
|
+
saveConfig({ accessToken: d.access_token, refreshToken: d.refresh_token ?? cfg.refreshToken });
|
|
33
|
+
} else {
|
|
34
|
+
saveConfig({ accessToken: undefined, refreshToken: undefined, email: undefined });
|
|
35
|
+
}
|
|
36
|
+
} catch {
|
|
37
|
+
/* 网络失败:保留现状,下次再试 */
|
|
38
|
+
} finally {
|
|
39
|
+
refreshing = null;
|
|
40
|
+
}
|
|
41
|
+
})();
|
|
42
|
+
await refreshing;
|
|
43
|
+
}
|
|
2
44
|
|
|
3
45
|
function authHeaders() {
|
|
4
46
|
const t = loadConfig().accessToken;
|
|
@@ -6,10 +48,11 @@ function authHeaders() {
|
|
|
6
48
|
}
|
|
7
49
|
|
|
8
50
|
/** 所有请求 best-effort:考勤进程绝不能因为网络问题影响宿主 harness */
|
|
9
|
-
export async function post(path, body, { timeoutMs = 8000 } = {}) {
|
|
51
|
+
export async function post(path, body, { timeoutMs = 8000, method = "POST" } = {}) {
|
|
10
52
|
try {
|
|
53
|
+
await ensureFresh();
|
|
11
54
|
const res = await fetch(`${apiBase()}${path}`, {
|
|
12
|
-
method
|
|
55
|
+
method,
|
|
13
56
|
headers: { "Content-Type": "application/json", ...authHeaders() },
|
|
14
57
|
body: JSON.stringify(body),
|
|
15
58
|
signal: AbortSignal.timeout(timeoutMs),
|
|
@@ -22,7 +65,9 @@ export async function post(path, body, { timeoutMs = 8000 } = {}) {
|
|
|
22
65
|
|
|
23
66
|
export async function get(path, { timeoutMs = 8000 } = {}) {
|
|
24
67
|
try {
|
|
68
|
+
await ensureFresh();
|
|
25
69
|
const res = await fetch(`${apiBase()}${path}`, {
|
|
70
|
+
headers: authHeaders(),
|
|
26
71
|
signal: AbortSignal.timeout(timeoutMs),
|
|
27
72
|
});
|
|
28
73
|
return await res.json();
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import { existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { join, dirname } from "node:path";
|
|
4
|
+
import { execFileSync } from "node:child_process";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* 各 harness 的 MCP 自动接入(幂等,不破坏用户已有配置)。
|
|
8
|
+
* 返回 [{ name, status: 'ok'|'already'|'manual'|'absent', note }]
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
const STD_ENTRY = { command: "npx", args: ["-y", "agentosity", "serve"] };
|
|
12
|
+
|
|
13
|
+
function h(...p) {
|
|
14
|
+
return join(homedir(), ...p);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** JSON 配置合并写入;文件损坏/无法解析时抛错交给调用方降级为 manual */
|
|
18
|
+
function mergeJson(path, mutate) {
|
|
19
|
+
let obj = {};
|
|
20
|
+
if (existsSync(path)) {
|
|
21
|
+
obj = JSON.parse(readFileSync(path, "utf8"));
|
|
22
|
+
}
|
|
23
|
+
const changed = mutate(obj);
|
|
24
|
+
if (changed) {
|
|
25
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
26
|
+
writeFileSync(path, JSON.stringify(obj, null, 2) + "\n");
|
|
27
|
+
}
|
|
28
|
+
return changed;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function mergeMcpServers(path) {
|
|
32
|
+
return mergeJson(path, (obj) => {
|
|
33
|
+
obj.mcpServers ??= {};
|
|
34
|
+
if (obj.mcpServers.agentosity) return false;
|
|
35
|
+
obj.mcpServers.agentosity = { ...STD_ENTRY };
|
|
36
|
+
return true;
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function installAllHarnesses() {
|
|
41
|
+
const results = [];
|
|
42
|
+
const add = (name, status, note = "") => results.push({ name, status, note });
|
|
43
|
+
|
|
44
|
+
// Claude Code:官方 CLI 注册(user 级,覆盖所有项目)
|
|
45
|
+
try {
|
|
46
|
+
execFileSync(
|
|
47
|
+
"claude",
|
|
48
|
+
["mcp", "add", "--scope", "user", "agentosity", "--", "npx", "-y", "agentosity", "serve"],
|
|
49
|
+
{ stdio: "pipe", timeout: 20000 }
|
|
50
|
+
);
|
|
51
|
+
add("Claude Code", "ok");
|
|
52
|
+
} catch {
|
|
53
|
+
if (existsSync(h(".claude"))) {
|
|
54
|
+
add("Claude Code", "manual", "claude mcp add --scope user agentosity -- npx -y agentosity serve");
|
|
55
|
+
} else {
|
|
56
|
+
add("Claude Code", "absent");
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Codex CLI:config.toml 追加
|
|
61
|
+
if (existsSync(h(".codex"))) {
|
|
62
|
+
const cfgPath = h(".codex", "config.toml");
|
|
63
|
+
const existing = existsSync(cfgPath) ? readFileSync(cfgPath, "utf8") : "";
|
|
64
|
+
if (existing.includes("mcp_servers.agentosity")) {
|
|
65
|
+
add("Codex CLI", "already");
|
|
66
|
+
} else {
|
|
67
|
+
writeFileSync(
|
|
68
|
+
cfgPath,
|
|
69
|
+
existing + '\n[mcp_servers.agentosity]\ncommand = "npx"\nargs = ["-y", "agentosity", "serve"]\n'
|
|
70
|
+
);
|
|
71
|
+
add("Codex CLI", "ok");
|
|
72
|
+
}
|
|
73
|
+
} else {
|
|
74
|
+
add("Codex CLI", "absent");
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Gemini CLI:~/.gemini/settings.json 的 mcpServers
|
|
78
|
+
if (existsSync(h(".gemini"))) {
|
|
79
|
+
try {
|
|
80
|
+
const changed = mergeMcpServers(h(".gemini", "settings.json"));
|
|
81
|
+
add("Gemini CLI", changed ? "ok" : "already");
|
|
82
|
+
} catch {
|
|
83
|
+
add("Gemini CLI", "manual", '~/.gemini/settings.json 的 mcpServers 里加 "agentosity"');
|
|
84
|
+
}
|
|
85
|
+
} else {
|
|
86
|
+
add("Gemini CLI", "absent");
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Cursor:~/.cursor/mcp.json
|
|
90
|
+
if (existsSync(h(".cursor"))) {
|
|
91
|
+
try {
|
|
92
|
+
const changed = mergeMcpServers(h(".cursor", "mcp.json"));
|
|
93
|
+
add("Cursor", changed ? "ok" : "already");
|
|
94
|
+
} catch {
|
|
95
|
+
add("Cursor", "manual", '~/.cursor/mcp.json 的 mcpServers 里加 "agentosity"');
|
|
96
|
+
}
|
|
97
|
+
} else {
|
|
98
|
+
add("Cursor", "absent");
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Windsurf:~/.codeium/windsurf/mcp_config.json
|
|
102
|
+
if (existsSync(h(".codeium", "windsurf"))) {
|
|
103
|
+
try {
|
|
104
|
+
const changed = mergeMcpServers(h(".codeium", "windsurf", "mcp_config.json"));
|
|
105
|
+
add("Windsurf", changed ? "ok" : "already");
|
|
106
|
+
} catch {
|
|
107
|
+
add("Windsurf", "manual", "~/.codeium/windsurf/mcp_config.json 的 mcpServers");
|
|
108
|
+
}
|
|
109
|
+
} else {
|
|
110
|
+
add("Windsurf", "absent");
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// OpenCode:~/.config/opencode/opencode.json(若用户用的是 .jsonc,不动它,给手动提示)
|
|
114
|
+
const ocJson = h(".config", "opencode", "opencode.json");
|
|
115
|
+
const ocJsonc = h(".config", "opencode", "opencode.jsonc");
|
|
116
|
+
const ocInstalled = existsSync(h(".local", "share", "opencode")) || existsSync(h(".config", "opencode"));
|
|
117
|
+
if (!ocInstalled) {
|
|
118
|
+
add("OpenCode", "absent");
|
|
119
|
+
} else if (existsSync(ocJsonc) && !existsSync(ocJson)) {
|
|
120
|
+
add(
|
|
121
|
+
"OpenCode",
|
|
122
|
+
"manual",
|
|
123
|
+
'opencode.jsonc 里加:"mcp": { "agentosity": { "type": "local", "command": ["npx", "-y", "agentosity", "serve"], "enabled": true } }'
|
|
124
|
+
);
|
|
125
|
+
} else {
|
|
126
|
+
try {
|
|
127
|
+
const changed = mergeJson(ocJson, (obj) => {
|
|
128
|
+
obj.mcp ??= {};
|
|
129
|
+
if (obj.mcp.agentosity) return false;
|
|
130
|
+
obj.mcp.agentosity = { type: "local", command: ["npx", "-y", "agentosity", "serve"], enabled: true };
|
|
131
|
+
return true;
|
|
132
|
+
});
|
|
133
|
+
add("OpenCode", changed ? "ok" : "already");
|
|
134
|
+
} catch {
|
|
135
|
+
add("OpenCode", "manual", "opencode.json 解析失败,手动加 mcp.agentosity");
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
return results;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export function formatInstallResults(results) {
|
|
143
|
+
const icon = { ok: "✅", already: "✅", manual: "✍️", absent: "—" };
|
|
144
|
+
return results
|
|
145
|
+
.filter((r) => r.status !== "absent")
|
|
146
|
+
.map((r) => ` ${icon[r.status]} ${r.name}${r.status === "already" ? "(已配置)" : ""}${r.note ? `:${r.note}` : ""}`)
|
|
147
|
+
.join("\n");
|
|
148
|
+
}
|
package/src/index.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
|
-
import { execFileSync } from "node:child_process";
|
|
2
1
|
import { loadConfig, saveConfig, apiBase } from "./config.js";
|
|
3
2
|
import { post, get } from "./api.js";
|
|
4
3
|
import { serve } from "./mcp.js";
|
|
4
|
+
import { installAllHarnesses, formatInstallResults } from "./harness-config.js";
|
|
5
|
+
import { runRadar } from "./radar.js";
|
|
5
6
|
|
|
6
7
|
export async function main(argv) {
|
|
7
8
|
const cmd = argv[0] ?? "help";
|
|
@@ -19,55 +20,60 @@ export async function main(argv) {
|
|
|
19
20
|
return status();
|
|
20
21
|
case "login":
|
|
21
22
|
return login(argv[1], argv[2]);
|
|
23
|
+
case "radar":
|
|
24
|
+
return runRadar();
|
|
22
25
|
default:
|
|
23
26
|
console.log(`agentosity — AI-native is a number now.
|
|
24
27
|
|
|
25
|
-
|
|
26
|
-
npx agentosity
|
|
28
|
+
三步开始(需登录):
|
|
29
|
+
npx agentosity login <邮箱> # 1. 发验证码
|
|
30
|
+
npx agentosity login <邮箱> <验证码> # 2. 登录
|
|
31
|
+
npx agentosity init "<公司名>" # 3. 绑定公司 + 自动接入所有 harness
|
|
32
|
+
|
|
33
|
+
其他:
|
|
34
|
+
npx agentosity radar 进程雷达:补录本机未接入 MCP 的 Agent 会话(常驻)
|
|
35
|
+
npx agentosity status 看榜 + 你的 Agent 今日战报
|
|
27
36
|
npx agentosity clockout 人类下班打卡
|
|
28
|
-
npx agentosity status 看榜:在岗 Agent / Agent 加班榜
|
|
29
|
-
npx agentosity login <邮箱> 发验证码;再跑 login <邮箱> <验证码> 完成登录
|
|
30
37
|
npx agentosity serve (由 harness 自动拉起)stdio MCP 考勤进程
|
|
31
38
|
`);
|
|
32
39
|
}
|
|
33
40
|
}
|
|
34
41
|
|
|
35
42
|
async function init(company) {
|
|
43
|
+
const pre = loadConfig();
|
|
44
|
+
if (!pre.accessToken) {
|
|
45
|
+
console.error(`Agentosity 需要登录使用:
|
|
46
|
+
npx agentosity login <邮箱> # 收验证码
|
|
47
|
+
npx agentosity login <邮箱> <验证码> # 登录
|
|
48
|
+
然后再跑 init。`);
|
|
49
|
+
process.exit(1);
|
|
50
|
+
}
|
|
36
51
|
if (!company) {
|
|
37
|
-
console.error(
|
|
52
|
+
console.error('用法:npx agentosity init "你的公司名"');
|
|
38
53
|
process.exit(1);
|
|
39
54
|
}
|
|
40
|
-
|
|
41
|
-
console.log(`✅ 已绑定公司:${company}`);
|
|
55
|
+
saveConfig({ company });
|
|
42
56
|
|
|
43
|
-
//
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
console.log("✅ Claude Code:已注册 MCP 考勤(全局)");
|
|
52
|
-
} catch {
|
|
53
|
-
/* 没装 claude CLI,走手动 */
|
|
57
|
+
// 服务端绑定公司(唯一真相;改绑每周一次)
|
|
58
|
+
const created = await post("/api/companies", { name: company });
|
|
59
|
+
if (created?.id) {
|
|
60
|
+
const bind = await post("/api/profile", { companyId: created.id }, { method: "PUT" });
|
|
61
|
+
if (bind?.error) console.log(`⚠️ ${bind.error}(本地归属仍按「${company}」记)`);
|
|
62
|
+
else console.log(`✅ 公司已绑定:${created.name ?? company}`);
|
|
63
|
+
} else {
|
|
64
|
+
console.log(`⚠️ 公司绑定暂未同步(网络?),本地归属按「${company}」记`);
|
|
54
65
|
}
|
|
55
66
|
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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"]
|
|
67
|
+
// 全家 harness 自动接入
|
|
68
|
+
console.log("\n接入 Agent 考勤:");
|
|
69
|
+
const results = installAllHarnesses();
|
|
70
|
+
console.log(formatInstallResults(results) || " (未发现已安装的 harness)");
|
|
65
71
|
|
|
66
|
-
|
|
72
|
+
console.log(`
|
|
73
|
+
从现在起,新开的 Agent 会话会自动考勤——模型零参与,只上报时长,不读任何内容。
|
|
74
|
+
已开着的老会话不会被追踪(配置只对新会话生效),要收编它们:npx agentosity radar
|
|
67
75
|
|
|
68
|
-
看榜:${apiBase()}/agents
|
|
69
|
-
设备 ID:${cfg.deviceId}
|
|
70
|
-
`);
|
|
76
|
+
看榜:${apiBase()}/agents`);
|
|
71
77
|
}
|
|
72
78
|
|
|
73
79
|
async function login(email, code) {
|
|
@@ -87,7 +93,7 @@ async function login(email, code) {
|
|
|
87
93
|
const cfg = saveConfig({}); // 确保 deviceId 存在
|
|
88
94
|
const r = await post("/api/auth/verify", { email, code, deviceId: cfg.deviceId });
|
|
89
95
|
if (r?.ok) {
|
|
90
|
-
saveConfig({ email: r.email, accessToken: r.access_token });
|
|
96
|
+
saveConfig({ email: r.email, accessToken: r.access_token, refreshToken: r.refresh_token });
|
|
91
97
|
console.log(`✅ 已登录 ${r.email},这台设备的历史记录已并入账号`);
|
|
92
98
|
} else {
|
|
93
99
|
console.error(`登录失败:${r?.error ?? "验证码不对或已过期"}`);
|
|
@@ -97,19 +103,14 @@ async function login(email, code) {
|
|
|
97
103
|
|
|
98
104
|
async function clockout() {
|
|
99
105
|
const cfg = loadConfig();
|
|
100
|
-
if (!cfg.
|
|
101
|
-
console.error("
|
|
106
|
+
if (!cfg.accessToken) {
|
|
107
|
+
console.error("需要先登录:npx agentosity login <邮箱>");
|
|
102
108
|
process.exit(1);
|
|
103
109
|
}
|
|
104
|
-
const
|
|
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 });
|
|
110
|
+
const r = await post("/api/checkin", { deviceId: cfg.deviceId });
|
|
111
111
|
if (r?.ok) {
|
|
112
|
-
console.log(`✅ 下班快乐!${
|
|
112
|
+
console.log(`✅ 下班快乐!${r.clocked_local}`);
|
|
113
|
+
if (r.rank_company != null) console.log(` 你是公司今天第 ${r.rank_company} 个下班的 · 全网第 ${r.rank_global} 个`);
|
|
113
114
|
if (r.note) console.log(` ${r.note}`);
|
|
114
115
|
console.log(` 明早 10:00 揭榜:${apiBase()}/me`);
|
|
115
116
|
} else {
|
|
@@ -124,7 +125,25 @@ async function status() {
|
|
|
124
125
|
console.error("拿不到数据,检查网络");
|
|
125
126
|
process.exit(1);
|
|
126
127
|
}
|
|
127
|
-
|
|
128
|
+
const w = d.live.working ?? 0;
|
|
129
|
+
const i = d.live.idle ?? 0;
|
|
130
|
+
console.log(`🤖 此刻全网 ${d.live.total} 个 Agent 在岗(⚡${w} 干活 · 😴${i} 挂机)\n`);
|
|
131
|
+
|
|
132
|
+
// 个人战报(登录或有设备身份时)
|
|
133
|
+
const cfg = loadConfig();
|
|
134
|
+
if (cfg.accessToken || cfg.deviceId) {
|
|
135
|
+
const mine = await get(`/api/my-agents?device=${cfg.deviceId ?? ""}`);
|
|
136
|
+
if (mine?.sessions > 0) {
|
|
137
|
+
console.log(
|
|
138
|
+
`⚡️ 你的 Agent 今天:干活 ${mine.active_hours}h · 会话 ${mine.sessions} 个 · 在岗 ${mine.session_hours}h · 此刻 ${mine.live_now} 个在跑`
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
const today = await get(`/api/my-today?device=${cfg.deviceId ?? ""}`);
|
|
142
|
+
if (today?.checked_in) {
|
|
143
|
+
console.log(`✅ 你今天 ${today.clocked_local} 已打卡下班${today.company ? `(${today.company})` : ""}`);
|
|
144
|
+
}
|
|
145
|
+
console.log("");
|
|
146
|
+
}
|
|
128
147
|
console.log("Agent 加班榜(近 7 天,Active Agent-Hours):");
|
|
129
148
|
(d.board ?? []).slice(0, 10).forEach((r, i) => {
|
|
130
149
|
const live = r.live_now > 0 ? ` · ● 在岗 ${r.live_now}` : "";
|
package/src/radar.js
ADDED
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
import { execFileSync } from "node:child_process";
|
|
2
|
+
import { existsSync, readdirSync, statSync } from "node:fs";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { createHash } from "node:crypto";
|
|
6
|
+
import { post, get } from "./api.js";
|
|
7
|
+
import { loadConfig } from "./config.js";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* 进程雷达(CLI 版,与 menu bar App 内的雷达同一套逻辑):
|
|
11
|
+
* 扫描本机 harness 进程,把没有 MCP 考勤的会话补录入册。
|
|
12
|
+
* 前台常驻,Ctrl+C 优雅收尾。v1 支持 macOS / Linux。
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
const TICK_MS = 30_000;
|
|
16
|
+
const HARNESSES = [
|
|
17
|
+
["claude", "claude-code"],
|
|
18
|
+
["codex", "codex"],
|
|
19
|
+
["opencode", "opencode"],
|
|
20
|
+
["gemini", "gemini-cli"],
|
|
21
|
+
];
|
|
22
|
+
|
|
23
|
+
function sh(cmd, args) {
|
|
24
|
+
try {
|
|
25
|
+
return execFileSync(cmd, args, { encoding: "utf8", timeout: 5000 });
|
|
26
|
+
} catch {
|
|
27
|
+
return "";
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const pgrepExact = (name) =>
|
|
32
|
+
sh("pgrep", ["-x", name]).split("\n").map((s) => parseInt(s, 10)).filter(Boolean);
|
|
33
|
+
const childrenOf = (pid) =>
|
|
34
|
+
sh("pgrep", ["-P", String(pid)]).split("\n").map((s) => parseInt(s, 10)).filter(Boolean);
|
|
35
|
+
const commandOf = (pid) => sh("ps", ["-o", "command=", "-p", String(pid)]);
|
|
36
|
+
|
|
37
|
+
function cpuSecondsOf(pid) {
|
|
38
|
+
const raw = sh("ps", ["-o", "time=", "-p", String(pid)]).trim();
|
|
39
|
+
if (!raw) return 0;
|
|
40
|
+
return raw.split(":").reverse().reduce((acc, part, i) => acc + parseFloat(part) * 60 ** i, 0);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function cwdOf(pid) {
|
|
44
|
+
const out = sh("lsof", ["-a", "-p", String(pid), "-d", "cwd", "-Fn"]);
|
|
45
|
+
for (const line of out.split("\n")) {
|
|
46
|
+
if (line.startsWith("n")) return line.slice(1);
|
|
47
|
+
}
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function sessionArtifact(harness, cwd) {
|
|
52
|
+
const home = homedir();
|
|
53
|
+
switch (harness) {
|
|
54
|
+
case "claude-code": {
|
|
55
|
+
if (!cwd) return null;
|
|
56
|
+
const slug = cwd.replace(/[/.\s_]/g, "-");
|
|
57
|
+
return join(home, ".claude", "projects", slug);
|
|
58
|
+
}
|
|
59
|
+
case "codex": {
|
|
60
|
+
const d = new Date();
|
|
61
|
+
return join(
|
|
62
|
+
home, ".codex", "sessions",
|
|
63
|
+
String(d.getFullYear()),
|
|
64
|
+
String(d.getMonth() + 1).padStart(2, "0"),
|
|
65
|
+
String(d.getDate()).padStart(2, "0")
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
case "opencode":
|
|
69
|
+
return join(home, ".local", "share", "opencode", "opencode.db-wal");
|
|
70
|
+
case "gemini-cli":
|
|
71
|
+
return cwd ? join(home, ".gemini", "tmp", createHash("sha256").update(cwd).digest("hex")) : null;
|
|
72
|
+
default:
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function newestMtimeMs(path) {
|
|
78
|
+
try {
|
|
79
|
+
const st = statSync(path);
|
|
80
|
+
if (st.isFile()) return st.mtimeMs;
|
|
81
|
+
let newest = 0;
|
|
82
|
+
for (const f of readdirSync(path)) {
|
|
83
|
+
try {
|
|
84
|
+
const m = statSync(join(path, f)).mtimeMs;
|
|
85
|
+
if (m > newest) newest = m;
|
|
86
|
+
} catch { /* 忽略单个文件错误 */ }
|
|
87
|
+
}
|
|
88
|
+
return newest;
|
|
89
|
+
} catch {
|
|
90
|
+
return 0;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export async function runRadar() {
|
|
95
|
+
if (process.platform === "win32") {
|
|
96
|
+
console.error("进程雷达 v1 支持 macOS / Linux;Windows 请先用 MCP 接入(npx agentosity init),雷达支持在路上。");
|
|
97
|
+
process.exit(1);
|
|
98
|
+
}
|
|
99
|
+
// menu bar App 自带雷达,俩雷达一起跑会重复记账
|
|
100
|
+
if (sh("pgrep", ["-x", "Agentosity"]).trim() && !process.argv.includes("--force")) {
|
|
101
|
+
console.error("检测到 Agentosity 菜单栏 App 正在运行(它自带雷达),无需再跑 CLI 雷达。确要双开:加 --force");
|
|
102
|
+
process.exit(1);
|
|
103
|
+
}
|
|
104
|
+
const cfg = loadConfig();
|
|
105
|
+
if (!cfg.accessToken) {
|
|
106
|
+
console.error("需要先登录:npx agentosity login <邮箱>");
|
|
107
|
+
process.exit(1);
|
|
108
|
+
}
|
|
109
|
+
const prof = await get(`/api/profile?device=${cfg.deviceId ?? ""}`);
|
|
110
|
+
const company = prof?.company?.name ?? cfg.company;
|
|
111
|
+
if (!company) {
|
|
112
|
+
console.error("还没绑定公司:先跑 npx agentosity init \"你的公司名\"");
|
|
113
|
+
process.exit(1);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
console.log(`📡 进程雷达启动 · 公司:${company} · 每 30 秒扫描一次(Ctrl+C 退出并收尾)`);
|
|
117
|
+
const tracked = new Map(); // pid → {sessionId, harness, cwd, activeSeconds, baseline:Set|null, lastCpu}
|
|
118
|
+
|
|
119
|
+
async function endAll() {
|
|
120
|
+
for (const [, t] of tracked) {
|
|
121
|
+
if (t.sessionId) {
|
|
122
|
+
await post("/api/agent/end", { session_id: t.sessionId, active_seconds: t.activeSeconds }, { timeoutMs: 3000 });
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
tracked.clear();
|
|
126
|
+
}
|
|
127
|
+
let stopping = false;
|
|
128
|
+
const stop = async () => {
|
|
129
|
+
if (stopping) return;
|
|
130
|
+
stopping = true;
|
|
131
|
+
console.log("\n收尾中…");
|
|
132
|
+
await endAll();
|
|
133
|
+
process.exit(0);
|
|
134
|
+
};
|
|
135
|
+
process.on("SIGINT", stop);
|
|
136
|
+
process.on("SIGTERM", stop);
|
|
137
|
+
|
|
138
|
+
async function tick() {
|
|
139
|
+
const found = new Map();
|
|
140
|
+
for (const [bin, harness] of HARNESSES) {
|
|
141
|
+
for (const pid of pgrepExact(bin)) found.set(pid, harness);
|
|
142
|
+
}
|
|
143
|
+
// 消失 → 下班
|
|
144
|
+
for (const [pid, t] of [...tracked]) {
|
|
145
|
+
if (!found.has(pid)) {
|
|
146
|
+
if (t.sessionId) await post("/api/agent/end", { session_id: t.sessionId, active_seconds: t.activeSeconds });
|
|
147
|
+
tracked.delete(pid);
|
|
148
|
+
console.log(`↓ 会话结束 pid=${pid}`);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
// 新进程 → 入册(已有 MCP 考勤的跳过)
|
|
152
|
+
for (const [pid, harness] of found) {
|
|
153
|
+
if (tracked.has(pid)) continue;
|
|
154
|
+
if (childrenOf(pid).some((c) => commandOf(c).includes("agentosity"))) continue;
|
|
155
|
+
const cwd = cwdOf(pid);
|
|
156
|
+
const r = await post("/api/agent/start", {
|
|
157
|
+
company, harness, probe: "radar", deviceId: cfg.deviceId,
|
|
158
|
+
});
|
|
159
|
+
tracked.set(pid, {
|
|
160
|
+
sessionId: r?.session_id ?? null, harness, cwd,
|
|
161
|
+
activeSeconds: 0, baseline: null, lastCpu: -1,
|
|
162
|
+
});
|
|
163
|
+
console.log(`↑ 补录 ${harness} pid=${pid}${cwd ? ` (${cwd})` : ""}`);
|
|
164
|
+
}
|
|
165
|
+
// 目录共享判定
|
|
166
|
+
const artifactCount = new Map();
|
|
167
|
+
for (const [, t] of tracked) {
|
|
168
|
+
const a = sessionArtifact(t.harness, t.cwd);
|
|
169
|
+
if (a) artifactCount.set(a, (artifactCount.get(a) ?? 0) + 1);
|
|
170
|
+
}
|
|
171
|
+
// 心跳 + 活跃度(CPU 增量 / 基线外子进程 / 独占目录写盘)
|
|
172
|
+
for (const [pid, t] of tracked) {
|
|
173
|
+
if (!t.sessionId) continue;
|
|
174
|
+
let active = false;
|
|
175
|
+
const cpu = cpuSecondsOf(pid);
|
|
176
|
+
if (t.lastCpu >= 0 && cpu - t.lastCpu > 1.0) active = true;
|
|
177
|
+
t.lastCpu = cpu;
|
|
178
|
+
|
|
179
|
+
const children = new Set(childrenOf(pid));
|
|
180
|
+
if (t.baseline === null) {
|
|
181
|
+
t.baseline = children;
|
|
182
|
+
} else {
|
|
183
|
+
t.baseline = new Set([...t.baseline].filter((p) => children.has(p)));
|
|
184
|
+
for (const c of children) if (!t.baseline.has(c)) { active = true; break; }
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
if (!active) {
|
|
188
|
+
const a = sessionArtifact(t.harness, t.cwd);
|
|
189
|
+
if (a && artifactCount.get(a) === 1 && existsSync(a)) {
|
|
190
|
+
if (Date.now() - newestMtimeMs(a) < 90_000) active = true;
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
if (active) t.activeSeconds += TICK_MS / 1000;
|
|
194
|
+
await post("/api/agent/heartbeat", {
|
|
195
|
+
session_id: t.sessionId, active_seconds: Math.round(t.activeSeconds), probe: "radar", active,
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
process.stdout.write(`\r📡 在册 ${tracked.size} 个会话 `);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// 主循环
|
|
202
|
+
for (;;) {
|
|
203
|
+
try {
|
|
204
|
+
await tick();
|
|
205
|
+
} catch {
|
|
206
|
+
/* 单轮失败不退出 */
|
|
207
|
+
}
|
|
208
|
+
await new Promise((r) => setTimeout(r, TICK_MS));
|
|
209
|
+
}
|
|
210
|
+
}
|