@cleocode/adapters 2026.10.21 → 2026.10.22-rc.ci.693

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.
@@ -52,14 +52,22 @@ import {
52
52
  heavyCommandHookObject,
53
53
  hookFileVisibleToGit,
54
54
  isHeavyHookObject,
55
+ isSessionEndHookObject,
56
+ isStopDispatchHookObject,
57
+ isSubagentStartHookObject,
55
58
  isUserHomeDir,
56
59
  nonDirectoryAncestor,
57
60
  OLDER_CLEO_MARKER_PREFIX,
58
61
  OPENCODE_PLUGIN_FILE,
59
62
  opencodeHeavyCommandPluginSource,
63
+ sessionEndHookObject,
64
+ stopDispatchHookObject,
65
+ subagentStartHookObject,
60
66
  syncClaudeCodeHeavyCommandHook,
61
67
  syncCodexHeavyCommandHook,
68
+ syncJsonSessionEndHook,
62
69
  syncJsonStopDispatchHook,
70
+ syncJsonSubagentStartHook,
63
71
  syncOpencodeHeavyCommandPlugin,
64
72
  trackedFileModified,
65
73
  } from './heavy-command-hook-install.js';
@@ -196,12 +204,15 @@ function errorText(err: unknown): string {
196
204
 
197
205
  /** Sync one provider; never throws. */
198
206
  /**
199
- * Install CLEO's one Stop hook (T13796): goal tick → ask-enforce (T13420) →
200
- * session end, folding the separate ask-enforce and session-end Stop hooks in
201
- * place. Claude Code always keeps it (its Stop hook ended the session before
202
- * the dispatcher); Codex's rides the heavy-command delivery and is removed
203
- * when that mode is `off`. Ask-enforce inside it honours `CLEO_ASK_ENFORCE` at
204
- * run time. Never fails the heavy-command outcome.
207
+ * Install CLEO's one Stop hook (T13796): goal tick → ask-enforce (T13420),
208
+ * folding the separate ask-enforce and old session-end Stop hooks in place.
209
+ * Claude Code always keeps it, with the SessionEnd hook that ends the CLEO
210
+ * session when the harness session really ends (T13913; a turn's Stop never
211
+ * does); Codex's rides the heavy-command delivery and is removed when that
212
+ * mode is `off`. Ask-enforce inside it honours `CLEO_ASK_ENFORCE` at run time.
213
+ * For Claude Code it also installs the SubagentStart lean hook (T13667),
214
+ * removed when the heavy-command mode is `off`. Never fails the heavy-command
215
+ * outcome.
205
216
  */
206
217
  async function syncStopDispatch(
207
218
  provider: 'claude-code' | 'codex',
@@ -213,6 +224,20 @@ async function syncStopDispatch(
213
224
  } catch {
214
225
  // Fail open: a config the Stop hook cannot be written to keeps the heavy-command result.
215
226
  }
227
+ if (provider !== 'claude-code') return;
228
+ try {
229
+ await syncJsonSessionEndHook(target, true);
230
+ } catch {
231
+ // Fail open, as above.
232
+ }
233
+ // T13667: the lean rules for harness-spawned subagents. The level (`off`
234
+ // included) is read at run time, so the hook is installed whenever the
235
+ // heavy-command hook is.
236
+ try {
237
+ await syncJsonSubagentStartHook(target, mode !== 'off');
238
+ } catch {
239
+ // Fail open, as above.
240
+ }
216
241
  }
217
242
 
218
243
  /**
@@ -249,8 +274,11 @@ export async function syncProjectStopDispatch(
249
274
  if (shared !== null) throw new HeavyHookSharedConfigError(target, shared);
250
275
  }
251
276
  const status = await syncJsonStopDispatchHook(target, provider, true);
252
- if (provider === 'claude-code') excludeLocalSettingsFromGit(projectDir);
253
- else excludeHookFileFromGit(projectDir, CODEX_HOOKS_FILE);
277
+ if (provider === 'claude-code') {
278
+ // T13913: the Stop no longer ends the session; SessionEnd does.
279
+ await syncJsonSessionEndHook(target, true);
280
+ excludeLocalSettingsFromGit(projectDir);
281
+ } else excludeHookFileFromGit(projectDir, CODEX_HOOKS_FILE);
254
282
  return { provider, status, target };
255
283
  } catch (err) {
256
284
  return {
@@ -364,9 +392,72 @@ export async function syncProjectHeavyCommandHooks(
364
392
  /** What a JSON hook config holds: CLEO's hook (current or not), or why it cannot say. */
365
393
  type JsonHookPresence =
366
394
  | { readonly kind: 'absent' }
367
- | { readonly kind: 'present'; readonly current: boolean }
395
+ | {
396
+ readonly kind: 'present';
397
+ readonly current: boolean;
398
+ /** Why it is not current, when a companion hook is missing or stale (T13960). */
399
+ readonly stale?: string;
400
+ }
368
401
  | { readonly kind: 'unreadable'; readonly error: string };
369
402
 
403
+ /**
404
+ * The hooks CLEO installs beside the heavy-command hook (T13960): the Stop
405
+ * dispatcher, and for Claude Code the SessionEnd and SubagentStart hooks. A
406
+ * binary updated without a hook re-sync keeps the old set, e.g. a Stop that
407
+ * still ends the session and no SessionEnd, so each is audited too. With the
408
+ * heavy-command mode `off`, Claude Code keeps only its Stop and SessionEnd
409
+ * hooks and Codex keeps none (what sync installs).
410
+ */
411
+ function companionHooks(
412
+ provider: 'claude-code' | 'codex',
413
+ modeOff = false,
414
+ ): ReadonlyArray<readonly [string, Record<string, unknown>, (hook: unknown) => boolean]> {
415
+ const stop = ['Stop', stopDispatchHookObject(provider), isStopDispatchHookObject] as const;
416
+ if (provider === 'codex') return modeOff ? [] : [stop];
417
+ const end = ['SessionEnd', sessionEndHookObject(), isSessionEndHookObject] as const;
418
+ return modeOff
419
+ ? [stop, end]
420
+ : [stop, end, ['SubagentStart', subagentStartHookObject(), isSubagentStartHookObject]];
421
+ }
422
+
423
+ /** The first companion hook that is missing or differs from this build's, named, or null. */
424
+ function staleCompanion(
425
+ hooks: Record<string, unknown>,
426
+ provider: 'claude-code' | 'codex',
427
+ modeOff = false,
428
+ ): string | null {
429
+ for (const [event, desired, isMine] of companionHooks(provider, modeOff)) {
430
+ const groups = hooks[event];
431
+ const mine = (Array.isArray(groups) ? groups : [])
432
+ .flatMap((g) => (isPlainObject(g) && Array.isArray(g.hooks) ? g.hooks : []))
433
+ .filter(isMine);
434
+ if (mine.length === 0) return `its ${event} hook is missing`;
435
+ if (!mine.some((h) => JSON.stringify(h) === JSON.stringify(desired))) {
436
+ return `its ${event} hook was written by another CLEO build`;
437
+ }
438
+ }
439
+ return null;
440
+ }
441
+
442
+ /**
443
+ * With the heavy-command mode `off`: the first of Claude Code's own Stop and
444
+ * SessionEnd hooks that is missing or stale in `file`, or null (also when the
445
+ * file cannot be read: that is reported elsewhere).
446
+ */
447
+ function offCompanionStale(file: string): string | null {
448
+ let config: unknown = {};
449
+ try {
450
+ const raw = readFileSync(file, 'utf-8');
451
+ if (raw.trim() !== '') config = JSON.parse(raw);
452
+ } catch (err) {
453
+ const code = (err as NodeJS.ErrnoException).code;
454
+ if (code !== 'ENOENT' && code !== 'ENOTDIR') return null;
455
+ }
456
+ if (!isPlainObject(config)) return null;
457
+ const hooks = isPlainObject(config.hooks) ? config.hooks : {};
458
+ return staleCompanion(hooks, 'claude-code', true);
459
+ }
460
+
370
461
  /** Find CLEO's heavy-command hook in a JSON hook config (`hooks.PreToolUse[].hooks[]`). */
371
462
  function jsonHookPresence(file: string, provider: 'claude-code' | 'codex'): JsonHookPresence {
372
463
  let raw: string;
@@ -396,7 +487,12 @@ function jsonHookPresence(file: string, provider: 'claude-code' | 'codex'): Json
396
487
  if (!isPlainObject(group) || !Array.isArray(group.hooks)) continue;
397
488
  for (const hook of group.hooks) {
398
489
  if (!isHeavyHookObject(hook)) continue;
399
- if (JSON.stringify(hook) === expected) return { kind: 'present', current: true };
490
+ if (JSON.stringify(hook) === expected) {
491
+ const stale = isPlainObject(hooks) ? staleCompanion(hooks, provider) : null;
492
+ return stale === null
493
+ ? { kind: 'present', current: true }
494
+ : { kind: 'present', current: false, stale };
495
+ }
400
496
  found = { kind: 'present', current: false };
401
497
  }
402
498
  }
@@ -469,6 +565,17 @@ function inspectProvider(
469
565
  };
470
566
  }
