helloagents 3.0.21-beta.1 → 3.0.22-beta.1

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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "helloagents",
3
- "version": "3.0.21-beta.1",
3
+ "version": "3.0.22-beta.1",
4
4
  "description": "HelloAGENTS — The orchestration kernel that makes any AI CLI smarter. Adds intelligent routing, quality verification (Ralph Loop), safety guards, and notifications.",
5
5
  "author": {
6
6
  "name": "HelloWind",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "helloagents",
3
- "version": "3.0.21-beta.1",
3
+ "version": "3.0.22-beta.1",
4
4
  "description": "HelloAGENTS — Quality-driven orchestration kernel for AI CLIs with intelligent routing, quality verification (Ralph Loop), safety guards, and notifications.",
5
5
  "author": {
6
6
  "name": "HelloWind",
package/README.md CHANGED
@@ -8,7 +8,7 @@
8
8
 
9
9
  **A workflow layer for AI coding CLIs: skills, project knowledge, delivery checks, safer config writes, and resumable execution.**
10
10
 
11
- [![Version](https://img.shields.io/badge/version-3.0.21-orange.svg)](./package.json)
11
+ [![Version](https://img.shields.io/badge/version-3.0.22-orange.svg)](./package.json)
12
12
  [![npm](https://img.shields.io/npm/v/helloagents.svg)](https://www.npmjs.com/package/helloagents)
13
13
  [![Node](https://img.shields.io/badge/node-%3E%3D18-339933.svg)](./package.json)
14
14
  [![Skills](https://img.shields.io/badge/skills-14-6366f1.svg)](./skills)
@@ -645,6 +645,7 @@ Codex is rules-file driven by default.
645
645
  - standby creates `~/.codex/helloagents -> ~/.helloagents/helloagents`
646
646
  - global mode installs the native local-plugin chain and also loads silent hooks from `~/.codex/hooks.json`
647
647
  - Codex hooks only synchronize runtime state and enforce Stop gates; they do not inject HelloAGENTS rules or route text through hook output
648
+ - Codex closeout de-duplicates Stop hooks and native `codex-notify`, so one turn does not notify twice
648
649
  - `/goal` remains Codex-native. Enable it explicitly with `helloagents codex goals enable` when long-running plan execution is needed
649
650
  - Goal-aware commands resume from `tasks.md`, `contract.json`, and `state_path`; they do not create goals automatically or mark them complete before HelloAGENTS verification and closeout
650
651
 
@@ -656,7 +657,7 @@ Run all tests:
656
657
  npm test
657
658
  ```
658
659
 
659
- The current test suite covers:
660
+ The current suite includes 103 tests and covers:
660
661
 
661
662
  - install, update, uninstall, cleanup, and mode switching
662
663
  - Claude, Gemini, and Codex config merge and restore behavior
package/README_CN.md CHANGED
@@ -8,7 +8,7 @@
8
8
 
9
9
  **面向 AI 编码 CLI 的工作流层:技能、知识库、交付检查、更安全的配置写入,以及可恢复的执行流程。**
10
10
 
11
- [![Version](https://img.shields.io/badge/version-3.0.21-orange.svg)](./package.json)
11
+ [![Version](https://img.shields.io/badge/version-3.0.22-orange.svg)](./package.json)
12
12
  [![npm](https://img.shields.io/npm/v/helloagents.svg)](https://www.npmjs.com/package/helloagents)
13
13
  [![Node](https://img.shields.io/badge/node-%3E%3D18-339933.svg)](./package.json)
14
14
  [![Skills](https://img.shields.io/badge/skills-14-6366f1.svg)](./skills)
@@ -647,6 +647,7 @@ Codex 默认走规则文件驱动。
647
647
  - 标准模式创建 `~/.codex/helloagents -> ~/.helloagents/helloagents`
648
648
  - 全局模式安装原生本地插件流程,并同样用 `~/.codex/hooks.json` 加载静默 hooks
649
649
  - Codex hooks 只做静默运行态同步和 Stop 门禁,不通过 hook 注入 HelloAGENTS 规则或路由说明
650
+ - Codex 收尾会对 Stop hook 和原生 `codex-notify` 去重,避免同一轮重复通知
650
651
  - `/goal` 保持 Codex 原生能力;需要长程执行时,用 `helloagents codex goals enable` 显式启用
651
652
  - 感知 goal 的命令从 `tasks.md`、`contract.json` 和 `state_path` 恢复;不会自动创建 goal,也不会在 HelloAGENTS 验证和收尾前标记完成
652
653
 
@@ -658,7 +659,7 @@ Codex 默认走规则文件驱动。
658
659
  npm test
659
660
  ```
660
661
 
661
- 当前测试覆盖:
662
+ 当前测试共 103 项,覆盖:
662
663
 
663
664
  - 安装、更新、卸载、清理和模式切换
664
665
  - Claude、Gemini、Codex 的配置合并与恢复
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "helloagents",
3
- "version": "3.0.21-beta.1",
3
+ "version": "3.0.22-beta.1",
4
4
  "description": "Quality-driven orchestration kernel for AI CLIs",
5
5
  "contextFileName": "bootstrap.md",
6
6
  "author": "HelloWind",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "helloagents",
3
- "version": "3.0.21-beta.1",
3
+ "version": "3.0.22-beta.1",
4
4
  "type": "module",
5
5
  "description": "HelloAGENTS — The orchestration kernel that makes any AI CLI smarter. Adds intelligent routing, quality verification (Ralph Loop), safety guards, and notifications.",
6
6
  "author": "HelloWind",
@@ -0,0 +1,213 @@
1
+ import { createHash } from 'node:crypto'
2
+ import { closeSync, mkdirSync, openSync, readFileSync, statSync, unlinkSync, writeFileSync } from 'node:fs'
3
+ import { dirname } from 'node:path'
4
+
5
+ import { getRuntimeEvidencePath, readRuntimeEvidence, writeRuntimeEvidence } from './runtime-artifacts.mjs'
6
+
7
+ export const CODEX_CLOSEOUT_EVIDENCE_FILE = 'codex-native-stop.json'
8
+ const CODEX_CLOSEOUT_LOCK_FILE = 'codex-native-stop.lock'
9
+ const WEAK_KEY_TTL_MS = 10_000
10
+ const LOCK_STALE_MS = 120_000
11
+
12
+ function getTurnId(payload = {}) {
13
+ return String(payload.turnId || payload.turn_id || payload['turn-id'] || '').trim()
14
+ }
15
+
16
+ function getSessionId(payload = {}) {
17
+ return String(payload.sessionId || payload.session_id || payload['session-id'] || '').trim()
18
+ }
19
+
20
+ function normalizeMessage(message = '') {
21
+ return String(message || '')
22
+ .replace(/\s+/g, ' ')
23
+ .trim()
24
+ .slice(0, 240)
25
+ }
26
+
27
+ function hashValue(value = '') {
28
+ return createHash('sha1').update(String(value)).digest('hex').slice(0, 16)
29
+ }
30
+
31
+ function uniqueKeys(values = []) {
32
+ return Array.from(new Set(values.filter(Boolean)))
33
+ }
34
+
35
+ function readLockPayload(lockPath) {
36
+ try {
37
+ return JSON.parse(readFileSync(lockPath, 'utf-8'))
38
+ } catch {
39
+ return null
40
+ }
41
+ }
42
+
43
+ function isLockStale(lockPath, now = Date.now()) {
44
+ try {
45
+ const stat = statSync(lockPath)
46
+ return now - stat.mtimeMs > LOCK_STALE_MS
47
+ } catch {
48
+ return false
49
+ }
50
+ }
51
+
52
+ function writeLockFile(lockPath, payload) {
53
+ mkdirSync(dirname(lockPath), { recursive: true })
54
+ const fd = openSync(lockPath, 'wx')
55
+ try {
56
+ writeFileSync(fd, `${JSON.stringify(payload, null, 2)}\n`, 'utf-8')
57
+ } finally {
58
+ closeSync(fd)
59
+ }
60
+ }
61
+
62
+ function releaseLockFile(lockPath) {
63
+ try {
64
+ unlinkSync(lockPath)
65
+ } catch {}
66
+ }
67
+
68
+ function intersects(left = [], right = []) {
69
+ if (!left.length || !right.length) return false
70
+ const rightSet = new Set(right)
71
+ return left.some((item) => rightSet.has(item))
72
+ }
73
+
74
+ export function buildCodexCloseoutSnapshot({ payload = {}, turnState = null } = {}) {
75
+ const turnId = getTurnId(payload)
76
+ const sessionId = getSessionId(payload)
77
+ const message = normalizeMessage(
78
+ payload.lastAssistantMessage
79
+ || payload.last_assistant_message
80
+ || payload['last-assistant-message']
81
+ || '',
82
+ )
83
+ const messageHash = message ? hashValue(message) : ''
84
+
85
+ const strongKeys = uniqueKeys([
86
+ turnId ? `turn:${turnId}` : '',
87
+ turnState?.key && turnState?.updatedAt
88
+ ? `state:${turnState.key}:${turnState.updatedAt}`
89
+ : '',
90
+ ])
91
+ const weakKeys = uniqueKeys([
92
+ sessionId && messageHash ? `session-message:${sessionId}:${messageHash}` : '',
93
+ !sessionId && messageHash ? `message:${messageHash}` : '',
94
+ ])
95
+
96
+ return {
97
+ turnId,
98
+ sessionId,
99
+ messageHash,
100
+ strongKeys,
101
+ weakKeys,
102
+ }
103
+ }
104
+
105
+ export function matchesCodexCloseoutEvidence(evidence, snapshot, now = Date.now()) {
106
+ if (!evidence || typeof evidence !== 'object') return false
107
+
108
+ const strongKeys = Array.isArray(evidence.strongKeys) ? evidence.strongKeys : []
109
+ const weakKeys = Array.isArray(evidence.weakKeys) ? evidence.weakKeys : []
110
+ if (intersects(snapshot.strongKeys, strongKeys)) return true
111
+
112
+ const currentHasStrong = snapshot.strongKeys.length > 0
113
+ const evidenceHasStrong = strongKeys.length > 0
114
+ if (currentHasStrong && evidenceHasStrong) return false
115
+
116
+ const updatedAt = Date.parse(evidence.updatedAt || '')
117
+ if (!Number.isFinite(updatedAt) || now - updatedAt > WEAK_KEY_TTL_MS) return false
118
+ return intersects(snapshot.weakKeys, weakKeys)
119
+ }
120
+
121
+ /**
122
+ * Try to claim the current Codex closeout so Stop and native notify handle one turn only once.
123
+ */
124
+ export function beginCodexCloseoutClaim(cwd, { payload = {}, turnState = null, source = '' } = {}) {
125
+ const snapshot = buildCodexCloseoutSnapshot({ payload, turnState })
126
+ const lockPath = getRuntimeEvidencePath(cwd, CODEX_CLOSEOUT_LOCK_FILE, { payload })
127
+ const evidencePath = getRuntimeEvidencePath(cwd, CODEX_CLOSEOUT_EVIDENCE_FILE, { payload })
128
+ const now = Date.now()
129
+ const lockPayload = {
130
+ source,
131
+ pid: process.pid,
132
+ createdAt: new Date(now).toISOString(),
133
+ turnId: snapshot.turnId,
134
+ sessionId: snapshot.sessionId,
135
+ }
136
+
137
+ try {
138
+ writeLockFile(lockPath, lockPayload)
139
+ } catch (error) {
140
+ if (error?.code === 'EEXIST' && isLockStale(lockPath, now)) {
141
+ releaseLockFile(lockPath)
142
+ try {
143
+ writeLockFile(lockPath, lockPayload)
144
+ } catch (retryError) {
145
+ if (retryError?.code !== 'EEXIST') throw retryError
146
+ }
147
+ } else if (error?.code !== 'EEXIST') {
148
+ throw error
149
+ }
150
+ }
151
+
152
+ const lockOwner = readLockPayload(lockPath)
153
+ if (
154
+ !lockOwner
155
+ || lockOwner.createdAt !== lockPayload.createdAt
156
+ || lockOwner.source !== source
157
+ || lockOwner.pid !== process.pid
158
+ ) {
159
+ return {
160
+ claimed: false,
161
+ reason: 'busy',
162
+ snapshot,
163
+ evidencePath,
164
+ }
165
+ }
166
+
167
+ const evidence = readRuntimeEvidence(cwd, CODEX_CLOSEOUT_EVIDENCE_FILE, { payload })
168
+ if (matchesCodexCloseoutEvidence(evidence, snapshot, now)) {
169
+ releaseLockFile(lockPath)
170
+ return {
171
+ claimed: false,
172
+ reason: 'duplicate',
173
+ snapshot,
174
+ evidencePath,
175
+ }
176
+ }
177
+
178
+ return {
179
+ claimed: true,
180
+ cwd,
181
+ payload,
182
+ source,
183
+ lockPath,
184
+ evidencePath,
185
+ snapshot,
186
+ }
187
+ }
188
+
189
+ /**
190
+ * Persist the handled closeout fingerprint and release the in-flight lock.
191
+ */
192
+ export function finalizeCodexCloseoutClaim(claim, meta = {}) {
193
+ if (!claim?.claimed) return
194
+
195
+ try {
196
+ if (meta.handled !== false) {
197
+ writeRuntimeEvidence(claim.cwd, CODEX_CLOSEOUT_EVIDENCE_FILE, {
198
+ version: 2,
199
+ updatedAt: new Date().toISOString(),
200
+ source: meta.source || claim.source || '',
201
+ turnKind: meta.turnKind || '',
202
+ event: meta.event || '',
203
+ turnId: claim.snapshot.turnId,
204
+ sessionId: claim.snapshot.sessionId,
205
+ messageHash: claim.snapshot.messageHash,
206
+ strongKeys: claim.snapshot.strongKeys,
207
+ weakKeys: claim.snapshot.weakKeys,
208
+ }, { payload: claim.payload })
209
+ }
210
+ } finally {
211
+ releaseLockFile(claim.lockPath)
212
+ }
213
+ }
@@ -7,6 +7,7 @@ import { readFileSync } from 'node:fs';
7
7
  import { fileURLToPath } from 'node:url';
8
8
  import { homedir } from 'node:os';
9
9
  import { playSound as _playSound, desktopNotify as _desktopNotify } from './notify-ui.mjs';
10
+ import { beginCodexCloseoutClaim, finalizeCodexCloseoutClaim } from './notify-closeout.mjs';
10
11
  import { resolveNotificationSource } from './notify-source.mjs';
11
12
  import { buildCompactionContext, buildInjectContext, buildRouteInstruction, buildSemanticRouteInstruction, resolveCanonicalCommandSkill } from './notify-context.mjs';
12
13
  import { resolveNotifyHost, shouldIgnoreCodexNotifyClient } from './notify-events.mjs';
@@ -16,7 +17,6 @@ import { cleanupProjectSessions } from './project-session-cleanup.mjs';
16
17
  import { handleRouteCommand, resolveBootstrapFile } from './notify-route.mjs';
17
18
  import { readSettings, readStdinJson, output, suppressedOutput, emptySuppress } from './notify-shared.mjs';
18
19
  import { clearRouteContext, getApplicableRouteContext, writeRouteContext } from './runtime-context.mjs';
19
- import { readRuntimeEvidence, writeRuntimeEvidence } from './runtime-artifacts.mjs';
20
20
  import { appendReplayEvent, startReplaySession } from './replay-state.mjs';
21
21
  import { clearTurnState, readTurnState } from './turn-state.mjs';
22
22
  import { getWorkflowRecommendation } from './workflow-state.mjs';
@@ -38,7 +38,6 @@ const EVENT_NAME = {
38
38
  PreCompact: IS_GEMINI ? 'BeforeAgent' : 'PreCompact',
39
39
  };
40
40
  const RALPH_LOOP_ROUTE_COMMANDS = new Set(['verify', 'loop']);
41
- const CODEX_NATIVE_STOP_FILE = 'codex-native-stop.json';
42
41
 
43
42
  const playSound = (event) => _playSound(PKG_ROOT, event);
44
43
  const desktopNotify = (event, extra) => _desktopNotify(PKG_ROOT, event, extra);
@@ -140,27 +139,6 @@ function attachTurnSession(payload = {}, cwd = payload.cwd || process.cwd()) {
140
139
  return { ...payload, sessionId };
141
140
  }
142
141
 
143
- function getCodexTurnId(payload = {}) {
144
- return String(payload.turnId || payload.turn_id || payload['turn-id'] || '').trim();
145
- }
146
-
147
- function markCodexNativeStopProcessed(cwd, payload = {}) {
148
- if (!IS_CODEX) return;
149
- const turnId = getCodexTurnId(payload);
150
- if (!turnId) return;
151
- writeRuntimeEvidence(cwd, CODEX_NATIVE_STOP_FILE, {
152
- turnId,
153
- updatedAt: new Date().toISOString(),
154
- }, { payload });
155
- }
156
-
157
- function hasCodexNativeStopProcessed(cwd, payload = {}) {
158
- const turnId = getCodexTurnId(payload);
159
- if (!turnId) return false;
160
- const evidence = readRuntimeEvidence(cwd, CODEX_NATIVE_STOP_FILE, { payload });
161
- return evidence?.turnId === turnId;
162
- }
163
-
164
142
  function readMainTurnState(cwd, payload = {}) {
165
143
  const turnState = readTurnState(cwd, { payload });
166
144
  return turnState?.role === 'main' ? turnState : null;
@@ -175,6 +153,43 @@ function shouldProcessCloseout(turnState) {
175
153
  return false;
176
154
  }
177
155
 
156
+ function processTurnCloseout(payload, turnPayload, turnState, settings = getSettings()) {
157
+ const cwd = turnPayload.cwd || process.cwd();
158
+
159
+ if (runTurnStopGate(turnPayload)) {
160
+ if (turnState && turnState.kind !== 'complete') consumeMainTurnState(cwd, turnState, turnPayload);
161
+ return { blocked: true };
162
+ }
163
+
164
+ if (!turnState) {
165
+ notifyByLevel('complete', buildNotifyExtra(turnPayload), settings);
166
+ clearRouteContext({ cwd, payload: turnPayload });
167
+ return { blocked: false };
168
+ }
169
+
170
+ if (turnState.kind !== 'complete') {
171
+ consumeMainTurnState(cwd, turnState, turnPayload);
172
+ clearRouteContext({ cwd, payload: turnPayload });
173
+ return { blocked: false };
174
+ }
175
+
176
+ if (runRalphLoop(turnPayload, { turnState })) {
177
+ consumeMainTurnState(cwd, turnState, turnPayload);
178
+ notifyByLevel('warning', buildNotifyExtra(payload), settings);
179
+ return { blocked: true };
180
+ }
181
+ if (runDeliveryGate(turnPayload)) {
182
+ consumeMainTurnState(cwd, turnState, turnPayload);
183
+ notifyByLevel('warning', buildNotifyExtra(payload), settings);
184
+ return { blocked: true };
185
+ }
186
+
187
+ notifyByLevel('complete', buildNotifyExtra(payload), settings);
188
+ consumeMainTurnState(cwd, turnState, turnPayload);
189
+ clearRouteContext({ cwd, payload: turnPayload });
190
+ return { blocked: false };
191
+ }
192
+
178
193
  function cmdPreCompact() {
179
194
  const payload = readPayloadFromStdin();
180
195
  const cwd = payload.cwd || process.cwd();
@@ -275,37 +290,29 @@ function cmdStop() {
275
290
  const payload = readPayloadFromStdin();
276
291
  const cwd = payload.cwd || process.cwd();
277
292
  const turnPayload = attachTurnSession(payload, cwd);
278
- if (IS_CODEX && hasCodexNativeStopProcessed(cwd, turnPayload)) {
279
- emptySuppress();
280
- return;
281
- }
282
293
  const turnState = readMainTurnState(cwd, turnPayload);
283
- if (runTurnStopGate(turnPayload)) {
284
- if (turnState && turnState.kind !== 'complete') consumeMainTurnState(cwd, turnState, turnPayload);
285
- markCodexNativeStopProcessed(cwd, turnPayload);
286
- return;
287
- }
288
- const shouldProcess = shouldProcessCloseout(turnState);
289
- if (shouldProcess && runRalphLoop(turnPayload, { turnState })) {
290
- consumeMainTurnState(cwd, turnState, turnPayload);
291
- notifyByLevel('warning', buildNotifyExtra(payload));
292
- markCodexNativeStopProcessed(cwd, turnPayload);
293
- return;
294
- }
295
- if (shouldProcess && runDeliveryGate(turnPayload)) {
296
- consumeMainTurnState(cwd, turnState, turnPayload);
297
- notifyByLevel('warning', buildNotifyExtra(payload));
298
- markCodexNativeStopProcessed(cwd, turnPayload);
294
+ const closeoutClaim = IS_CODEX
295
+ ? beginCodexCloseoutClaim(cwd, { payload: turnPayload, turnState, source: 'stop' })
296
+ : null;
297
+ if (IS_CODEX && !closeoutClaim?.claimed) {
298
+ emptySuppress();
299
299
  return;
300
300
  }
301
301
 
302
- const settings = getSettings();
303
- if (shouldProcess || !turnState) {
304
- notifyByLevel('complete', buildNotifyExtra(payload), settings);
302
+ let handled = false;
303
+ let result = { blocked: false };
304
+ try {
305
+ result = processTurnCloseout(payload, turnPayload, turnState, getSettings());
306
+ handled = true;
307
+ } finally {
308
+ finalizeCodexCloseoutClaim(closeoutClaim, {
309
+ handled,
310
+ source: 'stop',
311
+ event: 'stop',
312
+ turnKind: turnState?.kind || '',
313
+ });
305
314
  }
306
- consumeMainTurnState(cwd, turnState, turnPayload);
307
- clearRouteContext({ cwd, payload: turnPayload });
308
- markCodexNativeStopProcessed(cwd, turnPayload);
315
+ if (result.blocked) return;
309
316
  emptySuppress();
310
317
  }
311
318
 
@@ -333,39 +340,27 @@ function cmdCodexNotify() {
333
340
  return;
334
341
  }
335
342
  if (type !== 'agent-turn-complete') return;
336
- if (hasCodexNativeStopProcessed(cwd, turnPayload)) return;
337
343
 
338
344
  const turnState = readMainTurnState(cwd, turnPayload);
339
- if (runTurnStopGate(turnPayload)) {
340
- if (turnState && turnState.kind !== 'complete') consumeMainTurnState(cwd, turnState, turnPayload);
341
- return;
342
- }
343
- if (!turnState) {
344
- notifyByLevel('complete', buildNotifyExtra(data), getSettings());
345
- clearRouteContext({ cwd, payload: turnPayload });
346
- return;
347
- }
348
- if (turnState.kind !== 'complete') {
349
- consumeMainTurnState(cwd, turnState, turnPayload);
350
- clearRouteContext({ cwd, payload: turnPayload });
351
- return;
352
- }
345
+ const closeoutClaim = beginCodexCloseoutClaim(cwd, {
346
+ payload: turnPayload,
347
+ turnState,
348
+ source: 'codex-notify',
349
+ });
350
+ if (!closeoutClaim.claimed) return;
353
351
 
354
- const settings = getSettings();
355
- if (runRalphLoop(turnPayload, { turnState })) {
356
- consumeMainTurnState(cwd, turnState, turnPayload);
357
- notifyByLevel('warning', buildNotifyExtra(data), settings);
358
- return;
359
- }
360
- if (runDeliveryGate(turnPayload)) {
361
- consumeMainTurnState(cwd, turnState, turnPayload);
362
- notifyByLevel('warning', buildNotifyExtra(data), settings);
363
- return;
352
+ let handled = false;
353
+ try {
354
+ processTurnCloseout(data, turnPayload, turnState, getSettings());
355
+ handled = true;
356
+ } finally {
357
+ finalizeCodexCloseoutClaim(closeoutClaim, {
358
+ handled,
359
+ source: 'codex-notify',
360
+ event: type,
361
+ turnKind: turnState?.kind || '',
362
+ });
364
363
  }
365
-
366
- notifyByLevel('complete', buildNotifyExtra(data), settings);
367
- consumeMainTurnState(cwd, turnState, turnPayload);
368
- clearRouteContext({ cwd, payload: turnPayload });
369
364
  }
370
365
 
371
366
  switch (cmd) {