helloagents 3.0.22 → 3.0.25

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.
@@ -0,0 +1,264 @@
1
+ import { createHash } from 'node:crypto'
2
+
3
+ import { isTomlTableHeader, normalizeToml } from './cli-toml.mjs'
4
+ import { removeIfExists, safeJson, safeRead, safeWrite } from './cli-utils.mjs'
5
+
6
+ const MANAGED_MARKER = '# helloagents-managed'
7
+
8
+ const HOOK_EVENT_KEY = {
9
+ PreToolUse: 'pre_tool_use',
10
+ PermissionRequest: 'permission_request',
11
+ PostToolUse: 'post_tool_use',
12
+ PreCompact: 'pre_compact',
13
+ PostCompact: 'post_compact',
14
+ SessionStart: 'session_start',
15
+ UserPromptSubmit: 'user_prompt_submit',
16
+ Stop: 'stop',
17
+ }
18
+
19
+ const EVENTS_WITH_MATCHER = new Set([
20
+ 'PreToolUse',
21
+ 'PermissionRequest',
22
+ 'PostToolUse',
23
+ 'PreCompact',
24
+ 'PostCompact',
25
+ 'SessionStart',
26
+ ])
27
+
28
+ const HOOK_STATE_HEADER_RE = /^\[hooks\.state\."((?:\\.|[^"])*)"\](?:\s*#.*)?$/
29
+
30
+ function normalizeLineEndings(text = '') {
31
+ return String(text || '').replace(/\r\n/g, '\n')
32
+ }
33
+
34
+ function escapeTomlBasicString(value = '') {
35
+ return String(value || '')
36
+ .replace(/\\/g, '\\\\')
37
+ .replace(/"/g, '\\"')
38
+ }
39
+
40
+ function unescapeTomlBasicString(value = '') {
41
+ return String(value || '')
42
+ .replace(/\\"/g, '"')
43
+ .replace(/\\\\/g, '\\')
44
+ }
45
+
46
+ function canonicalizeJson(value) {
47
+ if (Array.isArray(value)) return value.map(canonicalizeJson)
48
+ if (!value || typeof value !== 'object') return value
49
+
50
+ return Object.keys(value)
51
+ .sort()
52
+ .reduce((acc, key) => {
53
+ if (value[key] !== undefined) acc[key] = canonicalizeJson(value[key])
54
+ return acc
55
+ }, {})
56
+ }
57
+
58
+ function hashNormalizedHookIdentity(identity) {
59
+ const serialized = JSON.stringify(canonicalizeJson(identity))
60
+ return `sha256:${createHash('sha256').update(serialized).digest('hex')}`
61
+ }
62
+
63
+ function normalizeHookMatcher(eventName, matcher) {
64
+ if (!EVENTS_WITH_MATCHER.has(eventName)) return undefined
65
+ return matcher === undefined ? undefined : String(matcher)
66
+ }
67
+
68
+ function normalizeHookTimeout(timeout) {
69
+ const value = Number(timeout)
70
+ if (!Number.isFinite(value)) return 600
71
+ return Math.max(1, Math.trunc(value))
72
+ }
73
+
74
+ function buildHookDescriptor(eventName, group, handler) {
75
+ return JSON.stringify({
76
+ eventName,
77
+ matcher: normalizeHookMatcher(eventName, group?.matcher),
78
+ command: handler?.command || '',
79
+ })
80
+ }
81
+
82
+ function buildNormalizedHookIdentity(eventName, group, handler) {
83
+ const matcher = normalizeHookMatcher(eventName, group?.matcher)
84
+ const statusMessage = typeof handler?.statusMessage === 'string'
85
+ ? handler.statusMessage
86
+ : undefined
87
+
88
+ return {
89
+ event_name: HOOK_EVENT_KEY[eventName],
90
+ ...(matcher !== undefined ? { matcher } : {}),
91
+ hooks: [
92
+ {
93
+ type: 'command',
94
+ command: String(handler?.command || ''),
95
+ timeout: normalizeHookTimeout(handler?.timeout),
96
+ async: Boolean(handler?.async),
97
+ ...(statusMessage !== undefined ? { statusMessage } : {}),
98
+ },
99
+ ],
100
+ }
101
+ }
102
+
103
+ function isHelloagentsCommandHandler(handler) {
104
+ return handler?.type === 'command'
105
+ && typeof handler.command === 'string'
106
+ && handler.command.includes('helloagents')
107
+ }
108
+
109
+ function serializeHookStateBlock(entry) {
110
+ const lines = [`[hooks.state."${escapeTomlBasicString(entry.key)}"]`]
111
+ if (entry.enabled === false) lines.push('enabled = false')
112
+ lines.push(`trusted_hash = "${escapeTomlBasicString(entry.trustedHash)}" ${MANAGED_MARKER}`)
113
+ return lines.join('\n')
114
+ }
115
+
116
+ function collectHookStateSections(text = '') {
117
+ const lines = normalizeLineEndings(text).split('\n')
118
+ const sections = []
119
+
120
+ for (let index = 0; index < lines.length; index += 1) {
121
+ const match = HOOK_STATE_HEADER_RE.exec(lines[index].trim())
122
+ if (!match) continue
123
+
124
+ let end = lines.length
125
+ for (let cursor = index + 1; cursor < lines.length; cursor += 1) {
126
+ if (isTomlTableHeader(lines[cursor])) {
127
+ end = cursor
128
+ break
129
+ }
130
+ }
131
+
132
+ const bodyLines = lines.slice(index + 1, end)
133
+ const enabledLine = bodyLines.find((line) => /^\s*enabled\s*=/.test(line))
134
+ const trustedHashLine = bodyLines.find((line) => /^\s*trusted_hash\s*=/.test(line))
135
+ const trustedHashMatch = trustedHashLine?.match(/^\s*trusted_hash\s*=\s*"((?:\\.|[^"])*)"/)
136
+
137
+ sections.push({
138
+ key: unescapeTomlBasicString(match[1]),
139
+ start: index,
140
+ end,
141
+ enabled: /^\s*enabled\s*=\s*false\b/.test(enabledLine || '') ? false : undefined,
142
+ trustedHash: trustedHashMatch ? unescapeTomlBasicString(trustedHashMatch[1]) : '',
143
+ managed: lines[index].includes(MANAGED_MARKER)
144
+ || bodyLines.some((line) => line.includes(MANAGED_MARKER)),
145
+ })
146
+
147
+ index = end - 1
148
+ }
149
+
150
+ return { lines, sections }
151
+ }
152
+
153
+ function removeHookStateSections(text, shouldRemove) {
154
+ const { lines, sections } = collectHookStateSections(text)
155
+ if (!sections.length) return normalizeToml(text)
156
+
157
+ const removedStarts = new Set(
158
+ sections
159
+ .filter(shouldRemove)
160
+ .map((section) => section.start),
161
+ )
162
+
163
+ if (!removedStarts.size) return normalizeToml(text)
164
+
165
+ const kept = []
166
+ for (let index = 0; index < lines.length;) {
167
+ const section = sections.find((item) => item.start === index)
168
+ if (!section) {
169
+ kept.push(lines[index])
170
+ index += 1
171
+ continue
172
+ }
173
+ if (!removedStarts.has(section.start)) {
174
+ kept.push(...lines.slice(section.start, section.end))
175
+ }
176
+ index = section.end
177
+ }
178
+
179
+ return normalizeToml(kept.join('\n'))
180
+ }
181
+
182
+ function appendHookStateBlocks(text, entries) {
183
+ if (!entries.length) return normalizeToml(text)
184
+ const blocks = entries.map(serializeHookStateBlock).join('\n\n')
185
+ const base = normalizeLineEndings(text).trimEnd()
186
+ return normalizeToml(base ? `${base}\n\n${blocks}` : blocks)
187
+ }
188
+
189
+ export function buildManagedCodexHookTrustEntries(hooksPath, hooksData = safeJson(hooksPath)) {
190
+ const hooks = hooksData?.hooks
191
+ if (!hooks || typeof hooks !== 'object') return []
192
+
193
+ const entries = []
194
+ for (const eventName of Object.keys(HOOK_EVENT_KEY)) {
195
+ const groups = hooks[eventName]
196
+ if (!Array.isArray(groups)) continue
197
+
198
+ groups.forEach((group, groupIndex) => {
199
+ const handlers = Array.isArray(group?.hooks) ? group.hooks : []
200
+ handlers.forEach((handler, handlerIndex) => {
201
+ if (!isHelloagentsCommandHandler(handler)) return
202
+
203
+ const key = `${hooksPath}:${HOOK_EVENT_KEY[eventName]}:${groupIndex}:${handlerIndex}`
204
+ entries.push({
205
+ key,
206
+ trustedHash: hashNormalizedHookIdentity(
207
+ buildNormalizedHookIdentity(eventName, group, handler),
208
+ ),
209
+ descriptor: buildHookDescriptor(eventName, group, handler),
210
+ })
211
+ })
212
+ })
213
+ }
214
+
215
+ return entries
216
+ }
217
+
218
+ export function readCodexHookStateSections(text = '') {
219
+ return collectHookStateSections(text).sections
220
+ }
221
+
222
+ export function syncManagedCodexHookTrust(configPath, hooksPath, hooksData = safeJson(hooksPath)) {
223
+ const entries = buildManagedCodexHookTrustEntries(hooksPath, hooksData)
224
+ if (!entries.length) return cleanupManagedCodexHookTrust(configPath)
225
+
226
+ const keySet = new Set(entries.map((entry) => entry.key))
227
+ const existingText = safeRead(configPath) || ''
228
+ const existingSections = readCodexHookStateSections(existingText)
229
+ const enabledByDescriptor = new Map()
230
+
231
+ for (const section of existingSections) {
232
+ if (!keySet.has(section.key) || section.enabled !== false) continue
233
+ const matchingEntry = entries.find((entry) => entry.key === section.key)
234
+ if (matchingEntry) enabledByDescriptor.set(matchingEntry.descriptor, false)
235
+ }
236
+
237
+ const cleanedText = removeHookStateSections(
238
+ existingText,
239
+ (section) => section.managed || keySet.has(section.key),
240
+ )
241
+
242
+ const nextEntries = entries.map((entry) => ({
243
+ ...entry,
244
+ enabled: enabledByDescriptor.get(entry.descriptor),
245
+ }))
246
+
247
+ const nextText = appendHookStateBlocks(cleanedText, nextEntries)
248
+ if (normalizeLineEndings(nextText) === normalizeLineEndings(existingText)) return false
249
+
250
+ safeWrite(configPath, nextText)
251
+ return true
252
+ }
253
+
254
+ export function cleanupManagedCodexHookTrust(configPath) {
255
+ const existingText = safeRead(configPath)
256
+ if (!existingText) return false
257
+
258
+ const nextText = removeHookStateSections(existingText, (section) => section.managed)
259
+ if (normalizeLineEndings(nextText) === normalizeLineEndings(existingText)) return false
260
+
261
+ if (nextText.trim()) safeWrite(configPath, nextText)
262
+ else removeIfExists(configPath)
263
+ return true
264
+ }
@@ -1,8 +1,8 @@
1
1
  import { join, dirname } from 'node:path';
