@kairyou/agent-tools 0.13.1 → 0.13.3

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/dist/log/hook.mjs CHANGED
@@ -897,21 +897,75 @@ async function main() {
897
897
  const statePath = path.join(CACHE_ROOT, `${day}.state.json`);
898
898
  const snapshotsRoot = path.join(CACHE_ROOT, `${day}.snapshots`);
899
899
  await fs.mkdir(CACHE_ROOT, { recursive: true });
900
- await cleanupCache(day);
901
- const state = await loadState(statePath, day);
902
- await handleEvent(state, hookInput, { now, snapshotsRoot, effective, scopeKey, scopePath: scope?.path || "" });
903
- await writeFileAtomic(statePath, `${JSON.stringify(state, null, 2)}
904
- `);
905
- if (effective.format === "detailed") {
906
- const report = await renderDetailedReport(state, {
900
+ const release = await acquireLock(path.join(CACHE_ROOT, `${day}.lock`));
901
+ if (!release) {
902
+ await debugLog(`dropped ${eventName}: could not acquire ${day}.lock`);
903
+ return;
904
+ }
905
+ try {
906
+ await cleanupCache(day);
907
+ const state = await loadState(statePath, day);
908
+ await handleEvent(state, hookInput, {
909
+ now,
907
910
  snapshotsRoot,
908
- language: effective.language,
909
- scopeKey
911
+ effective,
912
+ scopeKey,
913
+ scopePath: scope?.path || ""
910
914
  });
911
- await fs.mkdir(effective.output, { recursive: true });
912
- await writeFileAtomic(path.join(effective.output, `${day}.md`), report);
913
- } else {
914
- await updateDailyFile(effective.output, day, buildDailyItems(state, scopeKey));
915
+ await writeFileAtomic(statePath, `${JSON.stringify(state, null, 2)}
916
+ `);
917
+ if (effective.format === "detailed") {
918
+ const report = await renderDetailedReport(state, {
919
+ snapshotsRoot,
920
+ language: effective.language,
921
+ scopeKey
922
+ });
923
+ await fs.mkdir(effective.output, { recursive: true });
924
+ await writeFileAtomic(path.join(effective.output, `${day}.md`), report);
925
+ } else {
926
+ await updateDailyFile(effective.output, day, buildDailyItems(state, scopeKey));
927
+ }
928
+ } finally {
929
+ await release();
930
+ }
931
+ }
932
+ var LOCK_TIMEOUT_MS = 5e3;
933
+ var LOCK_RETRY_MS = 25;
934
+ var LOCK_STALE_MS = 3e4;
935
+ async function acquireLock(lockPath) {
936
+ const deadline = Date.now() + LOCK_TIMEOUT_MS;
937
+ while (Date.now() < deadline) {
938
+ try {
939
+ const handle = await fs.open(lockPath, "wx");
940
+ await handle.close();
941
+ return async () => {
942
+ try {
943
+ await fs.unlink(lockPath);
944
+ } catch {
945
+ }
946
+ };
947
+ } catch (error) {
948
+ if (error?.code !== "EEXIST") return null;
949
+ const age = await fs.stat(lockPath).then(({ mtimeMs }) => Date.now() - mtimeMs).catch(() => 0);
950
+ if (age > LOCK_STALE_MS) {
951
+ await fs.unlink(lockPath).catch(() => {
952
+ });
953
+ continue;
954
+ }
955
+ await sleep(LOCK_RETRY_MS);
956
+ }
957
+ }
958
+ return null;
959
+ }
960
+ function sleep(ms) {
961
+ return new Promise((resolve) => setTimeout(resolve, ms));
962
+ }
963
+ async function debugLog(message) {
964
+ if (process.env.AGENT_TOOLS_LOG_DEBUG !== "1") return;
965
+ try {
966
+ await fs.appendFile(path.join(CACHE_ROOT, "debug.log"), `${(/* @__PURE__ */ new Date()).toISOString()} ${message}
967
+ `);
968
+ } catch {
915
969
  }
916
970
  }
917
971
  function eventCwd(input) {
@@ -980,6 +1034,11 @@ async function handleEvent(state, input, context) {
980
1034
  const session = ensureSession(state, sessionId, timestamp);
981
1035
  session.last_time = timestamp;
982
1036
  if (eventName === "UserPromptSubmit") {
1037
+ const last = session.turns[session.turns.length - 1];
1038
+ if (last && !last.request_text && last.result_summary) {
1039
+ last.request_text = excerptMultiline(getPromptText(input), 1600);
1040
+ return;
1041
+ }
983
1042
  session.turns.push(
984
1043
  createTurn(session, timestamp, getPromptText(input), resolveProjectLabel(input, context.scopePath), context.scopeKey)
985
1044
  );
@@ -1028,7 +1087,7 @@ async function cleanupCache(day) {
1028
1087
  }
1029
1088
  await Promise.all(
1030
1089
  entries.map(async (entry) => {
1031
- const isState = entry.isFile() && /^\d{4}-\d{2}-\d{2}\.state\.json$/.test(entry.name);
1090
+ const isState = entry.isFile() && /^\d{4}-\d{2}-\d{2}\.(state\.json|lock)$/.test(entry.name);
1032
1091
  const isSnapshots = entry.isDirectory() && /^\d{4}-\d{2}-\d{2}\.snapshots$/.test(entry.name);
1033
1092
  if (!isState && !isSnapshots || entry.name.startsWith(day)) return;
1034
1093
  try {
@@ -1182,9 +1241,9 @@ function dailyItemText(turn) {
1182
1241
  const outcome = String(turn.result_summary || "");
1183
1242
  if (!hasSubstantiveTurn(request, outcome) || isTrivialTurn(request, outcome)) return "";
1184
1243
  const source = outcome || request;
1185
- const firstLine = source.split("\n").map((line) => line.replace(/^[#>*\-\s`]+/, "").trim()).find((line) => line.length > 0);
1186
- if (!firstLine) return "";
1187
- return firstLine.length > DAILY_ITEM_MAX_CHARS ? `${firstLine.slice(0, DAILY_ITEM_MAX_CHARS - 3)}...` : firstLine;
1244
+ const flattened = source.split("\n").map((line) => line.replace(/^[#>*\-\s`|]+/, "").trim()).filter(Boolean).join(" ");
1245
+ if (!flattened) return "";
1246
+ return flattened.length > DAILY_ITEM_MAX_CHARS ? `${flattened.slice(0, DAILY_ITEM_MAX_CHARS - 3)}...` : flattened;
1188
1247
  }
1189
1248
  async function updateDailyFile(outputFile, day, items) {
1190
1249
  if (items.length === 0) return;
@@ -1528,7 +1587,19 @@ function getShellCommand(input) {
1528
1587
  async function writeFileAtomic(file, text) {
1529
1588
  const temp = `${file}.${process.pid}.tmp`;
1530
1589
  await fs.writeFile(temp, text, "utf8");
1531
- await fs.rename(temp, file);
1590
+ for (let attempt = 0; ; attempt += 1) {
1591
+ try {
1592
+ await fs.rename(temp, file);
1593
+ return;
1594
+ } catch (error) {
1595
+ if (attempt >= 5 || !["EPERM", "EACCES", "EBUSY"].includes(error?.code)) {
1596
+ await fs.unlink(temp).catch(() => {
1597
+ });
1598
+ throw error;
1599
+ }
1600
+ await sleep(20 * (attempt + 1));
1601
+ }
1602
+ }
1532
1603
  }
1533
1604
  function excerptMultiline(text, limit) {
1534
1605
  const value = String(text || "").replace(/\r\n/g, "\n").trim();
package/docs/en/extras.md CHANGED
@@ -78,14 +78,15 @@ npx -y @kairyou/agent-tools@latest log -a claude codex opencode
78
78
  ```markdown
79
79
  + 2026-08-03
80
80
  <!-- log:2026-08-03:start -->
81
- 1. project-a: traced the login timeout to sessions never renewing.
82
- 2. project-a: fixed the login timeout, added regression tests.
83
- 3. project-b: traced empty report exports to an inverted permission filter.
81
+ 1. project-a: Found the cause of the login timeout: the renewal branch never updated the cache expiry, so the second request still read the old value. Patched session.ts and verified...
82
+ 2. project-a: Fix is in, with 3 regression tests covering the renewal path; all passing.
83
+ 3. project-b: Empty report exports came from an inverted permission filter, now corrected and confirmed working.
84
84
  <!-- log:2026-08-03:end -->
85
85
  ```
86
86
 
87
- One line per turn, written from the AI's own summary at the end of that turn; no
88
- cross-turn consolidation. Updates likewise rewrite only what sits between the markers.
87
+ One line per turn, taken verbatim from the closing text of that turn's AI reply
88
+ (truncated when long); no distilling, no cross-turn consolidation. Updates likewise
89
+ rewrite only what sits between the markers.
89
90
 
90
91
  `detailed` output example (excerpt):
91
92
 
@@ -76,14 +76,14 @@ npx -y @kairyou/agent-tools@latest log -a claude codex opencode
76
76
  ```markdown
77
77
  + 2026-08-03
78
78
  <!-- log:2026-08-03:start -->
79
- 1. project-a: 排查登录超时, 定位到会话缓存没有续期.
80
- 2. project-a: 修复登录超时, 补充回归用例.
81
- 3. project-b: 定位报表导出为空的问题, 原因是权限过滤条件写反.
79
+ 1. project-a: 已定位登录超时的原因: 会话缓存在续期分支上没有更新过期时间, 第二次请求拿到的还是旧值。已经在 session.ts 补上续期并本地验证通过, 接下来...
80
+ 2. project-a: 修复完成, 新增 3 个回归用例覆盖续期路径, 全部通过。
81
+ 3. project-b: 报表导出为空定位到权限过滤条件写反, 已修正并确认导出恢复正常。
82
82
  <!-- log:2026-08-03:end -->
83
83
  ```
84
84
 
85
- 每轮一行, 内容是那一轮结束时 AI 自己写的总结, 不做跨轮归纳; 同样只重写标记之间
86
- 的部分, 文件里的其他内容不会被碰.
85
+ 每轮一行, 直接摘录那一轮 AI 回复的收尾内容(过长会截断), 不做提炼也不跨轮归纳;
86
+ 同样只重写标记之间的部分, 文件里的其他内容不会被碰.
87
87
 
88
88
  `detailed` 输出示例(节选):
89
89
 
@@ -13,12 +13,10 @@
13
13
  // recorded, and an entry may override format/output/language
14
14
  //
15
15
  // State and diff snapshots live in ~/.agent-tools/cache/log/ and only the
16
- // current day is kept. Concurrent sessions share the day state without a lock,
17
- // last-writer-wins: a simultaneous write from another session can drop that
18
- // event's update, up to a whole turn with its outcome and file records.
19
- // Accepted as best effort — the log is generated data, never user content.
20
- // The opencode adapter serializes its own sends, so single-session events
21
- // there never race each other.
16
+ // current day is kept. Sessions across agents share that state, so each run
17
+ // takes ~/.agent-tools/cache/log/<date>.lock for the whole read-update-render
18
+ // cycle; an event that cannot get the lock within seconds is dropped rather
19
+ // than corrupting the day.
22
20
 
23
21
  import { spawnSync } from "node:child_process";
24
22
  import fs from "node:fs/promises";
@@ -67,22 +65,89 @@ async function main() {
67
65
  const snapshotsRoot = path.join(CACHE_ROOT, `${day}.snapshots`);
68
66
 
69
67
  await fs.mkdir(CACHE_ROOT, { recursive: true });
70
- await cleanupCache(day);
71
68
 
72
- const state = await loadState(statePath, day);
73
- await handleEvent(state, hookInput, { now, snapshotsRoot, effective, scopeKey, scopePath: scope?.path || "" });
74
- await writeFileAtomic(statePath, `${JSON.stringify(state, null, 2)}\n`);
75
-
76
- if (effective.format === "detailed") {
77
- const report = await renderDetailedReport(state, {
69
+ // Read, update and render under one lock: concurrent sessions across agents
70
+ // otherwise overwrite each other's turns, and on Windows their simultaneous
71
+ // renames fail outright with EPERM.
72
+ const release = await acquireLock(path.join(CACHE_ROOT, `${day}.lock`));
73
+ if (!release) {
74
+ await debugLog(`dropped ${eventName}: could not acquire ${day}.lock`);
75
+ return;
76
+ }
77
+ try {
78
+ await cleanupCache(day);
79
+ const state = await loadState(statePath, day);
80
+ await handleEvent(state, hookInput, {
81
+ now,
78
82
  snapshotsRoot,
79
- language: effective.language,
83
+ effective,
80
84
  scopeKey,
85
+ scopePath: scope?.path || "",
81
86
  });
82
- await fs.mkdir(effective.output, { recursive: true });
83
- await writeFileAtomic(path.join(effective.output, `${day}.md`), report);
84
- } else {
85
- await updateDailyFile(effective.output, day, buildDailyItems(state, scopeKey));
87
+ await writeFileAtomic(statePath, `${JSON.stringify(state, null, 2)}\n`);
88
+
89
+ if (effective.format === "detailed") {
90
+ const report = await renderDetailedReport(state, {
91
+ snapshotsRoot,
92
+ language: effective.language,
93
+ scopeKey,
94
+ });
95
+ await fs.mkdir(effective.output, { recursive: true });
96
+ await writeFileAtomic(path.join(effective.output, `${day}.md`), report);
97
+ } else {
98
+ await updateDailyFile(effective.output, day, buildDailyItems(state, scopeKey));
99
+ }
100
+ } finally {
101
+ await release();
102
+ }
103
+ }
104
+
105
+ // ---- Cross-process lock. ----
106
+
107
+ const LOCK_TIMEOUT_MS = 5_000;
108
+ const LOCK_RETRY_MS = 25;
109
+ // A hook run is short; a lock older than this belongs to a process that died.
110
+ const LOCK_STALE_MS = 30_000;
111
+
112
+ async function acquireLock(lockPath) {
113
+ const deadline = Date.now() + LOCK_TIMEOUT_MS;
114
+ while (Date.now() < deadline) {
115
+ try {
116
+ const handle = await fs.open(lockPath, "wx");
117
+ await handle.close();
118
+ return async () => {
119
+ try {
120
+ await fs.unlink(lockPath);
121
+ } catch {
122
+ // Already reclaimed as stale by another process.
123
+ }
124
+ };
125
+ } catch (error) {
126
+ if (error?.code !== "EEXIST") return null;
127
+ const age = await fs
128
+ .stat(lockPath)
129
+ .then(({ mtimeMs }) => Date.now() - mtimeMs)
130
+ .catch(() => 0);
131
+ if (age > LOCK_STALE_MS) {
132
+ await fs.unlink(lockPath).catch(() => {});
133
+ continue;
134
+ }
135
+ await sleep(LOCK_RETRY_MS);
136
+ }
137
+ }
138
+ return null;
139
+ }
140
+
141
+ function sleep(ms) {
142
+ return new Promise((resolve) => setTimeout(resolve, ms));
143
+ }
144
+
145
+ async function debugLog(message) {
146
+ if (process.env.AGENT_TOOLS_LOG_DEBUG !== "1") return;
147
+ try {
148
+ await fs.appendFile(path.join(CACHE_ROOT, "debug.log"), `${new Date().toISOString()} ${message}\n`);
149
+ } catch {
150
+ // Diagnostics must never break logging.
86
151
  }
87
152
  }
88
153
 
@@ -170,9 +235,16 @@ async function handleEvent(state, input, context) {
170
235
  session.last_time = timestamp;
171
236
 
172
237
  if (eventName === "UserPromptSubmit") {
173
- // Always start a turn, even for greetings: reusing the previous turn would
174
- // let this prompt's Stop overwrite the previous result summary. Trivial
175
- // turns are filtered at render time instead.
238
+ const last = session.turns[session.turns.length - 1];
239
+ // A Stop that arrived before its prompt left a turn holding only an
240
+ // outcome; fill this prompt in rather than splitting one exchange in two.
241
+ if (last && !last.request_text && last.result_summary) {
242
+ last.request_text = excerptMultiline(getPromptText(input), 1600);
243
+ return;
244
+ }
245
+ // Otherwise always start a turn, even for greetings: reusing the previous
246
+ // turn would let this prompt's Stop overwrite the previous result summary.
247
+ // Trivial turns are filtered at render time instead.
176
248
  session.turns.push(
177
249
  createTurn(session, timestamp, getPromptText(input), resolveProjectLabel(input, context.scopePath), context.scopeKey)
178
250
  );
@@ -231,7 +303,7 @@ async function cleanupCache(day) {
231
303
  }
232
304
  await Promise.all(
233
305
  entries.map(async (entry) => {
234
- const isState = entry.isFile() && /^\d{4}-\d{2}-\d{2}\.state\.json$/.test(entry.name);
306
+ const isState = entry.isFile() && /^\d{4}-\d{2}-\d{2}\.(state\.json|lock)$/.test(entry.name);
235
307
  const isSnapshots = entry.isDirectory() && /^\d{4}-\d{2}-\d{2}\.snapshots$/.test(entry.name);
236
308
  if ((!isState && !isSnapshots) || entry.name.startsWith(day)) return;
237
309
  try {
@@ -427,14 +499,17 @@ function dailyItemText(turn) {
427
499
  const outcome = String(turn.result_summary || "");
428
500
  if (!hasSubstantiveTurn(request, outcome) || isTrivialTurn(request, outcome)) return "";
429
501
  const source = outcome || request;
430
- const firstLine = source
502
+ // Flattened rather than first-line: a structured summary often opens with a
503
+ // preamble line, and the substance sits in the lines after it.
504
+ const flattened = source
431
505
  .split("\n")
432
- .map((line) => line.replace(/^[#>*\-\s`]+/, "").trim())
433
- .find((line) => line.length > 0);
434
- if (!firstLine) return "";
435
- return firstLine.length > DAILY_ITEM_MAX_CHARS
436
- ? `${firstLine.slice(0, DAILY_ITEM_MAX_CHARS - 3)}...`
437
- : firstLine;
506
+ .map((line) => line.replace(/^[#>*\-\s`|]+/, "").trim())
507
+ .filter(Boolean)
508
+ .join(" ");
509
+ if (!flattened) return "";
510
+ return flattened.length > DAILY_ITEM_MAX_CHARS
511
+ ? `${flattened.slice(0, DAILY_ITEM_MAX_CHARS - 3)}...`
512
+ : flattened;
438
513
  }
439
514
 
440
515
  async function updateDailyFile(outputFile, day, items) {
@@ -859,10 +934,23 @@ function getShellCommand(input) {
859
934
 
860
935
  // ---- Small helpers. ----
861
936
 
937
+ // Windows fails the rename with EPERM when another process holds the target
938
+ // open, so a lost race retries briefly instead of dropping the write.
862
939
  async function writeFileAtomic(file, text) {
863
940
  const temp = `${file}.${process.pid}.tmp`;
864
941
  await fs.writeFile(temp, text, "utf8");
865
- await fs.rename(temp, file);
942
+ for (let attempt = 0; ; attempt += 1) {
943
+ try {
944
+ await fs.rename(temp, file);
945
+ return;
946
+ } catch (error) {
947
+ if (attempt >= 5 || !["EPERM", "EACCES", "EBUSY"].includes(error?.code)) {
948
+ await fs.unlink(temp).catch(() => {});
949
+ throw error;
950
+ }
951
+ await sleep(20 * (attempt + 1));
952
+ }
953
+ }
866
954
  }
867
955
 
868
956
  function excerptMultiline(text, limit) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kairyou/agent-tools",
3
- "version": "0.13.1",
3
+ "version": "0.13.3",
4
4
  "description": "Reusable Agent Skills, plus integrations (statusline, provider usage, vision) that install into Codex, Claude Code, and opencode.",
5
5
  "license": "MIT",
6
6
  "repository": {