ai-worklens-agent 0.1.6 → 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
@@ -38,18 +38,20 @@ npm run mcp
38
38
  Windows 命令提示符或 PowerShell 使用一行命令,不要混用 macOS/Linux 的反斜杠换行:
39
39
 
40
40
  ```bat
41
- npx -y --loglevel=error --registry https://registry.npmjs.org -p ai-worklens-agent@0.1.6 worklens-agent-install --server-url http://192.168.1.241:8797 --tool codex --employee-pinyin zhangpeng
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
42
  ```
43
43
 
44
44
  macOS / Linux:
45
45
 
46
46
  ```bash
47
- NPM_CONFIG_UPDATE_NOTIFIER=false npx -y --loglevel=error -p ai-worklens-agent@0.1.6 worklens-agent-install \
47
+ NPM_CONFIG_UPDATE_NOTIFIER=false npx -y --loglevel=error -p ai-worklens-agent@0.1.7 worklens-agent-install \
48
48
  --server-url http://192.168.1.241:8797 \
49
49
  --tool codex \
50
50
  --employee-pinyin zhangsan
51
51
  ```
52
52
 
53
+ 安装完成后请重新打开一个新的 Codex 会话。第一次触发 Hook 时,如果 Codex 提示信任或允许 Hook,请选择信任/允许;否则 `codex-hook.cmd` 手动验证可以成功,但真实 Codex 会话不会触发采集。
54
+
53
55
  发布到公共 npm 源需要 npm 账号 token。Scoped 包名需要账号拥有对应 scope。
54
56
 
55
57
  ```bash
@@ -68,7 +70,7 @@ NPM_TOKEN=<npm_token> npm run client:npm:publish -- \
68
70
  如果管理员在官网发布了直链安装包,可以下载安装包后执行包内安装脚本:
69
71
 
70
72
  ```bash
71
- curl -fL http://192.168.1.241:8797/site/downloads/ai-worklens-codex-0.1.6.sh \
73
+ curl -fL http://192.168.1.241:8797/site/downloads/ai-worklens-codex-0.1.7.sh \
72
74
  -o ai-worklens-install.sh
73
75
  chmod +x ai-worklens-install.sh
74
76
  ./ai-worklens-install.sh zhangsan
@@ -97,6 +99,8 @@ Windows 命令提示符验证 Codex hook 是否真实可执行:
97
99
  echo {"hook_event_name":"UserPromptSubmit","prompt":"worklens windows smoke","session_id":"manual-smoke"} | "%USERPROFILE%\.ai-worklens\codex-hook.cmd"
98
100
  ```
99
101
 
102
+ 如果这条命令成功上报,但真实 Codex 会话仍没有数据,优先检查是否已经在 Codex 中信任/允许了 Hook,并确认使用的是安装后新打开的 Codex 会话。
103
+
100
104
  模拟 Claude Code 工具 hook 输入:
101
105
 
102
106
  ```bash
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ai-worklens-agent",
3
- "version": "0.1.6",
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] || "")) {
@@ -1,4 +1,4 @@
1
- export const CLIENT_AGENT_VERSION = "0.1.6";
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
  };