From 64ad97a3685463d595247127f58f28369e7938db Mon Sep 17 00:00:00 2001 From: Keaton Hoskins Date: Sat, 17 Oct 2026 14:44:11 -0700 Subject: [PATCH] feat(T13884): pass CLEO_HOOK_PROVIDER to the goal tick runTickKillable takes the dispatcher's --provider and spawns the tick with CLEO_HOOK_PROVIDER set to it, the rest of the environment inherited. The name is GOAL_TICK_PROVIDER_ENV in @cleocode/contracts/goal-runtime.js so cleo-goal can import it. Co-Authored-By: Claude Opus 5.5 --- .changeset/goal-tick-provider-env.md | 11 +++++++++++ .../src/cli/__tests__/stop-dispatch-entry.test.ts | 10 ++++++++++ packages/cleo/src/cli/stop-dispatch-entry.ts | 9 ++++++++- packages/contracts/src/goal-runtime.ts | 7 +++++++ 4 files changed, 36 insertions(+), 1 deletion(-) create mode 100644 .changeset/goal-tick-provider-env.md diff --git a/.changeset/goal-tick-provider-env.md b/.changeset/goal-tick-provider-env.md new file mode 100644 index 0000000000..681c6129ed --- /dev/null +++ b/.changeset/goal-tick-provider-env.md @@ -0,0 +1,11 @@ +--- +id: goal-tick-provider-env +tasks: [T13884] +kind: feat +summary: the Stop dispatcher passes CLEO_HOOK_PROVIDER (claude-code or codex) to the goal tick +--- + +`cleo hook stop --provider

` now spawns the goal tick with +`CLEO_HOOK_PROVIDER=

` added to the inherited environment, so `cleo-goal tick` +can tell which harness's Stop it is answering. The name is exported as +`GOAL_TICK_PROVIDER_ENV` from `@cleocode/contracts/goal-runtime.js`. diff --git a/packages/cleo/src/cli/__tests__/stop-dispatch-entry.test.ts b/packages/cleo/src/cli/__tests__/stop-dispatch-entry.test.ts index 4a650d028e..60d59fbf38 100644 --- a/packages/cleo/src/cli/__tests__/stop-dispatch-entry.test.ts +++ b/packages/cleo/src/cli/__tests__/stop-dispatch-entry.test.ts @@ -7,6 +7,7 @@ * @task T13796 */ +import { GOAL_TICK_PROVIDER_ENV } from '@cleocode/contracts/goal-runtime.js'; import { describe, expect, it, vi } from 'vitest'; import { askEnforceOptedOut, @@ -216,6 +217,15 @@ describe('stopDispatch (T13796)', () => { expect(r.stdout).not.toContain('pwned'); }); + it('the tick child sees CLEO_HOOK_PROVIDER set to the dispatching harness (T13884)', async () => { + const echo = `node -e "process.stdout.write(JSON.stringify({p: process.env.${GOAL_TICK_PROVIDER_ENV}, h: process.env.HOME ?? null}))"`; + for (const provider of ['claude-code', 'codex'] as const) { + const run = await runTickKillable(echo, STOP, 5000, provider); + expect(run.status).toBe(0); + expect(JSON.parse(run.stdout)).toEqual({ p: provider, h: process.env.HOME ?? null }); + } + }); + it('a tick that traps SIGTERM is SIGKILLed with its group at the deadline, and fails open', async () => { const marker = `cleo-t13796-${process.pid}-${Date.now()}`; const trap = `node -e "process.on('SIGTERM',()=>{});setInterval(()=>{},1000)" ${marker}-trap`; diff --git a/packages/cleo/src/cli/stop-dispatch-entry.ts b/packages/cleo/src/cli/stop-dispatch-entry.ts index 9e8c0d6a03..19eb843f6a 100644 --- a/packages/cleo/src/cli/stop-dispatch-entry.ts +++ b/packages/cleo/src/cli/stop-dispatch-entry.ts @@ -43,6 +43,7 @@ import { spawn, spawnSync } from 'node:child_process'; import { existsSync } from 'node:fs'; import { dirname, join, resolve } from 'node:path'; import type { HookJsonValue } from '@cleocode/contracts'; +import { GOAL_TICK_PROVIDER_ENV } from '@cleocode/contracts/goal-runtime.js'; /** Harnesses `cleo hook stop` dispatches for. */ export const STOP_DISPATCH_PROVIDERS = ['claude-code', 'codex'] as const; @@ -231,7 +232,7 @@ export function processStopDispatchDeps( return goalTickCommand({ ...process.env, ...env }); }, hasGoalWorkspace: (input) => hasGoalWorkspace(input, env), - runTick: runTickKillable, + runTick: (command, input, timeoutMs) => runTickKillable(command, input, timeoutMs, provider), endSession: () => { const cli = process.argv[1]; spawnSync( @@ -285,12 +286,15 @@ export function hasGoalWorkspace( * @param command - The tick command line. * @param input - The Stop JSON for its stdin. * @param timeoutMs - Deadline. + * @param provider - The harness whose Stop this is, passed to the tick as + * {@link GOAL_TICK_PROVIDER_ENV}; the rest of the environment is inherited. * @returns What the tick did. */ export function runTickKillable( command: string, input: string, timeoutMs: number, + provider?: StopDispatchProvider, ): Promise { return new Promise((done) => { let stdout = ''; @@ -305,6 +309,9 @@ export function runTickKillable( shell: true, detached: true, stdio: ['pipe', 'pipe', 'ignore'], + ...(provider === undefined + ? {} + : { env: { ...process.env, [GOAL_TICK_PROVIDER_ENV]: provider } }), }); const timer = setTimeout(() => { try { diff --git a/packages/contracts/src/goal-runtime.ts b/packages/contracts/src/goal-runtime.ts index 13161ed2a7..da565e0849 100644 --- a/packages/contracts/src/goal-runtime.ts +++ b/packages/contracts/src/goal-runtime.ts @@ -9,6 +9,13 @@ * @epic T13791 */ +/** + * Environment variable naming the harness whose Stop the dispatcher is + * answering (`claude-code` or `codex`), set on the `cleo-goal tick` child so + * the tick can tell harnesses apart (T13884). + */ +export const GOAL_TICK_PROVIDER_ENV = 'CLEO_HOOK_PROVIDER'; + /** The only `schemaVersion` this CLEO understands for every shape below. */ export const GOAL_RUNTIME_SCHEMA_VERSION = 1;