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.
@@ -7,13 +7,17 @@ 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
+ import {
11
+ beginCodexCloseoutClaim,
12
+ finalizeCodexCloseoutClaim,
13
+ hasCodexQuickNotifyEvidence,
14
+ writeCodexQuickNotifyEvidence,
15
+ } from './notify-closeout.mjs';
11
16
  import { resolveNotificationSource } from './notify-source.mjs';
12
17
  import { buildCompactionContext, buildInjectContext, buildRouteInstruction, buildSemanticRouteInstruction, resolveCanonicalCommandSkill } from './notify-context.mjs';
13
18
  import { resolveNotifyHost, shouldIgnoreCodexNotifyClient } from './notify-events.mjs';
14
- import { runGateScript } from './notify-gates.mjs';
15
19
  import { normalizeNotifyPayload } from './notify-payload.mjs';
16
- import { cleanupProjectSessions } from './project-session-cleanup.mjs';
20
+ import { cleanupProjectSessions, PROJECT_SESSION_CLEANUP_COOLDOWN_MS } from './project-session-cleanup.mjs';
17
21
  import { handleRouteCommand, resolveBootstrapFile } from './notify-route.mjs';
18
22
  import { readSettings, readStdinJson, output, suppressedOutput, emptySuppress } from './notify-shared.mjs';
19
23
  import { clearRouteContext, getApplicableRouteContext, writeRouteContext } from './runtime-context.mjs';
@@ -38,8 +42,15 @@ const EVENT_NAME = {
38
42
  PreCompact: IS_GEMINI ? 'BeforeAgent' : 'PreCompact',
39
43
  };
40
44
  const RALPH_LOOP_ROUTE_COMMANDS = new Set(['verify', 'loop']);
45
+ const CODEX_HOOKS_FILE = join(homedir(), '.codex', 'hooks.json');
46
+ const GATE_MODULE_LOADERS = {
47
+ 'turn-stop-gate': () => import('./turn-stop-gate.mjs'),
48
+ 'delivery-gate': () => import('./delivery-gate.mjs'),
49
+ 'ralph-loop': () => import('./ralph-loop.mjs'),
50
+ };
51
+ const gateEvaluatorCache = new Map();
41
52
 
42
- const playSound = (event) => _playSound(PKG_ROOT, event);
53
+ const playSound = (event, options) => _playSound(PKG_ROOT, event, options);
43
54
  const desktopNotify = (event, extra) => _desktopNotify(PKG_ROOT, event, extra);
44
55
 