2
2
  import { existsSync } from 'node:fs';
3
3
  import {
4
- ensureDir, safeRead, safeWrite, removeIfExists,
5
- readJsonOrThrow, copyEntries,
4
+ ensureDir, safeJson, safeRead, safeWrite, removeIfExists,
5
+ readJsonOrThrow,
6
6
  createLink, removeLink, injectMarkedContent, removeMarkedContent,
7
7
  cleanSettingsHooks, loadHooksWithCliEntry, mergeSettingsHooks,
8
8
  } from './cli-utils.mjs';
@@ -12,6 +12,7 @@ import {
12
12
  CODEX_MANAGED_MODEL_INSTRUCTIONS_PATH,
13
13
  CODEX_PLUGIN_CONFIG_HEADER,
14
14
  installCodexManagedTopLevelConfig,
15
+ installCodexManagedTuiConfig,
15
16
  isManagedCodexBackupInstruction,
16
17
  isManagedCodexGoalsFeature,
17
18
  isManagedCodexModelInstruction,
@@ -20,12 +21,17 @@ import {
20
21
  readCodexGoalsFeatureLine,
21
22
  readLegacyCodexHooksFeatureLine,
22
23
  removeCodexGoalsFeatureConfig,
24
+ removeCodexManagedTuiConfig,
23
25
  removeLegacyManagedCodexHooksFeatureConfig,
24
26
  removeCodexPluginConfig,
25
27
  restoreCodexGoalsFeatureConfig,
26
28
  restoreCodexTopLevelConfig,
27
29
  upsertCodexPluginConfig,
28
30
  } from './cli-codex-config.mjs';
31
+ import {
32
+ cleanupManagedCodexHookTrust,
33
+ syncManagedCodexHookTrust,
34
+ } from './cli-codex-hooks-state.mjs';
29
35
  import {
30
36
  readTopLevelTomlLine,
31
37
  readTopLevelTomlBlock,
@@ -132,11 +138,14 @@ function writeCodexRuntimeCarrier(filePath, bootstrapPath, settings) {
132
138
  function installCodexStandaloneHooks(home, pkgRoot) {
133
139
  const hooksData = loadHooksWithCliEntry(pkgRoot, 'hooks-codex.json', '${PLUGIN_ROOT}');
134
140
  if (!hooksData) return false;
135
- mergeSettingsHooks(join(home, '.codex', CODEX_HOOKS_BASENAME), hooksData);
141
+ const hooksPath = join(home, '.codex', CODEX_HOOKS_BASENAME);
142
+ mergeSettingsHooks(hooksPath, hooksData);
143
+ syncManagedCodexHookTrust(join(home, '.codex', CODEX_CONFIG_BASENAME), hooksPath, safeJson(hooksPath));
136
144
  return true;
137
145
  }
138
146
 
139
147
  function cleanupCodexStandaloneHooks(home) {
148
+ cleanupManagedCodexHookTrust(join(home, '.codex', CODEX_CONFIG_BASENAME));
140
149
  cleanSettingsHooks(join(home, '.codex', CODEX_HOOKS_BASENAME));
141
150
  }
142
151
 
@@ -160,6 +169,7 @@ function cleanupCodexManagedConfig(configPath, { removePluginConfig = false } =
160
169
  if (shouldRestoreCodexGoalsFeature) {
161
170
  toml = removeCodexGoalsFeatureConfig(toml);
162
171
  }
172
+ toml = removeCodexManagedTuiConfig(toml);
163
173
  if (shouldRemoveLegacyCodexHooksFeature) {
164
174
  toml = removeLegacyManagedCodexHooksFeatureConfig(toml);
165
175
  }
@@ -209,6 +219,7 @@ export function installCodexStandby(home, pkgRoot) {
209
219
  toml = installCodexManagedTopLevelConfig(toml, {
210
220
  modelInstructionsPath: CODEX_MANAGED_MODEL_INSTRUCTIONS_PATH,
211
221
  });
222
+ toml = installCodexManagedTuiConfig(toml);
212
223
  toml = removeLegacyManagedCodexHooksFeatureConfig(toml);
213
224
  safeWrite(configPath, toml);
214
225
  installCodexStandaloneHooks(home, pkgRoot);
@@ -277,20 +288,15 @@ export function installCodexGlobal(home, pkgRoot) {
277
288
  removeIfExists(join(codexDir, 'plugins', 'cache', CODEX_MARKETPLACE_NAME, CODEX_PLUGIN_NAME));
278
289
 
279
290
  ensureDir(join(home, 'plugins'));
280
- ensureDir(installedPluginRoot);
291
+ ensureDir(dirname(installedPluginRoot));
281
292
 
282
293
  const settings = readCarrierSettings(home);
283
- copyEntries(pkgRoot, pluginRoot, CODEX_RUNTIME_ENTRIES);
284
- copyEntries(pkgRoot, installedPluginRoot, CODEX_RUNTIME_ENTRIES);
285
- createLink(pluginRoot, join(codexDir, 'helloagents'));
286
- writeCodexRuntimeCarrier(
287
- join(pluginRoot, CODEX_RUNTIME_CARRIER),
288
- join(pluginRoot, 'bootstrap.md'),
289
- settings,
290
- );
294
+ createLink(pkgRoot, pluginRoot);
295
+ createLink(pkgRoot, installedPluginRoot);
296
+ createLink(pkgRoot, join(codexDir, 'helloagents'));
291
297
  writeCodexRuntimeCarrier(
292
- join(installedPluginRoot, CODEX_RUNTIME_CARRIER),
293
- join(installedPluginRoot, 'bootstrap.md'),
298
+ join(pkgRoot, CODEX_RUNTIME_CARRIER),
299
+ join(pkgRoot, 'bootstrap.md'),
294
300
  settings,
295
301
  );
296
302
  const homeCarrierPath = join(codexDir, CODEX_RUNTIME_CARRIER);
@@ -304,6 +310,7 @@ export function installCodexGlobal(home, pkgRoot) {
304
310
  toml = installCodexManagedTopLevelConfig(toml, {
305
311
  modelInstructionsPath: CODEX_MANAGED_MODEL_INSTRUCTIONS_PATH,
306
312
  });
313
+ toml = installCodexManagedTuiConfig(toml);
307
314
  toml = removeLegacyManagedCodexHooksFeatureConfig(toml);
308
315
  toml = upsertCodexPluginConfig(toml);
309
316
  safeWrite(configPath, toml);
@@ -9,6 +9,11 @@ import {
9
9
  readCodexHooksFeatureLine,
10
10
  readLegacyCodexHooksFeatureLine,
11
11
  } from './cli-codex-config.mjs'
12
+ import {
13
+ buildManagedCodexHookTrustEntries,
14
+ readCodexHookStateSections,
15
+ } from './cli-codex-hooks-state.mjs'
16
+ import { getStableRuntimeRoot } from './cli-runtime-root.mjs'
12
17
  import { buildRuntimeCarrier } from './cli-runtime-carrier.mjs'
13
18
  import { readTopLevelTomlLine } from './cli-toml.mjs'
14
19
  import { loadHooksWithCliEntry, safeJson, safeRead } from './cli-utils.mjs'
@@ -101,6 +106,8 @@ function appendCodexStandbyIssues(runtime, issues, checks) {
101
106
  if (!checks.codexHooksFeature) issues.push(buildDoctorIssue(runtime, 'codex-hooks-feature-disabled', 'Codex hooks 功能被显式关闭', 'Codex hooks feature is explicitly disabled'))
102
107
  if (!checks.standaloneHooks) issues.push(buildDoctorIssue(runtime, 'standby-hooks-missing', 'standby `~/.codex/hooks.json` 缺少 HelloAGENTS hooks', 'Standby `~/.codex/hooks.json` is missing HelloAGENTS hooks'))
103
108
  if (checks.standaloneHooks && !checks.standaloneHooksMatch) issues.push(buildDoctorIssue(runtime, 'standby-hooks-drift', 'standby `~/.codex/hooks.json` 与当前 hooks-codex.json 不一致', 'Standby `~/.codex/hooks.json` differs from the current hooks-codex.json'))
109
+ if (checks.standaloneHooks && !checks.managedHookTrust) issues.push(buildDoctorIssue(runtime, 'standby-hook-trust-missing', 'standby `config.toml` 缺少 HelloAGENTS hooks trust', 'Standby `config.toml` is missing HelloAGENTS hook trust metadata'))
110
+ if (checks.standaloneHooks && checks.managedHookTrust && !checks.managedHookTrustMatch) issues.push(buildDoctorIssue(runtime, 'standby-hook-trust-drift', 'standby hooks trust 与当前 hooks 定义不一致', 'Standby hook trust metadata differs from the current hooks definition'))
104
111
  if (checks.pluginRoot || checks.pluginCache || checks.marketplaceEntry || checks.pluginEnabled) {
105
112
  issues.push(buildDoctorIssue(runtime, 'standby-global-residue', 'standby 模式下仍残留 global 插件文件或配置', 'Global plugin artifacts still remain while Codex is in standby mode'))
106
113
  }
@@ -109,9 +116,11 @@ function appendCodexStandbyIssues(runtime, issues, checks) {
109
116
  function appendCodexGlobalIssues(runtime, issues, checks, pluginVersion, cacheVersion) {
110
117
  if (!checks.carrierMarker) issues.push(buildDoctorIssue(runtime, 'global-home-carrier-missing', 'global `~/.codex/AGENTS.md` 缺少 HelloAGENTS 规则内容', 'Global `~/.codex/AGENTS.md` is missing the HelloAGENTS carrier'))
111
118
  if (checks.carrierMarker && !checks.carrierContentMatch) issues.push(buildDoctorIssue(runtime, 'global-home-carrier-drift', 'global `~/.codex/AGENTS.md` 与当前全局模式规则不一致', 'Global `~/.codex/AGENTS.md` differs from the current global rules'))
112
- if (!checks.globalHomeLink) issues.push(buildDoctorIssue(runtime, 'global-read-root-link-missing', 'global `~/.codex/helloagents` 链接缺失或未指向当前插件根目录', 'Global `~/.codex/helloagents` link is missing or does not point to the current plugin root'))
119
+ if (!checks.globalHomeLink) issues.push(buildDoctorIssue(runtime, 'global-read-root-link-missing', 'global `~/.codex/helloagents` 链接缺失或未指向稳定运行根目录', 'Global `~/.codex/helloagents` link is missing or does not point to the stable runtime root'))
113
120
  if (!checks.pluginRoot) issues.push(buildDoctorIssue(runtime, 'global-plugin-root-missing', 'global 插件根目录缺失', 'Global plugin root is missing'))
114
121
  if (!checks.pluginCache) issues.push(buildDoctorIssue(runtime, 'global-plugin-cache-missing', 'global 插件缓存目录缺失', 'Global plugin cache directory is missing'))
122
+ if (checks.pluginRoot && !checks.pluginRootLink) issues.push(buildDoctorIssue(runtime, 'global-plugin-root-link-drift', 'global 插件根目录未链接到稳定运行根目录', 'Global plugin root does not link to the stable runtime root'))
123
+ if (checks.pluginCache && !checks.pluginCacheLink) issues.push(buildDoctorIssue(runtime, 'global-plugin-cache-link-drift', 'global 插件缓存未链接到稳定运行根目录', 'Global plugin cache does not link to the stable runtime root'))
115
124
  if (checks.pluginRoot && !checks.pluginCarrierMatch) issues.push(buildDoctorIssue(runtime, 'global-plugin-carrier-drift', 'global 插件根目录中的 AGENTS.md 与当前全局模式规则不一致', 'Global plugin AGENTS.md differs from the current global rules'))
116
125
  if (checks.pluginCache && !checks.pluginCacheCarrierMatch) issues.push(buildDoctorIssue(runtime, 'global-plugin-cache-carrier-drift', 'global 插件缓存中的 AGENTS.md 与当前全局模式规则不一致', 'Global plugin cache AGENTS.md differs from the current global rules'))
117
126
  if (!checks.marketplaceEntry) issues.push(buildDoctorIssue(runtime, 'global-marketplace-missing', 'global marketplace 条目缺失', 'Global marketplace entry is missing'))
@@ -123,9 +132,10 @@ function appendCodexGlobalIssues(runtime, issues, checks, pluginVersion, cacheVe
123
132
  if (!checks.codexHooksFeature) issues.push(buildDoctorIssue(runtime, 'codex-hooks-feature-disabled', 'Codex hooks 功能被显式关闭', 'Codex hooks feature is explicitly disabled'))
124
133
  if (!checks.standaloneHooks) issues.push(buildDoctorIssue(runtime, 'global-hooks-missing', 'global `~/.codex/hooks.json` 缺少 HelloAGENTS hooks', 'Global `~/.codex/hooks.json` is missing HelloAGENTS hooks'))
125
134
  if (checks.standaloneHooks && !checks.standaloneHooksMatch) issues.push(buildDoctorIssue(runtime, 'global-hooks-drift', 'global `~/.codex/hooks.json` 与当前 hooks-codex.json 不一致', 'Global `~/.codex/hooks.json` differs from the current hooks-codex.json'))
135
+ if (checks.standaloneHooks && !checks.managedHookTrust) issues.push(buildDoctorIssue(runtime, 'global-hook-trust-missing', 'global `config.toml` 缺少 HelloAGENTS hooks trust', 'Global `config.toml` is missing HelloAGENTS hook trust metadata'))
136
+ if (checks.standaloneHooks && checks.managedHookTrust && !checks.managedHookTrustMatch) issues.push(buildDoctorIssue(runtime, 'global-hook-trust-drift', 'global hooks trust 与当前 hooks 定义不一致', 'Global hook trust metadata differs from the current hooks definition'))
126
137
  if (pluginVersion && !checks.pluginVersionMatch) issues.push(buildDoctorIssue(runtime, 'global-plugin-version-drift', 'global 插件根目录版本与当前包版本不一致', 'Global plugin root version does not match the current package version'))
127
138
  if (cacheVersion && !checks.pluginCacheVersionMatch) issues.push(buildDoctorIssue(runtime, 'global-plugin-cache-version-drift', 'global 插件缓存版本与当前包版本不一致', 'Global plugin cache version does not match the current package version'))
128
- if (checks.homeLink) issues.push(buildDoctorIssue(runtime, 'global-standby-link-residue', 'global 模式下仍残留 standby home 链接', 'Standby home link still remains while Codex is in global mode'))
129
139
  }
130
140
 
131
141
  function buildCodexChecks(runtime, settings, trackedMode, detectedMode) {
@@ -133,7 +143,10 @@ function buildCodexChecks(runtime, settings, trackedMode, detectedMode) {
133
143
  const codexConfig = safeRead(join(codexDir, 'config.toml')) || ''
134
144
  const pluginRoot = join(runtime.home, 'plugins', CODEX_PLUGIN_NAME)
135
145
  const pluginCacheRoot = join(codexDir, 'plugins', 'cache', CODEX_MARKETPLACE_NAME, CODEX_PLUGIN_NAME, 'local')
146
+ const runtimeRoot = safeRealTarget(getStableRuntimeRoot(runtime.home)) || normalizePath(getStableRuntimeRoot(runtime.home))
136
147
  const homeLinkTarget = safeRealTarget(join(codexDir, 'helloagents'))
148
+ const pluginRootTarget = safeRealTarget(pluginRoot)
149
+ const pluginCacheTarget = safeRealTarget(pluginCacheRoot)
137
150
  const expectedHomeCarrier = (detectedMode === 'global' || (detectedMode === 'none' && trackedMode === 'global'))
138
151
  ? 'bootstrap.md'
139
152
  : 'bootstrap-lite.md'
@@ -141,6 +154,12 @@ function buildCodexChecks(runtime, settings, trackedMode, detectedMode) {
141
154
  const marketplace = safeJson(join(runtime.home, '.agents', 'plugins', 'marketplace.json')) || {}
142
155
  const modelInstructionsLine = readTopLevelTomlLine(codexConfig, 'model_instructions_file')
143
156
  const expectedHooks = readExpectedHooks(runtime, 'hooks-codex.json', '${PLUGIN_ROOT}')
157
+ const expectedHookTrust = buildManagedCodexHookTrustEntries(join(codexDir, 'hooks.json'), codexHooks)
158
+ const managedHookTrust = new Map(
159
+ readCodexHookStateSections(codexConfig)
160
+ .filter((section) => section.managed)
161
+ .map((section) => [section.key, section.trustedHash]),
162
+ )
144
163
  const hooksFeatureLine = readCodexHooksFeatureLine(codexConfig)
145
164
  const goalsFeatureLine = readCodexGoalsFeatureLine(codexConfig)
146
165
  const legacyHooksFeatureLine = readLegacyCodexHooksFeatureLine(codexConfig)
@@ -151,7 +170,7 @@ function buildCodexChecks(runtime, settings, trackedMode, detectedMode) {
151
170
  carrierContentMatch: normalizeText((safeRead(join(codexDir, 'AGENTS.md')) || '').match(/<!-- HELLOAGENTS_START -->([\s\S]*?)<!-- HELLOAGENTS_END -->/)?.[1] || '')
152
171
  === readExpectedCarrierContent(runtime, expectedHomeCarrier, settings),
153
172
  homeLink: homeLinkTarget === (safeRealTarget(runtime.pkgRoot) || normalizePath(runtime.pkgRoot)),
154
- globalHomeLink: homeLinkTarget === (safeRealTarget(pluginRoot) || normalizePath(pluginRoot)),
173
+ globalHomeLink: homeLinkTarget === runtimeRoot,
155
174
  modelInstructionsFile: !!modelInstructionsLine,
156
175
  modelInstructionsPathMatch: !!modelInstructionsLine && normalizePath(modelInstructionsLine).includes(`"${CODEX_MANAGED_MODEL_INSTRUCTIONS_PATH}"`),
157
176
  codexNotify: codexConfig.includes('codex-notify'),
@@ -162,8 +181,12 @@ function buildCodexChecks(runtime, settings, trackedMode, detectedMode) {
162
181
  legacyCodexHooksFeature: Boolean(legacyHooksFeatureLine),
163
182
  standaloneHooks: JSON.stringify(codexHooks.hooks || {}).includes('helloagents'),
164
183
  standaloneHooksMatch: managedHooksMatch(codexHooks.hooks || {}, expectedHooks),
184
+ managedHookTrust: expectedHookTrust.every((entry) => managedHookTrust.has(entry.key)),
185
+ managedHookTrustMatch: expectedHookTrust.every((entry) => managedHookTrust.get(entry.key) === entry.trustedHash),
165
186
  pluginRoot: existsSync(pluginRoot),
166
187
  pluginCache: existsSync(pluginCacheRoot),
188
+ pluginRootLink: pluginRootTarget === runtimeRoot,
189
+ pluginCacheLink: pluginCacheTarget === runtimeRoot,
167
190
  pluginCarrierMatch: normalizeText(safeRead(join(pluginRoot, 'AGENTS.md')) || '') === readExpectedCarrierContent(runtime, 'bootstrap.md', settings),
168
191
  pluginCacheCarrierMatch: normalizeText(safeRead(join(pluginCacheRoot, 'AGENTS.md')) || '') === readExpectedCarrierContent(runtime, 'bootstrap.md', settings),
169
192
  marketplaceEntry: Array.isArray(marketplace.plugins) && marketplace.plugins.some((plugin) => plugin?.name === CODEX_PLUGIN_NAME),
@@ -6,6 +6,7 @@ import {
6
6
  CODEX_PLUGIN_KEY,
7
7
  CODEX_PLUGIN_NAME,
8
8
  } from './cli-codex.mjs'
9
+ import { getStableRuntimeRoot } from './cli-runtime-root.mjs'
9
10
  import { safeJson, safeRead } from './cli-utils.mjs'
10
11
 
11
12
  const HOST_ALIASES = new Map([
@@ -68,19 +69,18 @@ function detectCodexMode(home) {
68
69
  const codexHomeLink = join(codexDir, 'helloagents')
69
70
  const codexConfig = safeRead(join(codexDir, 'config.toml')) || ''
70
71
  const marketplace = safeRead(join(home, '.agents', 'plugins', 'marketplace.json')) || ''
71
- const globalPluginRoot = normalizePath(join(home, 'plugins', CODEX_PLUGIN_NAME))
72
+ const runtimeRoot = normalizePath(getStableRuntimeRoot(home))
72
73
  const codexHomeLinkTarget = safeRealTarget(codexHomeLink)
73
74
  if (
74
75
  existsSync(join(home, 'plugins', CODEX_PLUGIN_NAME))
75
76
  || existsSync(join(codexDir, 'plugins', 'cache', CODEX_MARKETPLACE_NAME, CODEX_PLUGIN_NAME))
76
77
  || marketplace.includes(`"name": "${CODEX_PLUGIN_NAME}"`)
77
78
  || codexConfig.includes(CODEX_PLUGIN_KEY)
78
- || codexHomeLinkTarget === globalPluginRoot
79
79
  ) {
80
80
  return 'global'
81
81
  }
82
82
  if (
83
- (existsSync(codexHomeLink) && codexHomeLinkTarget !== globalPluginRoot)
83
+ (existsSync(codexHomeLink) && codexHomeLinkTarget === runtimeRoot)
84
84
  || hasHelloagentsMarker(join(codexDir, 'AGENTS.md'))
85
85
  || codexConfig.includes('codex-notify')
86
86
  || codexConfig.includes('HelloAGENTS')
@@ -59,14 +59,18 @@ function clearTrackedHostMode(settings, host) {
59
59
  delete settings.host_install_modes[host]
60
60
  }
61
61
 
62
- function setAllTrackedHostModes(settings, mode) {
63
- settings.host_install_modes = Object.fromEntries(HOSTS.map((host) => [host, mode]))
64
- }
65
-
66
62
  function clearAllTrackedHostModes(settings) {
67
63
  settings.host_install_modes = {}
68
64
  }
69
65
 
66
+ function syncTrackedHostMode(settings, host, result, mode) {
67
+ if (!result?.skipped && result?.ok !== false) {
68
+ setTrackedHostMode(settings, host, mode)
69
+ return
70
+ }
71
+ clearTrackedHostMode(settings, host)
72
+ }
73
+
70
74
  export function normalizeHost(value = '') {
71
75
  return normalizeLifecycleHost(value)
72
76
  }
@@ -157,8 +161,11 @@ export function switchMode(newMode) {
157
161
  runtime.ok(runtime.msg(`当前已是 ${newMode} 模式,正在刷新安装`, `Already in ${newMode} mode, refreshing installation`))
158
162
  }
159
163
 
160
- installAllHosts(runtime, newMode)
161
- setAllTrackedHostModes(config, newMode)
164
+ const results = installAllHosts(runtime, newMode)
165
+ clearAllTrackedHostModes(config)
166
+ for (const host of HOSTS) {
167
+ syncTrackedHostMode(config, host, results?.[host], newMode)
168
+ }
162
169
  writeSettings(config)
163
170
  runtime.printInstallMsg(newMode, isRefresh ? 'refresh' : 'switch')
164
171
  }
@@ -229,7 +236,7 @@ export function runScopedLifecycle(action, rawArgs) {
229
236
  writeSettings(settings)
230
237
  }
231
238
  } else if (!result.skipped) {
232
- setTrackedHostMode(settings, host, mode)
239
+ syncTrackedHostMode(settings, host, result, mode)
233
240
  writeSettings(settings)
234
241
  }
235
242
  }
@@ -87,7 +87,7 @@ HelloAGENTS v${pkgVersion} — The orchestration kernel for AI CLIs
87
87
  ${msg('安装', 'Install')}:
88
88
  npm install -g helloagents ${msg('(安装命令并同步稳定运行根目录;CLI 部署需显式执行 helloagents install ...)', '(installs the command and syncs the stable runtime root; deploy to CLIs explicitly with helloagents install ...)')}
89
89
  HELLOAGENTS=codex:global npm install -g helloagents
90
- helloagents-js.cmd ${msg('(受管宿主配置的跨平台稳定入口)', '(cross-platform stable entrypoint for managed host configs)')}
90
+ helloagents-js ${msg('(受管宿主配置的跨平台稳定入口)', '(cross-platform stable entrypoint for managed host configs)')}
91
91
 
92
92
  ${msg('模式切换', 'Mode switching')}:
93
93
  helloagents --global ${msg('全局模式(自动尝试 Claude/Gemini 插件或扩展;Codex 自动装原生本地插件)', 'Global mode (auto-attempts Claude/Gemini plugins or extensions; native local plugin auto-install for Codex)')}
@@ -35,6 +35,7 @@ export function copyEntries(sourceRoot, targetRoot, entries) {
35
35
  export function createLink(target, linkPath) {
36
36
  removeLink(linkPath);
37
37
  try {
38
+ ensureDir(dirname(linkPath));
38
39
  symlinkSync(target, linkPath, IS_WIN ? 'junction' : 'dir');
39
40
  return true;
40
41
  } catch { return false; }
@@ -132,9 +133,9 @@ export function cleanSettingsHooks(settingsPath, cleanPermissions = false) {
132
133
 
133
134
  function rewriteHookCommandToCli(command = '', pathVar = '') {
134
135
  const replacements = new Map([
135
- [`node "${pathVar}/scripts/notify.mjs"`, 'helloagents-js.cmd notify'],
136
- [`node "${pathVar}/scripts/guard.mjs"`, 'helloagents-js.cmd guard'],
137
- [`node "${pathVar}/scripts/ralph-loop.mjs"`, 'helloagents-js.cmd ralph-loop'],
136
+ [`node "${pathVar}/scripts/notify.mjs"`, 'helloagents-js notify'],
137
+ [`node "${pathVar}/scripts/guard.mjs"`, 'helloagents-js guard'],
138
+ [`node "${pathVar}/scripts/ralph-loop.mjs"`, 'helloagents-js ralph-loop'],
138
139
  ]);
139
140
 
140
141
  let next = command;
@@ -5,6 +5,7 @@
5
5
  * or when the plan artifacts are incomplete enough that delivery is not trustworthy.
6
6
  */
7
7
  import { readFileSync } from 'node:fs'
8
+ import { fileURLToPath } from 'node:url'
8
9
  import { getAdvisorEvidenceStatus } from './advisor-state.mjs'
9
10
  import { getCloseoutEvidenceStatus } from './closeout-state.mjs'
10
11
  import { getAdvisorRequirement, getVisualValidationRequirement } from './plan-contract.mjs'
@@ -129,11 +130,15 @@ function collectGateIssues(planEntries, verificationStatus, reviewStatus, adviso
129
130
  return issues
130
131
  }
131
132
 
132
- function main() {
133
- let data = {}
133
+ function readStdinJson() {
134
134
  try {
135
- data = JSON.parse(readFileSync(0, 'utf-8'))
136
- } catch {}
135
+ return JSON.parse(readFileSync(0, 'utf-8'))
136
+ } catch {
137
+ return {}
138
+ }
139
+ }
140
+
141
+ export function evaluateDeliveryGate(data = {}) {
137
142
  const cwd = data.cwd || process.cwd()
138
143
  const workflowOptions = { payload: data }
139
144
  const snapshot = getWorkflowSnapshot(cwd, workflowOptions)
@@ -146,8 +151,7 @@ function main() {
146
151
  ...workflowOptions,
147
152
  })
148
153
  if (gatePlans.length === 0) {
149
- process.stdout.write(JSON.stringify({ suppressOutput: true }))
150
- return
154
+ return { suppressOutput: true }
151
155
  }
152
156
 
153
157
  const advisorRequirements = gatePlans.map((entry) => getAdvisorRequirement(entry.contract))
@@ -177,15 +181,20 @@ function main() {
177
181
 
178
182
  const issues = collectGateIssues(gatePlans, verificationStatus, reviewStatus, advisorStatus, visualStatus, closeoutStatus)
179
183
  if (issues.length === 0) {
180
- process.stdout.write(JSON.stringify({ suppressOutput: true }))
181
- return
184
+ return { suppressOutput: true }
182
185
  }
183
186
 
184
- process.stdout.write(JSON.stringify({
187
+ return {
185
188
  decision: 'block',
186
189
  reason: buildDeliveryBlockReason(issues, recommendation, buildDeliveryGateHint(cwd, workflowOptions)),
187
190
  suppressOutput: true,
188
- }))
191
+ }
189
192
  }
190
193
 
191
- main()
194
+ function main() {
195
+ process.stdout.write(JSON.stringify(evaluateDeliveryGate(readStdinJson())))
196
+ }
197
+
198
+ if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) {
199
+ main()
200
+ }