ai-worklens-agent 0.1.5 → 0.1.7

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
@@ -35,13 +35,23 @@ npm run mcp
35
35
 
36
36
  如果管理员已经把员工端发布到 npm 或企业私有 npm 源,可以使用 `npx` 首次安装:
37
37
 
38
+ Windows 命令提示符或 PowerShell 使用一行命令,不要混用 macOS/Linux 的反斜杠换行:
39
+
40
+ ```bat
41
+ npx -y --loglevel=error --registry https://registry.npmjs.org -p ai-worklens-agent@0.1.7 worklens-agent-install --server-url http://192.168.1.241:8797 --tool codex --employee-pinyin zhangpeng
42
+ ```
43
+
44
+ macOS / Linux:
45
+
38
46
  ```bash
39
- NPM_CONFIG_UPDATE_NOTIFIER=false npx -y --loglevel=error -p ai-worklens-agent@0.1.5 worklens-agent-install \
47
+ NPM_CONFIG_UPDATE_NOTIFIER=false npx -y --loglevel=error -p ai-worklens-agent@0.1.7 worklens-agent-install \
40
48
  --server-url http://192.168.1.241:8797 \
41
49
  --tool codex \
42
50
  --employee-pinyin zhangsan
43
51
  ```
44
52
 
53
+ 安装完成后请重新打开一个新的 Codex 会话。第一次触发 Hook 时,如果 Codex 提示信任或允许 Hook,请选择信任/允许;否则 `codex-hook.cmd` 手动验证可以成功,但真实 Codex 会话不会触发采集。
54
+
45
55
  发布到公共 npm 源需要 npm 账号 token。Scoped 包名需要账号拥有对应 scope。
46
56
 
47
57
  ```bash
@@ -60,7 +70,7 @@ NPM_TOKEN=<npm_token> npm run client:npm:publish -- \
60
70
  如果管理员在官网发布了直链安装包,可以下载安装包后执行包内安装脚本:
61
71
 
62
72
  ```bash
63
- curl -fL http://192.168.1.241:8797/site/downloads/ai-worklens-codex-0.1.5.sh \
73
+ curl -fL http://192.168.1.241:8797/site/downloads/ai-worklens-codex-0.1.7.sh \
64
74
  -o ai-worklens-install.sh
65
75
  chmod +x ai-worklens-install.sh
66
76
  ./ai-worklens-install.sh zhangsan
@@ -83,6 +93,14 @@ npm run agent -- event \
83
93
  echo '{"event":"plugin_use","pluginName":"Spreadsheets","message":"整理报价清单"}' | npm run hook:codex
84
94
  ```
85
95
 
96
+ Windows 命令提示符验证 Codex hook 是否真实可执行:
97
+
98
+ ```bat
99
+ echo {"hook_event_name":"UserPromptSubmit","prompt":"worklens windows smoke","session_id":"manual-smoke"} | "%USERPROFILE%\.ai-worklens\codex-hook.cmd"
100
+ ```
101
+
102
+ 如果这条命令成功上报,但真实 Codex 会话仍没有数据,优先检查是否已经在 Codex 中信任/允许了 Hook,并确认使用的是安装后新打开的 Codex 会话。
103
+
86
104
  模拟 Claude Code 工具 hook 输入:
87
105
 
88
106
  ```bash
@@ -154,11 +172,11 @@ WORKLENS_QUEUE_FILE=/path/to/queue.json
154
172
 
155
173
  - `client.json`:中心端地址、员工身份、上传配置。
156
174
  - `install-manifest.json`:MCP server、Hook adapter、CLI event entrypoint。
157
- - `codex-mcp-snippet.toml` / `codex-hook.sh`:Codex 配置片段。
158
- - `claude-code-mcp.json` / `claude-code-hook.sh` / `claude-code-hooks-settings.json`:Claude Code 配置片段和官方 hooks events 覆盖。
159
- - `opencode-mcp.jsonc` / `opencode-hook.sh` / `opencode-ai-worklens-plugin.js`:OpenCode MCP 配置和本地插件事件覆盖。
160
- - `worklens-checkin.sh`:同步远程规则、补传离线队列并上报健康状态。
161
- - `worklens-auto-update.sh`:补传离线队列,拉取中心端静默更新策略,自动重写本地采集组件。
175
+ - `codex-mcp-snippet.toml` / `codex-hook.sh` / `codex-hook.cmd`:Codex 配置片段。
176
+ - `claude-code-mcp.json` / `claude-code-hook.sh` / `claude-code-hook.cmd` / `claude-code-hooks-settings.json`:Claude Code 配置片段和官方 hooks events 覆盖。
177
+ - `opencode-mcp.jsonc` / `opencode-hook.sh` / `opencode-hook.cmd` / `opencode-ai-worklens-plugin.js`:OpenCode MCP 配置和本地插件事件覆盖。
178
+ - `worklens-checkin.sh` / `worklens-checkin.cmd`:同步远程规则、补传离线队列并上报健康状态。
179
+ - `worklens-auto-update.sh` / `worklens-auto-update.cmd`:补传离线队列,拉取中心端静默更新策略,自动重写本地采集组件。
162
180
  - `worklens-register-autoupdate.sh`:在 macOS 用户级 LaunchAgent 注册后台巡检任务。
163
181
  - `worklens-unregister-autoupdate.sh`:移除后台巡检任务。
164
182
  - `worklens-install-or-update.sh`:同步配置、静默更新、注册后台任务并自检。
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ai-worklens-agent",
3
- "version": "0.1.5",
3
+ "version": "0.1.7",
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
@@ -121,6 +121,14 @@ export function defaultQueueFile(configFile = defaultConfigFile()) {
121
121
  return envValue(process.env, "WORKLENS_QUEUE_FILE") || path.join(path.dirname(configFile), "queue.json");
122
122
  }
123
123
 