471
567
  if (mode === 'off') {
568
+ // T13960: Claude Code keeps its Stop and SessionEnd hooks whatever the mode,
569
+ // so an update without a re-sync must not read as `disabled`.
570
+ const stale = provider === 'claude-code' && detected ? offCompanionStale(target) : null;
571
+ if (stale !== null) {
572
+ return {
573
+ ...base,
574
+ state: 'outdated',
575
+ detail: `resources.heavyCommandHook is off, but ${stale} in ${target}`,
576
+ remedy: `run: ${HEAVY_HOOK_FIX_COMMAND}`,
577
+ };
578
+ }
472
579
  return presence.kind === 'present'
473
580
  ? {
474
581
  ...base,
@@ -479,11 +586,22 @@ function inspectProvider(
479
586
  : { ...base, state: 'disabled', detail: 'resources.heavyCommandHook is off' };
480
587
  }
481
588
  if (presence.kind === 'present') {
482
- if (!presence.current) {
589
+ // A project's own (committed) .codex/hooks.json is the team's: CLEO never
590
+ // writes its Stop entry there either, so a missing or stale companion is
591
+ // not CLEO's to flag (--fix would refuse it); the shared-config checks
592
+ // below still apply.
593
+ const companionOnly =
594
+ presence.stale !== undefined &&
595
+ provider === 'codex' &&
596
+ codexHooksSharedReason(projectDir, gitChecks) !== null;
597
+ if (!presence.current && !companionOnly) {
483
598
  return {
484
599
  ...base,
485
600
  state: 'outdated',
486
- detail: `the hook in ${target} (or a legacy location) was written by another CLEO build`,
601
+ detail:
602
+ presence.stale !== undefined
603
+ ? `the hook in ${target} is current, but ${presence.stale}`
604
+ : `the hook in ${target} (or a legacy location) was written by another CLEO build`,
487
605
  remedy: `run: ${HEAVY_HOOK_FIX_COMMAND}`,
488
606
  };
489
607
  }
@@ -463,14 +463,15 @@ export function isAskEnforceHookObject(hook: unknown): boolean {
463
463
  /** The command CLEO's Stop dispatcher runs (T13796); also its marker. */
464
464
  export const STOP_DISPATCH_HOOK_ID = 'cleo hook stop';
465
465
 
466
- /** Seconds a Stop dispatch may take: the goal tick (30 s), ask-enforce and `cleo session end`. */
466
+ /** Seconds a Stop dispatch may take: the goal tick (60 s at most) and ask-enforce. */
467
467
  export const STOP_DISPATCH_HOOK_TIMEOUT_SEC = 120;
468
468
 
469
469
  /** Claude Code's Stop command before T13796, which ended the session on its own. */
470
470
  export const LEGACY_SESSION_END_STOP_COMMAND = 'cleo session end --quiet # cleo-hook';
471
471
 
472
472
  /**
473
- * CLEO's one Stop hook object (T13796): goal tick → ask-enforce → session end.
473
+ * CLEO's one Stop hook object (T13796): goal tick → ask-enforce. It never ends
474
+ * the session (T13913); {@link sessionEndHookObject} does, on `SessionEnd`.
474
475
  * Like the ask-enforce script, it forwards only a JSON answer and always exits
475
476
  * 0, so a machine without `cleo` (or with an older one) is never blocked.
476
477
  *
@@ -561,6 +562,151 @@ export async function syncJsonStopDispatchHook(
561
562
  return result;
562
563
  }
563
564
 
565
+ /** The command CLEO's SubagentStart lean hook runs (T13667); also its marker. */
566
+ export const SUBAGENT_START_HOOK_ID = 'cleo hook subagent-start';
567
+
568
+ /**
569
+ * CLEO's SubagentStart lean hook object (T13667), Claude Code only. Like the
570
+ * Stop dispatcher, it forwards only a JSON answer and always exits 0, so a
571
+ * machine without `cleo` (or with an older one) never breaks a subagent.
572
+ */
573
+ export function subagentStartHookObject(): Record<string, unknown> {
574
+ const script = `command -v cleo >/dev/null 2>&1 || exit 0; out="$(${SUBAGENT_START_HOOK_ID} --provider claude-code 2>/dev/null)"; case "$out" in "{"*) printf '%s\\n' "$out";; esac; exit 0`;
575
+ return {
576
+ type: 'command',
577
+ command: `${script} ${CLEO_HOOK_MARKER}`,
578
+ timeout: HEAVY_COMMAND_HOOK_TIMEOUT_SEC,
579
+ };
580
+ }
581
+
582
+ /** Whether one hook object is CLEO's SubagentStart lean hook. */
583
+ export function isSubagentStartHookObject(hook: unknown): boolean {
584
+ return (
585
+ isPlainObject(hook) &&
586
+ typeof hook.command === 'string' &&
587
+ hook.command.includes(CLEO_HOOK_MARKER) &&
588
+ hook.command.includes(`${SUBAGENT_START_HOOK_ID} `)
589
+ );
590
+ }
591
+
592
+ /**
593
+ * Add, refresh or (`install: false`) remove CLEO's SubagentStart lean hook
594
+ * (`hooks.SubagentStart`, T13667) through CAAMP's locked atomic writer. Claude
595
+ * Code only: Codex has no SubagentStart event, and CLEO-spawned agents get the
596
+ * rules from the spawn prompt. Only CLEO's own object is touched; a malformed
597
+ * file throws untouched.
598
+ *
599
+ * @param configPath - Claude Code's `.claude/settings.local.json`.
600
+ * @param install - `false` removes the hook.
601
+ * @returns what changed.
602
+ */
603
+ export function syncJsonSubagentStartHook(
604
+ configPath: string,
605
+ install: boolean,
606
+ ): Promise<HeavyHookSyncResult> {
607
+ return syncJsonEventHook(
608
+ configPath,
609
+ 'SubagentStart',
610
+ install ? subagentStartHookObject() : null,
611
+ isSubagentStartHookObject,
612
+ );
613
+ }
614
+
615
+ /** The command CLEO's SessionEnd hook runs (T13913); also its marker. */
616
+ export const SESSION_END_HOOK_ID = 'cleo hook session-end';
617
+
618
+ /** Seconds the SessionEnd hook may take: `cleo session end`, waited for at most 20 s, then detached. */
619
+ export const SESSION_END_HOOK_TIMEOUT_SEC = 30;
620
+
621
+ /**
622
+ * CLEO's SessionEnd hook object (T13913), Claude Code only: the harness
623
+ * session really ended, so the CLEO session ends. A turn's Stop no longer
624
+ * does. Prints nothing and always exits 0, so a machine without `cleo` (or
625
+ * with an older one) never breaks the exit.
626
+ */
627
+ export function sessionEndHookObject(): Record<string, unknown> {
628
+ const script = `command -v cleo >/dev/null 2>&1 || exit 0; ${SESSION_END_HOOK_ID} --provider claude-code >/dev/null 2>&1; exit 0`;
629
+ return {
630
+ type: 'command',
631
+ command: `${script} ${CLEO_HOOK_MARKER}`,
632
+ timeout: SESSION_END_HOOK_TIMEOUT_SEC,
633
+ };
634
+ }
635
+
636
+ /** Whether one hook object is CLEO's SessionEnd hook. */
637
+ export function isSessionEndHookObject(hook: unknown): boolean {
638
+ return (
639
+ isPlainObject(hook) &&
640
+ typeof hook.command === 'string' &&
641
+ hook.command.includes(CLEO_HOOK_MARKER) &&
642
+ hook.command.includes(`${SESSION_END_HOOK_ID} `)
643
+ );
644
+ }
645
+
646
+ /**
647
+ * Leave exactly one CLEO SessionEnd hook (T13913) in a `hooks` map, or
648
+ * (`install: false`) none; user hooks stay.
649
+ *
650
+ * @param hooks - The config's `hooks` map (edited in place).
651
+ * @param install - `false` removes it.
652
+ * @returns whether the map changed.
653
+ */
654
+ export function applySessionEndHook(hooks: Record<string, unknown>, install: boolean): boolean {
655
+ const desired = install ? sessionEndHookObject() : null;
656
+ const placed = placeHeavyHook(hooks, desired, 'SessionEnd', isSessionEndHookObject);
657
+ if (desired === null || placed.found) return placed.changed;
658
+ appendHookEntry(hooks, 'SessionEnd', { hooks: [desired] });
659
+ return true;
660
+ }
661
+
662
+ /**
663
+ * Add, refresh or (`install: false`) remove CLEO's SessionEnd hook
664
+ * (`hooks.SessionEnd`, T13913) through CAAMP's locked atomic writer. Claude
665
+ * Code only: Codex has no session-end event. A malformed file throws
666
+ * untouched.
667
+ *
668
+ * @param configPath - Claude Code's `.claude/settings.local.json`.
669
+ * @param install - `false` removes the hook.
670
+ * @returns what changed.
671
+ */
672
+ export function syncJsonSessionEndHook(
673
+ configPath: string,
674
+ install: boolean,
675
+ ): Promise<HeavyHookSyncResult> {
676
+ return syncJsonEventHook(
677
+ configPath,
678
+ 'SessionEnd',
679
+ install ? sessionEndHookObject() : null,
680
+ isSessionEndHookObject,
681
+ );
682
+ }
683
+
684
+ /** Place one CLEO hook object on one event (null removes it), through CAAMP's locked writer. */
685
+ async function syncJsonEventHook(
686
+ configPath: string,
687
+ event: string,
688
+ desired: Record<string, unknown> | null,
689
+ match: (hook: unknown) => boolean,
690
+ ): Promise<HeavyHookSyncResult> {
691
+ const install = desired !== null;
692
+ if (!install && !existsSync(configPath)) return 'unchanged';
693
+ if (install) ensureParentDir(configPath);
694
+ let result: HeavyHookSyncResult = 'unchanged';
695
+ await updateJsonConfigFile(configPath, (config) => {
696
+ if (!install && config.hooks === undefined) return false;
697
+ const hooks = hookMap(config);
698
+ const placed = placeHeavyHook(hooks, desired, event, match);
699
+ if (desired === null || placed.found) {
700
+ if (placed.changed) result = install ? 'updated' : 'removed';
701
+ return placed.changed;
702
+ }
703
+ appendHookEntry(hooks, event, { hooks: [desired] });
704
+ result = 'installed';
705
+ return true;
706
+ });
707
+ return result;
708
+ }
709
+
564
710
  /** Claude Code's per-machine settings file, relative to the project. */
565
711
  export const CLAUDE_LOCAL_SETTINGS = '.claude/settings.local.json';
566
712