ai-worklens-agent 0.1.1 → 0.1.4

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/README.md CHANGED
@@ -36,8 +36,8 @@ npm run mcp
36
36
  如果管理员已经把员工端发布到 npm 或企业私有 npm 源,可以使用 `npx` 首次安装:
37
37
 
38
38
  ```bash
39
- npx -y -p ai-worklens-agent@0.1.0 worklens-agent-install \
40
- --server-url http://127.0.0.1:8797 \
39
+ NPM_CONFIG_UPDATE_NOTIFIER=false npx -y --loglevel=error -p ai-worklens-agent@0.1.4 worklens-agent-install \
40
+ --server-url http://192.168.1.241:8797 \
41
41
  --tool codex \
42
42
  --employee-pinyin zhangsan
43
43
  ```
@@ -60,7 +60,7 @@ NPM_TOKEN=<npm_token> npm run client:npm:publish -- \
60
60
  如果管理员在官网发布了直链安装包,可以下载安装包后执行包内安装脚本:
61
61
 
62
62
  ```bash
63
- curl -fL http://127.0.0.1:8797/site/downloads/ai-worklens-codex-0.1.0.sh \
63
+ curl -fL http://192.168.1.241:8797/site/downloads/ai-worklens-codex-0.1.4.sh \
64
64
  -o ai-worklens-install.sh
65
65
  chmod +x ai-worklens-install.sh
66
66
  ./ai-worklens-install.sh zhangsan
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ai-worklens-agent",
3
- "version": "0.1.1",
3
+ "version": "0.1.4",
4
4
  "description": "Employee-side collector agent for AI WorkLens.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -18,5 +18,10 @@
18
18
  "claude-code",
19
19
  "opencode"
20
20
  ],
21
- "license": "UNLICENSED"
21
+ "license": "UNLICENSED",
22
+ "private": false,
23
+ "publishConfig": {
24
+ "access": "public",
25
+ "registry": "https://registry.npmjs.org"
26
+ }
22
27
  }
package/src/config.mjs CHANGED
@@ -22,6 +22,92 @@ function readJson(filePath) {
22
22
  }
23
23
  }
24
24
 
25
+ function readText(filePath) {
26
+ try {
27
+ if (!fs.existsSync(filePath)) return "";
28
+ return fs.readFileSync(filePath, "utf8");
29
+ } catch {
30
+ return "";
31
+ }
32
+ }
33
+
34
+ function firstValue(...values) {
35
+ return values.map((value) => String(value || "").trim()).find(Boolean) || "";
36
+ }
37
+
38
+ function knownModel(model) {
39
+ return Boolean(model?.label && model.label !== "unknown");
40
+ }
41
+
42
+ function rootTomlString(content, key) {
43
+ const keyPattern = new RegExp(`^${key}\\s*=\\s*["']([^"']+)["']`);
44
+ for (const line of String(content || "").split(/\r?\n/)) {
45
+ const trimmed = line.trim();
46
+ if (!trimmed || trimmed.startsWith("#")) continue;
47
+ if (trimmed.startsWith("[")) return "";
48
+ const match = trimmed.match(keyPattern);
49
+ if (match) return match[1].trim();
50
+ }
51
+ return "";
52
+ }
53
+
54
+ function jsonishString(content, key) {
55
+ const match = String(content || "").match(new RegExp(`["']?${key}["']?\\s*[:=]\\s*["']([^"']+)["']`));
56
+ return match?.[1]?.trim() || "";
57
+ }
58
+
59
+ function inferCodexModel(homeDir, env) {
60
+ const codexConfig = readText(path.join(homeDir, ".codex", "config.toml"));
61
+ return normalizeModelInfo({
62
+ provider: firstValue(env.OPENAI_PROVIDER, "openai"),
63
+ name: firstValue(
64
+ env.CODEX_MODEL,
65
+ env.OPENAI_MODEL,
66
+ rootTomlString(codexConfig, "model"),
67
+ jsonishString(codexConfig, "model")
68
+ )
69
+ });
70
+ }
71
+
72
+ function inferClaudeModel(homeDir, env) {
73
+ const claudeSettings = readJson(path.join(homeDir, ".claude", "settings.json"));
74
+ const claudeJson = readJson(path.join(homeDir, ".claude.json"));
75
+ return normalizeModelInfo({
76
+ provider: firstValue(env.ANTHROPIC_PROVIDER, "anthropic"),
77
+ name: firstValue(
78
+ env.CLAUDE_CODE_MODEL,
79
+ env.CLAUDE_MODEL,
80
+ env.ANTHROPIC_MODEL,
81
+ claudeSettings.model,
82
+ claudeSettings.modelName,
83
+ claudeSettings.env?.ANTHROPIC_MODEL,
84
+ claudeJson.model,
85
+ claudeJson.modelName
86
+ )
87
+ });
88
+ }
89
+
90
+ function inferOpenCodeModel(homeDir, env) {
91
+ const candidates = [
92
+ path.join(homeDir, ".config", "opencode", "opencode.json"),
93
+ path.join(homeDir, ".config", "opencode", "opencode.jsonc"),
94
+ path.join(homeDir, ".opencode", "config.json"),
95
+ path.join(homeDir, ".opencode", "config.jsonc")
96
+ ];
97
+ const configText = candidates.map(readText).find(Boolean) || "";
98
+ return normalizeModelInfo({
99
+ name: firstValue(env.OPENCODE_MODEL, env.OPENAI_MODEL, jsonishString(configText, "model")),
100
+ provider: firstValue(env.OPENCODE_PROVIDER, jsonishString(configText, "provider"))
101
+ });
102
+ }
103
+
104
+ function inferToolModel({ tool, homeDir, env }) {
105
+ const normalizedTool = normalizeToolId(tool);
106
+ if (normalizedTool === "claude-code") return inferClaudeModel(homeDir, env);
107
+ if (normalizedTool === "opencode") return inferOpenCodeModel(homeDir, env);
108
+ return inferCodexModel(homeDir, env);
109
+ }
110
+
25
111
  function defaultClientId() {
26
112
  const seed = `${os.hostname()}:${os.userInfo().username}`;
27
113
  return `device_${crypto.createHash("sha256").update(seed).digest("hex").slice(0, 12)}`;
@@ -38,9 +124,19 @@ export function defaultQueueFile(configFile = defaultConfigFile()) {
38
124
  export function loadClientConfig(options = {}) {
39
125
  const configFile = options.configFile || defaultConfigFile();
40
126
  const fileConfig = readJson(configFile);
41
- const env = process.env;
127
+ const env = options.env || process.env;
128
+ const homeDir = options.homeDir || envValue(env, "WORKLENS_HOME_DIR") || fileConfig.homeDir || os.homedir();
129
+ const tool = normalizeToolId(options.tool || envValue(env, "WORKLENS_TOOL") || fileConfig.tool || "codex");
42
130
  const serverUrl = options.serverUrl || envValue(env, "WORKLENS_SERVER_URL") || fileConfig.serverUrl || "http://127.0.0.1:8797";
43
131
  const collection = normalizeCollectionSettings(options.collection || fileConfig.collection || {});
132
+ const configuredModel = normalizeModelInfo({
133
+ ...fileConfig.model,
134
+ provider: options.modelProvider || envValue(env, "WORKLENS_MODEL_PROVIDER") || fileConfig.model?.provider,
135
+ name: options.modelName || envValue(env, "WORKLENS_MODEL_NAME") || fileConfig.model?.name,
136
+ version: options.modelVersion || envValue(env, "WORKLENS_MODEL_VERSION") || fileConfig.model?.version,
137
+ family: options.modelFamily || envValue(env, "WORKLENS_MODEL_FAMILY") || fileConfig.model?.family
138
+ });
139
+ const inferredModel = inferToolModel({ tool, homeDir, env });
44
140
  const employee = {
45
141
  id: options.employeeId || envValue(env, "WORKLENS_EMPLOYEE_ID") || fileConfig.employee?.id || os.userInfo().username,
46
142
  name: options.employeeName || envValue(env, "WORKLENS_EMPLOYEE_NAME") || fileConfig.employee?.name || os.userInfo().username,
@@ -50,19 +146,14 @@ export function loadClientConfig(options = {}) {
50
146
  };
51
147
  return {
52
148
  configFile,
149
+ homeDir,
53
150
  queueFile: options.queueFile || defaultQueueFile(configFile),
54
151
  serverUrl,
55
152
  collectorToken: options.collectorToken || envValue(env, "WORKLENS_COLLECTOR_TOKEN") || fileConfig.collectorToken || "",
56
153
  clientId: options.clientId || envValue(env, "WORKLENS_CLIENT_ID") || fileConfig.clientId || defaultClientId(),
57
154
  clientVersion: options.clientVersion || fileConfig.clientVersion || "0.1.0",
58
- tool: normalizeToolId(options.tool || envValue(env, "WORKLENS_TOOL") || fileConfig.tool || "codex"),
59
- model: normalizeModelInfo({
60
- ...fileConfig.model,
61
- provider: options.modelProvider || envValue(env, "WORKLENS_MODEL_PROVIDER") || fileConfig.model?.provider,
62
- name: options.modelName || envValue(env, "WORKLENS_MODEL_NAME") || fileConfig.model?.name,
63
- version: options.modelVersion || envValue(env, "WORKLENS_MODEL_VERSION") || fileConfig.model?.version,
64
- family: options.modelFamily || envValue(env, "WORKLENS_MODEL_FAMILY") || fileConfig.model?.family
65
- }),
155
+ tool,
156
+ model: knownModel(configuredModel) ? configuredModel : inferredModel,
66
157
  workspaceRoot: options.workspaceRoot || envValue(env, "WORKLENS_WORKSPACE_ROOT") || process.cwd(),
67
158
  repoName: options.repoName || envValue(env, "WORKLENS_REPO_NAME") || path.basename(options.workspaceRoot || envValue(env, "WORKLENS_WORKSPACE_ROOT") || process.cwd()),
68
159
  branch: options.branch || envValue(env, "WORKLENS_BRANCH") || fileConfig.branch || "",
@@ -70,11 +70,17 @@ function eventTypeFromHook(name, payload) {
70
70
  if (["pretooluse", "toolcall", "tooluse", "toolusebefore", "toolexecutebefore"].includes(compact)) {
71
71
  const toolName = toolNameFromPayload(payload);
72
72
  const command = commandFromPayload(payload);
73
+ if (skillNameFromPayload(payload)) return "skill_use";
74
+ if (mcpServerFromPayload(payload)) return "mcp_tool_call";
75
+ if (pluginNameFromPayload(payload)) return "plugin_use";
73
76
  return /bash|shell|terminal|command/i.test(toolName) ? commandEventType(command) : "tool_call";
74
77
  }
75
78
  if (["posttooluse", "posttoolbatch", "tooluseafter", "toolexecuteafter"].includes(compact)) {
76
79
  const toolName = toolNameFromPayload(payload);
77
80
  const command = commandFromPayload(payload);
81
+ if (skillNameFromPayload(payload)) return "skill_use";
82
+ if (mcpServerFromPayload(payload)) return "mcp_tool_call";
83
+ if (pluginNameFromPayload(payload)) return "plugin_use";
78
84
  return /bash|shell|terminal|command/i.test(toolName) ? commandEventType(command) : "tool_result";
79
85
  }
80
86
  if (["commandexecuted", "tuicommandexecute"].includes(compact)) return commandEventType(commandFromPayload(payload));
@@ -96,13 +102,32 @@ function commandEventType(command) {
96
102
  }
97
103
 
98
104
  function toolNameFromPayload(payload) {
99
- return payload.toolName ||
100
- payload.tool_name ||
101
- payload.tool ||
102
- payload.input?.tool ||
103
- payload.output?.tool ||
104
- payload.request?.tool ||
105
- "";
105
+ return firstText(
106
+ payload.toolName,
107
+ payload.tool_name,
108
+ payload.tool,
109
+ payload.name,
110
+ payload.input?.toolName,
111
+ payload.input?.tool_name,
112
+ payload.input?.tool,
113
+ payload.input?.name,
114
+ payload.output?.toolName,
115
+ payload.output?.tool_name,
116
+ payload.output?.tool,
117
+ payload.output?.name,
118
+ payload.request?.toolName,
119
+ payload.request?.tool_name,
120
+ payload.request?.tool,
121
+ payload.tool_input?.toolName,
122
+ payload.tool_input?.tool_name,
123
+ payload.tool_input?.tool,
124
+ payload.toolInput?.toolName,
125
+ payload.toolInput?.tool_name,
126
+ payload.toolInput?.tool,
127
+ payload.tool_args?.toolName,
128
+ payload.tool_args?.tool_name,
129
+ payload.tool_args?.tool
130
+ );
106
131
  }
107
132
 
108
133
  function firstText(...values) {
@@ -127,6 +152,111 @@ function modeFromPayload(payload) {
127
152
  );
128
153
  }
129
154
 
155
+ function skillNameFromPayload(payload) {
156
+ return firstText(
157
+ payload.skillName,
158
+ payload.skill_name,
159
+ payload.skill,
160
+ payload.metadata?.skillName,
161
+ payload.metadata?.skill_name,
162
+ payload.metadata?.skill,
163
+ payload.context?.skillName,
164
+ payload.context?.skill_name,
165
+ payload.context?.skill,
166
+ payload.input?.skillName,
167
+ payload.input?.skill_name,
168
+ payload.input?.skill,
169
+ payload.output?.skillName,
170
+ payload.output?.skill_name,
171
+ payload.output?.skill
172
+ );
173
+ }
174
+
175
+ function pluginNameFromPayload(payload) {
176
+ const explicit = firstText(
177
+ payload.pluginName,
178
+ payload.plugin_name,
179
+ payload.plugin,
180
+ payload.metadata?.pluginName,
181
+ payload.metadata?.plugin_name,
182
+ payload.metadata?.plugin,
183
+ payload.context?.pluginName,
184
+ payload.context?.plugin_name,
185
+ payload.context?.plugin,
186
+ payload.input?.pluginName,
187
+ payload.input?.plugin_name,
188
+ payload.input?.plugin,
189
+ payload.output?.pluginName,
190
+ payload.output?.plugin_name,
191
+ payload.output?.plugin
192
+ );
193
+ if (explicit) return explicit;
194
+ return pluginNameFromMcpNamespace(mcpNamespaceFromToolName(toolNameFromPayload(payload)));
195
+ }
196
+
197
+ function mcpServerFromPayload(payload) {
198
+ const explicit = firstText(
199
+ payload.mcpServer,
200
+ payload.mcp_server,
201
+ payload.server,
202
+ payload.metadata?.mcpServer,
203
+ payload.metadata?.mcp_server,
204
+ payload.metadata?.server,
205
+ payload.context?.mcpServer,
206
+ payload.context?.mcp_server,
207
+ payload.context?.server,
208
+ payload.input?.mcpServer,
209
+ payload.input?.mcp_server,
210
+ payload.input?.server,
211
+ payload.output?.mcpServer,
212
+ payload.output?.mcp_server,
213
+ payload.output?.server
214
+ );
215
+ if (explicit) return normalizeMcpServerName(explicit);
216
+ return normalizeMcpServerName(mcpNamespaceFromToolName(toolNameFromPayload(payload)));
217
+ }
218
+
219
+ function mcpNamespaceFromToolName(value) {
220
+ const toolName = String(value || "");
221
+ const match = toolName.match(/^mcp__(.+?)(?:[.:].*)?$/);
222
+ return match ? match[1] : "";
223
+ }
224
+
225
+ function normalizeMcpServerName(value) {
226
+ const raw = String(value || "").trim();
227
+ if (!raw) return "";
228
+ if (raw.startsWith("codex_apps__")) return raw.slice("codex_apps__".length).replaceAll("_", "-");
229
+ return raw.replaceAll("_", "-");
230
+ }
231
+
232
+ function pluginNameFromMcpNamespace(value) {
233
+ const raw = String(value || "").trim();
234
+ if (!raw) return "";
235
+ const normalized = normalizeMcpServerName(raw);
236
+ const key = normalized.toLowerCase();
237
+ const rawKey = raw.toLowerCase();
238
+ const mapping = {
239
+ playwright: "Browser",
240
+ browser: "Browser",
241
+ chrome: "Chrome",
242
+ "computer-use": "Computer Use",
243
+ figma: "Figma",
244
+ "outlook-email": "Outlook Email",
245
+ presentations: "Presentations",
246
+ spreadsheets: "Spreadsheets"
247
+ };
248
+ if (rawKey.startsWith("codex_apps__")) return mapping[key] || upperPluginName(normalized);
249
+ return mapping[key] || "";
250
+ }
251
+
252
+ function upperPluginName(value) {
253
+ return String(value || "")
254
+ .split(/[-_\s]+/)
255
+ .filter(Boolean)
256
+ .map((part) => `${part[0]?.toUpperCase() || ""}${part.slice(1)}`)
257
+ .join(" ");
258
+ }
259
+
130
260
  function previousModeFromPayload(payload) {
131
261
  return firstText(
132
262
  payload.previousMode,
@@ -268,6 +398,10 @@ export function normalizeHookPayload(payload, args, config) {
268
398
  const eventType = eventTypeFromHook(name, payload);
269
399
  const summary = pickSummary(payload, name, eventType);
270
400
  const metadata = payload.metadata && typeof payload.metadata === "object" ? payload.metadata : {};
401
+ const skillName = skillNameFromPayload(payload);
402
+ const pluginName = pluginNameFromPayload(payload);
403
+ const mcpServer = mcpServerFromPayload(payload);
404
+ const toolName = toolNameFromPayload(payload);
271
405
  return buildEvent({
272
406
  ...payload,
273
407
  eventType,
@@ -277,9 +411,9 @@ export function normalizeHookPayload(payload, args, config) {
277
411
  hookName: name,
278
412
  localSessionId: payload.sessionId || payload.session_id || payload.localSessionId,
279
413
  turnIndex: payload.turnIndex || payload.turn_index,
280
- skillName: payload.skillName || payload.skill_name || payload.skill,
281
- pluginName: payload.pluginName || payload.plugin_name || payload.plugin,
282
- mcpServer: payload.mcpServer || payload.mcp_server || payload.server,
414
+ skillName,
415
+ pluginName,
416
+ mcpServer,
283
417
  files: fileRefsFromPayload(payload),
284
418
  commands: commandFromPayload(payload),
285
419
  durationSeconds: payload.durationSeconds || payload.duration_seconds || payload.duration_ms && Number(payload.duration_ms) / 1000,
@@ -289,7 +423,11 @@ export function normalizeHookPayload(payload, args, config) {
289
423
  sourceTool: config.tool,
290
424
  rawHookEvent: name,
291
425
  hookEventName: payload.hook_event_name || payload.hookEventName || payload.event || payload.type || name,
292
- toolName: toolNameFromPayload(payload),
426
+ toolName,
427
+ skillName,
428
+ pluginName,
429
+ mcpServer,
430
+ mcpNamespace: mcpNamespaceFromToolName(toolName),
293
431
  codexMode: nextModeFromPayload(payload),
294
432
  interactionType: eventType,
295
433
  cwd: payload.cwd || payload.directory || payload.project?.directory,
@@ -305,7 +443,7 @@ export function normalizeHookPayload(payload, args, config) {
305
443
  previousMode: previousModeFromPayload(payload),
306
444
  nextMode: nextModeFromPayload(payload),
307
445
  phase: payload.phase || payload.input?.phase || payload.output?.phase,
308
- toolName: toolNameFromPayload(payload),
446
+ toolName,
309
447
  toolStatus: statusFromPayload(payload),
310
448
  exitCode: payload.exitCode ?? payload.exit_code ?? payload.output?.exitCode ?? payload.output?.exit_code,
311
449
  permissionDecision: permissionDecisionFromPayload(payload),
package/src/install.mjs CHANGED
@@ -2,12 +2,14 @@
2
2
  import fs from "node:fs";
3
3
  import os from "node:os";
4
4
  import path from "node:path";
5
+ import { spawnSync } from "node:child_process";
5
6
  import { fileURLToPath } from "node:url";
6
7
  import { writeClientConfig } from "./config.mjs";
7
8
  import { getToolProfile, listToolProfiles, normalizeToolId } from "./protocol/tool-profiles.mjs";
8
9
 
9
10
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
10
11
  const agentSrcDir = __dirname;
12
+ const clientPackageVersion = readPackageVersion();
11
13
 
12
14
  function parseArgs(argv) {
13
15
  const result = {};
@@ -25,10 +27,20 @@ function parseArgs(argv) {
25
27
  return result;
26
28
  }
27
29
 
30
+ function readPackageVersion() {
31
+ try {
32
+ const packageJson = JSON.parse(fs.readFileSync(path.resolve(__dirname, "../package.json"), "utf8"));
33
+ return packageJson.version || "0.1.0";
34
+ } catch {
35
+ return "0.1.0";
36
+ }
37
+ }
38
+
28
39
  export function buildInstallManifest(targetDir, options = {}) {
29
40
  const node = process.execPath;
30
41
  const selectedTool = normalizeToolId(options.tool || "codex");
31
42
  const profile = getToolProfile(selectedTool);
43
+ const serverUrl = options.serverUrl || "http://127.0.0.1:8797";
32
44
  const displayTargetDir = displayPath(targetDir);
33
45
  const configFile = path.join(displayTargetDir, "client.json");
34
46
  const mcpCommand = node;
@@ -40,6 +52,7 @@ export function buildInstallManifest(targetDir, options = {}) {
40
52
  displayTargetDir,
41
53
  selectedTool,
42
54
  configFile,
55
+ serverUrl,
43
56
  mcpCommand,
44
57
  mcpArgs,
45
58
  hookCommand,
@@ -55,7 +68,7 @@ export function buildInstallManifest(targetDir, options = {}) {
55
68
  name: profile.mcpServerName,
56
69
  command: mcpCommand,
57
70
  args: mcpArgs,
58
- env: { WORKLENS_CONFIG_FILE: configFile, WORKLENS_TOOL: selectedTool }
71
+ env: { WORKLENS_CONFIG_FILE: configFile, WORKLENS_TOOL: selectedTool, WORKLENS_SERVER_URL: serverUrl }
59
72
  },
60
73
  hook: {
61
74
  name: profile.hookName,
@@ -94,12 +107,13 @@ export function buildInstallManifest(targetDir, options = {}) {
94
107
  fullReplyUploaded: false,
95
108
  localRedaction: true
96
109
  },
97
- serverUrl: options.serverUrl || "http://127.0.0.1:8797"
110
+ serverUrl
98
111
  };
99
112
  }
100
113
 
101
114
  export function installClient(options = {}) {
102
115
  const targetDir = options.targetDir || path.join(os.homedir(), ".ai-worklens");
116
+ const homeDir = options.homeDir || os.homedir();
103
117
  fs.mkdirSync(targetDir, { recursive: true, mode: 0o700 });
104
118
  const configFile = path.join(targetDir, "client.json");
105
119
  const manifestFile = path.join(targetDir, "install-manifest.json");
@@ -113,11 +127,23 @@ export function installClient(options = {}) {
113
127
  const installOrUpdateScriptFile = path.join(targetDir, "worklens-install-or-update.sh");
114
128
  const readmeFile = path.join(targetDir, "README.md");
115
129
  const selectedTool = normalizeToolId(options.tool || "codex");
130
+ const installedClientVersion = options.clientVersion || clientPackageVersion;
131
+ const updatePolicyInput = options.updatePolicy || {};
132
+ const installedArtifactRevision = String(
133
+ options.artifactRevision
134
+ || (String(updatePolicyInput.clientVersion || "") === String(installedClientVersion) ? updatePolicyInput.artifactRevision : "")
135
+ || installedClientVersion
136
+ );
137
+ const updatePolicy = {
138
+ ...updatePolicyInput,
139
+ appliedRevision: options.appliedRevision || installedArtifactRevision,
140
+ lastUpdatedAt: updatePolicyInput.lastUpdatedAt || new Date().toISOString()
141
+ };
116
142
  writeClientConfig(configFile, {
117
143
  serverUrl: options.serverUrl || "http://127.0.0.1:8797",
118
144
  collectorToken: options.collectorToken || "",
119
145
  clientId: options.clientId || "",
120
- clientVersion: options.clientVersion || "0.1.0",
146
+ clientVersion: installedClientVersion,
121
147
  tool: selectedTool,
122
148
  model: {
123
149
  provider: options.modelProvider || "",
@@ -134,7 +160,7 @@ export function installClient(options = {}) {
134
160
  },
135
161
  upload: options.upload || { timeoutMs: 5000, batchSize: 50 },
136
162
  collection: options.collection || {},
137
- update: options.updatePolicy || {}
163
+ update: updatePolicy
138
164
  });
139
165
  const manifest = buildInstallManifest(targetDir, options);
140
166
  fs.writeFileSync(manifestFile, `${JSON.stringify(manifest, null, 2)}\n`, { mode: 0o600 });
@@ -166,12 +192,20 @@ export function installClient(options = {}) {
166
192
  fs.chmodSync(installOrUpdateScriptFile, 0o700);
167
193
  fs.writeFileSync(readmeFile, buildReadme(manifest), { mode: 0o600 });
168
194
  fs.chmodSync(readmeFile, 0o600);
195
+ const integrations = options.writeToolConfigs === false
196
+ ? { ok: true, skipped: true, items: [] }
197
+ : installToolIntegrations(manifest, {
198
+ homeDir,
199
+ integrations: options.integrations || "selected",
200
+ useToolCli: options.useToolCli !== false
201
+ });
169
202
  return {
170
203
  ok: true,
171
204
  targetDir,
172
205
  configFile,
173
206
  manifestFile,
174
207
  generatedFiles: manifest.generatedFiles,
208
+ integrations,
175
209
  manifest
176
210
  };
177
211
  }
@@ -200,7 +234,7 @@ function upperFirst(value) {
200
234
  return input ? `${input[0].toUpperCase()}${input.slice(1)}` : input;
201
235
  }
202
236
 
203
- function buildToolArtifacts({ targetDir, displayTargetDir, configFile, mcpCommand, mcpArgs }) {
237
+ function buildToolArtifacts({ targetDir, displayTargetDir, configFile, serverUrl, mcpCommand, mcpArgs }) {
204
238
  return Object.fromEntries(listToolProfiles().map((profile) => {
205
239
  const hookArgs = [path.join(agentSrcDir, "hook-adapter.mjs"), "--config", configFile, "--tool", profile.id];
206
240
  const artifact = {
@@ -216,10 +250,11 @@ function buildToolArtifacts({ targetDir, displayTargetDir, configFile, mcpComman
216
250
  name: profile.mcpServerName,
217
251
  command: mcpCommand,
218
252
  args: mcpArgs,
219
- env: { WORKLENS_CONFIG_FILE: configFile, WORKLENS_TOOL: profile.id }
253
+ env: { WORKLENS_CONFIG_FILE: configFile, WORKLENS_TOOL: profile.id, WORKLENS_SERVER_URL: serverUrl }
220
254
  },
221
255
  config: toolConfigFor(profile, {
222
256
  configFile,
257
+ serverUrl,
223
258
  mcpCommand,
224
259
  mcpArgs,
225
260
  hookCommand: mcpCommand,
@@ -240,7 +275,7 @@ function buildToolArtifacts({ targetDir, displayTargetDir, configFile, mcpComman
240
275
  }));
241
276
  }
242
277
 
243
- function toolConfigFor(profile, { configFile, mcpCommand, mcpArgs, hookCommand, hookArgs }) {
278
+ function toolConfigFor(profile, { configFile, serverUrl, mcpCommand, mcpArgs, hookCommand, hookArgs }) {
244
279
  if (profile.id === "codex") {
245
280
  return [
246
281
  `[mcp_servers.${profile.mcpServerName}]`,
@@ -249,7 +284,8 @@ function toolConfigFor(profile, { configFile, mcpCommand, mcpArgs, hookCommand,
249
284
  "",
250
285
  `[mcp_servers.${profile.mcpServerName}.env]`,
251
286
  `WORKLENS_CONFIG_FILE = ${tomlString(configFile)}`,
252
- `WORKLENS_TOOL = ${tomlString(profile.id)}`
287
+ `WORKLENS_TOOL = ${tomlString(profile.id)}`,
288
+ `WORKLENS_SERVER_URL = ${tomlString(serverUrl)}`
253
289
  ].join("\n");
254
290
  }
255
291
  if (profile.id === "claude-code") {
@@ -258,7 +294,7 @@ function toolConfigFor(profile, { configFile, mcpCommand, mcpArgs, hookCommand,
258
294
  [profile.mcpServerName]: {
259
295
  command: mcpCommand,
260
296
  args: mcpArgs,
261
- env: { WORKLENS_CONFIG_FILE: configFile, WORKLENS_TOOL: profile.id }
297
+ env: { WORKLENS_CONFIG_FILE: configFile, WORKLENS_TOOL: profile.id, WORKLENS_SERVER_URL: serverUrl }
262
298
  }
263
299
  }
264
300
  }, null, 2);
@@ -271,12 +307,12 @@ function toolConfigFor(profile, { configFile, mcpCommand, mcpArgs, hookCommand,
271
307
  type: "local",
272
308
  command: [mcpCommand, ...mcpArgs],
273
309
  enabled: true,
274
- environment: { WORKLENS_CONFIG_FILE: configFile, WORKLENS_TOOL: profile.id }
310
+ environment: { WORKLENS_CONFIG_FILE: configFile, WORKLENS_TOOL: profile.id, WORKLENS_SERVER_URL: serverUrl }
275
311
  }
276
312
  }
277
313
  }, null, 2);
278
314
  }
279
- return JSON.stringify({ command: mcpCommand, args: mcpArgs, env: { WORKLENS_CONFIG_FILE: configFile, WORKLENS_TOOL: profile.id } }, null, 2);
315
+ return JSON.stringify({ command: mcpCommand, args: mcpArgs, env: { WORKLENS_CONFIG_FILE: configFile, WORKLENS_TOOL: profile.id, WORKLENS_SERVER_URL: serverUrl } }, null, 2);
280
316
  }
281
317
 
282
318
  function extraFilesFor(profile, { targetDir, hookCommand, hookArgs }) {
@@ -332,8 +368,7 @@ function buildClaudeHooksSettings(command, baseArgs) {
332
368
  hooks: [
333
369
  {
334
370
  type: "command",
335
- command,
336
- args: [...baseArgs, "--event", eventName],
371
+ command: commandLine(command, [...baseArgs, "--event", eventName]),
337
372
  timeout: 10
338
373
  }
339
374
  ]
@@ -347,6 +382,249 @@ function buildClaudeHooksSettings(command, baseArgs) {
347
382
  }, null, 2);
348
383
  }
349
384
 
385
+ function installToolIntegrations(manifest, { homeDir, integrations = "selected", useToolCli = true } = {}) {
386
+ const selected = selectedIntegrationTools(integrations, manifest);
387
+ const items = [];
388
+ const errors = [];
389
+ const run = (tool, fn) => {
390
+ if (!selected.includes(tool)) return;
391
+ try {
392
+ items.push({ tool, ok: true, ...fn() });
393
+ } catch (error) {
394
+ const item = { tool, ok: false, error: error.message };
395
+ items.push(item);
396
+ errors.push(item);
397
+ }
398
+ };
399
+ run("codex", () => installCodexIntegration(manifest, homeDir));
400
+ run("claude-code", () => installClaudeCodeIntegration(manifest, homeDir, { useToolCli }));
401
+ run("opencode", () => installOpenCodeIntegration(manifest, homeDir));
402
+ return { ok: errors.length === 0, items };
403
+ }
404
+
405
+ function selectedIntegrationTools(value, manifest) {
406
+ const raw = String(value || "selected").trim().toLowerCase();
407
+ if (["none", "false", "off", "0"].includes(raw)) return [];
408
+ if (raw === "selected") return [manifest.selectedTool];
409
+ if (raw === "all") return Object.keys(manifest.toolArtifacts);
410
+ return raw.split(",").map((item) => normalizeToolId(item.trim())).filter(Boolean);
411
+ }
412
+
413
+ function installCodexIntegration(manifest, homeDir) {
414
+ const artifact = manifest.toolArtifacts.codex;
415
+ const configPath = path.join(homeDir, ".codex", "config.toml");
416
+ const hooksPath = path.join(homeDir, ".codex", "hooks.json");
417
+ const config = readTextConfig(configPath);
418
+ const nextConfig = appendTomlSections(removeTomlSections(config, [
419
+ `mcp_servers.${artifact.profile.mcpServerName}`,
420
+ `mcp_servers.${artifact.profile.mcpServerName}.env`
421
+ ]), artifact.config);
422
+ writeTextConfig(configPath, nextConfig);
423
+
424
+ const hooks = readJsonConfig(hooksPath, { hooks: {} });
425
+ hooks.hooks = hooks.hooks && typeof hooks.hooks === "object" ? hooks.hooks : {};
426
+ const command = shellQuote(artifact.hookFile);
427
+ for (const eventName of ["SessionStart", "PreToolUse", "PostToolUse", "UserPromptSubmit", "Stop"]) {
428
+ const entry = {
429
+ hooks: [{ type: "command", command, ...(eventName === "Stop" ? { timeout: 30 } : {}) }]
430
+ };
431
+ if (eventName === "SessionStart") entry.matcher = "startup|resume";
432
+ hooks.hooks[eventName] = ensureHookEntry(removeWorkLensHookEntries(hooks.hooks[eventName]), entry);
433
+ }
434
+ writeJsonConfig(hooksPath, hooks);
435
+ enableExistingCodexHookState(configPath, hooksPath, ["SessionStart", "PreToolUse", "PostToolUse", "UserPromptSubmit", "Stop"]);
436
+ return { configPath, hooksPath };
437
+ }
438
+
439
+ function installClaudeCodeIntegration(manifest, homeDir, { useToolCli = true } = {}) {
440
+ const artifact = manifest.toolArtifacts["claude-code"];
441
+ const globalConfigPath = path.join(homeDir, ".claude.json");
442
+ const settingsPath = path.join(homeDir, ".claude", "settings.json");
443
+ const serverConfig = {
444
+ type: "stdio",
445
+ command: artifact.mcp.command,
446
+ args: artifact.mcp.args,
447
+ env: artifact.mcp.env
448
+ };
449
+ const mcpConfig = readJsonConfig(globalConfigPath, {});
450
+ mcpConfig.mcpServers = mcpConfig.mcpServers && typeof mcpConfig.mcpServers === "object" ? mcpConfig.mcpServers : {};
451
+ mcpConfig.mcpServers[artifact.profile.mcpServerName] = serverConfig;
452
+ writeJsonConfig(globalConfigPath, mcpConfig);
453
+
454
+ const settings = readJsonConfig(settingsPath, {});
455
+ const hooksSettings = JSON.parse(artifact.extraFiles.hooksSettings.content);
456
+ settings.hooks = mergeHookSettings(settings.hooks, hooksSettings.hooks);
457
+ writeJsonConfig(settingsPath, settings);
458
+ const cli = useToolCli ? installClaudeMcpWithCli(artifact.profile.mcpServerName, serverConfig, homeDir) : { ok: true, skipped: true };
459
+ return { globalConfigPath, settingsPath, cli };
460
+ }
461
+
462
+ function installOpenCodeIntegration(manifest, homeDir) {
463
+ const artifact = manifest.toolArtifacts.opencode;
464
+ const configPath = path.join(homeDir, ".config", "opencode", "opencode.json");
465
+ const pluginPath = path.join(homeDir, ".config", "opencode", "plugins", "ai-worklens.js");
466
+ const config = readJsonConfig(configPath, { $schema: "https://opencode.ai/config.json" });
467
+ const generated = JSON.parse(artifact.config);
468
+ config.$schema = config.$schema || generated.$schema;
469
+ config.mcp = config.mcp && typeof config.mcp === "object" ? config.mcp : {};
470
+ config.mcp[artifact.profile.mcpServerName] = generated.mcp[artifact.profile.mcpServerName];
471
+ writeJsonConfig(configPath, config);
472
+ writeTextConfig(pluginPath, `${artifact.extraFiles.plugin.content}\n`, 0o600);
473
+ return { configPath, pluginPath };
474
+ }
475
+
476
+ function mergeHookSettings(current, incoming) {
477
+ const hooks = current && typeof current === "object" ? { ...current } : {};
478
+ for (const [eventName, entries] of Object.entries(incoming || {})) {
479
+ const base = removeWorkLensHookEntries(hooks[eventName]);
480
+ hooks[eventName] = (entries || []).reduce((list, entry) => ensureHookEntry(list, entry), base);
481
+ }
482
+ return hooks;
483
+ }
484
+
485
+ function removeWorkLensHookEntries(current) {
486
+ const list = Array.isArray(current) ? current : [];
487
+ return list.filter((entry) => !hookEntryContainsWorkLens(entry));
488
+ }
489
+
490
+ function hookEntryContainsWorkLens(entry = {}) {
491
+ return (entry.hooks || []).some((hook) => workLensHookCommandPattern().test(String(hook.command || "")));
492
+ }
493
+
494
+ function workLensHookCommandPattern() {
495
+ return /(?:ai[-_]?worklens|worklens|silent-ai-observatory|hook-adapter\.mjs|codex-hook\.sh|claude-code-hook\.sh|opencode-hook\.sh)/i;
496
+ }
497
+
498
+ function ensureHookEntry(current, entry) {
499
+ const list = Array.isArray(current) ? current : [];
500
+ const key = JSON.stringify(entry);
501
+ if (list.some((item) => JSON.stringify(item) === key)) return list;
502
+ return [...list, entry];
503
+ }
504
+
505
+ function commandLine(command, args) {
506
+ return [command, ...args].map(shellQuote).join(" ");
507
+ }
508
+
509
+ function readTextConfig(filePath) {
510
+ try {
511
+ return fs.existsSync(filePath) ? fs.readFileSync(filePath, "utf8") : "";
512
+ } catch {
513
+ return "";
514
+ }
515
+ }
516
+
517
+ function readJsonConfig(filePath, fallback) {
518
+ if (!fs.existsSync(filePath)) return structuredClone(fallback);
519
+ const raw = fs.readFileSync(filePath, "utf8").trim();
520
+ if (!raw) return structuredClone(fallback);
521
+ return JSON.parse(raw);
522
+ }
523
+
524
+ function writeTextConfig(filePath, content, mode = 0o600) {
525
+ fs.mkdirSync(path.dirname(filePath), { recursive: true, mode: 0o700 });
526
+ const tempFile = `${filePath}.tmp-${process.pid}`;
527
+ fs.writeFileSync(tempFile, content, { mode });
528
+ fs.renameSync(tempFile, filePath);
529
+ fs.chmodSync(filePath, mode);
530
+ }
531
+
532
+ function writeJsonConfig(filePath, value) {
533
+ writeTextConfig(filePath, `${JSON.stringify(value, null, 2)}\n`, 0o600);
534
+ }
535
+
536
+ function enableExistingCodexHookState(configPath, hooksPath, eventNames) {
537
+ if (!fs.existsSync(configPath)) return;
538
+ const content = fs.readFileSync(configPath, "utf8");
539
+ const next = enableHookStateSections(content, hooksPath, eventNames);
540
+ if (next !== content) writeTextConfig(configPath, next);
541
+ }
542
+
543
+ function enableHookStateSections(content, hooksPath, eventNames) {
544
+ const hookEvents = new Set(eventNames.map(codexHookStateEventName));
545
+ const lines = String(content || "").split(/\n/);
546
+ let inWorkLensHookState = false;
547
+ let changed = false;
548
+ const output = lines.map((line) => {
549
+ const section = line.match(/^\[hooks\.state\."((?:\\.|[^"\\])*)"\]\s*$/);
550
+ if (section) {
551
+ const stateKey = parseTomlQuotedString(section[1]);
552
+ inWorkLensHookState = isWorkLensHookStateKey(stateKey, hooksPath, hookEvents);
553
+ return line;
554
+ }
555
+ if (/^\[[^\]]+\]\s*$/.test(line)) {
556
+ inWorkLensHookState = false;
557
+ return line;
558
+ }
559
+ if (inWorkLensHookState && /^enabled\s*=\s*false\s*$/.test(line)) {
560
+ changed = true;
561
+ return "enabled = true";
562
+ }
563
+ return line;
564
+ });
565
+ return changed ? output.join("\n") : content;
566
+ }
567
+
568
+ function parseTomlQuotedString(value) {
569
+ try {
570
+ return JSON.parse(`"${value}"`);
571
+ } catch {
572
+ return String(value || "");
573
+ }
574
+ }
575
+
576
+ function isWorkLensHookStateKey(stateKey, hooksPath, hookEvents) {
577
+ const prefix = `${hooksPath}:`;
578
+ if (!String(stateKey || "").startsWith(prefix)) return false;
579
+ const rest = String(stateKey).slice(prefix.length);
580
+ const eventName = rest.split(":")[0];
581
+ return hookEvents.has(eventName);
582
+ }
583
+
584
+ function codexHookStateEventName(eventName) {
585
+ return String(eventName || "")
586
+ .replace(/([a-z0-9])([A-Z])/g, "$1_$2")
587
+ .replace(/[^a-zA-Z0-9]+/g, "_")
588
+ .replace(/^_+|_+$/g, "")
589
+ .toLowerCase();
590
+ }
591
+
592
+ function installClaudeMcpWithCli(name, serverConfig, homeDir) {
593
+ const result = spawnSync("claude", [
594
+ "mcp",
595
+ "add-json",
596
+ "-s",
597
+ "user",
598
+ name,
599
+ JSON.stringify(serverConfig)
600
+ ], {
601
+ encoding: "utf8",
602
+ timeout: 10000,
603
+ env: { ...process.env, HOME: homeDir }
604
+ });
605
+ if (result.error) return { ok: false, error: result.error.message };
606
+ if (result.status !== 0) {
607
+ const stderr = String(result.stderr || "").trim();
608
+ const stdout = String(result.stdout || "").trim();
609
+ return { ok: false, status: result.status, error: stderr || stdout || "claude mcp add-json failed" };
610
+ }
611
+ return { ok: true };
612
+ }
613
+
614
+ function removeTomlSections(content, sectionNames) {
615
+ let output = String(content || "");
616
+ for (const sectionName of sectionNames) {
617
+ const escaped = sectionName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
618
+ output = output.replace(new RegExp(`(^|\\n)\\[${escaped}\\]\\n[\\s\\S]*?(?=\\n\\[[^\\]]+\\]|$)`, "g"), "\n");
619
+ }
620
+ return output.trimEnd();
621
+ }
622
+
623
+ function appendTomlSections(content, sections) {
624
+ const prefix = String(content || "").trimEnd();
625
+ return `${prefix ? `${prefix}\n\n` : ""}${sections.trim()}\n`;
626
+ }
627
+
350
628
  function buildOpenCodePlugin(command, baseArgs) {
351
629
  const events = [
352
630
  "command.executed",
@@ -580,6 +858,60 @@ function buildReadme(manifest) {
580
858
  ].join("\n") + "\n";
581
859
  }
582
860
 
861
+ function runPostInstall(result, args = {}) {
862
+ if (args["post-install"] === "false") return { ok: true, skipped: true };
863
+ const checkin = spawnSync(result.generatedFiles.checkinScript, [], {
864
+ encoding: "utf8",
865
+ timeout: Number(args["post-install-timeout-ms"] || 15000)
866
+ });
867
+ const register = spawnSync(result.generatedFiles.registerAutoUpdateScript, [], {
868
+ encoding: "utf8",
869
+ timeout: Number(args["post-install-timeout-ms"] || 15000)
870
+ });
871
+ return {
872
+ ok: checkin.status === 0,
873
+ registered: register.status === 0,
874
+ checkinStatus: checkin.status,
875
+ registerStatus: register.status,
876
+ error: checkin.error?.message || ""
877
+ };
878
+ }
879
+
880
+ function formatInstallSuccess(result, args, postInstall = {}) {
881
+ const tool = result.manifest?.selectedTool || args.tool || "codex";
882
+ const employeeAlias = args["employee-pinyin"] || args["employee-id"] || "未填写";
883
+ const integrationLine = result.integrations?.skipped
884
+ ? "工具配置: 已跳过"
885
+ : result.integrations?.ok
886
+ ? `工具配置: 已写入 ${integrationSummary(result.integrations.items)}`
887
+ : `工具配置: 部分失败 ${integrationSummary(result.integrations.items)}`;
888
+ const checkinLine = postInstall.skipped
889
+ ? "中心端登记: 已跳过"
890
+ : postInstall.ok
891
+ ? "中心端登记: 已完成"
892
+ : "中心端登记: 未完成,后台任务会继续重试";
893
+ return [
894
+ "AI WorkLens 安装成功",
895
+ `工具: ${tool} / 注册名: ${employeeAlias}`,
896
+ `配置目录: ${result.targetDir}`,
897
+ integrationLine,
898
+ checkinLine
899
+ ].join("\n") + "\n";
900
+ }
901
+
902
+ function integrationSummary(items = []) {
903
+ if (!items.length) return "";
904
+ const labels = { codex: "Codex", "claude-code": "Claude Code", opencode: "OpenCode" };
905
+ return items.map((item) => `${labels[item.tool] || item.tool}${item.ok ? "" : "失败"}`).join(" / ");
906
+ }
907
+
908
+ function formatInstallFailure(error) {
909
+ return [
910
+ "AI WorkLens 安装失败",
911
+ `原因: ${error.message || "未知错误"}`
912
+ ].join("\n") + "\n";
913
+ }
914
+
583
915
  function isMainModule() {
584
916
  if (!process.argv[1]) return false;
585
917
  try {
@@ -591,21 +923,33 @@ function isMainModule() {
591
923
 
592
924
  if (isMainModule()) {
593
925
  const args = parseArgs(process.argv.slice(2));
594
- const result = installClient({
595
- targetDir: args["target-dir"],
596
- serverUrl: args["server-url"],
597
- collectorToken: args["collector-token"],
598
- employeeId: args["employee-id"],
599
- employeeName: args["employee-name"],
600
- employeePinyin: args["employee-pinyin"],
601
- department: args.department,
602
- role: args.role,
603
- clientId: args["client-id"],
604
- tool: args.tool,
605
- modelProvider: args["model-provider"],
606
- modelName: args["model-name"],
607
- modelVersion: args["model-version"],
608
- modelFamily: args["model-family"]
609
- });
610
- process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
926
+ try {
927
+ const result = installClient({
928
+ targetDir: args["target-dir"],
929
+ serverUrl: args["server-url"],
930
+ collectorToken: args["collector-token"],
931
+ employeeId: args["employee-id"],
932
+ employeeName: args["employee-name"],
933
+ employeePinyin: args["employee-pinyin"],
934
+ department: args.department,
935
+ role: args.role,
936
+ clientId: args["client-id"],
937
+ tool: args.tool,
938
+ homeDir: args["home-dir"],
939
+ integrations: args.integrations,
940
+ writeToolConfigs: args["write-tool-configs"] !== "false" && args["write-tool-configs"] !== "0",
941
+ useToolCli: args["use-tool-cli"] !== "false" && args["use-tool-cli"] !== "0",
942
+ modelProvider: args["model-provider"],
943
+ modelName: args["model-name"],
944
+ modelVersion: args["model-version"],
945
+ modelFamily: args["model-family"]
946
+ });
947
+ const postInstall = runPostInstall(result, args);
948
+ if (args.json) process.stdout.write(`${JSON.stringify({ ...result, postInstall }, null, 2)}\n`);
949
+ else process.stdout.write(formatInstallSuccess(result, args, postInstall));
950
+ } catch (error) {
951
+ if (args.json) process.stderr.write(`${JSON.stringify({ ok: false, error: error.message }, null, 2)}\n`);
952
+ else process.stderr.write(formatInstallFailure(error));
953
+ process.exitCode = 1;
954
+ }
611
955
  }
@@ -1,10 +1,10 @@
1
- export const CLIENT_AGENT_VERSION = "0.1.0";
1
+ export const CLIENT_AGENT_VERSION = "0.1.4";
2
2
 
3
3
  export const DEFAULT_CLIENT_UPDATE_POLICY = {
4
4
  enabled: true,
5
5
  autoUpdate: true,
6
6
  clientVersion: CLIENT_AGENT_VERSION,
7
- artifactRevision: "0.1.0",
7
+ artifactRevision: CLIENT_AGENT_VERSION,
8
8
  checkIntervalMinutes: 360,
9
9
  registerBackgroundTask: true,
10
10
  refreshArtifacts: true
@@ -31,6 +31,35 @@ export function eventTypeLabel(type) {
31
31
  return eventTypeDefinition(type)?.label || "工具调用";
32
32
  }
33
33
 
34
+ const FALLBACK_SECONDS_BY_EVENT_TYPE = {
35
+ session_start: 10,
36
+ session_end: 10,
37
+ user_prompt: 30,
38
+ assistant_response: 45,
39
+ planning: 45,
40
+ research: 45,
41
+ implementation: 45,
42
+ document: 45,
43
+ tool_call: 20,
44
+ tool_result: 20,
45
+ mcp_tool_call: 20,
46
+ skill_use: 30,
47
+ plugin_use: 30,
48
+ command: 20,
49
+ mode_change: 10,
50
+ permission: 10,
51
+ verification: 30,
52
+ retry: 30,
53
+ rework: 30,
54
+ error: 10
55
+ };
56
+
57
+ function eventDurationSeconds(event = {}) {
58
+ const explicit = Number(event.metrics?.durationSeconds ?? event.durationSeconds ?? 0);
59
+ if (Number.isFinite(explicit) && explicit > 0) return explicit;
60
+ return FALLBACK_SECONDS_BY_EVENT_TYPE[event.eventType] || 20;
61
+ }
62
+
34
63
  export function eventTypeDistribution(events = [], { includeZero = false, limit = EVENT_TYPE_DEFINITIONS.length } = {}) {
35
64
  const buckets = new Map(EVENT_TYPE_DEFINITIONS.map((item) => [
36
65
  item.type,
@@ -41,14 +70,14 @@ export function eventTypeDistribution(events = [], { includeZero = false, limit
41
70
  description: item.description,
42
71
  order: item.order,
43
72
  events: 0,
44
- minutes: 0
73
+ seconds: 0
45
74
  }
46
75
  ]));
47
76
  for (const event of events) {
48
77
  const type = EVENT_TYPES.has(event.eventType) ? event.eventType : "tool_call";
49
78
  const bucket = buckets.get(type);
50
79
  bucket.events += 1;
51
- bucket.minutes += Math.round(Number(event.metrics?.durationSeconds || 0) / 60);
80
+ bucket.seconds += eventDurationSeconds(event);
52
81
  }
53
82
  return [...buckets.values()]
54
83
  .filter((item) => includeZero || item.events > 0)
@@ -57,5 +86,8 @@ export function eventTypeDistribution(events = [], { includeZero = false, limit
57
86
  return a.order - b.order;
58
87
  })
59
88
  .slice(0, limit)
60
- .map(({ order, ...item }) => item);
89
+ .map(({ order, seconds, ...item }) => ({
90
+ ...item,
91
+ minutes: item.events ? Math.max(1, Math.round(seconds / 60)) : 0
92
+ }));
61
93
  }
@@ -44,7 +44,7 @@ export function usage() {
44
44
  "Notes:",
45
45
  " - Public npm scoped packages require --access public.",
46
46
  " - For @scope/name, the npm account token must have permission for that scope.",
47
- " - Token can be passed through NPM_TOKEN or --token; it is written only to a temporary .npmrc."
47
+ " - Token can be passed through NPM_TOKEN or --token; it is written only to a temporary .npmrc and deleted after publish."
48
48
  ].join("\n");
49
49
  }
50
50
 
@@ -109,7 +109,7 @@ export function buildNpmPublishArgs(stagedDir, options = {}) {
109
109
  "--access",
110
110
  options.access || "public",
111
111
  "--tag",
112
- options.tag || "latest",
112
+ normalizeNpmPublishTag(options.tag),
113
113
  "--cache",
114
114
  path.join(stagedDir, ".npm-cache"),
115
115
  ...(options.dryRun ? ["--dry-run"] : []),
@@ -118,17 +118,73 @@ export function buildNpmPublishArgs(stagedDir, options = {}) {
118
118
  ];
119
119
  }
120
120
 
121
+ export function normalizeNpmPublishTag(value = "latest") {
122
+ const tag = String(value || "latest").trim() || "latest";
123
+ if (/^(?:v)?\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/.test(tag)) return "latest";
124
+ if (/^[~^<>=*]*\d/.test(tag)) return "latest";
125
+ return tag;
126
+ }
127
+
121
128
  export function publishToNpm(options = {}) {
122
129
  if (!options.dryRun && !options.token) {
123
130
  throw new Error("NPM_TOKEN is required for real public npm publish; use --dry-run to preview without token");
124
131
  }
125
- const prepared = preparePublishDirectory(options);
126
- const args = buildNpmPublishArgs(prepared.tempDir, options);
127
- const result = spawnSync("npm", args, { stdio: "inherit" });
128
- if (result.status !== 0) {
129
- throw new Error(`npm publish failed with exit code ${result.status}`);
132
+ let prepared = null;
133
+ try {
134
+ prepared = preparePublishDirectory(options);
135
+ const args = buildNpmPublishArgs(prepared.tempDir, options);
136
+ const result = spawnSync("npm", args, { encoding: "utf8" });
137
+ if (result.error) {
138
+ throw result.error;
139
+ }
140
+ if (result.status !== 0) {
141
+ throw new Error(formatNpmPublishFailure(result));
142
+ }
143
+ return {
144
+ ...prepared,
145
+ ok: true,
146
+ registry: options.registry || PUBLIC_NPM_REGISTRY,
147
+ tag: normalizeNpmPublishTag(options.tag),
148
+ dryRun: options.dryRun === true,
149
+ status: result.status,
150
+ stdout: sanitizeNpmOutput(result.stdout || ""),
151
+ stderr: sanitizeNpmOutput(result.stderr || "")
152
+ };
153
+ } finally {
154
+ if (prepared?.tempDir && !options.keepStagedDir) {
155
+ fs.rmSync(prepared.tempDir, { recursive: true, force: true });
156
+ }
130
157
  }
131
- return prepared;
158
+ }
159
+
160
+ export function formatNpmPublishFailure(result = {}) {
161
+ const status = result.status ?? result.signal ?? "unknown";
162
+ const output = summarizeNpmOutput([result.stderr, result.stdout].filter(Boolean).join("\n"));
163
+ const suffix = output ? `: ${output}` : "";
164
+ return `npm publish failed with exit code ${status}${suffix}`;
165
+ }
166
+
167
+ function sanitizeNpmOutput(value = "") {
168
+ return String(value || "")
169
+ .replace(/(_authToken=)[^\s]+/gi, "$1<redacted>")
170
+ .replace(/(\/\/[^:\s]+\/?:_authToken=)[^\s]+/gi, "$1<redacted>")
171
+ .trim()
172
+ .slice(0, 4000);
173
+ }
174
+
175
+ function summarizeNpmOutput(value = "") {
176
+ const lines = sanitizeNpmOutput(value)
177
+ .split(/\r?\n/)
178
+ .map((line) => line.trim())
179
+ .filter(Boolean)
180
+ .filter((line) => !/complete log of this run/i.test(line));
181
+ const errorLines = lines.filter((line) => /^npm\s+error\b/i.test(line));
182
+ const selected = errorLines.length
183
+ ? errorLines
184
+ : lines.filter((line) => !/^npm\s+notice\b/i.test(line));
185
+ return (selected.length ? selected : lines.slice(-8))
186
+ .join("\n")
187
+ .slice(0, 1600);
132
188
  }
133
189
 
134
190
  if (fileURLToPath(import.meta.url) === path.resolve(process.argv[1] || "")) {
package/src/uploader.mjs CHANGED
@@ -1,3 +1,4 @@
1
+ import fs from "node:fs";
1
2
  import path from "node:path";
2
3
  import { EventQueue } from "./queue.mjs";
3
4
  import { writeClientConfig } from "./config.mjs";
@@ -303,6 +304,7 @@ export class ClientAgent {
303
304
  clientId: this.config.clientId,
304
305
  clientVersion: policy.clientVersion,
305
306
  tool: this.config.tool,
307
+ homeDir: this.config.homeDir,
306
308
  modelProvider: this.config.model?.provider || "",
307
309
  modelName: this.config.model?.name || "",
308
310
  modelVersion: this.config.model?.version || "",
@@ -396,6 +398,20 @@ export class ClientAgent {
396
398
  this.config.update?.enabled !== false && this.config.update?.autoUpdate !== false,
397
399
  "中心端已关闭客户端静默更新"
398
400
  ));
401
+ if (this.config.tool === "codex") {
402
+ const codexHooks = codexHookDiagnostics(this.config);
403
+ checks.push(check(
404
+ "codex_hooks_configured",
405
+ codexHooks.configured,
406
+ "Codex hook 未配置到 ~/.codex/hooks.json,无法静默采集提示词、工具、MCP 和模式事件"
407
+ ));
408
+ checks.push(check(
409
+ "codex_hooks_enabled",
410
+ codexHooks.enabled,
411
+ codexHooks.message
412
+ ));
413
+ status.codexHooks = codexHooks;
414
+ }
399
415
 
400
416
  const issues = checks.filter((item) => !item.ok).map((item) => item.message);
401
417
  return {
@@ -413,6 +429,92 @@ function check(name, ok, message = "") {
413
429
  return { name, ok: Boolean(ok), message: ok ? "" : message };
414
430
  }
415
431
 
432
+ function codexHookDiagnostics(config) {
433
+ const homeDir = config.homeDir || "";
434
+ const hooksPath = path.join(homeDir, ".codex", "hooks.json");
435
+ const configPath = path.join(homeDir, ".codex", "config.toml");
436
+ const hookEvents = new Set(["session_start", "pre_tool_use", "post_tool_use", "user_prompt_submit", "stop"]);
437
+ const configuredEvents = codexConfiguredHookEvents(hooksPath);
438
+ const disabledStates = codexDisabledHookStates(configPath, hooksPath, hookEvents);
439
+ return {
440
+ hooksPath,
441
+ configPath,
442
+ configured: configuredEvents.length > 0,
443
+ configuredEvents,
444
+ enabled: disabledStates.length === 0,
445
+ disabledStates,
446
+ message: disabledStates.length
447
+ ? `Codex hook 已配置但未启用:${disabledStates.join("、")},请重新运行安装命令或执行 worklens-agent-install 修复。`
448
+ : ""
449
+ };
450
+ }
451
+
452
+ function codexConfiguredHookEvents(hooksPath) {
453
+ try {
454
+ if (!fs.existsSync(hooksPath)) return [];
455
+ const hooks = JSON.parse(fs.readFileSync(hooksPath, "utf8"));
456
+ return Object.entries(hooks.hooks || {})
457
+ .filter(([, entries]) => entriesContainWorkLensHook(entries))
458
+ .map(([eventName]) => eventName);
459
+ } catch {
460
+ return [];
461
+ }
462
+ }
463
+
464
+ function entriesContainWorkLensHook(entries) {
465
+ return (Array.isArray(entries) ? entries : []).some((entry) => {
466
+ return (entry.hooks || []).some((hook) => workLensHookPattern().test(String(hook.command || "")));
467
+ });
468
+ }
469
+
470
+ function workLensHookPattern() {
471
+ return /(?:ai[-_]?worklens|worklens|silent-ai-observatory|hook-adapter\.mjs|codex-hook\.sh)/i;
472
+ }
473
+
474
+ function codexDisabledHookStates(configPath, hooksPath, hookEvents) {
475
+ try {
476
+ if (!fs.existsSync(configPath)) return [];
477
+ const lines = fs.readFileSync(configPath, "utf8").split(/\n/);
478
+ let current = "";
479
+ let currentMatches = false;
480
+ const disabled = [];
481
+ for (const line of lines) {
482
+ const section = line.match(/^\[hooks\.state\."((?:\\.|[^"\\])*)"\]\s*$/);
483
+ if (section) {
484
+ current = parseTomlQuotedString(section[1]);
485
+ currentMatches = isCodexHookStateKey(current, hooksPath, hookEvents);
486
+ continue;
487
+ }
488
+ if (/^\[[^\]]+\]\s*$/.test(line)) {
489
+ current = "";
490
+ currentMatches = false;
491
+ continue;
492
+ }
493
+ if (currentMatches && /^enabled\s*=\s*false\s*$/.test(line)) {
494
+ disabled.push(current.slice(`${hooksPath}:`.length).split(":")[0]);
495
+ }
496
+ }
497
+ return [...new Set(disabled)];
498
+ } catch {
499
+ return [];
500
+ }
501
+ }
502
+
503
+ function parseTomlQuotedString(value) {
504
+ try {
505
+ return JSON.parse(`"${value}"`);
506
+ } catch {
507
+ return String(value || "");
508
+ }
509
+ }
510
+
511
+ function isCodexHookStateKey(stateKey, hooksPath, hookEvents) {
512
+ const prefix = `${hooksPath}:`;
513
+ if (!String(stateKey || "").startsWith(prefix)) return false;
514
+ const eventName = String(stateKey).slice(prefix.length).split(":")[0];
515
+ return hookEvents.has(eventName);
516
+ }
517
+
416
518
  function clampAccepted(value, batchLength) {
417
519
  const accepted = Number(value);
418
520
  if (!Number.isFinite(accepted)) return batchLength;