124
+ export function defaultStateFile(configFile = defaultConfigFile()) {
125
+ return envValue(process.env, "WORKLENS_STATE_FILE") || path.join(path.dirname(configFile), "state.json");
126
+ }
127
+
128
+ export function defaultLogDir(configFile = defaultConfigFile()) {
129
+ return envValue(process.env, "WORKLENS_LOG_DIR") || path.join(path.dirname(configFile), "logs");
130
+ }
131
+
124
132
  export function loadClientConfig(options = {}) {
125
133
  const configFile = options.configFile || defaultConfigFile();
126
134
  const fileConfig = readJson(configFile);
@@ -150,6 +158,8 @@ export function loadClientConfig(options = {}) {
150
158
  configFile,
151
159
  homeDir,
152
160
  queueFile: options.queueFile || defaultQueueFile(configFile),
161
+ stateFile: options.stateFile || defaultStateFile(configFile),
162
+ logDir: options.logDir || defaultLogDir(configFile),
153
163
  serverUrl,
154
164
  collectorToken: options.collectorToken || envValue(env, "WORKLENS_COLLECTOR_TOKEN") || fileConfig.collectorToken || "",
155
165
  clientId: options.clientId || envValue(env, "WORKLENS_CLIENT_ID") || fileConfig.clientId || defaultClientId(),
@@ -2,6 +2,8 @@
2
2
  import { loadClientConfig } from "./config.mjs";
3
3
  import { buildEvent } from "./event-builder.mjs";
4
4
  import { ClientAgent } from "./uploader.mjs";
5
+ import { appendRuntimeLog, eventLogEntry, updateRuntimeState } from "./runtime-state.mjs";
6
+ import fs from "node:fs";
5
7
  import path from "node:path";
6
8
  import { fileURLToPath } from "node:url";
7
9
 
@@ -152,6 +154,10 @@ function modeFromPayload(payload) {
152
154
  );
153
155
  }
154
156
 
157
+ function sessionIdFromPayload(payload) {
158
+ return firstText(payload.sessionId, payload.session_id, payload.localSessionId, payload.conversationId, payload.conversation_id);
159
+ }
160
+
155
161
  function skillNameFromPayload(payload) {
156
162
  return firstText(
157
163
  payload.skillName,
@@ -341,7 +347,51 @@ function fileRefsFromPayload(payload) {
341
347
  payload.toolInput?.filePath;
342
348
  }
343
349
 
344
- function pickSummary(payload, name, eventType) {
350
+ function compactSummaryText(value, options = {}) {
351
+ const text = String(value || "").replace(/\s+/g, " ").trim();
352
+ if (!text) return "";
353
+ if (options.allowRaw) return text;
354
+ const longPatch = /\*\*\*\s+Begin Patch|\bdiff --git\b|(?:^|\s)@@\s|^\s*[+-]{3}\s/m.test(String(value || ""));
355
+ if (longPatch && text.length > 240) {
356
+ return "围绕代码补丁或文件修改进行协作,包含较长 diff/patch 内容,已在采集端摘要化。";
357
+ }
358
+ const longCommand = options.kind && ["command", "verification", "tool_call", "tool_result"].includes(options.kind);
359
+ if (longCommand && text.length > 420) {
360
+ return `${text.slice(0, 220)} ...(较长工具输入已摘要)`;
361
+ }
362
+ if (text.length <= 420) return text;
363
+ return `${text.slice(0, 260)} ... ${text.slice(-100)}`;
364
+ }
365
+
366
+ function allowRawContent(config, eventType) {
367
+ if (eventType === "user_prompt") return config.collection?.storeRawPrompts === true;
368
+ if (eventType === "assistant_response") return config.collection?.storeFullReplies === true;
369
+ return false;
370
+ }
371
+
372
+ function isDiagnosticPayload(payload, args, name) {
373
+ const sessionId = sessionIdFromPayload(payload);
374
+ const text = [
375
+ payload.prompt,
376
+ payload.message,
377
+ payload.summary,
378
+ payload.content,
379
+ payload.title,
380
+ payload.input?.prompt,
381
+ payload.input?.message
382
+ ].map((item) => String(item || "")).join(" ");
383
+ return Boolean(
384
+ args.diagnostic === true ||
385
+ args.diagnostic === "true" ||
386
+ payload.diagnostic === true ||
387
+ payload.metadata?.diagnostic === true ||
388
+ /^smoke-|^hook-smoke$|^manual-smoke$/i.test(sessionId) ||
389
+ /worklens\s+(?:windows\s+)?smoke|hook\s+smoke|ai-worklens\s+smoke/i.test(text) ||
390
+ /hook-smoke/i.test(name)
391
+ );
392
+ }
393
+
394
+ function pickSummary(payload, name, eventType, config) {
345
395
  if (eventType === "mode_change") {
346
396
  const previousMode = previousModeFromPayload(payload);
347
397
  const nextMode = nextModeFromPayload(payload);
@@ -390,18 +440,22 @@ function pickSummary(payload, name, eventType) {
390
440
  payload.error ||
391
441
  payload.status ||
392
442
  `hook:${name}`;
393
- return { title, content };
443
+ return {
444
+ title: compactSummaryText(title, { allowRaw: true }),
445
+ content: compactSummaryText(content, { allowRaw: allowRawContent(config, eventType), kind: eventType })
446
+ };
394
447
  }
395
448
 
396
449
  export function normalizeHookPayload(payload, args, config) {
397
450
  const name = hookName(payload, args);
398
451
  const eventType = eventTypeFromHook(name, payload);
399
- const summary = pickSummary(payload, name, eventType);
452
+ const summary = pickSummary(payload, name, eventType, config);
400
453
  const metadata = payload.metadata && typeof payload.metadata === "object" ? payload.metadata : {};
401
454
  const skillName = skillNameFromPayload(payload);
402
455
  const pluginName = pluginNameFromPayload(payload);
403
456
  const mcpServer = mcpServerFromPayload(payload);
404
457
  const toolName = toolNameFromPayload(payload);
458
+ const diagnostic = isDiagnosticPayload(payload, args, name);
405
459
  return buildEvent({
406
460
  ...payload,
407
461
  eventType,
@@ -409,7 +463,7 @@ export function normalizeHookPayload(payload, args, config) {
409
463
  title: summary.title,
410
464
  content: summary.content,
411
465
  hookName: name,
412
- localSessionId: payload.sessionId || payload.session_id || payload.localSessionId,
466
+ localSessionId: sessionIdFromPayload(payload),
413
467
  turnIndex: payload.turnIndex || payload.turn_index,
414
468
  skillName,
415
469
  pluginName,
@@ -435,7 +489,8 @@ export function normalizeHookPayload(payload, args, config) {
435
489
  permissionDecision: permissionDecisionFromPayload(payload),
436
490
  exitCode: payload.exitCode ?? payload.exit_code,
437
491
  success: payload.success,
438
- status: statusFromPayload(payload)
492
+ status: statusFromPayload(payload),
493
+ diagnostic
439
494
  },
440
495
  process: {
441
496
  interactionType: eventType,
@@ -453,6 +508,108 @@ export function normalizeHookPayload(payload, args, config) {
453
508
  }, config);
454
509
  }
455
510
 
511
+ function textFromContent(value, depth = 0) {
512
+ if (depth > 5 || value === undefined || value === null) return "";
513
+ if (typeof value === "string") return value;
514
+ if (typeof value === "number" || typeof value === "boolean") return String(value);
515
+ if (Array.isArray(value)) return value.map((item) => textFromContent(item, depth + 1)).filter(Boolean).join(" ");
516
+ if (typeof value === "object") {
517
+ if (typeof value.text === "string") return value.text;
518
+ if (typeof value.content === "string") return value.content;
519
+ if (Array.isArray(value.content)) return textFromContent(value.content, depth + 1);
520
+ if (typeof value.message === "string") return value.message;
521
+ if (value.message && typeof value.message === "object") return textFromContent(value.message, depth + 1);
522
+ if (typeof value.summary === "string") return value.summary;
523
+ }
524
+ return "";
525
+ }
526
+
527
+ function transcriptAssistantSummary(filePath) {
528
+ if (!filePath) return null;
529
+ try {
530
+ if (!fs.existsSync(filePath)) return null;
531
+ const stats = fs.statSync(filePath);
532
+ const maxBytes = 1_000_000;
533
+ const raw = fs.readFileSync(filePath, "utf8");
534
+ const content = stats.size > maxBytes ? raw.slice(-maxBytes) : raw;
535
+ let lastAssistantText = "";
536
+ let turnIndex = 0;
537
+ for (const line of content.split(/\r?\n/)) {
538
+ if (!line.trim()) continue;
539
+ let item = null;
540
+ try {
541
+ item = JSON.parse(line);
542
+ } catch {
543
+ continue;
544
+ }
545
+ const message = item.message && typeof item.message === "object" ? item.message : item;
546
+ const role = String(message.role || item.role || item.type || "").toLowerCase();
547
+ const text = textFromContent(message.content || message.text || message.summary || message);
548
+ if (!text) continue;
549
+ if (role.includes("user")) turnIndex += 1;
550
+ if (role.includes("assistant")) lastAssistantText = text;
551
+ }
552
+ if (!lastAssistantText) return null;
553
+ return { text: lastAssistantText, turnIndex };
554
+ } catch {
555
+ return null;
556
+ }
557
+ }
558
+
559
+ function assistantSummaryFromPayload(payload) {
560
+ const direct = firstText(
561
+ payload.assistantSummary,
562
+ payload.assistant_summary,
563
+ payload.assistantResponse,
564
+ payload.assistant_response,
565
+ payload.responseSummary,
566
+ payload.response_summary,
567
+ payload.outputSummary,
568
+ payload.output_summary,
569
+ payload.output?.summary,
570
+ payload.output?.responseSummary,
571
+ payload.result?.summary
572
+ );
573
+ if (direct) return { text: direct, turnIndex: Number(payload.turnIndex || payload.turn_index || 0) };
574
+ return transcriptAssistantSummary(payload.transcript_path || payload.transcriptPath);
575
+ }
576
+
577
+ function assistantEventFromHook(payload, args, config, baseEvent) {
578
+ const name = hookName(payload, args);
579
+ if (baseEvent.eventType === "assistant_response") return null;
580
+ if (baseEvent.eventType !== "session_end") return null;
581
+ const summary = assistantSummaryFromPayload(payload);
582
+ if (!summary?.text) return null;
583
+ const diagnostic = Boolean(baseEvent.metadata?.diagnostic);
584
+ return buildEvent({
585
+ eventType: "assistant_response",
586
+ source: payload.source || `${config.tool}_hook`,
587
+ title: "AI 回复摘要",
588
+ content: compactSummaryText(summary.text, { allowRaw: allowRawContent(config, "assistant_response"), kind: "assistant_response" }),
589
+ localSessionId: sessionIdFromPayload(payload) || baseEvent.session?.localSessionId,
590
+ turnIndex: summary.turnIndex || payload.turnIndex || payload.turn_index,
591
+ durationSeconds: payload.assistantDurationSeconds || payload.assistant_duration_seconds || 0,
592
+ metadata: {
593
+ hookAdapter: "ai-worklens",
594
+ sourceTool: config.tool,
595
+ rawHookEvent: name,
596
+ hookEventName: payload.hook_event_name || payload.hookEventName || payload.event || payload.type || name,
597
+ derivedFrom: payload.transcript_path || payload.transcriptPath ? "transcript" : "hook_payload",
598
+ transcriptPath: payload.transcript_path || payload.transcriptPath,
599
+ diagnostic
600
+ },
601
+ process: {
602
+ interactionType: "assistant_response"
603
+ }
604
+ }, config);
605
+ }
606
+
607
+ export function normalizeHookEvents(payload, args, config) {
608
+ const baseEvent = normalizeHookPayload(payload, args, config);
609
+ const assistantEvent = assistantEventFromHook(payload, args, config, baseEvent);
610
+ return assistantEvent ? [baseEvent, assistantEvent] : [baseEvent];
611
+ }
612
+
456
613
  async function main() {
457
614
  const args = parseArgs(process.argv.slice(2));
458
615
  const payload = await readStdin();
@@ -475,10 +632,32 @@ async function main() {
475
632
  workspaceRoot: args.workspace,
476
633
  branch: args.branch
477
634
  });
478
- const event = normalizeHookPayload(payload, args, config);
635
+ const events = normalizeHookEvents(payload, args, config);
479
636
  const agent = new ClientAgent(config);
480
- const result = await agent.record(event);
481
- process.stdout.write(`${JSON.stringify({ ok: true, hook: event.metadata.rawHookEvent, eventType: event.eventType, eventId: event.eventId, result })}\n`);
637
+ const results = [];
638
+ for (const event of events) {
639
+ appendRuntimeLog(config, "hook", {
640
+ action: "received",
641
+ rawHookEvent: event.metadata.rawHookEvent,
642
+ ...eventLogEntry(event)
643
+ });
644
+ updateRuntimeState(config, {
645
+ lastHookAt: event.occurredAt,
646
+ lastHookEventType: event.eventType,
647
+ lastHookEventId: event.eventId,
648
+ lastHookRawEvent: event.metadata.rawHookEvent || "",
649
+ lastHookDiagnostic: Boolean(event.metadata.diagnostic)
650
+ });
651
+ results.push({ eventId: event.eventId, eventType: event.eventType, result: await agent.record(event) });
652
+ }
653
+ process.stdout.write(`${JSON.stringify({
654
+ ok: true,
655
+ hook: events[0]?.metadata.rawHookEvent,
656
+ eventType: events[0]?.eventType,
657
+ eventId: events[0]?.eventId,
658
+ eventIds: events.map((event) => event.eventId),
659
+ events: results
660
+ })}\n`);
482
661
  }
483
662
 
484
663
  if (fileURLToPath(import.meta.url) === path.resolve(process.argv[1] || "")) {
package/src/install.mjs CHANGED
@@ -38,6 +38,7 @@ function readPackageVersion() {
38
38
 
39
39
  export function buildInstallManifest(targetDir, options = {}) {
40
40
  const node = process.execPath;
41
+ const platform = normalizePlatform(options.platform);
41
42
  const selectedTool = normalizeToolId(options.tool || "codex");
42
43
  const profile = getToolProfile(selectedTool);
43
44
  const serverUrl = options.serverUrl || "http://127.0.0.1:8797";
@@ -50,6 +51,7 @@ export function buildInstallManifest(targetDir, options = {}) {
50
51
  const artifacts = buildToolArtifacts({
51
52
  targetDir,
52
53
  displayTargetDir,
54
+ platform,
53
55
  selectedTool,
54
56
  configFile,
55
57
  serverUrl,
@@ -61,6 +63,7 @@ export function buildInstallManifest(targetDir, options = {}) {
61
63
  const selectedArtifact = artifacts[selectedTool];
62
64
  return {
63
65
  version: 1,
66
+ platform,
64
67
  targetDir: displayTargetDir,
65
68
  selectedTool,
66
69
  supportedTools: listToolProfiles(),
@@ -93,6 +96,9 @@ export function buildInstallManifest(targetDir, options = {}) {
93
96
  autoUpdateScript: path.join(targetDir, "worklens-auto-update.sh"),
94
97
  registerAutoUpdateScript: path.join(targetDir, "worklens-register-autoupdate.sh"),
95
98
  unregisterAutoUpdateScript: path.join(targetDir, "worklens-unregister-autoupdate.sh"),
99
+ windowsCheckinScript: path.join(targetDir, "worklens-checkin.cmd"),
100
+ windowsSelfCheckScript: path.join(targetDir, "worklens-self-check.cmd"),
101
+ windowsAutoUpdateScript: path.join(targetDir, "worklens-auto-update.cmd"),
96
102
  windowsRegisterAutoUpdateScript: path.join(targetDir, "worklens-register-autoupdate.ps1"),
97
103
  installOrUpdateScript: path.join(targetDir, "worklens-install-or-update.sh"),
98
104
  readme: path.join(targetDir, "README.md")
@@ -112,6 +118,7 @@ export function buildInstallManifest(targetDir, options = {}) {
112
118
  }
113
119
 
114
120
  export function installClient(options = {}) {
121
+ const platform = normalizePlatform(options.platform);
115
122
  const targetDir = options.targetDir || path.join(os.homedir(), ".ai-worklens");
116
123
  const homeDir = options.homeDir || os.homedir();
117
124
  fs.mkdirSync(targetDir, { recursive: true, mode: 0o700 });
@@ -123,6 +130,9 @@ export function installClient(options = {}) {
123
130
  const autoUpdateScriptFile = path.join(targetDir, "worklens-auto-update.sh");
124
131
  const registerAutoUpdateScriptFile = path.join(targetDir, "worklens-register-autoupdate.sh");
125
132
  const unregisterAutoUpdateScriptFile = path.join(targetDir, "worklens-unregister-autoupdate.sh");
133
+ const windowsCheckinScriptFile = path.join(targetDir, "worklens-checkin.cmd");
134
+ const windowsSelfCheckScriptFile = path.join(targetDir, "worklens-self-check.cmd");
135
+ const windowsAutoUpdateScriptFile = path.join(targetDir, "worklens-auto-update.cmd");
126
136
  const windowsRegisterAutoUpdateScriptFile = path.join(targetDir, "worklens-register-autoupdate.ps1");
127
137
  const installOrUpdateScriptFile = path.join(targetDir, "worklens-install-or-update.sh");
128
138
  const readmeFile = path.join(targetDir, "README.md");
@@ -162,13 +172,13 @@ export function installClient(options = {}) {
162
172
  collection: options.collection || {},
163
173
  update: updatePolicy
164
174
  });
165
- const manifest = buildInstallManifest(targetDir, options);
175
+ const manifest = buildInstallManifest(targetDir, { ...options, platform });
166
176
  fs.writeFileSync(manifestFile, `${JSON.stringify(manifest, null, 2)}\n`, { mode: 0o600 });
167
177
  fs.chmodSync(manifestFile, 0o600);
168
178
  for (const artifact of Object.values(manifest.toolArtifacts)) {
169
179
  fs.writeFileSync(artifact.configFile, `${artifact.config}\n`, { mode: 0o600 });
170
180
  fs.chmodSync(artifact.configFile, 0o600);
171
- fs.writeFileSync(artifact.hookFile, buildHookScript(artifact.hook.command, artifact.hook.args), { mode: 0o700 });
181
+ fs.writeFileSync(artifact.hookFile, buildHookScript(artifact.hook.command, artifact.hook.args, platform), { mode: 0o700 });
172
182
  fs.chmodSync(artifact.hookFile, 0o700);
173
183
  for (const file of Object.values(artifact.extraFiles || {})) {
174
184
  fs.writeFileSync(file.file, `${file.content}\n`, { mode: file.mode || 0o600 });
@@ -182,6 +192,12 @@ export function installClient(options = {}) {
182
192
  fs.chmodSync(selfCheckScriptFile, 0o700);
183
193
  fs.writeFileSync(autoUpdateScriptFile, buildAutoUpdateScript(manifest.eventCli.command, manifest.eventCli.args), { mode: 0o700 });
184
194
  fs.chmodSync(autoUpdateScriptFile, 0o700);
195
+ fs.writeFileSync(windowsCheckinScriptFile, buildWindowsCheckinScript(manifest.eventCli.command, manifest.eventCli.args), { mode: 0o600 });
196
+ fs.chmodSync(windowsCheckinScriptFile, 0o600);
197
+ fs.writeFileSync(windowsSelfCheckScriptFile, buildWindowsSelfCheckScript(manifest.eventCli.command, manifest.eventCli.args), { mode: 0o600 });
198
+ fs.chmodSync(windowsSelfCheckScriptFile, 0o600);
199
+ fs.writeFileSync(windowsAutoUpdateScriptFile, buildWindowsAutoUpdateScript(manifest.eventCli.command, manifest.eventCli.args), { mode: 0o600 });
200
+ fs.chmodSync(windowsAutoUpdateScriptFile, 0o600);
185
201
  fs.writeFileSync(registerAutoUpdateScriptFile, buildRegisterAutoUpdateScript(targetDir), { mode: 0o700 });
186
202
  fs.chmodSync(registerAutoUpdateScriptFile, 0o700);
187
203
  fs.writeFileSync(unregisterAutoUpdateScriptFile, buildUnregisterAutoUpdateScript(), { mode: 0o700 });
@@ -234,13 +250,26 @@ function upperFirst(value) {
234
250
  return input ? `${input[0].toUpperCase()}${input.slice(1)}` : input;
235
251
  }
236
252
 
237
- function buildToolArtifacts({ targetDir, displayTargetDir, configFile, serverUrl, mcpCommand, mcpArgs }) {
253
+ function normalizePlatform(value = process.platform) {
254
+ return String(value || process.platform).toLowerCase() === "win32" ? "win32" : "posix";
255
+ }
256
+
257
+ function isWindowsPlatform(platform = process.platform) {
258
+ return normalizePlatform(platform) === "win32";
259
+ }
260
+
261
+ function hookFileNameFor(profile, platform = process.platform) {
262
+ if (!isWindowsPlatform(platform)) return profile.hookFileName;
263
+ return profile.hookFileName.replace(/\.sh$/i, ".cmd");
264
+ }
265
+
266
+ function buildToolArtifacts({ targetDir, displayTargetDir, platform, configFile, serverUrl, mcpCommand, mcpArgs }) {
238
267
  return Object.fromEntries(listToolProfiles().map((profile) => {
239
268
  const hookArgs = [path.join(agentSrcDir, "hook-adapter.mjs"), "--config", configFile, "--tool", profile.id];
240
269
  const artifact = {
241
270
  profile,
242
271
  configFile: path.join(targetDir, profile.configFileName),
243
- hookFile: path.join(targetDir, profile.hookFileName),
272
+ hookFile: path.join(targetDir, hookFileNameFor(profile, platform)),
244
273
  hook: {
245
274
  name: profile.hookName,
246
275
  command: mcpCommand,
@@ -263,6 +292,7 @@ function buildToolArtifacts({ targetDir, displayTargetDir, configFile, serverUrl
263
292
  }),
264
293
  extraFiles: extraFilesFor(profile, {
265
294
  targetDir,
295
+ platform,
266
296
  configFile,
267
297
  mcpCommand,
268
298
  mcpArgs,
@@ -315,12 +345,12 @@ function toolConfigFor(profile, { configFile, serverUrl, mcpCommand, mcpArgs, ho
315
345
  return JSON.stringify({ command: mcpCommand, args: mcpArgs, env: { WORKLENS_CONFIG_FILE: configFile, WORKLENS_TOOL: profile.id, WORKLENS_SERVER_URL: serverUrl } }, null, 2);
316
346
  }
317
347
 
318
- function extraFilesFor(profile, { targetDir, hookCommand, hookArgs }) {
348
+ function extraFilesFor(profile, { targetDir, platform, hookCommand, hookArgs }) {
319
349
  if (profile.id === "claude-code") {
320
350
  return {
321
351
  hooksSettings: {
322
352
  file: path.join(targetDir, "claude-code-hooks-settings.json"),
323
- content: buildClaudeHooksSettings(hookCommand, hookArgs)
353
+ content: buildClaudeHooksSettings(hookCommand, hookArgs, platform)
324
354
  }
325
355
  };
326
356
  }
@@ -336,7 +366,7 @@ function extraFilesFor(profile, { targetDir, hookCommand, hookArgs }) {
336
366
  return {};
337
367
  }
338
368
 
339
- function buildClaudeHooksSettings(command, baseArgs) {
369
+ function buildClaudeHooksSettings(command, baseArgs, platform = process.platform) {
340
370
  const events = [
341
371
  "SessionStart",
342
372
  "ModeChange",
@@ -368,7 +398,7 @@ function buildClaudeHooksSettings(command, baseArgs) {
368
398
  hooks: [
369
399
  {
370
400
  type: "command",
371
- command: commandLine(command, [...baseArgs, "--event", eventName]),
401
+ command: commandLine(command, [...baseArgs, "--event", eventName], platform),
372
402
  timeout: 10
373
403
  }
374
404
  ]
@@ -412,6 +442,7 @@ function selectedIntegrationTools(value, manifest) {
412
442
 
413
443
  function installCodexIntegration(manifest, homeDir) {
414
444
  const artifact = manifest.toolArtifacts.codex;
445
+ const platform = normalizePlatform(manifest.platform);
415
446
  const configPath = path.join(homeDir, ".codex", "config.toml");
416
447
  const hooksPath = path.join(homeDir, ".codex", "hooks.json");
417
448
  const config = readTextConfig(configPath);
@@ -423,7 +454,7 @@ function installCodexIntegration(manifest, homeDir) {
423
454
 
424
455
  const hooks = readJsonConfig(hooksPath, { hooks: {} });
425
456
  hooks.hooks = hooks.hooks && typeof hooks.hooks === "object" ? hooks.hooks : {};
426
- const command = shellQuote(artifact.hookFile);
457
+ const command = isWindowsPlatform(platform) ? windowsCommandFileInvocation(artifact.hookFile) : shellQuote(artifact.hookFile);
427
458
  for (const eventName of ["SessionStart", "PreToolUse", "PostToolUse", "UserPromptSubmit", "Stop"]) {
428
459
  const entry = {
429
460
  hooks: [{ type: "command", command, ...(eventName === "Stop" ? { timeout: 30 } : {}) }]
@@ -492,7 +523,7 @@ function hookEntryContainsWorkLens(entry = {}) {
492
523
  }
493
524
 
494
525
  function workLensHookCommandPattern() {
495
- return /(?:ai[-_]?worklens|worklens|silent-ai-observatory|hook-adapter\.mjs|codex-hook\.sh|claude-code-hook\.sh|opencode-hook\.sh)/i;
526
+ return /(?:ai[-_]?worklens|worklens|silent-ai-observatory|hook-adapter\.mjs|codex-hook\.(?:sh|cmd)|claude-code-hook\.(?:sh|cmd)|opencode-hook\.(?:sh|cmd))/i;
496
527
  }
497
528
 
498
529
  function ensureHookEntry(current, entry) {
@@ -502,8 +533,8 @@ function ensureHookEntry(current, entry) {
502
533
  return [...list, entry];
503
534
  }
504
535
 
505
- function commandLine(command, args) {
506
- return [command, ...args].map(shellQuote).join(" ");
536
+ function commandLine(command, args, platform = process.platform) {
537
+ return [command, ...args].map((item) => quoteForShell(item, platform)).join(" ");
507
538
  }
508
539
 
509
540
  function readTextConfig(filePath) {
@@ -695,7 +726,20 @@ function shellQuote(value) {
695
726
  return `'${String(value).replaceAll("'", "'\\''")}'`;
696
727
  }
697
728
 
698
- function buildHookScript(command, args) {
729
+ function windowsQuote(value) {
730
+ return `"${String(value).replaceAll('"', '""')}"`;
731
+ }
732
+
733
+ function quoteForShell(value, platform = process.platform) {
734
+ return isWindowsPlatform(platform) ? windowsQuote(value) : shellQuote(value);
735
+ }
736
+
737
+ function windowsCommandFileInvocation(filePath) {
738
+ return `cmd.exe /d /s /c ${windowsQuote(filePath)}`;
739
+ }
740
+
741
+ function buildHookScript(command, args, platform = process.platform) {
742
+ if (isWindowsPlatform(platform)) return buildWindowsHookScript(command, args);
699
743
  return [
700
744
  "#!/usr/bin/env sh",
701
745
  "set -eu",
@@ -703,6 +747,14 @@ function buildHookScript(command, args) {
703
747
  ].join("\n") + "\n";
704
748
  }
705
749
 
750
+ function buildWindowsHookScript(command, args) {
751
+ return [
752
+ "@echo off",
753
+ "setlocal",
754
+ commandLine(command, args, "win32")
755
+ ].join("\r\n") + "\r\n";
756
+ }
757
+
706
758
  function buildCheckinScript(command, args) {
707
759
  return [
708
760
  "#!/usr/bin/env sh",
@@ -713,6 +765,17 @@ function buildCheckinScript(command, args) {
713
765
  ].join("\n") + "\n";
714
766
  }
715
767
 
768
+ function buildWindowsCheckinScript(command, args) {
769
+ const base = commandLine(command, args, "win32");
770
+ return [
771
+ "@echo off",
772
+ "setlocal",
773
+ `${base} sync-config`,
774
+ `${base} recover --sync false --checkin false`,
775
+ `${base} checkin`
776
+ ].join("\r\n") + "\r\n";
777
+ }
778
+
716
779
  function buildSelfCheckScript(command, args) {
717
780
  return [
718
781
  "#!/usr/bin/env sh",
@@ -721,6 +784,14 @@ function buildSelfCheckScript(command, args) {
721
784
  ].join("\n") + "\n";
722
785
  }
723
786
 
787
+ function buildWindowsSelfCheckScript(command, args) {
788
+ return [
789
+ "@echo off",
790
+ "setlocal",
791
+ `${commandLine(command, args, "win32")} doctor`
792
+ ].join("\r\n") + "\r\n";
793
+ }
794
+
724
795
  function buildAutoUpdateScript(command, args) {
725
796
  return [
726
797
  "#!/usr/bin/env sh",
@@ -730,6 +801,16 @@ function buildAutoUpdateScript(command, args) {
730
801
  ].join("\n") + "\n";
731
802
  }
732
803
 
804
+ function buildWindowsAutoUpdateScript(command, args) {
805
+ const base = commandLine(command, args, "win32");
806
+ return [
807
+ "@echo off",
808
+ "setlocal",
809
+ `${base} recover --checkin false`,
810
+ `${base} auto-update`
811
+ ].join("\r\n") + "\r\n";
812
+ }
813
+
733
814
  function buildRegisterAutoUpdateScript(targetDir) {
734
815
  const plist = "com.ai-worklens.autoupdate.plist";
735
816
  const interval = 1800;
@@ -793,11 +874,11 @@ function buildUnregisterAutoUpdateScript() {
793
874
  }
794
875
 
795
876
  function buildWindowsRegisterAutoUpdateScript(targetDir) {
796
- const autoUpdate = path.win32.join("%USERPROFILE%", ".ai-worklens", "worklens-auto-update.sh");
877
+ const autoUpdate = path.win32.join("%USERPROFILE%", ".ai-worklens", "worklens-auto-update.cmd");
797
878
  return [
798
879
  "$TaskName = \"AIWorkLensAutoUpdate\"",
799
880
  `$AutoUpdate = \"${autoUpdate}\"`,
800
- "$Action = New-ScheduledTaskAction -Execute \"wsl.exe\" -Argument \"sh $AutoUpdate\"",
881
+ "$Action = New-ScheduledTaskAction -Execute \"cmd.exe\" -Argument \"/d /s /c `\"$AutoUpdate`\"\"",
801
882
  "$Trigger = New-ScheduledTaskTrigger -Once -At (Get-Date).AddMinutes(5) -RepetitionInterval (New-TimeSpan -Hours 6)",
802
883
  "$Settings = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries",
803
884
  "Register-ScheduledTask -TaskName $TaskName -Action $Action -Trigger $Trigger -Settings $Settings -Force",
@@ -841,12 +922,12 @@ function buildReadme(manifest) {
841
922
  `- client.json: 员工端配置和中心端下发规则。`,
842
923
  `- install-manifest.json: 安装清单。`,
843
924
  `- *-mcp.*: 各 AI 工具的 MCP 配置片段。`,
844
- `- *-hook.sh: 各 AI 工具的 hook adapter 启动脚本。`,
925
+ `- *-hook.sh / *-hook.cmd: 各 AI 工具的 hook adapter 启动脚本。`,
845
926
  `- claude-code-hooks-settings.json: Claude Code hooks 配置片段。`,
846
927
  `- opencode-ai-worklens-plugin.js: OpenCode 本地插件。`,
847
- `- worklens-checkin.sh: 同步规则、补传离线队列并上报健康状态。`,
848
- `- worklens-self-check.sh: 检查中心端连通性、本地配置和离线队列。`,
849
- `- worklens-auto-update.sh: 拉取中心端版本策略,并在中心端恢复后自动补传离线队列。`,
928
+ `- worklens-checkin.sh / worklens-checkin.cmd: 同步规则、补传离线队列并上报健康状态。`,
929
+ `- worklens-self-check.sh / worklens-self-check.cmd: 检查中心端连通性、本地配置和离线队列。`,
930
+ `- worklens-auto-update.sh / worklens-auto-update.cmd: 拉取中心端版本策略,并在中心端恢复后自动补传离线队列。`,
850
931
  `- worklens-register-autoupdate.sh: 在 macOS 用户级 LaunchAgent 注册后台静默更新和恢复上报任务。`,
851
932
  `- worklens-unregister-autoupdate.sh: 移除后台静默更新任务。`,
852
933
  `- worklens-install-or-update.sh: 同步中心端规则、上报健康并执行自检。`,
@@ -860,13 +941,26 @@ function buildReadme(manifest) {
860
941
 
861
942
  function runPostInstall(result, args = {}) {
862
943
  if (args["post-install"] === "false") return { ok: true, skipped: true };
863
- const checkin = spawnSync(result.generatedFiles.checkinScript, [], {
944
+ const platform = normalizePlatform(result.manifest?.platform || process.platform);
945
+ const timeout = Number(args["post-install-timeout-ms"] || 15000);
946
+ const checkinScript = isWindowsPlatform(platform)
947
+ ? result.generatedFiles.windowsCheckinScript
948
+ : result.generatedFiles.checkinScript;
949
+ const checkinCommand = isWindowsPlatform(platform) ? "cmd.exe" : checkinScript;
950
+ const checkinArgs = isWindowsPlatform(platform) ? ["/d", "/s", "/c", checkinScript] : [];
951
+ const registerCommand = isWindowsPlatform(platform)
952
+ ? "powershell.exe"
953
+ : result.generatedFiles.registerAutoUpdateScript;
954
+ const registerArgs = isWindowsPlatform(platform)
955
+ ? ["-NoProfile", "-ExecutionPolicy", "Bypass", "-File", result.generatedFiles.windowsRegisterAutoUpdateScript]
956
+ : [];
957
+ const checkin = spawnSync(checkinCommand, checkinArgs, {
864
958
  encoding: "utf8",
865
- timeout: Number(args["post-install-timeout-ms"] || 15000)
959
+ timeout
866
960
  });
867
- const register = spawnSync(result.generatedFiles.registerAutoUpdateScript, [], {
961
+ const register = spawnSync(registerCommand, registerArgs, {
868
962
  encoding: "utf8",
869
- timeout: Number(args["post-install-timeout-ms"] || 15000)
963
+ timeout
870
964
  });
871
965
  return {
872
966
  ok: checkin.status === 0,
@@ -1,4 +1,4 @@
1
- export const CLIENT_AGENT_VERSION = "0.1.5";
1
+ export const CLIENT_AGENT_VERSION = "0.1.7";
2
2
 
3
3
  export const DEFAULT_CLIENT_UPDATE_POLICY = {
4
4
  enabled: true,
@@ -31,7 +31,7 @@ export function parseArgs(argv = []) {
31
31
  }
32
32
  options.packageName = options.packageName || process.env.WORKLENS_NPM_PACKAGE_NAME || "";
33
33
  options.version = options.version || process.env.WORKLENS_NPM_VERSION || "";
34
- options.token = options.token || process.env.NPM_TOKEN || "";
34
+ options.token = options.token || process.env.NPM_TOKEN || process.env.WORKLENS_NPM_TOKEN || "";
35
35
  return options;
36
36
  }
37
37
 
@@ -40,11 +40,12 @@ export function usage() {
40
40
  "Usage:",
41
41
  " npm run client:npm:publish -- --package-name <npm-package> --version <version> --tag latest --dry-run",
42
42
  " NPM_TOKEN=<token> npm run client:npm:publish -- --package-name <npm-package> --version <version> --tag latest",
43
+ " WORKLENS_NPM_TOKEN=<token> npm run client:npm:publish -- --package-name <npm-package> --version <version> --tag latest",
43
44
  "",
44
45
  "Notes:",
45
46
  " - Public npm scoped packages require --access public.",
46
47
  " - 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 and deleted after publish."
48
+ " - Token can be passed through NPM_TOKEN, WORKLENS_NPM_TOKEN or --token; it is written only to a temporary .npmrc and deleted after publish."
48
49
  ].join("\n");
49
50
  }
50
51
 
@@ -127,7 +128,7 @@ export function normalizeNpmPublishTag(value = "latest") {
127
128
 
128
129
  export function publishToNpm(options = {}) {
129
130
  if (!options.dryRun && !options.token) {
130
- throw new Error("NPM_TOKEN is required for real public npm publish; use --dry-run to preview without token");
131
+ throw new Error("NPM_TOKEN or WORKLENS_NPM_TOKEN is required for real public npm publish; use --dry-run to preview without token");
131
132
  }
132
133
  let prepared = null;
133
134
  try {
@@ -0,0 +1,79 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+
4
+ function baseDir(config = {}) {
5
+ return path.dirname(config.configFile || config.queueFile || path.join(process.cwd(), "client.json"));
6
+ }
7
+
8
+ export function runtimeStateFile(config = {}) {
9
+ return config.stateFile || path.join(baseDir(config), "state.json");
10
+ }
11
+
12
+ export function runtimeLogDir(config = {}) {
13
+ return config.logDir || path.join(baseDir(config), "logs");
14
+ }
15
+
16
+ function readJson(filePath, fallback) {
17
+ try {
18
+ if (!fs.existsSync(filePath)) return fallback;
19
+ return JSON.parse(fs.readFileSync(filePath, "utf8"));
20
+ } catch {
21
+ return fallback;
22
+ }
23
+ }
24
+
25
+ function writeJsonAtomic(filePath, value) {
26
+ fs.mkdirSync(path.dirname(filePath), { recursive: true, mode: 0o700 });
27
+ const tempPath = path.join(path.dirname(filePath), `.${path.basename(filePath)}.${process.pid}.${Date.now()}.tmp`);
28
+ fs.writeFileSync(tempPath, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600 });
29
+ fs.renameSync(tempPath, filePath);
30
+ fs.chmodSync(filePath, 0o600);
31
+ }
32
+
33
+ export function readRuntimeState(config = {}) {
34
+ return readJson(runtimeStateFile(config), {});
35
+ }
36
+
37
+ export function updateRuntimeState(config = {}, patch = {}) {
38
+ try {
39
+ const filePath = runtimeStateFile(config);
40
+ const current = readJson(filePath, {});
41
+ const next = {
42
+ ...current,
43
+ ...patch,
44
+ updatedAt: new Date().toISOString()
45
+ };
46
+ writeJsonAtomic(filePath, next);
47
+ return next;
48
+ } catch {
49
+ return null;
50
+ }
51
+ }
52
+
53
+ export function appendRuntimeLog(config = {}, name, entry = {}) {
54
+ try {
55
+ const directory = runtimeLogDir(config);
56
+ fs.mkdirSync(directory, { recursive: true, mode: 0o700 });
57
+ const filePath = path.join(directory, `${name}.log`);
58
+ const line = {
59
+ at: new Date().toISOString(),
60
+ ...entry
61
+ };
62
+ fs.appendFileSync(filePath, `${JSON.stringify(line)}\n`, { mode: 0o600 });
63
+ fs.chmodSync(filePath, 0o600);
64
+ return filePath;
65
+ } catch {
66
+ return "";
67
+ }
68
+ }
69
+
70
+ export function eventLogEntry(event = {}) {
71
+ return {
72
+ eventId: event.eventId,
73
+ eventType: event.eventType,
74
+ source: event.source,
75
+ sessionId: event.session?.localSessionId || "",
76
+ turnIndex: event.session?.turnIndex || 0,
77
+ diagnostic: Boolean(event.metadata?.diagnostic)
78
+ };
79
+ }
package/src/uploader.mjs CHANGED
@@ -5,6 +5,7 @@ import { writeClientConfig } from "./config.mjs";
5
5
  import { normalizeCollectionSettings } from "./protocol/collection-settings.mjs";
6
6
  import { normalizeClientUpdatePolicy, updateNeeded } from "./protocol/client-update-policy.mjs";
7
7
  import { installClient } from "./install.mjs";
8
+ import { appendRuntimeLog, eventLogEntry, readRuntimeState, updateRuntimeState } from "./runtime-state.mjs";
8
9
 
9
10
  async function requestJson(url, config, options = {}) {
10
11
  const controller = new AbortController();
@@ -69,7 +70,30 @@ export class ClientAgent {
69
70
 
70
71
  async record(event) {
71
72
  const length = await this.queue.enqueue(event);
73
+ appendRuntimeLog(this.config, "events", {
74
+ action: "enqueue",
75
+ queueSize: length,
76
+ ...eventLogEntry(event)
77
+ });
78
+ updateRuntimeState(this.config, {
79
+ lastEventAt: event.occurredAt,
80
+ lastEventId: event.eventId,
81
+ lastEventType: event.eventType,
82
+ lastQueueSize: length
83
+ });
72
84
  if (this.config.collection?.enabled === false) {
85
+ appendRuntimeLog(this.config, "upload", {
86
+ action: "skip",
87
+ reason: "collection_disabled",
88
+ queueSize: length,
89
+ ...eventLogEntry(event)
90
+ });
91
+ updateRuntimeState(this.config, {
92
+ lastUploadStatus: "skipped",
93
+ lastUploadError: "collection_disabled",
94
+ lastUploadRemaining: length,
95
+ lastQueueSize: length
96
+ });
73
97
  return {
74
98
  ok: true,
75
99
  queued: length,
@@ -108,6 +132,22 @@ export class ClientAgent {
108
132
  await this.queue.markFailed(failedItems.map((item) => item.id), "partial_upload_not_accepted");
109
133
  }
110
134
  const stats = await this.queue.stats();
135
+ appendRuntimeLog(this.config, "upload", {
136
+ action: "flush",
137
+ ok: failedItems.length === 0,
138
+ sent: accepted,
139
+ remaining: stats.total,
140
+ postponed: stats.waiting,
141
+ partial: failedItems.length > 0
142
+ });
143
+ updateRuntimeState(this.config, {
144
+ lastUploadAt: new Date().toISOString(),
145
+ lastUploadStatus: failedItems.length === 0 ? "ok" : "partial",
146
+ lastUploadError: failedItems.length ? "partial_upload_not_accepted" : "",
147
+ lastUploadSent: accepted,
148
+ lastUploadRemaining: stats.total,
149
+ lastQueueSize: stats.total
150
+ });
111
151
  return {
112
152
  ok: failedItems.length === 0,
113
153
  sent: accepted,
@@ -120,6 +160,22 @@ export class ClientAgent {
120
160
  } catch (error) {
121
161
  await this.queue.markFailed(batch.map((item) => item.id), error);
122
162
  const stats = await this.queue.stats();
163
+ appendRuntimeLog(this.config, "upload", {
164
+ action: "flush",
165
+ ok: false,
166
+ sent: 0,
167
+ remaining: stats.total,
168
+ postponed: stats.waiting,
169
+ error: error.message
170
+ });
171
+ updateRuntimeState(this.config, {
172
+ lastUploadAt: new Date().toISOString(),
173
+ lastUploadStatus: "failed",
174
+ lastUploadError: error.message,
175
+ lastUploadSent: 0,
176
+ lastUploadRemaining: stats.total,
177
+ lastQueueSize: stats.total
178
+ });
123
179
  return {
124
180
  ok: false,
125
181
  sent: 0,
@@ -180,6 +236,7 @@ export class ClientAgent {
180
236
 
181
237
  async checkin(overrides = {}) {
182
238
  const queueItems = await this.queue.list();
239
+ const runtime = readRuntimeState(this.config);
183
240
  return postJson("/api/clients/checkin", {
184
241
  employeeId: this.config.employee.id,
185
242
  employeeName: this.config.employee.name,
@@ -193,7 +250,15 @@ export class ClientAgent {
193
250
  mcpReady: overrides.mcpReady ?? true,
194
251
  hookReady: overrides.hookReady ?? true,
195
252
  uploadQueueSize: queueItems.length,
196
- issues: overrides.issues || []
253
+ issues: overrides.issues || [],
254
+ lastHookAt: overrides.lastHookAt ?? runtime.lastHookAt ?? "",
255
+ lastHookEventType: overrides.lastHookEventType ?? runtime.lastHookEventType ?? "",
256
+ lastHookEventId: overrides.lastHookEventId ?? runtime.lastHookEventId ?? "",
257
+ lastUploadAt: overrides.lastUploadAt ?? runtime.lastUploadAt ?? "",
258
+ lastUploadStatus: overrides.lastUploadStatus ?? runtime.lastUploadStatus ?? "",
259
+ lastUploadError: overrides.lastUploadError ?? runtime.lastUploadError ?? "",
260
+ lastUploadSent: overrides.lastUploadSent ?? runtime.lastUploadSent ?? 0,
261
+ lastUploadRemaining: overrides.lastUploadRemaining ?? runtime.lastUploadRemaining ?? queueItems.length
197
262
  }, this.config);
198
263
  }
199
264
 
@@ -355,8 +420,11 @@ export class ClientAgent {
355
420
  clientId: this.config.clientId,
356
421
  configFile: this.config.configFile,
357
422
  queueFile: this.config.queueFile,
423
+ stateFile: this.config.stateFile,
424
+ logDir: this.config.logDir,
358
425
  queueSize: queueItems.length,
359
426
  queue: queueStats,
427
+ runtime: readRuntimeState(this.config),
360
428
  collection: this.config.collection,
361
429
  update: this.config.update
362
430
  };
@@ -410,6 +478,11 @@ export class ClientAgent {
410
478
  codexHooks.enabled,
411
479
  codexHooks.message
412
480
  ));
481
+ checks.push(check(
482
+ "codex_hooks_executable",
483
+ codexHooks.executable,
484
+ codexHooks.executableMessage || "Codex hook 指向的脚本不存在,或不是当前系统可执行的脚本格式"
485
+ ));
413
486
  status.codexHooks = codexHooks;
414
487
  }
415
488
 
@@ -435,13 +508,18 @@ function codexHookDiagnostics(config) {
435
508
  const configPath = path.join(homeDir, ".codex", "config.toml");
436
509
  const hookEvents = new Set(["session_start", "pre_tool_use", "post_tool_use", "user_prompt_submit", "stop"]);
437
510
  const configuredEvents = codexConfiguredHookEvents(hooksPath);
511
+ const hookCommands = codexConfiguredHookCommands(hooksPath);
438
512
  const disabledStates = codexDisabledHookStates(configPath, hooksPath, hookEvents);
513
+ const executableState = codexHookExecutableState(hookCommands);
439
514
  return {
440
515
  hooksPath,
441
516
  configPath,
442
517
  configured: configuredEvents.length > 0,
443
518
  configuredEvents,
519
+ hookCommands,
444
520
  enabled: disabledStates.length === 0,
521
+ executable: executableState.ok,
522
+ executableMessage: executableState.message,
445
523
  disabledStates,
446
524
  message: disabledStates.length
447
525
  ? `Codex hook 已配置但未启用:${disabledStates.join("、")},请重新运行安装命令或执行 worklens-agent-install 修复。`
@@ -461,6 +539,20 @@ function codexConfiguredHookEvents(hooksPath) {
461
539
  }
462
540
  }
463
541
 
542
+ function codexConfiguredHookCommands(hooksPath) {
543
+ try {
544
+ if (!fs.existsSync(hooksPath)) return [];
545
+ const hooks = JSON.parse(fs.readFileSync(hooksPath, "utf8"));
546
+ return Object.values(hooks.hooks || {})
547
+ .flatMap((entries) => Array.isArray(entries) ? entries : [])
548
+ .flatMap((entry) => Array.isArray(entry.hooks) ? entry.hooks : [])
549
+ .map((hook) => String(hook.command || ""))
550
+ .filter((command) => workLensHookPattern().test(command));
551
+ } catch {
552
+ return [];
553
+ }
554
+ }
555
+
464
556
  function entriesContainWorkLensHook(entries) {
465
557
  return (Array.isArray(entries) ? entries : []).some((entry) => {
466
558
  return (entry.hooks || []).some((hook) => workLensHookPattern().test(String(hook.command || "")));
@@ -468,7 +560,30 @@ function entriesContainWorkLensHook(entries) {
468
560
  }
469
561
 
470
562
  function workLensHookPattern() {
471
- return /(?:ai[-_]?worklens|worklens|silent-ai-observatory|hook-adapter\.mjs|codex-hook\.sh)/i;
563
+ return /(?:ai[-_]?worklens|worklens|silent-ai-observatory|hook-adapter\.mjs|codex-hook\.(?:sh|cmd))/i;
564
+ }
565
+
566
+ function codexHookExecutableState(commands) {
567
+ if (!commands.length) return { ok: false, message: "未找到 AI WorkLens Codex hook 命令" };
568
+ const scriptPaths = commands.map(extractCodexHookScriptPath).filter(Boolean);
569
+ if (!scriptPaths.length) return { ok: false, message: "Codex hook 命令中未找到 codex-hook 脚本路径" };
570
+ for (const scriptPath of scriptPaths) {
571
+ if (!fs.existsSync(scriptPath)) return { ok: false, message: `Codex hook 脚本不存在:${scriptPath}` };
572
+ if (process.platform === "win32" && !/\.cmd$/i.test(scriptPath)) {
573
+ return { ok: false, message: `Windows 需要 codex-hook.cmd,当前是:${scriptPath}` };
574
+ }
575
+ if (process.platform !== "win32" && !/\.sh$/i.test(scriptPath)) {
576
+ return { ok: false, message: `macOS/Linux 需要 codex-hook.sh,当前是:${scriptPath}` };
577
+ }
578
+ }
579
+ return { ok: true, message: "" };
580
+ }
581
+
582
+ function extractCodexHookScriptPath(command) {
583
+ const quoted = String(command || "").match(/["']([^"']*codex-hook\.(?:sh|cmd))["']/i);
584
+ if (quoted) return quoted[1];
585
+ const unquoted = String(command || "").match(/([^\s]+codex-hook\.(?:sh|cmd))/i);
586
+ return unquoted ? unquoted[1] : "";
472
587
  }
473
588
 
474
589
  function codexDisabledHookStates(configPath, hooksPath, hookEvents) {