45
56
  function normalizeNotifyLevel(value) {
@@ -47,13 +58,13 @@ function normalizeNotifyLevel(value) {
47
58
  return [0, 1, 2, 3].includes(level) ? level : 0;
48
59
  }
49
60
 
50
- function notifyByLevel(event, extra, settings = getSettings()) {
61
+ function notifyByLevel(event, extra, settings = getSettings(), options = {}) {
51
62
  const level = normalizeNotifyLevel(settings.notify_level ?? 0);
52
63
  if (level === 1) desktopNotify(event, extra);
53
- if (level === 2) playSound(event);
64
+ if (level === 2) playSound(event, options);
54
65
  if (level === 3) {
55
66
  desktopNotify(event, extra);
56
- playSound(event);
67
+ playSound(event, options);
57
68
  }
58
69
  }
59
70
 
@@ -84,50 +95,184 @@ function shouldRunRalphLoop(cwd, turnState, payload = {}) {
84
95
  return RALPH_LOOP_ROUTE_COMMANDS.has(routeContext?.skillName);
85
96
  }
86
97
 
87
- function runRalphLoop(payload, { turnState } = {}) {
98
+ function buildGateErrorReason(source, detail = '') {
99
+ return [
100
+ `[HelloAGENTS Runtime] ${source} 执行失败,已暂停完成通知。`,
101
+ detail ? `原因:${detail}` : '',
102
+ '请修复脚本或重新运行验证后再报告完成。',
103
+ ].filter(Boolean).join('\n');
104
+ }
105
+
106
+ function emitInlineGateError(payload, source, detail = '') {
107
+ const reason = buildGateErrorReason(source, detail);
108
+ appendReplayEvent(payload.cwd || process.cwd(), {
109
+ host: HOST,
110
+ event: 'runtime_gate_error',
111
+ source,
112
+ reason,
113
+ payload,
114
+ });
115
+ output({
116
+ decision: 'block',
117
+ reason,
118
+ suppressOutput: true,
119
+ });
120
+ return true;
121
+ }
122
+
123
+ function stringifyStreamChunk(chunk, encoding) {
124
+ if (typeof chunk === 'string') return chunk;
125
+ if (chunk instanceof Uint8Array || Buffer.isBuffer(chunk)) {
126
+ return Buffer.from(chunk).toString(typeof encoding === 'string' ? encoding : 'utf-8');
127
+ }
128
+ return String(chunk ?? '');
129
+ }
130
+
131
+ async function importGateModule(source) {
132
+ const loader = GATE_MODULE_LOADERS[source];
133
+ if (!loader) {
134
+ throw new Error(`无法解析的 JSON:未知 gate 模块 ${source}。`);
135
+ }
136
+
137
+ const originalStdoutWrite = process.stdout.write.bind(process.stdout);
138
+ const originalStderrWrite = process.stderr.write.bind(process.stderr);
139
+ let capturedStdout = '';
140
+ let capturedStderr = '';
141
+
142
+ process.stdout.write = (chunk, encoding, callback) => {
143
+ capturedStdout += stringifyStreamChunk(chunk, encoding);
144
+ if (typeof encoding === 'function') encoding();
145
+ if (typeof callback === 'function') callback();
146
+ return true;
147
+ };
148
+ process.stderr.write = (chunk, encoding, callback) => {
149
+ capturedStderr += stringifyStreamChunk(chunk, encoding);
150
+ if (typeof encoding === 'function') encoding();
151
+ if (typeof callback === 'function') callback();
152
+ return true;
153
+ };
154
+
155
+ try {
156
+ const module = await loader();
157
+ if (capturedStdout.trim() || capturedStderr.trim()) {
158
+ throw new Error(`无法解析的 JSON:模块导入时输出了意外内容。${capturedStdout || capturedStderr}`);
159
+ }
160
+ return module;
161
+ } finally {
162
+ process.stdout.write = originalStdoutWrite;
163
+ process.stderr.write = originalStderrWrite;
164
+ }
165
+ }
166
+
167
+ async function loadGateEvaluator(source, exportName) {
168
+ let evaluatorPromise = gateEvaluatorCache.get(source);
169
+ if (!evaluatorPromise) {
170
+ evaluatorPromise = importGateModule(source).then((module) => {
171
+ const evaluate = module?.[exportName];
172
+ if (typeof evaluate !== 'function') {
173
+ throw new Error(`无法解析的 JSON:模块未导出 ${exportName}。`);
174
+ }
175
+ return evaluate;
176
+ });
177
+ gateEvaluatorCache.set(source, evaluatorPromise);
178
+ }
179
+
180
+ try {
181
+ return await evaluatorPromise;
182
+ } catch (error) {
183
+ gateEvaluatorCache.delete(source);
184
+ throw error;
185
+ }
186
+ }
187
+
188
+ async function runInlineGate({ payload, source, blockEvent, exportName, evaluateArgs }) {
189
+ let evaluate;
190
+ try {
191
+ evaluate = await loadGateEvaluator(source, exportName);
192
+ } catch (error) {
193
+ return emitInlineGateError(payload, source, error?.message || String(error));
194
+ }
195
+
196
+ let gateOutput;
197
+ try {
198
+ gateOutput = await evaluate(...evaluateArgs);
199
+ } catch (error) {
200
+ return emitInlineGateError(payload, source, error?.message || String(error));
201
+ }
202
+
203
+ if (!gateOutput || typeof gateOutput !== 'object' || Array.isArray(gateOutput)) {
204
+ return emitInlineGateError(payload, source, '无法解析的 JSON:gate 返回值不是对象。');
205
+ }
206
+
207
+ if (gateOutput?.decision === 'block') {
208
+ appendReplayEvent(payload.cwd || process.cwd(), {
209
+ host: HOST,
210
+ event: blockEvent,
211
+ source,
212
+ reason: gateOutput.reason || '',
213
+ payload,
214
+ });
215
+ output(gateOutput);
216
+ return true;
217
+ }
218
+
219
+ return false;
220
+ }
221
+
222
+ async function runRalphLoop(payload, { turnState } = {}) {
88
223
  const settings = getSettings();
89
224
  if (settings.ralph_loop_enabled === false) return false;
90
225
  const cwd = payload.cwd || process.cwd();
91
226
  if (!shouldRunRalphLoop(cwd, turnState, payload)) return false;
92
- return runGateScript({
227
+ return await runInlineGate({
93
228
  payload,
94
- host: HOST,
95
- scriptPath: join(__dirname, 'ralph-loop.mjs'),
96
- args: IS_GEMINI ? ['--gemini'] : HOST === 'codex' ? ['--codex'] : [],
97
229
  source: 'ralph-loop',
98
230
  blockEvent: 'verify_gate_blocked',
99
- timeout: 120_000,
100
- appendReplayEvent,
101
- output,
231
+ exportName: 'evaluateRalphLoop',
232
+ evaluateArgs: [payload, {
233
+ isSubagent: false,
234
+ isGemini: IS_GEMINI,
235
+ hookEventName: HOST === 'codex' ? 'Stop' : (IS_GEMINI ? 'SessionEnd' : 'Stop'),
236
+ }],
102
237
  });
103
238
  }
104
239
 
105
- function runDeliveryGate(payload) {
106
- return runGateScript({
240
+ async function runDeliveryGate(payload) {
241
+ return await runInlineGate({
107
242
  payload,
108
- host: HOST,
109
- scriptPath: join(__dirname, 'delivery-gate.mjs'),
110
243
  source: 'delivery-gate',
111
244
  blockEvent: 'delivery_gate_blocked',
112
- timeout: 30_000,
113
- appendReplayEvent,
114
- output,
245
+ exportName: 'evaluateDeliveryGate',
246
+ evaluateArgs: [payload],
115
247
  });
116
248
  }
117
249
 
118
- function runTurnStopGate(payload) {
119
- return runGateScript({
250
+ async function runTurnStopGate(payload) {
251
+ return await runInlineGate({
120
252
  payload,
121
- host: HOST,
122
- scriptPath: join(__dirname, 'turn-stop-gate.mjs'),
123
253
  source: 'turn-stop-gate',
124
254
  blockEvent: 'turn_stop_blocked',
125
- timeout: 30_000,
126
- appendReplayEvent,
127
- output,
255
+ exportName: 'evaluateTurnStopGate',
256
+ evaluateArgs: [payload],
128
257
  });
129
258
  }
130
259
 
260
+ function hasManagedCodexStopHook() {
261
+ if (!IS_CODEX) return false;
262
+ try {
263
+ const hooksData = JSON.parse(readFileSync(CODEX_HOOKS_FILE, 'utf-8'));
264
+ const groups = Array.isArray(hooksData?.hooks?.Stop) ? hooksData.hooks.Stop : [];
265
+ return groups.some((group) => Array.isArray(group?.hooks) && group.hooks.some((handler) =>
266
+ handler?.type === 'command'
267
+ && typeof handler.command === 'string'
268
+ && handler.command.includes('helloagents-js')
269
+ && handler.command.includes('notify stop --codex'),
270
+ ));
271
+ } catch {
272
+ return false;
273
+ }
274
+ }
275
+
131
276
  function attachTurnSession(payload = {}, cwd = payload.cwd || process.cwd()) {
132
277
  const sessionId = resolveSessionToken({
133
278
  payload,
@@ -148,21 +293,23 @@ function consumeMainTurnState(cwd, turnState, payload = {}) {
148
293
  if (turnState?.role === 'main') clearTurnState(cwd, { payload });
149
294
  }
150
295
 
151
- function shouldProcessCloseout(turnState) {
296
+ function shouldEmitManagedCodexCompleteNotify(cwd, turnState, payload = {}) {
152
297
  if (turnState) return turnState.kind === 'complete';
153
- return false;
298
+ const routeContext = getApplicableRouteContext({ cwd, payload });
299
+ return routeContext?.skillName !== 'auto';
154
300
  }
155
301
 
156
- function processTurnCloseout(payload, turnPayload, turnState, settings = getSettings()) {
302
+ async function processTurnCloseout(payload, turnPayload, turnState, settings = getSettings(), options = {}) {
157
303
  const cwd = turnPayload.cwd || process.cwd();
304
+ const skipCompleteNotify = options.skipCompleteNotify === true;
158
305
 
159
- if (runTurnStopGate(turnPayload)) {
306
+ if (await runTurnStopGate(turnPayload)) {
160
307
  if (turnState && turnState.kind !== 'complete') consumeMainTurnState(cwd, turnState, turnPayload);
161
308
  return { blocked: true };
162
309
  }
163
310
 
164
311
  if (!turnState) {
165
- notifyByLevel('complete', buildNotifyExtra(turnPayload), settings);
312
+ if (!skipCompleteNotify) notifyByLevel('complete', buildNotifyExtra(turnPayload), settings);
166
313
  clearRouteContext({ cwd, payload: turnPayload });
167
314
  return { blocked: false };
168
315
  }
@@ -173,18 +320,18 @@ function processTurnCloseout(payload, turnPayload, turnState, settings = getSett
173
320
  return { blocked: false };
174
321
  }
175
322
 
176
- if (runRalphLoop(turnPayload, { turnState })) {
323
+ if (await runRalphLoop(turnPayload, { turnState })) {
177
324
  consumeMainTurnState(cwd, turnState, turnPayload);
178
325
  notifyByLevel('warning', buildNotifyExtra(payload), settings);
179
326
  return { blocked: true };
180
327
  }
181
- if (runDeliveryGate(turnPayload)) {
328
+ if (await runDeliveryGate(turnPayload)) {
182
329
  consumeMainTurnState(cwd, turnState, turnPayload);
183
330
  notifyByLevel('warning', buildNotifyExtra(payload), settings);
184
331
  return { blocked: true };
185
332
  }
186
333
 
187
- notifyByLevel('complete', buildNotifyExtra(payload), settings);
334
+ if (!skipCompleteNotify) notifyByLevel('complete', buildNotifyExtra(payload), settings);
188
335
  consumeMainTurnState(cwd, turnState, turnPayload);
189
336
  clearRouteContext({ cwd, payload: turnPayload });
190
337
  return { blocked: false };
@@ -230,6 +377,7 @@ function cmdRoute() {
230
377
  clearRouteContext,
231
378
  appendReplayEvent,
232
379
  getWorkflowRecommendation,
380
+ recordReplayEvents: !IS_SILENT,
233
381
  suppress: (context) => IS_SILENT
234
382
  ? emptySuppress()
235
383
  : suppressedOutput(EVENT_NAME.UserPromptSubmit, context),
@@ -251,20 +399,24 @@ function cmdInject() {
251
399
  installMode: settings.install_mode || '',
252
400
  payload,
253
401
  });
254
- appendReplayEvent(cwd, {
255
- host: HOST,
256
- event: 'session_injected',
257
- source,
258
- payload,
259
- details: {
260
- bootstrapFile,
261
- installMode: settings.install_mode || '',
262
- activatedProject: isProjectRuntimeActive(cwd),
263
- },
264
- });
402
+ if (!IS_SILENT) {
403
+ appendReplayEvent(cwd, {
404
+ host: HOST,
405
+ event: 'session_injected',
406
+ source,
407
+ payload,
408
+ details: {
409
+ bootstrapFile,
410
+ installMode: settings.install_mode || '',
411
+ activatedProject: isProjectRuntimeActive(cwd),
412
+ },
413
+ });
414
+ }
265
415
  clearRouteContext({ cwd, payload });
266
416
  clearTurnState(cwd, { payload });
267
- cleanupProjectSessions(cwd);
417
+ cleanupProjectSessions(cwd, {
418
+ minIntervalMs: IS_SILENT ? PROJECT_SESSION_CLEANUP_COOLDOWN_MS : 0,
419
+ });
268
420
  if (IS_SILENT) {
269
421
  emptySuppress();
270
422
  return;
@@ -286,11 +438,16 @@ function cmdInject() {
286
438
  suppressedOutput(EVENT_NAME.SessionStart, context || undefined);
287
439
  }
288
440
 
289
- function cmdStop() {
441
+ async function cmdStop() {
290
442
  const payload = readPayloadFromStdin();
291
443
  const cwd = payload.cwd || process.cwd();
292
444
  const turnPayload = attachTurnSession(payload, cwd);
293
445
  const turnState = readMainTurnState(cwd, turnPayload);
446
+ const managedCodexStopHook = IS_CODEX && hasManagedCodexStopHook();
447
+ const skipCompleteNotify = managedCodexStopHook && hasCodexQuickNotifyEvidence(cwd, {
448
+ payload: turnPayload,
449
+ turnState,
450
+ });
294
451
  const closeoutClaim = IS_CODEX
295
452
  ? beginCodexCloseoutClaim(cwd, { payload: turnPayload, turnState, source: 'stop' })
296
453
  : null;
@@ -302,7 +459,9 @@ function cmdStop() {
302
459
  let handled = false;
303
460
  let result = { blocked: false };
304
461
  try {
305
- result = processTurnCloseout(payload, turnPayload, turnState, getSettings());
462
+ result = await processTurnCloseout(payload, turnPayload, turnState, getSettings(), {
463
+ skipCompleteNotify,
464
+ });
306
465
  handled = true;
307
466
  } finally {
308
467
  finalizeCodexCloseoutClaim(closeoutClaim, {
@@ -317,14 +476,14 @@ function cmdStop() {
317
476
  }
318
477
 
319
478
  function cmdSound() {
320
- playSound(process.argv[3] || 'complete');
479
+ playSound(process.argv[3] || 'complete', { mode: 'blocking' });
321
480
  }
322
481
 
323
482
  function cmdDesktop() {
324
483
  desktopNotify(process.argv[3] || 'complete', buildNotifyExtra({ cwd: process.cwd() }));
325
484
  }
326
485
 
327
- function cmdCodexNotify() {
486
+ async function cmdCodexNotify() {
328
487
  let data = {};
329
488
  try { data = JSON.parse(process.argv[3] || '{}'); } catch {}
330
489
  data = normalizeNotifyPayload(data);
@@ -336,10 +495,22 @@ function cmdCodexNotify() {
336
495
  if (shouldIgnoreCodexNotifyClient(client)) return;
337
496
 
338
497
  if (type === 'approval-requested') {
339
- notifyByLevel('confirm', buildNotifyExtra(data));
498
+ notifyByLevel('confirm', buildNotifyExtra(data), getSettings(), { mode: 'blocking' });
340
499
  return;
341
500
  }
342
501
  if (type !== 'agent-turn-complete') return;
502
+ if (hasManagedCodexStopHook()) {
503
+ const turnState = readMainTurnState(cwd, turnPayload);
504
+ if (shouldEmitManagedCodexCompleteNotify(cwd, turnState, turnPayload)) {
505
+ notifyByLevel('complete', buildNotifyExtra(data), getSettings(), { mode: 'blocking' });
506
+ writeCodexQuickNotifyEvidence(cwd, {
507
+ payload: turnPayload,
508
+ turnState,
509
+ event: type,
510
+ });
511
+ }
512
+ return;
513
+ }
343
514
 
344
515
  const turnState = readMainTurnState(cwd, turnPayload);
345
516
  const closeoutClaim = beginCodexCloseoutClaim(cwd, {
@@ -351,7 +522,7 @@ function cmdCodexNotify() {
351
522
 
352
523
  let handled = false;
353
524
  try {
354
- processTurnCloseout(data, turnPayload, turnState, getSettings());
525
+ await processTurnCloseout(data, turnPayload, turnState, getSettings());
355
526
  handled = true;
356
527
  } finally {
357
528
  finalizeCodexCloseoutClaim(closeoutClaim, {
@@ -363,15 +534,19 @@ function cmdCodexNotify() {
363
534
  }
364
535
  }
365
536
 
366
- switch (cmd) {
367
- case 'inject': cmdInject(); break;
368
- case 'stop': cmdStop(); break;
369
- case 'pre-compact': cmdPreCompact(); break;
370
- case 'route': cmdRoute(); break;
371
- case 'sound': cmdSound(); break;
372
- case 'desktop': cmdDesktop(); break;
373
- case 'codex-notify': cmdCodexNotify(); break;
374
- default:
375
- process.stderr.write(`notify.mjs: unknown command "${cmd}"\n`);
376
- process.exit(1);
537
+ async function main() {
538
+ switch (cmd) {
539
+ case 'inject': cmdInject(); break;
540
+ case 'stop': await cmdStop(); break;
541
+ case 'pre-compact': cmdPreCompact(); break;
542
+ case 'route': cmdRoute(); break;
543
+ case 'sound': cmdSound(); break;
544
+ case 'desktop': cmdDesktop(); break;
545
+ case 'codex-notify': await cmdCodexNotify(); break;
546
+ default:
547
+ process.stderr.write(`notify.mjs: unknown command "${cmd}"\n`);
548
+ process.exit(1);
549
+ }
377
550
  }
551
+
552
+ await main();
@@ -10,8 +10,11 @@ import {
10
10
  getProjectActivationDir,
11
11
  getProjectRoot,
12
12
  readJsonFile,
13
+ writeJsonFileAtomic,
13
14
  } from './runtime-scope.mjs'
14
15
 
16
+ export const PROJECT_SESSION_CLEANUP_COOLDOWN_MS = 10 * 60 * 1000
17
+
15
18
  function removePath(filePath, result, bucket) {
16
19
  try {
17
20
  rmSync(filePath, { recursive: true, force: true })
@@ -58,7 +61,21 @@ function shouldKeepSession(active, workspace, session) {
58
61
  return activeWorkspace === workspace && active.session === session
59
62
  }
60
63
 
61
- export function cleanupProjectSessions(cwd) {
64
+ function readCleanupCheckedAt(active) {
65
+ const raw = active && typeof active === 'object' ? active.cleanupCheckedAt : ''
66
+ const timestamp = Date.parse(raw || '')
67
+ return Number.isFinite(timestamp) ? timestamp : 0
68
+ }
69
+
70
+ function writeCleanupCheckpoint(activePath, active, now) {
71
+ if (!active || typeof active !== 'object' || Object.keys(active).length === 0) return
72
+ writeJsonFileAtomic(activePath, {
73
+ ...active,
74
+ cleanupCheckedAt: new Date(now).toISOString(),
75
+ })
76
+ }
77
+
78
+ export function cleanupProjectSessions(cwd, { now = Date.now(), minIntervalMs = 0 } = {}) {
62
79
  const projectRoot = getProjectRoot(cwd)
63
80
  const activationDir = getProjectActivationDir(projectRoot)
64
81
  const sessionsDir = join(activationDir, PROJECT_SESSIONS_DIR_NAME)
@@ -69,9 +86,17 @@ export function cleanupProjectSessions(cwd) {
69
86
  removedEmptyDirs: [],
70
87
  removedRouteOnlyDirs: [],
71
88
  errors: [],
89
+ skipped: false,
72
90
  }
73
91
 
74
92
  if (!existsSync(sessionsDir)) return result
93
+ if (minIntervalMs > 0) {
94
+ const lastCleanupAt = readCleanupCheckedAt(active)
95
+ if (lastCleanupAt > 0 && now - lastCleanupAt < minIntervalMs) {
96
+ result.skipped = true
97
+ return result
98
+ }
99
+ }
75
100
 
76
101
  for (const workspaceEntry of readdirSync(sessionsDir, { withFileTypes: true })) {
77
102
  if (!workspaceEntry.isDirectory()) continue
@@ -102,5 +127,6 @@ export function cleanupProjectSessions(cwd) {
102
127
  }
103
128
  }
104
129
 
130
+ writeCleanupCheckpoint(activePath, active, now)
105
131
  return result
106
132
  }